feat(php): implement Laravel development environment support
Add pkg/php for Laravel/PHP development: - Auto-detect Laravel project and required services - FrankenPHP/Octane, Vite, Horizon, Reverb, Redis orchestration - mkcert SSL integration for local HTTPS - Unified log streaming with colored service prefixes - Graceful shutdown handling CLI commands: - core php dev - start all services - core php logs - unified/per-service logs - core php stop - stop all services - core php status - show service status - core php ssl - setup SSL certificates Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
b4e1b1423d
commit
96d394435a
8 changed files with 2559 additions and 0 deletions
528
cmd/core/cmd/php.go
Normal file
528
cmd/core/cmd/php.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -87,6 +87,7 @@ func Execute() error {
|
||||||
AddReleaseCommand(app)
|
AddReleaseCommand(app)
|
||||||
AddContainerCommands(app)
|
AddContainerCommands(app)
|
||||||
AddTemplatesCommand(app)
|
AddTemplatesCommand(app)
|
||||||
|
AddPHPCommands(app)
|
||||||
// Run the application
|
// Run the application
|
||||||
return app.Run()
|
return app.Run()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
288
pkg/php/detect.go
Normal file
288
pkg/php/detect.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
539
pkg/php/detect_test.go
Normal file
539
pkg/php/detect_test.go
Normal file
|
|
@ -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 := `<?php
|
||||||
|
return [
|
||||||
|
'server' => '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("<?php return [];"), 0644)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Add reverb.php
|
||||||
|
err = os.WriteFile(filepath.Join(configDir, "reverb.php"), []byte("<?php return [];"), 0644)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Add .env with Redis
|
||||||
|
envContent := `APP_NAME=TestApp
|
||||||
|
CACHE_DRIVER=redis
|
||||||
|
REDIS_HOST=127.0.0.1`
|
||||||
|
err = os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
services := DetectServices(dir)
|
||||||
|
|
||||||
|
assert.Contains(t, services, ServiceFrankenPHP)
|
||||||
|
assert.Contains(t, services, ServiceVite)
|
||||||
|
assert.Contains(t, services, ServiceHorizon)
|
||||||
|
assert.Contains(t, services, ServiceReverb)
|
||||||
|
assert.Contains(t, services, ServiceRedis)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("minimal Laravel project", func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
// Setup minimal 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"
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
err = os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
services := DetectServices(dir)
|
||||||
|
|
||||||
|
assert.Contains(t, services, ServiceFrankenPHP)
|
||||||
|
assert.NotContains(t, services, ServiceVite)
|
||||||
|
assert.NotContains(t, services, ServiceHorizon)
|
||||||
|
assert.NotContains(t, services, ServiceReverb)
|
||||||
|
assert.NotContains(t, services, ServiceRedis)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectServices_Bad(t *testing.T) {
|
||||||
|
t.Run("non-Laravel project", func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
services := DetectServices(dir)
|
||||||
|
assert.Empty(t, services)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectPackageManager_Good(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
lockFile string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{"bun detected", "bun.lockb", "bun"},
|
||||||
|
{"pnpm detected", "pnpm-lock.yaml", "pnpm"},
|
||||||
|
{"yarn detected", "yarn.lock", "yarn"},
|
||||||
|
{"npm detected", "package-lock.json", "npm"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
err := os.WriteFile(filepath.Join(dir, tt.lockFile), []byte(""), 0644)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
result := DetectPackageManager(dir)
|
||||||
|
assert.Equal(t, tt.expected, result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("no lock file defaults to npm", func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
result := DetectPackageManager(dir)
|
||||||
|
assert.Equal(t, "npm", result)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("bun takes priority over npm", func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
// Create both lock files
|
||||||
|
err := os.WriteFile(filepath.Join(dir, "bun.lockb"), []byte(""), 0644)
|
||||||
|
require.NoError(t, err)
|
||||||
|
err = os.WriteFile(filepath.Join(dir, "package-lock.json"), []byte(""), 0644)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
result := DetectPackageManager(dir)
|
||||||
|
assert.Equal(t, "bun", result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetLaravelAppName_Good(t *testing.T) {
|
||||||
|
t.Run("simple app name", func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
envContent := `APP_NAME=MyApp
|
||||||
|
APP_ENV=local`
|
||||||
|
err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, "MyApp", GetLaravelAppName(dir))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("quoted app name", func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
envContent := `APP_NAME="My Awesome App"
|
||||||
|
APP_ENV=local`
|
||||||
|
err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, "My Awesome App", GetLaravelAppName(dir))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("single quoted app name", func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
envContent := `APP_NAME='My App'
|
||||||
|
APP_ENV=local`
|
||||||
|
err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, "My App", GetLaravelAppName(dir))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetLaravelAppName_Bad(t *testing.T) {
|
||||||
|
t.Run("no .env file", func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
assert.Equal(t, "", GetLaravelAppName(dir))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("no APP_NAME in .env", func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
envContent := `APP_ENV=local
|
||||||
|
APP_DEBUG=true`
|
||||||
|
err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, "", GetLaravelAppName(dir))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetLaravelAppURL_Good(t *testing.T) {
|
||||||
|
t.Run("standard URL", func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
envContent := `APP_NAME=MyApp
|
||||||
|
APP_URL=https://myapp.test`
|
||||||
|
err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, "https://myapp.test", GetLaravelAppURL(dir))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("quoted URL", func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
envContent := `APP_URL="http://localhost:8000"`
|
||||||
|
err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, "http://localhost:8000", GetLaravelAppURL(dir))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractDomainFromURL_Good(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
url string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{"https://example.com", "example.com"},
|
||||||
|
{"http://example.com", "example.com"},
|
||||||
|
{"https://example.com:8080", "example.com"},
|
||||||
|
{"https://example.com/path/to/page", "example.com"},
|
||||||
|
{"https://example.com:443/path", "example.com"},
|
||||||
|
{"localhost", "localhost"},
|
||||||
|
{"localhost:8000", "localhost"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.url, func(t *testing.T) {
|
||||||
|
result := ExtractDomainFromURL(tt.url)
|
||||||
|
assert.Equal(t, tt.expected, result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNeedsRedis_Good(t *testing.T) {
|
||||||
|
t.Run("CACHE_DRIVER=redis", func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
envContent := `APP_NAME=Test
|
||||||
|
CACHE_DRIVER=redis`
|
||||||
|
err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.True(t, needsRedis(dir))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("QUEUE_CONNECTION=redis", func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
envContent := `APP_NAME=Test
|
||||||
|
QUEUE_CONNECTION=redis`
|
||||||
|
err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.True(t, needsRedis(dir))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("REDIS_HOST localhost", func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
envContent := `APP_NAME=Test
|
||||||
|
REDIS_HOST=localhost`
|
||||||
|
err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.True(t, needsRedis(dir))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("REDIS_HOST 127.0.0.1", func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
envContent := `APP_NAME=Test
|
||||||
|
REDIS_HOST=127.0.0.1`
|
||||||
|
err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.True(t, needsRedis(dir))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNeedsRedis_Bad(t *testing.T) {
|
||||||
|
t.Run("no .env file", func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
assert.False(t, needsRedis(dir))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("no redis configuration", func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
envContent := `APP_NAME=Test
|
||||||
|
CACHE_DRIVER=file
|
||||||
|
QUEUE_CONNECTION=sync`
|
||||||
|
err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.False(t, needsRedis(dir))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("commented redis config", func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
envContent := `APP_NAME=Test
|
||||||
|
# CACHE_DRIVER=redis`
|
||||||
|
err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.False(t, needsRedis(dir))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHasVite_Good(t *testing.T) {
|
||||||
|
viteFiles := []string{
|
||||||
|
"vite.config.js",
|
||||||
|
"vite.config.ts",
|
||||||
|
"vite.config.mjs",
|
||||||
|
"vite.config.mts",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, file := range viteFiles {
|
||||||
|
t.Run(file, func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
err := os.WriteFile(filepath.Join(dir, file), []byte("export default {}"), 0644)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.True(t, hasVite(dir))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHasVite_Bad(t *testing.T) {
|
||||||
|
t.Run("no vite config", func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
assert.False(t, hasVite(dir))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("wrong file name", func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
err := os.WriteFile(filepath.Join(dir, "vite.config.json"), []byte("{}"), 0644)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.False(t, hasVite(dir))
|
||||||
|
})
|
||||||
|
}
|
||||||
396
pkg/php/php.go
Normal file
396
pkg/php/php.go
Normal file
|
|
@ -0,0 +1,396 @@
|
||||||
|
package php
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Options configures the development server.
|
||||||
|
type Options struct {
|
||||||
|
// Dir is the Laravel project directory.
|
||||||
|
Dir string
|
||||||
|
|
||||||
|
// Services specifies which services to start.
|
||||||
|
// If empty, services are auto-detected.
|
||||||
|
Services []DetectedService
|
||||||
|
|
||||||
|
// NoVite disables the Vite dev server.
|
||||||
|
NoVite bool
|
||||||
|
|
||||||
|
// NoHorizon disables Laravel Horizon.
|
||||||
|
NoHorizon bool
|
||||||
|
|
||||||
|
// NoReverb disables Laravel Reverb.
|
||||||
|
NoReverb bool
|
||||||
|
|
||||||
|
// NoRedis disables the Redis server.
|
||||||
|
NoRedis bool
|
||||||
|
|
||||||
|
// HTTPS enables HTTPS with mkcert certificates.
|
||||||
|
HTTPS bool
|
||||||
|
|
||||||
|
// Domain is the domain for SSL certificates.
|
||||||
|
// Defaults to APP_URL from .env or "localhost".
|
||||||
|
Domain string
|
||||||
|
|
||||||
|
// Ports for each service
|
||||||
|
FrankenPHPPort int
|
||||||
|
HTTPSPort int
|
||||||
|
VitePort int
|
||||||
|
ReverbPort int
|
||||||
|
RedisPort int
|
||||||
|
}
|
||||||
|
|
||||||
|
// DevServer manages all development services.
|
||||||
|
type DevServer struct {
|
||||||
|
opts Options
|
||||||
|
services []Service
|
||||||
|
ctx context.Context
|
||||||
|
cancel context.CancelFunc
|
||||||
|
mu sync.RWMutex
|
||||||
|
running bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewDevServer creates a new development server manager.
|
||||||
|
func NewDevServer(opts Options) *DevServer {
|
||||||
|
return &DevServer{
|
||||||
|
opts: opts,
|
||||||
|
services: make([]Service, 0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start starts all detected/configured services.
|
||||||
|
func (d *DevServer) Start(ctx context.Context, opts Options) error {
|
||||||
|
d.mu.Lock()
|
||||||
|
defer d.mu.Unlock()
|
||||||
|
|
||||||
|
if d.running {
|
||||||
|
return fmt.Errorf("dev server is already running")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge options
|
||||||
|
if opts.Dir != "" {
|
||||||
|
d.opts.Dir = opts.Dir
|
||||||
|
}
|
||||||
|
if d.opts.Dir == "" {
|
||||||
|
cwd, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get working directory: %w", err)
|
||||||
|
}
|
||||||
|
d.opts.Dir = cwd
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify this is a Laravel project
|
||||||
|
if !IsLaravelProject(d.opts.Dir) {
|
||||||
|
return fmt.Errorf("not a Laravel project: %s", d.opts.Dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create cancellable context
|
||||||
|
d.ctx, d.cancel = context.WithCancel(ctx)
|
||||||
|
|
||||||
|
// Detect or use provided services
|
||||||
|
services := opts.Services
|
||||||
|
if len(services) == 0 {
|
||||||
|
services = DetectServices(d.opts.Dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter out disabled services
|
||||||
|
services = d.filterServices(services, opts)
|
||||||
|
|
||||||
|
// Setup SSL if HTTPS is enabled
|
||||||
|
var certFile, keyFile string
|
||||||
|
if opts.HTTPS {
|
||||||
|
domain := opts.Domain
|
||||||
|
if domain == "" {
|
||||||
|
// Try to get domain from APP_URL
|
||||||
|
appURL := GetLaravelAppURL(d.opts.Dir)
|
||||||
|
if appURL != "" {
|
||||||
|
domain = ExtractDomainFromURL(appURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if domain == "" {
|
||||||
|
domain = "localhost"
|
||||||
|
}
|
||||||
|
|
||||||
|
var err error
|
||||||
|
certFile, keyFile, err = SetupSSLIfNeeded(domain, SSLOptions{})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to setup SSL: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create services
|
||||||
|
d.services = make([]Service, 0)
|
||||||
|
|
||||||
|
for _, svc := range services {
|
||||||
|
var service Service
|
||||||
|
|
||||||
|
switch svc {
|
||||||
|
case ServiceFrankenPHP:
|
||||||
|
port := opts.FrankenPHPPort
|
||||||
|
if port == 0 {
|
||||||
|
port = 8000
|
||||||
|
}
|
||||||
|
httpsPort := opts.HTTPSPort
|
||||||
|
if httpsPort == 0 {
|
||||||
|
httpsPort = 443
|
||||||
|
}
|
||||||
|
service = NewFrankenPHPService(d.opts.Dir, FrankenPHPOptions{
|
||||||
|
Port: port,
|
||||||
|
HTTPSPort: httpsPort,
|
||||||
|
HTTPS: opts.HTTPS,
|
||||||
|
CertFile: certFile,
|
||||||
|
KeyFile: keyFile,
|
||||||
|
})
|
||||||
|
|
||||||
|
case ServiceVite:
|
||||||
|
port := opts.VitePort
|
||||||
|
if port == 0 {
|
||||||
|
port = 5173
|
||||||
|
}
|
||||||
|
service = NewViteService(d.opts.Dir, ViteOptions{
|
||||||
|
Port: port,
|
||||||
|
})
|
||||||
|
|
||||||
|
case ServiceHorizon:
|
||||||
|
service = NewHorizonService(d.opts.Dir)
|
||||||
|
|
||||||
|
case ServiceReverb:
|
||||||
|
port := opts.ReverbPort
|
||||||
|
if port == 0 {
|
||||||
|
port = 8080
|
||||||
|
}
|
||||||
|
service = NewReverbService(d.opts.Dir, ReverbOptions{
|
||||||
|
Port: port,
|
||||||
|
})
|
||||||
|
|
||||||
|
case ServiceRedis:
|
||||||
|
port := opts.RedisPort
|
||||||
|
if port == 0 {
|
||||||
|
port = 6379
|
||||||
|
}
|
||||||
|
service = NewRedisService(d.opts.Dir, RedisOptions{
|
||||||
|
Port: port,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if service != nil {
|
||||||
|
d.services = append(d.services, service)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start all services
|
||||||
|
var startErrors []error
|
||||||
|
for _, svc := range d.services {
|
||||||
|
if err := svc.Start(d.ctx); err != nil {
|
||||||
|
startErrors = append(startErrors, fmt.Errorf("%s: %w", svc.Name(), err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(startErrors) > 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
|
||||||
|
}
|
||||||
473
pkg/php/services.go
Normal file
473
pkg/php/services.go
Normal file
|
|
@ -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()
|
||||||
|
}
|
||||||
162
pkg/php/ssl.go
Normal file
162
pkg/php/ssl.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
172
pkg/php/ssl_test.go
Normal file
172
pkg/php/ssl_test.go
Normal file
|
|
@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue