feat(php): add quality commands and split cmd/php for maintainability
Add new PHP quality commands: - psalm: Psalm static analysis with auto-fix support - audit: Security audit for composer and npm dependencies - security: Filesystem security checks (.env exposure, permissions) - qa: Full QA pipeline with quick/standard/full stages - rector: Automated code refactoring with dry-run - infection: Mutation testing Split cmd/php/php.go (2k+ lines) into logical files: - php.go: Styles and command registration - php_dev.go: dev, logs, stop, status, ssl - php_build.go: build, serve, shell - php_quality.go: test, fmt, analyse, psalm, audit, security, qa, rector, infection - php_packages.go: packages link/unlink/update/list - php_deploy.go: deploy commands QA pipeline improvements: - Suppress tool output noise in pipeline mode - Show actionable "To fix:" suggestions with commands Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
e687dc189c
commit
e4d79ce952
16 changed files with 4250 additions and 1483 deletions
895
cmd/build/build.go
Normal file
895
cmd/build/build.go
Normal file
|
|
@ -0,0 +1,895 @@
|
|||
// Package build provides project build commands with auto-detection.
|
||||
package build
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
buildpkg "github.com/host-uk/core/pkg/build"
|
||||
"github.com/host-uk/core/pkg/build/builders"
|
||||
"github.com/host-uk/core/pkg/build/signing"
|
||||
"github.com/host-uk/core/pkg/sdk"
|
||||
"github.com/leaanthony/clir"
|
||||
"github.com/leaanthony/debme"
|
||||
"github.com/leaanthony/gosod"
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
// Build command styles
|
||||
var (
|
||||
buildHeaderStyle = lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(lipgloss.Color("#3b82f6")) // blue-500
|
||||
|
||||
buildTargetStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#e2e8f0")) // gray-200
|
||||
|
||||
buildSuccessStyle = lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(lipgloss.Color("#22c55e")) // green-500
|
||||
|
||||
buildErrorStyle = lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(lipgloss.Color("#ef4444")) // red-500
|
||||
|
||||
buildDimStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#6b7280")) // gray-500
|
||||
)
|
||||
|
||||
//go:embed all:tmpl/gui
|
||||
var guiTemplate embed.FS
|
||||
|
||||
// AddBuildCommand adds the new build command and its subcommands to the clir app.
|
||||
func AddBuildCommand(app *clir.Cli) {
|
||||
buildCmd := app.NewSubCommand("build", "Build projects with auto-detection and cross-compilation")
|
||||
buildCmd.LongDescription("Builds the current project with automatic type detection.\n" +
|
||||
"Supports Go, Wails, Docker, LinuxKit, and Taskfile projects.\n" +
|
||||
"Configuration can be provided via .core/build.yaml or command-line flags.\n\n" +
|
||||
"Examples:\n" +
|
||||
" core build # Auto-detect and build\n" +
|
||||
" core build --type docker # Build Docker image\n" +
|
||||
" core build --type linuxkit # Build LinuxKit image\n" +
|
||||
" core build --type linuxkit --config linuxkit.yml --format qcow2-bios")
|
||||
|
||||
// Flags for the main build command
|
||||
var buildType string
|
||||
var ciMode bool
|
||||
var targets string
|
||||
var outputDir string
|
||||
var doArchive bool
|
||||
var doChecksum bool
|
||||
|
||||
// Docker/LinuxKit specific flags
|
||||
var configPath string
|
||||
var format string
|
||||
var push bool
|
||||
var imageName string
|
||||
|
||||
// Signing flags
|
||||
var noSign bool
|
||||
var notarize bool
|
||||
|
||||
buildCmd.StringFlag("type", "Builder type (go, wails, docker, linuxkit, taskfile) - auto-detected if not specified", &buildType)
|
||||
buildCmd.BoolFlag("ci", "CI mode - minimal output with JSON artifact list at the end", &ciMode)
|
||||
buildCmd.StringFlag("targets", "Comma-separated OS/arch pairs (e.g., linux/amd64,darwin/arm64)", &targets)
|
||||
buildCmd.StringFlag("output", "Output directory for artifacts (default: dist)", &outputDir)
|
||||
buildCmd.BoolFlag("archive", "Create archives (tar.gz for linux/darwin, zip for windows) - default: true", &doArchive)
|
||||
buildCmd.BoolFlag("checksum", "Generate SHA256 checksums and CHECKSUMS.txt - default: true", &doChecksum)
|
||||
|
||||
// Docker/LinuxKit specific
|
||||
buildCmd.StringFlag("config", "Config file path (for linuxkit: YAML config, for docker: Dockerfile)", &configPath)
|
||||
buildCmd.StringFlag("format", "Output format for linuxkit (iso-bios, qcow2-bios, raw, vmdk)", &format)
|
||||
buildCmd.BoolFlag("push", "Push Docker image after build (default: false)", &push)
|
||||
buildCmd.StringFlag("image", "Docker image name (e.g., host-uk/core-devops)", &imageName)
|
||||
|
||||
// Signing flags
|
||||
buildCmd.BoolFlag("no-sign", "Skip all code signing", &noSign)
|
||||
buildCmd.BoolFlag("notarize", "Enable macOS notarization (requires Apple credentials)", ¬arize)
|
||||
|
||||
// Set defaults for archive and checksum (true by default)
|
||||
doArchive = true
|
||||
doChecksum = true
|
||||
|
||||
// Default action for `core build` (no subcommand)
|
||||
buildCmd.Action(func() error {
|
||||
return runProjectBuild(buildType, ciMode, targets, outputDir, doArchive, doChecksum, configPath, format, push, imageName, noSign, notarize)
|
||||
})
|
||||
|
||||
// --- `build from-path` command (legacy PWA/GUI build) ---
|
||||
fromPathCmd := buildCmd.NewSubCommand("from-path", "Build from a local directory.")
|
||||
var fromPath string
|
||||
fromPathCmd.StringFlag("path", "The path to the static web application files.", &fromPath)
|
||||
fromPathCmd.Action(func() error {
|
||||
if fromPath == "" {
|
||||
return fmt.Errorf("the --path flag is required")
|
||||
}
|
||||
return runBuild(fromPath)
|
||||
})
|
||||
|
||||
// --- `build pwa` command (legacy PWA build) ---
|
||||
pwaCmd := buildCmd.NewSubCommand("pwa", "Build from a live PWA URL.")
|
||||
var pwaURL string
|
||||
pwaCmd.StringFlag("url", "The URL of the PWA to build.", &pwaURL)
|
||||
pwaCmd.Action(func() error {
|
||||
if pwaURL == "" {
|
||||
return fmt.Errorf("a URL argument is required")
|
||||
}
|
||||
return runPwaBuild(pwaURL)
|
||||
})
|
||||
|
||||
// --- `build sdk` command ---
|
||||
sdkBuildCmd := buildCmd.NewSubCommand("sdk", "Generate API SDKs from OpenAPI spec")
|
||||
sdkBuildCmd.LongDescription("Generates typed API clients from OpenAPI specifications.\n" +
|
||||
"Supports TypeScript, Python, Go, and PHP.\n\n" +
|
||||
"Examples:\n" +
|
||||
" core build sdk # Generate all configured SDKs\n" +
|
||||
" core build sdk --lang typescript # Generate only TypeScript SDK\n" +
|
||||
" core build sdk --spec api.yaml # Use specific OpenAPI spec")
|
||||
|
||||
var sdkSpec, sdkLang, sdkVersion string
|
||||
var sdkDryRun bool
|
||||
sdkBuildCmd.StringFlag("spec", "Path to OpenAPI spec file", &sdkSpec)
|
||||
sdkBuildCmd.StringFlag("lang", "Generate only this language (typescript, python, go, php)", &sdkLang)
|
||||
sdkBuildCmd.StringFlag("version", "Version to embed in generated SDKs", &sdkVersion)
|
||||
sdkBuildCmd.BoolFlag("dry-run", "Show what would be generated without writing files", &sdkDryRun)
|
||||
sdkBuildCmd.Action(func() error {
|
||||
return runBuildSDK(sdkSpec, sdkLang, sdkVersion, sdkDryRun)
|
||||
})
|
||||
}
|
||||
|
||||
// runProjectBuild handles the main `core build` command with auto-detection.
|
||||
func runProjectBuild(buildType string, ciMode bool, targetsFlag string, outputDir string, doArchive bool, doChecksum bool, configPath string, format string, push bool, imageName string, noSign bool, notarize bool) error {
|
||||
// Get current working directory as project root
|
||||
projectDir, err := os.Getwd()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get working directory: %w", err)
|
||||
}
|
||||
|
||||
// Load configuration from .core/build.yaml (or defaults)
|
||||
buildCfg, err := buildpkg.LoadConfig(projectDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load config: %w", err)
|
||||
}
|
||||
|
||||
// Detect project type if not specified
|
||||
var projectType buildpkg.ProjectType
|
||||
if buildType != "" {
|
||||
projectType = buildpkg.ProjectType(buildType)
|
||||
} else {
|
||||
projectType, err = buildpkg.PrimaryType(projectDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to detect project type: %w", err)
|
||||
}
|
||||
if projectType == "" {
|
||||
return fmt.Errorf("no supported project type detected in %s\n"+
|
||||
"Supported types: go (go.mod), wails (wails.json), node (package.json), php (composer.json)", projectDir)
|
||||
}
|
||||
}
|
||||
|
||||
// Determine targets
|
||||
var buildTargets []buildpkg.Target
|
||||
if targetsFlag != "" {
|
||||
// Parse from command line
|
||||
buildTargets, err = parseTargets(targetsFlag)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else if len(buildCfg.Targets) > 0 {
|
||||
// Use config targets
|
||||
buildTargets = buildCfg.ToTargets()
|
||||
} else {
|
||||
// Fall back to current OS/arch
|
||||
buildTargets = []buildpkg.Target{
|
||||
{OS: runtime.GOOS, Arch: runtime.GOARCH},
|
||||
}
|
||||
}
|
||||
|
||||
// Determine output directory
|
||||
if outputDir == "" {
|
||||
outputDir = "dist"
|
||||
}
|
||||
|
||||
// Determine binary name
|
||||
binaryName := buildCfg.Project.Binary
|
||||
if binaryName == "" {
|
||||
binaryName = buildCfg.Project.Name
|
||||
}
|
||||
if binaryName == "" {
|
||||
binaryName = filepath.Base(projectDir)
|
||||
}
|
||||
|
||||
// Print build info (unless CI mode)
|
||||
if !ciMode {
|
||||
fmt.Printf("%s Building project\n", buildHeaderStyle.Render("Build:"))
|
||||
fmt.Printf(" Type: %s\n", buildTargetStyle.Render(string(projectType)))
|
||||
fmt.Printf(" Output: %s\n", buildTargetStyle.Render(outputDir))
|
||||
fmt.Printf(" Binary: %s\n", buildTargetStyle.Render(binaryName))
|
||||
fmt.Printf(" Targets: %s\n", buildTargetStyle.Render(formatTargets(buildTargets)))
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
// Get the appropriate builder
|
||||
builder, err := getBuilder(projectType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create build config for the builder
|
||||
cfg := &buildpkg.Config{
|
||||
ProjectDir: projectDir,
|
||||
OutputDir: outputDir,
|
||||
Name: binaryName,
|
||||
Version: buildCfg.Project.Name, // Could be enhanced with git describe
|
||||
LDFlags: buildCfg.Build.LDFlags,
|
||||
// Docker/LinuxKit specific
|
||||
Dockerfile: configPath, // Reuse for Dockerfile path
|
||||
LinuxKitConfig: configPath,
|
||||
Push: push,
|
||||
Image: imageName,
|
||||
}
|
||||
|
||||
// Parse formats for LinuxKit
|
||||
if format != "" {
|
||||
cfg.Formats = strings.Split(format, ",")
|
||||
}
|
||||
|
||||
// Execute build
|
||||
ctx := context.Background()
|
||||
artifacts, err := builder.Build(ctx, cfg, buildTargets)
|
||||
if err != nil {
|
||||
if !ciMode {
|
||||
fmt.Printf("%s Build failed: %v\n", buildErrorStyle.Render("Error:"), err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if !ciMode {
|
||||
fmt.Printf("%s Built %d artifact(s)\n", buildSuccessStyle.Render("Success:"), len(artifacts))
|
||||
fmt.Println()
|
||||
for _, artifact := range artifacts {
|
||||
relPath, err := filepath.Rel(projectDir, artifact.Path)
|
||||
if err != nil {
|
||||
relPath = artifact.Path
|
||||
}
|
||||
fmt.Printf(" %s %s %s\n",
|
||||
buildSuccessStyle.Render("✓"),
|
||||
buildTargetStyle.Render(relPath),
|
||||
buildDimStyle.Render(fmt.Sprintf("(%s/%s)", artifact.OS, artifact.Arch)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Sign macOS binaries if enabled
|
||||
signCfg := buildCfg.Sign
|
||||
if notarize {
|
||||
signCfg.MacOS.Notarize = true
|
||||
}
|
||||
if noSign {
|
||||
signCfg.Enabled = false
|
||||
}
|
||||
|
||||
if signCfg.Enabled && runtime.GOOS == "darwin" {
|
||||
if !ciMode {
|
||||
fmt.Println()
|
||||
fmt.Printf("%s Signing binaries...\n", buildHeaderStyle.Render("Sign:"))
|
||||
}
|
||||
|
||||
// Convert buildpkg.Artifact to signing.Artifact
|
||||
signingArtifacts := make([]signing.Artifact, len(artifacts))
|
||||
for i, a := range artifacts {
|
||||
signingArtifacts[i] = signing.Artifact{Path: a.Path, OS: a.OS, Arch: a.Arch}
|
||||
}
|
||||
|
||||
if err := signing.SignBinaries(ctx, signCfg, signingArtifacts); err != nil {
|
||||
if !ciMode {
|
||||
fmt.Printf("%s Signing failed: %v\n", buildErrorStyle.Render("Error:"), err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if signCfg.MacOS.Notarize {
|
||||
if err := signing.NotarizeBinaries(ctx, signCfg, signingArtifacts); err != nil {
|
||||
if !ciMode {
|
||||
fmt.Printf("%s Notarization failed: %v\n", buildErrorStyle.Render("Error:"), err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Archive artifacts if enabled
|
||||
var archivedArtifacts []buildpkg.Artifact
|
||||
if doArchive && len(artifacts) > 0 {
|
||||
if !ciMode {
|
||||
fmt.Println()
|
||||
fmt.Printf("%s Creating archives...\n", buildHeaderStyle.Render("Archive:"))
|
||||
}
|
||||
|
||||
archivedArtifacts, err = buildpkg.ArchiveAll(artifacts)
|
||||
if err != nil {
|
||||
if !ciMode {
|
||||
fmt.Printf("%s Archive failed: %v\n", buildErrorStyle.Render("Error:"), err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if !ciMode {
|
||||
for _, artifact := range archivedArtifacts {
|
||||
relPath, err := filepath.Rel(projectDir, artifact.Path)
|
||||
if err != nil {
|
||||
relPath = artifact.Path
|
||||
}
|
||||
fmt.Printf(" %s %s %s\n",
|
||||
buildSuccessStyle.Render("✓"),
|
||||
buildTargetStyle.Render(relPath),
|
||||
buildDimStyle.Render(fmt.Sprintf("(%s/%s)", artifact.OS, artifact.Arch)),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Compute checksums if enabled
|
||||
var checksummedArtifacts []buildpkg.Artifact
|
||||
if doChecksum && len(archivedArtifacts) > 0 {
|
||||
if !ciMode {
|
||||
fmt.Println()
|
||||
fmt.Printf("%s Computing checksums...\n", buildHeaderStyle.Render("Checksum:"))
|
||||
}
|
||||
|
||||
checksummedArtifacts, err = buildpkg.ChecksumAll(archivedArtifacts)
|
||||
if err != nil {
|
||||
if !ciMode {
|
||||
fmt.Printf("%s Checksum failed: %v\n", buildErrorStyle.Render("Error:"), err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Write CHECKSUMS.txt
|
||||
checksumPath := filepath.Join(outputDir, "CHECKSUMS.txt")
|
||||
if err := buildpkg.WriteChecksumFile(checksummedArtifacts, checksumPath); err != nil {
|
||||
if !ciMode {
|
||||
fmt.Printf("%s Failed to write CHECKSUMS.txt: %v\n", buildErrorStyle.Render("Error:"), err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Sign checksums with GPG
|
||||
if signCfg.Enabled {
|
||||
if err := signing.SignChecksums(ctx, signCfg, checksumPath); err != nil {
|
||||
if !ciMode {
|
||||
fmt.Printf("%s GPG signing failed: %v\n", buildErrorStyle.Render("Error:"), err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if !ciMode {
|
||||
for _, artifact := range checksummedArtifacts {
|
||||
relPath, err := filepath.Rel(projectDir, artifact.Path)
|
||||
if err != nil {
|
||||
relPath = artifact.Path
|
||||
}
|
||||
fmt.Printf(" %s %s\n",
|
||||
buildSuccessStyle.Render("✓"),
|
||||
buildTargetStyle.Render(relPath),
|
||||
)
|
||||
fmt.Printf(" %s\n", buildDimStyle.Render(artifact.Checksum))
|
||||
}
|
||||
|
||||
relChecksumPath, err := filepath.Rel(projectDir, checksumPath)
|
||||
if err != nil {
|
||||
relChecksumPath = checksumPath
|
||||
}
|
||||
fmt.Printf(" %s %s\n",
|
||||
buildSuccessStyle.Render("✓"),
|
||||
buildTargetStyle.Render(relChecksumPath),
|
||||
)
|
||||
}
|
||||
} else if doChecksum && len(artifacts) > 0 && !doArchive {
|
||||
// Checksum raw binaries if archiving is disabled
|
||||
if !ciMode {
|
||||
fmt.Println()
|
||||
fmt.Printf("%s Computing checksums...\n", buildHeaderStyle.Render("Checksum:"))
|
||||
}
|
||||
|
||||
checksummedArtifacts, err = buildpkg.ChecksumAll(artifacts)
|
||||
if err != nil {
|
||||
if !ciMode {
|
||||
fmt.Printf("%s Checksum failed: %v\n", buildErrorStyle.Render("Error:"), err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Write CHECKSUMS.txt
|
||||
checksumPath := filepath.Join(outputDir, "CHECKSUMS.txt")
|
||||
if err := buildpkg.WriteChecksumFile(checksummedArtifacts, checksumPath); err != nil {
|
||||
if !ciMode {
|
||||
fmt.Printf("%s Failed to write CHECKSUMS.txt: %v\n", buildErrorStyle.Render("Error:"), err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Sign checksums with GPG
|
||||
if signCfg.Enabled {
|
||||
if err := signing.SignChecksums(ctx, signCfg, checksumPath); err != nil {
|
||||
if !ciMode {
|
||||
fmt.Printf("%s GPG signing failed: %v\n", buildErrorStyle.Render("Error:"), err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if !ciMode {
|
||||
for _, artifact := range checksummedArtifacts {
|
||||
relPath, err := filepath.Rel(projectDir, artifact.Path)
|
||||
if err != nil {
|
||||
relPath = artifact.Path
|
||||
}
|
||||
fmt.Printf(" %s %s\n",
|
||||
buildSuccessStyle.Render("✓"),
|
||||
buildTargetStyle.Render(relPath),
|
||||
)
|
||||
fmt.Printf(" %s\n", buildDimStyle.Render(artifact.Checksum))
|
||||
}
|
||||
|
||||
relChecksumPath, err := filepath.Rel(projectDir, checksumPath)
|
||||
if err != nil {
|
||||
relChecksumPath = checksumPath
|
||||
}
|
||||
fmt.Printf(" %s %s\n",
|
||||
buildSuccessStyle.Render("✓"),
|
||||
buildTargetStyle.Render(relChecksumPath),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Output results for CI mode
|
||||
if ciMode {
|
||||
// Determine which artifacts to output (prefer checksummed > archived > raw)
|
||||
var outputArtifacts []buildpkg.Artifact
|
||||
if len(checksummedArtifacts) > 0 {
|
||||
outputArtifacts = checksummedArtifacts
|
||||
} else if len(archivedArtifacts) > 0 {
|
||||
outputArtifacts = archivedArtifacts
|
||||
} else {
|
||||
outputArtifacts = artifacts
|
||||
}
|
||||
|
||||
// JSON output for CI
|
||||
output, err := json.MarshalIndent(outputArtifacts, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal artifacts: %w", err)
|
||||
}
|
||||
fmt.Println(string(output))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseTargets parses a comma-separated list of OS/arch pairs.
|
||||
func parseTargets(targetsFlag string) ([]buildpkg.Target, error) {
|
||||
parts := strings.Split(targetsFlag, ",")
|
||||
var targets []buildpkg.Target
|
||||
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
osArch := strings.Split(part, "/")
|
||||
if len(osArch) != 2 {
|
||||
return nil, fmt.Errorf("invalid target format %q, expected OS/arch (e.g., linux/amd64)", part)
|
||||
}
|
||||
|
||||
targets = append(targets, buildpkg.Target{
|
||||
OS: strings.TrimSpace(osArch[0]),
|
||||
Arch: strings.TrimSpace(osArch[1]),
|
||||
})
|
||||
}
|
||||
|
||||
if len(targets) == 0 {
|
||||
return nil, fmt.Errorf("no valid targets specified")
|
||||
}
|
||||
|
||||
return targets, nil
|
||||
}
|
||||
|
||||
// formatTargets returns a human-readable string of targets.
|
||||
func formatTargets(targets []buildpkg.Target) string {
|
||||
var parts []string
|
||||
for _, t := range targets {
|
||||
parts = append(parts, t.String())
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
// getBuilder returns the appropriate builder for the project type.
|
||||
func getBuilder(projectType buildpkg.ProjectType) (buildpkg.Builder, error) {
|
||||
switch projectType {
|
||||
case buildpkg.ProjectTypeWails:
|
||||
return builders.NewWailsBuilder(), nil
|
||||
case buildpkg.ProjectTypeGo:
|
||||
return builders.NewGoBuilder(), nil
|
||||
case buildpkg.ProjectTypeDocker:
|
||||
return builders.NewDockerBuilder(), nil
|
||||
case buildpkg.ProjectTypeLinuxKit:
|
||||
return builders.NewLinuxKitBuilder(), nil
|
||||
case buildpkg.ProjectTypeTaskfile:
|
||||
return builders.NewTaskfileBuilder(), nil
|
||||
case buildpkg.ProjectTypeNode:
|
||||
return nil, fmt.Errorf("Node.js builder not yet implemented")
|
||||
case buildpkg.ProjectTypePHP:
|
||||
return nil, fmt.Errorf("PHP builder not yet implemented")
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported project type: %s", projectType)
|
||||
}
|
||||
}
|
||||
|
||||
// --- SDK Build Logic ---
|
||||
|
||||
func runBuildSDK(specPath, lang, version string, dryRun bool) error {
|
||||
ctx := context.Background()
|
||||
|
||||
projectDir, err := os.Getwd()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get working directory: %w", err)
|
||||
}
|
||||
|
||||
// Load config
|
||||
config := sdk.DefaultConfig()
|
||||
if specPath != "" {
|
||||
config.Spec = specPath
|
||||
}
|
||||
|
||||
s := sdk.New(projectDir, config)
|
||||
if version != "" {
|
||||
s.SetVersion(version)
|
||||
}
|
||||
|
||||
fmt.Printf("%s Generating SDKs\n", buildHeaderStyle.Render("Build SDK:"))
|
||||
if dryRun {
|
||||
fmt.Printf(" %s\n", buildDimStyle.Render("(dry-run mode)"))
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
// Detect spec
|
||||
detectedSpec, err := s.DetectSpec()
|
||||
if err != nil {
|
||||
fmt.Printf("%s %v\n", buildErrorStyle.Render("Error:"), err)
|
||||
return err
|
||||
}
|
||||
fmt.Printf(" Spec: %s\n", buildTargetStyle.Render(detectedSpec))
|
||||
|
||||
if dryRun {
|
||||
if lang != "" {
|
||||
fmt.Printf(" Language: %s\n", buildTargetStyle.Render(lang))
|
||||
} else {
|
||||
fmt.Printf(" Languages: %s\n", buildTargetStyle.Render(strings.Join(config.Languages, ", ")))
|
||||
}
|
||||
fmt.Println()
|
||||
fmt.Printf("%s Would generate SDKs (dry-run)\n", buildSuccessStyle.Render("OK:"))
|
||||
return nil
|
||||
}
|
||||
|
||||
if lang != "" {
|
||||
// Generate single language
|
||||
if err := s.GenerateLanguage(ctx, lang); err != nil {
|
||||
fmt.Printf("%s %v\n", buildErrorStyle.Render("Error:"), err)
|
||||
return err
|
||||
}
|
||||
fmt.Printf(" Generated: %s\n", buildTargetStyle.Render(lang))
|
||||
} else {
|
||||
// Generate all
|
||||
if err := s.Generate(ctx); err != nil {
|
||||
fmt.Printf("%s %v\n", buildErrorStyle.Render("Error:"), err)
|
||||
return err
|
||||
}
|
||||
fmt.Printf(" Generated: %s\n", buildTargetStyle.Render(strings.Join(config.Languages, ", ")))
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Printf("%s SDK generation complete\n", buildSuccessStyle.Render("Success:"))
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- PWA Build Logic ---
|
||||
|
||||
func runPwaBuild(pwaURL string) error {
|
||||
fmt.Printf("Starting PWA build from URL: %s\n", pwaURL)
|
||||
|
||||
tempDir, err := os.MkdirTemp("", "core-pwa-build-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create temporary directory: %w", err)
|
||||
}
|
||||
// defer os.RemoveAll(tempDir) // Keep temp dir for debugging
|
||||
fmt.Printf("Downloading PWA to temporary directory: %s\n", tempDir)
|
||||
|
||||
if err := downloadPWA(pwaURL, tempDir); err != nil {
|
||||
return fmt.Errorf("failed to download PWA: %w", err)
|
||||
}
|
||||
|
||||
return runBuild(tempDir)
|
||||
}
|
||||
|
||||
func downloadPWA(baseURL, destDir string) error {
|
||||
// Fetch the main HTML page
|
||||
resp, err := http.Get(baseURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch URL %s: %w", baseURL, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read response body: %w", err)
|
||||
}
|
||||
|
||||
// Find the manifest URL from the HTML
|
||||
manifestURL, err := findManifestURL(string(body), baseURL)
|
||||
if err != nil {
|
||||
// If no manifest, it's not a PWA, but we can still try to package it as a simple site.
|
||||
fmt.Println("Warning: no manifest file found. Proceeding with basic site download.")
|
||||
if err := os.WriteFile(filepath.Join(destDir, "index.html"), body, 0644); err != nil {
|
||||
return fmt.Errorf("failed to write index.html: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("Found manifest: %s\n", manifestURL)
|
||||
|
||||
// Fetch and parse the manifest
|
||||
manifest, err := fetchManifest(manifestURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch or parse manifest: %w", err)
|
||||
}
|
||||
|
||||
// Download all assets listed in the manifest
|
||||
assets := collectAssets(manifest, manifestURL)
|
||||
for _, assetURL := range assets {
|
||||
if err := downloadAsset(assetURL, destDir); err != nil {
|
||||
fmt.Printf("Warning: failed to download asset %s: %v\n", assetURL, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Also save the root index.html
|
||||
if err := os.WriteFile(filepath.Join(destDir, "index.html"), body, 0644); err != nil {
|
||||
return fmt.Errorf("failed to write index.html: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("PWA download complete.")
|
||||
return nil
|
||||
}
|
||||
|
||||
func findManifestURL(htmlContent, baseURL string) (string, error) {
|
||||
doc, err := html.Parse(strings.NewReader(htmlContent))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var manifestPath string
|
||||
var f func(*html.Node)
|
||||
f = func(n *html.Node) {
|
||||
if n.Type == html.ElementNode && n.Data == "link" {
|
||||
var rel, href string
|
||||
for _, a := range n.Attr {
|
||||
if a.Key == "rel" {
|
||||
rel = a.Val
|
||||
}
|
||||
if a.Key == "href" {
|
||||
href = a.Val
|
||||
}
|
||||
}
|
||||
if rel == "manifest" && href != "" {
|
||||
manifestPath = href
|
||||
return
|
||||
}
|
||||
}
|
||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||
f(c)
|
||||
}
|
||||
}
|
||||
f(doc)
|
||||
|
||||
if manifestPath == "" {
|
||||
return "", fmt.Errorf("no <link rel=\"manifest\"> tag found")
|
||||
}
|
||||
|
||||
base, err := url.Parse(baseURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
manifestURL, err := base.Parse(manifestPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return manifestURL.String(), nil
|
||||
}
|
||||
|
||||
func fetchManifest(manifestURL string) (map[string]interface{}, error) {
|
||||
resp, err := http.Get(manifestURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var manifest map[string]interface{}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&manifest); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return manifest, nil
|
||||
}
|
||||
|
||||
func collectAssets(manifest map[string]interface{}, manifestURL string) []string {
|
||||
var assets []string
|
||||
base, _ := url.Parse(manifestURL)
|
||||
|
||||
// Add start_url
|
||||
if startURL, ok := manifest["start_url"].(string); ok {
|
||||
if resolved, err := base.Parse(startURL); err == nil {
|
||||
assets = append(assets, resolved.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Add icons
|
||||
if icons, ok := manifest["icons"].([]interface{}); ok {
|
||||
for _, icon := range icons {
|
||||
if iconMap, ok := icon.(map[string]interface{}); ok {
|
||||
if src, ok := iconMap["src"].(string); ok {
|
||||
if resolved, err := base.Parse(src); err == nil {
|
||||
assets = append(assets, resolved.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return assets
|
||||
}
|
||||
|
||||
func downloadAsset(assetURL, destDir string) error {
|
||||
resp, err := http.Get(assetURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
u, err := url.Parse(assetURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
path := filepath.Join(destDir, filepath.FromSlash(u.Path))
|
||||
if err := os.MkdirAll(filepath.Dir(path), os.ModePerm); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
out, err := os.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
_, err = io.Copy(out, resp.Body)
|
||||
return err
|
||||
}
|
||||
|
||||
// --- Standard Build Logic ---
|
||||
|
||||
func runBuild(fromPath string) error {
|
||||
fmt.Printf("Starting build from path: %s\n", fromPath)
|
||||
|
||||
info, err := os.Stat(fromPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid path specified: %w", err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return fmt.Errorf("path specified must be a directory")
|
||||
}
|
||||
|
||||
buildDir := ".core/build/app"
|
||||
htmlDir := filepath.Join(buildDir, "html")
|
||||
appName := filepath.Base(fromPath)
|
||||
if strings.HasPrefix(appName, "core-pwa-build-") {
|
||||
appName = "pwa-app"
|
||||
}
|
||||
outputExe := appName
|
||||
|
||||
if err := os.RemoveAll(buildDir); err != nil {
|
||||
return fmt.Errorf("failed to clean build directory: %w", err)
|
||||
}
|
||||
|
||||
// 1. Generate the project from the embedded template
|
||||
fmt.Println("Generating application from template...")
|
||||
templateFS, err := debme.FS(guiTemplate, "tmpl/gui")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to anchor template filesystem: %w", err)
|
||||
}
|
||||
sod := gosod.New(templateFS)
|
||||
if sod == nil {
|
||||
return fmt.Errorf("failed to create new sod instance")
|
||||
}
|
||||
|
||||
templateData := map[string]string{"AppName": appName}
|
||||
if err := sod.Extract(buildDir, templateData); err != nil {
|
||||
return fmt.Errorf("failed to extract template: %w", err)
|
||||
}
|
||||
|
||||
// 2. Copy the user's web app files
|
||||
fmt.Println("Copying application files...")
|
||||
if err := copyDir(fromPath, htmlDir); err != nil {
|
||||
return fmt.Errorf("failed to copy application files: %w", err)
|
||||
}
|
||||
|
||||
// 3. Compile the application
|
||||
fmt.Println("Compiling application...")
|
||||
|
||||
// Run go mod tidy
|
||||
cmd := exec.Command("go", "mod", "tidy")
|
||||
cmd.Dir = buildDir
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("go mod tidy failed: %w", err)
|
||||
}
|
||||
|
||||
// Run go build
|
||||
cmd = exec.Command("go", "build", "-o", outputExe)
|
||||
cmd.Dir = buildDir
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("go build failed: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("\nBuild successful! Executable created at: %s/%s\n", buildDir, outputExe)
|
||||
return nil
|
||||
}
|
||||
|
||||
// copyDir recursively copies a directory from src to dst.
|
||||
func copyDir(src, dst string) error {
|
||||
return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
relPath, err := filepath.Rel(src, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dstPath := filepath.Join(dst, relPath)
|
||||
|
||||
if info.IsDir() {
|
||||
return os.MkdirAll(dstPath, info.Mode())
|
||||
}
|
||||
|
||||
srcFile, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer srcFile.Close()
|
||||
|
||||
dstFile, err := os.Create(dstPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer dstFile.Close()
|
||||
|
||||
_, err = io.Copy(dstFile, srcFile)
|
||||
return err
|
||||
})
|
||||
}
|
||||
18
cmd/build/commands.go
Normal file
18
cmd/build/commands.go
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
// Package build provides project build commands with auto-detection.
|
||||
//
|
||||
// Supports building:
|
||||
// - Go projects (standard and cross-compilation)
|
||||
// - Wails desktop applications
|
||||
// - Docker images
|
||||
// - LinuxKit VM images
|
||||
// - Taskfile-based projects
|
||||
//
|
||||
// Configuration via .core/build.yaml or command-line flags.
|
||||
package build
|
||||
|
||||
import "github.com/leaanthony/clir"
|
||||
|
||||
// AddCommands registers the 'build' command and all subcommands.
|
||||
func AddCommands(app *clir.Cli) {
|
||||
AddBuildCommand(app)
|
||||
}
|
||||
7
cmd/build/tmpl/gui/go.mod.tmpl
Normal file
7
cmd/build/tmpl/gui/go.mod.tmpl
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
module {{.AppName}}
|
||||
|
||||
go 1.21
|
||||
|
||||
require (
|
||||
github.com/wailsapp/wails/v3 v3.0.0-alpha.8
|
||||
)
|
||||
0
cmd/build/tmpl/gui/html/.gitkeep
Normal file
0
cmd/build/tmpl/gui/html/.gitkeep
Normal file
1
cmd/build/tmpl/gui/html/.placeholder
Normal file
1
cmd/build/tmpl/gui/html/.placeholder
Normal file
|
|
@ -0,0 +1 @@
|
|||
// This file ensures the 'html' directory is correctly embedded by the Go compiler.
|
||||
25
cmd/build/tmpl/gui/main.go.tmpl
Normal file
25
cmd/build/tmpl/gui/main.go.tmpl
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"log"
|
||||
|
||||
"github.com/wailsapp/wails/v3/pkg/application"
|
||||
)
|
||||
|
||||
//go:embed all:html
|
||||
var assets embed.FS
|
||||
|
||||
func main() {
|
||||
app := application.New(application.Options{
|
||||
Name: "{{.AppName}}",
|
||||
Description: "A web application enclaved by Core.",
|
||||
Assets: application.AssetOptions{
|
||||
FS: assets,
|
||||
},
|
||||
})
|
||||
|
||||
if err := app.Run(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
|
@ -16,6 +16,12 @@
|
|||
// - test: Run PHPUnit/Pest tests
|
||||
// - fmt: Format code with Laravel Pint
|
||||
// - analyse: 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
|
||||
|
|
|
|||
1534
cmd/php/php.go
1534
cmd/php/php.go
File diff suppressed because it is too large
Load diff
296
cmd/php/php_build.go
Normal file
296
cmd/php/php_build.go
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
package php
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
phppkg "github.com/host-uk/core/pkg/php"
|
||||
"github.com/leaanthony/clir"
|
||||
)
|
||||
|
||||
func addPHPBuildCommand(parent *clir.Command) {
|
||||
var (
|
||||
buildType string
|
||||
imageName string
|
||||
tag string
|
||||
platform string
|
||||
dockerfile string
|
||||
outputPath string
|
||||
format string
|
||||
template string
|
||||
noCache bool
|
||||
)
|
||||
|
||||
buildCmd := parent.NewSubCommand("build", "Build Docker or LinuxKit image")
|
||||
buildCmd.LongDescription("Build a production-ready container image for the PHP project.\n\n" +
|
||||
"By default, builds a Docker image using FrankenPHP.\n" +
|
||||
"Use --type linuxkit to build a LinuxKit VM image instead.\n\n" +
|
||||
"Examples:\n" +
|
||||
" core php build # Build Docker image\n" +
|
||||
" core php build --name myapp --tag v1.0 # Build with custom name/tag\n" +
|
||||
" core php build --type linuxkit # Build LinuxKit image\n" +
|
||||
" core php build --type linuxkit --format iso # Build ISO image")
|
||||
|
||||
buildCmd.StringFlag("type", "Build type: docker (default) or linuxkit", &buildType)
|
||||
buildCmd.StringFlag("name", "Image name (default: project directory name)", &imageName)
|
||||
buildCmd.StringFlag("tag", "Image tag (default: latest)", &tag)
|
||||
buildCmd.StringFlag("platform", "Target platform (e.g., linux/amd64, linux/arm64)", &platform)
|
||||
buildCmd.StringFlag("dockerfile", "Path to custom Dockerfile", &dockerfile)
|
||||
buildCmd.StringFlag("output", "Output path for LinuxKit image", &outputPath)
|
||||
buildCmd.StringFlag("format", "LinuxKit output format: qcow2 (default), iso, raw, vmdk", &format)
|
||||
buildCmd.StringFlag("template", "LinuxKit template name (default: server-php)", &template)
|
||||
buildCmd.BoolFlag("no-cache", "Build without cache", &noCache)
|
||||
|
||||
buildCmd.Action(func() error {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get working directory: %w", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
switch strings.ToLower(buildType) {
|
||||
case "linuxkit":
|
||||
return runPHPBuildLinuxKit(ctx, cwd, linuxKitBuildOptions{
|
||||
OutputPath: outputPath,
|
||||
Format: format,
|
||||
Template: template,
|
||||
})
|
||||
default:
|
||||
return runPHPBuildDocker(ctx, cwd, dockerBuildOptions{
|
||||
ImageName: imageName,
|
||||
Tag: tag,
|
||||
Platform: platform,
|
||||
Dockerfile: dockerfile,
|
||||
NoCache: noCache,
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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 !phppkg.IsPHPProject(projectDir) {
|
||||
return fmt.Errorf("not a PHP project (missing composer.json)")
|
||||
}
|
||||
|
||||
fmt.Printf("%s Building Docker image...\n\n", dimStyle.Render("PHP:"))
|
||||
|
||||
// Show detected configuration
|
||||
config, err := phppkg.DetectDockerfileConfig(projectDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to detect project configuration: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("%s %s\n", dimStyle.Render("PHP Version:"), config.PHPVersion)
|
||||
fmt.Printf("%s %v\n", dimStyle.Render("Laravel:"), config.IsLaravel)
|
||||
fmt.Printf("%s %v\n", dimStyle.Render("Octane:"), config.HasOctane)
|
||||
fmt.Printf("%s %v\n", dimStyle.Render("Frontend:"), config.HasAssets)
|
||||
if len(config.PHPExtensions) > 0 {
|
||||
fmt.Printf("%s %s\n", dimStyle.Render("Extensions:"), strings.Join(config.PHPExtensions, ", "))
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
// Build options
|
||||
buildOpts := phppkg.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 = phppkg.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"
|
||||
}
|
||||
|
||||
fmt.Printf("%s %s:%s\n", dimStyle.Render("Image:"), buildOpts.ImageName, buildOpts.Tag)
|
||||
if opts.Platform != "" {
|
||||
fmt.Printf("%s %s\n", dimStyle.Render("Platform:"), opts.Platform)
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
if err := phppkg.BuildDocker(ctx, buildOpts); err != nil {
|
||||
return fmt.Errorf("build failed: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("\n%s Docker image built successfully\n", successStyle.Render("Done:"))
|
||||
fmt.Printf("%s docker run -p 80:80 -p 443:443 %s:%s\n",
|
||||
dimStyle.Render("Run with:"),
|
||||
buildOpts.ImageName, buildOpts.Tag)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func runPHPBuildLinuxKit(ctx context.Context, projectDir string, opts linuxKitBuildOptions) error {
|
||||
if !phppkg.IsPHPProject(projectDir) {
|
||||
return fmt.Errorf("not a PHP project (missing composer.json)")
|
||||
}
|
||||
|
||||
fmt.Printf("%s Building LinuxKit image...\n\n", dimStyle.Render("PHP:"))
|
||||
|
||||
buildOpts := phppkg.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"
|
||||
}
|
||||
|
||||
fmt.Printf("%s %s\n", dimStyle.Render("Template:"), buildOpts.Template)
|
||||
fmt.Printf("%s %s\n", dimStyle.Render("Format:"), buildOpts.Format)
|
||||
fmt.Println()
|
||||
|
||||
if err := phppkg.BuildLinuxKit(ctx, buildOpts); err != nil {
|
||||
return fmt.Errorf("build failed: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("\n%s LinuxKit image built successfully\n", successStyle.Render("Done:"))
|
||||
return nil
|
||||
}
|
||||
|
||||
func addPHPServeCommand(parent *clir.Command) {
|
||||
var (
|
||||
imageName string
|
||||
tag string
|
||||
containerName string
|
||||
port int
|
||||
httpsPort int
|
||||
detach bool
|
||||
envFile string
|
||||
)
|
||||
|
||||
serveCmd := parent.NewSubCommand("serve", "Run production container")
|
||||
serveCmd.LongDescription("Run a production PHP container.\n\n" +
|
||||
"This starts the built Docker image in production mode.\n\n" +
|
||||
"Examples:\n" +
|
||||
" core php serve --name myapp # Run container\n" +
|
||||
" core php serve --name myapp -d # Run detached\n" +
|
||||
" core php serve --name myapp --port 8080 # Custom port")
|
||||
|
||||
serveCmd.StringFlag("name", "Docker image name (required)", &imageName)
|
||||
serveCmd.StringFlag("tag", "Image tag (default: latest)", &tag)
|
||||
serveCmd.StringFlag("container", "Container name", &containerName)
|
||||
serveCmd.IntFlag("port", "HTTP port (default: 80)", &port)
|
||||
serveCmd.IntFlag("https-port", "HTTPS port (default: 443)", &httpsPort)
|
||||
serveCmd.BoolFlag("d", "Run in detached mode", &detach)
|
||||
serveCmd.StringFlag("env-file", "Path to environment file", &envFile)
|
||||
|
||||
serveCmd.Action(func() error {
|
||||
if imageName == "" {
|
||||
// Try to detect from current directory
|
||||
cwd, err := os.Getwd()
|
||||
if err == nil {
|
||||
imageName = phppkg.GetLaravelAppName(cwd)
|
||||
if imageName != "" {
|
||||
imageName = strings.ToLower(strings.ReplaceAll(imageName, " ", "-"))
|
||||
}
|
||||
}
|
||||
if imageName == "" {
|
||||
return fmt.Errorf("--name is required: specify the Docker image name")
|
||||
}
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
opts := phppkg.ServeOptions{
|
||||
ImageName: imageName,
|
||||
Tag: tag,
|
||||
ContainerName: containerName,
|
||||
Port: port,
|
||||
HTTPSPort: httpsPort,
|
||||
Detach: detach,
|
||||
EnvFile: envFile,
|
||||
Output: os.Stdout,
|
||||
}
|
||||
|
||||
fmt.Printf("%s Running production container...\n\n", dimStyle.Render("PHP:"))
|
||||
fmt.Printf("%s %s:%s\n", dimStyle.Render("Image:"), imageName, func() string {
|
||||
if tag == "" {
|
||||
return "latest"
|
||||
}
|
||||
return tag
|
||||
}())
|
||||
|
||||
effectivePort := port
|
||||
if effectivePort == 0 {
|
||||
effectivePort = 80
|
||||
}
|
||||
effectiveHTTPSPort := httpsPort
|
||||
if effectiveHTTPSPort == 0 {
|
||||
effectiveHTTPSPort = 443
|
||||
}
|
||||
|
||||
fmt.Printf("%s http://localhost:%d, https://localhost:%d\n",
|
||||
dimStyle.Render("Ports:"), effectivePort, effectiveHTTPSPort)
|
||||
fmt.Println()
|
||||
|
||||
if err := phppkg.ServeProduction(ctx, opts); err != nil {
|
||||
return fmt.Errorf("failed to start container: %w", err)
|
||||
}
|
||||
|
||||
if !detach {
|
||||
fmt.Printf("\n%s Container stopped\n", dimStyle.Render("PHP:"))
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func addPHPShellCommand(parent *clir.Command) {
|
||||
shellCmd := parent.NewSubCommand("shell", "Open shell in running container")
|
||||
shellCmd.LongDescription("Open an interactive shell in a running PHP container.\n\n" +
|
||||
"Examples:\n" +
|
||||
" core php shell abc123 # Shell into container by ID\n" +
|
||||
" core php shell myapp # Shell into container by name")
|
||||
|
||||
shellCmd.Action(func() error {
|
||||
args := shellCmd.OtherArgs()
|
||||
if len(args) == 0 {
|
||||
return fmt.Errorf("container ID or name is required")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
fmt.Printf("%s Opening shell in container %s...\n", dimStyle.Render("PHP:"), args[0])
|
||||
|
||||
if err := phppkg.Shell(ctx, args[0]); err != nil {
|
||||
return fmt.Errorf("failed to open shell: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
401
cmd/php/php_deploy.go
Normal file
401
cmd/php/php_deploy.go
Normal file
|
|
@ -0,0 +1,401 @@
|
|||
package php
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
phppkg "github.com/host-uk/core/pkg/php"
|
||||
"github.com/leaanthony/clir"
|
||||
)
|
||||
|
||||
// Deploy command styles
|
||||
var (
|
||||
phpDeployStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#10b981")) // emerald-500
|
||||
|
||||
phpDeployPendingStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#f59e0b")) // amber-500
|
||||
|
||||
phpDeployFailedStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#ef4444")) // red-500
|
||||
)
|
||||
|
||||
func addPHPDeployCommands(parent *clir.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)
|
||||
}
|
||||
|
||||
func addPHPDeployCommand(parent *clir.Command) {
|
||||
var (
|
||||
staging bool
|
||||
force bool
|
||||
wait bool
|
||||
)
|
||||
|
||||
deployCmd := parent.NewSubCommand("deploy", "Deploy to Coolify")
|
||||
deployCmd.LongDescription("Deploy the PHP application to Coolify.\n\n" +
|
||||
"Requires configuration in .env:\n" +
|
||||
" COOLIFY_URL=https://coolify.example.com\n" +
|
||||
" COOLIFY_TOKEN=your-api-token\n" +
|
||||
" COOLIFY_APP_ID=production-app-id\n" +
|
||||
" COOLIFY_STAGING_APP_ID=staging-app-id (optional)\n\n" +
|
||||
"Examples:\n" +
|
||||
" core php deploy # Deploy to production\n" +
|
||||
" core php deploy --staging # Deploy to staging\n" +
|
||||
" core php deploy --force # Force deployment\n" +
|
||||
" core php deploy --wait # Wait for deployment to complete")
|
||||
|
||||
deployCmd.BoolFlag("staging", "Deploy to staging environment", &staging)
|
||||
deployCmd.BoolFlag("force", "Force deployment even if no changes detected", &force)
|
||||
deployCmd.BoolFlag("wait", "Wait for deployment to complete", &wait)
|
||||
|
||||
deployCmd.Action(func() error {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get working directory: %w", err)
|
||||
}
|
||||
|
||||
env := phppkg.EnvProduction
|
||||
if staging {
|
||||
env = phppkg.EnvStaging
|
||||
}
|
||||
|
||||
fmt.Printf("%s Deploying to %s...\n\n", dimStyle.Render("Deploy:"), env)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
opts := phppkg.DeployOptions{
|
||||
Dir: cwd,
|
||||
Environment: env,
|
||||
Force: force,
|
||||
Wait: wait,
|
||||
}
|
||||
|
||||
status, err := phppkg.Deploy(ctx, opts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("deployment failed: %w", err)
|
||||
}
|
||||
|
||||
printDeploymentStatus(status)
|
||||
|
||||
if wait {
|
||||
if phppkg.IsDeploymentSuccessful(status.Status) {
|
||||
fmt.Printf("\n%s Deployment completed successfully\n", successStyle.Render("Done:"))
|
||||
} else {
|
||||
fmt.Printf("\n%s Deployment ended with status: %s\n", errorStyle.Render("Warning:"), status.Status)
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("\n%s Deployment triggered. Use 'core php deploy:status' to check progress.\n", successStyle.Render("Done:"))
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func addPHPDeployStatusCommand(parent *clir.Command) {
|
||||
var (
|
||||
staging bool
|
||||
deploymentID string
|
||||
)
|
||||
|
||||
statusCmd := parent.NewSubCommand("deploy:status", "Show deployment status")
|
||||
statusCmd.LongDescription("Show the status of a deployment.\n\n" +
|
||||
"Examples:\n" +
|
||||
" core php deploy:status # Latest production deployment\n" +
|
||||
" core php deploy:status --staging # Latest staging deployment\n" +
|
||||
" core php deploy:status --id abc123 # Specific deployment")
|
||||
|
||||
statusCmd.BoolFlag("staging", "Check staging environment", &staging)
|
||||
statusCmd.StringFlag("id", "Specific deployment ID", &deploymentID)
|
||||
|
||||
statusCmd.Action(func() error {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get working directory: %w", err)
|
||||
}
|
||||
|
||||
env := phppkg.EnvProduction
|
||||
if staging {
|
||||
env = phppkg.EnvStaging
|
||||
}
|
||||
|
||||
fmt.Printf("%s Checking %s deployment status...\n\n", dimStyle.Render("Deploy:"), env)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
opts := phppkg.StatusOptions{
|
||||
Dir: cwd,
|
||||
Environment: env,
|
||||
DeploymentID: deploymentID,
|
||||
}
|
||||
|
||||
status, err := phppkg.DeployStatus(ctx, opts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get status: %w", err)
|
||||
}
|
||||
|
||||
printDeploymentStatus(status)
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func addPHPDeployRollbackCommand(parent *clir.Command) {
|
||||
var (
|
||||
staging bool
|
||||
deploymentID string
|
||||
wait bool
|
||||
)
|
||||
|
||||
rollbackCmd := parent.NewSubCommand("deploy:rollback", "Rollback to previous deployment")
|
||||
rollbackCmd.LongDescription("Rollback to a previous deployment.\n\n" +
|
||||
"If no deployment ID is specified, rolls back to the most recent\n" +
|
||||
"successful deployment.\n\n" +
|
||||
"Examples:\n" +
|
||||
" core php deploy:rollback # Rollback to previous\n" +
|
||||
" core php deploy:rollback --staging # Rollback staging\n" +
|
||||
" core php deploy:rollback --id abc123 # Rollback to specific deployment")
|
||||
|
||||
rollbackCmd.BoolFlag("staging", "Rollback staging environment", &staging)
|
||||
rollbackCmd.StringFlag("id", "Specific deployment ID to rollback to", &deploymentID)
|
||||
rollbackCmd.BoolFlag("wait", "Wait for rollback to complete", &wait)
|
||||
|
||||
rollbackCmd.Action(func() error {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get working directory: %w", err)
|
||||
}
|
||||
|
||||
env := phppkg.EnvProduction
|
||||
if staging {
|
||||
env = phppkg.EnvStaging
|
||||
}
|
||||
|
||||
fmt.Printf("%s Rolling back %s...\n\n", dimStyle.Render("Deploy:"), env)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
opts := phppkg.RollbackOptions{
|
||||
Dir: cwd,
|
||||
Environment: env,
|
||||
DeploymentID: deploymentID,
|
||||
Wait: wait,
|
||||
}
|
||||
|
||||
status, err := phppkg.Rollback(ctx, opts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("rollback failed: %w", err)
|
||||
}
|
||||
|
||||
printDeploymentStatus(status)
|
||||
|
||||
if wait {
|
||||
if phppkg.IsDeploymentSuccessful(status.Status) {
|
||||
fmt.Printf("\n%s Rollback completed successfully\n", successStyle.Render("Done:"))
|
||||
} else {
|
||||
fmt.Printf("\n%s Rollback ended with status: %s\n", errorStyle.Render("Warning:"), status.Status)
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("\n%s Rollback triggered. Use 'core php deploy:status' to check progress.\n", successStyle.Render("Done:"))
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func addPHPDeployListCommand(parent *clir.Command) {
|
||||
var (
|
||||
staging bool
|
||||
limit int
|
||||
)
|
||||
|
||||
listCmd := parent.NewSubCommand("deploy:list", "List recent deployments")
|
||||
listCmd.LongDescription("List recent deployments.\n\n" +
|
||||
"Examples:\n" +
|
||||
" core php deploy:list # List production deployments\n" +
|
||||
" core php deploy:list --staging # List staging deployments\n" +
|
||||
" core php deploy:list --limit 20 # List more deployments")
|
||||
|
||||
listCmd.BoolFlag("staging", "List staging deployments", &staging)
|
||||
listCmd.IntFlag("limit", "Number of deployments to list (default: 10)", &limit)
|
||||
|
||||
listCmd.Action(func() error {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get working directory: %w", err)
|
||||
}
|
||||
|
||||
env := phppkg.EnvProduction
|
||||
if staging {
|
||||
env = phppkg.EnvStaging
|
||||
}
|
||||
|
||||
if limit == 0 {
|
||||
limit = 10
|
||||
}
|
||||
|
||||
fmt.Printf("%s Recent %s deployments:\n\n", dimStyle.Render("Deploy:"), env)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
deployments, err := phppkg.ListDeployments(ctx, cwd, env, limit)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list deployments: %w", err)
|
||||
}
|
||||
|
||||
if len(deployments) == 0 {
|
||||
fmt.Printf("%s No deployments found\n", dimStyle.Render("Info:"))
|
||||
return nil
|
||||
}
|
||||
|
||||
for i, d := range deployments {
|
||||
printDeploymentSummary(i+1, &d)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func printDeploymentStatus(status *phppkg.DeploymentStatus) {
|
||||
// Status with color
|
||||
statusStyle := phpDeployStyle
|
||||
switch status.Status {
|
||||
case "queued", "building", "deploying", "pending", "rolling_back":
|
||||
statusStyle = phpDeployPendingStyle
|
||||
case "failed", "error", "cancelled":
|
||||
statusStyle = phpDeployFailedStyle
|
||||
}
|
||||
|
||||
fmt.Printf("%s %s\n", dimStyle.Render("Status:"), statusStyle.Render(status.Status))
|
||||
|
||||
if status.ID != "" {
|
||||
fmt.Printf("%s %s\n", dimStyle.Render("ID:"), status.ID)
|
||||
}
|
||||
|
||||
if status.URL != "" {
|
||||
fmt.Printf("%s %s\n", dimStyle.Render("URL:"), linkStyle.Render(status.URL))
|
||||
}
|
||||
|
||||
if status.Branch != "" {
|
||||
fmt.Printf("%s %s\n", dimStyle.Render("Branch:"), status.Branch)
|
||||
}
|
||||
|
||||
if status.Commit != "" {
|
||||
commit := status.Commit
|
||||
if len(commit) > 7 {
|
||||
commit = commit[:7]
|
||||
}
|
||||
fmt.Printf("%s %s\n", dimStyle.Render("Commit:"), commit)
|
||||
if status.CommitMessage != "" {
|
||||
// Truncate long messages
|
||||
msg := status.CommitMessage
|
||||
if len(msg) > 60 {
|
||||
msg = msg[:57] + "..."
|
||||
}
|
||||
fmt.Printf("%s %s\n", dimStyle.Render("Message:"), msg)
|
||||
}
|
||||
}
|
||||
|
||||
if !status.StartedAt.IsZero() {
|
||||
fmt.Printf("%s %s\n", dimStyle.Render("Started:"), status.StartedAt.Format(time.RFC3339))
|
||||
}
|
||||
|
||||
if !status.CompletedAt.IsZero() {
|
||||
fmt.Printf("%s %s\n", dimStyle.Render("Completed:"), status.CompletedAt.Format(time.RFC3339))
|
||||
if !status.StartedAt.IsZero() {
|
||||
duration := status.CompletedAt.Sub(status.StartedAt)
|
||||
fmt.Printf("%s %s\n", dimStyle.Render("Duration:"), duration.Round(time.Second))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func printDeploymentSummary(index int, status *phppkg.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 = formatTimeAgo(status.StartedAt)
|
||||
}
|
||||
|
||||
fmt.Printf(" %s %s %s",
|
||||
dimStyle.Render(fmt.Sprintf("#%d", index)),
|
||||
statusStyle.Render(fmt.Sprintf("[%s]", status.Status)),
|
||||
id,
|
||||
)
|
||||
|
||||
if commit != "" {
|
||||
fmt.Printf(" %s", commit)
|
||||
}
|
||||
|
||||
if msg != "" {
|
||||
fmt.Printf(" - %s", msg)
|
||||
}
|
||||
|
||||
if age != "" {
|
||||
fmt.Printf(" %s", dimStyle.Render(fmt.Sprintf("(%s)", age)))
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
func formatTimeAgo(t time.Time) string {
|
||||
duration := time.Since(t)
|
||||
|
||||
switch {
|
||||
case duration < time.Minute:
|
||||
return "just now"
|
||||
case duration < time.Hour:
|
||||
mins := int(duration.Minutes())
|
||||
if mins == 1 {
|
||||
return "1 minute ago"
|
||||
}
|
||||
return fmt.Sprintf("%d minutes ago", mins)
|
||||
case duration < 24*time.Hour:
|
||||
hours := int(duration.Hours())
|
||||
if hours == 1 {
|
||||
return "1 hour ago"
|
||||
}
|
||||
return fmt.Sprintf("%d hours ago", hours)
|
||||
default:
|
||||
days := int(duration.Hours() / 24)
|
||||
if days == 1 {
|
||||
return "1 day ago"
|
||||
}
|
||||
return fmt.Sprintf("%d days ago", days)
|
||||
}
|
||||
}
|
||||
481
cmd/php/php_dev.go
Normal file
481
cmd/php/php_dev.go
Normal file
|
|
@ -0,0 +1,481 @@
|
|||
package php
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
phppkg "github.com/host-uk/core/pkg/php"
|
||||
"github.com/leaanthony/clir"
|
||||
)
|
||||
|
||||
func addPHPDevCommand(parent *clir.Command) {
|
||||
var (
|
||||
noVite bool
|
||||
noHorizon bool
|
||||
noReverb bool
|
||||
noRedis bool
|
||||
https bool
|
||||
domain string
|
||||
port int
|
||||
)
|
||||
|
||||
devCmd := parent.NewSubCommand("dev", "Start Laravel development environment")
|
||||
devCmd.LongDescription("Starts all detected Laravel services.\n\n" +
|
||||
"Auto-detects:\n" +
|
||||
" - Vite (vite.config.js/ts)\n" +
|
||||
" - Horizon (config/horizon.php)\n" +
|
||||
" - Reverb (config/reverb.php)\n" +
|
||||
" - Redis (from .env)")
|
||||
|
||||
devCmd.BoolFlag("no-vite", "Skip Vite dev server", &noVite)
|
||||
devCmd.BoolFlag("no-horizon", "Skip Laravel Horizon", &noHorizon)
|
||||
devCmd.BoolFlag("no-reverb", "Skip Laravel Reverb", &noReverb)
|
||||
devCmd.BoolFlag("no-redis", "Skip Redis server", &noRedis)
|
||||
devCmd.BoolFlag("https", "Enable HTTPS with mkcert", &https)
|
||||
devCmd.StringFlag("domain", "Domain for SSL certificate (default: from APP_URL or localhost)", &domain)
|
||||
devCmd.IntFlag("port", "FrankenPHP port (default: 8000)", &port)
|
||||
|
||||
devCmd.Action(func() error {
|
||||
return runPHPDev(phpDevOptions{
|
||||
NoVite: noVite,
|
||||
NoHorizon: noHorizon,
|
||||
NoReverb: noReverb,
|
||||
NoRedis: noRedis,
|
||||
HTTPS: https,
|
||||
Domain: domain,
|
||||
Port: port,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
type phpDevOptions struct {
|
||||
NoVite bool
|
||||
NoHorizon bool
|
||||
NoReverb bool
|
||||
NoRedis bool
|
||||
HTTPS bool
|
||||
Domain string
|
||||
Port int
|
||||
}
|
||||
|
||||
func runPHPDev(opts phpDevOptions) error {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get working directory: %w", err)
|
||||
}
|
||||
|
||||
// Check if this is a Laravel project
|
||||
if !phppkg.IsLaravelProject(cwd) {
|
||||
return fmt.Errorf("not a Laravel project (missing artisan or laravel/framework)")
|
||||
}
|
||||
|
||||
// Get app name for display
|
||||
appName := phppkg.GetLaravelAppName(cwd)
|
||||
if appName == "" {
|
||||
appName = "Laravel"
|
||||
}
|
||||
|
||||
fmt.Printf("%s Starting %s development environment\n\n", dimStyle.Render("PHP:"), appName)
|
||||
|
||||
// Detect services
|
||||
services := phppkg.DetectServices(cwd)
|
||||
fmt.Printf("%s Detected services:\n", dimStyle.Render("Services:"))
|
||||
for _, svc := range services {
|
||||
fmt.Printf(" %s %s\n", successStyle.Render("*"), svc)
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
// Setup options
|
||||
port := opts.Port
|
||||
if port == 0 {
|
||||
port = 8000
|
||||
}
|
||||
|
||||
devOpts := phppkg.Options{
|
||||
Dir: cwd,
|
||||
NoVite: opts.NoVite,
|
||||
NoHorizon: opts.NoHorizon,
|
||||
NoReverb: opts.NoReverb,
|
||||
NoRedis: opts.NoRedis,
|
||||
HTTPS: opts.HTTPS,
|
||||
Domain: opts.Domain,
|
||||
FrankenPHPPort: port,
|
||||
}
|
||||
|
||||
// Create and start dev server
|
||||
server := phppkg.NewDevServer(devOpts)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
// Handle shutdown signals
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
go func() {
|
||||
<-sigCh
|
||||
fmt.Printf("\n%s Shutting down...\n", dimStyle.Render("PHP:"))
|
||||
cancel()
|
||||
}()
|
||||
|
||||
if err := server.Start(ctx, devOpts); err != nil {
|
||||
return fmt.Errorf("failed to start services: %w", err)
|
||||
}
|
||||
|
||||
// Print status
|
||||
fmt.Printf("%s Services started:\n", successStyle.Render("Running:"))
|
||||
printServiceStatuses(server.Status())
|
||||
fmt.Println()
|
||||
|
||||
// Print URLs
|
||||
appURL := phppkg.GetLaravelAppURL(cwd)
|
||||
if appURL == "" {
|
||||
if opts.HTTPS {
|
||||
appURL = fmt.Sprintf("https://localhost:%d", port)
|
||||
} else {
|
||||
appURL = fmt.Sprintf("http://localhost:%d", port)
|
||||
}
|
||||
}
|
||||
fmt.Printf("%s %s\n", dimStyle.Render("App URL:"), linkStyle.Render(appURL))
|
||||
|
||||
// Check for Vite
|
||||
if !opts.NoVite && containsService(services, phppkg.ServiceVite) {
|
||||
fmt.Printf("%s %s\n", dimStyle.Render("Vite:"), linkStyle.Render("http://localhost:5173"))
|
||||
}
|
||||
|
||||
fmt.Printf("\n%s\n\n", dimStyle.Render("Press Ctrl+C to stop all services"))
|
||||
|
||||
// Stream unified logs
|
||||
logsReader, err := server.Logs("", true)
|
||||
if err != nil {
|
||||
fmt.Printf("%s Failed to get logs: %v\n", errorStyle.Render("Warning:"), err)
|
||||
} else {
|
||||
defer logsReader.Close()
|
||||
|
||||
scanner := bufio.NewScanner(logsReader)
|
||||
for scanner.Scan() {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
goto shutdown
|
||||
default:
|
||||
line := scanner.Text()
|
||||
printColoredLog(line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
shutdown:
|
||||
// Stop services
|
||||
if err := server.Stop(); err != nil {
|
||||
fmt.Printf("%s Error stopping services: %v\n", errorStyle.Render("Error:"), err)
|
||||
}
|
||||
|
||||
fmt.Printf("%s All services stopped\n", successStyle.Render("Done:"))
|
||||
return nil
|
||||
}
|
||||
|
||||
func addPHPLogsCommand(parent *clir.Command) {
|
||||
var follow bool
|
||||
var service string
|
||||
|
||||
logsCmd := parent.NewSubCommand("logs", "View service logs")
|
||||
logsCmd.LongDescription("Stream logs from Laravel services.\n\n" +
|
||||
"Services: frankenphp, vite, horizon, reverb, redis")
|
||||
|
||||
logsCmd.BoolFlag("follow", "Follow log output", &follow)
|
||||
logsCmd.StringFlag("service", "Specific service (default: all)", &service)
|
||||
|
||||
logsCmd.Action(func() error {
|
||||
return runPHPLogs(service, follow)
|
||||
})
|
||||
}
|
||||
|
||||
func runPHPLogs(service string, follow bool) error {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !phppkg.IsLaravelProject(cwd) {
|
||||
return fmt.Errorf("not a Laravel project")
|
||||
}
|
||||
|
||||
// Create a minimal server just to access logs
|
||||
server := phppkg.NewDevServer(phppkg.Options{Dir: cwd})
|
||||
|
||||
logsReader, err := server.Logs(service, follow)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get logs: %w", err)
|
||||
}
|
||||
defer logsReader.Close()
|
||||
|
||||
// Handle interrupt
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
go func() {
|
||||
<-sigCh
|
||||
cancel()
|
||||
}()
|
||||
|
||||
scanner := bufio.NewScanner(logsReader)
|
||||
for scanner.Scan() {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
default:
|
||||
printColoredLog(scanner.Text())
|
||||
}
|
||||
}
|
||||
|
||||
return scanner.Err()
|
||||
}
|
||||
|
||||
func addPHPStopCommand(parent *clir.Command) {
|
||||
stopCmd := parent.NewSubCommand("stop", "Stop all Laravel services")
|
||||
|
||||
stopCmd.Action(func() error {
|
||||
return runPHPStop()
|
||||
})
|
||||
}
|
||||
|
||||
func runPHPStop() error {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("%s Stopping services...\n", dimStyle.Render("PHP:"))
|
||||
|
||||
// We need to find running processes
|
||||
// This is a simplified version - in practice you'd want to track PIDs
|
||||
server := phppkg.NewDevServer(phppkg.Options{Dir: cwd})
|
||||
if err := server.Stop(); err != nil {
|
||||
return fmt.Errorf("failed to stop services: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("%s All services stopped\n", successStyle.Render("Done:"))
|
||||
return nil
|
||||
}
|
||||
|
||||
func addPHPStatusCommand(parent *clir.Command) {
|
||||
statusCmd := parent.NewSubCommand("status", "Show service status")
|
||||
|
||||
statusCmd.Action(func() error {
|
||||
return runPHPStatus()
|
||||
})
|
||||
}
|
||||
|
||||
func runPHPStatus() error {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !phppkg.IsLaravelProject(cwd) {
|
||||
return fmt.Errorf("not a Laravel project")
|
||||
}
|
||||
|
||||
appName := phppkg.GetLaravelAppName(cwd)
|
||||
if appName == "" {
|
||||
appName = "Laravel"
|
||||
}
|
||||
|
||||
fmt.Printf("%s %s\n\n", dimStyle.Render("Project:"), appName)
|
||||
|
||||
// Detect available services
|
||||
services := phppkg.DetectServices(cwd)
|
||||
fmt.Printf("%s\n", dimStyle.Render("Detected services:"))
|
||||
for _, svc := range services {
|
||||
style := getServiceStyle(string(svc))
|
||||
fmt.Printf(" %s %s\n", style.Render("*"), svc)
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
// Package manager
|
||||
pm := phppkg.DetectPackageManager(cwd)
|
||||
fmt.Printf("%s %s\n", dimStyle.Render("Package manager:"), pm)
|
||||
|
||||
// FrankenPHP status
|
||||
if phppkg.IsFrankenPHPProject(cwd) {
|
||||
fmt.Printf("%s %s\n", dimStyle.Render("Octane server:"), "FrankenPHP")
|
||||
}
|
||||
|
||||
// SSL status
|
||||
appURL := phppkg.GetLaravelAppURL(cwd)
|
||||
if appURL != "" {
|
||||
domain := phppkg.ExtractDomainFromURL(appURL)
|
||||
if phppkg.CertsExist(domain, phppkg.SSLOptions{}) {
|
||||
fmt.Printf("%s %s\n", dimStyle.Render("SSL certificates:"), successStyle.Render("installed"))
|
||||
} else {
|
||||
fmt.Printf("%s %s\n", dimStyle.Render("SSL certificates:"), dimStyle.Render("not setup"))
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func addPHPSSLCommand(parent *clir.Command) {
|
||||
var domain string
|
||||
|
||||
sslCmd := parent.NewSubCommand("ssl", "Setup SSL certificates with mkcert")
|
||||
|
||||
sslCmd.StringFlag("domain", "Domain for certificate (default: from APP_URL)", &domain)
|
||||
|
||||
sslCmd.Action(func() error {
|
||||
return runPHPSSL(domain)
|
||||
})
|
||||
}
|
||||
|
||||
func runPHPSSL(domain string) error {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get domain from APP_URL if not specified
|
||||
if domain == "" {
|
||||
appURL := phppkg.GetLaravelAppURL(cwd)
|
||||
if appURL != "" {
|
||||
domain = phppkg.ExtractDomainFromURL(appURL)
|
||||
}
|
||||
}
|
||||
if domain == "" {
|
||||
domain = "localhost"
|
||||
}
|
||||
|
||||
// Check if mkcert is installed
|
||||
if !phppkg.IsMkcertInstalled() {
|
||||
fmt.Printf("%s mkcert is not installed\n", errorStyle.Render("Error:"))
|
||||
fmt.Println("\nInstall with:")
|
||||
fmt.Println(" macOS: brew install mkcert")
|
||||
fmt.Println(" Linux: see https://github.com/FiloSottile/mkcert")
|
||||
return fmt.Errorf("mkcert not installed")
|
||||
}
|
||||
|
||||
fmt.Printf("%s Setting up SSL for %s\n", dimStyle.Render("SSL:"), domain)
|
||||
|
||||
// Check if certs already exist
|
||||
if phppkg.CertsExist(domain, phppkg.SSLOptions{}) {
|
||||
fmt.Printf("%s Certificates already exist\n", dimStyle.Render("Skip:"))
|
||||
|
||||
certFile, keyFile, _ := phppkg.CertPaths(domain, phppkg.SSLOptions{})
|
||||
fmt.Printf("%s %s\n", dimStyle.Render("Cert:"), certFile)
|
||||
fmt.Printf("%s %s\n", dimStyle.Render("Key:"), keyFile)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Setup SSL
|
||||
if err := phppkg.SetupSSL(domain, phppkg.SSLOptions{}); err != nil {
|
||||
return fmt.Errorf("failed to setup SSL: %w", err)
|
||||
}
|
||||
|
||||
certFile, keyFile, _ := phppkg.CertPaths(domain, phppkg.SSLOptions{})
|
||||
|
||||
fmt.Printf("%s SSL certificates created\n", successStyle.Render("Done:"))
|
||||
fmt.Printf("%s %s\n", dimStyle.Render("Cert:"), certFile)
|
||||
fmt.Printf("%s %s\n", dimStyle.Render("Key:"), keyFile)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Helper functions for dev commands
|
||||
|
||||
func printServiceStatuses(statuses []phppkg.ServiceStatus) {
|
||||
for _, s := range statuses {
|
||||
style := getServiceStyle(s.Name)
|
||||
var statusText string
|
||||
|
||||
if s.Error != nil {
|
||||
statusText = phpStatusError.Render(fmt.Sprintf("error: %v", s.Error))
|
||||
} else if s.Running {
|
||||
statusText = phpStatusRunning.Render("running")
|
||||
if s.Port > 0 {
|
||||
statusText += dimStyle.Render(fmt.Sprintf(" (port %d)", s.Port))
|
||||
}
|
||||
if s.PID > 0 {
|
||||
statusText += dimStyle.Render(fmt.Sprintf(" [pid %d]", s.PID))
|
||||
}
|
||||
} else {
|
||||
statusText = phpStatusStopped.Render("stopped")
|
||||
}
|
||||
|
||||
fmt.Printf(" %s %s\n", style.Render(s.Name+":"), statusText)
|
||||
}
|
||||
}
|
||||
|
||||
func printColoredLog(line string) {
|
||||
// Parse service prefix from log line
|
||||
timestamp := time.Now().Format("15:04:05")
|
||||
|
||||
var style lipgloss.Style
|
||||
serviceName := ""
|
||||
|
||||
if strings.HasPrefix(line, "[FrankenPHP]") {
|
||||
style = phpFrankenPHPStyle
|
||||
serviceName = "FrankenPHP"
|
||||
line = strings.TrimPrefix(line, "[FrankenPHP] ")
|
||||
} else if strings.HasPrefix(line, "[Vite]") {
|
||||
style = phpViteStyle
|
||||
serviceName = "Vite"
|
||||
line = strings.TrimPrefix(line, "[Vite] ")
|
||||
} else if strings.HasPrefix(line, "[Horizon]") {
|
||||
style = phpHorizonStyle
|
||||
serviceName = "Horizon"
|
||||
line = strings.TrimPrefix(line, "[Horizon] ")
|
||||
} else if strings.HasPrefix(line, "[Reverb]") {
|
||||
style = phpReverbStyle
|
||||
serviceName = "Reverb"
|
||||
line = strings.TrimPrefix(line, "[Reverb] ")
|
||||
} else if strings.HasPrefix(line, "[Redis]") {
|
||||
style = phpRedisStyle
|
||||
serviceName = "Redis"
|
||||
line = strings.TrimPrefix(line, "[Redis] ")
|
||||
} else {
|
||||
// Unknown service, print as-is
|
||||
fmt.Printf("%s %s\n", dimStyle.Render(timestamp), line)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("%s %s %s\n",
|
||||
dimStyle.Render(timestamp),
|
||||
style.Render(fmt.Sprintf("[%s]", serviceName)),
|
||||
line,
|
||||
)
|
||||
}
|
||||
|
||||
func getServiceStyle(name string) lipgloss.Style {
|
||||
switch strings.ToLower(name) {
|
||||
case "frankenphp":
|
||||
return phpFrankenPHPStyle
|
||||
case "vite":
|
||||
return phpViteStyle
|
||||
case "horizon":
|
||||
return phpHorizonStyle
|
||||
case "reverb":
|
||||
return phpReverbStyle
|
||||
case "redis":
|
||||
return phpRedisStyle
|
||||
default:
|
||||
return dimStyle
|
||||
}
|
||||
}
|
||||
|
||||
func containsService(services []phppkg.DetectedService, target phppkg.DetectedService) bool {
|
||||
for _, s := range services {
|
||||
if s == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
157
cmd/php/php_packages.go
Normal file
157
cmd/php/php_packages.go
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
package php
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
phppkg "github.com/host-uk/core/pkg/php"
|
||||
"github.com/leaanthony/clir"
|
||||
)
|
||||
|
||||
func addPHPPackagesCommands(parent *clir.Command) {
|
||||
packagesCmd := parent.NewSubCommand("packages", "Manage local PHP packages")
|
||||
packagesCmd.LongDescription("Link and manage local PHP packages for development.\n\n" +
|
||||
"Similar to npm link, this adds path repositories to composer.json\n" +
|
||||
"for developing packages alongside your project.\n\n" +
|
||||
"Commands:\n" +
|
||||
" link - Link local packages by path\n" +
|
||||
" unlink - Unlink packages by name\n" +
|
||||
" update - Update linked packages\n" +
|
||||
" list - List linked packages")
|
||||
|
||||
addPHPPackagesLinkCommand(packagesCmd)
|
||||
addPHPPackagesUnlinkCommand(packagesCmd)
|
||||
addPHPPackagesUpdateCommand(packagesCmd)
|
||||
addPHPPackagesListCommand(packagesCmd)
|
||||
}
|
||||
|
||||
func addPHPPackagesLinkCommand(parent *clir.Command) {
|
||||
linkCmd := parent.NewSubCommand("link", "Link local packages")
|
||||
linkCmd.LongDescription("Link local PHP packages for development.\n\n" +
|
||||
"Adds path repositories to composer.json with symlink enabled.\n" +
|
||||
"The package name is auto-detected from each path's composer.json.\n\n" +
|
||||
"Examples:\n" +
|
||||
" core php packages link ../my-package\n" +
|
||||
" core php packages link ../pkg-a ../pkg-b")
|
||||
|
||||
linkCmd.Action(func() error {
|
||||
args := linkCmd.OtherArgs()
|
||||
if len(args) == 0 {
|
||||
return fmt.Errorf("at least one package path is required")
|
||||
}
|
||||
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get working directory: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("%s Linking packages...\n\n", dimStyle.Render("PHP:"))
|
||||
|
||||
if err := phppkg.LinkPackages(cwd, args); err != nil {
|
||||
return fmt.Errorf("failed to link packages: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("\n%s Packages linked. Run 'composer update' to install.\n", successStyle.Render("Done:"))
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func addPHPPackagesUnlinkCommand(parent *clir.Command) {
|
||||
unlinkCmd := parent.NewSubCommand("unlink", "Unlink packages")
|
||||
unlinkCmd.LongDescription("Remove linked packages from composer.json.\n\n" +
|
||||
"Removes path repositories by package name.\n\n" +
|
||||
"Examples:\n" +
|
||||
" core php packages unlink vendor/my-package\n" +
|
||||
" core php packages unlink vendor/pkg-a vendor/pkg-b")
|
||||
|
||||
unlinkCmd.Action(func() error {
|
||||
args := unlinkCmd.OtherArgs()
|
||||
if len(args) == 0 {
|
||||
return fmt.Errorf("at least one package name is required")
|
||||
}
|
||||
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get working directory: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("%s Unlinking packages...\n\n", dimStyle.Render("PHP:"))
|
||||
|
||||
if err := phppkg.UnlinkPackages(cwd, args); err != nil {
|
||||
return fmt.Errorf("failed to unlink packages: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("\n%s Packages unlinked. Run 'composer update' to remove.\n", successStyle.Render("Done:"))
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func addPHPPackagesUpdateCommand(parent *clir.Command) {
|
||||
updateCmd := parent.NewSubCommand("update", "Update linked packages")
|
||||
updateCmd.LongDescription("Run composer update for linked packages.\n\n" +
|
||||
"If no packages specified, updates all packages.\n\n" +
|
||||
"Examples:\n" +
|
||||
" core php packages update\n" +
|
||||
" core php packages update vendor/my-package")
|
||||
|
||||
updateCmd.Action(func() error {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get working directory: %w", err)
|
||||
}
|
||||
|
||||
args := updateCmd.OtherArgs()
|
||||
|
||||
fmt.Printf("%s Updating packages...\n\n", dimStyle.Render("PHP:"))
|
||||
|
||||
if err := phppkg.UpdatePackages(cwd, args); err != nil {
|
||||
return fmt.Errorf("composer update failed: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("\n%s Packages updated\n", successStyle.Render("Done:"))
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func addPHPPackagesListCommand(parent *clir.Command) {
|
||||
listCmd := parent.NewSubCommand("list", "List linked packages")
|
||||
listCmd.LongDescription("List all locally linked packages.\n\n" +
|
||||
"Shows package name, path, and version for each linked package.")
|
||||
|
||||
listCmd.Action(func() error {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get working directory: %w", err)
|
||||
}
|
||||
|
||||
packages, err := phppkg.ListLinkedPackages(cwd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list packages: %w", err)
|
||||
}
|
||||
|
||||
if len(packages) == 0 {
|
||||
fmt.Printf("%s No linked packages found\n", dimStyle.Render("PHP:"))
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("%s Linked packages:\n\n", dimStyle.Render("PHP:"))
|
||||
|
||||
for _, pkg := range packages {
|
||||
name := pkg.Name
|
||||
if name == "" {
|
||||
name = "(unknown)"
|
||||
}
|
||||
version := pkg.Version
|
||||
if version == "" {
|
||||
version = "dev"
|
||||
}
|
||||
|
||||
fmt.Printf(" %s %s\n", successStyle.Render("*"), name)
|
||||
fmt.Printf(" %s %s\n", dimStyle.Render("Path:"), pkg.Path)
|
||||
fmt.Printf(" %s %s\n", dimStyle.Render("Version:"), version)
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
831
cmd/php/php_quality.go
Normal file
831
cmd/php/php_quality.go
Normal file
|
|
@ -0,0 +1,831 @@
|
|||
package php
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
phppkg "github.com/host-uk/core/pkg/php"
|
||||
"github.com/leaanthony/clir"
|
||||
)
|
||||
|
||||
func addPHPTestCommand(parent *clir.Command) {
|
||||
var (
|
||||
parallel bool
|
||||
coverage bool
|
||||
filter string
|
||||
group string
|
||||
)
|
||||
|
||||
testCmd := parent.NewSubCommand("test", "Run PHP tests (PHPUnit/Pest)")
|
||||
testCmd.LongDescription("Run PHP tests using PHPUnit or Pest.\n\n" +
|
||||
"Auto-detects Pest if tests/Pest.php exists, otherwise uses PHPUnit.\n\n" +
|
||||
"Examples:\n" +
|
||||
" core php test # Run all tests\n" +
|
||||
" core php test --parallel # Run tests in parallel\n" +
|
||||
" core php test --coverage # Run with coverage\n" +
|
||||
" core php test --filter UserTest # Filter by test name")
|
||||
|
||||
testCmd.BoolFlag("parallel", "Run tests in parallel", ¶llel)
|
||||
testCmd.BoolFlag("coverage", "Generate code coverage", &coverage)
|
||||
testCmd.StringFlag("filter", "Filter tests by name pattern", &filter)
|
||||
testCmd.StringFlag("group", "Run only tests in specified group", &group)
|
||||
|
||||
testCmd.Action(func() error {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get working directory: %w", err)
|
||||
}
|
||||
|
||||
if !phppkg.IsPHPProject(cwd) {
|
||||
return fmt.Errorf("not a PHP project (missing composer.json)")
|
||||
}
|
||||
|
||||
// Detect test runner
|
||||
runner := phppkg.DetectTestRunner(cwd)
|
||||
fmt.Printf("%s Running tests with %s\n\n", dimStyle.Render("PHP:"), runner)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
opts := phppkg.TestOptions{
|
||||
Dir: cwd,
|
||||
Filter: filter,
|
||||
Parallel: parallel,
|
||||
Coverage: coverage,
|
||||
Output: os.Stdout,
|
||||
}
|
||||
|
||||
if group != "" {
|
||||
opts.Groups = []string{group}
|
||||
}
|
||||
|
||||
if err := phppkg.RunTests(ctx, opts); err != nil {
|
||||
return fmt.Errorf("tests failed: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func addPHPFmtCommand(parent *clir.Command) {
|
||||
var (
|
||||
fix bool
|
||||
diff bool
|
||||
)
|
||||
|
||||
fmtCmd := parent.NewSubCommand("fmt", "Format PHP code with Laravel Pint")
|
||||
fmtCmd.LongDescription("Format PHP code using Laravel Pint.\n\n" +
|
||||
"Examples:\n" +
|
||||
" core php fmt # Check formatting (dry-run)\n" +
|
||||
" core php fmt --fix # Auto-fix formatting issues\n" +
|
||||
" core php fmt --diff # Show diff of changes")
|
||||
|
||||
fmtCmd.BoolFlag("fix", "Auto-fix formatting issues", &fix)
|
||||
fmtCmd.BoolFlag("diff", "Show diff of changes", &diff)
|
||||
|
||||
fmtCmd.Action(func() error {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get working directory: %w", err)
|
||||
}
|
||||
|
||||
if !phppkg.IsPHPProject(cwd) {
|
||||
return fmt.Errorf("not a PHP project (missing composer.json)")
|
||||
}
|
||||
|
||||
// Detect formatter
|
||||
formatter, found := phppkg.DetectFormatter(cwd)
|
||||
if !found {
|
||||
return fmt.Errorf("no formatter found (install Laravel Pint: composer require laravel/pint --dev)")
|
||||
}
|
||||
|
||||
action := "Checking"
|
||||
if fix {
|
||||
action = "Formatting"
|
||||
}
|
||||
fmt.Printf("%s %s code with %s\n\n", dimStyle.Render("PHP:"), action, formatter)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
opts := phppkg.FormatOptions{
|
||||
Dir: cwd,
|
||||
Fix: fix,
|
||||
Diff: diff,
|
||||
Output: os.Stdout,
|
||||
}
|
||||
|
||||
// Get any additional paths from args
|
||||
if args := fmtCmd.OtherArgs(); len(args) > 0 {
|
||||
opts.Paths = args
|
||||
}
|
||||
|
||||
if err := phppkg.Format(ctx, opts); err != nil {
|
||||
if fix {
|
||||
return fmt.Errorf("formatting failed: %w", err)
|
||||
}
|
||||
return fmt.Errorf("formatting issues found: %w", err)
|
||||
}
|
||||
|
||||
if fix {
|
||||
fmt.Printf("\n%s Code formatted successfully\n", successStyle.Render("Done:"))
|
||||
} else {
|
||||
fmt.Printf("\n%s No formatting issues found\n", successStyle.Render("Done:"))
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func addPHPAnalyseCommand(parent *clir.Command) {
|
||||
var (
|
||||
level int
|
||||
memory string
|
||||
)
|
||||
|
||||
analyseCmd := parent.NewSubCommand("analyse", "Run PHPStan static analysis")
|
||||
analyseCmd.LongDescription("Run PHPStan or Larastan static analysis.\n\n" +
|
||||
"Auto-detects Larastan if installed, otherwise uses PHPStan.\n\n" +
|
||||
"Examples:\n" +
|
||||
" core php analyse # Run analysis\n" +
|
||||
" core php analyse --level 9 # Run at max strictness\n" +
|
||||
" core php analyse --memory 2G # Increase memory limit")
|
||||
|
||||
analyseCmd.IntFlag("level", "PHPStan analysis level (0-9)", &level)
|
||||
analyseCmd.StringFlag("memory", "Memory limit (e.g., 2G)", &memory)
|
||||
|
||||
analyseCmd.Action(func() error {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get working directory: %w", err)
|
||||
}
|
||||
|
||||
if !phppkg.IsPHPProject(cwd) {
|
||||
return fmt.Errorf("not a PHP project (missing composer.json)")
|
||||
}
|
||||
|
||||
// Detect analyser
|
||||
analyser, found := phppkg.DetectAnalyser(cwd)
|
||||
if !found {
|
||||
return fmt.Errorf("no static analyser found (install PHPStan: composer require phpstan/phpstan --dev)")
|
||||
}
|
||||
|
||||
fmt.Printf("%s Running static analysis with %s\n\n", dimStyle.Render("PHP:"), analyser)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
opts := phppkg.AnalyseOptions{
|
||||
Dir: cwd,
|
||||
Level: level,
|
||||
Memory: memory,
|
||||
Output: os.Stdout,
|
||||
}
|
||||
|
||||
// Get any additional paths from args
|
||||
if args := analyseCmd.OtherArgs(); len(args) > 0 {
|
||||
opts.Paths = args
|
||||
}
|
||||
|
||||
if err := phppkg.Analyse(ctx, opts); err != nil {
|
||||
return fmt.Errorf("analysis found issues: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("\n%s No issues found\n", successStyle.Render("Done:"))
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// New QA Commands
|
||||
// =============================================================================
|
||||
|
||||
func addPHPPsalmCommand(parent *clir.Command) {
|
||||
var (
|
||||
level int
|
||||
fix bool
|
||||
baseline bool
|
||||
showInfo bool
|
||||
)
|
||||
|
||||
psalmCmd := parent.NewSubCommand("psalm", "Run Psalm static analysis")
|
||||
psalmCmd.LongDescription("Run Psalm deep static analysis with Laravel plugin support.\n\n" +
|
||||
"Psalm provides deeper type inference than PHPStan and catches\n" +
|
||||
"different classes of bugs. Both should be run for best coverage.\n\n" +
|
||||
"Examples:\n" +
|
||||
" core php psalm # Run analysis\n" +
|
||||
" core php psalm --fix # Auto-fix issues where possible\n" +
|
||||
" core php psalm --level 3 # Run at specific level (1-8)\n" +
|
||||
" core php psalm --baseline # Generate baseline file")
|
||||
|
||||
psalmCmd.IntFlag("level", "Error level (1=strictest, 8=most lenient)", &level)
|
||||
psalmCmd.BoolFlag("fix", "Auto-fix issues where possible", &fix)
|
||||
psalmCmd.BoolFlag("baseline", "Generate/update baseline file", &baseline)
|
||||
psalmCmd.BoolFlag("show-info", "Show info-level issues", &showInfo)
|
||||
|
||||
psalmCmd.Action(func() error {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get working directory: %w", err)
|
||||
}
|
||||
|
||||
if !phppkg.IsPHPProject(cwd) {
|
||||
return fmt.Errorf("not a PHP project (missing composer.json)")
|
||||
}
|
||||
|
||||
// Check if Psalm is available
|
||||
_, found := phppkg.DetectPsalm(cwd)
|
||||
if !found {
|
||||
fmt.Printf("%s Psalm not found\n\n", errorStyle.Render("Error:"))
|
||||
fmt.Printf("%s composer require --dev vimeo/psalm\n", dimStyle.Render("Install:"))
|
||||
fmt.Printf("%s ./vendor/bin/psalm --init\n", dimStyle.Render("Setup:"))
|
||||
return fmt.Errorf("psalm not installed")
|
||||
}
|
||||
|
||||
action := "Analysing"
|
||||
if fix {
|
||||
action = "Analysing and fixing"
|
||||
}
|
||||
fmt.Printf("%s %s code with Psalm\n\n", dimStyle.Render("Psalm:"), action)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
opts := phppkg.PsalmOptions{
|
||||
Dir: cwd,
|
||||
Level: level,
|
||||
Fix: fix,
|
||||
Baseline: baseline,
|
||||
ShowInfo: showInfo,
|
||||
Output: os.Stdout,
|
||||
}
|
||||
|
||||
if err := phppkg.RunPsalm(ctx, opts); err != nil {
|
||||
return fmt.Errorf("psalm found issues: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("\n%s No issues found\n", successStyle.Render("Done:"))
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func addPHPAuditCommand(parent *clir.Command) {
|
||||
var (
|
||||
jsonOutput bool
|
||||
fix bool
|
||||
)
|
||||
|
||||
auditCmd := parent.NewSubCommand("audit", "Security audit for dependencies")
|
||||
auditCmd.LongDescription("Check PHP and JavaScript dependencies for known vulnerabilities.\n\n" +
|
||||
"Runs composer audit and npm audit (if package.json exists).\n\n" +
|
||||
"Examples:\n" +
|
||||
" core php audit # Check all dependencies\n" +
|
||||
" core php audit --json # Output as JSON\n" +
|
||||
" core php audit --fix # Auto-fix where possible (npm only)")
|
||||
|
||||
auditCmd.BoolFlag("json", "Output in JSON format", &jsonOutput)
|
||||
auditCmd.BoolFlag("fix", "Auto-fix vulnerabilities (npm only)", &fix)
|
||||
|
||||
auditCmd.Action(func() error {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get working directory: %w", err)
|
||||
}
|
||||
|
||||
if !phppkg.IsPHPProject(cwd) {
|
||||
return fmt.Errorf("not a PHP project (missing composer.json)")
|
||||
}
|
||||
|
||||
fmt.Printf("%s Scanning dependencies for vulnerabilities\n\n", dimStyle.Render("Audit:"))
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
results, err := phppkg.RunAudit(ctx, phppkg.AuditOptions{
|
||||
Dir: cwd,
|
||||
JSON: jsonOutput,
|
||||
Fix: fix,
|
||||
Output: os.Stdout,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("audit failed: %w", err)
|
||||
}
|
||||
|
||||
// Print results
|
||||
totalVulns := 0
|
||||
hasErrors := false
|
||||
|
||||
for _, result := range results {
|
||||
icon := successStyle.Render("✓")
|
||||
status := successStyle.Render("secure")
|
||||
|
||||
if result.Error != nil {
|
||||
icon = errorStyle.Render("✗")
|
||||
status = errorStyle.Render("error")
|
||||
hasErrors = true
|
||||
} else if result.Vulnerabilities > 0 {
|
||||
icon = errorStyle.Render("✗")
|
||||
status = errorStyle.Render(fmt.Sprintf("%d vulnerabilities", result.Vulnerabilities))
|
||||
totalVulns += result.Vulnerabilities
|
||||
}
|
||||
|
||||
fmt.Printf(" %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)
|
||||
fmt.Printf(" %s %s\n", sevStyle.Render("["+severity+"]"), adv.Package)
|
||||
if adv.Title != "" {
|
||||
fmt.Printf(" %s\n", dimStyle.Render(adv.Title))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
|
||||
if totalVulns > 0 {
|
||||
fmt.Printf("%s Found %d vulnerabilities across dependencies\n", errorStyle.Render("Warning:"), totalVulns)
|
||||
fmt.Printf("%s composer update && npm update\n", dimStyle.Render("Fix:"))
|
||||
return fmt.Errorf("vulnerabilities found")
|
||||
}
|
||||
|
||||
if hasErrors {
|
||||
return fmt.Errorf("audit completed with errors")
|
||||
}
|
||||
|
||||
fmt.Printf("%s All dependencies are secure\n", successStyle.Render("Done:"))
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func addPHPSecurityCommand(parent *clir.Command) {
|
||||
var (
|
||||
severity string
|
||||
jsonOutput bool
|
||||
sarif bool
|
||||
url string
|
||||
)
|
||||
|
||||
securityCmd := parent.NewSubCommand("security", "Security vulnerability scanning")
|
||||
securityCmd.LongDescription("Scan for security vulnerabilities in configuration and code.\n\n" +
|
||||
"Checks environment config, file permissions, code patterns,\n" +
|
||||
"and runs security-focused static analysis.\n\n" +
|
||||
"Examples:\n" +
|
||||
" core php security # Run all checks\n" +
|
||||
" core php security --severity=high # Only high+ severity\n" +
|
||||
" core php security --json # JSON output")
|
||||
|
||||
securityCmd.StringFlag("severity", "Minimum severity (critical, high, medium, low)", &severity)
|
||||
securityCmd.BoolFlag("json", "Output in JSON format", &jsonOutput)
|
||||
securityCmd.BoolFlag("sarif", "Output in SARIF format (for GitHub Security)", &sarif)
|
||||
securityCmd.StringFlag("url", "URL to check HTTP headers (optional)", &url)
|
||||
|
||||
securityCmd.Action(func() error {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get working directory: %w", err)
|
||||
}
|
||||
|
||||
if !phppkg.IsPHPProject(cwd) {
|
||||
return fmt.Errorf("not a PHP project (missing composer.json)")
|
||||
}
|
||||
|
||||
fmt.Printf("%s Running security checks\n\n", dimStyle.Render("Security:"))
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
result, err := phppkg.RunSecurityChecks(ctx, phppkg.SecurityOptions{
|
||||
Dir: cwd,
|
||||
Severity: severity,
|
||||
JSON: jsonOutput,
|
||||
SARIF: sarif,
|
||||
URL: url,
|
||||
Output: os.Stdout,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("security check failed: %w", err)
|
||||
}
|
||||
|
||||
// Print results by category
|
||||
currentCategory := ""
|
||||
for _, check := range result.Checks {
|
||||
category := strings.Split(check.ID, "_")[0]
|
||||
if category != currentCategory {
|
||||
if currentCategory != "" {
|
||||
fmt.Println()
|
||||
}
|
||||
currentCategory = category
|
||||
fmt.Printf(" %s\n", dimStyle.Render(strings.ToUpper(category)+" CHECKS:"))
|
||||
}
|
||||
|
||||
icon := successStyle.Render("✓")
|
||||
if !check.Passed {
|
||||
icon = getSeverityStyle(check.Severity).Render("✗")
|
||||
}
|
||||
|
||||
fmt.Printf(" %s %s\n", icon, check.Name)
|
||||
if !check.Passed && check.Message != "" {
|
||||
fmt.Printf(" %s\n", dimStyle.Render(check.Message))
|
||||
if check.Fix != "" {
|
||||
fmt.Printf(" %s %s\n", dimStyle.Render("Fix:"), check.Fix)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
|
||||
// Print summary
|
||||
fmt.Printf("%s Security scan complete\n", dimStyle.Render("Summary:"))
|
||||
fmt.Printf(" %s %d/%d\n", dimStyle.Render("Passed:"), result.Summary.Passed, result.Summary.Total)
|
||||
|
||||
if result.Summary.Critical > 0 {
|
||||
fmt.Printf(" %s %d\n", phpSecurityCriticalStyle.Render("Critical:"), result.Summary.Critical)
|
||||
}
|
||||
if result.Summary.High > 0 {
|
||||
fmt.Printf(" %s %d\n", phpSecurityHighStyle.Render("High:"), result.Summary.High)
|
||||
}
|
||||
if result.Summary.Medium > 0 {
|
||||
fmt.Printf(" %s %d\n", phpSecurityMediumStyle.Render("Medium:"), result.Summary.Medium)
|
||||
}
|
||||
if result.Summary.Low > 0 {
|
||||
fmt.Printf(" %s %d\n", phpSecurityLowStyle.Render("Low:"), result.Summary.Low)
|
||||
}
|
||||
|
||||
if result.Summary.Critical > 0 || result.Summary.High > 0 {
|
||||
return fmt.Errorf("critical or high severity issues found")
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func addPHPQACommand(parent *clir.Command) {
|
||||
var (
|
||||
quick bool
|
||||
full bool
|
||||
fix bool
|
||||
)
|
||||
|
||||
qaCmd := parent.NewSubCommand("qa", "Run full QA pipeline")
|
||||
qaCmd.LongDescription("Run the complete quality assurance pipeline.\n\n" +
|
||||
"Stages:\n" +
|
||||
" quick: Security audit, code style, PHPStan\n" +
|
||||
" standard: Psalm, tests\n" +
|
||||
" full: Rector dry-run, mutation testing (slow)\n\n" +
|
||||
"Examples:\n" +
|
||||
" core php qa # Run quick + standard stages\n" +
|
||||
" core php qa --quick # Only quick checks\n" +
|
||||
" core php qa --full # All stages including slow ones\n" +
|
||||
" core php qa --fix # Auto-fix where possible")
|
||||
|
||||
qaCmd.BoolFlag("quick", "Only run quick checks", &quick)
|
||||
qaCmd.BoolFlag("full", "Run all stages including slow checks", &full)
|
||||
qaCmd.BoolFlag("fix", "Auto-fix issues where possible", &fix)
|
||||
|
||||
qaCmd.Action(func() error {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get working directory: %w", err)
|
||||
}
|
||||
|
||||
if !phppkg.IsPHPProject(cwd) {
|
||||
return fmt.Errorf("not a PHP project (missing composer.json)")
|
||||
}
|
||||
|
||||
// Determine stages
|
||||
opts := phppkg.QAOptions{
|
||||
Dir: cwd,
|
||||
Quick: quick,
|
||||
Full: full,
|
||||
Fix: fix,
|
||||
}
|
||||
stages := phppkg.GetQAStages(opts)
|
||||
|
||||
// Print header
|
||||
stageNames := make([]string, len(stages))
|
||||
for i, s := range stages {
|
||||
stageNames[i] = string(s)
|
||||
}
|
||||
fmt.Printf("%s Running QA pipeline (%s)\n\n", dimStyle.Render("QA:"), strings.Join(stageNames, " → "))
|
||||
|
||||
ctx := context.Background()
|
||||
var allPassed = true
|
||||
var results []phppkg.QACheckResult
|
||||
|
||||
for _, stage := range stages {
|
||||
fmt.Printf("%s\n", phpQAStageStyle.Render("═══ "+strings.ToUpper(string(stage))+" STAGE ═══"))
|
||||
|
||||
checks := phppkg.GetQAChecks(cwd, stage)
|
||||
if len(checks) == 0 {
|
||||
fmt.Printf(" %s\n\n", dimStyle.Render("No checks available"))
|
||||
continue
|
||||
}
|
||||
|
||||
for _, checkName := range checks {
|
||||
result := runQACheck(ctx, cwd, checkName, fix)
|
||||
result.Stage = stage
|
||||
results = append(results, result)
|
||||
|
||||
icon := phpQAPassedStyle.Render("✓")
|
||||
status := phpQAPassedStyle.Render("passed")
|
||||
if !result.Passed {
|
||||
icon = phpQAFailedStyle.Render("✗")
|
||||
status = phpQAFailedStyle.Render("failed")
|
||||
allPassed = false
|
||||
}
|
||||
|
||||
fmt.Printf(" %s %s %s %s\n", icon, result.Name, status, dimStyle.Render(result.Duration))
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
// Print summary
|
||||
passedCount := 0
|
||||
var failedChecks []phppkg.QACheckResult
|
||||
for _, r := range results {
|
||||
if r.Passed {
|
||||
passedCount++
|
||||
} else {
|
||||
failedChecks = append(failedChecks, r)
|
||||
}
|
||||
}
|
||||
|
||||
if allPassed {
|
||||
fmt.Printf("%s All checks passed (%d/%d)\n", phpQAPassedStyle.Render("QA PASSED:"), passedCount, len(results))
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("%s Some checks failed (%d/%d passed)\n\n", phpQAFailedStyle.Render("QA FAILED:"), passedCount, len(results))
|
||||
|
||||
// Show what needs fixing
|
||||
fmt.Printf("%s\n", dimStyle.Render("To fix:"))
|
||||
for _, check := range failedChecks {
|
||||
fixCmd := getQAFixCommand(check.Name, fix)
|
||||
issue := check.Output
|
||||
if issue == "" {
|
||||
issue = "issues found"
|
||||
}
|
||||
fmt.Printf(" %s %s\n", phpQAFailedStyle.Render("•"), check.Name+": "+issue)
|
||||
if fixCmd != "" {
|
||||
fmt.Printf(" %s %s\n", dimStyle.Render("→"), fixCmd)
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("QA pipeline failed")
|
||||
})
|
||||
}
|
||||
|
||||
func getQAFixCommand(checkName string, fixEnabled bool) string {
|
||||
switch checkName {
|
||||
case "audit":
|
||||
return "composer update && npm update"
|
||||
case "fmt":
|
||||
if fixEnabled {
|
||||
return ""
|
||||
}
|
||||
return "core php fmt --fix"
|
||||
case "analyse":
|
||||
return "Fix PHPStan errors shown above"
|
||||
case "psalm":
|
||||
return "Fix Psalm errors shown above"
|
||||
case "test":
|
||||
return "Fix failing tests shown above"
|
||||
case "rector":
|
||||
if fixEnabled {
|
||||
return ""
|
||||
}
|
||||
return "core php rector --fix"
|
||||
case "infection":
|
||||
return "Improve test coverage for mutated code"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func runQACheck(ctx context.Context, dir string, checkName string, fix bool) phppkg.QACheckResult {
|
||||
start := time.Now()
|
||||
result := phppkg.QACheckResult{Name: checkName, Passed: true}
|
||||
|
||||
// Capture output to prevent noise in QA pipeline
|
||||
var buf bytes.Buffer
|
||||
|
||||
switch checkName {
|
||||
case "audit":
|
||||
auditResults, _ := phppkg.RunAudit(ctx, phppkg.AuditOptions{Dir: dir, Output: io.Discard})
|
||||
var issues []string
|
||||
for _, r := range auditResults {
|
||||
if r.Vulnerabilities > 0 {
|
||||
issues = append(issues, fmt.Sprintf("%s: %d vulnerabilities", r.Tool, r.Vulnerabilities))
|
||||
result.Passed = false
|
||||
} else if r.Error != nil {
|
||||
issues = append(issues, fmt.Sprintf("%s: %v", r.Tool, r.Error))
|
||||
result.Passed = false
|
||||
}
|
||||
}
|
||||
if len(issues) > 0 {
|
||||
result.Output = strings.Join(issues, ", ")
|
||||
}
|
||||
|
||||
case "fmt":
|
||||
err := phppkg.Format(ctx, phppkg.FormatOptions{Dir: dir, Fix: fix, Output: io.Discard})
|
||||
result.Passed = err == nil
|
||||
if err != nil {
|
||||
result.Output = "Code style issues found"
|
||||
}
|
||||
|
||||
case "analyse":
|
||||
err := phppkg.Analyse(ctx, phppkg.AnalyseOptions{Dir: dir, Output: &buf})
|
||||
result.Passed = err == nil
|
||||
if err != nil {
|
||||
result.Output = "Static analysis errors"
|
||||
}
|
||||
|
||||
case "psalm":
|
||||
err := phppkg.RunPsalm(ctx, phppkg.PsalmOptions{Dir: dir, Fix: fix, Output: io.Discard})
|
||||
result.Passed = err == nil
|
||||
if err != nil {
|
||||
result.Output = "Type errors found"
|
||||
}
|
||||
|
||||
case "test":
|
||||
err := phppkg.RunTests(ctx, phppkg.TestOptions{Dir: dir, Output: io.Discard})
|
||||
result.Passed = err == nil
|
||||
if err != nil {
|
||||
result.Output = "Test failures"
|
||||
}
|
||||
|
||||
case "rector":
|
||||
err := phppkg.RunRector(ctx, phppkg.RectorOptions{Dir: dir, Fix: fix, Output: io.Discard})
|
||||
result.Passed = err == nil
|
||||
if err != nil {
|
||||
result.Output = "Code improvements available"
|
||||
}
|
||||
|
||||
case "infection":
|
||||
err := phppkg.RunInfection(ctx, phppkg.InfectionOptions{Dir: dir, Output: io.Discard})
|
||||
result.Passed = err == nil
|
||||
if err != nil {
|
||||
result.Output = "Mutation score below threshold"
|
||||
}
|
||||
}
|
||||
|
||||
result.Duration = time.Since(start).Round(time.Millisecond).String()
|
||||
return result
|
||||
}
|
||||
|
||||
func addPHPRectorCommand(parent *clir.Command) {
|
||||
var (
|
||||
fix bool
|
||||
diff bool
|
||||
clearCache bool
|
||||
)
|
||||
|
||||
rectorCmd := parent.NewSubCommand("rector", "Automated code refactoring")
|
||||
rectorCmd.LongDescription("Run Rector for automated code improvements and PHP upgrades.\n\n" +
|
||||
"Rector can automatically upgrade PHP syntax, improve code quality,\n" +
|
||||
"and apply framework-specific refactorings.\n\n" +
|
||||
"Examples:\n" +
|
||||
" core php rector # Dry-run (show changes)\n" +
|
||||
" core php rector --fix # Apply changes\n" +
|
||||
" core php rector --diff # Show detailed diff")
|
||||
|
||||
rectorCmd.BoolFlag("fix", "Apply changes (default is dry-run)", &fix)
|
||||
rectorCmd.BoolFlag("diff", "Show detailed diff of changes", &diff)
|
||||
rectorCmd.BoolFlag("clear-cache", "Clear Rector cache before running", &clearCache)
|
||||
|
||||
rectorCmd.Action(func() error {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get working directory: %w", err)
|
||||
}
|
||||
|
||||
if !phppkg.IsPHPProject(cwd) {
|
||||
return fmt.Errorf("not a PHP project (missing composer.json)")
|
||||
}
|
||||
|
||||
// Check if Rector is available
|
||||
if !phppkg.DetectRector(cwd) {
|
||||
fmt.Printf("%s Rector not found\n\n", errorStyle.Render("Error:"))
|
||||
fmt.Printf("%s composer require --dev rector/rector\n", dimStyle.Render("Install:"))
|
||||
fmt.Printf("%s ./vendor/bin/rector init\n", dimStyle.Render("Setup:"))
|
||||
return fmt.Errorf("rector not installed")
|
||||
}
|
||||
|
||||
action := "Analysing"
|
||||
if fix {
|
||||
action = "Refactoring"
|
||||
}
|
||||
fmt.Printf("%s %s code with Rector\n\n", dimStyle.Render("Rector:"), action)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
opts := phppkg.RectorOptions{
|
||||
Dir: cwd,
|
||||
Fix: fix,
|
||||
Diff: diff,
|
||||
ClearCache: clearCache,
|
||||
Output: os.Stdout,
|
||||
}
|
||||
|
||||
if err := phppkg.RunRector(ctx, opts); err != nil {
|
||||
if fix {
|
||||
return fmt.Errorf("rector failed: %w", err)
|
||||
}
|
||||
// Dry-run returns non-zero if changes would be made
|
||||
fmt.Printf("\n%s Changes suggested (use --fix to apply)\n", phpQAWarningStyle.Render("Info:"))
|
||||
return nil
|
||||
}
|
||||
|
||||
if fix {
|
||||
fmt.Printf("\n%s Code refactored successfully\n", successStyle.Render("Done:"))
|
||||
} else {
|
||||
fmt.Printf("\n%s No changes needed\n", successStyle.Render("Done:"))
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func addPHPInfectionCommand(parent *clir.Command) {
|
||||
var (
|
||||
minMSI int
|
||||
minCoveredMSI int
|
||||
threads int
|
||||
filter string
|
||||
onlyCovered bool
|
||||
)
|
||||
|
||||
infectionCmd := parent.NewSubCommand("infection", "Mutation testing for test quality")
|
||||
infectionCmd.LongDescription("Run Infection mutation testing to measure test suite quality.\n\n" +
|
||||
"Mutation testing modifies your code and checks if tests catch\n" +
|
||||
"the changes. High mutation score = high quality tests.\n\n" +
|
||||
"Warning: This can be slow on large codebases.\n\n" +
|
||||
"Examples:\n" +
|
||||
" core php infection # Run mutation testing\n" +
|
||||
" core php infection --min-msi=70 # Require 70% mutation score\n" +
|
||||
" core php infection --filter=User # Only test User* files")
|
||||
|
||||
infectionCmd.IntFlag("min-msi", "Minimum mutation score indicator (0-100, default: 50)", &minMSI)
|
||||
infectionCmd.IntFlag("min-covered-msi", "Minimum covered mutation score (0-100, default: 70)", &minCoveredMSI)
|
||||
infectionCmd.IntFlag("threads", "Number of parallel threads (default: 4)", &threads)
|
||||
infectionCmd.StringFlag("filter", "Filter files by pattern", &filter)
|
||||
infectionCmd.BoolFlag("only-covered", "Only mutate covered code", &onlyCovered)
|
||||
|
||||
infectionCmd.Action(func() error {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get working directory: %w", err)
|
||||
}
|
||||
|
||||
if !phppkg.IsPHPProject(cwd) {
|
||||
return fmt.Errorf("not a PHP project (missing composer.json)")
|
||||
}
|
||||
|
||||
// Check if Infection is available
|
||||
if !phppkg.DetectInfection(cwd) {
|
||||
fmt.Printf("%s Infection not found\n\n", errorStyle.Render("Error:"))
|
||||
fmt.Printf("%s composer require --dev infection/infection\n", dimStyle.Render("Install:"))
|
||||
return fmt.Errorf("infection not installed")
|
||||
}
|
||||
|
||||
fmt.Printf("%s Running mutation testing\n", dimStyle.Render("Infection:"))
|
||||
fmt.Printf("%s This may take a while...\n\n", dimStyle.Render("Note:"))
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
opts := phppkg.InfectionOptions{
|
||||
Dir: cwd,
|
||||
MinMSI: minMSI,
|
||||
MinCoveredMSI: minCoveredMSI,
|
||||
Threads: threads,
|
||||
Filter: filter,
|
||||
OnlyCovered: onlyCovered,
|
||||
Output: os.Stdout,
|
||||
}
|
||||
|
||||
if err := phppkg.RunInfection(ctx, opts); err != nil {
|
||||
return fmt.Errorf("mutation testing failed: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("\n%s Mutation testing complete\n", successStyle.Render("Done:"))
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func getSeverityStyle(severity string) lipgloss.Style {
|
||||
switch strings.ToLower(severity) {
|
||||
case "critical":
|
||||
return phpSecurityCriticalStyle
|
||||
case "high":
|
||||
return phpSecurityHighStyle
|
||||
case "medium":
|
||||
return phpSecurityMediumStyle
|
||||
case "low":
|
||||
return phpSecurityLowStyle
|
||||
default:
|
||||
return dimStyle
|
||||
}
|
||||
}
|
||||
|
|
@ -2,11 +2,13 @@ package php
|
|||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// FormatOptions configures PHP code formatting.
|
||||
|
|
@ -236,3 +238,708 @@ func buildPHPStanCommand(opts AnalyseOptions) (string, []string) {
|
|||
|
||||
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
|
||||
Output io.Writer
|
||||
}
|
||||
|
||||
// PsalmType represents the detected Psalm configuration.
|
||||
type PsalmType string
|
||||
|
||||
const (
|
||||
PsalmStandard PsalmType = "psalm"
|
||||
)
|
||||
|
||||
// DetectPsalm checks if Psalm is available in the project.
|
||||
func DetectPsalm(dir string) (PsalmType, bool) {
|
||||
// Check for psalm.xml config
|
||||
psalmConfig := filepath.Join(dir, "psalm.xml")
|
||||
psalmDistConfig := filepath.Join(dir, "psalm.xml.dist")
|
||||
|
||||
hasConfig := false
|
||||
if _, err := os.Stat(psalmConfig); err == nil {
|
||||
hasConfig = true
|
||||
}
|
||||
if _, err := os.Stat(psalmDistConfig); err == nil {
|
||||
hasConfig = true
|
||||
}
|
||||
|
||||
// Check for vendor binary
|
||||
psalmBin := filepath.Join(dir, "vendor", "bin", "psalm")
|
||||
if _, err := os.Stat(psalmBin); err == nil {
|
||||
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 fmt.Errorf("failed to get working directory: %w", err)
|
||||
}
|
||||
opts.Dir = cwd
|
||||
}
|
||||
|
||||
if opts.Output == nil {
|
||||
opts.Output = os.Stdout
|
||||
}
|
||||
|
||||
// Build command
|
||||
vendorBin := filepath.Join(opts.Dir, "vendor", "bin", "psalm")
|
||||
cmdName := "psalm"
|
||||
if _, err := os.Stat(vendorBin); err == nil {
|
||||
cmdName = vendorBin
|
||||
}
|
||||
|
||||
args := []string{"--no-progress"}
|
||||
|
||||
if opts.Level > 0 && opts.Level <= 8 {
|
||||
args = append(args, fmt.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")
|
||||
}
|
||||
|
||||
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 io.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, fmt.Errorf("failed to get working directory: %w", err)
|
||||
}
|
||||
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 _, err := os.Stat(filepath.Join(opts.Dir, "package.json")); err == nil {
|
||||
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 io.Writer
|
||||
}
|
||||
|
||||
// DetectRector checks if Rector is available in the project.
|
||||
func DetectRector(dir string) bool {
|
||||
// Check for rector.php config
|
||||
rectorConfig := filepath.Join(dir, "rector.php")
|
||||
if _, err := os.Stat(rectorConfig); err == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check for vendor binary
|
||||
rectorBin := filepath.Join(dir, "vendor", "bin", "rector")
|
||||
if _, err := os.Stat(rectorBin); err == nil {
|
||||
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 fmt.Errorf("failed to get working directory: %w", err)
|
||||
}
|
||||
opts.Dir = cwd
|
||||
}
|
||||
|
||||
if opts.Output == nil {
|
||||
opts.Output = os.Stdout
|
||||
}
|
||||
|
||||
// Build command
|
||||
vendorBin := filepath.Join(opts.Dir, "vendor", "bin", "rector")
|
||||
cmdName := "rector"
|
||||
if _, err := os.Stat(vendorBin); err == nil {
|
||||
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 io.Writer
|
||||
}
|
||||
|
||||
// DetectInfection checks if Infection is available in the project.
|
||||
func DetectInfection(dir string) bool {
|
||||
// Check for infection config files
|
||||
configs := []string{"infection.json", "infection.json5", "infection.json.dist"}
|
||||
for _, config := range configs {
|
||||
if _, err := os.Stat(filepath.Join(dir, config)); err == nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Check for vendor binary
|
||||
infectionBin := filepath.Join(dir, "vendor", "bin", "infection")
|
||||
if _, err := os.Stat(infectionBin); err == nil {
|
||||
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 fmt.Errorf("failed to get working directory: %w", err)
|
||||
}
|
||||
opts.Dir = cwd
|
||||
}
|
||||
|
||||
if opts.Output == nil {
|
||||
opts.Output = os.Stdout
|
||||
}
|
||||
|
||||
// Build command
|
||||
vendorBin := filepath.Join(opts.Dir, "vendor", "bin", "infection")
|
||||
cmdName := "infection"
|
||||
if _, err := os.Stat(vendorBin); err == nil {
|
||||
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, fmt.Sprintf("--min-msi=%d", minMSI))
|
||||
args = append(args, fmt.Sprintf("--min-covered-msi=%d", minCoveredMSI))
|
||||
args = append(args, fmt.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
|
||||
|
||||
const (
|
||||
QAStageQuick QAStage = "quick"
|
||||
QAStageStandard QAStage = "standard"
|
||||
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", "analyse"}
|
||||
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 io.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, fmt.Errorf("failed to get working directory: %w", err)
|
||||
}
|
||||
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: strings.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 = fmt.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
|
||||
|
||||
envPath := filepath.Join(dir, ".env")
|
||||
envContent, err := os.ReadFile(envPath)
|
||||
if err != nil {
|
||||
return checks
|
||||
}
|
||||
|
||||
envLines := strings.Split(string(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
|
||||
|
||||
// Check .env not in public
|
||||
publicEnvPaths := []string{"public/.env", "public_html/.env"}
|
||||
for _, path := range publicEnvPaths {
|
||||
fullPath := filepath.Join(dir, path)
|
||||
if _, err := os.Stat(fullPath); err == nil {
|
||||
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 _, err := os.Stat(fullPath); err == nil {
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -177,3 +177,341 @@ func TestBuildPHPStanCommand_Good(t *testing.T) {
|
|||
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, "analyse")
|
||||
})
|
||||
|
||||
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")
|
||||
})
|
||||
}
|
||||
|
|
|
|||
36
pkg/updater/build/main.go
Normal file
36
pkg/updater/build/main.go
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Read package.json
|
||||
data, err := ioutil.ReadFile("package.json")
|
||||
if err != nil {
|
||||
fmt.Println("Error reading package.json, skipping version file generation.")
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
// Parse package.json
|
||||
var pkg struct {
|
||||
Version string `json:"version"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &pkg); err != nil {
|
||||
fmt.Println("Error parsing package.json, skipping version file generation.")
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
// Create the version file
|
||||
content := fmt.Sprintf("package updater\n\n// Generated by go:generate. DO NOT EDIT.\n\nconst PkgVersion = %q\n", pkg.Version)
|
||||
err = ioutil.WriteFile("version.go", []byte(content), 0644)
|
||||
if err != nil {
|
||||
fmt.Printf("Error writing version file: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Println("Generated version.go with version:", pkg.Version)
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue