diff --git a/README.md b/README.md index 3398a3b..7a2a5a6 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,194 @@ PHP Store ========= -PHP Store allows to find and manage local PHP installations. +PHP Store discovers local PHP installations and selects the best installation +for a project. It supports PHP CLI, CGI, FPM, and FrankenPHP installations on +Linux, macOS, and Windows. + +Installation +------------ + +Install the package with Go modules: + +```console +go get github.com/symfony-cli/phpstore +``` + +Basic Usage +----------- + +Create a store, then select PHP for a project directory: + +```go +package main + +import ( + "log" + "os" + "path/filepath" + + "github.com/symfony-cli/phpstore" +) + +func main() { + configDir, err := os.UserConfigDir() + if err != nil { + log.Fatal(err) + } + + projectDir, err := os.Getwd() + if err != nil { + log.Fatal(err) + } + + cacheDir := filepath.Join(configDir, "my-app", "phpstore") + if err := os.MkdirAll(cacheDir, 0755); err != nil { + log.Fatal(err) + } + + store := phpstore.New(cacheDir, false, log.Printf) + selected, source, warning, err := store.BestVersionForDir(projectDir) + if err != nil { + log.Fatal(err) + } + if warning != "" { + log.Printf("warning: %s", warning) + } + + log.Printf("using PHP %s from %s at %s", selected.Version, source, selected.PHPPath) +} +``` + +Pass `nil` instead of `log.Printf` to disable discovery logs. + +Discovery and Caching +--------------------- + +`New()` discovers PHP installations in common platform-specific locations, +additional configured directories, and `PATH`. It uses `php-config` when +possible and falls back to running `php --version`. + +The constructor arguments are: + +- `configDir`: directory used for the `php_versions.json` discovery cache; +- `reload`: when `true`, removes the cache and performs a new discovery; +- `logger`: optional callback that receives formatted discovery messages. + +Use `Versions()` to inspect the discovered installations. The result is sorted +by PHP version in ascending order: + +```go +for _, version := range store.Versions() { + log.Printf( + "PHP %s: CLI=%s FPM=%s CGI=%s", + version.Version, + version.PHPPath, + version.FPMPath, + version.CGIPath, + ) +} +``` + +`IsVersionAvailable()` accepts a major, minor, or patch version prefix: + +```go +if store.IsVersionAvailable("8.4") { + log.Print("PHP 8.4 is available") +} +``` + +Version Selection +----------------- + +`BestVersionForDir()` selects PHP using the first matching source in this +order: + +1. The `SYMFONY_CLI_PHP_BINARY_PATH` override; +2. A `.php-version` file found from the requested directory upward; +3. `config.platform.php` in a `composer.json` file found from the requested + directory upward; +4. A `.php-version` file found from the current working directory upward; +5. The PHP type in `.symfony.cloud.yaml` found from the requested directory + upward; +6. The PHP type in `.platform.app.yaml` found from the requested directory + upward; +7. The first PHP installation found in `SYMFONY_CLI_PHP_PATH` or `PATH`; +8. The most recent discovered PHP installation. + +A version constraint can select a major, minor, or patch release: + +```text +8 +8.4 +8.4.6 +``` + +When an exact patch release is unavailable, selection falls back to the most +recent patch release from the same minor version and returns a warning. It does +not fall back to another minor version. + +PHP Flavors +----------- + +A version constraint can include a server flavor: + +```text +8.4-cli +8.4-cgi +8.4-fpm +8.4-frankenphp +``` + +The supported flavor constants are `FlavorCLI`, `FlavorCGI`, `FlavorFPM`, and +`FlavorFrankenPHP`. Use `SupportsFlavor()` to inspect support and +`ForceFlavor()` to select a supported flavor explicitly. + +Without a flavor constraint, `ServerPath()` and `ServerTypeName()` use +FrankenPHP when applicable, then prefer FPM, CGI, and CLI in that order. + +Environment Variables +--------------------- + +### `SYMFONY_CLI_PHP_PATH` + +Adds directories to PHP discovery before the regular `PATH`. Use the operating +system path-list separator to provide several directories. This variable only +expands discovery; it does not select one installation when several match. + +```console +export SYMFONY_CLI_PHP_PATH=/opt/php/8.3/bin:/opt/php/8.4/bin +``` + +On Windows PowerShell: + +```powershell +$env:SYMFONY_CLI_PHP_PATH = 'C:\php83;C:\php84' +``` + +### `SYMFONY_CLI_PHP_BINARY_PATH` + +Overrides automatic version selection with one specific PHP CLI binary. The +value must be an absolute path: + +```console +export SYMFONY_CLI_PHP_BINARY_PATH=/usr/local/php8.4/bin/php +``` + +On Windows PowerShell: + +```powershell +$env:SYMFONY_CLI_PHP_BINARY_PATH = 'C:\xampp\php\php.exe' +``` + +The store runs the selected binary with `--version`, resolves symlinks, and +detects companion FPM, CGI, `php-config`, `phpize`, and `phpdbg` binaries when +they follow a conventional installation layout. Standalone PHP binaries are +also supported. The selected installation is included in `Versions()`. A +successful override returns a warning to make the bypass of automatic selection +explicit. + +License +------- + +PHP Store is available under the GNU Affero General Public License version 3 +or later. See [LICENSE](LICENSE) for details. diff --git a/discovery.go b/discovery.go index 6e9d8aa..8b58ad0 100644 --- a/discovery.go +++ b/discovery.go @@ -167,44 +167,75 @@ func (s *PHPStore) discoverPHP(dir, binName string) *Version { func (s *PHPStore) discoverPHPViaPHP(dir, binName string) *Version { php := filepath.Join(dir, "bin", binName) if runtime.GOOS == "windows" { - binName += ".exe" - php = filepath.Join(dir, binName) + php = filepath.Join(dir, binName+".exe") } - if _, err := os.Stat(php); err != nil { + version, err := s.inspectPHPBinary(php, dir, binName, true) + if err != nil { + s.log(" %s", err) return nil } - var buf bytes.Buffer - cmd := exec.Command(php, "--version") - cmd.Stdout = &buf - cmd.Stderr = &buf - if err := cmd.Run(); err != nil { - s.log(` Unable to run "%s --version: %s"`, php, err) - return nil + return version +} + +func (s *PHPStore) discoverPHPPath(php string) (*Version, error) { + if !filepath.IsAbs(php) { + return nil, errors.Errorf("php binary path must be absolute, got %q", php) + } + + resolved, err := evalSymlinks(filepath.Clean(php)) + if err != nil { + return nil, errors.Wrapf(err, "unable to resolve PHP binary %q", php) + } + + dir := filepath.Dir(resolved) + binName := filepath.Base(resolved) + discoverCompanions := false + if runtime.GOOS == "windows" { + if extension := filepath.Ext(binName); strings.EqualFold(extension, ".exe") { + binName = binName[:len(binName)-len(extension)] + } + discoverCompanions = strings.Contains(binName, "php") + } else if filepath.Base(dir) == "bin" { + dir = filepath.Dir(dir) + discoverCompanions = strings.Contains(binName, "php") + } + + return s.inspectPHPBinary(php, dir, binName, discoverCompanions) +} + +func (s *PHPStore) inspectPHPBinary(php, dir, binName string, discoverCompanions bool) (*Version, error) { + if _, err := os.Stat(php); err != nil { + return nil, errors.Wrapf(err, "unable to access PHP binary %q", php) + } + + output, err := s.phpVersionOutput(php) + if err != nil { + return nil, errors.Wrapf(err, "unable to run %q", php+" --version") } - r := regexp.MustCompile(`PHP (\d+\.\d+\.\d+)`) - data := r.FindSubmatch(buf.Bytes()) + data := regexp.MustCompile(`PHP (\d+\.\d+\.\d+)`).FindSubmatch(output) if data == nil { - s.log(" %s is not a PHP binary", php) - return nil + return nil, errors.Errorf("path %q is not a PHP binary", php) } - php = filepath.Clean(php) - var err error - php, err = evalSymlinks(php) + + resolved, err := evalSymlinks(filepath.Clean(php)) if err != nil { - s.log(" %s is not a valid symlink", php) - return nil + return nil, errors.Wrapf(err, "unable to resolve PHP binary %q", php) } v := s.validateVersion(dir, normalizeVersion(string(data[1]))) if v == nil { - return nil + return nil, errors.Errorf("unable to parse PHP version %q", data[1]) } version := &Version{ Path: dir, Version: v.String(), FullVersion: v, - PHPPath: php, + PHPPath: resolved, + } + if !discoverCompanions { + s.log(" Found PHP: %s", version.PHPPath) + return version, nil } fpm := filepath.Join(dir, "sbin", strings.Replace(binName, "php", "php-fpm", 1)) @@ -217,14 +248,25 @@ func (s *PHPStore) discoverPHPViaPHP(dir, binName string) *Version { phpize := filepath.Join(dir, "bin", strings.Replace(binName, "php", "phpize", 1)) phpdbg := filepath.Join(dir, "bin", strings.Replace(binName, "php", "phpdbg", 1)) if runtime.GOOS == "windows" { - fpm = filepath.Join(dir, strings.Replace(binName, "php", "php-fpm", 1)) - cgi = filepath.Join(dir, strings.Replace(binName, "php", "php-cgi", 1)) - phpconfig = filepath.Join(dir, strings.Replace(binName, "php", "php-config", 1)) - phpize = filepath.Join(dir, strings.Replace(binName, "php", "phpize", 1)) - phpdbg = filepath.Join(dir, strings.Replace(binName, "php", "phpdbg", 1)) + fpm = filepath.Join(dir, strings.Replace(binName, "php", "php-fpm", 1)+".exe") + cgi = filepath.Join(dir, strings.Replace(binName, "php", "php-cgi", 1)+".exe") + phpconfig = filepath.Join(dir, strings.Replace(binName, "php", "php-config", 1)+".exe") + phpize = filepath.Join(dir, strings.Replace(binName, "php", "phpize", 1)+".exe") + phpdbg = filepath.Join(dir, strings.Replace(binName, "php", "phpdbg", 1)+".exe") } s.log(version.setServer(fpm, cgi, phpconfig, phpize, phpdbg)) - return version + + return version, nil +} + +func runPHPVersion(php string) ([]byte, error) { + var buf bytes.Buffer + cmd := exec.Command(php, "--version") + cmd.Stdout = &buf + cmd.Stderr = &buf + err := cmd.Run() + + return buf.Bytes(), err } func (s *PHPStore) discoverPHPViaPHPConfig(dir, binName string) *Version { diff --git a/store.go b/store.go index 80fd3c8..1dec2dc 100644 --- a/store.go +++ b/store.go @@ -33,6 +33,9 @@ import ( yaml "gopkg.in/yaml.v2" ) +// PHPBinaryPathEnvVar overrides automatic PHP version selection with an absolute PHP CLI binary path. +const PHPBinaryPathEnvVar = "SYMFONY_CLI_PHP_BINARY_PATH" + // PHPStore stores information about all locally installed PHP versions type PHPStore struct { configDir string @@ -40,6 +43,7 @@ type PHPStore struct { pathVersion *Version seen map[string]int discoveryLogFunc func(msg string, a ...interface{}) + phpVersionOutput func(string) ([]byte, error) } // New creates a new PHP store @@ -59,6 +63,7 @@ func newEmpty(configDir string, logger func(msg string, a ...interface{})) *PHPS configDir: configDir, seen: make(map[string]int), discoveryLogFunc: logger, + phpVersionOutput: runPHPVersion, } } @@ -80,6 +85,18 @@ func (s *PHPStore) IsVersionAvailable(version string) bool { // BestVersionForDir returns the configured PHP version for the given PHP script func (s *PHPStore) BestVersionForDir(dir string) (*Version, string, string, error) { + if binaryPath := os.Getenv(PHPBinaryPathEnvVar); binaryPath != "" { + source := PHPBinaryPathEnvVar + " environment variable" + selected, err := s.discoverPHPPath(binaryPath) + if err != nil { + return nil, source, "", errors.Wrapf(err, "invalid %s", PHPBinaryPathEnvVar) + } + selected = s.versions[s.addVersion(selected)] + s.sortVersions() + + return selected, source, "PHP version selection is overridden by " + PHPBinaryPathEnvVar, nil + } + // forced version? if os.Getenv("FORCED_PHP_VERSION") != "" { minorPHPVersion := strings.Join(strings.Split(os.Getenv("FORCED_PHP_VERSION"), ".")[0:2], ".") @@ -223,18 +240,29 @@ func (s *PHPStore) loadVersions() { } s.versions = append(s.versions, v) } - sort.Sort(s.versions) + s.sortVersions() return } } } s.discover() - sort.Sort(s.versions) + s.sortVersions() if contents, err := json.MarshalIndent(s.versions, "", " "); err == nil { _ = os.WriteFile(cache, contents, 0644) } } +func (s *PHPStore) sortVersions() { + sort.Sort(s.versions) + s.seen = make(map[string]int, len(s.versions)) + for idx, version := range s.versions { + s.seen[version.PHPPath] = idx + if symlink, err := evalSymlinks(version.PHPPath); err == nil { + s.seen[symlink] = idx + } + } +} + // addVersion ensures that all versions are unique in the store func (s *PHPStore) addVersion(version *Version) int { idx, ok := s.seen[version.PHPPath] diff --git a/store_test.go b/store_test.go index 54cdc16..a0959b6 100644 --- a/store_test.go +++ b/store_test.go @@ -1,11 +1,180 @@ package phpstore import ( + "os" "path/filepath" "sort" + "strings" "testing" ) +func TestPHPBinaryPathOverride(t *testing.T) { + firstRoot := t.TempDir() + firstPath := filepath.Join(firstRoot, "bin", "php") + writeTestFile(t, firstPath) + selectedRoot := t.TempDir() + selectedPath := filepath.Join(selectedRoot, "bin", "php") + selectedFPMPath := filepath.Join(selectedRoot, "sbin", "php-fpm") + writeTestFile(t, selectedPath) + writeTestFile(t, selectedFPMPath) + + store := newEmpty(t.TempDir(), nil) + first := NewVersion("8.4.6") + first.PHPPath = firstPath + store.addVersion(first) + selected := NewVersion("8.4.6") + selected.PHPPath = selectedPath + store.addVersion(selected) + var inspectedPath string + store.phpVersionOutput = func(path string) ([]byte, error) { + inspectedPath = path + return []byte("PHP 8.4.6 (cli)"), nil + } + t.Setenv(PHPBinaryPathEnvVar, selectedPath) + projectDir := t.TempDir() + if err := os.WriteFile(filepath.Join(projectDir, ".php-version"), []byte("8.3"), 0644); err != nil { + t.Fatal(err) + } + + actual, source, warning, err := store.BestVersionForDir(projectDir) + if err != nil { + t.Fatal(err) + } + expectedPath := resolveTestPath(t, selectedPath) + if actual.PHPPath != expectedPath { + t.Fatalf("expected PHP binary %q, got %q", expectedPath, actual.PHPPath) + } + expectedFPMPath := resolveTestPath(t, selectedFPMPath) + if actual.FPMPath != expectedFPMPath { + t.Fatalf("expected PHP FPM binary %q, got %q", expectedFPMPath, actual.FPMPath) + } + if inspectedPath != selectedPath { + t.Fatalf("expected to inspect %q, got %q", selectedPath, inspectedPath) + } + if source != PHPBinaryPathEnvVar+" environment variable" { + t.Fatalf("unexpected source %q", source) + } + if warning != "PHP version selection is overridden by "+PHPBinaryPathEnvVar { + t.Fatalf("unexpected warning %q", warning) + } + if len(store.Versions()) != 2 { + t.Fatalf("expected two PHP installations, got %d", len(store.Versions())) + } +} + +func TestPHPBinaryPathOverrideResolvesSymlink(t *testing.T) { + root := t.TempDir() + realPath := filepath.Join(root, "bin", "php") + writeTestFile(t, realPath) + symlinkPath := filepath.Join(t.TempDir(), "php") + if err := os.Symlink(realPath, symlinkPath); err != nil { + t.Skipf("symlinks are not available: %s", err) + } + + store := newEmpty(t.TempDir(), nil) + store.phpVersionOutput = func(path string) ([]byte, error) { + return []byte("PHP 8.4.6 (cli)"), nil + } + t.Setenv(PHPBinaryPathEnvVar, symlinkPath) + + actual, _, _, err := store.BestVersionForDir(t.TempDir()) + if err != nil { + t.Fatal(err) + } + expectedPath := resolveTestPath(t, realPath) + if actual.PHPPath != expectedPath { + t.Fatalf("expected resolved PHP binary %q, got %q", expectedPath, actual.PHPPath) + } + expectedRoot := resolveTestPath(t, root) + if actual.Path != expectedRoot { + t.Fatalf("expected PHP installation root %q, got %q", expectedRoot, actual.Path) + } +} + +func TestPHPBinaryPathOverrideSupportsStandaloneBinary(t *testing.T) { + binaryPath := filepath.Join(t.TempDir(), "custom-php") + writeTestFile(t, binaryPath) + + store := newEmpty(t.TempDir(), nil) + store.phpVersionOutput = func(path string) ([]byte, error) { + return []byte("PHP 8.5.2 (cli)"), nil + } + t.Setenv(PHPBinaryPathEnvVar, binaryPath) + + actual, _, _, err := store.BestVersionForDir(t.TempDir()) + if err != nil { + t.Fatal(err) + } + expectedPath := resolveTestPath(t, binaryPath) + if actual.PHPPath != expectedPath { + t.Fatalf("expected PHP binary %q, got %q", expectedPath, actual.PHPPath) + } + expectedRoot := filepath.Dir(expectedPath) + if actual.Path != expectedRoot { + t.Fatalf("expected PHP installation root %q, got %q", expectedRoot, actual.Path) + } + if !actual.IsCLIServer() { + t.Fatalf("expected standalone PHP binary to use the CLI server") + } +} + +func TestPHPBinaryPathOverrideRejectsInvalidPaths(t *testing.T) { + t.Run("relative path", func(t *testing.T) { + store := newEmpty(t.TempDir(), nil) + store.phpVersionOutput = func(path string) ([]byte, error) { + t.Fatal("relative PHP binary should not be inspected") + return nil, nil + } + t.Setenv(PHPBinaryPathEnvVar, filepath.Join("bin", "php")) + + _, source, warning, err := store.BestVersionForDir(t.TempDir()) + if err == nil || !strings.Contains(err.Error(), "php binary path must be absolute") { + t.Fatalf("expected an absolute path error, got %v", err) + } + if source != PHPBinaryPathEnvVar+" environment variable" { + t.Fatalf("unexpected source %q", source) + } + if warning != "" { + t.Fatalf("unexpected warning %q", warning) + } + }) + + t.Run("not a PHP binary", func(t *testing.T) { + binaryPath := filepath.Join(t.TempDir(), "custom-php") + writeTestFile(t, binaryPath) + store := newEmpty(t.TempDir(), nil) + store.phpVersionOutput = func(path string) ([]byte, error) { + return []byte("not PHP"), nil + } + t.Setenv(PHPBinaryPathEnvVar, binaryPath) + + _, _, _, err := store.BestVersionForDir(t.TempDir()) + if err == nil || !strings.Contains(err.Error(), "is not a PHP binary") { + t.Fatalf("expected a PHP binary error, got %v", err) + } + }) +} + +func writeTestFile(t *testing.T, path string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, nil, 0755); err != nil { + t.Fatal(err) + } +} + +func resolveTestPath(t *testing.T, path string) string { + t.Helper() + resolved, err := evalSymlinks(path) + if err != nil { + t.Fatal(err) + } + + return resolved +} + func TestBestVersion(t *testing.T) { store := newEmpty("/dev/null", nil) for _, v := range []string{"7.4.33", "8.0.27", "8.1.2", "8.1.14", "8.2.1"} {