UNPKG

citty-test-utils

Version:

Unified testing framework for CLI applications with auto-detecting local/cleanroom execution, vitest config integration, and simplified scenario DSL.

322 lines (285 loc) 8.69 kB
#!/usr/bin/env node /** * @fileoverview Smart CLI Detection Utility * @description Automatically detects CLI entry points from package.json and project structure */ import { readFileSync, existsSync, statSync } from 'fs' import { join, resolve } from 'path' import { cwd } from 'process' /** * Smart CLI Detection Utility * Automatically detects CLI entry points from package.json and project structure */ export class SmartCLIDetector { constructor(options = {}) { this.options = { workingDir: options.workingDir || cwd(), verbose: options.verbose || false, ...options, } } /** * Detect CLI entry point for the current project * @param {Object} options - Detection options * @returns {Promise<Object>} CLI detection result */ async detectCLI(options = {}) { const detectionOptions = { ...this.options, ...options } if (detectionOptions.verbose) { console.log('🔍 Starting smart CLI detection...') console.log(`Working directory: ${detectionOptions.workingDir}`) } try { // Step 1: Check for package.json in current directory const packageJsonPath = join(detectionOptions.workingDir, 'package.json') if (existsSync(packageJsonPath)) { const packageJson = this.readPackageJson(packageJsonPath) const binResult = this.detectFromBin(packageJson, detectionOptions.workingDir) if (binResult) { if (detectionOptions.verbose) { console.log(`✅ Found CLI via package.json bin: ${binResult.cliPath}`) } return binResult } } // Step 2: Check for common CLI file patterns const commonPatterns = [ 'src/cli.mjs', 'src/cli.js', 'cli.mjs', 'cli.js', 'index.mjs', 'index.js', 'bin/cli.mjs', 'bin/cli.js', 'bin/index.mjs', 'bin/index.js', ] for (const pattern of commonPatterns) { const cliPath = join(detectionOptions.workingDir, pattern) if (existsSync(cliPath)) { const stat = statSync(cliPath) if (stat.isFile()) { if (detectionOptions.verbose) { console.log(`✅ Found CLI via common pattern: ${cliPath}`) } return { cliPath, detectionMethod: 'common-pattern', confidence: 'medium', packageName: this.getPackageName(detectionOptions.workingDir), } } } } // Step 3: Check parent directories for package.json const parentResult = await this.checkParentDirectories(detectionOptions.workingDir) if (parentResult) { if (detectionOptions.verbose) { console.log(`✅ Found CLI via parent directory: ${parentResult.cliPath}`) } return parentResult } // Step 4: No CLI found if (detectionOptions.verbose) { console.log('❌ No CLI entry point detected') } return { cliPath: null, detectionMethod: 'none', confidence: 'none', error: 'No CLI entry point found in project structure', } } catch (error) { throw new Error(`Smart CLI detection failed: ${error.message}`) } } /** * Detect CLI from package.json bin field * @param {Object} packageJson - Package.json object * @param {string} workingDir - Working directory * @returns {Object|null} CLI detection result */ detectFromBin(packageJson, workingDir) { if (!packageJson.bin) return null let binPath = null let binName = null // Handle different bin formats if (typeof packageJson.bin === 'string') { binPath = packageJson.bin binName = packageJson.name } else if (typeof packageJson.bin === 'object') { // Get the first bin entry const entries = Object.entries(packageJson.bin) if (entries.length > 0) { binName = entries[0][0] binPath = entries[0][1] } } if (!binPath) return null // Resolve the bin path const resolvedPath = resolve(workingDir, binPath) if (existsSync(resolvedPath)) { return { cliPath: resolvedPath, binName, packageName: packageJson.name, detectionMethod: 'package-json-bin', confidence: 'high', packageJson: { name: packageJson.name, version: packageJson.version, description: packageJson.description, }, } } return null } /** * Check parent directories for package.json * @param {string} startDir - Starting directory * @returns {Promise<Object|null>} CLI detection result */ async checkParentDirectories(startDir) { let currentDir = startDir const maxDepth = 5 // Prevent infinite loops let depth = 0 while (depth < maxDepth) { const parentDir = join(currentDir, '..') // Check if we've reached the filesystem root if (parentDir === currentDir) { break } const packageJsonPath = join(parentDir, 'package.json') if (existsSync(packageJsonPath)) { const packageJson = this.readPackageJson(packageJsonPath) const binResult = this.detectFromBin(packageJson, parentDir) if (binResult) { return { ...binResult, detectionMethod: 'parent-package-json-bin', confidence: 'medium', } } } currentDir = parentDir depth++ } return null } /** * Read and parse package.json * @param {string} packageJsonPath - Path to package.json * @returns {Object} Parsed package.json */ readPackageJson(packageJsonPath) { try { const content = readFileSync(packageJsonPath, 'utf8') return JSON.parse(content) } catch (error) { throw new Error(`Failed to read package.json at ${packageJsonPath}: ${error.message}`) } } /** * Get package name from directory * @param {string} dir - Directory path * @returns {string} Package name */ getPackageName(dir) { try { const packageJsonPath = join(dir, 'package.json') if (existsSync(packageJsonPath)) { const packageJson = this.readPackageJson(packageJsonPath) return packageJson.name || 'unknown' } } catch (error) { // Ignore errors } return 'unknown' } /** * Validate detected CLI * @param {string} cliPath - Path to CLI file * @returns {Object} Validation result */ validateCLI(cliPath) { if (!cliPath || !existsSync(cliPath)) { return { valid: false, error: 'CLI file does not exist', } } try { const content = readFileSync(cliPath, 'utf8') // Basic validation checks const hasShebang = content.startsWith('#!') const hasDefineCommand = content.includes('defineCommand') const hasExport = content.includes('export') const hasImport = content.includes('import') return { valid: true, hasShebang, hasDefineCommand, hasExport, hasImport, fileSize: content.length, lineCount: content.split('\n').length, } } catch (error) { return { valid: false, error: `Failed to read CLI file: ${error.message}`, } } } /** * Get CLI usage information * @param {string} cliPath - Path to CLI file * @returns {Object} Usage information */ getCLIUsage(cliPath) { if (!cliPath || !existsSync(cliPath)) { return { available: false, error: 'CLI file does not exist', } } try { // Try to get help output const { spawn } = require('child_process') return new Promise((resolve) => { const child = spawn('node', [cliPath, '--help'], { stdio: ['pipe', 'pipe', 'pipe'], }) let stdout = '' let stderr = '' child.stdout.on('data', (data) => { stdout += data.toString() }) child.stderr.on('data', (data) => { stderr += data.toString() }) child.on('close', (code) => { resolve({ available: code === 0, exitCode: code, stdout, stderr, helpAvailable: stdout.includes('USAGE') || stdout.includes('Usage'), }) }) child.on('error', (error) => { resolve({ available: false, error: error.message, }) }) }) } catch (error) { return { available: false, error: `Failed to execute CLI: ${error.message}`, } } } }