chore(io): Migrate internal/cmd/php to Medium abstraction (#338)

Completes issue #112 by migrating all direct os.* filesystem calls in
internal/cmd/php to use the io.Medium abstraction via getMedium().

Changes:
- packages.go: os.ReadFile/WriteFile → getMedium().Read/Write
- container.go: os.WriteFile/Remove/MkdirAll/Stat → getMedium().Write/Delete/EnsureDir/IsFile
- services.go: os.MkdirAll/OpenFile/Open → getMedium().EnsureDir/Create/Open
- dockerfile.go: os.ReadFile/Stat → getMedium().Read/IsFile
- ssl.go: os.MkdirAll/Stat → getMedium().EnsureDir/IsFile
- cmd_ci.go: os.WriteFile → getMedium().Write
- cmd.go: os.Stat → getMedium().IsDir
- coolify.go: os.Open → getMedium().Read
- testing.go: os.Stat → getMedium().IsFile
- cmd_qa_runner.go: os.Stat → getMedium().IsFile
- detect.go: os.Stat/ReadFile → getMedium().Exists/Read
- quality.go: os.Stat/ReadFile → getMedium().Exists/IsFile/Read

All production files now use the consistent getMedium() pattern for
testability. Test files retain direct os.* calls as they manage test
fixtures directly.

Closes #112

Co-authored-by: Claude <developers@lethean.io>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Vi 2026-02-05 18:14:59 +00:00 committed by GitHub
parent 548e4589f7
commit c83f9a25a7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 179 additions and 128 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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, "#") {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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