feat: extract PHP/Laravel commands from core/cli
Port all PHP command files from core/cli internal/cmd/php/ into a standalone module. Inlines workspace dependency to avoid cross-module internal imports. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
6cb5957ca6
42 changed files with 13884 additions and 0 deletions
157
cmd.go
Normal file
157
cmd.go
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
package php
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
"forge.lthn.ai/core/go/pkg/io"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// DefaultMedium is the default filesystem medium used by the php package.
|
||||
// It defaults to io.Local (unsandboxed filesystem access).
|
||||
// Use SetMedium to change this for testing or sandboxed operation.
|
||||
var DefaultMedium io.Medium = io.Local
|
||||
|
||||
// SetMedium sets the default medium for filesystem operations.
|
||||
// This is primarily useful for testing with mock mediums.
|
||||
func SetMedium(m io.Medium) {
|
||||
DefaultMedium = m
|
||||
}
|
||||
|
||||
// getMedium returns the default medium for filesystem operations.
|
||||
func getMedium() io.Medium {
|
||||
return DefaultMedium
|
||||
}
|
||||
|
||||
func init() {
|
||||
cli.RegisterCommands(AddPHPCommands)
|
||||
}
|
||||
|
||||
// Style aliases from shared
|
||||
var (
|
||||
successStyle = cli.SuccessStyle
|
||||
errorStyle = cli.ErrorStyle
|
||||
dimStyle = cli.DimStyle
|
||||
linkStyle = cli.LinkStyle
|
||||
)
|
||||
|
||||
// Service colors for log output (domain-specific, keep local)
|
||||
var (
|
||||
phpFrankenPHPStyle = cli.NewStyle().Foreground(cli.ColourIndigo500)
|
||||
phpViteStyle = cli.NewStyle().Foreground(cli.ColourYellow500)
|
||||
phpHorizonStyle = cli.NewStyle().Foreground(cli.ColourOrange500)
|
||||
phpReverbStyle = cli.NewStyle().Foreground(cli.ColourViolet500)
|
||||
phpRedisStyle = cli.NewStyle().Foreground(cli.ColourRed500)
|
||||
)
|
||||
|
||||
// Status styles (from shared)
|
||||
var (
|
||||
phpStatusRunning = cli.SuccessStyle
|
||||
phpStatusStopped = cli.DimStyle
|
||||
phpStatusError = cli.ErrorStyle
|
||||
)
|
||||
|
||||
// QA command styles (from shared)
|
||||
var (
|
||||
phpQAPassedStyle = cli.SuccessStyle
|
||||
phpQAFailedStyle = cli.ErrorStyle
|
||||
phpQAWarningStyle = cli.WarningStyle
|
||||
phpQAStageStyle = cli.HeaderStyle
|
||||
)
|
||||
|
||||
// Security severity styles (from shared)
|
||||
var (
|
||||
phpSecurityCriticalStyle = cli.NewStyle().Bold().Foreground(cli.ColourRed500)
|
||||
phpSecurityHighStyle = cli.NewStyle().Bold().Foreground(cli.ColourOrange500)
|
||||
phpSecurityMediumStyle = cli.NewStyle().Foreground(cli.ColourAmber500)
|
||||
phpSecurityLowStyle = cli.NewStyle().Foreground(cli.ColourGray500)
|
||||
)
|
||||
|
||||
// AddPHPCommands adds PHP/Laravel development commands.
|
||||
func AddPHPCommands(root *cobra.Command) {
|
||||
phpCmd := &cobra.Command{
|
||||
Use: "php",
|
||||
Short: i18n.T("cmd.php.short"),
|
||||
Long: i18n.T("cmd.php.long"),
|
||||
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
|
||||
// Check if we are in a workspace root
|
||||
wsRoot, err := findWorkspaceRoot()
|
||||
if err != nil {
|
||||
return nil // Not in a workspace, regular behavior
|
||||
}
|
||||
|
||||
// Load workspace config
|
||||
config, err := loadWorkspaceConfig(wsRoot)
|
||||
if err != nil || config == nil {
|
||||
return nil // Failed to load or no config, ignore
|
||||
}
|
||||
|
||||
if config.Active == "" {
|
||||
return nil // No active package
|
||||
}
|
||||
|
||||
// Calculate package path
|
||||
pkgDir := config.PackagesDir
|
||||
if pkgDir == "" {
|
||||
pkgDir = "./packages"
|
||||
}
|
||||
if !filepath.IsAbs(pkgDir) {
|
||||
pkgDir = filepath.Join(wsRoot, pkgDir)
|
||||
}
|
||||
|
||||
targetDir := filepath.Join(pkgDir, config.Active)
|
||||
|
||||
// Check if target directory exists
|
||||
if !getMedium().IsDir(targetDir) {
|
||||
cli.Warnf("Active package directory not found: %s", targetDir)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Change working directory
|
||||
if err := os.Chdir(targetDir); err != nil {
|
||||
return cli.Err("failed to change directory to active package: %w", err)
|
||||
}
|
||||
|
||||
cli.Print("%s %s\n", dimStyle.Render("Workspace:"), config.Active)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
root.AddCommand(phpCmd)
|
||||
|
||||
// Development
|
||||
addPHPDevCommand(phpCmd)
|
||||
addPHPLogsCommand(phpCmd)
|
||||
addPHPStopCommand(phpCmd)
|
||||
addPHPStatusCommand(phpCmd)
|
||||
addPHPSSLCommand(phpCmd)
|
||||
|
||||
// Build & Deploy
|
||||
addPHPBuildCommand(phpCmd)
|
||||
addPHPServeCommand(phpCmd)
|
||||
addPHPShellCommand(phpCmd)
|
||||
|
||||
// Quality (existing)
|
||||
addPHPTestCommand(phpCmd)
|
||||
addPHPFmtCommand(phpCmd)
|
||||
addPHPStanCommand(phpCmd)
|
||||
|
||||
// Quality (new)
|
||||
addPHPPsalmCommand(phpCmd)
|
||||
addPHPAuditCommand(phpCmd)
|
||||
addPHPSecurityCommand(phpCmd)
|
||||
addPHPQACommand(phpCmd)
|
||||
addPHPRectorCommand(phpCmd)
|
||||
addPHPInfectionCommand(phpCmd)
|
||||
|
||||
// CI/CD Integration
|
||||
addPHPCICommand(phpCmd)
|
||||
|
||||
// Package Management
|
||||
addPHPPackagesCommands(phpCmd)
|
||||
|
||||
// Deployment
|
||||
addPHPDeployCommands(phpCmd)
|
||||
}
|
||||
291
cmd_build.go
Normal file
291
cmd_build.go
Normal file
|
|
@ -0,0 +1,291 @@
|
|||
package php
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
buildType string
|
||||
buildImageName string
|
||||
buildTag string
|
||||
buildPlatform string
|
||||
buildDockerfile string
|
||||
buildOutputPath string
|
||||
buildFormat string
|
||||
buildTemplate string
|
||||
buildNoCache bool
|
||||
)
|
||||
|
||||
func addPHPBuildCommand(parent *cobra.Command) {
|
||||
buildCmd := &cobra.Command{
|
||||
Use: "build",
|
||||
Short: i18n.T("cmd.php.build.short"),
|
||||
Long: i18n.T("cmd.php.build.long"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return cli.Err("%s: %w", i18n.T("i18n.fail.get", "working directory"), err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
switch strings.ToLower(buildType) {
|
||||
case "linuxkit":
|
||||
return runPHPBuildLinuxKit(ctx, cwd, linuxKitBuildOptions{
|
||||
OutputPath: buildOutputPath,
|
||||
Format: buildFormat,
|
||||
Template: buildTemplate,
|
||||
})
|
||||
default:
|
||||
return runPHPBuildDocker(ctx, cwd, dockerBuildOptions{
|
||||
ImageName: buildImageName,
|
||||
Tag: buildTag,
|
||||
Platform: buildPlatform,
|
||||
Dockerfile: buildDockerfile,
|
||||
NoCache: buildNoCache,
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
buildCmd.Flags().StringVar(&buildType, "type", "", i18n.T("cmd.php.build.flag.type"))
|
||||
buildCmd.Flags().StringVar(&buildImageName, "name", "", i18n.T("cmd.php.build.flag.name"))
|
||||
buildCmd.Flags().StringVar(&buildTag, "tag", "", i18n.T("common.flag.tag"))
|
||||
buildCmd.Flags().StringVar(&buildPlatform, "platform", "", i18n.T("cmd.php.build.flag.platform"))
|
||||
buildCmd.Flags().StringVar(&buildDockerfile, "dockerfile", "", i18n.T("cmd.php.build.flag.dockerfile"))
|
||||
buildCmd.Flags().StringVar(&buildOutputPath, "output", "", i18n.T("cmd.php.build.flag.output"))
|
||||
buildCmd.Flags().StringVar(&buildFormat, "format", "", i18n.T("cmd.php.build.flag.format"))
|
||||
buildCmd.Flags().StringVar(&buildTemplate, "template", "", i18n.T("cmd.php.build.flag.template"))
|
||||
buildCmd.Flags().BoolVar(&buildNoCache, "no-cache", false, i18n.T("cmd.php.build.flag.no_cache"))
|
||||
|
||||
parent.AddCommand(buildCmd)
|
||||
}
|
||||
|
||||
type dockerBuildOptions struct {
|
||||
ImageName string
|
||||
Tag string
|
||||
Platform string
|
||||
Dockerfile string
|
||||
NoCache bool
|
||||
}
|
||||
|
||||
type linuxKitBuildOptions struct {
|
||||
OutputPath string
|
||||
Format string
|
||||
Template string
|
||||
}
|
||||
|
||||
func runPHPBuildDocker(ctx context.Context, projectDir string, opts dockerBuildOptions) error {
|
||||
if !IsPHPProject(projectDir) {
|
||||
return errors.New(i18n.T("cmd.php.error.not_php"))
|
||||
}
|
||||
|
||||
cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.php")), i18n.T("cmd.php.build.building_docker"))
|
||||
|
||||
// Show detected configuration
|
||||
config, err := DetectDockerfileConfig(projectDir)
|
||||
if err != nil {
|
||||
return cli.Err("%s: %w", i18n.T("i18n.fail.detect", "project configuration"), err)
|
||||
}
|
||||
|
||||
cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.build.php_version")), config.PHPVersion)
|
||||
cli.Print("%s %v\n", dimStyle.Render(i18n.T("cmd.php.build.laravel")), config.IsLaravel)
|
||||
cli.Print("%s %v\n", dimStyle.Render(i18n.T("cmd.php.build.octane")), config.HasOctane)
|
||||
cli.Print("%s %v\n", dimStyle.Render(i18n.T("cmd.php.build.frontend")), config.HasAssets)
|
||||
if len(config.PHPExtensions) > 0 {
|
||||
cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.build.extensions")), strings.Join(config.PHPExtensions, ", "))
|
||||
}
|
||||
cli.Blank()
|
||||
|
||||
// Build options
|
||||
buildOpts := DockerBuildOptions{
|
||||
ProjectDir: projectDir,
|
||||
ImageName: opts.ImageName,
|
||||
Tag: opts.Tag,
|
||||
Platform: opts.Platform,
|
||||
Dockerfile: opts.Dockerfile,
|
||||
NoBuildCache: opts.NoCache,
|
||||
Output: os.Stdout,
|
||||
}
|
||||
|
||||
if buildOpts.ImageName == "" {
|
||||
buildOpts.ImageName = GetLaravelAppName(projectDir)
|
||||
if buildOpts.ImageName == "" {
|
||||
buildOpts.ImageName = "php-app"
|
||||
}
|
||||
// Sanitize for Docker
|
||||
buildOpts.ImageName = strings.ToLower(strings.ReplaceAll(buildOpts.ImageName, " ", "-"))
|
||||
}
|
||||
|
||||
if buildOpts.Tag == "" {
|
||||
buildOpts.Tag = "latest"
|
||||
}
|
||||
|
||||
cli.Print("%s %s:%s\n", dimStyle.Render(i18n.Label("image")), buildOpts.ImageName, buildOpts.Tag)
|
||||
if opts.Platform != "" {
|
||||
cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.build.platform")), opts.Platform)
|
||||
}
|
||||
cli.Blank()
|
||||
|
||||
if err := BuildDocker(ctx, buildOpts); err != nil {
|
||||
return cli.Err("%s: %w", i18n.T("i18n.fail.build"), err)
|
||||
}
|
||||
|
||||
cli.Print("\n%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("common.success.completed", map[string]any{"Action": "Docker image built"}))
|
||||
cli.Print("%s docker run -p 80:80 -p 443:443 %s:%s\n",
|
||||
dimStyle.Render(i18n.T("cmd.php.build.docker_run_with")),
|
||||
buildOpts.ImageName, buildOpts.Tag)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func runPHPBuildLinuxKit(ctx context.Context, projectDir string, opts linuxKitBuildOptions) error {
|
||||
if !IsPHPProject(projectDir) {
|
||||
return errors.New(i18n.T("cmd.php.error.not_php"))
|
||||
}
|
||||
|
||||
cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.php")), i18n.T("cmd.php.build.building_linuxkit"))
|
||||
|
||||
buildOpts := LinuxKitBuildOptions{
|
||||
ProjectDir: projectDir,
|
||||
OutputPath: opts.OutputPath,
|
||||
Format: opts.Format,
|
||||
Template: opts.Template,
|
||||
Output: os.Stdout,
|
||||
}
|
||||
|
||||
if buildOpts.Format == "" {
|
||||
buildOpts.Format = "qcow2"
|
||||
}
|
||||
if buildOpts.Template == "" {
|
||||
buildOpts.Template = "server-php"
|
||||
}
|
||||
|
||||
cli.Print("%s %s\n", dimStyle.Render(i18n.Label("template")), buildOpts.Template)
|
||||
cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.build.format")), buildOpts.Format)
|
||||
cli.Blank()
|
||||
|
||||
if err := BuildLinuxKit(ctx, buildOpts); err != nil {
|
||||
return cli.Err("%s: %w", i18n.T("i18n.fail.build"), err)
|
||||
}
|
||||
|
||||
cli.Print("\n%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("common.success.completed", map[string]any{"Action": "LinuxKit image built"}))
|
||||
return nil
|
||||
}
|
||||
|
||||
var (
|
||||
serveImageName string
|
||||
serveTag string
|
||||
serveContainerName string
|
||||
servePort int
|
||||
serveHTTPSPort int
|
||||
serveDetach bool
|
||||
serveEnvFile string
|
||||
)
|
||||
|
||||
func addPHPServeCommand(parent *cobra.Command) {
|
||||
serveCmd := &cobra.Command{
|
||||
Use: "serve",
|
||||
Short: i18n.T("cmd.php.serve.short"),
|
||||
Long: i18n.T("cmd.php.serve.long"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
imageName := serveImageName
|
||||
if imageName == "" {
|
||||
// Try to detect from current directory
|
||||
cwd, err := os.Getwd()
|
||||
if err == nil {
|
||||
imageName = GetLaravelAppName(cwd)
|
||||
if imageName != "" {
|
||||
imageName = strings.ToLower(strings.ReplaceAll(imageName, " ", "-"))
|
||||
}
|
||||
}
|
||||
if imageName == "" {
|
||||
return errors.New(i18n.T("cmd.php.serve.name_required"))
|
||||
}
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
opts := ServeOptions{
|
||||
ImageName: imageName,
|
||||
Tag: serveTag,
|
||||
ContainerName: serveContainerName,
|
||||
Port: servePort,
|
||||
HTTPSPort: serveHTTPSPort,
|
||||
Detach: serveDetach,
|
||||
EnvFile: serveEnvFile,
|
||||
Output: os.Stdout,
|
||||
}
|
||||
|
||||
cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.php")), i18n.ProgressSubject("run", "production container"))
|
||||
cli.Print("%s %s:%s\n", dimStyle.Render(i18n.Label("image")), imageName, func() string {
|
||||
if serveTag == "" {
|
||||
return "latest"
|
||||
}
|
||||
return serveTag
|
||||
}())
|
||||
|
||||
effectivePort := servePort
|
||||
if effectivePort == 0 {
|
||||
effectivePort = 80
|
||||
}
|
||||
effectiveHTTPSPort := serveHTTPSPort
|
||||
if effectiveHTTPSPort == 0 {
|
||||
effectiveHTTPSPort = 443
|
||||
}
|
||||
|
||||
cli.Print("%s http://localhost:%d, https://localhost:%d\n",
|
||||
dimStyle.Render("Ports:"), effectivePort, effectiveHTTPSPort)
|
||||
cli.Blank()
|
||||
|
||||
if err := ServeProduction(ctx, opts); err != nil {
|
||||
return cli.Err("%s: %w", i18n.T("i18n.fail.start", "container"), err)
|
||||
}
|
||||
|
||||
if !serveDetach {
|
||||
cli.Print("\n%s %s\n", dimStyle.Render(i18n.T("cmd.php.label.php")), i18n.T("cmd.php.serve.stopped"))
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
serveCmd.Flags().StringVar(&serveImageName, "name", "", i18n.T("cmd.php.serve.flag.name"))
|
||||
serveCmd.Flags().StringVar(&serveTag, "tag", "", i18n.T("common.flag.tag"))
|
||||
serveCmd.Flags().StringVar(&serveContainerName, "container", "", i18n.T("cmd.php.serve.flag.container"))
|
||||
serveCmd.Flags().IntVar(&servePort, "port", 0, i18n.T("cmd.php.serve.flag.port"))
|
||||
serveCmd.Flags().IntVar(&serveHTTPSPort, "https-port", 0, i18n.T("cmd.php.serve.flag.https_port"))
|
||||
serveCmd.Flags().BoolVarP(&serveDetach, "detach", "d", false, i18n.T("cmd.php.serve.flag.detach"))
|
||||
serveCmd.Flags().StringVar(&serveEnvFile, "env-file", "", i18n.T("cmd.php.serve.flag.env_file"))
|
||||
|
||||
parent.AddCommand(serveCmd)
|
||||
}
|
||||
|
||||
func addPHPShellCommand(parent *cobra.Command) {
|
||||
shellCmd := &cobra.Command{
|
||||
Use: "shell [container]",
|
||||
Short: i18n.T("cmd.php.shell.short"),
|
||||
Long: i18n.T("cmd.php.shell.long"),
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
|
||||
cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.label.php")), i18n.T("cmd.php.shell.opening", map[string]interface{}{"Container": args[0]}))
|
||||
|
||||
if err := Shell(ctx, args[0]); err != nil {
|
||||
return cli.Err("%s: %w", i18n.T("i18n.fail.open", "shell"), err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
parent.AddCommand(shellCmd)
|
||||
}
|
||||
562
cmd_ci.go
Normal file
562
cmd_ci.go
Normal file
|
|
@ -0,0 +1,562 @@
|
|||
// cmd_ci.go implements the 'php ci' command for CI/CD pipeline integration.
|
||||
//
|
||||
// Usage:
|
||||
// core php ci # Run full CI pipeline
|
||||
// core php ci --json # Output combined JSON report
|
||||
// core php ci --summary # Output markdown summary
|
||||
// core php ci --sarif # Generate SARIF files
|
||||
// core php ci --upload-sarif # Upload SARIF to GitHub Security
|
||||
// core php ci --fail-on=high # Only fail on high+ severity
|
||||
|
||||
package php
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// CI command flags
|
||||
var (
|
||||
ciJSON bool
|
||||
ciSummary bool
|
||||
ciSARIF bool
|
||||
ciUploadSARIF bool
|
||||
ciFailOn string
|
||||
)
|
||||
|
||||
// CIResult represents the overall CI pipeline result
|
||||
type CIResult struct {
|
||||
Passed bool `json:"passed"`
|
||||
ExitCode int `json:"exit_code"`
|
||||
Duration string `json:"duration"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
Checks []CICheckResult `json:"checks"`
|
||||
Summary CISummary `json:"summary"`
|
||||
Artifacts []string `json:"artifacts,omitempty"`
|
||||
}
|
||||
|
||||
// CICheckResult represents an individual check result
|
||||
type CICheckResult struct {
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"` // passed, failed, warning, skipped
|
||||
Duration string `json:"duration"`
|
||||
Details string `json:"details,omitempty"`
|
||||
Issues int `json:"issues,omitempty"`
|
||||
Errors int `json:"errors,omitempty"`
|
||||
Warnings int `json:"warnings,omitempty"`
|
||||
}
|
||||
|
||||
// CISummary contains aggregate statistics
|
||||
type CISummary struct {
|
||||
Total int `json:"total"`
|
||||
Passed int `json:"passed"`
|
||||
Failed int `json:"failed"`
|
||||
Warnings int `json:"warnings"`
|
||||
Skipped int `json:"skipped"`
|
||||
}
|
||||
|
||||
func addPHPCICommand(parent *cobra.Command) {
|
||||
ciCmd := &cobra.Command{
|
||||
Use: "ci",
|
||||
Short: i18n.T("cmd.php.ci.short"),
|
||||
Long: i18n.T("cmd.php.ci.long"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runPHPCI()
|
||||
},
|
||||
}
|
||||
|
||||
ciCmd.Flags().BoolVar(&ciJSON, "json", false, i18n.T("cmd.php.ci.flag.json"))
|
||||
ciCmd.Flags().BoolVar(&ciSummary, "summary", false, i18n.T("cmd.php.ci.flag.summary"))
|
||||
ciCmd.Flags().BoolVar(&ciSARIF, "sarif", false, i18n.T("cmd.php.ci.flag.sarif"))
|
||||
ciCmd.Flags().BoolVar(&ciUploadSARIF, "upload-sarif", false, i18n.T("cmd.php.ci.flag.upload_sarif"))
|
||||
ciCmd.Flags().StringVar(&ciFailOn, "fail-on", "error", i18n.T("cmd.php.ci.flag.fail_on"))
|
||||
|
||||
parent.AddCommand(ciCmd)
|
||||
}
|
||||
|
||||
func runPHPCI() error {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return cli.Err("%s: %w", i18n.T("i18n.fail.get", "working directory"), err)
|
||||
}
|
||||
|
||||
if !IsPHPProject(cwd) {
|
||||
return errors.New(i18n.T("cmd.php.error.not_php"))
|
||||
}
|
||||
|
||||
startTime := time.Now()
|
||||
ctx := context.Background()
|
||||
|
||||
// Define checks to run in order
|
||||
checks := []struct {
|
||||
name string
|
||||
run func(context.Context, string) (CICheckResult, error)
|
||||
sarif bool // Whether this check can generate SARIF
|
||||
}{
|
||||
{"test", runCITest, false},
|
||||
{"stan", runCIStan, true},
|
||||
{"psalm", runCIPsalm, true},
|
||||
{"fmt", runCIFmt, false},
|
||||
{"audit", runCIAudit, false},
|
||||
{"security", runCISecurity, false},
|
||||
}
|
||||
|
||||
result := CIResult{
|
||||
StartedAt: startTime,
|
||||
Passed: true,
|
||||
Checks: make([]CICheckResult, 0, len(checks)),
|
||||
}
|
||||
|
||||
var artifacts []string
|
||||
|
||||
// Print header unless JSON output
|
||||
if !ciJSON {
|
||||
cli.Print("\n%s\n", cli.BoldStyle.Render("core php ci - QA Pipeline"))
|
||||
cli.Print("%s\n\n", strings.Repeat("─", 40))
|
||||
}
|
||||
|
||||
// Run each check
|
||||
for _, check := range checks {
|
||||
if !ciJSON {
|
||||
cli.Print(" %s %s...", dimStyle.Render("→"), check.name)
|
||||
}
|
||||
|
||||
checkResult, err := check.run(ctx, cwd)
|
||||
if err != nil {
|
||||
checkResult = CICheckResult{
|
||||
Name: check.name,
|
||||
Status: "failed",
|
||||
Details: err.Error(),
|
||||
}
|
||||
}
|
||||
|
||||
result.Checks = append(result.Checks, checkResult)
|
||||
|
||||
// Update summary
|
||||
result.Summary.Total++
|
||||
switch checkResult.Status {
|
||||
case "passed":
|
||||
result.Summary.Passed++
|
||||
case "failed":
|
||||
result.Summary.Failed++
|
||||
if shouldFailOn(checkResult, ciFailOn) {
|
||||
result.Passed = false
|
||||
}
|
||||
case "warning":
|
||||
result.Summary.Warnings++
|
||||
case "skipped":
|
||||
result.Summary.Skipped++
|
||||
}
|
||||
|
||||
// Print result
|
||||
if !ciJSON {
|
||||
cli.Print("\r %s %s %s\n", getStatusIcon(checkResult.Status), check.name, dimStyle.Render(checkResult.Details))
|
||||
}
|
||||
|
||||
// Generate SARIF if requested
|
||||
if (ciSARIF || ciUploadSARIF) && check.sarif {
|
||||
sarifFile := filepath.Join(cwd, check.name+".sarif")
|
||||
if generateSARIF(ctx, cwd, check.name, sarifFile) == nil {
|
||||
artifacts = append(artifacts, sarifFile)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result.Duration = time.Since(startTime).Round(time.Millisecond).String()
|
||||
result.Artifacts = artifacts
|
||||
|
||||
// Set exit code
|
||||
if result.Passed {
|
||||
result.ExitCode = 0
|
||||
} else {
|
||||
result.ExitCode = 1
|
||||
}
|
||||
|
||||
// Output based on flags
|
||||
if ciJSON {
|
||||
if err := outputCIJSON(result); err != nil {
|
||||
return err
|
||||
}
|
||||
if !result.Passed {
|
||||
return cli.Exit(result.ExitCode, cli.Err("CI pipeline failed"))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if ciSummary {
|
||||
if err := outputCISummary(result); err != nil {
|
||||
return err
|
||||
}
|
||||
if !result.Passed {
|
||||
return cli.Err("CI pipeline failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Default table output
|
||||
cli.Print("\n%s\n", strings.Repeat("─", 40))
|
||||
|
||||
if result.Passed {
|
||||
cli.Print("%s %s\n", successStyle.Render("✓ CI PASSED"), dimStyle.Render(result.Duration))
|
||||
} else {
|
||||
cli.Print("%s %s\n", errorStyle.Render("✗ CI FAILED"), dimStyle.Render(result.Duration))
|
||||
}
|
||||
|
||||
if len(artifacts) > 0 {
|
||||
cli.Print("\n%s\n", dimStyle.Render("Artifacts:"))
|
||||
for _, a := range artifacts {
|
||||
cli.Print(" → %s\n", filepath.Base(a))
|
||||
}
|
||||
}
|
||||
|
||||
// Upload SARIF if requested
|
||||
if ciUploadSARIF && len(artifacts) > 0 {
|
||||
cli.Blank()
|
||||
for _, sarifFile := range artifacts {
|
||||
if err := uploadSARIFToGitHub(ctx, sarifFile); err != nil {
|
||||
cli.Print(" %s %s: %s\n", errorStyle.Render("✗"), filepath.Base(sarifFile), err)
|
||||
} else {
|
||||
cli.Print(" %s %s uploaded\n", successStyle.Render("✓"), filepath.Base(sarifFile))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !result.Passed {
|
||||
return cli.Err("CI pipeline failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// runCITest runs Pest/PHPUnit tests
|
||||
func runCITest(ctx context.Context, dir string) (CICheckResult, error) {
|
||||
start := time.Now()
|
||||
result := CICheckResult{Name: "test", Status: "passed"}
|
||||
|
||||
opts := TestOptions{
|
||||
Dir: dir,
|
||||
Output: nil, // Suppress output
|
||||
}
|
||||
|
||||
if err := RunTests(ctx, opts); err != nil {
|
||||
result.Status = "failed"
|
||||
result.Details = err.Error()
|
||||
} else {
|
||||
result.Details = "all tests passed"
|
||||
}
|
||||
|
||||
result.Duration = time.Since(start).Round(time.Millisecond).String()
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// runCIStan runs PHPStan
|
||||
func runCIStan(ctx context.Context, dir string) (CICheckResult, error) {
|
||||
start := time.Now()
|
||||
result := CICheckResult{Name: "stan", Status: "passed"}
|
||||
|
||||
_, found := DetectAnalyser(dir)
|
||||
if !found {
|
||||
result.Status = "skipped"
|
||||
result.Details = "PHPStan not configured"
|
||||
return result, nil
|
||||
}
|
||||
|
||||
opts := AnalyseOptions{
|
||||
Dir: dir,
|
||||
Output: nil,
|
||||
}
|
||||
|
||||
if err := Analyse(ctx, opts); err != nil {
|
||||
result.Status = "failed"
|
||||
result.Details = "errors found"
|
||||
} else {
|
||||
result.Details = "0 errors"
|
||||
}
|
||||
|
||||
result.Duration = time.Since(start).Round(time.Millisecond).String()
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// runCIPsalm runs Psalm
|
||||
func runCIPsalm(ctx context.Context, dir string) (CICheckResult, error) {
|
||||
start := time.Now()
|
||||
result := CICheckResult{Name: "psalm", Status: "passed"}
|
||||
|
||||
_, found := DetectPsalm(dir)
|
||||
if !found {
|
||||
result.Status = "skipped"
|
||||
result.Details = "Psalm not configured"
|
||||
return result, nil
|
||||
}
|
||||
|
||||
opts := PsalmOptions{
|
||||
Dir: dir,
|
||||
Output: nil,
|
||||
}
|
||||
|
||||
if err := RunPsalm(ctx, opts); err != nil {
|
||||
result.Status = "failed"
|
||||
result.Details = "errors found"
|
||||
} else {
|
||||
result.Details = "0 errors"
|
||||
}
|
||||
|
||||
result.Duration = time.Since(start).Round(time.Millisecond).String()
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// runCIFmt checks code formatting
|
||||
func runCIFmt(ctx context.Context, dir string) (CICheckResult, error) {
|
||||
start := time.Now()
|
||||
result := CICheckResult{Name: "fmt", Status: "passed"}
|
||||
|
||||
_, found := DetectFormatter(dir)
|
||||
if !found {
|
||||
result.Status = "skipped"
|
||||
result.Details = "no formatter configured"
|
||||
return result, nil
|
||||
}
|
||||
|
||||
opts := FormatOptions{
|
||||
Dir: dir,
|
||||
Fix: false, // Check only
|
||||
Output: nil,
|
||||
}
|
||||
|
||||
if err := Format(ctx, opts); err != nil {
|
||||
result.Status = "warning"
|
||||
result.Details = "formatting issues"
|
||||
} else {
|
||||
result.Details = "code style OK"
|
||||
}
|
||||
|
||||
result.Duration = time.Since(start).Round(time.Millisecond).String()
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// runCIAudit runs composer audit
|
||||
func runCIAudit(ctx context.Context, dir string) (CICheckResult, error) {
|
||||
start := time.Now()
|
||||
result := CICheckResult{Name: "audit", Status: "passed"}
|
||||
|
||||
results, err := RunAudit(ctx, AuditOptions{
|
||||
Dir: dir,
|
||||
Output: nil,
|
||||
})
|
||||
if err != nil {
|
||||
result.Status = "failed"
|
||||
result.Details = err.Error()
|
||||
result.Duration = time.Since(start).Round(time.Millisecond).String()
|
||||
return result, nil
|
||||
}
|
||||
|
||||
totalVulns := 0
|
||||
for _, r := range results {
|
||||
totalVulns += r.Vulnerabilities
|
||||
}
|
||||
|
||||
if totalVulns > 0 {
|
||||
result.Status = "failed"
|
||||
result.Details = fmt.Sprintf("%d vulnerabilities", totalVulns)
|
||||
result.Issues = totalVulns
|
||||
} else {
|
||||
result.Details = "no vulnerabilities"
|
||||
}
|
||||
|
||||
result.Duration = time.Since(start).Round(time.Millisecond).String()
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// runCISecurity runs security checks
|
||||
func runCISecurity(ctx context.Context, dir string) (CICheckResult, error) {
|
||||
start := time.Now()
|
||||
result := CICheckResult{Name: "security", Status: "passed"}
|
||||
|
||||
secResult, err := RunSecurityChecks(ctx, SecurityOptions{
|
||||
Dir: dir,
|
||||
Output: nil,
|
||||
})
|
||||
if err != nil {
|
||||
result.Status = "failed"
|
||||
result.Details = err.Error()
|
||||
result.Duration = time.Since(start).Round(time.Millisecond).String()
|
||||
return result, nil
|
||||
}
|
||||
|
||||
if secResult.Summary.Critical > 0 || secResult.Summary.High > 0 {
|
||||
result.Status = "failed"
|
||||
result.Details = fmt.Sprintf("%d critical, %d high", secResult.Summary.Critical, secResult.Summary.High)
|
||||
result.Issues = secResult.Summary.Critical + secResult.Summary.High
|
||||
} else if secResult.Summary.Medium > 0 {
|
||||
result.Status = "warning"
|
||||
result.Details = fmt.Sprintf("%d medium issues", secResult.Summary.Medium)
|
||||
result.Warnings = secResult.Summary.Medium
|
||||
} else {
|
||||
result.Details = "no issues"
|
||||
}
|
||||
|
||||
result.Duration = time.Since(start).Round(time.Millisecond).String()
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// shouldFailOn determines if a check should cause CI failure based on --fail-on
|
||||
func shouldFailOn(check CICheckResult, level string) bool {
|
||||
switch level {
|
||||
case "critical":
|
||||
return check.Status == "failed" && check.Issues > 0
|
||||
case "high", "error":
|
||||
return check.Status == "failed"
|
||||
case "warning":
|
||||
return check.Status == "failed" || check.Status == "warning"
|
||||
default:
|
||||
return check.Status == "failed"
|
||||
}
|
||||
}
|
||||
|
||||
// getStatusIcon returns the icon for a check status
|
||||
func getStatusIcon(status string) string {
|
||||
switch status {
|
||||
case "passed":
|
||||
return successStyle.Render("✓")
|
||||
case "failed":
|
||||
return errorStyle.Render("✗")
|
||||
case "warning":
|
||||
return phpQAWarningStyle.Render("⚠")
|
||||
case "skipped":
|
||||
return dimStyle.Render("-")
|
||||
default:
|
||||
return dimStyle.Render("?")
|
||||
}
|
||||
}
|
||||
|
||||
// outputCIJSON outputs the result as JSON
|
||||
func outputCIJSON(result CIResult) error {
|
||||
data, err := json.MarshalIndent(result, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println(string(data))
|
||||
return nil
|
||||
}
|
||||
|
||||
// outputCISummary outputs a markdown summary
|
||||
func outputCISummary(result CIResult) error {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString("## CI Pipeline Results\n\n")
|
||||
|
||||
if result.Passed {
|
||||
sb.WriteString("**Status:** ✅ Passed\n\n")
|
||||
} else {
|
||||
sb.WriteString("**Status:** ❌ Failed\n\n")
|
||||
}
|
||||
|
||||
sb.WriteString("| Check | Status | Details |\n")
|
||||
sb.WriteString("|-------|--------|----------|\n")
|
||||
|
||||
for _, check := range result.Checks {
|
||||
icon := "✅"
|
||||
switch check.Status {
|
||||
case "failed":
|
||||
icon = "❌"
|
||||
case "warning":
|
||||
icon = "⚠️"
|
||||
case "skipped":
|
||||
icon = "⏭️"
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("| %s | %s | %s |\n", check.Name, icon, check.Details))
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf("\n**Duration:** %s\n", result.Duration))
|
||||
|
||||
fmt.Print(sb.String())
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateSARIF generates a SARIF file for a specific check
|
||||
func generateSARIF(ctx context.Context, dir, checkName, outputFile string) error {
|
||||
var args []string
|
||||
|
||||
switch checkName {
|
||||
case "stan":
|
||||
args = []string{"vendor/bin/phpstan", "analyse", "--error-format=sarif", "--no-progress"}
|
||||
case "psalm":
|
||||
args = []string{"vendor/bin/psalm", "--output-format=sarif"}
|
||||
default:
|
||||
return fmt.Errorf("SARIF not supported for %s", checkName)
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(ctx, "php", args...)
|
||||
cmd.Dir = dir
|
||||
|
||||
// Capture output - command may exit non-zero when issues are found
|
||||
// but still produce valid SARIF output
|
||||
output, err := cmd.CombinedOutput()
|
||||
if len(output) == 0 {
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate SARIF: %w", err)
|
||||
}
|
||||
return fmt.Errorf("no SARIF output generated")
|
||||
}
|
||||
|
||||
// Validate output is valid JSON
|
||||
var js json.RawMessage
|
||||
if err := json.Unmarshal(output, &js); err != nil {
|
||||
return fmt.Errorf("invalid SARIF output: %w", err)
|
||||
}
|
||||
|
||||
return getMedium().Write(outputFile, string(output))
|
||||
}
|
||||
|
||||
// uploadSARIFToGitHub uploads a SARIF file to GitHub Security tab
|
||||
func uploadSARIFToGitHub(ctx context.Context, sarifFile string) error {
|
||||
// Validate commit SHA before calling API
|
||||
sha := getGitSHA()
|
||||
if sha == "" {
|
||||
return errors.New("cannot upload SARIF: git commit SHA not available (ensure you're in a git repository)")
|
||||
}
|
||||
|
||||
// Use gh CLI to upload
|
||||
cmd := exec.CommandContext(ctx, "gh", "api",
|
||||
"repos/{owner}/{repo}/code-scanning/sarifs",
|
||||
"-X", "POST",
|
||||
"-F", "sarif=@"+sarifFile,
|
||||
"-F", "ref="+getGitRef(),
|
||||
"-F", "commit_sha="+sha,
|
||||
)
|
||||
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("%s: %s", err, string(output))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// getGitRef returns the current git ref
|
||||
func getGitRef() string {
|
||||
cmd := exec.Command("git", "symbolic-ref", "HEAD")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "refs/heads/main"
|
||||
}
|
||||
return strings.TrimSpace(string(output))
|
||||
}
|
||||
|
||||
// getGitSHA returns the current git commit SHA
|
||||
func getGitSHA() string {
|
||||
cmd := exec.Command("git", "rev-parse", "HEAD")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(output))
|
||||
}
|
||||
41
cmd_commands.go
Normal file
41
cmd_commands.go
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
// Package php provides Laravel/PHP development and deployment commands.
|
||||
//
|
||||
// Development Commands:
|
||||
// - dev: Start Laravel environment (FrankenPHP, Vite, Horizon, Reverb, Redis)
|
||||
// - logs: Stream unified service logs
|
||||
// - stop: Stop all running services
|
||||
// - status: Show service status
|
||||
// - ssl: Setup SSL certificates with mkcert
|
||||
//
|
||||
// Build Commands:
|
||||
// - build: Build Docker or LinuxKit image
|
||||
// - serve: Run production container
|
||||
// - shell: Open shell in running container
|
||||
//
|
||||
// Code Quality:
|
||||
// - test: Run PHPUnit/Pest tests
|
||||
// - fmt: Format code with Laravel Pint
|
||||
// - stan: Run PHPStan/Larastan static analysis
|
||||
// - psalm: Run Psalm static analysis
|
||||
// - audit: Security audit for dependencies
|
||||
// - security: Security vulnerability scanning
|
||||
// - qa: Run full QA pipeline
|
||||
// - rector: Automated code refactoring
|
||||
// - infection: Mutation testing for test quality
|
||||
//
|
||||
// Package Management:
|
||||
// - packages link/unlink/update/list: Manage local Composer packages
|
||||
//
|
||||
// Deployment (Coolify):
|
||||
// - deploy: Deploy to Coolify
|
||||
// - deploy:status: Check deployment status
|
||||
// - deploy:rollback: Rollback deployment
|
||||
// - deploy:list: List recent deployments
|
||||
package php
|
||||
|
||||
import "github.com/spf13/cobra"
|
||||
|
||||
// AddCommands registers the 'php' command and all subcommands.
|
||||
func AddCommands(root *cobra.Command) {
|
||||
AddPHPCommands(root)
|
||||
}
|
||||
361
cmd_deploy.go
Normal file
361
cmd_deploy.go
Normal file
|
|
@ -0,0 +1,361 @@
|
|||
package php
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// Deploy command styles (aliases to shared)
|
||||
var (
|
||||
phpDeployStyle = cli.SuccessStyle
|
||||
phpDeployPendingStyle = cli.WarningStyle
|
||||
phpDeployFailedStyle = cli.ErrorStyle
|
||||
)
|
||||
|
||||
func addPHPDeployCommands(parent *cobra.Command) {
|
||||
// Main deploy command
|
||||
addPHPDeployCommand(parent)
|
||||
|
||||
// Deploy status subcommand (using colon notation: deploy:status)
|
||||
addPHPDeployStatusCommand(parent)
|
||||
|
||||
// Deploy rollback subcommand
|
||||
addPHPDeployRollbackCommand(parent)
|
||||
|
||||
// Deploy list subcommand
|
||||
addPHPDeployListCommand(parent)
|
||||
}
|
||||
|
||||
var (
|
||||
deployStaging bool
|
||||
deployForce bool
|
||||
deployWait bool
|
||||
)
|
||||
|
||||
func addPHPDeployCommand(parent *cobra.Command) {
|
||||
deployCmd := &cobra.Command{
|
||||
Use: "deploy",
|
||||
Short: i18n.T("cmd.php.deploy.short"),
|
||||
Long: i18n.T("cmd.php.deploy.long"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return cli.Err("%s: %w", i18n.T("i18n.fail.get", "working directory"), err)
|
||||
}
|
||||
|
||||
env := EnvProduction
|
||||
if deployStaging {
|
||||
env = EnvStaging
|
||||
}
|
||||
|
||||
cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.deploy")), i18n.T("cmd.php.deploy.deploying", map[string]interface{}{"Environment": env}))
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
opts := DeployOptions{
|
||||
Dir: cwd,
|
||||
Environment: env,
|
||||
Force: deployForce,
|
||||
Wait: deployWait,
|
||||
}
|
||||
|
||||
status, err := Deploy(ctx, opts)
|
||||
if err != nil {
|
||||
return cli.Err("%s: %w", i18n.T("cmd.php.error.deploy_failed"), err)
|
||||
}
|
||||
|
||||
printDeploymentStatus(status)
|
||||
|
||||
if deployWait {
|
||||
if IsDeploymentSuccessful(status.Status) {
|
||||
cli.Print("\n%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("common.success.completed", map[string]any{"Action": "Deployment completed"}))
|
||||
} else {
|
||||
cli.Print("\n%s %s\n", errorStyle.Render(i18n.Label("warning")), i18n.T("cmd.php.deploy.warning_status", map[string]interface{}{"Status": status.Status}))
|
||||
}
|
||||
} else {
|
||||
cli.Print("\n%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("cmd.php.deploy.triggered"))
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
deployCmd.Flags().BoolVar(&deployStaging, "staging", false, i18n.T("cmd.php.deploy.flag.staging"))
|
||||
deployCmd.Flags().BoolVar(&deployForce, "force", false, i18n.T("cmd.php.deploy.flag.force"))
|
||||
deployCmd.Flags().BoolVar(&deployWait, "wait", false, i18n.T("cmd.php.deploy.flag.wait"))
|
||||
|
||||
parent.AddCommand(deployCmd)
|
||||
}
|
||||
|
||||
var (
|
||||
deployStatusStaging bool
|
||||
deployStatusDeploymentID string
|
||||
)
|
||||
|
||||
func addPHPDeployStatusCommand(parent *cobra.Command) {
|
||||
statusCmd := &cobra.Command{
|
||||
Use: "deploy:status",
|
||||
Short: i18n.T("cmd.php.deploy_status.short"),
|
||||
Long: i18n.T("cmd.php.deploy_status.long"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return cli.Err("%s: %w", i18n.T("i18n.fail.get", "working directory"), err)
|
||||
}
|
||||
|
||||
env := EnvProduction
|
||||
if deployStatusStaging {
|
||||
env = EnvStaging
|
||||
}
|
||||
|
||||
cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.deploy")), i18n.ProgressSubject("check", "deployment status"))
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
opts := StatusOptions{
|
||||
Dir: cwd,
|
||||
Environment: env,
|
||||
DeploymentID: deployStatusDeploymentID,
|
||||
}
|
||||
|
||||
status, err := DeployStatus(ctx, opts)
|
||||
if err != nil {
|
||||
return cli.Err("%s: %w", i18n.T("i18n.fail.get", "status"), err)
|
||||
}
|
||||
|
||||
printDeploymentStatus(status)
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
statusCmd.Flags().BoolVar(&deployStatusStaging, "staging", false, i18n.T("cmd.php.deploy_status.flag.staging"))
|
||||
statusCmd.Flags().StringVar(&deployStatusDeploymentID, "id", "", i18n.T("cmd.php.deploy_status.flag.id"))
|
||||
|
||||
parent.AddCommand(statusCmd)
|
||||
}
|
||||
|
||||
var (
|
||||
rollbackStaging bool
|
||||
rollbackDeploymentID string
|
||||
rollbackWait bool
|
||||
)
|
||||
|
||||
func addPHPDeployRollbackCommand(parent *cobra.Command) {
|
||||
rollbackCmd := &cobra.Command{
|
||||
Use: "deploy:rollback",
|
||||
Short: i18n.T("cmd.php.deploy_rollback.short"),
|
||||
Long: i18n.T("cmd.php.deploy_rollback.long"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return cli.Err("%s: %w", i18n.T("i18n.fail.get", "working directory"), err)
|
||||
}
|
||||
|
||||
env := EnvProduction
|
||||
if rollbackStaging {
|
||||
env = EnvStaging
|
||||
}
|
||||
|
||||
cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.deploy")), i18n.T("cmd.php.deploy_rollback.rolling_back", map[string]interface{}{"Environment": env}))
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
opts := RollbackOptions{
|
||||
Dir: cwd,
|
||||
Environment: env,
|
||||
DeploymentID: rollbackDeploymentID,
|
||||
Wait: rollbackWait,
|
||||
}
|
||||
|
||||
status, err := Rollback(ctx, opts)
|
||||
if err != nil {
|
||||
return cli.Err("%s: %w", i18n.T("cmd.php.error.rollback_failed"), err)
|
||||
}
|
||||
|
||||
printDeploymentStatus(status)
|
||||
|
||||
if rollbackWait {
|
||||
if IsDeploymentSuccessful(status.Status) {
|
||||
cli.Print("\n%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("common.success.completed", map[string]any{"Action": "Rollback completed"}))
|
||||
} else {
|
||||
cli.Print("\n%s %s\n", errorStyle.Render(i18n.Label("warning")), i18n.T("cmd.php.deploy_rollback.warning_status", map[string]interface{}{"Status": status.Status}))
|
||||
}
|
||||
} else {
|
||||
cli.Print("\n%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("cmd.php.deploy_rollback.triggered"))
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
rollbackCmd.Flags().BoolVar(&rollbackStaging, "staging", false, i18n.T("cmd.php.deploy_rollback.flag.staging"))
|
||||
rollbackCmd.Flags().StringVar(&rollbackDeploymentID, "id", "", i18n.T("cmd.php.deploy_rollback.flag.id"))
|
||||
rollbackCmd.Flags().BoolVar(&rollbackWait, "wait", false, i18n.T("cmd.php.deploy_rollback.flag.wait"))
|
||||
|
||||
parent.AddCommand(rollbackCmd)
|
||||
}
|
||||
|
||||
var (
|
||||
deployListStaging bool
|
||||
deployListLimit int
|
||||
)
|
||||
|
||||
func addPHPDeployListCommand(parent *cobra.Command) {
|
||||
listCmd := &cobra.Command{
|
||||
Use: "deploy:list",
|
||||
Short: i18n.T("cmd.php.deploy_list.short"),
|
||||
Long: i18n.T("cmd.php.deploy_list.long"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return cli.Err("%s: %w", i18n.T("i18n.fail.get", "working directory"), err)
|
||||
}
|
||||
|
||||
env := EnvProduction
|
||||
if deployListStaging {
|
||||
env = EnvStaging
|
||||
}
|
||||
|
||||
limit := deployListLimit
|
||||
if limit == 0 {
|
||||
limit = 10
|
||||
}
|
||||
|
||||
cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.deploy")), i18n.T("cmd.php.deploy_list.recent", map[string]interface{}{"Environment": env}))
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
deployments, err := ListDeployments(ctx, cwd, env, limit)
|
||||
if err != nil {
|
||||
return cli.Err("%s: %w", i18n.T("i18n.fail.list", "deployments"), err)
|
||||
}
|
||||
|
||||
if len(deployments) == 0 {
|
||||
cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.label.info")), i18n.T("cmd.php.deploy_list.none_found"))
|
||||
return nil
|
||||
}
|
||||
|
||||
for i, d := range deployments {
|
||||
printDeploymentSummary(i+1, &d)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
listCmd.Flags().BoolVar(&deployListStaging, "staging", false, i18n.T("cmd.php.deploy_list.flag.staging"))
|
||||
listCmd.Flags().IntVar(&deployListLimit, "limit", 0, i18n.T("cmd.php.deploy_list.flag.limit"))
|
||||
|
||||
parent.AddCommand(listCmd)
|
||||
}
|
||||
|
||||
func printDeploymentStatus(status *DeploymentStatus) {
|
||||
// Status with color
|
||||
statusStyle := phpDeployStyle
|
||||
switch status.Status {
|
||||
case "queued", "building", "deploying", "pending", "rolling_back":
|
||||
statusStyle = phpDeployPendingStyle
|
||||
case "failed", "error", "cancelled":
|
||||
statusStyle = phpDeployFailedStyle
|
||||
}
|
||||
|
||||
cli.Print("%s %s\n", dimStyle.Render(i18n.Label("status")), statusStyle.Render(status.Status))
|
||||
|
||||
if status.ID != "" {
|
||||
cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.label.id")), status.ID)
|
||||
}
|
||||
|
||||
if status.URL != "" {
|
||||
cli.Print("%s %s\n", dimStyle.Render(i18n.Label("url")), linkStyle.Render(status.URL))
|
||||
}
|
||||
|
||||
if status.Branch != "" {
|
||||
cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.label.branch")), status.Branch)
|
||||
}
|
||||
|
||||
if status.Commit != "" {
|
||||
commit := status.Commit
|
||||
if len(commit) > 7 {
|
||||
commit = commit[:7]
|
||||
}
|
||||
cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.label.commit")), commit)
|
||||
if status.CommitMessage != "" {
|
||||
// Truncate long messages
|
||||
msg := status.CommitMessage
|
||||
if len(msg) > 60 {
|
||||
msg = msg[:57] + "..."
|
||||
}
|
||||
cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.label.message")), msg)
|
||||
}
|
||||
}
|
||||
|
||||
if !status.StartedAt.IsZero() {
|
||||
cli.Print("%s %s\n", dimStyle.Render(i18n.Label("started")), status.StartedAt.Format(time.RFC3339))
|
||||
}
|
||||
|
||||
if !status.CompletedAt.IsZero() {
|
||||
cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.label.completed")), status.CompletedAt.Format(time.RFC3339))
|
||||
if !status.StartedAt.IsZero() {
|
||||
duration := status.CompletedAt.Sub(status.StartedAt)
|
||||
cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.label.duration")), duration.Round(time.Second))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func printDeploymentSummary(index int, status *DeploymentStatus) {
|
||||
// Status with color
|
||||
statusStyle := phpDeployStyle
|
||||
switch status.Status {
|
||||
case "queued", "building", "deploying", "pending", "rolling_back":
|
||||
statusStyle = phpDeployPendingStyle
|
||||
case "failed", "error", "cancelled":
|
||||
statusStyle = phpDeployFailedStyle
|
||||
}
|
||||
|
||||
// Format: #1 [finished] abc1234 - commit message (2 hours ago)
|
||||
id := status.ID
|
||||
if len(id) > 8 {
|
||||
id = id[:8]
|
||||
}
|
||||
|
||||
commit := status.Commit
|
||||
if len(commit) > 7 {
|
||||
commit = commit[:7]
|
||||
}
|
||||
|
||||
msg := status.CommitMessage
|
||||
if len(msg) > 40 {
|
||||
msg = msg[:37] + "..."
|
||||
}
|
||||
|
||||
age := ""
|
||||
if !status.StartedAt.IsZero() {
|
||||
age = i18n.TimeAgo(status.StartedAt)
|
||||
}
|
||||
|
||||
cli.Print(" %s %s %s",
|
||||
dimStyle.Render(cli.Sprintf("#%d", index)),
|
||||
statusStyle.Render(cli.Sprintf("[%s]", status.Status)),
|
||||
id,
|
||||
)
|
||||
|
||||
if commit != "" {
|
||||
cli.Print(" %s", commit)
|
||||
}
|
||||
|
||||
if msg != "" {
|
||||
cli.Print(" - %s", msg)
|
||||
}
|
||||
|
||||
if age != "" {
|
||||
cli.Print(" %s", dimStyle.Render(cli.Sprintf("(%s)", age)))
|
||||
}
|
||||
|
||||
cli.Blank()
|
||||
}
|
||||
497
cmd_dev.go
Normal file
497
cmd_dev.go
Normal file
|
|
@ -0,0 +1,497 @@
|
|||
package php
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
devNoVite bool
|
||||
devNoHorizon bool
|
||||
devNoReverb bool
|
||||
devNoRedis bool
|
||||
devHTTPS bool
|
||||
devDomain string
|
||||
devPort int
|
||||
)
|
||||
|
||||
func addPHPDevCommand(parent *cobra.Command) {
|
||||
devCmd := &cobra.Command{
|
||||
Use: "dev",
|
||||
Short: i18n.T("cmd.php.dev.short"),
|
||||
Long: i18n.T("cmd.php.dev.long"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runPHPDev(phpDevOptions{
|
||||
NoVite: devNoVite,
|
||||
NoHorizon: devNoHorizon,
|
||||
NoReverb: devNoReverb,
|
||||
NoRedis: devNoRedis,
|
||||
HTTPS: devHTTPS,
|
||||
Domain: devDomain,
|
||||
Port: devPort,
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
devCmd.Flags().BoolVar(&devNoVite, "no-vite", false, i18n.T("cmd.php.dev.flag.no_vite"))
|
||||
devCmd.Flags().BoolVar(&devNoHorizon, "no-horizon", false, i18n.T("cmd.php.dev.flag.no_horizon"))
|
||||
devCmd.Flags().BoolVar(&devNoReverb, "no-reverb", false, i18n.T("cmd.php.dev.flag.no_reverb"))
|
||||
devCmd.Flags().BoolVar(&devNoRedis, "no-redis", false, i18n.T("cmd.php.dev.flag.no_redis"))
|
||||
devCmd.Flags().BoolVar(&devHTTPS, "https", false, i18n.T("cmd.php.dev.flag.https"))
|
||||
devCmd.Flags().StringVar(&devDomain, "domain", "", i18n.T("cmd.php.dev.flag.domain"))
|
||||
devCmd.Flags().IntVar(&devPort, "port", 0, i18n.T("cmd.php.dev.flag.port"))
|
||||
|
||||
parent.AddCommand(devCmd)
|
||||
}
|
||||
|
||||
type phpDevOptions struct {
|
||||
NoVite bool
|
||||
NoHorizon bool
|
||||
NoReverb bool
|
||||
NoRedis bool
|
||||
HTTPS bool
|
||||
Domain string
|
||||
Port int
|
||||
}
|
||||
|
||||
func runPHPDev(opts phpDevOptions) error {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return cli.Err("failed to get working directory: %w", err)
|
||||
}
|
||||
|
||||
// Check if this is a Laravel project
|
||||
if !IsLaravelProject(cwd) {
|
||||
return errors.New(i18n.T("cmd.php.error.not_laravel"))
|
||||
}
|
||||
|
||||
// Get app name for display
|
||||
appName := GetLaravelAppName(cwd)
|
||||
if appName == "" {
|
||||
appName = "Laravel"
|
||||
}
|
||||
|
||||
cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.php")), i18n.T("cmd.php.dev.starting", map[string]interface{}{"AppName": appName}))
|
||||
|
||||
// Detect services
|
||||
services := DetectServices(cwd)
|
||||
cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.label.services")), i18n.T("cmd.php.dev.detected_services"))
|
||||
for _, svc := range services {
|
||||
cli.Print(" %s %s\n", successStyle.Render("*"), svc)
|
||||
}
|
||||
cli.Blank()
|
||||
|
||||
// Setup options
|
||||
port := opts.Port
|
||||
if port == 0 {
|
||||
port = 8000
|
||||
}
|
||||
|
||||
devOpts := Options{
|
||||
Dir: cwd,
|
||||
NoVite: opts.NoVite,
|
||||
NoHorizon: opts.NoHorizon,
|
||||
NoReverb: opts.NoReverb,
|
||||
NoRedis: opts.NoRedis,
|
||||
HTTPS: opts.HTTPS,
|
||||
Domain: opts.Domain,
|
||||
FrankenPHPPort: port,
|
||||
}
|
||||
|
||||
// Create and start dev server
|
||||
server := NewDevServer(devOpts)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
// Handle shutdown signals
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
go func() {
|
||||
<-sigCh
|
||||
cli.Print("\n%s %s\n", dimStyle.Render(i18n.T("cmd.php.label.php")), i18n.T("cmd.php.dev.shutting_down"))
|
||||
cancel()
|
||||
}()
|
||||
|
||||
if err := server.Start(ctx, devOpts); err != nil {
|
||||
return cli.Err("%s: %w", i18n.T("i18n.fail.start", "services"), err)
|
||||
}
|
||||
|
||||
// Print status
|
||||
cli.Print("%s %s\n", successStyle.Render(i18n.T("cmd.php.label.running")), i18n.T("cmd.php.dev.services_started"))
|
||||
printServiceStatuses(server.Status())
|
||||
cli.Blank()
|
||||
|
||||
// Print URLs
|
||||
appURL := GetLaravelAppURL(cwd)
|
||||
if appURL == "" {
|
||||
if opts.HTTPS {
|
||||
appURL = cli.Sprintf("https://localhost:%d", port)
|
||||
} else {
|
||||
appURL = cli.Sprintf("http://localhost:%d", port)
|
||||
}
|
||||
}
|
||||
cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.label.app_url")), linkStyle.Render(appURL))
|
||||
|
||||
// Check for Vite
|
||||
if !opts.NoVite && containsService(services, ServiceVite) {
|
||||
cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.label.vite")), linkStyle.Render("http://localhost:5173"))
|
||||
}
|
||||
|
||||
cli.Print("\n%s\n\n", dimStyle.Render(i18n.T("cmd.php.dev.press_ctrl_c")))
|
||||
|
||||
// Stream unified logs
|
||||
logsReader, err := server.Logs("", true)
|
||||
if err != nil {
|
||||
cli.Print("%s %s\n", errorStyle.Render(i18n.Label("warning")), i18n.T("i18n.fail.get", "logs"))
|
||||
} else {
|
||||
defer func() { _ = logsReader.Close() }()
|
||||
|
||||
scanner := bufio.NewScanner(logsReader)
|
||||
for scanner.Scan() {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
goto shutdown
|
||||
default:
|
||||
line := scanner.Text()
|
||||
printColoredLog(line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
shutdown:
|
||||
// Stop services
|
||||
if err := server.Stop(); err != nil {
|
||||
cli.Print("%s %s\n", errorStyle.Render(i18n.Label("error")), i18n.T("cmd.php.dev.stop_error", map[string]interface{}{"Error": err}))
|
||||
}
|
||||
|
||||
cli.Print("%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("cmd.php.dev.all_stopped"))
|
||||
return nil
|
||||
}
|
||||
|
||||
var (
|
||||
logsFollow bool
|
||||
logsService string
|
||||
)
|
||||
|
||||
func addPHPLogsCommand(parent *cobra.Command) {
|
||||
logsCmd := &cobra.Command{
|
||||
Use: "logs",
|
||||
Short: i18n.T("cmd.php.logs.short"),
|
||||
Long: i18n.T("cmd.php.logs.long"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runPHPLogs(logsService, logsFollow)
|
||||
},
|
||||
}
|
||||
|
||||
logsCmd.Flags().BoolVar(&logsFollow, "follow", false, i18n.T("common.flag.follow"))
|
||||
logsCmd.Flags().StringVar(&logsService, "service", "", i18n.T("cmd.php.logs.flag.service"))
|
||||
|
||||
parent.AddCommand(logsCmd)
|
||||
}
|
||||
|
||||
func runPHPLogs(service string, follow bool) error {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !IsLaravelProject(cwd) {
|
||||
return errors.New(i18n.T("cmd.php.error.not_laravel_short"))
|
||||
}
|
||||
|
||||
// Create a minimal server just to access logs
|
||||
server := NewDevServer(Options{Dir: cwd})
|
||||
|
||||
logsReader, err := server.Logs(service, follow)
|
||||
if err != nil {
|
||||
return cli.Err("%s: %w", i18n.T("i18n.fail.get", "logs"), err)
|
||||
}
|
||||
defer func() { _ = logsReader.Close() }()
|
||||
|
||||
// Handle interrupt
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
go func() {
|
||||
<-sigCh
|
||||
cancel()
|
||||
}()
|
||||
|
||||
scanner := bufio.NewScanner(logsReader)
|
||||
for scanner.Scan() {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
default:
|
||||
printColoredLog(scanner.Text())
|
||||
}
|
||||
}
|
||||
|
||||
return scanner.Err()
|
||||
}
|
||||
|
||||
func addPHPStopCommand(parent *cobra.Command) {
|
||||
stopCmd := &cobra.Command{
|
||||
Use: "stop",
|
||||
Short: i18n.T("cmd.php.stop.short"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runPHPStop()
|
||||
},
|
||||
}
|
||||
|
||||
parent.AddCommand(stopCmd)
|
||||
}
|
||||
|
||||
func runPHPStop() error {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.label.php")), i18n.T("cmd.php.stop.stopping"))
|
||||
|
||||
// We need to find running processes
|
||||
// This is a simplified version - in practice you'd want to track PIDs
|
||||
server := NewDevServer(Options{Dir: cwd})
|
||||
if err := server.Stop(); err != nil {
|
||||
return cli.Err("%s: %w", i18n.T("i18n.fail.stop", "services"), err)
|
||||
}
|
||||
|
||||
cli.Print("%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("cmd.php.dev.all_stopped"))
|
||||
return nil
|
||||
}
|
||||
|
||||
func addPHPStatusCommand(parent *cobra.Command) {
|
||||
statusCmd := &cobra.Command{
|
||||
Use: "status",
|
||||
Short: i18n.T("cmd.php.status.short"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runPHPStatus()
|
||||
},
|
||||
}
|
||||
|
||||
parent.AddCommand(statusCmd)
|
||||
}
|
||||
|
||||
func runPHPStatus() error {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !IsLaravelProject(cwd) {
|
||||
return errors.New(i18n.T("cmd.php.error.not_laravel_short"))
|
||||
}
|
||||
|
||||
appName := GetLaravelAppName(cwd)
|
||||
if appName == "" {
|
||||
appName = "Laravel"
|
||||
}
|
||||
|
||||
cli.Print("%s %s\n\n", dimStyle.Render(i18n.Label("project")), appName)
|
||||
|
||||
// Detect available services
|
||||
services := DetectServices(cwd)
|
||||
cli.Print("%s\n", dimStyle.Render(i18n.T("cmd.php.status.detected_services")))
|
||||
for _, svc := range services {
|
||||
style := getServiceStyle(string(svc))
|
||||
cli.Print(" %s %s\n", style.Render("*"), svc)
|
||||
}
|
||||
cli.Blank()
|
||||
|
||||
// Package manager
|
||||
pm := DetectPackageManager(cwd)
|
||||
cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.status.package_manager")), pm)
|
||||
|
||||
// FrankenPHP status
|
||||
if IsFrankenPHPProject(cwd) {
|
||||
cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.status.octane_server")), "FrankenPHP")
|
||||
}
|
||||
|
||||
// SSL status
|
||||
appURL := GetLaravelAppURL(cwd)
|
||||
if appURL != "" {
|
||||
domain := ExtractDomainFromURL(appURL)
|
||||
if CertsExist(domain, SSLOptions{}) {
|
||||
cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.status.ssl_certs")), successStyle.Render(i18n.T("cmd.php.status.ssl_installed")))
|
||||
} else {
|
||||
cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.status.ssl_certs")), dimStyle.Render(i18n.T("cmd.php.status.ssl_not_setup")))
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var sslDomain string
|
||||
|
||||
func addPHPSSLCommand(parent *cobra.Command) {
|
||||
sslCmd := &cobra.Command{
|
||||
Use: "ssl",
|
||||
Short: i18n.T("cmd.php.ssl.short"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runPHPSSL(sslDomain)
|
||||
},
|
||||
}
|
||||
|
||||
sslCmd.Flags().StringVar(&sslDomain, "domain", "", i18n.T("cmd.php.ssl.flag.domain"))
|
||||
|
||||
parent.AddCommand(sslCmd)
|
||||
}
|
||||
|
||||
func runPHPSSL(domain string) error {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get domain from APP_URL if not specified
|
||||
if domain == "" {
|
||||
appURL := GetLaravelAppURL(cwd)
|
||||
if appURL != "" {
|
||||
domain = ExtractDomainFromURL(appURL)
|
||||
}
|
||||
}
|
||||
if domain == "" {
|
||||
domain = "localhost"
|
||||
}
|
||||
|
||||
// Check if mkcert is installed
|
||||
if !IsMkcertInstalled() {
|
||||
cli.Print("%s %s\n", errorStyle.Render(i18n.Label("error")), i18n.T("cmd.php.ssl.mkcert_not_installed"))
|
||||
cli.Print("\n%s\n", i18n.T("common.hint.install_with"))
|
||||
cli.Print(" %s\n", i18n.T("cmd.php.ssl.install_macos"))
|
||||
cli.Print(" %s\n", i18n.T("cmd.php.ssl.install_linux"))
|
||||
return errors.New(i18n.T("cmd.php.error.mkcert_not_installed"))
|
||||
}
|
||||
|
||||
cli.Print("%s %s\n", dimStyle.Render("SSL:"), i18n.T("cmd.php.ssl.setting_up", map[string]interface{}{"Domain": domain}))
|
||||
|
||||
// Check if certs already exist
|
||||
if CertsExist(domain, SSLOptions{}) {
|
||||
cli.Print("%s %s\n", dimStyle.Render(i18n.Label("skip")), i18n.T("cmd.php.ssl.certs_exist"))
|
||||
|
||||
certFile, keyFile, _ := CertPaths(domain, SSLOptions{})
|
||||
cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.ssl.cert_label")), certFile)
|
||||
cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.ssl.key_label")), keyFile)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Setup SSL
|
||||
if err := SetupSSL(domain, SSLOptions{}); err != nil {
|
||||
return cli.Err("%s: %w", i18n.T("i18n.fail.setup", "SSL"), err)
|
||||
}
|
||||
|
||||
certFile, keyFile, _ := CertPaths(domain, SSLOptions{})
|
||||
|
||||
cli.Print("%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("cmd.php.ssl.certs_created"))
|
||||
cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.ssl.cert_label")), certFile)
|
||||
cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.ssl.key_label")), keyFile)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Helper functions for dev commands
|
||||
|
||||
func printServiceStatuses(statuses []ServiceStatus) {
|
||||
for _, s := range statuses {
|
||||
style := getServiceStyle(s.Name)
|
||||
var statusText string
|
||||
|
||||
if s.Error != nil {
|
||||
statusText = phpStatusError.Render(i18n.T("cmd.php.status.error", map[string]interface{}{"Error": s.Error}))
|
||||
} else if s.Running {
|
||||
statusText = phpStatusRunning.Render(i18n.T("cmd.php.status.running"))
|
||||
if s.Port > 0 {
|
||||
statusText += dimStyle.Render(cli.Sprintf(" (%s)", i18n.T("cmd.php.status.port", map[string]interface{}{"Port": s.Port})))
|
||||
}
|
||||
if s.PID > 0 {
|
||||
statusText += dimStyle.Render(cli.Sprintf(" [%s]", i18n.T("cmd.php.status.pid", map[string]interface{}{"PID": s.PID})))
|
||||
}
|
||||
} else {
|
||||
statusText = phpStatusStopped.Render(i18n.T("cmd.php.status.stopped"))
|
||||
}
|
||||
|
||||
cli.Print(" %s %s\n", style.Render(s.Name+":"), statusText)
|
||||
}
|
||||
}
|
||||
|
||||
func printColoredLog(line string) {
|
||||
// Parse service prefix from log line
|
||||
timestamp := time.Now().Format("15:04:05")
|
||||
|
||||
var style *cli.AnsiStyle
|
||||
serviceName := ""
|
||||
|
||||
if strings.HasPrefix(line, "[FrankenPHP]") {
|
||||
style = phpFrankenPHPStyle
|
||||
serviceName = "FrankenPHP"
|
||||
line = strings.TrimPrefix(line, "[FrankenPHP] ")
|
||||
} else if strings.HasPrefix(line, "[Vite]") {
|
||||
style = phpViteStyle
|
||||
serviceName = "Vite"
|
||||
line = strings.TrimPrefix(line, "[Vite] ")
|
||||
} else if strings.HasPrefix(line, "[Horizon]") {
|
||||
style = phpHorizonStyle
|
||||
serviceName = "Horizon"
|
||||
line = strings.TrimPrefix(line, "[Horizon] ")
|
||||
} else if strings.HasPrefix(line, "[Reverb]") {
|
||||
style = phpReverbStyle
|
||||
serviceName = "Reverb"
|
||||
line = strings.TrimPrefix(line, "[Reverb] ")
|
||||
} else if strings.HasPrefix(line, "[Redis]") {
|
||||
style = phpRedisStyle
|
||||
serviceName = "Redis"
|
||||
line = strings.TrimPrefix(line, "[Redis] ")
|
||||
} else {
|
||||
// Unknown service, print as-is
|
||||
cli.Print("%s %s\n", dimStyle.Render(timestamp), line)
|
||||
return
|
||||
}
|
||||
|
||||
cli.Print("%s %s %s\n",
|
||||
dimStyle.Render(timestamp),
|
||||
style.Render(cli.Sprintf("[%s]", serviceName)),
|
||||
line,
|
||||
)
|
||||
}
|
||||
|
||||
func getServiceStyle(name string) *cli.AnsiStyle {
|
||||
switch strings.ToLower(name) {
|
||||
case "frankenphp":
|
||||
return phpFrankenPHPStyle
|
||||
case "vite":
|
||||
return phpViteStyle
|
||||
case "horizon":
|
||||
return phpHorizonStyle
|
||||
case "reverb":
|
||||
return phpReverbStyle
|
||||
case "redis":
|
||||
return phpRedisStyle
|
||||
default:
|
||||
return dimStyle
|
||||
}
|
||||
}
|
||||
|
||||
func containsService(services []DetectedService, target DetectedService) bool {
|
||||
for _, s := range services {
|
||||
if s == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
146
cmd_packages.go
Normal file
146
cmd_packages.go
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
package php
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func addPHPPackagesCommands(parent *cobra.Command) {
|
||||
packagesCmd := &cobra.Command{
|
||||
Use: "packages",
|
||||
Short: i18n.T("cmd.php.packages.short"),
|
||||
Long: i18n.T("cmd.php.packages.long"),
|
||||
}
|
||||
parent.AddCommand(packagesCmd)
|
||||
|
||||
addPHPPackagesLinkCommand(packagesCmd)
|
||||
addPHPPackagesUnlinkCommand(packagesCmd)
|
||||
addPHPPackagesUpdateCommand(packagesCmd)
|
||||
addPHPPackagesListCommand(packagesCmd)
|
||||
}
|
||||
|
||||
func addPHPPackagesLinkCommand(parent *cobra.Command) {
|
||||
linkCmd := &cobra.Command{
|
||||
Use: "link [paths...]",
|
||||
Short: i18n.T("cmd.php.packages.link.short"),
|
||||
Long: i18n.T("cmd.php.packages.link.long"),
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return cli.Err("%s: %w", i18n.T("i18n.fail.get", "working directory"), err)
|
||||
}
|
||||
|
||||
cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.php")), i18n.T("cmd.php.packages.link.linking"))
|
||||
|
||||
if err := LinkPackages(cwd, args); err != nil {
|
||||
return cli.Err("%s: %w", i18n.T("i18n.fail.link", "packages"), err)
|
||||
}
|
||||
|
||||
cli.Print("\n%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("cmd.php.packages.link.done"))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
parent.AddCommand(linkCmd)
|
||||
}
|
||||
|
||||
func addPHPPackagesUnlinkCommand(parent *cobra.Command) {
|
||||
unlinkCmd := &cobra.Command{
|
||||
Use: "unlink [packages...]",
|
||||
Short: i18n.T("cmd.php.packages.unlink.short"),
|
||||
Long: i18n.T("cmd.php.packages.unlink.long"),
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return cli.Err("%s: %w", i18n.T("i18n.fail.get", "working directory"), err)
|
||||
}
|
||||
|
||||
cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.php")), i18n.T("cmd.php.packages.unlink.unlinking"))
|
||||
|
||||
if err := UnlinkPackages(cwd, args); err != nil {
|
||||
return cli.Err("%s: %w", i18n.T("i18n.fail.unlink", "packages"), err)
|
||||
}
|
||||
|
||||
cli.Print("\n%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("cmd.php.packages.unlink.done"))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
parent.AddCommand(unlinkCmd)
|
||||
}
|
||||
|
||||
func addPHPPackagesUpdateCommand(parent *cobra.Command) {
|
||||
updateCmd := &cobra.Command{
|
||||
Use: "update [packages...]",
|
||||
Short: i18n.T("cmd.php.packages.update.short"),
|
||||
Long: i18n.T("cmd.php.packages.update.long"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return cli.Err("%s: %w", i18n.T("i18n.fail.get", "working directory"), err)
|
||||
}
|
||||
|
||||
cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.php")), i18n.T("cmd.php.packages.update.updating"))
|
||||
|
||||
if err := UpdatePackages(cwd, args); err != nil {
|
||||
return cli.Err("%s: %w", i18n.T("cmd.php.error.update_packages"), err)
|
||||
}
|
||||
|
||||
cli.Print("\n%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("cmd.php.packages.update.done"))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
parent.AddCommand(updateCmd)
|
||||
}
|
||||
|
||||
func addPHPPackagesListCommand(parent *cobra.Command) {
|
||||
listCmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: i18n.T("cmd.php.packages.list.short"),
|
||||
Long: i18n.T("cmd.php.packages.list.long"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return cli.Err("%s: %w", i18n.T("i18n.fail.get", "working directory"), err)
|
||||
}
|
||||
|
||||
packages, err := ListLinkedPackages(cwd)
|
||||
if err != nil {
|
||||
return cli.Err("%s: %w", i18n.T("i18n.fail.list", "packages"), err)
|
||||
}
|
||||
|
||||
if len(packages) == 0 {
|
||||
cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.label.php")), i18n.T("cmd.php.packages.list.none_found"))
|
||||
return nil
|
||||
}
|
||||
|
||||
cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.php")), i18n.T("cmd.php.packages.list.linked"))
|
||||
|
||||
for _, pkg := range packages {
|
||||
name := pkg.Name
|
||||
if name == "" {
|
||||
name = i18n.T("cmd.php.packages.list.unknown")
|
||||
}
|
||||
version := pkg.Version
|
||||
if version == "" {
|
||||
version = "dev"
|
||||
}
|
||||
|
||||
cli.Print(" %s %s\n", successStyle.Render("*"), name)
|
||||
cli.Print(" %s %s\n", dimStyle.Render(i18n.Label("path")), pkg.Path)
|
||||
cli.Print(" %s %s\n", dimStyle.Render(i18n.Label("version")), version)
|
||||
cli.Blank()
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
parent.AddCommand(listCmd)
|
||||
}
|
||||
343
cmd_qa_runner.go
Normal file
343
cmd_qa_runner.go
Normal file
|
|
@ -0,0 +1,343 @@
|
|||
package php
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/framework"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
"forge.lthn.ai/core/go/pkg/process"
|
||||
)
|
||||
|
||||
// QARunner orchestrates PHP QA checks using pkg/process.
|
||||
type QARunner struct {
|
||||
dir string
|
||||
fix bool
|
||||
service *process.Service
|
||||
core *framework.Core
|
||||
|
||||
// Output tracking
|
||||
outputMu sync.Mutex
|
||||
checkOutputs map[string][]string
|
||||
}
|
||||
|
||||
// NewQARunner creates a QA runner for the given directory.
|
||||
func NewQARunner(dir string, fix bool) (*QARunner, error) {
|
||||
// Create a Core with process service for the QA session
|
||||
core, err := framework.New(
|
||||
framework.WithName("process", process.NewService(process.Options{})),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, cli.WrapVerb(err, "create", "process service")
|
||||
}
|
||||
|
||||
svc, err := framework.ServiceFor[*process.Service](core, "process")
|
||||
if err != nil {
|
||||
return nil, cli.WrapVerb(err, "get", "process service")
|
||||
}
|
||||
|
||||
runner := &QARunner{
|
||||
dir: dir,
|
||||
fix: fix,
|
||||
service: svc,
|
||||
core: core,
|
||||
checkOutputs: make(map[string][]string),
|
||||
}
|
||||
|
||||
return runner, nil
|
||||
}
|
||||
|
||||
// BuildSpecs creates RunSpecs for the given QA checks.
|
||||
func (r *QARunner) BuildSpecs(checks []string) []process.RunSpec {
|
||||
specs := make([]process.RunSpec, 0, len(checks))
|
||||
|
||||
for _, check := range checks {
|
||||
spec := r.buildSpec(check)
|
||||
if spec != nil {
|
||||
specs = append(specs, *spec)
|
||||
}
|
||||
}
|
||||
|
||||
return specs
|
||||
}
|
||||
|
||||
// buildSpec creates a RunSpec for a single check.
|
||||
func (r *QARunner) buildSpec(check string) *process.RunSpec {
|
||||
switch check {
|
||||
case "audit":
|
||||
return &process.RunSpec{
|
||||
Name: "audit",
|
||||
Command: "composer",
|
||||
Args: []string{"audit", "--format=summary"},
|
||||
Dir: r.dir,
|
||||
}
|
||||
|
||||
case "fmt":
|
||||
m := getMedium()
|
||||
formatter, found := DetectFormatter(r.dir)
|
||||
if !found {
|
||||
return nil
|
||||
}
|
||||
if formatter == FormatterPint {
|
||||
vendorBin := filepath.Join(r.dir, "vendor", "bin", "pint")
|
||||
cmd := "pint"
|
||||
if m.IsFile(vendorBin) {
|
||||
cmd = vendorBin
|
||||
}
|
||||
args := []string{}
|
||||
if !r.fix {
|
||||
args = append(args, "--test")
|
||||
}
|
||||
return &process.RunSpec{
|
||||
Name: "fmt",
|
||||
Command: cmd,
|
||||
Args: args,
|
||||
Dir: r.dir,
|
||||
After: []string{"audit"},
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
case "stan":
|
||||
m := getMedium()
|
||||
_, found := DetectAnalyser(r.dir)
|
||||
if !found {
|
||||
return nil
|
||||
}
|
||||
vendorBin := filepath.Join(r.dir, "vendor", "bin", "phpstan")
|
||||
cmd := "phpstan"
|
||||
if m.IsFile(vendorBin) {
|
||||
cmd = vendorBin
|
||||
}
|
||||
return &process.RunSpec{
|
||||
Name: "stan",
|
||||
Command: cmd,
|
||||
Args: []string{"analyse", "--no-progress"},
|
||||
Dir: r.dir,
|
||||
After: []string{"fmt"},
|
||||
}
|
||||
|
||||
case "psalm":
|
||||
m := getMedium()
|
||||
_, found := DetectPsalm(r.dir)
|
||||
if !found {
|
||||
return nil
|
||||
}
|
||||
vendorBin := filepath.Join(r.dir, "vendor", "bin", "psalm")
|
||||
cmd := "psalm"
|
||||
if m.IsFile(vendorBin) {
|
||||
cmd = vendorBin
|
||||
}
|
||||
args := []string{"--no-progress"}
|
||||
if r.fix {
|
||||
args = append(args, "--alter", "--issues=all")
|
||||
}
|
||||
return &process.RunSpec{
|
||||
Name: "psalm",
|
||||
Command: cmd,
|
||||
Args: args,
|
||||
Dir: r.dir,
|
||||
After: []string{"stan"},
|
||||
}
|
||||
|
||||
case "test":
|
||||
m := getMedium()
|
||||
// Check for Pest first, fall back to PHPUnit
|
||||
pestBin := filepath.Join(r.dir, "vendor", "bin", "pest")
|
||||
phpunitBin := filepath.Join(r.dir, "vendor", "bin", "phpunit")
|
||||
|
||||
var cmd string
|
||||
if m.IsFile(pestBin) {
|
||||
cmd = pestBin
|
||||
} else if m.IsFile(phpunitBin) {
|
||||
cmd = phpunitBin
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Tests depend on stan (or psalm if available)
|
||||
after := []string{"stan"}
|
||||
if _, found := DetectPsalm(r.dir); found {
|
||||
after = []string{"psalm"}
|
||||
}
|
||||
|
||||
return &process.RunSpec{
|
||||
Name: "test",
|
||||
Command: cmd,
|
||||
Args: []string{},
|
||||
Dir: r.dir,
|
||||
After: after,
|
||||
}
|
||||
|
||||
case "rector":
|
||||
m := getMedium()
|
||||
if !DetectRector(r.dir) {
|
||||
return nil
|
||||
}
|
||||
vendorBin := filepath.Join(r.dir, "vendor", "bin", "rector")
|
||||
cmd := "rector"
|
||||
if m.IsFile(vendorBin) {
|
||||
cmd = vendorBin
|
||||
}
|
||||
args := []string{"process"}
|
||||
if !r.fix {
|
||||
args = append(args, "--dry-run")
|
||||
}
|
||||
return &process.RunSpec{
|
||||
Name: "rector",
|
||||
Command: cmd,
|
||||
Args: args,
|
||||
Dir: r.dir,
|
||||
After: []string{"test"},
|
||||
AllowFailure: true, // Dry-run returns non-zero if changes would be made
|
||||
}
|
||||
|
||||
case "infection":
|
||||
m := getMedium()
|
||||
if !DetectInfection(r.dir) {
|
||||
return nil
|
||||
}
|
||||
vendorBin := filepath.Join(r.dir, "vendor", "bin", "infection")
|
||||
cmd := "infection"
|
||||
if m.IsFile(vendorBin) {
|
||||
cmd = vendorBin
|
||||
}
|
||||
return &process.RunSpec{
|
||||
Name: "infection",
|
||||
Command: cmd,
|
||||
Args: []string{"--min-msi=50", "--min-covered-msi=70", "--threads=4"},
|
||||
Dir: r.dir,
|
||||
After: []string{"test"},
|
||||
AllowFailure: true,
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Run executes all QA checks and returns the results.
|
||||
func (r *QARunner) Run(ctx context.Context, stages []QAStage) (*QARunResult, error) {
|
||||
// Collect all checks from all stages
|
||||
var allChecks []string
|
||||
for _, stage := range stages {
|
||||
checks := GetQAChecks(r.dir, stage)
|
||||
allChecks = append(allChecks, checks...)
|
||||
}
|
||||
|
||||
if len(allChecks) == 0 {
|
||||
return &QARunResult{Passed: true}, nil
|
||||
}
|
||||
|
||||
// Build specs
|
||||
specs := r.BuildSpecs(allChecks)
|
||||
if len(specs) == 0 {
|
||||
return &QARunResult{Passed: true}, nil
|
||||
}
|
||||
|
||||
// Register output handler
|
||||
r.core.RegisterAction(func(c *framework.Core, msg framework.Message) error {
|
||||
switch m := msg.(type) {
|
||||
case process.ActionProcessOutput:
|
||||
r.outputMu.Lock()
|
||||
// Extract check name from process ID mapping
|
||||
for _, spec := range specs {
|
||||
if strings.Contains(m.ID, spec.Name) || m.ID != "" {
|
||||
// Store output for later display if needed
|
||||
r.checkOutputs[spec.Name] = append(r.checkOutputs[spec.Name], m.Line)
|
||||
break
|
||||
}
|
||||
}
|
||||
r.outputMu.Unlock()
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
// Create runner and execute
|
||||
runner := process.NewRunner(r.service)
|
||||
result, err := runner.RunAll(ctx, specs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Convert to QA result
|
||||
qaResult := &QARunResult{
|
||||
Passed: result.Success(),
|
||||
Duration: result.Duration.String(),
|
||||
Results: make([]QACheckRunResult, 0, len(result.Results)),
|
||||
}
|
||||
|
||||
for _, res := range result.Results {
|
||||
qaResult.Results = append(qaResult.Results, QACheckRunResult{
|
||||
Name: res.Name,
|
||||
Passed: res.Passed(),
|
||||
Skipped: res.Skipped,
|
||||
ExitCode: res.ExitCode,
|
||||
Duration: res.Duration.String(),
|
||||
Output: res.Output,
|
||||
})
|
||||
if res.Passed() {
|
||||
qaResult.PassedCount++
|
||||
} else if res.Skipped {
|
||||
qaResult.SkippedCount++
|
||||
} else {
|
||||
qaResult.FailedCount++
|
||||
}
|
||||
}
|
||||
|
||||
return qaResult, nil
|
||||
}
|
||||
|
||||
// GetCheckOutput returns captured output for a check.
|
||||
func (r *QARunner) GetCheckOutput(check string) []string {
|
||||
r.outputMu.Lock()
|
||||
defer r.outputMu.Unlock()
|
||||
return r.checkOutputs[check]
|
||||
}
|
||||
|
||||
// QARunResult holds the results of running QA checks.
|
||||
type QARunResult struct {
|
||||
Passed bool `json:"passed"`
|
||||
Duration string `json:"duration"`
|
||||
Results []QACheckRunResult `json:"results"`
|
||||
PassedCount int `json:"passed_count"`
|
||||
FailedCount int `json:"failed_count"`
|
||||
SkippedCount int `json:"skipped_count"`
|
||||
}
|
||||
|
||||
// QACheckRunResult holds the result of a single QA check.
|
||||
type QACheckRunResult struct {
|
||||
Name string `json:"name"`
|
||||
Passed bool `json:"passed"`
|
||||
Skipped bool `json:"skipped"`
|
||||
ExitCode int `json:"exit_code"`
|
||||
Duration string `json:"duration"`
|
||||
Output string `json:"output,omitempty"`
|
||||
}
|
||||
|
||||
// GetIssueMessage returns an issue message for a check.
|
||||
func (r QACheckRunResult) GetIssueMessage() string {
|
||||
if r.Passed || r.Skipped {
|
||||
return ""
|
||||
}
|
||||
switch r.Name {
|
||||
case "audit":
|
||||
return i18n.T("i18n.done.find", "vulnerabilities")
|
||||
case "fmt":
|
||||
return i18n.T("i18n.done.find", "style issues")
|
||||
case "stan":
|
||||
return i18n.T("i18n.done.find", "analysis errors")
|
||||
case "psalm":
|
||||
return i18n.T("i18n.done.find", "type errors")
|
||||
case "test":
|
||||
return i18n.T("i18n.done.fail", "tests")
|
||||
case "rector":
|
||||
return i18n.T("i18n.done.find", "refactoring suggestions")
|
||||
case "infection":
|
||||
return i18n.T("i18n.fail.pass", "mutation testing")
|
||||
default:
|
||||
return i18n.T("i18n.done.find", "issues")
|
||||
}
|
||||
}
|
||||
815
cmd_quality.go
Normal file
815
cmd_quality.go
Normal file
|
|
@ -0,0 +1,815 @@
|
|||
package php
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
testParallel bool
|
||||
testCoverage bool
|
||||
testFilter string
|
||||
testGroup string
|
||||
testJSON bool
|
||||
)
|
||||
|
||||
func addPHPTestCommand(parent *cobra.Command) {
|
||||
testCmd := &cobra.Command{
|
||||
Use: "test",
|
||||
Short: i18n.T("cmd.php.test.short"),
|
||||
Long: i18n.T("cmd.php.test.long"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return cli.Err("%s: %w", i18n.T("i18n.fail.get", "working directory"), err)
|
||||
}
|
||||
|
||||
if !IsPHPProject(cwd) {
|
||||
return errors.New(i18n.T("cmd.php.error.not_php"))
|
||||
}
|
||||
|
||||
if !testJSON {
|
||||
cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.php")), i18n.ProgressSubject("run", "tests"))
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
opts := TestOptions{
|
||||
Dir: cwd,
|
||||
Filter: testFilter,
|
||||
Parallel: testParallel,
|
||||
Coverage: testCoverage,
|
||||
JUnit: testJSON,
|
||||
Output: os.Stdout,
|
||||
}
|
||||
|
||||
if testGroup != "" {
|
||||
opts.Groups = []string{testGroup}
|
||||
}
|
||||
|
||||
if err := RunTests(ctx, opts); err != nil {
|
||||
return cli.Err("%s: %w", i18n.T("i18n.fail.run", "tests"), err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
testCmd.Flags().BoolVar(&testParallel, "parallel", false, i18n.T("cmd.php.test.flag.parallel"))
|
||||
testCmd.Flags().BoolVar(&testCoverage, "coverage", false, i18n.T("cmd.php.test.flag.coverage"))
|
||||
testCmd.Flags().StringVar(&testFilter, "filter", "", i18n.T("cmd.php.test.flag.filter"))
|
||||
testCmd.Flags().StringVar(&testGroup, "group", "", i18n.T("cmd.php.test.flag.group"))
|
||||
testCmd.Flags().BoolVar(&testJSON, "junit", false, i18n.T("cmd.php.test.flag.junit"))
|
||||
|
||||
parent.AddCommand(testCmd)
|
||||
}
|
||||
|
||||
var (
|
||||
fmtFix bool
|
||||
fmtDiff bool
|
||||
fmtJSON bool
|
||||
)
|
||||
|
||||
func addPHPFmtCommand(parent *cobra.Command) {
|
||||
fmtCmd := &cobra.Command{
|
||||
Use: "fmt [paths...]",
|
||||
Short: i18n.T("cmd.php.fmt.short"),
|
||||
Long: i18n.T("cmd.php.fmt.long"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return cli.Err("%s: %w", i18n.T("i18n.fail.get", "working directory"), err)
|
||||
}
|
||||
|
||||
if !IsPHPProject(cwd) {
|
||||
return errors.New(i18n.T("cmd.php.error.not_php"))
|
||||
}
|
||||
|
||||
// Detect formatter
|
||||
formatter, found := DetectFormatter(cwd)
|
||||
if !found {
|
||||
return errors.New(i18n.T("cmd.php.fmt.no_formatter"))
|
||||
}
|
||||
|
||||
if !fmtJSON {
|
||||
var msg string
|
||||
if fmtFix {
|
||||
msg = i18n.T("cmd.php.fmt.formatting", map[string]interface{}{"Formatter": formatter})
|
||||
} else {
|
||||
msg = i18n.ProgressSubject("check", "code style")
|
||||
}
|
||||
cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.php")), msg)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
opts := FormatOptions{
|
||||
Dir: cwd,
|
||||
Fix: fmtFix,
|
||||
Diff: fmtDiff,
|
||||
JSON: fmtJSON,
|
||||
Output: os.Stdout,
|
||||
}
|
||||
|
||||
// Get any additional paths from args
|
||||
if len(args) > 0 {
|
||||
opts.Paths = args
|
||||
}
|
||||
|
||||
if err := Format(ctx, opts); err != nil {
|
||||
if fmtFix {
|
||||
return cli.Err("%s: %w", i18n.T("cmd.php.error.fmt_failed"), err)
|
||||
}
|
||||
return cli.Err("%s: %w", i18n.T("cmd.php.error.fmt_issues"), err)
|
||||
}
|
||||
|
||||
if !fmtJSON {
|
||||
if fmtFix {
|
||||
cli.Print("\n%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("common.success.completed", map[string]any{"Action": "Code formatted"}))
|
||||
} else {
|
||||
cli.Print("\n%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("cmd.php.fmt.no_issues"))
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
fmtCmd.Flags().BoolVar(&fmtFix, "fix", false, i18n.T("cmd.php.fmt.flag.fix"))
|
||||
fmtCmd.Flags().BoolVar(&fmtDiff, "diff", false, i18n.T("common.flag.diff"))
|
||||
fmtCmd.Flags().BoolVar(&fmtJSON, "json", false, i18n.T("common.flag.json"))
|
||||
|
||||
parent.AddCommand(fmtCmd)
|
||||
}
|
||||
|
||||
var (
|
||||
stanLevel int
|
||||
stanMemory string
|
||||
stanJSON bool
|
||||
stanSARIF bool
|
||||
)
|
||||
|
||||
func addPHPStanCommand(parent *cobra.Command) {
|
||||
stanCmd := &cobra.Command{
|
||||
Use: "stan [paths...]",
|
||||
Short: i18n.T("cmd.php.analyse.short"),
|
||||
Long: i18n.T("cmd.php.analyse.long"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return cli.Err("%s: %w", i18n.T("i18n.fail.get", "working directory"), err)
|
||||
}
|
||||
|
||||
if !IsPHPProject(cwd) {
|
||||
return errors.New(i18n.T("cmd.php.error.not_php"))
|
||||
}
|
||||
|
||||
// Detect analyser
|
||||
_, found := DetectAnalyser(cwd)
|
||||
if !found {
|
||||
return errors.New(i18n.T("cmd.php.analyse.no_analyser"))
|
||||
}
|
||||
|
||||
if stanJSON && stanSARIF {
|
||||
return errors.New(i18n.T("common.error.json_sarif_exclusive"))
|
||||
}
|
||||
|
||||
if !stanJSON && !stanSARIF {
|
||||
cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.php")), i18n.ProgressSubject("run", "static analysis"))
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
opts := AnalyseOptions{
|
||||
Dir: cwd,
|
||||
Level: stanLevel,
|
||||
Memory: stanMemory,
|
||||
JSON: stanJSON,
|
||||
SARIF: stanSARIF,
|
||||
Output: os.Stdout,
|
||||
}
|
||||
|
||||
// Get any additional paths from args
|
||||
if len(args) > 0 {
|
||||
opts.Paths = args
|
||||
}
|
||||
|
||||
if err := Analyse(ctx, opts); err != nil {
|
||||
return cli.Err("%s: %w", i18n.T("cmd.php.error.analysis_issues"), err)
|
||||
}
|
||||
|
||||
if !stanJSON && !stanSARIF {
|
||||
cli.Print("\n%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("common.result.no_issues"))
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
stanCmd.Flags().IntVar(&stanLevel, "level", 0, i18n.T("cmd.php.analyse.flag.level"))
|
||||
stanCmd.Flags().StringVar(&stanMemory, "memory", "", i18n.T("cmd.php.analyse.flag.memory"))
|
||||
stanCmd.Flags().BoolVar(&stanJSON, "json", false, i18n.T("common.flag.json"))
|
||||
stanCmd.Flags().BoolVar(&stanSARIF, "sarif", false, i18n.T("common.flag.sarif"))
|
||||
|
||||
parent.AddCommand(stanCmd)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// New QA Commands
|
||||
// =============================================================================
|
||||
|
||||
var (
|
||||
psalmLevel int
|
||||
psalmFix bool
|
||||
psalmBaseline bool
|
||||
psalmShowInfo bool
|
||||
psalmJSON bool
|
||||
psalmSARIF bool
|
||||
)
|
||||
|
||||
func addPHPPsalmCommand(parent *cobra.Command) {
|
||||
psalmCmd := &cobra.Command{
|
||||
Use: "psalm",
|
||||
Short: i18n.T("cmd.php.psalm.short"),
|
||||
Long: i18n.T("cmd.php.psalm.long"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return cli.Err("%s: %w", i18n.T("i18n.fail.get", "working directory"), err)
|
||||
}
|
||||
|
||||
if !IsPHPProject(cwd) {
|
||||
return errors.New(i18n.T("cmd.php.error.not_php"))
|
||||
}
|
||||
|
||||
// Check if Psalm is available
|
||||
_, found := DetectPsalm(cwd)
|
||||
if !found {
|
||||
cli.Print("%s %s\n\n", errorStyle.Render(i18n.Label("error")), i18n.T("cmd.php.psalm.not_found"))
|
||||
cli.Print("%s %s\n", dimStyle.Render(i18n.Label("install")), i18n.T("cmd.php.psalm.install"))
|
||||
cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.label.setup")), i18n.T("cmd.php.psalm.setup"))
|
||||
return errors.New(i18n.T("cmd.php.error.psalm_not_installed"))
|
||||
}
|
||||
|
||||
if psalmJSON && psalmSARIF {
|
||||
return errors.New(i18n.T("common.error.json_sarif_exclusive"))
|
||||
}
|
||||
|
||||
if !psalmJSON && !psalmSARIF {
|
||||
var msg string
|
||||
if psalmFix {
|
||||
msg = i18n.T("cmd.php.psalm.analysing_fixing")
|
||||
} else {
|
||||
msg = i18n.T("cmd.php.psalm.analysing")
|
||||
}
|
||||
cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.psalm")), msg)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
opts := PsalmOptions{
|
||||
Dir: cwd,
|
||||
Level: psalmLevel,
|
||||
Fix: psalmFix,
|
||||
Baseline: psalmBaseline,
|
||||
ShowInfo: psalmShowInfo,
|
||||
JSON: psalmJSON,
|
||||
SARIF: psalmSARIF,
|
||||
Output: os.Stdout,
|
||||
}
|
||||
|
||||
if err := RunPsalm(ctx, opts); err != nil {
|
||||
return cli.Err("%s: %w", i18n.T("cmd.php.error.psalm_issues"), err)
|
||||
}
|
||||
|
||||
if !psalmJSON && !psalmSARIF {
|
||||
cli.Print("\n%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("common.result.no_issues"))
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
psalmCmd.Flags().IntVar(&psalmLevel, "level", 0, i18n.T("cmd.php.psalm.flag.level"))
|
||||
psalmCmd.Flags().BoolVar(&psalmFix, "fix", false, i18n.T("common.flag.fix"))
|
||||
psalmCmd.Flags().BoolVar(&psalmBaseline, "baseline", false, i18n.T("cmd.php.psalm.flag.baseline"))
|
||||
psalmCmd.Flags().BoolVar(&psalmShowInfo, "show-info", false, i18n.T("cmd.php.psalm.flag.show_info"))
|
||||
psalmCmd.Flags().BoolVar(&psalmJSON, "json", false, i18n.T("common.flag.json"))
|
||||
psalmCmd.Flags().BoolVar(&psalmSARIF, "sarif", false, i18n.T("common.flag.sarif"))
|
||||
|
||||
parent.AddCommand(psalmCmd)
|
||||
}
|
||||
|
||||
var (
|
||||
auditJSONOutput bool
|
||||
auditFix bool
|
||||
)
|
||||
|
||||
func addPHPAuditCommand(parent *cobra.Command) {
|
||||
auditCmd := &cobra.Command{
|
||||
Use: "audit",
|
||||
Short: i18n.T("cmd.php.audit.short"),
|
||||
Long: i18n.T("cmd.php.audit.long"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return cli.Err("%s: %w", i18n.T("i18n.fail.get", "working directory"), err)
|
||||
}
|
||||
|
||||
if !IsPHPProject(cwd) {
|
||||
return errors.New(i18n.T("cmd.php.error.not_php"))
|
||||
}
|
||||
|
||||
cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.audit")), i18n.T("cmd.php.audit.scanning"))
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
results, err := RunAudit(ctx, AuditOptions{
|
||||
Dir: cwd,
|
||||
JSON: auditJSONOutput,
|
||||
Fix: auditFix,
|
||||
Output: os.Stdout,
|
||||
})
|
||||
if err != nil {
|
||||
return cli.Err("%s: %w", i18n.T("cmd.php.error.audit_failed"), err)
|
||||
}
|
||||
|
||||
// Print results
|
||||
totalVulns := 0
|
||||
hasErrors := false
|
||||
|
||||
for _, result := range results {
|
||||
icon := successStyle.Render("✓")
|
||||
status := successStyle.Render(i18n.T("cmd.php.audit.secure"))
|
||||
|
||||
if result.Error != nil {
|
||||
icon = errorStyle.Render("✗")
|
||||
status = errorStyle.Render(i18n.T("cmd.php.audit.error"))
|
||||
hasErrors = true
|
||||
} else if result.Vulnerabilities > 0 {
|
||||
icon = errorStyle.Render("✗")
|
||||
status = errorStyle.Render(i18n.T("cmd.php.audit.vulnerabilities", map[string]interface{}{"Count": result.Vulnerabilities}))
|
||||
totalVulns += result.Vulnerabilities
|
||||
}
|
||||
|
||||
cli.Print(" %s %s %s\n", icon, dimStyle.Render(result.Tool+":"), status)
|
||||
|
||||
// Show advisories
|
||||
for _, adv := range result.Advisories {
|
||||
severity := adv.Severity
|
||||
if severity == "" {
|
||||
severity = "unknown"
|
||||
}
|
||||
sevStyle := getSeverityStyle(severity)
|
||||
cli.Print(" %s %s\n", sevStyle.Render("["+severity+"]"), adv.Package)
|
||||
if adv.Title != "" {
|
||||
cli.Print(" %s\n", dimStyle.Render(adv.Title))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cli.Blank()
|
||||
|
||||
if totalVulns > 0 {
|
||||
cli.Print("%s %s\n", errorStyle.Render(i18n.Label("warning")), i18n.T("cmd.php.audit.found_vulns", map[string]interface{}{"Count": totalVulns}))
|
||||
cli.Print("%s %s\n", dimStyle.Render(i18n.Label("fix")), i18n.T("common.hint.fix_deps"))
|
||||
return errors.New(i18n.T("cmd.php.error.vulns_found"))
|
||||
}
|
||||
|
||||
if hasErrors {
|
||||
return errors.New(i18n.T("cmd.php.audit.completed_errors"))
|
||||
}
|
||||
|
||||
cli.Print("%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("cmd.php.audit.all_secure"))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
auditCmd.Flags().BoolVar(&auditJSONOutput, "json", false, i18n.T("common.flag.json"))
|
||||
auditCmd.Flags().BoolVar(&auditFix, "fix", false, i18n.T("cmd.php.audit.flag.fix"))
|
||||
|
||||
parent.AddCommand(auditCmd)
|
||||
}
|
||||
|
||||
var (
|
||||
securitySeverity string
|
||||
securityJSONOutput bool
|
||||
securitySarif bool
|
||||
securityURL string
|
||||
)
|
||||
|
||||
func addPHPSecurityCommand(parent *cobra.Command) {
|
||||
securityCmd := &cobra.Command{
|
||||
Use: "security",
|
||||
Short: i18n.T("cmd.php.security.short"),
|
||||
Long: i18n.T("cmd.php.security.long"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return cli.Err("%s: %w", i18n.T("i18n.fail.get", "working directory"), err)
|
||||
}
|
||||
|
||||
if !IsPHPProject(cwd) {
|
||||
return errors.New(i18n.T("cmd.php.error.not_php"))
|
||||
}
|
||||
|
||||
cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.security")), i18n.ProgressSubject("run", "security checks"))
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
result, err := RunSecurityChecks(ctx, SecurityOptions{
|
||||
Dir: cwd,
|
||||
Severity: securitySeverity,
|
||||
JSON: securityJSONOutput,
|
||||
SARIF: securitySarif,
|
||||
URL: securityURL,
|
||||
Output: os.Stdout,
|
||||
})
|
||||
if err != nil {
|
||||
return cli.Err("%s: %w", i18n.T("cmd.php.error.security_failed"), err)
|
||||
}
|
||||
|
||||
// Print results by category
|
||||
currentCategory := ""
|
||||
for _, check := range result.Checks {
|
||||
category := strings.Split(check.ID, "_")[0]
|
||||
if category != currentCategory {
|
||||
if currentCategory != "" {
|
||||
cli.Blank()
|
||||
}
|
||||
currentCategory = category
|
||||
cli.Print(" %s\n", dimStyle.Render(strings.ToUpper(category)+i18n.T("cmd.php.security.checks_suffix")))
|
||||
}
|
||||
|
||||
icon := successStyle.Render("✓")
|
||||
if !check.Passed {
|
||||
icon = getSeverityStyle(check.Severity).Render("✗")
|
||||
}
|
||||
|
||||
cli.Print(" %s %s\n", icon, check.Name)
|
||||
if !check.Passed && check.Message != "" {
|
||||
cli.Print(" %s\n", dimStyle.Render(check.Message))
|
||||
if check.Fix != "" {
|
||||
cli.Print(" %s %s\n", dimStyle.Render(i18n.Label("fix")), check.Fix)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cli.Blank()
|
||||
|
||||
// Print summary
|
||||
cli.Print("%s %s\n", dimStyle.Render(i18n.Label("summary")), i18n.T("cmd.php.security.summary"))
|
||||
cli.Print(" %s %d/%d\n", dimStyle.Render(i18n.T("cmd.php.security.passed")), result.Summary.Passed, result.Summary.Total)
|
||||
|
||||
if result.Summary.Critical > 0 {
|
||||
cli.Print(" %s %d\n", phpSecurityCriticalStyle.Render(i18n.T("cmd.php.security.critical")), result.Summary.Critical)
|
||||
}
|
||||
if result.Summary.High > 0 {
|
||||
cli.Print(" %s %d\n", phpSecurityHighStyle.Render(i18n.T("cmd.php.security.high")), result.Summary.High)
|
||||
}
|
||||
if result.Summary.Medium > 0 {
|
||||
cli.Print(" %s %d\n", phpSecurityMediumStyle.Render(i18n.T("cmd.php.security.medium")), result.Summary.Medium)
|
||||
}
|
||||
if result.Summary.Low > 0 {
|
||||
cli.Print(" %s %d\n", phpSecurityLowStyle.Render(i18n.T("cmd.php.security.low")), result.Summary.Low)
|
||||
}
|
||||
|
||||
if result.Summary.Critical > 0 || result.Summary.High > 0 {
|
||||
return errors.New(i18n.T("cmd.php.error.critical_high_issues"))
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
securityCmd.Flags().StringVar(&securitySeverity, "severity", "", i18n.T("cmd.php.security.flag.severity"))
|
||||
securityCmd.Flags().BoolVar(&securityJSONOutput, "json", false, i18n.T("common.flag.json"))
|
||||
securityCmd.Flags().BoolVar(&securitySarif, "sarif", false, i18n.T("cmd.php.security.flag.sarif"))
|
||||
securityCmd.Flags().StringVar(&securityURL, "url", "", i18n.T("cmd.php.security.flag.url"))
|
||||
|
||||
parent.AddCommand(securityCmd)
|
||||
}
|
||||
|
||||
var (
|
||||
qaQuick bool
|
||||
qaFull bool
|
||||
qaFix bool
|
||||
qaJSON bool
|
||||
)
|
||||
|
||||
func addPHPQACommand(parent *cobra.Command) {
|
||||
qaCmd := &cobra.Command{
|
||||
Use: "qa",
|
||||
Short: i18n.T("cmd.php.qa.short"),
|
||||
Long: i18n.T("cmd.php.qa.long"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return cli.Err("%s: %w", i18n.T("i18n.fail.get", "working directory"), err)
|
||||
}
|
||||
|
||||
if !IsPHPProject(cwd) {
|
||||
return errors.New(i18n.T("cmd.php.error.not_php"))
|
||||
}
|
||||
|
||||
// Determine stages
|
||||
opts := QAOptions{
|
||||
Dir: cwd,
|
||||
Quick: qaQuick,
|
||||
Full: qaFull,
|
||||
Fix: qaFix,
|
||||
JSON: qaJSON,
|
||||
}
|
||||
stages := GetQAStages(opts)
|
||||
|
||||
// Print header
|
||||
if !qaJSON {
|
||||
cli.Print("%s %s\n\n", dimStyle.Render(i18n.Label("qa")), i18n.ProgressSubject("run", "QA pipeline"))
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create QA runner using pkg/process
|
||||
runner, err := NewQARunner(cwd, qaFix)
|
||||
if err != nil {
|
||||
return cli.Err("%s: %w", i18n.T("i18n.fail.create", "QA runner"), err)
|
||||
}
|
||||
|
||||
// Run all checks with dependency ordering
|
||||
result, err := runner.Run(ctx, stages)
|
||||
if err != nil {
|
||||
return cli.Err("%s: %w", i18n.T("i18n.fail.run", "QA checks"), err)
|
||||
}
|
||||
|
||||
// Display results by stage (skip when JSON output is enabled)
|
||||
if !qaJSON {
|
||||
currentStage := ""
|
||||
for _, checkResult := range result.Results {
|
||||
// Determine stage for this check
|
||||
stage := getCheckStage(checkResult.Name, stages, cwd)
|
||||
if stage != currentStage {
|
||||
if currentStage != "" {
|
||||
cli.Blank()
|
||||
}
|
||||
currentStage = stage
|
||||
cli.Print("%s\n", phpQAStageStyle.Render("── "+strings.ToUpper(stage)+" ──"))
|
||||
}
|
||||
|
||||
icon := phpQAPassedStyle.Render("✓")
|
||||
status := phpQAPassedStyle.Render(i18n.T("i18n.done.pass"))
|
||||
if checkResult.Skipped {
|
||||
icon = dimStyle.Render("-")
|
||||
status = dimStyle.Render(i18n.T("i18n.done.skip"))
|
||||
} else if !checkResult.Passed {
|
||||
icon = phpQAFailedStyle.Render("✗")
|
||||
status = phpQAFailedStyle.Render(i18n.T("i18n.done.fail"))
|
||||
}
|
||||
|
||||
cli.Print(" %s %s %s %s\n", icon, checkResult.Name, status, dimStyle.Render(checkResult.Duration))
|
||||
}
|
||||
cli.Blank()
|
||||
|
||||
// Print summary
|
||||
if result.Passed {
|
||||
cli.Print("%s %s\n", phpQAPassedStyle.Render("QA PASSED:"), i18n.T("i18n.count.check", result.PassedCount)+" "+i18n.T("i18n.done.pass"))
|
||||
cli.Print("%s %s\n", dimStyle.Render(i18n.T("i18n.label.duration")), result.Duration)
|
||||
return nil
|
||||
}
|
||||
|
||||
cli.Print("%s %s\n\n", phpQAFailedStyle.Render("QA FAILED:"), i18n.T("i18n.count.check", result.PassedCount)+"/"+cli.Sprint(len(result.Results))+" "+i18n.T("i18n.done.pass"))
|
||||
|
||||
// Show what needs fixing
|
||||
cli.Print("%s\n", dimStyle.Render(i18n.T("i18n.label.fix")))
|
||||
for _, checkResult := range result.Results {
|
||||
if checkResult.Passed || checkResult.Skipped {
|
||||
continue
|
||||
}
|
||||
fixCmd := getQAFixCommand(checkResult.Name, qaFix)
|
||||
issue := checkResult.GetIssueMessage()
|
||||
if issue == "" {
|
||||
issue = "issues found"
|
||||
}
|
||||
cli.Print(" %s %s\n", phpQAFailedStyle.Render("*"), checkResult.Name+": "+issue)
|
||||
if fixCmd != "" {
|
||||
cli.Print(" %s %s\n", dimStyle.Render("->"), fixCmd)
|
||||
}
|
||||
}
|
||||
|
||||
return cli.Err("%s", i18n.T("i18n.fail.run", "QA pipeline"))
|
||||
}
|
||||
|
||||
// JSON mode: output results as JSON
|
||||
output, err := json.MarshalIndent(result, "", " ")
|
||||
if err != nil {
|
||||
return cli.Wrap(err, "marshal JSON output")
|
||||
}
|
||||
cli.Text(string(output))
|
||||
|
||||
if !result.Passed {
|
||||
return cli.Err("%s", i18n.T("i18n.fail.run", "QA pipeline"))
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
qaCmd.Flags().BoolVar(&qaQuick, "quick", false, i18n.T("cmd.php.qa.flag.quick"))
|
||||
qaCmd.Flags().BoolVar(&qaFull, "full", false, i18n.T("cmd.php.qa.flag.full"))
|
||||
qaCmd.Flags().BoolVar(&qaFix, "fix", false, i18n.T("common.flag.fix"))
|
||||
qaCmd.Flags().BoolVar(&qaJSON, "json", false, i18n.T("common.flag.json"))
|
||||
|
||||
parent.AddCommand(qaCmd)
|
||||
}
|
||||
|
||||
// getCheckStage determines which stage a check belongs to.
|
||||
func getCheckStage(checkName string, stages []QAStage, dir string) string {
|
||||
for _, stage := range stages {
|
||||
checks := GetQAChecks(dir, stage)
|
||||
for _, c := range checks {
|
||||
if c == checkName {
|
||||
return string(stage)
|
||||
}
|
||||
}
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
func getQAFixCommand(checkName string, fixEnabled bool) string {
|
||||
switch checkName {
|
||||
case "audit":
|
||||
return i18n.T("i18n.progress.update", "dependencies")
|
||||
case "fmt":
|
||||
if fixEnabled {
|
||||
return ""
|
||||
}
|
||||
return "core php fmt --fix"
|
||||
case "stan":
|
||||
return i18n.T("i18n.progress.fix", "PHPStan errors")
|
||||
case "psalm":
|
||||
return i18n.T("i18n.progress.fix", "Psalm errors")
|
||||
case "test":
|
||||
return i18n.T("i18n.progress.fix", i18n.T("i18n.done.fail")+" tests")
|
||||
case "rector":
|
||||
if fixEnabled {
|
||||
return ""
|
||||
}
|
||||
return "core php rector --fix"
|
||||
case "infection":
|
||||
return i18n.T("i18n.progress.improve", "test coverage")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
var (
|
||||
rectorFix bool
|
||||
rectorDiff bool
|
||||
rectorClearCache bool
|
||||
)
|
||||
|
||||
func addPHPRectorCommand(parent *cobra.Command) {
|
||||
rectorCmd := &cobra.Command{
|
||||
Use: "rector",
|
||||
Short: i18n.T("cmd.php.rector.short"),
|
||||
Long: i18n.T("cmd.php.rector.long"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return cli.Err("%s: %w", i18n.T("i18n.fail.get", "working directory"), err)
|
||||
}
|
||||
|
||||
if !IsPHPProject(cwd) {
|
||||
return errors.New(i18n.T("cmd.php.error.not_php"))
|
||||
}
|
||||
|
||||
// Check if Rector is available
|
||||
if !DetectRector(cwd) {
|
||||
cli.Print("%s %s\n\n", errorStyle.Render(i18n.Label("error")), i18n.T("cmd.php.rector.not_found"))
|
||||
cli.Print("%s %s\n", dimStyle.Render(i18n.Label("install")), i18n.T("cmd.php.rector.install"))
|
||||
cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.label.setup")), i18n.T("cmd.php.rector.setup"))
|
||||
return errors.New(i18n.T("cmd.php.error.rector_not_installed"))
|
||||
}
|
||||
|
||||
var msg string
|
||||
if rectorFix {
|
||||
msg = i18n.T("cmd.php.rector.refactoring")
|
||||
} else {
|
||||
msg = i18n.T("cmd.php.rector.analysing")
|
||||
}
|
||||
cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.rector")), msg)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
opts := RectorOptions{
|
||||
Dir: cwd,
|
||||
Fix: rectorFix,
|
||||
Diff: rectorDiff,
|
||||
ClearCache: rectorClearCache,
|
||||
Output: os.Stdout,
|
||||
}
|
||||
|
||||
if err := RunRector(ctx, opts); err != nil {
|
||||
if rectorFix {
|
||||
return cli.Err("%s: %w", i18n.T("cmd.php.error.rector_failed"), err)
|
||||
}
|
||||
// Dry-run returns non-zero if changes would be made
|
||||
cli.Print("\n%s %s\n", phpQAWarningStyle.Render(i18n.T("cmd.php.label.info")), i18n.T("cmd.php.rector.changes_suggested"))
|
||||
return nil
|
||||
}
|
||||
|
||||
if rectorFix {
|
||||
cli.Print("\n%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("common.success.completed", map[string]any{"Action": "Code refactored"}))
|
||||
} else {
|
||||
cli.Print("\n%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("cmd.php.rector.no_changes"))
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
rectorCmd.Flags().BoolVar(&rectorFix, "fix", false, i18n.T("cmd.php.rector.flag.fix"))
|
||||
rectorCmd.Flags().BoolVar(&rectorDiff, "diff", false, i18n.T("cmd.php.rector.flag.diff"))
|
||||
rectorCmd.Flags().BoolVar(&rectorClearCache, "clear-cache", false, i18n.T("cmd.php.rector.flag.clear_cache"))
|
||||
|
||||
parent.AddCommand(rectorCmd)
|
||||
}
|
||||
|
||||
var (
|
||||
infectionMinMSI int
|
||||
infectionMinCoveredMSI int
|
||||
infectionThreads int
|
||||
infectionFilter string
|
||||
infectionOnlyCovered bool
|
||||
)
|
||||
|
||||
func addPHPInfectionCommand(parent *cobra.Command) {
|
||||
infectionCmd := &cobra.Command{
|
||||
Use: "infection",
|
||||
Short: i18n.T("cmd.php.infection.short"),
|
||||
Long: i18n.T("cmd.php.infection.long"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return cli.Err("%s: %w", i18n.T("i18n.fail.get", "working directory"), err)
|
||||
}
|
||||
|
||||
if !IsPHPProject(cwd) {
|
||||
return errors.New(i18n.T("cmd.php.error.not_php"))
|
||||
}
|
||||
|
||||
// Check if Infection is available
|
||||
if !DetectInfection(cwd) {
|
||||
cli.Print("%s %s\n\n", errorStyle.Render(i18n.Label("error")), i18n.T("cmd.php.infection.not_found"))
|
||||
cli.Print("%s %s\n", dimStyle.Render(i18n.Label("install")), i18n.T("cmd.php.infection.install"))
|
||||
return errors.New(i18n.T("cmd.php.error.infection_not_installed"))
|
||||
}
|
||||
|
||||
cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.label.infection")), i18n.ProgressSubject("run", "mutation testing"))
|
||||
cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.info")), i18n.T("cmd.php.infection.note"))
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
opts := InfectionOptions{
|
||||
Dir: cwd,
|
||||
MinMSI: infectionMinMSI,
|
||||
MinCoveredMSI: infectionMinCoveredMSI,
|
||||
Threads: infectionThreads,
|
||||
Filter: infectionFilter,
|
||||
OnlyCovered: infectionOnlyCovered,
|
||||
Output: os.Stdout,
|
||||
}
|
||||
|
||||
if err := RunInfection(ctx, opts); err != nil {
|
||||
return cli.Err("%s: %w", i18n.T("cmd.php.error.infection_failed"), err)
|
||||
}
|
||||
|
||||
cli.Print("\n%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("cmd.php.infection.complete"))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
infectionCmd.Flags().IntVar(&infectionMinMSI, "min-msi", 0, i18n.T("cmd.php.infection.flag.min_msi"))
|
||||
infectionCmd.Flags().IntVar(&infectionMinCoveredMSI, "min-covered-msi", 0, i18n.T("cmd.php.infection.flag.min_covered_msi"))
|
||||
infectionCmd.Flags().IntVar(&infectionThreads, "threads", 0, i18n.T("cmd.php.infection.flag.threads"))
|
||||
infectionCmd.Flags().StringVar(&infectionFilter, "filter", "", i18n.T("cmd.php.infection.flag.filter"))
|
||||
infectionCmd.Flags().BoolVar(&infectionOnlyCovered, "only-covered", false, i18n.T("cmd.php.infection.flag.only_covered"))
|
||||
|
||||
parent.AddCommand(infectionCmd)
|
||||
}
|
||||
|
||||
func getSeverityStyle(severity string) *cli.AnsiStyle {
|
||||
switch strings.ToLower(severity) {
|
||||
case "critical":
|
||||
return phpSecurityCriticalStyle
|
||||
case "high":
|
||||
return phpSecurityHighStyle
|
||||
case "medium":
|
||||
return phpSecurityMediumStyle
|
||||
case "low":
|
||||
return phpSecurityLowStyle
|
||||
default:
|
||||
return dimStyle
|
||||
}
|
||||
}
|
||||
451
container.go
Normal file
451
container.go
Normal file
|
|
@ -0,0 +1,451 @@
|
|||
package php
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
)
|
||||
|
||||
// DockerBuildOptions configures Docker image building for PHP projects.
|
||||
type DockerBuildOptions struct {
|
||||
// ProjectDir is the path to the PHP/Laravel project.
|
||||
ProjectDir string
|
||||
|
||||
// ImageName is the name for the Docker image.
|
||||
ImageName string
|
||||
|
||||
// Tag is the image tag (default: "latest").
|
||||
Tag string
|
||||
|
||||
// Platform specifies the target platform (e.g., "linux/amd64", "linux/arm64").
|
||||
Platform string
|
||||
|
||||
// Dockerfile is the path to a custom Dockerfile.
|
||||
// If empty, one will be auto-generated for FrankenPHP.
|
||||
Dockerfile string
|
||||
|
||||
// NoBuildCache disables Docker build cache.
|
||||
NoBuildCache bool
|
||||
|
||||
// BuildArgs are additional build arguments.
|
||||
BuildArgs map[string]string
|
||||
|
||||
// Output is the writer for build output (default: os.Stdout).
|
||||
Output io.Writer
|
||||
}
|
||||
|
||||
// LinuxKitBuildOptions configures LinuxKit image building for PHP projects.
|
||||
type LinuxKitBuildOptions struct {
|
||||
// ProjectDir is the path to the PHP/Laravel project.
|
||||
ProjectDir string
|
||||
|
||||
// OutputPath is the path for the output image.
|
||||
OutputPath string
|
||||
|
||||
// Format is the output format: "iso", "qcow2", "raw", "vmdk".
|
||||
Format string
|
||||
|
||||
// Template is the LinuxKit template name (default: "server-php").
|
||||
Template string
|
||||
|
||||
// Variables are template variables to apply.
|
||||
Variables map[string]string
|
||||
|
||||
// Output is the writer for build output (default: os.Stdout).
|
||||
Output io.Writer
|
||||
}
|
||||
|
||||
// ServeOptions configures running a production PHP container.
|
||||
type ServeOptions struct {
|
||||
// ImageName is the Docker image to run.
|
||||
ImageName string
|
||||
|
||||
// Tag is the image tag (default: "latest").
|
||||
Tag string
|
||||
|
||||
// ContainerName is the name for the container.
|
||||
ContainerName string
|
||||
|
||||
// Port is the host port to bind (default: 80).
|
||||
Port int
|
||||
|
||||
// HTTPSPort is the host HTTPS port to bind (default: 443).
|
||||
HTTPSPort int
|
||||
|
||||
// Detach runs the container in detached mode.
|
||||
Detach bool
|
||||
|
||||
// EnvFile is the path to an environment file.
|
||||
EnvFile string
|
||||
|
||||
// Volumes maps host paths to container paths.
|
||||
Volumes map[string]string
|
||||
|
||||
// Output is the writer for output (default: os.Stdout).
|
||||
Output io.Writer
|
||||
}
|
||||
|
||||
// BuildDocker builds a Docker image for the PHP project.
|
||||
func BuildDocker(ctx context.Context, opts DockerBuildOptions) error {
|
||||
if opts.ProjectDir == "" {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return cli.WrapVerb(err, "get", "working directory")
|
||||
}
|
||||
opts.ProjectDir = cwd
|
||||
}
|
||||
|
||||
// Validate project directory
|
||||
if !IsPHPProject(opts.ProjectDir) {
|
||||
return cli.Err("not a PHP project: %s (missing composer.json)", opts.ProjectDir)
|
||||
}
|
||||
|
||||
// Set defaults
|
||||
if opts.ImageName == "" {
|
||||
opts.ImageName = filepath.Base(opts.ProjectDir)
|
||||
}
|
||||
if opts.Tag == "" {
|
||||
opts.Tag = "latest"
|
||||
}
|
||||
if opts.Output == nil {
|
||||
opts.Output = os.Stdout
|
||||
}
|
||||
|
||||
// Determine Dockerfile path
|
||||
dockerfilePath := opts.Dockerfile
|
||||
var tempDockerfile string
|
||||
|
||||
if dockerfilePath == "" {
|
||||
// Generate Dockerfile
|
||||
content, err := GenerateDockerfile(opts.ProjectDir)
|
||||
if err != nil {
|
||||
return cli.WrapVerb(err, "generate", "Dockerfile")
|
||||
}
|
||||
|
||||
// Write to temporary file
|
||||
m := getMedium()
|
||||
tempDockerfile = filepath.Join(opts.ProjectDir, "Dockerfile.core-generated")
|
||||
if err := m.Write(tempDockerfile, content); err != nil {
|
||||
return cli.WrapVerb(err, "write", "Dockerfile")
|
||||
}
|
||||
defer func() { _ = m.Delete(tempDockerfile) }()
|
||||
|
||||
dockerfilePath = tempDockerfile
|
||||
}
|
||||
|
||||
// Build Docker image
|
||||
imageRef := cli.Sprintf("%s:%s", opts.ImageName, opts.Tag)
|
||||
|
||||
args := []string{"build", "-t", imageRef, "-f", dockerfilePath}
|
||||
|
||||
if opts.Platform != "" {
|
||||
args = append(args, "--platform", opts.Platform)
|
||||
}
|
||||
|
||||
if opts.NoBuildCache {
|
||||
args = append(args, "--no-cache")
|
||||
}
|
||||
|
||||
for key, value := range opts.BuildArgs {
|
||||
args = append(args, "--build-arg", cli.Sprintf("%s=%s", key, value))
|
||||
}
|
||||
|
||||
args = append(args, opts.ProjectDir)
|
||||
|
||||
cmd := exec.CommandContext(ctx, "docker", args...)
|
||||
cmd.Dir = opts.ProjectDir
|
||||
cmd.Stdout = opts.Output
|
||||
cmd.Stderr = opts.Output
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
return cli.Wrap(err, "docker build failed")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// BuildLinuxKit builds a LinuxKit image for the PHP project.
|
||||
func BuildLinuxKit(ctx context.Context, opts LinuxKitBuildOptions) error {
|
||||
if opts.ProjectDir == "" {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return cli.WrapVerb(err, "get", "working directory")
|
||||
}
|
||||
opts.ProjectDir = cwd
|
||||
}
|
||||
|
||||
// Validate project directory
|
||||
if !IsPHPProject(opts.ProjectDir) {
|
||||
return cli.Err("not a PHP project: %s (missing composer.json)", opts.ProjectDir)
|
||||
}
|
||||
|
||||
// Set defaults
|
||||
if opts.Template == "" {
|
||||
opts.Template = "server-php"
|
||||
}
|
||||
if opts.Format == "" {
|
||||
opts.Format = "qcow2"
|
||||
}
|
||||
if opts.OutputPath == "" {
|
||||
opts.OutputPath = filepath.Join(opts.ProjectDir, "dist", filepath.Base(opts.ProjectDir))
|
||||
}
|
||||
if opts.Output == nil {
|
||||
opts.Output = os.Stdout
|
||||
}
|
||||
|
||||
// Ensure output directory exists
|
||||
m := getMedium()
|
||||
outputDir := filepath.Dir(opts.OutputPath)
|
||||
if err := m.EnsureDir(outputDir); err != nil {
|
||||
return cli.WrapVerb(err, "create", "output directory")
|
||||
}
|
||||
|
||||
// Find linuxkit binary
|
||||
linuxkitPath, err := lookupLinuxKit()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get template content
|
||||
templateContent, err := getLinuxKitTemplate(opts.Template)
|
||||
if err != nil {
|
||||
return cli.WrapVerb(err, "get", "template")
|
||||
}
|
||||
|
||||
// Apply variables
|
||||
if opts.Variables == nil {
|
||||
opts.Variables = make(map[string]string)
|
||||
}
|
||||
// Add project-specific variables
|
||||
opts.Variables["PROJECT_DIR"] = opts.ProjectDir
|
||||
opts.Variables["PROJECT_NAME"] = filepath.Base(opts.ProjectDir)
|
||||
|
||||
content, err := applyTemplateVariables(templateContent, opts.Variables)
|
||||
if err != nil {
|
||||
return cli.WrapVerb(err, "apply", "template variables")
|
||||
}
|
||||
|
||||
// Write template to temp file
|
||||
tempYAML := filepath.Join(opts.ProjectDir, ".core-linuxkit.yml")
|
||||
if err := m.Write(tempYAML, content); err != nil {
|
||||
return cli.WrapVerb(err, "write", "template")
|
||||
}
|
||||
defer func() { _ = m.Delete(tempYAML) }()
|
||||
|
||||
// Build LinuxKit image
|
||||
args := []string{
|
||||
"build",
|
||||
"--format", opts.Format,
|
||||
"--name", opts.OutputPath,
|
||||
tempYAML,
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(ctx, linuxkitPath, args...)
|
||||
cmd.Dir = opts.ProjectDir
|
||||
cmd.Stdout = opts.Output
|
||||
cmd.Stderr = opts.Output
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
return cli.Wrap(err, "linuxkit build failed")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ServeProduction runs a production PHP container.
|
||||
func ServeProduction(ctx context.Context, opts ServeOptions) error {
|
||||
if opts.ImageName == "" {
|
||||
return cli.Err("image name is required")
|
||||
}
|
||||
|
||||
// Set defaults
|
||||
if opts.Tag == "" {
|
||||
opts.Tag = "latest"
|
||||
}
|
||||
if opts.Port == 0 {
|
||||
opts.Port = 80
|
||||
}
|
||||
if opts.HTTPSPort == 0 {
|
||||
opts.HTTPSPort = 443
|
||||
}
|
||||
if opts.Output == nil {
|
||||
opts.Output = os.Stdout
|
||||
}
|
||||
|
||||
imageRef := cli.Sprintf("%s:%s", opts.ImageName, opts.Tag)
|
||||
|
||||
args := []string{"run"}
|
||||
|
||||
if opts.Detach {
|
||||
args = append(args, "-d")
|
||||
} else {
|
||||
args = append(args, "--rm")
|
||||
}
|
||||
|
||||
if opts.ContainerName != "" {
|
||||
args = append(args, "--name", opts.ContainerName)
|
||||
}
|
||||
|
||||
// Port mappings
|
||||
args = append(args, "-p", cli.Sprintf("%d:80", opts.Port))
|
||||
args = append(args, "-p", cli.Sprintf("%d:443", opts.HTTPSPort))
|
||||
|
||||
// Environment file
|
||||
if opts.EnvFile != "" {
|
||||
args = append(args, "--env-file", opts.EnvFile)
|
||||
}
|
||||
|
||||
// Volume mounts
|
||||
for hostPath, containerPath := range opts.Volumes {
|
||||
args = append(args, "-v", cli.Sprintf("%s:%s", hostPath, containerPath))
|
||||
}
|
||||
|
||||
args = append(args, imageRef)
|
||||
|
||||
cmd := exec.CommandContext(ctx, "docker", args...)
|
||||
cmd.Stdout = opts.Output
|
||||
cmd.Stderr = opts.Output
|
||||
|
||||
if opts.Detach {
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return cli.WrapVerb(err, "start", "container")
|
||||
}
|
||||
containerID := strings.TrimSpace(string(output))
|
||||
cli.Print("Container started: %s\n", containerID[:12])
|
||||
return nil
|
||||
}
|
||||
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
// Shell opens a shell in a running container.
|
||||
func Shell(ctx context.Context, containerID string) error {
|
||||
if containerID == "" {
|
||||
return cli.Err("container ID is required")
|
||||
}
|
||||
|
||||
// Resolve partial container ID
|
||||
fullID, err := resolveDockerContainerID(ctx, containerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(ctx, "docker", "exec", "-it", fullID, "/bin/sh")
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
// IsPHPProject checks if the given directory is a PHP project.
|
||||
func IsPHPProject(dir string) bool {
|
||||
composerPath := filepath.Join(dir, "composer.json")
|
||||
return getMedium().IsFile(composerPath)
|
||||
}
|
||||
|
||||
// commonLinuxKitPaths defines default search locations for linuxkit.
|
||||
var commonLinuxKitPaths = []string{
|
||||
"/usr/local/bin/linuxkit",
|
||||
"/opt/homebrew/bin/linuxkit",
|
||||
}
|
||||
|
||||
// lookupLinuxKit finds the linuxkit binary.
|
||||
func lookupLinuxKit() (string, error) {
|
||||
// Check PATH first
|
||||
if path, err := exec.LookPath("linuxkit"); err == nil {
|
||||
return path, nil
|
||||
}
|
||||
|
||||
m := getMedium()
|
||||
for _, p := range commonLinuxKitPaths {
|
||||
if m.IsFile(p) {
|
||||
return p, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", cli.Err("linuxkit not found. Install with: brew install linuxkit (macOS) or see https://github.com/linuxkit/linuxkit")
|
||||
}
|
||||
|
||||
// getLinuxKitTemplate retrieves a LinuxKit template by name.
|
||||
func getLinuxKitTemplate(name string) (string, error) {
|
||||
// Default server-php template for PHP projects
|
||||
if name == "server-php" {
|
||||
return defaultServerPHPTemplate, nil
|
||||
}
|
||||
|
||||
// Try to load from container package templates
|
||||
// This would integrate with forge.lthn.ai/core/go/pkg/container
|
||||
return "", cli.Err("template not found: %s", name)
|
||||
}
|
||||
|
||||
// applyTemplateVariables applies variable substitution to template content.
|
||||
func applyTemplateVariables(content string, vars map[string]string) (string, error) {
|
||||
result := content
|
||||
for key, value := range vars {
|
||||
placeholder := "${" + key + "}"
|
||||
result = strings.ReplaceAll(result, placeholder, value)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// resolveDockerContainerID resolves a partial container ID to a full ID.
|
||||
func resolveDockerContainerID(ctx context.Context, partialID string) (string, error) {
|
||||
cmd := exec.CommandContext(ctx, "docker", "ps", "-a", "--no-trunc", "--format", "{{.ID}}")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "", cli.WrapVerb(err, "list", "containers")
|
||||
}
|
||||
|
||||
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
|
||||
var matches []string
|
||||
|
||||
for _, line := range lines {
|
||||
if strings.HasPrefix(line, partialID) {
|
||||
matches = append(matches, line)
|
||||
}
|
||||
}
|
||||
|
||||
switch len(matches) {
|
||||
case 0:
|
||||
return "", cli.Err("no container found matching: %s", partialID)
|
||||
case 1:
|
||||
return matches[0], nil
|
||||
default:
|
||||
return "", cli.Err("multiple containers match '%s', be more specific", partialID)
|
||||
}
|
||||
}
|
||||
|
||||
// defaultServerPHPTemplate is the default LinuxKit template for PHP servers.
|
||||
const defaultServerPHPTemplate = `# LinuxKit configuration for PHP/FrankenPHP server
|
||||
kernel:
|
||||
image: linuxkit/kernel:6.6.13
|
||||
cmdline: "console=tty0 console=ttyS0"
|
||||
init:
|
||||
- linuxkit/init:v1.0.1
|
||||
- linuxkit/runc:v1.0.1
|
||||
- linuxkit/containerd:v1.0.1
|
||||
onboot:
|
||||
- name: sysctl
|
||||
image: linuxkit/sysctl:v1.0.1
|
||||
- name: dhcpcd
|
||||
image: linuxkit/dhcpcd:v1.0.1
|
||||
command: ["/sbin/dhcpcd", "--nobackground", "-f", "/dhcpcd.conf"]
|
||||
services:
|
||||
- name: getty
|
||||
image: linuxkit/getty:v1.0.1
|
||||
env:
|
||||
- INSECURE=true
|
||||
- name: sshd
|
||||
image: linuxkit/sshd:v1.0.1
|
||||
files:
|
||||
- path: etc/ssh/authorized_keys
|
||||
contents: |
|
||||
${SSH_KEY:-}
|
||||
`
|
||||
383
container_test.go
Normal file
383
container_test.go
Normal file
|
|
@ -0,0 +1,383 @@
|
|||
package php
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestDockerBuildOptions_Good(t *testing.T) {
|
||||
t.Run("all fields accessible", func(t *testing.T) {
|
||||
opts := DockerBuildOptions{
|
||||
ProjectDir: "/project",
|
||||
ImageName: "myapp",
|
||||
Tag: "v1.0.0",
|
||||
Platform: "linux/amd64",
|
||||
Dockerfile: "/path/to/Dockerfile",
|
||||
NoBuildCache: true,
|
||||
BuildArgs: map[string]string{"ARG1": "value1"},
|
||||
Output: os.Stdout,
|
||||
}
|
||||
|
||||
assert.Equal(t, "/project", opts.ProjectDir)
|
||||
assert.Equal(t, "myapp", opts.ImageName)
|
||||
assert.Equal(t, "v1.0.0", opts.Tag)
|
||||
assert.Equal(t, "linux/amd64", opts.Platform)
|
||||
assert.Equal(t, "/path/to/Dockerfile", opts.Dockerfile)
|
||||
assert.True(t, opts.NoBuildCache)
|
||||
assert.Equal(t, "value1", opts.BuildArgs["ARG1"])
|
||||
assert.NotNil(t, opts.Output)
|
||||
})
|
||||
}
|
||||
|
||||
func TestLinuxKitBuildOptions_Good(t *testing.T) {
|
||||
t.Run("all fields accessible", func(t *testing.T) {
|
||||
opts := LinuxKitBuildOptions{
|
||||
ProjectDir: "/project",
|
||||
OutputPath: "/output/image.qcow2",
|
||||
Format: "qcow2",
|
||||
Template: "server-php",
|
||||
Variables: map[string]string{"VAR1": "value1"},
|
||||
Output: os.Stdout,
|
||||
}
|
||||
|
||||
assert.Equal(t, "/project", opts.ProjectDir)
|
||||
assert.Equal(t, "/output/image.qcow2", opts.OutputPath)
|
||||
assert.Equal(t, "qcow2", opts.Format)
|
||||
assert.Equal(t, "server-php", opts.Template)
|
||||
assert.Equal(t, "value1", opts.Variables["VAR1"])
|
||||
assert.NotNil(t, opts.Output)
|
||||
})
|
||||
}
|
||||
|
||||
func TestServeOptions_Good(t *testing.T) {
|
||||
t.Run("all fields accessible", func(t *testing.T) {
|
||||
opts := ServeOptions{
|
||||
ImageName: "myapp",
|
||||
Tag: "latest",
|
||||
ContainerName: "myapp-container",
|
||||
Port: 8080,
|
||||
HTTPSPort: 8443,
|
||||
Detach: true,
|
||||
EnvFile: "/path/to/.env",
|
||||
Volumes: map[string]string{"/host": "/container"},
|
||||
Output: os.Stdout,
|
||||
}
|
||||
|
||||
assert.Equal(t, "myapp", opts.ImageName)
|
||||
assert.Equal(t, "latest", opts.Tag)
|
||||
assert.Equal(t, "myapp-container", opts.ContainerName)
|
||||
assert.Equal(t, 8080, opts.Port)
|
||||
assert.Equal(t, 8443, opts.HTTPSPort)
|
||||
assert.True(t, opts.Detach)
|
||||
assert.Equal(t, "/path/to/.env", opts.EnvFile)
|
||||
assert.Equal(t, "/container", opts.Volumes["/host"])
|
||||
assert.NotNil(t, opts.Output)
|
||||
})
|
||||
}
|
||||
|
||||
func TestIsPHPProject_Container_Good(t *testing.T) {
|
||||
t.Run("returns true with composer.json", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(`{}`), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.True(t, IsPHPProject(dir))
|
||||
})
|
||||
}
|
||||
|
||||
func TestIsPHPProject_Container_Bad(t *testing.T) {
|
||||
t.Run("returns false without composer.json", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
assert.False(t, IsPHPProject(dir))
|
||||
})
|
||||
|
||||
t.Run("returns false for non-existent directory", func(t *testing.T) {
|
||||
assert.False(t, IsPHPProject("/non/existent/path"))
|
||||
})
|
||||
}
|
||||
|
||||
func TestLookupLinuxKit_Bad(t *testing.T) {
|
||||
t.Run("returns error when linuxkit not found", func(t *testing.T) {
|
||||
// Save original PATH and paths
|
||||
origPath := os.Getenv("PATH")
|
||||
origCommonPaths := commonLinuxKitPaths
|
||||
defer func() {
|
||||
_ = os.Setenv("PATH", origPath)
|
||||
commonLinuxKitPaths = origCommonPaths
|
||||
}()
|
||||
|
||||
// Set PATH to empty and clear common paths
|
||||
_ = os.Setenv("PATH", "")
|
||||
commonLinuxKitPaths = []string{}
|
||||
|
||||
_, err := lookupLinuxKit()
|
||||
if assert.Error(t, err) {
|
||||
assert.Contains(t, err.Error(), "linuxkit not found")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetLinuxKitTemplate_Good(t *testing.T) {
|
||||
t.Run("returns server-php template", func(t *testing.T) {
|
||||
content, err := getLinuxKitTemplate("server-php")
|
||||
assert.NoError(t, err)
|
||||
assert.Contains(t, content, "kernel:")
|
||||
assert.Contains(t, content, "linuxkit/kernel")
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetLinuxKitTemplate_Bad(t *testing.T) {
|
||||
t.Run("returns error for unknown template", func(t *testing.T) {
|
||||
_, err := getLinuxKitTemplate("unknown-template")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "template not found")
|
||||
})
|
||||
}
|
||||
|
||||
func TestApplyTemplateVariables_Good(t *testing.T) {
|
||||
t.Run("replaces variables", func(t *testing.T) {
|
||||
content := "Hello ${NAME}, welcome to ${PLACE}!"
|
||||
vars := map[string]string{
|
||||
"NAME": "World",
|
||||
"PLACE": "Earth",
|
||||
}
|
||||
|
||||
result, err := applyTemplateVariables(content, vars)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "Hello World, welcome to Earth!", result)
|
||||
})
|
||||
|
||||
t.Run("handles empty variables", func(t *testing.T) {
|
||||
content := "No variables here"
|
||||
vars := map[string]string{}
|
||||
|
||||
result, err := applyTemplateVariables(content, vars)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "No variables here", result)
|
||||
})
|
||||
|
||||
t.Run("leaves unmatched placeholders", func(t *testing.T) {
|
||||
content := "Hello ${NAME}, ${UNKNOWN} is unknown"
|
||||
vars := map[string]string{
|
||||
"NAME": "World",
|
||||
}
|
||||
|
||||
result, err := applyTemplateVariables(content, vars)
|
||||
assert.NoError(t, err)
|
||||
assert.Contains(t, result, "Hello World")
|
||||
assert.Contains(t, result, "${UNKNOWN}")
|
||||
})
|
||||
|
||||
t.Run("handles multiple occurrences", func(t *testing.T) {
|
||||
content := "${VAR} and ${VAR} again"
|
||||
vars := map[string]string{
|
||||
"VAR": "value",
|
||||
}
|
||||
|
||||
result, err := applyTemplateVariables(content, vars)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "value and value again", result)
|
||||
})
|
||||
}
|
||||
|
||||
func TestDefaultServerPHPTemplate_Good(t *testing.T) {
|
||||
t.Run("template has required sections", func(t *testing.T) {
|
||||
assert.Contains(t, defaultServerPHPTemplate, "kernel:")
|
||||
assert.Contains(t, defaultServerPHPTemplate, "init:")
|
||||
assert.Contains(t, defaultServerPHPTemplate, "services:")
|
||||
assert.Contains(t, defaultServerPHPTemplate, "onboot:")
|
||||
})
|
||||
|
||||
t.Run("template contains placeholders", func(t *testing.T) {
|
||||
assert.Contains(t, defaultServerPHPTemplate, "${SSH_KEY:-}")
|
||||
})
|
||||
}
|
||||
|
||||
func TestBuildDocker_Bad(t *testing.T) {
|
||||
t.Skip("requires Docker installed")
|
||||
|
||||
t.Run("fails for non-PHP project", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
err := BuildDocker(context.TODO(), DockerBuildOptions{ProjectDir: dir})
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "not a PHP project")
|
||||
})
|
||||
}
|
||||
|
||||
func TestBuildLinuxKit_Bad(t *testing.T) {
|
||||
t.Skip("requires linuxkit installed")
|
||||
|
||||
t.Run("fails for non-PHP project", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
err := BuildLinuxKit(context.TODO(), LinuxKitBuildOptions{ProjectDir: dir})
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "not a PHP project")
|
||||
})
|
||||
}
|
||||
|
||||
func TestServeProduction_Bad(t *testing.T) {
|
||||
t.Run("fails without image name", func(t *testing.T) {
|
||||
err := ServeProduction(context.TODO(), ServeOptions{})
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "image name is required")
|
||||
})
|
||||
}
|
||||
|
||||
func TestShell_Bad(t *testing.T) {
|
||||
t.Run("fails without container ID", func(t *testing.T) {
|
||||
err := Shell(context.TODO(), "")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "container ID is required")
|
||||
})
|
||||
}
|
||||
|
||||
func TestResolveDockerContainerID_Bad(t *testing.T) {
|
||||
t.Skip("requires Docker installed")
|
||||
}
|
||||
|
||||
func TestBuildDocker_DefaultOptions(t *testing.T) {
|
||||
t.Run("sets defaults correctly", func(t *testing.T) {
|
||||
// This tests the default logic without actually running Docker
|
||||
opts := DockerBuildOptions{}
|
||||
|
||||
// Verify default values would be set in BuildDocker
|
||||
if opts.Tag == "" {
|
||||
opts.Tag = "latest"
|
||||
}
|
||||
assert.Equal(t, "latest", opts.Tag)
|
||||
|
||||
if opts.ImageName == "" {
|
||||
opts.ImageName = filepath.Base("/project/myapp")
|
||||
}
|
||||
assert.Equal(t, "myapp", opts.ImageName)
|
||||
})
|
||||
}
|
||||
|
||||
func TestBuildLinuxKit_DefaultOptions(t *testing.T) {
|
||||
t.Run("sets defaults correctly", func(t *testing.T) {
|
||||
opts := LinuxKitBuildOptions{}
|
||||
|
||||
// Verify default values would be set
|
||||
if opts.Template == "" {
|
||||
opts.Template = "server-php"
|
||||
}
|
||||
assert.Equal(t, "server-php", opts.Template)
|
||||
|
||||
if opts.Format == "" {
|
||||
opts.Format = "qcow2"
|
||||
}
|
||||
assert.Equal(t, "qcow2", opts.Format)
|
||||
})
|
||||
}
|
||||
|
||||
func TestServeProduction_DefaultOptions(t *testing.T) {
|
||||
t.Run("sets defaults correctly", func(t *testing.T) {
|
||||
opts := ServeOptions{ImageName: "myapp"}
|
||||
|
||||
// Verify default values would be set
|
||||
if opts.Tag == "" {
|
||||
opts.Tag = "latest"
|
||||
}
|
||||
assert.Equal(t, "latest", opts.Tag)
|
||||
|
||||
if opts.Port == 0 {
|
||||
opts.Port = 80
|
||||
}
|
||||
assert.Equal(t, 80, opts.Port)
|
||||
|
||||
if opts.HTTPSPort == 0 {
|
||||
opts.HTTPSPort = 443
|
||||
}
|
||||
assert.Equal(t, 443, opts.HTTPSPort)
|
||||
})
|
||||
}
|
||||
|
||||
func TestLookupLinuxKit_Good(t *testing.T) {
|
||||
t.Skip("requires linuxkit installed")
|
||||
|
||||
t.Run("finds linuxkit in PATH", func(t *testing.T) {
|
||||
path, err := lookupLinuxKit()
|
||||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, path)
|
||||
})
|
||||
}
|
||||
|
||||
func TestBuildDocker_WithCustomDockerfile(t *testing.T) {
|
||||
t.Skip("requires Docker installed")
|
||||
|
||||
t.Run("uses custom Dockerfile when provided", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(`{"name":"test"}`), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
dockerfilePath := filepath.Join(dir, "Dockerfile.custom")
|
||||
err = os.WriteFile(dockerfilePath, []byte("FROM alpine"), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
opts := DockerBuildOptions{
|
||||
ProjectDir: dir,
|
||||
Dockerfile: dockerfilePath,
|
||||
}
|
||||
|
||||
// The function would use the custom Dockerfile
|
||||
assert.Equal(t, dockerfilePath, opts.Dockerfile)
|
||||
})
|
||||
}
|
||||
|
||||
func TestBuildDocker_GeneratesDockerfile(t *testing.T) {
|
||||
t.Skip("requires Docker installed")
|
||||
|
||||
t.Run("generates Dockerfile when not provided", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
// Create valid PHP project
|
||||
composerJSON := `{"name":"test","require":{"php":"^8.2","laravel/framework":"^11.0"}}`
|
||||
err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
opts := DockerBuildOptions{
|
||||
ProjectDir: dir,
|
||||
// Dockerfile not specified - should be generated
|
||||
}
|
||||
|
||||
assert.Empty(t, opts.Dockerfile)
|
||||
})
|
||||
}
|
||||
|
||||
func TestServeProduction_BuildsCorrectArgs(t *testing.T) {
|
||||
t.Run("builds correct docker run arguments", func(t *testing.T) {
|
||||
opts := ServeOptions{
|
||||
ImageName: "myapp",
|
||||
Tag: "v1.0.0",
|
||||
ContainerName: "myapp-prod",
|
||||
Port: 8080,
|
||||
HTTPSPort: 8443,
|
||||
Detach: true,
|
||||
EnvFile: "/path/.env",
|
||||
Volumes: map[string]string{
|
||||
"/host/storage": "/app/storage",
|
||||
},
|
||||
}
|
||||
|
||||
// Verify the expected image reference format
|
||||
imageRef := opts.ImageName + ":" + opts.Tag
|
||||
assert.Equal(t, "myapp:v1.0.0", imageRef)
|
||||
|
||||
// Verify port format
|
||||
portMapping := opts.Port
|
||||
assert.Equal(t, 8080, portMapping)
|
||||
})
|
||||
}
|
||||
|
||||
func TestShell_Integration(t *testing.T) {
|
||||
t.Skip("requires Docker with running container")
|
||||
}
|
||||
|
||||
func TestResolveDockerContainerID_Integration(t *testing.T) {
|
||||
t.Skip("requires Docker with running containers")
|
||||
}
|
||||
351
coolify.go
Normal file
351
coolify.go
Normal file
|
|
@ -0,0 +1,351 @@
|
|||
package php
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
)
|
||||
|
||||
// CoolifyClient is an HTTP client for the Coolify API.
|
||||
type CoolifyClient struct {
|
||||
BaseURL string
|
||||
Token string
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
|
||||
// CoolifyConfig holds configuration loaded from environment.
|
||||
type CoolifyConfig struct {
|
||||
URL string
|
||||
Token string
|
||||
AppID string
|
||||
StagingAppID string
|
||||
}
|
||||
|
||||
// CoolifyDeployment represents a deployment from the Coolify API.
|
||||
type CoolifyDeployment struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
CommitSHA string `json:"commit_sha,omitempty"`
|
||||
CommitMsg string `json:"commit_message,omitempty"`
|
||||
Branch string `json:"branch,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
FinishedAt time.Time `json:"finished_at,omitempty"`
|
||||
Log string `json:"log,omitempty"`
|
||||
DeployedURL string `json:"deployed_url,omitempty"`
|
||||
}
|
||||
|
||||
// CoolifyApp represents an application from the Coolify API.
|
||||
type CoolifyApp struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
FQDN string `json:"fqdn,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Repository string `json:"repository,omitempty"`
|
||||
Branch string `json:"branch,omitempty"`
|
||||
Environment string `json:"environment,omitempty"`
|
||||
}
|
||||
|
||||
// NewCoolifyClient creates a new Coolify API client.
|
||||
func NewCoolifyClient(baseURL, token string) *CoolifyClient {
|
||||
// Ensure baseURL doesn't have trailing slash
|
||||
baseURL = strings.TrimSuffix(baseURL, "/")
|
||||
|
||||
return &CoolifyClient{
|
||||
BaseURL: baseURL,
|
||||
Token: token,
|
||||
HTTPClient: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// LoadCoolifyConfig loads Coolify configuration from .env file in the given directory.
|
||||
func LoadCoolifyConfig(dir string) (*CoolifyConfig, error) {
|
||||
envPath := filepath.Join(dir, ".env")
|
||||
return LoadCoolifyConfigFromFile(envPath)
|
||||
}
|
||||
|
||||
// LoadCoolifyConfigFromFile loads Coolify configuration from a specific .env file.
|
||||
func LoadCoolifyConfigFromFile(path string) (*CoolifyConfig, error) {
|
||||
m := getMedium()
|
||||
config := &CoolifyConfig{}
|
||||
|
||||
// First try environment variables
|
||||
config.URL = os.Getenv("COOLIFY_URL")
|
||||
config.Token = os.Getenv("COOLIFY_TOKEN")
|
||||
config.AppID = os.Getenv("COOLIFY_APP_ID")
|
||||
config.StagingAppID = os.Getenv("COOLIFY_STAGING_APP_ID")
|
||||
|
||||
// Then try .env file
|
||||
if !m.Exists(path) {
|
||||
// No .env file, just use env vars
|
||||
return validateCoolifyConfig(config)
|
||||
}
|
||||
|
||||
content, err := m.Read(path)
|
||||
if err != nil {
|
||||
return nil, cli.WrapVerb(err, "read", ".env file")
|
||||
}
|
||||
|
||||
// Parse .env file
|
||||
lines := strings.Split(content, "\n")
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
|
||||
parts := strings.SplitN(line, "=", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
key := strings.TrimSpace(parts[0])
|
||||
value := strings.TrimSpace(parts[1])
|
||||
// Remove quotes if present
|
||||
value = strings.Trim(value, `"'`)
|
||||
|
||||
// Only override if not already set from env
|
||||
switch key {
|
||||
case "COOLIFY_URL":
|
||||
if config.URL == "" {
|
||||
config.URL = value
|
||||
}
|
||||
case "COOLIFY_TOKEN":
|
||||
if config.Token == "" {
|
||||
config.Token = value
|
||||
}
|
||||
case "COOLIFY_APP_ID":
|
||||
if config.AppID == "" {
|
||||
config.AppID = value
|
||||
}
|
||||
case "COOLIFY_STAGING_APP_ID":
|
||||
if config.StagingAppID == "" {
|
||||
config.StagingAppID = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return validateCoolifyConfig(config)
|
||||
}
|
||||
|
||||
// validateCoolifyConfig checks that required fields are set.
|
||||
func validateCoolifyConfig(config *CoolifyConfig) (*CoolifyConfig, error) {
|
||||
if config.URL == "" {
|
||||
return nil, cli.Err("COOLIFY_URL is not set")
|
||||
}
|
||||
if config.Token == "" {
|
||||
return nil, cli.Err("COOLIFY_TOKEN is not set")
|
||||
}
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// TriggerDeploy triggers a deployment for the specified application.
|
||||
func (c *CoolifyClient) TriggerDeploy(ctx context.Context, appID string, force bool) (*CoolifyDeployment, error) {
|
||||
endpoint := cli.Sprintf("%s/api/v1/applications/%s/deploy", c.BaseURL, appID)
|
||||
|
||||
payload := map[string]interface{}{}
|
||||
if force {
|
||||
payload["force"] = true
|
||||
}
|
||||
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, cli.WrapVerb(err, "marshal", "request")
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, cli.WrapVerb(err, "create", "request")
|
||||
}
|
||||
|
||||
c.setHeaders(req)
|
||||
|
||||
resp, err := c.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, cli.Wrap(err, "request failed")
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusAccepted {
|
||||
return nil, c.parseError(resp)
|
||||
}
|
||||
|
||||
var deployment CoolifyDeployment
|
||||
if err := json.NewDecoder(resp.Body).Decode(&deployment); err != nil {
|
||||
// Some Coolify versions return minimal response
|
||||
return &CoolifyDeployment{
|
||||
Status: "queued",
|
||||
CreatedAt: time.Now(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
return &deployment, nil
|
||||
}
|
||||
|
||||
// GetDeployment retrieves a specific deployment by ID.
|
||||
func (c *CoolifyClient) GetDeployment(ctx context.Context, appID, deploymentID string) (*CoolifyDeployment, error) {
|
||||
endpoint := cli.Sprintf("%s/api/v1/applications/%s/deployments/%s", c.BaseURL, appID, deploymentID)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, cli.WrapVerb(err, "create", "request")
|
||||
}
|
||||
|
||||
c.setHeaders(req)
|
||||
|
||||
resp, err := c.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, cli.Wrap(err, "request failed")
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, c.parseError(resp)
|
||||
}
|
||||
|
||||
var deployment CoolifyDeployment
|
||||
if err := json.NewDecoder(resp.Body).Decode(&deployment); err != nil {
|
||||
return nil, cli.WrapVerb(err, "decode", "response")
|
||||
}
|
||||
|
||||
return &deployment, nil
|
||||
}
|
||||
|
||||
// ListDeployments retrieves deployments for an application.
|
||||
func (c *CoolifyClient) ListDeployments(ctx context.Context, appID string, limit int) ([]CoolifyDeployment, error) {
|
||||
endpoint := cli.Sprintf("%s/api/v1/applications/%s/deployments", c.BaseURL, appID)
|
||||
if limit > 0 {
|
||||
endpoint = cli.Sprintf("%s?limit=%d", endpoint, limit)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, cli.WrapVerb(err, "create", "request")
|
||||
}
|
||||
|
||||
c.setHeaders(req)
|
||||
|
||||
resp, err := c.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, cli.Wrap(err, "request failed")
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, c.parseError(resp)
|
||||
}
|
||||
|
||||
var deployments []CoolifyDeployment
|
||||
if err := json.NewDecoder(resp.Body).Decode(&deployments); err != nil {
|
||||
return nil, cli.WrapVerb(err, "decode", "response")
|
||||
}
|
||||
|
||||
return deployments, nil
|
||||
}
|
||||
|
||||
// Rollback triggers a rollback to a previous deployment.
|
||||
func (c *CoolifyClient) Rollback(ctx context.Context, appID, deploymentID string) (*CoolifyDeployment, error) {
|
||||
endpoint := cli.Sprintf("%s/api/v1/applications/%s/rollback", c.BaseURL, appID)
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"deployment_id": deploymentID,
|
||||
}
|
||||
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, cli.WrapVerb(err, "marshal", "request")
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, cli.WrapVerb(err, "create", "request")
|
||||
}
|
||||
|
||||
c.setHeaders(req)
|
||||
|
||||
resp, err := c.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, cli.Wrap(err, "request failed")
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusAccepted {
|
||||
return nil, c.parseError(resp)
|
||||
}
|
||||
|
||||
var deployment CoolifyDeployment
|
||||
if err := json.NewDecoder(resp.Body).Decode(&deployment); err != nil {
|
||||
return &CoolifyDeployment{
|
||||
Status: "rolling_back",
|
||||
CreatedAt: time.Now(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
return &deployment, nil
|
||||
}
|
||||
|
||||
// GetApp retrieves application details.
|
||||
func (c *CoolifyClient) GetApp(ctx context.Context, appID string) (*CoolifyApp, error) {
|
||||
endpoint := cli.Sprintf("%s/api/v1/applications/%s", c.BaseURL, appID)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, cli.WrapVerb(err, "create", "request")
|
||||
}
|
||||
|
||||
c.setHeaders(req)
|
||||
|
||||
resp, err := c.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, cli.Wrap(err, "request failed")
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, c.parseError(resp)
|
||||
}
|
||||
|
||||
var app CoolifyApp
|
||||
if err := json.NewDecoder(resp.Body).Decode(&app); err != nil {
|
||||
return nil, cli.WrapVerb(err, "decode", "response")
|
||||
}
|
||||
|
||||
return &app, nil
|
||||
}
|
||||
|
||||
// setHeaders sets common headers for API requests.
|
||||
func (c *CoolifyClient) setHeaders(req *http.Request) {
|
||||
req.Header.Set("Authorization", "Bearer "+c.Token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
}
|
||||
|
||||
// parseError extracts error information from an API response.
|
||||
func (c *CoolifyClient) parseError(resp *http.Response) error {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
|
||||
var errResp struct {
|
||||
Message string `json:"message"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &errResp); err == nil {
|
||||
if errResp.Message != "" {
|
||||
return cli.Err("API error (%d): %s", resp.StatusCode, errResp.Message)
|
||||
}
|
||||
if errResp.Error != "" {
|
||||
return cli.Err("API error (%d): %s", resp.StatusCode, errResp.Error)
|
||||
}
|
||||
}
|
||||
|
||||
return cli.Err("API error (%d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
502
coolify_test.go
Normal file
502
coolify_test.go
Normal file
|
|
@ -0,0 +1,502 @@
|
|||
package php
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCoolifyClient_Good(t *testing.T) {
|
||||
t.Run("creates client with correct base URL", func(t *testing.T) {
|
||||
client := NewCoolifyClient("https://coolify.example.com", "token")
|
||||
|
||||
assert.Equal(t, "https://coolify.example.com", client.BaseURL)
|
||||
assert.Equal(t, "token", client.Token)
|
||||
assert.NotNil(t, client.HTTPClient)
|
||||
})
|
||||
|
||||
t.Run("strips trailing slash from base URL", func(t *testing.T) {
|
||||
client := NewCoolifyClient("https://coolify.example.com/", "token")
|
||||
assert.Equal(t, "https://coolify.example.com", client.BaseURL)
|
||||
})
|
||||
|
||||
t.Run("http client has timeout", func(t *testing.T) {
|
||||
client := NewCoolifyClient("https://coolify.example.com", "token")
|
||||
assert.Equal(t, 30*time.Second, client.HTTPClient.Timeout)
|
||||
})
|
||||
}
|
||||
|
||||
func TestCoolifyConfig_Good(t *testing.T) {
|
||||
t.Run("all fields accessible", func(t *testing.T) {
|
||||
config := CoolifyConfig{
|
||||
URL: "https://coolify.example.com",
|
||||
Token: "secret-token",
|
||||
AppID: "app-123",
|
||||
StagingAppID: "staging-456",
|
||||
}
|
||||
|
||||
assert.Equal(t, "https://coolify.example.com", config.URL)
|
||||
assert.Equal(t, "secret-token", config.Token)
|
||||
assert.Equal(t, "app-123", config.AppID)
|
||||
assert.Equal(t, "staging-456", config.StagingAppID)
|
||||
})
|
||||
}
|
||||
|
||||
func TestCoolifyDeployment_Good(t *testing.T) {
|
||||
t.Run("all fields accessible", func(t *testing.T) {
|
||||
now := time.Now()
|
||||
deployment := CoolifyDeployment{
|
||||
ID: "dep-123",
|
||||
Status: "finished",
|
||||
CommitSHA: "abc123",
|
||||
CommitMsg: "Test commit",
|
||||
Branch: "main",
|
||||
CreatedAt: now,
|
||||
FinishedAt: now.Add(5 * time.Minute),
|
||||
Log: "Build successful",
|
||||
DeployedURL: "https://app.example.com",
|
||||
}
|
||||
|
||||
assert.Equal(t, "dep-123", deployment.ID)
|
||||
assert.Equal(t, "finished", deployment.Status)
|
||||
assert.Equal(t, "abc123", deployment.CommitSHA)
|
||||
assert.Equal(t, "Test commit", deployment.CommitMsg)
|
||||
assert.Equal(t, "main", deployment.Branch)
|
||||
})
|
||||
}
|
||||
|
||||
func TestCoolifyApp_Good(t *testing.T) {
|
||||
t.Run("all fields accessible", func(t *testing.T) {
|
||||
app := CoolifyApp{
|
||||
ID: "app-123",
|
||||
Name: "MyApp",
|
||||
FQDN: "https://myapp.example.com",
|
||||
Status: "running",
|
||||
Repository: "https://github.com/user/repo",
|
||||
Branch: "main",
|
||||
Environment: "production",
|
||||
}
|
||||
|
||||
assert.Equal(t, "app-123", app.ID)
|
||||
assert.Equal(t, "MyApp", app.Name)
|
||||
assert.Equal(t, "https://myapp.example.com", app.FQDN)
|
||||
assert.Equal(t, "running", app.Status)
|
||||
})
|
||||
}
|
||||
|
||||
func TestLoadCoolifyConfigFromFile_Good(t *testing.T) {
|
||||
t.Run("loads config from .env file", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
envContent := `COOLIFY_URL=https://coolify.example.com
|
||||
COOLIFY_TOKEN=secret-token
|
||||
COOLIFY_APP_ID=app-123
|
||||
COOLIFY_STAGING_APP_ID=staging-456`
|
||||
|
||||
err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
config, err := LoadCoolifyConfigFromFile(filepath.Join(dir, ".env"))
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "https://coolify.example.com", config.URL)
|
||||
assert.Equal(t, "secret-token", config.Token)
|
||||
assert.Equal(t, "app-123", config.AppID)
|
||||
assert.Equal(t, "staging-456", config.StagingAppID)
|
||||
})
|
||||
|
||||
t.Run("handles quoted values", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
envContent := `COOLIFY_URL="https://coolify.example.com"
|
||||
COOLIFY_TOKEN='secret-token'`
|
||||
|
||||
err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
config, err := LoadCoolifyConfigFromFile(filepath.Join(dir, ".env"))
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "https://coolify.example.com", config.URL)
|
||||
assert.Equal(t, "secret-token", config.Token)
|
||||
})
|
||||
|
||||
t.Run("ignores comments", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
envContent := `# This is a comment
|
||||
COOLIFY_URL=https://coolify.example.com
|
||||
# COOLIFY_TOKEN=wrong-token
|
||||
COOLIFY_TOKEN=correct-token`
|
||||
|
||||
err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
config, err := LoadCoolifyConfigFromFile(filepath.Join(dir, ".env"))
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "correct-token", config.Token)
|
||||
})
|
||||
|
||||
t.Run("ignores blank lines", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
envContent := `COOLIFY_URL=https://coolify.example.com
|
||||
|
||||
COOLIFY_TOKEN=secret-token`
|
||||
|
||||
err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
config, err := LoadCoolifyConfigFromFile(filepath.Join(dir, ".env"))
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "https://coolify.example.com", config.URL)
|
||||
})
|
||||
}
|
||||
|
||||
func TestLoadCoolifyConfigFromFile_Bad(t *testing.T) {
|
||||
t.Run("fails when COOLIFY_URL missing", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
envContent := `COOLIFY_TOKEN=secret-token`
|
||||
|
||||
err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = LoadCoolifyConfigFromFile(filepath.Join(dir, ".env"))
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "COOLIFY_URL is not set")
|
||||
})
|
||||
|
||||
t.Run("fails when COOLIFY_TOKEN missing", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
envContent := `COOLIFY_URL=https://coolify.example.com`
|
||||
|
||||
err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = LoadCoolifyConfigFromFile(filepath.Join(dir, ".env"))
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "COOLIFY_TOKEN is not set")
|
||||
})
|
||||
}
|
||||
|
||||
func TestLoadCoolifyConfig_FromDirectory_Good(t *testing.T) {
|
||||
t.Run("loads from directory", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
envContent := `COOLIFY_URL=https://coolify.example.com
|
||||
COOLIFY_TOKEN=secret-token`
|
||||
|
||||
err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
config, err := LoadCoolifyConfig(dir)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "https://coolify.example.com", config.URL)
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidateCoolifyConfig_Bad(t *testing.T) {
|
||||
t.Run("returns error for empty URL", func(t *testing.T) {
|
||||
config := &CoolifyConfig{Token: "token"}
|
||||
_, err := validateCoolifyConfig(config)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "COOLIFY_URL is not set")
|
||||
})
|
||||
|
||||
t.Run("returns error for empty token", func(t *testing.T) {
|
||||
config := &CoolifyConfig{URL: "https://coolify.example.com"}
|
||||
_, err := validateCoolifyConfig(config)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "COOLIFY_TOKEN is not set")
|
||||
})
|
||||
}
|
||||
|
||||
func TestCoolifyClient_TriggerDeploy_Good(t *testing.T) {
|
||||
t.Run("triggers deployment successfully", func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/applications/app-123/deploy", r.URL.Path)
|
||||
assert.Equal(t, "POST", r.Method)
|
||||
assert.Equal(t, "Bearer secret-token", r.Header.Get("Authorization"))
|
||||
assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
|
||||
|
||||
resp := CoolifyDeployment{
|
||||
ID: "dep-456",
|
||||
Status: "queued",
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewCoolifyClient(server.URL, "secret-token")
|
||||
deployment, err := client.TriggerDeploy(context.Background(), "app-123", false)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "dep-456", deployment.ID)
|
||||
assert.Equal(t, "queued", deployment.Status)
|
||||
})
|
||||
|
||||
t.Run("triggers deployment with force", func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var body map[string]interface{}
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
assert.Equal(t, true, body["force"])
|
||||
|
||||
resp := CoolifyDeployment{ID: "dep-456", Status: "queued"}
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewCoolifyClient(server.URL, "secret-token")
|
||||
_, err := client.TriggerDeploy(context.Background(), "app-123", true)
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("handles minimal response", func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Return an invalid JSON response to trigger the fallback
|
||||
_, _ = w.Write([]byte("not json"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewCoolifyClient(server.URL, "secret-token")
|
||||
deployment, err := client.TriggerDeploy(context.Background(), "app-123", false)
|
||||
|
||||
assert.NoError(t, err)
|
||||
// The fallback response should be returned
|
||||
assert.Equal(t, "queued", deployment.Status)
|
||||
})
|
||||
}
|
||||
|
||||
func TestCoolifyClient_TriggerDeploy_Bad(t *testing.T) {
|
||||
t.Run("fails on HTTP error", func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"message": "Internal error"})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewCoolifyClient(server.URL, "secret-token")
|
||||
_, err := client.TriggerDeploy(context.Background(), "app-123", false)
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "API error")
|
||||
})
|
||||
}
|
||||
|
||||
func TestCoolifyClient_GetDeployment_Good(t *testing.T) {
|
||||
t.Run("gets deployment details", func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/applications/app-123/deployments/dep-456", r.URL.Path)
|
||||
assert.Equal(t, "GET", r.Method)
|
||||
|
||||
resp := CoolifyDeployment{
|
||||
ID: "dep-456",
|
||||
Status: "finished",
|
||||
CommitSHA: "abc123",
|
||||
Branch: "main",
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewCoolifyClient(server.URL, "secret-token")
|
||||
deployment, err := client.GetDeployment(context.Background(), "app-123", "dep-456")
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "dep-456", deployment.ID)
|
||||
assert.Equal(t, "finished", deployment.Status)
|
||||
assert.Equal(t, "abc123", deployment.CommitSHA)
|
||||
})
|
||||
}
|
||||
|
||||
func TestCoolifyClient_GetDeployment_Bad(t *testing.T) {
|
||||
t.Run("fails on 404", func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"error": "Not found"})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewCoolifyClient(server.URL, "secret-token")
|
||||
_, err := client.GetDeployment(context.Background(), "app-123", "dep-456")
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "Not found")
|
||||
})
|
||||
}
|
||||
|
||||
func TestCoolifyClient_ListDeployments_Good(t *testing.T) {
|
||||
t.Run("lists deployments", func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/applications/app-123/deployments", r.URL.Path)
|
||||
assert.Equal(t, "10", r.URL.Query().Get("limit"))
|
||||
|
||||
resp := []CoolifyDeployment{
|
||||
{ID: "dep-1", Status: "finished"},
|
||||
{ID: "dep-2", Status: "failed"},
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewCoolifyClient(server.URL, "secret-token")
|
||||
deployments, err := client.ListDeployments(context.Background(), "app-123", 10)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, deployments, 2)
|
||||
assert.Equal(t, "dep-1", deployments[0].ID)
|
||||
assert.Equal(t, "dep-2", deployments[1].ID)
|
||||
})
|
||||
|
||||
t.Run("lists without limit", func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "", r.URL.Query().Get("limit"))
|
||||
_ = json.NewEncoder(w).Encode([]CoolifyDeployment{})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewCoolifyClient(server.URL, "secret-token")
|
||||
_, err := client.ListDeployments(context.Background(), "app-123", 0)
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestCoolifyClient_Rollback_Good(t *testing.T) {
|
||||
t.Run("triggers rollback", func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/applications/app-123/rollback", r.URL.Path)
|
||||
assert.Equal(t, "POST", r.Method)
|
||||
|
||||
var body map[string]string
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
assert.Equal(t, "dep-old", body["deployment_id"])
|
||||
|
||||
resp := CoolifyDeployment{
|
||||
ID: "dep-new",
|
||||
Status: "rolling_back",
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewCoolifyClient(server.URL, "secret-token")
|
||||
deployment, err := client.Rollback(context.Background(), "app-123", "dep-old")
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "dep-new", deployment.ID)
|
||||
assert.Equal(t, "rolling_back", deployment.Status)
|
||||
})
|
||||
}
|
||||
|
||||
func TestCoolifyClient_GetApp_Good(t *testing.T) {
|
||||
t.Run("gets app details", func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/applications/app-123", r.URL.Path)
|
||||
assert.Equal(t, "GET", r.Method)
|
||||
|
||||
resp := CoolifyApp{
|
||||
ID: "app-123",
|
||||
Name: "MyApp",
|
||||
FQDN: "https://myapp.example.com",
|
||||
Status: "running",
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewCoolifyClient(server.URL, "secret-token")
|
||||
app, err := client.GetApp(context.Background(), "app-123")
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "app-123", app.ID)
|
||||
assert.Equal(t, "MyApp", app.Name)
|
||||
assert.Equal(t, "https://myapp.example.com", app.FQDN)
|
||||
})
|
||||
}
|
||||
|
||||
func TestCoolifyClient_SetHeaders(t *testing.T) {
|
||||
t.Run("sets all required headers", func(t *testing.T) {
|
||||
client := NewCoolifyClient("https://coolify.example.com", "my-token")
|
||||
req, _ := http.NewRequest("GET", "https://coolify.example.com", nil)
|
||||
|
||||
client.setHeaders(req)
|
||||
|
||||
assert.Equal(t, "Bearer my-token", req.Header.Get("Authorization"))
|
||||
assert.Equal(t, "application/json", req.Header.Get("Content-Type"))
|
||||
assert.Equal(t, "application/json", req.Header.Get("Accept"))
|
||||
})
|
||||
}
|
||||
|
||||
func TestCoolifyClient_ParseError(t *testing.T) {
|
||||
t.Run("parses message field", func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"message": "Bad request message"})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewCoolifyClient(server.URL, "token")
|
||||
_, err := client.GetApp(context.Background(), "app-123")
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "Bad request message")
|
||||
})
|
||||
|
||||
t.Run("parses error field", func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"error": "Error message"})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewCoolifyClient(server.URL, "token")
|
||||
_, err := client.GetApp(context.Background(), "app-123")
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "Error message")
|
||||
})
|
||||
|
||||
t.Run("returns raw body when no JSON fields", func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte("Raw error message"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewCoolifyClient(server.URL, "token")
|
||||
_, err := client.GetApp(context.Background(), "app-123")
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "Raw error message")
|
||||
})
|
||||
}
|
||||
|
||||
func TestEnvironmentVariablePriority(t *testing.T) {
|
||||
t.Run("env vars take precedence over .env file", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
envContent := `COOLIFY_URL=https://from-file.com
|
||||
COOLIFY_TOKEN=file-token`
|
||||
|
||||
err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Set environment variables
|
||||
origURL := os.Getenv("COOLIFY_URL")
|
||||
origToken := os.Getenv("COOLIFY_TOKEN")
|
||||
defer func() {
|
||||
_ = os.Setenv("COOLIFY_URL", origURL)
|
||||
_ = os.Setenv("COOLIFY_TOKEN", origToken)
|
||||
}()
|
||||
|
||||
_ = os.Setenv("COOLIFY_URL", "https://from-env.com")
|
||||
_ = os.Setenv("COOLIFY_TOKEN", "env-token")
|
||||
|
||||
config, err := LoadCoolifyConfig(dir)
|
||||
assert.NoError(t, err)
|
||||
// Environment variables should take precedence
|
||||
assert.Equal(t, "https://from-env.com", config.URL)
|
||||
assert.Equal(t, "env-token", config.Token)
|
||||
})
|
||||
}
|
||||
407
deploy.go
Normal file
407
deploy.go
Normal file
|
|
@ -0,0 +1,407 @@
|
|||
package php
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
)
|
||||
|
||||
// Environment represents a deployment environment.
|
||||
type Environment string
|
||||
|
||||
const (
|
||||
// EnvProduction is the production environment.
|
||||
EnvProduction Environment = "production"
|
||||
// EnvStaging is the staging environment.
|
||||
EnvStaging Environment = "staging"
|
||||
)
|
||||
|
||||
// DeployOptions configures a deployment.
|
||||
type DeployOptions struct {
|
||||
// Dir is the project directory containing .env config.
|
||||
Dir string
|
||||
|
||||
// Environment is the target environment (production or staging).
|
||||
Environment Environment
|
||||
|
||||
// Force triggers a deployment even if no changes are detected.
|
||||
Force bool
|
||||
|
||||
// Wait blocks until deployment completes.
|
||||
Wait bool
|
||||
|
||||
// WaitTimeout is the maximum time to wait for deployment.
|
||||
// Defaults to 10 minutes.
|
||||
WaitTimeout time.Duration
|
||||
|
||||
// PollInterval is how often to check deployment status when waiting.
|
||||
// Defaults to 5 seconds.
|
||||
PollInterval time.Duration
|
||||
}
|
||||
|
||||
// StatusOptions configures a status check.
|
||||
type StatusOptions struct {
|
||||
// Dir is the project directory containing .env config.
|
||||
Dir string
|
||||
|
||||
// Environment is the target environment (production or staging).
|
||||
Environment Environment
|
||||
|
||||
// DeploymentID is a specific deployment to check.
|
||||
// If empty, returns the latest deployment.
|
||||
DeploymentID string
|
||||
}
|
||||
|
||||
// RollbackOptions configures a rollback.
|
||||
type RollbackOptions struct {
|
||||
// Dir is the project directory containing .env config.
|
||||
Dir string
|
||||
|
||||
// Environment is the target environment (production or staging).
|
||||
Environment Environment
|
||||
|
||||
// DeploymentID is the deployment to rollback to.
|
||||
// If empty, rolls back to the previous successful deployment.
|
||||
DeploymentID string
|
||||
|
||||
// Wait blocks until rollback completes.
|
||||
Wait bool
|
||||
|
||||
// WaitTimeout is the maximum time to wait for rollback.
|
||||
WaitTimeout time.Duration
|
||||
}
|
||||
|
||||
// DeploymentStatus represents the status of a deployment.
|
||||
type DeploymentStatus struct {
|
||||
// ID is the deployment identifier.
|
||||
ID string
|
||||
|
||||
// Status is the current deployment status.
|
||||
// Values: queued, building, deploying, finished, failed, cancelled
|
||||
Status string
|
||||
|
||||
// URL is the deployed application URL.
|
||||
URL string
|
||||
|
||||
// Commit is the git commit SHA.
|
||||
Commit string
|
||||
|
||||
// CommitMessage is the git commit message.
|
||||
CommitMessage string
|
||||
|
||||
// Branch is the git branch.
|
||||
Branch string
|
||||
|
||||
// StartedAt is when the deployment started.
|
||||
StartedAt time.Time
|
||||
|
||||
// CompletedAt is when the deployment completed.
|
||||
CompletedAt time.Time
|
||||
|
||||
// Log contains deployment logs.
|
||||
Log string
|
||||
}
|
||||
|
||||
// Deploy triggers a deployment to Coolify.
|
||||
func Deploy(ctx context.Context, opts DeployOptions) (*DeploymentStatus, error) {
|
||||
if opts.Dir == "" {
|
||||
opts.Dir = "."
|
||||
}
|
||||
if opts.Environment == "" {
|
||||
opts.Environment = EnvProduction
|
||||
}
|
||||
if opts.WaitTimeout == 0 {
|
||||
opts.WaitTimeout = 10 * time.Minute
|
||||
}
|
||||
if opts.PollInterval == 0 {
|
||||
opts.PollInterval = 5 * time.Second
|
||||
}
|
||||
|
||||
// Load config
|
||||
config, err := LoadCoolifyConfig(opts.Dir)
|
||||
if err != nil {
|
||||
return nil, cli.WrapVerb(err, "load", "Coolify config")
|
||||
}
|
||||
|
||||
// Get app ID for environment
|
||||
appID := getAppIDForEnvironment(config, opts.Environment)
|
||||
if appID == "" {
|
||||
return nil, cli.Err("no app ID configured for %s environment", opts.Environment)
|
||||
}
|
||||
|
||||
// Create client
|
||||
client := NewCoolifyClient(config.URL, config.Token)
|
||||
|
||||
// Trigger deployment
|
||||
deployment, err := client.TriggerDeploy(ctx, appID, opts.Force)
|
||||
if err != nil {
|
||||
return nil, cli.WrapVerb(err, "trigger", "deployment")
|
||||
}
|
||||
|
||||
status := convertDeployment(deployment)
|
||||
|
||||
// Wait for completion if requested
|
||||
if opts.Wait && deployment.ID != "" {
|
||||
status, err = waitForDeployment(ctx, client, appID, deployment.ID, opts.WaitTimeout, opts.PollInterval)
|
||||
if err != nil {
|
||||
return status, err
|
||||
}
|
||||
}
|
||||
|
||||
// Get app info for URL
|
||||
app, err := client.GetApp(ctx, appID)
|
||||
if err == nil && app.FQDN != "" {
|
||||
status.URL = app.FQDN
|
||||
}
|
||||
|
||||
return status, nil
|
||||
}
|
||||
|
||||
// DeployStatus retrieves the status of a deployment.
|
||||
func DeployStatus(ctx context.Context, opts StatusOptions) (*DeploymentStatus, error) {
|
||||
if opts.Dir == "" {
|
||||
opts.Dir = "."
|
||||
}
|
||||
if opts.Environment == "" {
|
||||
opts.Environment = EnvProduction
|
||||
}
|
||||
|
||||
// Load config
|
||||
config, err := LoadCoolifyConfig(opts.Dir)
|
||||
if err != nil {
|
||||
return nil, cli.WrapVerb(err, "load", "Coolify config")
|
||||
}
|
||||
|
||||
// Get app ID for environment
|
||||
appID := getAppIDForEnvironment(config, opts.Environment)
|
||||
if appID == "" {
|
||||
return nil, cli.Err("no app ID configured for %s environment", opts.Environment)
|
||||
}
|
||||
|
||||
// Create client
|
||||
client := NewCoolifyClient(config.URL, config.Token)
|
||||
|
||||
var deployment *CoolifyDeployment
|
||||
|
||||
if opts.DeploymentID != "" {
|
||||
// Get specific deployment
|
||||
deployment, err = client.GetDeployment(ctx, appID, opts.DeploymentID)
|
||||
if err != nil {
|
||||
return nil, cli.WrapVerb(err, "get", "deployment")
|
||||
}
|
||||
} else {
|
||||
// Get latest deployment
|
||||
deployments, err := client.ListDeployments(ctx, appID, 1)
|
||||
if err != nil {
|
||||
return nil, cli.WrapVerb(err, "list", "deployments")
|
||||
}
|
||||
if len(deployments) == 0 {
|
||||
return nil, cli.Err("no deployments found")
|
||||
}
|
||||
deployment = &deployments[0]
|
||||
}
|
||||
|
||||
status := convertDeployment(deployment)
|
||||
|
||||
// Get app info for URL
|
||||
app, err := client.GetApp(ctx, appID)
|
||||
if err == nil && app.FQDN != "" {
|
||||
status.URL = app.FQDN
|
||||
}
|
||||
|
||||
return status, nil
|
||||
}
|
||||
|
||||
// Rollback triggers a rollback to a previous deployment.
|
||||
func Rollback(ctx context.Context, opts RollbackOptions) (*DeploymentStatus, error) {
|
||||
if opts.Dir == "" {
|
||||
opts.Dir = "."
|
||||
}
|
||||
if opts.Environment == "" {
|
||||
opts.Environment = EnvProduction
|
||||
}
|
||||
if opts.WaitTimeout == 0 {
|
||||
opts.WaitTimeout = 10 * time.Minute
|
||||
}
|
||||
|
||||
// Load config
|
||||
config, err := LoadCoolifyConfig(opts.Dir)
|
||||
if err != nil {
|
||||
return nil, cli.WrapVerb(err, "load", "Coolify config")
|
||||
}
|
||||
|
||||
// Get app ID for environment
|
||||
appID := getAppIDForEnvironment(config, opts.Environment)
|
||||
if appID == "" {
|
||||
return nil, cli.Err("no app ID configured for %s environment", opts.Environment)
|
||||
}
|
||||
|
||||
// Create client
|
||||
client := NewCoolifyClient(config.URL, config.Token)
|
||||
|
||||
// Find deployment to rollback to
|
||||
deploymentID := opts.DeploymentID
|
||||
if deploymentID == "" {
|
||||
// Find previous successful deployment
|
||||
deployments, err := client.ListDeployments(ctx, appID, 10)
|
||||
if err != nil {
|
||||
return nil, cli.WrapVerb(err, "list", "deployments")
|
||||
}
|
||||
|
||||
// Skip the first (current) deployment, find the last successful one
|
||||
for i, d := range deployments {
|
||||
if i == 0 {
|
||||
continue // Skip current deployment
|
||||
}
|
||||
if d.Status == "finished" || d.Status == "success" {
|
||||
deploymentID = d.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if deploymentID == "" {
|
||||
return nil, cli.Err("no previous successful deployment found to rollback to")
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger rollback
|
||||
deployment, err := client.Rollback(ctx, appID, deploymentID)
|
||||
if err != nil {
|
||||
return nil, cli.WrapVerb(err, "trigger", "rollback")
|
||||
}
|
||||
|
||||
status := convertDeployment(deployment)
|
||||
|
||||
// Wait for completion if requested
|
||||
if opts.Wait && deployment.ID != "" {
|
||||
status, err = waitForDeployment(ctx, client, appID, deployment.ID, opts.WaitTimeout, 5*time.Second)
|
||||
if err != nil {
|
||||
return status, err
|
||||
}
|
||||
}
|
||||
|
||||
return status, nil
|
||||
}
|
||||
|
||||
// ListDeployments retrieves recent deployments.
|
||||
func ListDeployments(ctx context.Context, dir string, env Environment, limit int) ([]DeploymentStatus, error) {
|
||||
if dir == "" {
|
||||
dir = "."
|
||||
}
|
||||
if env == "" {
|
||||
env = EnvProduction
|
||||
}
|
||||
if limit == 0 {
|
||||
limit = 10
|
||||
}
|
||||
|
||||
// Load config
|
||||
config, err := LoadCoolifyConfig(dir)
|
||||
if err != nil {
|
||||
return nil, cli.WrapVerb(err, "load", "Coolify config")
|
||||
}
|
||||
|
||||
// Get app ID for environment
|
||||
appID := getAppIDForEnvironment(config, env)
|
||||
if appID == "" {
|
||||
return nil, cli.Err("no app ID configured for %s environment", env)
|
||||
}
|
||||
|
||||
// Create client
|
||||
client := NewCoolifyClient(config.URL, config.Token)
|
||||
|
||||
deployments, err := client.ListDeployments(ctx, appID, limit)
|
||||
if err != nil {
|
||||
return nil, cli.WrapVerb(err, "list", "deployments")
|
||||
}
|
||||
|
||||
result := make([]DeploymentStatus, len(deployments))
|
||||
for i, d := range deployments {
|
||||
result[i] = *convertDeployment(&d)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// getAppIDForEnvironment returns the app ID for the given environment.
|
||||
func getAppIDForEnvironment(config *CoolifyConfig, env Environment) string {
|
||||
switch env {
|
||||
case EnvStaging:
|
||||
if config.StagingAppID != "" {
|
||||
return config.StagingAppID
|
||||
}
|
||||
return config.AppID // Fallback to production
|
||||
default:
|
||||
return config.AppID
|
||||
}
|
||||
}
|
||||
|
||||
// convertDeployment converts a CoolifyDeployment to DeploymentStatus.
|
||||
func convertDeployment(d *CoolifyDeployment) *DeploymentStatus {
|
||||
return &DeploymentStatus{
|
||||
ID: d.ID,
|
||||
Status: d.Status,
|
||||
URL: d.DeployedURL,
|
||||
Commit: d.CommitSHA,
|
||||
CommitMessage: d.CommitMsg,
|
||||
Branch: d.Branch,
|
||||
StartedAt: d.CreatedAt,
|
||||
CompletedAt: d.FinishedAt,
|
||||
Log: d.Log,
|
||||
}
|
||||
}
|
||||
|
||||
// waitForDeployment polls for deployment completion.
|
||||
func waitForDeployment(ctx context.Context, client *CoolifyClient, appID, deploymentID string, timeout, interval time.Duration) (*DeploymentStatus, error) {
|
||||
deadline := time.Now().Add(timeout)
|
||||
|
||||
for time.Now().Before(deadline) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
deployment, err := client.GetDeployment(ctx, appID, deploymentID)
|
||||
if err != nil {
|
||||
return nil, cli.WrapVerb(err, "get", "deployment status")
|
||||
}
|
||||
|
||||
status := convertDeployment(deployment)
|
||||
|
||||
// Check if deployment is complete
|
||||
switch deployment.Status {
|
||||
case "finished", "success":
|
||||
return status, nil
|
||||
case "failed", "error":
|
||||
return status, cli.Err("deployment failed: %s", deployment.Status)
|
||||
case "cancelled":
|
||||
return status, cli.Err("deployment was cancelled")
|
||||
}
|
||||
|
||||
// Still in progress, wait and retry
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return status, ctx.Err()
|
||||
case <-time.After(interval):
|
||||
}
|
||||
}
|
||||
|
||||
return nil, cli.Err("deployment timed out after %v", timeout)
|
||||
}
|
||||
|
||||
// IsDeploymentComplete returns true if the status indicates completion.
|
||||
func IsDeploymentComplete(status string) bool {
|
||||
switch status {
|
||||
case "finished", "success", "failed", "error", "cancelled":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// IsDeploymentSuccessful returns true if the status indicates success.
|
||||
func IsDeploymentSuccessful(status string) bool {
|
||||
return status == "finished" || status == "success"
|
||||
}
|
||||
221
deploy_internal_test.go
Normal file
221
deploy_internal_test.go
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
package php
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestConvertDeployment_Good(t *testing.T) {
|
||||
t.Run("converts all fields", func(t *testing.T) {
|
||||
now := time.Now()
|
||||
coolify := &CoolifyDeployment{
|
||||
ID: "dep-123",
|
||||
Status: "finished",
|
||||
CommitSHA: "abc123",
|
||||
CommitMsg: "Test commit",
|
||||
Branch: "main",
|
||||
CreatedAt: now,
|
||||
FinishedAt: now.Add(5 * time.Minute),
|
||||
Log: "Build successful",
|
||||
DeployedURL: "https://app.example.com",
|
||||
}
|
||||
|
||||
status := convertDeployment(coolify)
|
||||
|
||||
assert.Equal(t, "dep-123", status.ID)
|
||||
assert.Equal(t, "finished", status.Status)
|
||||
assert.Equal(t, "https://app.example.com", status.URL)
|
||||
assert.Equal(t, "abc123", status.Commit)
|
||||
assert.Equal(t, "Test commit", status.CommitMessage)
|
||||
assert.Equal(t, "main", status.Branch)
|
||||
assert.Equal(t, now, status.StartedAt)
|
||||
assert.Equal(t, now.Add(5*time.Minute), status.CompletedAt)
|
||||
assert.Equal(t, "Build successful", status.Log)
|
||||
})
|
||||
|
||||
t.Run("handles empty deployment", func(t *testing.T) {
|
||||
coolify := &CoolifyDeployment{}
|
||||
status := convertDeployment(coolify)
|
||||
|
||||
assert.Empty(t, status.ID)
|
||||
assert.Empty(t, status.Status)
|
||||
})
|
||||
}
|
||||
|
||||
func TestDeploymentStatus_Struct_Good(t *testing.T) {
|
||||
t.Run("all fields accessible", func(t *testing.T) {
|
||||
now := time.Now()
|
||||
status := DeploymentStatus{
|
||||
ID: "dep-123",
|
||||
Status: "finished",
|
||||
URL: "https://app.example.com",
|
||||
Commit: "abc123",
|
||||
CommitMessage: "Test commit",
|
||||
Branch: "main",
|
||||
StartedAt: now,
|
||||
CompletedAt: now.Add(5 * time.Minute),
|
||||
Log: "Build log",
|
||||
}
|
||||
|
||||
assert.Equal(t, "dep-123", status.ID)
|
||||
assert.Equal(t, "finished", status.Status)
|
||||
assert.Equal(t, "https://app.example.com", status.URL)
|
||||
assert.Equal(t, "abc123", status.Commit)
|
||||
assert.Equal(t, "Test commit", status.CommitMessage)
|
||||
assert.Equal(t, "main", status.Branch)
|
||||
assert.Equal(t, "Build log", status.Log)
|
||||
})
|
||||
}
|
||||
|
||||
func TestDeployOptions_Struct_Good(t *testing.T) {
|
||||
t.Run("all fields accessible", func(t *testing.T) {
|
||||
opts := DeployOptions{
|
||||
Dir: "/project",
|
||||
Environment: EnvProduction,
|
||||
Force: true,
|
||||
Wait: true,
|
||||
WaitTimeout: 10 * time.Minute,
|
||||
PollInterval: 5 * time.Second,
|
||||
}
|
||||
|
||||
assert.Equal(t, "/project", opts.Dir)
|
||||
assert.Equal(t, EnvProduction, opts.Environment)
|
||||
assert.True(t, opts.Force)
|
||||
assert.True(t, opts.Wait)
|
||||
assert.Equal(t, 10*time.Minute, opts.WaitTimeout)
|
||||
assert.Equal(t, 5*time.Second, opts.PollInterval)
|
||||
})
|
||||
}
|
||||
|
||||
func TestStatusOptions_Struct_Good(t *testing.T) {
|
||||
t.Run("all fields accessible", func(t *testing.T) {
|
||||
opts := StatusOptions{
|
||||
Dir: "/project",
|
||||
Environment: EnvStaging,
|
||||
DeploymentID: "dep-123",
|
||||
}
|
||||
|
||||
assert.Equal(t, "/project", opts.Dir)
|
||||
assert.Equal(t, EnvStaging, opts.Environment)
|
||||
assert.Equal(t, "dep-123", opts.DeploymentID)
|
||||
})
|
||||
}
|
||||
|
||||
func TestRollbackOptions_Struct_Good(t *testing.T) {
|
||||
t.Run("all fields accessible", func(t *testing.T) {
|
||||
opts := RollbackOptions{
|
||||
Dir: "/project",
|
||||
Environment: EnvProduction,
|
||||
DeploymentID: "dep-old",
|
||||
Wait: true,
|
||||
WaitTimeout: 5 * time.Minute,
|
||||
}
|
||||
|
||||
assert.Equal(t, "/project", opts.Dir)
|
||||
assert.Equal(t, EnvProduction, opts.Environment)
|
||||
assert.Equal(t, "dep-old", opts.DeploymentID)
|
||||
assert.True(t, opts.Wait)
|
||||
assert.Equal(t, 5*time.Minute, opts.WaitTimeout)
|
||||
})
|
||||
}
|
||||
|
||||
func TestEnvironment_Constants(t *testing.T) {
|
||||
t.Run("constants are defined", func(t *testing.T) {
|
||||
assert.Equal(t, Environment("production"), EnvProduction)
|
||||
assert.Equal(t, Environment("staging"), EnvStaging)
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetAppIDForEnvironment_Edge(t *testing.T) {
|
||||
t.Run("staging without staging ID falls back to production", func(t *testing.T) {
|
||||
config := &CoolifyConfig{
|
||||
AppID: "prod-123",
|
||||
// No StagingAppID set
|
||||
}
|
||||
|
||||
id := getAppIDForEnvironment(config, EnvStaging)
|
||||
assert.Equal(t, "prod-123", id)
|
||||
})
|
||||
|
||||
t.Run("staging with staging ID uses staging", func(t *testing.T) {
|
||||
config := &CoolifyConfig{
|
||||
AppID: "prod-123",
|
||||
StagingAppID: "staging-456",
|
||||
}
|
||||
|
||||
id := getAppIDForEnvironment(config, EnvStaging)
|
||||
assert.Equal(t, "staging-456", id)
|
||||
})
|
||||
|
||||
t.Run("production uses production ID", func(t *testing.T) {
|
||||
config := &CoolifyConfig{
|
||||
AppID: "prod-123",
|
||||
StagingAppID: "staging-456",
|
||||
}
|
||||
|
||||
id := getAppIDForEnvironment(config, EnvProduction)
|
||||
assert.Equal(t, "prod-123", id)
|
||||
})
|
||||
|
||||
t.Run("unknown environment uses production", func(t *testing.T) {
|
||||
config := &CoolifyConfig{
|
||||
AppID: "prod-123",
|
||||
}
|
||||
|
||||
id := getAppIDForEnvironment(config, "unknown")
|
||||
assert.Equal(t, "prod-123", id)
|
||||
})
|
||||
}
|
||||
|
||||
func TestIsDeploymentComplete_Edge(t *testing.T) {
|
||||
tests := []struct {
|
||||
status string
|
||||
expected bool
|
||||
}{
|
||||
{"finished", true},
|
||||
{"success", true},
|
||||
{"failed", true},
|
||||
{"error", true},
|
||||
{"cancelled", true},
|
||||
{"queued", false},
|
||||
{"building", false},
|
||||
{"deploying", false},
|
||||
{"pending", false},
|
||||
{"rolling_back", false},
|
||||
{"", false},
|
||||
{"unknown", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.status, func(t *testing.T) {
|
||||
result := IsDeploymentComplete(tt.status)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsDeploymentSuccessful_Edge(t *testing.T) {
|
||||
tests := []struct {
|
||||
status string
|
||||
expected bool
|
||||
}{
|
||||
{"finished", true},
|
||||
{"success", true},
|
||||
{"failed", false},
|
||||
{"error", false},
|
||||
{"cancelled", false},
|
||||
{"queued", false},
|
||||
{"building", false},
|
||||
{"deploying", false},
|
||||
{"", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.status, func(t *testing.T) {
|
||||
result := IsDeploymentSuccessful(tt.status)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
257
deploy_test.go
Normal file
257
deploy_test.go
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
package php
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadCoolifyConfig_Good(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
envContent string
|
||||
wantURL string
|
||||
wantToken string
|
||||
wantAppID string
|
||||
wantStaging string
|
||||
}{
|
||||
{
|
||||
name: "all values set",
|
||||
envContent: `COOLIFY_URL=https://coolify.example.com
|
||||
COOLIFY_TOKEN=secret-token
|
||||
COOLIFY_APP_ID=app-123
|
||||
COOLIFY_STAGING_APP_ID=staging-456`,
|
||||
wantURL: "https://coolify.example.com",
|
||||
wantToken: "secret-token",
|
||||
wantAppID: "app-123",
|
||||
wantStaging: "staging-456",
|
||||
},
|
||||
{
|
||||
name: "quoted values",
|
||||
envContent: `COOLIFY_URL="https://coolify.example.com"
|
||||
COOLIFY_TOKEN='secret-token'
|
||||
COOLIFY_APP_ID="app-123"`,
|
||||
wantURL: "https://coolify.example.com",
|
||||
wantToken: "secret-token",
|
||||
wantAppID: "app-123",
|
||||
},
|
||||
{
|
||||
name: "with comments and blank lines",
|
||||
envContent: `# Coolify configuration
|
||||
COOLIFY_URL=https://coolify.example.com
|
||||
|
||||
# API token
|
||||
COOLIFY_TOKEN=secret-token
|
||||
COOLIFY_APP_ID=app-123
|
||||
# COOLIFY_STAGING_APP_ID=not-this`,
|
||||
wantURL: "https://coolify.example.com",
|
||||
wantToken: "secret-token",
|
||||
wantAppID: "app-123",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Create temp directory
|
||||
dir := t.TempDir()
|
||||
envPath := filepath.Join(dir, ".env")
|
||||
|
||||
// Write .env file
|
||||
if err := os.WriteFile(envPath, []byte(tt.envContent), 0644); err != nil {
|
||||
t.Fatalf("failed to write .env: %v", err)
|
||||
}
|
||||
|
||||
// Load config
|
||||
config, err := LoadCoolifyConfig(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadCoolifyConfig() error = %v", err)
|
||||
}
|
||||
|
||||
if config.URL != tt.wantURL {
|
||||
t.Errorf("URL = %q, want %q", config.URL, tt.wantURL)
|
||||
}
|
||||
if config.Token != tt.wantToken {
|
||||
t.Errorf("Token = %q, want %q", config.Token, tt.wantToken)
|
||||
}
|
||||
if config.AppID != tt.wantAppID {
|
||||
t.Errorf("AppID = %q, want %q", config.AppID, tt.wantAppID)
|
||||
}
|
||||
if tt.wantStaging != "" && config.StagingAppID != tt.wantStaging {
|
||||
t.Errorf("StagingAppID = %q, want %q", config.StagingAppID, tt.wantStaging)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadCoolifyConfig_Bad(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
envContent string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "missing URL",
|
||||
envContent: "COOLIFY_TOKEN=secret",
|
||||
wantErr: "COOLIFY_URL is not set",
|
||||
},
|
||||
{
|
||||
name: "missing token",
|
||||
envContent: "COOLIFY_URL=https://coolify.example.com",
|
||||
wantErr: "COOLIFY_TOKEN is not set",
|
||||
},
|
||||
{
|
||||
name: "empty values",
|
||||
envContent: "COOLIFY_URL=\nCOOLIFY_TOKEN=",
|
||||
wantErr: "COOLIFY_URL is not set",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Create temp directory
|
||||
dir := t.TempDir()
|
||||
envPath := filepath.Join(dir, ".env")
|
||||
|
||||
// Write .env file
|
||||
if err := os.WriteFile(envPath, []byte(tt.envContent), 0644); err != nil {
|
||||
t.Fatalf("failed to write .env: %v", err)
|
||||
}
|
||||
|
||||
// Load config
|
||||
_, err := LoadCoolifyConfig(dir)
|
||||
if err == nil {
|
||||
t.Fatal("LoadCoolifyConfig() expected error, got nil")
|
||||
}
|
||||
|
||||
if err.Error() != tt.wantErr {
|
||||
t.Errorf("error = %q, want %q", err.Error(), tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAppIDForEnvironment_Good(t *testing.T) {
|
||||
config := &CoolifyConfig{
|
||||
URL: "https://coolify.example.com",
|
||||
Token: "token",
|
||||
AppID: "prod-123",
|
||||
StagingAppID: "staging-456",
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
env Environment
|
||||
wantID string
|
||||
}{
|
||||
{
|
||||
name: "production environment",
|
||||
env: EnvProduction,
|
||||
wantID: "prod-123",
|
||||
},
|
||||
{
|
||||
name: "staging environment",
|
||||
env: EnvStaging,
|
||||
wantID: "staging-456",
|
||||
},
|
||||
{
|
||||
name: "empty defaults to production",
|
||||
env: "",
|
||||
wantID: "prod-123",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
id := getAppIDForEnvironment(config, tt.env)
|
||||
if id != tt.wantID {
|
||||
t.Errorf("getAppIDForEnvironment() = %q, want %q", id, tt.wantID)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAppIDForEnvironment_FallbackToProduction(t *testing.T) {
|
||||
config := &CoolifyConfig{
|
||||
URL: "https://coolify.example.com",
|
||||
Token: "token",
|
||||
AppID: "prod-123",
|
||||
// No staging app ID
|
||||
}
|
||||
|
||||
// Staging should fall back to production
|
||||
id := getAppIDForEnvironment(config, EnvStaging)
|
||||
if id != "prod-123" {
|
||||
t.Errorf("getAppIDForEnvironment(EnvStaging) = %q, want %q (should fallback)", id, "prod-123")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsDeploymentComplete_Good(t *testing.T) {
|
||||
completeStatuses := []string{"finished", "success", "failed", "error", "cancelled"}
|
||||
for _, status := range completeStatuses {
|
||||
if !IsDeploymentComplete(status) {
|
||||
t.Errorf("IsDeploymentComplete(%q) = false, want true", status)
|
||||
}
|
||||
}
|
||||
|
||||
incompleteStatuses := []string{"queued", "building", "deploying", "pending", "rolling_back"}
|
||||
for _, status := range incompleteStatuses {
|
||||
if IsDeploymentComplete(status) {
|
||||
t.Errorf("IsDeploymentComplete(%q) = true, want false", status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsDeploymentSuccessful_Good(t *testing.T) {
|
||||
successStatuses := []string{"finished", "success"}
|
||||
for _, status := range successStatuses {
|
||||
if !IsDeploymentSuccessful(status) {
|
||||
t.Errorf("IsDeploymentSuccessful(%q) = false, want true", status)
|
||||
}
|
||||
}
|
||||
|
||||
failedStatuses := []string{"failed", "error", "cancelled", "queued", "building"}
|
||||
for _, status := range failedStatuses {
|
||||
if IsDeploymentSuccessful(status) {
|
||||
t.Errorf("IsDeploymentSuccessful(%q) = true, want false", status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewCoolifyClient_Good(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
baseURL string
|
||||
wantBaseURL string
|
||||
}{
|
||||
{
|
||||
name: "URL without trailing slash",
|
||||
baseURL: "https://coolify.example.com",
|
||||
wantBaseURL: "https://coolify.example.com",
|
||||
},
|
||||
{
|
||||
name: "URL with trailing slash",
|
||||
baseURL: "https://coolify.example.com/",
|
||||
wantBaseURL: "https://coolify.example.com",
|
||||
},
|
||||
{
|
||||
name: "URL with api path",
|
||||
baseURL: "https://coolify.example.com/api/",
|
||||
wantBaseURL: "https://coolify.example.com/api",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
client := NewCoolifyClient(tt.baseURL, "token")
|
||||
if client.BaseURL != tt.wantBaseURL {
|
||||
t.Errorf("BaseURL = %q, want %q", client.BaseURL, tt.wantBaseURL)
|
||||
}
|
||||
if client.Token != "token" {
|
||||
t.Errorf("Token = %q, want %q", client.Token, "token")
|
||||
}
|
||||
if client.HTTPClient == nil {
|
||||
t.Error("HTTPClient is nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
296
detect.go
Normal file
296
detect.go
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
package php
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// DetectedService represents a service that was detected in a Laravel project.
|
||||
type DetectedService string
|
||||
|
||||
// Detected service constants for Laravel projects.
|
||||
const (
|
||||
// ServiceFrankenPHP indicates FrankenPHP server is detected.
|
||||
ServiceFrankenPHP DetectedService = "frankenphp"
|
||||
// ServiceVite indicates Vite frontend bundler is detected.
|
||||
ServiceVite DetectedService = "vite"
|
||||
// ServiceHorizon indicates Laravel Horizon queue dashboard is detected.
|
||||
ServiceHorizon DetectedService = "horizon"
|
||||
// ServiceReverb indicates Laravel Reverb WebSocket server is detected.
|
||||
ServiceReverb DetectedService = "reverb"
|
||||
// ServiceRedis indicates Redis cache/queue backend is detected.
|
||||
ServiceRedis DetectedService = "redis"
|
||||
)
|
||||
|
||||
// IsLaravelProject checks if the given directory is a Laravel project.
|
||||
// It looks for the presence of artisan file and laravel in composer.json.
|
||||
func IsLaravelProject(dir string) bool {
|
||||
m := getMedium()
|
||||
|
||||
// Check for artisan file
|
||||
artisanPath := filepath.Join(dir, "artisan")
|
||||
if !m.Exists(artisanPath) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check composer.json for laravel/framework
|
||||
composerPath := filepath.Join(dir, "composer.json")
|
||||
data, err := m.Read(composerPath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
var composer struct {
|
||||
Require map[string]string `json:"require"`
|
||||
RequireDev map[string]string `json:"require-dev"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(data), &composer); err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check for laravel/framework in require
|
||||
if _, ok := composer.Require["laravel/framework"]; ok {
|
||||
return true
|
||||
}
|
||||
|
||||
// Also check require-dev (less common but possible)
|
||||
if _, ok := composer.RequireDev["laravel/framework"]; ok {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// IsFrankenPHPProject checks if the project is configured for FrankenPHP.
|
||||
// It looks for laravel/octane with frankenphp driver.
|
||||
func IsFrankenPHPProject(dir string) bool {
|
||||
m := getMedium()
|
||||
|
||||
// Check composer.json for laravel/octane
|
||||
composerPath := filepath.Join(dir, "composer.json")
|
||||
data, err := m.Read(composerPath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
var composer struct {
|
||||
Require map[string]string `json:"require"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(data), &composer); err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if _, ok := composer.Require["laravel/octane"]; !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check octane config for frankenphp
|
||||
configPath := filepath.Join(dir, "config", "octane.php")
|
||||
if !m.Exists(configPath) {
|
||||
// If no config exists but octane is installed, assume frankenphp
|
||||
return true
|
||||
}
|
||||
|
||||
configData, err := m.Read(configPath)
|
||||
if err != nil {
|
||||
return true // Assume frankenphp if we can't read config
|
||||
}
|
||||
|
||||
// Look for frankenphp in the config
|
||||
return strings.Contains(configData, "frankenphp")
|
||||
}
|
||||
|
||||
// DetectServices detects which services are needed based on project files.
|
||||
func DetectServices(dir string) []DetectedService {
|
||||
services := []DetectedService{}
|
||||
|
||||
// FrankenPHP/Octane is always needed for a Laravel dev environment
|
||||
if IsFrankenPHPProject(dir) || IsLaravelProject(dir) {
|
||||
services = append(services, ServiceFrankenPHP)
|
||||
}
|
||||
|
||||
// Check for Vite
|
||||
if hasVite(dir) {
|
||||
services = append(services, ServiceVite)
|
||||
}
|
||||
|
||||
// Check for Horizon
|
||||
if hasHorizon(dir) {
|
||||
services = append(services, ServiceHorizon)
|
||||
}
|
||||
|
||||
// Check for Reverb
|
||||
if hasReverb(dir) {
|
||||
services = append(services, ServiceReverb)
|
||||
}
|
||||
|
||||
// Check for Redis
|
||||
if needsRedis(dir) {
|
||||
services = append(services, ServiceRedis)
|
||||
}
|
||||
|
||||
return services
|
||||
}
|
||||
|
||||
// hasVite checks if the project uses Vite.
|
||||
func hasVite(dir string) bool {
|
||||
m := getMedium()
|
||||
viteConfigs := []string{
|
||||
"vite.config.js",
|
||||
"vite.config.ts",
|
||||
"vite.config.mjs",
|
||||
"vite.config.mts",
|
||||
}
|
||||
|
||||
for _, config := range viteConfigs {
|
||||
if m.Exists(filepath.Join(dir, config)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// hasHorizon checks if Laravel Horizon is configured.
|
||||
func hasHorizon(dir string) bool {
|
||||
horizonConfig := filepath.Join(dir, "config", "horizon.php")
|
||||
return getMedium().Exists(horizonConfig)
|
||||
}
|
||||
|
||||
// hasReverb checks if Laravel Reverb is configured.
|
||||
func hasReverb(dir string) bool {
|
||||
reverbConfig := filepath.Join(dir, "config", "reverb.php")
|
||||
return getMedium().Exists(reverbConfig)
|
||||
}
|
||||
|
||||
// needsRedis checks if the project uses Redis based on .env configuration.
|
||||
func needsRedis(dir string) bool {
|
||||
m := getMedium()
|
||||
envPath := filepath.Join(dir, ".env")
|
||||
content, err := m.Read(envPath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
lines := strings.Split(content, "\n")
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check for Redis-related environment variables
|
||||
redisIndicators := []string{
|
||||
"REDIS_HOST=",
|
||||
"CACHE_DRIVER=redis",
|
||||
"QUEUE_CONNECTION=redis",
|
||||
"SESSION_DRIVER=redis",
|
||||
"BROADCAST_DRIVER=redis",
|
||||
}
|
||||
|
||||
for _, indicator := range redisIndicators {
|
||||
if strings.HasPrefix(line, indicator) {
|
||||
// Check if it's set to localhost or 127.0.0.1
|
||||
if strings.Contains(line, "127.0.0.1") || strings.Contains(line, "localhost") ||
|
||||
indicator != "REDIS_HOST=" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// DetectPackageManager detects which package manager is used in the project.
|
||||
// Returns "npm", "pnpm", "yarn", or "bun".
|
||||
func DetectPackageManager(dir string) string {
|
||||
m := getMedium()
|
||||
// Check for lock files in order of preference
|
||||
lockFiles := []struct {
|
||||
file string
|
||||
manager string
|
||||
}{
|
||||
{"bun.lockb", "bun"},
|
||||
{"pnpm-lock.yaml", "pnpm"},
|
||||
{"yarn.lock", "yarn"},
|
||||
{"package-lock.json", "npm"},
|
||||
}
|
||||
|
||||
for _, lf := range lockFiles {
|
||||
if m.Exists(filepath.Join(dir, lf.file)) {
|
||||
return lf.manager
|
||||
}
|
||||
}
|
||||
|
||||
// Default to npm if no lock file found
|
||||
return "npm"
|
||||
}
|
||||
|
||||
// GetLaravelAppName extracts the application name from Laravel's .env file.
|
||||
func GetLaravelAppName(dir string) string {
|
||||
m := getMedium()
|
||||
envPath := filepath.Join(dir, ".env")
|
||||
content, err := m.Read(envPath)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
lines := strings.Split(content, "\n")
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "APP_NAME=") {
|
||||
value := strings.TrimPrefix(line, "APP_NAME=")
|
||||
// Remove quotes if present
|
||||
value = strings.Trim(value, `"'`)
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetLaravelAppURL extracts the application URL from Laravel's .env file.
|
||||
func GetLaravelAppURL(dir string) string {
|
||||
m := getMedium()
|
||||
envPath := filepath.Join(dir, ".env")
|
||||
content, err := m.Read(envPath)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
lines := strings.Split(content, "\n")
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "APP_URL=") {
|
||||
value := strings.TrimPrefix(line, "APP_URL=")
|
||||
// Remove quotes if present
|
||||
value = strings.Trim(value, `"'`)
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// ExtractDomainFromURL extracts the domain from a URL string.
|
||||
func ExtractDomainFromURL(url string) string {
|
||||
// Remove protocol
|
||||
domain := strings.TrimPrefix(url, "https://")
|
||||
domain = strings.TrimPrefix(domain, "http://")
|
||||
|
||||
// Remove port if present
|
||||
if idx := strings.Index(domain, ":"); idx != -1 {
|
||||
domain = domain[:idx]
|
||||
}
|
||||
|
||||
// Remove path if present
|
||||
if idx := strings.Index(domain, "/"); idx != -1 {
|
||||
domain = domain[:idx]
|
||||
}
|
||||
|
||||
return domain
|
||||
}
|
||||
663
detect_test.go
Normal file
663
detect_test.go
Normal file
|
|
@ -0,0 +1,663 @@
|
|||
package php
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestIsLaravelProject_Good(t *testing.T) {
|
||||
t.Run("valid Laravel project with artisan and composer.json", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
// Create artisan file
|
||||
artisanPath := filepath.Join(dir, "artisan")
|
||||
err := os.WriteFile(artisanPath, []byte("#!/usr/bin/env php\n"), 0755)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create composer.json with laravel/framework
|
||||
composerJSON := `{
|
||||
"name": "test/laravel-project",
|
||||
"require": {
|
||||
"php": "^8.2",
|
||||
"laravel/framework": "^11.0"
|
||||
}
|
||||
}`
|
||||
composerPath := filepath.Join(dir, "composer.json")
|
||||
err = os.WriteFile(composerPath, []byte(composerJSON), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.True(t, IsLaravelProject(dir))
|
||||
})
|
||||
|
||||
t.Run("Laravel in require-dev", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
// Create artisan file
|
||||
artisanPath := filepath.Join(dir, "artisan")
|
||||
err := os.WriteFile(artisanPath, []byte("#!/usr/bin/env php\n"), 0755)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create composer.json with laravel/framework in require-dev
|
||||
composerJSON := `{
|
||||
"name": "test/laravel-project",
|
||||
"require-dev": {
|
||||
"laravel/framework": "^11.0"
|
||||
}
|
||||
}`
|
||||
composerPath := filepath.Join(dir, "composer.json")
|
||||
err = os.WriteFile(composerPath, []byte(composerJSON), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.True(t, IsLaravelProject(dir))
|
||||
})
|
||||
}
|
||||
|
||||
func TestIsLaravelProject_Bad(t *testing.T) {
|
||||
t.Run("missing artisan file", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
// Create composer.json but no artisan
|
||||
composerJSON := `{
|
||||
"name": "test/laravel-project",
|
||||
"require": {
|
||||
"laravel/framework": "^11.0"
|
||||
}
|
||||
}`
|
||||
composerPath := filepath.Join(dir, "composer.json")
|
||||
err := os.WriteFile(composerPath, []byte(composerJSON), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.False(t, IsLaravelProject(dir))
|
||||
})
|
||||
|
||||
t.Run("missing composer.json", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
// Create artisan but no composer.json
|
||||
artisanPath := filepath.Join(dir, "artisan")
|
||||
err := os.WriteFile(artisanPath, []byte("#!/usr/bin/env php\n"), 0755)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.False(t, IsLaravelProject(dir))
|
||||
})
|
||||
|
||||
t.Run("composer.json without Laravel", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
// Create artisan file
|
||||
artisanPath := filepath.Join(dir, "artisan")
|
||||
err := os.WriteFile(artisanPath, []byte("#!/usr/bin/env php\n"), 0755)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create composer.json without laravel/framework
|
||||
composerJSON := `{
|
||||
"name": "test/symfony-project",
|
||||
"require": {
|
||||
"symfony/framework-bundle": "^7.0"
|
||||
}
|
||||
}`
|
||||
composerPath := filepath.Join(dir, "composer.json")
|
||||
err = os.WriteFile(composerPath, []byte(composerJSON), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.False(t, IsLaravelProject(dir))
|
||||
})
|
||||
|
||||
t.Run("invalid composer.json", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
// Create artisan file
|
||||
artisanPath := filepath.Join(dir, "artisan")
|
||||
err := os.WriteFile(artisanPath, []byte("#!/usr/bin/env php\n"), 0755)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create invalid composer.json
|
||||
composerPath := filepath.Join(dir, "composer.json")
|
||||
err = os.WriteFile(composerPath, []byte("not valid json{"), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.False(t, IsLaravelProject(dir))
|
||||
})
|
||||
|
||||
t.Run("empty directory", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
assert.False(t, IsLaravelProject(dir))
|
||||
})
|
||||
|
||||
t.Run("non-existent directory", func(t *testing.T) {
|
||||
assert.False(t, IsLaravelProject("/non/existent/path"))
|
||||
})
|
||||
}
|
||||
|
||||
func TestIsFrankenPHPProject_Good(t *testing.T) {
|
||||
t.Run("project with octane and frankenphp config", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
// Create composer.json with laravel/octane
|
||||
composerJSON := `{
|
||||
"require": {
|
||||
"laravel/octane": "^2.0"
|
||||
}
|
||||
}`
|
||||
err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create config directory and octane.php
|
||||
configDir := filepath.Join(dir, "config")
|
||||
err = os.MkdirAll(configDir, 0755)
|
||||
require.NoError(t, err)
|
||||
|
||||
octaneConfig := `<?php
|
||||
return [
|
||||
'server' => 'frankenphp',
|
||||
];`
|
||||
err = os.WriteFile(filepath.Join(configDir, "octane.php"), []byte(octaneConfig), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.True(t, IsFrankenPHPProject(dir))
|
||||
})
|
||||
|
||||
t.Run("project with octane but no config file", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
// Create composer.json with laravel/octane
|
||||
composerJSON := `{
|
||||
"require": {
|
||||
"laravel/octane": "^2.0"
|
||||
}
|
||||
}`
|
||||
err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
// No config file - should still return true (assume frankenphp)
|
||||
assert.True(t, IsFrankenPHPProject(dir))
|
||||
})
|
||||
|
||||
t.Run("project with octane but unreadable config file", func(t *testing.T) {
|
||||
if os.Geteuid() == 0 {
|
||||
t.Skip("root can read any file")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
|
||||
// Create composer.json with laravel/octane
|
||||
composerJSON := `{
|
||||
"require": {
|
||||
"laravel/octane": "^2.0"
|
||||
}
|
||||
}`
|
||||
err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create config directory and octane.php with no read permissions
|
||||
configDir := filepath.Join(dir, "config")
|
||||
err = os.MkdirAll(configDir, 0755)
|
||||
require.NoError(t, err)
|
||||
|
||||
octanePath := filepath.Join(configDir, "octane.php")
|
||||
err = os.WriteFile(octanePath, []byte("<?php return [];"), 0000)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = os.Chmod(octanePath, 0644) }() // Clean up
|
||||
|
||||
// Should return true (assume frankenphp if unreadable)
|
||||
assert.True(t, IsFrankenPHPProject(dir))
|
||||
})
|
||||
}
|
||||
|
||||
func TestIsFrankenPHPProject_Bad(t *testing.T) {
|
||||
t.Run("project without octane", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
composerJSON := `{
|
||||
"require": {
|
||||
"laravel/framework": "^11.0"
|
||||
}
|
||||
}`
|
||||
err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.False(t, IsFrankenPHPProject(dir))
|
||||
})
|
||||
|
||||
t.Run("missing composer.json", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
assert.False(t, IsFrankenPHPProject(dir))
|
||||
})
|
||||
}
|
||||
|
||||
func TestDetectServices_Good(t *testing.T) {
|
||||
t.Run("full Laravel project with all services", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
// Setup Laravel project
|
||||
err := os.WriteFile(filepath.Join(dir, "artisan"), []byte("#!/usr/bin/env php\n"), 0755)
|
||||
require.NoError(t, err)
|
||||
|
||||
composerJSON := `{
|
||||
"require": {
|
||||
"laravel/framework": "^11.0",
|
||||
"laravel/octane": "^2.0"
|
||||
}
|
||||
}`
|
||||
err = os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Add vite.config.js
|
||||
err = os.WriteFile(filepath.Join(dir, "vite.config.js"), []byte("export default {}"), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Add config directory
|
||||
configDir := filepath.Join(dir, "config")
|
||||
err = os.MkdirAll(configDir, 0755)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Add horizon.php
|
||||
err = os.WriteFile(filepath.Join(configDir, "horizon.php"), []byte("<?php return [];"), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Add reverb.php
|
||||
err = os.WriteFile(filepath.Join(configDir, "reverb.php"), []byte("<?php return [];"), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Add .env with Redis
|
||||
envContent := `APP_NAME=TestApp
|
||||
CACHE_DRIVER=redis
|
||||
REDIS_HOST=127.0.0.1`
|
||||
err = os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
services := DetectServices(dir)
|
||||
|
||||
assert.Contains(t, services, ServiceFrankenPHP)
|
||||
assert.Contains(t, services, ServiceVite)
|
||||
assert.Contains(t, services, ServiceHorizon)
|
||||
assert.Contains(t, services, ServiceReverb)
|
||||
assert.Contains(t, services, ServiceRedis)
|
||||
})
|
||||
|
||||
t.Run("minimal Laravel project", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
// Setup minimal Laravel project
|
||||
err := os.WriteFile(filepath.Join(dir, "artisan"), []byte("#!/usr/bin/env php\n"), 0755)
|
||||
require.NoError(t, err)
|
||||
|
||||
composerJSON := `{
|
||||
"require": {
|
||||
"laravel/framework": "^11.0"
|
||||
}
|
||||
}`
|
||||
err = os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
services := DetectServices(dir)
|
||||
|
||||
assert.Contains(t, services, ServiceFrankenPHP)
|
||||
assert.NotContains(t, services, ServiceVite)
|
||||
assert.NotContains(t, services, ServiceHorizon)
|
||||
assert.NotContains(t, services, ServiceReverb)
|
||||
assert.NotContains(t, services, ServiceRedis)
|
||||
})
|
||||
}
|
||||
|
||||
func TestHasHorizon_Good(t *testing.T) {
|
||||
t.Run("horizon config exists", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
configDir := filepath.Join(dir, "config")
|
||||
err := os.MkdirAll(configDir, 0755)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = os.WriteFile(filepath.Join(configDir, "horizon.php"), []byte("<?php return [];"), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.True(t, hasHorizon(dir))
|
||||
})
|
||||
}
|
||||
|
||||
func TestHasHorizon_Bad(t *testing.T) {
|
||||
t.Run("horizon config missing", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
assert.False(t, hasHorizon(dir))
|
||||
})
|
||||
}
|
||||
|
||||
func TestHasReverb_Good(t *testing.T) {
|
||||
t.Run("reverb config exists", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
configDir := filepath.Join(dir, "config")
|
||||
err := os.MkdirAll(configDir, 0755)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = os.WriteFile(filepath.Join(configDir, "reverb.php"), []byte("<?php return [];"), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.True(t, hasReverb(dir))
|
||||
})
|
||||
}
|
||||
|
||||
func TestHasReverb_Bad(t *testing.T) {
|
||||
t.Run("reverb config missing", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
assert.False(t, hasReverb(dir))
|
||||
})
|
||||
}
|
||||
|
||||
func TestDetectServices_Bad(t *testing.T) {
|
||||
t.Run("non-Laravel project", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
services := DetectServices(dir)
|
||||
assert.Empty(t, services)
|
||||
})
|
||||
}
|
||||
|
||||
func TestDetectPackageManager_Good(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
lockFile string
|
||||
expected string
|
||||
}{
|
||||
{"bun detected", "bun.lockb", "bun"},
|
||||
{"pnpm detected", "pnpm-lock.yaml", "pnpm"},
|
||||
{"yarn detected", "yarn.lock", "yarn"},
|
||||
{"npm detected", "package-lock.json", "npm"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
err := os.WriteFile(filepath.Join(dir, tt.lockFile), []byte(""), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
result := DetectPackageManager(dir)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("no lock file defaults to npm", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
result := DetectPackageManager(dir)
|
||||
assert.Equal(t, "npm", result)
|
||||
})
|
||||
|
||||
t.Run("bun takes priority over npm", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
// Create both lock files
|
||||
err := os.WriteFile(filepath.Join(dir, "bun.lockb"), []byte(""), 0644)
|
||||
require.NoError(t, err)
|
||||
err = os.WriteFile(filepath.Join(dir, "package-lock.json"), []byte(""), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
result := DetectPackageManager(dir)
|
||||
assert.Equal(t, "bun", result)
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetLaravelAppName_Good(t *testing.T) {
|
||||
t.Run("simple app name", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
envContent := `APP_NAME=MyApp
|
||||
APP_ENV=local`
|
||||
err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "MyApp", GetLaravelAppName(dir))
|
||||
})
|
||||
|
||||
t.Run("quoted app name", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
envContent := `APP_NAME="My Awesome App"
|
||||
APP_ENV=local`
|
||||
err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "My Awesome App", GetLaravelAppName(dir))
|
||||
})
|
||||
|
||||
t.Run("single quoted app name", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
envContent := `APP_NAME='My App'
|
||||
APP_ENV=local`
|
||||
err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "My App", GetLaravelAppName(dir))
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetLaravelAppName_Bad(t *testing.T) {
|
||||
t.Run("no .env file", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
assert.Equal(t, "", GetLaravelAppName(dir))
|
||||
})
|
||||
|
||||
t.Run("no APP_NAME in .env", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
envContent := `APP_ENV=local
|
||||
APP_DEBUG=true`
|
||||
err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "", GetLaravelAppName(dir))
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetLaravelAppURL_Good(t *testing.T) {
|
||||
t.Run("standard URL", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
envContent := `APP_NAME=MyApp
|
||||
APP_URL=https://myapp.test`
|
||||
err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "https://myapp.test", GetLaravelAppURL(dir))
|
||||
})
|
||||
|
||||
t.Run("quoted URL", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
envContent := `APP_URL="http://localhost:8000"`
|
||||
err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "http://localhost:8000", GetLaravelAppURL(dir))
|
||||
})
|
||||
}
|
||||
|
||||
func TestExtractDomainFromURL_Good(t *testing.T) {
|
||||
tests := []struct {
|
||||
url string
|
||||
expected string
|
||||
}{
|
||||
{"https://example.com", "example.com"},
|
||||
{"http://example.com", "example.com"},
|
||||
{"https://example.com:8080", "example.com"},
|
||||
{"https://example.com/path/to/page", "example.com"},
|
||||
{"https://example.com:443/path", "example.com"},
|
||||
{"localhost", "localhost"},
|
||||
{"localhost:8000", "localhost"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.url, func(t *testing.T) {
|
||||
result := ExtractDomainFromURL(tt.url)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNeedsRedis_Good(t *testing.T) {
|
||||
t.Run("CACHE_DRIVER=redis", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
envContent := `APP_NAME=Test
|
||||
CACHE_DRIVER=redis`
|
||||
err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.True(t, needsRedis(dir))
|
||||
})
|
||||
|
||||
t.Run("QUEUE_CONNECTION=redis", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
envContent := `APP_NAME=Test
|
||||
QUEUE_CONNECTION=redis`
|
||||
err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.True(t, needsRedis(dir))
|
||||
})
|
||||
|
||||
t.Run("REDIS_HOST localhost", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
envContent := `APP_NAME=Test
|
||||
REDIS_HOST=localhost`
|
||||
err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.True(t, needsRedis(dir))
|
||||
})
|
||||
|
||||
t.Run("REDIS_HOST 127.0.0.1", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
envContent := `APP_NAME=Test
|
||||
REDIS_HOST=127.0.0.1`
|
||||
err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.True(t, needsRedis(dir))
|
||||
})
|
||||
|
||||
t.Run("SESSION_DRIVER=redis", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
envContent := "SESSION_DRIVER=redis"
|
||||
err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, needsRedis(dir))
|
||||
})
|
||||
|
||||
t.Run("BROADCAST_DRIVER=redis", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
envContent := "BROADCAST_DRIVER=redis"
|
||||
err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, needsRedis(dir))
|
||||
})
|
||||
|
||||
t.Run("REDIS_HOST remote (should be false for local dev env)", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
envContent := "REDIS_HOST=redis.example.com"
|
||||
err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, needsRedis(dir))
|
||||
})
|
||||
}
|
||||
|
||||
func TestNeedsRedis_Bad(t *testing.T) {
|
||||
t.Run("no .env file", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
assert.False(t, needsRedis(dir))
|
||||
})
|
||||
|
||||
t.Run("no redis configuration", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
envContent := `APP_NAME=Test
|
||||
CACHE_DRIVER=file
|
||||
QUEUE_CONNECTION=sync`
|
||||
err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.False(t, needsRedis(dir))
|
||||
})
|
||||
|
||||
t.Run("commented redis config", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
envContent := `APP_NAME=Test
|
||||
# CACHE_DRIVER=redis`
|
||||
err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.False(t, needsRedis(dir))
|
||||
})
|
||||
}
|
||||
|
||||
func TestHasVite_Good(t *testing.T) {
|
||||
viteFiles := []string{
|
||||
"vite.config.js",
|
||||
"vite.config.ts",
|
||||
"vite.config.mjs",
|
||||
"vite.config.mts",
|
||||
}
|
||||
|
||||
for _, file := range viteFiles {
|
||||
t.Run(file, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
err := os.WriteFile(filepath.Join(dir, file), []byte("export default {}"), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.True(t, hasVite(dir))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasVite_Bad(t *testing.T) {
|
||||
t.Run("no vite config", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
assert.False(t, hasVite(dir))
|
||||
})
|
||||
|
||||
t.Run("wrong file name", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
err := os.WriteFile(filepath.Join(dir, "vite.config.json"), []byte("{}"), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.False(t, hasVite(dir))
|
||||
})
|
||||
}
|
||||
|
||||
func TestIsFrankenPHPProject_ConfigWithoutFrankenPHP(t *testing.T) {
|
||||
t.Run("octane config without frankenphp", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
// Create composer.json with laravel/octane
|
||||
composerJSON := `{
|
||||
"require": {
|
||||
"laravel/octane": "^2.0"
|
||||
}
|
||||
}`
|
||||
err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create config directory and octane.php without frankenphp
|
||||
configDir := filepath.Join(dir, "config")
|
||||
err = os.MkdirAll(configDir, 0755)
|
||||
require.NoError(t, err)
|
||||
|
||||
octaneConfig := `<?php
|
||||
return [
|
||||
'server' => 'swoole',
|
||||
];`
|
||||
err = os.WriteFile(filepath.Join(configDir, "octane.php"), []byte(octaneConfig), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.False(t, IsFrankenPHPProject(dir))
|
||||
})
|
||||
}
|
||||
398
dockerfile.go
Normal file
398
dockerfile.go
Normal file
|
|
@ -0,0 +1,398 @@
|
|||
package php
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
)
|
||||
|
||||
// DockerfileConfig holds configuration for generating a Dockerfile.
|
||||
type DockerfileConfig struct {
|
||||
// PHPVersion is the PHP version to use (default: "8.3").
|
||||
PHPVersion string
|
||||
|
||||
// BaseImage is the base Docker image (default: "dunglas/frankenphp").
|
||||
BaseImage string
|
||||
|
||||
// PHPExtensions is the list of PHP extensions to install.
|
||||
PHPExtensions []string
|
||||
|
||||
// HasAssets indicates if the project has frontend assets to build.
|
||||
HasAssets bool
|
||||
|
||||
// PackageManager is the Node.js package manager (npm, pnpm, yarn, bun).
|
||||
PackageManager string
|
||||
|
||||
// IsLaravel indicates if this is a Laravel project.
|
||||
IsLaravel bool
|
||||
|
||||
// HasOctane indicates if Laravel Octane is installed.
|
||||
HasOctane bool
|
||||
|
||||
// UseAlpine uses the Alpine-based image (smaller).
|
||||
UseAlpine bool
|
||||
}
|
||||
|
||||
// GenerateDockerfile generates a Dockerfile for a PHP/Laravel project.
|
||||
// It auto-detects dependencies from composer.json and project structure.
|
||||
func GenerateDockerfile(dir string) (string, error) {
|
||||
config, err := DetectDockerfileConfig(dir)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return GenerateDockerfileFromConfig(config), nil
|
||||
}
|
||||
|
||||
// DetectDockerfileConfig detects configuration from project files.
|
||||
func DetectDockerfileConfig(dir string) (*DockerfileConfig, error) {
|
||||
m := getMedium()
|
||||
config := &DockerfileConfig{
|
||||
PHPVersion: "8.3",
|
||||
BaseImage: "dunglas/frankenphp",
|
||||
UseAlpine: true,
|
||||
}
|
||||
|
||||
// Read composer.json
|
||||
composerPath := filepath.Join(dir, "composer.json")
|
||||
composerContent, err := m.Read(composerPath)
|
||||
if err != nil {
|
||||
return nil, cli.WrapVerb(err, "read", "composer.json")
|
||||
}
|
||||
|
||||
var composer ComposerJSON
|
||||
if err := json.Unmarshal([]byte(composerContent), &composer); err != nil {
|
||||
return nil, cli.WrapVerb(err, "parse", "composer.json")
|
||||
}
|
||||
|
||||
// Detect PHP version from composer.json
|
||||
if phpVersion, ok := composer.Require["php"]; ok {
|
||||
config.PHPVersion = extractPHPVersion(phpVersion)
|
||||
}
|
||||
|
||||
// Detect if Laravel
|
||||
if _, ok := composer.Require["laravel/framework"]; ok {
|
||||
config.IsLaravel = true
|
||||
}
|
||||
|
||||
// Detect if Octane
|
||||
if _, ok := composer.Require["laravel/octane"]; ok {
|
||||
config.HasOctane = true
|
||||
}
|
||||
|
||||
// Detect required PHP extensions
|
||||
config.PHPExtensions = detectPHPExtensions(composer)
|
||||
|
||||
// Detect frontend assets
|
||||
config.HasAssets = hasNodeAssets(dir)
|
||||
if config.HasAssets {
|
||||
config.PackageManager = DetectPackageManager(dir)
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// GenerateDockerfileFromConfig generates a Dockerfile from the given configuration.
|
||||
func GenerateDockerfileFromConfig(config *DockerfileConfig) string {
|
||||
var sb strings.Builder
|
||||
|
||||
// Base image
|
||||
baseTag := cli.Sprintf("latest-php%s", config.PHPVersion)
|
||||
if config.UseAlpine {
|
||||
baseTag += "-alpine"
|
||||
}
|
||||
|
||||
sb.WriteString("# Auto-generated Dockerfile for FrankenPHP\n")
|
||||
sb.WriteString("# Generated by Core Framework\n\n")
|
||||
|
||||
// Multi-stage build for smaller images
|
||||
if config.HasAssets {
|
||||
// Frontend build stage
|
||||
sb.WriteString("# Stage 1: Build frontend assets\n")
|
||||
sb.WriteString("FROM node:20-alpine AS frontend\n\n")
|
||||
sb.WriteString("WORKDIR /app\n\n")
|
||||
|
||||
// Copy package files based on package manager
|
||||
switch config.PackageManager {
|
||||
case "pnpm":
|
||||
sb.WriteString("RUN corepack enable && corepack prepare pnpm@latest --activate\n\n")
|
||||
sb.WriteString("COPY package.json pnpm-lock.yaml ./\n")
|
||||
sb.WriteString("RUN pnpm install --frozen-lockfile\n\n")
|
||||
case "yarn":
|
||||
sb.WriteString("COPY package.json yarn.lock ./\n")
|
||||
sb.WriteString("RUN yarn install --frozen-lockfile\n\n")
|
||||
case "bun":
|
||||
sb.WriteString("RUN npm install -g bun\n\n")
|
||||
sb.WriteString("COPY package.json bun.lockb ./\n")
|
||||
sb.WriteString("RUN bun install --frozen-lockfile\n\n")
|
||||
default: // npm
|
||||
sb.WriteString("COPY package.json package-lock.json ./\n")
|
||||
sb.WriteString("RUN npm ci\n\n")
|
||||
}
|
||||
|
||||
sb.WriteString("COPY . .\n\n")
|
||||
|
||||
// Build command
|
||||
switch config.PackageManager {
|
||||
case "pnpm":
|
||||
sb.WriteString("RUN pnpm run build\n\n")
|
||||
case "yarn":
|
||||
sb.WriteString("RUN yarn build\n\n")
|
||||
case "bun":
|
||||
sb.WriteString("RUN bun run build\n\n")
|
||||
default:
|
||||
sb.WriteString("RUN npm run build\n\n")
|
||||
}
|
||||
}
|
||||
|
||||
// PHP build stage
|
||||
stageNum := 2
|
||||
if config.HasAssets {
|
||||
sb.WriteString(cli.Sprintf("# Stage %d: PHP application\n", stageNum))
|
||||
}
|
||||
sb.WriteString(cli.Sprintf("FROM %s:%s AS app\n\n", config.BaseImage, baseTag))
|
||||
|
||||
sb.WriteString("WORKDIR /app\n\n")
|
||||
|
||||
// Install PHP extensions if needed
|
||||
if len(config.PHPExtensions) > 0 {
|
||||
sb.WriteString("# Install PHP extensions\n")
|
||||
sb.WriteString(cli.Sprintf("RUN install-php-extensions %s\n\n", strings.Join(config.PHPExtensions, " ")))
|
||||
}
|
||||
|
||||
// Copy composer files first for better caching
|
||||
sb.WriteString("# Copy composer files\n")
|
||||
sb.WriteString("COPY composer.json composer.lock ./\n\n")
|
||||
|
||||
// Install composer dependencies
|
||||
sb.WriteString("# Install PHP dependencies\n")
|
||||
sb.WriteString("RUN composer install --no-dev --no-scripts --optimize-autoloader --no-interaction\n\n")
|
||||
|
||||
// Copy application code
|
||||
sb.WriteString("# Copy application code\n")
|
||||
sb.WriteString("COPY . .\n\n")
|
||||
|
||||
// Run post-install scripts
|
||||
sb.WriteString("# Run composer scripts\n")
|
||||
sb.WriteString("RUN composer dump-autoload --optimize\n\n")
|
||||
|
||||
// Copy frontend assets if built
|
||||
if config.HasAssets {
|
||||
sb.WriteString("# Copy built frontend assets\n")
|
||||
sb.WriteString("COPY --from=frontend /app/public/build public/build\n\n")
|
||||
}
|
||||
|
||||
// Laravel-specific setup
|
||||
if config.IsLaravel {
|
||||
sb.WriteString("# Laravel setup\n")
|
||||
sb.WriteString("RUN php artisan config:cache \\\n")
|
||||
sb.WriteString(" && php artisan route:cache \\\n")
|
||||
sb.WriteString(" && php artisan view:cache\n\n")
|
||||
|
||||
// Set permissions
|
||||
sb.WriteString("# Set permissions for Laravel\n")
|
||||
sb.WriteString("RUN chown -R www-data:www-data storage bootstrap/cache \\\n")
|
||||
sb.WriteString(" && chmod -R 775 storage bootstrap/cache\n\n")
|
||||
}
|
||||
|
||||
// Expose ports
|
||||
sb.WriteString("# Expose ports\n")
|
||||
sb.WriteString("EXPOSE 80 443\n\n")
|
||||
|
||||
// Health check
|
||||
sb.WriteString("# Health check\n")
|
||||
sb.WriteString("HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \\\n")
|
||||
sb.WriteString(" CMD curl -f http://localhost/up || exit 1\n\n")
|
||||
|
||||
// Start command
|
||||
sb.WriteString("# Start FrankenPHP\n")
|
||||
if config.HasOctane {
|
||||
sb.WriteString("CMD [\"php\", \"artisan\", \"octane:start\", \"--server=frankenphp\", \"--host=0.0.0.0\", \"--port=80\"]\n")
|
||||
} else {
|
||||
sb.WriteString("CMD [\"frankenphp\", \"run\", \"--config\", \"/etc/caddy/Caddyfile\"]\n")
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// ComposerJSON represents the structure of composer.json.
|
||||
type ComposerJSON struct {
|
||||
Name string `json:"name"`
|
||||
Require map[string]string `json:"require"`
|
||||
RequireDev map[string]string `json:"require-dev"`
|
||||
}
|
||||
|
||||
// detectPHPExtensions detects required PHP extensions from composer.json.
|
||||
func detectPHPExtensions(composer ComposerJSON) []string {
|
||||
extensionMap := make(map[string]bool)
|
||||
|
||||
// Check for common packages and their required extensions
|
||||
packageExtensions := map[string][]string{
|
||||
// Database
|
||||
"doctrine/dbal": {"pdo_mysql", "pdo_pgsql"},
|
||||
"illuminate/database": {"pdo_mysql"},
|
||||
"laravel/framework": {"pdo_mysql", "bcmath", "ctype", "fileinfo", "mbstring", "openssl", "tokenizer", "xml"},
|
||||
"mongodb/mongodb": {"mongodb"},
|
||||
"predis/predis": {"redis"},
|
||||
"phpredis/phpredis": {"redis"},
|
||||
"laravel/horizon": {"redis", "pcntl"},
|
||||
"aws/aws-sdk-php": {"curl"},
|
||||
"intervention/image": {"gd"},
|
||||
"intervention/image-laravel": {"gd"},
|
||||
"spatie/image": {"gd"},
|
||||
"league/flysystem-aws-s3-v3": {"curl"},
|
||||
"guzzlehttp/guzzle": {"curl"},
|
||||
"nelmio/cors-bundle": {},
|
||||
// Queues
|
||||
"laravel/reverb": {"pcntl"},
|
||||
"php-amqplib/php-amqplib": {"sockets"},
|
||||
// Misc
|
||||
"moneyphp/money": {"bcmath", "intl"},
|
||||
"symfony/intl": {"intl"},
|
||||
"nesbot/carbon": {"intl"},
|
||||
"spatie/laravel-medialibrary": {"exif", "gd"},
|
||||
}
|
||||
|
||||
// Check all require and require-dev dependencies
|
||||
allDeps := make(map[string]string)
|
||||
for pkg, ver := range composer.Require {
|
||||
allDeps[pkg] = ver
|
||||
}
|
||||
for pkg, ver := range composer.RequireDev {
|
||||
allDeps[pkg] = ver
|
||||
}
|
||||
|
||||
// Find required extensions
|
||||
for pkg := range allDeps {
|
||||
if exts, ok := packageExtensions[pkg]; ok {
|
||||
for _, ext := range exts {
|
||||
extensionMap[ext] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Check for direct ext- requirements
|
||||
if strings.HasPrefix(pkg, "ext-") {
|
||||
ext := strings.TrimPrefix(pkg, "ext-")
|
||||
// Skip extensions that are built into PHP
|
||||
builtIn := map[string]bool{
|
||||
"json": true, "ctype": true, "iconv": true,
|
||||
"session": true, "simplexml": true, "pdo": true,
|
||||
"xml": true, "tokenizer": true,
|
||||
}
|
||||
if !builtIn[ext] {
|
||||
extensionMap[ext] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to sorted slice
|
||||
extensions := make([]string, 0, len(extensionMap))
|
||||
for ext := range extensionMap {
|
||||
extensions = append(extensions, ext)
|
||||
}
|
||||
sort.Strings(extensions)
|
||||
|
||||
return extensions
|
||||
}
|
||||
|
||||
// extractPHPVersion extracts a clean PHP version from a composer constraint.
|
||||
func extractPHPVersion(constraint string) string {
|
||||
// Handle common formats: ^8.2, >=8.2, 8.2.*, ~8.2
|
||||
constraint = strings.TrimLeft(constraint, "^>=~")
|
||||
constraint = strings.TrimRight(constraint, ".*")
|
||||
|
||||
// Extract major.minor
|
||||
parts := strings.Split(constraint, ".")
|
||||
if len(parts) >= 2 {
|
||||
return parts[0] + "." + parts[1]
|
||||
}
|
||||
if len(parts) == 1 {
|
||||
return parts[0] + ".0"
|
||||
}
|
||||
|
||||
return "8.3" // default
|
||||
}
|
||||
|
||||
// hasNodeAssets checks if the project has frontend assets.
|
||||
func hasNodeAssets(dir string) bool {
|
||||
m := getMedium()
|
||||
packageJSON := filepath.Join(dir, "package.json")
|
||||
if !m.IsFile(packageJSON) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check for build script in package.json
|
||||
content, err := m.Read(packageJSON)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
var pkg struct {
|
||||
Scripts map[string]string `json:"scripts"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(content), &pkg); err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if there's a build script
|
||||
_, hasBuild := pkg.Scripts["build"]
|
||||
return hasBuild
|
||||
}
|
||||
|
||||
// GenerateDockerignore generates a .dockerignore file content for PHP projects.
|
||||
func GenerateDockerignore(dir string) string {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString("# Git\n")
|
||||
sb.WriteString(".git\n")
|
||||
sb.WriteString(".gitignore\n")
|
||||
sb.WriteString(".gitattributes\n\n")
|
||||
|
||||
sb.WriteString("# Node\n")
|
||||
sb.WriteString("node_modules\n\n")
|
||||
|
||||
sb.WriteString("# Development\n")
|
||||
sb.WriteString(".env\n")
|
||||
sb.WriteString(".env.local\n")
|
||||
sb.WriteString(".env.*.local\n")
|
||||
sb.WriteString("*.log\n")
|
||||
sb.WriteString(".phpunit.result.cache\n")
|
||||
sb.WriteString("phpunit.xml\n")
|
||||
sb.WriteString(".php-cs-fixer.cache\n")
|
||||
sb.WriteString("phpstan.neon\n\n")
|
||||
|
||||
sb.WriteString("# IDE\n")
|
||||
sb.WriteString(".idea\n")
|
||||
sb.WriteString(".vscode\n")
|
||||
sb.WriteString("*.swp\n")
|
||||
sb.WriteString("*.swo\n\n")
|
||||
|
||||
sb.WriteString("# Laravel specific\n")
|
||||
sb.WriteString("storage/app/*\n")
|
||||
sb.WriteString("storage/logs/*\n")
|
||||
sb.WriteString("storage/framework/cache/*\n")
|
||||
sb.WriteString("storage/framework/sessions/*\n")
|
||||
sb.WriteString("storage/framework/views/*\n")
|
||||
sb.WriteString("bootstrap/cache/*\n\n")
|
||||
|
||||
sb.WriteString("# Build artifacts\n")
|
||||
sb.WriteString("public/hot\n")
|
||||
sb.WriteString("public/storage\n")
|
||||
sb.WriteString("vendor\n\n")
|
||||
|
||||
sb.WriteString("# Docker\n")
|
||||
sb.WriteString("Dockerfile*\n")
|
||||
sb.WriteString("docker-compose*.yml\n")
|
||||
sb.WriteString(".dockerignore\n\n")
|
||||
|
||||
sb.WriteString("# Documentation\n")
|
||||
sb.WriteString("README.md\n")
|
||||
sb.WriteString("CHANGELOG.md\n")
|
||||
sb.WriteString("docs\n")
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
634
dockerfile_test.go
Normal file
634
dockerfile_test.go
Normal file
|
|
@ -0,0 +1,634 @@
|
|||
package php
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGenerateDockerfile_Good(t *testing.T) {
|
||||
t.Run("basic Laravel project", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
// Create composer.json
|
||||
composerJSON := `{
|
||||
"name": "test/laravel-project",
|
||||
"require": {
|
||||
"php": "^8.2",
|
||||
"laravel/framework": "^11.0"
|
||||
}
|
||||
}`
|
||||
err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create composer.lock
|
||||
err = os.WriteFile(filepath.Join(dir, "composer.lock"), []byte("{}"), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
content, err := GenerateDockerfile(dir)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Check content
|
||||
assert.Contains(t, content, "FROM dunglas/frankenphp")
|
||||
assert.Contains(t, content, "php8.2")
|
||||
assert.Contains(t, content, "COPY composer.json composer.lock")
|
||||
assert.Contains(t, content, "composer install")
|
||||
assert.Contains(t, content, "EXPOSE 80 443")
|
||||
})
|
||||
|
||||
t.Run("Laravel project with Octane", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
composerJSON := `{
|
||||
"name": "test/laravel-octane",
|
||||
"require": {
|
||||
"php": "^8.3",
|
||||
"laravel/framework": "^11.0",
|
||||
"laravel/octane": "^2.0"
|
||||
}
|
||||
}`
|
||||
err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644)
|
||||
require.NoError(t, err)
|
||||
err = os.WriteFile(filepath.Join(dir, "composer.lock"), []byte("{}"), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
content, err := GenerateDockerfile(dir)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Contains(t, content, "php8.3")
|
||||
assert.Contains(t, content, "octane:start")
|
||||
})
|
||||
|
||||
t.Run("project with frontend assets", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
composerJSON := `{
|
||||
"name": "test/laravel-vite",
|
||||
"require": {
|
||||
"php": "^8.3",
|
||||
"laravel/framework": "^11.0"
|
||||
}
|
||||
}`
|
||||
err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644)
|
||||
require.NoError(t, err)
|
||||
err = os.WriteFile(filepath.Join(dir, "composer.lock"), []byte("{}"), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
packageJSON := `{
|
||||
"name": "test-app",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build"
|
||||
}
|
||||
}`
|
||||
err = os.WriteFile(filepath.Join(dir, "package.json"), []byte(packageJSON), 0644)
|
||||
require.NoError(t, err)
|
||||
err = os.WriteFile(filepath.Join(dir, "package-lock.json"), []byte("{}"), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
content, err := GenerateDockerfile(dir)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should have multi-stage build
|
||||
assert.Contains(t, content, "FROM node:20-alpine AS frontend")
|
||||
assert.Contains(t, content, "npm ci")
|
||||
assert.Contains(t, content, "npm run build")
|
||||
assert.Contains(t, content, "COPY --from=frontend")
|
||||
})
|
||||
|
||||
t.Run("project with pnpm", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
composerJSON := `{
|
||||
"name": "test/laravel-pnpm",
|
||||
"require": {
|
||||
"php": "^8.3",
|
||||
"laravel/framework": "^11.0"
|
||||
}
|
||||
}`
|
||||
err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644)
|
||||
require.NoError(t, err)
|
||||
err = os.WriteFile(filepath.Join(dir, "composer.lock"), []byte("{}"), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
packageJSON := `{
|
||||
"name": "test-app",
|
||||
"scripts": {
|
||||
"build": "vite build"
|
||||
}
|
||||
}`
|
||||
err = os.WriteFile(filepath.Join(dir, "package.json"), []byte(packageJSON), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create pnpm-lock.yaml
|
||||
err = os.WriteFile(filepath.Join(dir, "pnpm-lock.yaml"), []byte("lockfileVersion: 6.0"), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
content, err := GenerateDockerfile(dir)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Contains(t, content, "pnpm install")
|
||||
assert.Contains(t, content, "pnpm run build")
|
||||
})
|
||||
|
||||
t.Run("project with Redis dependency", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
composerJSON := `{
|
||||
"name": "test/laravel-redis",
|
||||
"require": {
|
||||
"php": "^8.3",
|
||||
"laravel/framework": "^11.0",
|
||||
"predis/predis": "^2.0"
|
||||
}
|
||||
}`
|
||||
err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644)
|
||||
require.NoError(t, err)
|
||||
err = os.WriteFile(filepath.Join(dir, "composer.lock"), []byte("{}"), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
content, err := GenerateDockerfile(dir)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Contains(t, content, "install-php-extensions")
|
||||
assert.Contains(t, content, "redis")
|
||||
})
|
||||
|
||||
t.Run("project with explicit ext- requirements", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
composerJSON := `{
|
||||
"name": "test/with-extensions",
|
||||
"require": {
|
||||
"php": "^8.3",
|
||||
"ext-gd": "*",
|
||||
"ext-imagick": "*",
|
||||
"ext-intl": "*"
|
||||
}
|
||||
}`
|
||||
err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644)
|
||||
require.NoError(t, err)
|
||||
err = os.WriteFile(filepath.Join(dir, "composer.lock"), []byte("{}"), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
content, err := GenerateDockerfile(dir)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Contains(t, content, "install-php-extensions")
|
||||
assert.Contains(t, content, "gd")
|
||||
assert.Contains(t, content, "imagick")
|
||||
assert.Contains(t, content, "intl")
|
||||
})
|
||||
}
|
||||
|
||||
func TestGenerateDockerfile_Bad(t *testing.T) {
|
||||
t.Run("missing composer.json", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
_, err := GenerateDockerfile(dir)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "composer.json")
|
||||
})
|
||||
|
||||
t.Run("invalid composer.json", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte("not json{"), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = GenerateDockerfile(dir)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestDetectDockerfileConfig_Good(t *testing.T) {
|
||||
t.Run("full Laravel project", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
composerJSON := `{
|
||||
"name": "test/full-laravel",
|
||||
"require": {
|
||||
"php": "^8.3",
|
||||
"laravel/framework": "^11.0",
|
||||
"laravel/octane": "^2.0",
|
||||
"predis/predis": "^2.0",
|
||||
"intervention/image": "^3.0"
|
||||
}
|
||||
}`
|
||||
err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
packageJSON := `{"scripts": {"build": "vite build"}}`
|
||||
err = os.WriteFile(filepath.Join(dir, "package.json"), []byte(packageJSON), 0644)
|
||||
require.NoError(t, err)
|
||||
err = os.WriteFile(filepath.Join(dir, "yarn.lock"), []byte(""), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
config, err := DetectDockerfileConfig(dir)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "8.3", config.PHPVersion)
|
||||
assert.True(t, config.IsLaravel)
|
||||
assert.True(t, config.HasOctane)
|
||||
assert.True(t, config.HasAssets)
|
||||
assert.Equal(t, "yarn", config.PackageManager)
|
||||
assert.Contains(t, config.PHPExtensions, "redis")
|
||||
assert.Contains(t, config.PHPExtensions, "gd")
|
||||
})
|
||||
}
|
||||
|
||||
func TestDetectDockerfileConfig_Bad(t *testing.T) {
|
||||
t.Run("non-existent directory", func(t *testing.T) {
|
||||
_, err := DetectDockerfileConfig("/non/existent/path")
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestExtractPHPVersion_Good(t *testing.T) {
|
||||
tests := []struct {
|
||||
constraint string
|
||||
expected string
|
||||
}{
|
||||
{"^8.2", "8.2"},
|
||||
{"^8.3", "8.3"},
|
||||
{">=8.2", "8.2"},
|
||||
{"~8.2", "8.2"},
|
||||
{"8.2.*", "8.2"},
|
||||
{"8.2.0", "8.2"},
|
||||
{"8", "8.0"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.constraint, func(t *testing.T) {
|
||||
result := extractPHPVersion(tt.constraint)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectPHPExtensions_Good(t *testing.T) {
|
||||
t.Run("detects Redis from predis", func(t *testing.T) {
|
||||
composer := ComposerJSON{
|
||||
Require: map[string]string{
|
||||
"predis/predis": "^2.0",
|
||||
},
|
||||
}
|
||||
|
||||
extensions := detectPHPExtensions(composer)
|
||||
assert.Contains(t, extensions, "redis")
|
||||
})
|
||||
|
||||
t.Run("detects GD from intervention/image", func(t *testing.T) {
|
||||
composer := ComposerJSON{
|
||||
Require: map[string]string{
|
||||
"intervention/image": "^3.0",
|
||||
},
|
||||
}
|
||||
|
||||
extensions := detectPHPExtensions(composer)
|
||||
assert.Contains(t, extensions, "gd")
|
||||
})
|
||||
|
||||
t.Run("detects multiple extensions from Laravel", func(t *testing.T) {
|
||||
composer := ComposerJSON{
|
||||
Require: map[string]string{
|
||||
"laravel/framework": "^11.0",
|
||||
},
|
||||
}
|
||||
|
||||
extensions := detectPHPExtensions(composer)
|
||||
assert.Contains(t, extensions, "pdo_mysql")
|
||||
assert.Contains(t, extensions, "bcmath")
|
||||
})
|
||||
|
||||
t.Run("detects explicit ext- requirements", func(t *testing.T) {
|
||||
composer := ComposerJSON{
|
||||
Require: map[string]string{
|
||||
"ext-gd": "*",
|
||||
"ext-imagick": "*",
|
||||
},
|
||||
}
|
||||
|
||||
extensions := detectPHPExtensions(composer)
|
||||
assert.Contains(t, extensions, "gd")
|
||||
assert.Contains(t, extensions, "imagick")
|
||||
})
|
||||
|
||||
t.Run("skips built-in extensions", func(t *testing.T) {
|
||||
composer := ComposerJSON{
|
||||
Require: map[string]string{
|
||||
"ext-json": "*",
|
||||
"ext-session": "*",
|
||||
"ext-pdo": "*",
|
||||
},
|
||||
}
|
||||
|
||||
extensions := detectPHPExtensions(composer)
|
||||
assert.NotContains(t, extensions, "json")
|
||||
assert.NotContains(t, extensions, "session")
|
||||
assert.NotContains(t, extensions, "pdo")
|
||||
})
|
||||
|
||||
t.Run("sorts extensions alphabetically", func(t *testing.T) {
|
||||
composer := ComposerJSON{
|
||||
Require: map[string]string{
|
||||
"ext-zip": "*",
|
||||
"ext-gd": "*",
|
||||
"ext-intl": "*",
|
||||
},
|
||||
}
|
||||
|
||||
extensions := detectPHPExtensions(composer)
|
||||
|
||||
// Check they are sorted
|
||||
for i := 1; i < len(extensions); i++ {
|
||||
assert.True(t, extensions[i-1] < extensions[i],
|
||||
"extensions should be sorted: %v", extensions)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestHasNodeAssets_Good(t *testing.T) {
|
||||
t.Run("with build script", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
packageJSON := `{
|
||||
"name": "test",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build"
|
||||
}
|
||||
}`
|
||||
err := os.WriteFile(filepath.Join(dir, "package.json"), []byte(packageJSON), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.True(t, hasNodeAssets(dir))
|
||||
})
|
||||
}
|
||||
|
||||
func TestHasNodeAssets_Bad(t *testing.T) {
|
||||
t.Run("no package.json", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
assert.False(t, hasNodeAssets(dir))
|
||||
})
|
||||
|
||||
t.Run("no build script", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
packageJSON := `{
|
||||
"name": "test",
|
||||
"scripts": {
|
||||
"dev": "vite"
|
||||
}
|
||||
}`
|
||||
err := os.WriteFile(filepath.Join(dir, "package.json"), []byte(packageJSON), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.False(t, hasNodeAssets(dir))
|
||||
})
|
||||
|
||||
t.Run("invalid package.json", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
err := os.WriteFile(filepath.Join(dir, "package.json"), []byte("invalid{"), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.False(t, hasNodeAssets(dir))
|
||||
})
|
||||
}
|
||||
|
||||
func TestGenerateDockerignore_Good(t *testing.T) {
|
||||
t.Run("generates complete dockerignore", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
content := GenerateDockerignore(dir)
|
||||
|
||||
// Check key entries
|
||||
assert.Contains(t, content, ".git")
|
||||
assert.Contains(t, content, "node_modules")
|
||||
assert.Contains(t, content, ".env")
|
||||
assert.Contains(t, content, "vendor")
|
||||
assert.Contains(t, content, "storage/logs/*")
|
||||
assert.Contains(t, content, ".idea")
|
||||
assert.Contains(t, content, ".vscode")
|
||||
})
|
||||
}
|
||||
|
||||
func TestGenerateDockerfileFromConfig_Good(t *testing.T) {
|
||||
t.Run("minimal config", func(t *testing.T) {
|
||||
config := &DockerfileConfig{
|
||||
PHPVersion: "8.3",
|
||||
BaseImage: "dunglas/frankenphp",
|
||||
UseAlpine: true,
|
||||
}
|
||||
|
||||
content := GenerateDockerfileFromConfig(config)
|
||||
|
||||
assert.Contains(t, content, "FROM dunglas/frankenphp:latest-php8.3-alpine")
|
||||
assert.Contains(t, content, "WORKDIR /app")
|
||||
assert.Contains(t, content, "COPY composer.json composer.lock")
|
||||
assert.Contains(t, content, "EXPOSE 80 443")
|
||||
})
|
||||
|
||||
t.Run("with extensions", func(t *testing.T) {
|
||||
config := &DockerfileConfig{
|
||||
PHPVersion: "8.3",
|
||||
BaseImage: "dunglas/frankenphp",
|
||||
UseAlpine: true,
|
||||
PHPExtensions: []string{"redis", "gd", "intl"},
|
||||
}
|
||||
|
||||
content := GenerateDockerfileFromConfig(config)
|
||||
|
||||
assert.Contains(t, content, "install-php-extensions redis gd intl")
|
||||
})
|
||||
|
||||
t.Run("Laravel with Octane", func(t *testing.T) {
|
||||
config := &DockerfileConfig{
|
||||
PHPVersion: "8.3",
|
||||
BaseImage: "dunglas/frankenphp",
|
||||
UseAlpine: true,
|
||||
IsLaravel: true,
|
||||
HasOctane: true,
|
||||
}
|
||||
|
||||
content := GenerateDockerfileFromConfig(config)
|
||||
|
||||
assert.Contains(t, content, "php artisan config:cache")
|
||||
assert.Contains(t, content, "php artisan route:cache")
|
||||
assert.Contains(t, content, "php artisan view:cache")
|
||||
assert.Contains(t, content, "chown -R www-data:www-data storage")
|
||||
assert.Contains(t, content, "octane:start")
|
||||
})
|
||||
|
||||
t.Run("with frontend assets", func(t *testing.T) {
|
||||
config := &DockerfileConfig{
|
||||
PHPVersion: "8.3",
|
||||
BaseImage: "dunglas/frankenphp",
|
||||
UseAlpine: true,
|
||||
HasAssets: true,
|
||||
PackageManager: "npm",
|
||||
}
|
||||
|
||||
content := GenerateDockerfileFromConfig(config)
|
||||
|
||||
// Multi-stage build
|
||||
assert.Contains(t, content, "FROM node:20-alpine AS frontend")
|
||||
assert.Contains(t, content, "COPY package.json package-lock.json")
|
||||
assert.Contains(t, content, "RUN npm ci")
|
||||
assert.Contains(t, content, "RUN npm run build")
|
||||
assert.Contains(t, content, "COPY --from=frontend /app/public/build public/build")
|
||||
})
|
||||
|
||||
t.Run("with yarn", func(t *testing.T) {
|
||||
config := &DockerfileConfig{
|
||||
PHPVersion: "8.3",
|
||||
BaseImage: "dunglas/frankenphp",
|
||||
UseAlpine: true,
|
||||
HasAssets: true,
|
||||
PackageManager: "yarn",
|
||||
}
|
||||
|
||||
content := GenerateDockerfileFromConfig(config)
|
||||
|
||||
assert.Contains(t, content, "COPY package.json yarn.lock")
|
||||
assert.Contains(t, content, "yarn install --frozen-lockfile")
|
||||
assert.Contains(t, content, "yarn build")
|
||||
})
|
||||
|
||||
t.Run("with bun", func(t *testing.T) {
|
||||
config := &DockerfileConfig{
|
||||
PHPVersion: "8.3",
|
||||
BaseImage: "dunglas/frankenphp",
|
||||
UseAlpine: true,
|
||||
HasAssets: true,
|
||||
PackageManager: "bun",
|
||||
}
|
||||
|
||||
content := GenerateDockerfileFromConfig(config)
|
||||
|
||||
assert.Contains(t, content, "npm install -g bun")
|
||||
assert.Contains(t, content, "COPY package.json bun.lockb")
|
||||
assert.Contains(t, content, "bun install --frozen-lockfile")
|
||||
assert.Contains(t, content, "bun run build")
|
||||
})
|
||||
|
||||
t.Run("non-alpine image", func(t *testing.T) {
|
||||
config := &DockerfileConfig{
|
||||
PHPVersion: "8.3",
|
||||
BaseImage: "dunglas/frankenphp",
|
||||
UseAlpine: false,
|
||||
}
|
||||
|
||||
content := GenerateDockerfileFromConfig(config)
|
||||
|
||||
assert.Contains(t, content, "FROM dunglas/frankenphp:latest-php8.3 AS app")
|
||||
assert.NotContains(t, content, "alpine")
|
||||
})
|
||||
}
|
||||
|
||||
func TestIsPHPProject_Good(t *testing.T) {
|
||||
t.Run("project with composer.json", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte("{}"), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.True(t, IsPHPProject(dir))
|
||||
})
|
||||
}
|
||||
|
||||
func TestIsPHPProject_Bad(t *testing.T) {
|
||||
t.Run("project without composer.json", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
assert.False(t, IsPHPProject(dir))
|
||||
})
|
||||
|
||||
t.Run("non-existent directory", func(t *testing.T) {
|
||||
assert.False(t, IsPHPProject("/non/existent/path"))
|
||||
})
|
||||
}
|
||||
|
||||
func TestExtractPHPVersion_Edge(t *testing.T) {
|
||||
t.Run("handles single major version", func(t *testing.T) {
|
||||
result := extractPHPVersion("8")
|
||||
assert.Equal(t, "8.0", result)
|
||||
})
|
||||
}
|
||||
|
||||
func TestDetectPHPExtensions_RequireDev(t *testing.T) {
|
||||
t.Run("detects extensions from require-dev", func(t *testing.T) {
|
||||
composer := ComposerJSON{
|
||||
RequireDev: map[string]string{
|
||||
"predis/predis": "^2.0",
|
||||
},
|
||||
}
|
||||
|
||||
extensions := detectPHPExtensions(composer)
|
||||
assert.Contains(t, extensions, "redis")
|
||||
})
|
||||
}
|
||||
|
||||
func TestDockerfileStructure_Good(t *testing.T) {
|
||||
t.Run("Dockerfile has proper structure", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
composerJSON := `{
|
||||
"name": "test/app",
|
||||
"require": {
|
||||
"php": "^8.3",
|
||||
"laravel/framework": "^11.0",
|
||||
"laravel/octane": "^2.0",
|
||||
"predis/predis": "^2.0"
|
||||
}
|
||||
}`
|
||||
err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644)
|
||||
require.NoError(t, err)
|
||||
err = os.WriteFile(filepath.Join(dir, "composer.lock"), []byte("{}"), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
packageJSON := `{"scripts": {"build": "vite build"}}`
|
||||
err = os.WriteFile(filepath.Join(dir, "package.json"), []byte(packageJSON), 0644)
|
||||
require.NoError(t, err)
|
||||
err = os.WriteFile(filepath.Join(dir, "package-lock.json"), []byte("{}"), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
content, err := GenerateDockerfile(dir)
|
||||
require.NoError(t, err)
|
||||
|
||||
lines := strings.Split(content, "\n")
|
||||
var fromCount, workdirCount, copyCount, runCount, exposeCount, cmdCount int
|
||||
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
switch {
|
||||
case strings.HasPrefix(trimmed, "FROM "):
|
||||
fromCount++
|
||||
case strings.HasPrefix(trimmed, "WORKDIR "):
|
||||
workdirCount++
|
||||
case strings.HasPrefix(trimmed, "COPY "):
|
||||
copyCount++
|
||||
case strings.HasPrefix(trimmed, "RUN "):
|
||||
runCount++
|
||||
case strings.HasPrefix(trimmed, "EXPOSE "):
|
||||
exposeCount++
|
||||
case strings.HasPrefix(trimmed, "CMD ["):
|
||||
// Only count actual CMD instructions, not HEALTHCHECK CMD
|
||||
cmdCount++
|
||||
}
|
||||
}
|
||||
|
||||
// Multi-stage build should have 2 FROM statements
|
||||
assert.Equal(t, 2, fromCount, "should have 2 FROM statements for multi-stage build")
|
||||
|
||||
// Should have proper structure
|
||||
assert.GreaterOrEqual(t, workdirCount, 1, "should have WORKDIR")
|
||||
assert.GreaterOrEqual(t, copyCount, 3, "should have multiple COPY statements")
|
||||
assert.GreaterOrEqual(t, runCount, 2, "should have multiple RUN statements")
|
||||
assert.Equal(t, 1, exposeCount, "should have exactly one EXPOSE")
|
||||
assert.Equal(t, 1, cmdCount, "should have exactly one CMD")
|
||||
})
|
||||
}
|
||||
25
go.mod
Normal file
25
go.mod
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
module forge.lthn.ai/core/php
|
||||
|
||||
go 1.25.5
|
||||
|
||||
require (
|
||||
forge.lthn.ai/core/go v0.0.0
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/stretchr/testify v1.11.1
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/ProtonMail/go-crypto v1.3.0 // indirect
|
||||
github.com/cloudflare/circl v1.6.3 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/term v0.40.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
)
|
||||
|
||||
replace forge.lthn.ai/core/go => ../go
|
||||
32
go.sum
Normal file
32
go.sum
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw=
|
||||
github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE=
|
||||
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
|
||||
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
||||
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
16
i18n.go
Normal file
16
i18n.go
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
// Package php provides PHP/Laravel development tools.
|
||||
package php
|
||||
|
||||
import (
|
||||
"embed"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
)
|
||||
|
||||
//go:embed locales/*.json
|
||||
var localeFS embed.FS
|
||||
|
||||
func init() {
|
||||
// Register PHP translations with the i18n system
|
||||
i18n.RegisterLocales(localeFS, "locales")
|
||||
}
|
||||
147
locales/en_GB.json
Normal file
147
locales/en_GB.json
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
{
|
||||
"cmd": {
|
||||
"php": {
|
||||
"short": "Laravel/PHP development tools",
|
||||
"long": "Laravel and PHP development tools including testing, formatting, static analysis, and deployment",
|
||||
"label": {
|
||||
"php": "PHP:",
|
||||
"audit": "Audit:",
|
||||
"psalm": "Psalm:",
|
||||
"rector": "Rector:",
|
||||
"security": "Security:",
|
||||
"infection": "Infection:",
|
||||
"info": "Info:",
|
||||
"setup": "Setup:"
|
||||
},
|
||||
"error": {
|
||||
"not_php": "Not a PHP project (no composer.json found)",
|
||||
"fmt_failed": "Formatting failed",
|
||||
"fmt_issues": "Style issues found",
|
||||
"analysis_issues": "Analysis errors found",
|
||||
"audit_failed": "Audit failed",
|
||||
"vulns_found": "Vulnerabilities found",
|
||||
"psalm_not_installed": "Psalm not installed",
|
||||
"psalm_issues": "Psalm found type errors",
|
||||
"rector_not_installed": "Rector not installed",
|
||||
"rector_failed": "Rector failed",
|
||||
"infection_not_installed": "Infection not installed",
|
||||
"infection_failed": "Mutation testing failed",
|
||||
"security_failed": "Security check failed",
|
||||
"critical_high_issues": "Critical or high severity issues found"
|
||||
},
|
||||
"test": {
|
||||
"short": "Run PHPUnit/Pest tests",
|
||||
"long": "Run PHPUnit or Pest tests with optional filtering, parallel execution, and coverage",
|
||||
"flag": {
|
||||
"parallel": "Run tests in parallel",
|
||||
"coverage": "Generate code coverage report",
|
||||
"filter": "Filter tests by name",
|
||||
"group": "Run only tests in this group"
|
||||
}
|
||||
},
|
||||
"fmt": {
|
||||
"short": "Format PHP code with Laravel Pint",
|
||||
"long": "Format PHP code using Laravel Pint code style fixer",
|
||||
"no_formatter": "No code formatter found (install laravel/pint)",
|
||||
"no_issues": "No style issues found",
|
||||
"formatting": "Formatting with {{.Formatter}}...",
|
||||
"flag": {
|
||||
"fix": "Fix style issues (default: check only)"
|
||||
}
|
||||
},
|
||||
"analyse": {
|
||||
"short": "Run PHPStan static analysis",
|
||||
"long": "Run PHPStan/Larastan for static code analysis",
|
||||
"no_analyser": "No static analyser found (install phpstan/phpstan or nunomaduro/larastan)",
|
||||
"flag": {
|
||||
"level": "Analysis level (0-9, default: from config)",
|
||||
"memory": "Memory limit (e.g., 2G)"
|
||||
}
|
||||
},
|
||||
"audit": {
|
||||
"short": "Security audit for dependencies",
|
||||
"long": "Audit Composer and NPM dependencies for known vulnerabilities",
|
||||
"scanning": "Scanning dependencies for vulnerabilities...",
|
||||
"secure": "No vulnerabilities",
|
||||
"error": "Audit error",
|
||||
"vulnerabilities": "{{.Count}} vulnerabilities found",
|
||||
"found_vulns": "Found {{.Count}} vulnerabilities",
|
||||
"all_secure": "All dependencies secure",
|
||||
"completed_errors": "Audit completed with errors",
|
||||
"flag": {
|
||||
"fix": "Attempt to fix vulnerabilities"
|
||||
}
|
||||
},
|
||||
"psalm": {
|
||||
"short": "Run Psalm static analysis",
|
||||
"long": "Run Psalm for deep static analysis and type checking",
|
||||
"not_found": "Psalm not found",
|
||||
"install": "composer require --dev vimeo/psalm",
|
||||
"setup": "vendor/bin/psalm --init",
|
||||
"analysing": "Analysing with Psalm...",
|
||||
"analysing_fixing": "Analysing and fixing with Psalm...",
|
||||
"flag": {
|
||||
"level": "Analysis level (1-8)",
|
||||
"baseline": "Generate or update baseline",
|
||||
"show_info": "Show informational issues"
|
||||
}
|
||||
},
|
||||
"rector": {
|
||||
"short": "Automated code refactoring",
|
||||
"long": "Run Rector for automated code upgrades and refactoring",
|
||||
"not_found": "Rector not found",
|
||||
"install": "composer require --dev rector/rector",
|
||||
"setup": "vendor/bin/rector init",
|
||||
"analysing": "Analysing code for refactoring opportunities...",
|
||||
"refactoring": "Refactoring code...",
|
||||
"no_changes": "No refactoring changes needed",
|
||||
"changes_suggested": "Rector suggests changes (run with --fix to apply)",
|
||||
"flag": {
|
||||
"fix": "Apply refactoring changes",
|
||||
"diff": "Show diff of changes",
|
||||
"clear_cache": "Clear Rector cache before running"
|
||||
}
|
||||
},
|
||||
"infection": {
|
||||
"short": "Mutation testing for test quality",
|
||||
"long": "Run Infection mutation testing to measure test suite quality",
|
||||
"not_found": "Infection not found",
|
||||
"install": "composer require --dev infection/infection",
|
||||
"note": "This may take a while depending on test suite size",
|
||||
"complete": "Mutation testing complete",
|
||||
"flag": {
|
||||
"min_msi": "Minimum Mutation Score Indicator (0-100)",
|
||||
"min_covered_msi": "Minimum covered code MSI (0-100)",
|
||||
"threads": "Number of parallel threads",
|
||||
"filter": "Filter mutants by file path",
|
||||
"only_covered": "Only mutate covered code"
|
||||
}
|
||||
},
|
||||
"security": {
|
||||
"short": "Security vulnerability scanning",
|
||||
"long": "Run comprehensive security checks on PHP codebase",
|
||||
"checks_suffix": " CHECKS",
|
||||
"summary": "Security scan complete",
|
||||
"passed": "Passed:",
|
||||
"critical": "Critical:",
|
||||
"high": "High:",
|
||||
"medium": "Medium:",
|
||||
"low": "Low:",
|
||||
"flag": {
|
||||
"severity": "Minimum severity to report (low, medium, high, critical)",
|
||||
"sarif": "Output in SARIF format",
|
||||
"url": "Application URL for runtime checks"
|
||||
}
|
||||
},
|
||||
"qa": {
|
||||
"short": "Run full QA pipeline",
|
||||
"long": "Run comprehensive quality assurance: audit, format, analyse, test, and more",
|
||||
"flag": {
|
||||
"quick": "Run quick checks only (audit, fmt, stan)",
|
||||
"full": "Run all stages including slow checks",
|
||||
"fix": "Auto-fix issues where possible"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
308
packages.go
Normal file
308
packages.go
Normal file
|
|
@ -0,0 +1,308 @@
|
|||
package php
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
)
|
||||
|
||||
// LinkedPackage represents a linked local package.
|
||||
type LinkedPackage struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
// composerRepository represents a composer repository entry.
|
||||
type composerRepository struct {
|
||||
Type string `json:"type"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Options map[string]any `json:"options,omitempty"`
|
||||
}
|
||||
|
||||
// readComposerJSON reads and parses composer.json from the given directory.
|
||||
func readComposerJSON(dir string) (map[string]json.RawMessage, error) {
|
||||
m := getMedium()
|
||||
composerPath := filepath.Join(dir, "composer.json")
|
||||
content, err := m.Read(composerPath)
|
||||
if err != nil {
|
||||
return nil, cli.WrapVerb(err, "read", "composer.json")
|
||||
}
|
||||
|
||||
var raw map[string]json.RawMessage
|
||||
if err := json.Unmarshal([]byte(content), &raw); err != nil {
|
||||
return nil, cli.WrapVerb(err, "parse", "composer.json")
|
||||
}
|
||||
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
// writeComposerJSON writes the composer.json to the given directory.
|
||||
func writeComposerJSON(dir string, raw map[string]json.RawMessage) error {
|
||||
m := getMedium()
|
||||
composerPath := filepath.Join(dir, "composer.json")
|
||||
|
||||
data, err := json.MarshalIndent(raw, "", " ")
|
||||
if err != nil {
|
||||
return cli.WrapVerb(err, "marshal", "composer.json")
|
||||
}
|
||||
|
||||
// Add trailing newline
|
||||
content := string(data) + "\n"
|
||||
|
||||
if err := m.Write(composerPath, content); err != nil {
|
||||
return cli.WrapVerb(err, "write", "composer.json")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getRepositories extracts repositories from raw composer.json.
|
||||
func getRepositories(raw map[string]json.RawMessage) ([]composerRepository, error) {
|
||||
reposRaw, ok := raw["repositories"]
|
||||
if !ok {
|
||||
return []composerRepository{}, nil
|
||||
}
|
||||
|
||||
var repos []composerRepository
|
||||
if err := json.Unmarshal(reposRaw, &repos); err != nil {
|
||||
return nil, cli.WrapVerb(err, "parse", "repositories")
|
||||
}
|
||||
|
||||
return repos, nil
|
||||
}
|
||||
|
||||
// setRepositories sets repositories in raw composer.json.
|
||||
func setRepositories(raw map[string]json.RawMessage, repos []composerRepository) error {
|
||||
if len(repos) == 0 {
|
||||
delete(raw, "repositories")
|
||||
return nil
|
||||
}
|
||||
|
||||
reposData, err := json.Marshal(repos)
|
||||
if err != nil {
|
||||
return cli.WrapVerb(err, "marshal", "repositories")
|
||||
}
|
||||
|
||||
raw["repositories"] = reposData
|
||||
return nil
|
||||
}
|
||||
|
||||
// getPackageInfo reads package name and version from a composer.json in the given path.
|
||||
func getPackageInfo(packagePath string) (name, version string, err error) {
|
||||
m := getMedium()
|
||||
composerPath := filepath.Join(packagePath, "composer.json")
|
||||
content, err := m.Read(composerPath)
|
||||
if err != nil {
|
||||
return "", "", cli.WrapVerb(err, "read", "package composer.json")
|
||||
}
|
||||
|
||||
var pkg struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(content), &pkg); err != nil {
|
||||
return "", "", cli.WrapVerb(err, "parse", "package composer.json")
|
||||
}
|
||||
|
||||
if pkg.Name == "" {
|
||||
return "", "", cli.Err("package name not found in composer.json")
|
||||
}
|
||||
|
||||
return pkg.Name, pkg.Version, nil
|
||||
}
|
||||
|
||||
// LinkPackages adds path repositories to composer.json for local package development.
|
||||
func LinkPackages(dir string, packages []string) error {
|
||||
if !IsPHPProject(dir) {
|
||||
return cli.Err("not a PHP project (missing composer.json)")
|
||||
}
|
||||
|
||||
raw, err := readComposerJSON(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
repos, err := getRepositories(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, packagePath := range packages {
|
||||
// Resolve absolute path
|
||||
absPath, err := filepath.Abs(packagePath)
|
||||
if err != nil {
|
||||
return cli.Err("failed to resolve path %s: %w", packagePath, err)
|
||||
}
|
||||
|
||||
// Verify the path exists and has a composer.json
|
||||
if !IsPHPProject(absPath) {
|
||||
return cli.Err("not a PHP package (missing composer.json): %s", absPath)
|
||||
}
|
||||
|
||||
// Get package name for validation
|
||||
pkgName, _, err := getPackageInfo(absPath)
|
||||
if err != nil {
|
||||
return cli.Err("failed to get package info from %s: %w", absPath, err)
|
||||
}
|
||||
|
||||
// Check if already linked
|
||||
alreadyLinked := false
|
||||
for _, repo := range repos {
|
||||
if repo.Type == "path" && repo.URL == absPath {
|
||||
alreadyLinked = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if alreadyLinked {
|
||||
continue
|
||||
}
|
||||
|
||||
// Add path repository
|
||||
repos = append(repos, composerRepository{
|
||||
Type: "path",
|
||||
URL: absPath,
|
||||
Options: map[string]any{
|
||||
"symlink": true,
|
||||
},
|
||||
})
|
||||
|
||||
cli.Print("Linked: %s -> %s\n", pkgName, absPath)
|
||||
}
|
||||
|
||||
if err := setRepositories(raw, repos); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return writeComposerJSON(dir, raw)
|
||||
}
|
||||
|
||||
// UnlinkPackages removes path repositories from composer.json.
|
||||
func UnlinkPackages(dir string, packages []string) error {
|
||||
if !IsPHPProject(dir) {
|
||||
return cli.Err("not a PHP project (missing composer.json)")
|
||||
}
|
||||
|
||||
raw, err := readComposerJSON(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
repos, err := getRepositories(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Build set of packages to unlink
|
||||
toUnlink := make(map[string]bool)
|
||||
for _, pkg := range packages {
|
||||
toUnlink[pkg] = true
|
||||
}
|
||||
|
||||
// Filter out unlinked packages
|
||||
filtered := make([]composerRepository, 0, len(repos))
|
||||
for _, repo := range repos {
|
||||
if repo.Type != "path" {
|
||||
filtered = append(filtered, repo)
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if this repo should be unlinked
|
||||
shouldUnlink := false
|
||||
|
||||
// Try to get package name from the path
|
||||
if IsPHPProject(repo.URL) {
|
||||
pkgName, _, err := getPackageInfo(repo.URL)
|
||||
if err == nil && toUnlink[pkgName] {
|
||||
shouldUnlink = true
|
||||
cli.Print("Unlinked: %s\n", pkgName)
|
||||
}
|
||||
}
|
||||
|
||||
// Also check if path matches any of the provided names
|
||||
for pkg := range toUnlink {
|
||||
if repo.URL == pkg || filepath.Base(repo.URL) == pkg {
|
||||
shouldUnlink = true
|
||||
cli.Print("Unlinked: %s\n", repo.URL)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !shouldUnlink {
|
||||
filtered = append(filtered, repo)
|
||||
}
|
||||
}
|
||||
|
||||
if err := setRepositories(raw, filtered); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return writeComposerJSON(dir, raw)
|
||||
}
|
||||
|
||||
// UpdatePackages runs composer update for specific packages.
|
||||
func UpdatePackages(dir string, packages []string) error {
|
||||
if !IsPHPProject(dir) {
|
||||
return cli.Err("not a PHP project (missing composer.json)")
|
||||
}
|
||||
|
||||
args := []string{"update"}
|
||||
args = append(args, packages...)
|
||||
|
||||
cmd := exec.Command("composer", args...)
|
||||
cmd.Dir = dir
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
// ListLinkedPackages returns all path repositories from composer.json.
|
||||
func ListLinkedPackages(dir string) ([]LinkedPackage, error) {
|
||||
if !IsPHPProject(dir) {
|
||||
return nil, cli.Err("not a PHP project (missing composer.json)")
|
||||
}
|
||||
|
||||
raw, err := readComposerJSON(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
repos, err := getRepositories(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
linked := make([]LinkedPackage, 0)
|
||||
for _, repo := range repos {
|
||||
if repo.Type != "path" {
|
||||
continue
|
||||
}
|
||||
|
||||
pkg := LinkedPackage{
|
||||
Path: repo.URL,
|
||||
}
|
||||
|
||||
// Try to get package info
|
||||
if IsPHPProject(repo.URL) {
|
||||
name, version, err := getPackageInfo(repo.URL)
|
||||
if err == nil {
|
||||
pkg.Name = name
|
||||
pkg.Version = version
|
||||
}
|
||||
}
|
||||
|
||||
if pkg.Name == "" {
|
||||
pkg.Name = filepath.Base(repo.URL)
|
||||
}
|
||||
|
||||
linked = append(linked, pkg)
|
||||
}
|
||||
|
||||
return linked, nil
|
||||
}
|
||||
543
packages_test.go
Normal file
543
packages_test.go
Normal file
|
|
@ -0,0 +1,543 @@
|
|||
package php
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestReadComposerJSON_Good(t *testing.T) {
|
||||
t.Run("reads valid composer.json", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
composerJSON := `{
|
||||
"name": "test/project",
|
||||
"require": {
|
||||
"php": "^8.2"
|
||||
}
|
||||
}`
|
||||
err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
raw, err := readComposerJSON(dir)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, raw)
|
||||
assert.Contains(t, string(raw["name"]), "test/project")
|
||||
})
|
||||
|
||||
t.Run("preserves all fields", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
composerJSON := `{
|
||||
"name": "test/project",
|
||||
"description": "Test project",
|
||||
"require": {"php": "^8.2"},
|
||||
"autoload": {"psr-4": {"App\\": "src/"}}
|
||||
}`
|
||||
err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
raw, err := readComposerJSON(dir)
|
||||
assert.NoError(t, err)
|
||||
assert.Contains(t, string(raw["autoload"]), "psr-4")
|
||||
})
|
||||
}
|
||||
|
||||
func TestReadComposerJSON_Bad(t *testing.T) {
|
||||
t.Run("missing composer.json", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
_, err := readComposerJSON(dir)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "Failed to read composer.json")
|
||||
})
|
||||
|
||||
t.Run("invalid JSON", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte("not json{"), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = readComposerJSON(dir)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "Failed to parse composer.json")
|
||||
})
|
||||
}
|
||||
|
||||
func TestWriteComposerJSON_Good(t *testing.T) {
|
||||
t.Run("writes valid composer.json", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
raw := make(map[string]json.RawMessage)
|
||||
raw["name"] = json.RawMessage(`"test/project"`)
|
||||
|
||||
err := writeComposerJSON(dir, raw)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify file was written
|
||||
content, err := os.ReadFile(filepath.Join(dir, "composer.json"))
|
||||
assert.NoError(t, err)
|
||||
assert.Contains(t, string(content), "test/project")
|
||||
// Verify trailing newline
|
||||
assert.True(t, content[len(content)-1] == '\n')
|
||||
})
|
||||
|
||||
t.Run("pretty prints with indentation", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
raw := make(map[string]json.RawMessage)
|
||||
raw["name"] = json.RawMessage(`"test/project"`)
|
||||
raw["require"] = json.RawMessage(`{"php":"^8.2"}`)
|
||||
|
||||
err := writeComposerJSON(dir, raw)
|
||||
assert.NoError(t, err)
|
||||
|
||||
content, err := os.ReadFile(filepath.Join(dir, "composer.json"))
|
||||
assert.NoError(t, err)
|
||||
// Should be indented
|
||||
assert.Contains(t, string(content), " ")
|
||||
})
|
||||
}
|
||||
|
||||
func TestWriteComposerJSON_Bad(t *testing.T) {
|
||||
t.Run("fails for non-existent directory", func(t *testing.T) {
|
||||
raw := make(map[string]json.RawMessage)
|
||||
raw["name"] = json.RawMessage(`"test/project"`)
|
||||
|
||||
err := writeComposerJSON("/non/existent/path", raw)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "Failed to write composer.json")
|
||||
})
|
||||
}
|
||||
func TestGetRepositories_Good(t *testing.T) {
|
||||
t.Run("returns empty slice when no repositories", func(t *testing.T) {
|
||||
raw := make(map[string]json.RawMessage)
|
||||
raw["name"] = json.RawMessage(`"test/project"`)
|
||||
|
||||
repos, err := getRepositories(raw)
|
||||
assert.NoError(t, err)
|
||||
assert.Empty(t, repos)
|
||||
})
|
||||
|
||||
t.Run("parses existing repositories", func(t *testing.T) {
|
||||
raw := make(map[string]json.RawMessage)
|
||||
raw["name"] = json.RawMessage(`"test/project"`)
|
||||
raw["repositories"] = json.RawMessage(`[{"type":"path","url":"/path/to/package"}]`)
|
||||
|
||||
repos, err := getRepositories(raw)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, repos, 1)
|
||||
assert.Equal(t, "path", repos[0].Type)
|
||||
assert.Equal(t, "/path/to/package", repos[0].URL)
|
||||
})
|
||||
|
||||
t.Run("parses repositories with options", func(t *testing.T) {
|
||||
raw := make(map[string]json.RawMessage)
|
||||
raw["repositories"] = json.RawMessage(`[{"type":"path","url":"/path","options":{"symlink":true}}]`)
|
||||
|
||||
repos, err := getRepositories(raw)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, repos, 1)
|
||||
assert.NotNil(t, repos[0].Options)
|
||||
assert.Equal(t, true, repos[0].Options["symlink"])
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetRepositories_Bad(t *testing.T) {
|
||||
t.Run("fails for invalid repositories JSON", func(t *testing.T) {
|
||||
raw := make(map[string]json.RawMessage)
|
||||
raw["repositories"] = json.RawMessage(`not valid json`)
|
||||
|
||||
_, err := getRepositories(raw)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "Failed to parse repositories")
|
||||
})
|
||||
}
|
||||
|
||||
func TestSetRepositories_Good(t *testing.T) {
|
||||
t.Run("sets repositories", func(t *testing.T) {
|
||||
raw := make(map[string]json.RawMessage)
|
||||
repos := []composerRepository{
|
||||
{Type: "path", URL: "/path/to/package"},
|
||||
}
|
||||
|
||||
err := setRepositories(raw, repos)
|
||||
assert.NoError(t, err)
|
||||
assert.Contains(t, string(raw["repositories"]), "/path/to/package")
|
||||
})
|
||||
|
||||
t.Run("removes repositories key when empty", func(t *testing.T) {
|
||||
raw := make(map[string]json.RawMessage)
|
||||
raw["repositories"] = json.RawMessage(`[{"type":"path"}]`)
|
||||
|
||||
err := setRepositories(raw, []composerRepository{})
|
||||
assert.NoError(t, err)
|
||||
_, exists := raw["repositories"]
|
||||
assert.False(t, exists)
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetPackageInfo_Good(t *testing.T) {
|
||||
t.Run("extracts package name and version", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
composerJSON := `{
|
||||
"name": "vendor/package",
|
||||
"version": "1.0.0"
|
||||
}`
|
||||
err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
name, version, err := getPackageInfo(dir)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "vendor/package", name)
|
||||
assert.Equal(t, "1.0.0", version)
|
||||
})
|
||||
|
||||
t.Run("works without version", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
composerJSON := `{
|
||||
"name": "vendor/package"
|
||||
}`
|
||||
err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
name, version, err := getPackageInfo(dir)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "vendor/package", name)
|
||||
assert.Equal(t, "", version)
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetPackageInfo_Bad(t *testing.T) {
|
||||
t.Run("missing composer.json", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
_, _, err := getPackageInfo(dir)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "Failed to read package composer.json")
|
||||
})
|
||||
|
||||
t.Run("invalid JSON", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte("not json{"), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, _, err = getPackageInfo(dir)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "Failed to parse package composer.json")
|
||||
})
|
||||
|
||||
t.Run("missing name", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
composerJSON := `{"version": "1.0.0"}`
|
||||
err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, _, err = getPackageInfo(dir)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "package name not found")
|
||||
})
|
||||
}
|
||||
|
||||
func TestLinkPackages_Good(t *testing.T) {
|
||||
t.Run("links a package", func(t *testing.T) {
|
||||
// Create project directory
|
||||
projectDir := t.TempDir()
|
||||
err := os.WriteFile(filepath.Join(projectDir, "composer.json"), []byte(`{"name":"test/project"}`), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create package directory
|
||||
packageDir := t.TempDir()
|
||||
err = os.WriteFile(filepath.Join(packageDir, "composer.json"), []byte(`{"name":"vendor/package"}`), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = LinkPackages(projectDir, []string{packageDir})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify repository was added
|
||||
raw, err := readComposerJSON(projectDir)
|
||||
assert.NoError(t, err)
|
||||
repos, err := getRepositories(raw)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, repos, 1)
|
||||
assert.Equal(t, "path", repos[0].Type)
|
||||
})
|
||||
|
||||
t.Run("skips already linked package", func(t *testing.T) {
|
||||
// Create project with existing repository
|
||||
projectDir := t.TempDir()
|
||||
packageDir := t.TempDir()
|
||||
|
||||
err := os.WriteFile(filepath.Join(packageDir, "composer.json"), []byte(`{"name":"vendor/package"}`), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
absPackagePath, _ := filepath.Abs(packageDir)
|
||||
composerJSON := `{
|
||||
"name": "test/project",
|
||||
"repositories": [{"type":"path","url":"` + absPackagePath + `"}]
|
||||
}`
|
||||
err = os.WriteFile(filepath.Join(projectDir, "composer.json"), []byte(composerJSON), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Link again - should not add duplicate
|
||||
err = LinkPackages(projectDir, []string{packageDir})
|
||||
assert.NoError(t, err)
|
||||
|
||||
raw, err := readComposerJSON(projectDir)
|
||||
assert.NoError(t, err)
|
||||
repos, err := getRepositories(raw)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, repos, 1) // Still only one
|
||||
})
|
||||
|
||||
t.Run("links multiple packages", func(t *testing.T) {
|
||||
projectDir := t.TempDir()
|
||||
err := os.WriteFile(filepath.Join(projectDir, "composer.json"), []byte(`{"name":"test/project"}`), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
pkg1Dir := t.TempDir()
|
||||
err = os.WriteFile(filepath.Join(pkg1Dir, "composer.json"), []byte(`{"name":"vendor/pkg1"}`), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
pkg2Dir := t.TempDir()
|
||||
err = os.WriteFile(filepath.Join(pkg2Dir, "composer.json"), []byte(`{"name":"vendor/pkg2"}`), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = LinkPackages(projectDir, []string{pkg1Dir, pkg2Dir})
|
||||
assert.NoError(t, err)
|
||||
|
||||
raw, err := readComposerJSON(projectDir)
|
||||
assert.NoError(t, err)
|
||||
repos, err := getRepositories(raw)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, repos, 2)
|
||||
})
|
||||
}
|
||||
|
||||
func TestLinkPackages_Bad(t *testing.T) {
|
||||
t.Run("fails for non-PHP project", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
err := LinkPackages(dir, []string{"/path/to/package"})
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "not a PHP project")
|
||||
})
|
||||
|
||||
t.Run("fails for non-PHP package", func(t *testing.T) {
|
||||
projectDir := t.TempDir()
|
||||
err := os.WriteFile(filepath.Join(projectDir, "composer.json"), []byte(`{"name":"test/project"}`), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
packageDir := t.TempDir()
|
||||
// No composer.json in package
|
||||
|
||||
err = LinkPackages(projectDir, []string{packageDir})
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "not a PHP package")
|
||||
})
|
||||
}
|
||||
|
||||
func TestUnlinkPackages_Good(t *testing.T) {
|
||||
t.Run("unlinks package by name", func(t *testing.T) {
|
||||
projectDir := t.TempDir()
|
||||
packageDir := t.TempDir()
|
||||
|
||||
err := os.WriteFile(filepath.Join(packageDir, "composer.json"), []byte(`{"name":"vendor/package"}`), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
absPackagePath, _ := filepath.Abs(packageDir)
|
||||
composerJSON := `{
|
||||
"name": "test/project",
|
||||
"repositories": [{"type":"path","url":"` + absPackagePath + `"}]
|
||||
}`
|
||||
err = os.WriteFile(filepath.Join(projectDir, "composer.json"), []byte(composerJSON), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = UnlinkPackages(projectDir, []string{"vendor/package"})
|
||||
assert.NoError(t, err)
|
||||
|
||||
raw, err := readComposerJSON(projectDir)
|
||||
assert.NoError(t, err)
|
||||
repos, err := getRepositories(raw)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, repos, 0)
|
||||
})
|
||||
|
||||
t.Run("unlinks package by path", func(t *testing.T) {
|
||||
projectDir := t.TempDir()
|
||||
packageDir := t.TempDir()
|
||||
|
||||
absPackagePath, _ := filepath.Abs(packageDir)
|
||||
composerJSON := `{
|
||||
"name": "test/project",
|
||||
"repositories": [{"type":"path","url":"` + absPackagePath + `"}]
|
||||
}`
|
||||
err := os.WriteFile(filepath.Join(projectDir, "composer.json"), []byte(composerJSON), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = UnlinkPackages(projectDir, []string{absPackagePath})
|
||||
assert.NoError(t, err)
|
||||
|
||||
raw, err := readComposerJSON(projectDir)
|
||||
assert.NoError(t, err)
|
||||
repos, err := getRepositories(raw)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, repos, 0)
|
||||
})
|
||||
|
||||
t.Run("keeps non-path repositories", func(t *testing.T) {
|
||||
projectDir := t.TempDir()
|
||||
composerJSON := `{
|
||||
"name": "test/project",
|
||||
"repositories": [
|
||||
{"type":"vcs","url":"https://github.com/vendor/package"},
|
||||
{"type":"path","url":"/local/path"}
|
||||
]
|
||||
}`
|
||||
err := os.WriteFile(filepath.Join(projectDir, "composer.json"), []byte(composerJSON), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = UnlinkPackages(projectDir, []string{"/local/path"})
|
||||
assert.NoError(t, err)
|
||||
|
||||
raw, err := readComposerJSON(projectDir)
|
||||
assert.NoError(t, err)
|
||||
repos, err := getRepositories(raw)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, repos, 1)
|
||||
assert.Equal(t, "vcs", repos[0].Type)
|
||||
})
|
||||
}
|
||||
|
||||
func TestUnlinkPackages_Bad(t *testing.T) {
|
||||
t.Run("fails for non-PHP project", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
err := UnlinkPackages(dir, []string{"vendor/package"})
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "not a PHP project")
|
||||
})
|
||||
}
|
||||
|
||||
func TestListLinkedPackages_Good(t *testing.T) {
|
||||
t.Run("lists linked packages", func(t *testing.T) {
|
||||
projectDir := t.TempDir()
|
||||
packageDir := t.TempDir()
|
||||
|
||||
err := os.WriteFile(filepath.Join(packageDir, "composer.json"), []byte(`{"name":"vendor/package","version":"1.0.0"}`), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
absPackagePath, _ := filepath.Abs(packageDir)
|
||||
composerJSON := `{
|
||||
"name": "test/project",
|
||||
"repositories": [{"type":"path","url":"` + absPackagePath + `"}]
|
||||
}`
|
||||
err = os.WriteFile(filepath.Join(projectDir, "composer.json"), []byte(composerJSON), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
linked, err := ListLinkedPackages(projectDir)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, linked, 1)
|
||||
assert.Equal(t, "vendor/package", linked[0].Name)
|
||||
assert.Equal(t, "1.0.0", linked[0].Version)
|
||||
assert.Equal(t, absPackagePath, linked[0].Path)
|
||||
})
|
||||
|
||||
t.Run("returns empty list when no linked packages", func(t *testing.T) {
|
||||
projectDir := t.TempDir()
|
||||
err := os.WriteFile(filepath.Join(projectDir, "composer.json"), []byte(`{"name":"test/project"}`), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
linked, err := ListLinkedPackages(projectDir)
|
||||
assert.NoError(t, err)
|
||||
assert.Empty(t, linked)
|
||||
})
|
||||
|
||||
t.Run("uses basename when package info unavailable", func(t *testing.T) {
|
||||
projectDir := t.TempDir()
|
||||
composerJSON := `{
|
||||
"name": "test/project",
|
||||
"repositories": [{"type":"path","url":"/nonexistent/package-name"}]
|
||||
}`
|
||||
err := os.WriteFile(filepath.Join(projectDir, "composer.json"), []byte(composerJSON), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
linked, err := ListLinkedPackages(projectDir)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, linked, 1)
|
||||
assert.Equal(t, "package-name", linked[0].Name)
|
||||
})
|
||||
|
||||
t.Run("ignores non-path repositories", func(t *testing.T) {
|
||||
projectDir := t.TempDir()
|
||||
composerJSON := `{
|
||||
"name": "test/project",
|
||||
"repositories": [
|
||||
{"type":"vcs","url":"https://github.com/vendor/package"}
|
||||
]
|
||||
}`
|
||||
err := os.WriteFile(filepath.Join(projectDir, "composer.json"), []byte(composerJSON), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
linked, err := ListLinkedPackages(projectDir)
|
||||
assert.NoError(t, err)
|
||||
assert.Empty(t, linked)
|
||||
})
|
||||
}
|
||||
|
||||
func TestListLinkedPackages_Bad(t *testing.T) {
|
||||
t.Run("fails for non-PHP project", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
_, err := ListLinkedPackages(dir)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "not a PHP project")
|
||||
})
|
||||
}
|
||||
|
||||
func TestUpdatePackages_Bad(t *testing.T) {
|
||||
t.Run("fails for non-PHP project", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
err := UpdatePackages(dir, []string{"vendor/package"})
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "not a PHP project")
|
||||
})
|
||||
}
|
||||
|
||||
func TestUpdatePackages_Good(t *testing.T) {
|
||||
t.Skip("requires Composer installed")
|
||||
|
||||
t.Run("runs composer update", func(t *testing.T) {
|
||||
projectDir := t.TempDir()
|
||||
err := os.WriteFile(filepath.Join(projectDir, "composer.json"), []byte(`{"name":"test/project"}`), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
_ = UpdatePackages(projectDir, []string{"vendor/package"})
|
||||
// This will fail because composer update needs real dependencies
|
||||
// but it validates the command runs
|
||||
})
|
||||
}
|
||||
|
||||
func TestLinkedPackage_Struct(t *testing.T) {
|
||||
t.Run("all fields accessible", func(t *testing.T) {
|
||||
pkg := LinkedPackage{
|
||||
Name: "vendor/package",
|
||||
Path: "/path/to/package",
|
||||
Version: "1.0.0",
|
||||
}
|
||||
|
||||
assert.Equal(t, "vendor/package", pkg.Name)
|
||||
assert.Equal(t, "/path/to/package", pkg.Path)
|
||||
assert.Equal(t, "1.0.0", pkg.Version)
|
||||
})
|
||||
}
|
||||
|
||||
func TestComposerRepository_Struct(t *testing.T) {
|
||||
t.Run("all fields accessible", func(t *testing.T) {
|
||||
repo := composerRepository{
|
||||
Type: "path",
|
||||
URL: "/path/to/package",
|
||||
Options: map[string]any{
|
||||
"symlink": true,
|
||||
},
|
||||
}
|
||||
|
||||
assert.Equal(t, "path", repo.Type)
|
||||
assert.Equal(t, "/path/to/package", repo.URL)
|
||||
assert.Equal(t, true, repo.Options["symlink"])
|
||||
})
|
||||
}
|
||||
397
php.go
Normal file
397
php.go
Normal file
|
|
@ -0,0 +1,397 @@
|
|||
package php
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
)
|
||||
|
||||
// Options configures the development server.
|
||||
type Options struct {
|
||||
// Dir is the Laravel project directory.
|
||||
Dir string
|
||||
|
||||
// Services specifies which services to start.
|
||||
// If empty, services are auto-detected.
|
||||
Services []DetectedService
|
||||
|
||||
// NoVite disables the Vite dev server.
|
||||
NoVite bool
|
||||
|
||||
// NoHorizon disables Laravel Horizon.
|
||||
NoHorizon bool
|
||||
|
||||
// NoReverb disables Laravel Reverb.
|
||||
NoReverb bool
|
||||
|
||||
// NoRedis disables the Redis server.
|
||||
NoRedis bool
|
||||
|
||||
// HTTPS enables HTTPS with mkcert certificates.
|
||||
HTTPS bool
|
||||
|
||||
// Domain is the domain for SSL certificates.
|
||||
// Defaults to APP_URL from .env or "localhost".
|
||||
Domain string
|
||||
|
||||
// Ports for each service
|
||||
FrankenPHPPort int
|
||||
HTTPSPort int
|
||||
VitePort int
|
||||
ReverbPort int
|
||||
RedisPort int
|
||||
}
|
||||
|
||||
// DevServer manages all development services.
|
||||
type DevServer struct {
|
||||
opts Options
|
||||
services []Service
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
mu sync.RWMutex
|
||||
running bool
|
||||
}
|
||||
|
||||
// NewDevServer creates a new development server manager.
|
||||
func NewDevServer(opts Options) *DevServer {
|
||||
return &DevServer{
|
||||
opts: opts,
|
||||
services: make([]Service, 0),
|
||||
}
|
||||
}
|
||||
|
||||
// Start starts all detected/configured services.
|
||||
func (d *DevServer) Start(ctx context.Context, opts Options) error {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
|
||||
if d.running {
|
||||
return cli.Err("dev server is already running")
|
||||
}
|
||||
|
||||
// Merge options
|
||||
if opts.Dir != "" {
|
||||
d.opts.Dir = opts.Dir
|
||||
}
|
||||
if d.opts.Dir == "" {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return cli.WrapVerb(err, "get", "working directory")
|
||||
}
|
||||
d.opts.Dir = cwd
|
||||
}
|
||||
|
||||
// Verify this is a Laravel project
|
||||
if !IsLaravelProject(d.opts.Dir) {
|
||||
return cli.Err("not a Laravel project: %s", d.opts.Dir)
|
||||
}
|
||||
|
||||
// Create cancellable context
|
||||
d.ctx, d.cancel = context.WithCancel(ctx)
|
||||
|
||||
// Detect or use provided services
|
||||
services := opts.Services
|
||||
if len(services) == 0 {
|
||||
services = DetectServices(d.opts.Dir)
|
||||
}
|
||||
|
||||
// Filter out disabled services
|
||||
services = d.filterServices(services, opts)
|
||||
|
||||
// Setup SSL if HTTPS is enabled
|
||||
var certFile, keyFile string
|
||||
if opts.HTTPS {
|
||||
domain := opts.Domain
|
||||
if domain == "" {
|
||||
// Try to get domain from APP_URL
|
||||
appURL := GetLaravelAppURL(d.opts.Dir)
|
||||
if appURL != "" {
|
||||
domain = ExtractDomainFromURL(appURL)
|
||||
}
|
||||
}
|
||||
if domain == "" {
|
||||
domain = "localhost"
|
||||
}
|
||||
|
||||
var err error
|
||||
certFile, keyFile, err = SetupSSLIfNeeded(domain, SSLOptions{})
|
||||
if err != nil {
|
||||
return cli.WrapVerb(err, "setup", "SSL")
|
||||
}
|
||||
}
|
||||
|
||||
// Create services
|
||||
d.services = make([]Service, 0)
|
||||
|
||||
for _, svc := range services {
|
||||
var service Service
|
||||
|
||||
switch svc {
|
||||
case ServiceFrankenPHP:
|
||||
port := opts.FrankenPHPPort
|
||||
if port == 0 {
|
||||
port = 8000
|
||||
}
|
||||
httpsPort := opts.HTTPSPort
|
||||
if httpsPort == 0 {
|
||||
httpsPort = 443
|
||||
}
|
||||
service = NewFrankenPHPService(d.opts.Dir, FrankenPHPOptions{
|
||||
Port: port,
|
||||
HTTPSPort: httpsPort,
|
||||
HTTPS: opts.HTTPS,
|
||||
CertFile: certFile,
|
||||
KeyFile: keyFile,
|
||||
})
|
||||
|
||||
case ServiceVite:
|
||||
port := opts.VitePort
|
||||
if port == 0 {
|
||||
port = 5173
|
||||
}
|
||||
service = NewViteService(d.opts.Dir, ViteOptions{
|
||||
Port: port,
|
||||
})
|
||||
|
||||
case ServiceHorizon:
|
||||
service = NewHorizonService(d.opts.Dir)
|
||||
|
||||
case ServiceReverb:
|
||||
port := opts.ReverbPort
|
||||
if port == 0 {
|
||||
port = 8080
|
||||
}
|
||||
service = NewReverbService(d.opts.Dir, ReverbOptions{
|
||||
Port: port,
|
||||
})
|
||||
|
||||
case ServiceRedis:
|
||||
port := opts.RedisPort
|
||||
if port == 0 {
|
||||
port = 6379
|
||||
}
|
||||
service = NewRedisService(d.opts.Dir, RedisOptions{
|
||||
Port: port,
|
||||
})
|
||||
}
|
||||
|
||||
if service != nil {
|
||||
d.services = append(d.services, service)
|
||||
}
|
||||
}
|
||||
|
||||
// Start all services
|
||||
var startErrors []error
|
||||
for _, svc := range d.services {
|
||||
if err := svc.Start(d.ctx); err != nil {
|
||||
startErrors = append(startErrors, cli.Err("%s: %v", svc.Name(), err))
|
||||
}
|
||||
}
|
||||
|
||||
if len(startErrors) > 0 {
|
||||
// Stop any services that did start
|
||||
for _, svc := range d.services {
|
||||
_ = svc.Stop()
|
||||
}
|
||||
return cli.Err("failed to start services: %v", startErrors)
|
||||
}
|
||||
|
||||
d.running = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// filterServices removes disabled services from the list.
|
||||
func (d *DevServer) filterServices(services []DetectedService, opts Options) []DetectedService {
|
||||
filtered := make([]DetectedService, 0)
|
||||
|
||||
for _, svc := range services {
|
||||
switch svc {
|
||||
case ServiceVite:
|
||||
if !opts.NoVite {
|
||||
filtered = append(filtered, svc)
|
||||
}
|
||||
case ServiceHorizon:
|
||||
if !opts.NoHorizon {
|
||||
filtered = append(filtered, svc)
|
||||
}
|
||||
case ServiceReverb:
|
||||
if !opts.NoReverb {
|
||||
filtered = append(filtered, svc)
|
||||
}
|
||||
case ServiceRedis:
|
||||
if !opts.NoRedis {
|
||||
filtered = append(filtered, svc)
|
||||
}
|
||||
default:
|
||||
filtered = append(filtered, svc)
|
||||
}
|
||||
}
|
||||
|
||||
return filtered
|
||||
}
|
||||
|
||||
// Stop stops all services gracefully.
|
||||
func (d *DevServer) Stop() error {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
|
||||
if !d.running {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Cancel context first
|
||||
if d.cancel != nil {
|
||||
d.cancel()
|
||||
}
|
||||
|
||||
// Stop all services in reverse order
|
||||
var stopErrors []error
|
||||
for i := len(d.services) - 1; i >= 0; i-- {
|
||||
svc := d.services[i]
|
||||
if err := svc.Stop(); err != nil {
|
||||
stopErrors = append(stopErrors, cli.Err("%s: %v", svc.Name(), err))
|
||||
}
|
||||
}
|
||||
|
||||
d.running = false
|
||||
|
||||
if len(stopErrors) > 0 {
|
||||
return cli.Err("errors stopping services: %v", stopErrors)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Logs returns a reader for the specified service's logs.
|
||||
// If service is empty, returns unified logs from all services.
|
||||
func (d *DevServer) Logs(service string, follow bool) (io.ReadCloser, error) {
|
||||
d.mu.RLock()
|
||||
defer d.mu.RUnlock()
|
||||
|
||||
if service == "" {
|
||||
// Return unified logs
|
||||
return d.unifiedLogs(follow)
|
||||
}
|
||||
|
||||
// Find specific service
|
||||
for _, svc := range d.services {
|
||||
if svc.Name() == service {
|
||||
return svc.Logs(follow)
|
||||
}
|
||||
}
|
||||
|
||||
return nil, cli.Err("service not found: %s", service)
|
||||
}
|
||||
|
||||
// unifiedLogs creates a reader that combines logs from all services.
|
||||
func (d *DevServer) unifiedLogs(follow bool) (io.ReadCloser, error) {
|
||||
readers := make([]io.ReadCloser, 0)
|
||||
|
||||
for _, svc := range d.services {
|
||||
reader, err := svc.Logs(follow)
|
||||
if err != nil {
|
||||
// Close any readers we already opened
|
||||
for _, r := range readers {
|
||||
_ = r.Close()
|
||||
}
|
||||
return nil, cli.Err("failed to get logs for %s: %v", svc.Name(), err)
|
||||
}
|
||||
readers = append(readers, reader)
|
||||
}
|
||||
|
||||
return newMultiServiceReader(d.services, readers, follow), nil
|
||||
}
|
||||
|
||||
// Status returns the status of all services.
|
||||
func (d *DevServer) Status() []ServiceStatus {
|
||||
d.mu.RLock()
|
||||
defer d.mu.RUnlock()
|
||||
|
||||
statuses := make([]ServiceStatus, 0, len(d.services))
|
||||
for _, svc := range d.services {
|
||||
statuses = append(statuses, svc.Status())
|
||||
}
|
||||
|
||||
return statuses
|
||||
}
|
||||
|
||||
// IsRunning returns true if the dev server is running.
|
||||
func (d *DevServer) IsRunning() bool {
|
||||
d.mu.RLock()
|
||||
defer d.mu.RUnlock()
|
||||
return d.running
|
||||
}
|
||||
|
||||
// Services returns the list of managed services.
|
||||
func (d *DevServer) Services() []Service {
|
||||
d.mu.RLock()
|
||||
defer d.mu.RUnlock()
|
||||
return d.services
|
||||
}
|
||||
|
||||
// multiServiceReader combines multiple service log readers.
|
||||
type multiServiceReader struct {
|
||||
services []Service
|
||||
readers []io.ReadCloser
|
||||
follow bool
|
||||
closed bool
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
func newMultiServiceReader(services []Service, readers []io.ReadCloser, follow bool) *multiServiceReader {
|
||||
return &multiServiceReader{
|
||||
services: services,
|
||||
readers: readers,
|
||||
follow: follow,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *multiServiceReader) Read(p []byte) (n int, err error) {
|
||||
m.mu.RLock()
|
||||
if m.closed {
|
||||
m.mu.RUnlock()
|
||||
return 0, io.EOF
|
||||
}
|
||||
m.mu.RUnlock()
|
||||
|
||||
// Round-robin read from all readers
|
||||
for i, reader := range m.readers {
|
||||
buf := make([]byte, len(p))
|
||||
n, err := reader.Read(buf)
|
||||
if n > 0 {
|
||||
// Prefix with service name
|
||||
prefix := cli.Sprintf("[%s] ", m.services[i].Name())
|
||||
copy(p, prefix)
|
||||
copy(p[len(prefix):], buf[:n])
|
||||
return n + len(prefix), nil
|
||||
}
|
||||
if err != nil && err != io.EOF {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
|
||||
if m.follow {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
return 0, io.EOF
|
||||
}
|
||||
|
||||
func (m *multiServiceReader) Close() error {
|
||||
m.mu.Lock()
|
||||
m.closed = true
|
||||
m.mu.Unlock()
|
||||
|
||||
var closeErr error
|
||||
for _, reader := range m.readers {
|
||||
if err := reader.Close(); err != nil && closeErr == nil {
|
||||
closeErr = err
|
||||
}
|
||||
}
|
||||
return closeErr
|
||||
}
|
||||
644
php_test.go
Normal file
644
php_test.go
Normal file
|
|
@ -0,0 +1,644 @@
|
|||
package php
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewDevServer_Good(t *testing.T) {
|
||||
t.Run("creates dev server with default options", func(t *testing.T) {
|
||||
opts := Options{}
|
||||
server := NewDevServer(opts)
|
||||
|
||||
assert.NotNil(t, server)
|
||||
assert.Empty(t, server.services)
|
||||
assert.False(t, server.running)
|
||||
})
|
||||
|
||||
t.Run("creates dev server with custom options", func(t *testing.T) {
|
||||
opts := Options{
|
||||
Dir: "/tmp/test",
|
||||
NoVite: true,
|
||||
NoHorizon: true,
|
||||
FrankenPHPPort: 9000,
|
||||
}
|
||||
server := NewDevServer(opts)
|
||||
|
||||
assert.NotNil(t, server)
|
||||
assert.Equal(t, "/tmp/test", server.opts.Dir)
|
||||
assert.True(t, server.opts.NoVite)
|
||||
})
|
||||
}
|
||||
|
||||
func TestDevServer_IsRunning_Good(t *testing.T) {
|
||||
t.Run("returns false when not running", func(t *testing.T) {
|
||||
server := NewDevServer(Options{})
|
||||
assert.False(t, server.IsRunning())
|
||||
})
|
||||
}
|
||||
|
||||
func TestDevServer_Status_Good(t *testing.T) {
|
||||
t.Run("returns empty status when no services", func(t *testing.T) {
|
||||
server := NewDevServer(Options{})
|
||||
statuses := server.Status()
|
||||
assert.Empty(t, statuses)
|
||||
})
|
||||
}
|
||||
|
||||
func TestDevServer_Services_Good(t *testing.T) {
|
||||
t.Run("returns empty services list initially", func(t *testing.T) {
|
||||
server := NewDevServer(Options{})
|
||||
services := server.Services()
|
||||
assert.Empty(t, services)
|
||||
})
|
||||
}
|
||||
|
||||
func TestDevServer_Stop_Good(t *testing.T) {
|
||||
t.Run("returns nil when not running", func(t *testing.T) {
|
||||
server := NewDevServer(Options{})
|
||||
err := server.Stop()
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestDevServer_Start_Bad(t *testing.T) {
|
||||
t.Run("fails when already running", func(t *testing.T) {
|
||||
server := NewDevServer(Options{})
|
||||
server.running = true
|
||||
|
||||
err := server.Start(context.Background(), Options{})
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "already running")
|
||||
})
|
||||
|
||||
t.Run("fails for non-Laravel project", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
server := NewDevServer(Options{Dir: dir})
|
||||
|
||||
err := server.Start(context.Background(), Options{Dir: dir})
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "not a Laravel project")
|
||||
})
|
||||
}
|
||||
|
||||
func TestDevServer_Logs_Bad(t *testing.T) {
|
||||
t.Run("fails for non-existent service", func(t *testing.T) {
|
||||
server := NewDevServer(Options{})
|
||||
|
||||
_, err := server.Logs("nonexistent", false)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "service not found")
|
||||
})
|
||||
}
|
||||
|
||||
func TestDevServer_filterServices_Good(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
services []DetectedService
|
||||
opts Options
|
||||
expected []DetectedService
|
||||
}{
|
||||
{
|
||||
name: "no filtering with default options",
|
||||
services: []DetectedService{ServiceFrankenPHP, ServiceVite, ServiceHorizon},
|
||||
opts: Options{},
|
||||
expected: []DetectedService{ServiceFrankenPHP, ServiceVite, ServiceHorizon},
|
||||
},
|
||||
{
|
||||
name: "filters Vite when NoVite is true",
|
||||
services: []DetectedService{ServiceFrankenPHP, ServiceVite, ServiceHorizon},
|
||||
opts: Options{NoVite: true},
|
||||
expected: []DetectedService{ServiceFrankenPHP, ServiceHorizon},
|
||||
},
|
||||
{
|
||||
name: "filters Horizon when NoHorizon is true",
|
||||
services: []DetectedService{ServiceFrankenPHP, ServiceVite, ServiceHorizon},
|
||||
opts: Options{NoHorizon: true},
|
||||
expected: []DetectedService{ServiceFrankenPHP, ServiceVite},
|
||||
},
|
||||
{
|
||||
name: "filters Reverb when NoReverb is true",
|
||||
services: []DetectedService{ServiceFrankenPHP, ServiceReverb},
|
||||
opts: Options{NoReverb: true},
|
||||
expected: []DetectedService{ServiceFrankenPHP},
|
||||
},
|
||||
{
|
||||
name: "filters Redis when NoRedis is true",
|
||||
services: []DetectedService{ServiceFrankenPHP, ServiceRedis},
|
||||
opts: Options{NoRedis: true},
|
||||
expected: []DetectedService{ServiceFrankenPHP},
|
||||
},
|
||||
{
|
||||
name: "filters multiple services",
|
||||
services: []DetectedService{ServiceFrankenPHP, ServiceVite, ServiceHorizon, ServiceReverb, ServiceRedis},
|
||||
opts: Options{NoVite: true, NoHorizon: true, NoReverb: true, NoRedis: true},
|
||||
expected: []DetectedService{ServiceFrankenPHP},
|
||||
},
|
||||
{
|
||||
name: "keeps unknown services",
|
||||
services: []DetectedService{ServiceFrankenPHP},
|
||||
opts: Options{NoVite: true},
|
||||
expected: []DetectedService{ServiceFrankenPHP},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
server := NewDevServer(Options{})
|
||||
result := server.filterServices(tt.services, tt.opts)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultiServiceReader_Good(t *testing.T) {
|
||||
t.Run("closes all readers on Close", func(t *testing.T) {
|
||||
// Create mock readers using files
|
||||
dir := t.TempDir()
|
||||
file1, err := os.CreateTemp(dir, "log1-*.log")
|
||||
require.NoError(t, err)
|
||||
_, _ = file1.WriteString("test1")
|
||||
_, _ = file1.Seek(0, 0)
|
||||
|
||||
file2, err := os.CreateTemp(dir, "log2-*.log")
|
||||
require.NoError(t, err)
|
||||
_, _ = file2.WriteString("test2")
|
||||
_, _ = file2.Seek(0, 0)
|
||||
|
||||
// Create mock services
|
||||
services := []Service{
|
||||
&FrankenPHPService{baseService: baseService{name: "svc1"}},
|
||||
&ViteService{baseService: baseService{name: "svc2"}},
|
||||
}
|
||||
readers := []io.ReadCloser{file1, file2}
|
||||
|
||||
reader := newMultiServiceReader(services, readers, false)
|
||||
assert.NotNil(t, reader)
|
||||
|
||||
err = reader.Close()
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, reader.closed)
|
||||
})
|
||||
|
||||
t.Run("returns EOF when closed", func(t *testing.T) {
|
||||
reader := &multiServiceReader{closed: true}
|
||||
buf := make([]byte, 10)
|
||||
n, err := reader.Read(buf)
|
||||
assert.Equal(t, 0, n)
|
||||
assert.Equal(t, io.EOF, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestMultiServiceReader_Read_Good(t *testing.T) {
|
||||
t.Run("reads from readers with service prefix", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
file1, err := os.CreateTemp(dir, "log-*.log")
|
||||
require.NoError(t, err)
|
||||
_, _ = file1.WriteString("log content")
|
||||
_, _ = file1.Seek(0, 0)
|
||||
|
||||
services := []Service{
|
||||
&FrankenPHPService{baseService: baseService{name: "TestService"}},
|
||||
}
|
||||
readers := []io.ReadCloser{file1}
|
||||
|
||||
reader := newMultiServiceReader(services, readers, false)
|
||||
buf := make([]byte, 100)
|
||||
n, err := reader.Read(buf)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Greater(t, n, 0)
|
||||
result := string(buf[:n])
|
||||
assert.Contains(t, result, "[TestService]")
|
||||
})
|
||||
|
||||
t.Run("returns EOF when all readers are exhausted in non-follow mode", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
file1, err := os.CreateTemp(dir, "log-*.log")
|
||||
require.NoError(t, err)
|
||||
_ = file1.Close() // Empty file
|
||||
|
||||
file1, err = os.Open(file1.Name())
|
||||
require.NoError(t, err)
|
||||
|
||||
services := []Service{
|
||||
&FrankenPHPService{baseService: baseService{name: "TestService"}},
|
||||
}
|
||||
readers := []io.ReadCloser{file1}
|
||||
|
||||
reader := newMultiServiceReader(services, readers, false)
|
||||
buf := make([]byte, 100)
|
||||
n, err := reader.Read(buf)
|
||||
|
||||
assert.Equal(t, 0, n)
|
||||
assert.Equal(t, io.EOF, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestOptions_Good(t *testing.T) {
|
||||
t.Run("all fields are accessible", func(t *testing.T) {
|
||||
opts := Options{
|
||||
Dir: "/test",
|
||||
Services: []DetectedService{ServiceFrankenPHP},
|
||||
NoVite: true,
|
||||
NoHorizon: true,
|
||||
NoReverb: true,
|
||||
NoRedis: true,
|
||||
HTTPS: true,
|
||||
Domain: "test.local",
|
||||
FrankenPHPPort: 8000,
|
||||
HTTPSPort: 443,
|
||||
VitePort: 5173,
|
||||
ReverbPort: 8080,
|
||||
RedisPort: 6379,
|
||||
}
|
||||
|
||||
assert.Equal(t, "/test", opts.Dir)
|
||||
assert.Equal(t, []DetectedService{ServiceFrankenPHP}, opts.Services)
|
||||
assert.True(t, opts.NoVite)
|
||||
assert.True(t, opts.NoHorizon)
|
||||
assert.True(t, opts.NoReverb)
|
||||
assert.True(t, opts.NoRedis)
|
||||
assert.True(t, opts.HTTPS)
|
||||
assert.Equal(t, "test.local", opts.Domain)
|
||||
assert.Equal(t, 8000, opts.FrankenPHPPort)
|
||||
assert.Equal(t, 443, opts.HTTPSPort)
|
||||
assert.Equal(t, 5173, opts.VitePort)
|
||||
assert.Equal(t, 8080, opts.ReverbPort)
|
||||
assert.Equal(t, 6379, opts.RedisPort)
|
||||
})
|
||||
}
|
||||
|
||||
func TestDevServer_StartStop_Integration(t *testing.T) {
|
||||
t.Skip("requires PHP/FrankenPHP installed")
|
||||
|
||||
dir := t.TempDir()
|
||||
setupLaravelProject(t, dir)
|
||||
|
||||
server := NewDevServer(Options{Dir: dir})
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
err := server.Start(ctx, Options{Dir: dir})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, server.IsRunning())
|
||||
|
||||
err = server.Stop()
|
||||
require.NoError(t, err)
|
||||
assert.False(t, server.IsRunning())
|
||||
}
|
||||
|
||||
// setupLaravelProject creates a minimal Laravel project structure for testing.
|
||||
func setupLaravelProject(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
|
||||
// Create artisan file
|
||||
err := os.WriteFile(filepath.Join(dir, "artisan"), []byte("#!/usr/bin/env php\n"), 0755)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create composer.json with Laravel
|
||||
composerJSON := `{
|
||||
"name": "test/laravel-project",
|
||||
"require": {
|
||||
"php": "^8.2",
|
||||
"laravel/framework": "^11.0",
|
||||
"laravel/octane": "^2.0"
|
||||
}
|
||||
}`
|
||||
err = os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestDevServer_UnifiedLogs_Bad(t *testing.T) {
|
||||
t.Run("returns error when service logs fail", func(t *testing.T) {
|
||||
server := NewDevServer(Options{})
|
||||
|
||||
// Create a mock service that will fail to provide logs
|
||||
mockService := &FrankenPHPService{
|
||||
baseService: baseService{
|
||||
name: "FailingService",
|
||||
logPath: "", // No log path set will cause error
|
||||
},
|
||||
}
|
||||
server.services = []Service{mockService}
|
||||
|
||||
_, err := server.Logs("", false)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "failed to get logs")
|
||||
})
|
||||
}
|
||||
|
||||
func TestDevServer_Logs_Good(t *testing.T) {
|
||||
t.Run("finds specific service logs", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
logFile := filepath.Join(dir, "test.log")
|
||||
err := os.WriteFile(logFile, []byte("test log content"), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
server := NewDevServer(Options{})
|
||||
mockService := &FrankenPHPService{
|
||||
baseService: baseService{
|
||||
name: "TestService",
|
||||
logPath: logFile,
|
||||
},
|
||||
}
|
||||
server.services = []Service{mockService}
|
||||
|
||||
reader, err := server.Logs("TestService", false)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, reader)
|
||||
_ = reader.Close()
|
||||
})
|
||||
}
|
||||
|
||||
func TestDevServer_MergeOptions_Good(t *testing.T) {
|
||||
t.Run("start merges options correctly", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
server := NewDevServer(Options{Dir: "/original"})
|
||||
|
||||
// Setup a minimal non-Laravel project to trigger an error
|
||||
// but still test the options merge happens first
|
||||
err := server.Start(context.Background(), Options{Dir: dir})
|
||||
assert.Error(t, err) // Will fail because not Laravel project
|
||||
// But the directory should have been merged
|
||||
assert.Equal(t, dir, server.opts.Dir)
|
||||
})
|
||||
}
|
||||
|
||||
func TestDetectedService_Constants(t *testing.T) {
|
||||
t.Run("all service constants are defined", func(t *testing.T) {
|
||||
assert.Equal(t, DetectedService("frankenphp"), ServiceFrankenPHP)
|
||||
assert.Equal(t, DetectedService("vite"), ServiceVite)
|
||||
assert.Equal(t, DetectedService("horizon"), ServiceHorizon)
|
||||
assert.Equal(t, DetectedService("reverb"), ServiceReverb)
|
||||
assert.Equal(t, DetectedService("redis"), ServiceRedis)
|
||||
})
|
||||
}
|
||||
|
||||
func TestDevServer_HTTPSSetup(t *testing.T) {
|
||||
t.Run("extracts domain from APP_URL when HTTPS enabled", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
// Create Laravel project
|
||||
err := os.WriteFile(filepath.Join(dir, "artisan"), []byte("#!/usr/bin/env php\n"), 0755)
|
||||
require.NoError(t, err)
|
||||
|
||||
composerJSON := `{
|
||||
"require": {
|
||||
"laravel/framework": "^11.0",
|
||||
"laravel/octane": "^2.0"
|
||||
}
|
||||
}`
|
||||
err = os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create .env with APP_URL
|
||||
envContent := "APP_URL=https://myapp.test"
|
||||
err = os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify we can extract the domain
|
||||
url := GetLaravelAppURL(dir)
|
||||
domain := ExtractDomainFromURL(url)
|
||||
assert.Equal(t, "myapp.test", domain)
|
||||
})
|
||||
}
|
||||
|
||||
func TestDevServer_PortDefaults(t *testing.T) {
|
||||
t.Run("uses default ports when not specified", func(t *testing.T) {
|
||||
// This tests the logic in Start() for default port assignment
|
||||
// We verify the constants/defaults by checking what would be created
|
||||
|
||||
// FrankenPHP default port is 8000
|
||||
svc := NewFrankenPHPService("/tmp", FrankenPHPOptions{})
|
||||
assert.Equal(t, 8000, svc.port)
|
||||
|
||||
// Vite default port is 5173
|
||||
vite := NewViteService("/tmp", ViteOptions{})
|
||||
assert.Equal(t, 5173, vite.port)
|
||||
|
||||
// Reverb default port is 8080
|
||||
reverb := NewReverbService("/tmp", ReverbOptions{})
|
||||
assert.Equal(t, 8080, reverb.port)
|
||||
|
||||
// Redis default port is 6379
|
||||
redis := NewRedisService("/tmp", RedisOptions{})
|
||||
assert.Equal(t, 6379, redis.port)
|
||||
})
|
||||
}
|
||||
|
||||
func TestDevServer_ServiceCreation(t *testing.T) {
|
||||
t.Run("creates correct services based on detected services", func(t *testing.T) {
|
||||
// Test that the switch statement in Start() creates the right service types
|
||||
services := []DetectedService{
|
||||
ServiceFrankenPHP,
|
||||
ServiceVite,
|
||||
ServiceHorizon,
|
||||
ServiceReverb,
|
||||
ServiceRedis,
|
||||
}
|
||||
|
||||
// Verify each service type string
|
||||
expected := []string{"frankenphp", "vite", "horizon", "reverb", "redis"}
|
||||
for i, svc := range services {
|
||||
assert.Equal(t, expected[i], string(svc))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestMultiServiceReader_CloseError(t *testing.T) {
|
||||
t.Run("returns first close error", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
// Create a real file that we can close
|
||||
file1, err := os.CreateTemp(dir, "log-*.log")
|
||||
require.NoError(t, err)
|
||||
file1Name := file1.Name()
|
||||
_ = file1.Close()
|
||||
|
||||
// Reopen for reading
|
||||
file1, err = os.Open(file1Name)
|
||||
require.NoError(t, err)
|
||||
|
||||
services := []Service{
|
||||
&FrankenPHPService{baseService: baseService{name: "svc1"}},
|
||||
}
|
||||
readers := []io.ReadCloser{file1}
|
||||
|
||||
reader := newMultiServiceReader(services, readers, false)
|
||||
err = reader.Close()
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Second close should still work (files already closed)
|
||||
// The closed flag prevents double-processing
|
||||
assert.True(t, reader.closed)
|
||||
})
|
||||
}
|
||||
|
||||
func TestMultiServiceReader_FollowMode(t *testing.T) {
|
||||
t.Run("returns 0 bytes without error in follow mode when no data", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
file1, err := os.CreateTemp(dir, "log-*.log")
|
||||
require.NoError(t, err)
|
||||
file1Name := file1.Name()
|
||||
_ = file1.Close()
|
||||
|
||||
// Reopen for reading (empty file)
|
||||
file1, err = os.Open(file1Name)
|
||||
require.NoError(t, err)
|
||||
|
||||
services := []Service{
|
||||
&FrankenPHPService{baseService: baseService{name: "svc1"}},
|
||||
}
|
||||
readers := []io.ReadCloser{file1}
|
||||
|
||||
reader := newMultiServiceReader(services, readers, true) // follow=true
|
||||
|
||||
// Use a channel to timeout the read since follow mode waits
|
||||
done := make(chan bool)
|
||||
go func() {
|
||||
buf := make([]byte, 100)
|
||||
n, err := reader.Read(buf)
|
||||
// In follow mode, should return 0 bytes and nil error (waiting for more data)
|
||||
assert.Equal(t, 0, n)
|
||||
assert.NoError(t, err)
|
||||
done <- true
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
// Good, read completed
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
// Also acceptable - follow mode is waiting
|
||||
}
|
||||
|
||||
_ = reader.Close()
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetLaravelAppURL_Bad(t *testing.T) {
|
||||
t.Run("no .env file", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
assert.Equal(t, "", GetLaravelAppURL(dir))
|
||||
})
|
||||
|
||||
t.Run("no APP_URL in .env", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
envContent := "APP_NAME=Test\nAPP_ENV=local"
|
||||
err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "", GetLaravelAppURL(dir))
|
||||
})
|
||||
}
|
||||
|
||||
func TestExtractDomainFromURL_Edge(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
url string
|
||||
expected string
|
||||
}{
|
||||
{"empty string", "", ""},
|
||||
{"just domain", "example.com", "example.com"},
|
||||
{"http only", "http://", ""},
|
||||
{"https only", "https://", ""},
|
||||
{"domain with trailing slash", "https://example.com/", "example.com"},
|
||||
{"complex path", "https://example.com:8080/path/to/page?query=1", "example.com"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Strip protocol
|
||||
result := ExtractDomainFromURL(tt.url)
|
||||
if tt.url != "" && !strings.HasPrefix(tt.url, "http://") && !strings.HasPrefix(tt.url, "https://") && !strings.Contains(tt.url, ":") && !strings.Contains(tt.url, "/") {
|
||||
assert.Equal(t, tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevServer_StatusWithServices(t *testing.T) {
|
||||
t.Run("returns statuses for all services", func(t *testing.T) {
|
||||
server := NewDevServer(Options{})
|
||||
|
||||
// Add mock services
|
||||
server.services = []Service{
|
||||
&FrankenPHPService{baseService: baseService{name: "svc1", running: true, port: 8000}},
|
||||
&ViteService{baseService: baseService{name: "svc2", running: false, port: 5173}},
|
||||
}
|
||||
|
||||
statuses := server.Status()
|
||||
assert.Len(t, statuses, 2)
|
||||
assert.Equal(t, "svc1", statuses[0].Name)
|
||||
assert.True(t, statuses[0].Running)
|
||||
assert.Equal(t, "svc2", statuses[1].Name)
|
||||
assert.False(t, statuses[1].Running)
|
||||
})
|
||||
}
|
||||
|
||||
func TestDevServer_ServicesReturnsAll(t *testing.T) {
|
||||
t.Run("returns all services", func(t *testing.T) {
|
||||
server := NewDevServer(Options{})
|
||||
|
||||
// Add mock services
|
||||
server.services = []Service{
|
||||
&FrankenPHPService{baseService: baseService{name: "svc1"}},
|
||||
&ViteService{baseService: baseService{name: "svc2"}},
|
||||
&HorizonService{baseService: baseService{name: "svc3"}},
|
||||
}
|
||||
|
||||
services := server.Services()
|
||||
assert.Len(t, services, 3)
|
||||
})
|
||||
}
|
||||
|
||||
func TestDevServer_StopWithCancel(t *testing.T) {
|
||||
t.Run("calls cancel when running", func(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
server := NewDevServer(Options{})
|
||||
server.running = true
|
||||
server.cancel = cancel
|
||||
server.ctx = ctx
|
||||
|
||||
// Add a mock service that won't error
|
||||
server.services = []Service{
|
||||
&FrankenPHPService{baseService: baseService{name: "svc1", running: false}},
|
||||
}
|
||||
|
||||
err := server.Stop()
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, server.running)
|
||||
})
|
||||
}
|
||||
|
||||
func TestMultiServiceReader_CloseWithErrors(t *testing.T) {
|
||||
t.Run("handles multiple close errors", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
// Create files
|
||||
file1, err := os.CreateTemp(dir, "log1-*.log")
|
||||
require.NoError(t, err)
|
||||
file2, err := os.CreateTemp(dir, "log2-*.log")
|
||||
require.NoError(t, err)
|
||||
|
||||
services := []Service{
|
||||
&FrankenPHPService{baseService: baseService{name: "svc1"}},
|
||||
&ViteService{baseService: baseService{name: "svc2"}},
|
||||
}
|
||||
readers := []io.ReadCloser{file1, file2}
|
||||
|
||||
reader := newMultiServiceReader(services, readers, false)
|
||||
|
||||
// Close successfully
|
||||
err = reader.Close()
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
}
|
||||
994
quality.go
Normal file
994
quality.go
Normal file
|
|
@ -0,0 +1,994 @@
|
|||
package php
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
goio "io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
)
|
||||
|
||||
// FormatOptions configures PHP code formatting.
|
||||
type FormatOptions struct {
|
||||
// Dir is the project directory (defaults to current working directory).
|
||||
Dir string
|
||||
|
||||
// Fix automatically fixes formatting issues.
|
||||
Fix bool
|
||||
|
||||
// Diff shows a diff of changes instead of modifying files.
|
||||
Diff bool
|
||||
|
||||
// JSON outputs results in JSON format.
|
||||
JSON bool
|
||||
|
||||
// Paths limits formatting to specific paths.
|
||||
Paths []string
|
||||
|
||||
// Output is the writer for output (defaults to os.Stdout).
|
||||
Output goio.Writer
|
||||
}
|
||||
|
||||
// AnalyseOptions configures PHP static analysis.
|
||||
type AnalyseOptions struct {
|
||||
// Dir is the project directory (defaults to current working directory).
|
||||
Dir string
|
||||
|
||||
// Level is the PHPStan analysis level (0-9).
|
||||
Level int
|
||||
|
||||
// Paths limits analysis to specific paths.
|
||||
Paths []string
|
||||
|
||||
// Memory is the memory limit for analysis (e.g., "2G").
|
||||
Memory string
|
||||
|
||||
// JSON outputs results in JSON format.
|
||||
JSON bool
|
||||
|
||||
// SARIF outputs results in SARIF format for GitHub Security tab.
|
||||
SARIF bool
|
||||
|
||||
// Output is the writer for output (defaults to os.Stdout).
|
||||
Output goio.Writer
|
||||
}
|
||||
|
||||
// FormatterType represents the detected formatter.
|
||||
type FormatterType string
|
||||
|
||||
// Formatter type constants.
|
||||
const (
|
||||
// FormatterPint indicates Laravel Pint code formatter.
|
||||
FormatterPint FormatterType = "pint"
|
||||
)
|
||||
|
||||
// AnalyserType represents the detected static analyser.
|
||||
type AnalyserType string
|
||||
|
||||
// Static analyser type constants.
|
||||
const (
|
||||
// AnalyserPHPStan indicates standard PHPStan analyser.
|
||||
AnalyserPHPStan AnalyserType = "phpstan"
|
||||
// AnalyserLarastan indicates Laravel-specific Larastan analyser.
|
||||
AnalyserLarastan AnalyserType = "larastan"
|
||||
)
|
||||
|
||||
// DetectFormatter detects which formatter is available in the project.
|
||||
func DetectFormatter(dir string) (FormatterType, bool) {
|
||||
m := getMedium()
|
||||
|
||||
// Check for Pint config
|
||||
pintConfig := filepath.Join(dir, "pint.json")
|
||||
if m.Exists(pintConfig) {
|
||||
return FormatterPint, true
|
||||
}
|
||||
|
||||
// Check for vendor binary
|
||||
pintBin := filepath.Join(dir, "vendor", "bin", "pint")
|
||||
if m.Exists(pintBin) {
|
||||
return FormatterPint, true
|
||||
}
|
||||
|
||||
return "", false
|
||||
}
|
||||
|
||||
// DetectAnalyser detects which static analyser is available in the project.
|
||||
func DetectAnalyser(dir string) (AnalyserType, bool) {
|
||||
m := getMedium()
|
||||
|
||||
// Check for PHPStan config
|
||||
phpstanConfig := filepath.Join(dir, "phpstan.neon")
|
||||
phpstanDistConfig := filepath.Join(dir, "phpstan.neon.dist")
|
||||
|
||||
hasConfig := m.Exists(phpstanConfig) || m.Exists(phpstanDistConfig)
|
||||
|
||||
// Check for vendor binary
|
||||
phpstanBin := filepath.Join(dir, "vendor", "bin", "phpstan")
|
||||
hasBin := m.Exists(phpstanBin)
|
||||
|
||||
if hasConfig || hasBin {
|
||||
// Check if it's Larastan (Laravel-specific PHPStan)
|
||||
larastanPath := filepath.Join(dir, "vendor", "larastan", "larastan")
|
||||
if m.Exists(larastanPath) {
|
||||
return AnalyserLarastan, true
|
||||
}
|
||||
// Also check nunomaduro/larastan
|
||||
larastanPath2 := filepath.Join(dir, "vendor", "nunomaduro", "larastan")
|
||||
if m.Exists(larastanPath2) {
|
||||
return AnalyserLarastan, true
|
||||
}
|
||||
return AnalyserPHPStan, true
|
||||
}
|
||||
|
||||
return "", false
|
||||
}
|
||||
|
||||
// Format runs Laravel Pint to format PHP code.
|
||||
func Format(ctx context.Context, opts FormatOptions) error {
|
||||
if opts.Dir == "" {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return cli.WrapVerb(err, "get", "working directory")
|
||||
}
|
||||
opts.Dir = cwd
|
||||
}
|
||||
|
||||
if opts.Output == nil {
|
||||
opts.Output = os.Stdout
|
||||
}
|
||||
|
||||
// Check if formatter is available
|
||||
formatter, found := DetectFormatter(opts.Dir)
|
||||
if !found {
|
||||
return cli.Err("no formatter found (install Laravel Pint: composer require laravel/pint --dev)")
|
||||
}
|
||||
|
||||
var cmdName string
|
||||
var args []string
|
||||
|
||||
switch formatter {
|
||||
case FormatterPint:
|
||||
cmdName, args = buildPintCommand(opts)
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(ctx, cmdName, args...)
|
||||
cmd.Dir = opts.Dir
|
||||
cmd.Stdout = opts.Output
|
||||
cmd.Stderr = opts.Output
|
||||
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
// Analyse runs PHPStan or Larastan for static analysis.
|
||||
func Analyse(ctx context.Context, opts AnalyseOptions) error {
|
||||
if opts.Dir == "" {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return cli.WrapVerb(err, "get", "working directory")
|
||||
}
|
||||
opts.Dir = cwd
|
||||
}
|
||||
|
||||
if opts.Output == nil {
|
||||
opts.Output = os.Stdout
|
||||
}
|
||||
|
||||
// Check if analyser is available
|
||||
analyser, found := DetectAnalyser(opts.Dir)
|
||||
if !found {
|
||||
return cli.Err("no static analyser found (install PHPStan: composer require phpstan/phpstan --dev)")
|
||||
}
|
||||
|
||||
var cmdName string
|
||||
var args []string
|
||||
|
||||
switch analyser {
|
||||
case AnalyserPHPStan, AnalyserLarastan:
|
||||
cmdName, args = buildPHPStanCommand(opts)
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(ctx, cmdName, args...)
|
||||
cmd.Dir = opts.Dir
|
||||
cmd.Stdout = opts.Output
|
||||
cmd.Stderr = opts.Output
|
||||
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
// buildPintCommand builds the command for running Laravel Pint.
|
||||
func buildPintCommand(opts FormatOptions) (string, []string) {
|
||||
m := getMedium()
|
||||
|
||||
// Check for vendor binary first
|
||||
vendorBin := filepath.Join(opts.Dir, "vendor", "bin", "pint")
|
||||
cmdName := "pint"
|
||||
if m.Exists(vendorBin) {
|
||||
cmdName = vendorBin
|
||||
}
|
||||
|
||||
var args []string
|
||||
|
||||
if !opts.Fix {
|
||||
args = append(args, "--test")
|
||||
}
|
||||
|
||||
if opts.Diff {
|
||||
args = append(args, "--diff")
|
||||
}
|
||||
|
||||
if opts.JSON {
|
||||
args = append(args, "--format=json")
|
||||
}
|
||||
|
||||
// Add specific paths if provided
|
||||
args = append(args, opts.Paths...)
|
||||
|
||||
return cmdName, args
|
||||
}
|
||||
|
||||
// buildPHPStanCommand builds the command for running PHPStan.
|
||||
func buildPHPStanCommand(opts AnalyseOptions) (string, []string) {
|
||||
m := getMedium()
|
||||
|
||||
// Check for vendor binary first
|
||||
vendorBin := filepath.Join(opts.Dir, "vendor", "bin", "phpstan")
|
||||
cmdName := "phpstan"
|
||||
if m.Exists(vendorBin) {
|
||||
cmdName = vendorBin
|
||||
}
|
||||
|
||||
args := []string{"analyse"}
|
||||
|
||||
if opts.Level > 0 {
|
||||
args = append(args, "--level", cli.Sprintf("%d", opts.Level))
|
||||
}
|
||||
|
||||
if opts.Memory != "" {
|
||||
args = append(args, "--memory-limit", opts.Memory)
|
||||
}
|
||||
|
||||
// Output format - SARIF takes precedence over JSON
|
||||
if opts.SARIF {
|
||||
args = append(args, "--error-format=sarif")
|
||||
} else if opts.JSON {
|
||||
args = append(args, "--error-format=json")
|
||||
}
|
||||
|
||||
// Add specific paths if provided
|
||||
args = append(args, opts.Paths...)
|
||||
|
||||
return cmdName, args
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Psalm Static Analysis
|
||||
// =============================================================================
|
||||
|
||||
// PsalmOptions configures Psalm static analysis.
|
||||
type PsalmOptions struct {
|
||||
Dir string
|
||||
Level int // Error level (1=strictest, 8=most lenient)
|
||||
Fix bool // Auto-fix issues where possible
|
||||
Baseline bool // Generate/update baseline file
|
||||
ShowInfo bool // Show info-level issues
|
||||
JSON bool // Output in JSON format
|
||||
SARIF bool // Output in SARIF format for GitHub Security tab
|
||||
Output goio.Writer
|
||||
}
|
||||
|
||||
// PsalmType represents the detected Psalm configuration.
|
||||
type PsalmType string
|
||||
|
||||
// Psalm configuration type constants.
|
||||
const (
|
||||
// PsalmStandard indicates standard Psalm configuration.
|
||||
PsalmStandard PsalmType = "psalm"
|
||||
)
|
||||
|
||||
// DetectPsalm checks if Psalm is available in the project.
|
||||
func DetectPsalm(dir string) (PsalmType, bool) {
|
||||
m := getMedium()
|
||||
|
||||
// Check for psalm.xml config
|
||||
psalmConfig := filepath.Join(dir, "psalm.xml")
|
||||
psalmDistConfig := filepath.Join(dir, "psalm.xml.dist")
|
||||
|
||||
hasConfig := m.Exists(psalmConfig) || m.Exists(psalmDistConfig)
|
||||
|
||||
// Check for vendor binary
|
||||
psalmBin := filepath.Join(dir, "vendor", "bin", "psalm")
|
||||
if m.Exists(psalmBin) {
|
||||
return PsalmStandard, true
|
||||
}
|
||||
|
||||
if hasConfig {
|
||||
return PsalmStandard, true
|
||||
}
|
||||
|
||||
return "", false
|
||||
}
|
||||
|
||||
// RunPsalm runs Psalm static analysis.
|
||||
func RunPsalm(ctx context.Context, opts PsalmOptions) error {
|
||||
if opts.Dir == "" {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return cli.WrapVerb(err, "get", "working directory")
|
||||
}
|
||||
opts.Dir = cwd
|
||||
}
|
||||
|
||||
if opts.Output == nil {
|
||||
opts.Output = os.Stdout
|
||||
}
|
||||
|
||||
m := getMedium()
|
||||
|
||||
// Build command
|
||||
vendorBin := filepath.Join(opts.Dir, "vendor", "bin", "psalm")
|
||||
cmdName := "psalm"
|
||||
if m.Exists(vendorBin) {
|
||||
cmdName = vendorBin
|
||||
}
|
||||
|
||||
args := []string{"--no-progress"}
|
||||
|
||||
if opts.Level > 0 && opts.Level <= 8 {
|
||||
args = append(args, cli.Sprintf("--error-level=%d", opts.Level))
|
||||
}
|
||||
|
||||
if opts.Fix {
|
||||
args = append(args, "--alter", "--issues=all")
|
||||
}
|
||||
|
||||
if opts.Baseline {
|
||||
args = append(args, "--set-baseline=psalm-baseline.xml")
|
||||
}
|
||||
|
||||
if opts.ShowInfo {
|
||||
args = append(args, "--show-info=true")
|
||||
}
|
||||
|
||||
// Output format - SARIF takes precedence over JSON
|
||||
if opts.SARIF {
|
||||
args = append(args, "--output-format=sarif")
|
||||
} else if opts.JSON {
|
||||
args = append(args, "--output-format=json")
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(ctx, cmdName, args...)
|
||||
cmd.Dir = opts.Dir
|
||||
cmd.Stdout = opts.Output
|
||||
cmd.Stderr = opts.Output
|
||||
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Security Audit
|
||||
// =============================================================================
|
||||
|
||||
// AuditOptions configures dependency security auditing.
|
||||
type AuditOptions struct {
|
||||
Dir string
|
||||
JSON bool // Output in JSON format
|
||||
Fix bool // Auto-fix vulnerabilities (npm only)
|
||||
Output goio.Writer
|
||||
}
|
||||
|
||||
// AuditResult holds the results of a security audit.
|
||||
type AuditResult struct {
|
||||
Tool string
|
||||
Vulnerabilities int
|
||||
Advisories []AuditAdvisory
|
||||
Error error
|
||||
}
|
||||
|
||||
// AuditAdvisory represents a single security advisory.
|
||||
type AuditAdvisory struct {
|
||||
Package string
|
||||
Severity string
|
||||
Title string
|
||||
URL string
|
||||
Identifiers []string
|
||||
}
|
||||
|
||||
// RunAudit runs security audits on dependencies.
|
||||
func RunAudit(ctx context.Context, opts AuditOptions) ([]AuditResult, error) {
|
||||
if opts.Dir == "" {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return nil, cli.WrapVerb(err, "get", "working directory")
|
||||
}
|
||||
opts.Dir = cwd
|
||||
}
|
||||
|
||||
if opts.Output == nil {
|
||||
opts.Output = os.Stdout
|
||||
}
|
||||
|
||||
var results []AuditResult
|
||||
|
||||
// Run composer audit
|
||||
composerResult := runComposerAudit(ctx, opts)
|
||||
results = append(results, composerResult)
|
||||
|
||||
// Run npm audit if package.json exists
|
||||
if getMedium().Exists(filepath.Join(opts.Dir, "package.json")) {
|
||||
npmResult := runNpmAudit(ctx, opts)
|
||||
results = append(results, npmResult)
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func runComposerAudit(ctx context.Context, opts AuditOptions) AuditResult {
|
||||
result := AuditResult{Tool: "composer"}
|
||||
|
||||
args := []string{"audit", "--format=json"}
|
||||
|
||||
cmd := exec.CommandContext(ctx, "composer", args...)
|
||||
cmd.Dir = opts.Dir
|
||||
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
// composer audit returns non-zero if vulnerabilities found
|
||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||
output = append(output, exitErr.Stderr...)
|
||||
}
|
||||
}
|
||||
|
||||
// Parse JSON output
|
||||
var auditData struct {
|
||||
Advisories map[string][]struct {
|
||||
Title string `json:"title"`
|
||||
Link string `json:"link"`
|
||||
CVE string `json:"cve"`
|
||||
AffectedRanges string `json:"affectedVersions"`
|
||||
} `json:"advisories"`
|
||||
}
|
||||
|
||||
if jsonErr := json.Unmarshal(output, &auditData); jsonErr == nil {
|
||||
for pkg, advisories := range auditData.Advisories {
|
||||
for _, adv := range advisories {
|
||||
result.Advisories = append(result.Advisories, AuditAdvisory{
|
||||
Package: pkg,
|
||||
Title: adv.Title,
|
||||
URL: adv.Link,
|
||||
Identifiers: []string{adv.CVE},
|
||||
})
|
||||
}
|
||||
}
|
||||
result.Vulnerabilities = len(result.Advisories)
|
||||
} else if err != nil {
|
||||
result.Error = err
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func runNpmAudit(ctx context.Context, opts AuditOptions) AuditResult {
|
||||
result := AuditResult{Tool: "npm"}
|
||||
|
||||
args := []string{"audit", "--json"}
|
||||
if opts.Fix {
|
||||
args = []string{"audit", "fix"}
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(ctx, "npm", args...)
|
||||
cmd.Dir = opts.Dir
|
||||
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||
output = append(output, exitErr.Stderr...)
|
||||
}
|
||||
}
|
||||
|
||||
if !opts.Fix {
|
||||
// Parse JSON output
|
||||
var auditData struct {
|
||||
Metadata struct {
|
||||
Vulnerabilities struct {
|
||||
Total int `json:"total"`
|
||||
} `json:"vulnerabilities"`
|
||||
} `json:"metadata"`
|
||||
Vulnerabilities map[string]struct {
|
||||
Severity string `json:"severity"`
|
||||
Via []any `json:"via"`
|
||||
} `json:"vulnerabilities"`
|
||||
}
|
||||
|
||||
if jsonErr := json.Unmarshal(output, &auditData); jsonErr == nil {
|
||||
result.Vulnerabilities = auditData.Metadata.Vulnerabilities.Total
|
||||
for pkg, vuln := range auditData.Vulnerabilities {
|
||||
result.Advisories = append(result.Advisories, AuditAdvisory{
|
||||
Package: pkg,
|
||||
Severity: vuln.Severity,
|
||||
})
|
||||
}
|
||||
} else if err != nil {
|
||||
result.Error = err
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Rector Automated Refactoring
|
||||
// =============================================================================
|
||||
|
||||
// RectorOptions configures Rector code refactoring.
|
||||
type RectorOptions struct {
|
||||
Dir string
|
||||
Fix bool // Apply changes (default is dry-run)
|
||||
Diff bool // Show detailed diff
|
||||
ClearCache bool // Clear cache before running
|
||||
Output goio.Writer
|
||||
}
|
||||
|
||||
// DetectRector checks if Rector is available in the project.
|
||||
func DetectRector(dir string) bool {
|
||||
m := getMedium()
|
||||
|
||||
// Check for rector.php config
|
||||
rectorConfig := filepath.Join(dir, "rector.php")
|
||||
if m.Exists(rectorConfig) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check for vendor binary
|
||||
rectorBin := filepath.Join(dir, "vendor", "bin", "rector")
|
||||
if m.Exists(rectorBin) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// RunRector runs Rector for automated code refactoring.
|
||||
func RunRector(ctx context.Context, opts RectorOptions) error {
|
||||
if opts.Dir == "" {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return cli.WrapVerb(err, "get", "working directory")
|
||||
}
|
||||
opts.Dir = cwd
|
||||
}
|
||||
|
||||
if opts.Output == nil {
|
||||
opts.Output = os.Stdout
|
||||
}
|
||||
|
||||
m := getMedium()
|
||||
|
||||
// Build command
|
||||
vendorBin := filepath.Join(opts.Dir, "vendor", "bin", "rector")
|
||||
cmdName := "rector"
|
||||
if m.Exists(vendorBin) {
|
||||
cmdName = vendorBin
|
||||
}
|
||||
|
||||
args := []string{"process"}
|
||||
|
||||
if !opts.Fix {
|
||||
args = append(args, "--dry-run")
|
||||
}
|
||||
|
||||
if opts.Diff {
|
||||
args = append(args, "--output-format", "diff")
|
||||
}
|
||||
|
||||
if opts.ClearCache {
|
||||
args = append(args, "--clear-cache")
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(ctx, cmdName, args...)
|
||||
cmd.Dir = opts.Dir
|
||||
cmd.Stdout = opts.Output
|
||||
cmd.Stderr = opts.Output
|
||||
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Infection Mutation Testing
|
||||
// =============================================================================
|
||||
|
||||
// InfectionOptions configures Infection mutation testing.
|
||||
type InfectionOptions struct {
|
||||
Dir string
|
||||
MinMSI int // Minimum mutation score indicator (0-100)
|
||||
MinCoveredMSI int // Minimum covered mutation score (0-100)
|
||||
Threads int // Number of parallel threads
|
||||
Filter string // Filter files by pattern
|
||||
OnlyCovered bool // Only mutate covered code
|
||||
Output goio.Writer
|
||||
}
|
||||
|
||||
// DetectInfection checks if Infection is available in the project.
|
||||
func DetectInfection(dir string) bool {
|
||||
m := getMedium()
|
||||
|
||||
// Check for infection config files
|
||||
configs := []string{"infection.json", "infection.json5", "infection.json.dist"}
|
||||
for _, config := range configs {
|
||||
if m.Exists(filepath.Join(dir, config)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Check for vendor binary
|
||||
infectionBin := filepath.Join(dir, "vendor", "bin", "infection")
|
||||
if m.Exists(infectionBin) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// RunInfection runs Infection mutation testing.
|
||||
func RunInfection(ctx context.Context, opts InfectionOptions) error {
|
||||
if opts.Dir == "" {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return cli.WrapVerb(err, "get", "working directory")
|
||||
}
|
||||
opts.Dir = cwd
|
||||
}
|
||||
|
||||
if opts.Output == nil {
|
||||
opts.Output = os.Stdout
|
||||
}
|
||||
|
||||
m := getMedium()
|
||||
|
||||
// Build command
|
||||
vendorBin := filepath.Join(opts.Dir, "vendor", "bin", "infection")
|
||||
cmdName := "infection"
|
||||
if m.Exists(vendorBin) {
|
||||
cmdName = vendorBin
|
||||
}
|
||||
|
||||
var args []string
|
||||
|
||||
// Set defaults
|
||||
minMSI := opts.MinMSI
|
||||
if minMSI == 0 {
|
||||
minMSI = 50
|
||||
}
|
||||
minCoveredMSI := opts.MinCoveredMSI
|
||||
if minCoveredMSI == 0 {
|
||||
minCoveredMSI = 70
|
||||
}
|
||||
threads := opts.Threads
|
||||
if threads == 0 {
|
||||
threads = 4
|
||||
}
|
||||
|
||||
args = append(args, cli.Sprintf("--min-msi=%d", minMSI))
|
||||
args = append(args, cli.Sprintf("--min-covered-msi=%d", minCoveredMSI))
|
||||
args = append(args, cli.Sprintf("--threads=%d", threads))
|
||||
|
||||
if opts.Filter != "" {
|
||||
args = append(args, "--filter="+opts.Filter)
|
||||
}
|
||||
|
||||
if opts.OnlyCovered {
|
||||
args = append(args, "--only-covered")
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(ctx, cmdName, args...)
|
||||
cmd.Dir = opts.Dir
|
||||
cmd.Stdout = opts.Output
|
||||
cmd.Stderr = opts.Output
|
||||
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// QA Pipeline
|
||||
// =============================================================================
|
||||
|
||||
// QAOptions configures the full QA pipeline.
|
||||
type QAOptions struct {
|
||||
Dir string
|
||||
Quick bool // Only run quick checks
|
||||
Full bool // Run all stages including slow checks
|
||||
Fix bool // Auto-fix issues where possible
|
||||
JSON bool // Output results as JSON
|
||||
}
|
||||
|
||||
// QAStage represents a stage in the QA pipeline.
|
||||
type QAStage string
|
||||
|
||||
// QA pipeline stage constants.
|
||||
const (
|
||||
// QAStageQuick runs fast checks only (audit, fmt, stan).
|
||||
QAStageQuick QAStage = "quick"
|
||||
// QAStageStandard runs standard checks including tests.
|
||||
QAStageStandard QAStage = "standard"
|
||||
// QAStageFull runs all checks including slow security scans.
|
||||
QAStageFull QAStage = "full"
|
||||
)
|
||||
|
||||
// QACheckResult holds the result of a single QA check.
|
||||
type QACheckResult struct {
|
||||
Name string
|
||||
Stage QAStage
|
||||
Passed bool
|
||||
Duration string
|
||||
Error error
|
||||
Output string
|
||||
}
|
||||
|
||||
// QAResult holds the results of the full QA pipeline.
|
||||
type QAResult struct {
|
||||
Stages []QAStage
|
||||
Checks []QACheckResult
|
||||
Passed bool
|
||||
Summary string
|
||||
}
|
||||
|
||||
// GetQAStages returns the stages to run based on options.
|
||||
func GetQAStages(opts QAOptions) []QAStage {
|
||||
if opts.Quick {
|
||||
return []QAStage{QAStageQuick}
|
||||
}
|
||||
if opts.Full {
|
||||
return []QAStage{QAStageQuick, QAStageStandard, QAStageFull}
|
||||
}
|
||||
// Default: quick + standard
|
||||
return []QAStage{QAStageQuick, QAStageStandard}
|
||||
}
|
||||
|
||||
// GetQAChecks returns the checks for a given stage.
|
||||
func GetQAChecks(dir string, stage QAStage) []string {
|
||||
switch stage {
|
||||
case QAStageQuick:
|
||||
checks := []string{"audit", "fmt", "stan"}
|
||||
return checks
|
||||
case QAStageStandard:
|
||||
checks := []string{}
|
||||
if _, found := DetectPsalm(dir); found {
|
||||
checks = append(checks, "psalm")
|
||||
}
|
||||
checks = append(checks, "test")
|
||||
return checks
|
||||
case QAStageFull:
|
||||
checks := []string{}
|
||||
if DetectRector(dir) {
|
||||
checks = append(checks, "rector")
|
||||
}
|
||||
if DetectInfection(dir) {
|
||||
checks = append(checks, "infection")
|
||||
}
|
||||
return checks
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Security Checks
|
||||
// =============================================================================
|
||||
|
||||
// SecurityOptions configures security scanning.
|
||||
type SecurityOptions struct {
|
||||
Dir string
|
||||
Severity string // Minimum severity (critical, high, medium, low)
|
||||
JSON bool // Output in JSON format
|
||||
SARIF bool // Output in SARIF format
|
||||
URL string // URL to check HTTP headers (optional)
|
||||
Output goio.Writer
|
||||
}
|
||||
|
||||
// SecurityResult holds the results of security scanning.
|
||||
type SecurityResult struct {
|
||||
Checks []SecurityCheck
|
||||
Summary SecuritySummary
|
||||
}
|
||||
|
||||
// SecurityCheck represents a single security check result.
|
||||
type SecurityCheck struct {
|
||||
ID string
|
||||
Name string
|
||||
Description string
|
||||
Severity string
|
||||
Passed bool
|
||||
Message string
|
||||
Fix string
|
||||
CWE string
|
||||
}
|
||||
|
||||
// SecuritySummary summarizes security check results.
|
||||
type SecuritySummary struct {
|
||||
Total int
|
||||
Passed int
|
||||
Critical int
|
||||
High int
|
||||
Medium int
|
||||
Low int
|
||||
}
|
||||
|
||||
// RunSecurityChecks runs security checks on the project.
|
||||
func RunSecurityChecks(ctx context.Context, opts SecurityOptions) (*SecurityResult, error) {
|
||||
if opts.Dir == "" {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return nil, cli.WrapVerb(err, "get", "working directory")
|
||||
}
|
||||
opts.Dir = cwd
|
||||
}
|
||||
|
||||
result := &SecurityResult{}
|
||||
|
||||
// Run composer audit
|
||||
auditResults, _ := RunAudit(ctx, AuditOptions{Dir: opts.Dir})
|
||||
for _, audit := range auditResults {
|
||||
check := SecurityCheck{
|
||||
ID: audit.Tool + "_audit",
|
||||
Name: i18n.Title(audit.Tool) + " Security Audit",
|
||||
Description: "Check " + audit.Tool + " dependencies for vulnerabilities",
|
||||
Severity: "critical",
|
||||
Passed: audit.Vulnerabilities == 0 && audit.Error == nil,
|
||||
CWE: "CWE-1395",
|
||||
}
|
||||
if !check.Passed {
|
||||
check.Message = cli.Sprintf("Found %d vulnerabilities", audit.Vulnerabilities)
|
||||
}
|
||||
result.Checks = append(result.Checks, check)
|
||||
}
|
||||
|
||||
// Check .env file for security issues
|
||||
envChecks := runEnvSecurityChecks(opts.Dir)
|
||||
result.Checks = append(result.Checks, envChecks...)
|
||||
|
||||
// Check filesystem security
|
||||
fsChecks := runFilesystemSecurityChecks(opts.Dir)
|
||||
result.Checks = append(result.Checks, fsChecks...)
|
||||
|
||||
// Calculate summary
|
||||
for _, check := range result.Checks {
|
||||
result.Summary.Total++
|
||||
if check.Passed {
|
||||
result.Summary.Passed++
|
||||
} else {
|
||||
switch check.Severity {
|
||||
case "critical":
|
||||
result.Summary.Critical++
|
||||
case "high":
|
||||
result.Summary.High++
|
||||
case "medium":
|
||||
result.Summary.Medium++
|
||||
case "low":
|
||||
result.Summary.Low++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func runEnvSecurityChecks(dir string) []SecurityCheck {
|
||||
var checks []SecurityCheck
|
||||
|
||||
m := getMedium()
|
||||
envPath := filepath.Join(dir, ".env")
|
||||
envContent, err := m.Read(envPath)
|
||||
if err != nil {
|
||||
return checks
|
||||
}
|
||||
|
||||
envLines := strings.Split(envContent, "\n")
|
||||
envMap := make(map[string]string)
|
||||
for _, line := range envLines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
parts := strings.SplitN(line, "=", 2)
|
||||
if len(parts) == 2 {
|
||||
envMap[parts[0]] = parts[1]
|
||||
}
|
||||
}
|
||||
|
||||
// Check APP_DEBUG
|
||||
if debug, ok := envMap["APP_DEBUG"]; ok {
|
||||
check := SecurityCheck{
|
||||
ID: "debug_mode",
|
||||
Name: "Debug Mode Disabled",
|
||||
Description: "APP_DEBUG should be false in production",
|
||||
Severity: "critical",
|
||||
Passed: strings.ToLower(debug) != "true",
|
||||
CWE: "CWE-215",
|
||||
}
|
||||
if !check.Passed {
|
||||
check.Message = "Debug mode exposes sensitive information"
|
||||
check.Fix = "Set APP_DEBUG=false in .env"
|
||||
}
|
||||
checks = append(checks, check)
|
||||
}
|
||||
|
||||
// Check APP_KEY
|
||||
if key, ok := envMap["APP_KEY"]; ok {
|
||||
check := SecurityCheck{
|
||||
ID: "app_key_set",
|
||||
Name: "Application Key Set",
|
||||
Description: "APP_KEY must be set and valid",
|
||||
Severity: "critical",
|
||||
Passed: len(key) >= 32,
|
||||
CWE: "CWE-321",
|
||||
}
|
||||
if !check.Passed {
|
||||
check.Message = "Missing or weak encryption key"
|
||||
check.Fix = "Run: php artisan key:generate"
|
||||
}
|
||||
checks = append(checks, check)
|
||||
}
|
||||
|
||||
// Check APP_URL for HTTPS
|
||||
if url, ok := envMap["APP_URL"]; ok {
|
||||
check := SecurityCheck{
|
||||
ID: "https_enforced",
|
||||
Name: "HTTPS Enforced",
|
||||
Description: "APP_URL should use HTTPS in production",
|
||||
Severity: "high",
|
||||
Passed: strings.HasPrefix(url, "https://"),
|
||||
CWE: "CWE-319",
|
||||
}
|
||||
if !check.Passed {
|
||||
check.Message = "Application not using HTTPS"
|
||||
check.Fix = "Update APP_URL to use https://"
|
||||
}
|
||||
checks = append(checks, check)
|
||||
}
|
||||
|
||||
return checks
|
||||
}
|
||||
|
||||
func runFilesystemSecurityChecks(dir string) []SecurityCheck {
|
||||
var checks []SecurityCheck
|
||||
m := getMedium()
|
||||
|
||||
// Check .env not in public
|
||||
publicEnvPaths := []string{"public/.env", "public_html/.env"}
|
||||
for _, path := range publicEnvPaths {
|
||||
fullPath := filepath.Join(dir, path)
|
||||
if m.Exists(fullPath) {
|
||||
checks = append(checks, SecurityCheck{
|
||||
ID: "env_not_public",
|
||||
Name: ".env Not Publicly Accessible",
|
||||
Description: ".env file should not be in public directory",
|
||||
Severity: "critical",
|
||||
Passed: false,
|
||||
Message: "Environment file exposed to web at " + path,
|
||||
CWE: "CWE-538",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Check .git not in public
|
||||
publicGitPaths := []string{"public/.git", "public_html/.git"}
|
||||
for _, path := range publicGitPaths {
|
||||
fullPath := filepath.Join(dir, path)
|
||||
if m.Exists(fullPath) {
|
||||
checks = append(checks, SecurityCheck{
|
||||
ID: "git_not_public",
|
||||
Name: ".git Not Publicly Accessible",
|
||||
Description: ".git directory should not be in public",
|
||||
Severity: "critical",
|
||||
Passed: false,
|
||||
Message: "Git repository exposed to web (source code leak)",
|
||||
CWE: "CWE-538",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return checks
|
||||
}
|
||||
304
quality_extended_test.go
Normal file
304
quality_extended_test.go
Normal file
|
|
@ -0,0 +1,304 @@
|
|||
package php
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestFormatOptions_Struct(t *testing.T) {
|
||||
t.Run("all fields accessible", func(t *testing.T) {
|
||||
opts := FormatOptions{
|
||||
Dir: "/project",
|
||||
Fix: true,
|
||||
Diff: true,
|
||||
Paths: []string{"app", "tests"},
|
||||
Output: os.Stdout,
|
||||
}
|
||||
|
||||
assert.Equal(t, "/project", opts.Dir)
|
||||
assert.True(t, opts.Fix)
|
||||
assert.True(t, opts.Diff)
|
||||
assert.Equal(t, []string{"app", "tests"}, opts.Paths)
|
||||
assert.NotNil(t, opts.Output)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAnalyseOptions_Struct(t *testing.T) {
|
||||
t.Run("all fields accessible", func(t *testing.T) {
|
||||
opts := AnalyseOptions{
|
||||
Dir: "/project",
|
||||
Level: 5,
|
||||
Paths: []string{"src"},
|
||||
Memory: "2G",
|
||||
Output: os.Stdout,
|
||||
}
|
||||
|
||||
assert.Equal(t, "/project", opts.Dir)
|
||||
assert.Equal(t, 5, opts.Level)
|
||||
assert.Equal(t, []string{"src"}, opts.Paths)
|
||||
assert.Equal(t, "2G", opts.Memory)
|
||||
assert.NotNil(t, opts.Output)
|
||||
})
|
||||
}
|
||||
|
||||
func TestFormatterType_Constants(t *testing.T) {
|
||||
t.Run("constants are defined", func(t *testing.T) {
|
||||
assert.Equal(t, FormatterType("pint"), FormatterPint)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAnalyserType_Constants(t *testing.T) {
|
||||
t.Run("constants are defined", func(t *testing.T) {
|
||||
assert.Equal(t, AnalyserType("phpstan"), AnalyserPHPStan)
|
||||
assert.Equal(t, AnalyserType("larastan"), AnalyserLarastan)
|
||||
})
|
||||
}
|
||||
|
||||
func TestDetectFormatter_Extended(t *testing.T) {
|
||||
t.Run("returns not found for empty directory", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
_, found := DetectFormatter(dir)
|
||||
assert.False(t, found)
|
||||
})
|
||||
|
||||
t.Run("prefers pint.json over vendor binary", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
// Create pint.json
|
||||
err := os.WriteFile(filepath.Join(dir, "pint.json"), []byte("{}"), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
formatter, found := DetectFormatter(dir)
|
||||
assert.True(t, found)
|
||||
assert.Equal(t, FormatterPint, formatter)
|
||||
})
|
||||
}
|
||||
|
||||
func TestDetectAnalyser_Extended(t *testing.T) {
|
||||
t.Run("returns not found for empty directory", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
_, found := DetectAnalyser(dir)
|
||||
assert.False(t, found)
|
||||
})
|
||||
|
||||
t.Run("detects phpstan from vendor binary alone", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
// Create vendor binary
|
||||
binDir := filepath.Join(dir, "vendor", "bin")
|
||||
err := os.MkdirAll(binDir, 0755)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = os.WriteFile(filepath.Join(binDir, "phpstan"), []byte(""), 0755)
|
||||
require.NoError(t, err)
|
||||
|
||||
analyser, found := DetectAnalyser(dir)
|
||||
assert.True(t, found)
|
||||
assert.Equal(t, AnalyserPHPStan, analyser)
|
||||
})
|
||||
|
||||
t.Run("detects larastan from larastan/larastan vendor path", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
// Create phpstan.neon
|
||||
err := os.WriteFile(filepath.Join(dir, "phpstan.neon"), []byte(""), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create larastan/larastan path
|
||||
larastanPath := filepath.Join(dir, "vendor", "larastan", "larastan")
|
||||
err = os.MkdirAll(larastanPath, 0755)
|
||||
require.NoError(t, err)
|
||||
|
||||
analyser, found := DetectAnalyser(dir)
|
||||
assert.True(t, found)
|
||||
assert.Equal(t, AnalyserLarastan, analyser)
|
||||
})
|
||||
|
||||
t.Run("detects larastan from nunomaduro/larastan vendor path", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
// Create phpstan.neon
|
||||
err := os.WriteFile(filepath.Join(dir, "phpstan.neon"), []byte(""), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create nunomaduro/larastan path
|
||||
larastanPath := filepath.Join(dir, "vendor", "nunomaduro", "larastan")
|
||||
err = os.MkdirAll(larastanPath, 0755)
|
||||
require.NoError(t, err)
|
||||
|
||||
analyser, found := DetectAnalyser(dir)
|
||||
assert.True(t, found)
|
||||
assert.Equal(t, AnalyserLarastan, analyser)
|
||||
})
|
||||
}
|
||||
|
||||
func TestBuildPintCommand_Extended(t *testing.T) {
|
||||
t.Run("uses global pint when no vendor binary", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
opts := FormatOptions{Dir: dir}
|
||||
|
||||
cmd, _ := buildPintCommand(opts)
|
||||
assert.Equal(t, "pint", cmd)
|
||||
})
|
||||
|
||||
t.Run("adds test flag when Fix is false", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
opts := FormatOptions{Dir: dir, Fix: false}
|
||||
|
||||
_, args := buildPintCommand(opts)
|
||||
assert.Contains(t, args, "--test")
|
||||
})
|
||||
|
||||
t.Run("does not add test flag when Fix is true", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
opts := FormatOptions{Dir: dir, Fix: true}
|
||||
|
||||
_, args := buildPintCommand(opts)
|
||||
assert.NotContains(t, args, "--test")
|
||||
})
|
||||
|
||||
t.Run("adds diff flag", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
opts := FormatOptions{Dir: dir, Diff: true}
|
||||
|
||||
_, args := buildPintCommand(opts)
|
||||
assert.Contains(t, args, "--diff")
|
||||
})
|
||||
|
||||
t.Run("adds paths", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
opts := FormatOptions{Dir: dir, Paths: []string{"app", "tests"}}
|
||||
|
||||
_, args := buildPintCommand(opts)
|
||||
assert.Contains(t, args, "app")
|
||||
assert.Contains(t, args, "tests")
|
||||
})
|
||||
}
|
||||
|
||||
func TestBuildPHPStanCommand_Extended(t *testing.T) {
|
||||
t.Run("uses global phpstan when no vendor binary", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
opts := AnalyseOptions{Dir: dir}
|
||||
|
||||
cmd, _ := buildPHPStanCommand(opts)
|
||||
assert.Equal(t, "phpstan", cmd)
|
||||
})
|
||||
|
||||
t.Run("adds level flag", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
opts := AnalyseOptions{Dir: dir, Level: 8}
|
||||
|
||||
_, args := buildPHPStanCommand(opts)
|
||||
assert.Contains(t, args, "--level")
|
||||
assert.Contains(t, args, "8")
|
||||
})
|
||||
|
||||
t.Run("does not add level flag when zero", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
opts := AnalyseOptions{Dir: dir, Level: 0}
|
||||
|
||||
_, args := buildPHPStanCommand(opts)
|
||||
assert.NotContains(t, args, "--level")
|
||||
})
|
||||
|
||||
t.Run("adds memory limit", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
opts := AnalyseOptions{Dir: dir, Memory: "4G"}
|
||||
|
||||
_, args := buildPHPStanCommand(opts)
|
||||
assert.Contains(t, args, "--memory-limit")
|
||||
assert.Contains(t, args, "4G")
|
||||
})
|
||||
|
||||
t.Run("does not add memory flag when empty", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
opts := AnalyseOptions{Dir: dir, Memory: ""}
|
||||
|
||||
_, args := buildPHPStanCommand(opts)
|
||||
assert.NotContains(t, args, "--memory-limit")
|
||||
})
|
||||
|
||||
t.Run("adds paths", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
opts := AnalyseOptions{Dir: dir, Paths: []string{"src", "app"}}
|
||||
|
||||
_, args := buildPHPStanCommand(opts)
|
||||
assert.Contains(t, args, "src")
|
||||
assert.Contains(t, args, "app")
|
||||
})
|
||||
}
|
||||
|
||||
func TestFormat_Bad(t *testing.T) {
|
||||
t.Run("fails when no formatter found", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
opts := FormatOptions{Dir: dir}
|
||||
|
||||
err := Format(context.TODO(), opts)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "no formatter found")
|
||||
})
|
||||
|
||||
t.Run("uses cwd when dir not specified", func(t *testing.T) {
|
||||
// When no formatter found in cwd, should still fail with "no formatter found"
|
||||
opts := FormatOptions{Dir: ""}
|
||||
|
||||
err := Format(context.TODO(), opts)
|
||||
// May or may not find a formatter depending on cwd, but function should not panic
|
||||
if err != nil {
|
||||
// Expected - no formatter in cwd
|
||||
assert.Contains(t, err.Error(), "no formatter")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("uses stdout when output not specified", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
// Create pint.json to enable formatter detection
|
||||
err := os.WriteFile(filepath.Join(dir, "pint.json"), []byte("{}"), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
opts := FormatOptions{Dir: dir, Output: nil}
|
||||
|
||||
// Will fail because pint isn't actually installed, but tests the code path
|
||||
err = Format(context.Background(), opts)
|
||||
assert.Error(t, err) // Pint not installed
|
||||
})
|
||||
}
|
||||
|
||||
func TestAnalyse_Bad(t *testing.T) {
|
||||
t.Run("fails when no analyser found", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
opts := AnalyseOptions{Dir: dir}
|
||||
|
||||
err := Analyse(context.TODO(), opts)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "no static analyser found")
|
||||
})
|
||||
|
||||
t.Run("uses cwd when dir not specified", func(t *testing.T) {
|
||||
opts := AnalyseOptions{Dir: ""}
|
||||
|
||||
err := Analyse(context.TODO(), opts)
|
||||
// May or may not find an analyser depending on cwd
|
||||
if err != nil {
|
||||
assert.Contains(t, err.Error(), "no static analyser")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("uses stdout when output not specified", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
// Create phpstan.neon to enable analyser detection
|
||||
err := os.WriteFile(filepath.Join(dir, "phpstan.neon"), []byte(""), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
opts := AnalyseOptions{Dir: dir, Output: nil}
|
||||
|
||||
// Will fail because phpstan isn't actually installed, but tests the code path
|
||||
err = Analyse(context.Background(), opts)
|
||||
assert.Error(t, err) // PHPStan not installed
|
||||
})
|
||||
}
|
||||
517
quality_test.go
Normal file
517
quality_test.go
Normal file
|
|
@ -0,0 +1,517 @@
|
|||
package php
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestDetectFormatter_Good(t *testing.T) {
|
||||
t.Run("detects pint.json", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
err := os.WriteFile(filepath.Join(dir, "pint.json"), []byte("{}"), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
formatter, found := DetectFormatter(dir)
|
||||
assert.True(t, found)
|
||||
assert.Equal(t, FormatterPint, formatter)
|
||||
})
|
||||
|
||||
t.Run("detects vendor binary", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
binDir := filepath.Join(dir, "vendor", "bin")
|
||||
err := os.MkdirAll(binDir, 0755)
|
||||
require.NoError(t, err)
|
||||
err = os.WriteFile(filepath.Join(binDir, "pint"), []byte(""), 0755)
|
||||
require.NoError(t, err)
|
||||
|
||||
formatter, found := DetectFormatter(dir)
|
||||
assert.True(t, found)
|
||||
assert.Equal(t, FormatterPint, formatter)
|
||||
})
|
||||
}
|
||||
|
||||
func TestDetectFormatter_Bad(t *testing.T) {
|
||||
t.Run("no formatter", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
_, found := DetectFormatter(dir)
|
||||
assert.False(t, found)
|
||||
})
|
||||
}
|
||||
|
||||
func TestDetectAnalyser_Good(t *testing.T) {
|
||||
t.Run("detects phpstan.neon", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
err := os.WriteFile(filepath.Join(dir, "phpstan.neon"), []byte(""), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
analyser, found := DetectAnalyser(dir)
|
||||
assert.True(t, found)
|
||||
assert.Equal(t, AnalyserPHPStan, analyser)
|
||||
})
|
||||
|
||||
t.Run("detects phpstan.neon.dist", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
err := os.WriteFile(filepath.Join(dir, "phpstan.neon.dist"), []byte(""), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
analyser, found := DetectAnalyser(dir)
|
||||
assert.True(t, found)
|
||||
assert.Equal(t, AnalyserPHPStan, analyser)
|
||||
})
|
||||
|
||||
t.Run("detects larastan", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
err := os.WriteFile(filepath.Join(dir, "phpstan.neon"), []byte(""), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
larastanDir := filepath.Join(dir, "vendor", "larastan", "larastan")
|
||||
err = os.MkdirAll(larastanDir, 0755)
|
||||
require.NoError(t, err)
|
||||
|
||||
analyser, found := DetectAnalyser(dir)
|
||||
assert.True(t, found)
|
||||
assert.Equal(t, AnalyserLarastan, analyser)
|
||||
})
|
||||
|
||||
t.Run("detects nunomaduro/larastan", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
err := os.WriteFile(filepath.Join(dir, "phpstan.neon"), []byte(""), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
larastanDir := filepath.Join(dir, "vendor", "nunomaduro", "larastan")
|
||||
err = os.MkdirAll(larastanDir, 0755)
|
||||
require.NoError(t, err)
|
||||
|
||||
analyser, found := DetectAnalyser(dir)
|
||||
assert.True(t, found)
|
||||
assert.Equal(t, AnalyserLarastan, analyser)
|
||||
})
|
||||
}
|
||||
|
||||
func TestBuildPintCommand_Good(t *testing.T) {
|
||||
t.Run("basic command", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
opts := FormatOptions{Dir: dir}
|
||||
cmd, args := buildPintCommand(opts)
|
||||
assert.Equal(t, "pint", cmd)
|
||||
assert.Contains(t, args, "--test")
|
||||
})
|
||||
|
||||
t.Run("fix enabled", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
opts := FormatOptions{Dir: dir, Fix: true}
|
||||
_, args := buildPintCommand(opts)
|
||||
assert.NotContains(t, args, "--test")
|
||||
})
|
||||
|
||||
t.Run("diff enabled", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
opts := FormatOptions{Dir: dir, Diff: true}
|
||||
_, args := buildPintCommand(opts)
|
||||
assert.Contains(t, args, "--diff")
|
||||
})
|
||||
|
||||
t.Run("with specific paths", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
paths := []string{"app", "tests"}
|
||||
opts := FormatOptions{Dir: dir, Paths: paths}
|
||||
_, args := buildPintCommand(opts)
|
||||
assert.Equal(t, paths, args[len(args)-2:])
|
||||
})
|
||||
|
||||
t.Run("uses vendor binary if exists", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
binDir := filepath.Join(dir, "vendor", "bin")
|
||||
err := os.MkdirAll(binDir, 0755)
|
||||
require.NoError(t, err)
|
||||
pintPath := filepath.Join(binDir, "pint")
|
||||
err = os.WriteFile(pintPath, []byte(""), 0755)
|
||||
require.NoError(t, err)
|
||||
|
||||
opts := FormatOptions{Dir: dir}
|
||||
cmd, _ := buildPintCommand(opts)
|
||||
assert.Equal(t, pintPath, cmd)
|
||||
})
|
||||
}
|
||||
|
||||
func TestBuildPHPStanCommand_Good(t *testing.T) {
|
||||
t.Run("basic command", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
opts := AnalyseOptions{Dir: dir}
|
||||
cmd, args := buildPHPStanCommand(opts)
|
||||
assert.Equal(t, "phpstan", cmd)
|
||||
assert.Equal(t, []string{"analyse"}, args)
|
||||
})
|
||||
|
||||
t.Run("with level", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
opts := AnalyseOptions{Dir: dir, Level: 5}
|
||||
_, args := buildPHPStanCommand(opts)
|
||||
assert.Contains(t, args, "--level")
|
||||
assert.Contains(t, args, "5")
|
||||
})
|
||||
|
||||
t.Run("with memory limit", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
opts := AnalyseOptions{Dir: dir, Memory: "2G"}
|
||||
_, args := buildPHPStanCommand(opts)
|
||||
assert.Contains(t, args, "--memory-limit")
|
||||
assert.Contains(t, args, "2G")
|
||||
})
|
||||
|
||||
t.Run("uses vendor binary if exists", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
binDir := filepath.Join(dir, "vendor", "bin")
|
||||
err := os.MkdirAll(binDir, 0755)
|
||||
require.NoError(t, err)
|
||||
phpstanPath := filepath.Join(binDir, "phpstan")
|
||||
err = os.WriteFile(phpstanPath, []byte(""), 0755)
|
||||
require.NoError(t, err)
|
||||
|
||||
opts := AnalyseOptions{Dir: dir}
|
||||
cmd, _ := buildPHPStanCommand(opts)
|
||||
assert.Equal(t, phpstanPath, cmd)
|
||||
})
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Psalm Detection Tests
|
||||
// =============================================================================
|
||||
|
||||
func TestDetectPsalm_Good(t *testing.T) {
|
||||
t.Run("detects psalm.xml", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
err := os.WriteFile(filepath.Join(dir, "psalm.xml"), []byte(""), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Also need vendor binary for it to return true
|
||||
binDir := filepath.Join(dir, "vendor", "bin")
|
||||
err = os.MkdirAll(binDir, 0755)
|
||||
require.NoError(t, err)
|
||||
err = os.WriteFile(filepath.Join(binDir, "psalm"), []byte(""), 0755)
|
||||
require.NoError(t, err)
|
||||
|
||||
psalmType, found := DetectPsalm(dir)
|
||||
assert.True(t, found)
|
||||
assert.Equal(t, PsalmStandard, psalmType)
|
||||
})
|
||||
|
||||
t.Run("detects psalm.xml.dist", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
err := os.WriteFile(filepath.Join(dir, "psalm.xml.dist"), []byte(""), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
binDir := filepath.Join(dir, "vendor", "bin")
|
||||
err = os.MkdirAll(binDir, 0755)
|
||||
require.NoError(t, err)
|
||||
err = os.WriteFile(filepath.Join(binDir, "psalm"), []byte(""), 0755)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, found := DetectPsalm(dir)
|
||||
assert.True(t, found)
|
||||
})
|
||||
|
||||
t.Run("detects vendor binary only", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
binDir := filepath.Join(dir, "vendor", "bin")
|
||||
err := os.MkdirAll(binDir, 0755)
|
||||
require.NoError(t, err)
|
||||
err = os.WriteFile(filepath.Join(binDir, "psalm"), []byte(""), 0755)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, found := DetectPsalm(dir)
|
||||
assert.True(t, found)
|
||||
})
|
||||
}
|
||||
|
||||
func TestDetectPsalm_Bad(t *testing.T) {
|
||||
t.Run("no psalm", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
_, found := DetectPsalm(dir)
|
||||
assert.False(t, found)
|
||||
})
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Rector Detection Tests
|
||||
// =============================================================================
|
||||
|
||||
func TestDetectRector_Good(t *testing.T) {
|
||||
t.Run("detects rector.php", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
err := os.WriteFile(filepath.Join(dir, "rector.php"), []byte("<?php"), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
found := DetectRector(dir)
|
||||
assert.True(t, found)
|
||||
})
|
||||
|
||||
t.Run("detects vendor binary", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
binDir := filepath.Join(dir, "vendor", "bin")
|
||||
err := os.MkdirAll(binDir, 0755)
|
||||
require.NoError(t, err)
|
||||
err = os.WriteFile(filepath.Join(binDir, "rector"), []byte(""), 0755)
|
||||
require.NoError(t, err)
|
||||
|
||||
found := DetectRector(dir)
|
||||
assert.True(t, found)
|
||||
})
|
||||
}
|
||||
|
||||
func TestDetectRector_Bad(t *testing.T) {
|
||||
t.Run("no rector", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
found := DetectRector(dir)
|
||||
assert.False(t, found)
|
||||
})
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Infection Detection Tests
|
||||
// =============================================================================
|
||||
|
||||
func TestDetectInfection_Good(t *testing.T) {
|
||||
t.Run("detects infection.json", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
err := os.WriteFile(filepath.Join(dir, "infection.json"), []byte("{}"), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
found := DetectInfection(dir)
|
||||
assert.True(t, found)
|
||||
})
|
||||
|
||||
t.Run("detects infection.json5", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
err := os.WriteFile(filepath.Join(dir, "infection.json5"), []byte("{}"), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
found := DetectInfection(dir)
|
||||
assert.True(t, found)
|
||||
})
|
||||
|
||||
t.Run("detects vendor binary", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
binDir := filepath.Join(dir, "vendor", "bin")
|
||||
err := os.MkdirAll(binDir, 0755)
|
||||
require.NoError(t, err)
|
||||
err = os.WriteFile(filepath.Join(binDir, "infection"), []byte(""), 0755)
|
||||
require.NoError(t, err)
|
||||
|
||||
found := DetectInfection(dir)
|
||||
assert.True(t, found)
|
||||
})
|
||||
}
|
||||
|
||||
func TestDetectInfection_Bad(t *testing.T) {
|
||||
t.Run("no infection", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
found := DetectInfection(dir)
|
||||
assert.False(t, found)
|
||||
})
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// QA Pipeline Tests
|
||||
// =============================================================================
|
||||
|
||||
func TestGetQAStages_Good(t *testing.T) {
|
||||
t.Run("default stages", func(t *testing.T) {
|
||||
opts := QAOptions{}
|
||||
stages := GetQAStages(opts)
|
||||
assert.Equal(t, []QAStage{QAStageQuick, QAStageStandard}, stages)
|
||||
})
|
||||
|
||||
t.Run("quick only", func(t *testing.T) {
|
||||
opts := QAOptions{Quick: true}
|
||||
stages := GetQAStages(opts)
|
||||
assert.Equal(t, []QAStage{QAStageQuick}, stages)
|
||||
})
|
||||
|
||||
t.Run("full stages", func(t *testing.T) {
|
||||
opts := QAOptions{Full: true}
|
||||
stages := GetQAStages(opts)
|
||||
assert.Equal(t, []QAStage{QAStageQuick, QAStageStandard, QAStageFull}, stages)
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetQAChecks_Good(t *testing.T) {
|
||||
t.Run("quick stage checks", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
checks := GetQAChecks(dir, QAStageQuick)
|
||||
assert.Contains(t, checks, "audit")
|
||||
assert.Contains(t, checks, "fmt")
|
||||
assert.Contains(t, checks, "stan")
|
||||
})
|
||||
|
||||
t.Run("standard stage includes test", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
checks := GetQAChecks(dir, QAStageStandard)
|
||||
assert.Contains(t, checks, "test")
|
||||
})
|
||||
|
||||
t.Run("standard stage includes psalm if available", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
binDir := filepath.Join(dir, "vendor", "bin")
|
||||
err := os.MkdirAll(binDir, 0755)
|
||||
require.NoError(t, err)
|
||||
err = os.WriteFile(filepath.Join(binDir, "psalm"), []byte(""), 0755)
|
||||
require.NoError(t, err)
|
||||
|
||||
checks := GetQAChecks(dir, QAStageStandard)
|
||||
assert.Contains(t, checks, "psalm")
|
||||
})
|
||||
|
||||
t.Run("full stage includes rector if available", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
err := os.WriteFile(filepath.Join(dir, "rector.php"), []byte("<?php"), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
checks := GetQAChecks(dir, QAStageFull)
|
||||
assert.Contains(t, checks, "rector")
|
||||
})
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Security Checks Tests
|
||||
// =============================================================================
|
||||
|
||||
func TestRunEnvSecurityChecks_Good(t *testing.T) {
|
||||
t.Run("detects debug mode enabled", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
envContent := "APP_DEBUG=true\nAPP_KEY=base64:abcdefghijklmnopqrstuvwxyz123456\n"
|
||||
err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
checks := runEnvSecurityChecks(dir)
|
||||
|
||||
var debugCheck *SecurityCheck
|
||||
for i := range checks {
|
||||
if checks[i].ID == "debug_mode" {
|
||||
debugCheck = &checks[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
require.NotNil(t, debugCheck)
|
||||
assert.False(t, debugCheck.Passed)
|
||||
assert.Equal(t, "critical", debugCheck.Severity)
|
||||
})
|
||||
|
||||
t.Run("passes with debug disabled", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
envContent := "APP_DEBUG=false\nAPP_KEY=base64:abcdefghijklmnopqrstuvwxyz123456\n"
|
||||
err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
checks := runEnvSecurityChecks(dir)
|
||||
|
||||
var debugCheck *SecurityCheck
|
||||
for i := range checks {
|
||||
if checks[i].ID == "debug_mode" {
|
||||
debugCheck = &checks[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
require.NotNil(t, debugCheck)
|
||||
assert.True(t, debugCheck.Passed)
|
||||
})
|
||||
|
||||
t.Run("detects weak app key", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
envContent := "APP_KEY=short\n"
|
||||
err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
checks := runEnvSecurityChecks(dir)
|
||||
|
||||
var keyCheck *SecurityCheck
|
||||
for i := range checks {
|
||||
if checks[i].ID == "app_key_set" {
|
||||
keyCheck = &checks[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
require.NotNil(t, keyCheck)
|
||||
assert.False(t, keyCheck.Passed)
|
||||
})
|
||||
|
||||
t.Run("detects non-https app url", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
envContent := "APP_URL=http://example.com\n"
|
||||
err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
checks := runEnvSecurityChecks(dir)
|
||||
|
||||
var urlCheck *SecurityCheck
|
||||
for i := range checks {
|
||||
if checks[i].ID == "https_enforced" {
|
||||
urlCheck = &checks[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
require.NotNil(t, urlCheck)
|
||||
assert.False(t, urlCheck.Passed)
|
||||
})
|
||||
}
|
||||
|
||||
func TestRunFilesystemSecurityChecks_Good(t *testing.T) {
|
||||
t.Run("detects .env in public", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
publicDir := filepath.Join(dir, "public")
|
||||
err := os.MkdirAll(publicDir, 0755)
|
||||
require.NoError(t, err)
|
||||
err = os.WriteFile(filepath.Join(publicDir, ".env"), []byte(""), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
checks := runFilesystemSecurityChecks(dir)
|
||||
|
||||
found := false
|
||||
for _, check := range checks {
|
||||
if check.ID == "env_not_public" && !check.Passed {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, found, "should detect .env in public directory")
|
||||
})
|
||||
|
||||
t.Run("detects .git in public", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
gitDir := filepath.Join(dir, "public", ".git")
|
||||
err := os.MkdirAll(gitDir, 0755)
|
||||
require.NoError(t, err)
|
||||
|
||||
checks := runFilesystemSecurityChecks(dir)
|
||||
|
||||
found := false
|
||||
for _, check := range checks {
|
||||
if check.ID == "git_not_public" && !check.Passed {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, found, "should detect .git in public directory")
|
||||
})
|
||||
|
||||
t.Run("passes with clean public directory", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
publicDir := filepath.Join(dir, "public")
|
||||
err := os.MkdirAll(publicDir, 0755)
|
||||
require.NoError(t, err)
|
||||
// Add only safe files
|
||||
err = os.WriteFile(filepath.Join(publicDir, "index.php"), []byte("<?php"), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
checks := runFilesystemSecurityChecks(dir)
|
||||
assert.Empty(t, checks, "should not report issues for clean public directory")
|
||||
})
|
||||
}
|
||||
486
services.go
Normal file
486
services.go
Normal file
|
|
@ -0,0 +1,486 @@
|
|||
// Package php provides Laravel/PHP development environment management.
|
||||
package php
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
)
|
||||
|
||||
// Service represents a managed development service.
|
||||
type Service interface {
|
||||
// Name returns the service name.
|
||||
Name() string
|
||||
// Start starts the service.
|
||||
Start(ctx context.Context) error
|
||||
// Stop stops the service gracefully.
|
||||
Stop() error
|
||||
// Logs returns a reader for the service logs.
|
||||
Logs(follow bool) (io.ReadCloser, error)
|
||||
// Status returns the current service status.
|
||||
Status() ServiceStatus
|
||||
}
|
||||
|
||||
// ServiceStatus represents the status of a service.
|
||||
type ServiceStatus struct {
|
||||
Name string
|
||||
Running bool
|
||||
PID int
|
||||
Port int
|
||||
Error error
|
||||
}
|
||||
|
||||
// baseService provides common functionality for all services.
|
||||
type baseService struct {
|
||||
name string
|
||||
port int
|
||||
dir string
|
||||
cmd *exec.Cmd
|
||||
logFile *os.File
|
||||
logPath string
|
||||
mu sync.RWMutex
|
||||
running bool
|
||||
lastError error
|
||||
}
|
||||
|
||||
func (s *baseService) Name() string {
|
||||
return s.name
|
||||
}
|
||||
|
||||
func (s *baseService) Status() ServiceStatus {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
status := ServiceStatus{
|
||||
Name: s.name,
|
||||
Running: s.running,
|
||||
Port: s.port,
|
||||
Error: s.lastError,
|
||||
}
|
||||
|
||||
if s.cmd != nil && s.cmd.Process != nil {
|
||||
status.PID = s.cmd.Process.Pid
|
||||
}
|
||||
|
||||
return status
|
||||
}
|
||||
|
||||
func (s *baseService) Logs(follow bool) (io.ReadCloser, error) {
|
||||
if s.logPath == "" {
|
||||
return nil, cli.Err("no log file available for %s", s.name)
|
||||
}
|
||||
|
||||
m := getMedium()
|
||||
file, err := m.Open(s.logPath)
|
||||
if err != nil {
|
||||
return nil, cli.WrapVerb(err, "open", "log file")
|
||||
}
|
||||
|
||||
if !follow {
|
||||
return file.(io.ReadCloser), nil
|
||||
}
|
||||
|
||||
// For follow mode, return a tailing reader
|
||||
// Type assert to get the underlying *os.File for tailing
|
||||
osFile, ok := file.(*os.File)
|
||||
if !ok {
|
||||
file.Close()
|
||||
return nil, cli.Err("log file is not a regular file")
|
||||
}
|
||||
return newTailReader(osFile), nil
|
||||
}
|
||||
|
||||
func (s *baseService) startProcess(ctx context.Context, cmdName string, args []string, env []string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if s.running {
|
||||
return cli.Err("%s is already running", s.name)
|
||||
}
|
||||
|
||||
// Create log file
|
||||
m := getMedium()
|
||||
logDir := filepath.Join(s.dir, ".core", "logs")
|
||||
if err := m.EnsureDir(logDir); err != nil {
|
||||
return cli.WrapVerb(err, "create", "log directory")
|
||||
}
|
||||
|
||||
s.logPath = filepath.Join(logDir, cli.Sprintf("%s.log", strings.ToLower(s.name)))
|
||||
logWriter, err := m.Create(s.logPath)
|
||||
if err != nil {
|
||||
return cli.WrapVerb(err, "create", "log file")
|
||||
}
|
||||
// Type assert to get the underlying *os.File for use with exec.Cmd
|
||||
logFile, ok := logWriter.(*os.File)
|
||||
if !ok {
|
||||
logWriter.Close()
|
||||
return cli.Err("log file is not a regular file")
|
||||
}
|
||||
s.logFile = logFile
|
||||
|
||||
// Create command
|
||||
s.cmd = exec.CommandContext(ctx, cmdName, args...)
|
||||
s.cmd.Dir = s.dir
|
||||
s.cmd.Stdout = logFile
|
||||
s.cmd.Stderr = logFile
|
||||
s.cmd.Env = append(os.Environ(), env...)
|
||||
|
||||
// Set platform-specific process attributes for clean shutdown
|
||||
setSysProcAttr(s.cmd)
|
||||
|
||||
if err := s.cmd.Start(); err != nil {
|
||||
_ = logFile.Close()
|
||||
s.lastError = err
|
||||
return cli.WrapVerb(err, "start", s.name)
|
||||
}
|
||||
|
||||
s.running = true
|
||||
s.lastError = nil
|
||||
|
||||
// Monitor process in background
|
||||
go func() {
|
||||
err := s.cmd.Wait()
|
||||
s.mu.Lock()
|
||||
s.running = false
|
||||
if err != nil {
|
||||
s.lastError = err
|
||||
}
|
||||
if s.logFile != nil {
|
||||
_ = s.logFile.Close()
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *baseService) stopProcess() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if !s.running || s.cmd == nil || s.cmd.Process == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Send termination signal to process (group on Unix)
|
||||
_ = signalProcessGroup(s.cmd, termSignal())
|
||||
|
||||
// Wait for graceful shutdown with timeout
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
_ = s.cmd.Wait()
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
// Process exited gracefully
|
||||
case <-time.After(5 * time.Second):
|
||||
// Force kill
|
||||
_ = signalProcessGroup(s.cmd, killSignal())
|
||||
}
|
||||
|
||||
s.running = false
|
||||
return nil
|
||||
}
|
||||
|
||||
// FrankenPHPService manages the FrankenPHP/Octane server.
|
||||
type FrankenPHPService struct {
|
||||
baseService
|
||||
https bool
|
||||
httpsPort int
|
||||
certFile string
|
||||
keyFile string
|
||||
}
|
||||
|
||||
// NewFrankenPHPService creates a new FrankenPHP service.
|
||||
func NewFrankenPHPService(dir string, opts FrankenPHPOptions) *FrankenPHPService {
|
||||
port := opts.Port
|
||||
if port == 0 {
|
||||
port = 8000
|
||||
}
|
||||
httpsPort := opts.HTTPSPort
|
||||
if httpsPort == 0 {
|
||||
httpsPort = 443
|
||||
}
|
||||
|
||||
return &FrankenPHPService{
|
||||
baseService: baseService{
|
||||
name: "FrankenPHP",
|
||||
port: port,
|
||||
dir: dir,
|
||||
},
|
||||
https: opts.HTTPS,
|
||||
httpsPort: httpsPort,
|
||||
certFile: opts.CertFile,
|
||||
keyFile: opts.KeyFile,
|
||||
}
|
||||
}
|
||||
|
||||
// FrankenPHPOptions configures the FrankenPHP service.
|
||||
type FrankenPHPOptions struct {
|
||||
Port int
|
||||
HTTPSPort int
|
||||
HTTPS bool
|
||||
CertFile string
|
||||
KeyFile string
|
||||
}
|
||||
|
||||
// Start launches the FrankenPHP Octane server.
|
||||
func (s *FrankenPHPService) Start(ctx context.Context) error {
|
||||
args := []string{
|
||||
"artisan", "octane:start",
|
||||
"--server=frankenphp",
|
||||
cli.Sprintf("--port=%d", s.port),
|
||||
"--no-interaction",
|
||||
}
|
||||
|
||||
if s.https && s.certFile != "" && s.keyFile != "" {
|
||||
args = append(args,
|
||||
cli.Sprintf("--https-port=%d", s.httpsPort),
|
||||
cli.Sprintf("--https-certificate=%s", s.certFile),
|
||||
cli.Sprintf("--https-certificate-key=%s", s.keyFile),
|
||||
)
|
||||
}
|
||||
|
||||
return s.startProcess(ctx, "php", args, nil)
|
||||
}
|
||||
|
||||
// Stop terminates the FrankenPHP server process.
|
||||
func (s *FrankenPHPService) Stop() error {
|
||||
return s.stopProcess()
|
||||
}
|
||||
|
||||
// ViteService manages the Vite development server.
|
||||
type ViteService struct {
|
||||
baseService
|
||||
packageManager string
|
||||
}
|
||||
|
||||
// NewViteService creates a new Vite service.
|
||||
func NewViteService(dir string, opts ViteOptions) *ViteService {
|
||||
port := opts.Port
|
||||
if port == 0 {
|
||||
port = 5173
|
||||
}
|
||||
|
||||
pm := opts.PackageManager
|
||||
if pm == "" {
|
||||
pm = DetectPackageManager(dir)
|
||||
}
|
||||
|
||||
return &ViteService{
|
||||
baseService: baseService{
|
||||
name: "Vite",
|
||||
port: port,
|
||||
dir: dir,
|
||||
},
|
||||
packageManager: pm,
|
||||
}
|
||||
}
|
||||
|
||||
// ViteOptions configures the Vite service.
|
||||
type ViteOptions struct {
|
||||
Port int
|
||||
PackageManager string
|
||||
}
|
||||
|
||||
// Start launches the Vite development server.
|
||||
func (s *ViteService) Start(ctx context.Context) error {
|
||||
var cmdName string
|
||||
var args []string
|
||||
|
||||
switch s.packageManager {
|
||||
case "bun":
|
||||
cmdName = "bun"
|
||||
args = []string{"run", "dev"}
|
||||
case "pnpm":
|
||||
cmdName = "pnpm"
|
||||
args = []string{"run", "dev"}
|
||||
case "yarn":
|
||||
cmdName = "yarn"
|
||||
args = []string{"dev"}
|
||||
default:
|
||||
cmdName = "npm"
|
||||
args = []string{"run", "dev"}
|
||||
}
|
||||
|
||||
return s.startProcess(ctx, cmdName, args, nil)
|
||||
}
|
||||
|
||||
// Stop terminates the Vite development server.
|
||||
func (s *ViteService) Stop() error {
|
||||
return s.stopProcess()
|
||||
}
|
||||
|
||||
// HorizonService manages Laravel Horizon.
|
||||
type HorizonService struct {
|
||||
baseService
|
||||
}
|
||||
|
||||
// NewHorizonService creates a new Horizon service.
|
||||
func NewHorizonService(dir string) *HorizonService {
|
||||
return &HorizonService{
|
||||
baseService: baseService{
|
||||
name: "Horizon",
|
||||
port: 0, // Horizon doesn't expose a port directly
|
||||
dir: dir,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Start launches the Laravel Horizon queue worker.
|
||||
func (s *HorizonService) Start(ctx context.Context) error {
|
||||
return s.startProcess(ctx, "php", []string{"artisan", "horizon"}, nil)
|
||||
}
|
||||
|
||||
// Stop terminates Horizon using its terminate command.
|
||||
func (s *HorizonService) Stop() error {
|
||||
// Horizon has its own terminate command
|
||||
cmd := exec.Command("php", "artisan", "horizon:terminate")
|
||||
cmd.Dir = s.dir
|
||||
_ = cmd.Run() // Ignore errors, will also kill via signal
|
||||
|
||||
return s.stopProcess()
|
||||
}
|
||||
|
||||
// ReverbService manages Laravel Reverb WebSocket server.
|
||||
type ReverbService struct {
|
||||
baseService
|
||||
}
|
||||
|
||||
// NewReverbService creates a new Reverb service.
|
||||
func NewReverbService(dir string, opts ReverbOptions) *ReverbService {
|
||||
port := opts.Port
|
||||
if port == 0 {
|
||||
port = 8080
|
||||
}
|
||||
|
||||
return &ReverbService{
|
||||
baseService: baseService{
|
||||
name: "Reverb",
|
||||
port: port,
|
||||
dir: dir,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ReverbOptions configures the Reverb service.
|
||||
type ReverbOptions struct {
|
||||
Port int
|
||||
}
|
||||
|
||||
// Start launches the Laravel Reverb WebSocket server.
|
||||
func (s *ReverbService) Start(ctx context.Context) error {
|
||||
args := []string{
|
||||
"artisan", "reverb:start",
|
||||
cli.Sprintf("--port=%d", s.port),
|
||||
}
|
||||
|
||||
return s.startProcess(ctx, "php", args, nil)
|
||||
}
|
||||
|
||||
// Stop terminates the Reverb WebSocket server.
|
||||
func (s *ReverbService) Stop() error {
|
||||
return s.stopProcess()
|
||||
}
|
||||
|
||||
// RedisService manages a local Redis server.
|
||||
type RedisService struct {
|
||||
baseService
|
||||
configFile string
|
||||
}
|
||||
|
||||
// NewRedisService creates a new Redis service.
|
||||
func NewRedisService(dir string, opts RedisOptions) *RedisService {
|
||||
port := opts.Port
|
||||
if port == 0 {
|
||||
port = 6379
|
||||
}
|
||||
|
||||
return &RedisService{
|
||||
baseService: baseService{
|
||||
name: "Redis",
|
||||
port: port,
|
||||
dir: dir,
|
||||
},
|
||||
configFile: opts.ConfigFile,
|
||||
}
|
||||
}
|
||||
|
||||
// RedisOptions configures the Redis service.
|
||||
type RedisOptions struct {
|
||||
Port int
|
||||
ConfigFile string
|
||||
}
|
||||
|
||||
// Start launches the Redis server.
|
||||
func (s *RedisService) Start(ctx context.Context) error {
|
||||
args := []string{
|
||||
"--port", cli.Sprintf("%d", s.port),
|
||||
"--daemonize", "no",
|
||||
}
|
||||
|
||||
if s.configFile != "" {
|
||||
args = []string{s.configFile}
|
||||
args = append(args, "--port", cli.Sprintf("%d", s.port), "--daemonize", "no")
|
||||
}
|
||||
|
||||
return s.startProcess(ctx, "redis-server", args, nil)
|
||||
}
|
||||
|
||||
// Stop terminates Redis using the shutdown command.
|
||||
func (s *RedisService) Stop() error {
|
||||
// Try graceful shutdown via redis-cli
|
||||
cmd := exec.Command("redis-cli", "-p", cli.Sprintf("%d", s.port), "shutdown", "nosave")
|
||||
_ = cmd.Run() // Ignore errors
|
||||
|
||||
return s.stopProcess()
|
||||
}
|
||||
|
||||
// tailReader wraps a file and provides tailing functionality.
|
||||
type tailReader struct {
|
||||
file *os.File
|
||||
reader *bufio.Reader
|
||||
closed bool
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
func newTailReader(file *os.File) *tailReader {
|
||||
return &tailReader{
|
||||
file: file,
|
||||
reader: bufio.NewReader(file),
|
||||
}
|
||||
}
|
||||
|
||||
func (t *tailReader) Read(p []byte) (n int, err error) {
|
||||
t.mu.RLock()
|
||||
if t.closed {
|
||||
t.mu.RUnlock()
|
||||
return 0, io.EOF
|
||||
}
|
||||
t.mu.RUnlock()
|
||||
|
||||
n, err = t.reader.Read(p)
|
||||
if err == io.EOF {
|
||||
// Wait a bit and try again (tailing behavior)
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
return 0, nil
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (t *tailReader) Close() error {
|
||||
t.mu.Lock()
|
||||
t.closed = true
|
||||
t.mu.Unlock()
|
||||
return t.file.Close()
|
||||
}
|
||||
313
services_extended_test.go
Normal file
313
services_extended_test.go
Normal file
|
|
@ -0,0 +1,313 @@
|
|||
package php
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestBaseService_Name_Good(t *testing.T) {
|
||||
t.Run("returns service name", func(t *testing.T) {
|
||||
s := &baseService{name: "TestService"}
|
||||
assert.Equal(t, "TestService", s.Name())
|
||||
})
|
||||
}
|
||||
|
||||
func TestBaseService_Status_Good(t *testing.T) {
|
||||
t.Run("returns status when not running", func(t *testing.T) {
|
||||
s := &baseService{
|
||||
name: "TestService",
|
||||
port: 8080,
|
||||
running: false,
|
||||
}
|
||||
|
||||
status := s.Status()
|
||||
assert.Equal(t, "TestService", status.Name)
|
||||
assert.Equal(t, 8080, status.Port)
|
||||
assert.False(t, status.Running)
|
||||
assert.Equal(t, 0, status.PID)
|
||||
})
|
||||
|
||||
t.Run("returns status when running", func(t *testing.T) {
|
||||
s := &baseService{
|
||||
name: "TestService",
|
||||
port: 8080,
|
||||
running: true,
|
||||
}
|
||||
|
||||
status := s.Status()
|
||||
assert.True(t, status.Running)
|
||||
})
|
||||
|
||||
t.Run("returns error in status", func(t *testing.T) {
|
||||
testErr := assert.AnError
|
||||
s := &baseService{
|
||||
name: "TestService",
|
||||
lastError: testErr,
|
||||
}
|
||||
|
||||
status := s.Status()
|
||||
assert.Equal(t, testErr, status.Error)
|
||||
})
|
||||
}
|
||||
|
||||
func TestBaseService_Logs_Good(t *testing.T) {
|
||||
t.Run("returns log file content", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
logPath := filepath.Join(dir, "test.log")
|
||||
err := os.WriteFile(logPath, []byte("test log content"), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
s := &baseService{logPath: logPath}
|
||||
reader, err := s.Logs(false)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, reader)
|
||||
_ = reader.Close()
|
||||
})
|
||||
|
||||
t.Run("returns tail reader in follow mode", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
logPath := filepath.Join(dir, "test.log")
|
||||
err := os.WriteFile(logPath, []byte("test log content"), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
s := &baseService{logPath: logPath}
|
||||
reader, err := s.Logs(true)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, reader)
|
||||
// Verify it's a tailReader by checking it implements ReadCloser
|
||||
_, ok := reader.(*tailReader)
|
||||
assert.True(t, ok)
|
||||
_ = reader.Close()
|
||||
})
|
||||
}
|
||||
|
||||
func TestBaseService_Logs_Bad(t *testing.T) {
|
||||
t.Run("returns error when no log path", func(t *testing.T) {
|
||||
s := &baseService{name: "TestService"}
|
||||
_, err := s.Logs(false)
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "no log file available")
|
||||
})
|
||||
|
||||
t.Run("returns error when log file doesn't exist", func(t *testing.T) {
|
||||
s := &baseService{logPath: "/nonexistent/path/log.log"}
|
||||
_, err := s.Logs(false)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "Failed to open log file")
|
||||
})
|
||||
}
|
||||
|
||||
func TestTailReader_Good(t *testing.T) {
|
||||
t.Run("creates new tail reader", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
logPath := filepath.Join(dir, "test.log")
|
||||
err := os.WriteFile(logPath, []byte("content"), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
file, err := os.Open(logPath)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = file.Close() }()
|
||||
|
||||
reader := newTailReader(file)
|
||||
assert.NotNil(t, reader)
|
||||
assert.NotNil(t, reader.file)
|
||||
assert.NotNil(t, reader.reader)
|
||||
assert.False(t, reader.closed)
|
||||
})
|
||||
|
||||
t.Run("closes file on Close", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
logPath := filepath.Join(dir, "test.log")
|
||||
err := os.WriteFile(logPath, []byte("content"), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
file, err := os.Open(logPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
reader := newTailReader(file)
|
||||
err = reader.Close()
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, reader.closed)
|
||||
})
|
||||
|
||||
t.Run("returns EOF when closed", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
logPath := filepath.Join(dir, "test.log")
|
||||
err := os.WriteFile(logPath, []byte("content"), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
file, err := os.Open(logPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
reader := newTailReader(file)
|
||||
_ = reader.Close()
|
||||
|
||||
buf := make([]byte, 100)
|
||||
n, _ := reader.Read(buf)
|
||||
// When closed, should return 0 bytes (the closed flag causes early return)
|
||||
assert.Equal(t, 0, n)
|
||||
})
|
||||
}
|
||||
|
||||
func TestFrankenPHPService_Extended(t *testing.T) {
|
||||
t.Run("all options set correctly", func(t *testing.T) {
|
||||
opts := FrankenPHPOptions{
|
||||
Port: 9000,
|
||||
HTTPSPort: 9443,
|
||||
HTTPS: true,
|
||||
CertFile: "/path/to/cert.pem",
|
||||
KeyFile: "/path/to/key.pem",
|
||||
}
|
||||
|
||||
service := NewFrankenPHPService("/project", opts)
|
||||
|
||||
assert.Equal(t, "FrankenPHP", service.Name())
|
||||
assert.Equal(t, 9000, service.port)
|
||||
assert.Equal(t, 9443, service.httpsPort)
|
||||
assert.True(t, service.https)
|
||||
assert.Equal(t, "/path/to/cert.pem", service.certFile)
|
||||
assert.Equal(t, "/path/to/key.pem", service.keyFile)
|
||||
assert.Equal(t, "/project", service.dir)
|
||||
})
|
||||
}
|
||||
|
||||
func TestViteService_Extended(t *testing.T) {
|
||||
t.Run("auto-detects package manager", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
// Create bun.lockb to trigger bun detection
|
||||
err := os.WriteFile(filepath.Join(dir, "bun.lockb"), []byte(""), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
service := NewViteService(dir, ViteOptions{})
|
||||
|
||||
assert.Equal(t, "bun", service.packageManager)
|
||||
})
|
||||
|
||||
t.Run("uses provided package manager", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
service := NewViteService(dir, ViteOptions{PackageManager: "pnpm"})
|
||||
|
||||
assert.Equal(t, "pnpm", service.packageManager)
|
||||
})
|
||||
}
|
||||
|
||||
func TestHorizonService_Extended(t *testing.T) {
|
||||
t.Run("has zero port", func(t *testing.T) {
|
||||
service := NewHorizonService("/project")
|
||||
assert.Equal(t, 0, service.port)
|
||||
})
|
||||
}
|
||||
|
||||
func TestReverbService_Extended(t *testing.T) {
|
||||
t.Run("uses default port 8080", func(t *testing.T) {
|
||||
service := NewReverbService("/project", ReverbOptions{})
|
||||
assert.Equal(t, 8080, service.port)
|
||||
})
|
||||
|
||||
t.Run("uses custom port", func(t *testing.T) {
|
||||
service := NewReverbService("/project", ReverbOptions{Port: 9090})
|
||||
assert.Equal(t, 9090, service.port)
|
||||
})
|
||||
}
|
||||
|
||||
func TestRedisService_Extended(t *testing.T) {
|
||||
t.Run("uses default port 6379", func(t *testing.T) {
|
||||
service := NewRedisService("/project", RedisOptions{})
|
||||
assert.Equal(t, 6379, service.port)
|
||||
})
|
||||
|
||||
t.Run("accepts config file", func(t *testing.T) {
|
||||
service := NewRedisService("/project", RedisOptions{ConfigFile: "/path/to/redis.conf"})
|
||||
assert.Equal(t, "/path/to/redis.conf", service.configFile)
|
||||
})
|
||||
}
|
||||
|
||||
func TestServiceStatus_Struct(t *testing.T) {
|
||||
t.Run("all fields accessible", func(t *testing.T) {
|
||||
testErr := assert.AnError
|
||||
status := ServiceStatus{
|
||||
Name: "TestService",
|
||||
Running: true,
|
||||
PID: 12345,
|
||||
Port: 8080,
|
||||
Error: testErr,
|
||||
}
|
||||
|
||||
assert.Equal(t, "TestService", status.Name)
|
||||
assert.True(t, status.Running)
|
||||
assert.Equal(t, 12345, status.PID)
|
||||
assert.Equal(t, 8080, status.Port)
|
||||
assert.Equal(t, testErr, status.Error)
|
||||
})
|
||||
}
|
||||
|
||||
func TestFrankenPHPOptions_Struct(t *testing.T) {
|
||||
t.Run("all fields accessible", func(t *testing.T) {
|
||||
opts := FrankenPHPOptions{
|
||||
Port: 8000,
|
||||
HTTPSPort: 443,
|
||||
HTTPS: true,
|
||||
CertFile: "cert.pem",
|
||||
KeyFile: "key.pem",
|
||||
}
|
||||
|
||||
assert.Equal(t, 8000, opts.Port)
|
||||
assert.Equal(t, 443, opts.HTTPSPort)
|
||||
assert.True(t, opts.HTTPS)
|
||||
assert.Equal(t, "cert.pem", opts.CertFile)
|
||||
assert.Equal(t, "key.pem", opts.KeyFile)
|
||||
})
|
||||
}
|
||||
|
||||
func TestViteOptions_Struct(t *testing.T) {
|
||||
t.Run("all fields accessible", func(t *testing.T) {
|
||||
opts := ViteOptions{
|
||||
Port: 5173,
|
||||
PackageManager: "bun",
|
||||
}
|
||||
|
||||
assert.Equal(t, 5173, opts.Port)
|
||||
assert.Equal(t, "bun", opts.PackageManager)
|
||||
})
|
||||
}
|
||||
|
||||
func TestReverbOptions_Struct(t *testing.T) {
|
||||
t.Run("all fields accessible", func(t *testing.T) {
|
||||
opts := ReverbOptions{Port: 8080}
|
||||
assert.Equal(t, 8080, opts.Port)
|
||||
})
|
||||
}
|
||||
|
||||
func TestRedisOptions_Struct(t *testing.T) {
|
||||
t.Run("all fields accessible", func(t *testing.T) {
|
||||
opts := RedisOptions{
|
||||
Port: 6379,
|
||||
ConfigFile: "redis.conf",
|
||||
}
|
||||
|
||||
assert.Equal(t, 6379, opts.Port)
|
||||
assert.Equal(t, "redis.conf", opts.ConfigFile)
|
||||
})
|
||||
}
|
||||
|
||||
func TestBaseService_StopProcess_Good(t *testing.T) {
|
||||
t.Run("returns nil when not running", func(t *testing.T) {
|
||||
s := &baseService{running: false}
|
||||
err := s.stopProcess()
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("returns nil when cmd is nil", func(t *testing.T) {
|
||||
s := &baseService{running: true, cmd: nil}
|
||||
err := s.stopProcess()
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
}
|
||||
100
services_test.go
Normal file
100
services_test.go
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
package php
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestNewFrankenPHPService_Good(t *testing.T) {
|
||||
t.Run("default options", func(t *testing.T) {
|
||||
dir := "/tmp/test"
|
||||
service := NewFrankenPHPService(dir, FrankenPHPOptions{})
|
||||
|
||||
assert.Equal(t, "FrankenPHP", service.Name())
|
||||
assert.Equal(t, 8000, service.port)
|
||||
assert.Equal(t, 443, service.httpsPort)
|
||||
assert.False(t, service.https)
|
||||
})
|
||||
|
||||
t.Run("custom options", func(t *testing.T) {
|
||||
dir := "/tmp/test"
|
||||
opts := FrankenPHPOptions{
|
||||
Port: 9000,
|
||||
HTTPSPort: 8443,
|
||||
HTTPS: true,
|
||||
CertFile: "cert.pem",
|
||||
KeyFile: "key.pem",
|
||||
}
|
||||
service := NewFrankenPHPService(dir, opts)
|
||||
|
||||
assert.Equal(t, 9000, service.port)
|
||||
assert.Equal(t, 8443, service.httpsPort)
|
||||
assert.True(t, service.https)
|
||||
assert.Equal(t, "cert.pem", service.certFile)
|
||||
assert.Equal(t, "key.pem", service.keyFile)
|
||||
})
|
||||
}
|
||||
|
||||
func TestNewViteService_Good(t *testing.T) {
|
||||
t.Run("default options", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
service := NewViteService(dir, ViteOptions{})
|
||||
|
||||
assert.Equal(t, "Vite", service.Name())
|
||||
assert.Equal(t, 5173, service.port)
|
||||
assert.Equal(t, "npm", service.packageManager) // default when no lock file
|
||||
})
|
||||
|
||||
t.Run("custom package manager", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
service := NewViteService(dir, ViteOptions{PackageManager: "pnpm"})
|
||||
|
||||
assert.Equal(t, "pnpm", service.packageManager)
|
||||
})
|
||||
}
|
||||
|
||||
func TestNewHorizonService_Good(t *testing.T) {
|
||||
service := NewHorizonService("/tmp/test")
|
||||
assert.Equal(t, "Horizon", service.Name())
|
||||
assert.Equal(t, 0, service.port)
|
||||
}
|
||||
|
||||
func TestNewReverbService_Good(t *testing.T) {
|
||||
t.Run("default options", func(t *testing.T) {
|
||||
service := NewReverbService("/tmp/test", ReverbOptions{})
|
||||
assert.Equal(t, "Reverb", service.Name())
|
||||
assert.Equal(t, 8080, service.port)
|
||||
})
|
||||
|
||||
t.Run("custom port", func(t *testing.T) {
|
||||
service := NewReverbService("/tmp/test", ReverbOptions{Port: 9090})
|
||||
assert.Equal(t, 9090, service.port)
|
||||
})
|
||||
}
|
||||
|
||||
func TestNewRedisService_Good(t *testing.T) {
|
||||
t.Run("default options", func(t *testing.T) {
|
||||
service := NewRedisService("/tmp/test", RedisOptions{})
|
||||
assert.Equal(t, "Redis", service.Name())
|
||||
assert.Equal(t, 6379, service.port)
|
||||
})
|
||||
|
||||
t.Run("custom config", func(t *testing.T) {
|
||||
service := NewRedisService("/tmp/test", RedisOptions{ConfigFile: "redis.conf"})
|
||||
assert.Equal(t, "redis.conf", service.configFile)
|
||||
})
|
||||
}
|
||||
|
||||
func TestBaseService_Status(t *testing.T) {
|
||||
s := &baseService{
|
||||
name: "TestService",
|
||||
port: 1234,
|
||||
running: true,
|
||||
}
|
||||
|
||||
status := s.Status()
|
||||
assert.Equal(t, "TestService", status.Name)
|
||||
assert.Equal(t, 1234, status.Port)
|
||||
assert.True(t, status.Running)
|
||||
}
|
||||
41
services_unix.go
Normal file
41
services_unix.go
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
//go:build unix
|
||||
|
||||
package php
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// setSysProcAttr sets Unix-specific process attributes for clean process group handling.
|
||||
func setSysProcAttr(cmd *exec.Cmd) {
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||
Setpgid: true,
|
||||
}
|
||||
}
|
||||
|
||||
// signalProcessGroup sends a signal to the process group.
|
||||
// On Unix, this uses negative PID to signal the entire group.
|
||||
func signalProcessGroup(cmd *exec.Cmd, sig syscall.Signal) error {
|
||||
if cmd.Process == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
pgid, err := syscall.Getpgid(cmd.Process.Pid)
|
||||
if err == nil {
|
||||
return syscall.Kill(-pgid, sig)
|
||||
}
|
||||
|
||||
// Fallback to signaling just the process
|
||||
return cmd.Process.Signal(sig)
|
||||
}
|
||||
|
||||
// termSignal returns SIGTERM for Unix.
|
||||
func termSignal() syscall.Signal {
|
||||
return syscall.SIGTERM
|
||||
}
|
||||
|
||||
// killSignal returns SIGKILL for Unix.
|
||||
func killSignal() syscall.Signal {
|
||||
return syscall.SIGKILL
|
||||
}
|
||||
34
services_windows.go
Normal file
34
services_windows.go
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
//go:build windows
|
||||
|
||||
package php
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
// setSysProcAttr sets Windows-specific process attributes.
|
||||
// Windows doesn't support Setpgid, so this is a no-op.
|
||||
func setSysProcAttr(cmd *exec.Cmd) {
|
||||
// No-op on Windows - process groups work differently
|
||||
}
|
||||
|
||||
// signalProcessGroup sends a termination signal to the process.
|
||||
// On Windows, we can only signal the main process, not a group.
|
||||
func signalProcessGroup(cmd *exec.Cmd, sig os.Signal) error {
|
||||
if cmd.Process == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return cmd.Process.Signal(sig)
|
||||
}
|
||||
|
||||
// termSignal returns os.Interrupt for Windows (closest to SIGTERM).
|
||||
func termSignal() os.Signal {
|
||||
return os.Interrupt
|
||||
}
|
||||
|
||||
// killSignal returns os.Kill for Windows.
|
||||
func killSignal() os.Signal {
|
||||
return os.Kill
|
||||
}
|
||||
165
ssl.go
Normal file
165
ssl.go
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
package php
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
)
|
||||
|
||||
const (
|
||||
// DefaultSSLDir is the default directory for SSL certificates.
|
||||
DefaultSSLDir = ".core/ssl"
|
||||
)
|
||||
|
||||
// SSLOptions configures SSL certificate generation.
|
||||
type SSLOptions struct {
|
||||
// Dir is the directory to store certificates.
|
||||
// Defaults to ~/.core/ssl/
|
||||
Dir string
|
||||
}
|
||||
|
||||
// GetSSLDir returns the SSL directory, creating it if necessary.
|
||||
func GetSSLDir(opts SSLOptions) (string, error) {
|
||||
m := getMedium()
|
||||
dir := opts.Dir
|
||||
if dir == "" {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", cli.WrapVerb(err, "get", "home directory")
|
||||
}
|
||||
dir = filepath.Join(home, DefaultSSLDir)
|
||||
}
|
||||
|
||||
if err := m.EnsureDir(dir); err != nil {
|
||||
return "", cli.WrapVerb(err, "create", "SSL directory")
|
||||
}
|
||||
|
||||
return dir, nil
|
||||
}
|
||||
|
||||
// CertPaths returns the paths to the certificate and key files for a domain.
|
||||
func CertPaths(domain string, opts SSLOptions) (certFile, keyFile string, err error) {
|
||||
dir, err := GetSSLDir(opts)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
certFile = filepath.Join(dir, cli.Sprintf("%s.pem", domain))
|
||||
keyFile = filepath.Join(dir, cli.Sprintf("%s-key.pem", domain))
|
||||
|
||||
return certFile, keyFile, nil
|
||||
}
|
||||
|
||||
// CertsExist checks if SSL certificates exist for the given domain.
|
||||
func CertsExist(domain string, opts SSLOptions) bool {
|
||||
m := getMedium()
|
||||
certFile, keyFile, err := CertPaths(domain, opts)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if !m.IsFile(certFile) {
|
||||
return false
|
||||
}
|
||||
|
||||
if !m.IsFile(keyFile) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// SetupSSL creates local SSL certificates using mkcert.
|
||||
// It installs the local CA if not already installed and generates
|
||||
// certificates for the given domain.
|
||||
func SetupSSL(domain string, opts SSLOptions) error {
|
||||
// Check if mkcert is installed
|
||||
if _, err := exec.LookPath("mkcert"); err != nil {
|
||||
return cli.Err("mkcert is not installed. Install it with: brew install mkcert (macOS) or see https://github.com/FiloSottile/mkcert")
|
||||
}
|
||||
|
||||
dir, err := GetSSLDir(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Install local CA (idempotent operation)
|
||||
installCmd := exec.Command("mkcert", "-install")
|
||||
if output, err := installCmd.CombinedOutput(); err != nil {
|
||||
return cli.Err("failed to install mkcert CA: %v\n%s", err, output)
|
||||
}
|
||||
|
||||
// Generate certificates
|
||||
certFile := filepath.Join(dir, cli.Sprintf("%s.pem", domain))
|
||||
keyFile := filepath.Join(dir, cli.Sprintf("%s-key.pem", domain))
|
||||
|
||||
// mkcert generates cert and key with specific naming
|
||||
genCmd := exec.Command("mkcert",
|
||||
"-cert-file", certFile,
|
||||
"-key-file", keyFile,
|
||||
domain,
|
||||
"localhost",
|
||||
"127.0.0.1",
|
||||
"::1",
|
||||
)
|
||||
|
||||
if output, err := genCmd.CombinedOutput(); err != nil {
|
||||
return cli.Err("failed to generate certificates: %v\n%s", err, output)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetupSSLIfNeeded checks if certificates exist and creates them if not.
|
||||
func SetupSSLIfNeeded(domain string, opts SSLOptions) (certFile, keyFile string, err error) {
|
||||
certFile, keyFile, err = CertPaths(domain, opts)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
if !CertsExist(domain, opts) {
|
||||
if err := SetupSSL(domain, opts); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
}
|
||||
|
||||
return certFile, keyFile, nil
|
||||
}
|
||||
|
||||
// IsMkcertInstalled checks if mkcert is available in PATH.
|
||||
func IsMkcertInstalled() bool {
|
||||
_, err := exec.LookPath("mkcert")
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// InstallMkcertCA installs the local CA for mkcert.
|
||||
func InstallMkcertCA() error {
|
||||
if !IsMkcertInstalled() {
|
||||
return cli.Err("mkcert is not installed")
|
||||
}
|
||||
|
||||
cmd := exec.Command("mkcert", "-install")
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return cli.Err("failed to install mkcert CA: %v\n%s", err, output)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetMkcertCARoot returns the path to the mkcert CA root directory.
|
||||
func GetMkcertCARoot() (string, error) {
|
||||
if !IsMkcertInstalled() {
|
||||
return "", cli.Err("mkcert is not installed")
|
||||
}
|
||||
|
||||
cmd := exec.Command("mkcert", "-CAROOT")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "", cli.WrapVerb(err, "get", "mkcert CA root")
|
||||
}
|
||||
|
||||
return filepath.Clean(string(output)), nil
|
||||
}
|
||||
219
ssl_extended_test.go
Normal file
219
ssl_extended_test.go
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
package php
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestSSLOptions_Struct(t *testing.T) {
|
||||
t.Run("all fields accessible", func(t *testing.T) {
|
||||
opts := SSLOptions{Dir: "/custom/ssl/dir"}
|
||||
assert.Equal(t, "/custom/ssl/dir", opts.Dir)
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetSSLDir_Bad(t *testing.T) {
|
||||
t.Run("fails to create directory in invalid path", func(t *testing.T) {
|
||||
// Try to create a directory in a path that can't exist
|
||||
opts := SSLOptions{Dir: "/dev/null/cannot/create"}
|
||||
_, err := GetSSLDir(opts)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "Failed to create SSL directory")
|
||||
})
|
||||
}
|
||||
|
||||
func TestCertPaths_Bad(t *testing.T) {
|
||||
t.Run("fails when GetSSLDir fails", func(t *testing.T) {
|
||||
opts := SSLOptions{Dir: "/dev/null/cannot/create"}
|
||||
_, _, err := CertPaths("domain.test", opts)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestCertsExist_Detailed(t *testing.T) {
|
||||
t.Run("returns true when both cert and key exist", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
domain := "test.local"
|
||||
|
||||
// Create both files
|
||||
certPath := filepath.Join(dir, domain+".pem")
|
||||
keyPath := filepath.Join(dir, domain+"-key.pem")
|
||||
|
||||
err := os.WriteFile(certPath, []byte("cert"), 0644)
|
||||
require.NoError(t, err)
|
||||
err = os.WriteFile(keyPath, []byte("key"), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
result := CertsExist(domain, SSLOptions{Dir: dir})
|
||||
assert.True(t, result)
|
||||
})
|
||||
|
||||
t.Run("returns false when only cert exists", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
domain := "test.local"
|
||||
|
||||
certPath := filepath.Join(dir, domain+".pem")
|
||||
err := os.WriteFile(certPath, []byte("cert"), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
result := CertsExist(domain, SSLOptions{Dir: dir})
|
||||
assert.False(t, result)
|
||||
})
|
||||
|
||||
t.Run("returns false when only key exists", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
domain := "test.local"
|
||||
|
||||
keyPath := filepath.Join(dir, domain+"-key.pem")
|
||||
err := os.WriteFile(keyPath, []byte("key"), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
result := CertsExist(domain, SSLOptions{Dir: dir})
|
||||
assert.False(t, result)
|
||||
})
|
||||
|
||||
t.Run("returns false when CertPaths fails", func(t *testing.T) {
|
||||
result := CertsExist("domain.test", SSLOptions{Dir: "/dev/null/cannot/create"})
|
||||
assert.False(t, result)
|
||||
})
|
||||
}
|
||||
|
||||
func TestSetupSSL_RequiresMkcert(t *testing.T) {
|
||||
t.Run("fails when mkcert not installed", func(t *testing.T) {
|
||||
if IsMkcertInstalled() {
|
||||
t.Skip("mkcert is installed, skipping error test")
|
||||
}
|
||||
|
||||
err := SetupSSL("example.test", SSLOptions{})
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "mkcert is not installed")
|
||||
})
|
||||
}
|
||||
|
||||
func TestSetupSSLIfNeeded_UsesExisting(t *testing.T) {
|
||||
t.Run("returns existing certs without regenerating", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
domain := "existing.test"
|
||||
|
||||
// Create existing certs
|
||||
certPath := filepath.Join(dir, domain+".pem")
|
||||
keyPath := filepath.Join(dir, domain+"-key.pem")
|
||||
|
||||
err := os.WriteFile(certPath, []byte("existing cert"), 0644)
|
||||
require.NoError(t, err)
|
||||
err = os.WriteFile(keyPath, []byte("existing key"), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
resultCert, resultKey, err := SetupSSLIfNeeded(domain, SSLOptions{Dir: dir})
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, certPath, resultCert)
|
||||
assert.Equal(t, keyPath, resultKey)
|
||||
|
||||
// Verify original content wasn't changed
|
||||
content, _ := os.ReadFile(certPath)
|
||||
assert.Equal(t, "existing cert", string(content))
|
||||
})
|
||||
}
|
||||
|
||||
func TestSetupSSLIfNeeded_Bad(t *testing.T) {
|
||||
t.Run("fails when CertPaths fails", func(t *testing.T) {
|
||||
_, _, err := SetupSSLIfNeeded("domain.test", SSLOptions{Dir: "/dev/null/cannot/create"})
|
||||
assert.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("fails when SetupSSL fails", func(t *testing.T) {
|
||||
if IsMkcertInstalled() {
|
||||
t.Skip("mkcert is installed, skipping error test")
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
_, _, err := SetupSSLIfNeeded("domain.test", SSLOptions{Dir: dir})
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestInstallMkcertCA_Bad(t *testing.T) {
|
||||
t.Run("fails when mkcert not installed", func(t *testing.T) {
|
||||
if IsMkcertInstalled() {
|
||||
t.Skip("mkcert is installed, skipping error test")
|
||||
}
|
||||
|
||||
err := InstallMkcertCA()
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "mkcert is not installed")
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetMkcertCARoot_Bad(t *testing.T) {
|
||||
t.Run("fails when mkcert not installed", func(t *testing.T) {
|
||||
if IsMkcertInstalled() {
|
||||
t.Skip("mkcert is installed, skipping error test")
|
||||
}
|
||||
|
||||
_, err := GetMkcertCARoot()
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "mkcert is not installed")
|
||||
})
|
||||
}
|
||||
|
||||
func TestCertPathsNaming(t *testing.T) {
|
||||
t.Run("uses correct naming convention", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
domain := "myapp.example.com"
|
||||
|
||||
certFile, keyFile, err := CertPaths(domain, SSLOptions{Dir: dir})
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, filepath.Join(dir, "myapp.example.com.pem"), certFile)
|
||||
assert.Equal(t, filepath.Join(dir, "myapp.example.com-key.pem"), keyFile)
|
||||
})
|
||||
|
||||
t.Run("handles localhost", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
certFile, keyFile, err := CertPaths("localhost", SSLOptions{Dir: dir})
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, filepath.Join(dir, "localhost.pem"), certFile)
|
||||
assert.Equal(t, filepath.Join(dir, "localhost-key.pem"), keyFile)
|
||||
})
|
||||
|
||||
t.Run("handles wildcard-like domains", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
domain := "*.example.com"
|
||||
|
||||
certFile, keyFile, err := CertPaths(domain, SSLOptions{Dir: dir})
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Contains(t, certFile, "*.example.com.pem")
|
||||
assert.Contains(t, keyFile, "*.example.com-key.pem")
|
||||
})
|
||||
}
|
||||
|
||||
func TestDefaultSSLDir_Value(t *testing.T) {
|
||||
t.Run("has expected default value", func(t *testing.T) {
|
||||
assert.Equal(t, ".core/ssl", DefaultSSLDir)
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetSSLDir_CreatesDirectory(t *testing.T) {
|
||||
t.Run("creates nested directory structure", func(t *testing.T) {
|
||||
baseDir := t.TempDir()
|
||||
nestedDir := filepath.Join(baseDir, "level1", "level2", "ssl")
|
||||
|
||||
dir, err := GetSSLDir(SSLOptions{Dir: nestedDir})
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, nestedDir, dir)
|
||||
|
||||
// Verify directory exists
|
||||
info, err := os.Stat(dir)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, info.IsDir())
|
||||
})
|
||||
}
|
||||
172
ssl_test.go
Normal file
172
ssl_test.go
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
package php
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGetSSLDir_Good(t *testing.T) {
|
||||
t.Run("uses provided directory", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
customDir := filepath.Join(dir, "custom-ssl")
|
||||
|
||||
result, err := GetSSLDir(SSLOptions{Dir: customDir})
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, customDir, result)
|
||||
|
||||
// Verify directory was created
|
||||
info, err := os.Stat(result)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, info.IsDir())
|
||||
})
|
||||
|
||||
t.Run("uses default directory when not specified", func(t *testing.T) {
|
||||
// Skip if we can't get home dir
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
t.Skip("cannot get home directory")
|
||||
}
|
||||
|
||||
result, err := GetSSLDir(SSLOptions{})
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, filepath.Join(home, DefaultSSLDir), result)
|
||||
})
|
||||
}
|
||||
|
||||
func TestCertPaths_Good(t *testing.T) {
|
||||
t.Run("returns correct paths for domain", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
certFile, keyFile, err := CertPaths("example.test", SSLOptions{Dir: dir})
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, filepath.Join(dir, "example.test.pem"), certFile)
|
||||
assert.Equal(t, filepath.Join(dir, "example.test-key.pem"), keyFile)
|
||||
})
|
||||
|
||||
t.Run("handles domain with subdomain", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
certFile, keyFile, err := CertPaths("app.example.test", SSLOptions{Dir: dir})
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, filepath.Join(dir, "app.example.test.pem"), certFile)
|
||||
assert.Equal(t, filepath.Join(dir, "app.example.test-key.pem"), keyFile)
|
||||
})
|
||||
}
|
||||
|
||||
func TestCertsExist_Good(t *testing.T) {
|
||||
t.Run("returns true when both files exist", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
domain := "myapp.test"
|
||||
|
||||
// Create cert and key files
|
||||
certFile := filepath.Join(dir, domain+".pem")
|
||||
keyFile := filepath.Join(dir, domain+"-key.pem")
|
||||
|
||||
err := os.WriteFile(certFile, []byte("cert content"), 0644)
|
||||
require.NoError(t, err)
|
||||
err = os.WriteFile(keyFile, []byte("key content"), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.True(t, CertsExist(domain, SSLOptions{Dir: dir}))
|
||||
})
|
||||
}
|
||||
|
||||
func TestCertsExist_Bad(t *testing.T) {
|
||||
t.Run("returns false when cert missing", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
domain := "myapp.test"
|
||||
|
||||
// Create only key file
|
||||
keyFile := filepath.Join(dir, domain+"-key.pem")
|
||||
err := os.WriteFile(keyFile, []byte("key content"), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.False(t, CertsExist(domain, SSLOptions{Dir: dir}))
|
||||
})
|
||||
|
||||
t.Run("returns false when key missing", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
domain := "myapp.test"
|
||||
|
||||
// Create only cert file
|
||||
certFile := filepath.Join(dir, domain+".pem")
|
||||
err := os.WriteFile(certFile, []byte("cert content"), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.False(t, CertsExist(domain, SSLOptions{Dir: dir}))
|
||||
})
|
||||
|
||||
t.Run("returns false when neither exists", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
domain := "myapp.test"
|
||||
|
||||
assert.False(t, CertsExist(domain, SSLOptions{Dir: dir}))
|
||||
})
|
||||
|
||||
t.Run("returns false for invalid directory", func(t *testing.T) {
|
||||
// Use invalid directory path
|
||||
assert.False(t, CertsExist("domain.test", SSLOptions{Dir: "/nonexistent/path/that/does/not/exist"}))
|
||||
})
|
||||
}
|
||||
|
||||
func TestSetupSSL_Bad(t *testing.T) {
|
||||
t.Run("returns error when mkcert not installed", func(t *testing.T) {
|
||||
// This test assumes mkcert might not be installed
|
||||
// If it is installed, we skip this test
|
||||
if IsMkcertInstalled() {
|
||||
t.Skip("mkcert is installed, skipping error test")
|
||||
}
|
||||
|
||||
err := SetupSSL("example.test", SSLOptions{})
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "mkcert is not installed")
|
||||
})
|
||||
}
|
||||
|
||||
func TestSetupSSLIfNeeded_Good(t *testing.T) {
|
||||
t.Run("returns existing certs without regenerating", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
domain := "existing.test"
|
||||
|
||||
// Create existing cert files
|
||||
certFile := filepath.Join(dir, domain+".pem")
|
||||
keyFile := filepath.Join(dir, domain+"-key.pem")
|
||||
|
||||
err := os.WriteFile(certFile, []byte("existing cert"), 0644)
|
||||
require.NoError(t, err)
|
||||
err = os.WriteFile(keyFile, []byte("existing key"), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
resultCert, resultKey, err := SetupSSLIfNeeded(domain, SSLOptions{Dir: dir})
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, certFile, resultCert)
|
||||
assert.Equal(t, keyFile, resultKey)
|
||||
|
||||
// Verify files weren't modified
|
||||
data, err := os.ReadFile(certFile)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "existing cert", string(data))
|
||||
})
|
||||
}
|
||||
|
||||
func TestIsMkcertInstalled_Good(t *testing.T) {
|
||||
// This test just verifies the function runs without error
|
||||
// The actual result depends on whether mkcert is installed
|
||||
result := IsMkcertInstalled()
|
||||
t.Logf("mkcert installed: %v", result)
|
||||
}
|
||||
|
||||
func TestDefaultSSLDir_Good(t *testing.T) {
|
||||
t.Run("constant has expected value", func(t *testing.T) {
|
||||
assert.Equal(t, ".core/ssl", DefaultSSLDir)
|
||||
})
|
||||
}
|
||||
195
testing.go
Normal file
195
testing.go
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
package php
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
)
|
||||
|
||||
// TestOptions configures PHP test execution.
|
||||
type TestOptions struct {
|
||||
// Dir is the project directory (defaults to current working directory).
|
||||
Dir string
|
||||
|
||||
// Filter filters tests by name pattern.
|
||||
Filter string
|
||||
|
||||
// Parallel runs tests in parallel.
|
||||
Parallel bool
|
||||
|
||||
// Coverage generates code coverage.
|
||||
Coverage bool
|
||||
|
||||
// CoverageFormat is the coverage output format (text, html, clover).
|
||||
CoverageFormat string
|
||||
|
||||
// Groups runs only tests in the specified groups.
|
||||
Groups []string
|
||||
|
||||
// JUnit outputs results in JUnit XML format via --log-junit.
|
||||
JUnit bool
|
||||
|
||||
// Output is the writer for test output (defaults to os.Stdout).
|
||||
Output io.Writer
|
||||
}
|
||||
|
||||
// TestRunner represents the detected test runner.
|
||||
type TestRunner string
|
||||
|
||||
// Test runner type constants.
|
||||
const (
|
||||
// TestRunnerPest indicates Pest testing framework.
|
||||
TestRunnerPest TestRunner = "pest"
|
||||
// TestRunnerPHPUnit indicates PHPUnit testing framework.
|
||||
TestRunnerPHPUnit TestRunner = "phpunit"
|
||||
)
|
||||
|
||||
// DetectTestRunner detects which test runner is available in the project.
|
||||
// Returns Pest if tests/Pest.php exists, otherwise PHPUnit.
|
||||
func DetectTestRunner(dir string) TestRunner {
|
||||
// Check for Pest
|
||||
pestFile := filepath.Join(dir, "tests", "Pest.php")
|
||||
if getMedium().IsFile(pestFile) {
|
||||
return TestRunnerPest
|
||||
}
|
||||
|
||||
return TestRunnerPHPUnit
|
||||
}
|
||||
|
||||
// RunTests runs PHPUnit or Pest tests.
|
||||
func RunTests(ctx context.Context, opts TestOptions) error {
|
||||
if opts.Dir == "" {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return cli.WrapVerb(err, "get", "working directory")
|
||||
}
|
||||
opts.Dir = cwd
|
||||
}
|
||||
|
||||
if opts.Output == nil {
|
||||
opts.Output = os.Stdout
|
||||
}
|
||||
|
||||
// Detect test runner
|
||||
runner := DetectTestRunner(opts.Dir)
|
||||
|
||||
// Build command based on runner
|
||||
var cmdName string
|
||||
var args []string
|
||||
|
||||
switch runner {
|
||||
case TestRunnerPest:
|
||||
cmdName, args = buildPestCommand(opts)
|
||||
default:
|
||||
cmdName, args = buildPHPUnitCommand(opts)
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(ctx, cmdName, args...)
|
||||
cmd.Dir = opts.Dir
|
||||
cmd.Stdout = opts.Output
|
||||
cmd.Stderr = opts.Output
|
||||
cmd.Stdin = os.Stdin
|
||||
|
||||
// Set XDEBUG_MODE=coverage to avoid PHPUnit 11 warning
|
||||
cmd.Env = append(os.Environ(), "XDEBUG_MODE=coverage")
|
||||
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
// RunParallel runs tests in parallel using the appropriate runner.
|
||||
func RunParallel(ctx context.Context, opts TestOptions) error {
|
||||
opts.Parallel = true
|
||||
return RunTests(ctx, opts)
|
||||
}
|
||||
|
||||
// buildPestCommand builds the command for running Pest tests.
|
||||
func buildPestCommand(opts TestOptions) (string, []string) {
|
||||
m := getMedium()
|
||||
// Check for vendor binary first
|
||||
vendorBin := filepath.Join(opts.Dir, "vendor", "bin", "pest")
|
||||
cmdName := "pest"
|
||||
if m.IsFile(vendorBin) {
|
||||
cmdName = vendorBin
|
||||
}
|
||||
|
||||
var args []string
|
||||
|
||||
if opts.Filter != "" {
|
||||
args = append(args, "--filter", opts.Filter)
|
||||
}
|
||||
|
||||
if opts.Parallel {
|
||||
args = append(args, "--parallel")
|
||||
}
|
||||
|
||||
if opts.Coverage {
|
||||
switch opts.CoverageFormat {
|
||||
case "html":
|
||||
args = append(args, "--coverage-html", "coverage")
|
||||
case "clover":
|
||||
args = append(args, "--coverage-clover", "coverage.xml")
|
||||
default:
|
||||
args = append(args, "--coverage")
|
||||
}
|
||||
}
|
||||
|
||||
for _, group := range opts.Groups {
|
||||
args = append(args, "--group", group)
|
||||
}
|
||||
|
||||
if opts.JUnit {
|
||||
args = append(args, "--log-junit", "test-results.xml")
|
||||
}
|
||||
|
||||
return cmdName, args
|
||||
}
|
||||
|
||||
// buildPHPUnitCommand builds the command for running PHPUnit tests.
|
||||
func buildPHPUnitCommand(opts TestOptions) (string, []string) {
|
||||
m := getMedium()
|
||||
// Check for vendor binary first
|
||||
vendorBin := filepath.Join(opts.Dir, "vendor", "bin", "phpunit")
|
||||
cmdName := "phpunit"
|
||||
if m.IsFile(vendorBin) {
|
||||
cmdName = vendorBin
|
||||
}
|
||||
|
||||
var args []string
|
||||
|
||||
if opts.Filter != "" {
|
||||
args = append(args, "--filter", opts.Filter)
|
||||
}
|
||||
|
||||
if opts.Parallel {
|
||||
// PHPUnit uses paratest for parallel execution
|
||||
paratestBin := filepath.Join(opts.Dir, "vendor", "bin", "paratest")
|
||||
if m.IsFile(paratestBin) {
|
||||
cmdName = paratestBin
|
||||
}
|
||||
}
|
||||
|
||||
if opts.Coverage {
|
||||
switch opts.CoverageFormat {
|
||||
case "html":
|
||||
args = append(args, "--coverage-html", "coverage")
|
||||
case "clover":
|
||||
args = append(args, "--coverage-clover", "coverage.xml")
|
||||
default:
|
||||
args = append(args, "--coverage-text")
|
||||
}
|
||||
}
|
||||
|
||||
for _, group := range opts.Groups {
|
||||
args = append(args, "--group", group)
|
||||
}
|
||||
|
||||
if opts.JUnit {
|
||||
args = append(args, "--log-junit", "test-results.xml", "--testdox")
|
||||
}
|
||||
|
||||
return cmdName, args
|
||||
}
|
||||
380
testing_test.go
Normal file
380
testing_test.go
Normal file
|
|
@ -0,0 +1,380 @@
|
|||
package php
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestDetectTestRunner_Good(t *testing.T) {
|
||||
t.Run("detects Pest when tests/Pest.php exists", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
testsDir := filepath.Join(dir, "tests")
|
||||
err := os.MkdirAll(testsDir, 0755)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = os.WriteFile(filepath.Join(testsDir, "Pest.php"), []byte("<?php\n"), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
runner := DetectTestRunner(dir)
|
||||
assert.Equal(t, TestRunnerPest, runner)
|
||||
})
|
||||
|
||||
t.Run("returns PHPUnit when no Pest.php", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
runner := DetectTestRunner(dir)
|
||||
assert.Equal(t, TestRunnerPHPUnit, runner)
|
||||
})
|
||||
|
||||
t.Run("returns PHPUnit when tests directory exists but no Pest.php", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
testsDir := filepath.Join(dir, "tests")
|
||||
err := os.MkdirAll(testsDir, 0755)
|
||||
require.NoError(t, err)
|
||||
|
||||
runner := DetectTestRunner(dir)
|
||||
assert.Equal(t, TestRunnerPHPUnit, runner)
|
||||
})
|
||||
}
|
||||
|
||||
func TestBuildPestCommand_Good(t *testing.T) {
|
||||
t.Run("basic command", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
opts := TestOptions{Dir: dir}
|
||||
|
||||
cmd, args := buildPestCommand(opts)
|
||||
assert.Equal(t, "pest", cmd)
|
||||
assert.Empty(t, args)
|
||||
})
|
||||
|
||||
t.Run("with filter", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
opts := TestOptions{Dir: dir, Filter: "UserTest"}
|
||||
|
||||
_, args := buildPestCommand(opts)
|
||||
assert.Contains(t, args, "--filter")
|
||||
assert.Contains(t, args, "UserTest")
|
||||
})
|
||||
|
||||
t.Run("with parallel", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
opts := TestOptions{Dir: dir, Parallel: true}
|
||||
|
||||
_, args := buildPestCommand(opts)
|
||||
assert.Contains(t, args, "--parallel")
|
||||
})
|
||||
|
||||
t.Run("with coverage", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
opts := TestOptions{Dir: dir, Coverage: true}
|
||||
|
||||
_, args := buildPestCommand(opts)
|
||||
assert.Contains(t, args, "--coverage")
|
||||
})
|
||||
|
||||
t.Run("with coverage HTML format", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
opts := TestOptions{Dir: dir, Coverage: true, CoverageFormat: "html"}
|
||||
|
||||
_, args := buildPestCommand(opts)
|
||||
assert.Contains(t, args, "--coverage-html")
|
||||
assert.Contains(t, args, "coverage")
|
||||
})
|
||||
|
||||
t.Run("with coverage clover format", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
opts := TestOptions{Dir: dir, Coverage: true, CoverageFormat: "clover"}
|
||||
|
||||
_, args := buildPestCommand(opts)
|
||||
assert.Contains(t, args, "--coverage-clover")
|
||||
assert.Contains(t, args, "coverage.xml")
|
||||
})
|
||||
|
||||
t.Run("with groups", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
opts := TestOptions{Dir: dir, Groups: []string{"unit", "integration"}}
|
||||
|
||||
_, args := buildPestCommand(opts)
|
||||
assert.Contains(t, args, "--group")
|
||||
assert.Contains(t, args, "unit")
|
||||
assert.Contains(t, args, "integration")
|
||||
})
|
||||
|
||||
t.Run("uses vendor binary when exists", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
binDir := filepath.Join(dir, "vendor", "bin")
|
||||
err := os.MkdirAll(binDir, 0755)
|
||||
require.NoError(t, err)
|
||||
|
||||
pestPath := filepath.Join(binDir, "pest")
|
||||
err = os.WriteFile(pestPath, []byte("#!/bin/bash"), 0755)
|
||||
require.NoError(t, err)
|
||||
|
||||
opts := TestOptions{Dir: dir}
|
||||
cmd, _ := buildPestCommand(opts)
|
||||
assert.Equal(t, pestPath, cmd)
|
||||
})
|
||||
|
||||
t.Run("all options combined", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
opts := TestOptions{
|
||||
Dir: dir,
|
||||
Filter: "Test",
|
||||
Parallel: true,
|
||||
Coverage: true,
|
||||
CoverageFormat: "html",
|
||||
Groups: []string{"unit"},
|
||||
}
|
||||
|
||||
_, args := buildPestCommand(opts)
|
||||
assert.Contains(t, args, "--filter")
|
||||
assert.Contains(t, args, "--parallel")
|
||||
assert.Contains(t, args, "--coverage-html")
|
||||
assert.Contains(t, args, "--group")
|
||||
})
|
||||
}
|
||||
|
||||
func TestBuildPHPUnitCommand_Good(t *testing.T) {
|
||||
t.Run("basic command", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
opts := TestOptions{Dir: dir}
|
||||
|
||||
cmd, args := buildPHPUnitCommand(opts)
|
||||
assert.Equal(t, "phpunit", cmd)
|
||||
assert.Empty(t, args)
|
||||
})
|
||||
|
||||
t.Run("with filter", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
opts := TestOptions{Dir: dir, Filter: "UserTest"}
|
||||
|
||||
_, args := buildPHPUnitCommand(opts)
|
||||
assert.Contains(t, args, "--filter")
|
||||
assert.Contains(t, args, "UserTest")
|
||||
})
|
||||
|
||||
t.Run("with parallel uses paratest", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
binDir := filepath.Join(dir, "vendor", "bin")
|
||||
err := os.MkdirAll(binDir, 0755)
|
||||
require.NoError(t, err)
|
||||
|
||||
paratestPath := filepath.Join(binDir, "paratest")
|
||||
err = os.WriteFile(paratestPath, []byte("#!/bin/bash"), 0755)
|
||||
require.NoError(t, err)
|
||||
|
||||
opts := TestOptions{Dir: dir, Parallel: true}
|
||||
cmd, _ := buildPHPUnitCommand(opts)
|
||||
assert.Equal(t, paratestPath, cmd)
|
||||
})
|
||||
|
||||
t.Run("parallel without paratest stays phpunit", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
opts := TestOptions{Dir: dir, Parallel: true}
|
||||
|
||||
cmd, _ := buildPHPUnitCommand(opts)
|
||||
assert.Equal(t, "phpunit", cmd)
|
||||
})
|
||||
|
||||
t.Run("with coverage", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
opts := TestOptions{Dir: dir, Coverage: true}
|
||||
|
||||
_, args := buildPHPUnitCommand(opts)
|
||||
assert.Contains(t, args, "--coverage-text")
|
||||
})
|
||||
|
||||
t.Run("with coverage HTML format", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
opts := TestOptions{Dir: dir, Coverage: true, CoverageFormat: "html"}
|
||||
|
||||
_, args := buildPHPUnitCommand(opts)
|
||||
assert.Contains(t, args, "--coverage-html")
|
||||
assert.Contains(t, args, "coverage")
|
||||
})
|
||||
|
||||
t.Run("with coverage clover format", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
opts := TestOptions{Dir: dir, Coverage: true, CoverageFormat: "clover"}
|
||||
|
||||
_, args := buildPHPUnitCommand(opts)
|
||||
assert.Contains(t, args, "--coverage-clover")
|
||||
assert.Contains(t, args, "coverage.xml")
|
||||
})
|
||||
|
||||
t.Run("with groups", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
opts := TestOptions{Dir: dir, Groups: []string{"unit", "integration"}}
|
||||
|
||||
_, args := buildPHPUnitCommand(opts)
|
||||
assert.Contains(t, args, "--group")
|
||||
assert.Contains(t, args, "unit")
|
||||
assert.Contains(t, args, "integration")
|
||||
})
|
||||
|
||||
t.Run("uses vendor binary when exists", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
binDir := filepath.Join(dir, "vendor", "bin")
|
||||
err := os.MkdirAll(binDir, 0755)
|
||||
require.NoError(t, err)
|
||||
|
||||
phpunitPath := filepath.Join(binDir, "phpunit")
|
||||
err = os.WriteFile(phpunitPath, []byte("#!/bin/bash"), 0755)
|
||||
require.NoError(t, err)
|
||||
|
||||
opts := TestOptions{Dir: dir}
|
||||
cmd, _ := buildPHPUnitCommand(opts)
|
||||
assert.Equal(t, phpunitPath, cmd)
|
||||
})
|
||||
}
|
||||
|
||||
func TestTestOptions_Struct(t *testing.T) {
|
||||
t.Run("all fields accessible", func(t *testing.T) {
|
||||
opts := TestOptions{
|
||||
Dir: "/test",
|
||||
Filter: "TestName",
|
||||
Parallel: true,
|
||||
Coverage: true,
|
||||
CoverageFormat: "html",
|
||||
Groups: []string{"unit"},
|
||||
Output: os.Stdout,
|
||||
}
|
||||
|
||||
assert.Equal(t, "/test", opts.Dir)
|
||||
assert.Equal(t, "TestName", opts.Filter)
|
||||
assert.True(t, opts.Parallel)
|
||||
assert.True(t, opts.Coverage)
|
||||
assert.Equal(t, "html", opts.CoverageFormat)
|
||||
assert.Equal(t, []string{"unit"}, opts.Groups)
|
||||
assert.NotNil(t, opts.Output)
|
||||
})
|
||||
}
|
||||
|
||||
func TestTestRunner_Constants(t *testing.T) {
|
||||
t.Run("constants are defined", func(t *testing.T) {
|
||||
assert.Equal(t, TestRunner("pest"), TestRunnerPest)
|
||||
assert.Equal(t, TestRunner("phpunit"), TestRunnerPHPUnit)
|
||||
})
|
||||
}
|
||||
|
||||
func TestRunTests_Bad(t *testing.T) {
|
||||
t.Skip("requires PHP test runner installed")
|
||||
}
|
||||
|
||||
func TestRunParallel_Bad(t *testing.T) {
|
||||
t.Skip("requires PHP test runner installed")
|
||||
}
|
||||
|
||||
func TestRunTests_Integration(t *testing.T) {
|
||||
t.Skip("requires PHP/Pest/PHPUnit installed")
|
||||
}
|
||||
|
||||
func TestBuildPestCommand_CoverageOptions(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
coverageFormat string
|
||||
expectedArg string
|
||||
}{
|
||||
{"default coverage", "", "--coverage"},
|
||||
{"html coverage", "html", "--coverage-html"},
|
||||
{"clover coverage", "clover", "--coverage-clover"},
|
||||
{"unknown format uses default", "unknown", "--coverage"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
opts := TestOptions{
|
||||
Dir: dir,
|
||||
Coverage: true,
|
||||
CoverageFormat: tt.coverageFormat,
|
||||
}
|
||||
|
||||
_, args := buildPestCommand(opts)
|
||||
|
||||
// For unknown format, should fall through to default
|
||||
if tt.coverageFormat == "unknown" {
|
||||
assert.Contains(t, args, "--coverage")
|
||||
} else {
|
||||
assert.Contains(t, args, tt.expectedArg)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPHPUnitCommand_CoverageOptions(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
coverageFormat string
|
||||
expectedArg string
|
||||
}{
|
||||
{"default coverage", "", "--coverage-text"},
|
||||
{"html coverage", "html", "--coverage-html"},
|
||||
{"clover coverage", "clover", "--coverage-clover"},
|
||||
{"unknown format uses default", "unknown", "--coverage-text"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
opts := TestOptions{
|
||||
Dir: dir,
|
||||
Coverage: true,
|
||||
CoverageFormat: tt.coverageFormat,
|
||||
}
|
||||
|
||||
_, args := buildPHPUnitCommand(opts)
|
||||
|
||||
if tt.coverageFormat == "unknown" {
|
||||
assert.Contains(t, args, "--coverage-text")
|
||||
} else {
|
||||
assert.Contains(t, args, tt.expectedArg)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPestCommand_MultipleGroups(t *testing.T) {
|
||||
t.Run("adds multiple group flags", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
opts := TestOptions{
|
||||
Dir: dir,
|
||||
Groups: []string{"unit", "integration", "feature"},
|
||||
}
|
||||
|
||||
_, args := buildPestCommand(opts)
|
||||
|
||||
// Should have --group for each group
|
||||
groupCount := 0
|
||||
for _, arg := range args {
|
||||
if arg == "--group" {
|
||||
groupCount++
|
||||
}
|
||||
}
|
||||
assert.Equal(t, 3, groupCount)
|
||||
})
|
||||
}
|
||||
|
||||
func TestBuildPHPUnitCommand_MultipleGroups(t *testing.T) {
|
||||
t.Run("adds multiple group flags", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
opts := TestOptions{
|
||||
Dir: dir,
|
||||
Groups: []string{"unit", "integration"},
|
||||
}
|
||||
|
||||
_, args := buildPHPUnitCommand(opts)
|
||||
|
||||
groupCount := 0
|
||||
for _, arg := range args {
|
||||
if arg == "--group" {
|
||||
groupCount++
|
||||
}
|
||||
}
|
||||
assert.Equal(t, 2, groupCount)
|
||||
})
|
||||
}
|
||||
76
workspace.go
Normal file
76
workspace.go
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
package php
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/io"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// workspaceConfig holds workspace-level configuration from .core/workspace.yaml.
|
||||
type workspaceConfig struct {
|
||||
Version int `yaml:"version"`
|
||||
Active string `yaml:"active"` // Active package name
|
||||
DefaultOnly []string `yaml:"default_only"` // Default types for setup
|
||||
PackagesDir string `yaml:"packages_dir"` // Where packages are cloned
|
||||
}
|
||||
|
||||
// defaultWorkspaceConfig returns a config with default values.
|
||||
func defaultWorkspaceConfig() *workspaceConfig {
|
||||
return &workspaceConfig{
|
||||
Version: 1,
|
||||
PackagesDir: "./packages",
|
||||
}
|
||||
}
|
||||
|
||||
// loadWorkspaceConfig tries to load workspace.yaml from the given directory's .core subfolder.
|
||||
// Returns nil if no config file exists.
|
||||
func loadWorkspaceConfig(dir string) (*workspaceConfig, error) {
|
||||
path := filepath.Join(dir, ".core", "workspace.yaml")
|
||||
data, err := io.Local.Read(path)
|
||||
if err != nil {
|
||||
if !io.Local.IsFile(path) {
|
||||
parent := filepath.Dir(dir)
|
||||
if parent != dir {
|
||||
return loadWorkspaceConfig(parent)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to read workspace config: %w", err)
|
||||
}
|
||||
|
||||
config := defaultWorkspaceConfig()
|
||||
if err := yaml.Unmarshal([]byte(data), config); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse workspace config: %w", err)
|
||||
}
|
||||
|
||||
if config.Version != 1 {
|
||||
return nil, fmt.Errorf("unsupported workspace config version: %d", config.Version)
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// findWorkspaceRoot searches for the root directory containing .core/workspace.yaml.
|
||||
func findWorkspaceRoot() (string, error) {
|
||||
dir, err := os.Getwd()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
for {
|
||||
if io.Local.IsFile(filepath.Join(dir, ".core", "workspace.yaml")) {
|
||||
return dir, nil
|
||||
}
|
||||
|
||||
parent := filepath.Dir(dir)
|
||||
if parent == dir {
|
||||
break
|
||||
}
|
||||
dir = parent
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("not in a workspace")
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue