Initial commit

This commit is contained in:
Zhongwei Li
2025-11-30 09:04:14 +08:00
commit 70c36b5eff
248 changed files with 47482 additions and 0 deletions

View File

@@ -0,0 +1,259 @@
---
name: oclif-patterns
description: Enterprise CLI patterns using oclif framework with TypeScript. Use when building oclif CLIs, creating plugins, implementing commands with flags/args, adding auto-documentation, testing CLI commands, or when user mentions oclif, enterprise CLI, TypeScript CLI, plugin system, or CLI testing.
allowed-tools: Read, Write, Edit, Bash, Glob, Grep
---
# oclif Enterprise CLI Patterns
Provides comprehensive patterns for building production-grade CLIs with oclif framework.
## Core Capabilities
### 1. Command Structure
- Single and multi-command CLIs
- Flag definitions (string, boolean, integer, custom)
- Argument parsing with validation
- Command inheritance and base classes
- Async command execution
### 2. Plugin System
- Installable plugins
- Plugin discovery and loading
- Hook system for extensibility
- Plugin commands and lifecycle
### 3. Auto-Documentation
- Auto-generated help text
- README generation
- Command reference docs
- Flag and argument documentation
### 4. Testing Patterns
- Command unit tests
- Integration testing
- Mock stdin/stdout
- Fixture management
- Test helpers
## Implementation Guide
### Command Creation
**Use templates**:
- `templates/command-basic.ts` - Simple command with flags
- `templates/command-advanced.ts` - Complex command with validation
- `templates/command-async.ts` - Async operations
- `templates/base-command.ts` - Custom base class
**Key patterns**:
1. Import Command from '@oclif/core'
2. Define flags using `Flags` object
3. Define args using `Args` object
4. Implement async run() method
5. Use this.log() for output
6. Use this.error() for errors
### Flag Patterns
**Common flags**:
- String: `Flags.string({ description, required, default })`
- Boolean: `Flags.boolean({ description, allowNo })`
- Integer: `Flags.integer({ description, min, max })`
- Custom: `Flags.custom<T>({ parse: async (input) => T })`
- Multiple: `Flags.string({ multiple: true })`
**Best practices**:
- Always provide clear descriptions
- Use char for common short flags
- Set required vs optional explicitly
- Provide sensible defaults
- Validate in parse function for custom flags
### Argument Patterns
**Definition**:
```typescript
static args = {
name: Args.string({ description: 'Name', required: true }),
file: Args.file({ description: 'File path', exists: true })
}
```
**Access in run()**:
```typescript
const { args } = await this.parse(MyCommand)
```
### Plugin Development
**Use templates**:
- `templates/plugin-package.json` - Plugin package.json
- `templates/plugin-command.ts` - Plugin command structure
- `templates/plugin-hooks.ts` - Hook implementations
**Plugin structure**:
```
my-plugin/
├── package.json (oclif configuration)
├── src/
│ ├── commands/ (plugin commands)
│ └── hooks/ (lifecycle hooks)
├── test/ (plugin tests)
└── README.md
```
### Testing Setup
**Use templates**:
- `templates/test-command.ts` - Command test template
- `templates/test-helpers.ts` - Test utilities
- `templates/test-setup.ts` - Test configuration
**Testing approach**:
1. Use @oclif/test for test helpers
2. Mock stdin/stdout with fancy-test
3. Test flag parsing separately
4. Test command execution
5. Test error handling
6. Use fixtures for file operations
### Auto-Documentation
**Generated automatically**:
- Command help via `--help` flag
- README.md with command reference
- Usage examples
- Flag and argument tables
**Use scripts**:
- `scripts/generate-docs.sh` - Generate all documentation
- `scripts/update-readme.sh` - Update README with commands
## Quick Start Examples
### Create Basic Command
```bash
# Use template
./scripts/create-command.sh my-command basic
# Results in: src/commands/my-command.ts
```
### Create Plugin
```bash
# Use template
./scripts/create-plugin.sh my-plugin
# Results in: plugin directory structure
```
### Run Tests
```bash
# Use test helpers
npm test
# or with coverage
npm run test:coverage
```
## Validation Scripts
**Available validators**:
- `scripts/validate-command.sh` - Check command structure
- `scripts/validate-plugin.sh` - Verify plugin structure
- `scripts/validate-tests.sh` - Ensure test coverage
## Templates Reference
### TypeScript Commands
1. `command-basic.ts` - Simple command pattern
2. `command-advanced.ts` - Full-featured command
3. `command-async.ts` - Async/await patterns
4. `base-command.ts` - Custom base class
5. `command-with-config.ts` - Configuration management
### Plugin System
6. `plugin-package.json` - Plugin package.json
7. `plugin-command.ts` - Plugin command
8. `plugin-hooks.ts` - Hook implementations
9. `plugin-manifest.json` - Plugin manifest
### Testing
10. `test-command.ts` - Command unit test
11. `test-helpers.ts` - Test utilities
12. `test-setup.ts` - Test configuration
13. `test-integration.ts` - Integration test
### Configuration
14. `tsconfig.json` - TypeScript config
15. `package.json` - oclif package.json
16. `.eslintrc.json` - ESLint config
## Examples Directory
See `examples/` for complete working examples:
- `examples/basic-cli/` - Simple CLI with commands
- `examples/plugin-cli/` - CLI with plugin support
- `examples/enterprise-cli/` - Full enterprise setup
## Common Patterns
### Error Handling
```typescript
if (!valid) {
this.error('Invalid input', { exit: 1 })
}
```
### Spinner/Progress
```typescript
const spinner = ux.action.start('Processing')
// ... work
ux.action.stop()
```
### Prompts
```typescript
const answer = await ux.prompt('Continue?')
```
### Table Output
```typescript
ux.table(data, { columns: [...] })
```
## Requirements
- Node.js 18+
- TypeScript 5+
- @oclif/core ^3.0.0
- @oclif/test for testing
- Knowledge of TypeScript decorators (optional but helpful)
## Best Practices
1. **Command Design**: Keep commands focused, single responsibility
2. **Flags**: Use descriptive names, provide help text
3. **Testing**: Test command parsing and execution separately
4. **Documentation**: Let oclif generate docs, keep them updated
5. **Plugins**: Design for extensibility from the start
6. **Error Messages**: Provide actionable error messages
7. **TypeScript**: Use strict mode, define proper types
8. **Async**: Use async/await, handle promises properly
## Advanced Features
### Custom Flag Types
Create reusable custom flag parsers for complex validation.
### Hook System
Implement hooks for: init, prerun, postrun, command_not_found.
### Topic Commands
Organize commands into topics (e.g., `mycli topic:command`).
### Auto-Update
Use @oclif/plugin-update for automatic CLI updates.
### Analytics
Integrate analytics to track command usage.

View File

@@ -0,0 +1,188 @@
# Basic CLI Example
Complete example of a simple oclif CLI with multiple commands.
## Project Structure
```
mycli/
├── package.json
├── tsconfig.json
├── src/
│ ├── commands/
│ │ ├── hello.ts
│ │ ├── goodbye.ts
│ │ └── config.ts
│ └── index.ts
├── test/
│ └── commands/
│ └── hello.test.ts
└── bin/
└── run.js
```
## Step-by-Step Setup
### 1. Initialize Project
```bash
mkdir mycli && cd mycli
npm init -y
npm install @oclif/core
npm install --save-dev @oclif/test @types/node typescript ts-node oclif
```
### 2. Create package.json Configuration
Add oclif configuration:
```json
{
"name": "mycli",
"version": "1.0.0",
"oclif": {
"bin": "mycli",
"commands": "./lib/commands",
"plugins": [
"@oclif/plugin-help"
]
}
}
```
### 3. Create Hello Command
File: `src/commands/hello.ts`
```typescript
import { Command, Flags, Args } from '@oclif/core'
export default class Hello extends Command {
static description = 'Say hello to someone'
static examples = [
'<%= config.bin %> <%= command.id %> Alice',
'<%= config.bin %> <%= command.id %> Bob --greeting="Hi"',
]
static flags = {
greeting: Flags.string({
char: 'g',
description: 'Greeting to use',
default: 'Hello',
}),
excited: Flags.boolean({
char: 'e',
description: 'Add exclamation',
default: false,
}),
}
static args = {
name: Args.string({
description: 'Name to greet',
required: true,
}),
}
async run(): Promise<void> {
const { args, flags } = await this.parse(Hello)
const punctuation = flags.excited ? '!' : '.'
this.log(`${flags.greeting}, ${args.name}${punctuation}`)
}
}
```
### 4. Build and Test
```bash
npm run build
./bin/run.js hello World
# Output: Hello, World.
./bin/run.js hello World --greeting="Hi" --excited
# Output: Hi, World!
```
### 5. Add Help Documentation
```bash
./bin/run.js hello --help
# Output:
# Say hello to someone
#
# USAGE
# $ mycli hello NAME [-g <value>] [-e]
#
# ARGUMENTS
# NAME Name to greet
#
# FLAGS
# -e, --excited Add exclamation
# -g, --greeting=<value> [default: Hello] Greeting to use
```
## Testing
File: `test/commands/hello.test.ts`
```typescript
import { expect, test } from '@oclif/test'
describe('hello', () => {
test
.stdout()
.command(['hello', 'World'])
.it('says hello', ctx => {
expect(ctx.stdout).to.contain('Hello, World.')
})
test
.stdout()
.command(['hello', 'Alice', '--excited'])
.it('says hello with excitement', ctx => {
expect(ctx.stdout).to.contain('Hello, Alice!')
})
test
.stdout()
.command(['hello', 'Bob', '--greeting=Hi'])
.it('uses custom greeting', ctx => {
expect(ctx.stdout).to.contain('Hi, Bob.')
})
})
```
## Run Tests
```bash
npm test
```
## Distribution
### Package for npm
```bash
npm pack
npm publish
```
### Install Globally
```bash
npm install -g .
mycli hello World
```
## Key Concepts Demonstrated
1. **Command Structure**: Basic command with flags and args
2. **Flag Types**: String and boolean flags with defaults
3. **Arguments**: Required string argument
4. **Help Documentation**: Auto-generated from metadata
5. **Testing**: Using @oclif/test for command testing
6. **Build Process**: TypeScript compilation to lib/
7. **CLI Binary**: bin/run.js entry point

View File

@@ -0,0 +1,427 @@
# 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`
```typescript
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`
```typescript
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`
```typescript
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`
```typescript
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`
```typescript
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

View File

@@ -0,0 +1,307 @@
# Plugin CLI Example
Complete example of an oclif CLI with plugin support.
## Overview
This example shows:
- Main CLI with core commands
- Plugin system for extensibility
- Plugin installation and management
- Shared hooks between CLI and plugins
## Main CLI Structure
```
mycli/
├── package.json
├── src/
│ ├── commands/
│ │ └── core.ts
│ └── hooks/
│ └── init.ts
└── plugins/
└── plugin-deploy/
├── package.json
└── src/
└── commands/
└── deploy.ts
```
## Step 1: Create Main CLI
### Main CLI package.json
```json
{
"name": "mycli",
"version": "1.0.0",
"oclif": {
"bin": "mycli",
"commands": "./lib/commands",
"plugins": [
"@oclif/plugin-help",
"@oclif/plugin-plugins"
],
"hooks": {
"init": "./lib/hooks/init"
}
},
"dependencies": {
"@oclif/core": "^3.0.0",
"@oclif/plugin-help": "^6.0.0",
"@oclif/plugin-plugins": "^4.0.0"
}
}
```
### Core Command
File: `src/commands/core.ts`
```typescript
import { Command, Flags } from '@oclif/core'
export default class Core extends Command {
static description = 'Core CLI functionality'
static flags = {
version: Flags.boolean({
char: 'v',
description: 'Show CLI version',
}),
}
async run(): Promise<void> {
const { flags } = await this.parse(Core)
if (flags.version) {
this.log(`Version: ${this.config.version}`)
return
}
this.log('Core CLI is running')
// List installed plugins
const plugins = this.config.plugins
this.log(`\nInstalled plugins: ${plugins.length}`)
plugins.forEach(p => {
this.log(` - ${p.name} (${p.version})`)
})
}
}
```
### Init Hook
File: `src/hooks/init.ts`
```typescript
import { Hook } from '@oclif/core'
const hook: Hook<'init'> = async function (opts) {
// Initialize CLI
this.debug('Initializing mycli...')
// Check for updates, load config, etc.
}
export default hook
```
## Step 2: Create Plugin
### Plugin package.json
```json
{
"name": "@mycli/plugin-deploy",
"version": "1.0.0",
"description": "Deployment plugin for mycli",
"oclif": {
"bin": "mycli",
"commands": "./lib/commands",
"topics": {
"deploy": {
"description": "Deployment commands"
}
}
},
"dependencies": {
"@oclif/core": "^3.0.0"
}
}
```
### Deploy Command
File: `plugins/plugin-deploy/src/commands/deploy.ts`
```typescript
import { Command, Flags } from '@oclif/core'
export default class Deploy extends Command {
static description = 'Deploy application'
static examples = [
'<%= config.bin %> deploy --env production',
]
static flags = {
env: Flags.string({
char: 'e',
description: 'Environment to deploy to',
options: ['development', 'staging', 'production'],
required: true,
}),
force: Flags.boolean({
char: 'f',
description: 'Force deployment',
default: false,
}),
}
async run(): Promise<void> {
const { flags } = await this.parse(Deploy)
this.log(`Deploying to ${flags.env}...`)
if (flags.force) {
this.log('Force deployment enabled')
}
// Deployment logic here
this.log('✓ Deployment successful')
}
}
```
## Step 3: Build and Link Plugin
```bash
# Build main CLI
cd mycli
npm run build
# Build plugin
cd plugins/plugin-deploy
npm run build
# Link plugin to main CLI
cd ../../
mycli plugins:link ./plugins/plugin-deploy
```
## Step 4: Use Plugin Commands
```bash
# List plugins
mycli plugins
# Use plugin command
mycli deploy --env production
# Get help for plugin command
mycli deploy --help
```
## Step 5: Install Plugin from npm
### Publish Plugin
```bash
cd plugins/plugin-deploy
npm publish
```
### Install Plugin
```bash
mycli plugins:install @mycli/plugin-deploy
```
## Plugin Management Commands
```bash
# List installed plugins
mycli plugins
# Install plugin
mycli plugins:install @mycli/plugin-name
# Update plugin
mycli plugins:update @mycli/plugin-name
# Uninstall plugin
mycli plugins:uninstall @mycli/plugin-name
# Link local plugin (development)
mycli plugins:link /path/to/plugin
```
## Advanced: Plugin with Hooks
File: `plugins/plugin-deploy/src/hooks/prerun.ts`
```typescript
import { Hook } from '@oclif/core'
const hook: Hook<'prerun'> = async function (opts) {
// Check deployment prerequisites
if (opts.Command.id === 'deploy') {
this.log('Checking deployment prerequisites...')
// Check environment, credentials, etc.
}
}
export default hook
```
Register in plugin package.json:
```json
{
"oclif": {
"hooks": {
"prerun": "./lib/hooks/prerun"
}
}
}
```
## Key Concepts Demonstrated
1. **Plugin System**: @oclif/plugin-plugins integration
2. **Plugin Discovery**: Automatic command loading from plugins
3. **Plugin Management**: Install, update, uninstall commands
4. **Local Development**: plugins:link for local plugin development
5. **Hooks**: Shared hooks between main CLI and plugins
6. **Topic Commands**: Organized plugin commands (deploy:*)
7. **Plugin Metadata**: Package.json oclif configuration
8. **Plugin Distribution**: Publishing to npm
## Testing Plugins
File: `plugins/plugin-deploy/test/commands/deploy.test.ts`
```typescript
import { expect, test } from '@oclif/test'
describe('deploy', () => {
test
.stdout()
.command(['deploy', '--env', 'production'])
.it('deploys to production', ctx => {
expect(ctx.stdout).to.contain('Deploying to production')
expect(ctx.stdout).to.contain('successful')
})
test
.command(['deploy'])
.catch(error => {
expect(error.message).to.contain('Missing required flag')
})
.it('requires env flag')
})
```

View File

@@ -0,0 +1,393 @@
# oclif Patterns Quick Reference
Fast lookup for common oclif patterns and commands.
## Command Creation
### Basic Command
```typescript
import { Command, Flags, Args } from '@oclif/core'
export default class MyCommand extends Command {
static description = 'Description'
static flags = {
name: Flags.string({ char: 'n', required: true }),
}
async run(): Promise<void> {
const { flags } = await this.parse(MyCommand)
this.log(`Hello ${flags.name}`)
}
}
```
### Using Scripts
```bash
# Create command from template
./scripts/create-command.sh my-command basic
# Create advanced command
./scripts/create-command.sh deploy advanced
# Create async command
./scripts/create-command.sh fetch async
```
## Flag Patterns
### String Flag
```typescript
name: Flags.string({
char: 'n',
description: 'Name',
required: true,
default: 'World',
})
```
### Boolean Flag
```typescript
verbose: Flags.boolean({
char: 'v',
description: 'Verbose output',
default: false,
allowNo: true, // Enables --no-verbose
})
```
### Integer Flag
```typescript
port: Flags.integer({
char: 'p',
description: 'Port number',
min: 1024,
max: 65535,
default: 3000,
})
```
### Option Flag (Enum)
```typescript
env: Flags.string({
char: 'e',
description: 'Environment',
options: ['dev', 'staging', 'prod'],
required: true,
})
```
### Multiple Values
```typescript
tags: Flags.string({
char: 't',
description: 'Tags',
multiple: true,
})
// Usage: --tags=foo --tags=bar
```
### Custom Flag
```typescript
date: Flags.custom<Date>({
parse: async (input) => new Date(input),
})
```
## Argument Patterns
### Required Argument
```typescript
static args = {
file: Args.string({
description: 'File path',
required: true,
}),
}
```
### File Argument
```typescript
static args = {
file: Args.file({
description: 'Input file',
exists: true, // Validates file exists
}),
}
```
### Directory Argument
```typescript
static args = {
dir: Args.directory({
description: 'Target directory',
exists: true,
}),
}
```
## Output Patterns
### Simple Log
```typescript
this.log('Message')
```
### Error with Exit
```typescript
this.error('Error message', { exit: 1 })
```
### Warning
```typescript
this.warn('Warning message')
```
### Spinner
```typescript
import { ux } from '@oclif/core'
ux.action.start('Processing')
// ... work
ux.action.stop('done')
```
### Progress Bar
```typescript
import { ux } from '@oclif/core'
const total = 100
ux.progress.start(total)
for (let i = 0; i < total; i++) {
ux.progress.update(i)
}
ux.progress.stop()
```
### Table Output
```typescript
import { ux } from '@oclif/core'
ux.table(data, {
id: {},
name: {},
status: { extended: true },
})
```
### Prompt
```typescript
import { ux } from '@oclif/core'
const name = await ux.prompt('What is your name?')
const password = await ux.prompt('Password', { type: 'hide' })
const confirmed = await ux.confirm('Continue? (y/n)')
```
## Testing Patterns
### Basic Test
```typescript
import { expect, test } from '@oclif/test'
test
.stdout()
.command(['mycommand', '--name', 'Test'])
.it('runs command', ctx => {
expect(ctx.stdout).to.contain('Test')
})
```
### Test with Error
```typescript
test
.command(['mycommand'])
.catch(error => {
expect(error.message).to.contain('Missing')
})
.it('fails without flags')
```
### Test with Mock
```typescript
test
.nock('https://api.example.com', api =>
api.get('/data').reply(200, { result: 'success' })
)
.stdout()
.command(['mycommand'])
.it('handles API call', ctx => {
expect(ctx.stdout).to.contain('success')
})
```
### Test with Environment
```typescript
test
.env({ API_KEY: 'test-key' })
.stdout()
.command(['mycommand'])
.it('reads from env')
```
## Plugin Patterns
### Create Plugin
```bash
./scripts/create-plugin.sh my-plugin
```
### Link Plugin
```bash
mycli plugins:link ./plugin-my-plugin
```
### Install Plugin
```bash
mycli plugins:install @mycli/plugin-name
```
## Hook Patterns
### Init Hook
```typescript
import { Hook } from '@oclif/core'
const hook: Hook<'init'> = async function (opts) {
// Runs before any command
}
export default hook
```
### Prerun Hook
```typescript
const hook: Hook<'prerun'> = async function (opts) {
const { Command, argv } = opts
// Runs before each command
}
```
## Common Commands
### Generate Documentation
```bash
npm run prepack
# Generates oclif.manifest.json and updates README.md
```
### Build
```bash
npm run build
```
### Test
```bash
npm test
npm run test:coverage
```
### Lint
```bash
npm run lint
npm run lint:fix
```
## Validation
### Validate Command
```bash
./scripts/validate-command.sh src/commands/mycommand.ts
```
### Validate Plugin
```bash
./scripts/validate-plugin.sh ./my-plugin
```
### Validate Tests
```bash
./scripts/validate-tests.sh
```
## Configuration Patterns
### Read Config
```typescript
const configPath = path.join(this.config.home, '.myclirc')
const config = await fs.readJson(configPath)
```
### Write Config
```typescript
await fs.writeJson(configPath, config, { spaces: 2 })
```
### Environment Variables
```typescript
const apiKey = process.env.API_KEY || this.error('API_KEY required')
```
## Error Handling
### Try-Catch
```typescript
try {
await riskyOperation()
} catch (error) {
this.error(`Operation failed: ${error.message}`, { exit: 1 })
}
```
### Custom Error
```typescript
if (!valid) {
this.error('Invalid input', {
exit: 1,
suggestions: ['Try --help for usage']
})
}
```
## Async Patterns
### Concurrent Operations
```typescript
const results = await Promise.all([
operation1(),
operation2(),
operation3(),
])
```
### Sequential Operations
```typescript
for (const item of items) {
await processItem(item)
}
```
### With Timeout
```typescript
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 5000)
try {
const response = await fetch(url, { signal: controller.signal })
} finally {
clearTimeout(timeout)
}
```
## Best Practices
1. Always provide clear descriptions for flags and commands
2. Use char flags for common options (e.g., -v for verbose)
3. Validate inputs early in the run() method
4. Use ux.action.start/stop for long operations
5. Handle errors gracefully with helpful messages
6. Test both success and failure cases
7. Generate documentation with oclif manifest
8. Use TypeScript strict mode
9. Follow naming conventions (kebab-case for commands)
10. Keep commands focused and single-purpose

View File

@@ -0,0 +1,78 @@
#!/usr/bin/env bash
# Create oclif command from template
# Usage: ./create-command.sh <command-name> <template-type>
# Template types: basic, advanced, async
set -e
COMMAND_NAME="$1"
TEMPLATE_TYPE="${2:-basic}"
if [ -z "$COMMAND_NAME" ]; then
echo "Error: Command name is required"
echo "Usage: ./create-command.sh <command-name> <template-type>"
echo "Template types: basic, advanced, async"
exit 1
fi
# Validate template type
if [[ ! "$TEMPLATE_TYPE" =~ ^(basic|advanced|async)$ ]]; then
echo "Error: Invalid template type. Must be: basic, advanced, or async"
exit 1
fi
# Get script directory
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TEMPLATE_DIR="$(dirname "$SCRIPT_DIR")/templates"
# Determine output directory
if [ -d "src/commands" ]; then
OUTPUT_DIR="src/commands"
elif [ -d "commands" ]; then
OUTPUT_DIR="commands"
else
echo "Error: Cannot find commands directory. Are you in the CLI project root?"
exit 1
fi
# Convert command name to proper format
# e.g., "my-command" -> "MyCommand"
COMMAND_CLASS=$(echo "$COMMAND_NAME" | sed -r 's/(^|-)([a-z])/\U\2/g')
# Determine output file path
OUTPUT_FILE="$OUTPUT_DIR/${COMMAND_NAME}.ts"
# Check if file already exists
if [ -f "$OUTPUT_FILE" ]; then
echo "Error: Command file already exists: $OUTPUT_FILE"
exit 1
fi
# Select template file
TEMPLATE_FILE="$TEMPLATE_DIR/command-${TEMPLATE_TYPE}.ts"
if [ ! -f "$TEMPLATE_FILE" ]; then
echo "Error: Template file not found: $TEMPLATE_FILE"
exit 1
fi
# Create command file from template
echo "Creating command from template: $TEMPLATE_TYPE"
cp "$TEMPLATE_FILE" "$OUTPUT_FILE"
# Replace placeholders
sed -i "s/{{COMMAND_NAME}}/$COMMAND_CLASS/g" "$OUTPUT_FILE"
sed -i "s/{{DESCRIPTION}}/Command description for $COMMAND_NAME/g" "$OUTPUT_FILE"
echo "✓ Created command: $OUTPUT_FILE"
echo ""
echo "Next steps:"
echo " 1. Edit the command: $OUTPUT_FILE"
echo " 2. Update the description and flags"
echo " 3. Implement the run() method"
echo " 4. Build: npm run build"
echo " 5. Test: npm test"
echo ""
echo "Run the command:"
echo " ./bin/run.js $COMMAND_NAME --help"

View File

@@ -0,0 +1,127 @@
#!/usr/bin/env bash
# Create oclif plugin structure
# Usage: ./create-plugin.sh <plugin-name>
set -e
PLUGIN_NAME="$1"
if [ -z "$PLUGIN_NAME" ]; then
echo "Error: Plugin name is required"
echo "Usage: ./create-plugin.sh <plugin-name>"
exit 1
fi
# Get script directory
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TEMPLATE_DIR="$(dirname "$SCRIPT_DIR")/templates"
# Determine output directory
PLUGIN_DIR="plugin-$PLUGIN_NAME"
if [ -d "$PLUGIN_DIR" ]; then
echo "Error: Plugin directory already exists: $PLUGIN_DIR"
exit 1
fi
echo "Creating plugin: $PLUGIN_NAME"
# Create directory structure
mkdir -p "$PLUGIN_DIR/src/commands"
mkdir -p "$PLUGIN_DIR/src/hooks"
mkdir -p "$PLUGIN_DIR/test"
# Copy package.json template
cp "$TEMPLATE_DIR/plugin-package.json" "$PLUGIN_DIR/package.json"
# Replace placeholders in package.json
sed -i "s/{{PLUGIN_NAME}}/$PLUGIN_NAME/g" "$PLUGIN_DIR/package.json"
sed -i "s/{{DESCRIPTION}}/Plugin for $PLUGIN_NAME/g" "$PLUGIN_DIR/package.json"
sed -i "s/{{AUTHOR}}/Your Name/g" "$PLUGIN_DIR/package.json"
sed -i "s/{{GITHUB_USER}}/yourusername/g" "$PLUGIN_DIR/package.json"
# Copy TypeScript config
cp "$TEMPLATE_DIR/tsconfig.json" "$PLUGIN_DIR/tsconfig.json"
# Copy ESLint config
cp "$TEMPLATE_DIR/.eslintrc.json" "$PLUGIN_DIR/.eslintrc.json"
# Create plugin command
cp "$TEMPLATE_DIR/plugin-command.ts" "$PLUGIN_DIR/src/commands/$PLUGIN_NAME.ts"
sed -i "s/{{PLUGIN_NAME}}/$PLUGIN_NAME/g" "$PLUGIN_DIR/src/commands/$PLUGIN_NAME.ts"
sed -i "s/{{COMMAND_NAME}}/main/g" "$PLUGIN_DIR/src/commands/$PLUGIN_NAME.ts"
sed -i "s/{{COMMAND_CLASS}}/Main/g" "$PLUGIN_DIR/src/commands/$PLUGIN_NAME.ts"
sed -i "s/{{DESCRIPTION}}/Main plugin command/g" "$PLUGIN_DIR/src/commands/$PLUGIN_NAME.ts"
# Create hooks
cp "$TEMPLATE_DIR/plugin-hooks.ts" "$PLUGIN_DIR/src/hooks/init.ts"
sed -i "s/{{PLUGIN_NAME}}/$PLUGIN_NAME/g" "$PLUGIN_DIR/src/hooks/init.ts"
# Create index.ts
cat > "$PLUGIN_DIR/src/index.ts" << EOF
export { default as init } from './hooks/init'
EOF
# Create README
cat > "$PLUGIN_DIR/README.md" << EOF
# @mycli/plugin-$PLUGIN_NAME
Plugin for mycli: $PLUGIN_NAME
## Installation
\`\`\`bash
mycli plugins:install @mycli/plugin-$PLUGIN_NAME
\`\`\`
## Usage
\`\`\`bash
mycli $PLUGIN_NAME:main --help
\`\`\`
## Commands
<!-- commands -->
<!-- commandsstop -->
## Development
\`\`\`bash
npm install
npm run build
npm test
\`\`\`
## License
MIT
EOF
# Create .gitignore
cat > "$PLUGIN_DIR/.gitignore" << EOF
*-debug.log
*-error.log
*.tgz
.DS_Store
/.nyc_output
/dist
/lib
/package-lock.json
/tmp
node_modules
oclif.manifest.json
tsconfig.tsbuildinfo
EOF
echo "✓ Created plugin structure: $PLUGIN_DIR"
echo ""
echo "Next steps:"
echo " cd $PLUGIN_DIR"
echo " npm install"
echo " npm run build"
echo " npm test"
echo ""
echo "To install plugin locally:"
echo " mycli plugins:link $PWD/$PLUGIN_DIR"

View File

@@ -0,0 +1,28 @@
#!/usr/bin/env bash
# Generate oclif documentation
# Usage: ./generate-docs.sh
set -e
echo "Generating oclif documentation..."
# Check if oclif is installed
if ! command -v oclif &> /dev/null; then
echo "Error: oclif CLI not found. Install with: npm install -g oclif"
exit 1
fi
# Generate manifest
echo "→ Generating command manifest..."
oclif manifest
# Generate README
echo "→ Generating README..."
oclif readme
echo "✓ Documentation generated successfully"
echo ""
echo "Generated files:"
echo " - oclif.manifest.json (command metadata)"
echo " - README.md (updated with command reference)"

View File

@@ -0,0 +1,76 @@
#!/usr/bin/env bash
# Validate oclif command structure
# Usage: ./validate-command.sh <command-file>
set -e
COMMAND_FILE="$1"
if [ -z "$COMMAND_FILE" ]; then
echo "Error: Command file path is required"
echo "Usage: ./validate-command.sh <command-file>"
exit 1
fi
if [ ! -f "$COMMAND_FILE" ]; then
echo "Error: Command file not found: $COMMAND_FILE"
exit 1
fi
echo "Validating command: $COMMAND_FILE"
# Check for required imports
if ! grep -q "from '@oclif/core'" "$COMMAND_FILE"; then
echo "✗ Missing import from @oclif/core"
exit 1
fi
echo "✓ Has @oclif/core import"
# Check for Command class
if ! grep -q "extends Command" "$COMMAND_FILE"; then
echo "✗ Missing 'extends Command'"
exit 1
fi
echo "✓ Extends Command class"
# Check for description
if ! grep -q "static description" "$COMMAND_FILE"; then
echo "⚠ Warning: Missing static description"
else
echo "✓ Has static description"
fi
# Check for examples
if ! grep -q "static examples" "$COMMAND_FILE"; then
echo "⚠ Warning: Missing static examples"
else
echo "✓ Has static examples"
fi
# Check for run method
if ! grep -q "async run()" "$COMMAND_FILE"; then
echo "✗ Missing async run() method"
exit 1
fi
echo "✓ Has async run() method"
# Check for proper flag access
if grep -q "this.parse(" "$COMMAND_FILE"; then
echo "✓ Properly parses flags"
else
echo "⚠ Warning: May not be parsing flags correctly"
fi
# Check TypeScript
if command -v tsc &> /dev/null; then
echo "→ Checking TypeScript compilation..."
if tsc --noEmit "$COMMAND_FILE" 2>/dev/null; then
echo "✓ TypeScript compilation successful"
else
echo "⚠ Warning: TypeScript compilation has issues"
fi
fi
echo ""
echo "✓ Command validation complete"

View File

@@ -0,0 +1,99 @@
#!/usr/bin/env bash
# Validate oclif plugin structure
# Usage: ./validate-plugin.sh <plugin-directory>
set -e
PLUGIN_DIR="${1:-.}"
if [ ! -d "$PLUGIN_DIR" ]; then
echo "Error: Plugin directory not found: $PLUGIN_DIR"
exit 1
fi
echo "Validating plugin: $PLUGIN_DIR"
# Check for package.json
if [ ! -f "$PLUGIN_DIR/package.json" ]; then
echo "✗ Missing package.json"
exit 1
fi
echo "✓ Has package.json"
# Check for oclif configuration in package.json
if ! grep -q '"oclif"' "$PLUGIN_DIR/package.json"; then
echo "✗ Missing oclif configuration in package.json"
exit 1
fi
echo "✓ Has oclif configuration"
# Check for commands directory
if [ ! -d "$PLUGIN_DIR/src/commands" ]; then
echo "⚠ Warning: Missing src/commands directory"
else
echo "✓ Has src/commands directory"
# Check for at least one command
COMMAND_COUNT=$(find "$PLUGIN_DIR/src/commands" -name "*.ts" | wc -l)
if [ "$COMMAND_COUNT" -eq 0 ]; then
echo "⚠ Warning: No commands found"
else
echo "✓ Has $COMMAND_COUNT command(s)"
fi
fi
# Check for hooks directory (optional)
if [ -d "$PLUGIN_DIR/src/hooks" ]; then
echo "✓ Has src/hooks directory"
HOOK_COUNT=$(find "$PLUGIN_DIR/src/hooks" -name "*.ts" | wc -l)
echo " ($HOOK_COUNT hook(s))"
fi
# Check for test directory
if [ ! -d "$PLUGIN_DIR/test" ]; then
echo "⚠ Warning: Missing test directory"
else
echo "✓ Has test directory"
# Check for test files
TEST_COUNT=$(find "$PLUGIN_DIR/test" -name "*.test.ts" | wc -l)
if [ "$TEST_COUNT" -eq 0 ]; then
echo "⚠ Warning: No test files found"
else
echo "✓ Has $TEST_COUNT test file(s)"
fi
fi
# Check for TypeScript config
if [ ! -f "$PLUGIN_DIR/tsconfig.json" ]; then
echo "⚠ Warning: Missing tsconfig.json"
else
echo "✓ Has tsconfig.json"
fi
# Check for README
if [ ! -f "$PLUGIN_DIR/README.md" ]; then
echo "⚠ Warning: Missing README.md"
else
echo "✓ Has README.md"
fi
# Check dependencies in package.json
if grep -q '"@oclif/core"' "$PLUGIN_DIR/package.json"; then
echo "✓ Has @oclif/core dependency"
else
echo "✗ Missing @oclif/core dependency"
fi
# Check if plugin can be built
if [ -f "$PLUGIN_DIR/package.json" ]; then
if grep -q '"build"' "$PLUGIN_DIR/package.json"; then
echo "✓ Has build script"
else
echo "⚠ Warning: Missing build script"
fi
fi
echo ""
echo "✓ Plugin validation complete"

View File

@@ -0,0 +1,59 @@
#!/usr/bin/env bash
# Validate test coverage for oclif CLI
# Usage: ./validate-tests.sh
set -e
echo "Validating test coverage..."
# Check if test directory exists
if [ ! -d "test" ]; then
echo "✗ Missing test directory"
exit 1
fi
echo "✓ Has test directory"
# Count test files
TEST_COUNT=$(find test -name "*.test.ts" | wc -l)
if [ "$TEST_COUNT" -eq 0 ]; then
echo "✗ No test files found"
exit 1
fi
echo "✓ Has $TEST_COUNT test file(s)"
# Count command files
if [ -d "src/commands" ]; then
COMMAND_COUNT=$(find src/commands -name "*.ts" | wc -l)
echo " $COMMAND_COUNT command file(s) in src/commands"
# Check if each command has tests
for cmd in src/commands/*.ts; do
CMD_NAME=$(basename "$cmd" .ts)
if [ ! -f "test/commands/$CMD_NAME.test.ts" ]; then
echo "⚠ Warning: No test for command: $CMD_NAME"
fi
done
fi
# Run tests if available
if command -v npm &> /dev/null && grep -q '"test"' package.json; then
echo ""
echo "→ Running tests..."
if npm test; then
echo "✓ All tests passed"
else
echo "✗ Some tests failed"
exit 1
fi
fi
# Check for test coverage script
if grep -q '"test:coverage"' package.json; then
echo ""
echo "→ Checking test coverage..."
npm run test:coverage
fi
echo ""
echo "✓ Test validation complete"

View File

@@ -0,0 +1,18 @@
{
"extends": [
"oclif",
"oclif-typescript"
],
"rules": {
"object-curly-spacing": ["error", "always"],
"unicorn/no-abusive-eslint-disable": "off",
"unicorn/prefer-module": "off",
"unicorn/prefer-top-level-await": "off",
"valid-jsdoc": "off",
"no-console": "warn",
"no-warning-comments": "warn",
"@typescript-eslint/explicit-module-boundary-types": "off",
"@typescript-eslint/no-explicit-any": "warn",
"perfectionist/sort-objects": "off"
}
}

View File

@@ -0,0 +1,192 @@
import { Command, Flags } from '@oclif/core'
import * as fs from 'fs-extra'
import * as path from 'path'
/**
* Base command class with common functionality for all commands
* Extend this class instead of Command for consistent behavior
*/
export default abstract class BaseCommand extends Command {
// Global flags available to all commands
static baseFlags = {
config: Flags.string({
char: 'c',
description: 'Path to config file',
env: 'CLI_CONFIG',
}),
'log-level': Flags.string({
description: 'Log level',
options: ['error', 'warn', 'info', 'debug'],
default: 'info',
}),
json: Flags.boolean({
description: 'Output as JSON',
default: false,
}),
}
protected config_: any = null
/**
* Initialize command - load config, setup logging
*/
async init(): Promise<void> {
await super.init()
await this.loadConfig()
this.setupLogging()
}
/**
* Load configuration file
*/
private async loadConfig(): Promise<void> {
const { flags } = await this.parse(this.constructor as typeof BaseCommand)
if (flags.config) {
if (!await fs.pathExists(flags.config)) {
this.error(`Config file not found: ${flags.config}`, { exit: 1 })
}
try {
const content = await fs.readFile(flags.config, 'utf-8')
this.config_ = JSON.parse(content)
this.debug(`Loaded config from ${flags.config}`)
} catch (error) {
this.error(`Failed to parse config file: ${error instanceof Error ? error.message : 'Unknown error'}`, {
exit: 1,
})
}
} else {
// Try to load default config locations
const defaultLocations = [
path.join(process.cwd(), '.clirc'),
path.join(process.cwd(), '.cli.json'),
path.join(this.config.home, '.config', 'cli', 'config.json'),
]
for (const location of defaultLocations) {
if (await fs.pathExists(location)) {
try {
const content = await fs.readFile(location, 'utf-8')
this.config_ = JSON.parse(content)
this.debug(`Loaded config from ${location}`)
break
} catch {
// Ignore parse errors for default configs
}
}
}
}
}
/**
* Setup logging based on log-level flag
*/
private setupLogging(): void {
// Implementation would setup actual logging library
this.debug('Logging initialized')
}
/**
* Get config value with dot notation support
*/
protected getConfig(key: string, defaultValue?: any): any {
if (!this.config_) return defaultValue
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
}
/**
* Output data respecting --json flag
*/
protected output(data: any, message?: string): void {
const { flags } = this.parse(this.constructor as typeof BaseCommand)
if (flags.json) {
this.log(JSON.stringify(data, null, 2))
} else if (message) {
this.log(message)
} else if (typeof data === 'string') {
this.log(data)
} else {
this.log(JSON.stringify(data, null, 2))
}
}
/**
* Enhanced error handling
*/
protected handleError(error: Error, context?: string): never {
const message = context ? `${context}: ${error.message}` : error.message
if (this.config.debug) {
this.error(error.stack || message, { exit: 1 })
} else {
this.error(message, { exit: 1 })
}
}
/**
* Log only if verbose/debug mode
*/
protected debug(message: string): void {
const { flags } = this.parse(this.constructor as typeof BaseCommand)
if (flags['log-level'] === 'debug') {
this.log(`[DEBUG] ${message}`)
}
}
/**
* Validate required environment variables
*/
protected requireEnv(vars: string[]): void {
const missing = vars.filter(v => !process.env[v])
if (missing.length > 0) {
this.error(
`Missing required environment variables: ${missing.join(', ')}`,
{ exit: 1 }
)
}
}
/**
* Check if running in CI environment
*/
protected isCI(): boolean {
return Boolean(process.env.CI)
}
/**
* Prompt user unless in CI or non-interactive mode
*/
protected async prompt(message: string, defaultValue?: string): Promise<string> {
if (this.isCI()) {
return defaultValue || ''
}
const { default: inquirer } = await import('inquirer')
const { value } = await inquirer.prompt([
{
type: 'input',
name: 'value',
message,
default: defaultValue,
},
])
return value
}
}

View File

@@ -0,0 +1,146 @@
import { Command, Flags, Args, ux } from '@oclif/core'
import * as fs from 'fs-extra'
import * as path from 'path'
export default class {{COMMAND_NAME}} extends Command {
static description = '{{DESCRIPTION}}'
static examples = [
'<%= config.bin %> <%= command.id %> myfile.txt --output result.json',
'<%= config.bin %> <%= command.id %> data.csv --format json --validate',
]
static flags = {
output: Flags.string({
char: 'o',
description: 'Output file path',
required: true,
}),
format: Flags.string({
char: 'f',
description: 'Output format',
options: ['json', 'yaml', 'csv'],
default: 'json',
}),
validate: Flags.boolean({
description: 'Validate input before processing',
default: false,
}),
force: Flags.boolean({
description: 'Overwrite existing output file',
default: false,
}),
verbose: Flags.boolean({
char: 'v',
description: 'Verbose output',
default: false,
}),
}
static args = {
file: Args.string({
description: 'Input file to process',
required: true,
}),
}
async run(): Promise<void> {
const { args, flags } = await this.parse({{COMMAND_NAME}})
// Validation
await this.validateInput(args.file, flags)
// Processing with spinner
ux.action.start('Processing file')
try {
const result = await this.processFile(args.file, flags)
ux.action.stop('done')
// Output
await this.writeOutput(result, flags.output, flags.format, flags.force)
this.log(`✓ Successfully processed ${args.file}`)
this.log(`✓ Output written to ${flags.output}`)
} catch (error) {
ux.action.stop('failed')
this.error(`Processing failed: ${error instanceof Error ? error.message : 'Unknown error'}`, {
exit: 1,
})
}
}
private async validateInput(file: string, flags: any): Promise<void> {
// Check file exists
if (!await fs.pathExists(file)) {
this.error(`File not found: ${file}`, { exit: 1 })
}
// Check output directory exists
const outputDir = path.dirname(flags.output)
if (!await fs.pathExists(outputDir)) {
this.error(`Output directory not found: ${outputDir}`, { exit: 1 })
}
// Check output file doesn't exist (unless force)
if (!flags.force && await fs.pathExists(flags.output)) {
const overwrite = await ux.confirm(`Output file ${flags.output} exists. Overwrite? (y/n)`)
if (!overwrite) {
this.error('Operation cancelled', { exit: 0 })
}
}
if (flags.validate) {
if (flags.verbose) {
this.log('Running validation...')
}
// Perform custom validation here
}
}
private async processFile(file: string, flags: any): Promise<any> {
// Read input file
const content = await fs.readFile(file, 'utf-8')
if (flags.verbose) {
this.log(`Read ${content.length} bytes from ${file}`)
}
// Process content (placeholder - implement your logic)
const result = {
source: file,
format: flags.format,
timestamp: new Date().toISOString(),
data: content,
}
return result
}
private async writeOutput(
result: any,
output: string,
format: string,
force: boolean
): Promise<void> {
let content: string
switch (format) {
case 'json':
content = JSON.stringify(result, null, 2)
break
case 'yaml':
// Implement YAML formatting
content = JSON.stringify(result, null, 2) // Placeholder
break
case 'csv':
// Implement CSV formatting
content = JSON.stringify(result, null, 2) // Placeholder
break
default:
content = JSON.stringify(result, null, 2)
}
await fs.writeFile(output, content, 'utf-8')
}
}

View File

@@ -0,0 +1,180 @@
import { Command, Flags, ux } from '@oclif/core'
export default class {{COMMAND_NAME}} extends Command {
static description = 'Async command with parallel operations and error handling'
static examples = [
'<%= config.bin %> <%= command.id %> --urls https://api1.com,https://api2.com',
'<%= config.bin %> <%= command.id %> --urls https://api.com --retry 3 --timeout 5000',
]
static flags = {
urls: Flags.string({
char: 'u',
description: 'Comma-separated list of URLs to fetch',
required: true,
}),
parallel: Flags.integer({
char: 'p',
description: 'Maximum parallel operations',
default: 5,
}),
timeout: Flags.integer({
char: 't',
description: 'Request timeout in milliseconds',
default: 10000,
}),
retry: Flags.integer({
char: 'r',
description: 'Number of retry attempts',
default: 0,
}),
verbose: Flags.boolean({
char: 'v',
description: 'Verbose output',
default: false,
}),
}
async run(): Promise<void> {
const { flags } = await this.parse({{COMMAND_NAME}})
const urls = flags.urls.split(',').map(u => u.trim())
if (flags.verbose) {
this.log(`Processing ${urls.length} URLs with max ${flags.parallel} parallel operations`)
}
// Process with progress bar
ux.action.start(`Fetching ${urls.length} URLs`)
try {
const results = await this.fetchWithConcurrency(urls, flags)
ux.action.stop('done')
// Display results table
this.displayResults(results, flags.verbose)
} catch (error) {
ux.action.stop('failed')
this.error(`Operation failed: ${error instanceof Error ? error.message : 'Unknown error'}`, {
exit: 1,
})
}
}
private async fetchWithConcurrency(
urls: string[],
flags: any
): Promise<Array<{ url: string; status: string; data?: any; error?: string }>> {
const results: Array<{ url: string; status: string; data?: any; error?: string }> = []
const chunks = this.chunkArray(urls, flags.parallel)
for (const chunk of chunks) {
const promises = chunk.map(url => this.fetchWithRetry(url, flags))
const chunkResults = await Promise.allSettled(promises)
chunkResults.forEach((result, index) => {
if (result.status === 'fulfilled') {
results.push({
url: chunk[index],
status: 'success',
data: result.value,
})
} else {
results.push({
url: chunk[index],
status: 'failed',
error: result.reason.message,
})
}
})
}
return results
}
private async fetchWithRetry(url: string, flags: any): Promise<any> {
let lastError: Error | null = null
for (let attempt = 0; attempt <= flags.retry; attempt++) {
try {
if (flags.verbose && attempt > 0) {
this.log(`Retry ${attempt}/${flags.retry} for ${url}`)
}
const response = await this.fetchWithTimeout(url, flags.timeout)
return response
} catch (error) {
lastError = error instanceof Error ? error : new Error('Unknown error')
if (attempt < flags.retry) {
// Exponential backoff
await this.sleep(Math.pow(2, attempt) * 1000)
}
}
}
throw lastError
}
private async fetchWithTimeout(url: string, timeout: number): Promise<any> {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), timeout)
try {
const response = await fetch(url, { signal: controller.signal })
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
const data = await response.json()
return data
} finally {
clearTimeout(timeoutId)
}
}
private displayResults(
results: Array<{ url: string; status: string; data?: any; error?: string }>,
verbose: boolean
): void {
const successCount = results.filter(r => r.status === 'success').length
const failCount = results.filter(r => r.status === 'failed').length
this.log(`\nResults: ${successCount} successful, ${failCount} failed\n`)
if (verbose) {
ux.table(results, {
url: {
header: 'URL',
},
status: {
header: 'Status',
},
error: {
header: 'Error',
get: row => row.error || '-',
},
})
} else {
// Show only failures
const failures = results.filter(r => r.status === 'failed')
if (failures.length > 0) {
this.log('Failed URLs:')
failures.forEach(f => this.log(` × ${f.url}: ${f.error}`))
}
}
}
private chunkArray<T>(array: T[], size: number): T[][] {
const chunks: T[][] = []
for (let i = 0; i < array.length; i += size) {
chunks.push(array.slice(i, i + size))
}
return chunks
}
private sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}
}

View File

@@ -0,0 +1,44 @@
import { Command, Flags } from '@oclif/core'
export default class {{COMMAND_NAME}} extends Command {
static description = '{{DESCRIPTION}}'
static examples = [
'<%= config.bin %> <%= command.id %> --name World',
'<%= config.bin %> <%= command.id %> --name "John Doe" --verbose',
]
static flags = {
name: Flags.string({
char: 'n',
description: 'Name to use',
required: true,
}),
verbose: Flags.boolean({
char: 'v',
description: 'Show verbose output',
default: false,
}),
force: Flags.boolean({
char: 'f',
description: 'Force operation',
default: false,
}),
}
static args = {}
async run(): Promise<void> {
const { flags } = await this.parse({{COMMAND_NAME}})
if (flags.verbose) {
this.log('Verbose mode enabled')
}
this.log(`Hello, ${flags.name}!`)
if (flags.force) {
this.log('Force mode: proceeding without confirmation')
}
}
}

View File

@@ -0,0 +1,198 @@
import { Command, Flags } from '@oclif/core'
import * as fs from 'fs-extra'
import * as path from 'path'
import * as os from 'os'
export default class {{COMMAND_NAME}} extends Command {
static description = 'Command with configuration file management'
static examples = [
'<%= config.bin %> <%= command.id %> --init',
'<%= config.bin %> <%= command.id %> --set key=value',
'<%= config.bin %> <%= command.id %> --get key',
'<%= config.bin %> <%= command.id %> --list',
]
static flags = {
init: Flags.boolean({
description: 'Initialize configuration file',
exclusive: ['set', 'get', 'list'],
}),
set: Flags.string({
description: 'Set configuration value (key=value)',
multiple: true,
exclusive: ['init', 'get', 'list'],
}),
get: Flags.string({
description: 'Get configuration value by key',
exclusive: ['init', 'set', 'list'],
}),
list: Flags.boolean({
description: 'List all configuration values',
exclusive: ['init', 'set', 'get'],
}),
global: Flags.boolean({
char: 'g',
description: 'Use global config instead of local',
default: false,
}),
}
private readonly DEFAULT_CONFIG = {
version: '1.0.0',
settings: {
theme: 'default',
verbose: false,
timeout: 30000,
},
}
async run(): Promise<void> {
const { flags } = await this.parse({{COMMAND_NAME}})
const configPath = this.getConfigPath(flags.global)
if (flags.init) {
await this.initConfig(configPath)
} else if (flags.set && flags.set.length > 0) {
await this.setConfig(configPath, flags.set)
} else if (flags.get) {
await this.getConfigValue(configPath, flags.get)
} else if (flags.list) {
await this.listConfig(configPath)
} else {
// Default: show current config location
this.log(`Config location: ${configPath}`)
if (await fs.pathExists(configPath)) {
this.log('Config file exists')
await this.listConfig(configPath)
} else {
this.log('Config file does not exist. Run with --init to create.')
}
}
}
private getConfigPath(global: boolean): string {
if (global) {
return path.join(os.homedir(), '.config', 'mycli', 'config.json')
} else {
return path.join(process.cwd(), '.myclirc')
}
}
private async initConfig(configPath: string): Promise<void> {
if (await fs.pathExists(configPath)) {
this.error('Config file already exists. Remove it first or use --set to update.', {
exit: 1,
})
}
// Ensure directory exists
await fs.ensureDir(path.dirname(configPath))
// Write default config
await fs.writeJson(configPath, this.DEFAULT_CONFIG, { spaces: 2 })
this.log(`✓ Initialized config file at: ${configPath}`)
}
private async setConfig(configPath: string, settings: string[]): Promise<void> {
// Load existing config or use default
let config = this.DEFAULT_CONFIG
if (await fs.pathExists(configPath)) {
config = await fs.readJson(configPath)
} else {
await fs.ensureDir(path.dirname(configPath))
}
// Parse and set values
for (const setting of settings) {
const [key, ...valueParts] = setting.split('=')
const value = valueParts.join('=')
if (!value) {
this.warn(`Skipping invalid setting: ${setting}`)
continue
}
// Support dot notation
this.setNestedValue(config, key, this.parseValue(value))
this.log(`✓ Set ${key} = ${value}`)
}
// Save config
await fs.writeJson(configPath, config, { spaces: 2 })
this.log(`\n✓ Updated config file: ${configPath}`)
}
private async getConfigValue(configPath: string, key: string): Promise<void> {
if (!await fs.pathExists(configPath)) {
this.error('Config file does not exist. Run with --init first.', { exit: 1 })
}
const config = await fs.readJson(configPath)
const value = this.getNestedValue(config, key)
if (value === undefined) {
this.error(`Key not found: ${key}`, { exit: 1 })
}
this.log(typeof value === 'object' ? JSON.stringify(value, null, 2) : String(value))
}
private async listConfig(configPath: string): Promise<void> {
if (!await fs.pathExists(configPath)) {
this.error('Config file does not exist. Run with --init first.', { exit: 1 })
}
const config = await fs.readJson(configPath)
this.log('\nCurrent configuration:')
this.log(JSON.stringify(config, null, 2))
}
private setNestedValue(obj: any, path: string, value: any): void {
const keys = path.split('.')
const lastKey = keys.pop()!
let current = obj
for (const key of keys) {
if (!(key in current) || typeof current[key] !== 'object') {
current[key] = {}
}
current = current[key]
}
current[lastKey] = value
}
private getNestedValue(obj: any, path: string): any {
const keys = path.split('.')
let current = obj
for (const key of keys) {
if (current && typeof current === 'object' && key in current) {
current = current[key]
} else {
return undefined
}
}
return current
}
private parseValue(value: string): any {
// Try to parse as JSON
if (value === 'true') return true
if (value === 'false') return false
if (value === 'null') return null
if (/^-?\d+$/.test(value)) return parseInt(value, 10)
if (/^-?\d+\.\d+$/.test(value)) return parseFloat(value)
if (value.startsWith('{') || value.startsWith('[')) {
try {
return JSON.parse(value)
} catch {
return value
}
}
return value
}
}

View File

@@ -0,0 +1,92 @@
{
"name": "{{CLI_NAME}}",
"version": "1.0.0",
"description": "{{DESCRIPTION}}",
"author": "{{AUTHOR}}",
"license": "MIT",
"main": "lib/index.js",
"types": "lib/index.d.ts",
"bin": {
"{{CLI_BIN}}": "./bin/run.js"
},
"files": [
"/bin",
"/lib",
"/oclif.manifest.json"
],
"keywords": [
"oclif",
"cli",
"{{CLI_NAME}}"
],
"oclif": {
"bin": "{{CLI_BIN}}",
"dirname": "{{CLI_BIN}}",
"commands": "./lib/commands",
"plugins": [
"@oclif/plugin-help",
"@oclif/plugin-plugins"
],
"topicSeparator": ":",
"topics": {
"config": {
"description": "Manage CLI configuration"
}
},
"hooks": {
"init": "./lib/hooks/init",
"prerun": "./lib/hooks/prerun"
},
"additionalHelpFlags": ["-h"],
"additionalVersionFlags": ["-v"]
},
"scripts": {
"build": "tsc -b",
"clean": "rm -rf lib && rm -f tsconfig.tsbuildinfo",
"dev": "ts-node src/index.ts",
"lint": "eslint . --ext .ts",
"lint:fix": "eslint . --ext .ts --fix",
"postpack": "rm -f oclif.manifest.json",
"prepack": "npm run build && oclif manifest && oclif readme",
"test": "mocha --forbid-only \"test/**/*.test.ts\"",
"test:watch": "mocha --watch \"test/**/*.test.ts\"",
"test:coverage": "nyc npm test",
"version": "oclif readme && git add README.md",
"posttest": "npm run lint"
},
"dependencies": {
"@oclif/core": "^3.0.0",
"@oclif/plugin-help": "^6.0.0",
"@oclif/plugin-plugins": "^4.0.0",
"fs-extra": "^11.0.0"
},
"devDependencies": {
"@oclif/test": "^3.0.0",
"@types/chai": "^4",
"@types/fs-extra": "^11",
"@types/mocha": "^10",
"@types/node": "^18",
"@typescript-eslint/eslint-plugin": "^6",
"@typescript-eslint/parser": "^6",
"chai": "^4",
"eslint": "^8",
"eslint-config-oclif": "^5",
"eslint-config-oclif-typescript": "^3",
"mocha": "^10",
"nyc": "^15",
"oclif": "^4.0.0",
"ts-node": "^10",
"typescript": "^5"
},
"engines": {
"node": ">=18.0.0"
},
"repository": {
"type": "git",
"url": "https://github.com/{{GITHUB_USER}}/{{CLI_NAME}}.git"
},
"bugs": {
"url": "https://github.com/{{GITHUB_USER}}/{{CLI_NAME}}/issues"
},
"homepage": "https://github.com/{{GITHUB_USER}}/{{CLI_NAME}}#readme"
}

View File

@@ -0,0 +1,54 @@
import { Command, Flags } from '@oclif/core'
/**
* Plugin command that extends the main CLI
*
* This command will be available as:
* mycli {{PLUGIN_NAME}}:{{COMMAND_NAME}}
*/
export default class {{COMMAND_CLASS}} extends Command {
static description = 'Plugin command: {{DESCRIPTION}}'
static examples = [
'<%= config.bin %> {{PLUGIN_NAME}}:{{COMMAND_NAME}} --option value',
]
static flags = {
option: Flags.string({
char: 'o',
description: 'Plugin-specific option',
}),
verbose: Flags.boolean({
char: 'v',
description: 'Verbose output',
default: false,
}),
}
static args = {}
async run(): Promise<void> {
const { flags } = await this.parse({{COMMAND_CLASS}})
if (flags.verbose) {
this.log('Running plugin command...')
}
// Plugin-specific logic here
this.log(`Plugin {{PLUGIN_NAME}} executing: ${this.id}`)
if (flags.option) {
this.log(`Option value: ${flags.option}`)
}
// Access main CLI config if needed
const cliConfig = this.config
this.log(`CLI version: ${cliConfig.version}`)
// You can also access other plugins
const plugins = cliConfig.plugins
if (flags.verbose) {
this.log(`Loaded plugins: ${plugins.map(p => p.name).join(', ')}`)
}
}
}

View File

@@ -0,0 +1,90 @@
import { Hook } from '@oclif/core'
/**
* oclif Hook Types:
* - init: Runs before any command
* - prerun: Runs before a command's run method
* - postrun: Runs after a command's run method
* - command_not_found: Runs when command not found
*/
/**
* Init hook - runs before any command
*/
export const init: Hook<'init'> = async function (opts) {
// Access configuration
const { config } = opts
// Plugin initialization logic
this.log('Plugin {{PLUGIN_NAME}} initialized')
// Example: Check for required environment variables
if (!process.env.PLUGIN_API_KEY) {
this.warn('PLUGIN_API_KEY not set - some features may not work')
}
// Example: Load plugin configuration
try {
// Load config from default locations
} catch (error) {
this.debug('Failed to load plugin config')
}
}
/**
* Prerun hook - runs before each command
*/
export const prerun: Hook<'prerun'> = async function (opts) {
const { Command, argv } = opts
// Log command execution (in debug mode)
this.debug(`Executing command: ${Command.id}`)
// Example: Validate environment before running commands
// Example: Log analytics
// Example: Check for updates
}
/**
* Postrun hook - runs after each command
*/
export const postrun: Hook<'postrun'> = async function (opts) {
const { Command } = opts
this.debug(`Command completed: ${Command.id}`)
// Example: Cleanup operations
// Example: Log analytics
// Example: Cache results
}
/**
* Command not found hook - runs when command doesn't exist
*/
export const command_not_found: Hook<'command_not_found'> = async function (opts) {
const { id } = opts
this.log(`Command "${id}" not found`)
// Example: Suggest similar commands
const suggestions = this.config.commands
.filter(c => c.id.includes(id) || id.includes(c.id))
.map(c => c.id)
.slice(0, 5)
if (suggestions.length > 0) {
this.log('\nDid you mean one of these?')
suggestions.forEach(s => this.log(` - ${s}`))
}
// Example: Check if command is in a plugin that's not installed
// Example: Suggest installing missing plugin
}
/**
* Custom plugin-specific hooks
* Export them and register in package.json oclif.hooks
*/
export const customHook = async function (opts: any) {
// Custom hook logic
}

View File

@@ -0,0 +1,41 @@
{
"name": "@mycli/plugin-{{PLUGIN_NAME}}",
"version": "1.0.0",
"description": "{{DESCRIPTION}}",
"commands": {
"{{PLUGIN_NAME}}:{{COMMAND_NAME}}": {
"id": "{{PLUGIN_NAME}}:{{COMMAND_NAME}}",
"description": "{{COMMAND_DESCRIPTION}}",
"pluginName": "@mycli/plugin-{{PLUGIN_NAME}}",
"pluginType": "core",
"aliases": [],
"examples": [
"<%= config.bin %> {{PLUGIN_NAME}}:{{COMMAND_NAME}} --option value"
],
"flags": {
"option": {
"name": "option",
"type": "option",
"char": "o",
"description": "Plugin-specific option",
"multiple": false
},
"verbose": {
"name": "verbose",
"type": "boolean",
"char": "v",
"description": "Verbose output",
"allowNo": false
}
},
"args": {}
}
},
"hooks": {
"init": [
{
"file": "./lib/hooks/init.js"
}
]
}
}

View File

@@ -0,0 +1,71 @@
{
"name": "@mycli/plugin-{{PLUGIN_NAME}}",
"version": "1.0.0",
"description": "{{DESCRIPTION}}",
"author": "{{AUTHOR}}",
"license": "MIT",
"main": "lib/index.js",
"types": "lib/index.d.ts",
"files": [
"/lib",
"/oclif.manifest.json"
],
"keywords": [
"oclif-plugin",
"cli",
"{{PLUGIN_NAME}}"
],
"oclif": {
"commands": "./lib/commands",
"bin": "mycli",
"devPlugins": [
"@oclif/plugin-help"
],
"topics": {
"{{PLUGIN_NAME}}": {
"description": "{{DESCRIPTION}}"
}
},
"hooks": {
"init": "./lib/hooks/init"
}
},
"scripts": {
"build": "tsc -b",
"clean": "rm -rf lib && rm -f tsconfig.tsbuildinfo",
"lint": "eslint . --ext .ts",
"postpack": "rm -f oclif.manifest.json",
"prepack": "npm run build && oclif manifest && oclif readme",
"test": "mocha --forbid-only \"test/**/*.test.ts\"",
"test:coverage": "nyc npm test",
"version": "oclif readme && git add README.md"
},
"dependencies": {
"@oclif/core": "^3.0.0"
},
"devDependencies": {
"@oclif/test": "^3.0.0",
"@types/chai": "^4",
"@types/mocha": "^10",
"@types/node": "^18",
"@typescript-eslint/eslint-plugin": "^6",
"@typescript-eslint/parser": "^6",
"chai": "^4",
"eslint": "^8",
"mocha": "^10",
"nyc": "^15",
"oclif": "^4.0.0",
"ts-node": "^10",
"typescript": "^5"
},
"engines": {
"node": ">=18.0.0"
},
"repository": {
"type": "git",
"url": "https://github.com/{{GITHUB_USER}}/{{PLUGIN_NAME}}.git"
},
"bugs": {
"url": "https://github.com/{{GITHUB_USER}}/{{PLUGIN_NAME}}/issues"
}
}

View File

@@ -0,0 +1,170 @@
import { expect, test } from '@oclif/test'
import * as fs from 'fs-extra'
import * as path from 'path'
describe('{{COMMAND_NAME}}', () => {
// Setup and teardown
const testDir = path.join(__dirname, 'fixtures', 'test-output')
beforeEach(async () => {
await fs.ensureDir(testDir)
})
afterEach(async () => {
await fs.remove(testDir)
})
// Test basic execution
test
.stdout()
.command(['{{COMMAND_NAME}}', '--help'])
.it('shows help', ctx => {
expect(ctx.stdout).to.contain('{{DESCRIPTION}}')
})
// Test with required flags
test
.stdout()
.command(['{{COMMAND_NAME}}', '--name', 'World'])
.it('runs with --name flag', ctx => {
expect(ctx.stdout).to.contain('Hello, World!')
})
// Test flag validation
test
.command(['{{COMMAND_NAME}}'])
.catch(error => {
expect(error.message).to.contain('Missing required flag')
})
.it('fails without required flags')
// Test with multiple flags
test
.stdout()
.command(['{{COMMAND_NAME}}', '--name', 'Test', '--verbose'])
.it('runs with verbose flag', ctx => {
expect(ctx.stdout).to.contain('Verbose mode enabled')
expect(ctx.stdout).to.contain('Hello, Test!')
})
// Test force flag behavior
test
.stdout()
.command(['{{COMMAND_NAME}}', '--name', 'Test', '--force'])
.it('runs with force flag', ctx => {
expect(ctx.stdout).to.contain('Force mode')
})
// Test error handling
test
.command(['{{COMMAND_NAME}}', '--name', ''])
.catch(error => {
expect(error.message).to.contain('Invalid')
})
.it('handles invalid input')
// Test exit codes
test
.command(['{{COMMAND_NAME}}', '--name', 'Test'])
.exit(0)
.it('exits with code 0 on success')
test
.command(['{{COMMAND_NAME}}'])
.exit(2)
.it('exits with code 2 on missing flags')
// Test with environment variables
test
.env({ CLI_NAME: 'EnvTest' })
.stdout()
.command(['{{COMMAND_NAME}}', '--name', 'fromEnv'])
.it('reads from environment variables', ctx => {
// Test env-based behavior
})
// Test with stdin input
test
.stdin('input data\n')
.stdout()
.command(['{{COMMAND_NAME}}', '--name', 'Test'])
.it('handles stdin input', ctx => {
// Test stdin handling
})
// Test file operations
test
.do(() => {
const filePath = path.join(testDir, 'test.txt')
return fs.writeFile(filePath, 'test content')
})
.stdout()
.command(['{{COMMAND_NAME}}', '--name', 'Test', '--file', path.join(testDir, 'test.txt')])
.it('processes file input', ctx => {
expect(ctx.stdout).to.contain('Success')
})
// Test async operations
test
.stdout()
.timeout(5000) // Increase timeout for async operations
.command(['{{COMMAND_NAME}}', '--name', 'Test', '--async'])
.it('handles async operations', ctx => {
expect(ctx.stdout).to.contain('completed')
})
// Test with mocked dependencies
test
.nock('https://api.example.com', api =>
api.get('/data').reply(200, { result: 'success' })
)
.stdout()
.command(['{{COMMAND_NAME}}', '--name', 'Test', '--api'])
.it('handles API calls', ctx => {
expect(ctx.stdout).to.contain('success')
})
// Test JSON output
test
.stdout()
.command(['{{COMMAND_NAME}}', '--name', 'Test', '--json'])
.it('outputs JSON format', ctx => {
const output = JSON.parse(ctx.stdout)
expect(output).to.have.property('name', 'Test')
})
})
// Grouped tests by functionality
describe('{{COMMAND_NAME}} - Flag Parsing', () => {
test
.stdout()
.command(['{{COMMAND_NAME}}', '-n', 'Short'])
.it('accepts short flags', ctx => {
expect(ctx.stdout).to.contain('Short')
})
test
.stdout()
.command(['{{COMMAND_NAME}}', '--name=Inline'])
.it('accepts inline flag values', ctx => {
expect(ctx.stdout).to.contain('Inline')
})
})
describe('{{COMMAND_NAME}} - Error Cases', () => {
test
.stderr()
.command(['{{COMMAND_NAME}}', '--invalid-flag'])
.catch(error => {
expect(error.message).to.contain('Unexpected argument')
})
.it('handles invalid flags')
test
.stderr()
.command(['{{COMMAND_NAME}}', '--name', 'Test', 'extra-arg'])
.catch(error => {
expect(error.message).to.contain('Unexpected argument')
})
.it('rejects unexpected arguments')
})

View File

@@ -0,0 +1,253 @@
import * as fs from 'fs-extra'
import * as path from 'path'
import { Config } from '@oclif/core'
/**
* Test helper utilities for oclif commands
*/
/**
* Create a temporary test directory
*/
export async function createTestDir(name: string): Promise<string> {
const dir = path.join(__dirname, 'fixtures', name)
await fs.ensureDir(dir)
return dir
}
/**
* Clean up test directory
*/
export async function cleanTestDir(dir: string): Promise<void> {
await fs.remove(dir)
}
/**
* Create a test fixture file
*/
export async function createFixture(
dir: string,
filename: string,
content: string
): Promise<string> {
const filePath = path.join(dir, filename)
await fs.writeFile(filePath, content)
return filePath
}
/**
* Read fixture file
*/
export async function readFixture(dir: string, filename: string): Promise<string> {
const filePath = path.join(dir, filename)
return fs.readFile(filePath, 'utf-8')
}
/**
* Create test config
*/
export async function createTestConfig(overrides?: any): Promise<any> {
const config = {
version: '1.0.0',
settings: {
verbose: false,
timeout: 30000,
},
...overrides,
}
return config
}
/**
* Mock stdin input
*/
export function mockStdin(input: string): void {
const originalStdin = process.stdin
const Readable = require('stream').Readable
const stdin = new Readable()
stdin.push(input)
stdin.push(null)
// @ts-ignore
process.stdin = stdin
}
/**
* Restore stdin
*/
export function restoreStdin(): void {
// Restore original stdin if needed
}
/**
* Capture stdout
*/
export class StdoutCapture {
private originalWrite: any
private output: string[] = []
start(): void {
this.output = []
this.originalWrite = process.stdout.write
process.stdout.write = ((chunk: any, encoding?: any, callback?: any) => {
this.output.push(chunk.toString())
return true
}) as any
}
stop(): void {
process.stdout.write = this.originalWrite
}
getOutput(): string {
return this.output.join('')
}
getLines(): string[] {
return this.getOutput().split('\n').filter(Boolean)
}
clear(): void {
this.output = []
}
}
/**
* Capture stderr
*/
export class StderrCapture {
private originalWrite: any
private output: string[] = []
start(): void {
this.output = []
this.originalWrite = process.stderr.write
process.stderr.write = ((chunk: any, encoding?: any, callback?: any) => {
this.output.push(chunk.toString())
return true
}) as any
}
stop(): void {
process.stderr.write = this.originalWrite
}
getOutput(): string {
return this.output.join('')
}
clear(): void {
this.output = []
}
}
/**
* Wait for a condition to be true
*/
export async function waitFor(
condition: () => boolean | Promise<boolean>,
timeout = 5000,
interval = 100
): Promise<void> {
const start = Date.now()
while (Date.now() - start < timeout) {
if (await condition()) {
return
}
await sleep(interval)
}
throw new Error('Timeout waiting for condition')
}
/**
* Sleep for specified milliseconds
*/
export function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}
/**
* Create mock HTTP response
*/
export function createMockResponse(status: number, data: any): any {
return {
ok: status >= 200 && status < 300,
status,
statusText: status === 200 ? 'OK' : 'Error',
json: async () => data,
text: async () => JSON.stringify(data),
headers: new Map(),
}
}
/**
* Mock fetch globally
*/
export function mockFetch(responses: Map<string, any>): void {
const originalFetch = global.fetch
global.fetch = async (url: string | URL, options?: any) => {
const urlStr = url.toString()
const response = responses.get(urlStr)
if (!response) {
throw new Error(`No mock response for URL: ${urlStr}`)
}
return response
} as any
}
/**
* Restore fetch
*/
export function restoreFetch(): void {
// Restore if needed
}
/**
* Create test environment variables
*/
export function withEnv(vars: Record<string, string>, fn: () => void | Promise<void>): any {
return async () => {
const original = { ...process.env }
Object.assign(process.env, vars)
try {
await fn()
} finally {
process.env = original
}
}
}
/**
* Assert file exists
*/
export async function assertFileExists(filePath: string): Promise<void> {
const exists = await fs.pathExists(filePath)
if (!exists) {
throw new Error(`Expected file to exist: ${filePath}`)
}
}
/**
* Assert file contains
*/
export async function assertFileContains(filePath: string, content: string): Promise<void> {
await assertFileExists(filePath)
const fileContent = await fs.readFile(filePath, 'utf-8')
if (!fileContent.includes(content)) {
throw new Error(`Expected file ${filePath} to contain: ${content}`)
}
}
/**
* Create test oclif config
*/
export async function createOclifConfig(root: string): Promise<Config> {
return Config.load(root)
}
/**
* Run command programmatically
*/
export async function runCommand(args: string[], config?: Config): Promise<any> {
const { run } = await import('@oclif/core')
return run(args, config ? config.root : undefined)
}

View File

@@ -0,0 +1,202 @@
import { expect } from 'chai'
import { runCommand } from '@oclif/test'
import * as fs from 'fs-extra'
import * as path from 'path'
/**
* Integration tests for complete CLI workflows
*/
describe('Integration Tests', () => {
const testDir = path.join(__dirname, 'fixtures', 'integration')
before(async () => {
await fs.ensureDir(testDir)
})
after(async () => {
await fs.remove(testDir)
})
describe('Complete workflow', () => {
it('runs full command chain', async () => {
// Step 1: Initialize
const initResult = await runCommand(['init', '--dir', testDir])
expect(initResult).to.have.property('code', 0)
// Verify initialization
const configPath = path.join(testDir, '.clirc')
expect(await fs.pathExists(configPath)).to.be.true
// Step 2: Configure
const configResult = await runCommand([
'config',
'--set',
'key=value',
'--dir',
testDir,
])
expect(configResult).to.have.property('code', 0)
// Verify configuration
const config = await fs.readJson(configPath)
expect(config).to.have.property('key', 'value')
// Step 3: Execute main operation
const execResult = await runCommand(['execute', '--dir', testDir])
expect(execResult).to.have.property('code', 0)
// Verify output
const outputPath = path.join(testDir, 'output.json')
expect(await fs.pathExists(outputPath)).to.be.true
})
it('handles errors gracefully', async () => {
// Attempt operation without initialization
try {
await runCommand(['execute', '--dir', '/nonexistent'])
expect.fail('Should have thrown error')
} catch (error: any) {
expect(error.message).to.include('not initialized')
}
})
})
describe('Plugin integration', () => {
it('loads and executes plugin commands', async () => {
// Install plugin
const installResult = await runCommand(['plugins:install', '@mycli/plugin-test'])
expect(installResult).to.have.property('code', 0)
// Execute plugin command
const pluginResult = await runCommand(['test:command', '--option', 'value'])
expect(pluginResult).to.have.property('code', 0)
// Uninstall plugin
const uninstallResult = await runCommand(['plugins:uninstall', '@mycli/plugin-test'])
expect(uninstallResult).to.have.property('code', 0)
})
})
describe('Multi-command workflows', () => {
it('chains commands with data flow', async () => {
// Generate data
const generateResult = await runCommand([
'generate',
'--output',
path.join(testDir, 'data.json'),
])
expect(generateResult).to.have.property('code', 0)
// Process data
const processResult = await runCommand([
'process',
'--input',
path.join(testDir, 'data.json'),
'--output',
path.join(testDir, 'processed.json'),
])
expect(processResult).to.have.property('code', 0)
// Validate output
const validateResult = await runCommand([
'validate',
path.join(testDir, 'processed.json'),
])
expect(validateResult).to.have.property('code', 0)
})
})
describe('Environment-specific behavior', () => {
it('respects environment variables', async () => {
// Set environment
process.env.CLI_ENV = 'production'
process.env.CLI_DEBUG = 'false'
const result = await runCommand(['status'])
expect(result).to.have.property('code', 0)
// Cleanup
delete process.env.CLI_ENV
delete process.env.CLI_DEBUG
})
it('handles CI environment', async () => {
// Simulate CI environment
process.env.CI = 'true'
// Commands should not prompt in CI
const result = await runCommand(['deploy', '--auto-confirm'])
expect(result).to.have.property('code', 0)
// Cleanup
delete process.env.CI
})
})
describe('Error recovery', () => {
it('recovers from partial failures', async () => {
// Start operation
const startResult = await runCommand([
'start-operation',
'--output',
path.join(testDir, 'operation.lock'),
])
expect(startResult).to.have.property('code', 0)
// Simulate failure (lock file exists)
expect(await fs.pathExists(path.join(testDir, 'operation.lock'))).to.be.true
// Retry with cleanup
const retryResult = await runCommand([
'start-operation',
'--output',
path.join(testDir, 'operation.lock'),
'--force',
])
expect(retryResult).to.have.property('code', 0)
})
})
describe('Performance', () => {
it('handles large datasets efficiently', async () => {
const largeFile = path.join(testDir, 'large.json')
// Generate large dataset
const largeData = Array.from({ length: 10000 }, (_, i) => ({
id: i,
name: `item-${i}`,
data: 'x'.repeat(100),
}))
await fs.writeJson(largeFile, largeData)
// Process large file
const startTime = Date.now()
const result = await runCommand(['process-large', '--input', largeFile])
const duration = Date.now() - startTime
expect(result).to.have.property('code', 0)
expect(duration).to.be.lessThan(30000) // Should complete within 30 seconds
})
})
describe('Concurrent operations', () => {
it('handles concurrent commands', async () => {
// Run multiple commands in parallel
const results = await Promise.all([
runCommand(['task-1', '--output', path.join(testDir, 'out1.json')]),
runCommand(['task-2', '--output', path.join(testDir, 'out2.json')]),
runCommand(['task-3', '--output', path.join(testDir, 'out3.json')]),
])
// All should succeed
results.forEach(result => {
expect(result).to.have.property('code', 0)
})
// Verify all outputs
expect(await fs.pathExists(path.join(testDir, 'out1.json'))).to.be.true
expect(await fs.pathExists(path.join(testDir, 'out2.json'))).to.be.true
expect(await fs.pathExists(path.join(testDir, 'out3.json'))).to.be.true
})
})
})

View File

@@ -0,0 +1,109 @@
import { expect } from 'chai'
import * as path from 'path'
/**
* Global test setup for oclif commands
*
* This file is loaded before all tests
*/
// Extend chai with custom assertions if needed
expect.extend = function (assertions: any) {
Object.assign(expect, assertions)
}
// Set test environment
process.env.NODE_ENV = 'test'
process.env.CLI_TEST = 'true'
// Disable colors in test output
process.env.FORCE_COLOR = '0'
// Set test timeout
const DEFAULT_TIMEOUT = 10000
if (typeof (global as any).setTimeout !== 'undefined') {
;(global as any).setTimeout(DEFAULT_TIMEOUT)
}
// Setup global test fixtures directory
export const FIXTURES_DIR = path.join(__dirname, 'fixtures')
// Mock console methods if needed
export function mockConsole() {
const originalLog = console.log
const originalError = console.error
const originalWarn = console.warn
const logs: string[] = []
const errors: string[] = []
const warns: string[] = []
console.log = (...args: any[]) => {
logs.push(args.join(' '))
}
console.error = (...args: any[]) => {
errors.push(args.join(' '))
}
console.warn = (...args: any[]) => {
warns.push(args.join(' '))
}
return {
logs,
errors,
warns,
restore: () => {
console.log = originalLog
console.error = originalError
console.warn = originalWarn
},
}
}
// Global before hook
before(async () => {
// Setup test database, services, etc.
})
// Global after hook
after(async () => {
// Cleanup test resources
})
// Global beforeEach hook
beforeEach(() => {
// Reset state before each test
})
// Global afterEach hook
afterEach(() => {
// Cleanup after each test
})
/**
* Custom matchers for oclif tests
*/
export const customMatchers = {
/**
* Check if output contains text
*/
toContainOutput(received: string, expected: string): boolean {
return received.includes(expected)
},
/**
* Check if command succeeded
*/
toSucceed(received: { code: number }): boolean {
return received.code === 0
},
/**
* Check if command failed with specific code
*/
toFailWith(received: { code: number }, expectedCode: number): boolean {
return received.code === expectedCode
},
}

View File

@@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"lib": ["ES2022"],
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "./lib",
"rootDir": "./src",
"composite": true,
"incremental": true,
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"moduleResolution": "node",
"types": ["node"],
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "lib", "test"]
}