commit 6cb5957ca6114bd09e68588693ef3cbaaf37aa0d Author: Claude Date: Mon Feb 16 14:40:59 2026 +0000 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 diff --git a/cmd.go b/cmd.go new file mode 100644 index 0000000..59ee99e --- /dev/null +++ b/cmd.go @@ -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) +} diff --git a/cmd_build.go b/cmd_build.go new file mode 100644 index 0000000..b8b7583 --- /dev/null +++ b/cmd_build.go @@ -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) +} diff --git a/cmd_ci.go b/cmd_ci.go new file mode 100644 index 0000000..1c4344f --- /dev/null +++ b/cmd_ci.go @@ -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)) +} diff --git a/cmd_commands.go b/cmd_commands.go new file mode 100644 index 0000000..c0a2444 --- /dev/null +++ b/cmd_commands.go @@ -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) +} diff --git a/cmd_deploy.go b/cmd_deploy.go new file mode 100644 index 0000000..2298a43 --- /dev/null +++ b/cmd_deploy.go @@ -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() +} diff --git a/cmd_dev.go b/cmd_dev.go new file mode 100644 index 0000000..d2d8de0 --- /dev/null +++ b/cmd_dev.go @@ -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 +} diff --git a/cmd_packages.go b/cmd_packages.go new file mode 100644 index 0000000..fa1172b --- /dev/null +++ b/cmd_packages.go @@ -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) +} diff --git a/cmd_qa_runner.go b/cmd_qa_runner.go new file mode 100644 index 0000000..7e9d7ae --- /dev/null +++ b/cmd_qa_runner.go @@ -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") + } +} diff --git a/cmd_quality.go b/cmd_quality.go new file mode 100644 index 0000000..e76363e --- /dev/null +++ b/cmd_quality.go @@ -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 + } +} diff --git a/container.go b/container.go new file mode 100644 index 0000000..1df5dea --- /dev/null +++ b/container.go @@ -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:-} +` diff --git a/container_test.go b/container_test.go new file mode 100644 index 0000000..c0d0e19 --- /dev/null +++ b/container_test.go @@ -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") +} diff --git a/coolify.go b/coolify.go new file mode 100644 index 0000000..fd08a06 --- /dev/null +++ b/coolify.go @@ -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)) +} diff --git a/coolify_test.go b/coolify_test.go new file mode 100644 index 0000000..8176c88 --- /dev/null +++ b/coolify_test.go @@ -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) + }) +} diff --git a/deploy.go b/deploy.go new file mode 100644 index 0000000..9717ae7 --- /dev/null +++ b/deploy.go @@ -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" +} diff --git a/deploy_internal_test.go b/deploy_internal_test.go new file mode 100644 index 0000000..9362aaf --- /dev/null +++ b/deploy_internal_test.go @@ -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) + }) + } +} diff --git a/deploy_test.go b/deploy_test.go new file mode 100644 index 0000000..228de7d --- /dev/null +++ b/deploy_test.go @@ -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") + } + }) + } +} diff --git a/detect.go b/detect.go new file mode 100644 index 0000000..c13da9d --- /dev/null +++ b/detect.go @@ -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 +} diff --git a/detect_test.go b/detect_test.go new file mode 100644 index 0000000..9b72f84 --- /dev/null +++ b/detect_test.go @@ -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 := ` '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(" 'swoole', +];` + err = os.WriteFile(filepath.Join(configDir, "octane.php"), []byte(octaneConfig), 0644) + require.NoError(t, err) + + assert.False(t, IsFrankenPHPProject(dir)) + }) +} diff --git a/dockerfile.go b/dockerfile.go new file mode 100644 index 0000000..be7afd1 --- /dev/null +++ b/dockerfile.go @@ -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() +} diff --git a/dockerfile_test.go b/dockerfile_test.go new file mode 100644 index 0000000..5c3b1ce --- /dev/null +++ b/dockerfile_test.go @@ -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") + }) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..6a7620d --- /dev/null +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..8bdc375 --- /dev/null +++ b/go.sum @@ -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= diff --git a/i18n.go b/i18n.go new file mode 100644 index 0000000..96a60a9 --- /dev/null +++ b/i18n.go @@ -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") +} diff --git a/locales/en_GB.json b/locales/en_GB.json new file mode 100644 index 0000000..4f74cd8 --- /dev/null +++ b/locales/en_GB.json @@ -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" + } + } + } + } +} diff --git a/packages.go b/packages.go new file mode 100644 index 0000000..03645d6 --- /dev/null +++ b/packages.go @@ -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 +} diff --git a/packages_test.go b/packages_test.go new file mode 100644 index 0000000..a340a9b --- /dev/null +++ b/packages_test.go @@ -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"]) + }) +} diff --git a/php.go b/php.go new file mode 100644 index 0000000..96393eb --- /dev/null +++ b/php.go @@ -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 +} diff --git a/php_test.go b/php_test.go new file mode 100644 index 0000000..e295d73 --- /dev/null +++ b/php_test.go @@ -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) + }) +} diff --git a/quality.go b/quality.go new file mode 100644 index 0000000..a7f9638 --- /dev/null +++ b/quality.go @@ -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 +} diff --git a/quality_extended_test.go b/quality_extended_test.go new file mode 100644 index 0000000..8c1c00e --- /dev/null +++ b/quality_extended_test.go @@ -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 + }) +} diff --git a/quality_test.go b/quality_test.go new file mode 100644 index 0000000..710e3fa --- /dev/null +++ b/quality_test.go @@ -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("