feat(php): add quality commands and split cmd/php for maintainability

Add new PHP quality commands:
- psalm: Psalm static analysis with auto-fix support
- audit: Security audit for composer and npm dependencies
- security: Filesystem security checks (.env exposure, permissions)
- qa: Full QA pipeline with quick/standard/full stages
- rector: Automated code refactoring with dry-run
- infection: Mutation testing

Split cmd/php/php.go (2k+ lines) into logical files:
- php.go: Styles and command registration
- php_dev.go: dev, logs, stop, status, ssl
- php_build.go: build, serve, shell
- php_quality.go: test, fmt, analyse, psalm, audit, security, qa, rector, infection
- php_packages.go: packages link/unlink/update/list
- php_deploy.go: deploy commands

QA pipeline improvements:
- Suppress tool output noise in pipeline mode
- Show actionable "To fix:" suggestions with commands

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Snider 2026-01-29 23:58:03 +00:00
parent e687dc189c
commit e4d79ce952
16 changed files with 4250 additions and 1483 deletions

895
cmd/build/build.go Normal file
View file

@ -0,0 +1,895 @@
// Package build provides project build commands with auto-detection.
package build
import (
"context"
"embed"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"github.com/charmbracelet/lipgloss"
buildpkg "github.com/host-uk/core/pkg/build"
"github.com/host-uk/core/pkg/build/builders"
"github.com/host-uk/core/pkg/build/signing"
"github.com/host-uk/core/pkg/sdk"
"github.com/leaanthony/clir"
"github.com/leaanthony/debme"
"github.com/leaanthony/gosod"
"golang.org/x/net/html"
)
// Build command styles
var (
buildHeaderStyle = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#3b82f6")) // blue-500
buildTargetStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#e2e8f0")) // gray-200
buildSuccessStyle = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#22c55e")) // green-500
buildErrorStyle = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#ef4444")) // red-500
buildDimStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#6b7280")) // gray-500
)
//go:embed all:tmpl/gui
var guiTemplate embed.FS
// AddBuildCommand adds the new build command and its subcommands to the clir app.
func AddBuildCommand(app *clir.Cli) {
buildCmd := app.NewSubCommand("build", "Build projects with auto-detection and cross-compilation")
buildCmd.LongDescription("Builds the current project with automatic type detection.\n" +
"Supports Go, Wails, Docker, LinuxKit, and Taskfile projects.\n" +
"Configuration can be provided via .core/build.yaml or command-line flags.\n\n" +
"Examples:\n" +
" core build # Auto-detect and build\n" +
" core build --type docker # Build Docker image\n" +
" core build --type linuxkit # Build LinuxKit image\n" +
" core build --type linuxkit --config linuxkit.yml --format qcow2-bios")
// Flags for the main build command
var buildType string
var ciMode bool
var targets string
var outputDir string
var doArchive bool
var doChecksum bool
// Docker/LinuxKit specific flags
var configPath string
var format string
var push bool
var imageName string
// Signing flags
var noSign bool
var notarize bool
buildCmd.StringFlag("type", "Builder type (go, wails, docker, linuxkit, taskfile) - auto-detected if not specified", &buildType)
buildCmd.BoolFlag("ci", "CI mode - minimal output with JSON artifact list at the end", &ciMode)
buildCmd.StringFlag("targets", "Comma-separated OS/arch pairs (e.g., linux/amd64,darwin/arm64)", &targets)
buildCmd.StringFlag("output", "Output directory for artifacts (default: dist)", &outputDir)
buildCmd.BoolFlag("archive", "Create archives (tar.gz for linux/darwin, zip for windows) - default: true", &doArchive)
buildCmd.BoolFlag("checksum", "Generate SHA256 checksums and CHECKSUMS.txt - default: true", &doChecksum)
// Docker/LinuxKit specific
buildCmd.StringFlag("config", "Config file path (for linuxkit: YAML config, for docker: Dockerfile)", &configPath)
buildCmd.StringFlag("format", "Output format for linuxkit (iso-bios, qcow2-bios, raw, vmdk)", &format)
buildCmd.BoolFlag("push", "Push Docker image after build (default: false)", &push)
buildCmd.StringFlag("image", "Docker image name (e.g., host-uk/core-devops)", &imageName)
// Signing flags
buildCmd.BoolFlag("no-sign", "Skip all code signing", &noSign)
buildCmd.BoolFlag("notarize", "Enable macOS notarization (requires Apple credentials)", &notarize)
// Set defaults for archive and checksum (true by default)
doArchive = true
doChecksum = true
// Default action for `core build` (no subcommand)
buildCmd.Action(func() error {
return runProjectBuild(buildType, ciMode, targets, outputDir, doArchive, doChecksum, configPath, format, push, imageName, noSign, notarize)
})
// --- `build from-path` command (legacy PWA/GUI build) ---
fromPathCmd := buildCmd.NewSubCommand("from-path", "Build from a local directory.")
var fromPath string
fromPathCmd.StringFlag("path", "The path to the static web application files.", &fromPath)
fromPathCmd.Action(func() error {
if fromPath == "" {
return fmt.Errorf("the --path flag is required")
}
return runBuild(fromPath)
})
// --- `build pwa` command (legacy PWA build) ---
pwaCmd := buildCmd.NewSubCommand("pwa", "Build from a live PWA URL.")
var pwaURL string
pwaCmd.StringFlag("url", "The URL of the PWA to build.", &pwaURL)
pwaCmd.Action(func() error {
if pwaURL == "" {
return fmt.Errorf("a URL argument is required")
}
return runPwaBuild(pwaURL)
})
// --- `build sdk` command ---
sdkBuildCmd := buildCmd.NewSubCommand("sdk", "Generate API SDKs from OpenAPI spec")
sdkBuildCmd.LongDescription("Generates typed API clients from OpenAPI specifications.\n" +
"Supports TypeScript, Python, Go, and PHP.\n\n" +
"Examples:\n" +
" core build sdk # Generate all configured SDKs\n" +
" core build sdk --lang typescript # Generate only TypeScript SDK\n" +
" core build sdk --spec api.yaml # Use specific OpenAPI spec")
var sdkSpec, sdkLang, sdkVersion string
var sdkDryRun bool
sdkBuildCmd.StringFlag("spec", "Path to OpenAPI spec file", &sdkSpec)
sdkBuildCmd.StringFlag("lang", "Generate only this language (typescript, python, go, php)", &sdkLang)
sdkBuildCmd.StringFlag("version", "Version to embed in generated SDKs", &sdkVersion)
sdkBuildCmd.BoolFlag("dry-run", "Show what would be generated without writing files", &sdkDryRun)
sdkBuildCmd.Action(func() error {
return runBuildSDK(sdkSpec, sdkLang, sdkVersion, sdkDryRun)
})
}
// runProjectBuild handles the main `core build` command with auto-detection.
func runProjectBuild(buildType string, ciMode bool, targetsFlag string, outputDir string, doArchive bool, doChecksum bool, configPath string, format string, push bool, imageName string, noSign bool, notarize bool) error {
// Get current working directory as project root
projectDir, err := os.Getwd()
if err != nil {
return fmt.Errorf("failed to get working directory: %w", err)
}
// Load configuration from .core/build.yaml (or defaults)
buildCfg, err := buildpkg.LoadConfig(projectDir)
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}
// Detect project type if not specified
var projectType buildpkg.ProjectType
if buildType != "" {
projectType = buildpkg.ProjectType(buildType)
} else {
projectType, err = buildpkg.PrimaryType(projectDir)
if err != nil {
return fmt.Errorf("failed to detect project type: %w", err)
}
if projectType == "" {
return fmt.Errorf("no supported project type detected in %s\n"+
"Supported types: go (go.mod), wails (wails.json), node (package.json), php (composer.json)", projectDir)
}
}
// Determine targets
var buildTargets []buildpkg.Target
if targetsFlag != "" {
// Parse from command line
buildTargets, err = parseTargets(targetsFlag)
if err != nil {
return err
}
} else if len(buildCfg.Targets) > 0 {
// Use config targets
buildTargets = buildCfg.ToTargets()
} else {
// Fall back to current OS/arch
buildTargets = []buildpkg.Target{
{OS: runtime.GOOS, Arch: runtime.GOARCH},
}
}
// Determine output directory
if outputDir == "" {
outputDir = "dist"
}
// Determine binary name
binaryName := buildCfg.Project.Binary
if binaryName == "" {
binaryName = buildCfg.Project.Name
}
if binaryName == "" {
binaryName = filepath.Base(projectDir)
}
// Print build info (unless CI mode)
if !ciMode {
fmt.Printf("%s Building project\n", buildHeaderStyle.Render("Build:"))
fmt.Printf(" Type: %s\n", buildTargetStyle.Render(string(projectType)))
fmt.Printf(" Output: %s\n", buildTargetStyle.Render(outputDir))
fmt.Printf(" Binary: %s\n", buildTargetStyle.Render(binaryName))
fmt.Printf(" Targets: %s\n", buildTargetStyle.Render(formatTargets(buildTargets)))
fmt.Println()
}
// Get the appropriate builder
builder, err := getBuilder(projectType)
if err != nil {
return err
}
// Create build config for the builder
cfg := &buildpkg.Config{
ProjectDir: projectDir,
OutputDir: outputDir,
Name: binaryName,
Version: buildCfg.Project.Name, // Could be enhanced with git describe
LDFlags: buildCfg.Build.LDFlags,
// Docker/LinuxKit specific
Dockerfile: configPath, // Reuse for Dockerfile path
LinuxKitConfig: configPath,
Push: push,
Image: imageName,
}
// Parse formats for LinuxKit
if format != "" {
cfg.Formats = strings.Split(format, ",")
}
// Execute build
ctx := context.Background()
artifacts, err := builder.Build(ctx, cfg, buildTargets)
if err != nil {
if !ciMode {
fmt.Printf("%s Build failed: %v\n", buildErrorStyle.Render("Error:"), err)
}
return err
}
if !ciMode {
fmt.Printf("%s Built %d artifact(s)\n", buildSuccessStyle.Render("Success:"), len(artifacts))
fmt.Println()
for _, artifact := range artifacts {
relPath, err := filepath.Rel(projectDir, artifact.Path)
if err != nil {
relPath = artifact.Path
}
fmt.Printf(" %s %s %s\n",
buildSuccessStyle.Render("✓"),
buildTargetStyle.Render(relPath),
buildDimStyle.Render(fmt.Sprintf("(%s/%s)", artifact.OS, artifact.Arch)),
)
}
}
// Sign macOS binaries if enabled
signCfg := buildCfg.Sign
if notarize {
signCfg.MacOS.Notarize = true
}
if noSign {
signCfg.Enabled = false
}
if signCfg.Enabled && runtime.GOOS == "darwin" {
if !ciMode {
fmt.Println()
fmt.Printf("%s Signing binaries...\n", buildHeaderStyle.Render("Sign:"))
}
// Convert buildpkg.Artifact to signing.Artifact
signingArtifacts := make([]signing.Artifact, len(artifacts))
for i, a := range artifacts {
signingArtifacts[i] = signing.Artifact{Path: a.Path, OS: a.OS, Arch: a.Arch}
}
if err := signing.SignBinaries(ctx, signCfg, signingArtifacts); err != nil {
if !ciMode {
fmt.Printf("%s Signing failed: %v\n", buildErrorStyle.Render("Error:"), err)
}
return err
}
if signCfg.MacOS.Notarize {
if err := signing.NotarizeBinaries(ctx, signCfg, signingArtifacts); err != nil {
if !ciMode {
fmt.Printf("%s Notarization failed: %v\n", buildErrorStyle.Render("Error:"), err)
}
return err
}
}
}
// Archive artifacts if enabled
var archivedArtifacts []buildpkg.Artifact
if doArchive && len(artifacts) > 0 {
if !ciMode {
fmt.Println()
fmt.Printf("%s Creating archives...\n", buildHeaderStyle.Render("Archive:"))
}
archivedArtifacts, err = buildpkg.ArchiveAll(artifacts)
if err != nil {
if !ciMode {
fmt.Printf("%s Archive failed: %v\n", buildErrorStyle.Render("Error:"), err)
}
return err
}
if !ciMode {
for _, artifact := range archivedArtifacts {
relPath, err := filepath.Rel(projectDir, artifact.Path)
if err != nil {
relPath = artifact.Path
}
fmt.Printf(" %s %s %s\n",
buildSuccessStyle.Render("✓"),
buildTargetStyle.Render(relPath),
buildDimStyle.Render(fmt.Sprintf("(%s/%s)", artifact.OS, artifact.Arch)),
)
}
}
}
// Compute checksums if enabled
var checksummedArtifacts []buildpkg.Artifact
if doChecksum && len(archivedArtifacts) > 0 {
if !ciMode {
fmt.Println()
fmt.Printf("%s Computing checksums...\n", buildHeaderStyle.Render("Checksum:"))
}
checksummedArtifacts, err = buildpkg.ChecksumAll(archivedArtifacts)
if err != nil {
if !ciMode {
fmt.Printf("%s Checksum failed: %v\n", buildErrorStyle.Render("Error:"), err)
}
return err
}
// Write CHECKSUMS.txt
checksumPath := filepath.Join(outputDir, "CHECKSUMS.txt")
if err := buildpkg.WriteChecksumFile(checksummedArtifacts, checksumPath); err != nil {
if !ciMode {
fmt.Printf("%s Failed to write CHECKSUMS.txt: %v\n", buildErrorStyle.Render("Error:"), err)
}
return err
}
// Sign checksums with GPG
if signCfg.Enabled {
if err := signing.SignChecksums(ctx, signCfg, checksumPath); err != nil {
if !ciMode {
fmt.Printf("%s GPG signing failed: %v\n", buildErrorStyle.Render("Error:"), err)
}
return err
}
}
if !ciMode {
for _, artifact := range checksummedArtifacts {
relPath, err := filepath.Rel(projectDir, artifact.Path)
if err != nil {
relPath = artifact.Path
}
fmt.Printf(" %s %s\n",
buildSuccessStyle.Render("✓"),
buildTargetStyle.Render(relPath),
)
fmt.Printf(" %s\n", buildDimStyle.Render(artifact.Checksum))
}
relChecksumPath, err := filepath.Rel(projectDir, checksumPath)
if err != nil {
relChecksumPath = checksumPath
}
fmt.Printf(" %s %s\n",
buildSuccessStyle.Render("✓"),
buildTargetStyle.Render(relChecksumPath),
)
}
} else if doChecksum && len(artifacts) > 0 && !doArchive {
// Checksum raw binaries if archiving is disabled
if !ciMode {
fmt.Println()
fmt.Printf("%s Computing checksums...\n", buildHeaderStyle.Render("Checksum:"))
}
checksummedArtifacts, err = buildpkg.ChecksumAll(artifacts)
if err != nil {
if !ciMode {
fmt.Printf("%s Checksum failed: %v\n", buildErrorStyle.Render("Error:"), err)
}
return err
}
// Write CHECKSUMS.txt
checksumPath := filepath.Join(outputDir, "CHECKSUMS.txt")
if err := buildpkg.WriteChecksumFile(checksummedArtifacts, checksumPath); err != nil {
if !ciMode {
fmt.Printf("%s Failed to write CHECKSUMS.txt: %v\n", buildErrorStyle.Render("Error:"), err)
}
return err
}
// Sign checksums with GPG
if signCfg.Enabled {
if err := signing.SignChecksums(ctx, signCfg, checksumPath); err != nil {
if !ciMode {
fmt.Printf("%s GPG signing failed: %v\n", buildErrorStyle.Render("Error:"), err)
}
return err
}
}
if !ciMode {
for _, artifact := range checksummedArtifacts {
relPath, err := filepath.Rel(projectDir, artifact.Path)
if err != nil {
relPath = artifact.Path
}
fmt.Printf(" %s %s\n",
buildSuccessStyle.Render("✓"),
buildTargetStyle.Render(relPath),
)
fmt.Printf(" %s\n", buildDimStyle.Render(artifact.Checksum))
}
relChecksumPath, err := filepath.Rel(projectDir, checksumPath)
if err != nil {
relChecksumPath = checksumPath
}
fmt.Printf(" %s %s\n",
buildSuccessStyle.Render("✓"),
buildTargetStyle.Render(relChecksumPath),
)
}
}
// Output results for CI mode
if ciMode {
// Determine which artifacts to output (prefer checksummed > archived > raw)
var outputArtifacts []buildpkg.Artifact
if len(checksummedArtifacts) > 0 {
outputArtifacts = checksummedArtifacts
} else if len(archivedArtifacts) > 0 {
outputArtifacts = archivedArtifacts
} else {
outputArtifacts = artifacts
}
// JSON output for CI
output, err := json.MarshalIndent(outputArtifacts, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal artifacts: %w", err)
}
fmt.Println(string(output))
}
return nil
}
// parseTargets parses a comma-separated list of OS/arch pairs.
func parseTargets(targetsFlag string) ([]buildpkg.Target, error) {
parts := strings.Split(targetsFlag, ",")
var targets []buildpkg.Target
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
osArch := strings.Split(part, "/")
if len(osArch) != 2 {
return nil, fmt.Errorf("invalid target format %q, expected OS/arch (e.g., linux/amd64)", part)
}
targets = append(targets, buildpkg.Target{
OS: strings.TrimSpace(osArch[0]),
Arch: strings.TrimSpace(osArch[1]),
})
}
if len(targets) == 0 {
return nil, fmt.Errorf("no valid targets specified")
}
return targets, nil
}
// formatTargets returns a human-readable string of targets.
func formatTargets(targets []buildpkg.Target) string {
var parts []string
for _, t := range targets {
parts = append(parts, t.String())
}
return strings.Join(parts, ", ")
}
// getBuilder returns the appropriate builder for the project type.
func getBuilder(projectType buildpkg.ProjectType) (buildpkg.Builder, error) {
switch projectType {
case buildpkg.ProjectTypeWails:
return builders.NewWailsBuilder(), nil
case buildpkg.ProjectTypeGo:
return builders.NewGoBuilder(), nil
case buildpkg.ProjectTypeDocker:
return builders.NewDockerBuilder(), nil
case buildpkg.ProjectTypeLinuxKit:
return builders.NewLinuxKitBuilder(), nil
case buildpkg.ProjectTypeTaskfile:
return builders.NewTaskfileBuilder(), nil
case buildpkg.ProjectTypeNode:
return nil, fmt.Errorf("Node.js builder not yet implemented")
case buildpkg.ProjectTypePHP:
return nil, fmt.Errorf("PHP builder not yet implemented")
default:
return nil, fmt.Errorf("unsupported project type: %s", projectType)
}
}
// --- SDK Build Logic ---
func runBuildSDK(specPath, lang, version string, dryRun bool) error {
ctx := context.Background()
projectDir, err := os.Getwd()
if err != nil {
return fmt.Errorf("failed to get working directory: %w", err)
}
// Load config
config := sdk.DefaultConfig()
if specPath != "" {
config.Spec = specPath
}
s := sdk.New(projectDir, config)
if version != "" {
s.SetVersion(version)
}
fmt.Printf("%s Generating SDKs\n", buildHeaderStyle.Render("Build SDK:"))
if dryRun {
fmt.Printf(" %s\n", buildDimStyle.Render("(dry-run mode)"))
}
fmt.Println()
// Detect spec
detectedSpec, err := s.DetectSpec()
if err != nil {
fmt.Printf("%s %v\n", buildErrorStyle.Render("Error:"), err)
return err
}
fmt.Printf(" Spec: %s\n", buildTargetStyle.Render(detectedSpec))
if dryRun {
if lang != "" {
fmt.Printf(" Language: %s\n", buildTargetStyle.Render(lang))
} else {
fmt.Printf(" Languages: %s\n", buildTargetStyle.Render(strings.Join(config.Languages, ", ")))
}
fmt.Println()
fmt.Printf("%s Would generate SDKs (dry-run)\n", buildSuccessStyle.Render("OK:"))
return nil
}
if lang != "" {
// Generate single language
if err := s.GenerateLanguage(ctx, lang); err != nil {
fmt.Printf("%s %v\n", buildErrorStyle.Render("Error:"), err)
return err
}
fmt.Printf(" Generated: %s\n", buildTargetStyle.Render(lang))
} else {
// Generate all
if err := s.Generate(ctx); err != nil {
fmt.Printf("%s %v\n", buildErrorStyle.Render("Error:"), err)
return err
}
fmt.Printf(" Generated: %s\n", buildTargetStyle.Render(strings.Join(config.Languages, ", ")))
}
fmt.Println()
fmt.Printf("%s SDK generation complete\n", buildSuccessStyle.Render("Success:"))
return nil
}
// --- PWA Build Logic ---
func runPwaBuild(pwaURL string) error {
fmt.Printf("Starting PWA build from URL: %s\n", pwaURL)
tempDir, err := os.MkdirTemp("", "core-pwa-build-*")
if err != nil {
return fmt.Errorf("failed to create temporary directory: %w", err)
}
// defer os.RemoveAll(tempDir) // Keep temp dir for debugging
fmt.Printf("Downloading PWA to temporary directory: %s\n", tempDir)
if err := downloadPWA(pwaURL, tempDir); err != nil {
return fmt.Errorf("failed to download PWA: %w", err)
}
return runBuild(tempDir)
}
func downloadPWA(baseURL, destDir string) error {
// Fetch the main HTML page
resp, err := http.Get(baseURL)
if err != nil {
return fmt.Errorf("failed to fetch URL %s: %w", baseURL, err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("failed to read response body: %w", err)
}
// Find the manifest URL from the HTML
manifestURL, err := findManifestURL(string(body), baseURL)
if err != nil {
// If no manifest, it's not a PWA, but we can still try to package it as a simple site.
fmt.Println("Warning: no manifest file found. Proceeding with basic site download.")
if err := os.WriteFile(filepath.Join(destDir, "index.html"), body, 0644); err != nil {
return fmt.Errorf("failed to write index.html: %w", err)
}
return nil
}
fmt.Printf("Found manifest: %s\n", manifestURL)
// Fetch and parse the manifest
manifest, err := fetchManifest(manifestURL)
if err != nil {
return fmt.Errorf("failed to fetch or parse manifest: %w", err)
}
// Download all assets listed in the manifest
assets := collectAssets(manifest, manifestURL)
for _, assetURL := range assets {
if err := downloadAsset(assetURL, destDir); err != nil {
fmt.Printf("Warning: failed to download asset %s: %v\n", assetURL, err)
}
}
// Also save the root index.html
if err := os.WriteFile(filepath.Join(destDir, "index.html"), body, 0644); err != nil {
return fmt.Errorf("failed to write index.html: %w", err)
}
fmt.Println("PWA download complete.")
return nil
}
func findManifestURL(htmlContent, baseURL string) (string, error) {
doc, err := html.Parse(strings.NewReader(htmlContent))
if err != nil {
return "", err
}
var manifestPath string
var f func(*html.Node)
f = func(n *html.Node) {
if n.Type == html.ElementNode && n.Data == "link" {
var rel, href string
for _, a := range n.Attr {
if a.Key == "rel" {
rel = a.Val
}
if a.Key == "href" {
href = a.Val
}
}
if rel == "manifest" && href != "" {
manifestPath = href
return
}
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
f(c)
}
}
f(doc)
if manifestPath == "" {
return "", fmt.Errorf("no <link rel=\"manifest\"> tag found")
}
base, err := url.Parse(baseURL)
if err != nil {
return "", err
}
manifestURL, err := base.Parse(manifestPath)
if err != nil {
return "", err
}
return manifestURL.String(), nil
}
func fetchManifest(manifestURL string) (map[string]interface{}, error) {
resp, err := http.Get(manifestURL)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var manifest map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&manifest); err != nil {
return nil, err
}
return manifest, nil
}
func collectAssets(manifest map[string]interface{}, manifestURL string) []string {
var assets []string
base, _ := url.Parse(manifestURL)
// Add start_url
if startURL, ok := manifest["start_url"].(string); ok {
if resolved, err := base.Parse(startURL); err == nil {
assets = append(assets, resolved.String())
}
}
// Add icons
if icons, ok := manifest["icons"].([]interface{}); ok {
for _, icon := range icons {
if iconMap, ok := icon.(map[string]interface{}); ok {
if src, ok := iconMap["src"].(string); ok {
if resolved, err := base.Parse(src); err == nil {
assets = append(assets, resolved.String())
}
}
}
}
}
return assets
}
func downloadAsset(assetURL, destDir string) error {
resp, err := http.Get(assetURL)
if err != nil {
return err
}
defer resp.Body.Close()
u, err := url.Parse(assetURL)
if err != nil {
return err
}
path := filepath.Join(destDir, filepath.FromSlash(u.Path))
if err := os.MkdirAll(filepath.Dir(path), os.ModePerm); err != nil {
return err
}
out, err := os.Create(path)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, resp.Body)
return err
}
// --- Standard Build Logic ---
func runBuild(fromPath string) error {
fmt.Printf("Starting build from path: %s\n", fromPath)
info, err := os.Stat(fromPath)
if err != nil {
return fmt.Errorf("invalid path specified: %w", err)
}
if !info.IsDir() {
return fmt.Errorf("path specified must be a directory")
}
buildDir := ".core/build/app"
htmlDir := filepath.Join(buildDir, "html")
appName := filepath.Base(fromPath)
if strings.HasPrefix(appName, "core-pwa-build-") {
appName = "pwa-app"
}
outputExe := appName
if err := os.RemoveAll(buildDir); err != nil {
return fmt.Errorf("failed to clean build directory: %w", err)
}
// 1. Generate the project from the embedded template
fmt.Println("Generating application from template...")
templateFS, err := debme.FS(guiTemplate, "tmpl/gui")
if err != nil {
return fmt.Errorf("failed to anchor template filesystem: %w", err)
}
sod := gosod.New(templateFS)
if sod == nil {
return fmt.Errorf("failed to create new sod instance")
}
templateData := map[string]string{"AppName": appName}
if err := sod.Extract(buildDir, templateData); err != nil {
return fmt.Errorf("failed to extract template: %w", err)
}
// 2. Copy the user's web app files
fmt.Println("Copying application files...")
if err := copyDir(fromPath, htmlDir); err != nil {
return fmt.Errorf("failed to copy application files: %w", err)
}
// 3. Compile the application
fmt.Println("Compiling application...")
// Run go mod tidy
cmd := exec.Command("go", "mod", "tidy")
cmd.Dir = buildDir
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("go mod tidy failed: %w", err)
}
// Run go build
cmd = exec.Command("go", "build", "-o", outputExe)
cmd.Dir = buildDir
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("go build failed: %w", err)
}
fmt.Printf("\nBuild successful! Executable created at: %s/%s\n", buildDir, outputExe)
return nil
}
// copyDir recursively copies a directory from src to dst.
func copyDir(src, dst string) error {
return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
relPath, err := filepath.Rel(src, path)
if err != nil {
return err
}
dstPath := filepath.Join(dst, relPath)
if info.IsDir() {
return os.MkdirAll(dstPath, info.Mode())
}
srcFile, err := os.Open(path)
if err != nil {
return err
}
defer srcFile.Close()
dstFile, err := os.Create(dstPath)
if err != nil {
return err
}
defer dstFile.Close()
_, err = io.Copy(dstFile, srcFile)
return err
})
}

18
cmd/build/commands.go Normal file
View file

@ -0,0 +1,18 @@
// Package build provides project build commands with auto-detection.
//
// Supports building:
// - Go projects (standard and cross-compilation)
// - Wails desktop applications
// - Docker images
// - LinuxKit VM images
// - Taskfile-based projects
//
// Configuration via .core/build.yaml or command-line flags.
package build
import "github.com/leaanthony/clir"
// AddCommands registers the 'build' command and all subcommands.
func AddCommands(app *clir.Cli) {
AddBuildCommand(app)
}

View file

@ -0,0 +1,7 @@
module {{.AppName}}
go 1.21
require (
github.com/wailsapp/wails/v3 v3.0.0-alpha.8
)

View file

View file

@ -0,0 +1 @@
// This file ensures the 'html' directory is correctly embedded by the Go compiler.

View file

@ -0,0 +1,25 @@
package main
import (
"embed"
"log"
"github.com/wailsapp/wails/v3/pkg/application"
)
//go:embed all:html
var assets embed.FS
func main() {
app := application.New(application.Options{
Name: "{{.AppName}}",
Description: "A web application enclaved by Core.",
Assets: application.AssetOptions{
FS: assets,
},
})
if err := app.Run(); err != nil {
log.Fatal(err)
}
}

View file

@ -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

File diff suppressed because it is too large Load diff

296
cmd/php/php_build.go Normal file
View file

@ -0,0 +1,296 @@
package php
import (
"context"
"fmt"
"os"
"strings"
phppkg "github.com/host-uk/core/pkg/php"
"github.com/leaanthony/clir"
)
func addPHPBuildCommand(parent *clir.Command) {
var (
buildType string
imageName string
tag string
platform string
dockerfile string
outputPath string
format string
template string
noCache bool
)
buildCmd := parent.NewSubCommand("build", "Build Docker or LinuxKit image")
buildCmd.LongDescription("Build a production-ready container image for the PHP project.\n\n" +
"By default, builds a Docker image using FrankenPHP.\n" +
"Use --type linuxkit to build a LinuxKit VM image instead.\n\n" +
"Examples:\n" +
" core php build # Build Docker image\n" +
" core php build --name myapp --tag v1.0 # Build with custom name/tag\n" +
" core php build --type linuxkit # Build LinuxKit image\n" +
" core php build --type linuxkit --format iso # Build ISO image")
buildCmd.StringFlag("type", "Build type: docker (default) or linuxkit", &buildType)
buildCmd.StringFlag("name", "Image name (default: project directory name)", &imageName)
buildCmd.StringFlag("tag", "Image tag (default: latest)", &tag)
buildCmd.StringFlag("platform", "Target platform (e.g., linux/amd64, linux/arm64)", &platform)
buildCmd.StringFlag("dockerfile", "Path to custom Dockerfile", &dockerfile)
buildCmd.StringFlag("output", "Output path for LinuxKit image", &outputPath)
buildCmd.StringFlag("format", "LinuxKit output format: qcow2 (default), iso, raw, vmdk", &format)
buildCmd.StringFlag("template", "LinuxKit template name (default: server-php)", &template)
buildCmd.BoolFlag("no-cache", "Build without cache", &noCache)
buildCmd.Action(func() error {
cwd, err := os.Getwd()
if err != nil {
return fmt.Errorf("failed to get working directory: %w", err)
}
ctx := context.Background()
switch strings.ToLower(buildType) {
case "linuxkit":
return runPHPBuildLinuxKit(ctx, cwd, linuxKitBuildOptions{
OutputPath: outputPath,
Format: format,
Template: template,
})
default:
return runPHPBuildDocker(ctx, cwd, dockerBuildOptions{
ImageName: imageName,
Tag: tag,
Platform: platform,
Dockerfile: dockerfile,
NoCache: noCache,
})
}
})
}
type dockerBuildOptions struct {
ImageName string
Tag string
Platform string
Dockerfile string
NoCache bool
}
type linuxKitBuildOptions struct {
OutputPath string
Format string
Template string
}
func runPHPBuildDocker(ctx context.Context, projectDir string, opts dockerBuildOptions) error {
if !phppkg.IsPHPProject(projectDir) {
return fmt.Errorf("not a PHP project (missing composer.json)")
}
fmt.Printf("%s Building Docker image...\n\n", dimStyle.Render("PHP:"))
// Show detected configuration
config, err := phppkg.DetectDockerfileConfig(projectDir)
if err != nil {
return fmt.Errorf("failed to detect project configuration: %w", err)
}
fmt.Printf("%s %s\n", dimStyle.Render("PHP Version:"), config.PHPVersion)
fmt.Printf("%s %v\n", dimStyle.Render("Laravel:"), config.IsLaravel)
fmt.Printf("%s %v\n", dimStyle.Render("Octane:"), config.HasOctane)
fmt.Printf("%s %v\n", dimStyle.Render("Frontend:"), config.HasAssets)
if len(config.PHPExtensions) > 0 {
fmt.Printf("%s %s\n", dimStyle.Render("Extensions:"), strings.Join(config.PHPExtensions, ", "))
}
fmt.Println()
// Build options
buildOpts := phppkg.DockerBuildOptions{
ProjectDir: projectDir,
ImageName: opts.ImageName,
Tag: opts.Tag,
Platform: opts.Platform,
Dockerfile: opts.Dockerfile,
NoBuildCache: opts.NoCache,
Output: os.Stdout,
}
if buildOpts.ImageName == "" {
buildOpts.ImageName = phppkg.GetLaravelAppName(projectDir)
if buildOpts.ImageName == "" {
buildOpts.ImageName = "php-app"
}
// Sanitize for Docker
buildOpts.ImageName = strings.ToLower(strings.ReplaceAll(buildOpts.ImageName, " ", "-"))
}
if buildOpts.Tag == "" {
buildOpts.Tag = "latest"
}
fmt.Printf("%s %s:%s\n", dimStyle.Render("Image:"), buildOpts.ImageName, buildOpts.Tag)
if opts.Platform != "" {
fmt.Printf("%s %s\n", dimStyle.Render("Platform:"), opts.Platform)
}
fmt.Println()
if err := phppkg.BuildDocker(ctx, buildOpts); err != nil {
return fmt.Errorf("build failed: %w", err)
}
fmt.Printf("\n%s Docker image built successfully\n", successStyle.Render("Done:"))
fmt.Printf("%s docker run -p 80:80 -p 443:443 %s:%s\n",
dimStyle.Render("Run with:"),
buildOpts.ImageName, buildOpts.Tag)
return nil
}
func runPHPBuildLinuxKit(ctx context.Context, projectDir string, opts linuxKitBuildOptions) error {
if !phppkg.IsPHPProject(projectDir) {
return fmt.Errorf("not a PHP project (missing composer.json)")
}
fmt.Printf("%s Building LinuxKit image...\n\n", dimStyle.Render("PHP:"))
buildOpts := phppkg.LinuxKitBuildOptions{
ProjectDir: projectDir,
OutputPath: opts.OutputPath,
Format: opts.Format,
Template: opts.Template,
Output: os.Stdout,
}
if buildOpts.Format == "" {
buildOpts.Format = "qcow2"
}
if buildOpts.Template == "" {
buildOpts.Template = "server-php"
}
fmt.Printf("%s %s\n", dimStyle.Render("Template:"), buildOpts.Template)
fmt.Printf("%s %s\n", dimStyle.Render("Format:"), buildOpts.Format)
fmt.Println()
if err := phppkg.BuildLinuxKit(ctx, buildOpts); err != nil {
return fmt.Errorf("build failed: %w", err)
}
fmt.Printf("\n%s LinuxKit image built successfully\n", successStyle.Render("Done:"))
return nil
}
func addPHPServeCommand(parent *clir.Command) {
var (
imageName string
tag string
containerName string
port int
httpsPort int
detach bool
envFile string
)
serveCmd := parent.NewSubCommand("serve", "Run production container")
serveCmd.LongDescription("Run a production PHP container.\n\n" +
"This starts the built Docker image in production mode.\n\n" +
"Examples:\n" +
" core php serve --name myapp # Run container\n" +
" core php serve --name myapp -d # Run detached\n" +
" core php serve --name myapp --port 8080 # Custom port")
serveCmd.StringFlag("name", "Docker image name (required)", &imageName)
serveCmd.StringFlag("tag", "Image tag (default: latest)", &tag)
serveCmd.StringFlag("container", "Container name", &containerName)
serveCmd.IntFlag("port", "HTTP port (default: 80)", &port)
serveCmd.IntFlag("https-port", "HTTPS port (default: 443)", &httpsPort)
serveCmd.BoolFlag("d", "Run in detached mode", &detach)
serveCmd.StringFlag("env-file", "Path to environment file", &envFile)
serveCmd.Action(func() error {
if imageName == "" {
// Try to detect from current directory
cwd, err := os.Getwd()
if err == nil {
imageName = phppkg.GetLaravelAppName(cwd)
if imageName != "" {
imageName = strings.ToLower(strings.ReplaceAll(imageName, " ", "-"))
}
}
if imageName == "" {
return fmt.Errorf("--name is required: specify the Docker image name")
}
}
ctx := context.Background()
opts := phppkg.ServeOptions{
ImageName: imageName,
Tag: tag,
ContainerName: containerName,
Port: port,
HTTPSPort: httpsPort,
Detach: detach,
EnvFile: envFile,
Output: os.Stdout,
}
fmt.Printf("%s Running production container...\n\n", dimStyle.Render("PHP:"))
fmt.Printf("%s %s:%s\n", dimStyle.Render("Image:"), imageName, func() string {
if tag == "" {
return "latest"
}
return tag
}())
effectivePort := port
if effectivePort == 0 {
effectivePort = 80
}
effectiveHTTPSPort := httpsPort
if effectiveHTTPSPort == 0 {
effectiveHTTPSPort = 443
}
fmt.Printf("%s http://localhost:%d, https://localhost:%d\n",
dimStyle.Render("Ports:"), effectivePort, effectiveHTTPSPort)
fmt.Println()
if err := phppkg.ServeProduction(ctx, opts); err != nil {
return fmt.Errorf("failed to start container: %w", err)
}
if !detach {
fmt.Printf("\n%s Container stopped\n", dimStyle.Render("PHP:"))
}
return nil
})
}
func addPHPShellCommand(parent *clir.Command) {
shellCmd := parent.NewSubCommand("shell", "Open shell in running container")
shellCmd.LongDescription("Open an interactive shell in a running PHP container.\n\n" +
"Examples:\n" +
" core php shell abc123 # Shell into container by ID\n" +
" core php shell myapp # Shell into container by name")
shellCmd.Action(func() error {
args := shellCmd.OtherArgs()
if len(args) == 0 {
return fmt.Errorf("container ID or name is required")
}
ctx := context.Background()
fmt.Printf("%s Opening shell in container %s...\n", dimStyle.Render("PHP:"), args[0])
if err := phppkg.Shell(ctx, args[0]); err != nil {
return fmt.Errorf("failed to open shell: %w", err)
}
return nil
})
}

401
cmd/php/php_deploy.go Normal file
View file

@ -0,0 +1,401 @@
package php
import (
"context"
"fmt"
"os"
"time"
"github.com/charmbracelet/lipgloss"
phppkg "github.com/host-uk/core/pkg/php"
"github.com/leaanthony/clir"
)
// Deploy command styles
var (
phpDeployStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#10b981")) // emerald-500
phpDeployPendingStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#f59e0b")) // amber-500
phpDeployFailedStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#ef4444")) // red-500
)
func addPHPDeployCommands(parent *clir.Command) {
// Main deploy command
addPHPDeployCommand(parent)
// Deploy status subcommand (using colon notation: deploy:status)
addPHPDeployStatusCommand(parent)
// Deploy rollback subcommand
addPHPDeployRollbackCommand(parent)
// Deploy list subcommand
addPHPDeployListCommand(parent)
}
func addPHPDeployCommand(parent *clir.Command) {
var (
staging bool
force bool
wait bool
)
deployCmd := parent.NewSubCommand("deploy", "Deploy to Coolify")
deployCmd.LongDescription("Deploy the PHP application to Coolify.\n\n" +
"Requires configuration in .env:\n" +
" COOLIFY_URL=https://coolify.example.com\n" +
" COOLIFY_TOKEN=your-api-token\n" +
" COOLIFY_APP_ID=production-app-id\n" +
" COOLIFY_STAGING_APP_ID=staging-app-id (optional)\n\n" +
"Examples:\n" +
" core php deploy # Deploy to production\n" +
" core php deploy --staging # Deploy to staging\n" +
" core php deploy --force # Force deployment\n" +
" core php deploy --wait # Wait for deployment to complete")
deployCmd.BoolFlag("staging", "Deploy to staging environment", &staging)
deployCmd.BoolFlag("force", "Force deployment even if no changes detected", &force)
deployCmd.BoolFlag("wait", "Wait for deployment to complete", &wait)
deployCmd.Action(func() error {
cwd, err := os.Getwd()
if err != nil {
return fmt.Errorf("failed to get working directory: %w", err)
}
env := phppkg.EnvProduction
if staging {
env = phppkg.EnvStaging
}
fmt.Printf("%s Deploying to %s...\n\n", dimStyle.Render("Deploy:"), env)
ctx := context.Background()
opts := phppkg.DeployOptions{
Dir: cwd,
Environment: env,
Force: force,
Wait: wait,
}
status, err := phppkg.Deploy(ctx, opts)
if err != nil {
return fmt.Errorf("deployment failed: %w", err)
}
printDeploymentStatus(status)
if wait {
if phppkg.IsDeploymentSuccessful(status.Status) {
fmt.Printf("\n%s Deployment completed successfully\n", successStyle.Render("Done:"))
} else {
fmt.Printf("\n%s Deployment ended with status: %s\n", errorStyle.Render("Warning:"), status.Status)
}
} else {
fmt.Printf("\n%s Deployment triggered. Use 'core php deploy:status' to check progress.\n", successStyle.Render("Done:"))
}
return nil
})
}
func addPHPDeployStatusCommand(parent *clir.Command) {
var (
staging bool
deploymentID string
)
statusCmd := parent.NewSubCommand("deploy:status", "Show deployment status")
statusCmd.LongDescription("Show the status of a deployment.\n\n" +
"Examples:\n" +
" core php deploy:status # Latest production deployment\n" +
" core php deploy:status --staging # Latest staging deployment\n" +
" core php deploy:status --id abc123 # Specific deployment")
statusCmd.BoolFlag("staging", "Check staging environment", &staging)
statusCmd.StringFlag("id", "Specific deployment ID", &deploymentID)
statusCmd.Action(func() error {
cwd, err := os.Getwd()
if err != nil {
return fmt.Errorf("failed to get working directory: %w", err)
}
env := phppkg.EnvProduction
if staging {
env = phppkg.EnvStaging
}
fmt.Printf("%s Checking %s deployment status...\n\n", dimStyle.Render("Deploy:"), env)
ctx := context.Background()
opts := phppkg.StatusOptions{
Dir: cwd,
Environment: env,
DeploymentID: deploymentID,
}
status, err := phppkg.DeployStatus(ctx, opts)
if err != nil {
return fmt.Errorf("failed to get status: %w", err)
}
printDeploymentStatus(status)
return nil
})
}
func addPHPDeployRollbackCommand(parent *clir.Command) {
var (
staging bool
deploymentID string
wait bool
)
rollbackCmd := parent.NewSubCommand("deploy:rollback", "Rollback to previous deployment")
rollbackCmd.LongDescription("Rollback to a previous deployment.\n\n" +
"If no deployment ID is specified, rolls back to the most recent\n" +
"successful deployment.\n\n" +
"Examples:\n" +
" core php deploy:rollback # Rollback to previous\n" +
" core php deploy:rollback --staging # Rollback staging\n" +
" core php deploy:rollback --id abc123 # Rollback to specific deployment")
rollbackCmd.BoolFlag("staging", "Rollback staging environment", &staging)
rollbackCmd.StringFlag("id", "Specific deployment ID to rollback to", &deploymentID)
rollbackCmd.BoolFlag("wait", "Wait for rollback to complete", &wait)
rollbackCmd.Action(func() error {
cwd, err := os.Getwd()
if err != nil {
return fmt.Errorf("failed to get working directory: %w", err)
}
env := phppkg.EnvProduction
if staging {
env = phppkg.EnvStaging
}
fmt.Printf("%s Rolling back %s...\n\n", dimStyle.Render("Deploy:"), env)
ctx := context.Background()
opts := phppkg.RollbackOptions{
Dir: cwd,
Environment: env,
DeploymentID: deploymentID,
Wait: wait,
}
status, err := phppkg.Rollback(ctx, opts)
if err != nil {
return fmt.Errorf("rollback failed: %w", err)
}
printDeploymentStatus(status)
if wait {
if phppkg.IsDeploymentSuccessful(status.Status) {
fmt.Printf("\n%s Rollback completed successfully\n", successStyle.Render("Done:"))
} else {
fmt.Printf("\n%s Rollback ended with status: %s\n", errorStyle.Render("Warning:"), status.Status)
}
} else {
fmt.Printf("\n%s Rollback triggered. Use 'core php deploy:status' to check progress.\n", successStyle.Render("Done:"))
}
return nil
})
}
func addPHPDeployListCommand(parent *clir.Command) {
var (
staging bool
limit int
)
listCmd := parent.NewSubCommand("deploy:list", "List recent deployments")
listCmd.LongDescription("List recent deployments.\n\n" +
"Examples:\n" +
" core php deploy:list # List production deployments\n" +
" core php deploy:list --staging # List staging deployments\n" +
" core php deploy:list --limit 20 # List more deployments")
listCmd.BoolFlag("staging", "List staging deployments", &staging)
listCmd.IntFlag("limit", "Number of deployments to list (default: 10)", &limit)
listCmd.Action(func() error {
cwd, err := os.Getwd()
if err != nil {
return fmt.Errorf("failed to get working directory: %w", err)
}
env := phppkg.EnvProduction
if staging {
env = phppkg.EnvStaging
}
if limit == 0 {
limit = 10
}
fmt.Printf("%s Recent %s deployments:\n\n", dimStyle.Render("Deploy:"), env)
ctx := context.Background()
deployments, err := phppkg.ListDeployments(ctx, cwd, env, limit)
if err != nil {
return fmt.Errorf("failed to list deployments: %w", err)
}
if len(deployments) == 0 {
fmt.Printf("%s No deployments found\n", dimStyle.Render("Info:"))
return nil
}
for i, d := range deployments {
printDeploymentSummary(i+1, &d)
}
return nil
})
}
func printDeploymentStatus(status *phppkg.DeploymentStatus) {
// Status with color
statusStyle := phpDeployStyle
switch status.Status {
case "queued", "building", "deploying", "pending", "rolling_back":
statusStyle = phpDeployPendingStyle
case "failed", "error", "cancelled":
statusStyle = phpDeployFailedStyle
}
fmt.Printf("%s %s\n", dimStyle.Render("Status:"), statusStyle.Render(status.Status))
if status.ID != "" {
fmt.Printf("%s %s\n", dimStyle.Render("ID:"), status.ID)
}
if status.URL != "" {
fmt.Printf("%s %s\n", dimStyle.Render("URL:"), linkStyle.Render(status.URL))
}
if status.Branch != "" {
fmt.Printf("%s %s\n", dimStyle.Render("Branch:"), status.Branch)
}
if status.Commit != "" {
commit := status.Commit
if len(commit) > 7 {
commit = commit[:7]
}
fmt.Printf("%s %s\n", dimStyle.Render("Commit:"), commit)
if status.CommitMessage != "" {
// Truncate long messages
msg := status.CommitMessage
if len(msg) > 60 {
msg = msg[:57] + "..."
}
fmt.Printf("%s %s\n", dimStyle.Render("Message:"), msg)
}
}
if !status.StartedAt.IsZero() {
fmt.Printf("%s %s\n", dimStyle.Render("Started:"), status.StartedAt.Format(time.RFC3339))
}
if !status.CompletedAt.IsZero() {
fmt.Printf("%s %s\n", dimStyle.Render("Completed:"), status.CompletedAt.Format(time.RFC3339))
if !status.StartedAt.IsZero() {
duration := status.CompletedAt.Sub(status.StartedAt)
fmt.Printf("%s %s\n", dimStyle.Render("Duration:"), duration.Round(time.Second))
}
}
}
func printDeploymentSummary(index int, status *phppkg.DeploymentStatus) {
// Status with color
statusStyle := phpDeployStyle
switch status.Status {
case "queued", "building", "deploying", "pending", "rolling_back":
statusStyle = phpDeployPendingStyle
case "failed", "error", "cancelled":
statusStyle = phpDeployFailedStyle
}
// Format: #1 [finished] abc1234 - commit message (2 hours ago)
id := status.ID
if len(id) > 8 {
id = id[:8]
}
commit := status.Commit
if len(commit) > 7 {
commit = commit[:7]
}
msg := status.CommitMessage
if len(msg) > 40 {
msg = msg[:37] + "..."
}
age := ""
if !status.StartedAt.IsZero() {
age = formatTimeAgo(status.StartedAt)
}
fmt.Printf(" %s %s %s",
dimStyle.Render(fmt.Sprintf("#%d", index)),
statusStyle.Render(fmt.Sprintf("[%s]", status.Status)),
id,
)
if commit != "" {
fmt.Printf(" %s", commit)
}
if msg != "" {
fmt.Printf(" - %s", msg)
}
if age != "" {
fmt.Printf(" %s", dimStyle.Render(fmt.Sprintf("(%s)", age)))
}
fmt.Println()
}
func formatTimeAgo(t time.Time) string {
duration := time.Since(t)
switch {
case duration < time.Minute:
return "just now"
case duration < time.Hour:
mins := int(duration.Minutes())
if mins == 1 {
return "1 minute ago"
}
return fmt.Sprintf("%d minutes ago", mins)
case duration < 24*time.Hour:
hours := int(duration.Hours())
if hours == 1 {
return "1 hour ago"
}
return fmt.Sprintf("%d hours ago", hours)
default:
days := int(duration.Hours() / 24)
if days == 1 {
return "1 day ago"
}
return fmt.Sprintf("%d days ago", days)
}
}

481
cmd/php/php_dev.go Normal file
View file

@ -0,0 +1,481 @@
package php
import (
"bufio"
"context"
"fmt"
"os"
"os/signal"
"strings"
"syscall"
"time"
"github.com/charmbracelet/lipgloss"
phppkg "github.com/host-uk/core/pkg/php"
"github.com/leaanthony/clir"
)
func addPHPDevCommand(parent *clir.Command) {
var (
noVite bool
noHorizon bool
noReverb bool
noRedis bool
https bool
domain string
port int
)
devCmd := parent.NewSubCommand("dev", "Start Laravel development environment")
devCmd.LongDescription("Starts all detected Laravel services.\n\n" +
"Auto-detects:\n" +
" - Vite (vite.config.js/ts)\n" +
" - Horizon (config/horizon.php)\n" +
" - Reverb (config/reverb.php)\n" +
" - Redis (from .env)")
devCmd.BoolFlag("no-vite", "Skip Vite dev server", &noVite)
devCmd.BoolFlag("no-horizon", "Skip Laravel Horizon", &noHorizon)
devCmd.BoolFlag("no-reverb", "Skip Laravel Reverb", &noReverb)
devCmd.BoolFlag("no-redis", "Skip Redis server", &noRedis)
devCmd.BoolFlag("https", "Enable HTTPS with mkcert", &https)
devCmd.StringFlag("domain", "Domain for SSL certificate (default: from APP_URL or localhost)", &domain)
devCmd.IntFlag("port", "FrankenPHP port (default: 8000)", &port)
devCmd.Action(func() error {
return runPHPDev(phpDevOptions{
NoVite: noVite,
NoHorizon: noHorizon,
NoReverb: noReverb,
NoRedis: noRedis,
HTTPS: https,
Domain: domain,
Port: port,
})
})
}
type phpDevOptions struct {
NoVite bool
NoHorizon bool
NoReverb bool
NoRedis bool
HTTPS bool
Domain string
Port int
}
func runPHPDev(opts phpDevOptions) error {
cwd, err := os.Getwd()
if err != nil {
return fmt.Errorf("failed to get working directory: %w", err)
}
// Check if this is a Laravel project
if !phppkg.IsLaravelProject(cwd) {
return fmt.Errorf("not a Laravel project (missing artisan or laravel/framework)")
}
// Get app name for display
appName := phppkg.GetLaravelAppName(cwd)
if appName == "" {
appName = "Laravel"
}
fmt.Printf("%s Starting %s development environment\n\n", dimStyle.Render("PHP:"), appName)
// Detect services
services := phppkg.DetectServices(cwd)
fmt.Printf("%s Detected services:\n", dimStyle.Render("Services:"))
for _, svc := range services {
fmt.Printf(" %s %s\n", successStyle.Render("*"), svc)
}
fmt.Println()
// Setup options
port := opts.Port
if port == 0 {
port = 8000
}
devOpts := phppkg.Options{
Dir: cwd,
NoVite: opts.NoVite,
NoHorizon: opts.NoHorizon,
NoReverb: opts.NoReverb,
NoRedis: opts.NoRedis,
HTTPS: opts.HTTPS,
Domain: opts.Domain,
FrankenPHPPort: port,
}
// Create and start dev server
server := phppkg.NewDevServer(devOpts)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Handle shutdown signals
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-sigCh
fmt.Printf("\n%s Shutting down...\n", dimStyle.Render("PHP:"))
cancel()
}()
if err := server.Start(ctx, devOpts); err != nil {
return fmt.Errorf("failed to start services: %w", err)
}
// Print status
fmt.Printf("%s Services started:\n", successStyle.Render("Running:"))
printServiceStatuses(server.Status())
fmt.Println()
// Print URLs
appURL := phppkg.GetLaravelAppURL(cwd)
if appURL == "" {
if opts.HTTPS {
appURL = fmt.Sprintf("https://localhost:%d", port)
} else {
appURL = fmt.Sprintf("http://localhost:%d", port)
}
}
fmt.Printf("%s %s\n", dimStyle.Render("App URL:"), linkStyle.Render(appURL))
// Check for Vite
if !opts.NoVite && containsService(services, phppkg.ServiceVite) {
fmt.Printf("%s %s\n", dimStyle.Render("Vite:"), linkStyle.Render("http://localhost:5173"))
}
fmt.Printf("\n%s\n\n", dimStyle.Render("Press Ctrl+C to stop all services"))
// Stream unified logs
logsReader, err := server.Logs("", true)
if err != nil {
fmt.Printf("%s Failed to get logs: %v\n", errorStyle.Render("Warning:"), err)
} else {
defer logsReader.Close()
scanner := bufio.NewScanner(logsReader)
for scanner.Scan() {
select {
case <-ctx.Done():
goto shutdown
default:
line := scanner.Text()
printColoredLog(line)
}
}
}
shutdown:
// Stop services
if err := server.Stop(); err != nil {
fmt.Printf("%s Error stopping services: %v\n", errorStyle.Render("Error:"), err)
}
fmt.Printf("%s All services stopped\n", successStyle.Render("Done:"))
return nil
}
func addPHPLogsCommand(parent *clir.Command) {
var follow bool
var service string
logsCmd := parent.NewSubCommand("logs", "View service logs")
logsCmd.LongDescription("Stream logs from Laravel services.\n\n" +
"Services: frankenphp, vite, horizon, reverb, redis")
logsCmd.BoolFlag("follow", "Follow log output", &follow)
logsCmd.StringFlag("service", "Specific service (default: all)", &service)
logsCmd.Action(func() error {
return runPHPLogs(service, follow)
})
}
func runPHPLogs(service string, follow bool) error {
cwd, err := os.Getwd()
if err != nil {
return err
}
if !phppkg.IsLaravelProject(cwd) {
return fmt.Errorf("not a Laravel project")
}
// Create a minimal server just to access logs
server := phppkg.NewDevServer(phppkg.Options{Dir: cwd})
logsReader, err := server.Logs(service, follow)
if err != nil {
return fmt.Errorf("failed to get logs: %w", err)
}
defer logsReader.Close()
// Handle interrupt
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-sigCh
cancel()
}()
scanner := bufio.NewScanner(logsReader)
for scanner.Scan() {
select {
case <-ctx.Done():
return nil
default:
printColoredLog(scanner.Text())
}
}
return scanner.Err()
}
func addPHPStopCommand(parent *clir.Command) {
stopCmd := parent.NewSubCommand("stop", "Stop all Laravel services")
stopCmd.Action(func() error {
return runPHPStop()
})
}
func runPHPStop() error {
cwd, err := os.Getwd()
if err != nil {
return err
}
fmt.Printf("%s Stopping services...\n", dimStyle.Render("PHP:"))
// We need to find running processes
// This is a simplified version - in practice you'd want to track PIDs
server := phppkg.NewDevServer(phppkg.Options{Dir: cwd})
if err := server.Stop(); err != nil {
return fmt.Errorf("failed to stop services: %w", err)
}
fmt.Printf("%s All services stopped\n", successStyle.Render("Done:"))
return nil
}
func addPHPStatusCommand(parent *clir.Command) {
statusCmd := parent.NewSubCommand("status", "Show service status")
statusCmd.Action(func() error {
return runPHPStatus()
})
}
func runPHPStatus() error {
cwd, err := os.Getwd()
if err != nil {
return err
}
if !phppkg.IsLaravelProject(cwd) {
return fmt.Errorf("not a Laravel project")
}
appName := phppkg.GetLaravelAppName(cwd)
if appName == "" {
appName = "Laravel"
}
fmt.Printf("%s %s\n\n", dimStyle.Render("Project:"), appName)
// Detect available services
services := phppkg.DetectServices(cwd)
fmt.Printf("%s\n", dimStyle.Render("Detected services:"))
for _, svc := range services {
style := getServiceStyle(string(svc))
fmt.Printf(" %s %s\n", style.Render("*"), svc)
}
fmt.Println()
// Package manager
pm := phppkg.DetectPackageManager(cwd)
fmt.Printf("%s %s\n", dimStyle.Render("Package manager:"), pm)
// FrankenPHP status
if phppkg.IsFrankenPHPProject(cwd) {
fmt.Printf("%s %s\n", dimStyle.Render("Octane server:"), "FrankenPHP")
}
// SSL status
appURL := phppkg.GetLaravelAppURL(cwd)
if appURL != "" {
domain := phppkg.ExtractDomainFromURL(appURL)
if phppkg.CertsExist(domain, phppkg.SSLOptions{}) {
fmt.Printf("%s %s\n", dimStyle.Render("SSL certificates:"), successStyle.Render("installed"))
} else {
fmt.Printf("%s %s\n", dimStyle.Render("SSL certificates:"), dimStyle.Render("not setup"))
}
}
return nil
}
func addPHPSSLCommand(parent *clir.Command) {
var domain string
sslCmd := parent.NewSubCommand("ssl", "Setup SSL certificates with mkcert")
sslCmd.StringFlag("domain", "Domain for certificate (default: from APP_URL)", &domain)
sslCmd.Action(func() error {
return runPHPSSL(domain)
})
}
func runPHPSSL(domain string) error {
cwd, err := os.Getwd()
if err != nil {
return err
}
// Get domain from APP_URL if not specified
if domain == "" {
appURL := phppkg.GetLaravelAppURL(cwd)
if appURL != "" {
domain = phppkg.ExtractDomainFromURL(appURL)
}
}
if domain == "" {
domain = "localhost"
}
// Check if mkcert is installed
if !phppkg.IsMkcertInstalled() {
fmt.Printf("%s mkcert is not installed\n", errorStyle.Render("Error:"))
fmt.Println("\nInstall with:")
fmt.Println(" macOS: brew install mkcert")
fmt.Println(" Linux: see https://github.com/FiloSottile/mkcert")
return fmt.Errorf("mkcert not installed")
}
fmt.Printf("%s Setting up SSL for %s\n", dimStyle.Render("SSL:"), domain)
// Check if certs already exist
if phppkg.CertsExist(domain, phppkg.SSLOptions{}) {
fmt.Printf("%s Certificates already exist\n", dimStyle.Render("Skip:"))
certFile, keyFile, _ := phppkg.CertPaths(domain, phppkg.SSLOptions{})
fmt.Printf("%s %s\n", dimStyle.Render("Cert:"), certFile)
fmt.Printf("%s %s\n", dimStyle.Render("Key:"), keyFile)
return nil
}
// Setup SSL
if err := phppkg.SetupSSL(domain, phppkg.SSLOptions{}); err != nil {
return fmt.Errorf("failed to setup SSL: %w", err)
}
certFile, keyFile, _ := phppkg.CertPaths(domain, phppkg.SSLOptions{})
fmt.Printf("%s SSL certificates created\n", successStyle.Render("Done:"))
fmt.Printf("%s %s\n", dimStyle.Render("Cert:"), certFile)
fmt.Printf("%s %s\n", dimStyle.Render("Key:"), keyFile)
return nil
}
// Helper functions for dev commands
func printServiceStatuses(statuses []phppkg.ServiceStatus) {
for _, s := range statuses {
style := getServiceStyle(s.Name)
var statusText string
if s.Error != nil {
statusText = phpStatusError.Render(fmt.Sprintf("error: %v", s.Error))
} else if s.Running {
statusText = phpStatusRunning.Render("running")
if s.Port > 0 {
statusText += dimStyle.Render(fmt.Sprintf(" (port %d)", s.Port))
}
if s.PID > 0 {
statusText += dimStyle.Render(fmt.Sprintf(" [pid %d]", s.PID))
}
} else {
statusText = phpStatusStopped.Render("stopped")
}
fmt.Printf(" %s %s\n", style.Render(s.Name+":"), statusText)
}
}
func printColoredLog(line string) {
// Parse service prefix from log line
timestamp := time.Now().Format("15:04:05")
var style lipgloss.Style
serviceName := ""
if strings.HasPrefix(line, "[FrankenPHP]") {
style = phpFrankenPHPStyle
serviceName = "FrankenPHP"
line = strings.TrimPrefix(line, "[FrankenPHP] ")
} else if strings.HasPrefix(line, "[Vite]") {
style = phpViteStyle
serviceName = "Vite"
line = strings.TrimPrefix(line, "[Vite] ")
} else if strings.HasPrefix(line, "[Horizon]") {
style = phpHorizonStyle
serviceName = "Horizon"
line = strings.TrimPrefix(line, "[Horizon] ")
} else if strings.HasPrefix(line, "[Reverb]") {
style = phpReverbStyle
serviceName = "Reverb"
line = strings.TrimPrefix(line, "[Reverb] ")
} else if strings.HasPrefix(line, "[Redis]") {
style = phpRedisStyle
serviceName = "Redis"
line = strings.TrimPrefix(line, "[Redis] ")
} else {
// Unknown service, print as-is
fmt.Printf("%s %s\n", dimStyle.Render(timestamp), line)
return
}
fmt.Printf("%s %s %s\n",
dimStyle.Render(timestamp),
style.Render(fmt.Sprintf("[%s]", serviceName)),
line,
)
}
func getServiceStyle(name string) lipgloss.Style {
switch strings.ToLower(name) {
case "frankenphp":
return phpFrankenPHPStyle
case "vite":
return phpViteStyle
case "horizon":
return phpHorizonStyle
case "reverb":
return phpReverbStyle
case "redis":
return phpRedisStyle
default:
return dimStyle
}
}
func containsService(services []phppkg.DetectedService, target phppkg.DetectedService) bool {
for _, s := range services {
if s == target {
return true
}
}
return false
}

157
cmd/php/php_packages.go Normal file
View file

@ -0,0 +1,157 @@
package php
import (
"fmt"
"os"
phppkg "github.com/host-uk/core/pkg/php"
"github.com/leaanthony/clir"
)
func addPHPPackagesCommands(parent *clir.Command) {
packagesCmd := parent.NewSubCommand("packages", "Manage local PHP packages")
packagesCmd.LongDescription("Link and manage local PHP packages for development.\n\n" +
"Similar to npm link, this adds path repositories to composer.json\n" +
"for developing packages alongside your project.\n\n" +
"Commands:\n" +
" link - Link local packages by path\n" +
" unlink - Unlink packages by name\n" +
" update - Update linked packages\n" +
" list - List linked packages")
addPHPPackagesLinkCommand(packagesCmd)
addPHPPackagesUnlinkCommand(packagesCmd)
addPHPPackagesUpdateCommand(packagesCmd)
addPHPPackagesListCommand(packagesCmd)
}
func addPHPPackagesLinkCommand(parent *clir.Command) {
linkCmd := parent.NewSubCommand("link", "Link local packages")
linkCmd.LongDescription("Link local PHP packages for development.\n\n" +
"Adds path repositories to composer.json with symlink enabled.\n" +
"The package name is auto-detected from each path's composer.json.\n\n" +
"Examples:\n" +
" core php packages link ../my-package\n" +
" core php packages link ../pkg-a ../pkg-b")
linkCmd.Action(func() error {
args := linkCmd.OtherArgs()
if len(args) == 0 {
return fmt.Errorf("at least one package path is required")
}
cwd, err := os.Getwd()
if err != nil {
return fmt.Errorf("failed to get working directory: %w", err)
}
fmt.Printf("%s Linking packages...\n\n", dimStyle.Render("PHP:"))
if err := phppkg.LinkPackages(cwd, args); err != nil {
return fmt.Errorf("failed to link packages: %w", err)
}
fmt.Printf("\n%s Packages linked. Run 'composer update' to install.\n", successStyle.Render("Done:"))
return nil
})
}
func addPHPPackagesUnlinkCommand(parent *clir.Command) {
unlinkCmd := parent.NewSubCommand("unlink", "Unlink packages")
unlinkCmd.LongDescription("Remove linked packages from composer.json.\n\n" +
"Removes path repositories by package name.\n\n" +
"Examples:\n" +
" core php packages unlink vendor/my-package\n" +
" core php packages unlink vendor/pkg-a vendor/pkg-b")
unlinkCmd.Action(func() error {
args := unlinkCmd.OtherArgs()
if len(args) == 0 {
return fmt.Errorf("at least one package name is required")
}
cwd, err := os.Getwd()
if err != nil {
return fmt.Errorf("failed to get working directory: %w", err)
}
fmt.Printf("%s Unlinking packages...\n\n", dimStyle.Render("PHP:"))
if err := phppkg.UnlinkPackages(cwd, args); err != nil {
return fmt.Errorf("failed to unlink packages: %w", err)
}
fmt.Printf("\n%s Packages unlinked. Run 'composer update' to remove.\n", successStyle.Render("Done:"))
return nil
})
}
func addPHPPackagesUpdateCommand(parent *clir.Command) {
updateCmd := parent.NewSubCommand("update", "Update linked packages")
updateCmd.LongDescription("Run composer update for linked packages.\n\n" +
"If no packages specified, updates all packages.\n\n" +
"Examples:\n" +
" core php packages update\n" +
" core php packages update vendor/my-package")
updateCmd.Action(func() error {
cwd, err := os.Getwd()
if err != nil {
return fmt.Errorf("failed to get working directory: %w", err)
}
args := updateCmd.OtherArgs()
fmt.Printf("%s Updating packages...\n\n", dimStyle.Render("PHP:"))
if err := phppkg.UpdatePackages(cwd, args); err != nil {
return fmt.Errorf("composer update failed: %w", err)
}
fmt.Printf("\n%s Packages updated\n", successStyle.Render("Done:"))
return nil
})
}
func addPHPPackagesListCommand(parent *clir.Command) {
listCmd := parent.NewSubCommand("list", "List linked packages")
listCmd.LongDescription("List all locally linked packages.\n\n" +
"Shows package name, path, and version for each linked package.")
listCmd.Action(func() error {
cwd, err := os.Getwd()
if err != nil {
return fmt.Errorf("failed to get working directory: %w", err)
}
packages, err := phppkg.ListLinkedPackages(cwd)
if err != nil {
return fmt.Errorf("failed to list packages: %w", err)
}
if len(packages) == 0 {
fmt.Printf("%s No linked packages found\n", dimStyle.Render("PHP:"))
return nil
}
fmt.Printf("%s Linked packages:\n\n", dimStyle.Render("PHP:"))
for _, pkg := range packages {
name := pkg.Name
if name == "" {
name = "(unknown)"
}
version := pkg.Version
if version == "" {
version = "dev"
}
fmt.Printf(" %s %s\n", successStyle.Render("*"), name)
fmt.Printf(" %s %s\n", dimStyle.Render("Path:"), pkg.Path)
fmt.Printf(" %s %s\n", dimStyle.Render("Version:"), version)
fmt.Println()
}
return nil
})
}

831
cmd/php/php_quality.go Normal file
View file

@ -0,0 +1,831 @@
package php
import (
"bytes"
"context"
"fmt"
"io"
"os"
"strings"
"time"
"github.com/charmbracelet/lipgloss"
phppkg "github.com/host-uk/core/pkg/php"
"github.com/leaanthony/clir"
)
func addPHPTestCommand(parent *clir.Command) {
var (
parallel bool
coverage bool
filter string
group string
)
testCmd := parent.NewSubCommand("test", "Run PHP tests (PHPUnit/Pest)")
testCmd.LongDescription("Run PHP tests using PHPUnit or Pest.\n\n" +
"Auto-detects Pest if tests/Pest.php exists, otherwise uses PHPUnit.\n\n" +
"Examples:\n" +
" core php test # Run all tests\n" +
" core php test --parallel # Run tests in parallel\n" +
" core php test --coverage # Run with coverage\n" +
" core php test --filter UserTest # Filter by test name")
testCmd.BoolFlag("parallel", "Run tests in parallel", &parallel)
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
}
}

View file

@ -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
}

View file

@ -177,3 +177,341 @@ func TestBuildPHPStanCommand_Good(t *testing.T) {
assert.Equal(t, phpstanPath, cmd)
})
}
// =============================================================================
// Psalm Detection Tests
// =============================================================================
func TestDetectPsalm_Good(t *testing.T) {
t.Run("detects psalm.xml", func(t *testing.T) {
dir := t.TempDir()
err := os.WriteFile(filepath.Join(dir, "psalm.xml"), []byte(""), 0644)
require.NoError(t, err)
// Also need vendor binary for it to return true
binDir := filepath.Join(dir, "vendor", "bin")
err = os.MkdirAll(binDir, 0755)
require.NoError(t, err)
err = os.WriteFile(filepath.Join(binDir, "psalm"), []byte(""), 0755)
require.NoError(t, err)
psalmType, found := DetectPsalm(dir)
assert.True(t, found)
assert.Equal(t, PsalmStandard, psalmType)
})
t.Run("detects psalm.xml.dist", func(t *testing.T) {
dir := t.TempDir()
err := os.WriteFile(filepath.Join(dir, "psalm.xml.dist"), []byte(""), 0644)
require.NoError(t, err)
binDir := filepath.Join(dir, "vendor", "bin")
err = os.MkdirAll(binDir, 0755)
require.NoError(t, err)
err = os.WriteFile(filepath.Join(binDir, "psalm"), []byte(""), 0755)
require.NoError(t, err)
_, found := DetectPsalm(dir)
assert.True(t, found)
})
t.Run("detects vendor binary only", func(t *testing.T) {
dir := t.TempDir()
binDir := filepath.Join(dir, "vendor", "bin")
err := os.MkdirAll(binDir, 0755)
require.NoError(t, err)
err = os.WriteFile(filepath.Join(binDir, "psalm"), []byte(""), 0755)
require.NoError(t, err)
_, found := DetectPsalm(dir)
assert.True(t, found)
})
}
func TestDetectPsalm_Bad(t *testing.T) {
t.Run("no psalm", func(t *testing.T) {
dir := t.TempDir()
_, found := DetectPsalm(dir)
assert.False(t, found)
})
}
// =============================================================================
// Rector Detection Tests
// =============================================================================
func TestDetectRector_Good(t *testing.T) {
t.Run("detects rector.php", func(t *testing.T) {
dir := t.TempDir()
err := os.WriteFile(filepath.Join(dir, "rector.php"), []byte("<?php"), 0644)
require.NoError(t, err)
found := DetectRector(dir)
assert.True(t, found)
})
t.Run("detects vendor binary", func(t *testing.T) {
dir := t.TempDir()
binDir := filepath.Join(dir, "vendor", "bin")
err := os.MkdirAll(binDir, 0755)
require.NoError(t, err)
err = os.WriteFile(filepath.Join(binDir, "rector"), []byte(""), 0755)
require.NoError(t, err)
found := DetectRector(dir)
assert.True(t, found)
})
}
func TestDetectRector_Bad(t *testing.T) {
t.Run("no rector", func(t *testing.T) {
dir := t.TempDir()
found := DetectRector(dir)
assert.False(t, found)
})
}
// =============================================================================
// Infection Detection Tests
// =============================================================================
func TestDetectInfection_Good(t *testing.T) {
t.Run("detects infection.json", func(t *testing.T) {
dir := t.TempDir()
err := os.WriteFile(filepath.Join(dir, "infection.json"), []byte("{}"), 0644)
require.NoError(t, err)
found := DetectInfection(dir)
assert.True(t, found)
})
t.Run("detects infection.json5", func(t *testing.T) {
dir := t.TempDir()
err := os.WriteFile(filepath.Join(dir, "infection.json5"), []byte("{}"), 0644)
require.NoError(t, err)
found := DetectInfection(dir)
assert.True(t, found)
})
t.Run("detects vendor binary", func(t *testing.T) {
dir := t.TempDir()
binDir := filepath.Join(dir, "vendor", "bin")
err := os.MkdirAll(binDir, 0755)
require.NoError(t, err)
err = os.WriteFile(filepath.Join(binDir, "infection"), []byte(""), 0755)
require.NoError(t, err)
found := DetectInfection(dir)
assert.True(t, found)
})
}
func TestDetectInfection_Bad(t *testing.T) {
t.Run("no infection", func(t *testing.T) {
dir := t.TempDir()
found := DetectInfection(dir)
assert.False(t, found)
})
}
// =============================================================================
// QA Pipeline Tests
// =============================================================================
func TestGetQAStages_Good(t *testing.T) {
t.Run("default stages", func(t *testing.T) {
opts := QAOptions{}
stages := GetQAStages(opts)
assert.Equal(t, []QAStage{QAStageQuick, QAStageStandard}, stages)
})
t.Run("quick only", func(t *testing.T) {
opts := QAOptions{Quick: true}
stages := GetQAStages(opts)
assert.Equal(t, []QAStage{QAStageQuick}, stages)
})
t.Run("full stages", func(t *testing.T) {
opts := QAOptions{Full: true}
stages := GetQAStages(opts)
assert.Equal(t, []QAStage{QAStageQuick, QAStageStandard, QAStageFull}, stages)
})
}
func TestGetQAChecks_Good(t *testing.T) {
t.Run("quick stage checks", func(t *testing.T) {
dir := t.TempDir()
checks := GetQAChecks(dir, QAStageQuick)
assert.Contains(t, checks, "audit")
assert.Contains(t, checks, "fmt")
assert.Contains(t, checks, "analyse")
})
t.Run("standard stage includes test", func(t *testing.T) {
dir := t.TempDir()
checks := GetQAChecks(dir, QAStageStandard)
assert.Contains(t, checks, "test")
})
t.Run("standard stage includes psalm if available", func(t *testing.T) {
dir := t.TempDir()
binDir := filepath.Join(dir, "vendor", "bin")
err := os.MkdirAll(binDir, 0755)
require.NoError(t, err)
err = os.WriteFile(filepath.Join(binDir, "psalm"), []byte(""), 0755)
require.NoError(t, err)
checks := GetQAChecks(dir, QAStageStandard)
assert.Contains(t, checks, "psalm")
})
t.Run("full stage includes rector if available", func(t *testing.T) {
dir := t.TempDir()
err := os.WriteFile(filepath.Join(dir, "rector.php"), []byte("<?php"), 0644)
require.NoError(t, err)
checks := GetQAChecks(dir, QAStageFull)
assert.Contains(t, checks, "rector")
})
}
// =============================================================================
// Security Checks Tests
// =============================================================================
func TestRunEnvSecurityChecks_Good(t *testing.T) {
t.Run("detects debug mode enabled", func(t *testing.T) {
dir := t.TempDir()
envContent := "APP_DEBUG=true\nAPP_KEY=base64:abcdefghijklmnopqrstuvwxyz123456\n"
err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644)
require.NoError(t, err)
checks := runEnvSecurityChecks(dir)
var debugCheck *SecurityCheck
for i := range checks {
if checks[i].ID == "debug_mode" {
debugCheck = &checks[i]
break
}
}
require.NotNil(t, debugCheck)
assert.False(t, debugCheck.Passed)
assert.Equal(t, "critical", debugCheck.Severity)
})
t.Run("passes with debug disabled", func(t *testing.T) {
dir := t.TempDir()
envContent := "APP_DEBUG=false\nAPP_KEY=base64:abcdefghijklmnopqrstuvwxyz123456\n"
err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644)
require.NoError(t, err)
checks := runEnvSecurityChecks(dir)
var debugCheck *SecurityCheck
for i := range checks {
if checks[i].ID == "debug_mode" {
debugCheck = &checks[i]
break
}
}
require.NotNil(t, debugCheck)
assert.True(t, debugCheck.Passed)
})
t.Run("detects weak app key", func(t *testing.T) {
dir := t.TempDir()
envContent := "APP_KEY=short\n"
err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644)
require.NoError(t, err)
checks := runEnvSecurityChecks(dir)
var keyCheck *SecurityCheck
for i := range checks {
if checks[i].ID == "app_key_set" {
keyCheck = &checks[i]
break
}
}
require.NotNil(t, keyCheck)
assert.False(t, keyCheck.Passed)
})
t.Run("detects non-https app url", func(t *testing.T) {
dir := t.TempDir()
envContent := "APP_URL=http://example.com\n"
err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644)
require.NoError(t, err)
checks := runEnvSecurityChecks(dir)
var urlCheck *SecurityCheck
for i := range checks {
if checks[i].ID == "https_enforced" {
urlCheck = &checks[i]
break
}
}
require.NotNil(t, urlCheck)
assert.False(t, urlCheck.Passed)
})
}
func TestRunFilesystemSecurityChecks_Good(t *testing.T) {
t.Run("detects .env in public", func(t *testing.T) {
dir := t.TempDir()
publicDir := filepath.Join(dir, "public")
err := os.MkdirAll(publicDir, 0755)
require.NoError(t, err)
err = os.WriteFile(filepath.Join(publicDir, ".env"), []byte(""), 0644)
require.NoError(t, err)
checks := runFilesystemSecurityChecks(dir)
found := false
for _, check := range checks {
if check.ID == "env_not_public" && !check.Passed {
found = true
break
}
}
assert.True(t, found, "should detect .env in public directory")
})
t.Run("detects .git in public", func(t *testing.T) {
dir := t.TempDir()
gitDir := filepath.Join(dir, "public", ".git")
err := os.MkdirAll(gitDir, 0755)
require.NoError(t, err)
checks := runFilesystemSecurityChecks(dir)
found := false
for _, check := range checks {
if check.ID == "git_not_public" && !check.Passed {
found = true
break
}
}
assert.True(t, found, "should detect .git in public directory")
})
t.Run("passes with clean public directory", func(t *testing.T) {
dir := t.TempDir()
publicDir := filepath.Join(dir, "public")
err := os.MkdirAll(publicDir, 0755)
require.NoError(t, err)
// Add only safe files
err = os.WriteFile(filepath.Join(publicDir, "index.php"), []byte("<?php"), 0644)
require.NoError(t, err)
checks := runFilesystemSecurityChecks(dir)
assert.Empty(t, checks, "should not report issues for clean public directory")
})
}

36
pkg/updater/build/main.go Normal file
View file

@ -0,0 +1,36 @@
package main
import (
"encoding/json"
"fmt"
"io/ioutil"
"os"
)
func main() {
// Read package.json
data, err := ioutil.ReadFile("package.json")
if err != nil {
fmt.Println("Error reading package.json, skipping version file generation.")
os.Exit(0)
}
// Parse package.json
var pkg struct {
Version string `json:"version"`
}
if err := json.Unmarshal(data, &pkg); err != nil {
fmt.Println("Error parsing package.json, skipping version file generation.")
os.Exit(0)
}
// Create the version file
content := fmt.Sprintf("package updater\n\n// Generated by go:generate. DO NOT EDIT.\n\nconst PkgVersion = %q\n", pkg.Version)
err = ioutil.WriteFile("version.go", []byte(content), 0644)
if err != nil {
fmt.Printf("Error writing version file: %v\n", err)
os.Exit(1)
}
fmt.Println("Generated version.go with version:", pkg.Version)
}