diff --git a/internal/cmd/php/cmd.go b/internal/cmd/php/cmd.go index 80091ea..0bbfc6f 100644 --- a/internal/cmd/php/cmd.go +++ b/internal/cmd/php/cmd.go @@ -7,9 +7,26 @@ import ( "github.com/host-uk/core/internal/cmd/workspace" "github.com/host-uk/core/pkg/cli" "github.com/host-uk/core/pkg/i18n" + "github.com/host-uk/core/pkg/io" "github.com/spf13/cobra" ) +// DefaultMedium is the default filesystem medium used by the php package. +// It defaults to io.Local (unsandboxed filesystem access). +// Use SetMedium to change this for testing or sandboxed operation. +var DefaultMedium io.Medium = io.Local + +// SetMedium sets the default medium for filesystem operations. +// This is primarily useful for testing with mock mediums. +func SetMedium(m io.Medium) { + DefaultMedium = m +} + +// getMedium returns the default medium for filesystem operations. +func getMedium() io.Medium { + return DefaultMedium +} + func init() { cli.RegisterCommands(AddPHPCommands) } @@ -89,7 +106,7 @@ func AddPHPCommands(root *cobra.Command) { targetDir := filepath.Join(pkgDir, config.Active) // Check if target directory exists - if _, err := os.Stat(targetDir); err != nil { + if !getMedium().IsDir(targetDir) { cli.Warnf("Active package directory not found: %s", targetDir) return nil } diff --git a/internal/cmd/php/cmd_ci.go b/internal/cmd/php/cmd_ci.go index 40b23fe..8c9c619 100644 --- a/internal/cmd/php/cmd_ci.go +++ b/internal/cmd/php/cmd_ci.go @@ -515,7 +515,7 @@ func generateSARIF(ctx context.Context, dir, checkName, outputFile string) error return fmt.Errorf("invalid SARIF output: %w", err) } - return os.WriteFile(outputFile, output, 0644) + return getMedium().Write(outputFile, string(output)) } // uploadSARIFToGitHub uploads a SARIF file to GitHub Security tab diff --git a/internal/cmd/php/cmd_qa_runner.go b/internal/cmd/php/cmd_qa_runner.go index c61ea46..69c8a6e 100644 --- a/internal/cmd/php/cmd_qa_runner.go +++ b/internal/cmd/php/cmd_qa_runner.go @@ -2,7 +2,6 @@ package php import ( "context" - "os" "path/filepath" "strings" "sync" @@ -77,6 +76,7 @@ func (r *QARunner) buildSpec(check string) *process.RunSpec { } case "fmt": + m := getMedium() formatter, found := DetectFormatter(r.dir) if !found { return nil @@ -84,7 +84,7 @@ func (r *QARunner) buildSpec(check string) *process.RunSpec { if formatter == FormatterPint { vendorBin := filepath.Join(r.dir, "vendor", "bin", "pint") cmd := "pint" - if _, err := os.Stat(vendorBin); err == nil { + if m.IsFile(vendorBin) { cmd = vendorBin } args := []string{} @@ -102,13 +102,14 @@ func (r *QARunner) buildSpec(check string) *process.RunSpec { return nil case "stan": + m := getMedium() _, found := DetectAnalyser(r.dir) if !found { return nil } vendorBin := filepath.Join(r.dir, "vendor", "bin", "phpstan") cmd := "phpstan" - if _, err := os.Stat(vendorBin); err == nil { + if m.IsFile(vendorBin) { cmd = vendorBin } return &process.RunSpec{ @@ -120,13 +121,14 @@ func (r *QARunner) buildSpec(check string) *process.RunSpec { } case "psalm": + m := getMedium() _, found := DetectPsalm(r.dir) if !found { return nil } vendorBin := filepath.Join(r.dir, "vendor", "bin", "psalm") cmd := "psalm" - if _, err := os.Stat(vendorBin); err == nil { + if m.IsFile(vendorBin) { cmd = vendorBin } args := []string{"--no-progress"} @@ -142,14 +144,15 @@ func (r *QARunner) buildSpec(check string) *process.RunSpec { } case "test": + m := getMedium() // Check for Pest first, fall back to PHPUnit pestBin := filepath.Join(r.dir, "vendor", "bin", "pest") phpunitBin := filepath.Join(r.dir, "vendor", "bin", "phpunit") var cmd string - if _, err := os.Stat(pestBin); err == nil { + if m.IsFile(pestBin) { cmd = pestBin - } else if _, err := os.Stat(phpunitBin); err == nil { + } else if m.IsFile(phpunitBin) { cmd = phpunitBin } else { return nil @@ -170,12 +173,13 @@ func (r *QARunner) buildSpec(check string) *process.RunSpec { } case "rector": + m := getMedium() if !DetectRector(r.dir) { return nil } vendorBin := filepath.Join(r.dir, "vendor", "bin", "rector") cmd := "rector" - if _, err := os.Stat(vendorBin); err == nil { + if m.IsFile(vendorBin) { cmd = vendorBin } args := []string{"process"} @@ -192,12 +196,13 @@ func (r *QARunner) buildSpec(check string) *process.RunSpec { } case "infection": + m := getMedium() if !DetectInfection(r.dir) { return nil } vendorBin := filepath.Join(r.dir, "vendor", "bin", "infection") cmd := "infection" - if _, err := os.Stat(vendorBin); err == nil { + if m.IsFile(vendorBin) { cmd = vendorBin } return &process.RunSpec{ diff --git a/internal/cmd/php/container.go b/internal/cmd/php/container.go index 9b8f630..8fe16e0 100644 --- a/internal/cmd/php/container.go +++ b/internal/cmd/php/container.go @@ -128,11 +128,12 @@ func BuildDocker(ctx context.Context, opts DockerBuildOptions) error { } // Write to temporary file + m := getMedium() tempDockerfile = filepath.Join(opts.ProjectDir, "Dockerfile.core-generated") - if err := os.WriteFile(tempDockerfile, []byte(content), 0644); err != nil { + if err := m.Write(tempDockerfile, content); err != nil { return cli.WrapVerb(err, "write", "Dockerfile") } - defer func() { _ = os.Remove(tempDockerfile) }() + defer func() { _ = m.Delete(tempDockerfile) }() dockerfilePath = tempDockerfile } @@ -198,8 +199,9 @@ func BuildLinuxKit(ctx context.Context, opts LinuxKitBuildOptions) error { } // Ensure output directory exists + m := getMedium() outputDir := filepath.Dir(opts.OutputPath) - if err := os.MkdirAll(outputDir, 0755); err != nil { + if err := m.EnsureDir(outputDir); err != nil { return cli.WrapVerb(err, "create", "output directory") } @@ -230,10 +232,10 @@ func BuildLinuxKit(ctx context.Context, opts LinuxKitBuildOptions) error { // Write template to temp file tempYAML := filepath.Join(opts.ProjectDir, ".core-linuxkit.yml") - if err := os.WriteFile(tempYAML, []byte(content), 0644); err != nil { + if err := m.Write(tempYAML, content); err != nil { return cli.WrapVerb(err, "write", "template") } - defer func() { _ = os.Remove(tempYAML) }() + defer func() { _ = m.Delete(tempYAML) }() // Build LinuxKit image args := []string{ @@ -345,8 +347,7 @@ func Shell(ctx context.Context, containerID string) error { // IsPHPProject checks if the given directory is a PHP project. func IsPHPProject(dir string) bool { composerPath := filepath.Join(dir, "composer.json") - _, err := os.Stat(composerPath) - return err == nil + return getMedium().IsFile(composerPath) } // commonLinuxKitPaths defines default search locations for linuxkit. @@ -362,8 +363,9 @@ func lookupLinuxKit() (string, error) { return path, nil } + m := getMedium() for _, p := range commonLinuxKitPaths { - if _, err := os.Stat(p); err == nil { + if m.IsFile(p) { return p, nil } } diff --git a/internal/cmd/php/coolify.go b/internal/cmd/php/coolify.go index 76aa4ca..017fa26 100644 --- a/internal/cmd/php/coolify.go +++ b/internal/cmd/php/coolify.go @@ -75,6 +75,7 @@ func LoadCoolifyConfig(dir string) (*CoolifyConfig, error) { // LoadCoolifyConfigFromFile loads Coolify configuration from a specific .env file. func LoadCoolifyConfigFromFile(path string) (*CoolifyConfig, error) { + m := getMedium() config := &CoolifyConfig{} // First try environment variables @@ -84,23 +85,18 @@ func LoadCoolifyConfigFromFile(path string) (*CoolifyConfig, error) { config.StagingAppID = os.Getenv("COOLIFY_STAGING_APP_ID") // Then try .env file - file, err := os.Open(path) - if err != nil { - if os.IsNotExist(err) { - // No .env file, just use env vars - return validateCoolifyConfig(config) - } - return nil, cli.WrapVerb(err, "open", ".env file") + if !m.Exists(path) { + // No .env file, just use env vars + return validateCoolifyConfig(config) } - defer func() { _ = file.Close() }() - content, err := io.ReadAll(file) + content, err := m.Read(path) if err != nil { return nil, cli.WrapVerb(err, "read", ".env file") } // Parse .env file - lines := strings.Split(string(content), "\n") + lines := strings.Split(content, "\n") for _, line := range lines { line = strings.TrimSpace(line) if line == "" || strings.HasPrefix(line, "#") { diff --git a/internal/cmd/php/detect.go b/internal/cmd/php/detect.go index 7a97709..c13da9d 100644 --- a/internal/cmd/php/detect.go +++ b/internal/cmd/php/detect.go @@ -1,9 +1,7 @@ package php import ( - "bufio" "encoding/json" - "os" "path/filepath" "strings" ) @@ -28,15 +26,17 @@ const ( // IsLaravelProject checks if the given directory is a Laravel project. // It looks for the presence of artisan file and laravel in composer.json. func IsLaravelProject(dir string) bool { + m := getMedium() + // Check for artisan file artisanPath := filepath.Join(dir, "artisan") - if _, err := os.Stat(artisanPath); os.IsNotExist(err) { + if !m.Exists(artisanPath) { return false } // Check composer.json for laravel/framework composerPath := filepath.Join(dir, "composer.json") - data, err := os.ReadFile(composerPath) + data, err := m.Read(composerPath) if err != nil { return false } @@ -46,7 +46,7 @@ func IsLaravelProject(dir string) bool { RequireDev map[string]string `json:"require-dev"` } - if err := json.Unmarshal(data, &composer); err != nil { + if err := json.Unmarshal([]byte(data), &composer); err != nil { return false } @@ -66,9 +66,11 @@ func IsLaravelProject(dir string) bool { // IsFrankenPHPProject checks if the project is configured for FrankenPHP. // It looks for laravel/octane with frankenphp driver. func IsFrankenPHPProject(dir string) bool { + m := getMedium() + // Check composer.json for laravel/octane composerPath := filepath.Join(dir, "composer.json") - data, err := os.ReadFile(composerPath) + data, err := m.Read(composerPath) if err != nil { return false } @@ -77,7 +79,7 @@ func IsFrankenPHPProject(dir string) bool { Require map[string]string `json:"require"` } - if err := json.Unmarshal(data, &composer); err != nil { + if err := json.Unmarshal([]byte(data), &composer); err != nil { return false } @@ -87,18 +89,18 @@ func IsFrankenPHPProject(dir string) bool { // Check octane config for frankenphp configPath := filepath.Join(dir, "config", "octane.php") - if _, err := os.Stat(configPath); os.IsNotExist(err) { + if !m.Exists(configPath) { // If no config exists but octane is installed, assume frankenphp return true } - configData, err := os.ReadFile(configPath) + configData, err := m.Read(configPath) if err != nil { return true // Assume frankenphp if we can't read config } // Look for frankenphp in the config - return strings.Contains(string(configData), "frankenphp") + return strings.Contains(configData, "frankenphp") } // DetectServices detects which services are needed based on project files. @@ -135,6 +137,7 @@ func DetectServices(dir string) []DetectedService { // hasVite checks if the project uses Vite. func hasVite(dir string) bool { + m := getMedium() viteConfigs := []string{ "vite.config.js", "vite.config.ts", @@ -143,7 +146,7 @@ func hasVite(dir string) bool { } for _, config := range viteConfigs { - if _, err := os.Stat(filepath.Join(dir, config)); err == nil { + if m.Exists(filepath.Join(dir, config)) { return true } } @@ -154,29 +157,27 @@ func hasVite(dir string) bool { // hasHorizon checks if Laravel Horizon is configured. func hasHorizon(dir string) bool { horizonConfig := filepath.Join(dir, "config", "horizon.php") - _, err := os.Stat(horizonConfig) - return err == nil + return getMedium().Exists(horizonConfig) } // hasReverb checks if Laravel Reverb is configured. func hasReverb(dir string) bool { reverbConfig := filepath.Join(dir, "config", "reverb.php") - _, err := os.Stat(reverbConfig) - return err == nil + return getMedium().Exists(reverbConfig) } // needsRedis checks if the project uses Redis based on .env configuration. func needsRedis(dir string) bool { + m := getMedium() envPath := filepath.Join(dir, ".env") - file, err := os.Open(envPath) + content, err := m.Read(envPath) if err != nil { return false } - defer func() { _ = file.Close() }() - scanner := bufio.NewScanner(file) - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) + lines := strings.Split(content, "\n") + for _, line := range lines { + line = strings.TrimSpace(line) if strings.HasPrefix(line, "#") { continue } @@ -207,6 +208,7 @@ func needsRedis(dir string) bool { // DetectPackageManager detects which package manager is used in the project. // Returns "npm", "pnpm", "yarn", or "bun". func DetectPackageManager(dir string) string { + m := getMedium() // Check for lock files in order of preference lockFiles := []struct { file string @@ -219,7 +221,7 @@ func DetectPackageManager(dir string) string { } for _, lf := range lockFiles { - if _, err := os.Stat(filepath.Join(dir, lf.file)); err == nil { + if m.Exists(filepath.Join(dir, lf.file)) { return lf.manager } } @@ -230,16 +232,16 @@ func DetectPackageManager(dir string) string { // GetLaravelAppName extracts the application name from Laravel's .env file. func GetLaravelAppName(dir string) string { + m := getMedium() envPath := filepath.Join(dir, ".env") - file, err := os.Open(envPath) + content, err := m.Read(envPath) if err != nil { return "" } - defer func() { _ = file.Close() }() - scanner := bufio.NewScanner(file) - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) + lines := strings.Split(content, "\n") + for _, line := range lines { + line = strings.TrimSpace(line) if strings.HasPrefix(line, "APP_NAME=") { value := strings.TrimPrefix(line, "APP_NAME=") // Remove quotes if present @@ -253,16 +255,16 @@ func GetLaravelAppName(dir string) string { // GetLaravelAppURL extracts the application URL from Laravel's .env file. func GetLaravelAppURL(dir string) string { + m := getMedium() envPath := filepath.Join(dir, ".env") - file, err := os.Open(envPath) + content, err := m.Read(envPath) if err != nil { return "" } - defer func() { _ = file.Close() }() - scanner := bufio.NewScanner(file) - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) + lines := strings.Split(content, "\n") + for _, line := range lines { + line = strings.TrimSpace(line) if strings.HasPrefix(line, "APP_URL=") { value := strings.TrimPrefix(line, "APP_URL=") // Remove quotes if present diff --git a/internal/cmd/php/dockerfile.go b/internal/cmd/php/dockerfile.go index 43a3b6c..4081a16 100644 --- a/internal/cmd/php/dockerfile.go +++ b/internal/cmd/php/dockerfile.go @@ -2,7 +2,6 @@ package php import ( "encoding/json" - "os" "path/filepath" "sort" "strings" @@ -50,6 +49,7 @@ func GenerateDockerfile(dir string) (string, error) { // DetectDockerfileConfig detects configuration from project files. func DetectDockerfileConfig(dir string) (*DockerfileConfig, error) { + m := getMedium() config := &DockerfileConfig{ PHPVersion: "8.3", BaseImage: "dunglas/frankenphp", @@ -58,13 +58,13 @@ func DetectDockerfileConfig(dir string) (*DockerfileConfig, error) { // Read composer.json composerPath := filepath.Join(dir, "composer.json") - composerData, err := os.ReadFile(composerPath) + composerContent, err := m.Read(composerPath) if err != nil { return nil, cli.WrapVerb(err, "read", "composer.json") } var composer ComposerJSON - if err := json.Unmarshal(composerData, &composer); err != nil { + if err := json.Unmarshal([]byte(composerContent), &composer); err != nil { return nil, cli.WrapVerb(err, "parse", "composer.json") } @@ -318,13 +318,14 @@ func extractPHPVersion(constraint string) string { // hasNodeAssets checks if the project has frontend assets. func hasNodeAssets(dir string) bool { + m := getMedium() packageJSON := filepath.Join(dir, "package.json") - if _, err := os.Stat(packageJSON); err != nil { + if !m.IsFile(packageJSON) { return false } // Check for build script in package.json - data, err := os.ReadFile(packageJSON) + content, err := m.Read(packageJSON) if err != nil { return false } @@ -333,7 +334,7 @@ func hasNodeAssets(dir string) bool { Scripts map[string]string `json:"scripts"` } - if err := json.Unmarshal(data, &pkg); err != nil { + if err := json.Unmarshal([]byte(content), &pkg); err != nil { return false } diff --git a/internal/cmd/php/packages.go b/internal/cmd/php/packages.go index ba3501f..ce68605 100644 --- a/internal/cmd/php/packages.go +++ b/internal/cmd/php/packages.go @@ -25,14 +25,15 @@ type composerRepository struct { // readComposerJSON reads and parses composer.json from the given directory. func readComposerJSON(dir string) (map[string]json.RawMessage, error) { + m := getMedium() composerPath := filepath.Join(dir, "composer.json") - data, err := os.ReadFile(composerPath) + content, err := m.Read(composerPath) if err != nil { return nil, cli.WrapVerb(err, "read", "composer.json") } var raw map[string]json.RawMessage - if err := json.Unmarshal(data, &raw); err != nil { + if err := json.Unmarshal([]byte(content), &raw); err != nil { return nil, cli.WrapVerb(err, "parse", "composer.json") } @@ -41,6 +42,7 @@ func readComposerJSON(dir string) (map[string]json.RawMessage, error) { // writeComposerJSON writes the composer.json to the given directory. func writeComposerJSON(dir string, raw map[string]json.RawMessage) error { + m := getMedium() composerPath := filepath.Join(dir, "composer.json") data, err := json.MarshalIndent(raw, "", " ") @@ -49,9 +51,9 @@ func writeComposerJSON(dir string, raw map[string]json.RawMessage) error { } // Add trailing newline - data = append(data, '\n') + content := string(data) + "\n" - if err := os.WriteFile(composerPath, data, 0644); err != nil { + if err := m.Write(composerPath, content); err != nil { return cli.WrapVerb(err, "write", "composer.json") } @@ -91,8 +93,9 @@ func setRepositories(raw map[string]json.RawMessage, repos []composerRepository) // getPackageInfo reads package name and version from a composer.json in the given path. func getPackageInfo(packagePath string) (name, version string, err error) { + m := getMedium() composerPath := filepath.Join(packagePath, "composer.json") - data, err := os.ReadFile(composerPath) + content, err := m.Read(composerPath) if err != nil { return "", "", cli.WrapVerb(err, "read", "package composer.json") } @@ -102,7 +105,7 @@ func getPackageInfo(packagePath string) (name, version string, err error) { Version string `json:"version"` } - if err := json.Unmarshal(data, &pkg); err != nil { + if err := json.Unmarshal([]byte(content), &pkg); err != nil { return "", "", cli.WrapVerb(err, "parse", "package composer.json") } diff --git a/internal/cmd/php/quality.go b/internal/cmd/php/quality.go index 8f9109f..1e39863 100644 --- a/internal/cmd/php/quality.go +++ b/internal/cmd/php/quality.go @@ -3,7 +3,7 @@ package php import ( "context" "encoding/json" - "io" + goio "io" "os" "os/exec" "path/filepath" @@ -31,7 +31,7 @@ type FormatOptions struct { Paths []string // Output is the writer for output (defaults to os.Stdout). - Output io.Writer + Output goio.Writer } // AnalyseOptions configures PHP static analysis. @@ -55,7 +55,7 @@ type AnalyseOptions struct { SARIF bool // Output is the writer for output (defaults to os.Stdout). - Output io.Writer + Output goio.Writer } // FormatterType represents the detected formatter. @@ -80,15 +80,17 @@ const ( // DetectFormatter detects which formatter is available in the project. func DetectFormatter(dir string) (FormatterType, bool) { + m := getMedium() + // Check for Pint config pintConfig := filepath.Join(dir, "pint.json") - if _, err := os.Stat(pintConfig); err == nil { + if m.Exists(pintConfig) { return FormatterPint, true } // Check for vendor binary pintBin := filepath.Join(dir, "vendor", "bin", "pint") - if _, err := os.Stat(pintBin); err == nil { + if m.Exists(pintBin) { return FormatterPint, true } @@ -97,34 +99,27 @@ func DetectFormatter(dir string) (FormatterType, bool) { // DetectAnalyser detects which static analyser is available in the project. func DetectAnalyser(dir string) (AnalyserType, bool) { + m := getMedium() + // Check for PHPStan config phpstanConfig := filepath.Join(dir, "phpstan.neon") phpstanDistConfig := filepath.Join(dir, "phpstan.neon.dist") - hasConfig := false - if _, err := os.Stat(phpstanConfig); err == nil { - hasConfig = true - } - if _, err := os.Stat(phpstanDistConfig); err == nil { - hasConfig = true - } + hasConfig := m.Exists(phpstanConfig) || m.Exists(phpstanDistConfig) // Check for vendor binary phpstanBin := filepath.Join(dir, "vendor", "bin", "phpstan") - hasBin := false - if _, err := os.Stat(phpstanBin); err == nil { - hasBin = true - } + hasBin := m.Exists(phpstanBin) if hasConfig || hasBin { // Check if it's Larastan (Laravel-specific PHPStan) larastanPath := filepath.Join(dir, "vendor", "larastan", "larastan") - if _, err := os.Stat(larastanPath); err == nil { + if m.Exists(larastanPath) { return AnalyserLarastan, true } // Also check nunomaduro/larastan larastanPath2 := filepath.Join(dir, "vendor", "nunomaduro", "larastan") - if _, err := os.Stat(larastanPath2); err == nil { + if m.Exists(larastanPath2) { return AnalyserLarastan, true } return AnalyserPHPStan, true @@ -207,10 +202,12 @@ func Analyse(ctx context.Context, opts AnalyseOptions) error { // buildPintCommand builds the command for running Laravel Pint. func buildPintCommand(opts FormatOptions) (string, []string) { + m := getMedium() + // Check for vendor binary first vendorBin := filepath.Join(opts.Dir, "vendor", "bin", "pint") cmdName := "pint" - if _, err := os.Stat(vendorBin); err == nil { + if m.Exists(vendorBin) { cmdName = vendorBin } @@ -236,10 +233,12 @@ func buildPintCommand(opts FormatOptions) (string, []string) { // buildPHPStanCommand builds the command for running PHPStan. func buildPHPStanCommand(opts AnalyseOptions) (string, []string) { + m := getMedium() + // Check for vendor binary first vendorBin := filepath.Join(opts.Dir, "vendor", "bin", "phpstan") cmdName := "phpstan" - if _, err := os.Stat(vendorBin); err == nil { + if m.Exists(vendorBin) { cmdName = vendorBin } @@ -279,7 +278,7 @@ type PsalmOptions struct { ShowInfo bool // Show info-level issues JSON bool // Output in JSON format SARIF bool // Output in SARIF format for GitHub Security tab - Output io.Writer + Output goio.Writer } // PsalmType represents the detected Psalm configuration. @@ -293,21 +292,17 @@ const ( // DetectPsalm checks if Psalm is available in the project. func DetectPsalm(dir string) (PsalmType, bool) { + m := getMedium() + // Check for psalm.xml config psalmConfig := filepath.Join(dir, "psalm.xml") psalmDistConfig := filepath.Join(dir, "psalm.xml.dist") - hasConfig := false - if _, err := os.Stat(psalmConfig); err == nil { - hasConfig = true - } - if _, err := os.Stat(psalmDistConfig); err == nil { - hasConfig = true - } + hasConfig := m.Exists(psalmConfig) || m.Exists(psalmDistConfig) // Check for vendor binary psalmBin := filepath.Join(dir, "vendor", "bin", "psalm") - if _, err := os.Stat(psalmBin); err == nil { + if m.Exists(psalmBin) { return PsalmStandard, true } @@ -332,10 +327,12 @@ func RunPsalm(ctx context.Context, opts PsalmOptions) error { opts.Output = os.Stdout } + m := getMedium() + // Build command vendorBin := filepath.Join(opts.Dir, "vendor", "bin", "psalm") cmdName := "psalm" - if _, err := os.Stat(vendorBin); err == nil { + if m.Exists(vendorBin) { cmdName = vendorBin } @@ -381,7 +378,7 @@ type AuditOptions struct { Dir string JSON bool // Output in JSON format Fix bool // Auto-fix vulnerabilities (npm only) - Output io.Writer + Output goio.Writer } // AuditResult holds the results of a security audit. @@ -422,7 +419,7 @@ func RunAudit(ctx context.Context, opts AuditOptions) ([]AuditResult, error) { results = append(results, composerResult) // Run npm audit if package.json exists - if _, err := os.Stat(filepath.Join(opts.Dir, "package.json")); err == nil { + if getMedium().Exists(filepath.Join(opts.Dir, "package.json")) { npmResult := runNpmAudit(ctx, opts) results = append(results, npmResult) } @@ -533,20 +530,22 @@ type RectorOptions struct { Fix bool // Apply changes (default is dry-run) Diff bool // Show detailed diff ClearCache bool // Clear cache before running - Output io.Writer + Output goio.Writer } // DetectRector checks if Rector is available in the project. func DetectRector(dir string) bool { + m := getMedium() + // Check for rector.php config rectorConfig := filepath.Join(dir, "rector.php") - if _, err := os.Stat(rectorConfig); err == nil { + if m.Exists(rectorConfig) { return true } // Check for vendor binary rectorBin := filepath.Join(dir, "vendor", "bin", "rector") - if _, err := os.Stat(rectorBin); err == nil { + if m.Exists(rectorBin) { return true } @@ -567,10 +566,12 @@ func RunRector(ctx context.Context, opts RectorOptions) error { opts.Output = os.Stdout } + m := getMedium() + // Build command vendorBin := filepath.Join(opts.Dir, "vendor", "bin", "rector") cmdName := "rector" - if _, err := os.Stat(vendorBin); err == nil { + if m.Exists(vendorBin) { cmdName = vendorBin } @@ -608,22 +609,24 @@ type InfectionOptions struct { Threads int // Number of parallel threads Filter string // Filter files by pattern OnlyCovered bool // Only mutate covered code - Output io.Writer + Output goio.Writer } // DetectInfection checks if Infection is available in the project. func DetectInfection(dir string) bool { + m := getMedium() + // Check for infection config files configs := []string{"infection.json", "infection.json5", "infection.json.dist"} for _, config := range configs { - if _, err := os.Stat(filepath.Join(dir, config)); err == nil { + if m.Exists(filepath.Join(dir, config)) { return true } } // Check for vendor binary infectionBin := filepath.Join(dir, "vendor", "bin", "infection") - if _, err := os.Stat(infectionBin); err == nil { + if m.Exists(infectionBin) { return true } @@ -644,10 +647,12 @@ func RunInfection(ctx context.Context, opts InfectionOptions) error { opts.Output = os.Stdout } + m := getMedium() + // Build command vendorBin := filepath.Join(opts.Dir, "vendor", "bin", "infection") cmdName := "infection" - if _, err := os.Stat(vendorBin); err == nil { + if m.Exists(vendorBin) { cmdName = vendorBin } @@ -780,7 +785,7 @@ type SecurityOptions struct { JSON bool // Output in JSON format SARIF bool // Output in SARIF format URL string // URL to check HTTP headers (optional) - Output io.Writer + Output goio.Writer } // SecurityResult holds the results of security scanning. @@ -873,13 +878,14 @@ func RunSecurityChecks(ctx context.Context, opts SecurityOptions) (*SecurityResu func runEnvSecurityChecks(dir string) []SecurityCheck { var checks []SecurityCheck + m := getMedium() envPath := filepath.Join(dir, ".env") - envContent, err := os.ReadFile(envPath) + envContent, err := m.Read(envPath) if err != nil { return checks } - envLines := strings.Split(string(envContent), "\n") + envLines := strings.Split(envContent, "\n") envMap := make(map[string]string) for _, line := range envLines { line = strings.TrimSpace(line) @@ -948,12 +954,13 @@ func runEnvSecurityChecks(dir string) []SecurityCheck { func runFilesystemSecurityChecks(dir string) []SecurityCheck { var checks []SecurityCheck + m := getMedium() // Check .env not in public publicEnvPaths := []string{"public/.env", "public_html/.env"} for _, path := range publicEnvPaths { fullPath := filepath.Join(dir, path) - if _, err := os.Stat(fullPath); err == nil { + if m.Exists(fullPath) { checks = append(checks, SecurityCheck{ ID: "env_not_public", Name: ".env Not Publicly Accessible", @@ -970,7 +977,7 @@ func runFilesystemSecurityChecks(dir string) []SecurityCheck { publicGitPaths := []string{"public/.git", "public_html/.git"} for _, path := range publicGitPaths { fullPath := filepath.Join(dir, path) - if _, err := os.Stat(fullPath); err == nil { + if m.Exists(fullPath) { checks = append(checks, SecurityCheck{ ID: "git_not_public", Name: ".git Not Publicly Accessible", diff --git a/internal/cmd/php/services.go b/internal/cmd/php/services.go index 81b8594..583dc1f 100644 --- a/internal/cmd/php/services.go +++ b/internal/cmd/php/services.go @@ -78,17 +78,24 @@ func (s *baseService) Logs(follow bool) (io.ReadCloser, error) { return nil, cli.Err("no log file available for %s", s.name) } - file, err := os.Open(s.logPath) + m := getMedium() + file, err := m.Open(s.logPath) if err != nil { return nil, cli.WrapVerb(err, "open", "log file") } if !follow { - return file, nil + return file.(io.ReadCloser), nil } // For follow mode, return a tailing reader - return newTailReader(file), nil + // Type assert to get the underlying *os.File for tailing + osFile, ok := file.(*os.File) + if !ok { + file.Close() + return nil, cli.Err("log file is not a regular file") + } + return newTailReader(osFile), nil } func (s *baseService) startProcess(ctx context.Context, cmdName string, args []string, env []string) error { @@ -100,16 +107,23 @@ func (s *baseService) startProcess(ctx context.Context, cmdName string, args []s } // Create log file + m := getMedium() logDir := filepath.Join(s.dir, ".core", "logs") - if err := os.MkdirAll(logDir, 0755); err != nil { + if err := m.EnsureDir(logDir); err != nil { return cli.WrapVerb(err, "create", "log directory") } s.logPath = filepath.Join(logDir, cli.Sprintf("%s.log", strings.ToLower(s.name))) - logFile, err := os.OpenFile(s.logPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) + logWriter, err := m.Create(s.logPath) if err != nil { return cli.WrapVerb(err, "create", "log file") } + // Type assert to get the underlying *os.File for use with exec.Cmd + logFile, ok := logWriter.(*os.File) + if !ok { + logWriter.Close() + return cli.Err("log file is not a regular file") + } s.logFile = logFile // Create command diff --git a/internal/cmd/php/ssl.go b/internal/cmd/php/ssl.go index c81e762..f3cd2d2 100644 --- a/internal/cmd/php/ssl.go +++ b/internal/cmd/php/ssl.go @@ -22,6 +22,7 @@ type SSLOptions struct { // GetSSLDir returns the SSL directory, creating it if necessary. func GetSSLDir(opts SSLOptions) (string, error) { + m := getMedium() dir := opts.Dir if dir == "" { home, err := os.UserHomeDir() @@ -31,7 +32,7 @@ func GetSSLDir(opts SSLOptions) (string, error) { dir = filepath.Join(home, DefaultSSLDir) } - if err := os.MkdirAll(dir, 0755); err != nil { + if err := m.EnsureDir(dir); err != nil { return "", cli.WrapVerb(err, "create", "SSL directory") } @@ -53,16 +54,17 @@ func CertPaths(domain string, opts SSLOptions) (certFile, keyFile string, err er // CertsExist checks if SSL certificates exist for the given domain. func CertsExist(domain string, opts SSLOptions) bool { + m := getMedium() certFile, keyFile, err := CertPaths(domain, opts) if err != nil { return false } - if _, err := os.Stat(certFile); os.IsNotExist(err) { + if !m.IsFile(certFile) { return false } - if _, err := os.Stat(keyFile); os.IsNotExist(err) { + if !m.IsFile(keyFile) { return false } diff --git a/internal/cmd/php/testing.go b/internal/cmd/php/testing.go index 7a5ebbb..520aff2 100644 --- a/internal/cmd/php/testing.go +++ b/internal/cmd/php/testing.go @@ -53,7 +53,7 @@ const ( func DetectTestRunner(dir string) TestRunner { // Check for Pest pestFile := filepath.Join(dir, "tests", "Pest.php") - if _, err := os.Stat(pestFile); err == nil { + if getMedium().IsFile(pestFile) { return TestRunnerPest } @@ -108,10 +108,11 @@ func RunParallel(ctx context.Context, opts TestOptions) error { // buildPestCommand builds the command for running Pest tests. func buildPestCommand(opts TestOptions) (string, []string) { + m := getMedium() // Check for vendor binary first vendorBin := filepath.Join(opts.Dir, "vendor", "bin", "pest") cmdName := "pest" - if _, err := os.Stat(vendorBin); err == nil { + if m.IsFile(vendorBin) { cmdName = vendorBin } @@ -149,10 +150,11 @@ func buildPestCommand(opts TestOptions) (string, []string) { // buildPHPUnitCommand builds the command for running PHPUnit tests. func buildPHPUnitCommand(opts TestOptions) (string, []string) { + m := getMedium() // Check for vendor binary first vendorBin := filepath.Join(opts.Dir, "vendor", "bin", "phpunit") cmdName := "phpunit" - if _, err := os.Stat(vendorBin); err == nil { + if m.IsFile(vendorBin) { cmdName = vendorBin } @@ -165,7 +167,7 @@ func buildPHPUnitCommand(opts TestOptions) (string, []string) { if opts.Parallel { // PHPUnit uses paratest for parallel execution paratestBin := filepath.Join(opts.Dir, "vendor", "bin", "paratest") - if _, err := os.Stat(paratestBin); err == nil { + if m.IsFile(paratestBin) { cmdName = paratestBin } }