cli/cmd/php/php_dev.go

482 lines
12 KiB
Go
Raw Normal View History

package php
import (
"bufio"
"context"
"fmt"
"os"
"os/signal"
"strings"
"syscall"
"time"
"github.com/charmbracelet/lipgloss"
phppkg "github.com/host-uk/core/pkg/php"
"github.com/leaanthony/clir"
)
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 !phppkg.IsLaravelProject(cwd) {
return fmt.Errorf("not a Laravel project (missing artisan or laravel/framework)")
}
// Get app name for display
appName := phppkg.GetLaravelAppName(cwd)
if appName == "" {
appName = "Laravel"
}
fmt.Printf("%s Starting %s development environment\n\n", dimStyle.Render("PHP:"), appName)
// Detect services
services := phppkg.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 := phppkg.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 := phppkg.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 := phppkg.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, phppkg.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 !phppkg.IsLaravelProject(cwd) {
return fmt.Errorf("not a Laravel project")
}
// Create a minimal server just to access logs
server := phppkg.NewDevServer(phppkg.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 := phppkg.NewDevServer(phppkg.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 !phppkg.IsLaravelProject(cwd) {
return fmt.Errorf("not a Laravel project")
}
appName := phppkg.GetLaravelAppName(cwd)
if appName == "" {
appName = "Laravel"
}
fmt.Printf("%s %s\n\n", dimStyle.Render("Project:"), appName)
// Detect available services
services := phppkg.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 := phppkg.DetectPackageManager(cwd)
fmt.Printf("%s %s\n", dimStyle.Render("Package manager:"), pm)
// FrankenPHP status
if phppkg.IsFrankenPHPProject(cwd) {
fmt.Printf("%s %s\n", dimStyle.Render("Octane server:"), "FrankenPHP")
}
// SSL status
appURL := phppkg.GetLaravelAppURL(cwd)
if appURL != "" {
domain := phppkg.ExtractDomainFromURL(appURL)
if phppkg.CertsExist(domain, phppkg.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 := phppkg.GetLaravelAppURL(cwd)
if appURL != "" {
domain = phppkg.ExtractDomainFromURL(appURL)
}
}
if domain == "" {
domain = "localhost"
}
// Check if mkcert is installed
if !phppkg.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 phppkg.CertsExist(domain, phppkg.SSLOptions{}) {
fmt.Printf("%s Certificates already exist\n", dimStyle.Render("Skip:"))
certFile, keyFile, _ := phppkg.CertPaths(domain, phppkg.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 := phppkg.SetupSSL(domain, phppkg.SSLOptions{}); err != nil {
return fmt.Errorf("failed to setup SSL: %w", err)
}
certFile, keyFile, _ := phppkg.CertPaths(domain, phppkg.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 for dev commands
func printServiceStatuses(statuses []phppkg.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 []phppkg.DetectedService, target phppkg.DetectedService) bool {
for _, s := range services {
if s == target {
return true
}
}
return false
}