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)
|
||||
AddContainerCommands(app)
|
||||
AddTemplatesCommand(app)
|
||||
AddPHPCommands(app)
|
||||
// Run the application
|
||||
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