UNPKG

@aaronshaf/ger

Version:

Gerrit CLI and SDK - A modern CLI tool and TypeScript SDK for Gerrit Code Review, built with Effect-TS

299 lines (268 loc) 11 kB
import chalk from 'chalk' import { Effect, pipe, Console } from 'effect' import { ConfigService, ConfigServiceLive, ConfigError, type ConfigServiceImpl, } from '@/services/config' import type { GerritCredentials } from '@/schemas/gerrit' import { AppConfig } from '@/schemas/config' import { Schema } from '@effect/schema' import { input, password } from '@inquirer/prompts' import { spawn } from 'node:child_process' import { normalizeGerritHost } from '@/utils/url-parser' // Check if a command exists on the system const checkCommandExists = (command: string): Promise<boolean> => new Promise((resolve) => { const child = spawn('which', [command], { stdio: 'ignore' }) child.on('close', (code) => { resolve(code === 0) }) child.on('error', () => { resolve(false) }) }) // AI tools to check for in order of preference const AI_TOOLS = ['claude', 'llm', 'opencode', 'gemini'] as const // Effect wrapper for detecting available AI tools const detectAvailableAITools = () => Effect.tryPromise({ try: async () => { const availableTools: string[] = [] for (const tool of AI_TOOLS) { const exists = await checkCommandExists(tool) if (exists) { availableTools.push(tool) } } return availableTools }, catch: (error) => new ConfigError({ message: `Failed to detect AI tools: ${error}` }), }) // Effect wrapper for getting existing config const getExistingConfig = (configService: ConfigServiceImpl) => configService.getFullConfig.pipe(Effect.orElseSucceed(() => null)) // Test connection with credentials const verifyCredentials = (credentials: GerritCredentials) => Effect.tryPromise({ try: async () => { const auth = Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64') const response = await fetch(`${credentials.host}/a/config/server/version`, { headers: { Authorization: `Basic ${auth}` }, }) if (!response.ok) { throw new Error(`Authentication failed: ${response.status}`) } return response.ok }, catch: (error) => { if (error instanceof Error) { // Authentication/permission errors if (error.message.includes('401')) { return new ConfigError({ message: 'Invalid credentials. Please check your username and password.', }) } if (error.message.includes('403')) { return new ConfigError({ message: 'Access denied. Please verify your credentials and server permissions.', }) } // Network/hostname errors if (error.message.includes('ENOTFOUND')) { return new ConfigError({ message: `Hostname not found. Please check that the Gerrit URL is correct.\nExample: https://gerrit.example.com (without /a/ or paths)`, }) } if (error.message.includes('ECONNREFUSED')) { return new ConfigError({ message: `Connection refused. The server may be down or the port may be incorrect.\nPlease verify the URL and try again.`, }) } if (error.message.includes('ETIMEDOUT')) { return new ConfigError({ message: `Connection timed out. Please check:\n• Your internet connection\n• The Gerrit server URL\n• Any firewall or VPN settings`, }) } if (error.message.includes('certificate') || error.message.includes('SSL')) { return new ConfigError({ message: `SSL/Certificate error. Please ensure the URL uses HTTPS and the certificate is valid.`, }) } // URL format errors if (error.message.includes('Invalid URL') || error.message.includes('fetch failed')) { return new ConfigError({ message: `Invalid URL format. Please use the full URL including https://\nExample: https://gerrit.example.com`, }) } // Generic network errors if (error.message.includes('network') || error.message.includes('fetch')) { return new ConfigError({ message: `Network error: ${error.message}\nPlease check your connection and the Gerrit server URL.`, }) } return new ConfigError({ message: error.message }) } return new ConfigError({ message: 'Unknown error occurred' }) }, }) // Pure Effect-based setup implementation using inquirer const setupEffect = (configService: ConfigServiceImpl) => pipe( Effect.all([getExistingConfig(configService), detectAvailableAITools()]), Effect.flatMap(([existingConfig, availableTools]) => pipe( Console.log(chalk.bold('🔧 Gerrit CLI Setup')), Effect.flatMap(() => Console.log('')), Effect.flatMap(() => { if (existingConfig) { return Console.log(chalk.dim('(Press Enter to keep existing values)')) } else { return pipe( Console.log(chalk.cyan('Please provide your Gerrit connection details:')), Effect.flatMap(() => Console.log(chalk.dim('Example URL: https://gerrit.example.com')), ), Effect.flatMap(() => Console.log( chalk.dim( 'You can find your HTTP password in Gerrit Settings > HTTP Credentials', ), ), ), ) } }), Effect.flatMap(() => Effect.tryPromise({ try: async () => { console.log('') // Enable raw mode for proper password masking const wasRawMode = process.stdin.isRaw if (process.stdin.isTTY && !wasRawMode) { process.stdin.setRawMode(true) } try { // Gerrit Host URL const host = await input({ message: 'Gerrit Host URL (e.g., https://gerrit.example.com)', default: existingConfig?.host, }) // Username const username = await input({ message: 'Username (your Gerrit username)', default: existingConfig?.username, }) // Password - with proper masking and visual feedback const passwordMessage = existingConfig?.password ? `HTTP Password (generated from Gerrit settings) ${chalk.dim('(press Enter to keep existing)')}` : 'HTTP Password (generated from Gerrit settings)' const passwordValue = (await password({ message: passwordMessage, mask: true, // Show * characters as user types })) || existingConfig?.password || '' console.log('') console.log(chalk.yellow('Optional: AI Configuration')) // Show detected AI tools if (availableTools.length > 0) { console.log(chalk.dim(`Detected AI tools: ${availableTools.join(', ')}`)) } // Get default suggestion — no default to claude const defaultCommand = existingConfig?.aiTool || (availableTools[0] ?? '') // AI tool command with smart default const aiToolCommand = await input({ message: availableTools.length > 0 ? 'AI tool command (detected from system)' : 'AI tool command (e.g., claude, llm, opencode, gemini)', default: defaultCommand || undefined, }) console.log('') console.log(chalk.yellow('Optional: CI Retrigger')) console.log( chalk.dim( 'Comment to post when triggering a CI build (e.g. a magic trigger string your CI watches for)', ), ) const retriggerComment = await input({ message: 'CI retrigger comment (leave blank to skip)', default: existingConfig?.retriggerComment ?? undefined, }) // Build flat config const configData = { host: normalizeGerritHost(host), username: username.trim(), password: passwordValue, ...(aiToolCommand && { aiTool: aiToolCommand, }), aiAutoDetect: !aiToolCommand, ...(retriggerComment.trim() && { retriggerComment: retriggerComment.trim(), }), } // Validate config using Schema instead of type assertion const fullConfig = Schema.decodeUnknownSync(AppConfig)(configData) return fullConfig } finally { // Restore raw mode state if (process.stdin.isTTY && !wasRawMode) { process.stdin.setRawMode(false) } } }, catch: (error) => { if (error instanceof Error && error.message.includes('User force closed')) { console.log(`\n${chalk.yellow('Setup cancelled')}`) process.exit(0) } return new ConfigError({ message: error instanceof Error ? error.message : 'Failed to get user input', }) }, }), ), ), ), Effect.tap(() => Console.log('\nVerifying credentials...')), Effect.flatMap((config) => pipe( verifyCredentials({ host: config.host, username: config.username, password: config.password, }), Effect.map(() => config), ), ), Effect.tap(() => Console.log(chalk.green('Successfully authenticated'))), Effect.flatMap((config) => configService.saveFullConfig(config)), Effect.tap(() => Console.log(chalk.green('\nConfiguration saved successfully!'))), Effect.tap(() => Console.log('You can now use:')), Effect.tap(() => Console.log(' • "ger mine" to view your changes')), Effect.tap(() => Console.log(' • "ger show <change-id>" to view change details')), Effect.catchAll((error) => pipe( Console.error( chalk.red(`\n${error instanceof ConfigError ? error.message : `Setup failed: ${error}`}`), ), Effect.flatMap(() => Effect.fail(error)), ), ), ) export async function setup(): Promise<void> { const program = pipe( ConfigService, Effect.flatMap((configService) => setupEffect(configService)), ).pipe(Effect.provide(ConfigServiceLive)) try { await Effect.runPromise(program) } catch { // Error already handled and displayed process.exit(1) } }