feat: extract PHP/Laravel commands from core/cli

Port all PHP command files from core/cli internal/cmd/php/ into a
standalone module. Inlines workspace dependency to avoid cross-module
internal imports.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Claude 2026-02-16 14:40:59 +00:00
commit 6cb5957ca6
No known key found for this signature in database
GPG key ID: AF404715446AEB41
42 changed files with 13884 additions and 0 deletions

157
cmd.go Normal file
View file

@ -0,0 +1,157 @@
package php
import (
"os"
"path/filepath"
"forge.lthn.ai/core/go/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n"
"forge.lthn.ai/core/go/pkg/io"
"github.com/spf13/cobra"
)
// DefaultMedium is the default filesystem medium used by the php package.
// It defaults to io.Local (unsandboxed filesystem access).
// Use SetMedium to change this for testing or sandboxed operation.
var DefaultMedium io.Medium = io.Local
// SetMedium sets the default medium for filesystem operations.
// This is primarily useful for testing with mock mediums.
func SetMedium(m io.Medium) {
DefaultMedium = m
}
// getMedium returns the default medium for filesystem operations.
func getMedium() io.Medium {
return DefaultMedium
}
func init() {
cli.RegisterCommands(AddPHPCommands)
}
// Style aliases from shared
var (
successStyle = cli.SuccessStyle
errorStyle = cli.ErrorStyle
dimStyle = cli.DimStyle
linkStyle = cli.LinkStyle
)
// Service colors for log output (domain-specific, keep local)
var (
phpFrankenPHPStyle = cli.NewStyle().Foreground(cli.ColourIndigo500)
phpViteStyle = cli.NewStyle().Foreground(cli.ColourYellow500)
phpHorizonStyle = cli.NewStyle().Foreground(cli.ColourOrange500)
phpReverbStyle = cli.NewStyle().Foreground(cli.ColourViolet500)
phpRedisStyle = cli.NewStyle().Foreground(cli.ColourRed500)
)
// Status styles (from shared)
var (
phpStatusRunning = cli.SuccessStyle
phpStatusStopped = cli.DimStyle
phpStatusError = cli.ErrorStyle
)
// QA command styles (from shared)
var (
phpQAPassedStyle = cli.SuccessStyle
phpQAFailedStyle = cli.ErrorStyle
phpQAWarningStyle = cli.WarningStyle
phpQAStageStyle = cli.HeaderStyle
)
// Security severity styles (from shared)
var (
phpSecurityCriticalStyle = cli.NewStyle().Bold().Foreground(cli.ColourRed500)
phpSecurityHighStyle = cli.NewStyle().Bold().Foreground(cli.ColourOrange500)
phpSecurityMediumStyle = cli.NewStyle().Foreground(cli.ColourAmber500)
phpSecurityLowStyle = cli.NewStyle().Foreground(cli.ColourGray500)
)
// AddPHPCommands adds PHP/Laravel development commands.
func AddPHPCommands(root *cobra.Command) {
phpCmd := &cobra.Command{
Use: "php",
Short: i18n.T("cmd.php.short"),
Long: i18n.T("cmd.php.long"),
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
// Check if we are in a workspace root
wsRoot, err := findWorkspaceRoot()
if err != nil {
return nil // Not in a workspace, regular behavior
}
// Load workspace config
config, err := loadWorkspaceConfig(wsRoot)
if err != nil || config == nil {
return nil // Failed to load or no config, ignore
}
if config.Active == "" {
return nil // No active package
}
// Calculate package path
pkgDir := config.PackagesDir
if pkgDir == "" {
pkgDir = "./packages"
}
if !filepath.IsAbs(pkgDir) {
pkgDir = filepath.Join(wsRoot, pkgDir)
}
targetDir := filepath.Join(pkgDir, config.Active)
// Check if target directory exists
if !getMedium().IsDir(targetDir) {
cli.Warnf("Active package directory not found: %s", targetDir)
return nil
}
// Change working directory
if err := os.Chdir(targetDir); err != nil {
return cli.Err("failed to change directory to active package: %w", err)
}
cli.Print("%s %s\n", dimStyle.Render("Workspace:"), config.Active)
return nil
},
}
root.AddCommand(phpCmd)
// Development
addPHPDevCommand(phpCmd)
addPHPLogsCommand(phpCmd)
addPHPStopCommand(phpCmd)
addPHPStatusCommand(phpCmd)
addPHPSSLCommand(phpCmd)
// Build & Deploy
addPHPBuildCommand(phpCmd)
addPHPServeCommand(phpCmd)
addPHPShellCommand(phpCmd)
// Quality (existing)
addPHPTestCommand(phpCmd)
addPHPFmtCommand(phpCmd)
addPHPStanCommand(phpCmd)
// Quality (new)
addPHPPsalmCommand(phpCmd)
addPHPAuditCommand(phpCmd)
addPHPSecurityCommand(phpCmd)
addPHPQACommand(phpCmd)
addPHPRectorCommand(phpCmd)
addPHPInfectionCommand(phpCmd)
// CI/CD Integration
addPHPCICommand(phpCmd)
// Package Management
addPHPPackagesCommands(phpCmd)
// Deployment
addPHPDeployCommands(phpCmd)
}

291
cmd_build.go Normal file
View file

@ -0,0 +1,291 @@
package php
import (
"context"
"errors"
"os"
"strings"
"forge.lthn.ai/core/go/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n"
"github.com/spf13/cobra"
)
var (
buildType string
buildImageName string
buildTag string
buildPlatform string
buildDockerfile string
buildOutputPath string
buildFormat string
buildTemplate string
buildNoCache bool
)
func addPHPBuildCommand(parent *cobra.Command) {
buildCmd := &cobra.Command{
Use: "build",
Short: i18n.T("cmd.php.build.short"),
Long: i18n.T("cmd.php.build.long"),
RunE: func(cmd *cobra.Command, args []string) error {
cwd, err := os.Getwd()
if err != nil {
return cli.Err("%s: %w", i18n.T("i18n.fail.get", "working directory"), err)
}
ctx := context.Background()
switch strings.ToLower(buildType) {
case "linuxkit":
return runPHPBuildLinuxKit(ctx, cwd, linuxKitBuildOptions{
OutputPath: buildOutputPath,
Format: buildFormat,
Template: buildTemplate,
})
default:
return runPHPBuildDocker(ctx, cwd, dockerBuildOptions{
ImageName: buildImageName,
Tag: buildTag,
Platform: buildPlatform,
Dockerfile: buildDockerfile,
NoCache: buildNoCache,
})
}
},
}
buildCmd.Flags().StringVar(&buildType, "type", "", i18n.T("cmd.php.build.flag.type"))
buildCmd.Flags().StringVar(&buildImageName, "name", "", i18n.T("cmd.php.build.flag.name"))
buildCmd.Flags().StringVar(&buildTag, "tag", "", i18n.T("common.flag.tag"))
buildCmd.Flags().StringVar(&buildPlatform, "platform", "", i18n.T("cmd.php.build.flag.platform"))
buildCmd.Flags().StringVar(&buildDockerfile, "dockerfile", "", i18n.T("cmd.php.build.flag.dockerfile"))
buildCmd.Flags().StringVar(&buildOutputPath, "output", "", i18n.T("cmd.php.build.flag.output"))
buildCmd.Flags().StringVar(&buildFormat, "format", "", i18n.T("cmd.php.build.flag.format"))
buildCmd.Flags().StringVar(&buildTemplate, "template", "", i18n.T("cmd.php.build.flag.template"))
buildCmd.Flags().BoolVar(&buildNoCache, "no-cache", false, i18n.T("cmd.php.build.flag.no_cache"))
parent.AddCommand(buildCmd)
}
type dockerBuildOptions struct {
ImageName string
Tag string
Platform string
Dockerfile string
NoCache bool
}
type linuxKitBuildOptions struct {
OutputPath string
Format string
Template string
}
func runPHPBuildDocker(ctx context.Context, projectDir string, opts dockerBuildOptions) error {
if !IsPHPProject(projectDir) {
return errors.New(i18n.T("cmd.php.error.not_php"))
}
cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.php")), i18n.T("cmd.php.build.building_docker"))
// Show detected configuration
config, err := DetectDockerfileConfig(projectDir)
if err != nil {
return cli.Err("%s: %w", i18n.T("i18n.fail.detect", "project configuration"), err)
}
cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.build.php_version")), config.PHPVersion)
cli.Print("%s %v\n", dimStyle.Render(i18n.T("cmd.php.build.laravel")), config.IsLaravel)
cli.Print("%s %v\n", dimStyle.Render(i18n.T("cmd.php.build.octane")), config.HasOctane)
cli.Print("%s %v\n", dimStyle.Render(i18n.T("cmd.php.build.frontend")), config.HasAssets)
if len(config.PHPExtensions) > 0 {
cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.build.extensions")), strings.Join(config.PHPExtensions, ", "))
}
cli.Blank()
// Build options
buildOpts := DockerBuildOptions{
ProjectDir: projectDir,
ImageName: opts.ImageName,
Tag: opts.Tag,
Platform: opts.Platform,
Dockerfile: opts.Dockerfile,
NoBuildCache: opts.NoCache,
Output: os.Stdout,
}
if buildOpts.ImageName == "" {
buildOpts.ImageName = GetLaravelAppName(projectDir)
if buildOpts.ImageName == "" {
buildOpts.ImageName = "php-app"
}
// Sanitize for Docker
buildOpts.ImageName = strings.ToLower(strings.ReplaceAll(buildOpts.ImageName, " ", "-"))
}
if buildOpts.Tag == "" {
buildOpts.Tag = "latest"
}
cli.Print("%s %s:%s\n", dimStyle.Render(i18n.Label("image")), buildOpts.ImageName, buildOpts.Tag)
if opts.Platform != "" {
cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.build.platform")), opts.Platform)
}
cli.Blank()
if err := BuildDocker(ctx, buildOpts); err != nil {
return cli.Err("%s: %w", i18n.T("i18n.fail.build"), err)
}
cli.Print("\n%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("common.success.completed", map[string]any{"Action": "Docker image built"}))
cli.Print("%s docker run -p 80:80 -p 443:443 %s:%s\n",
dimStyle.Render(i18n.T("cmd.php.build.docker_run_with")),
buildOpts.ImageName, buildOpts.Tag)
return nil
}
func runPHPBuildLinuxKit(ctx context.Context, projectDir string, opts linuxKitBuildOptions) error {
if !IsPHPProject(projectDir) {
return errors.New(i18n.T("cmd.php.error.not_php"))
}
cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.php")), i18n.T("cmd.php.build.building_linuxkit"))
buildOpts := LinuxKitBuildOptions{
ProjectDir: projectDir,
OutputPath: opts.OutputPath,
Format: opts.Format,
Template: opts.Template,
Output: os.Stdout,
}
if buildOpts.Format == "" {
buildOpts.Format = "qcow2"
}
if buildOpts.Template == "" {
buildOpts.Template = "server-php"
}
cli.Print("%s %s\n", dimStyle.Render(i18n.Label("template")), buildOpts.Template)
cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.build.format")), buildOpts.Format)
cli.Blank()
if err := BuildLinuxKit(ctx, buildOpts); err != nil {
return cli.Err("%s: %w", i18n.T("i18n.fail.build"), err)
}
cli.Print("\n%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("common.success.completed", map[string]any{"Action": "LinuxKit image built"}))
return nil
}
var (
serveImageName string
serveTag string
serveContainerName string
servePort int
serveHTTPSPort int
serveDetach bool
serveEnvFile string
)
func addPHPServeCommand(parent *cobra.Command) {
serveCmd := &cobra.Command{
Use: "serve",
Short: i18n.T("cmd.php.serve.short"),
Long: i18n.T("cmd.php.serve.long"),
RunE: func(cmd *cobra.Command, args []string) error {
imageName := serveImageName
if imageName == "" {
// Try to detect from current directory
cwd, err := os.Getwd()
if err == nil {
imageName = GetLaravelAppName(cwd)
if imageName != "" {
imageName = strings.ToLower(strings.ReplaceAll(imageName, " ", "-"))
}
}
if imageName == "" {
return errors.New(i18n.T("cmd.php.serve.name_required"))
}
}
ctx := context.Background()
opts := ServeOptions{
ImageName: imageName,
Tag: serveTag,
ContainerName: serveContainerName,
Port: servePort,
HTTPSPort: serveHTTPSPort,
Detach: serveDetach,
EnvFile: serveEnvFile,
Output: os.Stdout,
}
cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.php")), i18n.ProgressSubject("run", "production container"))
cli.Print("%s %s:%s\n", dimStyle.Render(i18n.Label("image")), imageName, func() string {
if serveTag == "" {
return "latest"
}
return serveTag
}())
effectivePort := servePort
if effectivePort == 0 {
effectivePort = 80
}
effectiveHTTPSPort := serveHTTPSPort
if effectiveHTTPSPort == 0 {
effectiveHTTPSPort = 443
}
cli.Print("%s http://localhost:%d, https://localhost:%d\n",
dimStyle.Render("Ports:"), effectivePort, effectiveHTTPSPort)
cli.Blank()
if err := ServeProduction(ctx, opts); err != nil {
return cli.Err("%s: %w", i18n.T("i18n.fail.start", "container"), err)
}
if !serveDetach {
cli.Print("\n%s %s\n", dimStyle.Render(i18n.T("cmd.php.label.php")), i18n.T("cmd.php.serve.stopped"))
}
return nil
},
}
serveCmd.Flags().StringVar(&serveImageName, "name", "", i18n.T("cmd.php.serve.flag.name"))
serveCmd.Flags().StringVar(&serveTag, "tag", "", i18n.T("common.flag.tag"))
serveCmd.Flags().StringVar(&serveContainerName, "container", "", i18n.T("cmd.php.serve.flag.container"))
serveCmd.Flags().IntVar(&servePort, "port", 0, i18n.T("cmd.php.serve.flag.port"))
serveCmd.Flags().IntVar(&serveHTTPSPort, "https-port", 0, i18n.T("cmd.php.serve.flag.https_port"))
serveCmd.Flags().BoolVarP(&serveDetach, "detach", "d", false, i18n.T("cmd.php.serve.flag.detach"))
serveCmd.Flags().StringVar(&serveEnvFile, "env-file", "", i18n.T("cmd.php.serve.flag.env_file"))
parent.AddCommand(serveCmd)
}
func addPHPShellCommand(parent *cobra.Command) {
shellCmd := &cobra.Command{
Use: "shell [container]",
Short: i18n.T("cmd.php.shell.short"),
Long: i18n.T("cmd.php.shell.long"),
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := context.Background()
cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.label.php")), i18n.T("cmd.php.shell.opening", map[string]interface{}{"Container": args[0]}))
if err := Shell(ctx, args[0]); err != nil {
return cli.Err("%s: %w", i18n.T("i18n.fail.open", "shell"), err)
}
return nil
},
}
parent.AddCommand(shellCmd)
}

562
cmd_ci.go Normal file
View file

@ -0,0 +1,562 @@
// cmd_ci.go implements the 'php ci' command for CI/CD pipeline integration.
//
// Usage:
// core php ci # Run full CI pipeline
// core php ci --json # Output combined JSON report
// core php ci --summary # Output markdown summary
// core php ci --sarif # Generate SARIF files
// core php ci --upload-sarif # Upload SARIF to GitHub Security
// core php ci --fail-on=high # Only fail on high+ severity
package php
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"forge.lthn.ai/core/go/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n"
"github.com/spf13/cobra"
)
// CI command flags
var (
ciJSON bool
ciSummary bool
ciSARIF bool
ciUploadSARIF bool
ciFailOn string
)
// CIResult represents the overall CI pipeline result
type CIResult struct {
Passed bool `json:"passed"`
ExitCode int `json:"exit_code"`
Duration string `json:"duration"`
StartedAt time.Time `json:"started_at"`
Checks []CICheckResult `json:"checks"`
Summary CISummary `json:"summary"`
Artifacts []string `json:"artifacts,omitempty"`
}
// CICheckResult represents an individual check result
type CICheckResult struct {
Name string `json:"name"`
Status string `json:"status"` // passed, failed, warning, skipped
Duration string `json:"duration"`
Details string `json:"details,omitempty"`
Issues int `json:"issues,omitempty"`
Errors int `json:"errors,omitempty"`
Warnings int `json:"warnings,omitempty"`
}
// CISummary contains aggregate statistics
type CISummary struct {
Total int `json:"total"`
Passed int `json:"passed"`
Failed int `json:"failed"`
Warnings int `json:"warnings"`
Skipped int `json:"skipped"`
}
func addPHPCICommand(parent *cobra.Command) {
ciCmd := &cobra.Command{
Use: "ci",
Short: i18n.T("cmd.php.ci.short"),
Long: i18n.T("cmd.php.ci.long"),
RunE: func(cmd *cobra.Command, args []string) error {
return runPHPCI()
},
}
ciCmd.Flags().BoolVar(&ciJSON, "json", false, i18n.T("cmd.php.ci.flag.json"))
ciCmd.Flags().BoolVar(&ciSummary, "summary", false, i18n.T("cmd.php.ci.flag.summary"))
ciCmd.Flags().BoolVar(&ciSARIF, "sarif", false, i18n.T("cmd.php.ci.flag.sarif"))
ciCmd.Flags().BoolVar(&ciUploadSARIF, "upload-sarif", false, i18n.T("cmd.php.ci.flag.upload_sarif"))
ciCmd.Flags().StringVar(&ciFailOn, "fail-on", "error", i18n.T("cmd.php.ci.flag.fail_on"))
parent.AddCommand(ciCmd)
}
func runPHPCI() error {
cwd, err := os.Getwd()
if err != nil {
return cli.Err("%s: %w", i18n.T("i18n.fail.get", "working directory"), err)
}
if !IsPHPProject(cwd) {
return errors.New(i18n.T("cmd.php.error.not_php"))
}
startTime := time.Now()
ctx := context.Background()
// Define checks to run in order
checks := []struct {
name string
run func(context.Context, string) (CICheckResult, error)
sarif bool // Whether this check can generate SARIF
}{
{"test", runCITest, false},
{"stan", runCIStan, true},
{"psalm", runCIPsalm, true},
{"fmt", runCIFmt, false},
{"audit", runCIAudit, false},
{"security", runCISecurity, false},
}
result := CIResult{
StartedAt: startTime,
Passed: true,
Checks: make([]CICheckResult, 0, len(checks)),
}
var artifacts []string
// Print header unless JSON output
if !ciJSON {
cli.Print("\n%s\n", cli.BoldStyle.Render("core php ci - QA Pipeline"))
cli.Print("%s\n\n", strings.Repeat("─", 40))
}
// Run each check
for _, check := range checks {
if !ciJSON {
cli.Print(" %s %s...", dimStyle.Render("→"), check.name)
}
checkResult, err := check.run(ctx, cwd)
if err != nil {
checkResult = CICheckResult{
Name: check.name,
Status: "failed",
Details: err.Error(),
}
}
result.Checks = append(result.Checks, checkResult)
// Update summary
result.Summary.Total++
switch checkResult.Status {
case "passed":
result.Summary.Passed++
case "failed":
result.Summary.Failed++
if shouldFailOn(checkResult, ciFailOn) {
result.Passed = false
}
case "warning":
result.Summary.Warnings++
case "skipped":
result.Summary.Skipped++
}
// Print result
if !ciJSON {
cli.Print("\r %s %s %s\n", getStatusIcon(checkResult.Status), check.name, dimStyle.Render(checkResult.Details))
}
// Generate SARIF if requested
if (ciSARIF || ciUploadSARIF) && check.sarif {
sarifFile := filepath.Join(cwd, check.name+".sarif")
if generateSARIF(ctx, cwd, check.name, sarifFile) == nil {
artifacts = append(artifacts, sarifFile)
}
}
}
result.Duration = time.Since(startTime).Round(time.Millisecond).String()
result.Artifacts = artifacts
// Set exit code
if result.Passed {
result.ExitCode = 0
} else {
result.ExitCode = 1
}
// Output based on flags
if ciJSON {
if err := outputCIJSON(result); err != nil {
return err
}
if !result.Passed {
return cli.Exit(result.ExitCode, cli.Err("CI pipeline failed"))
}
return nil
}
if ciSummary {
if err := outputCISummary(result); err != nil {
return err
}
if !result.Passed {
return cli.Err("CI pipeline failed")
}
return nil
}
// Default table output
cli.Print("\n%s\n", strings.Repeat("─", 40))
if result.Passed {
cli.Print("%s %s\n", successStyle.Render("✓ CI PASSED"), dimStyle.Render(result.Duration))
} else {
cli.Print("%s %s\n", errorStyle.Render("✗ CI FAILED"), dimStyle.Render(result.Duration))
}
if len(artifacts) > 0 {
cli.Print("\n%s\n", dimStyle.Render("Artifacts:"))
for _, a := range artifacts {
cli.Print(" → %s\n", filepath.Base(a))
}
}
// Upload SARIF if requested
if ciUploadSARIF && len(artifacts) > 0 {
cli.Blank()
for _, sarifFile := range artifacts {
if err := uploadSARIFToGitHub(ctx, sarifFile); err != nil {
cli.Print(" %s %s: %s\n", errorStyle.Render("✗"), filepath.Base(sarifFile), err)
} else {
cli.Print(" %s %s uploaded\n", successStyle.Render("✓"), filepath.Base(sarifFile))
}
}
}
if !result.Passed {
return cli.Err("CI pipeline failed")
}
return nil
}
// runCITest runs Pest/PHPUnit tests
func runCITest(ctx context.Context, dir string) (CICheckResult, error) {
start := time.Now()
result := CICheckResult{Name: "test", Status: "passed"}
opts := TestOptions{
Dir: dir,
Output: nil, // Suppress output
}
if err := RunTests(ctx, opts); err != nil {
result.Status = "failed"
result.Details = err.Error()
} else {
result.Details = "all tests passed"
}
result.Duration = time.Since(start).Round(time.Millisecond).String()
return result, nil
}
// runCIStan runs PHPStan
func runCIStan(ctx context.Context, dir string) (CICheckResult, error) {
start := time.Now()
result := CICheckResult{Name: "stan", Status: "passed"}
_, found := DetectAnalyser(dir)
if !found {
result.Status = "skipped"
result.Details = "PHPStan not configured"
return result, nil
}
opts := AnalyseOptions{
Dir: dir,
Output: nil,
}
if err := Analyse(ctx, opts); err != nil {
result.Status = "failed"
result.Details = "errors found"
} else {
result.Details = "0 errors"
}
result.Duration = time.Since(start).Round(time.Millisecond).String()
return result, nil
}
// runCIPsalm runs Psalm
func runCIPsalm(ctx context.Context, dir string) (CICheckResult, error) {
start := time.Now()
result := CICheckResult{Name: "psalm", Status: "passed"}
_, found := DetectPsalm(dir)
if !found {
result.Status = "skipped"
result.Details = "Psalm not configured"
return result, nil
}
opts := PsalmOptions{
Dir: dir,
Output: nil,
}
if err := RunPsalm(ctx, opts); err != nil {
result.Status = "failed"
result.Details = "errors found"
} else {
result.Details = "0 errors"
}
result.Duration = time.Since(start).Round(time.Millisecond).String()
return result, nil
}
// runCIFmt checks code formatting
func runCIFmt(ctx context.Context, dir string) (CICheckResult, error) {
start := time.Now()
result := CICheckResult{Name: "fmt", Status: "passed"}
_, found := DetectFormatter(dir)
if !found {
result.Status = "skipped"
result.Details = "no formatter configured"
return result, nil
}
opts := FormatOptions{
Dir: dir,
Fix: false, // Check only
Output: nil,
}
if err := Format(ctx, opts); err != nil {
result.Status = "warning"
result.Details = "formatting issues"
} else {
result.Details = "code style OK"
}
result.Duration = time.Since(start).Round(time.Millisecond).String()
return result, nil
}
// runCIAudit runs composer audit
func runCIAudit(ctx context.Context, dir string) (CICheckResult, error) {
start := time.Now()
result := CICheckResult{Name: "audit", Status: "passed"}
results, err := RunAudit(ctx, AuditOptions{
Dir: dir,
Output: nil,
})
if err != nil {
result.Status = "failed"
result.Details = err.Error()
result.Duration = time.Since(start).Round(time.Millisecond).String()
return result, nil
}
totalVulns := 0
for _, r := range results {
totalVulns += r.Vulnerabilities
}
if totalVulns > 0 {
result.Status = "failed"
result.Details = fmt.Sprintf("%d vulnerabilities", totalVulns)
result.Issues = totalVulns
} else {
result.Details = "no vulnerabilities"
}
result.Duration = time.Since(start).Round(time.Millisecond).String()
return result, nil
}
// runCISecurity runs security checks
func runCISecurity(ctx context.Context, dir string) (CICheckResult, error) {
start := time.Now()
result := CICheckResult{Name: "security", Status: "passed"}
secResult, err := RunSecurityChecks(ctx, SecurityOptions{
Dir: dir,
Output: nil,
})
if err != nil {
result.Status = "failed"
result.Details = err.Error()
result.Duration = time.Since(start).Round(time.Millisecond).String()
return result, nil
}
if secResult.Summary.Critical > 0 || secResult.Summary.High > 0 {
result.Status = "failed"
result.Details = fmt.Sprintf("%d critical, %d high", secResult.Summary.Critical, secResult.Summary.High)
result.Issues = secResult.Summary.Critical + secResult.Summary.High
} else if secResult.Summary.Medium > 0 {
result.Status = "warning"
result.Details = fmt.Sprintf("%d medium issues", secResult.Summary.Medium)
result.Warnings = secResult.Summary.Medium
} else {
result.Details = "no issues"
}
result.Duration = time.Since(start).Round(time.Millisecond).String()
return result, nil
}
// shouldFailOn determines if a check should cause CI failure based on --fail-on
func shouldFailOn(check CICheckResult, level string) bool {
switch level {
case "critical":
return check.Status == "failed" && check.Issues > 0
case "high", "error":
return check.Status == "failed"
case "warning":
return check.Status == "failed" || check.Status == "warning"
default:
return check.Status == "failed"
}
}
// getStatusIcon returns the icon for a check status
func getStatusIcon(status string) string {
switch status {
case "passed":
return successStyle.Render("✓")
case "failed":
return errorStyle.Render("✗")
case "warning":
return phpQAWarningStyle.Render("⚠")
case "skipped":
return dimStyle.Render("-")
default:
return dimStyle.Render("?")
}
}
// outputCIJSON outputs the result as JSON
func outputCIJSON(result CIResult) error {
data, err := json.MarshalIndent(result, "", " ")
if err != nil {
return err
}
fmt.Println(string(data))
return nil
}
// outputCISummary outputs a markdown summary
func outputCISummary(result CIResult) error {
var sb strings.Builder
sb.WriteString("## CI Pipeline Results\n\n")
if result.Passed {
sb.WriteString("**Status:** ✅ Passed\n\n")
} else {
sb.WriteString("**Status:** ❌ Failed\n\n")
}
sb.WriteString("| Check | Status | Details |\n")
sb.WriteString("|-------|--------|----------|\n")
for _, check := range result.Checks {
icon := "✅"
switch check.Status {
case "failed":
icon = "❌"
case "warning":
icon = "⚠️"
case "skipped":
icon = "⏭️"
}
sb.WriteString(fmt.Sprintf("| %s | %s | %s |\n", check.Name, icon, check.Details))
}
sb.WriteString(fmt.Sprintf("\n**Duration:** %s\n", result.Duration))
fmt.Print(sb.String())
return nil
}
// generateSARIF generates a SARIF file for a specific check
func generateSARIF(ctx context.Context, dir, checkName, outputFile string) error {
var args []string
switch checkName {
case "stan":
args = []string{"vendor/bin/phpstan", "analyse", "--error-format=sarif", "--no-progress"}
case "psalm":
args = []string{"vendor/bin/psalm", "--output-format=sarif"}
default:
return fmt.Errorf("SARIF not supported for %s", checkName)
}
cmd := exec.CommandContext(ctx, "php", args...)
cmd.Dir = dir
// Capture output - command may exit non-zero when issues are found
// but still produce valid SARIF output
output, err := cmd.CombinedOutput()
if len(output) == 0 {
if err != nil {
return fmt.Errorf("failed to generate SARIF: %w", err)
}
return fmt.Errorf("no SARIF output generated")
}
// Validate output is valid JSON
var js json.RawMessage
if err := json.Unmarshal(output, &js); err != nil {
return fmt.Errorf("invalid SARIF output: %w", err)
}
return getMedium().Write(outputFile, string(output))
}
// uploadSARIFToGitHub uploads a SARIF file to GitHub Security tab
func uploadSARIFToGitHub(ctx context.Context, sarifFile string) error {
// Validate commit SHA before calling API
sha := getGitSHA()
if sha == "" {
return errors.New("cannot upload SARIF: git commit SHA not available (ensure you're in a git repository)")
}
// Use gh CLI to upload
cmd := exec.CommandContext(ctx, "gh", "api",
"repos/{owner}/{repo}/code-scanning/sarifs",
"-X", "POST",
"-F", "sarif=@"+sarifFile,
"-F", "ref="+getGitRef(),
"-F", "commit_sha="+sha,
)
if output, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("%s: %s", err, string(output))
}
return nil
}
// getGitRef returns the current git ref
func getGitRef() string {
cmd := exec.Command("git", "symbolic-ref", "HEAD")
output, err := cmd.Output()
if err != nil {
return "refs/heads/main"
}
return strings.TrimSpace(string(output))
}
// getGitSHA returns the current git commit SHA
func getGitSHA() string {
cmd := exec.Command("git", "rev-parse", "HEAD")
output, err := cmd.Output()
if err != nil {
return ""
}
return strings.TrimSpace(string(output))
}

41
cmd_commands.go Normal file
View file

@ -0,0 +1,41 @@
// Package php provides Laravel/PHP development and deployment commands.
//
// Development Commands:
// - dev: Start Laravel environment (FrankenPHP, Vite, Horizon, Reverb, Redis)
// - logs: Stream unified service logs
// - stop: Stop all running services
// - status: Show service status
// - ssl: Setup SSL certificates with mkcert
//
// Build Commands:
// - build: Build Docker or LinuxKit image
// - serve: Run production container
// - shell: Open shell in running container
//
// Code Quality:
// - test: Run PHPUnit/Pest tests
// - fmt: Format code with Laravel Pint
// - stan: Run PHPStan/Larastan static analysis
// - psalm: Run Psalm static analysis
// - audit: Security audit for dependencies
// - security: Security vulnerability scanning
// - qa: Run full QA pipeline
// - rector: Automated code refactoring
// - infection: Mutation testing for test quality
//
// Package Management:
// - packages link/unlink/update/list: Manage local Composer packages
//
// Deployment (Coolify):
// - deploy: Deploy to Coolify
// - deploy:status: Check deployment status
// - deploy:rollback: Rollback deployment
// - deploy:list: List recent deployments
package php
import "github.com/spf13/cobra"
// AddCommands registers the 'php' command and all subcommands.
func AddCommands(root *cobra.Command) {
AddPHPCommands(root)
}

361
cmd_deploy.go Normal file
View file

@ -0,0 +1,361 @@
package php
import (
"context"
"os"
"time"
"forge.lthn.ai/core/go/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n"
"github.com/spf13/cobra"
)
// Deploy command styles (aliases to shared)
var (
phpDeployStyle = cli.SuccessStyle
phpDeployPendingStyle = cli.WarningStyle
phpDeployFailedStyle = cli.ErrorStyle
)
func addPHPDeployCommands(parent *cobra.Command) {
// Main deploy command
addPHPDeployCommand(parent)
// Deploy status subcommand (using colon notation: deploy:status)
addPHPDeployStatusCommand(parent)
// Deploy rollback subcommand
addPHPDeployRollbackCommand(parent)
// Deploy list subcommand
addPHPDeployListCommand(parent)
}
var (
deployStaging bool
deployForce bool
deployWait bool
)
func addPHPDeployCommand(parent *cobra.Command) {
deployCmd := &cobra.Command{
Use: "deploy",
Short: i18n.T("cmd.php.deploy.short"),
Long: i18n.T("cmd.php.deploy.long"),
RunE: func(cmd *cobra.Command, args []string) error {
cwd, err := os.Getwd()
if err != nil {
return cli.Err("%s: %w", i18n.T("i18n.fail.get", "working directory"), err)
}
env := EnvProduction
if deployStaging {
env = EnvStaging
}
cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.deploy")), i18n.T("cmd.php.deploy.deploying", map[string]interface{}{"Environment": env}))
ctx := context.Background()
opts := DeployOptions{
Dir: cwd,
Environment: env,
Force: deployForce,
Wait: deployWait,
}
status, err := Deploy(ctx, opts)
if err != nil {
return cli.Err("%s: %w", i18n.T("cmd.php.error.deploy_failed"), err)
}
printDeploymentStatus(status)
if deployWait {
if IsDeploymentSuccessful(status.Status) {
cli.Print("\n%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("common.success.completed", map[string]any{"Action": "Deployment completed"}))
} else {
cli.Print("\n%s %s\n", errorStyle.Render(i18n.Label("warning")), i18n.T("cmd.php.deploy.warning_status", map[string]interface{}{"Status": status.Status}))
}
} else {
cli.Print("\n%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("cmd.php.deploy.triggered"))
}
return nil
},
}
deployCmd.Flags().BoolVar(&deployStaging, "staging", false, i18n.T("cmd.php.deploy.flag.staging"))
deployCmd.Flags().BoolVar(&deployForce, "force", false, i18n.T("cmd.php.deploy.flag.force"))
deployCmd.Flags().BoolVar(&deployWait, "wait", false, i18n.T("cmd.php.deploy.flag.wait"))
parent.AddCommand(deployCmd)
}
var (
deployStatusStaging bool
deployStatusDeploymentID string
)
func addPHPDeployStatusCommand(parent *cobra.Command) {
statusCmd := &cobra.Command{
Use: "deploy:status",
Short: i18n.T("cmd.php.deploy_status.short"),
Long: i18n.T("cmd.php.deploy_status.long"),
RunE: func(cmd *cobra.Command, args []string) error {
cwd, err := os.Getwd()
if err != nil {
return cli.Err("%s: %w", i18n.T("i18n.fail.get", "working directory"), err)
}
env := EnvProduction
if deployStatusStaging {
env = EnvStaging
}
cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.deploy")), i18n.ProgressSubject("check", "deployment status"))
ctx := context.Background()
opts := StatusOptions{
Dir: cwd,
Environment: env,
DeploymentID: deployStatusDeploymentID,
}
status, err := DeployStatus(ctx, opts)
if err != nil {
return cli.Err("%s: %w", i18n.T("i18n.fail.get", "status"), err)
}
printDeploymentStatus(status)
return nil
},
}
statusCmd.Flags().BoolVar(&deployStatusStaging, "staging", false, i18n.T("cmd.php.deploy_status.flag.staging"))
statusCmd.Flags().StringVar(&deployStatusDeploymentID, "id", "", i18n.T("cmd.php.deploy_status.flag.id"))
parent.AddCommand(statusCmd)
}
var (
rollbackStaging bool
rollbackDeploymentID string
rollbackWait bool
)
func addPHPDeployRollbackCommand(parent *cobra.Command) {
rollbackCmd := &cobra.Command{
Use: "deploy:rollback",
Short: i18n.T("cmd.php.deploy_rollback.short"),
Long: i18n.T("cmd.php.deploy_rollback.long"),
RunE: func(cmd *cobra.Command, args []string) error {
cwd, err := os.Getwd()
if err != nil {
return cli.Err("%s: %w", i18n.T("i18n.fail.get", "working directory"), err)
}
env := EnvProduction
if rollbackStaging {
env = EnvStaging
}
cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.deploy")), i18n.T("cmd.php.deploy_rollback.rolling_back", map[string]interface{}{"Environment": env}))
ctx := context.Background()
opts := RollbackOptions{
Dir: cwd,
Environment: env,
DeploymentID: rollbackDeploymentID,
Wait: rollbackWait,
}
status, err := Rollback(ctx, opts)
if err != nil {
return cli.Err("%s: %w", i18n.T("cmd.php.error.rollback_failed"), err)
}
printDeploymentStatus(status)
if rollbackWait {
if IsDeploymentSuccessful(status.Status) {
cli.Print("\n%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("common.success.completed", map[string]any{"Action": "Rollback completed"}))
} else {
cli.Print("\n%s %s\n", errorStyle.Render(i18n.Label("warning")), i18n.T("cmd.php.deploy_rollback.warning_status", map[string]interface{}{"Status": status.Status}))
}
} else {
cli.Print("\n%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("cmd.php.deploy_rollback.triggered"))
}
return nil
},
}
rollbackCmd.Flags().BoolVar(&rollbackStaging, "staging", false, i18n.T("cmd.php.deploy_rollback.flag.staging"))
rollbackCmd.Flags().StringVar(&rollbackDeploymentID, "id", "", i18n.T("cmd.php.deploy_rollback.flag.id"))
rollbackCmd.Flags().BoolVar(&rollbackWait, "wait", false, i18n.T("cmd.php.deploy_rollback.flag.wait"))
parent.AddCommand(rollbackCmd)
}
var (
deployListStaging bool
deployListLimit int
)
func addPHPDeployListCommand(parent *cobra.Command) {
listCmd := &cobra.Command{
Use: "deploy:list",
Short: i18n.T("cmd.php.deploy_list.short"),
Long: i18n.T("cmd.php.deploy_list.long"),
RunE: func(cmd *cobra.Command, args []string) error {
cwd, err := os.Getwd()
if err != nil {
return cli.Err("%s: %w", i18n.T("i18n.fail.get", "working directory"), err)
}
env := EnvProduction
if deployListStaging {
env = EnvStaging
}
limit := deployListLimit
if limit == 0 {
limit = 10
}
cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.deploy")), i18n.T("cmd.php.deploy_list.recent", map[string]interface{}{"Environment": env}))
ctx := context.Background()
deployments, err := ListDeployments(ctx, cwd, env, limit)
if err != nil {
return cli.Err("%s: %w", i18n.T("i18n.fail.list", "deployments"), err)
}
if len(deployments) == 0 {
cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.label.info")), i18n.T("cmd.php.deploy_list.none_found"))
return nil
}
for i, d := range deployments {
printDeploymentSummary(i+1, &d)
}
return nil
},
}
listCmd.Flags().BoolVar(&deployListStaging, "staging", false, i18n.T("cmd.php.deploy_list.flag.staging"))
listCmd.Flags().IntVar(&deployListLimit, "limit", 0, i18n.T("cmd.php.deploy_list.flag.limit"))
parent.AddCommand(listCmd)
}
func printDeploymentStatus(status *DeploymentStatus) {
// Status with color
statusStyle := phpDeployStyle
switch status.Status {
case "queued", "building", "deploying", "pending", "rolling_back":
statusStyle = phpDeployPendingStyle
case "failed", "error", "cancelled":
statusStyle = phpDeployFailedStyle
}
cli.Print("%s %s\n", dimStyle.Render(i18n.Label("status")), statusStyle.Render(status.Status))
if status.ID != "" {
cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.label.id")), status.ID)
}
if status.URL != "" {
cli.Print("%s %s\n", dimStyle.Render(i18n.Label("url")), linkStyle.Render(status.URL))
}
if status.Branch != "" {
cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.label.branch")), status.Branch)
}
if status.Commit != "" {
commit := status.Commit
if len(commit) > 7 {
commit = commit[:7]
}
cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.label.commit")), commit)
if status.CommitMessage != "" {
// Truncate long messages
msg := status.CommitMessage
if len(msg) > 60 {
msg = msg[:57] + "..."
}
cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.label.message")), msg)
}
}
if !status.StartedAt.IsZero() {
cli.Print("%s %s\n", dimStyle.Render(i18n.Label("started")), status.StartedAt.Format(time.RFC3339))
}
if !status.CompletedAt.IsZero() {
cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.label.completed")), status.CompletedAt.Format(time.RFC3339))
if !status.StartedAt.IsZero() {
duration := status.CompletedAt.Sub(status.StartedAt)
cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.label.duration")), duration.Round(time.Second))
}
}
}
func printDeploymentSummary(index int, status *DeploymentStatus) {
// Status with color
statusStyle := phpDeployStyle
switch status.Status {
case "queued", "building", "deploying", "pending", "rolling_back":
statusStyle = phpDeployPendingStyle
case "failed", "error", "cancelled":
statusStyle = phpDeployFailedStyle
}
// Format: #1 [finished] abc1234 - commit message (2 hours ago)
id := status.ID
if len(id) > 8 {
id = id[:8]
}
commit := status.Commit
if len(commit) > 7 {
commit = commit[:7]
}
msg := status.CommitMessage
if len(msg) > 40 {
msg = msg[:37] + "..."
}
age := ""
if !status.StartedAt.IsZero() {
age = i18n.TimeAgo(status.StartedAt)
}
cli.Print(" %s %s %s",
dimStyle.Render(cli.Sprintf("#%d", index)),
statusStyle.Render(cli.Sprintf("[%s]", status.Status)),
id,
)
if commit != "" {
cli.Print(" %s", commit)
}
if msg != "" {
cli.Print(" - %s", msg)
}
if age != "" {
cli.Print(" %s", dimStyle.Render(cli.Sprintf("(%s)", age)))
}
cli.Blank()
}

497
cmd_dev.go Normal file
View file

@ -0,0 +1,497 @@
package php
import (
"bufio"
"context"
"errors"
"os"
"os/signal"
"strings"
"syscall"
"time"
"forge.lthn.ai/core/go/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n"
"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: i18n.T("cmd.php.dev.short"),
Long: i18n.T("cmd.php.dev.long"),
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, i18n.T("cmd.php.dev.flag.no_vite"))
devCmd.Flags().BoolVar(&devNoHorizon, "no-horizon", false, i18n.T("cmd.php.dev.flag.no_horizon"))
devCmd.Flags().BoolVar(&devNoReverb, "no-reverb", false, i18n.T("cmd.php.dev.flag.no_reverb"))
devCmd.Flags().BoolVar(&devNoRedis, "no-redis", false, i18n.T("cmd.php.dev.flag.no_redis"))
devCmd.Flags().BoolVar(&devHTTPS, "https", false, i18n.T("cmd.php.dev.flag.https"))
devCmd.Flags().StringVar(&devDomain, "domain", "", i18n.T("cmd.php.dev.flag.domain"))
devCmd.Flags().IntVar(&devPort, "port", 0, i18n.T("cmd.php.dev.flag.port"))
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 cli.Err("failed to get working directory: %w", err)
}
// Check if this is a Laravel project
if !IsLaravelProject(cwd) {
return errors.New(i18n.T("cmd.php.error.not_laravel"))
}
// Get app name for display
appName := GetLaravelAppName(cwd)
if appName == "" {
appName = "Laravel"
}
cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.php")), i18n.T("cmd.php.dev.starting", map[string]interface{}{"AppName": appName}))
// Detect services
services := DetectServices(cwd)
cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.label.services")), i18n.T("cmd.php.dev.detected_services"))
for _, svc := range services {
cli.Print(" %s %s\n", successStyle.Render("*"), svc)
}
cli.Blank()
// Setup options
port := opts.Port
if port == 0 {
port = 8000
}
devOpts := 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 := 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
cli.Print("\n%s %s\n", dimStyle.Render(i18n.T("cmd.php.label.php")), i18n.T("cmd.php.dev.shutting_down"))
cancel()
}()
if err := server.Start(ctx, devOpts); err != nil {
return cli.Err("%s: %w", i18n.T("i18n.fail.start", "services"), err)
}
// Print status
cli.Print("%s %s\n", successStyle.Render(i18n.T("cmd.php.label.running")), i18n.T("cmd.php.dev.services_started"))
printServiceStatuses(server.Status())
cli.Blank()
// Print URLs
appURL := GetLaravelAppURL(cwd)
if appURL == "" {
if opts.HTTPS {
appURL = cli.Sprintf("https://localhost:%d", port)
} else {
appURL = cli.Sprintf("http://localhost:%d", port)
}
}
cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.label.app_url")), linkStyle.Render(appURL))
// Check for Vite
if !opts.NoVite && containsService(services, ServiceVite) {
cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.label.vite")), linkStyle.Render("http://localhost:5173"))
}
cli.Print("\n%s\n\n", dimStyle.Render(i18n.T("cmd.php.dev.press_ctrl_c")))
// Stream unified logs
logsReader, err := server.Logs("", true)
if err != nil {
cli.Print("%s %s\n", errorStyle.Render(i18n.Label("warning")), i18n.T("i18n.fail.get", "logs"))
} else {
defer func() { _ = 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 {
cli.Print("%s %s\n", errorStyle.Render(i18n.Label("error")), i18n.T("cmd.php.dev.stop_error", map[string]interface{}{"Error": err}))
}
cli.Print("%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("cmd.php.dev.all_stopped"))
return nil
}
var (
logsFollow bool
logsService string
)
func addPHPLogsCommand(parent *cobra.Command) {
logsCmd := &cobra.Command{
Use: "logs",
Short: i18n.T("cmd.php.logs.short"),
Long: i18n.T("cmd.php.logs.long"),
RunE: func(cmd *cobra.Command, args []string) error {
return runPHPLogs(logsService, logsFollow)
},
}
logsCmd.Flags().BoolVar(&logsFollow, "follow", false, i18n.T("common.flag.follow"))
logsCmd.Flags().StringVar(&logsService, "service", "", i18n.T("cmd.php.logs.flag.service"))
parent.AddCommand(logsCmd)
}
func runPHPLogs(service string, follow bool) error {
cwd, err := os.Getwd()
if err != nil {
return err
}
if !IsLaravelProject(cwd) {
return errors.New(i18n.T("cmd.php.error.not_laravel_short"))
}
// Create a minimal server just to access logs
server := NewDevServer(Options{Dir: cwd})
logsReader, err := server.Logs(service, follow)
if err != nil {
return cli.Err("%s: %w", i18n.T("i18n.fail.get", "logs"), err)
}
defer func() { _ = 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: i18n.T("cmd.php.stop.short"),
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
}
cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.label.php")), i18n.T("cmd.php.stop.stopping"))
// We need to find running processes
// This is a simplified version - in practice you'd want to track PIDs
server := NewDevServer(Options{Dir: cwd})
if err := server.Stop(); err != nil {
return cli.Err("%s: %w", i18n.T("i18n.fail.stop", "services"), err)
}
cli.Print("%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("cmd.php.dev.all_stopped"))
return nil
}
func addPHPStatusCommand(parent *cobra.Command) {
statusCmd := &cobra.Command{
Use: "status",
Short: i18n.T("cmd.php.status.short"),
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 !IsLaravelProject(cwd) {
return errors.New(i18n.T("cmd.php.error.not_laravel_short"))
}
appName := GetLaravelAppName(cwd)
if appName == "" {
appName = "Laravel"
}
cli.Print("%s %s\n\n", dimStyle.Render(i18n.Label("project")), appName)
// Detect available services
services := DetectServices(cwd)
cli.Print("%s\n", dimStyle.Render(i18n.T("cmd.php.status.detected_services")))
for _, svc := range services {
style := getServiceStyle(string(svc))
cli.Print(" %s %s\n", style.Render("*"), svc)
}
cli.Blank()
// Package manager
pm := DetectPackageManager(cwd)
cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.status.package_manager")), pm)
// FrankenPHP status
if IsFrankenPHPProject(cwd) {
cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.status.octane_server")), "FrankenPHP")
}
// SSL status
appURL := GetLaravelAppURL(cwd)
if appURL != "" {
domain := ExtractDomainFromURL(appURL)
if CertsExist(domain, SSLOptions{}) {
cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.status.ssl_certs")), successStyle.Render(i18n.T("cmd.php.status.ssl_installed")))
} else {
cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.status.ssl_certs")), dimStyle.Render(i18n.T("cmd.php.status.ssl_not_setup")))
}
}
return nil
}
var sslDomain string
func addPHPSSLCommand(parent *cobra.Command) {
sslCmd := &cobra.Command{
Use: "ssl",
Short: i18n.T("cmd.php.ssl.short"),
RunE: func(cmd *cobra.Command, args []string) error {
return runPHPSSL(sslDomain)
},
}
sslCmd.Flags().StringVar(&sslDomain, "domain", "", i18n.T("cmd.php.ssl.flag.domain"))
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 := GetLaravelAppURL(cwd)
if appURL != "" {
domain = ExtractDomainFromURL(appURL)
}
}
if domain == "" {
domain = "localhost"
}
// Check if mkcert is installed
if !IsMkcertInstalled() {
cli.Print("%s %s\n", errorStyle.Render(i18n.Label("error")), i18n.T("cmd.php.ssl.mkcert_not_installed"))
cli.Print("\n%s\n", i18n.T("common.hint.install_with"))
cli.Print(" %s\n", i18n.T("cmd.php.ssl.install_macos"))
cli.Print(" %s\n", i18n.T("cmd.php.ssl.install_linux"))
return errors.New(i18n.T("cmd.php.error.mkcert_not_installed"))
}
cli.Print("%s %s\n", dimStyle.Render("SSL:"), i18n.T("cmd.php.ssl.setting_up", map[string]interface{}{"Domain": domain}))
// Check if certs already exist
if CertsExist(domain, SSLOptions{}) {
cli.Print("%s %s\n", dimStyle.Render(i18n.Label("skip")), i18n.T("cmd.php.ssl.certs_exist"))
certFile, keyFile, _ := CertPaths(domain, SSLOptions{})
cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.ssl.cert_label")), certFile)
cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.ssl.key_label")), keyFile)
return nil
}
// Setup SSL
if err := SetupSSL(domain, SSLOptions{}); err != nil {
return cli.Err("%s: %w", i18n.T("i18n.fail.setup", "SSL"), err)
}
certFile, keyFile, _ := CertPaths(domain, SSLOptions{})
cli.Print("%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("cmd.php.ssl.certs_created"))
cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.ssl.cert_label")), certFile)
cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.ssl.key_label")), keyFile)
return nil
}
// Helper functions for dev commands
func printServiceStatuses(statuses []ServiceStatus) {
for _, s := range statuses {
style := getServiceStyle(s.Name)
var statusText string
if s.Error != nil {
statusText = phpStatusError.Render(i18n.T("cmd.php.status.error", map[string]interface{}{"Error": s.Error}))
} else if s.Running {
statusText = phpStatusRunning.Render(i18n.T("cmd.php.status.running"))
if s.Port > 0 {
statusText += dimStyle.Render(cli.Sprintf(" (%s)", i18n.T("cmd.php.status.port", map[string]interface{}{"Port": s.Port})))
}
if s.PID > 0 {
statusText += dimStyle.Render(cli.Sprintf(" [%s]", i18n.T("cmd.php.status.pid", map[string]interface{}{"PID": s.PID})))
}
} else {
statusText = phpStatusStopped.Render(i18n.T("cmd.php.status.stopped"))
}
cli.Print(" %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 *cli.AnsiStyle
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
cli.Print("%s %s\n", dimStyle.Render(timestamp), line)
return
}
cli.Print("%s %s %s\n",
dimStyle.Render(timestamp),
style.Render(cli.Sprintf("[%s]", serviceName)),
line,
)
}
func getServiceStyle(name string) *cli.AnsiStyle {
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 []DetectedService, target DetectedService) bool {
for _, s := range services {
if s == target {
return true
}
}
return false
}

146
cmd_packages.go Normal file
View file

@ -0,0 +1,146 @@
package php
import (
"os"
"forge.lthn.ai/core/go/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n"
"github.com/spf13/cobra"
)
func addPHPPackagesCommands(parent *cobra.Command) {
packagesCmd := &cobra.Command{
Use: "packages",
Short: i18n.T("cmd.php.packages.short"),
Long: i18n.T("cmd.php.packages.long"),
}
parent.AddCommand(packagesCmd)
addPHPPackagesLinkCommand(packagesCmd)
addPHPPackagesUnlinkCommand(packagesCmd)
addPHPPackagesUpdateCommand(packagesCmd)
addPHPPackagesListCommand(packagesCmd)
}
func addPHPPackagesLinkCommand(parent *cobra.Command) {
linkCmd := &cobra.Command{
Use: "link [paths...]",
Short: i18n.T("cmd.php.packages.link.short"),
Long: i18n.T("cmd.php.packages.link.long"),
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
cwd, err := os.Getwd()
if err != nil {
return cli.Err("%s: %w", i18n.T("i18n.fail.get", "working directory"), err)
}
cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.php")), i18n.T("cmd.php.packages.link.linking"))
if err := LinkPackages(cwd, args); err != nil {
return cli.Err("%s: %w", i18n.T("i18n.fail.link", "packages"), err)
}
cli.Print("\n%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("cmd.php.packages.link.done"))
return nil
},
}
parent.AddCommand(linkCmd)
}
func addPHPPackagesUnlinkCommand(parent *cobra.Command) {
unlinkCmd := &cobra.Command{
Use: "unlink [packages...]",
Short: i18n.T("cmd.php.packages.unlink.short"),
Long: i18n.T("cmd.php.packages.unlink.long"),
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
cwd, err := os.Getwd()
if err != nil {
return cli.Err("%s: %w", i18n.T("i18n.fail.get", "working directory"), err)
}
cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.php")), i18n.T("cmd.php.packages.unlink.unlinking"))
if err := UnlinkPackages(cwd, args); err != nil {
return cli.Err("%s: %w", i18n.T("i18n.fail.unlink", "packages"), err)
}
cli.Print("\n%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("cmd.php.packages.unlink.done"))
return nil
},
}
parent.AddCommand(unlinkCmd)
}
func addPHPPackagesUpdateCommand(parent *cobra.Command) {
updateCmd := &cobra.Command{
Use: "update [packages...]",
Short: i18n.T("cmd.php.packages.update.short"),
Long: i18n.T("cmd.php.packages.update.long"),
RunE: func(cmd *cobra.Command, args []string) error {
cwd, err := os.Getwd()
if err != nil {
return cli.Err("%s: %w", i18n.T("i18n.fail.get", "working directory"), err)
}
cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.php")), i18n.T("cmd.php.packages.update.updating"))
if err := UpdatePackages(cwd, args); err != nil {
return cli.Err("%s: %w", i18n.T("cmd.php.error.update_packages"), err)
}
cli.Print("\n%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("cmd.php.packages.update.done"))
return nil
},
}
parent.AddCommand(updateCmd)
}
func addPHPPackagesListCommand(parent *cobra.Command) {
listCmd := &cobra.Command{
Use: "list",
Short: i18n.T("cmd.php.packages.list.short"),
Long: i18n.T("cmd.php.packages.list.long"),
RunE: func(cmd *cobra.Command, args []string) error {
cwd, err := os.Getwd()
if err != nil {
return cli.Err("%s: %w", i18n.T("i18n.fail.get", "working directory"), err)
}
packages, err := ListLinkedPackages(cwd)
if err != nil {
return cli.Err("%s: %w", i18n.T("i18n.fail.list", "packages"), err)
}
if len(packages) == 0 {
cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.label.php")), i18n.T("cmd.php.packages.list.none_found"))
return nil
}
cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.php")), i18n.T("cmd.php.packages.list.linked"))
for _, pkg := range packages {
name := pkg.Name
if name == "" {
name = i18n.T("cmd.php.packages.list.unknown")
}
version := pkg.Version
if version == "" {
version = "dev"
}
cli.Print(" %s %s\n", successStyle.Render("*"), name)
cli.Print(" %s %s\n", dimStyle.Render(i18n.Label("path")), pkg.Path)
cli.Print(" %s %s\n", dimStyle.Render(i18n.Label("version")), version)
cli.Blank()
}
return nil
},
}
parent.AddCommand(listCmd)
}

343
cmd_qa_runner.go Normal file
View file

@ -0,0 +1,343 @@
package php
import (
"context"
"path/filepath"
"strings"
"sync"
"forge.lthn.ai/core/go/pkg/cli"
"forge.lthn.ai/core/go/pkg/framework"
"forge.lthn.ai/core/go/pkg/i18n"
"forge.lthn.ai/core/go/pkg/process"
)
// QARunner orchestrates PHP QA checks using pkg/process.
type QARunner struct {
dir string
fix bool
service *process.Service
core *framework.Core
// Output tracking
outputMu sync.Mutex
checkOutputs map[string][]string
}
// NewQARunner creates a QA runner for the given directory.
func NewQARunner(dir string, fix bool) (*QARunner, error) {
// Create a Core with process service for the QA session
core, err := framework.New(
framework.WithName("process", process.NewService(process.Options{})),
)
if err != nil {
return nil, cli.WrapVerb(err, "create", "process service")
}
svc, err := framework.ServiceFor[*process.Service](core, "process")
if err != nil {
return nil, cli.WrapVerb(err, "get", "process service")
}
runner := &QARunner{
dir: dir,
fix: fix,
service: svc,
core: core,
checkOutputs: make(map[string][]string),
}
return runner, nil
}
// BuildSpecs creates RunSpecs for the given QA checks.
func (r *QARunner) BuildSpecs(checks []string) []process.RunSpec {
specs := make([]process.RunSpec, 0, len(checks))
for _, check := range checks {
spec := r.buildSpec(check)
if spec != nil {
specs = append(specs, *spec)
}
}
return specs
}
// buildSpec creates a RunSpec for a single check.
func (r *QARunner) buildSpec(check string) *process.RunSpec {
switch check {
case "audit":
return &process.RunSpec{
Name: "audit",
Command: "composer",
Args: []string{"audit", "--format=summary"},
Dir: r.dir,
}
case "fmt":
m := getMedium()
formatter, found := DetectFormatter(r.dir)
if !found {
return nil
}
if formatter == FormatterPint {
vendorBin := filepath.Join(r.dir, "vendor", "bin", "pint")
cmd := "pint"
if m.IsFile(vendorBin) {
cmd = vendorBin
}
args := []string{}
if !r.fix {
args = append(args, "--test")
}
return &process.RunSpec{
Name: "fmt",
Command: cmd,
Args: args,
Dir: r.dir,
After: []string{"audit"},
}
}
return nil
case "stan":
m := getMedium()
_, found := DetectAnalyser(r.dir)
if !found {
return nil
}
vendorBin := filepath.Join(r.dir, "vendor", "bin", "phpstan")
cmd := "phpstan"
if m.IsFile(vendorBin) {
cmd = vendorBin
}
return &process.RunSpec{
Name: "stan",
Command: cmd,
Args: []string{"analyse", "--no-progress"},
Dir: r.dir,
After: []string{"fmt"},
}
case "psalm":
m := getMedium()
_, found := DetectPsalm(r.dir)
if !found {
return nil
}
vendorBin := filepath.Join(r.dir, "vendor", "bin", "psalm")
cmd := "psalm"
if m.IsFile(vendorBin) {
cmd = vendorBin
}
args := []string{"--no-progress"}
if r.fix {
args = append(args, "--alter", "--issues=all")
}
return &process.RunSpec{
Name: "psalm",
Command: cmd,
Args: args,
Dir: r.dir,
After: []string{"stan"},
}
case "test":
m := getMedium()
// Check for Pest first, fall back to PHPUnit
pestBin := filepath.Join(r.dir, "vendor", "bin", "pest")
phpunitBin := filepath.Join(r.dir, "vendor", "bin", "phpunit")
var cmd string
if m.IsFile(pestBin) {
cmd = pestBin
} else if m.IsFile(phpunitBin) {
cmd = phpunitBin
} else {
return nil
}
// Tests depend on stan (or psalm if available)
after := []string{"stan"}
if _, found := DetectPsalm(r.dir); found {
after = []string{"psalm"}
}
return &process.RunSpec{
Name: "test",
Command: cmd,
Args: []string{},
Dir: r.dir,
After: after,
}
case "rector":
m := getMedium()
if !DetectRector(r.dir) {
return nil
}
vendorBin := filepath.Join(r.dir, "vendor", "bin", "rector")
cmd := "rector"
if m.IsFile(vendorBin) {
cmd = vendorBin
}
args := []string{"process"}
if !r.fix {
args = append(args, "--dry-run")
}
return &process.RunSpec{
Name: "rector",
Command: cmd,
Args: args,
Dir: r.dir,
After: []string{"test"},
AllowFailure: true, // Dry-run returns non-zero if changes would be made
}
case "infection":
m := getMedium()
if !DetectInfection(r.dir) {
return nil
}
vendorBin := filepath.Join(r.dir, "vendor", "bin", "infection")
cmd := "infection"
if m.IsFile(vendorBin) {
cmd = vendorBin
}
return &process.RunSpec{
Name: "infection",
Command: cmd,
Args: []string{"--min-msi=50", "--min-covered-msi=70", "--threads=4"},
Dir: r.dir,
After: []string{"test"},
AllowFailure: true,
}
}
return nil
}
// Run executes all QA checks and returns the results.
func (r *QARunner) Run(ctx context.Context, stages []QAStage) (*QARunResult, error) {
// Collect all checks from all stages
var allChecks []string
for _, stage := range stages {
checks := GetQAChecks(r.dir, stage)
allChecks = append(allChecks, checks...)
}
if len(allChecks) == 0 {
return &QARunResult{Passed: true}, nil
}
// Build specs
specs := r.BuildSpecs(allChecks)
if len(specs) == 0 {
return &QARunResult{Passed: true}, nil
}
// Register output handler
r.core.RegisterAction(func(c *framework.Core, msg framework.Message) error {
switch m := msg.(type) {
case process.ActionProcessOutput:
r.outputMu.Lock()
// Extract check name from process ID mapping
for _, spec := range specs {
if strings.Contains(m.ID, spec.Name) || m.ID != "" {
// Store output for later display if needed
r.checkOutputs[spec.Name] = append(r.checkOutputs[spec.Name], m.Line)
break
}
}
r.outputMu.Unlock()
}
return nil
})
// Create runner and execute
runner := process.NewRunner(r.service)
result, err := runner.RunAll(ctx, specs)
if err != nil {
return nil, err
}
// Convert to QA result
qaResult := &QARunResult{
Passed: result.Success(),
Duration: result.Duration.String(),
Results: make([]QACheckRunResult, 0, len(result.Results)),
}
for _, res := range result.Results {
qaResult.Results = append(qaResult.Results, QACheckRunResult{
Name: res.Name,
Passed: res.Passed(),
Skipped: res.Skipped,
ExitCode: res.ExitCode,
Duration: res.Duration.String(),
Output: res.Output,
})
if res.Passed() {
qaResult.PassedCount++
} else if res.Skipped {
qaResult.SkippedCount++
} else {
qaResult.FailedCount++
}
}
return qaResult, nil
}
// GetCheckOutput returns captured output for a check.
func (r *QARunner) GetCheckOutput(check string) []string {
r.outputMu.Lock()
defer r.outputMu.Unlock()
return r.checkOutputs[check]
}
// QARunResult holds the results of running QA checks.
type QARunResult struct {
Passed bool `json:"passed"`
Duration string `json:"duration"`
Results []QACheckRunResult `json:"results"`
PassedCount int `json:"passed_count"`
FailedCount int `json:"failed_count"`
SkippedCount int `json:"skipped_count"`
}
// QACheckRunResult holds the result of a single QA check.
type QACheckRunResult struct {
Name string `json:"name"`
Passed bool `json:"passed"`
Skipped bool `json:"skipped"`
ExitCode int `json:"exit_code"`
Duration string `json:"duration"`
Output string `json:"output,omitempty"`
}
// GetIssueMessage returns an issue message for a check.
func (r QACheckRunResult) GetIssueMessage() string {
if r.Passed || r.Skipped {
return ""
}
switch r.Name {
case "audit":
return i18n.T("i18n.done.find", "vulnerabilities")
case "fmt":
return i18n.T("i18n.done.find", "style issues")
case "stan":
return i18n.T("i18n.done.find", "analysis errors")
case "psalm":
return i18n.T("i18n.done.find", "type errors")
case "test":
return i18n.T("i18n.done.fail", "tests")
case "rector":
return i18n.T("i18n.done.find", "refactoring suggestions")
case "infection":
return i18n.T("i18n.fail.pass", "mutation testing")
default:
return i18n.T("i18n.done.find", "issues")
}
}

815
cmd_quality.go Normal file
View file

@ -0,0 +1,815 @@
package php
import (
"context"
"encoding/json"
"errors"
"os"
"strings"
"forge.lthn.ai/core/go/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n"
"github.com/spf13/cobra"
)
var (
testParallel bool
testCoverage bool
testFilter string
testGroup string
testJSON bool
)
func addPHPTestCommand(parent *cobra.Command) {
testCmd := &cobra.Command{
Use: "test",
Short: i18n.T("cmd.php.test.short"),
Long: i18n.T("cmd.php.test.long"),
RunE: func(cmd *cobra.Command, args []string) error {
cwd, err := os.Getwd()
if err != nil {
return cli.Err("%s: %w", i18n.T("i18n.fail.get", "working directory"), err)
}
if !IsPHPProject(cwd) {
return errors.New(i18n.T("cmd.php.error.not_php"))
}
if !testJSON {
cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.php")), i18n.ProgressSubject("run", "tests"))
}
ctx := context.Background()
opts := TestOptions{
Dir: cwd,
Filter: testFilter,
Parallel: testParallel,
Coverage: testCoverage,
JUnit: testJSON,
Output: os.Stdout,
}
if testGroup != "" {
opts.Groups = []string{testGroup}
}
if err := RunTests(ctx, opts); err != nil {
return cli.Err("%s: %w", i18n.T("i18n.fail.run", "tests"), err)
}
return nil
},
}
testCmd.Flags().BoolVar(&testParallel, "parallel", false, i18n.T("cmd.php.test.flag.parallel"))
testCmd.Flags().BoolVar(&testCoverage, "coverage", false, i18n.T("cmd.php.test.flag.coverage"))
testCmd.Flags().StringVar(&testFilter, "filter", "", i18n.T("cmd.php.test.flag.filter"))
testCmd.Flags().StringVar(&testGroup, "group", "", i18n.T("cmd.php.test.flag.group"))
testCmd.Flags().BoolVar(&testJSON, "junit", false, i18n.T("cmd.php.test.flag.junit"))
parent.AddCommand(testCmd)
}
var (
fmtFix bool
fmtDiff bool
fmtJSON bool
)
func addPHPFmtCommand(parent *cobra.Command) {
fmtCmd := &cobra.Command{
Use: "fmt [paths...]",
Short: i18n.T("cmd.php.fmt.short"),
Long: i18n.T("cmd.php.fmt.long"),
RunE: func(cmd *cobra.Command, args []string) error {
cwd, err := os.Getwd()
if err != nil {
return cli.Err("%s: %w", i18n.T("i18n.fail.get", "working directory"), err)
}
if !IsPHPProject(cwd) {
return errors.New(i18n.T("cmd.php.error.not_php"))
}
// Detect formatter
formatter, found := DetectFormatter(cwd)
if !found {
return errors.New(i18n.T("cmd.php.fmt.no_formatter"))
}
if !fmtJSON {
var msg string
if fmtFix {
msg = i18n.T("cmd.php.fmt.formatting", map[string]interface{}{"Formatter": formatter})
} else {
msg = i18n.ProgressSubject("check", "code style")
}
cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.php")), msg)
}
ctx := context.Background()
opts := FormatOptions{
Dir: cwd,
Fix: fmtFix,
Diff: fmtDiff,
JSON: fmtJSON,
Output: os.Stdout,
}
// Get any additional paths from args
if len(args) > 0 {
opts.Paths = args
}
if err := Format(ctx, opts); err != nil {
if fmtFix {
return cli.Err("%s: %w", i18n.T("cmd.php.error.fmt_failed"), err)
}
return cli.Err("%s: %w", i18n.T("cmd.php.error.fmt_issues"), err)
}
if !fmtJSON {
if fmtFix {
cli.Print("\n%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("common.success.completed", map[string]any{"Action": "Code formatted"}))
} else {
cli.Print("\n%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("cmd.php.fmt.no_issues"))
}
}
return nil
},
}
fmtCmd.Flags().BoolVar(&fmtFix, "fix", false, i18n.T("cmd.php.fmt.flag.fix"))
fmtCmd.Flags().BoolVar(&fmtDiff, "diff", false, i18n.T("common.flag.diff"))
fmtCmd.Flags().BoolVar(&fmtJSON, "json", false, i18n.T("common.flag.json"))
parent.AddCommand(fmtCmd)
}
var (
stanLevel int
stanMemory string
stanJSON bool
stanSARIF bool
)
func addPHPStanCommand(parent *cobra.Command) {
stanCmd := &cobra.Command{
Use: "stan [paths...]",
Short: i18n.T("cmd.php.analyse.short"),
Long: i18n.T("cmd.php.analyse.long"),
RunE: func(cmd *cobra.Command, args []string) error {
cwd, err := os.Getwd()
if err != nil {
return cli.Err("%s: %w", i18n.T("i18n.fail.get", "working directory"), err)
}
if !IsPHPProject(cwd) {
return errors.New(i18n.T("cmd.php.error.not_php"))
}
// Detect analyser
_, found := DetectAnalyser(cwd)
if !found {
return errors.New(i18n.T("cmd.php.analyse.no_analyser"))
}
if stanJSON && stanSARIF {
return errors.New(i18n.T("common.error.json_sarif_exclusive"))
}
if !stanJSON && !stanSARIF {
cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.php")), i18n.ProgressSubject("run", "static analysis"))
}
ctx := context.Background()
opts := AnalyseOptions{
Dir: cwd,
Level: stanLevel,
Memory: stanMemory,
JSON: stanJSON,
SARIF: stanSARIF,
Output: os.Stdout,
}
// Get any additional paths from args
if len(args) > 0 {
opts.Paths = args
}
if err := Analyse(ctx, opts); err != nil {
return cli.Err("%s: %w", i18n.T("cmd.php.error.analysis_issues"), err)
}
if !stanJSON && !stanSARIF {
cli.Print("\n%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("common.result.no_issues"))
}
return nil
},
}
stanCmd.Flags().IntVar(&stanLevel, "level", 0, i18n.T("cmd.php.analyse.flag.level"))
stanCmd.Flags().StringVar(&stanMemory, "memory", "", i18n.T("cmd.php.analyse.flag.memory"))
stanCmd.Flags().BoolVar(&stanJSON, "json", false, i18n.T("common.flag.json"))
stanCmd.Flags().BoolVar(&stanSARIF, "sarif", false, i18n.T("common.flag.sarif"))
parent.AddCommand(stanCmd)
}
// =============================================================================
// New QA Commands
// =============================================================================
var (
psalmLevel int
psalmFix bool
psalmBaseline bool
psalmShowInfo bool
psalmJSON bool
psalmSARIF bool
)
func addPHPPsalmCommand(parent *cobra.Command) {
psalmCmd := &cobra.Command{
Use: "psalm",
Short: i18n.T("cmd.php.psalm.short"),
Long: i18n.T("cmd.php.psalm.long"),
RunE: func(cmd *cobra.Command, args []string) error {
cwd, err := os.Getwd()
if err != nil {
return cli.Err("%s: %w", i18n.T("i18n.fail.get", "working directory"), err)
}
if !IsPHPProject(cwd) {
return errors.New(i18n.T("cmd.php.error.not_php"))
}
// Check if Psalm is available
_, found := DetectPsalm(cwd)
if !found {
cli.Print("%s %s\n\n", errorStyle.Render(i18n.Label("error")), i18n.T("cmd.php.psalm.not_found"))
cli.Print("%s %s\n", dimStyle.Render(i18n.Label("install")), i18n.T("cmd.php.psalm.install"))
cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.label.setup")), i18n.T("cmd.php.psalm.setup"))
return errors.New(i18n.T("cmd.php.error.psalm_not_installed"))
}
if psalmJSON && psalmSARIF {
return errors.New(i18n.T("common.error.json_sarif_exclusive"))
}
if !psalmJSON && !psalmSARIF {
var msg string
if psalmFix {
msg = i18n.T("cmd.php.psalm.analysing_fixing")
} else {
msg = i18n.T("cmd.php.psalm.analysing")
}
cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.psalm")), msg)
}
ctx := context.Background()
opts := PsalmOptions{
Dir: cwd,
Level: psalmLevel,
Fix: psalmFix,
Baseline: psalmBaseline,
ShowInfo: psalmShowInfo,
JSON: psalmJSON,
SARIF: psalmSARIF,
Output: os.Stdout,
}
if err := RunPsalm(ctx, opts); err != nil {
return cli.Err("%s: %w", i18n.T("cmd.php.error.psalm_issues"), err)
}
if !psalmJSON && !psalmSARIF {
cli.Print("\n%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("common.result.no_issues"))
}
return nil
},
}
psalmCmd.Flags().IntVar(&psalmLevel, "level", 0, i18n.T("cmd.php.psalm.flag.level"))
psalmCmd.Flags().BoolVar(&psalmFix, "fix", false, i18n.T("common.flag.fix"))
psalmCmd.Flags().BoolVar(&psalmBaseline, "baseline", false, i18n.T("cmd.php.psalm.flag.baseline"))
psalmCmd.Flags().BoolVar(&psalmShowInfo, "show-info", false, i18n.T("cmd.php.psalm.flag.show_info"))
psalmCmd.Flags().BoolVar(&psalmJSON, "json", false, i18n.T("common.flag.json"))
psalmCmd.Flags().BoolVar(&psalmSARIF, "sarif", false, i18n.T("common.flag.sarif"))
parent.AddCommand(psalmCmd)
}
var (
auditJSONOutput bool
auditFix bool
)
func addPHPAuditCommand(parent *cobra.Command) {
auditCmd := &cobra.Command{
Use: "audit",
Short: i18n.T("cmd.php.audit.short"),
Long: i18n.T("cmd.php.audit.long"),
RunE: func(cmd *cobra.Command, args []string) error {
cwd, err := os.Getwd()
if err != nil {
return cli.Err("%s: %w", i18n.T("i18n.fail.get", "working directory"), err)
}
if !IsPHPProject(cwd) {
return errors.New(i18n.T("cmd.php.error.not_php"))
}
cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.audit")), i18n.T("cmd.php.audit.scanning"))
ctx := context.Background()
results, err := RunAudit(ctx, AuditOptions{
Dir: cwd,
JSON: auditJSONOutput,
Fix: auditFix,
Output: os.Stdout,
})
if err != nil {
return cli.Err("%s: %w", i18n.T("cmd.php.error.audit_failed"), err)
}
// Print results
totalVulns := 0
hasErrors := false
for _, result := range results {
icon := successStyle.Render("✓")
status := successStyle.Render(i18n.T("cmd.php.audit.secure"))
if result.Error != nil {
icon = errorStyle.Render("✗")
status = errorStyle.Render(i18n.T("cmd.php.audit.error"))
hasErrors = true
} else if result.Vulnerabilities > 0 {
icon = errorStyle.Render("✗")
status = errorStyle.Render(i18n.T("cmd.php.audit.vulnerabilities", map[string]interface{}{"Count": result.Vulnerabilities}))
totalVulns += result.Vulnerabilities
}
cli.Print(" %s %s %s\n", icon, dimStyle.Render(result.Tool+":"), status)
// Show advisories
for _, adv := range result.Advisories {
severity := adv.Severity
if severity == "" {
severity = "unknown"
}
sevStyle := getSeverityStyle(severity)
cli.Print(" %s %s\n", sevStyle.Render("["+severity+"]"), adv.Package)
if adv.Title != "" {
cli.Print(" %s\n", dimStyle.Render(adv.Title))
}
}
}
cli.Blank()
if totalVulns > 0 {
cli.Print("%s %s\n", errorStyle.Render(i18n.Label("warning")), i18n.T("cmd.php.audit.found_vulns", map[string]interface{}{"Count": totalVulns}))
cli.Print("%s %s\n", dimStyle.Render(i18n.Label("fix")), i18n.T("common.hint.fix_deps"))
return errors.New(i18n.T("cmd.php.error.vulns_found"))
}
if hasErrors {
return errors.New(i18n.T("cmd.php.audit.completed_errors"))
}
cli.Print("%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("cmd.php.audit.all_secure"))
return nil
},
}
auditCmd.Flags().BoolVar(&auditJSONOutput, "json", false, i18n.T("common.flag.json"))
auditCmd.Flags().BoolVar(&auditFix, "fix", false, i18n.T("cmd.php.audit.flag.fix"))
parent.AddCommand(auditCmd)
}
var (
securitySeverity string
securityJSONOutput bool
securitySarif bool
securityURL string
)
func addPHPSecurityCommand(parent *cobra.Command) {
securityCmd := &cobra.Command{
Use: "security",
Short: i18n.T("cmd.php.security.short"),
Long: i18n.T("cmd.php.security.long"),
RunE: func(cmd *cobra.Command, args []string) error {
cwd, err := os.Getwd()
if err != nil {
return cli.Err("%s: %w", i18n.T("i18n.fail.get", "working directory"), err)
}
if !IsPHPProject(cwd) {
return errors.New(i18n.T("cmd.php.error.not_php"))
}
cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.security")), i18n.ProgressSubject("run", "security checks"))
ctx := context.Background()
result, err := RunSecurityChecks(ctx, SecurityOptions{
Dir: cwd,
Severity: securitySeverity,
JSON: securityJSONOutput,
SARIF: securitySarif,
URL: securityURL,
Output: os.Stdout,
})
if err != nil {
return cli.Err("%s: %w", i18n.T("cmd.php.error.security_failed"), err)
}
// Print results by category
currentCategory := ""
for _, check := range result.Checks {
category := strings.Split(check.ID, "_")[0]
if category != currentCategory {
if currentCategory != "" {
cli.Blank()
}
currentCategory = category
cli.Print(" %s\n", dimStyle.Render(strings.ToUpper(category)+i18n.T("cmd.php.security.checks_suffix")))
}
icon := successStyle.Render("✓")
if !check.Passed {
icon = getSeverityStyle(check.Severity).Render("✗")
}
cli.Print(" %s %s\n", icon, check.Name)
if !check.Passed && check.Message != "" {
cli.Print(" %s\n", dimStyle.Render(check.Message))
if check.Fix != "" {
cli.Print(" %s %s\n", dimStyle.Render(i18n.Label("fix")), check.Fix)
}
}
}
cli.Blank()
// Print summary
cli.Print("%s %s\n", dimStyle.Render(i18n.Label("summary")), i18n.T("cmd.php.security.summary"))
cli.Print(" %s %d/%d\n", dimStyle.Render(i18n.T("cmd.php.security.passed")), result.Summary.Passed, result.Summary.Total)
if result.Summary.Critical > 0 {
cli.Print(" %s %d\n", phpSecurityCriticalStyle.Render(i18n.T("cmd.php.security.critical")), result.Summary.Critical)
}
if result.Summary.High > 0 {
cli.Print(" %s %d\n", phpSecurityHighStyle.Render(i18n.T("cmd.php.security.high")), result.Summary.High)
}
if result.Summary.Medium > 0 {
cli.Print(" %s %d\n", phpSecurityMediumStyle.Render(i18n.T("cmd.php.security.medium")), result.Summary.Medium)
}
if result.Summary.Low > 0 {
cli.Print(" %s %d\n", phpSecurityLowStyle.Render(i18n.T("cmd.php.security.low")), result.Summary.Low)
}
if result.Summary.Critical > 0 || result.Summary.High > 0 {
return errors.New(i18n.T("cmd.php.error.critical_high_issues"))
}
return nil
},
}
securityCmd.Flags().StringVar(&securitySeverity, "severity", "", i18n.T("cmd.php.security.flag.severity"))
securityCmd.Flags().BoolVar(&securityJSONOutput, "json", false, i18n.T("common.flag.json"))
securityCmd.Flags().BoolVar(&securitySarif, "sarif", false, i18n.T("cmd.php.security.flag.sarif"))
securityCmd.Flags().StringVar(&securityURL, "url", "", i18n.T("cmd.php.security.flag.url"))
parent.AddCommand(securityCmd)
}
var (
qaQuick bool
qaFull bool
qaFix bool
qaJSON bool
)
func addPHPQACommand(parent *cobra.Command) {
qaCmd := &cobra.Command{
Use: "qa",
Short: i18n.T("cmd.php.qa.short"),
Long: i18n.T("cmd.php.qa.long"),
RunE: func(cmd *cobra.Command, args []string) error {
cwd, err := os.Getwd()
if err != nil {
return cli.Err("%s: %w", i18n.T("i18n.fail.get", "working directory"), err)
}
if !IsPHPProject(cwd) {
return errors.New(i18n.T("cmd.php.error.not_php"))
}
// Determine stages
opts := QAOptions{
Dir: cwd,
Quick: qaQuick,
Full: qaFull,
Fix: qaFix,
JSON: qaJSON,
}
stages := GetQAStages(opts)
// Print header
if !qaJSON {
cli.Print("%s %s\n\n", dimStyle.Render(i18n.Label("qa")), i18n.ProgressSubject("run", "QA pipeline"))
}
ctx := context.Background()
// Create QA runner using pkg/process
runner, err := NewQARunner(cwd, qaFix)
if err != nil {
return cli.Err("%s: %w", i18n.T("i18n.fail.create", "QA runner"), err)
}
// Run all checks with dependency ordering
result, err := runner.Run(ctx, stages)
if err != nil {
return cli.Err("%s: %w", i18n.T("i18n.fail.run", "QA checks"), err)
}
// Display results by stage (skip when JSON output is enabled)
if !qaJSON {
currentStage := ""
for _, checkResult := range result.Results {
// Determine stage for this check
stage := getCheckStage(checkResult.Name, stages, cwd)
if stage != currentStage {
if currentStage != "" {
cli.Blank()
}
currentStage = stage
cli.Print("%s\n", phpQAStageStyle.Render("── "+strings.ToUpper(stage)+" ──"))
}
icon := phpQAPassedStyle.Render("✓")
status := phpQAPassedStyle.Render(i18n.T("i18n.done.pass"))
if checkResult.Skipped {
icon = dimStyle.Render("-")
status = dimStyle.Render(i18n.T("i18n.done.skip"))
} else if !checkResult.Passed {
icon = phpQAFailedStyle.Render("✗")
status = phpQAFailedStyle.Render(i18n.T("i18n.done.fail"))
}
cli.Print(" %s %s %s %s\n", icon, checkResult.Name, status, dimStyle.Render(checkResult.Duration))
}
cli.Blank()
// Print summary
if result.Passed {
cli.Print("%s %s\n", phpQAPassedStyle.Render("QA PASSED:"), i18n.T("i18n.count.check", result.PassedCount)+" "+i18n.T("i18n.done.pass"))
cli.Print("%s %s\n", dimStyle.Render(i18n.T("i18n.label.duration")), result.Duration)
return nil
}
cli.Print("%s %s\n\n", phpQAFailedStyle.Render("QA FAILED:"), i18n.T("i18n.count.check", result.PassedCount)+"/"+cli.Sprint(len(result.Results))+" "+i18n.T("i18n.done.pass"))
// Show what needs fixing
cli.Print("%s\n", dimStyle.Render(i18n.T("i18n.label.fix")))
for _, checkResult := range result.Results {
if checkResult.Passed || checkResult.Skipped {
continue
}
fixCmd := getQAFixCommand(checkResult.Name, qaFix)
issue := checkResult.GetIssueMessage()
if issue == "" {
issue = "issues found"
}
cli.Print(" %s %s\n", phpQAFailedStyle.Render("*"), checkResult.Name+": "+issue)
if fixCmd != "" {
cli.Print(" %s %s\n", dimStyle.Render("->"), fixCmd)
}
}
return cli.Err("%s", i18n.T("i18n.fail.run", "QA pipeline"))
}
// JSON mode: output results as JSON
output, err := json.MarshalIndent(result, "", " ")
if err != nil {
return cli.Wrap(err, "marshal JSON output")
}
cli.Text(string(output))
if !result.Passed {
return cli.Err("%s", i18n.T("i18n.fail.run", "QA pipeline"))
}
return nil
},
}
qaCmd.Flags().BoolVar(&qaQuick, "quick", false, i18n.T("cmd.php.qa.flag.quick"))
qaCmd.Flags().BoolVar(&qaFull, "full", false, i18n.T("cmd.php.qa.flag.full"))
qaCmd.Flags().BoolVar(&qaFix, "fix", false, i18n.T("common.flag.fix"))
qaCmd.Flags().BoolVar(&qaJSON, "json", false, i18n.T("common.flag.json"))
parent.AddCommand(qaCmd)
}
// getCheckStage determines which stage a check belongs to.
func getCheckStage(checkName string, stages []QAStage, dir string) string {
for _, stage := range stages {
checks := GetQAChecks(dir, stage)
for _, c := range checks {
if c == checkName {
return string(stage)
}
}
}
return "unknown"
}
func getQAFixCommand(checkName string, fixEnabled bool) string {
switch checkName {
case "audit":
return i18n.T("i18n.progress.update", "dependencies")
case "fmt":
if fixEnabled {
return ""
}
return "core php fmt --fix"
case "stan":
return i18n.T("i18n.progress.fix", "PHPStan errors")
case "psalm":
return i18n.T("i18n.progress.fix", "Psalm errors")
case "test":
return i18n.T("i18n.progress.fix", i18n.T("i18n.done.fail")+" tests")
case "rector":
if fixEnabled {
return ""
}
return "core php rector --fix"
case "infection":
return i18n.T("i18n.progress.improve", "test coverage")
}
return ""
}
var (
rectorFix bool
rectorDiff bool
rectorClearCache bool
)
func addPHPRectorCommand(parent *cobra.Command) {
rectorCmd := &cobra.Command{
Use: "rector",
Short: i18n.T("cmd.php.rector.short"),
Long: i18n.T("cmd.php.rector.long"),
RunE: func(cmd *cobra.Command, args []string) error {
cwd, err := os.Getwd()
if err != nil {
return cli.Err("%s: %w", i18n.T("i18n.fail.get", "working directory"), err)
}
if !IsPHPProject(cwd) {
return errors.New(i18n.T("cmd.php.error.not_php"))
}
// Check if Rector is available
if !DetectRector(cwd) {
cli.Print("%s %s\n\n", errorStyle.Render(i18n.Label("error")), i18n.T("cmd.php.rector.not_found"))
cli.Print("%s %s\n", dimStyle.Render(i18n.Label("install")), i18n.T("cmd.php.rector.install"))
cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.label.setup")), i18n.T("cmd.php.rector.setup"))
return errors.New(i18n.T("cmd.php.error.rector_not_installed"))
}
var msg string
if rectorFix {
msg = i18n.T("cmd.php.rector.refactoring")
} else {
msg = i18n.T("cmd.php.rector.analysing")
}
cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.rector")), msg)
ctx := context.Background()
opts := RectorOptions{
Dir: cwd,
Fix: rectorFix,
Diff: rectorDiff,
ClearCache: rectorClearCache,
Output: os.Stdout,
}
if err := RunRector(ctx, opts); err != nil {
if rectorFix {
return cli.Err("%s: %w", i18n.T("cmd.php.error.rector_failed"), err)
}
// Dry-run returns non-zero if changes would be made
cli.Print("\n%s %s\n", phpQAWarningStyle.Render(i18n.T("cmd.php.label.info")), i18n.T("cmd.php.rector.changes_suggested"))
return nil
}
if rectorFix {
cli.Print("\n%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("common.success.completed", map[string]any{"Action": "Code refactored"}))
} else {
cli.Print("\n%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("cmd.php.rector.no_changes"))
}
return nil
},
}
rectorCmd.Flags().BoolVar(&rectorFix, "fix", false, i18n.T("cmd.php.rector.flag.fix"))
rectorCmd.Flags().BoolVar(&rectorDiff, "diff", false, i18n.T("cmd.php.rector.flag.diff"))
rectorCmd.Flags().BoolVar(&rectorClearCache, "clear-cache", false, i18n.T("cmd.php.rector.flag.clear_cache"))
parent.AddCommand(rectorCmd)
}
var (
infectionMinMSI int
infectionMinCoveredMSI int
infectionThreads int
infectionFilter string
infectionOnlyCovered bool
)
func addPHPInfectionCommand(parent *cobra.Command) {
infectionCmd := &cobra.Command{
Use: "infection",
Short: i18n.T("cmd.php.infection.short"),
Long: i18n.T("cmd.php.infection.long"),
RunE: func(cmd *cobra.Command, args []string) error {
cwd, err := os.Getwd()
if err != nil {
return cli.Err("%s: %w", i18n.T("i18n.fail.get", "working directory"), err)
}
if !IsPHPProject(cwd) {
return errors.New(i18n.T("cmd.php.error.not_php"))
}
// Check if Infection is available
if !DetectInfection(cwd) {
cli.Print("%s %s\n\n", errorStyle.Render(i18n.Label("error")), i18n.T("cmd.php.infection.not_found"))
cli.Print("%s %s\n", dimStyle.Render(i18n.Label("install")), i18n.T("cmd.php.infection.install"))
return errors.New(i18n.T("cmd.php.error.infection_not_installed"))
}
cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.label.infection")), i18n.ProgressSubject("run", "mutation testing"))
cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.info")), i18n.T("cmd.php.infection.note"))
ctx := context.Background()
opts := InfectionOptions{
Dir: cwd,
MinMSI: infectionMinMSI,
MinCoveredMSI: infectionMinCoveredMSI,
Threads: infectionThreads,
Filter: infectionFilter,
OnlyCovered: infectionOnlyCovered,
Output: os.Stdout,
}
if err := RunInfection(ctx, opts); err != nil {
return cli.Err("%s: %w", i18n.T("cmd.php.error.infection_failed"), err)
}
cli.Print("\n%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("cmd.php.infection.complete"))
return nil
},
}
infectionCmd.Flags().IntVar(&infectionMinMSI, "min-msi", 0, i18n.T("cmd.php.infection.flag.min_msi"))
infectionCmd.Flags().IntVar(&infectionMinCoveredMSI, "min-covered-msi", 0, i18n.T("cmd.php.infection.flag.min_covered_msi"))
infectionCmd.Flags().IntVar(&infectionThreads, "threads", 0, i18n.T("cmd.php.infection.flag.threads"))
infectionCmd.Flags().StringVar(&infectionFilter, "filter", "", i18n.T("cmd.php.infection.flag.filter"))
infectionCmd.Flags().BoolVar(&infectionOnlyCovered, "only-covered", false, i18n.T("cmd.php.infection.flag.only_covered"))
parent.AddCommand(infectionCmd)
}
func getSeverityStyle(severity string) *cli.AnsiStyle {
switch strings.ToLower(severity) {
case "critical":
return phpSecurityCriticalStyle
case "high":
return phpSecurityHighStyle
case "medium":
return phpSecurityMediumStyle
case "low":
return phpSecurityLowStyle
default:
return dimStyle
}
}

451
container.go Normal file
View file

@ -0,0 +1,451 @@
package php
import (
"context"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"forge.lthn.ai/core/go/pkg/cli"
)
// DockerBuildOptions configures Docker image building for PHP projects.
type DockerBuildOptions struct {
// ProjectDir is the path to the PHP/Laravel project.
ProjectDir string
// ImageName is the name for the Docker image.
ImageName string
// Tag is the image tag (default: "latest").
Tag string
// Platform specifies the target platform (e.g., "linux/amd64", "linux/arm64").
Platform string
// Dockerfile is the path to a custom Dockerfile.
// If empty, one will be auto-generated for FrankenPHP.
Dockerfile string
// NoBuildCache disables Docker build cache.
NoBuildCache bool
// BuildArgs are additional build arguments.
BuildArgs map[string]string
// Output is the writer for build output (default: os.Stdout).
Output io.Writer
}
// LinuxKitBuildOptions configures LinuxKit image building for PHP projects.
type LinuxKitBuildOptions struct {
// ProjectDir is the path to the PHP/Laravel project.
ProjectDir string
// OutputPath is the path for the output image.
OutputPath string
// Format is the output format: "iso", "qcow2", "raw", "vmdk".
Format string
// Template is the LinuxKit template name (default: "server-php").
Template string
// Variables are template variables to apply.
Variables map[string]string
// Output is the writer for build output (default: os.Stdout).
Output io.Writer
}
// ServeOptions configures running a production PHP container.
type ServeOptions struct {
// ImageName is the Docker image to run.
ImageName string
// Tag is the image tag (default: "latest").
Tag string
// ContainerName is the name for the container.
ContainerName string
// Port is the host port to bind (default: 80).
Port int
// HTTPSPort is the host HTTPS port to bind (default: 443).
HTTPSPort int
// Detach runs the container in detached mode.
Detach bool
// EnvFile is the path to an environment file.
EnvFile string
// Volumes maps host paths to container paths.
Volumes map[string]string
// Output is the writer for output (default: os.Stdout).
Output io.Writer
}
// BuildDocker builds a Docker image for the PHP project.
func BuildDocker(ctx context.Context, opts DockerBuildOptions) error {
if opts.ProjectDir == "" {
cwd, err := os.Getwd()
if err != nil {
return cli.WrapVerb(err, "get", "working directory")
}
opts.ProjectDir = cwd
}
// Validate project directory
if !IsPHPProject(opts.ProjectDir) {
return cli.Err("not a PHP project: %s (missing composer.json)", opts.ProjectDir)
}
// Set defaults
if opts.ImageName == "" {
opts.ImageName = filepath.Base(opts.ProjectDir)
}
if opts.Tag == "" {
opts.Tag = "latest"
}
if opts.Output == nil {
opts.Output = os.Stdout
}
// Determine Dockerfile path
dockerfilePath := opts.Dockerfile
var tempDockerfile string
if dockerfilePath == "" {
// Generate Dockerfile
content, err := GenerateDockerfile(opts.ProjectDir)
if err != nil {
return cli.WrapVerb(err, "generate", "Dockerfile")
}
// Write to temporary file
m := getMedium()
tempDockerfile = filepath.Join(opts.ProjectDir, "Dockerfile.core-generated")
if err := m.Write(tempDockerfile, content); err != nil {
return cli.WrapVerb(err, "write", "Dockerfile")
}
defer func() { _ = m.Delete(tempDockerfile) }()
dockerfilePath = tempDockerfile
}
// Build Docker image
imageRef := cli.Sprintf("%s:%s", opts.ImageName, opts.Tag)
args := []string{"build", "-t", imageRef, "-f", dockerfilePath}
if opts.Platform != "" {
args = append(args, "--platform", opts.Platform)
}
if opts.NoBuildCache {
args = append(args, "--no-cache")
}
for key, value := range opts.BuildArgs {
args = append(args, "--build-arg", cli.Sprintf("%s=%s", key, value))
}
args = append(args, opts.ProjectDir)
cmd := exec.CommandContext(ctx, "docker", args...)
cmd.Dir = opts.ProjectDir
cmd.Stdout = opts.Output
cmd.Stderr = opts.Output
if err := cmd.Run(); err != nil {
return cli.Wrap(err, "docker build failed")
}
return nil
}
// BuildLinuxKit builds a LinuxKit image for the PHP project.
func BuildLinuxKit(ctx context.Context, opts LinuxKitBuildOptions) error {
if opts.ProjectDir == "" {
cwd, err := os.Getwd()
if err != nil {
return cli.WrapVerb(err, "get", "working directory")
}
opts.ProjectDir = cwd
}
// Validate project directory
if !IsPHPProject(opts.ProjectDir) {
return cli.Err("not a PHP project: %s (missing composer.json)", opts.ProjectDir)
}
// Set defaults
if opts.Template == "" {
opts.Template = "server-php"
}
if opts.Format == "" {
opts.Format = "qcow2"
}
if opts.OutputPath == "" {
opts.OutputPath = filepath.Join(opts.ProjectDir, "dist", filepath.Base(opts.ProjectDir))
}
if opts.Output == nil {
opts.Output = os.Stdout
}
// Ensure output directory exists
m := getMedium()
outputDir := filepath.Dir(opts.OutputPath)
if err := m.EnsureDir(outputDir); err != nil {
return cli.WrapVerb(err, "create", "output directory")
}
// Find linuxkit binary
linuxkitPath, err := lookupLinuxKit()
if err != nil {
return err
}
// Get template content
templateContent, err := getLinuxKitTemplate(opts.Template)
if err != nil {
return cli.WrapVerb(err, "get", "template")
}
// Apply variables
if opts.Variables == nil {
opts.Variables = make(map[string]string)
}
// Add project-specific variables
opts.Variables["PROJECT_DIR"] = opts.ProjectDir
opts.Variables["PROJECT_NAME"] = filepath.Base(opts.ProjectDir)
content, err := applyTemplateVariables(templateContent, opts.Variables)
if err != nil {
return cli.WrapVerb(err, "apply", "template variables")
}
// Write template to temp file
tempYAML := filepath.Join(opts.ProjectDir, ".core-linuxkit.yml")
if err := m.Write(tempYAML, content); err != nil {
return cli.WrapVerb(err, "write", "template")
}
defer func() { _ = m.Delete(tempYAML) }()
// Build LinuxKit image
args := []string{
"build",
"--format", opts.Format,
"--name", opts.OutputPath,
tempYAML,
}
cmd := exec.CommandContext(ctx, linuxkitPath, args...)
cmd.Dir = opts.ProjectDir
cmd.Stdout = opts.Output
cmd.Stderr = opts.Output
if err := cmd.Run(); err != nil {
return cli.Wrap(err, "linuxkit build failed")
}
return nil
}
// ServeProduction runs a production PHP container.
func ServeProduction(ctx context.Context, opts ServeOptions) error {
if opts.ImageName == "" {
return cli.Err("image name is required")
}
// Set defaults
if opts.Tag == "" {
opts.Tag = "latest"
}
if opts.Port == 0 {
opts.Port = 80
}
if opts.HTTPSPort == 0 {
opts.HTTPSPort = 443
}
if opts.Output == nil {
opts.Output = os.Stdout
}
imageRef := cli.Sprintf("%s:%s", opts.ImageName, opts.Tag)
args := []string{"run"}
if opts.Detach {
args = append(args, "-d")
} else {
args = append(args, "--rm")
}
if opts.ContainerName != "" {
args = append(args, "--name", opts.ContainerName)
}
// Port mappings
args = append(args, "-p", cli.Sprintf("%d:80", opts.Port))
args = append(args, "-p", cli.Sprintf("%d:443", opts.HTTPSPort))
// Environment file
if opts.EnvFile != "" {
args = append(args, "--env-file", opts.EnvFile)
}
// Volume mounts
for hostPath, containerPath := range opts.Volumes {
args = append(args, "-v", cli.Sprintf("%s:%s", hostPath, containerPath))
}
args = append(args, imageRef)
cmd := exec.CommandContext(ctx, "docker", args...)
cmd.Stdout = opts.Output
cmd.Stderr = opts.Output
if opts.Detach {
output, err := cmd.Output()
if err != nil {
return cli.WrapVerb(err, "start", "container")
}
containerID := strings.TrimSpace(string(output))
cli.Print("Container started: %s\n", containerID[:12])
return nil
}
return cmd.Run()
}
// Shell opens a shell in a running container.
func Shell(ctx context.Context, containerID string) error {
if containerID == "" {
return cli.Err("container ID is required")
}
// Resolve partial container ID
fullID, err := resolveDockerContainerID(ctx, containerID)
if err != nil {
return err
}
cmd := exec.CommandContext(ctx, "docker", "exec", "-it", fullID, "/bin/sh")
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
// IsPHPProject checks if the given directory is a PHP project.
func IsPHPProject(dir string) bool {
composerPath := filepath.Join(dir, "composer.json")
return getMedium().IsFile(composerPath)
}
// commonLinuxKitPaths defines default search locations for linuxkit.
var commonLinuxKitPaths = []string{
"/usr/local/bin/linuxkit",
"/opt/homebrew/bin/linuxkit",
}
// lookupLinuxKit finds the linuxkit binary.
func lookupLinuxKit() (string, error) {
// Check PATH first
if path, err := exec.LookPath("linuxkit"); err == nil {
return path, nil
}
m := getMedium()
for _, p := range commonLinuxKitPaths {
if m.IsFile(p) {
return p, nil
}
}
return "", cli.Err("linuxkit not found. Install with: brew install linuxkit (macOS) or see https://github.com/linuxkit/linuxkit")
}
// getLinuxKitTemplate retrieves a LinuxKit template by name.
func getLinuxKitTemplate(name string) (string, error) {
// Default server-php template for PHP projects
if name == "server-php" {
return defaultServerPHPTemplate, nil
}
// Try to load from container package templates
// This would integrate with forge.lthn.ai/core/go/pkg/container
return "", cli.Err("template not found: %s", name)
}
// applyTemplateVariables applies variable substitution to template content.
func applyTemplateVariables(content string, vars map[string]string) (string, error) {
result := content
for key, value := range vars {
placeholder := "${" + key + "}"
result = strings.ReplaceAll(result, placeholder, value)
}
return result, nil
}
// resolveDockerContainerID resolves a partial container ID to a full ID.
func resolveDockerContainerID(ctx context.Context, partialID string) (string, error) {
cmd := exec.CommandContext(ctx, "docker", "ps", "-a", "--no-trunc", "--format", "{{.ID}}")
output, err := cmd.Output()
if err != nil {
return "", cli.WrapVerb(err, "list", "containers")
}
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
var matches []string
for _, line := range lines {
if strings.HasPrefix(line, partialID) {
matches = append(matches, line)
}
}
switch len(matches) {
case 0:
return "", cli.Err("no container found matching: %s", partialID)
case 1:
return matches[0], nil
default:
return "", cli.Err("multiple containers match '%s', be more specific", partialID)
}
}
// defaultServerPHPTemplate is the default LinuxKit template for PHP servers.
const defaultServerPHPTemplate = `# LinuxKit configuration for PHP/FrankenPHP server
kernel:
image: linuxkit/kernel:6.6.13
cmdline: "console=tty0 console=ttyS0"
init:
- linuxkit/init:v1.0.1
- linuxkit/runc:v1.0.1
- linuxkit/containerd:v1.0.1
onboot:
- name: sysctl
image: linuxkit/sysctl:v1.0.1
- name: dhcpcd
image: linuxkit/dhcpcd:v1.0.1
command: ["/sbin/dhcpcd", "--nobackground", "-f", "/dhcpcd.conf"]
services:
- name: getty
image: linuxkit/getty:v1.0.1
env:
- INSECURE=true
- name: sshd
image: linuxkit/sshd:v1.0.1
files:
- path: etc/ssh/authorized_keys
contents: |
${SSH_KEY:-}
`

383
container_test.go Normal file
View file

@ -0,0 +1,383 @@
package php
import (
"context"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestDockerBuildOptions_Good(t *testing.T) {
t.Run("all fields accessible", func(t *testing.T) {
opts := DockerBuildOptions{
ProjectDir: "/project",
ImageName: "myapp",
Tag: "v1.0.0",
Platform: "linux/amd64",
Dockerfile: "/path/to/Dockerfile",
NoBuildCache: true,
BuildArgs: map[string]string{"ARG1": "value1"},
Output: os.Stdout,
}
assert.Equal(t, "/project", opts.ProjectDir)
assert.Equal(t, "myapp", opts.ImageName)
assert.Equal(t, "v1.0.0", opts.Tag)
assert.Equal(t, "linux/amd64", opts.Platform)
assert.Equal(t, "/path/to/Dockerfile", opts.Dockerfile)
assert.True(t, opts.NoBuildCache)
assert.Equal(t, "value1", opts.BuildArgs["ARG1"])
assert.NotNil(t, opts.Output)
})
}
func TestLinuxKitBuildOptions_Good(t *testing.T) {
t.Run("all fields accessible", func(t *testing.T) {
opts := LinuxKitBuildOptions{
ProjectDir: "/project",
OutputPath: "/output/image.qcow2",
Format: "qcow2",
Template: "server-php",
Variables: map[string]string{"VAR1": "value1"},
Output: os.Stdout,
}
assert.Equal(t, "/project", opts.ProjectDir)
assert.Equal(t, "/output/image.qcow2", opts.OutputPath)
assert.Equal(t, "qcow2", opts.Format)
assert.Equal(t, "server-php", opts.Template)
assert.Equal(t, "value1", opts.Variables["VAR1"])
assert.NotNil(t, opts.Output)
})
}
func TestServeOptions_Good(t *testing.T) {
t.Run("all fields accessible", func(t *testing.T) {
opts := ServeOptions{
ImageName: "myapp",
Tag: "latest",
ContainerName: "myapp-container",
Port: 8080,
HTTPSPort: 8443,
Detach: true,
EnvFile: "/path/to/.env",
Volumes: map[string]string{"/host": "/container"},
Output: os.Stdout,
}
assert.Equal(t, "myapp", opts.ImageName)
assert.Equal(t, "latest", opts.Tag)
assert.Equal(t, "myapp-container", opts.ContainerName)
assert.Equal(t, 8080, opts.Port)
assert.Equal(t, 8443, opts.HTTPSPort)
assert.True(t, opts.Detach)
assert.Equal(t, "/path/to/.env", opts.EnvFile)
assert.Equal(t, "/container", opts.Volumes["/host"])
assert.NotNil(t, opts.Output)
})
}
func TestIsPHPProject_Container_Good(t *testing.T) {
t.Run("returns true with composer.json", func(t *testing.T) {
dir := t.TempDir()
err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(`{}`), 0644)
require.NoError(t, err)
assert.True(t, IsPHPProject(dir))
})
}
func TestIsPHPProject_Container_Bad(t *testing.T) {
t.Run("returns false without composer.json", func(t *testing.T) {
dir := t.TempDir()
assert.False(t, IsPHPProject(dir))
})
t.Run("returns false for non-existent directory", func(t *testing.T) {
assert.False(t, IsPHPProject("/non/existent/path"))
})
}
func TestLookupLinuxKit_Bad(t *testing.T) {
t.Run("returns error when linuxkit not found", func(t *testing.T) {
// Save original PATH and paths
origPath := os.Getenv("PATH")
origCommonPaths := commonLinuxKitPaths
defer func() {
_ = os.Setenv("PATH", origPath)
commonLinuxKitPaths = origCommonPaths
}()
// Set PATH to empty and clear common paths
_ = os.Setenv("PATH", "")
commonLinuxKitPaths = []string{}
_, err := lookupLinuxKit()
if assert.Error(t, err) {
assert.Contains(t, err.Error(), "linuxkit not found")
}
})
}
func TestGetLinuxKitTemplate_Good(t *testing.T) {
t.Run("returns server-php template", func(t *testing.T) {
content, err := getLinuxKitTemplate("server-php")
assert.NoError(t, err)
assert.Contains(t, content, "kernel:")
assert.Contains(t, content, "linuxkit/kernel")
})
}
func TestGetLinuxKitTemplate_Bad(t *testing.T) {
t.Run("returns error for unknown template", func(t *testing.T) {
_, err := getLinuxKitTemplate("unknown-template")
assert.Error(t, err)
assert.Contains(t, err.Error(), "template not found")
})
}
func TestApplyTemplateVariables_Good(t *testing.T) {
t.Run("replaces variables", func(t *testing.T) {
content := "Hello ${NAME}, welcome to ${PLACE}!"
vars := map[string]string{
"NAME": "World",
"PLACE": "Earth",
}
result, err := applyTemplateVariables(content, vars)
assert.NoError(t, err)
assert.Equal(t, "Hello World, welcome to Earth!", result)
})
t.Run("handles empty variables", func(t *testing.T) {
content := "No variables here"
vars := map[string]string{}
result, err := applyTemplateVariables(content, vars)
assert.NoError(t, err)
assert.Equal(t, "No variables here", result)
})
t.Run("leaves unmatched placeholders", func(t *testing.T) {
content := "Hello ${NAME}, ${UNKNOWN} is unknown"
vars := map[string]string{
"NAME": "World",
}
result, err := applyTemplateVariables(content, vars)
assert.NoError(t, err)
assert.Contains(t, result, "Hello World")
assert.Contains(t, result, "${UNKNOWN}")
})
t.Run("handles multiple occurrences", func(t *testing.T) {
content := "${VAR} and ${VAR} again"
vars := map[string]string{
"VAR": "value",
}
result, err := applyTemplateVariables(content, vars)
assert.NoError(t, err)
assert.Equal(t, "value and value again", result)
})
}
func TestDefaultServerPHPTemplate_Good(t *testing.T) {
t.Run("template has required sections", func(t *testing.T) {
assert.Contains(t, defaultServerPHPTemplate, "kernel:")
assert.Contains(t, defaultServerPHPTemplate, "init:")
assert.Contains(t, defaultServerPHPTemplate, "services:")
assert.Contains(t, defaultServerPHPTemplate, "onboot:")
})
t.Run("template contains placeholders", func(t *testing.T) {
assert.Contains(t, defaultServerPHPTemplate, "${SSH_KEY:-}")
})
}
func TestBuildDocker_Bad(t *testing.T) {
t.Skip("requires Docker installed")
t.Run("fails for non-PHP project", func(t *testing.T) {
dir := t.TempDir()
err := BuildDocker(context.TODO(), DockerBuildOptions{ProjectDir: dir})
assert.Error(t, err)
assert.Contains(t, err.Error(), "not a PHP project")
})
}
func TestBuildLinuxKit_Bad(t *testing.T) {
t.Skip("requires linuxkit installed")
t.Run("fails for non-PHP project", func(t *testing.T) {
dir := t.TempDir()
err := BuildLinuxKit(context.TODO(), LinuxKitBuildOptions{ProjectDir: dir})
assert.Error(t, err)
assert.Contains(t, err.Error(), "not a PHP project")
})
}
func TestServeProduction_Bad(t *testing.T) {
t.Run("fails without image name", func(t *testing.T) {
err := ServeProduction(context.TODO(), ServeOptions{})
assert.Error(t, err)
assert.Contains(t, err.Error(), "image name is required")
})
}
func TestShell_Bad(t *testing.T) {
t.Run("fails without container ID", func(t *testing.T) {
err := Shell(context.TODO(), "")
assert.Error(t, err)
assert.Contains(t, err.Error(), "container ID is required")
})
}
func TestResolveDockerContainerID_Bad(t *testing.T) {
t.Skip("requires Docker installed")
}
func TestBuildDocker_DefaultOptions(t *testing.T) {
t.Run("sets defaults correctly", func(t *testing.T) {
// This tests the default logic without actually running Docker
opts := DockerBuildOptions{}
// Verify default values would be set in BuildDocker
if opts.Tag == "" {
opts.Tag = "latest"
}
assert.Equal(t, "latest", opts.Tag)
if opts.ImageName == "" {
opts.ImageName = filepath.Base("/project/myapp")
}
assert.Equal(t, "myapp", opts.ImageName)
})
}
func TestBuildLinuxKit_DefaultOptions(t *testing.T) {
t.Run("sets defaults correctly", func(t *testing.T) {
opts := LinuxKitBuildOptions{}
// Verify default values would be set
if opts.Template == "" {
opts.Template = "server-php"
}
assert.Equal(t, "server-php", opts.Template)
if opts.Format == "" {
opts.Format = "qcow2"
}
assert.Equal(t, "qcow2", opts.Format)
})
}
func TestServeProduction_DefaultOptions(t *testing.T) {
t.Run("sets defaults correctly", func(t *testing.T) {
opts := ServeOptions{ImageName: "myapp"}
// Verify default values would be set
if opts.Tag == "" {
opts.Tag = "latest"
}
assert.Equal(t, "latest", opts.Tag)
if opts.Port == 0 {
opts.Port = 80
}
assert.Equal(t, 80, opts.Port)
if opts.HTTPSPort == 0 {
opts.HTTPSPort = 443
}
assert.Equal(t, 443, opts.HTTPSPort)
})
}
func TestLookupLinuxKit_Good(t *testing.T) {
t.Skip("requires linuxkit installed")
t.Run("finds linuxkit in PATH", func(t *testing.T) {
path, err := lookupLinuxKit()
assert.NoError(t, err)
assert.NotEmpty(t, path)
})
}
func TestBuildDocker_WithCustomDockerfile(t *testing.T) {
t.Skip("requires Docker installed")
t.Run("uses custom Dockerfile when provided", func(t *testing.T) {
dir := t.TempDir()
err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(`{"name":"test"}`), 0644)
require.NoError(t, err)
dockerfilePath := filepath.Join(dir, "Dockerfile.custom")
err = os.WriteFile(dockerfilePath, []byte("FROM alpine"), 0644)
require.NoError(t, err)
opts := DockerBuildOptions{
ProjectDir: dir,
Dockerfile: dockerfilePath,
}
// The function would use the custom Dockerfile
assert.Equal(t, dockerfilePath, opts.Dockerfile)
})
}
func TestBuildDocker_GeneratesDockerfile(t *testing.T) {
t.Skip("requires Docker installed")
t.Run("generates Dockerfile when not provided", func(t *testing.T) {
dir := t.TempDir()
// Create valid PHP project
composerJSON := `{"name":"test","require":{"php":"^8.2","laravel/framework":"^11.0"}}`
err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644)
require.NoError(t, err)
opts := DockerBuildOptions{
ProjectDir: dir,
// Dockerfile not specified - should be generated
}
assert.Empty(t, opts.Dockerfile)
})
}
func TestServeProduction_BuildsCorrectArgs(t *testing.T) {
t.Run("builds correct docker run arguments", func(t *testing.T) {
opts := ServeOptions{
ImageName: "myapp",
Tag: "v1.0.0",
ContainerName: "myapp-prod",
Port: 8080,
HTTPSPort: 8443,
Detach: true,
EnvFile: "/path/.env",
Volumes: map[string]string{
"/host/storage": "/app/storage",
},
}
// Verify the expected image reference format
imageRef := opts.ImageName + ":" + opts.Tag
assert.Equal(t, "myapp:v1.0.0", imageRef)
// Verify port format
portMapping := opts.Port
assert.Equal(t, 8080, portMapping)
})
}
func TestShell_Integration(t *testing.T) {
t.Skip("requires Docker with running container")
}
func TestResolveDockerContainerID_Integration(t *testing.T) {
t.Skip("requires Docker with running containers")
}

351
coolify.go Normal file
View file

@ -0,0 +1,351 @@
package php
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"forge.lthn.ai/core/go/pkg/cli"
)
// CoolifyClient is an HTTP client for the Coolify API.
type CoolifyClient struct {
BaseURL string
Token string
HTTPClient *http.Client
}
// CoolifyConfig holds configuration loaded from environment.
type CoolifyConfig struct {
URL string
Token string
AppID string
StagingAppID string
}
// CoolifyDeployment represents a deployment from the Coolify API.
type CoolifyDeployment struct {
ID string `json:"id"`
Status string `json:"status"`
CommitSHA string `json:"commit_sha,omitempty"`
CommitMsg string `json:"commit_message,omitempty"`
Branch string `json:"branch,omitempty"`
CreatedAt time.Time `json:"created_at"`
FinishedAt time.Time `json:"finished_at,omitempty"`
Log string `json:"log,omitempty"`
DeployedURL string `json:"deployed_url,omitempty"`
}
// CoolifyApp represents an application from the Coolify API.
type CoolifyApp struct {
ID string `json:"id"`
Name string `json:"name"`
FQDN string `json:"fqdn,omitempty"`
Status string `json:"status,omitempty"`
Repository string `json:"repository,omitempty"`
Branch string `json:"branch,omitempty"`
Environment string `json:"environment,omitempty"`
}
// NewCoolifyClient creates a new Coolify API client.
func NewCoolifyClient(baseURL, token string) *CoolifyClient {
// Ensure baseURL doesn't have trailing slash
baseURL = strings.TrimSuffix(baseURL, "/")
return &CoolifyClient{
BaseURL: baseURL,
Token: token,
HTTPClient: &http.Client{
Timeout: 30 * time.Second,
},
}
}
// LoadCoolifyConfig loads Coolify configuration from .env file in the given directory.
func LoadCoolifyConfig(dir string) (*CoolifyConfig, error) {
envPath := filepath.Join(dir, ".env")
return LoadCoolifyConfigFromFile(envPath)
}
// LoadCoolifyConfigFromFile loads Coolify configuration from a specific .env file.
func LoadCoolifyConfigFromFile(path string) (*CoolifyConfig, error) {
m := getMedium()
config := &CoolifyConfig{}
// First try environment variables
config.URL = os.Getenv("COOLIFY_URL")
config.Token = os.Getenv("COOLIFY_TOKEN")
config.AppID = os.Getenv("COOLIFY_APP_ID")
config.StagingAppID = os.Getenv("COOLIFY_STAGING_APP_ID")
// Then try .env file
if !m.Exists(path) {
// No .env file, just use env vars
return validateCoolifyConfig(config)
}
content, err := m.Read(path)
if err != nil {
return nil, cli.WrapVerb(err, "read", ".env file")
}
// Parse .env file
lines := strings.Split(content, "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
parts := strings.SplitN(line, "=", 2)
if len(parts) != 2 {
continue
}
key := strings.TrimSpace(parts[0])
value := strings.TrimSpace(parts[1])
// Remove quotes if present
value = strings.Trim(value, `"'`)
// Only override if not already set from env
switch key {
case "COOLIFY_URL":
if config.URL == "" {
config.URL = value
}
case "COOLIFY_TOKEN":
if config.Token == "" {
config.Token = value
}
case "COOLIFY_APP_ID":
if config.AppID == "" {
config.AppID = value
}
case "COOLIFY_STAGING_APP_ID":
if config.StagingAppID == "" {
config.StagingAppID = value
}
}
}
return validateCoolifyConfig(config)
}
// validateCoolifyConfig checks that required fields are set.
func validateCoolifyConfig(config *CoolifyConfig) (*CoolifyConfig, error) {
if config.URL == "" {
return nil, cli.Err("COOLIFY_URL is not set")
}
if config.Token == "" {
return nil, cli.Err("COOLIFY_TOKEN is not set")
}
return config, nil
}
// TriggerDeploy triggers a deployment for the specified application.
func (c *CoolifyClient) TriggerDeploy(ctx context.Context, appID string, force bool) (*CoolifyDeployment, error) {
endpoint := cli.Sprintf("%s/api/v1/applications/%s/deploy", c.BaseURL, appID)
payload := map[string]interface{}{}
if force {
payload["force"] = true
}
body, err := json.Marshal(payload)
if err != nil {
return nil, cli.WrapVerb(err, "marshal", "request")
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
return nil, cli.WrapVerb(err, "create", "request")
}
c.setHeaders(req)
resp, err := c.HTTPClient.Do(req)
if err != nil {
return nil, cli.Wrap(err, "request failed")
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusAccepted {
return nil, c.parseError(resp)
}
var deployment CoolifyDeployment
if err := json.NewDecoder(resp.Body).Decode(&deployment); err != nil {
// Some Coolify versions return minimal response
return &CoolifyDeployment{
Status: "queued",
CreatedAt: time.Now(),
}, nil
}
return &deployment, nil
}
// GetDeployment retrieves a specific deployment by ID.
func (c *CoolifyClient) GetDeployment(ctx context.Context, appID, deploymentID string) (*CoolifyDeployment, error) {
endpoint := cli.Sprintf("%s/api/v1/applications/%s/deployments/%s", c.BaseURL, appID, deploymentID)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, cli.WrapVerb(err, "create", "request")
}
c.setHeaders(req)
resp, err := c.HTTPClient.Do(req)
if err != nil {
return nil, cli.Wrap(err, "request failed")
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return nil, c.parseError(resp)
}
var deployment CoolifyDeployment
if err := json.NewDecoder(resp.Body).Decode(&deployment); err != nil {
return nil, cli.WrapVerb(err, "decode", "response")
}
return &deployment, nil
}
// ListDeployments retrieves deployments for an application.
func (c *CoolifyClient) ListDeployments(ctx context.Context, appID string, limit int) ([]CoolifyDeployment, error) {
endpoint := cli.Sprintf("%s/api/v1/applications/%s/deployments", c.BaseURL, appID)
if limit > 0 {
endpoint = cli.Sprintf("%s?limit=%d", endpoint, limit)
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, cli.WrapVerb(err, "create", "request")
}
c.setHeaders(req)
resp, err := c.HTTPClient.Do(req)
if err != nil {
return nil, cli.Wrap(err, "request failed")
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return nil, c.parseError(resp)
}
var deployments []CoolifyDeployment
if err := json.NewDecoder(resp.Body).Decode(&deployments); err != nil {
return nil, cli.WrapVerb(err, "decode", "response")
}
return deployments, nil
}
// Rollback triggers a rollback to a previous deployment.
func (c *CoolifyClient) Rollback(ctx context.Context, appID, deploymentID string) (*CoolifyDeployment, error) {
endpoint := cli.Sprintf("%s/api/v1/applications/%s/rollback", c.BaseURL, appID)
payload := map[string]interface{}{
"deployment_id": deploymentID,
}
body, err := json.Marshal(payload)
if err != nil {
return nil, cli.WrapVerb(err, "marshal", "request")
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
return nil, cli.WrapVerb(err, "create", "request")
}
c.setHeaders(req)
resp, err := c.HTTPClient.Do(req)
if err != nil {
return nil, cli.Wrap(err, "request failed")
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusAccepted {
return nil, c.parseError(resp)
}
var deployment CoolifyDeployment
if err := json.NewDecoder(resp.Body).Decode(&deployment); err != nil {
return &CoolifyDeployment{
Status: "rolling_back",
CreatedAt: time.Now(),
}, nil
}
return &deployment, nil
}
// GetApp retrieves application details.
func (c *CoolifyClient) GetApp(ctx context.Context, appID string) (*CoolifyApp, error) {
endpoint := cli.Sprintf("%s/api/v1/applications/%s", c.BaseURL, appID)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, cli.WrapVerb(err, "create", "request")
}
c.setHeaders(req)
resp, err := c.HTTPClient.Do(req)
if err != nil {
return nil, cli.Wrap(err, "request failed")
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return nil, c.parseError(resp)
}
var app CoolifyApp
if err := json.NewDecoder(resp.Body).Decode(&app); err != nil {
return nil, cli.WrapVerb(err, "decode", "response")
}
return &app, nil
}
// setHeaders sets common headers for API requests.
func (c *CoolifyClient) setHeaders(req *http.Request) {
req.Header.Set("Authorization", "Bearer "+c.Token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
}
// parseError extracts error information from an API response.
func (c *CoolifyClient) parseError(resp *http.Response) error {
body, _ := io.ReadAll(resp.Body)
var errResp struct {
Message string `json:"message"`
Error string `json:"error"`
}
if err := json.Unmarshal(body, &errResp); err == nil {
if errResp.Message != "" {
return cli.Err("API error (%d): %s", resp.StatusCode, errResp.Message)
}
if errResp.Error != "" {
return cli.Err("API error (%d): %s", resp.StatusCode, errResp.Error)
}
}
return cli.Err("API error (%d): %s", resp.StatusCode, string(body))
}

502
coolify_test.go Normal file
View file

@ -0,0 +1,502 @@
package php
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestCoolifyClient_Good(t *testing.T) {
t.Run("creates client with correct base URL", func(t *testing.T) {
client := NewCoolifyClient("https://coolify.example.com", "token")
assert.Equal(t, "https://coolify.example.com", client.BaseURL)
assert.Equal(t, "token", client.Token)
assert.NotNil(t, client.HTTPClient)
})
t.Run("strips trailing slash from base URL", func(t *testing.T) {
client := NewCoolifyClient("https://coolify.example.com/", "token")
assert.Equal(t, "https://coolify.example.com", client.BaseURL)
})
t.Run("http client has timeout", func(t *testing.T) {
client := NewCoolifyClient("https://coolify.example.com", "token")
assert.Equal(t, 30*time.Second, client.HTTPClient.Timeout)
})
}
func TestCoolifyConfig_Good(t *testing.T) {
t.Run("all fields accessible", func(t *testing.T) {
config := CoolifyConfig{
URL: "https://coolify.example.com",
Token: "secret-token",
AppID: "app-123",
StagingAppID: "staging-456",
}
assert.Equal(t, "https://coolify.example.com", config.URL)
assert.Equal(t, "secret-token", config.Token)
assert.Equal(t, "app-123", config.AppID)
assert.Equal(t, "staging-456", config.StagingAppID)
})
}
func TestCoolifyDeployment_Good(t *testing.T) {
t.Run("all fields accessible", func(t *testing.T) {
now := time.Now()
deployment := CoolifyDeployment{
ID: "dep-123",
Status: "finished",
CommitSHA: "abc123",
CommitMsg: "Test commit",
Branch: "main",
CreatedAt: now,
FinishedAt: now.Add(5 * time.Minute),
Log: "Build successful",
DeployedURL: "https://app.example.com",
}
assert.Equal(t, "dep-123", deployment.ID)
assert.Equal(t, "finished", deployment.Status)
assert.Equal(t, "abc123", deployment.CommitSHA)
assert.Equal(t, "Test commit", deployment.CommitMsg)
assert.Equal(t, "main", deployment.Branch)
})
}
func TestCoolifyApp_Good(t *testing.T) {
t.Run("all fields accessible", func(t *testing.T) {
app := CoolifyApp{
ID: "app-123",
Name: "MyApp",
FQDN: "https://myapp.example.com",
Status: "running",
Repository: "https://github.com/user/repo",
Branch: "main",
Environment: "production",
}
assert.Equal(t, "app-123", app.ID)
assert.Equal(t, "MyApp", app.Name)
assert.Equal(t, "https://myapp.example.com", app.FQDN)
assert.Equal(t, "running", app.Status)
})
}
func TestLoadCoolifyConfigFromFile_Good(t *testing.T) {
t.Run("loads config from .env file", func(t *testing.T) {
dir := t.TempDir()
envContent := `COOLIFY_URL=https://coolify.example.com
COOLIFY_TOKEN=secret-token
COOLIFY_APP_ID=app-123
COOLIFY_STAGING_APP_ID=staging-456`
err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644)
require.NoError(t, err)
config, err := LoadCoolifyConfigFromFile(filepath.Join(dir, ".env"))
assert.NoError(t, err)
assert.Equal(t, "https://coolify.example.com", config.URL)
assert.Equal(t, "secret-token", config.Token)
assert.Equal(t, "app-123", config.AppID)
assert.Equal(t, "staging-456", config.StagingAppID)
})
t.Run("handles quoted values", func(t *testing.T) {
dir := t.TempDir()
envContent := `COOLIFY_URL="https://coolify.example.com"
COOLIFY_TOKEN='secret-token'`
err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644)
require.NoError(t, err)
config, err := LoadCoolifyConfigFromFile(filepath.Join(dir, ".env"))
assert.NoError(t, err)
assert.Equal(t, "https://coolify.example.com", config.URL)
assert.Equal(t, "secret-token", config.Token)
})
t.Run("ignores comments", func(t *testing.T) {
dir := t.TempDir()
envContent := `# This is a comment
COOLIFY_URL=https://coolify.example.com
# COOLIFY_TOKEN=wrong-token
COOLIFY_TOKEN=correct-token`
err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644)
require.NoError(t, err)
config, err := LoadCoolifyConfigFromFile(filepath.Join(dir, ".env"))
assert.NoError(t, err)
assert.Equal(t, "correct-token", config.Token)
})
t.Run("ignores blank lines", func(t *testing.T) {
dir := t.TempDir()
envContent := `COOLIFY_URL=https://coolify.example.com
COOLIFY_TOKEN=secret-token`
err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644)
require.NoError(t, err)
config, err := LoadCoolifyConfigFromFile(filepath.Join(dir, ".env"))
assert.NoError(t, err)
assert.Equal(t, "https://coolify.example.com", config.URL)
})
}
func TestLoadCoolifyConfigFromFile_Bad(t *testing.T) {
t.Run("fails when COOLIFY_URL missing", func(t *testing.T) {
dir := t.TempDir()
envContent := `COOLIFY_TOKEN=secret-token`
err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644)
require.NoError(t, err)
_, err = LoadCoolifyConfigFromFile(filepath.Join(dir, ".env"))
assert.Error(t, err)
assert.Contains(t, err.Error(), "COOLIFY_URL is not set")
})
t.Run("fails when COOLIFY_TOKEN missing", func(t *testing.T) {
dir := t.TempDir()
envContent := `COOLIFY_URL=https://coolify.example.com`
err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644)
require.NoError(t, err)
_, err = LoadCoolifyConfigFromFile(filepath.Join(dir, ".env"))
assert.Error(t, err)
assert.Contains(t, err.Error(), "COOLIFY_TOKEN is not set")
})
}
func TestLoadCoolifyConfig_FromDirectory_Good(t *testing.T) {
t.Run("loads from directory", func(t *testing.T) {
dir := t.TempDir()
envContent := `COOLIFY_URL=https://coolify.example.com
COOLIFY_TOKEN=secret-token`
err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644)
require.NoError(t, err)
config, err := LoadCoolifyConfig(dir)
assert.NoError(t, err)
assert.Equal(t, "https://coolify.example.com", config.URL)
})
}
func TestValidateCoolifyConfig_Bad(t *testing.T) {
t.Run("returns error for empty URL", func(t *testing.T) {
config := &CoolifyConfig{Token: "token"}
_, err := validateCoolifyConfig(config)
assert.Error(t, err)
assert.Contains(t, err.Error(), "COOLIFY_URL is not set")
})
t.Run("returns error for empty token", func(t *testing.T) {
config := &CoolifyConfig{URL: "https://coolify.example.com"}
_, err := validateCoolifyConfig(config)
assert.Error(t, err)
assert.Contains(t, err.Error(), "COOLIFY_TOKEN is not set")
})
}
func TestCoolifyClient_TriggerDeploy_Good(t *testing.T) {
t.Run("triggers deployment successfully", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/applications/app-123/deploy", r.URL.Path)
assert.Equal(t, "POST", r.Method)
assert.Equal(t, "Bearer secret-token", r.Header.Get("Authorization"))
assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
resp := CoolifyDeployment{
ID: "dep-456",
Status: "queued",
CreatedAt: time.Now(),
}
_ = json.NewEncoder(w).Encode(resp)
}))
defer server.Close()
client := NewCoolifyClient(server.URL, "secret-token")
deployment, err := client.TriggerDeploy(context.Background(), "app-123", false)
assert.NoError(t, err)
assert.Equal(t, "dep-456", deployment.ID)
assert.Equal(t, "queued", deployment.Status)
})
t.Run("triggers deployment with force", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body map[string]interface{}
_ = json.NewDecoder(r.Body).Decode(&body)
assert.Equal(t, true, body["force"])
resp := CoolifyDeployment{ID: "dep-456", Status: "queued"}
_ = json.NewEncoder(w).Encode(resp)
}))
defer server.Close()
client := NewCoolifyClient(server.URL, "secret-token")
_, err := client.TriggerDeploy(context.Background(), "app-123", true)
assert.NoError(t, err)
})
t.Run("handles minimal response", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Return an invalid JSON response to trigger the fallback
_, _ = w.Write([]byte("not json"))
}))
defer server.Close()
client := NewCoolifyClient(server.URL, "secret-token")
deployment, err := client.TriggerDeploy(context.Background(), "app-123", false)
assert.NoError(t, err)
// The fallback response should be returned
assert.Equal(t, "queued", deployment.Status)
})
}
func TestCoolifyClient_TriggerDeploy_Bad(t *testing.T) {
t.Run("fails on HTTP error", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
_ = json.NewEncoder(w).Encode(map[string]string{"message": "Internal error"})
}))
defer server.Close()
client := NewCoolifyClient(server.URL, "secret-token")
_, err := client.TriggerDeploy(context.Background(), "app-123", false)
assert.Error(t, err)
assert.Contains(t, err.Error(), "API error")
})
}
func TestCoolifyClient_GetDeployment_Good(t *testing.T) {
t.Run("gets deployment details", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/applications/app-123/deployments/dep-456", r.URL.Path)
assert.Equal(t, "GET", r.Method)
resp := CoolifyDeployment{
ID: "dep-456",
Status: "finished",
CommitSHA: "abc123",
Branch: "main",
}
_ = json.NewEncoder(w).Encode(resp)
}))
defer server.Close()
client := NewCoolifyClient(server.URL, "secret-token")
deployment, err := client.GetDeployment(context.Background(), "app-123", "dep-456")
assert.NoError(t, err)
assert.Equal(t, "dep-456", deployment.ID)
assert.Equal(t, "finished", deployment.Status)
assert.Equal(t, "abc123", deployment.CommitSHA)
})
}
func TestCoolifyClient_GetDeployment_Bad(t *testing.T) {
t.Run("fails on 404", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
_ = json.NewEncoder(w).Encode(map[string]string{"error": "Not found"})
}))
defer server.Close()
client := NewCoolifyClient(server.URL, "secret-token")
_, err := client.GetDeployment(context.Background(), "app-123", "dep-456")
assert.Error(t, err)
assert.Contains(t, err.Error(), "Not found")
})
}
func TestCoolifyClient_ListDeployments_Good(t *testing.T) {
t.Run("lists deployments", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/applications/app-123/deployments", r.URL.Path)
assert.Equal(t, "10", r.URL.Query().Get("limit"))
resp := []CoolifyDeployment{
{ID: "dep-1", Status: "finished"},
{ID: "dep-2", Status: "failed"},
}
_ = json.NewEncoder(w).Encode(resp)
}))
defer server.Close()
client := NewCoolifyClient(server.URL, "secret-token")
deployments, err := client.ListDeployments(context.Background(), "app-123", 10)
assert.NoError(t, err)
assert.Len(t, deployments, 2)
assert.Equal(t, "dep-1", deployments[0].ID)
assert.Equal(t, "dep-2", deployments[1].ID)
})
t.Run("lists without limit", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "", r.URL.Query().Get("limit"))
_ = json.NewEncoder(w).Encode([]CoolifyDeployment{})
}))
defer server.Close()
client := NewCoolifyClient(server.URL, "secret-token")
_, err := client.ListDeployments(context.Background(), "app-123", 0)
assert.NoError(t, err)
})
}
func TestCoolifyClient_Rollback_Good(t *testing.T) {
t.Run("triggers rollback", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/applications/app-123/rollback", r.URL.Path)
assert.Equal(t, "POST", r.Method)
var body map[string]string
_ = json.NewDecoder(r.Body).Decode(&body)
assert.Equal(t, "dep-old", body["deployment_id"])
resp := CoolifyDeployment{
ID: "dep-new",
Status: "rolling_back",
}
_ = json.NewEncoder(w).Encode(resp)
}))
defer server.Close()
client := NewCoolifyClient(server.URL, "secret-token")
deployment, err := client.Rollback(context.Background(), "app-123", "dep-old")
assert.NoError(t, err)
assert.Equal(t, "dep-new", deployment.ID)
assert.Equal(t, "rolling_back", deployment.Status)
})
}
func TestCoolifyClient_GetApp_Good(t *testing.T) {
t.Run("gets app details", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/applications/app-123", r.URL.Path)
assert.Equal(t, "GET", r.Method)
resp := CoolifyApp{
ID: "app-123",
Name: "MyApp",
FQDN: "https://myapp.example.com",
Status: "running",
}
_ = json.NewEncoder(w).Encode(resp)
}))
defer server.Close()
client := NewCoolifyClient(server.URL, "secret-token")
app, err := client.GetApp(context.Background(), "app-123")
assert.NoError(t, err)
assert.Equal(t, "app-123", app.ID)
assert.Equal(t, "MyApp", app.Name)
assert.Equal(t, "https://myapp.example.com", app.FQDN)
})
}
func TestCoolifyClient_SetHeaders(t *testing.T) {
t.Run("sets all required headers", func(t *testing.T) {
client := NewCoolifyClient("https://coolify.example.com", "my-token")
req, _ := http.NewRequest("GET", "https://coolify.example.com", nil)
client.setHeaders(req)
assert.Equal(t, "Bearer my-token", req.Header.Get("Authorization"))
assert.Equal(t, "application/json", req.Header.Get("Content-Type"))
assert.Equal(t, "application/json", req.Header.Get("Accept"))
})
}
func TestCoolifyClient_ParseError(t *testing.T) {
t.Run("parses message field", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(map[string]string{"message": "Bad request message"})
}))
defer server.Close()
client := NewCoolifyClient(server.URL, "token")
_, err := client.GetApp(context.Background(), "app-123")
assert.Error(t, err)
assert.Contains(t, err.Error(), "Bad request message")
})
t.Run("parses error field", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(map[string]string{"error": "Error message"})
}))
defer server.Close()
client := NewCoolifyClient(server.URL, "token")
_, err := client.GetApp(context.Background(), "app-123")
assert.Error(t, err)
assert.Contains(t, err.Error(), "Error message")
})
t.Run("returns raw body when no JSON fields", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte("Raw error message"))
}))
defer server.Close()
client := NewCoolifyClient(server.URL, "token")
_, err := client.GetApp(context.Background(), "app-123")
assert.Error(t, err)
assert.Contains(t, err.Error(), "Raw error message")
})
}
func TestEnvironmentVariablePriority(t *testing.T) {
t.Run("env vars take precedence over .env file", func(t *testing.T) {
dir := t.TempDir()
envContent := `COOLIFY_URL=https://from-file.com
COOLIFY_TOKEN=file-token`
err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644)
require.NoError(t, err)
// Set environment variables
origURL := os.Getenv("COOLIFY_URL")
origToken := os.Getenv("COOLIFY_TOKEN")
defer func() {
_ = os.Setenv("COOLIFY_URL", origURL)
_ = os.Setenv("COOLIFY_TOKEN", origToken)
}()
_ = os.Setenv("COOLIFY_URL", "https://from-env.com")
_ = os.Setenv("COOLIFY_TOKEN", "env-token")
config, err := LoadCoolifyConfig(dir)
assert.NoError(t, err)
// Environment variables should take precedence
assert.Equal(t, "https://from-env.com", config.URL)
assert.Equal(t, "env-token", config.Token)
})
}

407
deploy.go Normal file
View file

@ -0,0 +1,407 @@
package php
import (
"context"
"time"
"forge.lthn.ai/core/go/pkg/cli"
)
// Environment represents a deployment environment.
type Environment string
const (
// EnvProduction is the production environment.
EnvProduction Environment = "production"
// EnvStaging is the staging environment.
EnvStaging Environment = "staging"
)
// DeployOptions configures a deployment.
type DeployOptions struct {
// Dir is the project directory containing .env config.
Dir string
// Environment is the target environment (production or staging).
Environment Environment
// Force triggers a deployment even if no changes are detected.
Force bool
// Wait blocks until deployment completes.
Wait bool
// WaitTimeout is the maximum time to wait for deployment.
// Defaults to 10 minutes.
WaitTimeout time.Duration
// PollInterval is how often to check deployment status when waiting.
// Defaults to 5 seconds.
PollInterval time.Duration
}
// StatusOptions configures a status check.
type StatusOptions struct {
// Dir is the project directory containing .env config.
Dir string
// Environment is the target environment (production or staging).
Environment Environment
// DeploymentID is a specific deployment to check.
// If empty, returns the latest deployment.
DeploymentID string
}
// RollbackOptions configures a rollback.
type RollbackOptions struct {
// Dir is the project directory containing .env config.
Dir string
// Environment is the target environment (production or staging).
Environment Environment
// DeploymentID is the deployment to rollback to.
// If empty, rolls back to the previous successful deployment.
DeploymentID string
// Wait blocks until rollback completes.
Wait bool
// WaitTimeout is the maximum time to wait for rollback.
WaitTimeout time.Duration
}
// DeploymentStatus represents the status of a deployment.
type DeploymentStatus struct {
// ID is the deployment identifier.
ID string
// Status is the current deployment status.
// Values: queued, building, deploying, finished, failed, cancelled
Status string
// URL is the deployed application URL.
URL string
// Commit is the git commit SHA.
Commit string
// CommitMessage is the git commit message.
CommitMessage string
// Branch is the git branch.
Branch string
// StartedAt is when the deployment started.
StartedAt time.Time
// CompletedAt is when the deployment completed.
CompletedAt time.Time
// Log contains deployment logs.
Log string
}
// Deploy triggers a deployment to Coolify.
func Deploy(ctx context.Context, opts DeployOptions) (*DeploymentStatus, error) {
if opts.Dir == "" {
opts.Dir = "."
}
if opts.Environment == "" {
opts.Environment = EnvProduction
}
if opts.WaitTimeout == 0 {
opts.WaitTimeout = 10 * time.Minute
}
if opts.PollInterval == 0 {
opts.PollInterval = 5 * time.Second
}
// Load config
config, err := LoadCoolifyConfig(opts.Dir)
if err != nil {
return nil, cli.WrapVerb(err, "load", "Coolify config")
}
// Get app ID for environment
appID := getAppIDForEnvironment(config, opts.Environment)
if appID == "" {
return nil, cli.Err("no app ID configured for %s environment", opts.Environment)
}
// Create client
client := NewCoolifyClient(config.URL, config.Token)
// Trigger deployment
deployment, err := client.TriggerDeploy(ctx, appID, opts.Force)
if err != nil {
return nil, cli.WrapVerb(err, "trigger", "deployment")
}
status := convertDeployment(deployment)
// Wait for completion if requested
if opts.Wait && deployment.ID != "" {
status, err = waitForDeployment(ctx, client, appID, deployment.ID, opts.WaitTimeout, opts.PollInterval)
if err != nil {
return status, err
}
}
// Get app info for URL
app, err := client.GetApp(ctx, appID)
if err == nil && app.FQDN != "" {
status.URL = app.FQDN
}
return status, nil
}
// DeployStatus retrieves the status of a deployment.
func DeployStatus(ctx context.Context, opts StatusOptions) (*DeploymentStatus, error) {
if opts.Dir == "" {
opts.Dir = "."
}
if opts.Environment == "" {
opts.Environment = EnvProduction
}
// Load config
config, err := LoadCoolifyConfig(opts.Dir)
if err != nil {
return nil, cli.WrapVerb(err, "load", "Coolify config")
}
// Get app ID for environment
appID := getAppIDForEnvironment(config, opts.Environment)
if appID == "" {
return nil, cli.Err("no app ID configured for %s environment", opts.Environment)
}
// Create client
client := NewCoolifyClient(config.URL, config.Token)
var deployment *CoolifyDeployment
if opts.DeploymentID != "" {
// Get specific deployment
deployment, err = client.GetDeployment(ctx, appID, opts.DeploymentID)
if err != nil {
return nil, cli.WrapVerb(err, "get", "deployment")
}
} else {
// Get latest deployment
deployments, err := client.ListDeployments(ctx, appID, 1)
if err != nil {
return nil, cli.WrapVerb(err, "list", "deployments")
}
if len(deployments) == 0 {
return nil, cli.Err("no deployments found")
}
deployment = &deployments[0]
}
status := convertDeployment(deployment)
// Get app info for URL
app, err := client.GetApp(ctx, appID)
if err == nil && app.FQDN != "" {
status.URL = app.FQDN
}
return status, nil
}
// Rollback triggers a rollback to a previous deployment.
func Rollback(ctx context.Context, opts RollbackOptions) (*DeploymentStatus, error) {
if opts.Dir == "" {
opts.Dir = "."
}
if opts.Environment == "" {
opts.Environment = EnvProduction
}
if opts.WaitTimeout == 0 {
opts.WaitTimeout = 10 * time.Minute
}
// Load config
config, err := LoadCoolifyConfig(opts.Dir)
if err != nil {
return nil, cli.WrapVerb(err, "load", "Coolify config")
}
// Get app ID for environment
appID := getAppIDForEnvironment(config, opts.Environment)
if appID == "" {
return nil, cli.Err("no app ID configured for %s environment", opts.Environment)
}
// Create client
client := NewCoolifyClient(config.URL, config.Token)
// Find deployment to rollback to
deploymentID := opts.DeploymentID
if deploymentID == "" {
// Find previous successful deployment
deployments, err := client.ListDeployments(ctx, appID, 10)
if err != nil {
return nil, cli.WrapVerb(err, "list", "deployments")
}
// Skip the first (current) deployment, find the last successful one
for i, d := range deployments {
if i == 0 {
continue // Skip current deployment
}
if d.Status == "finished" || d.Status == "success" {
deploymentID = d.ID
break
}
}
if deploymentID == "" {
return nil, cli.Err("no previous successful deployment found to rollback to")
}
}
// Trigger rollback
deployment, err := client.Rollback(ctx, appID, deploymentID)
if err != nil {
return nil, cli.WrapVerb(err, "trigger", "rollback")
}
status := convertDeployment(deployment)
// Wait for completion if requested
if opts.Wait && deployment.ID != "" {
status, err = waitForDeployment(ctx, client, appID, deployment.ID, opts.WaitTimeout, 5*time.Second)
if err != nil {
return status, err
}
}
return status, nil
}
// ListDeployments retrieves recent deployments.
func ListDeployments(ctx context.Context, dir string, env Environment, limit int) ([]DeploymentStatus, error) {
if dir == "" {
dir = "."
}
if env == "" {
env = EnvProduction
}
if limit == 0 {
limit = 10
}
// Load config
config, err := LoadCoolifyConfig(dir)
if err != nil {
return nil, cli.WrapVerb(err, "load", "Coolify config")
}
// Get app ID for environment
appID := getAppIDForEnvironment(config, env)
if appID == "" {
return nil, cli.Err("no app ID configured for %s environment", env)
}
// Create client
client := NewCoolifyClient(config.URL, config.Token)
deployments, err := client.ListDeployments(ctx, appID, limit)
if err != nil {
return nil, cli.WrapVerb(err, "list", "deployments")
}
result := make([]DeploymentStatus, len(deployments))
for i, d := range deployments {
result[i] = *convertDeployment(&d)
}
return result, nil
}
// getAppIDForEnvironment returns the app ID for the given environment.
func getAppIDForEnvironment(config *CoolifyConfig, env Environment) string {
switch env {
case EnvStaging:
if config.StagingAppID != "" {
return config.StagingAppID
}
return config.AppID // Fallback to production
default:
return config.AppID
}
}
// convertDeployment converts a CoolifyDeployment to DeploymentStatus.
func convertDeployment(d *CoolifyDeployment) *DeploymentStatus {
return &DeploymentStatus{
ID: d.ID,
Status: d.Status,
URL: d.DeployedURL,
Commit: d.CommitSHA,
CommitMessage: d.CommitMsg,
Branch: d.Branch,
StartedAt: d.CreatedAt,
CompletedAt: d.FinishedAt,
Log: d.Log,
}
}
// waitForDeployment polls for deployment completion.
func waitForDeployment(ctx context.Context, client *CoolifyClient, appID, deploymentID string, timeout, interval time.Duration) (*DeploymentStatus, error) {
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
deployment, err := client.GetDeployment(ctx, appID, deploymentID)
if err != nil {
return nil, cli.WrapVerb(err, "get", "deployment status")
}
status := convertDeployment(deployment)
// Check if deployment is complete
switch deployment.Status {
case "finished", "success":
return status, nil
case "failed", "error":
return status, cli.Err("deployment failed: %s", deployment.Status)
case "cancelled":
return status, cli.Err("deployment was cancelled")
}
// Still in progress, wait and retry
select {
case <-ctx.Done():
return status, ctx.Err()
case <-time.After(interval):
}
}
return nil, cli.Err("deployment timed out after %v", timeout)
}
// IsDeploymentComplete returns true if the status indicates completion.
func IsDeploymentComplete(status string) bool {
switch status {
case "finished", "success", "failed", "error", "cancelled":
return true
default:
return false
}
}
// IsDeploymentSuccessful returns true if the status indicates success.
func IsDeploymentSuccessful(status string) bool {
return status == "finished" || status == "success"
}

221
deploy_internal_test.go Normal file
View file

@ -0,0 +1,221 @@
package php
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestConvertDeployment_Good(t *testing.T) {
t.Run("converts all fields", func(t *testing.T) {
now := time.Now()
coolify := &CoolifyDeployment{
ID: "dep-123",
Status: "finished",
CommitSHA: "abc123",
CommitMsg: "Test commit",
Branch: "main",
CreatedAt: now,
FinishedAt: now.Add(5 * time.Minute),
Log: "Build successful",
DeployedURL: "https://app.example.com",
}
status := convertDeployment(coolify)
assert.Equal(t, "dep-123", status.ID)
assert.Equal(t, "finished", status.Status)
assert.Equal(t, "https://app.example.com", status.URL)
assert.Equal(t, "abc123", status.Commit)
assert.Equal(t, "Test commit", status.CommitMessage)
assert.Equal(t, "main", status.Branch)
assert.Equal(t, now, status.StartedAt)
assert.Equal(t, now.Add(5*time.Minute), status.CompletedAt)
assert.Equal(t, "Build successful", status.Log)
})
t.Run("handles empty deployment", func(t *testing.T) {
coolify := &CoolifyDeployment{}
status := convertDeployment(coolify)
assert.Empty(t, status.ID)
assert.Empty(t, status.Status)
})
}
func TestDeploymentStatus_Struct_Good(t *testing.T) {
t.Run("all fields accessible", func(t *testing.T) {
now := time.Now()
status := DeploymentStatus{
ID: "dep-123",
Status: "finished",
URL: "https://app.example.com",
Commit: "abc123",
CommitMessage: "Test commit",
Branch: "main",
StartedAt: now,
CompletedAt: now.Add(5 * time.Minute),
Log: "Build log",
}
assert.Equal(t, "dep-123", status.ID)
assert.Equal(t, "finished", status.Status)
assert.Equal(t, "https://app.example.com", status.URL)
assert.Equal(t, "abc123", status.Commit)
assert.Equal(t, "Test commit", status.CommitMessage)
assert.Equal(t, "main", status.Branch)
assert.Equal(t, "Build log", status.Log)
})
}
func TestDeployOptions_Struct_Good(t *testing.T) {
t.Run("all fields accessible", func(t *testing.T) {
opts := DeployOptions{
Dir: "/project",
Environment: EnvProduction,
Force: true,
Wait: true,
WaitTimeout: 10 * time.Minute,
PollInterval: 5 * time.Second,
}
assert.Equal(t, "/project", opts.Dir)
assert.Equal(t, EnvProduction, opts.Environment)
assert.True(t, opts.Force)
assert.True(t, opts.Wait)
assert.Equal(t, 10*time.Minute, opts.WaitTimeout)
assert.Equal(t, 5*time.Second, opts.PollInterval)
})
}
func TestStatusOptions_Struct_Good(t *testing.T) {
t.Run("all fields accessible", func(t *testing.T) {
opts := StatusOptions{
Dir: "/project",
Environment: EnvStaging,
DeploymentID: "dep-123",
}
assert.Equal(t, "/project", opts.Dir)
assert.Equal(t, EnvStaging, opts.Environment)
assert.Equal(t, "dep-123", opts.DeploymentID)
})
}
func TestRollbackOptions_Struct_Good(t *testing.T) {
t.Run("all fields accessible", func(t *testing.T) {
opts := RollbackOptions{
Dir: "/project",
Environment: EnvProduction,
DeploymentID: "dep-old",
Wait: true,
WaitTimeout: 5 * time.Minute,
}
assert.Equal(t, "/project", opts.Dir)
assert.Equal(t, EnvProduction, opts.Environment)
assert.Equal(t, "dep-old", opts.DeploymentID)
assert.True(t, opts.Wait)
assert.Equal(t, 5*time.Minute, opts.WaitTimeout)
})
}
func TestEnvironment_Constants(t *testing.T) {
t.Run("constants are defined", func(t *testing.T) {
assert.Equal(t, Environment("production"), EnvProduction)
assert.Equal(t, Environment("staging"), EnvStaging)
})
}
func TestGetAppIDForEnvironment_Edge(t *testing.T) {
t.Run("staging without staging ID falls back to production", func(t *testing.T) {
config := &CoolifyConfig{
AppID: "prod-123",
// No StagingAppID set
}
id := getAppIDForEnvironment(config, EnvStaging)
assert.Equal(t, "prod-123", id)
})
t.Run("staging with staging ID uses staging", func(t *testing.T) {
config := &CoolifyConfig{
AppID: "prod-123",
StagingAppID: "staging-456",
}
id := getAppIDForEnvironment(config, EnvStaging)
assert.Equal(t, "staging-456", id)
})
t.Run("production uses production ID", func(t *testing.T) {
config := &CoolifyConfig{
AppID: "prod-123",
StagingAppID: "staging-456",
}
id := getAppIDForEnvironment(config, EnvProduction)
assert.Equal(t, "prod-123", id)
})
t.Run("unknown environment uses production", func(t *testing.T) {
config := &CoolifyConfig{
AppID: "prod-123",
}
id := getAppIDForEnvironment(config, "unknown")
assert.Equal(t, "prod-123", id)
})
}
func TestIsDeploymentComplete_Edge(t *testing.T) {
tests := []struct {
status string
expected bool
}{
{"finished", true},
{"success", true},
{"failed", true},
{"error", true},
{"cancelled", true},
{"queued", false},
{"building", false},
{"deploying", false},
{"pending", false},
{"rolling_back", false},
{"", false},
{"unknown", false},
}
for _, tt := range tests {
t.Run(tt.status, func(t *testing.T) {
result := IsDeploymentComplete(tt.status)
assert.Equal(t, tt.expected, result)
})
}
}
func TestIsDeploymentSuccessful_Edge(t *testing.T) {
tests := []struct {
status string
expected bool
}{
{"finished", true},
{"success", true},
{"failed", false},
{"error", false},
{"cancelled", false},
{"queued", false},
{"building", false},
{"deploying", false},
{"", false},
}
for _, tt := range tests {
t.Run(tt.status, func(t *testing.T) {
result := IsDeploymentSuccessful(tt.status)
assert.Equal(t, tt.expected, result)
})
}
}

257
deploy_test.go Normal file
View file

@ -0,0 +1,257 @@
package php
import (
"os"
"path/filepath"
"testing"
)
func TestLoadCoolifyConfig_Good(t *testing.T) {
tests := []struct {
name string
envContent string
wantURL string
wantToken string
wantAppID string
wantStaging string
}{
{
name: "all values set",
envContent: `COOLIFY_URL=https://coolify.example.com
COOLIFY_TOKEN=secret-token
COOLIFY_APP_ID=app-123
COOLIFY_STAGING_APP_ID=staging-456`,
wantURL: "https://coolify.example.com",
wantToken: "secret-token",
wantAppID: "app-123",
wantStaging: "staging-456",
},
{
name: "quoted values",
envContent: `COOLIFY_URL="https://coolify.example.com"
COOLIFY_TOKEN='secret-token'
COOLIFY_APP_ID="app-123"`,
wantURL: "https://coolify.example.com",
wantToken: "secret-token",
wantAppID: "app-123",
},
{
name: "with comments and blank lines",
envContent: `# Coolify configuration
COOLIFY_URL=https://coolify.example.com
# API token
COOLIFY_TOKEN=secret-token
COOLIFY_APP_ID=app-123
# COOLIFY_STAGING_APP_ID=not-this`,
wantURL: "https://coolify.example.com",
wantToken: "secret-token",
wantAppID: "app-123",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create temp directory
dir := t.TempDir()
envPath := filepath.Join(dir, ".env")
// Write .env file
if err := os.WriteFile(envPath, []byte(tt.envContent), 0644); err != nil {
t.Fatalf("failed to write .env: %v", err)
}
// Load config
config, err := LoadCoolifyConfig(dir)
if err != nil {
t.Fatalf("LoadCoolifyConfig() error = %v", err)
}
if config.URL != tt.wantURL {
t.Errorf("URL = %q, want %q", config.URL, tt.wantURL)
}
if config.Token != tt.wantToken {
t.Errorf("Token = %q, want %q", config.Token, tt.wantToken)
}
if config.AppID != tt.wantAppID {
t.Errorf("AppID = %q, want %q", config.AppID, tt.wantAppID)
}
if tt.wantStaging != "" && config.StagingAppID != tt.wantStaging {
t.Errorf("StagingAppID = %q, want %q", config.StagingAppID, tt.wantStaging)
}
})
}
}
func TestLoadCoolifyConfig_Bad(t *testing.T) {
tests := []struct {
name string
envContent string
wantErr string
}{
{
name: "missing URL",
envContent: "COOLIFY_TOKEN=secret",
wantErr: "COOLIFY_URL is not set",
},
{
name: "missing token",
envContent: "COOLIFY_URL=https://coolify.example.com",
wantErr: "COOLIFY_TOKEN is not set",
},
{
name: "empty values",
envContent: "COOLIFY_URL=\nCOOLIFY_TOKEN=",
wantErr: "COOLIFY_URL is not set",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create temp directory
dir := t.TempDir()
envPath := filepath.Join(dir, ".env")
// Write .env file
if err := os.WriteFile(envPath, []byte(tt.envContent), 0644); err != nil {
t.Fatalf("failed to write .env: %v", err)
}
// Load config
_, err := LoadCoolifyConfig(dir)
if err == nil {
t.Fatal("LoadCoolifyConfig() expected error, got nil")
}
if err.Error() != tt.wantErr {
t.Errorf("error = %q, want %q", err.Error(), tt.wantErr)
}
})
}
}
func TestGetAppIDForEnvironment_Good(t *testing.T) {
config := &CoolifyConfig{
URL: "https://coolify.example.com",
Token: "token",
AppID: "prod-123",
StagingAppID: "staging-456",
}
tests := []struct {
name string
env Environment
wantID string
}{
{
name: "production environment",
env: EnvProduction,
wantID: "prod-123",
},
{
name: "staging environment",
env: EnvStaging,
wantID: "staging-456",
},
{
name: "empty defaults to production",
env: "",
wantID: "prod-123",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
id := getAppIDForEnvironment(config, tt.env)
if id != tt.wantID {
t.Errorf("getAppIDForEnvironment() = %q, want %q", id, tt.wantID)
}
})
}
}
func TestGetAppIDForEnvironment_FallbackToProduction(t *testing.T) {
config := &CoolifyConfig{
URL: "https://coolify.example.com",
Token: "token",
AppID: "prod-123",
// No staging app ID
}
// Staging should fall back to production
id := getAppIDForEnvironment(config, EnvStaging)
if id != "prod-123" {
t.Errorf("getAppIDForEnvironment(EnvStaging) = %q, want %q (should fallback)", id, "prod-123")
}
}
func TestIsDeploymentComplete_Good(t *testing.T) {
completeStatuses := []string{"finished", "success", "failed", "error", "cancelled"}
for _, status := range completeStatuses {
if !IsDeploymentComplete(status) {
t.Errorf("IsDeploymentComplete(%q) = false, want true", status)
}
}
incompleteStatuses := []string{"queued", "building", "deploying", "pending", "rolling_back"}
for _, status := range incompleteStatuses {
if IsDeploymentComplete(status) {
t.Errorf("IsDeploymentComplete(%q) = true, want false", status)
}
}
}
func TestIsDeploymentSuccessful_Good(t *testing.T) {
successStatuses := []string{"finished", "success"}
for _, status := range successStatuses {
if !IsDeploymentSuccessful(status) {
t.Errorf("IsDeploymentSuccessful(%q) = false, want true", status)
}
}
failedStatuses := []string{"failed", "error", "cancelled", "queued", "building"}
for _, status := range failedStatuses {
if IsDeploymentSuccessful(status) {
t.Errorf("IsDeploymentSuccessful(%q) = true, want false", status)
}
}
}
func TestNewCoolifyClient_Good(t *testing.T) {
tests := []struct {
name string
baseURL string
wantBaseURL string
}{
{
name: "URL without trailing slash",
baseURL: "https://coolify.example.com",
wantBaseURL: "https://coolify.example.com",
},
{
name: "URL with trailing slash",
baseURL: "https://coolify.example.com/",
wantBaseURL: "https://coolify.example.com",
},
{
name: "URL with api path",
baseURL: "https://coolify.example.com/api/",
wantBaseURL: "https://coolify.example.com/api",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
client := NewCoolifyClient(tt.baseURL, "token")
if client.BaseURL != tt.wantBaseURL {
t.Errorf("BaseURL = %q, want %q", client.BaseURL, tt.wantBaseURL)
}
if client.Token != "token" {
t.Errorf("Token = %q, want %q", client.Token, "token")
}
if client.HTTPClient == nil {
t.Error("HTTPClient is nil")
}
})
}
}

296
detect.go Normal file
View file

@ -0,0 +1,296 @@
package php
import (
"encoding/json"
"path/filepath"
"strings"
)
// DetectedService represents a service that was detected in a Laravel project.
type DetectedService string
// Detected service constants for Laravel projects.
const (
// ServiceFrankenPHP indicates FrankenPHP server is detected.
ServiceFrankenPHP DetectedService = "frankenphp"
// ServiceVite indicates Vite frontend bundler is detected.
ServiceVite DetectedService = "vite"
// ServiceHorizon indicates Laravel Horizon queue dashboard is detected.
ServiceHorizon DetectedService = "horizon"
// ServiceReverb indicates Laravel Reverb WebSocket server is detected.
ServiceReverb DetectedService = "reverb"
// ServiceRedis indicates Redis cache/queue backend is detected.
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 {
m := getMedium()
// Check for artisan file
artisanPath := filepath.Join(dir, "artisan")
if !m.Exists(artisanPath) {
return false
}
// Check composer.json for laravel/framework
composerPath := filepath.Join(dir, "composer.json")
data, err := m.Read(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([]byte(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 {
m := getMedium()
// Check composer.json for laravel/octane
composerPath := filepath.Join(dir, "composer.json")
data, err := m.Read(composerPath)
if err != nil {
return false
}
var composer struct {
Require map[string]string `json:"require"`
}
if err := json.Unmarshal([]byte(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 !m.Exists(configPath) {
// If no config exists but octane is installed, assume frankenphp
return true
}
configData, err := m.Read(configPath)
if err != nil {
return true // Assume frankenphp if we can't read config
}
// Look for frankenphp in the config
return strings.Contains(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 {
m := getMedium()
viteConfigs := []string{
"vite.config.js",
"vite.config.ts",
"vite.config.mjs",
"vite.config.mts",
}
for _, config := range viteConfigs {
if m.Exists(filepath.Join(dir, config)) {
return true
}
}
return false
}
// hasHorizon checks if Laravel Horizon is configured.
func hasHorizon(dir string) bool {
horizonConfig := filepath.Join(dir, "config", "horizon.php")
return getMedium().Exists(horizonConfig)
}
// hasReverb checks if Laravel Reverb is configured.
func hasReverb(dir string) bool {
reverbConfig := filepath.Join(dir, "config", "reverb.php")
return getMedium().Exists(reverbConfig)
}
// needsRedis checks if the project uses Redis based on .env configuration.
func needsRedis(dir string) bool {
m := getMedium()
envPath := filepath.Join(dir, ".env")
content, err := m.Read(envPath)
if err != nil {
return false
}
lines := strings.Split(content, "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
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 {
m := getMedium()
// 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 m.Exists(filepath.Join(dir, lf.file)) {
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 {
m := getMedium()
envPath := filepath.Join(dir, ".env")
content, err := m.Read(envPath)
if err != nil {
return ""
}
lines := strings.Split(content, "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
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 {
m := getMedium()
envPath := filepath.Join(dir, ".env")
content, err := m.Read(envPath)
if err != nil {
return ""
}
lines := strings.Split(content, "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
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
}

663
detect_test.go Normal file
View file

@ -0,0 +1,663 @@
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))
})
t.Run("project with octane but unreadable config file", func(t *testing.T) {
if os.Geteuid() == 0 {
t.Skip("root can read any file")
}
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 with no read permissions
configDir := filepath.Join(dir, "config")
err = os.MkdirAll(configDir, 0755)
require.NoError(t, err)
octanePath := filepath.Join(configDir, "octane.php")
err = os.WriteFile(octanePath, []byte("<?php return [];"), 0000)
require.NoError(t, err)
defer func() { _ = os.Chmod(octanePath, 0644) }() // Clean up
// Should return true (assume frankenphp if unreadable)
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 TestHasHorizon_Good(t *testing.T) {
t.Run("horizon config exists", func(t *testing.T) {
dir := t.TempDir()
configDir := filepath.Join(dir, "config")
err := os.MkdirAll(configDir, 0755)
require.NoError(t, err)
err = os.WriteFile(filepath.Join(configDir, "horizon.php"), []byte("<?php return [];"), 0644)
require.NoError(t, err)
assert.True(t, hasHorizon(dir))
})
}
func TestHasHorizon_Bad(t *testing.T) {
t.Run("horizon config missing", func(t *testing.T) {
dir := t.TempDir()
assert.False(t, hasHorizon(dir))
})
}
func TestHasReverb_Good(t *testing.T) {
t.Run("reverb config exists", func(t *testing.T) {
dir := t.TempDir()
configDir := filepath.Join(dir, "config")
err := os.MkdirAll(configDir, 0755)
require.NoError(t, err)
err = os.WriteFile(filepath.Join(configDir, "reverb.php"), []byte("<?php return [];"), 0644)
require.NoError(t, err)
assert.True(t, hasReverb(dir))
})
}
func TestHasReverb_Bad(t *testing.T) {
t.Run("reverb config missing", func(t *testing.T) {
dir := t.TempDir()
assert.False(t, hasReverb(dir))
})
}
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))
})
t.Run("SESSION_DRIVER=redis", func(t *testing.T) {
dir := t.TempDir()
envContent := "SESSION_DRIVER=redis"
err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644)
require.NoError(t, err)
assert.True(t, needsRedis(dir))
})
t.Run("BROADCAST_DRIVER=redis", func(t *testing.T) {
dir := t.TempDir()
envContent := "BROADCAST_DRIVER=redis"
err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644)
require.NoError(t, err)
assert.True(t, needsRedis(dir))
})
t.Run("REDIS_HOST remote (should be false for local dev env)", func(t *testing.T) {
dir := t.TempDir()
envContent := "REDIS_HOST=redis.example.com"
err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644)
require.NoError(t, err)
assert.False(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))
})
}
func TestIsFrankenPHPProject_ConfigWithoutFrankenPHP(t *testing.T) {
t.Run("octane config without frankenphp", 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 without frankenphp
configDir := filepath.Join(dir, "config")
err = os.MkdirAll(configDir, 0755)
require.NoError(t, err)
octaneConfig := `<?php
return [
'server' => 'swoole',
];`
err = os.WriteFile(filepath.Join(configDir, "octane.php"), []byte(octaneConfig), 0644)
require.NoError(t, err)
assert.False(t, IsFrankenPHPProject(dir))
})
}

398
dockerfile.go Normal file
View file

@ -0,0 +1,398 @@
package php
import (
"encoding/json"
"path/filepath"
"sort"
"strings"
"forge.lthn.ai/core/go/pkg/cli"
)
// DockerfileConfig holds configuration for generating a Dockerfile.
type DockerfileConfig struct {
// PHPVersion is the PHP version to use (default: "8.3").
PHPVersion string
// BaseImage is the base Docker image (default: "dunglas/frankenphp").
BaseImage string
// PHPExtensions is the list of PHP extensions to install.
PHPExtensions []string
// HasAssets indicates if the project has frontend assets to build.
HasAssets bool
// PackageManager is the Node.js package manager (npm, pnpm, yarn, bun).
PackageManager string
// IsLaravel indicates if this is a Laravel project.
IsLaravel bool
// HasOctane indicates if Laravel Octane is installed.
HasOctane bool
// UseAlpine uses the Alpine-based image (smaller).
UseAlpine bool
}
// GenerateDockerfile generates a Dockerfile for a PHP/Laravel project.
// It auto-detects dependencies from composer.json and project structure.
func GenerateDockerfile(dir string) (string, error) {
config, err := DetectDockerfileConfig(dir)
if err != nil {
return "", err
}
return GenerateDockerfileFromConfig(config), nil
}
// DetectDockerfileConfig detects configuration from project files.
func DetectDockerfileConfig(dir string) (*DockerfileConfig, error) {
m := getMedium()
config := &DockerfileConfig{
PHPVersion: "8.3",
BaseImage: "dunglas/frankenphp",
UseAlpine: true,
}
// Read composer.json
composerPath := filepath.Join(dir, "composer.json")
composerContent, err := m.Read(composerPath)
if err != nil {
return nil, cli.WrapVerb(err, "read", "composer.json")
}
var composer ComposerJSON
if err := json.Unmarshal([]byte(composerContent), &composer); err != nil {
return nil, cli.WrapVerb(err, "parse", "composer.json")
}
// Detect PHP version from composer.json
if phpVersion, ok := composer.Require["php"]; ok {
config.PHPVersion = extractPHPVersion(phpVersion)
}
// Detect if Laravel
if _, ok := composer.Require["laravel/framework"]; ok {
config.IsLaravel = true
}
// Detect if Octane
if _, ok := composer.Require["laravel/octane"]; ok {
config.HasOctane = true
}
// Detect required PHP extensions
config.PHPExtensions = detectPHPExtensions(composer)
// Detect frontend assets
config.HasAssets = hasNodeAssets(dir)
if config.HasAssets {
config.PackageManager = DetectPackageManager(dir)
}
return config, nil
}
// GenerateDockerfileFromConfig generates a Dockerfile from the given configuration.
func GenerateDockerfileFromConfig(config *DockerfileConfig) string {
var sb strings.Builder
// Base image
baseTag := cli.Sprintf("latest-php%s", config.PHPVersion)
if config.UseAlpine {
baseTag += "-alpine"
}
sb.WriteString("# Auto-generated Dockerfile for FrankenPHP\n")
sb.WriteString("# Generated by Core Framework\n\n")
// Multi-stage build for smaller images
if config.HasAssets {
// Frontend build stage
sb.WriteString("# Stage 1: Build frontend assets\n")
sb.WriteString("FROM node:20-alpine AS frontend\n\n")
sb.WriteString("WORKDIR /app\n\n")
// Copy package files based on package manager
switch config.PackageManager {
case "pnpm":
sb.WriteString("RUN corepack enable && corepack prepare pnpm@latest --activate\n\n")
sb.WriteString("COPY package.json pnpm-lock.yaml ./\n")
sb.WriteString("RUN pnpm install --frozen-lockfile\n\n")
case "yarn":
sb.WriteString("COPY package.json yarn.lock ./\n")
sb.WriteString("RUN yarn install --frozen-lockfile\n\n")
case "bun":
sb.WriteString("RUN npm install -g bun\n\n")
sb.WriteString("COPY package.json bun.lockb ./\n")
sb.WriteString("RUN bun install --frozen-lockfile\n\n")
default: // npm
sb.WriteString("COPY package.json package-lock.json ./\n")
sb.WriteString("RUN npm ci\n\n")
}
sb.WriteString("COPY . .\n\n")
// Build command
switch config.PackageManager {
case "pnpm":
sb.WriteString("RUN pnpm run build\n\n")
case "yarn":
sb.WriteString("RUN yarn build\n\n")
case "bun":
sb.WriteString("RUN bun run build\n\n")
default:
sb.WriteString("RUN npm run build\n\n")
}
}
// PHP build stage
stageNum := 2
if config.HasAssets {
sb.WriteString(cli.Sprintf("# Stage %d: PHP application\n", stageNum))
}
sb.WriteString(cli.Sprintf("FROM %s:%s AS app\n\n", config.BaseImage, baseTag))
sb.WriteString("WORKDIR /app\n\n")
// Install PHP extensions if needed
if len(config.PHPExtensions) > 0 {
sb.WriteString("# Install PHP extensions\n")
sb.WriteString(cli.Sprintf("RUN install-php-extensions %s\n\n", strings.Join(config.PHPExtensions, " ")))
}
// Copy composer files first for better caching
sb.WriteString("# Copy composer files\n")
sb.WriteString("COPY composer.json composer.lock ./\n\n")
// Install composer dependencies
sb.WriteString("# Install PHP dependencies\n")
sb.WriteString("RUN composer install --no-dev --no-scripts --optimize-autoloader --no-interaction\n\n")
// Copy application code
sb.WriteString("# Copy application code\n")
sb.WriteString("COPY . .\n\n")
// Run post-install scripts
sb.WriteString("# Run composer scripts\n")
sb.WriteString("RUN composer dump-autoload --optimize\n\n")
// Copy frontend assets if built
if config.HasAssets {
sb.WriteString("# Copy built frontend assets\n")
sb.WriteString("COPY --from=frontend /app/public/build public/build\n\n")
}
// Laravel-specific setup
if config.IsLaravel {
sb.WriteString("# Laravel setup\n")
sb.WriteString("RUN php artisan config:cache \\\n")
sb.WriteString(" && php artisan route:cache \\\n")
sb.WriteString(" && php artisan view:cache\n\n")
// Set permissions
sb.WriteString("# Set permissions for Laravel\n")
sb.WriteString("RUN chown -R www-data:www-data storage bootstrap/cache \\\n")
sb.WriteString(" && chmod -R 775 storage bootstrap/cache\n\n")
}
// Expose ports
sb.WriteString("# Expose ports\n")
sb.WriteString("EXPOSE 80 443\n\n")
// Health check
sb.WriteString("# Health check\n")
sb.WriteString("HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \\\n")
sb.WriteString(" CMD curl -f http://localhost/up || exit 1\n\n")
// Start command
sb.WriteString("# Start FrankenPHP\n")
if config.HasOctane {
sb.WriteString("CMD [\"php\", \"artisan\", \"octane:start\", \"--server=frankenphp\", \"--host=0.0.0.0\", \"--port=80\"]\n")
} else {
sb.WriteString("CMD [\"frankenphp\", \"run\", \"--config\", \"/etc/caddy/Caddyfile\"]\n")
}
return sb.String()
}
// ComposerJSON represents the structure of composer.json.
type ComposerJSON struct {
Name string `json:"name"`
Require map[string]string `json:"require"`
RequireDev map[string]string `json:"require-dev"`
}
// detectPHPExtensions detects required PHP extensions from composer.json.
func detectPHPExtensions(composer ComposerJSON) []string {
extensionMap := make(map[string]bool)
// Check for common packages and their required extensions
packageExtensions := map[string][]string{
// Database
"doctrine/dbal": {"pdo_mysql", "pdo_pgsql"},
"illuminate/database": {"pdo_mysql"},
"laravel/framework": {"pdo_mysql", "bcmath", "ctype", "fileinfo", "mbstring", "openssl", "tokenizer", "xml"},
"mongodb/mongodb": {"mongodb"},
"predis/predis": {"redis"},
"phpredis/phpredis": {"redis"},
"laravel/horizon": {"redis", "pcntl"},
"aws/aws-sdk-php": {"curl"},
"intervention/image": {"gd"},
"intervention/image-laravel": {"gd"},
"spatie/image": {"gd"},
"league/flysystem-aws-s3-v3": {"curl"},
"guzzlehttp/guzzle": {"curl"},
"nelmio/cors-bundle": {},
// Queues
"laravel/reverb": {"pcntl"},
"php-amqplib/php-amqplib": {"sockets"},
// Misc
"moneyphp/money": {"bcmath", "intl"},
"symfony/intl": {"intl"},
"nesbot/carbon": {"intl"},
"spatie/laravel-medialibrary": {"exif", "gd"},
}
// Check all require and require-dev dependencies
allDeps := make(map[string]string)
for pkg, ver := range composer.Require {
allDeps[pkg] = ver
}
for pkg, ver := range composer.RequireDev {
allDeps[pkg] = ver
}
// Find required extensions
for pkg := range allDeps {
if exts, ok := packageExtensions[pkg]; ok {
for _, ext := range exts {
extensionMap[ext] = true
}
}
// Check for direct ext- requirements
if strings.HasPrefix(pkg, "ext-") {
ext := strings.TrimPrefix(pkg, "ext-")
// Skip extensions that are built into PHP
builtIn := map[string]bool{
"json": true, "ctype": true, "iconv": true,
"session": true, "simplexml": true, "pdo": true,
"xml": true, "tokenizer": true,
}
if !builtIn[ext] {
extensionMap[ext] = true
}
}
}
// Convert to sorted slice
extensions := make([]string, 0, len(extensionMap))
for ext := range extensionMap {
extensions = append(extensions, ext)
}
sort.Strings(extensions)
return extensions
}
// extractPHPVersion extracts a clean PHP version from a composer constraint.
func extractPHPVersion(constraint string) string {
// Handle common formats: ^8.2, >=8.2, 8.2.*, ~8.2
constraint = strings.TrimLeft(constraint, "^>=~")
constraint = strings.TrimRight(constraint, ".*")
// Extract major.minor
parts := strings.Split(constraint, ".")
if len(parts) >= 2 {
return parts[0] + "." + parts[1]
}
if len(parts) == 1 {
return parts[0] + ".0"
}
return "8.3" // default
}
// hasNodeAssets checks if the project has frontend assets.
func hasNodeAssets(dir string) bool {
m := getMedium()
packageJSON := filepath.Join(dir, "package.json")
if !m.IsFile(packageJSON) {
return false
}
// Check for build script in package.json
content, err := m.Read(packageJSON)
if err != nil {
return false
}
var pkg struct {
Scripts map[string]string `json:"scripts"`
}
if err := json.Unmarshal([]byte(content), &pkg); err != nil {
return false
}
// Check if there's a build script
_, hasBuild := pkg.Scripts["build"]
return hasBuild
}
// GenerateDockerignore generates a .dockerignore file content for PHP projects.
func GenerateDockerignore(dir string) string {
var sb strings.Builder
sb.WriteString("# Git\n")
sb.WriteString(".git\n")
sb.WriteString(".gitignore\n")
sb.WriteString(".gitattributes\n\n")
sb.WriteString("# Node\n")
sb.WriteString("node_modules\n\n")
sb.WriteString("# Development\n")
sb.WriteString(".env\n")
sb.WriteString(".env.local\n")
sb.WriteString(".env.*.local\n")
sb.WriteString("*.log\n")
sb.WriteString(".phpunit.result.cache\n")
sb.WriteString("phpunit.xml\n")
sb.WriteString(".php-cs-fixer.cache\n")
sb.WriteString("phpstan.neon\n\n")
sb.WriteString("# IDE\n")
sb.WriteString(".idea\n")
sb.WriteString(".vscode\n")
sb.WriteString("*.swp\n")
sb.WriteString("*.swo\n\n")
sb.WriteString("# Laravel specific\n")
sb.WriteString("storage/app/*\n")
sb.WriteString("storage/logs/*\n")
sb.WriteString("storage/framework/cache/*\n")
sb.WriteString("storage/framework/sessions/*\n")
sb.WriteString("storage/framework/views/*\n")
sb.WriteString("bootstrap/cache/*\n\n")
sb.WriteString("# Build artifacts\n")
sb.WriteString("public/hot\n")
sb.WriteString("public/storage\n")
sb.WriteString("vendor\n\n")
sb.WriteString("# Docker\n")
sb.WriteString("Dockerfile*\n")
sb.WriteString("docker-compose*.yml\n")
sb.WriteString(".dockerignore\n\n")
sb.WriteString("# Documentation\n")
sb.WriteString("README.md\n")
sb.WriteString("CHANGELOG.md\n")
sb.WriteString("docs\n")
return sb.String()
}

634
dockerfile_test.go Normal file
View file

@ -0,0 +1,634 @@
package php
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGenerateDockerfile_Good(t *testing.T) {
t.Run("basic Laravel project", func(t *testing.T) {
dir := t.TempDir()
// Create composer.json
composerJSON := `{
"name": "test/laravel-project",
"require": {
"php": "^8.2",
"laravel/framework": "^11.0"
}
}`
err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644)
require.NoError(t, err)
// Create composer.lock
err = os.WriteFile(filepath.Join(dir, "composer.lock"), []byte("{}"), 0644)
require.NoError(t, err)
content, err := GenerateDockerfile(dir)
require.NoError(t, err)
// Check content
assert.Contains(t, content, "FROM dunglas/frankenphp")
assert.Contains(t, content, "php8.2")
assert.Contains(t, content, "COPY composer.json composer.lock")
assert.Contains(t, content, "composer install")
assert.Contains(t, content, "EXPOSE 80 443")
})
t.Run("Laravel project with Octane", func(t *testing.T) {
dir := t.TempDir()
composerJSON := `{
"name": "test/laravel-octane",
"require": {
"php": "^8.3",
"laravel/framework": "^11.0",
"laravel/octane": "^2.0"
}
}`
err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644)
require.NoError(t, err)
err = os.WriteFile(filepath.Join(dir, "composer.lock"), []byte("{}"), 0644)
require.NoError(t, err)
content, err := GenerateDockerfile(dir)
require.NoError(t, err)
assert.Contains(t, content, "php8.3")
assert.Contains(t, content, "octane:start")
})
t.Run("project with frontend assets", func(t *testing.T) {
dir := t.TempDir()
composerJSON := `{
"name": "test/laravel-vite",
"require": {
"php": "^8.3",
"laravel/framework": "^11.0"
}
}`
err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644)
require.NoError(t, err)
err = os.WriteFile(filepath.Join(dir, "composer.lock"), []byte("{}"), 0644)
require.NoError(t, err)
packageJSON := `{
"name": "test-app",
"scripts": {
"dev": "vite",
"build": "vite build"
}
}`
err = os.WriteFile(filepath.Join(dir, "package.json"), []byte(packageJSON), 0644)
require.NoError(t, err)
err = os.WriteFile(filepath.Join(dir, "package-lock.json"), []byte("{}"), 0644)
require.NoError(t, err)
content, err := GenerateDockerfile(dir)
require.NoError(t, err)
// Should have multi-stage build
assert.Contains(t, content, "FROM node:20-alpine AS frontend")
assert.Contains(t, content, "npm ci")
assert.Contains(t, content, "npm run build")
assert.Contains(t, content, "COPY --from=frontend")
})
t.Run("project with pnpm", func(t *testing.T) {
dir := t.TempDir()
composerJSON := `{
"name": "test/laravel-pnpm",
"require": {
"php": "^8.3",
"laravel/framework": "^11.0"
}
}`
err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644)
require.NoError(t, err)
err = os.WriteFile(filepath.Join(dir, "composer.lock"), []byte("{}"), 0644)
require.NoError(t, err)
packageJSON := `{
"name": "test-app",
"scripts": {
"build": "vite build"
}
}`
err = os.WriteFile(filepath.Join(dir, "package.json"), []byte(packageJSON), 0644)
require.NoError(t, err)
// Create pnpm-lock.yaml
err = os.WriteFile(filepath.Join(dir, "pnpm-lock.yaml"), []byte("lockfileVersion: 6.0"), 0644)
require.NoError(t, err)
content, err := GenerateDockerfile(dir)
require.NoError(t, err)
assert.Contains(t, content, "pnpm install")
assert.Contains(t, content, "pnpm run build")
})
t.Run("project with Redis dependency", func(t *testing.T) {
dir := t.TempDir()
composerJSON := `{
"name": "test/laravel-redis",
"require": {
"php": "^8.3",
"laravel/framework": "^11.0",
"predis/predis": "^2.0"
}
}`
err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644)
require.NoError(t, err)
err = os.WriteFile(filepath.Join(dir, "composer.lock"), []byte("{}"), 0644)
require.NoError(t, err)
content, err := GenerateDockerfile(dir)
require.NoError(t, err)
assert.Contains(t, content, "install-php-extensions")
assert.Contains(t, content, "redis")
})
t.Run("project with explicit ext- requirements", func(t *testing.T) {
dir := t.TempDir()
composerJSON := `{
"name": "test/with-extensions",
"require": {
"php": "^8.3",
"ext-gd": "*",
"ext-imagick": "*",
"ext-intl": "*"
}
}`
err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644)
require.NoError(t, err)
err = os.WriteFile(filepath.Join(dir, "composer.lock"), []byte("{}"), 0644)
require.NoError(t, err)
content, err := GenerateDockerfile(dir)
require.NoError(t, err)
assert.Contains(t, content, "install-php-extensions")
assert.Contains(t, content, "gd")
assert.Contains(t, content, "imagick")
assert.Contains(t, content, "intl")
})
}
func TestGenerateDockerfile_Bad(t *testing.T) {
t.Run("missing composer.json", func(t *testing.T) {
dir := t.TempDir()
_, err := GenerateDockerfile(dir)
assert.Error(t, err)
assert.Contains(t, err.Error(), "composer.json")
})
t.Run("invalid composer.json", func(t *testing.T) {
dir := t.TempDir()
err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte("not json{"), 0644)
require.NoError(t, err)
_, err = GenerateDockerfile(dir)
assert.Error(t, err)
})
}
func TestDetectDockerfileConfig_Good(t *testing.T) {
t.Run("full Laravel project", func(t *testing.T) {
dir := t.TempDir()
composerJSON := `{
"name": "test/full-laravel",
"require": {
"php": "^8.3",
"laravel/framework": "^11.0",
"laravel/octane": "^2.0",
"predis/predis": "^2.0",
"intervention/image": "^3.0"
}
}`
err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644)
require.NoError(t, err)
packageJSON := `{"scripts": {"build": "vite build"}}`
err = os.WriteFile(filepath.Join(dir, "package.json"), []byte(packageJSON), 0644)
require.NoError(t, err)
err = os.WriteFile(filepath.Join(dir, "yarn.lock"), []byte(""), 0644)
require.NoError(t, err)
config, err := DetectDockerfileConfig(dir)
require.NoError(t, err)
assert.Equal(t, "8.3", config.PHPVersion)
assert.True(t, config.IsLaravel)
assert.True(t, config.HasOctane)
assert.True(t, config.HasAssets)
assert.Equal(t, "yarn", config.PackageManager)
assert.Contains(t, config.PHPExtensions, "redis")
assert.Contains(t, config.PHPExtensions, "gd")
})
}
func TestDetectDockerfileConfig_Bad(t *testing.T) {
t.Run("non-existent directory", func(t *testing.T) {
_, err := DetectDockerfileConfig("/non/existent/path")
assert.Error(t, err)
})
}
func TestExtractPHPVersion_Good(t *testing.T) {
tests := []struct {
constraint string
expected string
}{
{"^8.2", "8.2"},
{"^8.3", "8.3"},
{">=8.2", "8.2"},
{"~8.2", "8.2"},
{"8.2.*", "8.2"},
{"8.2.0", "8.2"},
{"8", "8.0"},
}
for _, tt := range tests {
t.Run(tt.constraint, func(t *testing.T) {
result := extractPHPVersion(tt.constraint)
assert.Equal(t, tt.expected, result)
})
}
}
func TestDetectPHPExtensions_Good(t *testing.T) {
t.Run("detects Redis from predis", func(t *testing.T) {
composer := ComposerJSON{
Require: map[string]string{
"predis/predis": "^2.0",
},
}
extensions := detectPHPExtensions(composer)
assert.Contains(t, extensions, "redis")
})
t.Run("detects GD from intervention/image", func(t *testing.T) {
composer := ComposerJSON{
Require: map[string]string{
"intervention/image": "^3.0",
},
}
extensions := detectPHPExtensions(composer)
assert.Contains(t, extensions, "gd")
})
t.Run("detects multiple extensions from Laravel", func(t *testing.T) {
composer := ComposerJSON{
Require: map[string]string{
"laravel/framework": "^11.0",
},
}
extensions := detectPHPExtensions(composer)
assert.Contains(t, extensions, "pdo_mysql")
assert.Contains(t, extensions, "bcmath")
})
t.Run("detects explicit ext- requirements", func(t *testing.T) {
composer := ComposerJSON{
Require: map[string]string{
"ext-gd": "*",
"ext-imagick": "*",
},
}
extensions := detectPHPExtensions(composer)
assert.Contains(t, extensions, "gd")
assert.Contains(t, extensions, "imagick")
})
t.Run("skips built-in extensions", func(t *testing.T) {
composer := ComposerJSON{
Require: map[string]string{
"ext-json": "*",
"ext-session": "*",
"ext-pdo": "*",
},
}
extensions := detectPHPExtensions(composer)
assert.NotContains(t, extensions, "json")
assert.NotContains(t, extensions, "session")
assert.NotContains(t, extensions, "pdo")
})
t.Run("sorts extensions alphabetically", func(t *testing.T) {
composer := ComposerJSON{
Require: map[string]string{
"ext-zip": "*",
"ext-gd": "*",
"ext-intl": "*",
},
}
extensions := detectPHPExtensions(composer)
// Check they are sorted
for i := 1; i < len(extensions); i++ {
assert.True(t, extensions[i-1] < extensions[i],
"extensions should be sorted: %v", extensions)
}
})
}
func TestHasNodeAssets_Good(t *testing.T) {
t.Run("with build script", func(t *testing.T) {
dir := t.TempDir()
packageJSON := `{
"name": "test",
"scripts": {
"dev": "vite",
"build": "vite build"
}
}`
err := os.WriteFile(filepath.Join(dir, "package.json"), []byte(packageJSON), 0644)
require.NoError(t, err)
assert.True(t, hasNodeAssets(dir))
})
}
func TestHasNodeAssets_Bad(t *testing.T) {
t.Run("no package.json", func(t *testing.T) {
dir := t.TempDir()
assert.False(t, hasNodeAssets(dir))
})
t.Run("no build script", func(t *testing.T) {
dir := t.TempDir()
packageJSON := `{
"name": "test",
"scripts": {
"dev": "vite"
}
}`
err := os.WriteFile(filepath.Join(dir, "package.json"), []byte(packageJSON), 0644)
require.NoError(t, err)
assert.False(t, hasNodeAssets(dir))
})
t.Run("invalid package.json", func(t *testing.T) {
dir := t.TempDir()
err := os.WriteFile(filepath.Join(dir, "package.json"), []byte("invalid{"), 0644)
require.NoError(t, err)
assert.False(t, hasNodeAssets(dir))
})
}
func TestGenerateDockerignore_Good(t *testing.T) {
t.Run("generates complete dockerignore", func(t *testing.T) {
dir := t.TempDir()
content := GenerateDockerignore(dir)
// Check key entries
assert.Contains(t, content, ".git")
assert.Contains(t, content, "node_modules")
assert.Contains(t, content, ".env")
assert.Contains(t, content, "vendor")
assert.Contains(t, content, "storage/logs/*")
assert.Contains(t, content, ".idea")
assert.Contains(t, content, ".vscode")
})
}
func TestGenerateDockerfileFromConfig_Good(t *testing.T) {
t.Run("minimal config", func(t *testing.T) {
config := &DockerfileConfig{
PHPVersion: "8.3",
BaseImage: "dunglas/frankenphp",
UseAlpine: true,
}
content := GenerateDockerfileFromConfig(config)
assert.Contains(t, content, "FROM dunglas/frankenphp:latest-php8.3-alpine")
assert.Contains(t, content, "WORKDIR /app")
assert.Contains(t, content, "COPY composer.json composer.lock")
assert.Contains(t, content, "EXPOSE 80 443")
})
t.Run("with extensions", func(t *testing.T) {
config := &DockerfileConfig{
PHPVersion: "8.3",
BaseImage: "dunglas/frankenphp",
UseAlpine: true,
PHPExtensions: []string{"redis", "gd", "intl"},
}
content := GenerateDockerfileFromConfig(config)
assert.Contains(t, content, "install-php-extensions redis gd intl")
})
t.Run("Laravel with Octane", func(t *testing.T) {
config := &DockerfileConfig{
PHPVersion: "8.3",
BaseImage: "dunglas/frankenphp",
UseAlpine: true,
IsLaravel: true,
HasOctane: true,
}
content := GenerateDockerfileFromConfig(config)
assert.Contains(t, content, "php artisan config:cache")
assert.Contains(t, content, "php artisan route:cache")
assert.Contains(t, content, "php artisan view:cache")
assert.Contains(t, content, "chown -R www-data:www-data storage")
assert.Contains(t, content, "octane:start")
})
t.Run("with frontend assets", func(t *testing.T) {
config := &DockerfileConfig{
PHPVersion: "8.3",
BaseImage: "dunglas/frankenphp",
UseAlpine: true,
HasAssets: true,
PackageManager: "npm",
}
content := GenerateDockerfileFromConfig(config)
// Multi-stage build
assert.Contains(t, content, "FROM node:20-alpine AS frontend")
assert.Contains(t, content, "COPY package.json package-lock.json")
assert.Contains(t, content, "RUN npm ci")
assert.Contains(t, content, "RUN npm run build")
assert.Contains(t, content, "COPY --from=frontend /app/public/build public/build")
})
t.Run("with yarn", func(t *testing.T) {
config := &DockerfileConfig{
PHPVersion: "8.3",
BaseImage: "dunglas/frankenphp",
UseAlpine: true,
HasAssets: true,
PackageManager: "yarn",
}
content := GenerateDockerfileFromConfig(config)
assert.Contains(t, content, "COPY package.json yarn.lock")
assert.Contains(t, content, "yarn install --frozen-lockfile")
assert.Contains(t, content, "yarn build")
})
t.Run("with bun", func(t *testing.T) {
config := &DockerfileConfig{
PHPVersion: "8.3",
BaseImage: "dunglas/frankenphp",
UseAlpine: true,
HasAssets: true,
PackageManager: "bun",
}
content := GenerateDockerfileFromConfig(config)
assert.Contains(t, content, "npm install -g bun")
assert.Contains(t, content, "COPY package.json bun.lockb")
assert.Contains(t, content, "bun install --frozen-lockfile")
assert.Contains(t, content, "bun run build")
})
t.Run("non-alpine image", func(t *testing.T) {
config := &DockerfileConfig{
PHPVersion: "8.3",
BaseImage: "dunglas/frankenphp",
UseAlpine: false,
}
content := GenerateDockerfileFromConfig(config)
assert.Contains(t, content, "FROM dunglas/frankenphp:latest-php8.3 AS app")
assert.NotContains(t, content, "alpine")
})
}
func TestIsPHPProject_Good(t *testing.T) {
t.Run("project with composer.json", func(t *testing.T) {
dir := t.TempDir()
err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte("{}"), 0644)
require.NoError(t, err)
assert.True(t, IsPHPProject(dir))
})
}
func TestIsPHPProject_Bad(t *testing.T) {
t.Run("project without composer.json", func(t *testing.T) {
dir := t.TempDir()
assert.False(t, IsPHPProject(dir))
})
t.Run("non-existent directory", func(t *testing.T) {
assert.False(t, IsPHPProject("/non/existent/path"))
})
}
func TestExtractPHPVersion_Edge(t *testing.T) {
t.Run("handles single major version", func(t *testing.T) {
result := extractPHPVersion("8")
assert.Equal(t, "8.0", result)
})
}
func TestDetectPHPExtensions_RequireDev(t *testing.T) {
t.Run("detects extensions from require-dev", func(t *testing.T) {
composer := ComposerJSON{
RequireDev: map[string]string{
"predis/predis": "^2.0",
},
}
extensions := detectPHPExtensions(composer)
assert.Contains(t, extensions, "redis")
})
}
func TestDockerfileStructure_Good(t *testing.T) {
t.Run("Dockerfile has proper structure", func(t *testing.T) {
dir := t.TempDir()
composerJSON := `{
"name": "test/app",
"require": {
"php": "^8.3",
"laravel/framework": "^11.0",
"laravel/octane": "^2.0",
"predis/predis": "^2.0"
}
}`
err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644)
require.NoError(t, err)
err = os.WriteFile(filepath.Join(dir, "composer.lock"), []byte("{}"), 0644)
require.NoError(t, err)
packageJSON := `{"scripts": {"build": "vite build"}}`
err = os.WriteFile(filepath.Join(dir, "package.json"), []byte(packageJSON), 0644)
require.NoError(t, err)
err = os.WriteFile(filepath.Join(dir, "package-lock.json"), []byte("{}"), 0644)
require.NoError(t, err)
content, err := GenerateDockerfile(dir)
require.NoError(t, err)
lines := strings.Split(content, "\n")
var fromCount, workdirCount, copyCount, runCount, exposeCount, cmdCount int
for _, line := range lines {
trimmed := strings.TrimSpace(line)
switch {
case strings.HasPrefix(trimmed, "FROM "):
fromCount++
case strings.HasPrefix(trimmed, "WORKDIR "):
workdirCount++
case strings.HasPrefix(trimmed, "COPY "):
copyCount++
case strings.HasPrefix(trimmed, "RUN "):
runCount++
case strings.HasPrefix(trimmed, "EXPOSE "):
exposeCount++
case strings.HasPrefix(trimmed, "CMD ["):
// Only count actual CMD instructions, not HEALTHCHECK CMD
cmdCount++
}
}
// Multi-stage build should have 2 FROM statements
assert.Equal(t, 2, fromCount, "should have 2 FROM statements for multi-stage build")
// Should have proper structure
assert.GreaterOrEqual(t, workdirCount, 1, "should have WORKDIR")
assert.GreaterOrEqual(t, copyCount, 3, "should have multiple COPY statements")
assert.GreaterOrEqual(t, runCount, 2, "should have multiple RUN statements")
assert.Equal(t, 1, exposeCount, "should have exactly one EXPOSE")
assert.Equal(t, 1, cmdCount, "should have exactly one CMD")
})
}

25
go.mod Normal file
View file

@ -0,0 +1,25 @@
module forge.lthn.ai/core/php
go 1.25.5
require (
forge.lthn.ai/core/go v0.0.0
github.com/spf13/cobra v1.10.2
github.com/stretchr/testify v1.11.1
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/ProtonMail/go-crypto v1.3.0 // indirect
github.com/cloudflare/circl v1.6.3 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/spf13/pflag v1.0.10 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/term v0.40.0 // indirect
golang.org/x/text v0.34.0 // indirect
)
replace forge.lthn.ai/core/go => ../go

32
go.sum Normal file
View file

@ -0,0 +1,32 @@
github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw=
github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE=
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

16
i18n.go Normal file
View file

@ -0,0 +1,16 @@
// Package php provides PHP/Laravel development tools.
package php
import (
"embed"
"forge.lthn.ai/core/go/pkg/i18n"
)
//go:embed locales/*.json
var localeFS embed.FS
func init() {
// Register PHP translations with the i18n system
i18n.RegisterLocales(localeFS, "locales")
}

147
locales/en_GB.json Normal file
View file

@ -0,0 +1,147 @@
{
"cmd": {
"php": {
"short": "Laravel/PHP development tools",
"long": "Laravel and PHP development tools including testing, formatting, static analysis, and deployment",
"label": {
"php": "PHP:",
"audit": "Audit:",
"psalm": "Psalm:",
"rector": "Rector:",
"security": "Security:",
"infection": "Infection:",
"info": "Info:",
"setup": "Setup:"
},
"error": {
"not_php": "Not a PHP project (no composer.json found)",
"fmt_failed": "Formatting failed",
"fmt_issues": "Style issues found",
"analysis_issues": "Analysis errors found",
"audit_failed": "Audit failed",
"vulns_found": "Vulnerabilities found",
"psalm_not_installed": "Psalm not installed",
"psalm_issues": "Psalm found type errors",
"rector_not_installed": "Rector not installed",
"rector_failed": "Rector failed",
"infection_not_installed": "Infection not installed",
"infection_failed": "Mutation testing failed",
"security_failed": "Security check failed",
"critical_high_issues": "Critical or high severity issues found"
},
"test": {
"short": "Run PHPUnit/Pest tests",
"long": "Run PHPUnit or Pest tests with optional filtering, parallel execution, and coverage",
"flag": {
"parallel": "Run tests in parallel",
"coverage": "Generate code coverage report",
"filter": "Filter tests by name",
"group": "Run only tests in this group"
}
},
"fmt": {
"short": "Format PHP code with Laravel Pint",
"long": "Format PHP code using Laravel Pint code style fixer",
"no_formatter": "No code formatter found (install laravel/pint)",
"no_issues": "No style issues found",
"formatting": "Formatting with {{.Formatter}}...",
"flag": {
"fix": "Fix style issues (default: check only)"
}
},
"analyse": {
"short": "Run PHPStan static analysis",
"long": "Run PHPStan/Larastan for static code analysis",
"no_analyser": "No static analyser found (install phpstan/phpstan or nunomaduro/larastan)",
"flag": {
"level": "Analysis level (0-9, default: from config)",
"memory": "Memory limit (e.g., 2G)"
}
},
"audit": {
"short": "Security audit for dependencies",
"long": "Audit Composer and NPM dependencies for known vulnerabilities",
"scanning": "Scanning dependencies for vulnerabilities...",
"secure": "No vulnerabilities",
"error": "Audit error",
"vulnerabilities": "{{.Count}} vulnerabilities found",
"found_vulns": "Found {{.Count}} vulnerabilities",
"all_secure": "All dependencies secure",
"completed_errors": "Audit completed with errors",
"flag": {
"fix": "Attempt to fix vulnerabilities"
}
},
"psalm": {
"short": "Run Psalm static analysis",
"long": "Run Psalm for deep static analysis and type checking",
"not_found": "Psalm not found",
"install": "composer require --dev vimeo/psalm",
"setup": "vendor/bin/psalm --init",
"analysing": "Analysing with Psalm...",
"analysing_fixing": "Analysing and fixing with Psalm...",
"flag": {
"level": "Analysis level (1-8)",
"baseline": "Generate or update baseline",
"show_info": "Show informational issues"
}
},
"rector": {
"short": "Automated code refactoring",
"long": "Run Rector for automated code upgrades and refactoring",
"not_found": "Rector not found",
"install": "composer require --dev rector/rector",
"setup": "vendor/bin/rector init",
"analysing": "Analysing code for refactoring opportunities...",
"refactoring": "Refactoring code...",
"no_changes": "No refactoring changes needed",
"changes_suggested": "Rector suggests changes (run with --fix to apply)",
"flag": {
"fix": "Apply refactoring changes",
"diff": "Show diff of changes",
"clear_cache": "Clear Rector cache before running"
}
},
"infection": {
"short": "Mutation testing for test quality",
"long": "Run Infection mutation testing to measure test suite quality",
"not_found": "Infection not found",
"install": "composer require --dev infection/infection",
"note": "This may take a while depending on test suite size",
"complete": "Mutation testing complete",
"flag": {
"min_msi": "Minimum Mutation Score Indicator (0-100)",
"min_covered_msi": "Minimum covered code MSI (0-100)",
"threads": "Number of parallel threads",
"filter": "Filter mutants by file path",
"only_covered": "Only mutate covered code"
}
},
"security": {
"short": "Security vulnerability scanning",
"long": "Run comprehensive security checks on PHP codebase",
"checks_suffix": " CHECKS",
"summary": "Security scan complete",
"passed": "Passed:",
"critical": "Critical:",
"high": "High:",
"medium": "Medium:",
"low": "Low:",
"flag": {
"severity": "Minimum severity to report (low, medium, high, critical)",
"sarif": "Output in SARIF format",
"url": "Application URL for runtime checks"
}
},
"qa": {
"short": "Run full QA pipeline",
"long": "Run comprehensive quality assurance: audit, format, analyse, test, and more",
"flag": {
"quick": "Run quick checks only (audit, fmt, stan)",
"full": "Run all stages including slow checks",
"fix": "Auto-fix issues where possible"
}
}
}
}
}

308
packages.go Normal file
View file

@ -0,0 +1,308 @@
package php
import (
"encoding/json"
"os"
"os/exec"
"path/filepath"
"forge.lthn.ai/core/go/pkg/cli"
)
// LinkedPackage represents a linked local package.
type LinkedPackage struct {
Name string `json:"name"`
Path string `json:"path"`
Version string `json:"version"`
}
// composerRepository represents a composer repository entry.
type composerRepository struct {
Type string `json:"type"`
URL string `json:"url,omitempty"`
Options map[string]any `json:"options,omitempty"`
}
// readComposerJSON reads and parses composer.json from the given directory.
func readComposerJSON(dir string) (map[string]json.RawMessage, error) {
m := getMedium()
composerPath := filepath.Join(dir, "composer.json")
content, err := m.Read(composerPath)
if err != nil {
return nil, cli.WrapVerb(err, "read", "composer.json")
}
var raw map[string]json.RawMessage
if err := json.Unmarshal([]byte(content), &raw); err != nil {
return nil, cli.WrapVerb(err, "parse", "composer.json")
}
return raw, nil
}
// writeComposerJSON writes the composer.json to the given directory.
func writeComposerJSON(dir string, raw map[string]json.RawMessage) error {
m := getMedium()
composerPath := filepath.Join(dir, "composer.json")
data, err := json.MarshalIndent(raw, "", " ")
if err != nil {
return cli.WrapVerb(err, "marshal", "composer.json")
}
// Add trailing newline
content := string(data) + "\n"
if err := m.Write(composerPath, content); err != nil {
return cli.WrapVerb(err, "write", "composer.json")
}
return nil
}
// getRepositories extracts repositories from raw composer.json.
func getRepositories(raw map[string]json.RawMessage) ([]composerRepository, error) {
reposRaw, ok := raw["repositories"]
if !ok {
return []composerRepository{}, nil
}
var repos []composerRepository
if err := json.Unmarshal(reposRaw, &repos); err != nil {
return nil, cli.WrapVerb(err, "parse", "repositories")
}
return repos, nil
}
// setRepositories sets repositories in raw composer.json.
func setRepositories(raw map[string]json.RawMessage, repos []composerRepository) error {
if len(repos) == 0 {
delete(raw, "repositories")
return nil
}
reposData, err := json.Marshal(repos)
if err != nil {
return cli.WrapVerb(err, "marshal", "repositories")
}
raw["repositories"] = reposData
return nil
}
// getPackageInfo reads package name and version from a composer.json in the given path.
func getPackageInfo(packagePath string) (name, version string, err error) {
m := getMedium()
composerPath := filepath.Join(packagePath, "composer.json")
content, err := m.Read(composerPath)
if err != nil {
return "", "", cli.WrapVerb(err, "read", "package composer.json")
}
var pkg struct {
Name string `json:"name"`
Version string `json:"version"`
}
if err := json.Unmarshal([]byte(content), &pkg); err != nil {
return "", "", cli.WrapVerb(err, "parse", "package composer.json")
}
if pkg.Name == "" {
return "", "", cli.Err("package name not found in composer.json")
}
return pkg.Name, pkg.Version, nil
}
// LinkPackages adds path repositories to composer.json for local package development.
func LinkPackages(dir string, packages []string) error {
if !IsPHPProject(dir) {
return cli.Err("not a PHP project (missing composer.json)")
}
raw, err := readComposerJSON(dir)
if err != nil {
return err
}
repos, err := getRepositories(raw)
if err != nil {
return err
}
for _, packagePath := range packages {
// Resolve absolute path
absPath, err := filepath.Abs(packagePath)
if err != nil {
return cli.Err("failed to resolve path %s: %w", packagePath, err)
}
// Verify the path exists and has a composer.json
if !IsPHPProject(absPath) {
return cli.Err("not a PHP package (missing composer.json): %s", absPath)
}
// Get package name for validation
pkgName, _, err := getPackageInfo(absPath)
if err != nil {
return cli.Err("failed to get package info from %s: %w", absPath, err)
}
// Check if already linked
alreadyLinked := false
for _, repo := range repos {
if repo.Type == "path" && repo.URL == absPath {
alreadyLinked = true
break
}
}
if alreadyLinked {
continue
}
// Add path repository
repos = append(repos, composerRepository{
Type: "path",
URL: absPath,
Options: map[string]any{
"symlink": true,
},
})
cli.Print("Linked: %s -> %s\n", pkgName, absPath)
}
if err := setRepositories(raw, repos); err != nil {
return err
}
return writeComposerJSON(dir, raw)
}
// UnlinkPackages removes path repositories from composer.json.
func UnlinkPackages(dir string, packages []string) error {
if !IsPHPProject(dir) {
return cli.Err("not a PHP project (missing composer.json)")
}
raw, err := readComposerJSON(dir)
if err != nil {
return err
}
repos, err := getRepositories(raw)
if err != nil {
return err
}
// Build set of packages to unlink
toUnlink := make(map[string]bool)
for _, pkg := range packages {
toUnlink[pkg] = true
}
// Filter out unlinked packages
filtered := make([]composerRepository, 0, len(repos))
for _, repo := range repos {
if repo.Type != "path" {
filtered = append(filtered, repo)
continue
}
// Check if this repo should be unlinked
shouldUnlink := false
// Try to get package name from the path
if IsPHPProject(repo.URL) {
pkgName, _, err := getPackageInfo(repo.URL)
if err == nil && toUnlink[pkgName] {
shouldUnlink = true
cli.Print("Unlinked: %s\n", pkgName)
}
}
// Also check if path matches any of the provided names
for pkg := range toUnlink {
if repo.URL == pkg || filepath.Base(repo.URL) == pkg {
shouldUnlink = true
cli.Print("Unlinked: %s\n", repo.URL)
break
}
}
if !shouldUnlink {
filtered = append(filtered, repo)
}
}
if err := setRepositories(raw, filtered); err != nil {
return err
}
return writeComposerJSON(dir, raw)
}
// UpdatePackages runs composer update for specific packages.
func UpdatePackages(dir string, packages []string) error {
if !IsPHPProject(dir) {
return cli.Err("not a PHP project (missing composer.json)")
}
args := []string{"update"}
args = append(args, packages...)
cmd := exec.Command("composer", args...)
cmd.Dir = dir
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
// ListLinkedPackages returns all path repositories from composer.json.
func ListLinkedPackages(dir string) ([]LinkedPackage, error) {
if !IsPHPProject(dir) {
return nil, cli.Err("not a PHP project (missing composer.json)")
}
raw, err := readComposerJSON(dir)
if err != nil {
return nil, err
}
repos, err := getRepositories(raw)
if err != nil {
return nil, err
}
linked := make([]LinkedPackage, 0)
for _, repo := range repos {
if repo.Type != "path" {
continue
}
pkg := LinkedPackage{
Path: repo.URL,
}
// Try to get package info
if IsPHPProject(repo.URL) {
name, version, err := getPackageInfo(repo.URL)
if err == nil {
pkg.Name = name
pkg.Version = version
}
}
if pkg.Name == "" {
pkg.Name = filepath.Base(repo.URL)
}
linked = append(linked, pkg)
}
return linked, nil
}

543
packages_test.go Normal file
View file

@ -0,0 +1,543 @@
package php
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestReadComposerJSON_Good(t *testing.T) {
t.Run("reads valid composer.json", func(t *testing.T) {
dir := t.TempDir()
composerJSON := `{
"name": "test/project",
"require": {
"php": "^8.2"
}
}`
err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644)
require.NoError(t, err)
raw, err := readComposerJSON(dir)
assert.NoError(t, err)
assert.NotNil(t, raw)
assert.Contains(t, string(raw["name"]), "test/project")
})
t.Run("preserves all fields", func(t *testing.T) {
dir := t.TempDir()
composerJSON := `{
"name": "test/project",
"description": "Test project",
"require": {"php": "^8.2"},
"autoload": {"psr-4": {"App\\": "src/"}}
}`
err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644)
require.NoError(t, err)
raw, err := readComposerJSON(dir)
assert.NoError(t, err)
assert.Contains(t, string(raw["autoload"]), "psr-4")
})
}
func TestReadComposerJSON_Bad(t *testing.T) {
t.Run("missing composer.json", func(t *testing.T) {
dir := t.TempDir()
_, err := readComposerJSON(dir)
assert.Error(t, err)
assert.Contains(t, err.Error(), "Failed to read composer.json")
})
t.Run("invalid JSON", func(t *testing.T) {
dir := t.TempDir()
err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte("not json{"), 0644)
require.NoError(t, err)
_, err = readComposerJSON(dir)
assert.Error(t, err)
assert.Contains(t, err.Error(), "Failed to parse composer.json")
})
}
func TestWriteComposerJSON_Good(t *testing.T) {
t.Run("writes valid composer.json", func(t *testing.T) {
dir := t.TempDir()
raw := make(map[string]json.RawMessage)
raw["name"] = json.RawMessage(`"test/project"`)
err := writeComposerJSON(dir, raw)
assert.NoError(t, err)
// Verify file was written
content, err := os.ReadFile(filepath.Join(dir, "composer.json"))
assert.NoError(t, err)
assert.Contains(t, string(content), "test/project")
// Verify trailing newline
assert.True(t, content[len(content)-1] == '\n')
})
t.Run("pretty prints with indentation", func(t *testing.T) {
dir := t.TempDir()
raw := make(map[string]json.RawMessage)
raw["name"] = json.RawMessage(`"test/project"`)
raw["require"] = json.RawMessage(`{"php":"^8.2"}`)
err := writeComposerJSON(dir, raw)
assert.NoError(t, err)
content, err := os.ReadFile(filepath.Join(dir, "composer.json"))
assert.NoError(t, err)
// Should be indented
assert.Contains(t, string(content), " ")
})
}
func TestWriteComposerJSON_Bad(t *testing.T) {
t.Run("fails for non-existent directory", func(t *testing.T) {
raw := make(map[string]json.RawMessage)
raw["name"] = json.RawMessage(`"test/project"`)
err := writeComposerJSON("/non/existent/path", raw)
assert.Error(t, err)
assert.Contains(t, err.Error(), "Failed to write composer.json")
})
}
func TestGetRepositories_Good(t *testing.T) {
t.Run("returns empty slice when no repositories", func(t *testing.T) {
raw := make(map[string]json.RawMessage)
raw["name"] = json.RawMessage(`"test/project"`)
repos, err := getRepositories(raw)
assert.NoError(t, err)
assert.Empty(t, repos)
})
t.Run("parses existing repositories", func(t *testing.T) {
raw := make(map[string]json.RawMessage)
raw["name"] = json.RawMessage(`"test/project"`)
raw["repositories"] = json.RawMessage(`[{"type":"path","url":"/path/to/package"}]`)
repos, err := getRepositories(raw)
assert.NoError(t, err)
assert.Len(t, repos, 1)
assert.Equal(t, "path", repos[0].Type)
assert.Equal(t, "/path/to/package", repos[0].URL)
})
t.Run("parses repositories with options", func(t *testing.T) {
raw := make(map[string]json.RawMessage)
raw["repositories"] = json.RawMessage(`[{"type":"path","url":"/path","options":{"symlink":true}}]`)
repos, err := getRepositories(raw)
assert.NoError(t, err)
assert.Len(t, repos, 1)
assert.NotNil(t, repos[0].Options)
assert.Equal(t, true, repos[0].Options["symlink"])
})
}
func TestGetRepositories_Bad(t *testing.T) {
t.Run("fails for invalid repositories JSON", func(t *testing.T) {
raw := make(map[string]json.RawMessage)
raw["repositories"] = json.RawMessage(`not valid json`)
_, err := getRepositories(raw)
assert.Error(t, err)
assert.Contains(t, err.Error(), "Failed to parse repositories")
})
}
func TestSetRepositories_Good(t *testing.T) {
t.Run("sets repositories", func(t *testing.T) {
raw := make(map[string]json.RawMessage)
repos := []composerRepository{
{Type: "path", URL: "/path/to/package"},
}
err := setRepositories(raw, repos)
assert.NoError(t, err)
assert.Contains(t, string(raw["repositories"]), "/path/to/package")
})
t.Run("removes repositories key when empty", func(t *testing.T) {
raw := make(map[string]json.RawMessage)
raw["repositories"] = json.RawMessage(`[{"type":"path"}]`)
err := setRepositories(raw, []composerRepository{})
assert.NoError(t, err)
_, exists := raw["repositories"]
assert.False(t, exists)
})
}
func TestGetPackageInfo_Good(t *testing.T) {
t.Run("extracts package name and version", func(t *testing.T) {
dir := t.TempDir()
composerJSON := `{
"name": "vendor/package",
"version": "1.0.0"
}`
err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644)
require.NoError(t, err)
name, version, err := getPackageInfo(dir)
assert.NoError(t, err)
assert.Equal(t, "vendor/package", name)
assert.Equal(t, "1.0.0", version)
})
t.Run("works without version", func(t *testing.T) {
dir := t.TempDir()
composerJSON := `{
"name": "vendor/package"
}`
err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644)
require.NoError(t, err)
name, version, err := getPackageInfo(dir)
assert.NoError(t, err)
assert.Equal(t, "vendor/package", name)
assert.Equal(t, "", version)
})
}
func TestGetPackageInfo_Bad(t *testing.T) {
t.Run("missing composer.json", func(t *testing.T) {
dir := t.TempDir()
_, _, err := getPackageInfo(dir)
assert.Error(t, err)
assert.Contains(t, err.Error(), "Failed to read package composer.json")
})
t.Run("invalid JSON", func(t *testing.T) {
dir := t.TempDir()
err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte("not json{"), 0644)
require.NoError(t, err)
_, _, err = getPackageInfo(dir)
assert.Error(t, err)
assert.Contains(t, err.Error(), "Failed to parse package composer.json")
})
t.Run("missing name", func(t *testing.T) {
dir := t.TempDir()
composerJSON := `{"version": "1.0.0"}`
err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644)
require.NoError(t, err)
_, _, err = getPackageInfo(dir)
assert.Error(t, err)
assert.Contains(t, err.Error(), "package name not found")
})
}
func TestLinkPackages_Good(t *testing.T) {
t.Run("links a package", func(t *testing.T) {
// Create project directory
projectDir := t.TempDir()
err := os.WriteFile(filepath.Join(projectDir, "composer.json"), []byte(`{"name":"test/project"}`), 0644)
require.NoError(t, err)
// Create package directory
packageDir := t.TempDir()
err = os.WriteFile(filepath.Join(packageDir, "composer.json"), []byte(`{"name":"vendor/package"}`), 0644)
require.NoError(t, err)
err = LinkPackages(projectDir, []string{packageDir})
assert.NoError(t, err)
// Verify repository was added
raw, err := readComposerJSON(projectDir)
assert.NoError(t, err)
repos, err := getRepositories(raw)
assert.NoError(t, err)
assert.Len(t, repos, 1)
assert.Equal(t, "path", repos[0].Type)
})
t.Run("skips already linked package", func(t *testing.T) {
// Create project with existing repository
projectDir := t.TempDir()
packageDir := t.TempDir()
err := os.WriteFile(filepath.Join(packageDir, "composer.json"), []byte(`{"name":"vendor/package"}`), 0644)
require.NoError(t, err)
absPackagePath, _ := filepath.Abs(packageDir)
composerJSON := `{
"name": "test/project",
"repositories": [{"type":"path","url":"` + absPackagePath + `"}]
}`
err = os.WriteFile(filepath.Join(projectDir, "composer.json"), []byte(composerJSON), 0644)
require.NoError(t, err)
// Link again - should not add duplicate
err = LinkPackages(projectDir, []string{packageDir})
assert.NoError(t, err)
raw, err := readComposerJSON(projectDir)
assert.NoError(t, err)
repos, err := getRepositories(raw)
assert.NoError(t, err)
assert.Len(t, repos, 1) // Still only one
})
t.Run("links multiple packages", func(t *testing.T) {
projectDir := t.TempDir()
err := os.WriteFile(filepath.Join(projectDir, "composer.json"), []byte(`{"name":"test/project"}`), 0644)
require.NoError(t, err)
pkg1Dir := t.TempDir()
err = os.WriteFile(filepath.Join(pkg1Dir, "composer.json"), []byte(`{"name":"vendor/pkg1"}`), 0644)
require.NoError(t, err)
pkg2Dir := t.TempDir()
err = os.WriteFile(filepath.Join(pkg2Dir, "composer.json"), []byte(`{"name":"vendor/pkg2"}`), 0644)
require.NoError(t, err)
err = LinkPackages(projectDir, []string{pkg1Dir, pkg2Dir})
assert.NoError(t, err)
raw, err := readComposerJSON(projectDir)
assert.NoError(t, err)
repos, err := getRepositories(raw)
assert.NoError(t, err)
assert.Len(t, repos, 2)
})
}
func TestLinkPackages_Bad(t *testing.T) {
t.Run("fails for non-PHP project", func(t *testing.T) {
dir := t.TempDir()
err := LinkPackages(dir, []string{"/path/to/package"})
assert.Error(t, err)
assert.Contains(t, err.Error(), "not a PHP project")
})
t.Run("fails for non-PHP package", func(t *testing.T) {
projectDir := t.TempDir()
err := os.WriteFile(filepath.Join(projectDir, "composer.json"), []byte(`{"name":"test/project"}`), 0644)
require.NoError(t, err)
packageDir := t.TempDir()
// No composer.json in package
err = LinkPackages(projectDir, []string{packageDir})
assert.Error(t, err)
assert.Contains(t, err.Error(), "not a PHP package")
})
}
func TestUnlinkPackages_Good(t *testing.T) {
t.Run("unlinks package by name", func(t *testing.T) {
projectDir := t.TempDir()
packageDir := t.TempDir()
err := os.WriteFile(filepath.Join(packageDir, "composer.json"), []byte(`{"name":"vendor/package"}`), 0644)
require.NoError(t, err)
absPackagePath, _ := filepath.Abs(packageDir)
composerJSON := `{
"name": "test/project",
"repositories": [{"type":"path","url":"` + absPackagePath + `"}]
}`
err = os.WriteFile(filepath.Join(projectDir, "composer.json"), []byte(composerJSON), 0644)
require.NoError(t, err)
err = UnlinkPackages(projectDir, []string{"vendor/package"})
assert.NoError(t, err)
raw, err := readComposerJSON(projectDir)
assert.NoError(t, err)
repos, err := getRepositories(raw)
assert.NoError(t, err)
assert.Len(t, repos, 0)
})
t.Run("unlinks package by path", func(t *testing.T) {
projectDir := t.TempDir()
packageDir := t.TempDir()
absPackagePath, _ := filepath.Abs(packageDir)
composerJSON := `{
"name": "test/project",
"repositories": [{"type":"path","url":"` + absPackagePath + `"}]
}`
err := os.WriteFile(filepath.Join(projectDir, "composer.json"), []byte(composerJSON), 0644)
require.NoError(t, err)
err = UnlinkPackages(projectDir, []string{absPackagePath})
assert.NoError(t, err)
raw, err := readComposerJSON(projectDir)
assert.NoError(t, err)
repos, err := getRepositories(raw)
assert.NoError(t, err)
assert.Len(t, repos, 0)
})
t.Run("keeps non-path repositories", func(t *testing.T) {
projectDir := t.TempDir()
composerJSON := `{
"name": "test/project",
"repositories": [
{"type":"vcs","url":"https://github.com/vendor/package"},
{"type":"path","url":"/local/path"}
]
}`
err := os.WriteFile(filepath.Join(projectDir, "composer.json"), []byte(composerJSON), 0644)
require.NoError(t, err)
err = UnlinkPackages(projectDir, []string{"/local/path"})
assert.NoError(t, err)
raw, err := readComposerJSON(projectDir)
assert.NoError(t, err)
repos, err := getRepositories(raw)
assert.NoError(t, err)
assert.Len(t, repos, 1)
assert.Equal(t, "vcs", repos[0].Type)
})
}
func TestUnlinkPackages_Bad(t *testing.T) {
t.Run("fails for non-PHP project", func(t *testing.T) {
dir := t.TempDir()
err := UnlinkPackages(dir, []string{"vendor/package"})
assert.Error(t, err)
assert.Contains(t, err.Error(), "not a PHP project")
})
}
func TestListLinkedPackages_Good(t *testing.T) {
t.Run("lists linked packages", func(t *testing.T) {
projectDir := t.TempDir()
packageDir := t.TempDir()
err := os.WriteFile(filepath.Join(packageDir, "composer.json"), []byte(`{"name":"vendor/package","version":"1.0.0"}`), 0644)
require.NoError(t, err)
absPackagePath, _ := filepath.Abs(packageDir)
composerJSON := `{
"name": "test/project",
"repositories": [{"type":"path","url":"` + absPackagePath + `"}]
}`
err = os.WriteFile(filepath.Join(projectDir, "composer.json"), []byte(composerJSON), 0644)
require.NoError(t, err)
linked, err := ListLinkedPackages(projectDir)
assert.NoError(t, err)
assert.Len(t, linked, 1)
assert.Equal(t, "vendor/package", linked[0].Name)
assert.Equal(t, "1.0.0", linked[0].Version)
assert.Equal(t, absPackagePath, linked[0].Path)
})
t.Run("returns empty list when no linked packages", func(t *testing.T) {
projectDir := t.TempDir()
err := os.WriteFile(filepath.Join(projectDir, "composer.json"), []byte(`{"name":"test/project"}`), 0644)
require.NoError(t, err)
linked, err := ListLinkedPackages(projectDir)
assert.NoError(t, err)
assert.Empty(t, linked)
})
t.Run("uses basename when package info unavailable", func(t *testing.T) {
projectDir := t.TempDir()
composerJSON := `{
"name": "test/project",
"repositories": [{"type":"path","url":"/nonexistent/package-name"}]
}`
err := os.WriteFile(filepath.Join(projectDir, "composer.json"), []byte(composerJSON), 0644)
require.NoError(t, err)
linked, err := ListLinkedPackages(projectDir)
assert.NoError(t, err)
assert.Len(t, linked, 1)
assert.Equal(t, "package-name", linked[0].Name)
})
t.Run("ignores non-path repositories", func(t *testing.T) {
projectDir := t.TempDir()
composerJSON := `{
"name": "test/project",
"repositories": [
{"type":"vcs","url":"https://github.com/vendor/package"}
]
}`
err := os.WriteFile(filepath.Join(projectDir, "composer.json"), []byte(composerJSON), 0644)
require.NoError(t, err)
linked, err := ListLinkedPackages(projectDir)
assert.NoError(t, err)
assert.Empty(t, linked)
})
}
func TestListLinkedPackages_Bad(t *testing.T) {
t.Run("fails for non-PHP project", func(t *testing.T) {
dir := t.TempDir()
_, err := ListLinkedPackages(dir)
assert.Error(t, err)
assert.Contains(t, err.Error(), "not a PHP project")
})
}
func TestUpdatePackages_Bad(t *testing.T) {
t.Run("fails for non-PHP project", func(t *testing.T) {
dir := t.TempDir()
err := UpdatePackages(dir, []string{"vendor/package"})
assert.Error(t, err)
assert.Contains(t, err.Error(), "not a PHP project")
})
}
func TestUpdatePackages_Good(t *testing.T) {
t.Skip("requires Composer installed")
t.Run("runs composer update", func(t *testing.T) {
projectDir := t.TempDir()
err := os.WriteFile(filepath.Join(projectDir, "composer.json"), []byte(`{"name":"test/project"}`), 0644)
require.NoError(t, err)
_ = UpdatePackages(projectDir, []string{"vendor/package"})
// This will fail because composer update needs real dependencies
// but it validates the command runs
})
}
func TestLinkedPackage_Struct(t *testing.T) {
t.Run("all fields accessible", func(t *testing.T) {
pkg := LinkedPackage{
Name: "vendor/package",
Path: "/path/to/package",
Version: "1.0.0",
}
assert.Equal(t, "vendor/package", pkg.Name)
assert.Equal(t, "/path/to/package", pkg.Path)
assert.Equal(t, "1.0.0", pkg.Version)
})
}
func TestComposerRepository_Struct(t *testing.T) {
t.Run("all fields accessible", func(t *testing.T) {
repo := composerRepository{
Type: "path",
URL: "/path/to/package",
Options: map[string]any{
"symlink": true,
},
}
assert.Equal(t, "path", repo.Type)
assert.Equal(t, "/path/to/package", repo.URL)
assert.Equal(t, true, repo.Options["symlink"])
})
}

397
php.go Normal file
View file

@ -0,0 +1,397 @@
package php
import (
"context"
"io"
"os"
"sync"
"time"
"forge.lthn.ai/core/go/pkg/cli"
)
// 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 cli.Err("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 cli.WrapVerb(err, "get", "working directory")
}
d.opts.Dir = cwd
}
// Verify this is a Laravel project
if !IsLaravelProject(d.opts.Dir) {
return cli.Err("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 cli.WrapVerb(err, "setup", "SSL")
}
}
// 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, cli.Err("%s: %v", svc.Name(), err))
}
}
if len(startErrors) > 0 {
// Stop any services that did start
for _, svc := range d.services {
_ = svc.Stop()
}
return cli.Err("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, cli.Err("%s: %v", svc.Name(), err))
}
}
d.running = false
if len(stopErrors) > 0 {
return cli.Err("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, cli.Err("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, cli.Err("failed to get logs for %s: %v", 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 := cli.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
}

644
php_test.go Normal file
View file

@ -0,0 +1,644 @@
package php
import (
"context"
"io"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewDevServer_Good(t *testing.T) {
t.Run("creates dev server with default options", func(t *testing.T) {
opts := Options{}
server := NewDevServer(opts)
assert.NotNil(t, server)
assert.Empty(t, server.services)
assert.False(t, server.running)
})
t.Run("creates dev server with custom options", func(t *testing.T) {
opts := Options{
Dir: "/tmp/test",
NoVite: true,
NoHorizon: true,
FrankenPHPPort: 9000,
}
server := NewDevServer(opts)
assert.NotNil(t, server)
assert.Equal(t, "/tmp/test", server.opts.Dir)
assert.True(t, server.opts.NoVite)
})
}
func TestDevServer_IsRunning_Good(t *testing.T) {
t.Run("returns false when not running", func(t *testing.T) {
server := NewDevServer(Options{})
assert.False(t, server.IsRunning())
})
}
func TestDevServer_Status_Good(t *testing.T) {
t.Run("returns empty status when no services", func(t *testing.T) {
server := NewDevServer(Options{})
statuses := server.Status()
assert.Empty(t, statuses)
})
}
func TestDevServer_Services_Good(t *testing.T) {
t.Run("returns empty services list initially", func(t *testing.T) {
server := NewDevServer(Options{})
services := server.Services()
assert.Empty(t, services)
})
}
func TestDevServer_Stop_Good(t *testing.T) {
t.Run("returns nil when not running", func(t *testing.T) {
server := NewDevServer(Options{})
err := server.Stop()
assert.NoError(t, err)
})
}
func TestDevServer_Start_Bad(t *testing.T) {
t.Run("fails when already running", func(t *testing.T) {
server := NewDevServer(Options{})
server.running = true
err := server.Start(context.Background(), Options{})
assert.Error(t, err)
assert.Contains(t, err.Error(), "already running")
})
t.Run("fails for non-Laravel project", func(t *testing.T) {
dir := t.TempDir()
server := NewDevServer(Options{Dir: dir})
err := server.Start(context.Background(), Options{Dir: dir})
assert.Error(t, err)
assert.Contains(t, err.Error(), "not a Laravel project")
})
}
func TestDevServer_Logs_Bad(t *testing.T) {
t.Run("fails for non-existent service", func(t *testing.T) {
server := NewDevServer(Options{})
_, err := server.Logs("nonexistent", false)
assert.Error(t, err)
assert.Contains(t, err.Error(), "service not found")
})
}
func TestDevServer_filterServices_Good(t *testing.T) {
tests := []struct {
name string
services []DetectedService
opts Options
expected []DetectedService
}{
{
name: "no filtering with default options",
services: []DetectedService{ServiceFrankenPHP, ServiceVite, ServiceHorizon},
opts: Options{},
expected: []DetectedService{ServiceFrankenPHP, ServiceVite, ServiceHorizon},
},
{
name: "filters Vite when NoVite is true",
services: []DetectedService{ServiceFrankenPHP, ServiceVite, ServiceHorizon},
opts: Options{NoVite: true},
expected: []DetectedService{ServiceFrankenPHP, ServiceHorizon},
},
{
name: "filters Horizon when NoHorizon is true",
services: []DetectedService{ServiceFrankenPHP, ServiceVite, ServiceHorizon},
opts: Options{NoHorizon: true},
expected: []DetectedService{ServiceFrankenPHP, ServiceVite},
},
{
name: "filters Reverb when NoReverb is true",
services: []DetectedService{ServiceFrankenPHP, ServiceReverb},
opts: Options{NoReverb: true},
expected: []DetectedService{ServiceFrankenPHP},
},
{
name: "filters Redis when NoRedis is true",
services: []DetectedService{ServiceFrankenPHP, ServiceRedis},
opts: Options{NoRedis: true},
expected: []DetectedService{ServiceFrankenPHP},
},
{
name: "filters multiple services",
services: []DetectedService{ServiceFrankenPHP, ServiceVite, ServiceHorizon, ServiceReverb, ServiceRedis},
opts: Options{NoVite: true, NoHorizon: true, NoReverb: true, NoRedis: true},
expected: []DetectedService{ServiceFrankenPHP},
},
{
name: "keeps unknown services",
services: []DetectedService{ServiceFrankenPHP},
opts: Options{NoVite: true},
expected: []DetectedService{ServiceFrankenPHP},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := NewDevServer(Options{})
result := server.filterServices(tt.services, tt.opts)
assert.Equal(t, tt.expected, result)
})
}
}
func TestMultiServiceReader_Good(t *testing.T) {
t.Run("closes all readers on Close", func(t *testing.T) {
// Create mock readers using files
dir := t.TempDir()
file1, err := os.CreateTemp(dir, "log1-*.log")
require.NoError(t, err)
_, _ = file1.WriteString("test1")
_, _ = file1.Seek(0, 0)
file2, err := os.CreateTemp(dir, "log2-*.log")
require.NoError(t, err)
_, _ = file2.WriteString("test2")
_, _ = file2.Seek(0, 0)
// Create mock services
services := []Service{
&FrankenPHPService{baseService: baseService{name: "svc1"}},
&ViteService{baseService: baseService{name: "svc2"}},
}
readers := []io.ReadCloser{file1, file2}
reader := newMultiServiceReader(services, readers, false)
assert.NotNil(t, reader)
err = reader.Close()
assert.NoError(t, err)
assert.True(t, reader.closed)
})
t.Run("returns EOF when closed", func(t *testing.T) {
reader := &multiServiceReader{closed: true}
buf := make([]byte, 10)
n, err := reader.Read(buf)
assert.Equal(t, 0, n)
assert.Equal(t, io.EOF, err)
})
}
func TestMultiServiceReader_Read_Good(t *testing.T) {
t.Run("reads from readers with service prefix", func(t *testing.T) {
dir := t.TempDir()
file1, err := os.CreateTemp(dir, "log-*.log")
require.NoError(t, err)
_, _ = file1.WriteString("log content")
_, _ = file1.Seek(0, 0)
services := []Service{
&FrankenPHPService{baseService: baseService{name: "TestService"}},
}
readers := []io.ReadCloser{file1}
reader := newMultiServiceReader(services, readers, false)
buf := make([]byte, 100)
n, err := reader.Read(buf)
assert.NoError(t, err)
assert.Greater(t, n, 0)
result := string(buf[:n])
assert.Contains(t, result, "[TestService]")
})
t.Run("returns EOF when all readers are exhausted in non-follow mode", func(t *testing.T) {
dir := t.TempDir()
file1, err := os.CreateTemp(dir, "log-*.log")
require.NoError(t, err)
_ = file1.Close() // Empty file
file1, err = os.Open(file1.Name())
require.NoError(t, err)
services := []Service{
&FrankenPHPService{baseService: baseService{name: "TestService"}},
}
readers := []io.ReadCloser{file1}
reader := newMultiServiceReader(services, readers, false)
buf := make([]byte, 100)
n, err := reader.Read(buf)
assert.Equal(t, 0, n)
assert.Equal(t, io.EOF, err)
})
}
func TestOptions_Good(t *testing.T) {
t.Run("all fields are accessible", func(t *testing.T) {
opts := Options{
Dir: "/test",
Services: []DetectedService{ServiceFrankenPHP},
NoVite: true,
NoHorizon: true,
NoReverb: true,
NoRedis: true,
HTTPS: true,
Domain: "test.local",
FrankenPHPPort: 8000,
HTTPSPort: 443,
VitePort: 5173,
ReverbPort: 8080,
RedisPort: 6379,
}
assert.Equal(t, "/test", opts.Dir)
assert.Equal(t, []DetectedService{ServiceFrankenPHP}, opts.Services)
assert.True(t, opts.NoVite)
assert.True(t, opts.NoHorizon)
assert.True(t, opts.NoReverb)
assert.True(t, opts.NoRedis)
assert.True(t, opts.HTTPS)
assert.Equal(t, "test.local", opts.Domain)
assert.Equal(t, 8000, opts.FrankenPHPPort)
assert.Equal(t, 443, opts.HTTPSPort)
assert.Equal(t, 5173, opts.VitePort)
assert.Equal(t, 8080, opts.ReverbPort)
assert.Equal(t, 6379, opts.RedisPort)
})
}
func TestDevServer_StartStop_Integration(t *testing.T) {
t.Skip("requires PHP/FrankenPHP installed")
dir := t.TempDir()
setupLaravelProject(t, dir)
server := NewDevServer(Options{Dir: dir})
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
err := server.Start(ctx, Options{Dir: dir})
require.NoError(t, err)
assert.True(t, server.IsRunning())
err = server.Stop()
require.NoError(t, err)
assert.False(t, server.IsRunning())
}
// setupLaravelProject creates a minimal Laravel project structure for testing.
func setupLaravelProject(t *testing.T, dir string) {
t.Helper()
// Create artisan file
err := os.WriteFile(filepath.Join(dir, "artisan"), []byte("#!/usr/bin/env php\n"), 0755)
require.NoError(t, err)
// Create composer.json with Laravel
composerJSON := `{
"name": "test/laravel-project",
"require": {
"php": "^8.2",
"laravel/framework": "^11.0",
"laravel/octane": "^2.0"
}
}`
err = os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644)
require.NoError(t, err)
}
func TestDevServer_UnifiedLogs_Bad(t *testing.T) {
t.Run("returns error when service logs fail", func(t *testing.T) {
server := NewDevServer(Options{})
// Create a mock service that will fail to provide logs
mockService := &FrankenPHPService{
baseService: baseService{
name: "FailingService",
logPath: "", // No log path set will cause error
},
}
server.services = []Service{mockService}
_, err := server.Logs("", false)
assert.Error(t, err)
assert.Contains(t, err.Error(), "failed to get logs")
})
}
func TestDevServer_Logs_Good(t *testing.T) {
t.Run("finds specific service logs", func(t *testing.T) {
dir := t.TempDir()
logFile := filepath.Join(dir, "test.log")
err := os.WriteFile(logFile, []byte("test log content"), 0644)
require.NoError(t, err)
server := NewDevServer(Options{})
mockService := &FrankenPHPService{
baseService: baseService{
name: "TestService",
logPath: logFile,
},
}
server.services = []Service{mockService}
reader, err := server.Logs("TestService", false)
assert.NoError(t, err)
assert.NotNil(t, reader)
_ = reader.Close()
})
}
func TestDevServer_MergeOptions_Good(t *testing.T) {
t.Run("start merges options correctly", func(t *testing.T) {
dir := t.TempDir()
server := NewDevServer(Options{Dir: "/original"})
// Setup a minimal non-Laravel project to trigger an error
// but still test the options merge happens first
err := server.Start(context.Background(), Options{Dir: dir})
assert.Error(t, err) // Will fail because not Laravel project
// But the directory should have been merged
assert.Equal(t, dir, server.opts.Dir)
})
}
func TestDetectedService_Constants(t *testing.T) {
t.Run("all service constants are defined", func(t *testing.T) {
assert.Equal(t, DetectedService("frankenphp"), ServiceFrankenPHP)
assert.Equal(t, DetectedService("vite"), ServiceVite)
assert.Equal(t, DetectedService("horizon"), ServiceHorizon)
assert.Equal(t, DetectedService("reverb"), ServiceReverb)
assert.Equal(t, DetectedService("redis"), ServiceRedis)
})
}
func TestDevServer_HTTPSSetup(t *testing.T) {
t.Run("extracts domain from APP_URL when HTTPS enabled", func(t *testing.T) {
dir := t.TempDir()
// Create 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)
// Create .env with APP_URL
envContent := "APP_URL=https://myapp.test"
err = os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644)
require.NoError(t, err)
// Verify we can extract the domain
url := GetLaravelAppURL(dir)
domain := ExtractDomainFromURL(url)
assert.Equal(t, "myapp.test", domain)
})
}
func TestDevServer_PortDefaults(t *testing.T) {
t.Run("uses default ports when not specified", func(t *testing.T) {
// This tests the logic in Start() for default port assignment
// We verify the constants/defaults by checking what would be created
// FrankenPHP default port is 8000
svc := NewFrankenPHPService("/tmp", FrankenPHPOptions{})
assert.Equal(t, 8000, svc.port)
// Vite default port is 5173
vite := NewViteService("/tmp", ViteOptions{})
assert.Equal(t, 5173, vite.port)
// Reverb default port is 8080
reverb := NewReverbService("/tmp", ReverbOptions{})
assert.Equal(t, 8080, reverb.port)
// Redis default port is 6379
redis := NewRedisService("/tmp", RedisOptions{})
assert.Equal(t, 6379, redis.port)
})
}
func TestDevServer_ServiceCreation(t *testing.T) {
t.Run("creates correct services based on detected services", func(t *testing.T) {
// Test that the switch statement in Start() creates the right service types
services := []DetectedService{
ServiceFrankenPHP,
ServiceVite,
ServiceHorizon,
ServiceReverb,
ServiceRedis,
}
// Verify each service type string
expected := []string{"frankenphp", "vite", "horizon", "reverb", "redis"}
for i, svc := range services {
assert.Equal(t, expected[i], string(svc))
}
})
}
func TestMultiServiceReader_CloseError(t *testing.T) {
t.Run("returns first close error", func(t *testing.T) {
dir := t.TempDir()
// Create a real file that we can close
file1, err := os.CreateTemp(dir, "log-*.log")
require.NoError(t, err)
file1Name := file1.Name()
_ = file1.Close()
// Reopen for reading
file1, err = os.Open(file1Name)
require.NoError(t, err)
services := []Service{
&FrankenPHPService{baseService: baseService{name: "svc1"}},
}
readers := []io.ReadCloser{file1}
reader := newMultiServiceReader(services, readers, false)
err = reader.Close()
assert.NoError(t, err)
// Second close should still work (files already closed)
// The closed flag prevents double-processing
assert.True(t, reader.closed)
})
}
func TestMultiServiceReader_FollowMode(t *testing.T) {
t.Run("returns 0 bytes without error in follow mode when no data", func(t *testing.T) {
dir := t.TempDir()
file1, err := os.CreateTemp(dir, "log-*.log")
require.NoError(t, err)
file1Name := file1.Name()
_ = file1.Close()
// Reopen for reading (empty file)
file1, err = os.Open(file1Name)
require.NoError(t, err)
services := []Service{
&FrankenPHPService{baseService: baseService{name: "svc1"}},
}
readers := []io.ReadCloser{file1}
reader := newMultiServiceReader(services, readers, true) // follow=true
// Use a channel to timeout the read since follow mode waits
done := make(chan bool)
go func() {
buf := make([]byte, 100)
n, err := reader.Read(buf)
// In follow mode, should return 0 bytes and nil error (waiting for more data)
assert.Equal(t, 0, n)
assert.NoError(t, err)
done <- true
}()
select {
case <-done:
// Good, read completed
case <-time.After(500 * time.Millisecond):
// Also acceptable - follow mode is waiting
}
_ = reader.Close()
})
}
func TestGetLaravelAppURL_Bad(t *testing.T) {
t.Run("no .env file", func(t *testing.T) {
dir := t.TempDir()
assert.Equal(t, "", GetLaravelAppURL(dir))
})
t.Run("no APP_URL in .env", func(t *testing.T) {
dir := t.TempDir()
envContent := "APP_NAME=Test\nAPP_ENV=local"
err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644)
require.NoError(t, err)
assert.Equal(t, "", GetLaravelAppURL(dir))
})
}
func TestExtractDomainFromURL_Edge(t *testing.T) {
tests := []struct {
name string
url string
expected string
}{
{"empty string", "", ""},
{"just domain", "example.com", "example.com"},
{"http only", "http://", ""},
{"https only", "https://", ""},
{"domain with trailing slash", "https://example.com/", "example.com"},
{"complex path", "https://example.com:8080/path/to/page?query=1", "example.com"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Strip protocol
result := ExtractDomainFromURL(tt.url)
if tt.url != "" && !strings.HasPrefix(tt.url, "http://") && !strings.HasPrefix(tt.url, "https://") && !strings.Contains(tt.url, ":") && !strings.Contains(tt.url, "/") {
assert.Equal(t, tt.expected, result)
}
})
}
}
func TestDevServer_StatusWithServices(t *testing.T) {
t.Run("returns statuses for all services", func(t *testing.T) {
server := NewDevServer(Options{})
// Add mock services
server.services = []Service{
&FrankenPHPService{baseService: baseService{name: "svc1", running: true, port: 8000}},
&ViteService{baseService: baseService{name: "svc2", running: false, port: 5173}},
}
statuses := server.Status()
assert.Len(t, statuses, 2)
assert.Equal(t, "svc1", statuses[0].Name)
assert.True(t, statuses[0].Running)
assert.Equal(t, "svc2", statuses[1].Name)
assert.False(t, statuses[1].Running)
})
}
func TestDevServer_ServicesReturnsAll(t *testing.T) {
t.Run("returns all services", func(t *testing.T) {
server := NewDevServer(Options{})
// Add mock services
server.services = []Service{
&FrankenPHPService{baseService: baseService{name: "svc1"}},
&ViteService{baseService: baseService{name: "svc2"}},
&HorizonService{baseService: baseService{name: "svc3"}},
}
services := server.Services()
assert.Len(t, services, 3)
})
}
func TestDevServer_StopWithCancel(t *testing.T) {
t.Run("calls cancel when running", func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
server := NewDevServer(Options{})
server.running = true
server.cancel = cancel
server.ctx = ctx
// Add a mock service that won't error
server.services = []Service{
&FrankenPHPService{baseService: baseService{name: "svc1", running: false}},
}
err := server.Stop()
assert.NoError(t, err)
assert.False(t, server.running)
})
}
func TestMultiServiceReader_CloseWithErrors(t *testing.T) {
t.Run("handles multiple close errors", func(t *testing.T) {
dir := t.TempDir()
// Create files
file1, err := os.CreateTemp(dir, "log1-*.log")
require.NoError(t, err)
file2, err := os.CreateTemp(dir, "log2-*.log")
require.NoError(t, err)
services := []Service{
&FrankenPHPService{baseService: baseService{name: "svc1"}},
&ViteService{baseService: baseService{name: "svc2"}},
}
readers := []io.ReadCloser{file1, file2}
reader := newMultiServiceReader(services, readers, false)
// Close successfully
err = reader.Close()
assert.NoError(t, err)
})
}

994
quality.go Normal file
View file

@ -0,0 +1,994 @@
package php
import (
"context"
"encoding/json"
goio "io"
"os"
"os/exec"
"path/filepath"
"strings"
"forge.lthn.ai/core/go/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n"
)
// FormatOptions configures PHP code formatting.
type FormatOptions struct {
// Dir is the project directory (defaults to current working directory).
Dir string
// Fix automatically fixes formatting issues.
Fix bool
// Diff shows a diff of changes instead of modifying files.
Diff bool
// JSON outputs results in JSON format.
JSON bool
// Paths limits formatting to specific paths.
Paths []string
// Output is the writer for output (defaults to os.Stdout).
Output goio.Writer
}
// AnalyseOptions configures PHP static analysis.
type AnalyseOptions struct {
// Dir is the project directory (defaults to current working directory).
Dir string
// Level is the PHPStan analysis level (0-9).
Level int
// Paths limits analysis to specific paths.
Paths []string
// Memory is the memory limit for analysis (e.g., "2G").
Memory string
// JSON outputs results in JSON format.
JSON bool
// SARIF outputs results in SARIF format for GitHub Security tab.
SARIF bool
// Output is the writer for output (defaults to os.Stdout).
Output goio.Writer
}
// FormatterType represents the detected formatter.
type FormatterType string
// Formatter type constants.
const (
// FormatterPint indicates Laravel Pint code formatter.
FormatterPint FormatterType = "pint"
)
// AnalyserType represents the detected static analyser.
type AnalyserType string
// Static analyser type constants.
const (
// AnalyserPHPStan indicates standard PHPStan analyser.
AnalyserPHPStan AnalyserType = "phpstan"
// AnalyserLarastan indicates Laravel-specific Larastan analyser.
AnalyserLarastan AnalyserType = "larastan"
)
// DetectFormatter detects which formatter is available in the project.
func DetectFormatter(dir string) (FormatterType, bool) {
m := getMedium()
// Check for Pint config
pintConfig := filepath.Join(dir, "pint.json")
if m.Exists(pintConfig) {
return FormatterPint, true
}
// Check for vendor binary
pintBin := filepath.Join(dir, "vendor", "bin", "pint")
if m.Exists(pintBin) {
return FormatterPint, true
}
return "", false
}
// DetectAnalyser detects which static analyser is available in the project.
func DetectAnalyser(dir string) (AnalyserType, bool) {
m := getMedium()
// Check for PHPStan config
phpstanConfig := filepath.Join(dir, "phpstan.neon")
phpstanDistConfig := filepath.Join(dir, "phpstan.neon.dist")
hasConfig := m.Exists(phpstanConfig) || m.Exists(phpstanDistConfig)
// Check for vendor binary
phpstanBin := filepath.Join(dir, "vendor", "bin", "phpstan")
hasBin := m.Exists(phpstanBin)
if hasConfig || hasBin {
// Check if it's Larastan (Laravel-specific PHPStan)
larastanPath := filepath.Join(dir, "vendor", "larastan", "larastan")
if m.Exists(larastanPath) {
return AnalyserLarastan, true
}
// Also check nunomaduro/larastan
larastanPath2 := filepath.Join(dir, "vendor", "nunomaduro", "larastan")
if m.Exists(larastanPath2) {
return AnalyserLarastan, true
}
return AnalyserPHPStan, true
}
return "", false
}
// Format runs Laravel Pint to format PHP code.
func Format(ctx context.Context, opts FormatOptions) error {
if opts.Dir == "" {
cwd, err := os.Getwd()
if err != nil {
return cli.WrapVerb(err, "get", "working directory")
}
opts.Dir = cwd
}
if opts.Output == nil {
opts.Output = os.Stdout
}
// Check if formatter is available
formatter, found := DetectFormatter(opts.Dir)
if !found {
return cli.Err("no formatter found (install Laravel Pint: composer require laravel/pint --dev)")
}
var cmdName string
var args []string
switch formatter {
case FormatterPint:
cmdName, args = buildPintCommand(opts)
}
cmd := exec.CommandContext(ctx, cmdName, args...)
cmd.Dir = opts.Dir
cmd.Stdout = opts.Output
cmd.Stderr = opts.Output
return cmd.Run()
}
// Analyse runs PHPStan or Larastan for static analysis.
func Analyse(ctx context.Context, opts AnalyseOptions) error {
if opts.Dir == "" {
cwd, err := os.Getwd()
if err != nil {
return cli.WrapVerb(err, "get", "working directory")
}
opts.Dir = cwd
}
if opts.Output == nil {
opts.Output = os.Stdout
}
// Check if analyser is available
analyser, found := DetectAnalyser(opts.Dir)
if !found {
return cli.Err("no static analyser found (install PHPStan: composer require phpstan/phpstan --dev)")
}
var cmdName string
var args []string
switch analyser {
case AnalyserPHPStan, AnalyserLarastan:
cmdName, args = buildPHPStanCommand(opts)
}
cmd := exec.CommandContext(ctx, cmdName, args...)
cmd.Dir = opts.Dir
cmd.Stdout = opts.Output
cmd.Stderr = opts.Output
return cmd.Run()
}
// buildPintCommand builds the command for running Laravel Pint.
func buildPintCommand(opts FormatOptions) (string, []string) {
m := getMedium()
// Check for vendor binary first
vendorBin := filepath.Join(opts.Dir, "vendor", "bin", "pint")
cmdName := "pint"
if m.Exists(vendorBin) {
cmdName = vendorBin
}
var args []string
if !opts.Fix {
args = append(args, "--test")
}
if opts.Diff {
args = append(args, "--diff")
}
if opts.JSON {
args = append(args, "--format=json")
}
// Add specific paths if provided
args = append(args, opts.Paths...)
return cmdName, args
}
// buildPHPStanCommand builds the command for running PHPStan.
func buildPHPStanCommand(opts AnalyseOptions) (string, []string) {
m := getMedium()
// Check for vendor binary first
vendorBin := filepath.Join(opts.Dir, "vendor", "bin", "phpstan")
cmdName := "phpstan"
if m.Exists(vendorBin) {
cmdName = vendorBin
}
args := []string{"analyse"}
if opts.Level > 0 {
args = append(args, "--level", cli.Sprintf("%d", opts.Level))
}
if opts.Memory != "" {
args = append(args, "--memory-limit", opts.Memory)
}
// Output format - SARIF takes precedence over JSON
if opts.SARIF {
args = append(args, "--error-format=sarif")
} else if opts.JSON {
args = append(args, "--error-format=json")
}
// Add specific paths if provided
args = append(args, opts.Paths...)
return cmdName, args
}
// =============================================================================
// Psalm Static Analysis
// =============================================================================
// PsalmOptions configures Psalm static analysis.
type PsalmOptions struct {
Dir string
Level int // Error level (1=strictest, 8=most lenient)
Fix bool // Auto-fix issues where possible
Baseline bool // Generate/update baseline file
ShowInfo bool // Show info-level issues
JSON bool // Output in JSON format
SARIF bool // Output in SARIF format for GitHub Security tab
Output goio.Writer
}
// PsalmType represents the detected Psalm configuration.
type PsalmType string
// Psalm configuration type constants.
const (
// PsalmStandard indicates standard Psalm configuration.
PsalmStandard PsalmType = "psalm"
)
// DetectPsalm checks if Psalm is available in the project.
func DetectPsalm(dir string) (PsalmType, bool) {
m := getMedium()
// Check for psalm.xml config
psalmConfig := filepath.Join(dir, "psalm.xml")
psalmDistConfig := filepath.Join(dir, "psalm.xml.dist")
hasConfig := m.Exists(psalmConfig) || m.Exists(psalmDistConfig)
// Check for vendor binary
psalmBin := filepath.Join(dir, "vendor", "bin", "psalm")
if m.Exists(psalmBin) {
return PsalmStandard, true
}
if hasConfig {
return PsalmStandard, true
}
return "", false
}
// RunPsalm runs Psalm static analysis.
func RunPsalm(ctx context.Context, opts PsalmOptions) error {
if opts.Dir == "" {
cwd, err := os.Getwd()
if err != nil {
return cli.WrapVerb(err, "get", "working directory")
}
opts.Dir = cwd
}
if opts.Output == nil {
opts.Output = os.Stdout
}
m := getMedium()
// Build command
vendorBin := filepath.Join(opts.Dir, "vendor", "bin", "psalm")
cmdName := "psalm"
if m.Exists(vendorBin) {
cmdName = vendorBin
}
args := []string{"--no-progress"}
if opts.Level > 0 && opts.Level <= 8 {
args = append(args, cli.Sprintf("--error-level=%d", opts.Level))
}
if opts.Fix {
args = append(args, "--alter", "--issues=all")
}
if opts.Baseline {
args = append(args, "--set-baseline=psalm-baseline.xml")
}
if opts.ShowInfo {
args = append(args, "--show-info=true")
}
// Output format - SARIF takes precedence over JSON
if opts.SARIF {
args = append(args, "--output-format=sarif")
} else if opts.JSON {
args = append(args, "--output-format=json")
}
cmd := exec.CommandContext(ctx, cmdName, args...)
cmd.Dir = opts.Dir
cmd.Stdout = opts.Output
cmd.Stderr = opts.Output
return cmd.Run()
}
// =============================================================================
// Security Audit
// =============================================================================
// AuditOptions configures dependency security auditing.
type AuditOptions struct {
Dir string
JSON bool // Output in JSON format
Fix bool // Auto-fix vulnerabilities (npm only)
Output goio.Writer
}
// AuditResult holds the results of a security audit.
type AuditResult struct {
Tool string
Vulnerabilities int
Advisories []AuditAdvisory
Error error
}
// AuditAdvisory represents a single security advisory.
type AuditAdvisory struct {
Package string
Severity string
Title string
URL string
Identifiers []string
}
// RunAudit runs security audits on dependencies.
func RunAudit(ctx context.Context, opts AuditOptions) ([]AuditResult, error) {
if opts.Dir == "" {
cwd, err := os.Getwd()
if err != nil {
return nil, cli.WrapVerb(err, "get", "working directory")
}
opts.Dir = cwd
}
if opts.Output == nil {
opts.Output = os.Stdout
}
var results []AuditResult
// Run composer audit
composerResult := runComposerAudit(ctx, opts)
results = append(results, composerResult)
// Run npm audit if package.json exists
if getMedium().Exists(filepath.Join(opts.Dir, "package.json")) {
npmResult := runNpmAudit(ctx, opts)
results = append(results, npmResult)
}
return results, nil
}
func runComposerAudit(ctx context.Context, opts AuditOptions) AuditResult {
result := AuditResult{Tool: "composer"}
args := []string{"audit", "--format=json"}
cmd := exec.CommandContext(ctx, "composer", args...)
cmd.Dir = opts.Dir
output, err := cmd.Output()
if err != nil {
// composer audit returns non-zero if vulnerabilities found
if exitErr, ok := err.(*exec.ExitError); ok {
output = append(output, exitErr.Stderr...)
}
}
// Parse JSON output
var auditData struct {
Advisories map[string][]struct {
Title string `json:"title"`
Link string `json:"link"`
CVE string `json:"cve"`
AffectedRanges string `json:"affectedVersions"`
} `json:"advisories"`
}
if jsonErr := json.Unmarshal(output, &auditData); jsonErr == nil {
for pkg, advisories := range auditData.Advisories {
for _, adv := range advisories {
result.Advisories = append(result.Advisories, AuditAdvisory{
Package: pkg,
Title: adv.Title,
URL: adv.Link,
Identifiers: []string{adv.CVE},
})
}
}
result.Vulnerabilities = len(result.Advisories)
} else if err != nil {
result.Error = err
}
return result
}
func runNpmAudit(ctx context.Context, opts AuditOptions) AuditResult {
result := AuditResult{Tool: "npm"}
args := []string{"audit", "--json"}
if opts.Fix {
args = []string{"audit", "fix"}
}
cmd := exec.CommandContext(ctx, "npm", args...)
cmd.Dir = opts.Dir
output, err := cmd.Output()
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
output = append(output, exitErr.Stderr...)
}
}
if !opts.Fix {
// Parse JSON output
var auditData struct {
Metadata struct {
Vulnerabilities struct {
Total int `json:"total"`
} `json:"vulnerabilities"`
} `json:"metadata"`
Vulnerabilities map[string]struct {
Severity string `json:"severity"`
Via []any `json:"via"`
} `json:"vulnerabilities"`
}
if jsonErr := json.Unmarshal(output, &auditData); jsonErr == nil {
result.Vulnerabilities = auditData.Metadata.Vulnerabilities.Total
for pkg, vuln := range auditData.Vulnerabilities {
result.Advisories = append(result.Advisories, AuditAdvisory{
Package: pkg,
Severity: vuln.Severity,
})
}
} else if err != nil {
result.Error = err
}
}
return result
}
// =============================================================================
// Rector Automated Refactoring
// =============================================================================
// RectorOptions configures Rector code refactoring.
type RectorOptions struct {
Dir string
Fix bool // Apply changes (default is dry-run)
Diff bool // Show detailed diff
ClearCache bool // Clear cache before running
Output goio.Writer
}
// DetectRector checks if Rector is available in the project.
func DetectRector(dir string) bool {
m := getMedium()
// Check for rector.php config
rectorConfig := filepath.Join(dir, "rector.php")
if m.Exists(rectorConfig) {
return true
}
// Check for vendor binary
rectorBin := filepath.Join(dir, "vendor", "bin", "rector")
if m.Exists(rectorBin) {
return true
}
return false
}
// RunRector runs Rector for automated code refactoring.
func RunRector(ctx context.Context, opts RectorOptions) error {
if opts.Dir == "" {
cwd, err := os.Getwd()
if err != nil {
return cli.WrapVerb(err, "get", "working directory")
}
opts.Dir = cwd
}
if opts.Output == nil {
opts.Output = os.Stdout
}
m := getMedium()
// Build command
vendorBin := filepath.Join(opts.Dir, "vendor", "bin", "rector")
cmdName := "rector"
if m.Exists(vendorBin) {
cmdName = vendorBin
}
args := []string{"process"}
if !opts.Fix {
args = append(args, "--dry-run")
}
if opts.Diff {
args = append(args, "--output-format", "diff")
}
if opts.ClearCache {
args = append(args, "--clear-cache")
}
cmd := exec.CommandContext(ctx, cmdName, args...)
cmd.Dir = opts.Dir
cmd.Stdout = opts.Output
cmd.Stderr = opts.Output
return cmd.Run()
}
// =============================================================================
// Infection Mutation Testing
// =============================================================================
// InfectionOptions configures Infection mutation testing.
type InfectionOptions struct {
Dir string
MinMSI int // Minimum mutation score indicator (0-100)
MinCoveredMSI int // Minimum covered mutation score (0-100)
Threads int // Number of parallel threads
Filter string // Filter files by pattern
OnlyCovered bool // Only mutate covered code
Output goio.Writer
}
// DetectInfection checks if Infection is available in the project.
func DetectInfection(dir string) bool {
m := getMedium()
// Check for infection config files
configs := []string{"infection.json", "infection.json5", "infection.json.dist"}
for _, config := range configs {
if m.Exists(filepath.Join(dir, config)) {
return true
}
}
// Check for vendor binary
infectionBin := filepath.Join(dir, "vendor", "bin", "infection")
if m.Exists(infectionBin) {
return true
}
return false
}
// RunInfection runs Infection mutation testing.
func RunInfection(ctx context.Context, opts InfectionOptions) error {
if opts.Dir == "" {
cwd, err := os.Getwd()
if err != nil {
return cli.WrapVerb(err, "get", "working directory")
}
opts.Dir = cwd
}
if opts.Output == nil {
opts.Output = os.Stdout
}
m := getMedium()
// Build command
vendorBin := filepath.Join(opts.Dir, "vendor", "bin", "infection")
cmdName := "infection"
if m.Exists(vendorBin) {
cmdName = vendorBin
}
var args []string
// Set defaults
minMSI := opts.MinMSI
if minMSI == 0 {
minMSI = 50
}
minCoveredMSI := opts.MinCoveredMSI
if minCoveredMSI == 0 {
minCoveredMSI = 70
}
threads := opts.Threads
if threads == 0 {
threads = 4
}
args = append(args, cli.Sprintf("--min-msi=%d", minMSI))
args = append(args, cli.Sprintf("--min-covered-msi=%d", minCoveredMSI))
args = append(args, cli.Sprintf("--threads=%d", threads))
if opts.Filter != "" {
args = append(args, "--filter="+opts.Filter)
}
if opts.OnlyCovered {
args = append(args, "--only-covered")
}
cmd := exec.CommandContext(ctx, cmdName, args...)
cmd.Dir = opts.Dir
cmd.Stdout = opts.Output
cmd.Stderr = opts.Output
return cmd.Run()
}
// =============================================================================
// QA Pipeline
// =============================================================================
// QAOptions configures the full QA pipeline.
type QAOptions struct {
Dir string
Quick bool // Only run quick checks
Full bool // Run all stages including slow checks
Fix bool // Auto-fix issues where possible
JSON bool // Output results as JSON
}
// QAStage represents a stage in the QA pipeline.
type QAStage string
// QA pipeline stage constants.
const (
// QAStageQuick runs fast checks only (audit, fmt, stan).
QAStageQuick QAStage = "quick"
// QAStageStandard runs standard checks including tests.
QAStageStandard QAStage = "standard"
// QAStageFull runs all checks including slow security scans.
QAStageFull QAStage = "full"
)
// QACheckResult holds the result of a single QA check.
type QACheckResult struct {
Name string
Stage QAStage
Passed bool
Duration string
Error error
Output string
}
// QAResult holds the results of the full QA pipeline.
type QAResult struct {
Stages []QAStage
Checks []QACheckResult
Passed bool
Summary string
}
// GetQAStages returns the stages to run based on options.
func GetQAStages(opts QAOptions) []QAStage {
if opts.Quick {
return []QAStage{QAStageQuick}
}
if opts.Full {
return []QAStage{QAStageQuick, QAStageStandard, QAStageFull}
}
// Default: quick + standard
return []QAStage{QAStageQuick, QAStageStandard}
}
// GetQAChecks returns the checks for a given stage.
func GetQAChecks(dir string, stage QAStage) []string {
switch stage {
case QAStageQuick:
checks := []string{"audit", "fmt", "stan"}
return checks
case QAStageStandard:
checks := []string{}
if _, found := DetectPsalm(dir); found {
checks = append(checks, "psalm")
}
checks = append(checks, "test")
return checks
case QAStageFull:
checks := []string{}
if DetectRector(dir) {
checks = append(checks, "rector")
}
if DetectInfection(dir) {
checks = append(checks, "infection")
}
return checks
}
return nil
}
// =============================================================================
// Security Checks
// =============================================================================
// SecurityOptions configures security scanning.
type SecurityOptions struct {
Dir string
Severity string // Minimum severity (critical, high, medium, low)
JSON bool // Output in JSON format
SARIF bool // Output in SARIF format
URL string // URL to check HTTP headers (optional)
Output goio.Writer
}
// SecurityResult holds the results of security scanning.
type SecurityResult struct {
Checks []SecurityCheck
Summary SecuritySummary
}
// SecurityCheck represents a single security check result.
type SecurityCheck struct {
ID string
Name string
Description string
Severity string
Passed bool
Message string
Fix string
CWE string
}
// SecuritySummary summarizes security check results.
type SecuritySummary struct {
Total int
Passed int
Critical int
High int
Medium int
Low int
}
// RunSecurityChecks runs security checks on the project.
func RunSecurityChecks(ctx context.Context, opts SecurityOptions) (*SecurityResult, error) {
if opts.Dir == "" {
cwd, err := os.Getwd()
if err != nil {
return nil, cli.WrapVerb(err, "get", "working directory")
}
opts.Dir = cwd
}
result := &SecurityResult{}
// Run composer audit
auditResults, _ := RunAudit(ctx, AuditOptions{Dir: opts.Dir})
for _, audit := range auditResults {
check := SecurityCheck{
ID: audit.Tool + "_audit",
Name: i18n.Title(audit.Tool) + " Security Audit",
Description: "Check " + audit.Tool + " dependencies for vulnerabilities",
Severity: "critical",
Passed: audit.Vulnerabilities == 0 && audit.Error == nil,
CWE: "CWE-1395",
}
if !check.Passed {
check.Message = cli.Sprintf("Found %d vulnerabilities", audit.Vulnerabilities)
}
result.Checks = append(result.Checks, check)
}
// Check .env file for security issues
envChecks := runEnvSecurityChecks(opts.Dir)
result.Checks = append(result.Checks, envChecks...)
// Check filesystem security
fsChecks := runFilesystemSecurityChecks(opts.Dir)
result.Checks = append(result.Checks, fsChecks...)
// Calculate summary
for _, check := range result.Checks {
result.Summary.Total++
if check.Passed {
result.Summary.Passed++
} else {
switch check.Severity {
case "critical":
result.Summary.Critical++
case "high":
result.Summary.High++
case "medium":
result.Summary.Medium++
case "low":
result.Summary.Low++
}
}
}
return result, nil
}
func runEnvSecurityChecks(dir string) []SecurityCheck {
var checks []SecurityCheck
m := getMedium()
envPath := filepath.Join(dir, ".env")
envContent, err := m.Read(envPath)
if err != nil {
return checks
}
envLines := strings.Split(envContent, "\n")
envMap := make(map[string]string)
for _, line := range envLines {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
parts := strings.SplitN(line, "=", 2)
if len(parts) == 2 {
envMap[parts[0]] = parts[1]
}
}
// Check APP_DEBUG
if debug, ok := envMap["APP_DEBUG"]; ok {
check := SecurityCheck{
ID: "debug_mode",
Name: "Debug Mode Disabled",
Description: "APP_DEBUG should be false in production",
Severity: "critical",
Passed: strings.ToLower(debug) != "true",
CWE: "CWE-215",
}
if !check.Passed {
check.Message = "Debug mode exposes sensitive information"
check.Fix = "Set APP_DEBUG=false in .env"
}
checks = append(checks, check)
}
// Check APP_KEY
if key, ok := envMap["APP_KEY"]; ok {
check := SecurityCheck{
ID: "app_key_set",
Name: "Application Key Set",
Description: "APP_KEY must be set and valid",
Severity: "critical",
Passed: len(key) >= 32,
CWE: "CWE-321",
}
if !check.Passed {
check.Message = "Missing or weak encryption key"
check.Fix = "Run: php artisan key:generate"
}
checks = append(checks, check)
}
// Check APP_URL for HTTPS
if url, ok := envMap["APP_URL"]; ok {
check := SecurityCheck{
ID: "https_enforced",
Name: "HTTPS Enforced",
Description: "APP_URL should use HTTPS in production",
Severity: "high",
Passed: strings.HasPrefix(url, "https://"),
CWE: "CWE-319",
}
if !check.Passed {
check.Message = "Application not using HTTPS"
check.Fix = "Update APP_URL to use https://"
}
checks = append(checks, check)
}
return checks
}
func runFilesystemSecurityChecks(dir string) []SecurityCheck {
var checks []SecurityCheck
m := getMedium()
// Check .env not in public
publicEnvPaths := []string{"public/.env", "public_html/.env"}
for _, path := range publicEnvPaths {
fullPath := filepath.Join(dir, path)
if m.Exists(fullPath) {
checks = append(checks, SecurityCheck{
ID: "env_not_public",
Name: ".env Not Publicly Accessible",
Description: ".env file should not be in public directory",
Severity: "critical",
Passed: false,
Message: "Environment file exposed to web at " + path,
CWE: "CWE-538",
})
}
}
// Check .git not in public
publicGitPaths := []string{"public/.git", "public_html/.git"}
for _, path := range publicGitPaths {
fullPath := filepath.Join(dir, path)
if m.Exists(fullPath) {
checks = append(checks, SecurityCheck{
ID: "git_not_public",
Name: ".git Not Publicly Accessible",
Description: ".git directory should not be in public",
Severity: "critical",
Passed: false,
Message: "Git repository exposed to web (source code leak)",
CWE: "CWE-538",
})
}
}
return checks
}

304
quality_extended_test.go Normal file
View file

@ -0,0 +1,304 @@
package php
import (
"context"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestFormatOptions_Struct(t *testing.T) {
t.Run("all fields accessible", func(t *testing.T) {
opts := FormatOptions{
Dir: "/project",
Fix: true,
Diff: true,
Paths: []string{"app", "tests"},
Output: os.Stdout,
}
assert.Equal(t, "/project", opts.Dir)
assert.True(t, opts.Fix)
assert.True(t, opts.Diff)
assert.Equal(t, []string{"app", "tests"}, opts.Paths)
assert.NotNil(t, opts.Output)
})
}
func TestAnalyseOptions_Struct(t *testing.T) {
t.Run("all fields accessible", func(t *testing.T) {
opts := AnalyseOptions{
Dir: "/project",
Level: 5,
Paths: []string{"src"},
Memory: "2G",
Output: os.Stdout,
}
assert.Equal(t, "/project", opts.Dir)
assert.Equal(t, 5, opts.Level)
assert.Equal(t, []string{"src"}, opts.Paths)
assert.Equal(t, "2G", opts.Memory)
assert.NotNil(t, opts.Output)
})
}
func TestFormatterType_Constants(t *testing.T) {
t.Run("constants are defined", func(t *testing.T) {
assert.Equal(t, FormatterType("pint"), FormatterPint)
})
}
func TestAnalyserType_Constants(t *testing.T) {
t.Run("constants are defined", func(t *testing.T) {
assert.Equal(t, AnalyserType("phpstan"), AnalyserPHPStan)
assert.Equal(t, AnalyserType("larastan"), AnalyserLarastan)
})
}
func TestDetectFormatter_Extended(t *testing.T) {
t.Run("returns not found for empty directory", func(t *testing.T) {
dir := t.TempDir()
_, found := DetectFormatter(dir)
assert.False(t, found)
})
t.Run("prefers pint.json over vendor binary", func(t *testing.T) {
dir := t.TempDir()
// Create pint.json
err := os.WriteFile(filepath.Join(dir, "pint.json"), []byte("{}"), 0644)
require.NoError(t, err)
formatter, found := DetectFormatter(dir)
assert.True(t, found)
assert.Equal(t, FormatterPint, formatter)
})
}
func TestDetectAnalyser_Extended(t *testing.T) {
t.Run("returns not found for empty directory", func(t *testing.T) {
dir := t.TempDir()
_, found := DetectAnalyser(dir)
assert.False(t, found)
})
t.Run("detects phpstan from vendor binary alone", func(t *testing.T) {
dir := t.TempDir()
// Create vendor binary
binDir := filepath.Join(dir, "vendor", "bin")
err := os.MkdirAll(binDir, 0755)
require.NoError(t, err)
err = os.WriteFile(filepath.Join(binDir, "phpstan"), []byte(""), 0755)
require.NoError(t, err)
analyser, found := DetectAnalyser(dir)
assert.True(t, found)
assert.Equal(t, AnalyserPHPStan, analyser)
})
t.Run("detects larastan from larastan/larastan vendor path", func(t *testing.T) {
dir := t.TempDir()
// Create phpstan.neon
err := os.WriteFile(filepath.Join(dir, "phpstan.neon"), []byte(""), 0644)
require.NoError(t, err)
// Create larastan/larastan path
larastanPath := filepath.Join(dir, "vendor", "larastan", "larastan")
err = os.MkdirAll(larastanPath, 0755)
require.NoError(t, err)
analyser, found := DetectAnalyser(dir)
assert.True(t, found)
assert.Equal(t, AnalyserLarastan, analyser)
})
t.Run("detects larastan from nunomaduro/larastan vendor path", func(t *testing.T) {
dir := t.TempDir()
// Create phpstan.neon
err := os.WriteFile(filepath.Join(dir, "phpstan.neon"), []byte(""), 0644)
require.NoError(t, err)
// Create nunomaduro/larastan path
larastanPath := filepath.Join(dir, "vendor", "nunomaduro", "larastan")
err = os.MkdirAll(larastanPath, 0755)
require.NoError(t, err)
analyser, found := DetectAnalyser(dir)
assert.True(t, found)
assert.Equal(t, AnalyserLarastan, analyser)
})
}
func TestBuildPintCommand_Extended(t *testing.T) {
t.Run("uses global pint when no vendor binary", func(t *testing.T) {
dir := t.TempDir()
opts := FormatOptions{Dir: dir}
cmd, _ := buildPintCommand(opts)
assert.Equal(t, "pint", cmd)
})
t.Run("adds test flag when Fix is false", func(t *testing.T) {
dir := t.TempDir()
opts := FormatOptions{Dir: dir, Fix: false}
_, args := buildPintCommand(opts)
assert.Contains(t, args, "--test")
})
t.Run("does not add test flag when Fix is true", func(t *testing.T) {
dir := t.TempDir()
opts := FormatOptions{Dir: dir, Fix: true}
_, args := buildPintCommand(opts)
assert.NotContains(t, args, "--test")
})
t.Run("adds diff flag", func(t *testing.T) {
dir := t.TempDir()
opts := FormatOptions{Dir: dir, Diff: true}
_, args := buildPintCommand(opts)
assert.Contains(t, args, "--diff")
})
t.Run("adds paths", func(t *testing.T) {
dir := t.TempDir()
opts := FormatOptions{Dir: dir, Paths: []string{"app", "tests"}}
_, args := buildPintCommand(opts)
assert.Contains(t, args, "app")
assert.Contains(t, args, "tests")
})
}
func TestBuildPHPStanCommand_Extended(t *testing.T) {
t.Run("uses global phpstan when no vendor binary", func(t *testing.T) {
dir := t.TempDir()
opts := AnalyseOptions{Dir: dir}
cmd, _ := buildPHPStanCommand(opts)
assert.Equal(t, "phpstan", cmd)
})
t.Run("adds level flag", func(t *testing.T) {
dir := t.TempDir()
opts := AnalyseOptions{Dir: dir, Level: 8}
_, args := buildPHPStanCommand(opts)
assert.Contains(t, args, "--level")
assert.Contains(t, args, "8")
})
t.Run("does not add level flag when zero", func(t *testing.T) {
dir := t.TempDir()
opts := AnalyseOptions{Dir: dir, Level: 0}
_, args := buildPHPStanCommand(opts)
assert.NotContains(t, args, "--level")
})
t.Run("adds memory limit", func(t *testing.T) {
dir := t.TempDir()
opts := AnalyseOptions{Dir: dir, Memory: "4G"}
_, args := buildPHPStanCommand(opts)
assert.Contains(t, args, "--memory-limit")
assert.Contains(t, args, "4G")
})
t.Run("does not add memory flag when empty", func(t *testing.T) {
dir := t.TempDir()
opts := AnalyseOptions{Dir: dir, Memory: ""}
_, args := buildPHPStanCommand(opts)
assert.NotContains(t, args, "--memory-limit")
})
t.Run("adds paths", func(t *testing.T) {
dir := t.TempDir()
opts := AnalyseOptions{Dir: dir, Paths: []string{"src", "app"}}
_, args := buildPHPStanCommand(opts)
assert.Contains(t, args, "src")
assert.Contains(t, args, "app")
})
}
func TestFormat_Bad(t *testing.T) {
t.Run("fails when no formatter found", func(t *testing.T) {
dir := t.TempDir()
opts := FormatOptions{Dir: dir}
err := Format(context.TODO(), opts)
assert.Error(t, err)
assert.Contains(t, err.Error(), "no formatter found")
})
t.Run("uses cwd when dir not specified", func(t *testing.T) {
// When no formatter found in cwd, should still fail with "no formatter found"
opts := FormatOptions{Dir: ""}
err := Format(context.TODO(), opts)
// May or may not find a formatter depending on cwd, but function should not panic
if err != nil {
// Expected - no formatter in cwd
assert.Contains(t, err.Error(), "no formatter")
}
})
t.Run("uses stdout when output not specified", func(t *testing.T) {
dir := t.TempDir()
// Create pint.json to enable formatter detection
err := os.WriteFile(filepath.Join(dir, "pint.json"), []byte("{}"), 0644)
require.NoError(t, err)
opts := FormatOptions{Dir: dir, Output: nil}
// Will fail because pint isn't actually installed, but tests the code path
err = Format(context.Background(), opts)
assert.Error(t, err) // Pint not installed
})
}
func TestAnalyse_Bad(t *testing.T) {
t.Run("fails when no analyser found", func(t *testing.T) {
dir := t.TempDir()
opts := AnalyseOptions{Dir: dir}
err := Analyse(context.TODO(), opts)
assert.Error(t, err)
assert.Contains(t, err.Error(), "no static analyser found")
})
t.Run("uses cwd when dir not specified", func(t *testing.T) {
opts := AnalyseOptions{Dir: ""}
err := Analyse(context.TODO(), opts)
// May or may not find an analyser depending on cwd
if err != nil {
assert.Contains(t, err.Error(), "no static analyser")
}
})
t.Run("uses stdout when output not specified", func(t *testing.T) {
dir := t.TempDir()
// Create phpstan.neon to enable analyser detection
err := os.WriteFile(filepath.Join(dir, "phpstan.neon"), []byte(""), 0644)
require.NoError(t, err)
opts := AnalyseOptions{Dir: dir, Output: nil}
// Will fail because phpstan isn't actually installed, but tests the code path
err = Analyse(context.Background(), opts)
assert.Error(t, err) // PHPStan not installed
})
}

517
quality_test.go Normal file
View file

@ -0,0 +1,517 @@
package php
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestDetectFormatter_Good(t *testing.T) {
t.Run("detects pint.json", func(t *testing.T) {
dir := t.TempDir()
err := os.WriteFile(filepath.Join(dir, "pint.json"), []byte("{}"), 0644)
require.NoError(t, err)
formatter, found := DetectFormatter(dir)
assert.True(t, found)
assert.Equal(t, FormatterPint, formatter)
})
t.Run("detects vendor binary", func(t *testing.T) {
dir := t.TempDir()
binDir := filepath.Join(dir, "vendor", "bin")
err := os.MkdirAll(binDir, 0755)
require.NoError(t, err)
err = os.WriteFile(filepath.Join(binDir, "pint"), []byte(""), 0755)
require.NoError(t, err)
formatter, found := DetectFormatter(dir)
assert.True(t, found)
assert.Equal(t, FormatterPint, formatter)
})
}
func TestDetectFormatter_Bad(t *testing.T) {
t.Run("no formatter", func(t *testing.T) {
dir := t.TempDir()
_, found := DetectFormatter(dir)
assert.False(t, found)
})
}
func TestDetectAnalyser_Good(t *testing.T) {
t.Run("detects phpstan.neon", func(t *testing.T) {
dir := t.TempDir()
err := os.WriteFile(filepath.Join(dir, "phpstan.neon"), []byte(""), 0644)
require.NoError(t, err)
analyser, found := DetectAnalyser(dir)
assert.True(t, found)
assert.Equal(t, AnalyserPHPStan, analyser)
})
t.Run("detects phpstan.neon.dist", func(t *testing.T) {
dir := t.TempDir()
err := os.WriteFile(filepath.Join(dir, "phpstan.neon.dist"), []byte(""), 0644)
require.NoError(t, err)
analyser, found := DetectAnalyser(dir)
assert.True(t, found)
assert.Equal(t, AnalyserPHPStan, analyser)
})
t.Run("detects larastan", func(t *testing.T) {
dir := t.TempDir()
err := os.WriteFile(filepath.Join(dir, "phpstan.neon"), []byte(""), 0644)
require.NoError(t, err)
larastanDir := filepath.Join(dir, "vendor", "larastan", "larastan")
err = os.MkdirAll(larastanDir, 0755)
require.NoError(t, err)
analyser, found := DetectAnalyser(dir)
assert.True(t, found)
assert.Equal(t, AnalyserLarastan, analyser)
})
t.Run("detects nunomaduro/larastan", func(t *testing.T) {
dir := t.TempDir()
err := os.WriteFile(filepath.Join(dir, "phpstan.neon"), []byte(""), 0644)
require.NoError(t, err)
larastanDir := filepath.Join(dir, "vendor", "nunomaduro", "larastan")
err = os.MkdirAll(larastanDir, 0755)
require.NoError(t, err)
analyser, found := DetectAnalyser(dir)
assert.True(t, found)
assert.Equal(t, AnalyserLarastan, analyser)
})
}
func TestBuildPintCommand_Good(t *testing.T) {
t.Run("basic command", func(t *testing.T) {
dir := t.TempDir()
opts := FormatOptions{Dir: dir}
cmd, args := buildPintCommand(opts)
assert.Equal(t, "pint", cmd)
assert.Contains(t, args, "--test")
})
t.Run("fix enabled", func(t *testing.T) {
dir := t.TempDir()
opts := FormatOptions{Dir: dir, Fix: true}
_, args := buildPintCommand(opts)
assert.NotContains(t, args, "--test")
})
t.Run("diff enabled", func(t *testing.T) {
dir := t.TempDir()
opts := FormatOptions{Dir: dir, Diff: true}
_, args := buildPintCommand(opts)
assert.Contains(t, args, "--diff")
})
t.Run("with specific paths", func(t *testing.T) {
dir := t.TempDir()
paths := []string{"app", "tests"}
opts := FormatOptions{Dir: dir, Paths: paths}
_, args := buildPintCommand(opts)
assert.Equal(t, paths, args[len(args)-2:])
})
t.Run("uses vendor binary if exists", func(t *testing.T) {
dir := t.TempDir()
binDir := filepath.Join(dir, "vendor", "bin")
err := os.MkdirAll(binDir, 0755)
require.NoError(t, err)
pintPath := filepath.Join(binDir, "pint")
err = os.WriteFile(pintPath, []byte(""), 0755)
require.NoError(t, err)
opts := FormatOptions{Dir: dir}
cmd, _ := buildPintCommand(opts)
assert.Equal(t, pintPath, cmd)
})
}
func TestBuildPHPStanCommand_Good(t *testing.T) {
t.Run("basic command", func(t *testing.T) {
dir := t.TempDir()
opts := AnalyseOptions{Dir: dir}
cmd, args := buildPHPStanCommand(opts)
assert.Equal(t, "phpstan", cmd)
assert.Equal(t, []string{"analyse"}, args)
})
t.Run("with level", func(t *testing.T) {
dir := t.TempDir()
opts := AnalyseOptions{Dir: dir, Level: 5}
_, args := buildPHPStanCommand(opts)
assert.Contains(t, args, "--level")
assert.Contains(t, args, "5")
})
t.Run("with memory limit", func(t *testing.T) {
dir := t.TempDir()
opts := AnalyseOptions{Dir: dir, Memory: "2G"}
_, args := buildPHPStanCommand(opts)
assert.Contains(t, args, "--memory-limit")
assert.Contains(t, args, "2G")
})
t.Run("uses vendor binary if exists", func(t *testing.T) {
dir := t.TempDir()
binDir := filepath.Join(dir, "vendor", "bin")
err := os.MkdirAll(binDir, 0755)
require.NoError(t, err)
phpstanPath := filepath.Join(binDir, "phpstan")
err = os.WriteFile(phpstanPath, []byte(""), 0755)
require.NoError(t, err)
opts := AnalyseOptions{Dir: dir}
cmd, _ := buildPHPStanCommand(opts)
assert.Equal(t, phpstanPath, cmd)
})
}
// =============================================================================
// Psalm Detection Tests
// =============================================================================
func TestDetectPsalm_Good(t *testing.T) {
t.Run("detects psalm.xml", func(t *testing.T) {
dir := t.TempDir()
err := os.WriteFile(filepath.Join(dir, "psalm.xml"), []byte(""), 0644)
require.NoError(t, err)
// Also need vendor binary for it to return true
binDir := filepath.Join(dir, "vendor", "bin")
err = os.MkdirAll(binDir, 0755)
require.NoError(t, err)
err = os.WriteFile(filepath.Join(binDir, "psalm"), []byte(""), 0755)
require.NoError(t, err)
psalmType, found := DetectPsalm(dir)
assert.True(t, found)
assert.Equal(t, PsalmStandard, psalmType)
})
t.Run("detects psalm.xml.dist", func(t *testing.T) {
dir := t.TempDir()
err := os.WriteFile(filepath.Join(dir, "psalm.xml.dist"), []byte(""), 0644)
require.NoError(t, err)
binDir := filepath.Join(dir, "vendor", "bin")
err = os.MkdirAll(binDir, 0755)
require.NoError(t, err)
err = os.WriteFile(filepath.Join(binDir, "psalm"), []byte(""), 0755)
require.NoError(t, err)
_, found := DetectPsalm(dir)
assert.True(t, found)
})
t.Run("detects vendor binary only", func(t *testing.T) {
dir := t.TempDir()
binDir := filepath.Join(dir, "vendor", "bin")
err := os.MkdirAll(binDir, 0755)
require.NoError(t, err)
err = os.WriteFile(filepath.Join(binDir, "psalm"), []byte(""), 0755)
require.NoError(t, err)
_, found := DetectPsalm(dir)
assert.True(t, found)
})
}
func TestDetectPsalm_Bad(t *testing.T) {
t.Run("no psalm", func(t *testing.T) {
dir := t.TempDir()
_, found := DetectPsalm(dir)
assert.False(t, found)
})
}
// =============================================================================
// Rector Detection Tests
// =============================================================================
func TestDetectRector_Good(t *testing.T) {
t.Run("detects rector.php", func(t *testing.T) {
dir := t.TempDir()
err := os.WriteFile(filepath.Join(dir, "rector.php"), []byte("<?php"), 0644)
require.NoError(t, err)
found := DetectRector(dir)
assert.True(t, found)
})
t.Run("detects vendor binary", func(t *testing.T) {
dir := t.TempDir()
binDir := filepath.Join(dir, "vendor", "bin")
err := os.MkdirAll(binDir, 0755)
require.NoError(t, err)
err = os.WriteFile(filepath.Join(binDir, "rector"), []byte(""), 0755)
require.NoError(t, err)
found := DetectRector(dir)
assert.True(t, found)
})
}
func TestDetectRector_Bad(t *testing.T) {
t.Run("no rector", func(t *testing.T) {
dir := t.TempDir()
found := DetectRector(dir)
assert.False(t, found)
})
}
// =============================================================================
// Infection Detection Tests
// =============================================================================
func TestDetectInfection_Good(t *testing.T) {
t.Run("detects infection.json", func(t *testing.T) {
dir := t.TempDir()
err := os.WriteFile(filepath.Join(dir, "infection.json"), []byte("{}"), 0644)
require.NoError(t, err)
found := DetectInfection(dir)
assert.True(t, found)
})
t.Run("detects infection.json5", func(t *testing.T) {
dir := t.TempDir()
err := os.WriteFile(filepath.Join(dir, "infection.json5"), []byte("{}"), 0644)
require.NoError(t, err)
found := DetectInfection(dir)
assert.True(t, found)
})
t.Run("detects vendor binary", func(t *testing.T) {
dir := t.TempDir()
binDir := filepath.Join(dir, "vendor", "bin")
err := os.MkdirAll(binDir, 0755)
require.NoError(t, err)
err = os.WriteFile(filepath.Join(binDir, "infection"), []byte(""), 0755)
require.NoError(t, err)
found := DetectInfection(dir)
assert.True(t, found)
})
}
func TestDetectInfection_Bad(t *testing.T) {
t.Run("no infection", func(t *testing.T) {
dir := t.TempDir()
found := DetectInfection(dir)
assert.False(t, found)
})
}
// =============================================================================
// QA Pipeline Tests
// =============================================================================
func TestGetQAStages_Good(t *testing.T) {
t.Run("default stages", func(t *testing.T) {
opts := QAOptions{}
stages := GetQAStages(opts)
assert.Equal(t, []QAStage{QAStageQuick, QAStageStandard}, stages)
})
t.Run("quick only", func(t *testing.T) {
opts := QAOptions{Quick: true}
stages := GetQAStages(opts)
assert.Equal(t, []QAStage{QAStageQuick}, stages)
})
t.Run("full stages", func(t *testing.T) {
opts := QAOptions{Full: true}
stages := GetQAStages(opts)
assert.Equal(t, []QAStage{QAStageQuick, QAStageStandard, QAStageFull}, stages)
})
}
func TestGetQAChecks_Good(t *testing.T) {
t.Run("quick stage checks", func(t *testing.T) {
dir := t.TempDir()
checks := GetQAChecks(dir, QAStageQuick)
assert.Contains(t, checks, "audit")
assert.Contains(t, checks, "fmt")
assert.Contains(t, checks, "stan")
})
t.Run("standard stage includes test", func(t *testing.T) {
dir := t.TempDir()
checks := GetQAChecks(dir, QAStageStandard)
assert.Contains(t, checks, "test")
})
t.Run("standard stage includes psalm if available", func(t *testing.T) {
dir := t.TempDir()
binDir := filepath.Join(dir, "vendor", "bin")
err := os.MkdirAll(binDir, 0755)
require.NoError(t, err)
err = os.WriteFile(filepath.Join(binDir, "psalm"), []byte(""), 0755)
require.NoError(t, err)
checks := GetQAChecks(dir, QAStageStandard)
assert.Contains(t, checks, "psalm")
})
t.Run("full stage includes rector if available", func(t *testing.T) {
dir := t.TempDir()
err := os.WriteFile(filepath.Join(dir, "rector.php"), []byte("<?php"), 0644)
require.NoError(t, err)
checks := GetQAChecks(dir, QAStageFull)
assert.Contains(t, checks, "rector")
})
}
// =============================================================================
// Security Checks Tests
// =============================================================================
func TestRunEnvSecurityChecks_Good(t *testing.T) {
t.Run("detects debug mode enabled", func(t *testing.T) {
dir := t.TempDir()
envContent := "APP_DEBUG=true\nAPP_KEY=base64:abcdefghijklmnopqrstuvwxyz123456\n"
err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644)
require.NoError(t, err)
checks := runEnvSecurityChecks(dir)
var debugCheck *SecurityCheck
for i := range checks {
if checks[i].ID == "debug_mode" {
debugCheck = &checks[i]
break
}
}
require.NotNil(t, debugCheck)
assert.False(t, debugCheck.Passed)
assert.Equal(t, "critical", debugCheck.Severity)
})
t.Run("passes with debug disabled", func(t *testing.T) {
dir := t.TempDir()
envContent := "APP_DEBUG=false\nAPP_KEY=base64:abcdefghijklmnopqrstuvwxyz123456\n"
err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644)
require.NoError(t, err)
checks := runEnvSecurityChecks(dir)
var debugCheck *SecurityCheck
for i := range checks {
if checks[i].ID == "debug_mode" {
debugCheck = &checks[i]
break
}
}
require.NotNil(t, debugCheck)
assert.True(t, debugCheck.Passed)
})
t.Run("detects weak app key", func(t *testing.T) {
dir := t.TempDir()
envContent := "APP_KEY=short\n"
err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644)
require.NoError(t, err)
checks := runEnvSecurityChecks(dir)
var keyCheck *SecurityCheck
for i := range checks {
if checks[i].ID == "app_key_set" {
keyCheck = &checks[i]
break
}
}
require.NotNil(t, keyCheck)
assert.False(t, keyCheck.Passed)
})
t.Run("detects non-https app url", func(t *testing.T) {
dir := t.TempDir()
envContent := "APP_URL=http://example.com\n"
err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644)
require.NoError(t, err)
checks := runEnvSecurityChecks(dir)
var urlCheck *SecurityCheck
for i := range checks {
if checks[i].ID == "https_enforced" {
urlCheck = &checks[i]
break
}
}
require.NotNil(t, urlCheck)
assert.False(t, urlCheck.Passed)
})
}
func TestRunFilesystemSecurityChecks_Good(t *testing.T) {
t.Run("detects .env in public", func(t *testing.T) {
dir := t.TempDir()
publicDir := filepath.Join(dir, "public")
err := os.MkdirAll(publicDir, 0755)
require.NoError(t, err)
err = os.WriteFile(filepath.Join(publicDir, ".env"), []byte(""), 0644)
require.NoError(t, err)
checks := runFilesystemSecurityChecks(dir)
found := false
for _, check := range checks {
if check.ID == "env_not_public" && !check.Passed {
found = true
break
}
}
assert.True(t, found, "should detect .env in public directory")
})
t.Run("detects .git in public", func(t *testing.T) {
dir := t.TempDir()
gitDir := filepath.Join(dir, "public", ".git")
err := os.MkdirAll(gitDir, 0755)
require.NoError(t, err)
checks := runFilesystemSecurityChecks(dir)
found := false
for _, check := range checks {
if check.ID == "git_not_public" && !check.Passed {
found = true
break
}
}
assert.True(t, found, "should detect .git in public directory")
})
t.Run("passes with clean public directory", func(t *testing.T) {
dir := t.TempDir()
publicDir := filepath.Join(dir, "public")
err := os.MkdirAll(publicDir, 0755)
require.NoError(t, err)
// Add only safe files
err = os.WriteFile(filepath.Join(publicDir, "index.php"), []byte("<?php"), 0644)
require.NoError(t, err)
checks := runFilesystemSecurityChecks(dir)
assert.Empty(t, checks, "should not report issues for clean public directory")
})
}

486
services.go Normal file
View file

@ -0,0 +1,486 @@
// Package php provides Laravel/PHP development environment management.
package php
import (
"bufio"
"context"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
"forge.lthn.ai/core/go/pkg/cli"
)
// 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, cli.Err("no log file available for %s", s.name)
}
m := getMedium()
file, err := m.Open(s.logPath)
if err != nil {
return nil, cli.WrapVerb(err, "open", "log file")
}
if !follow {
return file.(io.ReadCloser), nil
}
// For follow mode, return a tailing reader
// Type assert to get the underlying *os.File for tailing
osFile, ok := file.(*os.File)
if !ok {
file.Close()
return nil, cli.Err("log file is not a regular file")
}
return newTailReader(osFile), 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 cli.Err("%s is already running", s.name)
}
// Create log file
m := getMedium()
logDir := filepath.Join(s.dir, ".core", "logs")
if err := m.EnsureDir(logDir); err != nil {
return cli.WrapVerb(err, "create", "log directory")
}
s.logPath = filepath.Join(logDir, cli.Sprintf("%s.log", strings.ToLower(s.name)))
logWriter, err := m.Create(s.logPath)
if err != nil {
return cli.WrapVerb(err, "create", "log file")
}
// Type assert to get the underlying *os.File for use with exec.Cmd
logFile, ok := logWriter.(*os.File)
if !ok {
logWriter.Close()
return cli.Err("log file is not a regular file")
}
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 platform-specific process attributes for clean shutdown
setSysProcAttr(s.cmd)
if err := s.cmd.Start(); err != nil {
_ = logFile.Close()
s.lastError = err
return cli.WrapVerb(err, "start", s.name)
}
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 termination signal to process (group on Unix)
_ = signalProcessGroup(s.cmd, termSignal())
// 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
_ = signalProcessGroup(s.cmd, killSignal())
}
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
}
// Start launches the FrankenPHP Octane server.
func (s *FrankenPHPService) Start(ctx context.Context) error {
args := []string{
"artisan", "octane:start",
"--server=frankenphp",
cli.Sprintf("--port=%d", s.port),
"--no-interaction",
}
if s.https && s.certFile != "" && s.keyFile != "" {
args = append(args,
cli.Sprintf("--https-port=%d", s.httpsPort),
cli.Sprintf("--https-certificate=%s", s.certFile),
cli.Sprintf("--https-certificate-key=%s", s.keyFile),
)
}
return s.startProcess(ctx, "php", args, nil)
}
// Stop terminates the FrankenPHP server process.
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
}
// Start launches the Vite development server.
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)
}
// Stop terminates the Vite development server.
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,
},
}
}
// Start launches the Laravel Horizon queue worker.
func (s *HorizonService) Start(ctx context.Context) error {
return s.startProcess(ctx, "php", []string{"artisan", "horizon"}, nil)
}
// Stop terminates Horizon using its terminate command.
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
}
// Start launches the Laravel Reverb WebSocket server.
func (s *ReverbService) Start(ctx context.Context) error {
args := []string{
"artisan", "reverb:start",
cli.Sprintf("--port=%d", s.port),
}
return s.startProcess(ctx, "php", args, nil)
}
// Stop terminates the Reverb WebSocket server.
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
}
// Start launches the Redis server.
func (s *RedisService) Start(ctx context.Context) error {
args := []string{
"--port", cli.Sprintf("%d", s.port),
"--daemonize", "no",
}
if s.configFile != "" {
args = []string{s.configFile}
args = append(args, "--port", cli.Sprintf("%d", s.port), "--daemonize", "no")
}
return s.startProcess(ctx, "redis-server", args, nil)
}
// Stop terminates Redis using the shutdown command.
func (s *RedisService) Stop() error {
// Try graceful shutdown via redis-cli
cmd := exec.Command("redis-cli", "-p", cli.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()
}

313
services_extended_test.go Normal file
View file

@ -0,0 +1,313 @@
package php
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestBaseService_Name_Good(t *testing.T) {
t.Run("returns service name", func(t *testing.T) {
s := &baseService{name: "TestService"}
assert.Equal(t, "TestService", s.Name())
})
}
func TestBaseService_Status_Good(t *testing.T) {
t.Run("returns status when not running", func(t *testing.T) {
s := &baseService{
name: "TestService",
port: 8080,
running: false,
}
status := s.Status()
assert.Equal(t, "TestService", status.Name)
assert.Equal(t, 8080, status.Port)
assert.False(t, status.Running)
assert.Equal(t, 0, status.PID)
})
t.Run("returns status when running", func(t *testing.T) {
s := &baseService{
name: "TestService",
port: 8080,
running: true,
}
status := s.Status()
assert.True(t, status.Running)
})
t.Run("returns error in status", func(t *testing.T) {
testErr := assert.AnError
s := &baseService{
name: "TestService",
lastError: testErr,
}
status := s.Status()
assert.Equal(t, testErr, status.Error)
})
}
func TestBaseService_Logs_Good(t *testing.T) {
t.Run("returns log file content", func(t *testing.T) {
dir := t.TempDir()
logPath := filepath.Join(dir, "test.log")
err := os.WriteFile(logPath, []byte("test log content"), 0644)
require.NoError(t, err)
s := &baseService{logPath: logPath}
reader, err := s.Logs(false)
assert.NoError(t, err)
assert.NotNil(t, reader)
_ = reader.Close()
})
t.Run("returns tail reader in follow mode", func(t *testing.T) {
dir := t.TempDir()
logPath := filepath.Join(dir, "test.log")
err := os.WriteFile(logPath, []byte("test log content"), 0644)
require.NoError(t, err)
s := &baseService{logPath: logPath}
reader, err := s.Logs(true)
assert.NoError(t, err)
assert.NotNil(t, reader)
// Verify it's a tailReader by checking it implements ReadCloser
_, ok := reader.(*tailReader)
assert.True(t, ok)
_ = reader.Close()
})
}
func TestBaseService_Logs_Bad(t *testing.T) {
t.Run("returns error when no log path", func(t *testing.T) {
s := &baseService{name: "TestService"}
_, err := s.Logs(false)
assert.Error(t, err)
assert.Contains(t, err.Error(), "no log file available")
})
t.Run("returns error when log file doesn't exist", func(t *testing.T) {
s := &baseService{logPath: "/nonexistent/path/log.log"}
_, err := s.Logs(false)
assert.Error(t, err)
assert.Contains(t, err.Error(), "Failed to open log file")
})
}
func TestTailReader_Good(t *testing.T) {
t.Run("creates new tail reader", func(t *testing.T) {
dir := t.TempDir()
logPath := filepath.Join(dir, "test.log")
err := os.WriteFile(logPath, []byte("content"), 0644)
require.NoError(t, err)
file, err := os.Open(logPath)
require.NoError(t, err)
defer func() { _ = file.Close() }()
reader := newTailReader(file)
assert.NotNil(t, reader)
assert.NotNil(t, reader.file)
assert.NotNil(t, reader.reader)
assert.False(t, reader.closed)
})
t.Run("closes file on Close", func(t *testing.T) {
dir := t.TempDir()
logPath := filepath.Join(dir, "test.log")
err := os.WriteFile(logPath, []byte("content"), 0644)
require.NoError(t, err)
file, err := os.Open(logPath)
require.NoError(t, err)
reader := newTailReader(file)
err = reader.Close()
assert.NoError(t, err)
assert.True(t, reader.closed)
})
t.Run("returns EOF when closed", func(t *testing.T) {
dir := t.TempDir()
logPath := filepath.Join(dir, "test.log")
err := os.WriteFile(logPath, []byte("content"), 0644)
require.NoError(t, err)
file, err := os.Open(logPath)
require.NoError(t, err)
reader := newTailReader(file)
_ = reader.Close()
buf := make([]byte, 100)
n, _ := reader.Read(buf)
// When closed, should return 0 bytes (the closed flag causes early return)
assert.Equal(t, 0, n)
})
}
func TestFrankenPHPService_Extended(t *testing.T) {
t.Run("all options set correctly", func(t *testing.T) {
opts := FrankenPHPOptions{
Port: 9000,
HTTPSPort: 9443,
HTTPS: true,
CertFile: "/path/to/cert.pem",
KeyFile: "/path/to/key.pem",
}
service := NewFrankenPHPService("/project", opts)
assert.Equal(t, "FrankenPHP", service.Name())
assert.Equal(t, 9000, service.port)
assert.Equal(t, 9443, service.httpsPort)
assert.True(t, service.https)
assert.Equal(t, "/path/to/cert.pem", service.certFile)
assert.Equal(t, "/path/to/key.pem", service.keyFile)
assert.Equal(t, "/project", service.dir)
})
}
func TestViteService_Extended(t *testing.T) {
t.Run("auto-detects package manager", func(t *testing.T) {
dir := t.TempDir()
// Create bun.lockb to trigger bun detection
err := os.WriteFile(filepath.Join(dir, "bun.lockb"), []byte(""), 0644)
require.NoError(t, err)
service := NewViteService(dir, ViteOptions{})
assert.Equal(t, "bun", service.packageManager)
})
t.Run("uses provided package manager", func(t *testing.T) {
dir := t.TempDir()
service := NewViteService(dir, ViteOptions{PackageManager: "pnpm"})
assert.Equal(t, "pnpm", service.packageManager)
})
}
func TestHorizonService_Extended(t *testing.T) {
t.Run("has zero port", func(t *testing.T) {
service := NewHorizonService("/project")
assert.Equal(t, 0, service.port)
})
}
func TestReverbService_Extended(t *testing.T) {
t.Run("uses default port 8080", func(t *testing.T) {
service := NewReverbService("/project", ReverbOptions{})
assert.Equal(t, 8080, service.port)
})
t.Run("uses custom port", func(t *testing.T) {
service := NewReverbService("/project", ReverbOptions{Port: 9090})
assert.Equal(t, 9090, service.port)
})
}
func TestRedisService_Extended(t *testing.T) {
t.Run("uses default port 6379", func(t *testing.T) {
service := NewRedisService("/project", RedisOptions{})
assert.Equal(t, 6379, service.port)
})
t.Run("accepts config file", func(t *testing.T) {
service := NewRedisService("/project", RedisOptions{ConfigFile: "/path/to/redis.conf"})
assert.Equal(t, "/path/to/redis.conf", service.configFile)
})
}
func TestServiceStatus_Struct(t *testing.T) {
t.Run("all fields accessible", func(t *testing.T) {
testErr := assert.AnError
status := ServiceStatus{
Name: "TestService",
Running: true,
PID: 12345,
Port: 8080,
Error: testErr,
}
assert.Equal(t, "TestService", status.Name)
assert.True(t, status.Running)
assert.Equal(t, 12345, status.PID)
assert.Equal(t, 8080, status.Port)
assert.Equal(t, testErr, status.Error)
})
}
func TestFrankenPHPOptions_Struct(t *testing.T) {
t.Run("all fields accessible", func(t *testing.T) {
opts := FrankenPHPOptions{
Port: 8000,
HTTPSPort: 443,
HTTPS: true,
CertFile: "cert.pem",
KeyFile: "key.pem",
}
assert.Equal(t, 8000, opts.Port)
assert.Equal(t, 443, opts.HTTPSPort)
assert.True(t, opts.HTTPS)
assert.Equal(t, "cert.pem", opts.CertFile)
assert.Equal(t, "key.pem", opts.KeyFile)
})
}
func TestViteOptions_Struct(t *testing.T) {
t.Run("all fields accessible", func(t *testing.T) {
opts := ViteOptions{
Port: 5173,
PackageManager: "bun",
}
assert.Equal(t, 5173, opts.Port)
assert.Equal(t, "bun", opts.PackageManager)
})
}
func TestReverbOptions_Struct(t *testing.T) {
t.Run("all fields accessible", func(t *testing.T) {
opts := ReverbOptions{Port: 8080}
assert.Equal(t, 8080, opts.Port)
})
}
func TestRedisOptions_Struct(t *testing.T) {
t.Run("all fields accessible", func(t *testing.T) {
opts := RedisOptions{
Port: 6379,
ConfigFile: "redis.conf",
}
assert.Equal(t, 6379, opts.Port)
assert.Equal(t, "redis.conf", opts.ConfigFile)
})
}
func TestBaseService_StopProcess_Good(t *testing.T) {
t.Run("returns nil when not running", func(t *testing.T) {
s := &baseService{running: false}
err := s.stopProcess()
assert.NoError(t, err)
})
t.Run("returns nil when cmd is nil", func(t *testing.T) {
s := &baseService{running: true, cmd: nil}
err := s.stopProcess()
assert.NoError(t, err)
})
}

100
services_test.go Normal file
View file

@ -0,0 +1,100 @@
package php
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestNewFrankenPHPService_Good(t *testing.T) {
t.Run("default options", func(t *testing.T) {
dir := "/tmp/test"
service := NewFrankenPHPService(dir, FrankenPHPOptions{})
assert.Equal(t, "FrankenPHP", service.Name())
assert.Equal(t, 8000, service.port)
assert.Equal(t, 443, service.httpsPort)
assert.False(t, service.https)
})
t.Run("custom options", func(t *testing.T) {
dir := "/tmp/test"
opts := FrankenPHPOptions{
Port: 9000,
HTTPSPort: 8443,
HTTPS: true,
CertFile: "cert.pem",
KeyFile: "key.pem",
}
service := NewFrankenPHPService(dir, opts)
assert.Equal(t, 9000, service.port)
assert.Equal(t, 8443, service.httpsPort)
assert.True(t, service.https)
assert.Equal(t, "cert.pem", service.certFile)
assert.Equal(t, "key.pem", service.keyFile)
})
}
func TestNewViteService_Good(t *testing.T) {
t.Run("default options", func(t *testing.T) {
dir := t.TempDir()
service := NewViteService(dir, ViteOptions{})
assert.Equal(t, "Vite", service.Name())
assert.Equal(t, 5173, service.port)
assert.Equal(t, "npm", service.packageManager) // default when no lock file
})
t.Run("custom package manager", func(t *testing.T) {
dir := t.TempDir()
service := NewViteService(dir, ViteOptions{PackageManager: "pnpm"})
assert.Equal(t, "pnpm", service.packageManager)
})
}
func TestNewHorizonService_Good(t *testing.T) {
service := NewHorizonService("/tmp/test")
assert.Equal(t, "Horizon", service.Name())
assert.Equal(t, 0, service.port)
}
func TestNewReverbService_Good(t *testing.T) {
t.Run("default options", func(t *testing.T) {
service := NewReverbService("/tmp/test", ReverbOptions{})
assert.Equal(t, "Reverb", service.Name())
assert.Equal(t, 8080, service.port)
})
t.Run("custom port", func(t *testing.T) {
service := NewReverbService("/tmp/test", ReverbOptions{Port: 9090})
assert.Equal(t, 9090, service.port)
})
}
func TestNewRedisService_Good(t *testing.T) {
t.Run("default options", func(t *testing.T) {
service := NewRedisService("/tmp/test", RedisOptions{})
assert.Equal(t, "Redis", service.Name())
assert.Equal(t, 6379, service.port)
})
t.Run("custom config", func(t *testing.T) {
service := NewRedisService("/tmp/test", RedisOptions{ConfigFile: "redis.conf"})
assert.Equal(t, "redis.conf", service.configFile)
})
}
func TestBaseService_Status(t *testing.T) {
s := &baseService{
name: "TestService",
port: 1234,
running: true,
}
status := s.Status()
assert.Equal(t, "TestService", status.Name)
assert.Equal(t, 1234, status.Port)
assert.True(t, status.Running)
}

41
services_unix.go Normal file
View file

@ -0,0 +1,41 @@
//go:build unix
package php
import (
"os/exec"
"syscall"
)
// setSysProcAttr sets Unix-specific process attributes for clean process group handling.
func setSysProcAttr(cmd *exec.Cmd) {
cmd.SysProcAttr = &syscall.SysProcAttr{
Setpgid: true,
}
}
// signalProcessGroup sends a signal to the process group.
// On Unix, this uses negative PID to signal the entire group.
func signalProcessGroup(cmd *exec.Cmd, sig syscall.Signal) error {
if cmd.Process == nil {
return nil
}
pgid, err := syscall.Getpgid(cmd.Process.Pid)
if err == nil {
return syscall.Kill(-pgid, sig)
}
// Fallback to signaling just the process
return cmd.Process.Signal(sig)
}
// termSignal returns SIGTERM for Unix.
func termSignal() syscall.Signal {
return syscall.SIGTERM
}
// killSignal returns SIGKILL for Unix.
func killSignal() syscall.Signal {
return syscall.SIGKILL
}

34
services_windows.go Normal file
View file

@ -0,0 +1,34 @@
//go:build windows
package php
import (
"os"
"os/exec"
)
// setSysProcAttr sets Windows-specific process attributes.
// Windows doesn't support Setpgid, so this is a no-op.
func setSysProcAttr(cmd *exec.Cmd) {
// No-op on Windows - process groups work differently
}
// signalProcessGroup sends a termination signal to the process.
// On Windows, we can only signal the main process, not a group.
func signalProcessGroup(cmd *exec.Cmd, sig os.Signal) error {
if cmd.Process == nil {
return nil
}
return cmd.Process.Signal(sig)
}
// termSignal returns os.Interrupt for Windows (closest to SIGTERM).
func termSignal() os.Signal {
return os.Interrupt
}
// killSignal returns os.Kill for Windows.
func killSignal() os.Signal {
return os.Kill
}

165
ssl.go Normal file
View file

@ -0,0 +1,165 @@
package php
import (
"os"
"os/exec"
"path/filepath"
"forge.lthn.ai/core/go/pkg/cli"
)
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) {
m := getMedium()
dir := opts.Dir
if dir == "" {
home, err := os.UserHomeDir()
if err != nil {
return "", cli.WrapVerb(err, "get", "home directory")
}
dir = filepath.Join(home, DefaultSSLDir)
}
if err := m.EnsureDir(dir); err != nil {
return "", cli.WrapVerb(err, "create", "SSL directory")
}
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, cli.Sprintf("%s.pem", domain))
keyFile = filepath.Join(dir, cli.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 {
m := getMedium()
certFile, keyFile, err := CertPaths(domain, opts)
if err != nil {
return false
}
if !m.IsFile(certFile) {
return false
}
if !m.IsFile(keyFile) {
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 cli.Err("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 cli.Err("failed to install mkcert CA: %v\n%s", err, output)
}
// Generate certificates
certFile := filepath.Join(dir, cli.Sprintf("%s.pem", domain))
keyFile := filepath.Join(dir, cli.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 cli.Err("failed to generate certificates: %v\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 cli.Err("mkcert is not installed")
}
cmd := exec.Command("mkcert", "-install")
output, err := cmd.CombinedOutput()
if err != nil {
return cli.Err("failed to install mkcert CA: %v\n%s", err, output)
}
return nil
}
// GetMkcertCARoot returns the path to the mkcert CA root directory.
func GetMkcertCARoot() (string, error) {
if !IsMkcertInstalled() {
return "", cli.Err("mkcert is not installed")
}
cmd := exec.Command("mkcert", "-CAROOT")
output, err := cmd.Output()
if err != nil {
return "", cli.WrapVerb(err, "get", "mkcert CA root")
}
return filepath.Clean(string(output)), nil
}

219
ssl_extended_test.go Normal file
View file

@ -0,0 +1,219 @@
package php
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestSSLOptions_Struct(t *testing.T) {
t.Run("all fields accessible", func(t *testing.T) {
opts := SSLOptions{Dir: "/custom/ssl/dir"}
assert.Equal(t, "/custom/ssl/dir", opts.Dir)
})
}
func TestGetSSLDir_Bad(t *testing.T) {
t.Run("fails to create directory in invalid path", func(t *testing.T) {
// Try to create a directory in a path that can't exist
opts := SSLOptions{Dir: "/dev/null/cannot/create"}
_, err := GetSSLDir(opts)
assert.Error(t, err)
assert.Contains(t, err.Error(), "Failed to create SSL directory")
})
}
func TestCertPaths_Bad(t *testing.T) {
t.Run("fails when GetSSLDir fails", func(t *testing.T) {
opts := SSLOptions{Dir: "/dev/null/cannot/create"}
_, _, err := CertPaths("domain.test", opts)
assert.Error(t, err)
})
}
func TestCertsExist_Detailed(t *testing.T) {
t.Run("returns true when both cert and key exist", func(t *testing.T) {
dir := t.TempDir()
domain := "test.local"
// Create both files
certPath := filepath.Join(dir, domain+".pem")
keyPath := filepath.Join(dir, domain+"-key.pem")
err := os.WriteFile(certPath, []byte("cert"), 0644)
require.NoError(t, err)
err = os.WriteFile(keyPath, []byte("key"), 0644)
require.NoError(t, err)
result := CertsExist(domain, SSLOptions{Dir: dir})
assert.True(t, result)
})
t.Run("returns false when only cert exists", func(t *testing.T) {
dir := t.TempDir()
domain := "test.local"
certPath := filepath.Join(dir, domain+".pem")
err := os.WriteFile(certPath, []byte("cert"), 0644)
require.NoError(t, err)
result := CertsExist(domain, SSLOptions{Dir: dir})
assert.False(t, result)
})
t.Run("returns false when only key exists", func(t *testing.T) {
dir := t.TempDir()
domain := "test.local"
keyPath := filepath.Join(dir, domain+"-key.pem")
err := os.WriteFile(keyPath, []byte("key"), 0644)
require.NoError(t, err)
result := CertsExist(domain, SSLOptions{Dir: dir})
assert.False(t, result)
})
t.Run("returns false when CertPaths fails", func(t *testing.T) {
result := CertsExist("domain.test", SSLOptions{Dir: "/dev/null/cannot/create"})
assert.False(t, result)
})
}
func TestSetupSSL_RequiresMkcert(t *testing.T) {
t.Run("fails when mkcert not installed", func(t *testing.T) {
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_UsesExisting(t *testing.T) {
t.Run("returns existing certs without regenerating", func(t *testing.T) {
dir := t.TempDir()
domain := "existing.test"
// Create existing certs
certPath := filepath.Join(dir, domain+".pem")
keyPath := filepath.Join(dir, domain+"-key.pem")
err := os.WriteFile(certPath, []byte("existing cert"), 0644)
require.NoError(t, err)
err = os.WriteFile(keyPath, []byte("existing key"), 0644)
require.NoError(t, err)
resultCert, resultKey, err := SetupSSLIfNeeded(domain, SSLOptions{Dir: dir})
assert.NoError(t, err)
assert.Equal(t, certPath, resultCert)
assert.Equal(t, keyPath, resultKey)
// Verify original content wasn't changed
content, _ := os.ReadFile(certPath)
assert.Equal(t, "existing cert", string(content))
})
}
func TestSetupSSLIfNeeded_Bad(t *testing.T) {
t.Run("fails when CertPaths fails", func(t *testing.T) {
_, _, err := SetupSSLIfNeeded("domain.test", SSLOptions{Dir: "/dev/null/cannot/create"})
assert.Error(t, err)
})
t.Run("fails when SetupSSL fails", func(t *testing.T) {
if IsMkcertInstalled() {
t.Skip("mkcert is installed, skipping error test")
}
dir := t.TempDir()
_, _, err := SetupSSLIfNeeded("domain.test", SSLOptions{Dir: dir})
assert.Error(t, err)
})
}
func TestInstallMkcertCA_Bad(t *testing.T) {
t.Run("fails when mkcert not installed", func(t *testing.T) {
if IsMkcertInstalled() {
t.Skip("mkcert is installed, skipping error test")
}
err := InstallMkcertCA()
assert.Error(t, err)
assert.Contains(t, err.Error(), "mkcert is not installed")
})
}
func TestGetMkcertCARoot_Bad(t *testing.T) {
t.Run("fails when mkcert not installed", func(t *testing.T) {
if IsMkcertInstalled() {
t.Skip("mkcert is installed, skipping error test")
}
_, err := GetMkcertCARoot()
assert.Error(t, err)
assert.Contains(t, err.Error(), "mkcert is not installed")
})
}
func TestCertPathsNaming(t *testing.T) {
t.Run("uses correct naming convention", func(t *testing.T) {
dir := t.TempDir()
domain := "myapp.example.com"
certFile, keyFile, err := CertPaths(domain, SSLOptions{Dir: dir})
assert.NoError(t, err)
assert.Equal(t, filepath.Join(dir, "myapp.example.com.pem"), certFile)
assert.Equal(t, filepath.Join(dir, "myapp.example.com-key.pem"), keyFile)
})
t.Run("handles localhost", func(t *testing.T) {
dir := t.TempDir()
certFile, keyFile, err := CertPaths("localhost", SSLOptions{Dir: dir})
assert.NoError(t, err)
assert.Equal(t, filepath.Join(dir, "localhost.pem"), certFile)
assert.Equal(t, filepath.Join(dir, "localhost-key.pem"), keyFile)
})
t.Run("handles wildcard-like domains", func(t *testing.T) {
dir := t.TempDir()
domain := "*.example.com"
certFile, keyFile, err := CertPaths(domain, SSLOptions{Dir: dir})
assert.NoError(t, err)
assert.Contains(t, certFile, "*.example.com.pem")
assert.Contains(t, keyFile, "*.example.com-key.pem")
})
}
func TestDefaultSSLDir_Value(t *testing.T) {
t.Run("has expected default value", func(t *testing.T) {
assert.Equal(t, ".core/ssl", DefaultSSLDir)
})
}
func TestGetSSLDir_CreatesDirectory(t *testing.T) {
t.Run("creates nested directory structure", func(t *testing.T) {
baseDir := t.TempDir()
nestedDir := filepath.Join(baseDir, "level1", "level2", "ssl")
dir, err := GetSSLDir(SSLOptions{Dir: nestedDir})
assert.NoError(t, err)
assert.Equal(t, nestedDir, dir)
// Verify directory exists
info, err := os.Stat(dir)
assert.NoError(t, err)
assert.True(t, info.IsDir())
})
}

172
ssl_test.go Normal file
View 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)
})
}

195
testing.go Normal file
View file

@ -0,0 +1,195 @@
package php
import (
"context"
"io"
"os"
"os/exec"
"path/filepath"
"forge.lthn.ai/core/go/pkg/cli"
)
// TestOptions configures PHP test execution.
type TestOptions struct {
// Dir is the project directory (defaults to current working directory).
Dir string
// Filter filters tests by name pattern.
Filter string
// Parallel runs tests in parallel.
Parallel bool
// Coverage generates code coverage.
Coverage bool
// CoverageFormat is the coverage output format (text, html, clover).
CoverageFormat string
// Groups runs only tests in the specified groups.
Groups []string
// JUnit outputs results in JUnit XML format via --log-junit.
JUnit bool
// Output is the writer for test output (defaults to os.Stdout).
Output io.Writer
}
// TestRunner represents the detected test runner.
type TestRunner string
// Test runner type constants.
const (
// TestRunnerPest indicates Pest testing framework.
TestRunnerPest TestRunner = "pest"
// TestRunnerPHPUnit indicates PHPUnit testing framework.
TestRunnerPHPUnit TestRunner = "phpunit"
)
// DetectTestRunner detects which test runner is available in the project.
// Returns Pest if tests/Pest.php exists, otherwise PHPUnit.
func DetectTestRunner(dir string) TestRunner {
// Check for Pest
pestFile := filepath.Join(dir, "tests", "Pest.php")
if getMedium().IsFile(pestFile) {
return TestRunnerPest
}
return TestRunnerPHPUnit
}
// RunTests runs PHPUnit or Pest tests.
func RunTests(ctx context.Context, opts TestOptions) error {
if opts.Dir == "" {
cwd, err := os.Getwd()
if err != nil {
return cli.WrapVerb(err, "get", "working directory")
}
opts.Dir = cwd
}
if opts.Output == nil {
opts.Output = os.Stdout
}
// Detect test runner
runner := DetectTestRunner(opts.Dir)
// Build command based on runner
var cmdName string
var args []string
switch runner {
case TestRunnerPest:
cmdName, args = buildPestCommand(opts)
default:
cmdName, args = buildPHPUnitCommand(opts)
}
cmd := exec.CommandContext(ctx, cmdName, args...)
cmd.Dir = opts.Dir
cmd.Stdout = opts.Output
cmd.Stderr = opts.Output
cmd.Stdin = os.Stdin
// Set XDEBUG_MODE=coverage to avoid PHPUnit 11 warning
cmd.Env = append(os.Environ(), "XDEBUG_MODE=coverage")
return cmd.Run()
}
// RunParallel runs tests in parallel using the appropriate runner.
func RunParallel(ctx context.Context, opts TestOptions) error {
opts.Parallel = true
return RunTests(ctx, opts)
}
// buildPestCommand builds the command for running Pest tests.
func buildPestCommand(opts TestOptions) (string, []string) {
m := getMedium()
// Check for vendor binary first
vendorBin := filepath.Join(opts.Dir, "vendor", "bin", "pest")
cmdName := "pest"
if m.IsFile(vendorBin) {
cmdName = vendorBin
}
var args []string
if opts.Filter != "" {
args = append(args, "--filter", opts.Filter)
}
if opts.Parallel {
args = append(args, "--parallel")
}
if opts.Coverage {
switch opts.CoverageFormat {
case "html":
args = append(args, "--coverage-html", "coverage")
case "clover":
args = append(args, "--coverage-clover", "coverage.xml")
default:
args = append(args, "--coverage")
}
}
for _, group := range opts.Groups {
args = append(args, "--group", group)
}
if opts.JUnit {
args = append(args, "--log-junit", "test-results.xml")
}
return cmdName, args
}
// buildPHPUnitCommand builds the command for running PHPUnit tests.
func buildPHPUnitCommand(opts TestOptions) (string, []string) {
m := getMedium()
// Check for vendor binary first
vendorBin := filepath.Join(opts.Dir, "vendor", "bin", "phpunit")
cmdName := "phpunit"
if m.IsFile(vendorBin) {
cmdName = vendorBin
}
var args []string
if opts.Filter != "" {
args = append(args, "--filter", opts.Filter)
}
if opts.Parallel {
// PHPUnit uses paratest for parallel execution
paratestBin := filepath.Join(opts.Dir, "vendor", "bin", "paratest")
if m.IsFile(paratestBin) {
cmdName = paratestBin
}
}
if opts.Coverage {
switch opts.CoverageFormat {
case "html":
args = append(args, "--coverage-html", "coverage")
case "clover":
args = append(args, "--coverage-clover", "coverage.xml")
default:
args = append(args, "--coverage-text")
}
}
for _, group := range opts.Groups {
args = append(args, "--group", group)
}
if opts.JUnit {
args = append(args, "--log-junit", "test-results.xml", "--testdox")
}
return cmdName, args
}

380
testing_test.go Normal file
View file

@ -0,0 +1,380 @@
package php
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestDetectTestRunner_Good(t *testing.T) {
t.Run("detects Pest when tests/Pest.php exists", func(t *testing.T) {
dir := t.TempDir()
testsDir := filepath.Join(dir, "tests")
err := os.MkdirAll(testsDir, 0755)
require.NoError(t, err)
err = os.WriteFile(filepath.Join(testsDir, "Pest.php"), []byte("<?php\n"), 0644)
require.NoError(t, err)
runner := DetectTestRunner(dir)
assert.Equal(t, TestRunnerPest, runner)
})
t.Run("returns PHPUnit when no Pest.php", func(t *testing.T) {
dir := t.TempDir()
runner := DetectTestRunner(dir)
assert.Equal(t, TestRunnerPHPUnit, runner)
})
t.Run("returns PHPUnit when tests directory exists but no Pest.php", func(t *testing.T) {
dir := t.TempDir()
testsDir := filepath.Join(dir, "tests")
err := os.MkdirAll(testsDir, 0755)
require.NoError(t, err)
runner := DetectTestRunner(dir)
assert.Equal(t, TestRunnerPHPUnit, runner)
})
}
func TestBuildPestCommand_Good(t *testing.T) {
t.Run("basic command", func(t *testing.T) {
dir := t.TempDir()
opts := TestOptions{Dir: dir}
cmd, args := buildPestCommand(opts)
assert.Equal(t, "pest", cmd)
assert.Empty(t, args)
})
t.Run("with filter", func(t *testing.T) {
dir := t.TempDir()
opts := TestOptions{Dir: dir, Filter: "UserTest"}
_, args := buildPestCommand(opts)
assert.Contains(t, args, "--filter")
assert.Contains(t, args, "UserTest")
})
t.Run("with parallel", func(t *testing.T) {
dir := t.TempDir()
opts := TestOptions{Dir: dir, Parallel: true}
_, args := buildPestCommand(opts)
assert.Contains(t, args, "--parallel")
})
t.Run("with coverage", func(t *testing.T) {
dir := t.TempDir()
opts := TestOptions{Dir: dir, Coverage: true}
_, args := buildPestCommand(opts)
assert.Contains(t, args, "--coverage")
})
t.Run("with coverage HTML format", func(t *testing.T) {
dir := t.TempDir()
opts := TestOptions{Dir: dir, Coverage: true, CoverageFormat: "html"}
_, args := buildPestCommand(opts)
assert.Contains(t, args, "--coverage-html")
assert.Contains(t, args, "coverage")
})
t.Run("with coverage clover format", func(t *testing.T) {
dir := t.TempDir()
opts := TestOptions{Dir: dir, Coverage: true, CoverageFormat: "clover"}
_, args := buildPestCommand(opts)
assert.Contains(t, args, "--coverage-clover")
assert.Contains(t, args, "coverage.xml")
})
t.Run("with groups", func(t *testing.T) {
dir := t.TempDir()
opts := TestOptions{Dir: dir, Groups: []string{"unit", "integration"}}
_, args := buildPestCommand(opts)
assert.Contains(t, args, "--group")
assert.Contains(t, args, "unit")
assert.Contains(t, args, "integration")
})
t.Run("uses vendor binary when exists", func(t *testing.T) {
dir := t.TempDir()
binDir := filepath.Join(dir, "vendor", "bin")
err := os.MkdirAll(binDir, 0755)
require.NoError(t, err)
pestPath := filepath.Join(binDir, "pest")
err = os.WriteFile(pestPath, []byte("#!/bin/bash"), 0755)
require.NoError(t, err)
opts := TestOptions{Dir: dir}
cmd, _ := buildPestCommand(opts)
assert.Equal(t, pestPath, cmd)
})
t.Run("all options combined", func(t *testing.T) {
dir := t.TempDir()
opts := TestOptions{
Dir: dir,
Filter: "Test",
Parallel: true,
Coverage: true,
CoverageFormat: "html",
Groups: []string{"unit"},
}
_, args := buildPestCommand(opts)
assert.Contains(t, args, "--filter")
assert.Contains(t, args, "--parallel")
assert.Contains(t, args, "--coverage-html")
assert.Contains(t, args, "--group")
})
}
func TestBuildPHPUnitCommand_Good(t *testing.T) {
t.Run("basic command", func(t *testing.T) {
dir := t.TempDir()
opts := TestOptions{Dir: dir}
cmd, args := buildPHPUnitCommand(opts)
assert.Equal(t, "phpunit", cmd)
assert.Empty(t, args)
})
t.Run("with filter", func(t *testing.T) {
dir := t.TempDir()
opts := TestOptions{Dir: dir, Filter: "UserTest"}
_, args := buildPHPUnitCommand(opts)
assert.Contains(t, args, "--filter")
assert.Contains(t, args, "UserTest")
})
t.Run("with parallel uses paratest", func(t *testing.T) {
dir := t.TempDir()
binDir := filepath.Join(dir, "vendor", "bin")
err := os.MkdirAll(binDir, 0755)
require.NoError(t, err)
paratestPath := filepath.Join(binDir, "paratest")
err = os.WriteFile(paratestPath, []byte("#!/bin/bash"), 0755)
require.NoError(t, err)
opts := TestOptions{Dir: dir, Parallel: true}
cmd, _ := buildPHPUnitCommand(opts)
assert.Equal(t, paratestPath, cmd)
})
t.Run("parallel without paratest stays phpunit", func(t *testing.T) {
dir := t.TempDir()
opts := TestOptions{Dir: dir, Parallel: true}
cmd, _ := buildPHPUnitCommand(opts)
assert.Equal(t, "phpunit", cmd)
})
t.Run("with coverage", func(t *testing.T) {
dir := t.TempDir()
opts := TestOptions{Dir: dir, Coverage: true}
_, args := buildPHPUnitCommand(opts)
assert.Contains(t, args, "--coverage-text")
})
t.Run("with coverage HTML format", func(t *testing.T) {
dir := t.TempDir()
opts := TestOptions{Dir: dir, Coverage: true, CoverageFormat: "html"}
_, args := buildPHPUnitCommand(opts)
assert.Contains(t, args, "--coverage-html")
assert.Contains(t, args, "coverage")
})
t.Run("with coverage clover format", func(t *testing.T) {
dir := t.TempDir()
opts := TestOptions{Dir: dir, Coverage: true, CoverageFormat: "clover"}
_, args := buildPHPUnitCommand(opts)
assert.Contains(t, args, "--coverage-clover")
assert.Contains(t, args, "coverage.xml")
})
t.Run("with groups", func(t *testing.T) {
dir := t.TempDir()
opts := TestOptions{Dir: dir, Groups: []string{"unit", "integration"}}
_, args := buildPHPUnitCommand(opts)
assert.Contains(t, args, "--group")
assert.Contains(t, args, "unit")
assert.Contains(t, args, "integration")
})
t.Run("uses vendor binary when exists", func(t *testing.T) {
dir := t.TempDir()
binDir := filepath.Join(dir, "vendor", "bin")
err := os.MkdirAll(binDir, 0755)
require.NoError(t, err)
phpunitPath := filepath.Join(binDir, "phpunit")
err = os.WriteFile(phpunitPath, []byte("#!/bin/bash"), 0755)
require.NoError(t, err)
opts := TestOptions{Dir: dir}
cmd, _ := buildPHPUnitCommand(opts)
assert.Equal(t, phpunitPath, cmd)
})
}
func TestTestOptions_Struct(t *testing.T) {
t.Run("all fields accessible", func(t *testing.T) {
opts := TestOptions{
Dir: "/test",
Filter: "TestName",
Parallel: true,
Coverage: true,
CoverageFormat: "html",
Groups: []string{"unit"},
Output: os.Stdout,
}
assert.Equal(t, "/test", opts.Dir)
assert.Equal(t, "TestName", opts.Filter)
assert.True(t, opts.Parallel)
assert.True(t, opts.Coverage)
assert.Equal(t, "html", opts.CoverageFormat)
assert.Equal(t, []string{"unit"}, opts.Groups)
assert.NotNil(t, opts.Output)
})
}
func TestTestRunner_Constants(t *testing.T) {
t.Run("constants are defined", func(t *testing.T) {
assert.Equal(t, TestRunner("pest"), TestRunnerPest)
assert.Equal(t, TestRunner("phpunit"), TestRunnerPHPUnit)
})
}
func TestRunTests_Bad(t *testing.T) {
t.Skip("requires PHP test runner installed")
}
func TestRunParallel_Bad(t *testing.T) {
t.Skip("requires PHP test runner installed")
}
func TestRunTests_Integration(t *testing.T) {
t.Skip("requires PHP/Pest/PHPUnit installed")
}
func TestBuildPestCommand_CoverageOptions(t *testing.T) {
tests := []struct {
name string
coverageFormat string
expectedArg string
}{
{"default coverage", "", "--coverage"},
{"html coverage", "html", "--coverage-html"},
{"clover coverage", "clover", "--coverage-clover"},
{"unknown format uses default", "unknown", "--coverage"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
dir := t.TempDir()
opts := TestOptions{
Dir: dir,
Coverage: true,
CoverageFormat: tt.coverageFormat,
}
_, args := buildPestCommand(opts)
// For unknown format, should fall through to default
if tt.coverageFormat == "unknown" {
assert.Contains(t, args, "--coverage")
} else {
assert.Contains(t, args, tt.expectedArg)
}
})
}
}
func TestBuildPHPUnitCommand_CoverageOptions(t *testing.T) {
tests := []struct {
name string
coverageFormat string
expectedArg string
}{
{"default coverage", "", "--coverage-text"},
{"html coverage", "html", "--coverage-html"},
{"clover coverage", "clover", "--coverage-clover"},
{"unknown format uses default", "unknown", "--coverage-text"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
dir := t.TempDir()
opts := TestOptions{
Dir: dir,
Coverage: true,
CoverageFormat: tt.coverageFormat,
}
_, args := buildPHPUnitCommand(opts)
if tt.coverageFormat == "unknown" {
assert.Contains(t, args, "--coverage-text")
} else {
assert.Contains(t, args, tt.expectedArg)
}
})
}
}
func TestBuildPestCommand_MultipleGroups(t *testing.T) {
t.Run("adds multiple group flags", func(t *testing.T) {
dir := t.TempDir()
opts := TestOptions{
Dir: dir,
Groups: []string{"unit", "integration", "feature"},
}
_, args := buildPestCommand(opts)
// Should have --group for each group
groupCount := 0
for _, arg := range args {
if arg == "--group" {
groupCount++
}
}
assert.Equal(t, 3, groupCount)
})
}
func TestBuildPHPUnitCommand_MultipleGroups(t *testing.T) {
t.Run("adds multiple group flags", func(t *testing.T) {
dir := t.TempDir()
opts := TestOptions{
Dir: dir,
Groups: []string{"unit", "integration"},
}
_, args := buildPHPUnitCommand(opts)
groupCount := 0
for _, arg := range args {
if arg == "--group" {
groupCount++
}
}
assert.Equal(t, 2, groupCount)
})
}

76
workspace.go Normal file
View file

@ -0,0 +1,76 @@
package php
import (
"fmt"
"os"
"path/filepath"
"forge.lthn.ai/core/go/pkg/io"
"gopkg.in/yaml.v3"
)
// workspaceConfig holds workspace-level configuration from .core/workspace.yaml.
type workspaceConfig struct {
Version int `yaml:"version"`
Active string `yaml:"active"` // Active package name
DefaultOnly []string `yaml:"default_only"` // Default types for setup
PackagesDir string `yaml:"packages_dir"` // Where packages are cloned
}
// defaultWorkspaceConfig returns a config with default values.
func defaultWorkspaceConfig() *workspaceConfig {
return &workspaceConfig{
Version: 1,
PackagesDir: "./packages",
}
}
// loadWorkspaceConfig tries to load workspace.yaml from the given directory's .core subfolder.
// Returns nil if no config file exists.
func loadWorkspaceConfig(dir string) (*workspaceConfig, error) {
path := filepath.Join(dir, ".core", "workspace.yaml")
data, err := io.Local.Read(path)
if err != nil {
if !io.Local.IsFile(path) {
parent := filepath.Dir(dir)
if parent != dir {
return loadWorkspaceConfig(parent)
}
return nil, nil
}
return nil, fmt.Errorf("failed to read workspace config: %w", err)
}
config := defaultWorkspaceConfig()
if err := yaml.Unmarshal([]byte(data), config); err != nil {
return nil, fmt.Errorf("failed to parse workspace config: %w", err)
}
if config.Version != 1 {
return nil, fmt.Errorf("unsupported workspace config version: %d", config.Version)
}
return config, nil
}
// findWorkspaceRoot searches for the root directory containing .core/workspace.yaml.
func findWorkspaceRoot() (string, error) {
dir, err := os.Getwd()
if err != nil {
return "", err
}
for {
if io.Local.IsFile(filepath.Join(dir, ".core", "workspace.yaml")) {
return dir, nil
}
parent := filepath.Dir(dir)
if parent == dir {
break
}
dir = parent
}
return "", fmt.Errorf("not in a workspace")
}