diff --git a/cmd/core/cmd/php.go b/cmd/core/cmd/php.go new file mode 100644 index 00000000..b0eb0852 --- /dev/null +++ b/cmd/core/cmd/php.go @@ -0,0 +1,528 @@ +package cmd + +import ( + "bufio" + "context" + "fmt" + "os" + "os/signal" + "strings" + "syscall" + "time" + + "github.com/charmbracelet/lipgloss" + "github.com/host-uk/core/pkg/php" + "github.com/leaanthony/clir" +) + +// Service colors for log output +var ( + phpFrankenPHPStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#6366f1")) // indigo-500 + + phpViteStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#eab308")) // yellow-500 + + phpHorizonStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#f97316")) // orange-500 + + phpReverbStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#8b5cf6")) // violet-500 + + phpRedisStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#ef4444")) // red-500 + + phpStatusRunning = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#22c55e")). // green-500 + Bold(true) + + phpStatusStopped = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#6b7280")) // gray-500 + + phpStatusError = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#ef4444")). // red-500 + Bold(true) +) + +// AddPHPCommands adds PHP/Laravel development commands. +func AddPHPCommands(parent *clir.Cli) { + phpCmd := parent.NewSubCommand("php", "Laravel/PHP development tools") + phpCmd.LongDescription("Manage Laravel development environment with FrankenPHP.\n\n" + + "Services orchestrated:\n" + + " - FrankenPHP/Octane (port 8000, HTTPS on 443)\n" + + " - Vite dev server (port 5173)\n" + + " - Laravel Horizon (queue workers)\n" + + " - Laravel Reverb (WebSocket, port 8080)\n" + + " - Redis (port 6379)") + + addPHPDevCommand(phpCmd) + addPHPLogsCommand(phpCmd) + addPHPStopCommand(phpCmd) + addPHPStatusCommand(phpCmd) + addPHPSSLCommand(phpCmd) +} + +func addPHPDevCommand(parent *clir.Command) { + var ( + noVite bool + noHorizon bool + noReverb bool + noRedis bool + https bool + domain string + port int + ) + + devCmd := parent.NewSubCommand("dev", "Start Laravel development environment") + devCmd.LongDescription("Starts all detected Laravel services.\n\n" + + "Auto-detects:\n" + + " - Vite (vite.config.js/ts)\n" + + " - Horizon (config/horizon.php)\n" + + " - Reverb (config/reverb.php)\n" + + " - Redis (from .env)") + + devCmd.BoolFlag("no-vite", "Skip Vite dev server", &noVite) + devCmd.BoolFlag("no-horizon", "Skip Laravel Horizon", &noHorizon) + devCmd.BoolFlag("no-reverb", "Skip Laravel Reverb", &noReverb) + devCmd.BoolFlag("no-redis", "Skip Redis server", &noRedis) + devCmd.BoolFlag("https", "Enable HTTPS with mkcert", &https) + devCmd.StringFlag("domain", "Domain for SSL certificate (default: from APP_URL or localhost)", &domain) + devCmd.IntFlag("port", "FrankenPHP port (default: 8000)", &port) + + devCmd.Action(func() error { + return runPHPDev(phpDevOptions{ + NoVite: noVite, + NoHorizon: noHorizon, + NoReverb: noReverb, + NoRedis: noRedis, + HTTPS: https, + Domain: domain, + Port: port, + }) + }) +} + +type phpDevOptions struct { + NoVite bool + NoHorizon bool + NoReverb bool + NoRedis bool + HTTPS bool + Domain string + Port int +} + +func runPHPDev(opts phpDevOptions) error { + cwd, err := os.Getwd() + if err != nil { + return fmt.Errorf("failed to get working directory: %w", err) + } + + // Check if this is a Laravel project + if !php.IsLaravelProject(cwd) { + return fmt.Errorf("not a Laravel project (missing artisan or laravel/framework)") + } + + // Get app name for display + appName := php.GetLaravelAppName(cwd) + if appName == "" { + appName = "Laravel" + } + + fmt.Printf("%s Starting %s development environment\n\n", dimStyle.Render("PHP:"), appName) + + // Detect services + services := php.DetectServices(cwd) + fmt.Printf("%s Detected services:\n", dimStyle.Render("Services:")) + for _, svc := range services { + fmt.Printf(" %s %s\n", successStyle.Render("*"), svc) + } + fmt.Println() + + // Setup options + port := opts.Port + if port == 0 { + port = 8000 + } + + devOpts := php.Options{ + Dir: cwd, + NoVite: opts.NoVite, + NoHorizon: opts.NoHorizon, + NoReverb: opts.NoReverb, + NoRedis: opts.NoRedis, + HTTPS: opts.HTTPS, + Domain: opts.Domain, + FrankenPHPPort: port, + } + + // Create and start dev server + server := php.NewDevServer(devOpts) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Handle shutdown signals + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + + go func() { + <-sigCh + fmt.Printf("\n%s Shutting down...\n", dimStyle.Render("PHP:")) + cancel() + }() + + if err := server.Start(ctx, devOpts); err != nil { + return fmt.Errorf("failed to start services: %w", err) + } + + // Print status + fmt.Printf("%s Services started:\n", successStyle.Render("Running:")) + printServiceStatuses(server.Status()) + fmt.Println() + + // Print URLs + appURL := php.GetLaravelAppURL(cwd) + if appURL == "" { + if opts.HTTPS { + appURL = fmt.Sprintf("https://localhost:%d", port) + } else { + appURL = fmt.Sprintf("http://localhost:%d", port) + } + } + fmt.Printf("%s %s\n", dimStyle.Render("App URL:"), linkStyle.Render(appURL)) + + // Check for Vite + if !opts.NoVite && containsService(services, php.ServiceVite) { + fmt.Printf("%s %s\n", dimStyle.Render("Vite:"), linkStyle.Render("http://localhost:5173")) + } + + fmt.Printf("\n%s\n\n", dimStyle.Render("Press Ctrl+C to stop all services")) + + // Stream unified logs + logsReader, err := server.Logs("", true) + if err != nil { + fmt.Printf("%s Failed to get logs: %v\n", errorStyle.Render("Warning:"), err) + } else { + defer logsReader.Close() + + scanner := bufio.NewScanner(logsReader) + for scanner.Scan() { + select { + case <-ctx.Done(): + goto shutdown + default: + line := scanner.Text() + printColoredLog(line) + } + } + } + +shutdown: + // Stop services + if err := server.Stop(); err != nil { + fmt.Printf("%s Error stopping services: %v\n", errorStyle.Render("Error:"), err) + } + + fmt.Printf("%s All services stopped\n", successStyle.Render("Done:")) + return nil +} + +func addPHPLogsCommand(parent *clir.Command) { + var follow bool + var service string + + logsCmd := parent.NewSubCommand("logs", "View service logs") + logsCmd.LongDescription("Stream logs from Laravel services.\n\n" + + "Services: frankenphp, vite, horizon, reverb, redis") + + logsCmd.BoolFlag("follow", "Follow log output", &follow) + logsCmd.StringFlag("service", "Specific service (default: all)", &service) + + logsCmd.Action(func() error { + return runPHPLogs(service, follow) + }) +} + +func runPHPLogs(service string, follow bool) error { + cwd, err := os.Getwd() + if err != nil { + return err + } + + if !php.IsLaravelProject(cwd) { + return fmt.Errorf("not a Laravel project") + } + + // Create a minimal server just to access logs + server := php.NewDevServer(php.Options{Dir: cwd}) + + logsReader, err := server.Logs(service, follow) + if err != nil { + return fmt.Errorf("failed to get logs: %w", err) + } + defer logsReader.Close() + + // Handle interrupt + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + + go func() { + <-sigCh + cancel() + }() + + scanner := bufio.NewScanner(logsReader) + for scanner.Scan() { + select { + case <-ctx.Done(): + return nil + default: + printColoredLog(scanner.Text()) + } + } + + return scanner.Err() +} + +func addPHPStopCommand(parent *clir.Command) { + stopCmd := parent.NewSubCommand("stop", "Stop all Laravel services") + + stopCmd.Action(func() error { + return runPHPStop() + }) +} + +func runPHPStop() error { + cwd, err := os.Getwd() + if err != nil { + return err + } + + fmt.Printf("%s Stopping services...\n", dimStyle.Render("PHP:")) + + // We need to find running processes + // This is a simplified version - in practice you'd want to track PIDs + server := php.NewDevServer(php.Options{Dir: cwd}) + if err := server.Stop(); err != nil { + return fmt.Errorf("failed to stop services: %w", err) + } + + fmt.Printf("%s All services stopped\n", successStyle.Render("Done:")) + return nil +} + +func addPHPStatusCommand(parent *clir.Command) { + statusCmd := parent.NewSubCommand("status", "Show service status") + + statusCmd.Action(func() error { + return runPHPStatus() + }) +} + +func runPHPStatus() error { + cwd, err := os.Getwd() + if err != nil { + return err + } + + if !php.IsLaravelProject(cwd) { + return fmt.Errorf("not a Laravel project") + } + + appName := php.GetLaravelAppName(cwd) + if appName == "" { + appName = "Laravel" + } + + fmt.Printf("%s %s\n\n", dimStyle.Render("Project:"), appName) + + // Detect available services + services := php.DetectServices(cwd) + fmt.Printf("%s\n", dimStyle.Render("Detected services:")) + for _, svc := range services { + style := getServiceStyle(string(svc)) + fmt.Printf(" %s %s\n", style.Render("*"), svc) + } + fmt.Println() + + // Package manager + pm := php.DetectPackageManager(cwd) + fmt.Printf("%s %s\n", dimStyle.Render("Package manager:"), pm) + + // FrankenPHP status + if php.IsFrankenPHPProject(cwd) { + fmt.Printf("%s %s\n", dimStyle.Render("Octane server:"), "FrankenPHP") + } + + // SSL status + appURL := php.GetLaravelAppURL(cwd) + if appURL != "" { + domain := php.ExtractDomainFromURL(appURL) + if php.CertsExist(domain, php.SSLOptions{}) { + fmt.Printf("%s %s\n", dimStyle.Render("SSL certificates:"), successStyle.Render("installed")) + } else { + fmt.Printf("%s %s\n", dimStyle.Render("SSL certificates:"), dimStyle.Render("not setup")) + } + } + + return nil +} + +func addPHPSSLCommand(parent *clir.Command) { + var domain string + + sslCmd := parent.NewSubCommand("ssl", "Setup SSL certificates with mkcert") + + sslCmd.StringFlag("domain", "Domain for certificate (default: from APP_URL)", &domain) + + sslCmd.Action(func() error { + return runPHPSSL(domain) + }) +} + +func runPHPSSL(domain string) error { + cwd, err := os.Getwd() + if err != nil { + return err + } + + // Get domain from APP_URL if not specified + if domain == "" { + appURL := php.GetLaravelAppURL(cwd) + if appURL != "" { + domain = php.ExtractDomainFromURL(appURL) + } + } + if domain == "" { + domain = "localhost" + } + + // Check if mkcert is installed + if !php.IsMkcertInstalled() { + fmt.Printf("%s mkcert is not installed\n", errorStyle.Render("Error:")) + fmt.Println("\nInstall with:") + fmt.Println(" macOS: brew install mkcert") + fmt.Println(" Linux: see https://github.com/FiloSottile/mkcert") + return fmt.Errorf("mkcert not installed") + } + + fmt.Printf("%s Setting up SSL for %s\n", dimStyle.Render("SSL:"), domain) + + // Check if certs already exist + if php.CertsExist(domain, php.SSLOptions{}) { + fmt.Printf("%s Certificates already exist\n", dimStyle.Render("Skip:")) + + certFile, keyFile, _ := php.CertPaths(domain, php.SSLOptions{}) + fmt.Printf("%s %s\n", dimStyle.Render("Cert:"), certFile) + fmt.Printf("%s %s\n", dimStyle.Render("Key:"), keyFile) + return nil + } + + // Setup SSL + if err := php.SetupSSL(domain, php.SSLOptions{}); err != nil { + return fmt.Errorf("failed to setup SSL: %w", err) + } + + certFile, keyFile, _ := php.CertPaths(domain, php.SSLOptions{}) + + fmt.Printf("%s SSL certificates created\n", successStyle.Render("Done:")) + fmt.Printf("%s %s\n", dimStyle.Render("Cert:"), certFile) + fmt.Printf("%s %s\n", dimStyle.Render("Key:"), keyFile) + + return nil +} + +// Helper functions + +func printServiceStatuses(statuses []php.ServiceStatus) { + for _, s := range statuses { + style := getServiceStyle(s.Name) + var statusText string + + if s.Error != nil { + statusText = phpStatusError.Render(fmt.Sprintf("error: %v", s.Error)) + } else if s.Running { + statusText = phpStatusRunning.Render("running") + if s.Port > 0 { + statusText += dimStyle.Render(fmt.Sprintf(" (port %d)", s.Port)) + } + if s.PID > 0 { + statusText += dimStyle.Render(fmt.Sprintf(" [pid %d]", s.PID)) + } + } else { + statusText = phpStatusStopped.Render("stopped") + } + + fmt.Printf(" %s %s\n", style.Render(s.Name+":"), statusText) + } +} + +func printColoredLog(line string) { + // Parse service prefix from log line + timestamp := time.Now().Format("15:04:05") + + var style lipgloss.Style + serviceName := "" + + if strings.HasPrefix(line, "[FrankenPHP]") { + style = phpFrankenPHPStyle + serviceName = "FrankenPHP" + line = strings.TrimPrefix(line, "[FrankenPHP] ") + } else if strings.HasPrefix(line, "[Vite]") { + style = phpViteStyle + serviceName = "Vite" + line = strings.TrimPrefix(line, "[Vite] ") + } else if strings.HasPrefix(line, "[Horizon]") { + style = phpHorizonStyle + serviceName = "Horizon" + line = strings.TrimPrefix(line, "[Horizon] ") + } else if strings.HasPrefix(line, "[Reverb]") { + style = phpReverbStyle + serviceName = "Reverb" + line = strings.TrimPrefix(line, "[Reverb] ") + } else if strings.HasPrefix(line, "[Redis]") { + style = phpRedisStyle + serviceName = "Redis" + line = strings.TrimPrefix(line, "[Redis] ") + } else { + // Unknown service, print as-is + fmt.Printf("%s %s\n", dimStyle.Render(timestamp), line) + return + } + + fmt.Printf("%s %s %s\n", + dimStyle.Render(timestamp), + style.Render(fmt.Sprintf("[%s]", serviceName)), + line, + ) +} + +func getServiceStyle(name string) lipgloss.Style { + switch strings.ToLower(name) { + case "frankenphp": + return phpFrankenPHPStyle + case "vite": + return phpViteStyle + case "horizon": + return phpHorizonStyle + case "reverb": + return phpReverbStyle + case "redis": + return phpRedisStyle + default: + return dimStyle + } +} + +func containsService(services []php.DetectedService, target php.DetectedService) bool { + for _, s := range services { + if s == target { + return true + } + } + return false +} diff --git a/cmd/core/cmd/root.go b/cmd/core/cmd/root.go index 7717873a..83477cde 100644 --- a/cmd/core/cmd/root.go +++ b/cmd/core/cmd/root.go @@ -87,6 +87,7 @@ func Execute() error { AddReleaseCommand(app) AddContainerCommands(app) AddTemplatesCommand(app) + AddPHPCommands(app) // Run the application return app.Run() } diff --git a/pkg/php/detect.go b/pkg/php/detect.go new file mode 100644 index 00000000..3afc0b5b --- /dev/null +++ b/pkg/php/detect.go @@ -0,0 +1,288 @@ +package php + +import ( + "bufio" + "encoding/json" + "os" + "path/filepath" + "strings" +) + +// DetectedService represents a service that was detected in a Laravel project. +type DetectedService string + +const ( + ServiceFrankenPHP DetectedService = "frankenphp" + ServiceVite DetectedService = "vite" + ServiceHorizon DetectedService = "horizon" + ServiceReverb DetectedService = "reverb" + ServiceRedis DetectedService = "redis" +) + +// IsLaravelProject checks if the given directory is a Laravel project. +// It looks for the presence of artisan file and laravel in composer.json. +func IsLaravelProject(dir string) bool { + // Check for artisan file + artisanPath := filepath.Join(dir, "artisan") + if _, err := os.Stat(artisanPath); os.IsNotExist(err) { + return false + } + + // Check composer.json for laravel/framework + composerPath := filepath.Join(dir, "composer.json") + data, err := os.ReadFile(composerPath) + if err != nil { + return false + } + + var composer struct { + Require map[string]string `json:"require"` + RequireDev map[string]string `json:"require-dev"` + } + + if err := json.Unmarshal(data, &composer); err != nil { + return false + } + + // Check for laravel/framework in require + if _, ok := composer.Require["laravel/framework"]; ok { + return true + } + + // Also check require-dev (less common but possible) + if _, ok := composer.RequireDev["laravel/framework"]; ok { + return true + } + + return false +} + +// IsFrankenPHPProject checks if the project is configured for FrankenPHP. +// It looks for laravel/octane with frankenphp driver. +func IsFrankenPHPProject(dir string) bool { + // Check composer.json for laravel/octane + composerPath := filepath.Join(dir, "composer.json") + data, err := os.ReadFile(composerPath) + if err != nil { + return false + } + + var composer struct { + Require map[string]string `json:"require"` + } + + if err := json.Unmarshal(data, &composer); err != nil { + return false + } + + if _, ok := composer.Require["laravel/octane"]; !ok { + return false + } + + // Check octane config for frankenphp + configPath := filepath.Join(dir, "config", "octane.php") + if _, err := os.Stat(configPath); os.IsNotExist(err) { + // If no config exists but octane is installed, assume frankenphp + return true + } + + configData, err := os.ReadFile(configPath) + if err != nil { + return true // Assume frankenphp if we can't read config + } + + // Look for frankenphp in the config + return strings.Contains(string(configData), "frankenphp") +} + +// DetectServices detects which services are needed based on project files. +func DetectServices(dir string) []DetectedService { + services := []DetectedService{} + + // FrankenPHP/Octane is always needed for a Laravel dev environment + if IsFrankenPHPProject(dir) || IsLaravelProject(dir) { + services = append(services, ServiceFrankenPHP) + } + + // Check for Vite + if hasVite(dir) { + services = append(services, ServiceVite) + } + + // Check for Horizon + if hasHorizon(dir) { + services = append(services, ServiceHorizon) + } + + // Check for Reverb + if hasReverb(dir) { + services = append(services, ServiceReverb) + } + + // Check for Redis + if needsRedis(dir) { + services = append(services, ServiceRedis) + } + + return services +} + +// hasVite checks if the project uses Vite. +func hasVite(dir string) bool { + viteConfigs := []string{ + "vite.config.js", + "vite.config.ts", + "vite.config.mjs", + "vite.config.mts", + } + + for _, config := range viteConfigs { + if _, err := os.Stat(filepath.Join(dir, config)); err == nil { + return true + } + } + + return false +} + +// hasHorizon checks if Laravel Horizon is configured. +func hasHorizon(dir string) bool { + horizonConfig := filepath.Join(dir, "config", "horizon.php") + _, err := os.Stat(horizonConfig) + return err == nil +} + +// hasReverb checks if Laravel Reverb is configured. +func hasReverb(dir string) bool { + reverbConfig := filepath.Join(dir, "config", "reverb.php") + _, err := os.Stat(reverbConfig) + return err == nil +} + +// needsRedis checks if the project uses Redis based on .env configuration. +func needsRedis(dir string) bool { + envPath := filepath.Join(dir, ".env") + file, err := os.Open(envPath) + if err != nil { + return false + } + defer file.Close() + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if strings.HasPrefix(line, "#") { + continue + } + + // Check for Redis-related environment variables + redisIndicators := []string{ + "REDIS_HOST=", + "CACHE_DRIVER=redis", + "QUEUE_CONNECTION=redis", + "SESSION_DRIVER=redis", + "BROADCAST_DRIVER=redis", + } + + for _, indicator := range redisIndicators { + if strings.HasPrefix(line, indicator) { + // Check if it's set to localhost or 127.0.0.1 + if strings.Contains(line, "127.0.0.1") || strings.Contains(line, "localhost") || + indicator != "REDIS_HOST=" { + return true + } + } + } + } + + return false +} + +// DetectPackageManager detects which package manager is used in the project. +// Returns "npm", "pnpm", "yarn", or "bun". +func DetectPackageManager(dir string) string { + // Check for lock files in order of preference + lockFiles := []struct { + file string + manager string + }{ + {"bun.lockb", "bun"}, + {"pnpm-lock.yaml", "pnpm"}, + {"yarn.lock", "yarn"}, + {"package-lock.json", "npm"}, + } + + for _, lf := range lockFiles { + if _, err := os.Stat(filepath.Join(dir, lf.file)); err == nil { + return lf.manager + } + } + + // Default to npm if no lock file found + return "npm" +} + +// GetLaravelAppName extracts the application name from Laravel's .env file. +func GetLaravelAppName(dir string) string { + envPath := filepath.Join(dir, ".env") + file, err := os.Open(envPath) + if err != nil { + return "" + } + defer file.Close() + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if strings.HasPrefix(line, "APP_NAME=") { + value := strings.TrimPrefix(line, "APP_NAME=") + // Remove quotes if present + value = strings.Trim(value, `"'`) + return value + } + } + + return "" +} + +// GetLaravelAppURL extracts the application URL from Laravel's .env file. +func GetLaravelAppURL(dir string) string { + envPath := filepath.Join(dir, ".env") + file, err := os.Open(envPath) + if err != nil { + return "" + } + defer file.Close() + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if strings.HasPrefix(line, "APP_URL=") { + value := strings.TrimPrefix(line, "APP_URL=") + // Remove quotes if present + value = strings.Trim(value, `"'`) + return value + } + } + + return "" +} + +// ExtractDomainFromURL extracts the domain from a URL string. +func ExtractDomainFromURL(url string) string { + // Remove protocol + domain := strings.TrimPrefix(url, "https://") + domain = strings.TrimPrefix(domain, "http://") + + // Remove port if present + if idx := strings.Index(domain, ":"); idx != -1 { + domain = domain[:idx] + } + + // Remove path if present + if idx := strings.Index(domain, "/"); idx != -1 { + domain = domain[:idx] + } + + return domain +} diff --git a/pkg/php/detect_test.go b/pkg/php/detect_test.go new file mode 100644 index 00000000..a39e8404 --- /dev/null +++ b/pkg/php/detect_test.go @@ -0,0 +1,539 @@ +package php + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestIsLaravelProject_Good(t *testing.T) { + t.Run("valid Laravel project with artisan and composer.json", func(t *testing.T) { + dir := t.TempDir() + + // Create artisan file + artisanPath := filepath.Join(dir, "artisan") + err := os.WriteFile(artisanPath, []byte("#!/usr/bin/env php\n"), 0755) + require.NoError(t, err) + + // Create composer.json with laravel/framework + composerJSON := `{ + "name": "test/laravel-project", + "require": { + "php": "^8.2", + "laravel/framework": "^11.0" + } + }` + composerPath := filepath.Join(dir, "composer.json") + err = os.WriteFile(composerPath, []byte(composerJSON), 0644) + require.NoError(t, err) + + assert.True(t, IsLaravelProject(dir)) + }) + + t.Run("Laravel in require-dev", func(t *testing.T) { + dir := t.TempDir() + + // Create artisan file + artisanPath := filepath.Join(dir, "artisan") + err := os.WriteFile(artisanPath, []byte("#!/usr/bin/env php\n"), 0755) + require.NoError(t, err) + + // Create composer.json with laravel/framework in require-dev + composerJSON := `{ + "name": "test/laravel-project", + "require-dev": { + "laravel/framework": "^11.0" + } + }` + composerPath := filepath.Join(dir, "composer.json") + err = os.WriteFile(composerPath, []byte(composerJSON), 0644) + require.NoError(t, err) + + assert.True(t, IsLaravelProject(dir)) + }) +} + +func TestIsLaravelProject_Bad(t *testing.T) { + t.Run("missing artisan file", func(t *testing.T) { + dir := t.TempDir() + + // Create composer.json but no artisan + composerJSON := `{ + "name": "test/laravel-project", + "require": { + "laravel/framework": "^11.0" + } + }` + composerPath := filepath.Join(dir, "composer.json") + err := os.WriteFile(composerPath, []byte(composerJSON), 0644) + require.NoError(t, err) + + assert.False(t, IsLaravelProject(dir)) + }) + + t.Run("missing composer.json", func(t *testing.T) { + dir := t.TempDir() + + // Create artisan but no composer.json + artisanPath := filepath.Join(dir, "artisan") + err := os.WriteFile(artisanPath, []byte("#!/usr/bin/env php\n"), 0755) + require.NoError(t, err) + + assert.False(t, IsLaravelProject(dir)) + }) + + t.Run("composer.json without Laravel", func(t *testing.T) { + dir := t.TempDir() + + // Create artisan file + artisanPath := filepath.Join(dir, "artisan") + err := os.WriteFile(artisanPath, []byte("#!/usr/bin/env php\n"), 0755) + require.NoError(t, err) + + // Create composer.json without laravel/framework + composerJSON := `{ + "name": "test/symfony-project", + "require": { + "symfony/framework-bundle": "^7.0" + } + }` + composerPath := filepath.Join(dir, "composer.json") + err = os.WriteFile(composerPath, []byte(composerJSON), 0644) + require.NoError(t, err) + + assert.False(t, IsLaravelProject(dir)) + }) + + t.Run("invalid composer.json", func(t *testing.T) { + dir := t.TempDir() + + // Create artisan file + artisanPath := filepath.Join(dir, "artisan") + err := os.WriteFile(artisanPath, []byte("#!/usr/bin/env php\n"), 0755) + require.NoError(t, err) + + // Create invalid composer.json + composerPath := filepath.Join(dir, "composer.json") + err = os.WriteFile(composerPath, []byte("not valid json{"), 0644) + require.NoError(t, err) + + assert.False(t, IsLaravelProject(dir)) + }) + + t.Run("empty directory", func(t *testing.T) { + dir := t.TempDir() + assert.False(t, IsLaravelProject(dir)) + }) + + t.Run("non-existent directory", func(t *testing.T) { + assert.False(t, IsLaravelProject("/non/existent/path")) + }) +} + +func TestIsFrankenPHPProject_Good(t *testing.T) { + t.Run("project with octane and frankenphp config", func(t *testing.T) { + dir := t.TempDir() + + // Create composer.json with laravel/octane + composerJSON := `{ + "require": { + "laravel/octane": "^2.0" + } + }` + err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644) + require.NoError(t, err) + + // Create config directory and octane.php + configDir := filepath.Join(dir, "config") + err = os.MkdirAll(configDir, 0755) + require.NoError(t, err) + + octaneConfig := ` 'frankenphp', +];` + err = os.WriteFile(filepath.Join(configDir, "octane.php"), []byte(octaneConfig), 0644) + require.NoError(t, err) + + assert.True(t, IsFrankenPHPProject(dir)) + }) + + t.Run("project with octane but no config file", func(t *testing.T) { + dir := t.TempDir() + + // Create composer.json with laravel/octane + composerJSON := `{ + "require": { + "laravel/octane": "^2.0" + } + }` + err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644) + require.NoError(t, err) + + // No config file - should still return true (assume frankenphp) + assert.True(t, IsFrankenPHPProject(dir)) + }) +} + +func TestIsFrankenPHPProject_Bad(t *testing.T) { + t.Run("project without octane", func(t *testing.T) { + dir := t.TempDir() + + composerJSON := `{ + "require": { + "laravel/framework": "^11.0" + } + }` + err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644) + require.NoError(t, err) + + assert.False(t, IsFrankenPHPProject(dir)) + }) + + t.Run("missing composer.json", func(t *testing.T) { + dir := t.TempDir() + assert.False(t, IsFrankenPHPProject(dir)) + }) +} + +func TestDetectServices_Good(t *testing.T) { + t.Run("full Laravel project with all services", func(t *testing.T) { + dir := t.TempDir() + + // Setup Laravel project + err := os.WriteFile(filepath.Join(dir, "artisan"), []byte("#!/usr/bin/env php\n"), 0755) + require.NoError(t, err) + + composerJSON := `{ + "require": { + "laravel/framework": "^11.0", + "laravel/octane": "^2.0" + } + }` + err = os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644) + require.NoError(t, err) + + // Add vite.config.js + err = os.WriteFile(filepath.Join(dir, "vite.config.js"), []byte("export default {}"), 0644) + require.NoError(t, err) + + // Add config directory + configDir := filepath.Join(dir, "config") + err = os.MkdirAll(configDir, 0755) + require.NoError(t, err) + + // Add horizon.php + err = os.WriteFile(filepath.Join(configDir, "horizon.php"), []byte(" 0 { + // Stop any services that did start + for _, svc := range d.services { + svc.Stop() + } + return fmt.Errorf("failed to start services: %v", startErrors) + } + + d.running = true + return nil +} + +// filterServices removes disabled services from the list. +func (d *DevServer) filterServices(services []DetectedService, opts Options) []DetectedService { + filtered := make([]DetectedService, 0) + + for _, svc := range services { + switch svc { + case ServiceVite: + if !opts.NoVite { + filtered = append(filtered, svc) + } + case ServiceHorizon: + if !opts.NoHorizon { + filtered = append(filtered, svc) + } + case ServiceReverb: + if !opts.NoReverb { + filtered = append(filtered, svc) + } + case ServiceRedis: + if !opts.NoRedis { + filtered = append(filtered, svc) + } + default: + filtered = append(filtered, svc) + } + } + + return filtered +} + +// Stop stops all services gracefully. +func (d *DevServer) Stop() error { + d.mu.Lock() + defer d.mu.Unlock() + + if !d.running { + return nil + } + + // Cancel context first + if d.cancel != nil { + d.cancel() + } + + // Stop all services in reverse order + var stopErrors []error + for i := len(d.services) - 1; i >= 0; i-- { + svc := d.services[i] + if err := svc.Stop(); err != nil { + stopErrors = append(stopErrors, fmt.Errorf("%s: %w", svc.Name(), err)) + } + } + + d.running = false + + if len(stopErrors) > 0 { + return fmt.Errorf("errors stopping services: %v", stopErrors) + } + + return nil +} + +// Logs returns a reader for the specified service's logs. +// If service is empty, returns unified logs from all services. +func (d *DevServer) Logs(service string, follow bool) (io.ReadCloser, error) { + d.mu.RLock() + defer d.mu.RUnlock() + + if service == "" { + // Return unified logs + return d.unifiedLogs(follow) + } + + // Find specific service + for _, svc := range d.services { + if svc.Name() == service { + return svc.Logs(follow) + } + } + + return nil, fmt.Errorf("service not found: %s", service) +} + +// unifiedLogs creates a reader that combines logs from all services. +func (d *DevServer) unifiedLogs(follow bool) (io.ReadCloser, error) { + readers := make([]io.ReadCloser, 0) + + for _, svc := range d.services { + reader, err := svc.Logs(follow) + if err != nil { + // Close any readers we already opened + for _, r := range readers { + r.Close() + } + return nil, fmt.Errorf("failed to get logs for %s: %w", svc.Name(), err) + } + readers = append(readers, reader) + } + + return newMultiServiceReader(d.services, readers, follow), nil +} + +// Status returns the status of all services. +func (d *DevServer) Status() []ServiceStatus { + d.mu.RLock() + defer d.mu.RUnlock() + + statuses := make([]ServiceStatus, 0, len(d.services)) + for _, svc := range d.services { + statuses = append(statuses, svc.Status()) + } + + return statuses +} + +// IsRunning returns true if the dev server is running. +func (d *DevServer) IsRunning() bool { + d.mu.RLock() + defer d.mu.RUnlock() + return d.running +} + +// Services returns the list of managed services. +func (d *DevServer) Services() []Service { + d.mu.RLock() + defer d.mu.RUnlock() + return d.services +} + +// multiServiceReader combines multiple service log readers. +type multiServiceReader struct { + services []Service + readers []io.ReadCloser + follow bool + closed bool + mu sync.RWMutex +} + +func newMultiServiceReader(services []Service, readers []io.ReadCloser, follow bool) *multiServiceReader { + return &multiServiceReader{ + services: services, + readers: readers, + follow: follow, + } +} + +func (m *multiServiceReader) Read(p []byte) (n int, err error) { + m.mu.RLock() + if m.closed { + m.mu.RUnlock() + return 0, io.EOF + } + m.mu.RUnlock() + + // Round-robin read from all readers + for i, reader := range m.readers { + buf := make([]byte, len(p)) + n, err := reader.Read(buf) + if n > 0 { + // Prefix with service name + prefix := fmt.Sprintf("[%s] ", m.services[i].Name()) + copy(p, prefix) + copy(p[len(prefix):], buf[:n]) + return n + len(prefix), nil + } + if err != nil && err != io.EOF { + return 0, err + } + } + + if m.follow { + time.Sleep(100 * time.Millisecond) + return 0, nil + } + + return 0, io.EOF +} + +func (m *multiServiceReader) Close() error { + m.mu.Lock() + m.closed = true + m.mu.Unlock() + + var closeErr error + for _, reader := range m.readers { + if err := reader.Close(); err != nil && closeErr == nil { + closeErr = err + } + } + return closeErr +} diff --git a/pkg/php/services.go b/pkg/php/services.go new file mode 100644 index 00000000..6bea808c --- /dev/null +++ b/pkg/php/services.go @@ -0,0 +1,473 @@ +// Package php provides Laravel/PHP development environment management. +package php + +import ( + "bufio" + "context" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "syscall" + "time" +) + +// Service represents a managed development service. +type Service interface { + // Name returns the service name. + Name() string + // Start starts the service. + Start(ctx context.Context) error + // Stop stops the service gracefully. + Stop() error + // Logs returns a reader for the service logs. + Logs(follow bool) (io.ReadCloser, error) + // Status returns the current service status. + Status() ServiceStatus +} + +// ServiceStatus represents the status of a service. +type ServiceStatus struct { + Name string + Running bool + PID int + Port int + Error error +} + +// baseService provides common functionality for all services. +type baseService struct { + name string + port int + dir string + cmd *exec.Cmd + logFile *os.File + logPath string + mu sync.RWMutex + running bool + lastError error +} + +func (s *baseService) Name() string { + return s.name +} + +func (s *baseService) Status() ServiceStatus { + s.mu.RLock() + defer s.mu.RUnlock() + + status := ServiceStatus{ + Name: s.name, + Running: s.running, + Port: s.port, + Error: s.lastError, + } + + if s.cmd != nil && s.cmd.Process != nil { + status.PID = s.cmd.Process.Pid + } + + return status +} + +func (s *baseService) Logs(follow bool) (io.ReadCloser, error) { + if s.logPath == "" { + return nil, fmt.Errorf("no log file available for %s", s.name) + } + + file, err := os.Open(s.logPath) + if err != nil { + return nil, fmt.Errorf("failed to open log file: %w", err) + } + + if !follow { + return file, nil + } + + // For follow mode, return a tailing reader + return newTailReader(file), nil +} + +func (s *baseService) startProcess(ctx context.Context, cmdName string, args []string, env []string) error { + s.mu.Lock() + defer s.mu.Unlock() + + if s.running { + return fmt.Errorf("%s is already running", s.name) + } + + // Create log file + logDir := filepath.Join(s.dir, ".core", "logs") + if err := os.MkdirAll(logDir, 0755); err != nil { + return fmt.Errorf("failed to create log directory: %w", err) + } + + s.logPath = filepath.Join(logDir, fmt.Sprintf("%s.log", strings.ToLower(s.name))) + logFile, err := os.OpenFile(s.logPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) + if err != nil { + return fmt.Errorf("failed to create log file: %w", err) + } + s.logFile = logFile + + // Create command + s.cmd = exec.CommandContext(ctx, cmdName, args...) + s.cmd.Dir = s.dir + s.cmd.Stdout = logFile + s.cmd.Stderr = logFile + s.cmd.Env = append(os.Environ(), env...) + + // Set process group for clean shutdown + s.cmd.SysProcAttr = &syscall.SysProcAttr{ + Setpgid: true, + } + + if err := s.cmd.Start(); err != nil { + logFile.Close() + s.lastError = err + return fmt.Errorf("failed to start %s: %w", s.name, err) + } + + s.running = true + s.lastError = nil + + // Monitor process in background + go func() { + err := s.cmd.Wait() + s.mu.Lock() + s.running = false + if err != nil { + s.lastError = err + } + if s.logFile != nil { + s.logFile.Close() + } + s.mu.Unlock() + }() + + return nil +} + +func (s *baseService) stopProcess() error { + s.mu.Lock() + defer s.mu.Unlock() + + if !s.running || s.cmd == nil || s.cmd.Process == nil { + return nil + } + + // Send SIGTERM to process group + pgid, err := syscall.Getpgid(s.cmd.Process.Pid) + if err == nil { + syscall.Kill(-pgid, syscall.SIGTERM) + } else { + s.cmd.Process.Signal(syscall.SIGTERM) + } + + // Wait for graceful shutdown with timeout + done := make(chan struct{}) + go func() { + s.cmd.Wait() + close(done) + }() + + select { + case <-done: + // Process exited gracefully + case <-time.After(5 * time.Second): + // Force kill + if pgid, err := syscall.Getpgid(s.cmd.Process.Pid); err == nil { + syscall.Kill(-pgid, syscall.SIGKILL) + } else { + s.cmd.Process.Kill() + } + } + + s.running = false + return nil +} + +// FrankenPHPService manages the FrankenPHP/Octane server. +type FrankenPHPService struct { + baseService + https bool + httpsPort int + certFile string + keyFile string +} + +// NewFrankenPHPService creates a new FrankenPHP service. +func NewFrankenPHPService(dir string, opts FrankenPHPOptions) *FrankenPHPService { + port := opts.Port + if port == 0 { + port = 8000 + } + httpsPort := opts.HTTPSPort + if httpsPort == 0 { + httpsPort = 443 + } + + return &FrankenPHPService{ + baseService: baseService{ + name: "FrankenPHP", + port: port, + dir: dir, + }, + https: opts.HTTPS, + httpsPort: httpsPort, + certFile: opts.CertFile, + keyFile: opts.KeyFile, + } +} + +// FrankenPHPOptions configures the FrankenPHP service. +type FrankenPHPOptions struct { + Port int + HTTPSPort int + HTTPS bool + CertFile string + KeyFile string +} + +func (s *FrankenPHPService) Start(ctx context.Context) error { + args := []string{ + "artisan", "octane:start", + "--server=frankenphp", + fmt.Sprintf("--port=%d", s.port), + "--no-interaction", + } + + if s.https && s.certFile != "" && s.keyFile != "" { + args = append(args, + fmt.Sprintf("--https-port=%d", s.httpsPort), + fmt.Sprintf("--https-certificate=%s", s.certFile), + fmt.Sprintf("--https-certificate-key=%s", s.keyFile), + ) + } + + return s.startProcess(ctx, "php", args, nil) +} + +func (s *FrankenPHPService) Stop() error { + return s.stopProcess() +} + +// ViteService manages the Vite development server. +type ViteService struct { + baseService + packageManager string +} + +// NewViteService creates a new Vite service. +func NewViteService(dir string, opts ViteOptions) *ViteService { + port := opts.Port + if port == 0 { + port = 5173 + } + + pm := opts.PackageManager + if pm == "" { + pm = DetectPackageManager(dir) + } + + return &ViteService{ + baseService: baseService{ + name: "Vite", + port: port, + dir: dir, + }, + packageManager: pm, + } +} + +// ViteOptions configures the Vite service. +type ViteOptions struct { + Port int + PackageManager string +} + +func (s *ViteService) Start(ctx context.Context) error { + var cmdName string + var args []string + + switch s.packageManager { + case "bun": + cmdName = "bun" + args = []string{"run", "dev"} + case "pnpm": + cmdName = "pnpm" + args = []string{"run", "dev"} + case "yarn": + cmdName = "yarn" + args = []string{"dev"} + default: + cmdName = "npm" + args = []string{"run", "dev"} + } + + return s.startProcess(ctx, cmdName, args, nil) +} + +func (s *ViteService) Stop() error { + return s.stopProcess() +} + +// HorizonService manages Laravel Horizon. +type HorizonService struct { + baseService +} + +// NewHorizonService creates a new Horizon service. +func NewHorizonService(dir string) *HorizonService { + return &HorizonService{ + baseService: baseService{ + name: "Horizon", + port: 0, // Horizon doesn't expose a port directly + dir: dir, + }, + } +} + +func (s *HorizonService) Start(ctx context.Context) error { + return s.startProcess(ctx, "php", []string{"artisan", "horizon"}, nil) +} + +func (s *HorizonService) Stop() error { + // Horizon has its own terminate command + cmd := exec.Command("php", "artisan", "horizon:terminate") + cmd.Dir = s.dir + cmd.Run() // Ignore errors, will also kill via signal + + return s.stopProcess() +} + +// ReverbService manages Laravel Reverb WebSocket server. +type ReverbService struct { + baseService +} + +// NewReverbService creates a new Reverb service. +func NewReverbService(dir string, opts ReverbOptions) *ReverbService { + port := opts.Port + if port == 0 { + port = 8080 + } + + return &ReverbService{ + baseService: baseService{ + name: "Reverb", + port: port, + dir: dir, + }, + } +} + +// ReverbOptions configures the Reverb service. +type ReverbOptions struct { + Port int +} + +func (s *ReverbService) Start(ctx context.Context) error { + args := []string{ + "artisan", "reverb:start", + fmt.Sprintf("--port=%d", s.port), + } + + return s.startProcess(ctx, "php", args, nil) +} + +func (s *ReverbService) Stop() error { + return s.stopProcess() +} + +// RedisService manages a local Redis server. +type RedisService struct { + baseService + configFile string +} + +// NewRedisService creates a new Redis service. +func NewRedisService(dir string, opts RedisOptions) *RedisService { + port := opts.Port + if port == 0 { + port = 6379 + } + + return &RedisService{ + baseService: baseService{ + name: "Redis", + port: port, + dir: dir, + }, + configFile: opts.ConfigFile, + } +} + +// RedisOptions configures the Redis service. +type RedisOptions struct { + Port int + ConfigFile string +} + +func (s *RedisService) Start(ctx context.Context) error { + args := []string{ + "--port", fmt.Sprintf("%d", s.port), + "--daemonize", "no", + } + + if s.configFile != "" { + args = []string{s.configFile} + args = append(args, "--port", fmt.Sprintf("%d", s.port), "--daemonize", "no") + } + + return s.startProcess(ctx, "redis-server", args, nil) +} + +func (s *RedisService) Stop() error { + // Try graceful shutdown via redis-cli + cmd := exec.Command("redis-cli", "-p", fmt.Sprintf("%d", s.port), "shutdown", "nosave") + cmd.Run() // Ignore errors + + return s.stopProcess() +} + +// tailReader wraps a file and provides tailing functionality. +type tailReader struct { + file *os.File + reader *bufio.Reader + closed bool + mu sync.RWMutex +} + +func newTailReader(file *os.File) *tailReader { + return &tailReader{ + file: file, + reader: bufio.NewReader(file), + } +} + +func (t *tailReader) Read(p []byte) (n int, err error) { + t.mu.RLock() + if t.closed { + t.mu.RUnlock() + return 0, io.EOF + } + t.mu.RUnlock() + + n, err = t.reader.Read(p) + if err == io.EOF { + // Wait a bit and try again (tailing behavior) + time.Sleep(100 * time.Millisecond) + return 0, nil + } + return n, err +} + +func (t *tailReader) Close() error { + t.mu.Lock() + t.closed = true + t.mu.Unlock() + return t.file.Close() +} diff --git a/pkg/php/ssl.go b/pkg/php/ssl.go new file mode 100644 index 00000000..14498ad0 --- /dev/null +++ b/pkg/php/ssl.go @@ -0,0 +1,162 @@ +package php + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" +) + +const ( + // DefaultSSLDir is the default directory for SSL certificates. + DefaultSSLDir = ".core/ssl" +) + +// SSLOptions configures SSL certificate generation. +type SSLOptions struct { + // Dir is the directory to store certificates. + // Defaults to ~/.core/ssl/ + Dir string +} + +// GetSSLDir returns the SSL directory, creating it if necessary. +func GetSSLDir(opts SSLOptions) (string, error) { + dir := opts.Dir + if dir == "" { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get home directory: %w", err) + } + dir = filepath.Join(home, DefaultSSLDir) + } + + if err := os.MkdirAll(dir, 0755); err != nil { + return "", fmt.Errorf("failed to create SSL directory: %w", err) + } + + return dir, nil +} + +// CertPaths returns the paths to the certificate and key files for a domain. +func CertPaths(domain string, opts SSLOptions) (certFile, keyFile string, err error) { + dir, err := GetSSLDir(opts) + if err != nil { + return "", "", err + } + + certFile = filepath.Join(dir, fmt.Sprintf("%s.pem", domain)) + keyFile = filepath.Join(dir, fmt.Sprintf("%s-key.pem", domain)) + + return certFile, keyFile, nil +} + +// CertsExist checks if SSL certificates exist for the given domain. +func CertsExist(domain string, opts SSLOptions) bool { + certFile, keyFile, err := CertPaths(domain, opts) + if err != nil { + return false + } + + if _, err := os.Stat(certFile); os.IsNotExist(err) { + return false + } + + if _, err := os.Stat(keyFile); os.IsNotExist(err) { + return false + } + + return true +} + +// SetupSSL creates local SSL certificates using mkcert. +// It installs the local CA if not already installed and generates +// certificates for the given domain. +func SetupSSL(domain string, opts SSLOptions) error { + // Check if mkcert is installed + if _, err := exec.LookPath("mkcert"); err != nil { + return fmt.Errorf("mkcert is not installed. Install it with: brew install mkcert (macOS) or see https://github.com/FiloSottile/mkcert") + } + + dir, err := GetSSLDir(opts) + if err != nil { + return err + } + + // Install local CA (idempotent operation) + installCmd := exec.Command("mkcert", "-install") + if output, err := installCmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to install mkcert CA: %w\n%s", err, output) + } + + // Generate certificates + certFile := filepath.Join(dir, fmt.Sprintf("%s.pem", domain)) + keyFile := filepath.Join(dir, fmt.Sprintf("%s-key.pem", domain)) + + // mkcert generates cert and key with specific naming + genCmd := exec.Command("mkcert", + "-cert-file", certFile, + "-key-file", keyFile, + domain, + "localhost", + "127.0.0.1", + "::1", + ) + + if output, err := genCmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to generate certificates: %w\n%s", err, output) + } + + return nil +} + +// SetupSSLIfNeeded checks if certificates exist and creates them if not. +func SetupSSLIfNeeded(domain string, opts SSLOptions) (certFile, keyFile string, err error) { + certFile, keyFile, err = CertPaths(domain, opts) + if err != nil { + return "", "", err + } + + if !CertsExist(domain, opts) { + if err := SetupSSL(domain, opts); err != nil { + return "", "", err + } + } + + return certFile, keyFile, nil +} + +// IsMkcertInstalled checks if mkcert is available in PATH. +func IsMkcertInstalled() bool { + _, err := exec.LookPath("mkcert") + return err == nil +} + +// InstallMkcertCA installs the local CA for mkcert. +func InstallMkcertCA() error { + if !IsMkcertInstalled() { + return fmt.Errorf("mkcert is not installed") + } + + cmd := exec.Command("mkcert", "-install") + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("failed to install mkcert CA: %w\n%s", err, output) + } + + return nil +} + +// GetMkcertCARoot returns the path to the mkcert CA root directory. +func GetMkcertCARoot() (string, error) { + if !IsMkcertInstalled() { + return "", fmt.Errorf("mkcert is not installed") + } + + cmd := exec.Command("mkcert", "-CAROOT") + output, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("failed to get mkcert CA root: %w", err) + } + + return filepath.Clean(string(output)), nil +} diff --git a/pkg/php/ssl_test.go b/pkg/php/ssl_test.go new file mode 100644 index 00000000..3e0a0a54 --- /dev/null +++ b/pkg/php/ssl_test.go @@ -0,0 +1,172 @@ +package php + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetSSLDir_Good(t *testing.T) { + t.Run("uses provided directory", func(t *testing.T) { + dir := t.TempDir() + customDir := filepath.Join(dir, "custom-ssl") + + result, err := GetSSLDir(SSLOptions{Dir: customDir}) + + assert.NoError(t, err) + assert.Equal(t, customDir, result) + + // Verify directory was created + info, err := os.Stat(result) + assert.NoError(t, err) + assert.True(t, info.IsDir()) + }) + + t.Run("uses default directory when not specified", func(t *testing.T) { + // Skip if we can't get home dir + home, err := os.UserHomeDir() + if err != nil { + t.Skip("cannot get home directory") + } + + result, err := GetSSLDir(SSLOptions{}) + + assert.NoError(t, err) + assert.Equal(t, filepath.Join(home, DefaultSSLDir), result) + }) +} + +func TestCertPaths_Good(t *testing.T) { + t.Run("returns correct paths for domain", func(t *testing.T) { + dir := t.TempDir() + + certFile, keyFile, err := CertPaths("example.test", SSLOptions{Dir: dir}) + + assert.NoError(t, err) + assert.Equal(t, filepath.Join(dir, "example.test.pem"), certFile) + assert.Equal(t, filepath.Join(dir, "example.test-key.pem"), keyFile) + }) + + t.Run("handles domain with subdomain", func(t *testing.T) { + dir := t.TempDir() + + certFile, keyFile, err := CertPaths("app.example.test", SSLOptions{Dir: dir}) + + assert.NoError(t, err) + assert.Equal(t, filepath.Join(dir, "app.example.test.pem"), certFile) + assert.Equal(t, filepath.Join(dir, "app.example.test-key.pem"), keyFile) + }) +} + +func TestCertsExist_Good(t *testing.T) { + t.Run("returns true when both files exist", func(t *testing.T) { + dir := t.TempDir() + domain := "myapp.test" + + // Create cert and key files + certFile := filepath.Join(dir, domain+".pem") + keyFile := filepath.Join(dir, domain+"-key.pem") + + err := os.WriteFile(certFile, []byte("cert content"), 0644) + require.NoError(t, err) + err = os.WriteFile(keyFile, []byte("key content"), 0644) + require.NoError(t, err) + + assert.True(t, CertsExist(domain, SSLOptions{Dir: dir})) + }) +} + +func TestCertsExist_Bad(t *testing.T) { + t.Run("returns false when cert missing", func(t *testing.T) { + dir := t.TempDir() + domain := "myapp.test" + + // Create only key file + keyFile := filepath.Join(dir, domain+"-key.pem") + err := os.WriteFile(keyFile, []byte("key content"), 0644) + require.NoError(t, err) + + assert.False(t, CertsExist(domain, SSLOptions{Dir: dir})) + }) + + t.Run("returns false when key missing", func(t *testing.T) { + dir := t.TempDir() + domain := "myapp.test" + + // Create only cert file + certFile := filepath.Join(dir, domain+".pem") + err := os.WriteFile(certFile, []byte("cert content"), 0644) + require.NoError(t, err) + + assert.False(t, CertsExist(domain, SSLOptions{Dir: dir})) + }) + + t.Run("returns false when neither exists", func(t *testing.T) { + dir := t.TempDir() + domain := "myapp.test" + + assert.False(t, CertsExist(domain, SSLOptions{Dir: dir})) + }) + + t.Run("returns false for invalid directory", func(t *testing.T) { + // Use invalid directory path + assert.False(t, CertsExist("domain.test", SSLOptions{Dir: "/nonexistent/path/that/does/not/exist"})) + }) +} + +func TestSetupSSL_Bad(t *testing.T) { + t.Run("returns error when mkcert not installed", func(t *testing.T) { + // This test assumes mkcert might not be installed + // If it is installed, we skip this test + if IsMkcertInstalled() { + t.Skip("mkcert is installed, skipping error test") + } + + err := SetupSSL("example.test", SSLOptions{}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "mkcert is not installed") + }) +} + +func TestSetupSSLIfNeeded_Good(t *testing.T) { + t.Run("returns existing certs without regenerating", func(t *testing.T) { + dir := t.TempDir() + domain := "existing.test" + + // Create existing cert files + certFile := filepath.Join(dir, domain+".pem") + keyFile := filepath.Join(dir, domain+"-key.pem") + + err := os.WriteFile(certFile, []byte("existing cert"), 0644) + require.NoError(t, err) + err = os.WriteFile(keyFile, []byte("existing key"), 0644) + require.NoError(t, err) + + resultCert, resultKey, err := SetupSSLIfNeeded(domain, SSLOptions{Dir: dir}) + + assert.NoError(t, err) + assert.Equal(t, certFile, resultCert) + assert.Equal(t, keyFile, resultKey) + + // Verify files weren't modified + data, err := os.ReadFile(certFile) + require.NoError(t, err) + assert.Equal(t, "existing cert", string(data)) + }) +} + +func TestIsMkcertInstalled_Good(t *testing.T) { + // This test just verifies the function runs without error + // The actual result depends on whether mkcert is installed + result := IsMkcertInstalled() + t.Logf("mkcert installed: %v", result) +} + +func TestDefaultSSLDir_Good(t *testing.T) { + t.Run("constant has expected value", func(t *testing.T) { + assert.Equal(t, ".core/ssl", DefaultSSLDir) + }) +}