Replace leaanthony/clir with spf13/cobra across all command packages. This provides better subcommand handling, built-in shell completion, and a more widely-used CLI framework. Changes: - Update cmd/core.go with cobra root command and completion support - Convert all subcommand packages to use *cobra.Command - Use init() functions for flag registration instead of inline setup - Maintain all existing functionality and flag behaviors Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
503 lines
12 KiB
Go
503 lines
12 KiB
Go
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/spf13/cobra"
|
|
)
|
|
|
|
var (
|
|
devNoVite bool
|
|
devNoHorizon bool
|
|
devNoReverb bool
|
|
devNoRedis bool
|
|
devHTTPS bool
|
|
devDomain string
|
|
devPort int
|
|
)
|
|
|
|
func addPHPDevCommand(parent *cobra.Command) {
|
|
devCmd := &cobra.Command{
|
|
Use: "dev",
|
|
Short: "Start Laravel development environment",
|
|
Long: "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)",
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
return runPHPDev(phpDevOptions{
|
|
NoVite: devNoVite,
|
|
NoHorizon: devNoHorizon,
|
|
NoReverb: devNoReverb,
|
|
NoRedis: devNoRedis,
|
|
HTTPS: devHTTPS,
|
|
Domain: devDomain,
|
|
Port: devPort,
|
|
})
|
|
},
|
|
}
|
|
|
|
devCmd.Flags().BoolVar(&devNoVite, "no-vite", false, "Skip Vite dev server")
|
|
devCmd.Flags().BoolVar(&devNoHorizon, "no-horizon", false, "Skip Laravel Horizon")
|
|
devCmd.Flags().BoolVar(&devNoReverb, "no-reverb", false, "Skip Laravel Reverb")
|
|
devCmd.Flags().BoolVar(&devNoRedis, "no-redis", false, "Skip Redis server")
|
|
devCmd.Flags().BoolVar(&devHTTPS, "https", false, "Enable HTTPS with mkcert")
|
|
devCmd.Flags().StringVar(&devDomain, "domain", "", "Domain for SSL certificate (default: from APP_URL or localhost)")
|
|
devCmd.Flags().IntVar(&devPort, "port", 0, "FrankenPHP port (default: 8000)")
|
|
|
|
parent.AddCommand(devCmd)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
var (
|
|
logsFollow bool
|
|
logsService string
|
|
)
|
|
|
|
func addPHPLogsCommand(parent *cobra.Command) {
|
|
logsCmd := &cobra.Command{
|
|
Use: "logs",
|
|
Short: "View service logs",
|
|
Long: "Stream logs from Laravel services.\n\n" +
|
|
"Services: frankenphp, vite, horizon, reverb, redis",
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
return runPHPLogs(logsService, logsFollow)
|
|
},
|
|
}
|
|
|
|
logsCmd.Flags().BoolVar(&logsFollow, "follow", false, "Follow log output")
|
|
logsCmd.Flags().StringVar(&logsService, "service", "", "Specific service (default: all)")
|
|
|
|
parent.AddCommand(logsCmd)
|
|
}
|
|
|
|
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 *cobra.Command) {
|
|
stopCmd := &cobra.Command{
|
|
Use: "stop",
|
|
Short: "Stop all Laravel services",
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
return runPHPStop()
|
|
},
|
|
}
|
|
|
|
parent.AddCommand(stopCmd)
|
|
}
|
|
|
|
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 *cobra.Command) {
|
|
statusCmd := &cobra.Command{
|
|
Use: "status",
|
|
Short: "Show service status",
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
return runPHPStatus()
|
|
},
|
|
}
|
|
|
|
parent.AddCommand(statusCmd)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
var sslDomain string
|
|
|
|
func addPHPSSLCommand(parent *cobra.Command) {
|
|
sslCmd := &cobra.Command{
|
|
Use: "ssl",
|
|
Short: "Setup SSL certificates with mkcert",
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
return runPHPSSL(sslDomain)
|
|
},
|
|
}
|
|
|
|
sslCmd.Flags().StringVar(&sslDomain, "domain", "", "Domain for certificate (default: from APP_URL)")
|
|
|
|
parent.AddCommand(sslCmd)
|
|
}
|
|
|
|
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
|
|
}
|