#!/usr/bin/env node // CLI tool for scaffolding new Hourglass performers with TypeChain integration import * as fs from 'fs'; import * as path from 'path'; import { execSync } from 'child_process'; import * as readline from 'readline'; interface ProjectConfig { name: string; description: string; useTypeChain: boolean; contractsPath?: string; port: number; author: string; } /** * CLI for creating new Hourglass performers */ class CreatePerformerCLI { private rl: readline.Interface; constructor() { this.rl = readline.createInterface({ input: process.stdin, output: process.stdout, }); } /** * Main CLI entry point */ async run(args: string[]): Promise { console.log('šŸ—ļø Hourglass Performer Scaffolding Tool'); console.log('━'.repeat(50)); try { const config = await this.collectProjectConfig(args); const projectPath = await this.createProject(config); await this.installDependencies(projectPath); if (config.useTypeChain && config.contractsPath) { await this.setupTypeChain(projectPath, config.contractsPath); } await this.createExampleFiles(projectPath, config); this.showSuccessMessage(projectPath, config); } catch (error) { console.error('āŒ Error creating performer:', error); process.exit(1); } finally { this.rl.close(); } } /** * Collect project configuration from user */ private async collectProjectConfig(args: string[]): Promise { const config: ProjectConfig = { name: '', description: '', useTypeChain: false, port: 8080, author: 'AVS Developer', }; // Check for project name in arguments if (args.length > 0) { config.name = args[0]; } else { config.name = await this.ask('Project name: '); } // Ensure name is not empty if (!config.name.trim()) { throw new Error('Project name is required'); } // Validate project name if (!config.name || !/^[a-zA-Z0-9-_]+$/.test(config.name)) { throw new Error('Invalid project name. Use alphanumeric characters, hyphens, and underscores only.'); } config.description = await this.ask(`Description (optional): `) || `${config.name} AVS performer`; config.author = await this.ask('Author: ') || config.author; const portInput = await this.ask('Port (8080): '); if (portInput) { const port = parseInt(portInput, 10); if (isNaN(port) || port < 1 || port > 65535) { throw new Error('Invalid port number'); } config.port = port; } const useTypeChainInput = await this.ask('Use TypeChain for Solidity contract integration? (y/N): '); config.useTypeChain = useTypeChainInput.toLowerCase() === 'y' || useTypeChainInput.toLowerCase() === 'yes'; if (config.useTypeChain) { config.contractsPath = await this.ask('Path to contract ABIs (./contracts): ') || './contracts'; } return config; } /** * Create project directory and basic structure */ private async createProject(config: ProjectConfig): Promise { const projectPath = path.resolve(process.cwd(), config.name); if (fs.existsSync(projectPath)) { throw new Error(`Directory ${config.name} already exists`); } console.log(`\nšŸ“ Creating project structure in ${projectPath}`); // Create directory structure fs.mkdirSync(projectPath, { recursive: true }); fs.mkdirSync(path.join(projectPath, 'src'), { recursive: true }); fs.mkdirSync(path.join(projectPath, 'src', 'workers'), { recursive: true }); fs.mkdirSync(path.join(projectPath, 'src', 'types'), { recursive: true }); if (config.useTypeChain && config.contractsPath) { fs.mkdirSync(path.join(projectPath, config.contractsPath), { recursive: true }); fs.mkdirSync(path.join(projectPath, 'typechain-types'), { recursive: true }); } // Create package.json const packageJson = this.generatePackageJson(config); fs.writeFileSync( path.join(projectPath, 'package.json'), JSON.stringify(packageJson, null, 2) ); // Create TypeScript config const tsConfig = this.generateTsConfig(config); fs.writeFileSync( path.join(projectPath, 'tsconfig.json'), JSON.stringify(tsConfig, null, 2) ); // Create .gitignore const gitignore = this.generateGitignore(config); fs.writeFileSync(path.join(projectPath, '.gitignore'), gitignore); // Create README const readme = this.generateReadme(config); fs.writeFileSync(path.join(projectPath, 'README.md'), readme); return projectPath; } /** * Install npm dependencies */ private async installDependencies(projectPath: string): Promise { console.log('\nšŸ“¦ Installing dependencies...'); try { execSync('npm install', { cwd: projectPath, stdio: 'inherit' }); } catch (error) { throw new Error('Failed to install dependencies'); } } /** * Set up TypeChain configuration */ private async setupTypeChain(projectPath: string, contractsPath: string): Promise { console.log('\nāš™ļø Setting up TypeChain...'); // Create typechain config const typechainConfig = { files: [`${contractsPath}/**/*.json`], target: 'ethers-v6', outDir: 'typechain-types', }; fs.writeFileSync( path.join(projectPath, 'typechain.config.json'), JSON.stringify(typechainConfig, null, 2) ); // Create sample ABI if contracts directory is empty const fullContractsPath = path.join(projectPath, contractsPath); if (fs.readdirSync(fullContractsPath).length === 0) { const sampleAbi = this.generateSampleAbi(); fs.writeFileSync( path.join(fullContractsPath, 'SampleContract.json'), JSON.stringify(sampleAbi, null, 2) ); } } /** * Create example files based on configuration */ private async createExampleFiles(projectPath: string, config: ProjectConfig): Promise { console.log('\nšŸ“ Creating example files...'); // Create main performer file const mainPerformer = config.useTypeChain ? this.generateSolidityPerformer(config) : this.generateBasicPerformer(config); fs.writeFileSync( path.join(projectPath, 'src', 'performer.ts'), mainPerformer ); // Create Docker files const dockerfile = this.generateDockerfile(config); fs.writeFileSync(path.join(projectPath, 'Dockerfile'), dockerfile); const dockerCompose = this.generateDockerCompose(config); fs.writeFileSync(path.join(projectPath, 'docker-compose.yml'), dockerCompose); // Create example worker if (config.useTypeChain) { const solidityWorker = this.generateSolidityWorkerExample(config); fs.writeFileSync( path.join(projectPath, 'src', 'workers', 'sampleWorker.ts'), solidityWorker ); } else { const basicWorker = this.generateBasicWorkerExample(config); fs.writeFileSync( path.join(projectPath, 'src', 'workers', 'sampleWorker.ts'), basicWorker ); } } /** * Generate package.json content */ private generatePackageJson(config: ProjectConfig): any { const basePackage: any = { name: config.name, version: '1.0.0', description: config.description, main: 'dist/performer.js', scripts: { build: 'tsc', 'build:watch': 'tsc --watch', dev: 'ts-node-dev --respawn --transpile-only src/performer.ts', start: 'node dist/performer.js', 'docker:build': `docker build -t ${config.name} .`, 'docker:run': `docker run -p ${config.port}:${config.port} ${config.name}`, }, keywords: ['hourglass', 'avs', 'performer', 'eigenlayer'], author: config.author, license: 'MIT', dependencies: { '@hourglass/performer': '^0.1.0', }, devDependencies: { '@types/node': '^20.10.5', typescript: '^5.3.3', 'ts-node-dev': '^2.0.0', }, }; if (config.useTypeChain) { basePackage.scripts['typechain'] = 'typechain --target ethers-v6 --out-dir typechain-types \'contracts/**/*.json\''; basePackage.scripts['prebuild'] = 'npm run typechain'; basePackage.dependencies['ethers'] = '^6.8.0'; basePackage.devDependencies['typechain'] = '^8.3.0'; basePackage.devDependencies['@typechain/ethers-v6'] = '^0.5.0'; } return basePackage; } /** * Generate TypeScript configuration */ private generateTsConfig(config: ProjectConfig): any { return { compilerOptions: { target: 'ES2020', module: 'commonjs', lib: ['ES2020'], outDir: './dist', rootDir: './src', strict: true, esModuleInterop: true, skipLibCheck: true, forceConsistentCasingInFileNames: true, declaration: true, declarationMap: true, sourceMap: true, resolveJsonModule: true, typeRoots: ['./node_modules/@types', './typechain-types'], }, include: ['src/**/*'], exclude: ['node_modules', 'dist'], }; } /** * Generate .gitignore content */ private generateGitignore(config: ProjectConfig): string { let gitignore = `# Dependencies node_modules/ npm-debug.log* yarn-debug.log* yarn-error.log* # Build output dist/ *.tsbuildinfo # Environment files .env .env.local .env.development.local .env.test.local .env.production.local # Logs logs/ *.log # IDE .vscode/ .idea/ *.swp *.swo # OS .DS_Store Thumbs.db `; if (config.useTypeChain) { gitignore += ` # TypeChain generated files typechain-types/ `; } return gitignore; } /** * Generate README content */ private generateReadme(config: ProjectConfig): string { return `# ${config.name} ${config.description} ## Quick Start \`\`\`bash # Install dependencies npm install # Build the project npm run build # Run in development mode npm run dev # Run in production npm start \`\`\` ## Docker \`\`\`bash # Build Docker image npm run docker:build # Run container npm run docker:run \`\`\` ${config.useTypeChain ? `## TypeChain Integration This project uses TypeChain for type-safe Solidity contract integration. \`\`\`bash # Generate TypeChain types from contract ABIs npm run typechain # The types will be generated in ./typechain-types/ \`\`\` Add your contract ABIs to the \`${config.contractsPath}\` directory and run \`npm run typechain\` to generate TypeScript types. ` : ''}## Architecture - **Performer**: Main gRPC server handling task execution - **Workers**: Task processing logic${config.useTypeChain ? ' with Solidity contract integration' : ''} - **Health & Monitoring**: Built-in health checks and metrics collection ## Development The performer server will automatically restart when you make changes to the source code in development mode. ## Production Deployment 1. Build the project: \`npm run build\` 2. Start the server: \`npm start\` 3. The server will listen on port ${config.port} ## Configuration Configure your performer by modifying the server configuration in \`src/performer.ts\`. ## Contributing 1. Fork the repository 2. Create a feature branch 3. Make your changes 4. Submit a pull request ## License MIT `; } /** * Generate sample ABI for TypeChain */ private generateSampleAbi(): any { return { abi: [ { inputs: [ { name: 'taskId', type: 'bytes32' }, { name: 'data', type: 'bytes' }, { name: 'user', type: 'address' }, ], name: 'processTask', outputs: [ { name: 'result', type: 'bytes' }, { name: 'success', type: 'bool' }, ], stateMutability: 'nonpayable', type: 'function', }, { inputs: [ { name: 'value', type: 'uint256' }, ], name: 'square', outputs: [ { name: 'result', type: 'uint256' }, ], stateMutability: 'pure', type: 'function', }, ], bytecode: '0x608060405234801561001057600080fd5b50...', }; } /** * Generate basic performer implementation */ private generateBasicPerformer(config: ProjectConfig): string { return `import { BaseWorker } from '@hourglass/performer'; /** * ${config.name} performer */ class ${this.toPascalCase(config.name)}Performer extends BaseWorker { async handleSimpleTask(input: any) { // TODO: Implement your AVS logic here // Example: Process a number and return its square const result = typeof input === 'number' ? input * input : 42; return result; } } // One-line server startup new ${this.toPascalCase(config.name)}Performer().start(${config.port}); `; } /** * Generate Solidity performer implementation */ private generateSolidityPerformer(config: ProjectConfig): string { return `import { SolidityWorker } from '@hourglass/performer'; // TODO: Import your contract types from typechain-types // import type { MyContract } from './typechain-types'; /** * ${config.name} performer with TypeChain integration */ class ${this.toPascalCase(config.name)}Performer extends SolidityWorker { async handleSolidityTask(params: { taskId: string; data: Uint8Array; user: string }) { // TODO: Implement your AVS logic here // params are automatically typed based on your contract ABI const { taskId, data, user } = params; // Example: Process the data and return result const processedData = new TextEncoder().encode( \`Processed: \${new TextDecoder().decode(data)}\` ); return { result: processedData, success: true, }; } } // One-line server startup new ${this.toPascalCase(config.name)}Performer().start(${config.port}); `; } /** * Generate basic worker example */ private generateBasicWorkerExample(config: ProjectConfig): string { return `import { BaseWorker } from '@hourglass/performer'; import { TaskRequest, TaskResponse } from '@hourglass/performer'; export class SampleWorker extends BaseWorker { async handleTask(task: TaskRequest): Promise { try { // Parse the task payload const input = this.parsePayload(task.payload); // Process the task - example: square a number const result = typeof input === 'number' ? input * input : 42; // Return the response return this.createResponse(task.taskId, result); } catch (error) { throw new Error(\`Task processing failed: \${error instanceof Error ? error.message : 'Unknown error'}\`); } } async validateTask(task: TaskRequest): Promise { await super.validateTask(task); // Add custom validation logic here if (!task.payload || task.payload.length === 0) { throw new Error('Task payload is required'); } } } `; } /** * Generate Solidity worker example */ private generateSolidityWorkerExample(config: ProjectConfig): string { return `import { SolidityWorker } from '@hourglass/performer'; import { SampleContract } from '../typechain-types/SampleContract'; // Import the contract ABI const contractAbi = require('../../contracts/SampleContract.json').abi; export class SampleSolidityWorker extends SolidityWorker { constructor() { super({ abi: contractAbi, functionName: 'processTask', autoDetectPayload: true, strictMode: false, }); } async handleSolidityTask( params: { taskId: string; data: Uint8Array; user: string } ): Promise<{ result: Uint8Array; success: boolean }> { try { // Access typed parameters from the contract const { taskId, data, user } = params; console.log(\`Processing task \${taskId} for user \${user}\`); // Simulate some processing const processedData = new TextEncoder().encode( \`Processed: \${new TextDecoder().decode(data)}\` ); return { result: processedData, success: true, }; } catch (error) { return { result: new TextEncoder().encode(\`Error: \${error instanceof Error ? error.message : 'Unknown error'}\`), success: false, }; } } } // Alternative: Simple worker using utility function export class SimpleSolidityWorker extends SolidityWorker { constructor() { super({ abi: contractAbi, functionName: 'square', autoDetectPayload: true, }); } async handleSolidityTask(params: { value: bigint }): Promise<{ result: bigint }> { const { value } = params; return { result: value * value, }; } } `; } /** * Generate Dockerfile */ private generateDockerfile(config: ProjectConfig): string { return `FROM node:18-alpine # Install build dependencies RUN apk add --no-cache python3 make g++ # Create non-root user RUN addgroup -g 1001 -S nodejs && \\ adduser -S performer -u 1001 WORKDIR /app # Copy package files COPY package*.json ./ # Install dependencies RUN npm ci --only=production # Copy built application COPY dist/ ./dist/ # Create required directories RUN mkdir -p /app/logs /app/data && \\ chown -R performer:nodejs /app # Switch to non-root user USER performer # Expose port EXPOSE ${config.port} # Health check HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \\ CMD node -e "const http = require('http'); \\ const options = { hostname: 'localhost', port: ${config.port}, path: '/health', timeout: 5000 }; \\ const req = http.request(options, (res) => { \\ process.exit(res.statusCode === 200 ? 0 : 1); \\ }); \\ req.on('error', () => process.exit(1)); \\ req.end();" # Run the application CMD ["node", "dist/performer.js"] `; } /** * Generate docker-compose.yml */ private generateDockerCompose(config: ProjectConfig): string { return `version: '3.8' services: ${config.name}: build: . ports: - "${config.port}:${config.port}" environment: - NODE_ENV=production - PORT=${config.port} - LOG_LEVEL=info - MAX_CONCURRENT_TASKS=20 volumes: - ./data:/app/data - ./logs:/app/logs${config.useTypeChain ? '\n - ./contracts:/app/contracts' : ''} restart: unless-stopped healthcheck: test: ["CMD", "nc", "-z", "localhost", "${config.port}"] interval: 30s timeout: 10s retries: 3 start_period: 10s `; } /** * Show success message with next steps */ private showSuccessMessage(projectPath: string, config: ProjectConfig): void { console.log('\nšŸŽ‰ Project created successfully!'); console.log('━'.repeat(50)); console.log(`šŸ“ Location: ${projectPath}`); console.log(`šŸš€ Port: ${config.port}`); console.log(`šŸ“„ TypeChain: ${config.useTypeChain ? 'Enabled' : 'Disabled'}`); console.log('\nšŸƒ Next steps:'); console.log(` cd ${config.name}`); if (config.useTypeChain) { console.log(' # Add your contract ABIs to ./contracts/'); console.log(' npm run typechain'); } console.log(' npm run dev'); console.log('\nšŸ“š Available commands:'); console.log(' npm run build - Build for production'); console.log(' npm run dev - Start development server'); console.log(' npm start - Start production server'); console.log(' npm run docker:build - Build Docker image'); console.log(' npm run docker:run - Run Docker container'); console.log(' npm run docker:dev - Start with Docker Compose'); if (config.useTypeChain) { console.log(' npm run typechain - Generate TypeChain types'); } console.log('\nšŸ”— Resources:'); console.log(' • Documentation: https://docs.hourglass.io'); console.log(' • Examples: https://github.com/Layr-Labs/hourglass-examples'); console.log(' • Support: https://discord.gg/hourglass'); } /** * Prompt user for input */ private ask(question: string): Promise { return new Promise((resolve) => { this.rl.question(question, resolve); }); } /** * Convert kebab-case to PascalCase */ private toPascalCase(str: string): string { return str.split('-').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(''); } } // CLI entry point if (require.main === module) { const cli = new CreatePerformerCLI(); const args = process.argv.slice(2); cli.run(args).catch(console.error); } export default CreatePerformerCLI;