Initial commit
This commit is contained in:
18
skills/oclif-patterns/templates/.eslintrc.json
Normal file
18
skills/oclif-patterns/templates/.eslintrc.json
Normal 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"
|
||||
}
|
||||
}
|
||||
192
skills/oclif-patterns/templates/base-command.ts
Normal file
192
skills/oclif-patterns/templates/base-command.ts
Normal 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
|
||||
}
|
||||
}
|
||||
146
skills/oclif-patterns/templates/command-advanced.ts
Normal file
146
skills/oclif-patterns/templates/command-advanced.ts
Normal 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')
|
||||
}
|
||||
}
|
||||
180
skills/oclif-patterns/templates/command-async.ts
Normal file
180
skills/oclif-patterns/templates/command-async.ts
Normal 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))
|
||||
}
|
||||
}
|
||||
44
skills/oclif-patterns/templates/command-basic.ts
Normal file
44
skills/oclif-patterns/templates/command-basic.ts
Normal 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')
|
||||
}
|
||||
}
|
||||
}
|
||||
198
skills/oclif-patterns/templates/command-with-config.ts
Normal file
198
skills/oclif-patterns/templates/command-with-config.ts
Normal 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
|
||||
}
|
||||
}
|
||||
92
skills/oclif-patterns/templates/package.json
Normal file
92
skills/oclif-patterns/templates/package.json
Normal 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"
|
||||
}
|
||||
54
skills/oclif-patterns/templates/plugin-command.ts
Normal file
54
skills/oclif-patterns/templates/plugin-command.ts
Normal 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(', ')}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
90
skills/oclif-patterns/templates/plugin-hooks.ts
Normal file
90
skills/oclif-patterns/templates/plugin-hooks.ts
Normal 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
|
||||
}
|
||||
41
skills/oclif-patterns/templates/plugin-manifest.json
Normal file
41
skills/oclif-patterns/templates/plugin-manifest.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
71
skills/oclif-patterns/templates/plugin-package.json
Normal file
71
skills/oclif-patterns/templates/plugin-package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
170
skills/oclif-patterns/templates/test-command.ts
Normal file
170
skills/oclif-patterns/templates/test-command.ts
Normal 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')
|
||||
})
|
||||
253
skills/oclif-patterns/templates/test-helpers.ts
Normal file
253
skills/oclif-patterns/templates/test-helpers.ts
Normal 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)
|
||||
}
|
||||
202
skills/oclif-patterns/templates/test-integration.ts
Normal file
202
skills/oclif-patterns/templates/test-integration.ts
Normal 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
|
||||
})
|
||||
})
|
||||
})
|
||||
109
skills/oclif-patterns/templates/test-setup.ts
Normal file
109
skills/oclif-patterns/templates/test-setup.ts
Normal 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
|
||||
},
|
||||
}
|
||||
27
skills/oclif-patterns/templates/tsconfig.json
Normal file
27
skills/oclif-patterns/templates/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user