/** * Project Provisioner for VC-SYS CLI * Command-driven provisioning system designed for Claude Code AI integration */ import chalk from 'chalk'; import ora from 'ora'; import { SupabaseManagementClient, SupabaseManagementError } from '../supabase/management-client'; import { ProvisioningManager } from '../supabase/provisioning-manager'; import { SchemaManager } from '../supabase/schema-manager'; import { ConfigManager } from '../core/config-manager'; import { SupabaseAuth } from '../auth/supabase-auth'; import { Logger } from '../core/logger'; import { ProvisioningError, ErrorCategory } from '../../types/errors'; import { ProvisioningConfig, ProvisioningResult, Organization } from '../../types/provisioning'; export interface ProvisionOptions { org?: string; name?: string; region?: string; schema?: string; template?: string; seeds?: boolean; listOrgs?: boolean; listRegions?: boolean; listTemplates?: boolean; } export class ProjectProvisioner { private client: SupabaseManagementClient; private provisioningManager: ProvisioningManager; private schemaManager: SchemaManager; private configManager: ConfigManager; private supabaseAuth: SupabaseAuth; private logger: Logger; constructor() { this.client = new SupabaseManagementClient(); this.provisioningManager = new ProvisioningManager(); this.schemaManager = new SchemaManager(); this.configManager = new ConfigManager(); this.supabaseAuth = new SupabaseAuth(); this.logger = new Logger('ProjectProvisioner'); } /** * Main provisioning entry point - command driven for Claude Code integration */ async provision(options: ProvisionOptions): Promise { try { // Handle info commands first if (options.listOrgs) { return await this.listOrganizations(); } if (options.listRegions) { return await this.listRegions(); } if (options.listTemplates) { return await this.listTemplates(); } // Ensure authentication for provisioning operations if (!(await this.supabaseAuth.isAuthenticated())) { this.outputStructured({ status: 'error', error: 'AUTHENTICATION_REQUIRED', message: 'Authentication required. Run: vcsys auth login', requiresAuth: true }); return false; } // Validate required parameters if (!options.org || !options.name) { this.outputStructured({ status: 'error', error: 'MISSING_PARAMETERS', message: 'Organization and project name are required', required: ['org', 'name'] }); return false; } // Build provisioning config const provisioningConfig = await this.buildProvisioningConfig(options); // Execute the provisioning workflow return await this.executeProvisioning(provisioningConfig); } catch (error) { this.logger.error('Provisioning failed', error); if (error instanceof SupabaseManagementError && error.requiresReauth) { this.outputStructured({ status: 'error', error: 'AUTH_EXPIRED', message: 'Authentication expired during provisioning', requiresAuth: true }); } else if (error instanceof ProvisioningError) { this.outputStructured({ status: 'error', error: error.category, message: error.message }); } else { this.outputStructured({ status: 'error', error: 'UNEXPECTED_ERROR', message: error instanceof Error ? error.message : 'Unknown error' }); } return false; } } /** * List available organizations for Claude Code integration */ private async listOrganizations(): Promise { try { const organizations = await this.client.listOrganizations(); this.outputStructured({ status: 'success', action: 'list_organizations', data: { count: organizations.length, organizations: organizations.map((org: Organization) => ({ id: org.id, name: org.name, tier: org.tier || 'FREE', slug: org.slug })) } }); return { success: true, organizations }; } catch (error) { this.outputStructured({ status: 'error', action: 'list_organizations', error: 'FETCH_FAILED', message: error instanceof Error ? error.message : 'Failed to fetch organizations' }); return false; } } /** * List available regions for Claude Code integration */ private async listRegions(): Promise { const regions = [ { id: 'us-east-1', name: 'US East (Virginia)', description: 'Recommended for Americas' }, { id: 'us-west-1', name: 'US West (Oregon)', description: 'West Coast optimized' }, { id: 'eu-west-1', name: 'Europe (Ireland)', description: 'GDPR compliant' }, { id: 'ap-southeast-1', name: 'Asia Pacific (Singapore)', description: 'Asia optimized' }, { id: 'ap-northeast-1', name: 'Asia Pacific (Tokyo)', description: 'Japan optimized' } ]; this.outputStructured({ status: 'success', action: 'list_regions', data: { count: regions.length, regions } }); return { success: true, regions }; } /** * List available schema templates for Claude Code integration */ private async listTemplates(): Promise { const templates = [ { id: 'saas-starter', name: 'SaaS Starter', description: 'Complete user management and projects system with RLS', features: ['User authentication', 'Project management', 'Task tracking', 'Row Level Security'] }, { id: 'chat-app', name: 'Chat Application', description: 'Real-time messaging with channels and user management', features: ['Real-time messaging', 'Channels/rooms', 'User profiles', 'Message history'] }, { id: 'minimal', name: 'Minimal Schema', description: 'Basic tables for simple applications', features: ['Basic user table', 'Simple data structure', 'Minimal setup'] } ]; this.outputStructured({ status: 'success', action: 'list_templates', data: { count: templates.length, templates } }); return { success: true, templates }; } /** * Build provisioning config from options */ private async buildProvisioningConfig(options: ProvisionOptions): Promise { // Resolve organization name to ID const organizations = await this.client.listOrganizations(); const org = organizations.find((o: Organization) => o.name.toLowerCase().includes(options.org!.toLowerCase()) || o.id === options.org ); if (!org) { throw new ProvisioningError(`Organization not found: ${options.org}`, ErrorCategory.VALIDATION); } // Determine schema path let schemaPath: string | undefined; if (options.schema) { schemaPath = options.schema; } else if (options.template && options.template !== 'minimal') { schemaPath = await this.schemaManager.getTemplate(options.template); } return { projectName: options.name!, organizationId: org.id, region: options.region || 'us-east-1', databasePassword: this.generateSecurePassword(), schemaPath, enableRLS: true, outputDir: process.cwd(), includeSeeds: options.seeds ?? true }; } /** * Execute the complete provisioning workflow with structured output */ private async executeProvisioning(config: ProvisioningConfig): Promise { this.outputStructured({ status: 'progress', action: 'start_provisioning', data: { projectName: config.projectName, region: config.region, estimatedTime: '2-5 minutes' } }); try { // Use the existing ProvisioningManager for the actual provisioning const result = await this.provisioningManager.provisionProject(config); if (result.success) { // Update .env.local with the new credentials await this.updateEnvironmentFile(result); // Update project configuration await this.updateProjectConfig(result); // Output structured success data for Claude Code this.outputStructured({ status: 'success', action: 'provisioning_complete', data: { project: { name: result.project?.name, ref: result.project?.ref, id: result.project?.id, url: result.project ? `https://${result.project.ref}.supabase.co` : undefined, region: result.project?.region || config.region, dashboardUrl: result.project ? `https://supabase.com/dashboard/project/${result.project.ref}` : undefined }, database: { schemaDeployed: true, rlsEnabled: config.enableRLS, seedsIncluded: config.includeSeeds }, files: { envUpdated: true, configSaved: true, envPath: '.env.local' }, credentials: { hasApiKeys: !!result.apiKeys, hasDatabaseUrl: !!result.databaseUrl } } }); } return result; } catch (error) { this.logger.error('Provisioning execution failed', error); this.outputStructured({ status: 'error', action: 'provisioning_failed', error: error instanceof ProvisioningError ? error.category : 'EXECUTION_ERROR', message: error instanceof Error ? error.message : 'Unknown error during provisioning' }); throw error; } } /** * Update .env.local file with Supabase credentials */ private async updateEnvironmentFile(result: ProvisioningResult): Promise { if (!result.project || !result.apiKeys) { throw new ProvisioningError('Missing project data for environment file', ErrorCategory.INTERNAL); } const envContent = this.generateEnvContent( `https://${result.project.ref}.supabase.co`, result.apiKeys.anon, result.apiKeys.serviceRole, result.databaseUrl ); await this.updateEnvLocal(envContent); } /** * Update project configuration with provisioning results */ private async updateProjectConfig(result: ProvisioningResult): Promise { if (!result.project) return; const config = await this.configManager.get('project', {}); config.supabase = { ...config.supabase, lastProjectRef: result.project.ref, lastProjectUrl: `https://${result.project.ref}.supabase.co`, lastProvisionedAt: new Date().toISOString(), projectId: result.project.id, region: result.project.region || 'us-east-1' }; await this.configManager.set('project', config); } /** * Intelligent .env.local file management */ private async updateEnvLocal(envContent: string): Promise { const fs = await import('fs/promises'); const path = await import('path'); const envPath = path.join(process.cwd(), '.env.local'); try { // Check if .env.local exists const envExists = await fs.access(envPath).then(() => true).catch(() => false); if (envExists) { // Merge with existing .env.local const existing = await fs.readFile(envPath, 'utf8'); const updated = this.mergeEnvContent(existing, envContent); await fs.writeFile(envPath, updated, 'utf8'); } else { // Create new .env.local await fs.writeFile(envPath, envContent, 'utf8'); } this.outputStructured({ status: 'success', action: 'env_updated', data: { path: '.env.local', existed: envExists, action: envExists ? 'merged' : 'created' } }); } catch (error) { this.logger.error('Failed to update .env.local', error); throw new ProvisioningError('Failed to update environment file', ErrorCategory.FILE_SYSTEM); } } /** * Intelligent environment content merging */ private mergeEnvContent(existing: string, newContent: string): string { const existingLines = existing.split('\n'); const newLines = newContent.split('\n'); // Remove old Supabase configuration lines const filtered = existingLines.filter(line => { const trimmed = line.trim(); return ( !trimmed.startsWith('NEXT_PUBLIC_SUPABASE_') && !trimmed.startsWith('SUPABASE_') && !trimmed.startsWith('DATABASE_URL=') && !trimmed.startsWith('DIRECT_URL=') && !trimmed.startsWith('# Supabase Configuration') && !trimmed.startsWith('# Generated by VC-SYS') && !trimmed.startsWith('NODE_ENV=development') && trimmed !== '' ); }); // Remove trailing empty lines while (filtered.length > 0 && filtered[filtered.length - 1].trim() === '') { filtered.pop(); } // Add proper spacing and new configuration return filtered.length > 0 ? [...filtered, '', ...newLines].join('\n') : newLines.join('\n'); } /** * Generate environment file content */ private generateEnvContent(projectUrl: string, anonKey: string, serviceKey: string, databaseUrl?: string): string { const projectRef = projectUrl.replace('https://', '').replace('.supabase.co', ''); const dbUrl = databaseUrl || `postgresql://postgres:[your-password]@db.${projectRef}.supabase.co:5432/postgres`; return `# Supabase Configuration # Generated by VC-SYS CLI on ${new Date().toISOString()} # Project URL NEXT_PUBLIC_SUPABASE_URL=${projectUrl} # Public API Key (safe for client-side use) NEXT_PUBLIC_SUPABASE_ANON_KEY=${anonKey} # Service Role Key (server-side only - never expose to client) SUPABASE_SERVICE_ROLE_KEY=${serviceKey} # Database URL (for direct connections if needed) DATABASE_URL=${dbUrl} DIRECT_URL=${dbUrl} # Development NODE_ENV=development # Supabase Project Details SUPABASE_PROJECT_REF=${projectRef} `; } /** * Output structured data for Claude Code integration */ private outputStructured(data: any): void { console.log('--- CLAUDE CODE INTEGRATION ---'); console.log(JSON.stringify(data, null, 2)); console.log('--- END INTEGRATION DATA ---'); } /** * Generate secure database password */ private generateSecurePassword(): string { const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*'; let password = ''; for (let i = 0; i < 16; i++) { password += chars.charAt(Math.floor(Math.random() * chars.length)); } return password; } }