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:
parent
548e4589f7
commit
c83f9a25a7
12 changed files with 179 additions and 128 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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, "#") {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue