From 4152155a03e7c954a2b37a951a6fd7b9a783f32a Mon Sep 17 00:00:00 2001 From: Christian Landgren Date: Thu, 14 May 2026 15:50:17 +0200 Subject: [PATCH 1/2] feat(cli): update default model to Kimi K2.6 for opencode init --- src/commands/code/api-key-handler.ts | 277 ++++++++++++++++++ src/commands/code/config-builder.ts | 240 +++++++++++++++ src/commands/code/config-merger.ts | 145 ++++++++++ src/commands/code/documentation-generator.ts | 227 +++++++++++++++ src/commands/code/handlers/index.ts | 7 + src/commands/code/handlers/init.ts | 189 ++++++++++++ src/commands/code/handlers/run.ts | 130 +++++++++ src/commands/code/handlers/update.ts | 289 +++++++++++++++++++ src/commands/code/helpers.ts | 130 +++++++++ src/commands/code/index.ts | 25 ++ src/commands/code/opencode-installer.ts | 115 ++++++++ src/commands/code/types.ts | 84 ++++++ 12 files changed, 1858 insertions(+) create mode 100644 src/commands/code/api-key-handler.ts create mode 100644 src/commands/code/config-builder.ts create mode 100644 src/commands/code/config-merger.ts create mode 100644 src/commands/code/documentation-generator.ts create mode 100644 src/commands/code/handlers/index.ts create mode 100644 src/commands/code/handlers/init.ts create mode 100644 src/commands/code/handlers/run.ts create mode 100644 src/commands/code/handlers/update.ts create mode 100644 src/commands/code/helpers.ts create mode 100644 src/commands/code/index.ts create mode 100644 src/commands/code/opencode-installer.ts create mode 100644 src/commands/code/types.ts diff --git a/src/commands/code/api-key-handler.ts b/src/commands/code/api-key-handler.ts new file mode 100644 index 0000000..f364bae --- /dev/null +++ b/src/commands/code/api-key-handler.ts @@ -0,0 +1,277 @@ +/** + * API key handling logic for code commands + * Handles key selection, creation, and rotation + */ + +import chalk from 'chalk' +import readline from 'readline' +import { ApiKeyService, CreateApiKeyOptions } from '../../services/api-key-service' +import { AuthService } from '../../services/auth-service' +import { COMMAND_GROUPS, SUBCOMMANDS } from '../../constants/command-structure' +import { handleError } from '../../utils/error-handler' +import { confirm, getInput } from './helpers' +import type { ApiKeyResult, CodeCommandOptions } from './types' + +/** + * Check authentication status + * Returns true if authenticated or has API key in env + */ +export async function checkAuthentication(): Promise<{ + authenticated: boolean + hasEnvKey: boolean +}> { + // Check if we have an API key in environment first + if (process.env.BERGET_API_KEY) { + return { authenticated: true, hasEnvKey: true } + } + + // Only require authentication if we don't have an API key + try { + const authService = AuthService.getInstance() + await authService.whoami() + return { authenticated: true, hasEnvKey: false } + } catch { + return { authenticated: false, hasEnvKey: false } + } +} + +/** + * Print authentication error and guidance + */ +export function printAuthenticationError(): void { + console.log(chalk.red('❌ Not authenticated with Berget AI.')) + console.log(chalk.blue('To get started, you have two options:')) + console.log('') + console.log(chalk.yellow('Option 1: Use an existing API key (recommended)')) + console.log(chalk.cyan(' Set BERGET_API_KEY environment variable:')) + console.log(chalk.dim(' export BERGET_API_KEY=your_api_key_here')) + console.log(chalk.cyan(' Or create a .env file in your project:')) + console.log(chalk.dim(' echo "BERGET_API_KEY=your_api_key_here" > .env')) + console.log('') + console.log(chalk.yellow('Option 2: Login and create a new API key')) + console.log(chalk.cyan(' berget auth login')) + console.log(chalk.cyan(` berget ${COMMAND_GROUPS.CODE} ${SUBCOMMANDS.CODE.INIT}`)) + console.log('') + console.log(chalk.blue('Then try again.')) +} + +/** + * Handle API key selection or creation + * Returns the API key and key name + */ +export async function handleApiKeySelection( + options: CodeCommandOptions, + projectName: string +): Promise { + // Check for environment variable first (regardless of automation mode) + if (process.env.BERGET_API_KEY) { + console.log(chalk.blue('🔑 Using BERGET_API_KEY from environment')) + return { + apiKey: process.env.BERGET_API_KEY, + keyName: `env-key-${projectName}`, + } + } + + try { + const apiKeyService = ApiKeyService.getInstance() + + // List existing API keys + if (!options.yes) { + console.log(chalk.blue('\n📋 Checking existing API keys...')) + } + const existingKeys = await apiKeyService.list() + + if (existingKeys.length > 0 && !options.yes) { + return await selectExistingOrCreateNew( + apiKeyService, + existingKeys, + projectName, + options + ) + } else { + // No existing keys or automation mode - create new one + return await createNewKey(apiKeyService, projectName, options) + } + } catch (error) { + if (process.env.BERGET_API_KEY) { + console.log( + chalk.yellow( + '⚠️ Could not verify API key with Berget API, but continuing with environment key' + ) + ) + console.log( + chalk.dim('This might be due to network issues or an invalid key') + ) + return { + apiKey: process.env.BERGET_API_KEY, + keyName: `env-key-${projectName}`, + } + } + + printApiKeyError(error) + return null + } +} + +/** + * Select an existing key or create a new one + */ +async function selectExistingOrCreateNew( + apiKeyService: ApiKeyService, + existingKeys: Array<{ + id: number + name: string + prefix: string + created: string + lastUsed: string | null + }>, + projectName: string, + options: CodeCommandOptions +): Promise { + console.log(chalk.blue('Found existing API keys:')) + console.log(chalk.dim('─'.repeat(60))) + + existingKeys.forEach((key, index) => { + console.log( + `${chalk.cyan((index + 1).toString())}. ${chalk.bold(key.name)} (${key.prefix}...)` + ) + console.log( + chalk.dim( + ` Created: ${new Date(key.created).toLocaleDateString('sv-SE')}` + ) + ) + console.log( + chalk.dim( + ` Last used: ${key.lastUsed ? new Date(key.lastUsed).toLocaleDateString('sv-SE') : 'Never'}` + ) + ) + if (index < existingKeys.length - 1) console.log() + }) + + console.log(chalk.dim('─'.repeat(60))) + console.log(chalk.cyan(`${existingKeys.length + 1}. Create a new API key`)) + + // Get user choice + const choice = await getUserChoice(existingKeys.length + 1) + const choiceIndex = parseInt(choice) - 1 + + if (choiceIndex >= 0 && choiceIndex < existingKeys.length) { + // Use existing key - need to rotate to get actual value + return await rotateExistingKey( + apiKeyService, + existingKeys[choiceIndex], + options + ) + } else if (choiceIndex === existingKeys.length) { + // Create new key + return await createNewKey(apiKeyService, projectName, options) + } + + console.log(chalk.red('Invalid selection.')) + return null +} + +/** + * Get user choice from stdin + */ +async function getUserChoice(maxOption: number): Promise { + return new Promise((resolve) => { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }) + rl.question( + chalk.blue(`\nSelect an option (1-${maxOption}): `), + (answer) => { + rl.close() + resolve(answer.trim()) + } + ) + }) +} + +/** + * Rotate an existing key to get the actual key value + */ +async function rotateExistingKey( + apiKeyService: ApiKeyService, + selectedKey: { id: number; name: string }, + options: CodeCommandOptions +): Promise { + console.log( + chalk.yellow( + `\n🔄 Rotating API key "${selectedKey.name}" to get the key value...` + ) + ) + + if ( + await confirm( + chalk.yellow('This will invalidate the current key. Continue? (Y/n): '), + options.yes + ) + ) { + const rotatedKey = await apiKeyService.rotate(selectedKey.id.toString()) + console.log(chalk.green('✓ API key rotated successfully')) + return { + apiKey: rotatedKey.key, + keyName: selectedKey.name, + } + } + + console.log( + chalk.yellow( + 'Cancelled. Please select a different option or create a new key.' + ) + ) + return null +} + +/** + * Create a new API key + */ +async function createNewKey( + apiKeyService: ApiKeyService, + projectName: string, + options: CodeCommandOptions +): Promise { + if (!options.yes) { + console.log(chalk.yellow('No existing API keys found.')) + } + console.log(chalk.blue('Creating a new API key...')) + + const defaultKeyName = `opencode-${projectName}-${Date.now()}` + const customName = await getInput( + chalk.blue(`Enter key name (default: ${defaultKeyName}): `), + defaultKeyName, + options.yes + ) + + const createOptions: CreateApiKeyOptions = { name: customName } + const keyData = await apiKeyService.create(createOptions) + console.log(chalk.green(`✓ Created new API key: ${customName}`)) + + return { + apiKey: keyData.key, + keyName: customName, + } +} + +/** + * Print API key error and guidance + */ +function printApiKeyError(error: unknown): void { + console.error(chalk.red('❌ Failed to handle API keys:')) + console.log(chalk.blue('This could be due to:')) + console.log(chalk.dim(' • Network connectivity issues')) + console.log(chalk.dim(' • Invalid authentication credentials')) + console.log(chalk.dim(' • API service temporarily unavailable')) + console.log('') + console.log(chalk.blue('Try using an API key directly:')) + console.log(chalk.cyan(' export BERGET_API_KEY=your_api_key_here')) + console.log( + chalk.cyan( + ` berget ${COMMAND_GROUPS.CODE} ${SUBCOMMANDS.CODE.INIT} --yes` + ) + ) + handleError('API key operation failed', error) +} diff --git a/src/commands/code/config-builder.ts b/src/commands/code/config-builder.ts new file mode 100644 index 0000000..32013b1 --- /dev/null +++ b/src/commands/code/config-builder.ts @@ -0,0 +1,240 @@ +/** + * OpenCode configuration builder + * Creates the opencode.json config structure - single source of truth + */ + +import type { MergeableConfig, AgentConfig, CommandConfig, ProviderConfig } from './types' +import type { ProviderModelConfig } from '../../utils/config-loader' + +interface ModelConfig { + primary: string + small: string +} + +type ProviderModels = Record + +/** + * Create the fullstack agent configuration + */ +function createFullstackAgent(model: string): AgentConfig { + return { + model, + temperature: 0.3, + top_p: 0.9, + mode: 'primary', + permission: { edit: 'allow', bash: 'allow', webfetch: 'allow' }, + description: + 'Router/coordinator agent for full-stack development with schema-driven architecture', + prompt: + 'Voice: Scandinavian calm—precise, concise, confident; no fluff. You are Berget Code Fullstack agent. Act as a router and coordinator in a monorepo. Bottom-up schema: database → OpenAPI → generated types. Top-down types: API → UI → components. Use openapi-fetch and Zod at every boundary; compile-time errors are desired when contracts change. Routing rules: if task/paths match /apps/frontend or React (.tsx) → use frontend; if /apps/app or Expo/React Native → app; if /infra, /k8s, flux-system, kustomization.yaml, Helm values → devops; if /services, Koa routers, services/adapters/domain → backend. If ambiguous, remain fullstack and outline the end-to-end plan, then delegate subtasks to the right persona. Security: validate inputs; secrets via FluxCD SOPS/Sealed Secrets. Documentation is generated from code—never duplicated. CRITICAL: When all implementation tasks are complete and ready for merge, ALWAYS invoke @quality subagent to handle testing, building, and complete PR management including URL provision.', + } +} + +/** + * Create the frontend agent configuration + */ +function createFrontendAgent(model: string): AgentConfig { + return { + model, + temperature: 0.4, + top_p: 0.9, + mode: 'primary', + permission: { edit: 'allow', bash: 'deny', webfetch: 'allow' }, + note: 'Bash access is denied for frontend persona to prevent shell command execution in UI environments. This restriction enforces security and architectural boundaries.', + description: + 'Builds Scandinavian, type-safe UIs with React, Tailwind, Shadcn.', + prompt: + 'You are Berget Code Frontend agent. Voice: Scandinavian calm—precise, concise, confident. React 18 + TypeScript. Tailwind + Shadcn UI only via the design system (index.css, tailwind.config.ts). Use semantic tokens for color/spacing/typography/motion; never ad-hoc classes or inline colors. Components are pure and responsive; props-first data; minimal global state (Zustand/Jotai). Accessibility and keyboard navigation mandatory. Mock data only at init under /data via typed hooks (e.g., useProducts() reading /data/products.json). Design: minimal, balanced, quiet motion. CRITICAL: When all frontend implementation tasks are complete and ready for merge, ALWAYS invoke @quality subagent to handle testing, building, and complete PR management including URL provision.', + } +} + +/** + * Create the backend agent configuration + */ +function createBackendAgent(model: string): AgentConfig { + return { + model, + temperature: 0.3, + top_p: 0.9, + mode: 'primary', + permission: { edit: 'allow', bash: 'allow', webfetch: 'allow' }, + description: + 'Functional, modular Koa + TypeScript services; schema-first with code quality focus.', + prompt: + 'You are Berget Code Backend agent. Voice: Scandinavian calm—precise, concise, confident. TypeScript + Koa. Prefer many small pure functions; avoid big try/catch blocks. Routes thin; logic in services/adapters/domain. Validate with Zod; auto-generate OpenAPI. Adapters isolate external systems; domain never depends on framework. Test with supertest; idempotent and stateless by default. Each microservice emits an OpenAPI contract; changes propagate upward to types. Code Quality & Refactoring Principles: Apply Single Responsibility Principle, fail fast with explicit errors, eliminate code duplication, remove nested complexity, use descriptive error codes, keep functions under 30 lines. Always leave code cleaner and more readable than you found it. CRITICAL: When all backend implementation tasks are complete and ready for merge, ALWAYS invoke @quality subagent to handle testing, building, and complete PR management including URL provision.', + } +} + +/** + * Create the devops agent configuration + */ +function createDevopsAgent(model: string): AgentConfig { + return { + model, + temperature: 0.3, + top_p: 0.8, + mode: 'primary', + permission: { edit: 'allow', bash: 'allow', webfetch: 'allow' }, + description: + 'Declarative GitOps infra with FluxCD, Kustomize, Helm, operators.', + prompt: + 'You are Berget Code DevOps agent. Voice: Scandinavian calm—precise, concise, confident. Start simple: k8s/{deployment,service,ingress}. Add FluxCD sync to repo and image automation. Use Kustomize bases/overlays (staging, production). Add dependencies via Helm from upstream sources; prefer native operators when available (CloudNativePG, cert-manager, external-dns). SemVer with -rc tags keeps CI environments current. Observability with Prometheus/Grafana. No manual kubectl in production—Git is the source of truth. For testing, building, and PR management, use @quality subagent.', + } +} + +/** + * Create the app agent configuration + */ +function createAppAgent(model: string): AgentConfig { + return { + model, + temperature: 0.4, + top_p: 0.9, + mode: 'primary', + permission: { edit: 'allow', bash: 'deny', webfetch: 'allow' }, + note: 'Bash access is denied for app persona to prevent shell command execution in mobile/Expo environments. This restriction enforces security and architectural boundaries.', + description: + 'Expo + React Native apps; props-first, offline-aware, shared tokens.', + prompt: + 'You are Berget Code App agent. Voice: Scandinavian calm—precise, concise, confident. Expo + React Native + TypeScript. Structure by components/hooks/services/navigation. Components are pure; data via props; refactor shared logic into hooks/stores. Share tokens with frontend. Mock data in /data via typed hooks; later replace with live APIs. Offline via SQLite/MMKV; notifications via Expo. Request permissions only when needed. Subtle, meaningful motion; light/dark parity. For testing, building, and PR management, use @quality subagent.', + } +} + +/** + * Create the security agent configuration + */ +function createSecurityAgent(model: string): AgentConfig { + return { + model, + temperature: 0.2, + top_p: 0.8, + mode: 'subagent', + permission: { edit: 'deny', bash: 'allow', webfetch: 'allow' }, + description: + 'Security specialist for pentesting, OWASP compliance, and vulnerability assessments.', + prompt: + 'Voice: Scandinavian calm—precise, concise, confident. You are Berget Code Security agent. Expert in application security, penetration testing, and OWASP standards. Core responsibilities: Conduct security assessments and penetration tests, Validate OWASP Top 10 compliance, Review code for security vulnerabilities, Implement security headers and Content Security Policy (CSP), Audit API security, Check for sensitive data exposure, Validate input sanitization and output encoding, Assess dependency security and supply chain risks. Tools and techniques: OWASP ZAP, Burp Suite, security linters, dependency scanners, manual code review. Always provide specific, actionable security recommendations with priority levels. Workflow: Always follow branch_strategy and commit_convention from workflow section. Never work directly in main. Agent awareness: Review code from all personas (frontend, backend, app, devops). If implementation changes are needed, suggest to switch to appropriate persona after security assessment.', + } +} + +/** + * Create the quality agent configuration + */ +function createQualityAgent(model: string): AgentConfig { + return { + model, + temperature: 0.1, + top_p: 0.9, + mode: 'subagent', + permission: { edit: 'allow', bash: 'allow', webfetch: 'allow' }, + description: + 'Quality assurance specialist for testing, building, and complete PR management.', + prompt: + 'Voice: Scandinavian calm—precise, concise, confident. You are Berget Code Quality agent. Specialist in code quality assurance, testing, building, and complete pull request lifecycle management.\n\nCore responsibilities:\n - Run comprehensive test suites (npm test, npm run test, jest, vitest)\n - Execute build processes (npm run build, webpack, vite, tsc)\n - Create and manage pull requests with proper descriptions\n - Handle merge conflicts and keep main updated\n - Monitor GitHub for reviewer comments and address them\n - Ensure code quality standards are met\n - Validate linting and formatting (npm run lint, prettier)\n - Check test coverage and performance benchmarks\n - Handle CI/CD pipeline validation\n\nComplete PR Workflow:\n 1. Ensure all tests pass: npm test\n 2. Build successfully: npm run build\n 3. Commit all changes with proper message\n 4. Push to feature branch\n 5. Update main branch and handle merge conflicts\n 6. Create or update PR with comprehensive description\n 7. Monitor for reviewer comments\n 8. Address feedback and push updates\n 9. Always provide PR URL for user review\n\nEssential CLI commands:\n - npm test or npm run test (run test suite)\n - npm run build (build project)\n - npm run lint (run linting)\n - npm run format (format code)\n - npm run test:coverage (check coverage)\n - git add . && git commit -m "message" && git push (commit and push)\n - git checkout main && git pull origin main (update main)\n - git checkout feature-branch && git merge main (handle conflicts)\n - gh pr create --title "title" --body "body" (create PR)\n - gh pr view --comments (check PR comments)\n - gh pr edit --title "title" --body "body" (update PR)\n\nPR Creation Process:\n - Always include clear summary of changes\n - List technical details and improvements\n - Include testing and validation results\n - Add any breaking changes or migration notes\n - Provide PR URL immediately after creation\n\nMerge Conflict Resolution:\n - Always update main before creating PR\n - Resolve conflicts in feature branch\n - Test after resolving conflicts\n - Push resolved changes\n\nAlways provide specific command examples and wait for processes to complete before proceeding.', + } +} + +/** + * Create command configurations + */ +function createCommands(): Record { + return { + fullstack: { + description: 'Switch to Fullstack (router)', + template: '{{input}}', + agent: 'fullstack', + }, + route: { + description: + 'Let Fullstack auto-route to the right persona based on files/intent', + template: 'ROUTE {{input}}', + agent: 'fullstack', + subtask: true, + }, + frontend: { + description: 'Switch to Frontend persona', + template: '{{input}}', + agent: 'frontend', + }, + backend: { + description: 'Switch to Backend persona', + template: '{{input}}', + agent: 'backend', + }, + devops: { + description: 'Switch to DevOps persona', + template: '{{input}}', + agent: 'devops', + }, + app: { + description: 'Switch to App persona', + template: '{{input}}', + agent: 'app', + }, + security: { + description: + 'Switch to Security persona for pentesting and OWASP compliance', + template: '{{input}}', + agent: 'security', + }, + quality: { + description: + 'Switch to Quality agent for testing, building, and PR management', + template: '{{input}}', + agent: 'quality', + }, + } +} + +/** + * Create provider configuration + */ +function createProvider(providerModels: ProviderModels): Record { + return { + berget: { + npm: '@ai-sdk/openai-compatible', + name: 'Berget AI', + options: { + baseURL: 'https://api.berget.ai/v1', + apiKey: '{env:BERGET_API_KEY}', + }, + models: providerModels, + }, + } +} + +/** + * Create complete OpenCode configuration + * This is the single source of truth for config structure + */ +export function createOpenCodeConfig( + modelConfig: ModelConfig, + providerModels: ProviderModels, + latestAgentConfig?: Record +): MergeableConfig { + const model = modelConfig.primary + + return { + $schema: 'https://opencode.ai/config.json', + username: 'berget-code', + theme: 'berget-dark', + share: 'manual', + autoupdate: true, + model: modelConfig.primary, + small_model: modelConfig.small, + agent: { + fullstack: createFullstackAgent(model), + frontend: createFrontendAgent(model), + backend: createBackendAgent(model), + devops: latestAgentConfig?.devops || createDevopsAgent(model), + app: createAppAgent(model), + security: createSecurityAgent(model), + quality: createQualityAgent(model), + }, + command: createCommands(), + watcher: { + ignore: ['node_modules', 'dist', '.git', 'coverage'], + }, + provider: createProvider(providerModels), + } +} diff --git a/src/commands/code/config-merger.ts b/src/commands/code/config-merger.ts new file mode 100644 index 0000000..7d8407e --- /dev/null +++ b/src/commands/code/config-merger.ts @@ -0,0 +1,145 @@ +/** + * Configuration merge logic for OpenCode + * Handles AI-powered and fallback merging of configurations + */ + +import chalk from 'chalk' +import { createAuthenticatedClient } from '../../client' +import { getModelConfig } from '../../utils/config-loader' +import type { MergeableConfig } from './types' + +/** + * Merge opencode configurations using chat completions API + */ +export async function mergeConfigurations( + currentConfig: MergeableConfig, + latestConfig: MergeableConfig +): Promise { + try { + const client = createAuthenticatedClient() + const modelConfig = getModelConfig() + + console.log(chalk.blue('🤖 Using AI to merge configurations...')) + + const mergePrompt = `You are a configuration merge specialist. Merge these two OpenCode configurations: + +CURRENT CONFIG (user's customizations): +${JSON.stringify(currentConfig, null, 2)} + +LATEST CONFIG (new updates): +${JSON.stringify(latestConfig, null, 2)} + +Merge rules: +1. Preserve ALL user customizations from current config +2. Add ALL new features and improvements from latest config +3. For conflicts, prefer user's customizations but add new latest features +4. Maintain valid JSON structure +5. Keep the merged configuration complete and functional + +Return ONLY the merged JSON configuration, no explanations.` + + const response = await client.POST('/v1/chat/completions', { + body: { + model: modelConfig.primary, + messages: [ + { + role: 'user', + content: mergePrompt, + }, + ], + temperature: 0.1, + max_tokens: 8000, + }, + }) + + if (response.error) { + console.warn(chalk.yellow('⚠️ AI merge failed, using fallback merge')) + return fallbackMerge(currentConfig, latestConfig) + } + + const content = response.data?.choices?.[0]?.message?.content + if (!content) { + console.warn(chalk.yellow('⚠️ No AI response, using fallback merge')) + return fallbackMerge(currentConfig, latestConfig) + } + + try { + const mergedConfig = JSON.parse(content.trim()) + console.log(chalk.green('✓ AI merge completed successfully')) + return mergedConfig + } catch { + console.warn( + chalk.yellow('⚠️ AI response invalid, using fallback merge') + ) + return fallbackMerge(currentConfig, latestConfig) + } + } catch { + console.warn(chalk.yellow('⚠️ AI merge unavailable, using fallback merge')) + return fallbackMerge(currentConfig, latestConfig) + } +} + +/** + * Fallback merge logic when AI merge is unavailable + */ +export function fallbackMerge( + currentConfig: MergeableConfig, + latestConfig: MergeableConfig +): MergeableConfig { + console.log(chalk.blue('🔀 Using fallback merge logic...')) + + const merged: MergeableConfig = { ...latestConfig } + + // Preserve user customizations + if (currentConfig.theme && currentConfig.theme !== latestConfig.theme) { + merged.theme = currentConfig.theme + } + + if (currentConfig.share && currentConfig.share !== latestConfig.share) { + merged.share = currentConfig.share + } + + // Merge custom agents while preserving new ones + if (currentConfig.agent && latestConfig.agent) { + merged.agent = { ...latestConfig.agent } + + // Add any custom agents from current config + Object.keys(currentConfig.agent).forEach((agentName) => { + if (!latestConfig.agent![agentName]) { + merged.agent![agentName] = currentConfig.agent![agentName] + console.log(chalk.cyan(` • Preserved custom agent: ${agentName}`)) + } + }) + } + + // Merge custom commands while preserving new ones + if (currentConfig.command && latestConfig.command) { + merged.command = { ...latestConfig.command } + + Object.keys(currentConfig.command).forEach((commandName) => { + if (!latestConfig.command![commandName]) { + merged.command![commandName] = currentConfig.command![commandName] + console.log(chalk.cyan(` • Preserved custom command: ${commandName}`)) + } + }) + } + + // Preserve custom provider settings if user has modified them + if (currentConfig.provider && latestConfig.provider) { + merged.provider = { ...latestConfig.provider } + + // Deep merge provider settings + Object.keys(currentConfig.provider).forEach((providerName) => { + if (merged.provider![providerName]) { + merged.provider![providerName] = { + ...merged.provider![providerName], + ...currentConfig.provider![providerName], + } + } else { + merged.provider![providerName] = currentConfig.provider![providerName] + } + }) + } + + return merged +} diff --git a/src/commands/code/documentation-generator.ts b/src/commands/code/documentation-generator.ts new file mode 100644 index 0000000..4e813bb --- /dev/null +++ b/src/commands/code/documentation-generator.ts @@ -0,0 +1,227 @@ +/** + * Documentation generator for AGENTS.md + * Single source of truth for agent documentation + */ + +import * as fs from 'fs' +import { writeFile } from 'fs/promises' +import path from 'path' +import chalk from 'chalk' + +/** + * Generate the AGENTS.md content + * This is the single source of truth for agent documentation + */ +export function createAgentsMdContent(projectName: string): string { + return `# Berget Code Agents + +This document describes the specialized agents available in this project for use with OpenCode. + +## Available Agents + +### Primary Agents + +#### fullstack +Router/coordinator agent for full-stack development with schema-driven architecture. Handles routing between different personas based on file paths and task requirements. + +**Use when:** +- Working across multiple parts of a monorepo +- Need to coordinate between frontend, backend, devops, and app +- Starting new projects and need to determine tech stack + +**Key features:** +- Schema-driven development (database → OpenAPI → types) +- Automatic routing to appropriate persona +- Tech stack discovery and recommendations + +#### frontend +Builds Scandinavian, type-safe UIs with React, Tailwind, and Shadcn. + +**Use when:** +- Working with React components (.tsx files) +- Frontend development in /apps/frontend +- UI/UX implementation + +**Key features:** +- Design system integration +- Semantic tokens and accessibility +- Props-first component architecture + +#### backend +Functional, modular Koa + TypeScript services with schema-first approach and code quality focus. + +**Use when:** +- Working with Koa routers and services +- Backend development in /services +- API development and database work + +**Key features:** +- Zod validation and OpenAPI generation +- Code quality and refactoring principles +- PR workflow integration + +#### devops +Declarative GitOps infrastructure with FluxCD, Kustomize, Helm, and operators. + +**Use when:** +- Working with Kubernetes manifests +- Infrastructure in /infra or /k8s +- CI/CD and deployment configurations + +**Key features:** +- GitOps workflows +- Operator-first approach +- SemVer with release candidates + +**Helm Values Configuration Process:** +1. Documentation First Approach: Always fetch official documentation from Artifact Hub/GitHub for the specific chart version before writing values. Search Artifact Hub for exact chart version documentation, check the chart's GitHub repository for official docs and examples, verify the exact version being used in the deployment. +2. Validation Requirements: Check for available validation schemas before committing YAML files. Use Helm's built-in validation tools (helm lint, helm template). Validate against JSON schema if available for the chart. Ensure YAML syntax correctness with linters. +3. Standard Workflow: Identify chart name and exact version. Fetch official documentation from Artifact Hub/GitHub. Check for available schemas and validation tools. Write values according to official documentation. Validate against schema (if available). Test with helm template or helm lint. Commit validated YAML files. +4. Quality Assurance: Never commit unvalidated Helm values. Use helm dependency update when adding new charts. Test rendering with helm template --dry-run before deployment. Document any custom values with comments referencing official docs. + +#### app +Expo + React Native applications with props-first architecture and offline awareness. + +**Use when:** +- Mobile app development with Expo +- React Native projects in /apps/app +- Cross-platform mobile development + +**Key features:** +- Shared design tokens with frontend +- Offline-first architecture +- Expo integration + +### Subagents + +#### security +Security specialist for penetration testing, OWASP compliance, and vulnerability assessments. + +**Use when:** +- Need security review of code changes +- OWASP Top 10 compliance checks +- Vulnerability assessments + +**Key features:** +- OWASP standards compliance +- Security best practices +- Actionable remediation strategies + +#### quality +Quality assurance specialist for testing, building, and PR management. + +**Use when:** +- Need to run test suites and build processes +- Creating or updating pull requests +- Monitoring GitHub for reviewer comments +- Ensuring code quality standards + +**Key features:** +- Comprehensive testing and building workflows +- PR creation and management +- GitHub integration for reviewer feedback +- CLI command expertise for quality assurance + +## Usage + +### Switching Agents +Use the \`\` key to cycle through primary agents during a session. + +### Manual Agent Selection +Use commands to switch to specific agents: +- \`/fullstack\` - Switch to Fullstack agent +- \`/frontend\` - Switch to Frontend agent +- \`/backend\` - Switch to Backend agent +- \`/devops\` - Switch to DevOps agent +- \`/app\` - Switch to App agent +- \`/quality\` - Switch to Quality agent for testing and PR management + +### Using Subagents +Mention subagents with \`@\` symbol: +- \`@security review this authentication implementation\` +- \`@quality run tests and create PR for these changes\` + +## Routing Rules + +The fullstack agent automatically routes tasks based on file patterns: + +- \`/apps/frontend\` or \`.tsx\` files → frontend +- \`/apps/app\` or Expo/React Native → app +- \`/infra\`, \`/k8s\`, FluxCD, Helm → devops +- \`/services\`, Koa routers → backend + +## Configuration + +All agents are configured in \`opencode.json\` with: +- Specialized prompts and temperature settings +- Appropriate tool permissions +- Model optimizations for their specific tasks + +## Environment Setup + +Configure \`.env\` with your API key: +\`\`\` +BERGET_API_KEY=your_api_key_here +\`\`\` + +## Workflow + +All agents follow these principles: +- Never work directly in main branch +- Follow branch strategy and commit conventions +- Create PRs for new functionality +- Run tests before committing +- Address reviewer feedback promptly + +--- + +*Generated by berget code init for ${projectName}* +` +} + +/** + * Write AGENTS.md file if it doesn't exist + * @returns true if file was created, false if it already existed + */ +export async function writeAgentsMd( + projectPath: string, + projectName: string, + forceOverwrite = false +): Promise { + const agentsMdPath = path.join(projectPath, 'AGENTS.md') + + if (fs.existsSync(agentsMdPath) && !forceOverwrite) { + console.log( + chalk.yellow('⚠ AGENTS.md already exists, skipping creation') + ) + return false + } + + const content = createAgentsMdContent(projectName) + await writeFile(agentsMdPath, content) + console.log(chalk.green('✓ Created AGENTS.md')) + console.log(chalk.dim(' Documentation for available agents and usage')) + return true +} + +/** + * Ensure .gitignore contains .env entry + */ +export async function ensureGitignoreHasEnv(projectPath: string): Promise { + const gitignorePath = path.join(projectPath, '.gitignore') + let gitignoreContent = '' + + if (fs.existsSync(gitignorePath)) { + gitignoreContent = fs.readFileSync(gitignorePath, 'utf8') + } + + if (!gitignoreContent.includes('.env')) { + gitignoreContent += + (gitignoreContent.endsWith('\n') ? '' : '\n') + '.env\n' + await writeFile(gitignorePath, gitignoreContent) + console.log(chalk.green('✓ Added .env to .gitignore')) + return true + } + + return false +} diff --git a/src/commands/code/handlers/index.ts b/src/commands/code/handlers/index.ts new file mode 100644 index 0000000..a9be0d8 --- /dev/null +++ b/src/commands/code/handlers/index.ts @@ -0,0 +1,7 @@ +/** + * Export all command handlers + */ + +export { handleInitCommand } from './init' +export { handleRunCommand } from './run' +export { handleUpdateCommand } from './update' diff --git a/src/commands/code/handlers/init.ts b/src/commands/code/handlers/init.ts new file mode 100644 index 0000000..c7b47f2 --- /dev/null +++ b/src/commands/code/handlers/init.ts @@ -0,0 +1,189 @@ +/** + * Handler for the 'berget code init' command + */ + +import chalk from 'chalk' +import * as fs from 'fs' +import { writeFile } from 'fs/promises' +import path from 'path' +import { COMMAND_GROUPS, SUBCOMMANDS } from '../../../constants/command-structure' +import { handleError } from '../../../utils/error-handler' +import { updateEnvFile } from '../../../utils/env-manager' +import { getModelConfig, getProviderModels, getConfigLoader } from '../../../utils/config-loader' +import type { CodeCommandOptions, AgentConfig } from '../types' +import { confirm, getProjectName } from '../helpers' +import { ensureOpencodeInstalled } from '../opencode-installer' +import { + checkAuthentication, + printAuthenticationError, + handleApiKeySelection, +} from '../api-key-handler' +import { createOpenCodeConfig } from '../config-builder' +import { writeAgentsMd, ensureGitignoreHasEnv } from '../documentation-generator' + +/** + * Handle the init command + */ +export async function handleInitCommand(options: CodeCommandOptions): Promise { + try { + const projectName = options.name || getProjectName() + const configPath = path.join(process.cwd(), 'opencode.json') + + // Check if already initialized + if (fs.existsSync(configPath) && !options.force) { + if (!options.yes) { + console.log(chalk.yellow('Project already initialized for OpenCode.')) + console.log(chalk.dim(`Config file: ${configPath}`)) + } + + if (!(await confirm('Do you want to reinitialize? (Y/n): ', options.yes))) { + return + } + } + + // Ensure opencode is installed + if (!(await ensureOpencodeInstalled(options.yes))) { + return + } + + // Check authentication + const authStatus = await checkAuthentication() + if (!authStatus.authenticated) { + printAuthenticationError() + return + } + + if (authStatus.hasEnvKey) { + console.log( + chalk.blue('🔑 Using BERGET_API_KEY from environment - no authentication required') + ) + } + + console.log(chalk.cyan(`Initializing OpenCode for project: ${projectName}`)) + + // Handle API key selection or creation + const apiKeyResult = await handleApiKeySelection(options, projectName) + if (!apiKeyResult) { + return + } + + const { apiKey } = apiKeyResult + + // Prepare paths + const envPath = path.join(process.cwd(), '.env') + + // Load latest agent configuration to ensure consistency + const latestAgentConfig = await loadLatestAgentConfig() + const modelConfig = getModelConfig() + const providerModels = getProviderModels() + + // Create opencode.json config + const config = createOpenCodeConfig(modelConfig, providerModels, latestAgentConfig) + + // Ask for permission to create config files + if (!options.yes) { + printConfigurationSummary(configPath, envPath, config) + } + + if (!(await confirm('\nCreate configuration files? (Y/n): ', options.yes))) { + console.log(chalk.yellow('Configuration file creation cancelled.')) + return + } + + // Write configuration files + await writeConfigurationFiles(envPath, configPath, apiKey, projectName, config) + + // Create AGENTS.md + await writeAgentsMd(process.cwd(), projectName) + + // Ensure .gitignore has .env + await ensureGitignoreHasEnv(process.cwd()) + + console.log(chalk.green('\n✅ Project initialized successfully!')) + console.log(chalk.blue('Next steps:')) + console.log( + chalk.blue(` berget ${COMMAND_GROUPS.CODE} ${SUBCOMMANDS.CODE.RUN}`) + ) + console.log(chalk.blue(' Or run: opencode')) + } catch (error) { + handleError('Failed to initialize project', error) + } +} + +/** + * Load the latest agent configuration from opencode.json + */ +async function loadLatestAgentConfig(): Promise | undefined> { + try { + const configLoader = getConfigLoader() + const config = configLoader.loadConfig() + return config.agent + } catch { + console.warn(chalk.yellow('⚠️ Could not load latest agent config, using fallback')) + return undefined + } +} + +/** + * Print configuration summary before creation + */ +function printConfigurationSummary( + configPath: string, + envPath: string, + config: Record +): void { + console.log(chalk.blue('\nAbout to create configuration files:')) + console.log(chalk.dim(`Config: ${configPath}`)) + console.log(chalk.dim(`Environment: ${envPath}`)) + console.log( + chalk.dim( + `Documentation: ${path.join(process.cwd(), 'AGENTS.md')} (if not exists)` + ) + ) + console.log( + chalk.dim(`Environment: ${path.join(process.cwd(), '.env')} will be updated`) + ) + console.log(chalk.dim('This will configure OpenCode to use Berget AI models.')) + console.log(chalk.cyan('\n💡 Benefits:')) + console.log( + chalk.cyan(' • API key stored separately in .env file (not committed to Git)') + ) + console.log(chalk.cyan(' • Easy cost separation per project/customer')) + console.log(chalk.cyan(' • Secure key management with environment variables')) + console.log( + chalk.cyan(" • Project-specific agent documentation (won't overwrite existing)") + ) +} + +/** + * Write configuration files + */ +async function writeConfigurationFiles( + envPath: string, + configPath: string, + apiKey: string, + projectName: string, + config: Record +): Promise { + try { + // Safely update .env file using dotenv + await updateEnvFile({ + envPath, + key: 'BERGET_API_KEY', + value: apiKey, + comment: `Berget AI Configuration for ${projectName} - Generated by berget code init - Do not commit to version control`, + }) + + // Create opencode.json + await writeFile(configPath, JSON.stringify(config, null, 2)) + console.log(chalk.green('✓ Created opencode.json')) + console.log(chalk.dim(` Model: ${config.model}`)) + console.log(chalk.dim(` Small Model: ${config.small_model}`)) + console.log(chalk.dim(` Theme: ${config.theme}`)) + console.log(chalk.dim(' API Key: Stored in .env as BERGET_API_KEY')) + } catch (error) { + console.error(chalk.red('Failed to create config files:')) + handleError('Config file creation failed', error) + throw error + } +} diff --git a/src/commands/code/handlers/run.ts b/src/commands/code/handlers/run.ts new file mode 100644 index 0000000..5b2ed1a --- /dev/null +++ b/src/commands/code/handlers/run.ts @@ -0,0 +1,130 @@ +/** + * Handler for the 'berget code run' command + */ + +import chalk from 'chalk' +import * as fs from 'fs' +import { readFile } from 'fs/promises' +import path from 'path' +import { spawn } from 'child_process' +import { COMMAND_GROUPS, SUBCOMMANDS } from '../../../constants/command-structure' +import { handleError } from '../../../utils/error-handler' +import type { CodeCommandOptions, MergeableConfig } from '../types' +import { ensureOpencodeInstalled } from '../opencode-installer' + +/** + * Handle the run command + */ +export async function handleRunCommand( + prompt: string | undefined, + options: CodeCommandOptions +): Promise { + try { + const configPath = path.join(process.cwd(), 'opencode.json') + + // Ensure opencode is installed + if (!(await ensureOpencodeInstalled(options.yes))) { + return + } + + let config: MergeableConfig | null = null + if (!options.noConfig && fs.existsSync(configPath)) { + config = await loadProjectConfig(configPath) + } + + if (!config) { + console.log(chalk.yellow('No project configuration found.')) + console.log( + chalk.blue( + `Run ${chalk.bold(`berget ${COMMAND_GROUPS.CODE} ${SUBCOMMANDS.CODE.INIT}`)} first.` + ) + ) + return + } + + // Set environment variables for opencode + const env = { ...process.env } + if (config.apiKey) { + env.OPENCODE_API_KEY = config.apiKey as string + } + + // Prepare opencode command + const opencodeArgs = buildOpencodeArgs(prompt, options, config) + + console.log(chalk.cyan('Starting OpenCode...')) + + // Spawn opencode process + spawnOpencode(opencodeArgs, env) + } catch (error) { + handleError('Failed to run OpenCode', error) + } +} + +/** + * Load project configuration from opencode.json + */ +async function loadProjectConfig(configPath: string): Promise { + try { + const configContent = await readFile(configPath, 'utf8') + const config = JSON.parse(configContent) as MergeableConfig + console.log(chalk.dim(`Loaded config for project: ${config.projectName || 'unknown'}`)) + console.log( + chalk.dim( + `Models: Analysis=${config.analysisModel || 'default'}, Build=${config.buildModel || 'default'}` + ) + ) + return config + } catch { + console.log(chalk.yellow('Warning: Failed to load opencode.json')) + return null + } +} + +/** + * Build opencode arguments based on options + */ +function buildOpencodeArgs( + prompt: string | undefined, + options: CodeCommandOptions, + config: MergeableConfig +): string[] { + const opencodeArgs: string[] = [] + + if (prompt) { + opencodeArgs.push('run', prompt) + } + + // Choose model based on analysis flag or override + let selectedModel = options.model || (config.buildModel as string | undefined) + if (options.analysis && !options.model) { + selectedModel = config.analysisModel as string | undefined + } + + if (selectedModel) { + opencodeArgs.push('--model', selectedModel) + } + + return opencodeArgs +} + +/** + * Spawn the opencode process + */ +function spawnOpencode(args: string[], env: NodeJS.ProcessEnv): void { + const opencode = spawn('opencode', args, { + stdio: 'inherit', + env: env, + shell: true, + }) + + opencode.on('close', (code) => { + if (code !== 0) { + console.log(chalk.red(`OpenCode exited with code ${code}`)) + } + }) + + opencode.on('error', (error) => { + console.error(chalk.red('Failed to start OpenCode:')) + console.error(error.message) + }) +} diff --git a/src/commands/code/handlers/update.ts b/src/commands/code/handlers/update.ts new file mode 100644 index 0000000..cdc2c1d --- /dev/null +++ b/src/commands/code/handlers/update.ts @@ -0,0 +1,289 @@ +/** + * Handler for the 'berget code update' command + */ + +import chalk from 'chalk' +import * as fs from 'fs' +import { readFile, writeFile } from 'fs/promises' +import path from 'path' +import { COMMAND_GROUPS, SUBCOMMANDS } from '../../../constants/command-structure' +import { handleError } from '../../../utils/error-handler' +import { getModelConfig, getProviderModels, getConfigLoader } from '../../../utils/config-loader' +import type { CodeCommandOptions, MergeableConfig, AgentConfig } from '../types' +import { confirm, askChoice, hasGit } from '../helpers' +import { ensureOpencodeInstalled } from '../opencode-installer' +import { createOpenCodeConfig } from '../config-builder' +import { writeAgentsMd } from '../documentation-generator' +import { mergeConfigurations } from '../config-merger' + +/** + * Handle the update command + */ +export async function handleUpdateCommand(options: CodeCommandOptions): Promise { + try { + console.log(chalk.cyan('🔄 Updating OpenCode configuration...')) + + // Ensure opencode is installed first + if (!(await ensureOpencodeInstalled(options.yes))) { + return + } + + const configPath = path.join(process.cwd(), 'opencode.json') + + // Check if project is initialized + if (!fs.existsSync(configPath)) { + console.log(chalk.red('❌ No OpenCode configuration found.')) + console.log( + chalk.blue( + `Run ${chalk.bold(`berget ${COMMAND_GROUPS.CODE} ${SUBCOMMANDS.CODE.INIT}`)} first.` + ) + ) + return + } + + // Read current configuration + const currentConfig = await readCurrentConfig(configPath) + if (!currentConfig) { + return + } + + printCurrentConfig(currentConfig) + + // Load latest configuration + const latestAgentConfig = await loadLatestAgentConfig() + const modelConfig = getModelConfig() + const providerModels = getProviderModels() + const latestConfig = createOpenCodeConfig(modelConfig, providerModels, latestAgentConfig) + + // Check if update is needed + const needsUpdate = JSON.stringify(currentConfig) !== JSON.stringify(latestConfig) + + if (!needsUpdate && !options.force) { + console.log(chalk.green('✅ Already using the latest configuration!')) + return + } + + if (needsUpdate) { + printAvailableUpdates(currentConfig, latestConfig, modelConfig) + } + + if (options.force) { + console.log(chalk.yellow('🔧 Force update requested')) + } + + // Print git status info + if (!options.yes) { + printGitStatus() + } + + // Get update strategy choice + const mergeChoice = await getUpdateStrategyChoice(options) + + if (!(await confirm(`\nProceed with ${mergeChoice}? (Y/n): `, options.yes))) { + console.log(chalk.yellow('Update cancelled.')) + return + } + + // Perform update + await performUpdate( + configPath, + currentConfig, + latestConfig, + mergeChoice + ) + + // Update AGENTS.md if it doesn't exist + await writeAgentsMd(process.cwd(), 'updated') + + printSuccessMessage() + } catch (error) { + handleError('Failed to update OpenCode configuration', error) + } +} + +/** + * Read current configuration from file + */ +async function readCurrentConfig(configPath: string): Promise { + try { + const configContent = await readFile(configPath, 'utf8') + return JSON.parse(configContent) as MergeableConfig + } catch (error) { + console.error(chalk.red('Failed to read current opencode.json:')) + handleError('Config read failed', error) + return null + } +} + +/** + * Load the latest agent configuration + */ +async function loadLatestAgentConfig(): Promise | undefined> { + try { + const configLoader = getConfigLoader() + const config = configLoader.loadConfig() + return config.agent + } catch { + console.warn(chalk.yellow('⚠️ Could not load latest agent config, using fallback')) + return undefined + } +} + +/** + * Print current configuration summary + */ +function printCurrentConfig(config: MergeableConfig): void { + console.log(chalk.blue('📋 Current configuration:')) + console.log(chalk.dim(` Model: ${config.model}`)) + console.log(chalk.dim(` Theme: ${config.theme}`)) + console.log( + chalk.dim(` Agents: ${Object.keys(config.agent || {}).length} configured`) + ) +} + +/** + * Print available updates + */ +function printAvailableUpdates( + currentConfig: MergeableConfig, + latestConfig: MergeableConfig, + modelConfig: { primary: string } +): void { + console.log(chalk.blue('\n🔄 Updates available:')) + + // Compare agents + const currentAgents = Object.keys(currentConfig.agent || {}) + const latestAgents = Object.keys(latestConfig.agent || {}) + const newAgents = latestAgents.filter((agent) => !currentAgents.includes(agent)) + + if (newAgents.length > 0) { + console.log(chalk.cyan(` • New agents: ${newAgents.join(', ')}`)) + } + + // Check for quality agent specifically + if (!currentConfig.agent?.quality && latestConfig.agent?.quality) { + console.log(chalk.cyan(' • Quality subagent for testing and PR management')) + } + + // Check for security subagent mode + if (currentConfig.agent?.security?.mode !== 'subagent') { + console.log(chalk.cyan(' • Security agent converted to subagent (read-only)')) + } + + // Check for model optimizations + const primaryModelKey = modelConfig.primary.replace('berget/', '') + const bergetModels = currentConfig.provider?.berget?.models as Record | undefined + if (!bergetModels?.[primaryModelKey]?.limit?.context) { + console.log(chalk.cyan(' • GLM-4.6 token limits and auto-compaction')) + } + + console.log(chalk.cyan(' • Latest agent prompts and improvements')) +} + +/** + * Print git status info + */ +function printGitStatus(): void { + console.log( + chalk.blue('\nThis will update your OpenCode configuration with the latest improvements.') + ) + + const hasGitRepo = hasGit() + if (!hasGitRepo) { + console.log( + chalk.yellow('⚠️ No .git repository detected - backup will be created') + ) + } else { + console.log(chalk.green('✓ Git repository detected - changes are tracked')) + } +} + +/** + * Get update strategy choice from user + */ +async function getUpdateStrategyChoice( + options: CodeCommandOptions +): Promise<'replace' | 'merge'> { + console.log(chalk.blue('\nChoose update strategy:')) + console.log( + chalk.cyan('1) Replace - Use latest configuration (your customizations will be lost)') + ) + console.log( + chalk.cyan('2) Merge - Combine latest updates with your customizations (recommended)') + ) + + if (options.yes) { + return 'merge' + } + + const choice = await askChoice( + '\nYour choice (1-2, default: 2): ', + ['replace', 'merge'], + 'merge' + ) + return choice as 'replace' | 'merge' +} + +/** + * Perform the configuration update + */ +async function performUpdate( + configPath: string, + currentConfig: MergeableConfig, + latestConfig: MergeableConfig, + mergeChoice: 'replace' | 'merge' +): Promise { + let backupPath: string | null = null + + // Create backup if no git + if (!hasGit()) { + backupPath = `${configPath}.backup.${Date.now()}` + await writeFile(backupPath, JSON.stringify(currentConfig, null, 2)) + console.log( + chalk.green(`✓ Backed up current config to ${path.basename(backupPath)}`) + ) + } + + try { + let finalConfig: MergeableConfig + + if (mergeChoice === 'merge') { + finalConfig = await mergeConfigurations(currentConfig, latestConfig) + console.log(chalk.green('✓ Merged configurations with latest updates')) + } else { + finalConfig = latestConfig + console.log(chalk.green('✓ Replaced with latest configuration')) + } + + // Write final configuration + await writeFile(configPath, JSON.stringify(finalConfig, null, 2)) + console.log(chalk.green(`✓ Updated opencode.json with ${mergeChoice} strategy`)) + } catch (error) { + console.error(chalk.red('Failed to update configuration:')) + handleError('Update failed', error) + + // Restore from backup if update failed + try { + await writeFile(configPath, JSON.stringify(currentConfig, null, 2)) + console.log(chalk.yellow('📁 Restored original configuration from backup')) + } catch { + console.error(chalk.red('Failed to restore backup')) + } + throw error + } +} + +/** + * Print success message with new features + */ +function printSuccessMessage(): void { + console.log(chalk.green('\n✅ Update completed successfully!')) + console.log(chalk.blue('New features available:')) + console.log(chalk.cyan(' • @quality subagent for testing and PR management')) + console.log(chalk.cyan(' • @security subagent for security reviews')) + console.log(chalk.cyan(' • Improved agent prompts and routing')) + console.log(chalk.cyan(' • GLM-4.6 token optimizations')) + console.log(chalk.blue('\nTry these new commands:')) + console.log(chalk.cyan(' @quality run tests and create PR')) + console.log(chalk.cyan(' @security review this code')) +} diff --git a/src/commands/code/helpers.ts b/src/commands/code/helpers.ts new file mode 100644 index 0000000..eb2418b --- /dev/null +++ b/src/commands/code/helpers.ts @@ -0,0 +1,130 @@ +/** + * User interaction helper functions for the code command module + */ + +import readline from 'readline' +import * as fs from 'fs' +import path from 'path' + +/** + * Check if current directory has git + */ +export function hasGit(): boolean { + try { + return fs.existsSync(path.join(process.cwd(), '.git')) + } catch { + return false + } +} + +/** + * Helper function to get user confirmation + */ +export async function confirm( + question: string, + autoYes = false +): Promise { + if (autoYes) { + return true + } + + return new Promise((resolve) => { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }) + + rl.question(question, (answer) => { + rl.close() + resolve( + answer.toLowerCase() === 'y' || + answer.toLowerCase() === 'yes' || + answer === '' + ) + }) + }) +} + +/** + * Helper function to get user choice from options + */ +export async function askChoice( + question: string, + options: string[], + defaultChoice?: string +): Promise { + return new Promise((resolve) => { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }) + + rl.question(question, (answer) => { + rl.close() + + const trimmed = answer.trim().toLowerCase() + + // Handle numeric input (1, 2, etc.) + const numericIndex = parseInt(trimmed) - 1 + if (numericIndex >= 0 && numericIndex < options.length) { + resolve(options[numericIndex]) + return + } + + // Handle text input + const matchingOption = options.find((option) => + option.toLowerCase().startsWith(trimmed) + ) + + if (matchingOption) { + resolve(matchingOption) + } else if (defaultChoice) { + resolve(defaultChoice) + } else { + resolve(options[0]) // Default to first option + } + }) + }) +} + +/** + * Helper function to get user input + */ +export async function getInput( + question: string, + defaultValue: string, + autoYes = false +): Promise { + if (autoYes) { + return defaultValue + } + + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }) + + return new Promise((resolve) => { + rl.question(question, (answer) => { + rl.close() + resolve(answer.trim() || defaultValue) + }) + }) +} + +/** + * Get project name from current directory or package.json + */ +export function getProjectName(): string { + try { + const packageJsonPath = path.join(process.cwd(), 'package.json') + if (fs.existsSync(packageJsonPath)) { + const packageJsonContent = fs.readFileSync(packageJsonPath, 'utf8') + const packageJson = JSON.parse(packageJsonContent) + return packageJson.name || path.basename(process.cwd()) + } + } catch { + // Ignore error and fallback to directory name + } + return path.basename(process.cwd()) +} diff --git a/src/commands/code/index.ts b/src/commands/code/index.ts new file mode 100644 index 0000000..c652681 --- /dev/null +++ b/src/commands/code/index.ts @@ -0,0 +1,25 @@ +/** + * Code command module exports + */ + +// Types +export type { + CodeCommandOptions, + MergeableConfig, + ApiKeyResult, +} from './types' + +// Handlers +export { + handleInitCommand, + handleRunCommand, + handleUpdateCommand, +} from './handlers' + +// Utilities +export { createOpenCodeConfig } from './config-builder' +export { mergeConfigurations, fallbackMerge } from './config-merger' +export { writeAgentsMd, ensureGitignoreHasEnv } from './documentation-generator' +export { ensureOpencodeInstalled, checkOpencodeInstalled } from './opencode-installer' +export { handleApiKeySelection, checkAuthentication } from './api-key-handler' +export { confirm, askChoice, getInput, getProjectName, hasGit } from './helpers' diff --git a/src/commands/code/opencode-installer.ts b/src/commands/code/opencode-installer.ts new file mode 100644 index 0000000..68a5fc5 --- /dev/null +++ b/src/commands/code/opencode-installer.ts @@ -0,0 +1,115 @@ +/** + * OpenCode installation and verification logic + */ + +import chalk from 'chalk' +import { spawn } from 'child_process' +import { confirm } from './helpers' + +/** + * Check if opencode is installed + */ +export function checkOpencodeInstalled(): Promise { + return new Promise((resolve) => { + const child = spawn('opencode', ['--version'], { + stdio: 'pipe', + shell: true, + }) + + child.on('close', (code) => { + resolve(code === 0) + }) + + child.on('error', () => { + resolve(false) + }) + }) +} + +/** + * Install opencode via npm + */ +export async function installOpencode(): Promise { + console.log(chalk.cyan('Installing OpenCode via npm...')) + + try { + await new Promise((resolve, reject) => { + const install = spawn('npm', ['install', '-g', 'opencode-ai'], { + stdio: 'inherit', + shell: true, + }) + + install.on('close', (code) => { + if (code === 0) { + console.log(chalk.green('✓ OpenCode installed successfully!')) + resolve() + } else { + reject(new Error(`Installation failed with code ${code}`)) + } + }) + + install.on('error', reject) + }) + + // Verify installation + const opencodeInstalled = await checkOpencodeInstalled() + if (!opencodeInstalled) { + console.log( + chalk.yellow('Installation completed but opencode command not found.') + ) + console.log( + chalk.yellow( + 'You may need to restart your terminal or check your PATH.' + ) + ) + return false + } + + return true + } catch (error) { + console.error(chalk.red('Failed to install OpenCode:')) + console.error(error instanceof Error ? error.message : String(error)) + console.log(chalk.blue('\nAlternative installation methods:')) + console.log(chalk.blue(' curl -fsSL https://opencode.ai/install | bash')) + console.log(chalk.blue(' Or visit: https://opencode.ai/docs')) + return false + } +} + +/** + * Ensure opencode is installed, offering to install if not + */ +export async function ensureOpencodeInstalled( + autoYes = false +): Promise { + let opencodeInstalled = await checkOpencodeInstalled() + if (!opencodeInstalled) { + if (!autoYes) { + console.log(chalk.red('OpenCode is not installed.')) + console.log( + chalk.blue('OpenCode is required for the AI coding assistant.') + ) + } + + if ( + await confirm( + 'Would you like to install OpenCode automatically? (Y/n): ', + autoYes + ) + ) { + opencodeInstalled = await installOpencode() + } else { + if (!autoYes) { + console.log(chalk.blue('\nInstallation cancelled.')) + console.log( + chalk.blue( + 'To install manually: curl -fsSL https://opencode.ai/install | bash' + ) + ) + console.log(chalk.blue('Or visit: https://opencode.ai/docs')) + } + } + } + + return opencodeInstalled +} diff --git a/src/commands/code/types.ts b/src/commands/code/types.ts new file mode 100644 index 0000000..b25ead6 --- /dev/null +++ b/src/commands/code/types.ts @@ -0,0 +1,84 @@ +/** + * Type definitions for the code command module + */ + +// Re-export shared types from config-loader +export type { AgentConfig, OpenCodeConfig } from '../../utils/config-loader' + +/** + * Options for code command actions + */ +export interface CodeCommandOptions { + name?: string + force?: boolean + yes?: boolean + model?: string + analysis?: boolean + noConfig?: boolean + [key: string]: unknown +} + +/** + * Command configuration for opencode.json + */ +export interface CommandConfig { + description: string + template: string + agent: string + subtask?: boolean +} + +/** + * Watcher configuration for opencode.json + */ +export interface WatcherConfig { + ignore: string[] +} + +/** + * Provider configuration for opencode.json + */ +export interface ProviderConfig { + npm: string + name: string + options: { + baseURL: string + apiKey: string + } + models: ProviderModels +} + +/** + * Provider models configuration + */ +export type ProviderModels = Record + +/** + * Extended type for merge operations (more flexible for merging) + */ +export interface MergeableConfig { + [key: string]: unknown + $schema?: string + username?: string + theme?: string + share?: string + autoupdate?: boolean + model?: string + small_model?: string + projectName?: string + apiKey?: string + analysisModel?: string + buildModel?: string + agent?: Record + command?: Record + watcher?: WatcherConfig + provider?: Record +} + +/** + * Result of API key handling + */ +export interface ApiKeyResult { + apiKey: string + keyName: string +} From f4770c10023e95890eea48b0a940beb9e1beef75 Mon Sep 17 00:00:00 2001 From: Christian Landgren Date: Mon, 6 Jul 2026 14:49:30 +0200 Subject: [PATCH 2/2] feat: add interactive cluster initialization wizard Add berget clusters init command for setting up new Kubernetes clusters with FluxCD GitOps and infrastructure components. Features: - Interactive wizard with clack prompts - Automatic YAML manifest generation from berget-k8s-template - Support for official template repo or custom repositories - Component selection: cert-manager, external-dns, ingress-nginx, cloudnative-pg, redis-operator, prometheus, grafana - Flux bootstrap integration - Prerequisites checking (kubectl, flux, git) New files: - src/commands/clusters/init.ts (wizard logic) - src/commands/clusters/init-command.ts (command adapter) - src/commands/clusters/yaml-generator.ts (YAML generation) - src/commands/clusters/__tests__/yaml-generator.test.ts Modified: - src/commands/clusters.ts (added init command) - src/constants/command-structure.ts (added INIT subcommand) --- src/commands/clusters.ts | 20 + src/commands/clusters/__tests__/init.test.ts | 257 +++++++++ .../clusters/__tests__/yaml-generator.test.ts | 70 +++ src/commands/clusters/init-command.ts | 59 ++ src/commands/clusters/init.ts | 525 ++++++++++++++++++ src/commands/clusters/yaml-generator.ts | 450 +++++++++++++++ src/constants/command-structure.ts | 3 + 7 files changed, 1384 insertions(+) create mode 100644 src/commands/clusters/__tests__/init.test.ts create mode 100644 src/commands/clusters/__tests__/yaml-generator.test.ts create mode 100644 src/commands/clusters/init-command.ts create mode 100644 src/commands/clusters/init.ts create mode 100644 src/commands/clusters/yaml-generator.ts diff --git a/src/commands/clusters.ts b/src/commands/clusters.ts index d4a1f48..e15f517 100644 --- a/src/commands/clusters.ts +++ b/src/commands/clusters.ts @@ -2,6 +2,7 @@ import { Command } from 'commander'; import { Cluster, ClusterService } from '../services/cluster-service'; import { handleError } from '../utils/error-handler'; +import { runClusterInitCommand } from './clusters/init-command'; /** * Register cluster commands @@ -11,6 +12,25 @@ export function registerClusterCommands(program: Command): void { .command(ClusterService.COMMAND_GROUP) .description('Manage Berget clusters'); + cluster + .command('init') + .description('Initialize a new Kubernetes cluster with FluxCD GitOps and infrastructure components') + .option('--cluster-name ', 'Cluster name (skip interactive prompt)') + .option('--domain ', 'Base domain for the cluster (skip interactive prompt)') + .option('--repo-url ', 'Git repository URL for FluxCD') + .option('--template-repo', 'Use the official berget-k8s-template repository') + .option( + '--components ', + 'Comma-separated list of components to install (e.g., cert-manager,external-dns,ingress-nginx)', + ) + .action(async (options) => { + try { + await runClusterInitCommand(options); + } catch (error) { + handleError('Failed to initialize cluster', error); + } + }); + cluster .command(ClusterService.COMMANDS.LIST) .description('List all Berget clusters') diff --git a/src/commands/clusters/__tests__/init.test.ts b/src/commands/clusters/__tests__/init.test.ts new file mode 100644 index 0000000..f3c3d4b --- /dev/null +++ b/src/commands/clusters/__tests__/init.test.ts @@ -0,0 +1,257 @@ +import { describe, expect, it } from 'vitest'; + +import { FakeCommandRunner } from '../../code/__tests__/fake-command-runner'; +import { FakeFileStore } from '../../code/__tests__/fake-file-store'; +import { CANCEL, confirm, FakePrompter, multiselect, select, text } from '../../code/__tests__/fake-prompter'; + +import { CancelledError, PrerequisiteError } from '../../code/errors'; + +import { runClusterInit } from '../init'; + +describe('runClusterInit', () => { + // Helper to create a FakeCommandRunner with all prerequisites installed + function createCommands(): FakeCommandRunner { + return new FakeCommandRunner() + .handle('kubectl --version', '') + .handle('flux --version', '') + .handle('git --version', ''); + } + + it('completes full wizard with template repo', async () => { + const commands = createCommands() + .handle(/git clone/, '') + .handle(/rm -rf/, '') + .handle(/flux bootstrap/, ''); + + const files = new FakeFileStore(); + const prompter = new FakePrompter([ + text('my-cluster'), + text('example.com'), + select('template'), + multiselect(['cert-manager', 'external-dns']), + confirm(true), + confirm(true), + ]); + + await runClusterInit( + { commands, cwd: '/test', files, prompter }, + {}, + ); + + prompter.assertExhausted(); + + // Verify directories created + expect(await files.exists('/test/clusters')).toBe(true); + expect(await files.exists('/test/clusters/flux-system')).toBe(true); + expect(await files.exists('/test/clusters/infrastructure/cert-manager')).toBe(true); + expect(await files.exists('/test/clusters/infrastructure/external-dns')).toBe(true); + + // Verify gotk-sync.yaml written + const gotkSync = await files.readFile('/test/clusters/flux-system/gotk-sync.yaml'); + expect(gotkSync).toContain('name: flux-system'); + expect(gotkSync).toContain('url: https://github.com/berget-ai/berget-k8s-template'); + + // Verify component manifests written + const certManager = await files.readFile('/test/clusters/infrastructure/cert-manager/cert-manager.yaml'); + expect(certManager).toContain('name: cert-manager'); + expect(certManager).toContain('namespace: cert-manager'); + + const externalDns = await files.readFile('/test/clusters/infrastructure/external-dns/external-dns.yaml'); + expect(externalDns).toContain('external-dns.my-cluster'); + expect(externalDns).toContain('example.com'); + }); + + it('completes wizard with existing repo', async () => { + const commands = createCommands(); + + const files = new FakeFileStore(); + const prompter = new FakePrompter([ + text('prod-cluster'), + text('berget.ai'), + select('existing'), + text('git@github.com:my-org/infra.git'), + multiselect(['ingress-nginx']), + confirm(true), + confirm(false), // Don't run flux bootstrap + ]); + + await runClusterInit( + { commands, cwd: '/test', files, prompter }, + {}, + ); + + prompter.assertExhausted(); + + const gotkSync = await files.readFile('/test/clusters/flux-system/gotk-sync.yaml'); + expect(gotkSync).toContain('git@github.com:my-org/infra.git'); + + const ingress = await files.readFile('/test/clusters/infrastructure/ingress-nginx.yaml'); + expect(ingress).toContain('name: ingress-nginx'); + }); + + it('skips interactive prompts when all options provided', async () => { + const commands = createCommands() + .handle(/git clone/, '') + .handle(/rm -rf/, '') + .handle(/flux bootstrap/, ''); + + const files = new FakeFileStore(); + const prompter = new FakePrompter([ + confirm(true), + confirm(false), + ]); + + await runClusterInit( + { commands, cwd: '/test', files, prompter }, + { + clusterName: 'auto-cluster', + components: ['prometheus', 'grafana'], + domain: 'auto.example.com', + templateRepo: true, + }, + ); + + prompter.assertExhausted(); + + const prometheus = await files.readFile('/test/clusters/infrastructure/monitoring/prometheus.yaml'); + expect(prometheus).toContain('name: prometheus'); + + const grafana = await files.readFile('/test/clusters/infrastructure/monitoring/grafana.yaml'); + expect(grafana).toContain('grafana.auto-cluster.auto.example.com'); + }); + + it('throws PrerequisiteError when kubectl is missing', async () => { + const commands = new FakeCommandRunner(); + // No handlers = nothing is installed + + const files = new FakeFileStore(); + const prompter = new FakePrompter([]); + + await expect( + runClusterInit({ commands, cwd: '/test', files, prompter }, {}), + ).rejects.toThrow(PrerequisiteError); + }); + + it('throws CancelledError when user cancels at confirmation', async () => { + const commands = createCommands(); + + const files = new FakeFileStore(); + const prompter = new FakePrompter([ + text('my-cluster'), + text('example.com'), + select('template'), + multiselect(['cert-manager']), + confirm(false), // Cancel at confirmation + ]); + + await expect( + runClusterInit({ commands, cwd: '/test', files, prompter }, {}), + ).rejects.toThrow(CancelledError); + }); + + it('throws CancelledError when user cancels cluster name prompt', async () => { + const commands = createCommands(); + + const files = new FakeFileStore(); + const prompter = new FakePrompter([ + text(CANCEL), + ]); + + await expect( + runClusterInit({ commands, cwd: '/test', files, prompter }, {}), + ).rejects.toThrow(CancelledError); + }); + + it('handles all available components', async () => { + const commands = createCommands() + .handle(/git clone/, '') + .handle(/rm -rf/, '') + .handle(/flux bootstrap/, ''); + + const files = new FakeFileStore(); + const prompter = new FakePrompter([ + text('full-cluster'), + text('test.com'), + select('template'), + multiselect([ + 'cert-manager', + 'external-dns', + 'ingress-nginx', + 'cloudnative-pg', + 'redis-operator', + 'prometheus', + 'grafana', + ]), + confirm(true), + confirm(false), + ]); + + await runClusterInit( + { commands, cwd: '/test', files, prompter }, + {}, + ); + + prompter.assertExhausted(); + + // Verify all components were written + expect(await files.readFile('/test/clusters/infrastructure/cert-manager/cert-manager.yaml')).toBeTruthy(); + expect(await files.readFile('/test/clusters/infrastructure/external-dns/external-dns.yaml')).toBeTruthy(); + expect(await files.readFile('/test/clusters/infrastructure/ingress-nginx/ingress-nginx.yaml')).toBeTruthy(); + expect(await files.readFile('/test/clusters/infrastructure/operators/cloudnative-pg/cloudnative-pg.yaml')).toBeTruthy(); + expect(await files.readFile('/test/clusters/infrastructure/operators/redis/redis-operator.yaml')).toBeTruthy(); + expect(await files.readFile('/test/clusters/infrastructure/monitoring/prometheus.yaml')).toBeTruthy(); + expect(await files.readFile('/test/clusters/infrastructure/monitoring/grafana.yaml')).toBeTruthy(); + }); + + it('exits gracefully when no components selected', async () => { + const commands = createCommands(); + + const files = new FakeFileStore(); + const prompter = new FakePrompter([ + text('empty-cluster'), + text('example.com'), + select('template'), + multiselect([]), + ]); + + await runClusterInit( + { commands, cwd: '/test', files, prompter }, + {}, + ); + + prompter.assertExhausted(); + + // Should not create any component files + const writtenFiles = files.getWrittenFiles(); + expect(writtenFiles.size).toBe(0); + }); + + it('validates cluster name format', async () => { + const commands = createCommands() + .handle(/git clone/, '') + .handle(/rm -rf/, '') + .handle(/flux bootstrap/, ''); + + const files = new FakeFileStore(); + const prompter = new FakePrompter([ + text('Invalid_Name'), // Invalid: contains underscore and uppercase + text('valid-name'), + text('example.com'), + select('template'), + multiselect(['cert-manager']), + confirm(true), + confirm(false), + ]); + + await runClusterInit( + { commands, cwd: '/test', files, prompter }, + {}, + ); + + prompter.assertExhausted(); + + // Should show validation note and ask again + const noteCall = prompter.calls.find((c) => c.method === 'note' && c.args.title === 'Invalid name'); + expect(noteCall).toBeDefined(); + }); +}); diff --git a/src/commands/clusters/__tests__/yaml-generator.test.ts b/src/commands/clusters/__tests__/yaml-generator.test.ts new file mode 100644 index 0000000..a2e127a --- /dev/null +++ b/src/commands/clusters/__tests__/yaml-generator.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest'; + +import { + generateComponentManifest, + getAvailableComponents, + getComponentDescription, +} from '../yaml-generator'; + +describe('yaml-generator', () => { + describe('getAvailableComponents', () => { + it('returns all available components', () => { + const components = getAvailableComponents(); + expect(components).toContain('cert-manager'); + expect(components).toContain('external-dns'); + expect(components).toContain('ingress-nginx'); + expect(components).toContain('cloudnative-pg'); + expect(components).toContain('redis-operator'); + expect(components).toContain('prometheus'); + expect(components).toContain('grafana'); + }); + }); + + describe('getComponentDescription', () => { + it('returns description for cert-manager', () => { + const desc = getComponentDescription('cert-manager'); + expect(desc).toContain('TLS'); + }); + + it('returns component name for unknown component', () => { + const desc = getComponentDescription('unknown'); + expect(desc).toBe('unknown'); + }); + }); + + describe('generateComponentManifest', () => { + it('generates cert-manager manifest with correct placeholders', () => { + const manifest = generateComponentManifest('cert-manager', { + clusterName: 'test-cluster', + domain: 'test.com', + }); + + expect(manifest.filename).toBe('cert-manager.yaml'); + expect(manifest.content).toContain('name: cert-manager'); + expect(manifest.content).toContain('namespace: cert-manager'); + expect(manifest.content).not.toContain('CLUSTER-NAME'); + }); + + it('generates external-dns manifest with replaced placeholders', () => { + const manifest = generateComponentManifest('external-dns', { + clusterName: 'prod-cluster', + domain: 'prod.com', + dnsServer: '10.0.0.1', + }); + + expect(manifest.content).toContain('external-dns.prod-cluster'); + expect(manifest.content).toContain('prod.com'); + expect(manifest.content).toContain('10.0.0.1'); + expect(manifest.content).not.toContain('example.com'); + }); + + it('throws error for unknown component', () => { + expect(() => + generateComponentManifest('unknown-component', { + clusterName: 'test', + domain: 'test.com', + }), + ).toThrow('Unknown component'); + }); + }); +}); diff --git a/src/commands/clusters/init-command.ts b/src/commands/clusters/init-command.ts new file mode 100644 index 0000000..1a5f665 --- /dev/null +++ b/src/commands/clusters/init-command.ts @@ -0,0 +1,59 @@ +import { ClackPrompter } from '../code/adapters/clack-prompter'; +import { FsFileStore } from '../code/adapters/fs-file-store'; +import { SpawnCommandRunner } from '../code/adapters/spawn-command-runner'; + +import { CancelledError, CommandFailedError, PrerequisiteError } from '../code/errors'; + +import { runClusterInit } from './init'; + +export interface InitCommandOptions { + clusterName?: string; + components?: string; + domain?: string; + repoUrl?: string; + templateRepo?: boolean; +} + +export async function runClusterInitCommand( + options: InitCommandOptions = {}, +): Promise { + try { + // Parse components if provided as comma-separated string + const components = options.components + ? options.components.split(',').map((c) => c.trim()) + : undefined; + + await runClusterInit( + { + commands: new SpawnCommandRunner(), + cwd: process.cwd(), + files: new FsFileStore(), + prompter: new ClackPrompter(), + }, + { + clusterName: options.clusterName, + components, + domain: options.domain, + repoUrl: options.repoUrl, + templateRepo: options.templateRepo, + }, + ); + + process.exit(0); + } catch (error) { + if (error instanceof CancelledError) { + console.log('\nOperation cancelled by user.'); + process.exit(130); + } + if (error instanceof PrerequisiteError) { + console.error(`\nMissing required tool: ${error.binary}`); + console.error(`Please install ${error.binary} and try again.`); + process.exit(2); + } + if (error instanceof CommandFailedError) { + console.error(`\nCommand failed: ${error.message}`); + process.exit(error.exitCode); + } + throw error; + } +} diff --git a/src/commands/clusters/init.ts b/src/commands/clusters/init.ts new file mode 100644 index 0000000..68c6135 --- /dev/null +++ b/src/commands/clusters/init.ts @@ -0,0 +1,525 @@ +import chalk from 'chalk'; + +import type { CommandRunner } from '../code/ports/command-runner'; +import type { FileStore } from '../code/ports/file-store'; +import type { Prompter } from '../code/ports/prompter'; + +import { CancelledError, CommandFailedError, PrerequisiteError } from '../code/errors'; + +import { + type ComponentConfig, + generateComponentManifest, + getAvailableComponents, + getComponentDescription, +} from './yaml-generator'; + +export interface ClusterInitDeps { + commands: CommandRunner; + cwd: string; + files: FileStore; + prompter: Prompter; +} + +export interface ClusterInitOptions { + clusterName?: string; + components?: string[]; + domain?: string; + repoUrl?: string; + templateRepo?: boolean; +} + +/** + * Run the interactive cluster initialization wizard + */ +export async function runClusterInit( + deps: ClusterInitDeps, + options: ClusterInitOptions = {}, +): Promise { + const { commands, prompter } = deps; + + prompter.intro(`${chalk.bgGreen.black(' berget clusters init ')}`); + prompter.note( + `This wizard will set up FluxCD GitOps and infrastructure components on your Kubernetes cluster.\n\nPrerequisites:\n • kubectl configured and pointing to your cluster\n • flux CLI installed\n • git configured with SSH key for GitHub`, + 'Cluster Initialization', + ); + + // Check prerequisites + await checkPrerequisites(commands, prompter); + + // Get cluster configuration + const clusterName = + options.clusterName || (await promptClusterName(prompter)); + const domain = options.domain || (await promptDomain(prompter)); + + // Determine repository setup + const repoConfig = await promptRepositorySetup(prompter, options); + + // Select components + const selectedComponents = + options.components || (await promptComponents(prompter)); + + if (selectedComponents.length === 0) { + prompter.note('No components selected. Exiting.', 'Cancelled'); + return; + } + + // Confirm configuration + const config: ComponentConfig = { + clusterName, + domain, + }; + + const confirmed = await confirmConfiguration(prompter, { + clusterName, + components: selectedComponents, + domain, + repoUrl: repoConfig.url, + }); + + if (!confirmed) { + throw new CancelledError(); + } + + // Execute setup + if (repoConfig.type === 'template') { + await setupFromTemplate(deps, config, selectedComponents, repoConfig); + } else { + await setupFromExistingRepo(deps, config, selectedComponents, repoConfig); + } + + prompter.outro('Cluster initialization complete!'); + prompter.note( + `Next steps:\n` + + ` 1. Commit and push the generated files to your repository\n` + + ` 2. FluxCD will automatically sync the components to your cluster\n` + + ` 3. Run 'flux get kustomizations -A' to monitor progress\n\n` + + `For secrets (external-dns TSIG, grafana admin):\n` + + ` kubectl create secret generic external-dns-tsig -n external-dns \\\n` + + ` --from-literal=tsig-secret-keyname=external-dns \\\n` + + ` --from-literal=tsig-secret-secret=YOUR_SECRET`, + 'Post-Setup', + ); +} + +async function checkPrerequisites( + commands: CommandRunner, + prompter: Prompter, +): Promise { + const s = prompter.spinner(); + s.start('Checking prerequisites...'); + + const checks = ['kubectl', 'flux', 'git']; + const missing: string[] = []; + + for (const binary of checks) { + const installed = await commands.checkInstalled(binary); + if (!installed) { + missing.push(binary); + } + } + + if (missing.length > 0) { + s.stop(`Missing prerequisites: ${missing.join(', ')}`); + throw new PrerequisiteError(missing[0]); + } + + s.stop('All prerequisites found.'); +} + +async function promptClusterName(prompter: Prompter): Promise { + const name = await prompter.text({ + message: 'What is your cluster name?', + placeholder: 'my-cluster', + }); + + if (!name || name.trim().length === 0) { + throw new CancelledError(); + } + + // Validate cluster name (DNS-compatible) + if (!/^[a-z0-9-]+$/.test(name)) { + prompter.note( + 'Cluster name must contain only lowercase letters, numbers, and hyphens.', + 'Invalid name', + ); + return promptClusterName(prompter); + } + + return name; +} + +async function promptDomain(prompter: Prompter): Promise { + const domain = await prompter.text({ + message: 'What is your base domain?', + placeholder: 'example.com', + }); + + if (!domain || domain.trim().length === 0) { + throw new CancelledError(); + } + + return domain; +} + +interface RepoConfig { + branch: string; + path: string; + type: 'existing' | 'new' | 'template'; + url: string; +} + +async function promptRepositorySetup( + prompter: Prompter, + options: ClusterInitOptions, +): Promise { + if (options.templateRepo) { + return { + branch: 'main', + path: `clusters/${options.clusterName || 'my-cluster'}`, + type: 'template', + url: 'https://github.com/berget-ai/berget-k8s-template', + }; + } + + if (options.repoUrl) { + return { + branch: 'main', + path: `clusters/${options.clusterName || 'my-cluster'}`, + type: 'existing', + url: options.repoUrl, + }; + } + + const choice = await prompter.select<'template' | 'existing' | 'new'>({ + message: 'How do you want to set up the GitOps repository?', + options: [ + { + hint: 'Use the official Berget cluster template (recommended)', + label: 'Use berget-k8s-template', + value: 'template', + }, + { + hint: 'Use an existing repository you have access to', + label: 'Use existing repository', + value: 'existing', + }, + { + hint: 'Create a new repository for this cluster', + label: 'Create new repository', + value: 'new', + }, + ], + }); + + if (choice === 'template') { + return { + branch: 'main', + path: `clusters/${options.clusterName || 'my-cluster'}`, + type: 'template', + url: 'https://github.com/berget-ai/berget-k8s-template', + }; + } + + if (choice === 'existing') { + const url = await prompter.text({ + message: 'Repository URL (SSH or HTTPS):', + placeholder: 'git@github.com:org/repo.git', + }); + + if (!url) { + throw new CancelledError(); + } + + return { + branch: 'main', + path: `clusters/${options.clusterName || 'my-cluster'}`, + type: 'existing', + url, + }; + } + + // New repository + const repoName = await prompter.text({ + message: 'New repository name:', + placeholder: 'my-cluster-infra', + }); + + if (!repoName) { + throw new CancelledError(); + } + + const owner = await prompter.text({ + message: 'GitHub owner/organization:', + placeholder: 'my-org', + }); + + if (!owner) { + throw new CancelledError(); + } + + return { + branch: 'main', + path: `clusters/${options.clusterName || 'my-cluster'}`, + type: 'new', + url: `git@github.com:${owner}/${repoName}.git`, + }; +} + +async function promptComponents(prompter: Prompter): Promise { + const components = getAvailableComponents(); + + prompter.note( + 'Select the infrastructure components to install.\nSpace to toggle, Enter to confirm.', + 'Component Selection', + ); + + const options = components.map((component) => ({ + hint: getComponentDescription(component), + label: component, + value: component, + })); + + const selected = await prompter.multiselect({ + message: 'Which components do you want to install?', + options, + required: false, + }); + + return selected; +} + +async function confirmConfiguration( + prompter: Prompter, + config: { + clusterName: string; + components: string[]; + domain: string; + repoUrl: string; + }, +): Promise { + prompter.note( + `Cluster: ${config.clusterName}\n` + + `Domain: ${config.domain}\n` + + `Repository: ${config.repoUrl}\n` + + `Components:\n` + + config.components.map((c) => ` • ${c}`).join('\n'), + 'Configuration Summary', + ); + + return prompter.confirm({ + initialValue: true, + message: 'Proceed with this configuration?', + }); +} + +async function setupFromTemplate( + deps: ClusterInitDeps, + config: ComponentConfig, + components: string[], + repoConfig: RepoConfig, +): Promise { + const { commands, cwd, files, prompter } = deps; + const s = prompter.spinner(); + + // Clone template repository + const tempDir = `${cwd}/.berget-cluster-init-${Date.now()}`; + s.start('Cloning berget-k8s-template...'); + try { + await commands.run('git', [ + 'clone', + '--depth', + '1', + 'https://github.com/berget-ai/berget-k8s-template.git', + tempDir, + ]); + s.stop('Template cloned.'); + } catch (error) { + s.stop('Failed to clone template.'); + throw new CommandFailedError('git clone', 1); + } + + // Copy cluster directory structure + const clusterDir = `${cwd}/clusters`; + s.start('Setting up cluster directory...'); + + await files.mkdir(clusterDir); + await files.mkdir(`${clusterDir}/flux-system`); + await files.mkdir(`${clusterDir}/infrastructure`); + await files.mkdir(`${clusterDir}/infrastructure/cert-manager`); + await files.mkdir(`${clusterDir}/infrastructure/external-dns`); + await files.mkdir(`${clusterDir}/infrastructure/ingress-nginx`); + await files.mkdir(`${clusterDir}/infrastructure/monitoring`); + await files.mkdir(`${clusterDir}/infrastructure/operators`); + await files.mkdir(`${clusterDir}/infrastructure/operators/cloudnative-pg`); + await files.mkdir(`${clusterDir}/infrastructure/operators/redis`); + await files.mkdir(`${clusterDir}/apps`); + + // Generate gotk-sync.yaml + const gotkSync = generateGotkSync(config.clusterName, repoConfig); + await files.writeFile(`${clusterDir}/flux-system/gotk-sync.yaml`, gotkSync); + + // Generate component manifests + for (const component of components) { + const manifest = generateComponentManifest(component, config); + let targetDir: string; + + if (component === 'cert-manager') { + targetDir = `${clusterDir}/infrastructure/cert-manager`; + } else if (component === 'external-dns') { + targetDir = `${clusterDir}/infrastructure/external-dns`; + } else if (component === 'ingress-nginx') { + targetDir = `${clusterDir}/infrastructure/ingress-nginx`; + } else if (component === 'cloudnative-pg') { + targetDir = `${clusterDir}/infrastructure/operators/cloudnative-pg`; + } else if (component === 'redis-operator') { + targetDir = `${clusterDir}/infrastructure/operators/redis`; + } else if (component === 'prometheus' || component === 'grafana') { + targetDir = `${clusterDir}/infrastructure/monitoring`; + } else { + targetDir = `${clusterDir}/infrastructure`; + } + + await files.writeFile(`${targetDir}/${manifest.filename}`, manifest.content); + } + + s.stop('Cluster directory created.'); + + // Cleanup temp directory + try { + await commands.run('rm', ['-rf', tempDir]); + } catch { + // Ignore cleanup errors + } + + // Optionally run flux bootstrap + const shouldBootstrap = await prompter.confirm({ + initialValue: true, + message: 'Run flux bootstrap now? (requires cluster access)', + }); + + if (shouldBootstrap) { + await runFluxBootstrap(deps, repoConfig, config.clusterName); + } +} + +async function setupFromExistingRepo( + deps: ClusterInitDeps, + config: ComponentConfig, + components: string[], + repoConfig: RepoConfig, +): Promise { + const { cwd, files, prompter } = deps; + const s = prompter.spinner(); + + const clusterDir = `${cwd}/clusters`; + s.start('Generating component manifests...'); + + // Ensure directory structure exists + await files.mkdir(clusterDir); + await files.mkdir(`${clusterDir}/flux-system`); + await files.mkdir(`${clusterDir}/infrastructure`); + + // Generate gotk-sync.yaml + const gotkSync = generateGotkSync(config.clusterName, repoConfig); + await files.writeFile(`${clusterDir}/flux-system/gotk-sync.yaml`, gotkSync); + + // Generate component manifests + for (const component of components) { + const manifest = generateComponentManifest(component, config); + const targetDir = `${clusterDir}/infrastructure`; + await files.mkdir(targetDir); + await files.writeFile(`${targetDir}/${manifest.filename}`, manifest.content); + } + + s.stop('Manifests generated.'); + + // Optionally run flux bootstrap + const shouldBootstrap = await prompter.confirm({ + initialValue: true, + message: 'Run flux bootstrap now? (requires cluster access)', + }); + + if (shouldBootstrap) { + await runFluxBootstrap(deps, repoConfig, config.clusterName); + } +} + +async function runFluxBootstrap( + deps: ClusterInitDeps, + repoConfig: RepoConfig, + _clusterName: string, +): Promise { + const { commands, prompter } = deps; + const s = prompter.spinner(); + + s.start('Running flux bootstrap...'); + + try { + const args = [ + 'bootstrap', + 'github', + '--owner', + extractOwner(repoConfig.url), + '--repository', + extractRepoName(repoConfig.url), + '--branch', + repoConfig.branch, + '--path', + repoConfig.path, + '--personal', + ]; + + await commands.run('flux', args); + s.stop('Flux bootstrap completed.'); + } catch (error) { + s.stop('Flux bootstrap failed.'); + throw new CommandFailedError('flux bootstrap', 1); + } +} + +function generateGotkSync(_clusterName: string, repoConfig: RepoConfig): string { + return `--- +# FluxCD GitOps Configuration +# Generated by berget clusters init +apiVersion: source.toolkit.fluxcd.io/v1 +kind: GitRepository +metadata: + name: flux-system + namespace: flux-system +spec: + interval: 1h + ref: + branch: ${repoConfig.branch} + secretRef: + name: flux-system + url: ${repoConfig.url} +--- +apiVersion: kustomize.toolkit.fluxcd.io/v1 +kind: Kustomization +metadata: + name: flux-system + namespace: flux-system +spec: + interval: 10m + path: ./${repoConfig.path} + prune: true + sourceRef: + kind: GitRepository + name: flux-system +`; +} + +function extractOwner(repoUrl: string): string { + // Handle both SSH and HTTPS URLs + // git@github.com:owner/repo.git -> owner + // https://github.com/owner/repo.git -> owner + const match = repoUrl.match(/github\.com[:/]([^/]+)/); + return match ? match[1] : 'unknown'; +} + +function extractRepoName(repoUrl: string): string { + // git@github.com:owner/repo.git -> repo + // https://github.com/owner/repo.git -> repo + const match = repoUrl.match(/github\.com[:/][^/]+\/(.+?)(?:\.git)?$/); + return match ? match[1] : 'infra'; +} diff --git a/src/commands/clusters/yaml-generator.ts b/src/commands/clusters/yaml-generator.ts new file mode 100644 index 0000000..c232dba --- /dev/null +++ b/src/commands/clusters/yaml-generator.ts @@ -0,0 +1,450 @@ +/** + * YAML Generator for cluster infrastructure components + * Generates FluxCD-compatible manifests based on berget-k8s-template + */ + +export interface ComponentConfig { + clusterName: string; + domain: string; + dnsServer?: string; + storageClass?: string; +} + +export interface GeneratedManifest { + content: string; + filename: string; + namespace: string; +} + +const PLACEHOLDERS = { + CLUSTER_NAME: 'CLUSTER-NAME', + DOMAIN: 'example.com', + DNS_SERVER: '1.2.3.4', + STORAGE_CLASS: 'standard', +}; + +function replacePlaceholders(content: string, config: ComponentConfig): string { + return content + .replaceAll(PLACEHOLDERS.CLUSTER_NAME, config.clusterName) + .replaceAll(PLACEHOLDERS.DOMAIN, config.domain) + .replaceAll(PLACEHOLDERS.DNS_SERVER, config.dnsServer || PLACEHOLDERS.DNS_SERVER) + .replaceAll(PLACEHOLDERS.STORAGE_CLASS, config.storageClass || PLACEHOLDERS.STORAGE_CLASS); +} + +// Base cert-manager manifest from berget-k8s-template +const CERT_MANAGER_BASE = `--- +# cert-manager: Automated TLS certificate management +# https://cert-manager.io/ +apiVersion: v1 +kind: Namespace +metadata: + name: cert-manager +--- +apiVersion: source.toolkit.fluxcd.io/v1beta2 +kind: HelmRepository +metadata: + name: cert-manager + namespace: cert-manager +spec: + interval: 24h + url: https://charts.jetstack.io +--- +apiVersion: helm.toolkit.fluxcd.io/v2beta1 +kind: HelmRelease +metadata: + name: cert-manager + namespace: cert-manager +spec: + interval: 30m + chart: + spec: + chart: cert-manager + version: "1.x" + sourceRef: + kind: HelmRepository + name: cert-manager + namespace: cert-manager + interval: 12h + values: + installCRDs: true + extraArgs: + - --dns01-recursive-nameservers-only + - --dns01-recursive-nameservers=1.1.1.1:53,8.8.8.8:53 + - --dns01-check-retry-period=30s + config: + featureGates: + ACMEHTTP01IngressPathTypeExact: false +`; + +// Base external-dns manifest from berget-k8s-template +const EXTERNAL_DNS_BASE = `--- +# external-dns: Automatic DNS record management +# https://github.com/kubernetes-sigs/external-dns +apiVersion: v1 +kind: Namespace +metadata: + name: external-dns +--- +apiVersion: source.toolkit.fluxcd.io/v1beta2 +kind: HelmRepository +metadata: + name: bitnami + namespace: flux-system +spec: + interval: 24h + url: https://charts.bitnami.com/bitnami +--- +apiVersion: helm.toolkit.fluxcd.io/v2beta1 +kind: HelmRelease +metadata: + name: external-dns + namespace: external-dns +spec: + interval: 30m + chart: + spec: + chart: external-dns + version: "8.x" + sourceRef: + kind: HelmRepository + name: bitnami + namespace: flux-system + interval: 12h + values: + installCRDs: true + interval: 2m + provider: rfc2136 + registry: txt + sources: + - ingress + - service + txtOwnerId: external-dns.CLUSTER-NAME + policy: sync + domainFilters: + - example.com + rfc2136: + host: "1.2.3.4" + port: 53 + zone: "example.com" + secretName: external-dns-tsig + tsigKeyname: "external-dns" + tsigSecretAlg: "hmac-sha512" + tsigAxfr: true +`; + +// Base ingress-nginx manifest from berget-k8s-template +const INGRESS_NGINX_BASE = `--- +# NGINX Ingress Controller +# https://kubernetes.github.io/ingress-nginx/ +apiVersion: v1 +kind: Namespace +metadata: + name: ingress-nginx +--- +apiVersion: source.toolkit.fluxcd.io/v1beta2 +kind: HelmRepository +metadata: + name: ingress-nginx + namespace: ingress-nginx +spec: + interval: 24h + url: https://kubernetes.github.io/ingress-nginx +--- +apiVersion: helm.toolkit.fluxcd.io/v2beta1 +kind: HelmRelease +metadata: + name: ingress-nginx + namespace: ingress-nginx +spec: + interval: 30m + chart: + spec: + chart: ingress-nginx + version: "4.x" + sourceRef: + kind: HelmRepository + name: ingress-nginx + namespace: ingress-nginx + interval: 12h + values: + controller: + service: + type: LoadBalancer + annotations: {} + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 500m + memory: 512Mi + metrics: + enabled: true + serviceMonitor: + enabled: true +`; + +// Base CloudNativePG manifest from berget-k8s-template +const CLOUDNATIVE_PG_BASE = `--- +# CloudNativePG Operator +# https://cloudnative-pg.io/ +apiVersion: v1 +kind: Namespace +metadata: + name: cnpg-system +--- +apiVersion: source.toolkit.fluxcd.io/v1beta2 +kind: HelmRepository +metadata: + name: cnpg + namespace: flux-system +spec: + interval: 30m + url: https://cloudnative-pg.github.io/charts +--- +apiVersion: helm.toolkit.fluxcd.io/v2 +kind: HelmRelease +metadata: + name: cloudnative-pg + namespace: flux-system +spec: + interval: 10m + chart: + spec: + chart: cloudnative-pg + version: ">=0.21.0" + sourceRef: + kind: HelmRepository + name: cnpg + namespace: flux-system + targetNamespace: cnpg-system + values: + config: + data: + INHERITED_ANNOTATIONS: "external-dns.alpha.kubernetes.io/hostname" + INHERITED_LABELS: "" + resources: + limits: + cpu: 500m + memory: 512Mi + requests: + cpu: 200m + memory: 256Mi + monitoring: + enabled: true + createPodMonitor: true +`; + +// Base Redis Operator manifest from berget-k8s-template +const REDIS_OPERATOR_BASE = `--- +# Redis Operator (Opstree) +# https://github.com/OT-CONTAINER-KIT/redis-operator +apiVersion: source.toolkit.fluxcd.io/v1beta2 +kind: HelmRepository +metadata: + name: ot-helm + namespace: flux-system +spec: + interval: 30m + url: https://ot-container-kit.github.io/helm-charts/ +--- +apiVersion: helm.toolkit.fluxcd.io/v2 +kind: HelmRelease +metadata: + name: redis-operator + namespace: flux-system +spec: + interval: 10m + chart: + spec: + chart: redis-operator + version: ">=0.22.0" + sourceRef: + kind: HelmRepository + name: ot-helm + namespace: flux-system + targetNamespace: kube-system + values: + resources: + limits: + cpu: 100m + memory: 200Mi + requests: + cpu: 100m + memory: 200Mi + serviceMonitor: + enabled: true +`; + +// Base Prometheus manifest from berget-k8s-template +const PROMETHEUS_BASE = `--- +# Prometheus Monitoring Stack +# https://github.com/prometheus-community/helm-charts +apiVersion: v1 +kind: Namespace +metadata: + name: monitoring +--- +apiVersion: source.toolkit.fluxcd.io/v1 +kind: HelmRepository +metadata: + name: prometheus-community + namespace: monitoring +spec: + interval: 1h + url: https://prometheus-community.github.io/helm-charts +--- +apiVersion: helm.toolkit.fluxcd.io/v2 +kind: HelmRelease +metadata: + name: prometheus + namespace: monitoring +spec: + interval: 10m + timeout: 10m + chart: + spec: + chart: prometheus + version: "25.x" + sourceRef: + kind: HelmRepository + name: prometheus-community + namespace: monitoring + interval: 1h + values: + alertmanager: + enabled: true + kube-state-metrics: + enabled: true + prometheus-node-exporter: + enabled: true + prometheus-pushgateway: + enabled: false + server: + retention: "15d" + persistentVolume: + enabled: true + size: 50Gi + storageClass: "standard" + resources: + limits: + cpu: 2000m + memory: 8Gi + requests: + cpu: 500m + memory: 2Gi + extraArgs: + storage.tsdb.wal-compression: null +`; + +// Base Grafana manifest from berget-k8s-template +const GRAFANA_BASE = `--- +# Grafana - Visualization and Dashboards +# https://grafana.com/ +apiVersion: source.toolkit.fluxcd.io/v1 +kind: HelmRepository +metadata: + name: grafana + namespace: monitoring +spec: + interval: 1h + url: https://grafana.github.io/helm-charts +--- +apiVersion: helm.toolkit.fluxcd.io/v2 +kind: HelmRelease +metadata: + name: grafana + namespace: monitoring +spec: + interval: 10m + chart: + spec: + chart: grafana + version: "7.x" + sourceRef: + kind: HelmRepository + name: grafana + namespace: monitoring + interval: 1h + values: + admin: + existingSecret: grafana-admin-credentials + ingress: + enabled: true + ingressClassName: nginx + hosts: + - grafana.CLUSTER-NAME.example.com + tls: + - secretName: grafana-tls + hosts: + - grafana.CLUSTER-NAME.example.com + datasources: + datasources.yaml: + apiVersion: 1 + datasources: + - name: Prometheus + type: prometheus + url: http://prometheus-server.monitoring.svc.cluster.local + access: proxy + isDefault: true + persistence: + enabled: true + size: 10Gi + storageClassName: standard + resources: + limits: + cpu: 500m + memory: 512Mi + requests: + cpu: 100m + memory: 128Mi +`; + +export const COMPONENT_MANIFESTS: Record = { + 'cert-manager': CERT_MANAGER_BASE, + 'external-dns': EXTERNAL_DNS_BASE, + 'ingress-nginx': INGRESS_NGINX_BASE, + 'cloudnative-pg': CLOUDNATIVE_PG_BASE, + 'redis-operator': REDIS_OPERATOR_BASE, + 'prometheus': PROMETHEUS_BASE, + 'grafana': GRAFANA_BASE, +}; + +export function generateComponentManifest( + component: string, + config: ComponentConfig, +): GeneratedManifest { + const base = COMPONENT_MANIFESTS[component]; + if (!base) { + throw new Error(`Unknown component: ${component}`); + } + + const content = replacePlaceholders(base, config); + + // Determine namespace from the manifest + const namespaceMatch = content.match(/name: ([a-z-]+)/); + const namespace = namespaceMatch ? namespaceMatch[1] : 'default'; + + return { + content, + filename: `${component}.yaml`, + namespace, + }; +} + +export function getAvailableComponents(): string[] { + return Object.keys(COMPONENT_MANIFESTS); +} + +export function getComponentDescription(component: string): string { + const descriptions: Record = { + 'cert-manager': 'Automated TLS certificate management (Let\'s Encrypt)', + 'external-dns': 'Automatic DNS record management via RFC2136', + 'ingress-nginx': 'HTTP/HTTPS ingress controller', + 'cloudnative-pg': 'Production-grade PostgreSQL operator', + 'redis-operator': 'Redis standalone/cluster/sentinel operator', + 'prometheus': 'Metrics collection and alerting', + 'grafana': 'Visualization dashboards for metrics', + }; + return descriptions[component] || component; +} diff --git a/src/constants/command-structure.ts b/src/constants/command-structure.ts index 472dd17..ac21d96 100644 --- a/src/constants/command-structure.ts +++ b/src/constants/command-structure.ts @@ -70,6 +70,7 @@ export const SUBCOMMANDS = { CLUSTERS: { DESCRIBE: 'describe', GET_USAGE: 'get-usage', + INIT: 'init', LIST: 'list', }, @@ -159,6 +160,8 @@ export const COMMAND_DESCRIPTIONS = { [`${COMMAND_GROUPS.CLUSTERS} ${SUBCOMMANDS.CLUSTERS.GET_USAGE}`]: 'Get resource usage for a cluster', + [`${COMMAND_GROUPS.CLUSTERS} ${SUBCOMMANDS.CLUSTERS.INIT}`]: + 'Initialize a new cluster with FluxCD GitOps and infrastructure components', [`${COMMAND_GROUPS.CLUSTERS} ${SUBCOMMANDS.CLUSTERS.LIST}`]: 'List all clusters', [`${COMMAND_GROUPS.CODE} ${SUBCOMMANDS.CODE.INIT}`]: 'Interactive setup for Berget AI coding tools',