From e4d79ce952d75a05495cc0e9b498163227140b69 Mon Sep 17 00:00:00 2001 From: Snider Date: Thu, 29 Jan 2026 23:58:03 +0000 Subject: [PATCH] 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 --- cmd/build/build.go | 895 +++++++++++++++ cmd/build/commands.go | 18 + cmd/build/tmpl/gui/go.mod.tmpl | 7 + cmd/build/tmpl/gui/html/.gitkeep | 0 cmd/build/tmpl/gui/html/.placeholder | 1 + cmd/build/tmpl/gui/main.go.tmpl | 25 + cmd/php/commands.go | 6 + cmd/php/php.go | 1534 +------------------------- cmd/php/php_build.go | 296 +++++ cmd/php/php_deploy.go | 401 +++++++ cmd/php/php_dev.go | 481 ++++++++ cmd/php/php_packages.go | 157 +++ cmd/php/php_quality.go | 831 ++++++++++++++ pkg/php/quality.go | 707 ++++++++++++ pkg/php/quality_test.go | 338 ++++++ pkg/updater/build/main.go | 36 + 16 files changed, 4250 insertions(+), 1483 deletions(-) create mode 100644 cmd/build/build.go create mode 100644 cmd/build/commands.go create mode 100644 cmd/build/tmpl/gui/go.mod.tmpl create mode 100644 cmd/build/tmpl/gui/html/.gitkeep create mode 100644 cmd/build/tmpl/gui/html/.placeholder create mode 100644 cmd/build/tmpl/gui/main.go.tmpl create mode 100644 cmd/php/php_build.go create mode 100644 cmd/php/php_deploy.go create mode 100644 cmd/php/php_dev.go create mode 100644 cmd/php/php_packages.go create mode 100644 cmd/php/php_quality.go create mode 100644 pkg/updater/build/main.go diff --git a/cmd/build/build.go b/cmd/build/build.go new file mode 100644 index 0000000..09d4ac5 --- /dev/null +++ b/cmd/build/build.go @@ -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 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 + }) +} diff --git a/cmd/build/commands.go b/cmd/build/commands.go new file mode 100644 index 0000000..ebbf0b2 --- /dev/null +++ b/cmd/build/commands.go @@ -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) +} diff --git a/cmd/build/tmpl/gui/go.mod.tmpl b/cmd/build/tmpl/gui/go.mod.tmpl new file mode 100644 index 0000000..1a30708 --- /dev/null +++ b/cmd/build/tmpl/gui/go.mod.tmpl @@ -0,0 +1,7 @@ +module {{.AppName}} + +go 1.21 + +require ( + github.com/wailsapp/wails/v3 v3.0.0-alpha.8 +) diff --git a/cmd/build/tmpl/gui/html/.gitkeep b/cmd/build/tmpl/gui/html/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/cmd/build/tmpl/gui/html/.placeholder b/cmd/build/tmpl/gui/html/.placeholder new file mode 100644 index 0000000..1044078 --- /dev/null +++ b/cmd/build/tmpl/gui/html/.placeholder @@ -0,0 +1 @@ +// This file ensures the 'html' directory is correctly embedded by the Go compiler. diff --git a/cmd/build/tmpl/gui/main.go.tmpl b/cmd/build/tmpl/gui/main.go.tmpl new file mode 100644 index 0000000..2b71fed --- /dev/null +++ b/cmd/build/tmpl/gui/main.go.tmpl @@ -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) + } +} diff --git a/cmd/php/commands.go b/cmd/php/commands.go index e157cf6..a83555a 100644 --- a/cmd/php/commands.go +++ b/cmd/php/commands.go @@ -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 diff --git a/cmd/php/php.go b/cmd/php/php.go index aa18e47..c4f97e4 100644 --- a/cmd/php/php.go +++ b/cmd/php/php.go @@ -2,22 +2,12 @@ package php import ( - "bufio" - "context" - "fmt" - "os" - "os/signal" - "strings" - "syscall" - "time" - "github.com/charmbracelet/lipgloss" "github.com/host-uk/core/cmd/shared" - phppkg "github.com/host-uk/core/pkg/php" "github.com/leaanthony/clir" ) -// Style aliases +// Style aliases from shared var ( successStyle = shared.SuccessStyle errorStyle = shared.ErrorStyle @@ -54,6 +44,39 @@ var ( Bold(true) ) +// QA command styles +var ( + phpQAPassedStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#22c55e")). // green-500 + Bold(true) + + phpQAFailedStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#ef4444")). // red-500 + Bold(true) + + phpQAWarningStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#f59e0b")). // amber-500 + Bold(true) + + phpQAStageStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#6366f1")). // indigo-500 + Bold(true) + + phpSecurityCriticalStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#ef4444")). // red-500 + Bold(true) + + phpSecurityHighStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#f97316")). // orange-500 + Bold(true) + + phpSecurityMediumStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#f59e0b")) // amber-500 + + phpSecurityLowStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#6b7280")) // gray-500 +) + // AddPHPCommands adds PHP/Laravel development commands. func AddPHPCommands(parent *clir.Cli) { phpCmd := parent.NewSubCommand("php", "Laravel/PHP development tools") @@ -65,1489 +88,34 @@ func AddPHPCommands(parent *clir.Cli) { " - Laravel Reverb (WebSocket, port 8080)\n" + " - Redis (port 6379)") + // Development addPHPDevCommand(phpCmd) addPHPLogsCommand(phpCmd) addPHPStopCommand(phpCmd) addPHPStatusCommand(phpCmd) addPHPSSLCommand(phpCmd) + + // Build & Deploy addPHPBuildCommand(phpCmd) addPHPServeCommand(phpCmd) addPHPShellCommand(phpCmd) + + // Quality (existing) addPHPTestCommand(phpCmd) addPHPFmtCommand(phpCmd) addPHPAnalyseCommand(phpCmd) + + // Quality (new) + addPHPPsalmCommand(phpCmd) + addPHPAuditCommand(phpCmd) + addPHPSecurityCommand(phpCmd) + addPHPQACommand(phpCmd) + addPHPRectorCommand(phpCmd) + addPHPInfectionCommand(phpCmd) + + // Package Management addPHPPackagesCommands(phpCmd) + + // Deployment addPHPDeployCommands(phpCmd) } - -func addPHPDevCommand(parent *clir.Command) { - var ( - noVite bool - noHorizon bool - noReverb bool - noRedis bool - https bool - domain string - port int - ) - - devCmd := parent.NewSubCommand("dev", "Start Laravel development environment") - devCmd.LongDescription("Starts all detected Laravel services.\n\n" + - "Auto-detects:\n" + - " - Vite (vite.config.js/ts)\n" + - " - Horizon (config/horizon.php)\n" + - " - Reverb (config/reverb.php)\n" + - " - Redis (from .env)") - - devCmd.BoolFlag("no-vite", "Skip Vite dev server", &noVite) - devCmd.BoolFlag("no-horizon", "Skip Laravel Horizon", &noHorizon) - devCmd.BoolFlag("no-reverb", "Skip Laravel Reverb", &noReverb) - devCmd.BoolFlag("no-redis", "Skip Redis server", &noRedis) - devCmd.BoolFlag("https", "Enable HTTPS with mkcert", &https) - devCmd.StringFlag("domain", "Domain for SSL certificate (default: from APP_URL or localhost)", &domain) - devCmd.IntFlag("port", "FrankenPHP port (default: 8000)", &port) - - devCmd.Action(func() error { - return runPHPDev(phpDevOptions{ - NoVite: noVite, - NoHorizon: noHorizon, - NoReverb: noReverb, - NoRedis: noRedis, - HTTPS: https, - Domain: domain, - Port: port, - }) - }) -} - -type phpDevOptions struct { - NoVite bool - NoHorizon bool - NoReverb bool - NoRedis bool - HTTPS bool - Domain string - Port int -} - -func runPHPDev(opts phpDevOptions) error { - cwd, err := os.Getwd() - if err != nil { - return fmt.Errorf("failed to get working directory: %w", err) - } - - // Check if this is a Laravel project - if !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 - -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 -} - -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 - }) -} - -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 - }) -} - -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 - }) -} - -// 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) - } -} diff --git a/cmd/php/php_build.go b/cmd/php/php_build.go new file mode 100644 index 0000000..942ad73 --- /dev/null +++ b/cmd/php/php_build.go @@ -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 + }) +} diff --git a/cmd/php/php_deploy.go b/cmd/php/php_deploy.go new file mode 100644 index 0000000..4a482fc --- /dev/null +++ b/cmd/php/php_deploy.go @@ -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) + } +} diff --git a/cmd/php/php_dev.go b/cmd/php/php_dev.go new file mode 100644 index 0000000..7f9bd3e --- /dev/null +++ b/cmd/php/php_dev.go @@ -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 +} diff --git a/cmd/php/php_packages.go b/cmd/php/php_packages.go new file mode 100644 index 0000000..306a0a5 --- /dev/null +++ b/cmd/php/php_packages.go @@ -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 + }) +} diff --git a/cmd/php/php_quality.go b/cmd/php/php_quality.go new file mode 100644 index 0000000..57150b3 --- /dev/null +++ b/cmd/php/php_quality.go @@ -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 + } +} diff --git a/pkg/php/quality.go b/pkg/php/quality.go index e53716c..5237408 100644 --- a/pkg/php/quality.go +++ b/pkg/php/quality.go @@ -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 +} diff --git a/pkg/php/quality_test.go b/pkg/php/quality_test.go index cd0954e..a465dcc 100644 --- a/pkg/php/quality_test.go +++ b/pkg/php/quality_test.go @@ -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("