diff --git a/cmd/project/project_create.go b/cmd/project/project_create.go index cc67c41d..03aec344 100644 --- a/cmd/project/project_create.go +++ b/cmd/project/project_create.go @@ -33,17 +33,39 @@ type createOptions struct { selectedVersion string selectedDeployment string selectedCI string - useDocker bool - initGit bool - withElasticsearch bool - withAMQP bool - noAudit bool + // phpVersion is the major.minor PHP series the project uses: the Docker image + // tag for Docker projects, the local PHP lookup otherwise. Persisted as-is. + phpVersion string + // phpVersionExplicit records that --php-version was passed, so the creation + // form does not ask again. + phpVersionExplicit bool + // phpBinary is the local executable phpVersion resolved to; never persisted. + phpBinary string + useDocker bool + initGit bool + withElasticsearch bool + withAMQP bool + noAudit bool interactive bool elasticsearchExplicit bool isVerbose bool } +func (o *createOptions) setPHP(installation system.PHPInstallation) { + o.phpBinary = installation.Binary + o.phpVersion = system.PHPVersionPin(installation.Version) +} + +// clearPHP drops a resolved local PHP, e.g. when the form switches to Docker. An +// explicit --php-version is kept, since it applies to Docker projects too. +func (o *createOptions) clearPHP() { + o.phpBinary = "" + if !o.phpVersionExplicit { + o.phpVersion = "" + } +} + var projectCreateCmd = &cobra.Command{ Use: "create [name] [version]", Short: "Create a new Shopware 6 project", @@ -72,6 +94,12 @@ var projectCreateCmd = &cobra.Command{ RunE: func(cmd *cobra.Command, args []string) error { opts := parseCreateFlags(cmd, args) + if opts.phpVersionExplicit { + if err := shop.ValidatePHPVersion(opts.phpVersion); err != nil { + return err + } + } + // A name passed directly as an argument skips the interactive name // prompt, which is where invalid names (e.g. wrong casing) are normally // rejected live. Validate it up front so it is forbidden immediately @@ -94,7 +122,7 @@ var projectCreateCmd = &cobra.Command{ filteredVersions := shop.FilterInstallVersions(releases) if opts.interactive { - if err := runCreateForm(cmd, &opts, filteredVersions); err != nil { + if err := runCreateForm(cmd, &opts, releases, filteredVersions); err != nil { return err } } else { @@ -125,6 +153,7 @@ func parseCreateFlags(cmd *cobra.Command, args []string) createOptions { versionFlag, _ := cmd.PersistentFlags().GetString("version") deploymentMethod, _ := cmd.PersistentFlags().GetString("deployment") ciSystem, _ := cmd.PersistentFlags().GetString("ci") + phpVersion, _ := cmd.PersistentFlags().GetString("php-version") if cmd.PersistentFlags().Changed("without-elasticsearch") { withoutElasticsearch, _ := cmd.PersistentFlags().GetBool("without-elasticsearch") @@ -143,6 +172,8 @@ func parseCreateFlags(cmd *cobra.Command, args []string) createOptions { selectedVersion: versionFlag, selectedDeployment: deploymentMethod, selectedCI: ciSystem, + phpVersion: phpVersion, + phpVersionExplicit: cmd.PersistentFlags().Changed("php-version"), interactive: system.IsInteractionEnabled(cmd.Context()), elasticsearchExplicit: elasticsearchExplicit, isVerbose: isVerbose, @@ -189,4 +220,8 @@ func init() { projectCreateCmd.PersistentFlags().String("version", "", "Shopware version to install (e.g., 6.6.0.0, latest)") projectCreateCmd.PersistentFlags().String("deployment", "", "Deployment method: none, deployer, platformsh, shopware-paas") projectCreateCmd.PersistentFlags().String("ci", "", "CI/CD system: none, github, gitlab") + projectCreateCmd.PersistentFlags().String("php-version", "", "PHP version to use (e.g. 8.3); selects the local PHP for local projects and the image tag for --docker projects") + _ = projectCreateCmd.RegisterFlagCompletionFunc("php-version", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { + return shop.SupportedPHPVersions, cobra.ShellCompDirectiveNoFileComp + }) } diff --git a/cmd/project/project_create_form.go b/cmd/project/project_create_form.go index 34d91058..80ef6398 100644 --- a/cmd/project/project_create_form.go +++ b/cmd/project/project_create_form.go @@ -7,6 +7,7 @@ import ( "charm.land/huh/v2" "charm.land/lipgloss/v2" + "github.com/shyim/go-composer/repository" "github.com/shyim/go-version" "github.com/spf13/cobra" @@ -15,7 +16,7 @@ import ( "github.com/shopware/shopware-cli/internal/tui" ) -func runCreateForm(cmd *cobra.Command, opts *createOptions, filteredVersions []*version.Version) error { //nolint:gocyclo +func runCreateForm(cmd *cobra.Command, opts *createOptions, releases []repository.Version, filteredVersions []*version.Version) error { //nolint:gocyclo type minorGroup struct { label string versions []string @@ -52,15 +53,18 @@ func runCreateForm(cmd *cobra.Command, opts *createOptions, filteredVersions []* huh.NewOption("GitLab CI", shop.CIGitLab), } - needsAdvanced := opts.selectedDeployment == "" || opts.selectedCI == "" || - !cmd.PersistentFlags().Changed("git") || - !cmd.PersistentFlags().Changed("with-amqp") || - !opts.elasticsearchExplicit - needsProjectFolder := opts.projectFolder == "" needsVersion := opts.selectedVersion == "" needsDeployment := opts.selectedDeployment == "" needsCI := opts.selectedCI == "" + // An explicit --php-version is authoritative and validated later, so the form + // must not offer a competing choice. + needsPHPVersion := !opts.phpVersionExplicit + + needsAdvanced := needsDeployment || needsCI || needsPHPVersion || + !cmd.PersistentFlags().Changed("git") || + !cmd.PersistentFlags().Changed("with-amqp") || + !opts.elasticsearchExplicit selectDocker := tui.Yes selectGit := tui.Yes @@ -79,6 +83,42 @@ func runCreateForm(cmd *cobra.Command, opts *createOptions, filteredVersions []* } selectedMinor := shop.VersionLatest + // Docker may come from the --docker flag or from the in-form question, and + // the PHP selection depends on the answer either way. + dockerSelected := func() bool { + if cmd.PersistentFlags().Changed("docker") { + return opts.useDocker + } + return selectDocker == tui.Yes + } + + // The patch-version group stays hidden for "latest" and leaves + // opts.selectedVersion empty, which is only defaulted after the form ran. + effectiveVersion := func() string { + if opts.selectedVersion != "" { + return opts.selectedVersion + } + return shop.VersionLatest + } + + // Discovery spawns a subprocess per candidate, so it runs at most once; only + // the constraint filtering is redone when the Shopware version changes. Docker + // projects never reach it: their PHP comes from the image, not this machine. + var phpInstallations []system.PHPInstallation + phpDiscovered := false + compatiblePHPForSelection := func() []system.PHPInstallation { + if !phpDiscovered { + phpInstallations = discoverPHPInstallations(cmd.Context()) + phpDiscovered = true + } + return filterCompatiblePHPFor(phpInstallations, releases, effectiveVersion(), filteredVersions) + } + + // Docker image tags the selected Shopware release supports. + dockerPHPForSelection := func() []string { + return phpConstraintFor(releases, effectiveVersion(), filteredVersions).SupportedVersions() + } + theme := huh.ThemeFunc(func(isDark bool) *huh.Styles { s := huh.ThemeCharm(isDark) s.Focused.Title = s.Focused.Title.Foreground(tui.BlueColor) @@ -160,11 +200,67 @@ func runCreateForm(cmd *cobra.Command, opts *createOptions, filteredVersions []* formGroups = append(formGroups, huh.NewGroup( tui.NewYesNo(). Title("Do you want to further customize the project creation?"). - Description("Configure deployment, CI/CD, and optional features"). + Description("Configure PHP, deployment, CI/CD, and optional features"). Value(&selectAdvanced), )) } + // A local project selects an executable installed on this machine; a Docker + // project selects an image tag. phpGroupShown must not depend on OptionsFunc + // having run: huh evaluates WithHideFunc during navigation, while OptionsFunc + // is dispatched asynchronously, so deciding visibility from its options hides + // the group forever. + var selectedPHP string + phpCandidates := func() int { + if dockerSelected() { + return len(dockerPHPForSelection()) + } + return len(compatiblePHPForSelection()) + } + phpGroupShown := func() bool { + return selectAdvanced == tui.Yes && shouldPromptPHPSelection(phpCandidates()) + } + + if needsPHPVersion { + formGroups = append(formGroups, huh.NewGroup( + huh.NewSelect[string](). + TitleFunc(func() string { + if dockerSelected() { + return "PHP Version" + } + return "PHP Executable" + }, &selectDocker). + DescriptionFunc(func() string { + if dockerSelected() { + return "Select the PHP version of the Docker image (persisted as docker.php.version in .shopware-project.yml)" + } + return "Select the PHP used to create and run this project (its version is persisted as php_version in .shopware-project.yml)" + }, &selectDocker). + Height(10). + OptionsFunc(func() []huh.Option[string] { + if dockerSelected() { + versions := dockerPHPForSelection() + if !slices.Contains(versions, selectedPHP) { + selectedPHP = highestOrEmpty(versions) + } + return phpVersionOptions(versions) + } + + compatible := compatiblePHPForSelection() + // Keep the selection valid when changing the Shopware + // version narrows the compatible set. + if system.FindPHPByBinary(compatible, selectedPHP) == nil { + selectedPHP = "" + if preferred := system.PreferredPHPInstallation(compatible); preferred != nil { + selectedPHP = preferred.Binary + } + } + return phpInstallationOptions(compatible) + }, []*string{&selectDocker, &opts.selectedVersion}). + Value(&selectedPHP), + ).WithHideFunc(func() bool { return !phpGroupShown() })) + } + if needsDeployment { opts.selectedDeployment = shop.DeploymentNone formGroups = append(formGroups, huh.NewGroup( @@ -241,6 +337,30 @@ func runCreateForm(cmd *cobra.Command, opts *createOptions, filteredVersions []* if !cmd.PersistentFlags().Changed("with-amqp") { opts.withAMQP = selectAMQP == tui.Yes } + if needsPHPVersion { + // Reset on every round so switching to Docker (or restarting the + // form) does not keep a stale selection from a previous pass. + opts.clearPHP() + switch { + case opts.useDocker: + // Only a version, since the PHP comes from the image. Left empty + // when unanswered: installAndFinalize then picks the highest the + // release supports. + if phpGroupShown() { + opts.phpVersion = selectedPHP + } + case phpGroupShown(): + if selected := system.FindPHPByBinary(compatiblePHPForSelection(), selectedPHP); selected != nil { + opts.setPHP(*selected) + } + default: + // Nothing was asked (at most one compatible install), but resolve + // it anyway so the summary shows the PHP that will be used. + if preferred := system.PreferredPHPInstallation(compatiblePHPForSelection()); preferred != nil { + opts.setPHP(*preferred) + } + } + } fmt.Println() fmt.Println(tui.SectionHeadingStyle.Render("Summary")) @@ -256,6 +376,13 @@ func runCreateForm(cmd *cobra.Command, opts *createOptions, filteredVersions []* fmt.Printf(" %s %s\n", labelStyle.Render("Deployment:"), opts.selectedDeployment) fmt.Printf(" %s %s\n", labelStyle.Render("CI/CD:"), opts.selectedCI) fmt.Printf(" %s %s\n", labelStyle.Render("Docker:"), onOff(opts.useDocker)) + if opts.phpVersion != "" { + phpDisplay := opts.phpVersion + if opts.phpBinary != "" { + phpDisplay += " (" + opts.phpBinary + ")" + } + fmt.Printf(" %s %s\n", labelStyle.Render("PHP:"), phpDisplay) + } fmt.Printf(" %s %s\n", labelStyle.Render("Git Repository:"), onOff(opts.initGit)) fmt.Printf(" %s %s\n", labelStyle.Render("OpenSearch:"), onOff(opts.withElasticsearch)) fmt.Printf(" %s %s\n", labelStyle.Render("AMQP:"), onOff(opts.withAMQP)) diff --git a/cmd/project/project_create_install.go b/cmd/project/project_create_install.go index 3000b749..52640ed3 100644 --- a/cmd/project/project_create_install.go +++ b/cmd/project/project_create_install.go @@ -30,11 +30,19 @@ func installAndFinalize(cmd *cobra.Command, opts *createOptions, phpConstraint * composerInstallPHP := "" if opts.useDocker { - composerInstallPHP = phpConstraint.HighestSupported() + // An explicitly requested version wins; otherwise pick the newest PHP the + // selected Shopware release supports. + composerInstallPHP = opts.phpVersion + if composerInstallPHP == "" { + composerInstallPHP = phpConstraint.HighestSupported() + opts.phpVersion = composerInstallPHP + } logging.FromContext(ctx).Infof("Using PHP %s for composer install", composerInstallPHP) + } else if opts.phpBinary != "" { + logging.FromContext(ctx).Infof("Using PHP %s (%s) for composer install", opts.phpVersion, opts.phpBinary) } - if output, err := runComposerInstall(ctx, opts.projectFolder, opts.useDocker, showSpinner, composerInstallPHP); err != nil { + if output, err := runComposerInstall(ctx, opts.projectFolder, opts.useDocker, showSpinner, composerInstallPHP, opts.phpBinary); err != nil { if !isComposerSecurityBlocked(output) || opts.noAudit { return err } @@ -43,7 +51,7 @@ func installAndFinalize(cmd *cobra.Command, opts *createOptions, phpConstraint * return err } - if _, err := runComposerInstall(ctx, opts.projectFolder, opts.useDocker, showSpinner, composerInstallPHP); err != nil { + if _, err := runComposerInstall(ctx, opts.projectFolder, opts.useDocker, showSpinner, composerInstallPHP, opts.phpBinary); err != nil { return err } } @@ -67,6 +75,10 @@ func installAndFinalize(cmd *cobra.Command, opts *createOptions, phpConstraint * shopCfg.Docker = &shop.ConfigDocker{ PHP: &shop.ConfigDockerPHP{Version: composerInstallPHP}, } + } else if opts.phpVersion != "" { + // The version, not the executable path: the same PHP lives elsewhere on + // other machines, so later commands look it up locally. + shopCfg.PHPVersion = opts.phpVersion } if err := shop.WriteConfig(shopCfg, opts.projectFolder); err != nil { @@ -153,7 +165,12 @@ func handleSecurityBlockedInstall(ctx context.Context, opts *createOptions, chos return nil } -func runComposerInstall(ctx context.Context, projectFolder string, useDocker bool, showSpinner bool, phpVersion string) (string, error) { +// runComposerInstall installs the project dependencies. phpVersion selects the +// Docker image PHP version for Docker installs; phpBinary selects the local +// PHP executable for non-Docker installs (falling back to PHP_BINARY and the +// plain composer binary when empty). When Composer is not installed, a copy +// of the Composer PHAR is downloaded and used instead. +func runComposerInstall(ctx context.Context, projectFolder string, useDocker bool, showSpinner bool, phpVersion string, phpBinary string) (string, error) { var cmdInstall *exec.Cmd if useDocker && !system.IsInsideContainer() { @@ -188,16 +205,21 @@ func runComposerInstall(ctx context.Context, projectFolder string, useDocker boo cmdInstall = exec.CommandContext(ctx, "docker", dockerArgs...) } else { - composerBinary, err := exec.LookPath("composer") + composerBinary, isPhar, err := system.ResolveComposer(ctx) if err != nil { return "", err } - phpBinary := os.Getenv("PHP_BINARY") + if phpBinary == "" { + phpBinary = os.Getenv("PHP_BINARY") + } - if phpBinary != "" { + switch { + case phpBinary != "": cmdInstall = exec.CommandContext(ctx, phpBinary, composerBinary, "install", "--no-interaction") - } else { + case isPhar: + cmdInstall = exec.CommandContext(ctx, "php", composerBinary, "install", "--no-interaction") + default: cmdInstall = exec.CommandContext(ctx, "composer", "install", "--no-interaction") } diff --git a/cmd/project/project_create_php.go b/cmd/project/project_create_php.go new file mode 100644 index 00000000..a9a8da4c --- /dev/null +++ b/cmd/project/project_create_php.go @@ -0,0 +1,181 @@ +package project + +import ( + "context" + "fmt" + "os" + "strings" + + "charm.land/huh/v2" + "charm.land/lipgloss/v2" + "github.com/shyim/go-composer/repository" + "github.com/shyim/go-version" + + "github.com/shopware/shopware-cli/internal/shop" + "github.com/shopware/shopware-cli/internal/system" + "github.com/shopware/shopware-cli/internal/tui" + "github.com/shopware/shopware-cli/logging" +) + +// discoverPHPInstallations and unusablePHPBinaryEnv are seams for tests, which +// must not depend on the PHP versions installed on the developer machine. +var ( + discoverPHPInstallations = system.DiscoverPHPInstallations + unusablePHPBinaryEnv = system.UnusablePHPBinaryEnv +) + +// resolveLocalPHP sets opts.phpBinary to the local PHP used to create the +// project. Precedence: --php-version, then the interactive form's choice, then a +// compatible PHP_BINARY, then the compatible PATH default. When nothing usable is +// discovered it stays empty and the dependency validation reports the error. +func resolveLocalPHP(ctx context.Context, opts *createOptions, phpConstraint *shop.PHPConstraint) error { + // Discovery omits an unusable PHP_BINARY, so check it separately rather than + // silently replacing it with another PHP. + if err := unusablePHPBinaryEnv(ctx); err != nil { + return err + } + + installations := discoverPHPInstallations(ctx) + + if opts.phpVersionExplicit { + installation := system.FindPHPByVersionPin(installations, opts.phpVersion) + if installation == nil { + return &system.PHPVersionNotFoundError{Pin: opts.phpVersion, Installations: installations} + } + + if phpConstraint != nil && !phpConstraint.Check(installation.Version) { + return fmt.Errorf("the requested PHP %s does not satisfy the PHP constraint %s of the selected Shopware version; pass --php-version with a matching version", opts.phpVersion, phpConstraint) + } + + opts.setPHP(*installation) + return nil + } + + compatible := system.FilterCompatiblePHP(installations, phpConstraint) + + if len(compatible) == 0 { + // Leave opts.phpBinary empty so the dependency validation reports the + // error, but show what was found so the user sees which versions exist. + if len(installations) > 0 { + fmt.Fprintln(os.Stderr, renderDiscoveredPHP(installations, phpConstraint)) + } + return nil + } + + // Only fill in what the form left empty: overwriting would discard the PHP the + // user picked in the wizard and confirmed in its summary. + if opts.interactive { + if opts.phpBinary != "" { + return nil + } + + if preferred := system.PreferredPHPInstallation(compatible); preferred != nil { + opts.setPHP(*preferred) + } + return nil + } + + fallback := system.FindPHPBySource(compatible, system.PHPSourceEnv) + if fallback == nil { + fallback = system.DefaultPHPInstallation(compatible) + } + if fallback != nil { + logging.FromContext(ctx).Infof("Using PHP %s from %s (%s); use --php-version to override", fallback.Version, fallback.Source, fallback.Binary) + opts.setPHP(*fallback) + return nil + } + + var found []string + for _, installation := range compatible { + found = append(found, installation.String()) + } + + return fmt.Errorf("neither PHP_BINARY nor the php found in PATH satisfies the PHP constraint %s of the selected Shopware version; select one of the compatible installations with --php-version: %s", phpConstraint, strings.Join(found, ", ")) +} + +// compatiblePHPFor returns the discovered PHP installations that satisfy the +// PHP constraint of the given Shopware version, newest first. +func compatiblePHPFor(ctx context.Context, releases []repository.Version, selectedVersion string, filteredVersions []*version.Version) []system.PHPInstallation { + return filterCompatiblePHPFor(discoverPHPInstallations(ctx), releases, selectedVersion, filteredVersions) +} + +// filterCompatiblePHPFor is separate from discovery so the form can refilter an +// already discovered set when the version changes, without probing again. +func filterCompatiblePHPFor(installations []system.PHPInstallation, releases []repository.Version, selectedVersion string, filteredVersions []*version.Version) []system.PHPInstallation { + if _, err := shop.ResolveInstallVersion(selectedVersion, filteredVersions); err != nil { + return nil + } + + return system.FilterCompatiblePHP(installations, phpConstraintFor(releases, selectedVersion, filteredVersions)) +} + +// phpConstraintFor returns the PHP constraint of the given Shopware version, or +// nil when it cannot be resolved (which matches every version). +func phpConstraintFor(releases []repository.Version, selectedVersion string, filteredVersions []*version.Version) *shop.PHPConstraint { + chosenVersion, err := shop.ResolveInstallVersion(selectedVersion, filteredVersions) + if err != nil { + return nil + } + + return shop.PHPConstraintForShopwareVersion(releases, chosenVersion) +} + +// shouldPromptPHPSelection reports whether the form asks which PHP to use: only +// when there is an actual choice. Must be decidable without the select field's +// async OptionsFunc, since huh evaluates hide funcs during navigation. +func shouldPromptPHPSelection(candidates int) bool { + return candidates > 1 +} + +// highestOrEmpty returns the last entry of a SupportedPHPVersions-ordered list +// (lowest to highest), or an empty string when there is none. +func highestOrEmpty(versions []string) string { + if len(versions) == 0 { + return "" + } + return versions[len(versions)-1] +} + +func phpVersionOptions(versions []string) []huh.Option[string] { + options := make([]huh.Option[string], 0, len(versions)) + for _, phpVersion := range versions { + options = append(options, huh.NewOption("PHP "+phpVersion, phpVersion)) + } + return options +} + +func phpInstallationOptions(installations []system.PHPInstallation) []huh.Option[string] { + options := make([]huh.Option[string], 0, len(installations)) + for _, installation := range installations { + label := installation.String() + if installation.Source != "" { + label += " (" + installation.Source + ")" + } + options = append(options, huh.NewOption(label, installation.Binary)) + } + return options +} + +// renderDiscoveredPHP renders the PHP installations found on this machine when +// none of them satisfies the PHP constraint of the selected Shopware version. +func renderDiscoveredPHP(installations []system.PHPInstallation, phpConstraint *shop.PHPConstraint) string { + var b strings.Builder + + title := "Discovered PHP installations" + if constraint := phpConstraint.String(); constraint != "" { + title += fmt.Sprintf(" (none satisfies %s)", constraint) + } + b.WriteString(tui.RedText.Bold(true).Render(title)) + b.WriteString("\n\n") + + cross := tui.RedText.Render("✗") + for _, installation := range installations { + fmt.Fprintf(&b, " %s %s %s\n", cross, tui.BoldText.Render(installation.String()), tui.DimText.Render("("+installation.Source+")")) + } + + return lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(tui.BlueColor). + Padding(1, 2). + Render(strings.TrimRight(b.String(), "\n")) +} diff --git a/cmd/project/project_create_php_test.go b/cmd/project/project_create_php_test.go new file mode 100644 index 00000000..f78c09e4 --- /dev/null +++ b/cmd/project/project_create_php_test.go @@ -0,0 +1,330 @@ +package project + +import ( + "context" + "fmt" + "testing" + + "github.com/shyim/go-composer/repository" + "github.com/shyim/go-version" + "github.com/stretchr/testify/assert" + + "github.com/shopware/shopware-cli/internal/shop" + "github.com/shopware/shopware-cli/internal/system" +) + +// stubDiscovery replaces the PHP discovery for the duration of the test so +// tests never depend on the PHP versions installed on the machine. +func stubDiscovery(t *testing.T, installations []system.PHPInstallation) { + t.Helper() + original := discoverPHPInstallations + discoverPHPInstallations = func(context.Context) []system.PHPInstallation { + return installations + } + t.Cleanup(func() { discoverPHPInstallations = original }) +} + +func TestResolveLocalPHPExplicitVersion(t *testing.T) { + stubDiscovery(t, []system.PHPInstallation{ + {Binary: "/php84", Version: "8.4.2", Source: system.PHPSourcePath, Default: true}, + {Binary: "/php83-new", Version: "8.3.33", Source: "homebrew"}, + {Binary: "/php83-old", Version: "8.3.7", Source: "homebrew"}, + }) + + explicit := func(phpVersion string) createOptions { + return createOptions{phpVersion: phpVersion, phpVersionExplicit: true} + } + + t.Run("resolves the newest patch release of the requested version", func(t *testing.T) { + opts := explicit("8.3") + err := resolveLocalPHP(t.Context(), &opts, shop.NewPHPConstraint("~8.3.0")) + assert.NoError(t, err) + assert.Equal(t, "/php83-new", opts.phpBinary) + assert.Equal(t, "8.3", opts.phpVersion) + }) + + t.Run("the requested version wins over the newer PATH default", func(t *testing.T) { + opts := explicit("8.3") + err := resolveLocalPHP(t.Context(), &opts, shop.NewPHPConstraint(">=8.2")) + assert.NoError(t, err) + assert.Equal(t, "/php83-new", opts.phpBinary) + }) + + t.Run("a version not installed is rejected", func(t *testing.T) { + opts := explicit("8.1") + err := resolveLocalPHP(t.Context(), &opts, shop.NewPHPConstraint(">=8.0")) + + var notFound *system.PHPVersionNotFoundError + assert.ErrorAs(t, err, ¬Found) + assert.Equal(t, "8.1", notFound.Pin) + }) + + t.Run("a version the Shopware release does not support is rejected", func(t *testing.T) { + opts := explicit("8.3") + err := resolveLocalPHP(t.Context(), &opts, shop.NewPHPConstraint("~8.4.0")) + assert.ErrorContains(t, err, "8.3") + assert.ErrorContains(t, err, "~8.4.0") + assert.ErrorContains(t, err, "--php-version") + }) +} + +// Replacing the form's pick would silently contradict the summary the user +// confirmed. +func TestResolveLocalPHPKeepsInteractiveSelection(t *testing.T) { + stubDiscovery(t, []system.PHPInstallation{ + {Binary: "/usr/bin/php", Version: "8.4.2", Source: system.PHPSourcePath, Default: true}, + {Binary: "/opt/php83", Version: "8.3.7", Source: "homebrew"}, + }) + + t.Run("a selection made in the form survives", func(t *testing.T) { + opts := createOptions{interactive: true, phpBinary: "/opt/php83", phpVersion: "8.3"} + err := resolveLocalPHP(t.Context(), &opts, shop.NewPHPConstraint(">=8.2")) + assert.NoError(t, err) + assert.Equal(t, "/opt/php83", opts.phpBinary) + assert.Equal(t, "8.3", opts.phpVersion) + }) + + t.Run("an empty selection is still filled in", func(t *testing.T) { + opts := createOptions{interactive: true} + err := resolveLocalPHP(t.Context(), &opts, shop.NewPHPConstraint(">=8.2")) + assert.NoError(t, err) + assert.Equal(t, "/usr/bin/php", opts.phpBinary) + }) +} + +func TestResolveLocalPHPRejectsUnusablePHPBinaryEnv(t *testing.T) { + stubDiscovery(t, []system.PHPInstallation{ + {Binary: "/usr/bin/php", Version: "8.4.2", Source: system.PHPSourcePath, Default: true}, + }) + + // Stub what the real helper returns, wrapping included. + original := unusablePHPBinaryEnv + unusablePHPBinaryEnv = func(context.Context) error { + return fmt.Errorf("PHP_BINARY is set but unusable: %w", &system.PHPBinaryError{Path: "php8.2", Reason: "is not an executable file"}) + } + t.Cleanup(func() { unusablePHPBinaryEnv = original }) + + opts := createOptions{} + err := resolveLocalPHP(t.Context(), &opts, shop.NewPHPConstraint(">=8.2")) + assert.ErrorContains(t, err, "PHP_BINARY is set but unusable") + assert.ErrorContains(t, err, "php8.2") + assert.Empty(t, opts.phpBinary) +} + +func TestResolveLocalPHPNonInteractiveFallback(t *testing.T) { + t.Run("prefers compatible PHP_BINARY over newer PATH candidate", func(t *testing.T) { + stubDiscovery(t, []system.PHPInstallation{ + {Binary: "/env/php", Version: "8.3.1", Source: system.PHPSourceEnv}, + {Binary: "/usr/bin/php", Version: "8.4.2", Source: system.PHPSourcePath, Default: true}, + }) + + opts := createOptions{} + err := resolveLocalPHP(t.Context(), &opts, shop.NewPHPConstraint(">=8.2")) + assert.NoError(t, err) + assert.Equal(t, "/env/php", opts.phpBinary) + }) + + t.Run("falls back to PATH when PHP_BINARY is incompatible", func(t *testing.T) { + stubDiscovery(t, []system.PHPInstallation{ + {Binary: "/env/php", Version: "8.1.0", Source: system.PHPSourceEnv}, + {Binary: "/usr/bin/php", Version: "8.3.2", Source: system.PHPSourcePath, Default: true}, + }) + + opts := createOptions{} + err := resolveLocalPHP(t.Context(), &opts, shop.NewPHPConstraint("~8.3.0")) + assert.NoError(t, err) + assert.Equal(t, "/usr/bin/php", opts.phpBinary) + }) + + t.Run("fails with flag hint when only other sources are compatible", func(t *testing.T) { + stubDiscovery(t, []system.PHPInstallation{ + {Binary: "/usr/bin/php", Version: "8.1.0", Source: system.PHPSourcePath, Default: true}, + {Binary: "/opt/homebrew/opt/php@8.3/bin/php", Version: "8.3.2", Source: "homebrew"}, + }) + + opts := createOptions{} + err := resolveLocalPHP(t.Context(), &opts, shop.NewPHPConstraint("~8.3.0")) + assert.ErrorContains(t, err, "--php-version") + assert.ErrorContains(t, err, "/opt/homebrew/opt/php@8.3/bin/php") + assert.Empty(t, opts.phpBinary) + }) + + t.Run("leaves selection empty when nothing is compatible", func(t *testing.T) { + stubDiscovery(t, []system.PHPInstallation{ + {Binary: "/usr/bin/php", Version: "8.1.0", Source: system.PHPSourcePath, Default: true}, + }) + + opts := createOptions{} + err := resolveLocalPHP(t.Context(), &opts, shop.NewPHPConstraint("~8.3.0")) + assert.NoError(t, err) + assert.Empty(t, opts.phpBinary) + }) + + t.Run("leaves selection empty when nothing is discovered", func(t *testing.T) { + stubDiscovery(t, nil) + + opts := createOptions{} + err := resolveLocalPHP(t.Context(), &opts, shop.NewPHPConstraint("~8.3.0")) + assert.NoError(t, err) + assert.Empty(t, opts.phpBinary) + }) +} + +func TestCompatiblePHPFor(t *testing.T) { + releases := []repository.Version{ + {Version: "v6.6.0.0", Require: map[string]string{"php": "~8.2.0 || ~8.3.0"}}, + {Version: "v6.7.0.0", Require: map[string]string{"php": "~8.3.0 || ~8.4.0"}}, + } + filteredVersions := []*version.Version{ + version.Must(version.NewVersion("6.7.0.0")), + version.Must(version.NewVersion("6.6.0.0")), + } + + stubDiscovery(t, []system.PHPInstallation{ + {Binary: "/php84", Version: "8.4.1", Source: system.PHPSourcePath, Default: true}, + {Binary: "/php83", Version: "8.3.2", Source: "homebrew"}, + {Binary: "/php82", Version: "8.2.9", Source: "homebrew"}, + }) + + binaries := func(installations []system.PHPInstallation) []string { + out := make([]string, 0, len(installations)) + for _, installation := range installations { + out = append(out, installation.Binary) + } + return out + } + + t.Run("filters by the constraint of the selected version", func(t *testing.T) { + got := compatiblePHPFor(t.Context(), releases, "6.6.0.0", filteredVersions) + assert.Equal(t, []string{"/php83", "/php82"}, binaries(got)) + }) + + t.Run("refilters when another version is selected", func(t *testing.T) { + got := compatiblePHPFor(t.Context(), releases, "6.7.0.0", filteredVersions) + assert.Equal(t, []string{"/php84", "/php83"}, binaries(got)) + }) + + t.Run("resolves latest to a concrete version", func(t *testing.T) { + got := compatiblePHPFor(t.Context(), releases, shop.VersionLatest, filteredVersions) + assert.Equal(t, []string{"/php84", "/php83"}, binaries(got)) + }) + + t.Run("returns nothing for an unknown version", func(t *testing.T) { + assert.Empty(t, compatiblePHPFor(t.Context(), releases, "6.1.0.0", filteredVersions)) + }) +} + +func TestShouldPromptPHPSelection(t *testing.T) { + assert.True(t, shouldPromptPHPSelection(2)) + assert.False(t, shouldPromptPHPSelection(1)) + assert.False(t, shouldPromptPHPSelection(0)) +} + +func TestPHPVersionOptions(t *testing.T) { + options := phpVersionOptions([]string{"8.2", "8.3"}) + + assert.Len(t, options, 2) + assert.Equal(t, "PHP 8.2", options[0].Key) + assert.Equal(t, "8.2", options[0].Value) +} + +func TestHighestOrEmpty(t *testing.T) { + // SupportedPHPVersions is ordered lowest to highest. + assert.Equal(t, "8.5", highestOrEmpty([]string{"8.3", "8.4", "8.5"})) + assert.Empty(t, highestOrEmpty(nil)) +} + +func TestPHPConstraintForDockerImages(t *testing.T) { + releases := []repository.Version{ + {Version: "v6.6.0.0", Require: map[string]string{"php": "~8.2.0 || ~8.3.0"}}, + {Version: "v6.7.0.0", Require: map[string]string{"php": "~8.3.0 || ~8.4.0"}}, + } + filteredVersions := []*version.Version{ + version.Must(version.NewVersion("6.7.0.0")), + version.Must(version.NewVersion("6.6.0.0")), + } + + // Docker offers image tags from SupportedPHPVersions filtered by the release, + // with no dependency on what is installed locally. + assert.Equal(t, []string{"8.2", "8.3"}, + phpConstraintFor(releases, "6.6.0.0", filteredVersions).SupportedVersions()) + assert.Equal(t, []string{"8.3", "8.4"}, + phpConstraintFor(releases, "6.7.0.0", filteredVersions).SupportedVersions()) +} + +// huh dispatches OptionsFunc asynchronously but evaluates hide funcs during +// navigation, so deciding visibility from its options hides the group forever. +func TestPHPSelectionVisibilityDoesNotDependOnOptionsFunc(t *testing.T) { + releases := []repository.Version{ + {Version: "v6.6.0.0", Require: map[string]string{"php": ">=8.2"}}, + } + filteredVersions := []*version.Version{version.Must(version.NewVersion("6.6.0.0"))} + + stubDiscovery(t, []system.PHPInstallation{ + {Binary: "/php83", Version: "8.3.2", Source: system.PHPSourcePath, Default: true}, + {Binary: "/php82", Version: "8.2.9", Source: "homebrew"}, + }) + + // Mirrors what the form does before any field is initialized. + compatible := compatiblePHPFor(t.Context(), releases, "6.6.0.0", filteredVersions) + assert.True(t, shouldPromptPHPSelection(len(compatible))) +} + +func TestPHPInstallationOptions(t *testing.T) { + options := phpInstallationOptions([]system.PHPInstallation{ + {Binary: "/php83", Version: "8.3.2", Source: "homebrew"}, + {Binary: "/php82", Version: "8.2.9"}, + }) + + assert.Len(t, options, 2) + assert.Contains(t, options[0].Key, "homebrew") + assert.Equal(t, "/php83", options[0].Value) + // A blank source must not render an empty "()" suffix. + assert.NotContains(t, options[1].Key, "(") + assert.Equal(t, "/php82", options[1].Value) +} + +func TestSetPHPRecordsPortableVersion(t *testing.T) { + opts := createOptions{} + opts.setPHP(system.PHPInstallation{Binary: "/opt/homebrew/Cellar/php@8.3/8.3.33/bin/php", Version: "8.3.33"}) + + // Only the major.minor version reaches the config. + assert.Equal(t, "/opt/homebrew/Cellar/php@8.3/8.3.33/bin/php", opts.phpBinary) + assert.Equal(t, "8.3", opts.phpVersion) + + opts.clearPHP() + assert.Empty(t, opts.phpBinary) + assert.Empty(t, opts.phpVersion) +} + +// An explicit --php-version still applies to Docker: it selects the image tag. +func TestClearPHPKeepsExplicitlyRequestedVersion(t *testing.T) { + opts := createOptions{phpVersion: "8.3", phpVersionExplicit: true, phpBinary: "/php83"} + + opts.clearPHP() + assert.Empty(t, opts.phpBinary) + assert.Equal(t, "8.3", opts.phpVersion) +} + +func TestValidateAndPreflightDockerAcceptsRequestedPHPVersion(t *testing.T) { + releases := []repository.Version{ + {Version: "v6.7.0.0", Require: map[string]string{"php": "~8.3.0 || ~8.4.0"}}, + } + filteredVersions := []*version.Version{version.Must(version.NewVersion("6.7.0.0"))} + + // A supported version is not asserted here: validateAndPreflight continues into + // the security-advisory check, which queries Packagist over the network. + + t.Run("a version the Shopware release does not support is rejected", func(t *testing.T) { + stubDiscovery(t, nil) + opts := createOptions{ + projectFolder: "my-shop", + useDocker: true, phpVersion: "8.2", phpVersionExplicit: true, + selectedVersion: "6.7.0.0", selectedDeployment: shop.DeploymentNone, selectedCI: shop.CINone, + } + + _, _, err := validateAndPreflight(t.Context(), &opts, releases, filteredVersions) + assert.ErrorContains(t, err, "8.2") + assert.ErrorContains(t, err, "--php-version") + }) +} diff --git a/cmd/project/project_create_validate.go b/cmd/project/project_create_validate.go index 0c311e8e..f1c1e206 100644 --- a/cmd/project/project_create_validate.go +++ b/cmd/project/project_create_validate.go @@ -34,8 +34,18 @@ func validateAndPreflight(ctx context.Context, opts *createOptions, releases []r opts.selectedCI = scaffold.CISystem opts.withElasticsearch = scaffold.UseElasticsearch + if opts.useDocker { + // Docker takes its PHP from the image, so there is no local executable to + // resolve, only the requested version to check. + if opts.phpVersionExplicit && phpConstraint != nil && !phpConstraint.Check(opts.phpVersion+".0") { + return "", nil, fmt.Errorf("the requested PHP %s does not satisfy the PHP constraint %s of the selected Shopware version; pass --php-version with a matching version", opts.phpVersion, phpConstraint) + } + } else if err := resolveLocalPHP(ctx, opts, phpConstraint); err != nil { + return "", nil, err + } + dockerHint := "re-run with " + tui.BoldText.Render("--docker") - if err := system.ValidateProjectDependencies(ctx, opts.useDocker, phpConstraint, "create a Shopware project", dockerHint); err != nil { + if err := system.ValidateProjectDependencies(ctx, opts.useDocker, phpConstraint, "create a Shopware project", dockerHint, opts.phpBinary); err != nil { return "", nil, err } diff --git a/cmd/project/project_dev.go b/cmd/project/project_dev.go index 370fc9a2..39c5c4f5 100644 --- a/cmd/project/project_dev.go +++ b/cmd/project/project_dev.go @@ -160,7 +160,18 @@ func newDevEnvironment(cmd *cobra.Command, projectRoot string, cfg *shop.Config) useDocker := exec.Type() == executor.TypeDocker dockerHint := "set the environment " + tui.BoldText.Render("type") + " to " + tui.BoldText.Render("docker") + " in " + tui.BoldText.Render(".shopware-project.yml") - if err := system.ValidateProjectDependencies(cmd.Context(), useDocker, nil, "start the development environment", dockerHint); err != nil { + + // Docker gets its PHP from the image. Must use the same precedence as the + // executor, or the dependencies of a different PHP would be validated. + var phpBinary string + if !useDocker { + phpBinary, err = system.ResolveProjectPHPBinary(cmd.Context(), cfg.PHPVersion) + if err != nil { + return nil, err + } + } + + if err := system.ValidateProjectDependencies(cmd.Context(), useDocker, nil, "start the development environment", dockerHint, phpBinary); err != nil { return nil, err } diff --git a/go.mod b/go.mod index 3d8efb25..b50b6bac 100644 --- a/go.mod +++ b/go.mod @@ -27,6 +27,7 @@ require ( github.com/shyim/go-composer v0.1.3 github.com/shyim/go-composer/sbom v0.1.1 github.com/shyim/go-endoflife-api v0.0.0-20260630085844-dc60358f29eb + github.com/shyim/go-php-discover v0.0.0-20260802092855-da3bc85f491c github.com/shyim/go-phplint v0.2.1 github.com/shyim/go-spdx v0.0.0-20260602055701-a935a2772ac1 github.com/shyim/go-version v0.0.0-20250828113848-97ec77491b32 diff --git a/go.sum b/go.sum index 2def5d55..b3f414f6 100644 --- a/go.sum +++ b/go.sum @@ -158,6 +158,8 @@ github.com/shyim/go-endoflife-api v0.0.0-20260630085844-dc60358f29eb h1:UV4IY3kU github.com/shyim/go-endoflife-api v0.0.0-20260630085844-dc60358f29eb/go.mod h1:A2eEOvzM8FuskmCqDmosSgoGGGw4BFIvvW9njuJfFfk= github.com/shyim/go-htmlprinter v0.0.0-20250417052954-e3e325d9ba3f h1:b7RObIj6TnesTadmtJ9pidOTxW72lODQcvwrLaje6/g= github.com/shyim/go-htmlprinter v0.0.0-20250417052954-e3e325d9ba3f/go.mod h1:UZ9KS4PRWtcsdL8IqdeXQK+L+L1tDqe6+lfmq9b12eo= +github.com/shyim/go-php-discover v0.0.0-20260802092855-da3bc85f491c h1:4u/ATdQ4wCgIpQ2KJVC+iJXeB92kP6dI30CDZMSRJJI= +github.com/shyim/go-php-discover v0.0.0-20260802092855-da3bc85f491c/go.mod h1:AGw/+zOkH6mNkBiTO4+2zCZvdMgd3oFyRLmxIMXQPGw= github.com/shyim/go-phplint v0.2.1 h1:raCPLnqTvZK+FmUuW7YhpD6XFtium3Jh0KwUkb2O++g= github.com/shyim/go-phplint v0.2.1/go.mod h1:EFbIzL9UZ0DKJwt9SbYUr2thlbK1BHiiaLGyue5XjcM= github.com/shyim/go-spdx v0.0.0-20260602055701-a935a2772ac1 h1:oD4MMCeWHSuvvLv2iXOKVMZRvJyn+r3xwIMU2BFttDo= diff --git a/internal/executor/executor_test.go b/internal/executor/executor_test.go index 47cc09d8..7a5920b3 100644 --- a/internal/executor/executor_test.go +++ b/internal/executor/executor_test.go @@ -1,12 +1,18 @@ package executor import ( + "context" + "fmt" + "os" + osexec "os/exec" + "path/filepath" "runtime" "testing" "github.com/stretchr/testify/assert" "github.com/shopware/shopware-cli/internal/shop" + "github.com/shopware/shopware-cli/internal/system" ) func TestNewLocalExecutor(t *testing.T) { @@ -46,6 +52,7 @@ func TestNewUnsupportedType(t *testing.T) { } func TestLocalExecutorConsoleCommand(t *testing.T) { + t.Setenv("PHP_BINARY", "") exec := &LocalExecutor{projectRoot: "/project"} p := exec.ConsoleCommand(t.Context(), "cache:clear") @@ -54,6 +61,8 @@ func TestLocalExecutorConsoleCommand(t *testing.T) { } func TestLocalExecutorComposerCommand(t *testing.T) { + t.Setenv("PHP_BINARY", "") + stubComposer(t, "/usr/local/bin/composer", false, nil) exec := &LocalExecutor{projectRoot: "/project"} p := exec.ComposerCommand(t.Context(), "install") @@ -62,6 +71,7 @@ func TestLocalExecutorComposerCommand(t *testing.T) { } func TestLocalExecutorPHPCommand(t *testing.T) { + t.Setenv("PHP_BINARY", "") exec := &LocalExecutor{projectRoot: "/project"} p := exec.PHPCommand(t.Context(), "-v") @@ -69,6 +79,153 @@ func TestLocalExecutorPHPCommand(t *testing.T) { assert.Equal(t, "/project", p.Cmd.Dir) } +// writeFakePHPBinary creates an executable reporting the given PHP version. +func writeFakePHPBinary(t *testing.T, path string, version string) { + t.Helper() + shPath, err := osexec.LookPath("sh") + assert.NoError(t, err) + + script := fmt.Sprintf("#!%s\necho PHP %s\n", shPath, version) + assert.NoError(t, os.WriteFile(path, []byte(script), 0o755)) +} + +// stubComposer resolves Composer to the given path, so tests neither depend on +// a Composer installation nor trigger a real PHAR download. +func stubComposer(t *testing.T, path string, isPhar bool, err error) { + t.Helper() + original := resolveComposer + resolveComposer = func(context.Context) (string, bool, error) { + return path, isPhar, err + } + t.Cleanup(func() { resolveComposer = original }) +} + +// stubPinnedPHP resolves a php_version to the given binary, keeping PHP_BINARY's +// real precedence over the pin. +func stubPinnedPHP(t *testing.T, binary string, err error) { + t.Helper() + original := resolveProjectPHPBinary + resolveProjectPHPBinary = func(_ context.Context, pin string) (string, error) { + if env := os.Getenv("PHP_BINARY"); env != "" { + return env, nil + } + if pin == "" { + return "", nil + } + return binary, err + } + t.Cleanup(func() { resolveProjectPHPBinary = original }) +} + +func TestLocalExecutorUsesProjectPHPVersion(t *testing.T) { + t.Setenv("PHP_BINARY", "") + stubPinnedPHP(t, "/opt/homebrew/opt/php@8.3/bin/php", nil) + exec := &LocalExecutor{projectRoot: "/project", shopCfg: &shop.Config{PHPVersion: "8.3"}} + + p := exec.ConsoleCommand(t.Context(), "cache:clear") + assert.Equal(t, []string{"/opt/homebrew/opt/php@8.3/bin/php", "bin/console", "cache:clear"}, p.Cmd.Args) + + p = exec.PHPCommand(t.Context(), "-v") + assert.Equal(t, []string{"/opt/homebrew/opt/php@8.3/bin/php", "-v"}, p.Cmd.Args) +} + +func TestLocalExecutorPHPBinaryEnvOverridesProjectPHPVersion(t *testing.T) { + t.Setenv("PHP_BINARY", "/env/php") + stubPinnedPHP(t, "/opt/homebrew/opt/php@8.3/bin/php", nil) + exec := &LocalExecutor{projectRoot: "/project", shopCfg: &shop.Config{PHPVersion: "8.3"}} + + p := exec.ConsoleCommand(t.Context(), "cache:clear") + assert.Equal(t, []string{"/env/php", "bin/console", "cache:clear"}, p.Cmd.Args) + + p = exec.PHPCommand(t.Context(), "-v") + assert.Equal(t, []string{"/env/php", "-v"}, p.Cmd.Args) +} + +func TestLocalExecutorReportsUnresolvablePHPVersion(t *testing.T) { + t.Setenv("PHP_BINARY", "") + notFound := &system.PHPVersionNotFoundError{Pin: "8.3"} + stubPinnedPHP(t, "", notFound) + exec := &LocalExecutor{projectRoot: "/project", shopCfg: &shop.Config{PHPVersion: "8.3"}} + + // The error is carried by the command so it surfaces when it runs. + for name, p := range map[string]*Process{ + "console": exec.ConsoleCommand(t.Context(), "cache:clear"), + "php": exec.PHPCommand(t.Context(), "-v"), + "composer": exec.ComposerCommand(t.Context(), "install"), + } { + t.Run(name, func(t *testing.T) { + assert.ErrorIs(t, p.Cmd.Err, notFound) + assert.ErrorIs(t, p.Run(), notFound) + }) + } +} + +func TestLocalExecutorFallsBackToPHPBinaryEnv(t *testing.T) { + dir := t.TempDir() + writeFakePHPBinary(t, filepath.Join(dir, "php"), "8.3.19") + t.Setenv("PHP_BINARY", filepath.Join(dir, "php")) + exec := &LocalExecutor{projectRoot: "/project", shopCfg: &shop.Config{}} + + p := exec.PHPCommand(t.Context(), "-v") + resolved, err := filepath.EvalSymlinks(filepath.Join(dir, "php")) + assert.NoError(t, err) + assert.Equal(t, []string{resolved, "-v"}, p.Cmd.Args) +} + +func TestLocalExecutorRejectsUnusablePHPBinaryEnv(t *testing.T) { + t.Setenv("PHP_BINARY", "/does/not/exist/php") + exec := &LocalExecutor{projectRoot: "/project", shopCfg: &shop.Config{}} + + p := exec.PHPCommand(t.Context(), "-v") + assert.ErrorContains(t, p.Cmd.Err, "PHP_BINARY is set but unusable") + assert.ErrorContains(t, p.Run(), "PHP_BINARY is set but unusable") +} + +func TestLocalExecutorComposerRunsThroughSelectedPHP(t *testing.T) { + binDir := t.TempDir() + composerPath := filepath.Join(binDir, "composer") + assert.NoError(t, os.WriteFile(composerPath, []byte("#!/bin/sh\n"), 0o755)) + t.Setenv("PATH", binDir) + t.Setenv("PHP_BINARY", "") + + stubPinnedPHP(t, "/custom/php", nil) + exec := &LocalExecutor{projectRoot: "/project", shopCfg: &shop.Config{PHPVersion: "8.3"}} + + p := exec.ComposerCommand(t.Context(), "install") + assert.Equal(t, []string{"/custom/php", composerPath, "install"}, p.Cmd.Args) +} + +func TestLocalExecutorComposerUsesDownloadedPharWithoutComposerInPath(t *testing.T) { + t.Setenv("PHP_BINARY", "") + stubComposer(t, "/cache/shopware-cli/composer.phar", true, nil) + + t.Run("with pinned php", func(t *testing.T) { + stubPinnedPHP(t, "/custom/php", nil) + exec := &LocalExecutor{projectRoot: "/project", shopCfg: &shop.Config{PHPVersion: "8.3"}} + + p := exec.ComposerCommand(t.Context(), "install") + assert.Equal(t, []string{"/custom/php", "/cache/shopware-cli/composer.phar", "install"}, p.Cmd.Args) + }) + + t.Run("with default php", func(t *testing.T) { + exec := &LocalExecutor{projectRoot: "/project"} + + p := exec.ComposerCommand(t.Context(), "install") + assert.Equal(t, []string{"php", "/cache/shopware-cli/composer.phar", "install"}, p.Cmd.Args) + }) +} + +func TestLocalExecutorComposerReportsFailedDownload(t *testing.T) { + t.Setenv("PHP_BINARY", "") + downloadErr := fmt.Errorf("cannot download composer: connection refused") + stubComposer(t, "", false, downloadErr) + exec := &LocalExecutor{projectRoot: "/project"} + + p := exec.ComposerCommand(t.Context(), "install") + assert.ErrorIs(t, p.Cmd.Err, downloadErr) + assert.ErrorIs(t, p.Run(), downloadErr) +} + func TestSymfonyCLIExecutorConsoleCommand(t *testing.T) { exec := &SymfonyCLIExecutor{BinaryPath: "/usr/local/bin/symfony", projectRoot: "/project"} diff --git a/internal/executor/local.go b/internal/executor/local.go index 95024455..524db8d0 100644 --- a/internal/executor/local.go +++ b/internal/executor/local.go @@ -8,6 +8,7 @@ import ( adminSdk "github.com/shopware/shopware-cli/internal/admin-api" "github.com/shopware/shopware-cli/internal/shop" + "github.com/shopware/shopware-cli/internal/system" ) type LocalExecutor struct { @@ -18,10 +19,52 @@ type LocalExecutor struct { envCfg *shop.EnvironmentConfig } +// resolveProjectPHPBinary is a seam for tests, which must not depend on the PHP +// versions installed on the machine running them. +var resolveProjectPHPBinary = system.ResolveProjectPHPBinary + +// resolveComposer is a seam for tests, which must not depend on a Composer +// installation or trigger a real PHAR download. +var resolveComposer = system.ResolveComposer + +// phpBinary returns the PHP executable used for this project's commands, +// following the precedence: PHP_BINARY > php_version from .shopware-project.yml > +// "php" from PATH. Resolution failures are returned rather than falling back, so +// the project never silently runs on a different PHP version. +func (l *LocalExecutor) phpBinary(ctx context.Context) (string, error) { + var pin string + if l.shopCfg != nil { + pin = l.shopCfg.PHPVersion + } + + phpBinary, err := resolveProjectPHPBinary(ctx, pin) + if err != nil { + return "", err + } + if phpBinary != "" { + return phpBinary, nil + } + + return "php", nil +} + +// phpCommand builds a command running the project's PHP. A resolution failure is +// attached to the command so it surfaces from Run/Output. +func (l *LocalExecutor) phpCommand(ctx context.Context, args ...string) *exec.Cmd { + phpBinary, err := l.phpBinary(ctx) + if err != nil { + cmd := exec.CommandContext(ctx, "php", args...) + cmd.Err = err + return cmd + } + + return exec.CommandContext(ctx, phpBinary, args...) +} + func (l *LocalExecutor) ConsoleCommand(ctx context.Context, args ...string) *Process { cmdArgs := []string{consoleCommandName(ctx)} cmdArgs = append(cmdArgs, args...) - cmd := exec.CommandContext(ctx, "php", cmdArgs...) + cmd := l.phpCommand(ctx, cmdArgs...) applyLocalEnv(l.projectRoot, l.env, cmd) applyDir(resolveDir(l.projectRoot, l.relDir), cmd) logCmd(ctx, cmd) @@ -29,7 +72,31 @@ func (l *LocalExecutor) ConsoleCommand(ctx context.Context, args ...string) *Pro } func (l *LocalExecutor) ComposerCommand(ctx context.Context, args ...string) *Process { - cmd := exec.CommandContext(ctx, "composer", args...) + var cmd *exec.Cmd + + phpBinary, err := l.phpBinary(ctx) + if err != nil { + cmd = exec.CommandContext(ctx, "composer", args...) + cmd.Err = err + return newProcess(cmd) + } + + composerBinary, isPhar, err := resolveComposer(ctx) + if err != nil { + cmd = exec.CommandContext(ctx, "composer", args...) + cmd.Err = err + return newProcess(cmd) + } + + // Run Composer through PHP when a specific PHP executable is selected (so + // dependency resolution and scripts use the same PHP as the project) or + // when only the downloaded PHAR is available. + if isPhar || phpBinary != "php" { + cmd = exec.CommandContext(ctx, phpBinary, append([]string{composerBinary}, args...)...) + } else { + cmd = exec.CommandContext(ctx, "composer", args...) + } + applyLocalEnv(l.projectRoot, l.env, cmd) applyDir(resolveDir(l.projectRoot, l.relDir), cmd) logCmd(ctx, cmd) @@ -37,7 +104,7 @@ func (l *LocalExecutor) ComposerCommand(ctx context.Context, args ...string) *Pr } func (l *LocalExecutor) PHPCommand(ctx context.Context, args ...string) *Process { - cmd := exec.CommandContext(ctx, "php", args...) + cmd := l.phpCommand(ctx, args...) applyLocalEnv(l.projectRoot, l.env, cmd) applyDir(resolveDir(l.projectRoot, l.relDir), cmd) logCmd(ctx, cmd) diff --git a/internal/shop/config.go b/internal/shop/config.go index a23bcfdc..92d5cb75 100644 --- a/internal/shop/config.go +++ b/internal/shop/config.go @@ -30,13 +30,15 @@ type Config struct { // The URL of the Shopware instance URL string `yaml:"url"` // Controls date-based compatibility behavior, formatted as YYYY-MM-DD. - CompatibilityDate string `yaml:"compatibility_date,omitempty" jsonschema:"format=date"` - Build *ConfigBuild `yaml:"build,omitempty"` - AdminApi *ConfigAdminApi `yaml:"admin_api,omitempty"` - ConfigDump *ConfigDump `yaml:"dump,omitempty"` - ConfigDeployment *ConfigDeployment `yaml:"deployment,omitempty"` - Validation *ConfigValidation `yaml:"validation,omitempty"` - ImageProxy *ConfigImageProxy `yaml:"image_proxy,omitempty"` + CompatibilityDate string `yaml:"compatibility_date,omitempty" jsonschema:"format=date"` + // PHP version (e.g. "8.3") used for local PHP and Composer commands of this project. Written by "project create" for non-Docker projects. The matching PHP is looked up on the machine running the command, so the value stays portable across machines; it takes precedence over the php found in PATH, while the PHP_BINARY environment variable overrides it. + PHPVersion string `yaml:"php_version,omitempty"` + Build *ConfigBuild `yaml:"build,omitempty"` + AdminApi *ConfigAdminApi `yaml:"admin_api,omitempty"` + ConfigDump *ConfigDump `yaml:"dump,omitempty"` + ConfigDeployment *ConfigDeployment `yaml:"deployment,omitempty"` + Validation *ConfigValidation `yaml:"validation,omitempty"` + ImageProxy *ConfigImageProxy `yaml:"image_proxy,omitempty"` // Docker dev environment configuration Docker *ConfigDocker `yaml:"docker,omitempty"` // Named environments for multi-environment management diff --git a/internal/shop/config_schema.json b/internal/shop/config_schema.json index 5ea9e40a..2219e281 100644 --- a/internal/shop/config_schema.json +++ b/internal/shop/config_schema.json @@ -20,6 +20,10 @@ "format": "date", "description": "Controls date-based compatibility behavior, formatted as YYYY-MM-DD." }, + "php_version": { + "type": "string", + "description": "PHP version (e.g. \"8.3\") used for local PHP and Composer commands of this project. Written by \"project create\" for non-Docker projects. The matching PHP is looked up on the machine running the command, so the value stays portable across machines; it takes precedence over the php found in PATH, while the PHP_BINARY environment variable overrides it." + }, "build": { "$ref": "#/$defs/ConfigBuild" }, diff --git a/internal/shop/config_test.go b/internal/shop/config_test.go index e9c695f3..8e3c9b3d 100644 --- a/internal/shop/config_test.go +++ b/internal/shop/config_test.go @@ -44,6 +44,42 @@ include: assert.NotNil(t, config.ConfigDump.Where) } +func TestConfigPHPVersionRoundTrip(t *testing.T) { + tmpDir := t.TempDir() + + cfg := NewConfig() + cfg.PHPVersion = "8.3" + + assert.NoError(t, WriteConfig(cfg, tmpDir)) + + // A portable version, not a machine-specific executable path. + written, err := os.ReadFile(filepath.Join(tmpDir, ".shopware-project.yml")) + assert.NoError(t, err) + assert.Contains(t, string(written), `php_version: "8.3"`) + assert.NotContains(t, string(written), "/bin/php") + + read, err := ReadConfig(t.Context(), filepath.Join(tmpDir, ".shopware-project.yml"), false) + assert.NoError(t, err) + assert.Equal(t, "8.3", read.PHPVersion) +} + +func TestConfigWithoutPHPVersionStaysBackwardCompatible(t *testing.T) { + tmpDir := t.TempDir() + + // A config written before php_version existed must read fine and must not + // gain the field on re-write. + cfg := NewConfig() + assert.NoError(t, WriteConfig(cfg, tmpDir)) + + read, err := ReadConfig(t.Context(), filepath.Join(tmpDir, ".shopware-project.yml"), false) + assert.NoError(t, err) + assert.Empty(t, read.PHPVersion) + + written, err := os.ReadFile(filepath.Join(tmpDir, ".shopware-project.yml")) + assert.NoError(t, err) + assert.NotContains(t, string(written), "php_version") +} + func TestReadConfigCompatibilityDateValidation(t *testing.T) { tmpDir := t.TempDir() configPath := filepath.Join(tmpDir, ".shopware-project.yml") diff --git a/internal/shop/php_constraint.go b/internal/shop/php_constraint.go index 451b6477..d3d340e8 100644 --- a/internal/shop/php_constraint.go +++ b/internal/shop/php_constraint.go @@ -14,6 +14,19 @@ import ( // ordered from lowest to highest. var SupportedPHPVersions = []string{"8.2", "8.3", "8.4", "8.5"} +// ValidatePHPVersion checks that a user-supplied PHP version is a supported +// major.minor series. Requiring the series (rather than a patch level) keeps the +// value usable as both a Docker image tag and a portable config pin. +func ValidatePHPVersion(phpVersion string) error { + for _, supported := range SupportedPHPVersions { + if phpVersion == supported { + return nil + } + } + + return fmt.Errorf("unsupported PHP version %q; supported versions are %s", phpVersion, strings.Join(SupportedPHPVersions, ", ")) +} + // PHPConstraint represents one or more composer-style `php` constraints (e.g. "^8.2" // or "~8.2.0 || ~8.3.0"). A nil receiver is treated as "no constraint" and matches // any supported PHP version. diff --git a/internal/shop/php_constraint_test.go b/internal/shop/php_constraint_test.go index 512afe0b..64d846f2 100644 --- a/internal/shop/php_constraint_test.go +++ b/internal/shop/php_constraint_test.go @@ -1,6 +1,7 @@ package shop import ( + "strings" "testing" "github.com/stretchr/testify/assert" @@ -66,3 +67,25 @@ func TestPHPConstraintCheck(t *testing.T) { assert.False(t, NewPHPConstraint("^8.2").Check("not-a-version")) }) } + +func TestValidatePHPVersion(t *testing.T) { + for _, supported := range SupportedPHPVersions { + assert.NoError(t, ValidatePHPVersion(supported)) + } + + t.Run("an unsupported series is rejected", func(t *testing.T) { + err := ValidatePHPVersion("8.0") + assert.ErrorContains(t, err, "8.0") + assert.ErrorContains(t, err, strings.Join(SupportedPHPVersions, ", ")) + }) + + t.Run("a patch level is rejected", func(t *testing.T) { + // The value doubles as a Docker image tag and a config pin, so it must be + // the major.minor series. + assert.Error(t, ValidatePHPVersion("8.3.19")) + }) + + t.Run("an empty version is rejected", func(t *testing.T) { + assert.Error(t, ValidatePHPVersion("")) + }) +} diff --git a/internal/system/composer.go b/internal/system/composer.go new file mode 100644 index 00000000..bbc90e11 --- /dev/null +++ b/internal/system/composer.go @@ -0,0 +1,137 @@ +package system + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/shopware/shopware-cli/logging" +) + +// composerPharURL points at the latest stable Composer PHAR; a var so tests +// can redirect it to a local server. A matching ".sha256sum" must exist. +var composerPharURL = "https://getcomposer.org/download/latest-stable/composer.phar" + +// ResolveComposer returns a usable Composer executable, preferring composer +// from PATH. When none is installed, it downloads the Composer PHAR into the +// shopware-cli cache directory (once) and returns its path. isPhar reports +// that the returned path must be run through a PHP binary instead of directly. +func ResolveComposer(ctx context.Context) (path string, isPhar bool, err error) { + if composerBinary, lookErr := exec.LookPath("composer"); lookErr == nil { + return composerBinary, false, nil + } + + pharPath := filepath.Join(GetShopwareCliCacheDir(), "composer.phar") + if _, statErr := os.Stat(pharPath); statErr == nil { + return pharPath, true, nil + } + + if err := downloadComposerPhar(ctx, pharPath); err != nil { + return "", false, err + } + + return pharPath, true, nil +} + +func downloadComposerPhar(ctx context.Context, target string) error { + logging.FromContext(ctx).Infof("Composer is not installed, downloading it from %s", composerPharURL) + + expectedSum, err := fetchComposerChecksum(ctx) + if err != nil { + return err + } + + body, err := httpGet(ctx, composerPharURL) + if err != nil { + return err + } + defer func() { + if err := body.Close(); err != nil { + logging.FromContext(ctx).Errorf("Cannot close composer download body: %v", err) + } + }() + + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return err + } + + // Download to a temp file and rename so a concurrent or aborted run never + // sees a half-written PHAR at the final path. + tmpFile, err := os.CreateTemp(filepath.Dir(target), "composer-*.phar.tmp") + if err != nil { + return err + } + defer func() { _ = os.Remove(tmpFile.Name()) }() + + hash := sha256.New() + _, copyErr := io.Copy(io.MultiWriter(tmpFile, hash), body) + closeErr := tmpFile.Close() + if copyErr != nil { + return fmt.Errorf("cannot download composer: %w", copyErr) + } + if closeErr != nil { + return closeErr + } + + if actualSum := hex.EncodeToString(hash.Sum(nil)); actualSum != expectedSum { + return fmt.Errorf("composer download is corrupted: checksum %s does not match expected %s", actualSum, expectedSum) + } + + if err := os.Chmod(tmpFile.Name(), 0o755); err != nil { + return err + } + + return os.Rename(tmpFile.Name(), target) +} + +// fetchComposerChecksum returns the expected SHA-256 of the Composer PHAR from +// the published ".sha256sum" file (format: " composer.phar"). +func fetchComposerChecksum(ctx context.Context) (string, error) { + body, err := httpGet(ctx, composerPharURL+".sha256sum") + if err != nil { + return "", err + } + defer func() { + if err := body.Close(); err != nil { + logging.FromContext(ctx).Errorf("Cannot close composer checksum body: %v", err) + } + }() + + content, err := io.ReadAll(body) + if err != nil { + return "", fmt.Errorf("cannot read composer checksum: %w", err) + } + + sum, _, _ := strings.Cut(strings.TrimSpace(string(content)), " ") + if sum == "" { + return "", fmt.Errorf("composer checksum file %s.sha256sum is empty", composerPharURL) + } + + return sum, nil +} + +func httpGet(ctx context.Context, url string) (io.ReadCloser, error) { + request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + + resp, err := http.DefaultClient.Do(request) + if err != nil { + return nil, fmt.Errorf("cannot download %s: %w", url, err) + } + + if resp.StatusCode != http.StatusOK { + _ = resp.Body.Close() + return nil, fmt.Errorf("cannot download %s: got %s", url, resp.Status) + } + + return resp.Body, nil +} diff --git a/internal/system/composer_test.go b/internal/system/composer_test.go new file mode 100644 index 00000000..7a750eaf --- /dev/null +++ b/internal/system/composer_test.go @@ -0,0 +1,129 @@ +package system + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/stretchr/testify/assert" +) + +// stubComposerPharServer serves a fake Composer PHAR (and its checksum file) +// and points composerPharURL at it. The checksum can be tampered with via +// breakChecksum to simulate a corrupted download. +func stubComposerPharServer(t *testing.T, pharContent string, breakChecksum bool) { + t.Helper() + + sum := sha256.Sum256([]byte(pharContent)) + checksum := hex.EncodeToString(sum[:]) + if breakChecksum { + checksum = "0000000000000000000000000000000000000000000000000000000000000000" + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/composer.phar": + _, _ = w.Write([]byte(pharContent)) + case "/composer.phar.sha256sum": + _, _ = fmt.Fprintf(w, "%s composer.phar\n", checksum) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(server.Close) + + original := composerPharURL + composerPharURL = server.URL + "/composer.phar" + t.Cleanup(func() { composerPharURL = original }) +} + +func TestResolveComposerPrefersPath(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("fake composer executable requires a shell script") + } + + binDir := t.TempDir() + composerPath := filepath.Join(binDir, "composer") + assert.NoError(t, os.WriteFile(composerPath, []byte("#!/bin/sh\n"), 0o755)) + t.Setenv("PATH", binDir) + t.Setenv("SHOPWARE_CLI_CACHE_DIR", t.TempDir()) + + path, isPhar, err := ResolveComposer(t.Context()) + assert.NoError(t, err) + assert.False(t, isPhar) + assert.Equal(t, composerPath, path) +} + +func TestResolveComposerDownloadsPhar(t *testing.T) { + t.Setenv("PATH", t.TempDir()) + cacheDir := t.TempDir() + t.Setenv("SHOPWARE_CLI_CACHE_DIR", cacheDir) + stubComposerPharServer(t, "fake composer phar", false) + + path, isPhar, err := ResolveComposer(t.Context()) + assert.NoError(t, err) + assert.True(t, isPhar) + assert.Equal(t, filepath.Join(cacheDir, "composer.phar"), path) + + content, err := os.ReadFile(path) + assert.NoError(t, err) + assert.Equal(t, "fake composer phar", string(content)) + + if runtime.GOOS != "windows" { + info, err := os.Stat(path) + assert.NoError(t, err) + assert.Equal(t, os.FileMode(0o755), info.Mode().Perm()) + } +} + +func TestResolveComposerReusesCachedPhar(t *testing.T) { + t.Setenv("PATH", t.TempDir()) + cacheDir := t.TempDir() + t.Setenv("SHOPWARE_CLI_CACHE_DIR", cacheDir) + assert.NoError(t, os.WriteFile(filepath.Join(cacheDir, "composer.phar"), []byte("cached phar"), 0o755)) + + // No stub server: any download attempt would hit getcomposer.org and fail + // the test on the unexpected network call being slow or blocked; the cached + // PHAR must short-circuit before that. + path, isPhar, err := ResolveComposer(t.Context()) + assert.NoError(t, err) + assert.True(t, isPhar) + assert.Equal(t, filepath.Join(cacheDir, "composer.phar"), path) + + content, err := os.ReadFile(path) + assert.NoError(t, err) + assert.Equal(t, "cached phar", string(content)) +} + +func TestResolveComposerRejectsCorruptedDownload(t *testing.T) { + t.Setenv("PATH", t.TempDir()) + cacheDir := t.TempDir() + t.Setenv("SHOPWARE_CLI_CACHE_DIR", cacheDir) + stubComposerPharServer(t, "fake composer phar", true) + + _, _, err := ResolveComposer(t.Context()) + assert.ErrorContains(t, err, "composer download is corrupted") + + _, statErr := os.Stat(filepath.Join(cacheDir, "composer.phar")) + assert.True(t, os.IsNotExist(statErr), "a corrupted PHAR must not be cached") +} + +func TestResolveComposerReportsDownloadFailure(t *testing.T) { + t.Setenv("PATH", t.TempDir()) + t.Setenv("SHOPWARE_CLI_CACHE_DIR", t.TempDir()) + + server := httptest.NewServer(http.NotFoundHandler()) + t.Cleanup(server.Close) + original := composerPharURL + composerPharURL = server.URL + "/composer.phar" + t.Cleanup(func() { composerPharURL = original }) + + _, _, err := ResolveComposer(t.Context()) + assert.ErrorContains(t, err, "cannot download") +} diff --git a/internal/system/php.go b/internal/system/php.go index 34f38c1d..d95cf7c4 100644 --- a/internal/system/php.go +++ b/internal/system/php.go @@ -5,11 +5,17 @@ import ( "fmt" "os" "os/exec" + "regexp" "strings" + "time" "github.com/shyim/go-version" ) +// phpVersionProbeTimeout bounds how long a single candidate executable may +// take to report its version before it is considered broken. +const phpVersionProbeTimeout = 10 * time.Second + // resolvePHPBinary returns the PHP binary to use. It prefers the PHP_BINARY // environment variable, matching the convention runComposerInstall already // follows, and falls back to the "php" binary found in PATH. @@ -21,30 +27,42 @@ func resolvePHPBinary() (string, error) { return exec.LookPath("php") } -// GetInstalledPHPVersion checks the installed PHP version on the system. -func GetInstalledPHPVersion(ctx context.Context) (string, error) { - // Check if PHP is installed - phpPath, err := resolvePHPBinary() - if err != nil { - return "", fmt.Errorf("PHP is not installed: %w", err) - } +// phpVersionOutput extracts the normalized version from the version banner in +// `php -v` output, e.g. "PHP 8.3.6-1ubuntu1 (cli) ..." -> "8.3.6". (?m) is +// required: PHP prints startup warnings before the banner, so anchoring to the +// start of the whole output would reject a usable PHP. +var phpVersionOutput = regexp.MustCompile(`(?m)^PHP\s+(\d+\.\d+(?:\.\d+)?)`) + +// GetPHPVersionOfBinary executes the given PHP binary and returns the +// normalized version it reports. It fails when the binary cannot be executed +// or does not produce PHP's version banner. +func GetPHPVersionOfBinary(ctx context.Context, phpPath string) (string, error) { + probeCtx, cancel := context.WithTimeout(ctx, phpVersionProbeTimeout) + defer cancel() - // Get the PHP version - cmd := exec.CommandContext(ctx, phpPath, "-v") + cmd := exec.CommandContext(probeCtx, phpPath, "-v") output, err := cmd.Output() if err != nil { return "", fmt.Errorf("failed to get PHP version: %w, output: %s", err, string(output)) } - splitt := strings.Split(string(output), " ") - - if len(splitt) < 2 { + matches := phpVersionOutput.FindStringSubmatch(strings.TrimSpace(string(output))) + if matches == nil { return "", fmt.Errorf("unexpected output format: %s", string(output)) } - // Parse the version from the output - version := splitt[1] - return strings.TrimSpace(version), nil + return matches[1], nil +} + +// GetInstalledPHPVersion checks the installed PHP version on the system. +func GetInstalledPHPVersion(ctx context.Context) (string, error) { + // Check if PHP is installed + phpPath, err := resolvePHPBinary() + if err != nil { + return "", fmt.Errorf("PHP is not installed: %w", err) + } + + return GetPHPVersionOfBinary(ctx, phpPath) } // GetAvailablePHPExtensions returns the list of loaded PHP extensions by parsing `php -m` output. @@ -78,15 +96,21 @@ func IsPHPVersionAtLeast(ctx context.Context, requiredVersion string) (bool, err return false, err } + return phpVersionAtLeast(installedVersion, requiredVersion), nil +} + +// phpVersionAtLeast reports whether installedVersion is at least +// requiredVersion. Unparseable versions report false. +func phpVersionAtLeast(installedVersion, requiredVersion string) bool { phpVersion, err := version.NewVersion(installedVersion) if err != nil { - return false, fmt.Errorf("failed to parse installed PHP version: %w", err) + return false } constraint, err := version.NewConstraint(fmt.Sprintf(">= %s", requiredVersion)) if err != nil { - return false, fmt.Errorf("failed to parse required PHP version constraint: %w", err) + return false } - return constraint.Check(phpVersion), nil + return constraint.Check(phpVersion) } diff --git a/internal/system/php_discovery.go b/internal/system/php_discovery.go new file mode 100644 index 00000000..20fb14cf --- /dev/null +++ b/internal/system/php_discovery.go @@ -0,0 +1,311 @@ +package system + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "slices" + "strings" + + phpdiscover "github.com/shyim/go-php-discover" + "github.com/shyim/go-version" +) + +// Sources a PHPInstallation can be discovered from. +const ( + PHPSourceEnv = "PHP_BINARY" + PHPSourcePath = phpdiscover.SourcePath + PHPSourceFlag = "flag" +) + +// PHPInstallation describes a usable PHP executable found on the machine. +type PHPInstallation struct { + Binary string // absolute/canonical executable path + Version string // normalized version reported by the executable + Source string // e.g. PHP_BINARY, PATH, homebrew, system package + Default bool // true for the executable a plain `php` resolves to on PATH +} + +// String renders the installation for user-facing output, +// e.g. "PHP 8.3.19 — /opt/homebrew/opt/php@8.3/bin/php". +func (i PHPInstallation) String() string { + return "PHP " + i.Version + " — " + i.Binary +} + +// DiscoverPHPInstallations returns the usable PHP executables installed on the +// machine via github.com/shyim/go-php-discover, plus an explicitly configured +// PHP_BINARY when set, newest version first with PHP_BINARY hoisted to the front. +func DiscoverPHPInstallations(ctx context.Context) []PHPInstallation { + found := phpdiscover.Discover(ctx) + + installations := make([]PHPInstallation, 0, len(found)+1) + + // Discover sorts ascending by version; iterate in reverse for newest first. + for i := len(found) - 1; i >= 0; i-- { + installations = append(installations, newPHPInstallation(found[i])) + } + + // The library does not know about PHP_BINARY, so it is probed separately and + // gets its own source; an already discovered binary is relabelled in place. + if phpBinary := os.Getenv("PHP_BINARY"); phpBinary != "" { + if probed, err := ProbePHPBinary(ctx, phpBinary, PHPSourceEnv); err == nil { + installation := *probed + if existing := FindPHPByBinary(installations, installation.Binary); existing != nil { + installation.Default = existing.Default + installations = slices.DeleteFunc(installations, func(i PHPInstallation) bool { + return i.Binary == installation.Binary + }) + } + installations = append([]PHPInstallation{installation}, installations...) + } + } + + return installations +} + +// UnusablePHPBinaryEnv reports why a configured PHP_BINARY cannot be used, or nil +// when it is unset or usable. DiscoverPHPInstallations omits an unusable +// PHP_BINARY, so callers reading discovery directly must check this to avoid +// silently falling back to another PHP. +func UnusablePHPBinaryEnv(ctx context.Context) error { + phpBinary := os.Getenv("PHP_BINARY") + if phpBinary == "" { + return nil + } + + if _, err := ProbePHPBinary(ctx, phpBinary, PHPSourceEnv); err != nil { + return unusablePHPBinaryError(err) + } + + return nil +} + +func unusablePHPBinaryError(err error) error { + return fmt.Errorf("PHP_BINARY is set but unusable: %w", err) +} + +// newPHPInstallation converts a discovered PHP into a PHPInstallation, dropping +// the distro/RC suffix from its version. The suffix must not survive: go-version +// cannot parse "8.1.2-1ubuntu2.14" at all, so keeping it would exclude Debian +// PHP from every constraint check. +func newPHPInstallation(p *phpdiscover.PHP) PHPInstallation { + return PHPInstallation{ + Binary: p.Path, + Version: fmt.Sprintf("%d.%d.%d", p.Version.Major, p.Version.Minor, p.Version.Patch), + Source: p.Source, + Default: p.IsSystem, + } +} + +// FilterCompatiblePHP returns the installations whose version satisfies the +// given constraint. A nil checker matches everything. +func FilterCompatiblePHP(installations []PHPInstallation, checker PHPVersionChecker) []PHPInstallation { + out := make([]PHPInstallation, 0, len(installations)) + for _, installation := range installations { + if checker == nil || checker.Check(installation.Version) { + out = append(out, installation) + } + } + return out +} + +// FindPHPBySource returns the first installation discovered from the given +// source, or nil when there is none. +func FindPHPBySource(installations []PHPInstallation, source string) *PHPInstallation { + for i := range installations { + if installations[i].Source == source { + return &installations[i] + } + } + return nil +} + +// PHPVersionPin renders the major.minor pin persisted in the project config +// (e.g. "8.3.19" becomes "8.3"). The patch level is dropped so the pin survives +// a PHP package update. +func PHPVersionPin(phpVersion string) string { + v, err := version.NewVersion(phpVersion) + if err != nil { + return phpVersion + } + + segments := v.Segments() + + return fmt.Sprintf("%d.%d", segments[0], segments[1]) +} + +// FindPHPByVersionPin returns the newest installation matching the pin, or nil +// when none does. The pin is a version prefix at any depth: "8" matches every +// 8.x, "8.3" every 8.3.x, "8.3.19" only that patch release. Unlike +// phpdiscover.FindVersion it works on an already discovered list, which also +// carries the PHP_BINARY entry. +func FindPHPByVersionPin(installations []PHPInstallation, pin string) *PHPInstallation { + if pin == "" { + return nil + } + + var best *PHPInstallation + + for i := range installations { + if !phpVersionHasPrefix(installations[i].Version, pin) { + continue + } + + if best == nil || comparePHPVersions(installations[i].Version, best.Version) > 0 { + best = &installations[i] + } + } + + return best +} + +// phpVersionHasPrefix reports whether phpVersion falls under the given version +// prefix, comparing whole numeric components so "8.3" does not match "8.30.1". +func phpVersionHasPrefix(phpVersion, prefix string) bool { + want := strings.Split(prefix, ".") + got := strings.Split(phpVersion, ".") + + if len(want) > len(got) { + return false + } + + for i := range want { + if want[i] != got[i] { + return false + } + } + + return true +} + +// comparePHPVersions orders two PHP version strings, treating unparseable +// versions as lowest. +func comparePHPVersions(a, b string) int { + va, errA := version.NewVersion(a) + vb, errB := version.NewVersion(b) + + switch { + case errA != nil && errB != nil: + return 0 + case errA != nil: + return -1 + case errB != nil: + return 1 + } + + return va.Compare(vb) +} + +// FindPHPByBinary returns the installation with the given binary path, or nil +// when the list contains no such entry. +func FindPHPByBinary(installations []PHPInstallation, binary string) *PHPInstallation { + if binary == "" { + return nil + } + for i := range installations { + if installations[i].Binary == binary { + return &installations[i] + } + } + return nil +} + +// DefaultPHPInstallation returns the installation a plain `php` resolves to on +// PATH, or nil when there is none. +func DefaultPHPInstallation(installations []PHPInstallation) *PHPInstallation { + for i := range installations { + if installations[i].Default { + return &installations[i] + } + } + return nil +} + +// PreferredPHPInstallation returns the installation to preselect for the user: +// the PHP_BINARY candidate when present, otherwise the PATH default, otherwise +// the first entry (the newest version). Returns nil for an empty list. +func PreferredPHPInstallation(installations []PHPInstallation) *PHPInstallation { + if installation := FindPHPBySource(installations, PHPSourceEnv); installation != nil { + return installation + } + if installation := DefaultPHPInstallation(installations); installation != nil { + return installation + } + if len(installations) > 0 { + return &installations[0] + } + return nil +} + +// ProbePHPBinary canonicalizes the given path, verifies that it is an +// executable file, and executes it to obtain its PHP version. It is used to +// validate an explicitly configured PHP binary (e.g. PHP_BINARY). +func ProbePHPBinary(ctx context.Context, path, source string) (*PHPInstallation, error) { + canonical, ok := canonicalExecutable(path) + if !ok { + return nil, &PHPBinaryError{Path: path, Reason: "is not an executable file"} + } + + phpVersion, err := GetPHPVersionOfBinary(ctx, canonical) + if err != nil { + return nil, &PHPBinaryError{Path: path, Reason: "did not report a PHP version", Err: err} + } + + return &PHPInstallation{Binary: canonical, Version: phpVersion, Source: source}, nil +} + +// canonicalExecutable resolves symlinks and relative segments in path and +// reports whether it points to an executable regular file. A bare command name +// (e.g. PHP_BINARY=php8.2) is looked up on PATH first. +func canonicalExecutable(path string) (string, bool) { + if !strings.ContainsRune(path, filepath.Separator) { + looked, err := exec.LookPath(path) + if err != nil { + return "", false + } + path = looked + } + + resolved, err := filepath.EvalSymlinks(path) + if err != nil { + return "", false + } + + resolved, err = filepath.Abs(resolved) + if err != nil { + return "", false + } + + info, err := os.Stat(resolved) + if err != nil || !info.Mode().IsRegular() { + return "", false + } + + if runtime.GOOS != "windows" && info.Mode().Perm()&0o111 == 0 { + return "", false + } + + return resolved, true +} + +// PHPBinaryError describes why a specific PHP binary path is not usable. +type PHPBinaryError struct { + Path string + Reason string + Err error +} + +func (e *PHPBinaryError) Error() string { + msg := "PHP binary " + e.Path + " " + e.Reason + if e.Err != nil { + msg += ": " + e.Err.Error() + } + return msg +} + +func (e *PHPBinaryError) Unwrap() error { + return e.Err +} diff --git a/internal/system/php_discovery_test.go b/internal/system/php_discovery_test.go new file mode 100644 index 00000000..d1cf9287 --- /dev/null +++ b/internal/system/php_discovery_test.go @@ -0,0 +1,292 @@ +package system + +import ( + "fmt" + "os" + "path/filepath" + "runtime" + "testing" + + phpdiscover "github.com/shyim/go-php-discover" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// canonicalPath resolves symlinks in path the same way discovery does, so +// assertions hold on platforms where t.TempDir() contains symlinks (macOS). +func canonicalPath(t *testing.T, path string) string { + t.Helper() + resolved, err := filepath.EvalSymlinks(path) + assert.NoError(t, err) + return resolved +} + +// versionSetChecker is a PHPVersionChecker accepting an explicit set of versions. +type versionSetChecker map[string]bool + +func (c versionSetChecker) Check(phpVersion string) bool { return c[phpVersion] } +func (c versionSetChecker) String() string { return "test constraint" } + +func findInstallation(installations []PHPInstallation, binary string) *PHPInstallation { + for i := range installations { + if installations[i].Binary == binary { + return &installations[i] + } + } + return nil +} + +func TestDiscoverPHPInstallationsUsesPATH(t *testing.T) { + pathDir := t.TempDir() + writeFakePHP(t, pathDir+"/php", "8.2.0") + writeFakePHP(t, pathDir+"/php8.3", "8.3.0") + writeFakePHP(t, pathDir+"/php-config", "8.3.0") // must be ignored + writeFakePHP(t, pathDir+"/phpize", "8.3.0") // must be ignored + + t.Setenv("PATH", pathDir) + t.Setenv("PHP_BINARY", "") + + installations := DiscoverPHPInstallations(t.Context()) + + require.NotNil(t, findInstallation(installations, canonicalPath(t, pathDir+"/php"))) + require.NotNil(t, findInstallation(installations, canonicalPath(t, pathDir+"/php8.3"))) + assert.Nil(t, findInstallation(installations, canonicalPath(t, pathDir+"/php-config"))) + assert.Nil(t, findInstallation(installations, canonicalPath(t, pathDir+"/phpize"))) +} + +func TestDiscoverPHPInstallationsPrefersPHPBinaryEnvFirst(t *testing.T) { + pathDir := t.TempDir() + writeFakePHP(t, pathDir+"/php", "8.2.0") + + binDir := t.TempDir() + writeFakePHP(t, binDir+"/my-php", "8.3.0") + + t.Setenv("PATH", pathDir) + t.Setenv("PHP_BINARY", binDir+"/my-php") + + installations := DiscoverPHPInstallations(t.Context()) + + require.NotEmpty(t, installations) + assert.Equal(t, PHPSourceEnv, installations[0].Source) + assert.Equal(t, canonicalPath(t, binDir+"/my-php"), installations[0].Binary) + assert.Equal(t, "8.3.0", installations[0].Version) +} + +func TestDiscoverPHPInstallationsDedupesPHPBinaryWithPATH(t *testing.T) { + pathDir := t.TempDir() + writeFakePHP(t, pathDir+"/php", "8.3.7") + + t.Setenv("PATH", pathDir) + t.Setenv("PHP_BINARY", pathDir+"/php") + + installations := DiscoverPHPInstallations(t.Context()) + + matching := 0 + for _, installation := range installations { + if installation.Binary == canonicalPath(t, pathDir+"/php") { + matching++ + assert.Equal(t, PHPSourceEnv, installation.Source) + assert.True(t, installation.Default) + } + } + assert.Equal(t, 1, matching) +} + +func TestDiscoverPHPInstallationsSortsNewestFirstKeepingPHPBinaryFirst(t *testing.T) { + pathDir := t.TempDir() + writeFakePHP(t, pathDir+"/php8.1", "8.1.2") + writeFakePHP(t, pathDir+"/php8.4", "8.4.1") + writeFakePHP(t, pathDir+"/php8.3", "8.3.7") + + t.Setenv("PATH", pathDir) + t.Setenv("PHP_BINARY", pathDir+"/php8.1") + + installations := DiscoverPHPInstallations(t.Context()) + + // PHP_BINARY stays first even though it is older + require.NotEmpty(t, installations) + assert.Equal(t, PHPSourceEnv, installations[0].Source) + assert.Equal(t, canonicalPath(t, pathDir+"/php8.1"), installations[0].Binary) + + // Among our PATH fakes, newer versions come first after PHP_BINARY. + php84 := findInstallation(installations, canonicalPath(t, pathDir+"/php8.4")) + php83 := findInstallation(installations, canonicalPath(t, pathDir+"/php8.3")) + require.NotNil(t, php84) + require.NotNil(t, php83) + + idx84, idx83 := -1, -1 + for i, installation := range installations { + switch installation.Binary { + case php84.Binary: + idx84 = i + case php83.Binary: + idx83 = i + } + } + assert.Less(t, idx84, idx83) +} + +// Debian-packaged PHP reports versions such as "8.3.6-0ubuntu0.24.04.1". +// phpdiscover keeps that suffix on purpose (it is what PHP prints, and its own +// comparisons ignore it), but github.com/shyim/go-version cannot parse it at all, +// so newPHPInstallation must strip it or those installations would be silently +// excluded from every constraint check. Do not "simplify" that into +// phpdiscover's Version.String(). +func TestDiscoverPHPInstallationsNormalizesDistroVersionSuffix(t *testing.T) { + pathDir := t.TempDir() + writeFakePHP(t, pathDir+"/php", "8.3.6-0ubuntu0.24.04.1") + + t.Setenv("PATH", pathDir) + t.Setenv("PHP_BINARY", "") + + installations := DiscoverPHPInstallations(t.Context()) + + installation := findInstallation(installations, canonicalPath(t, pathDir+"/php")) + require.NotNil(t, installation) + assert.Equal(t, "8.3.6", installation.Version) + + // The normalized version must still satisfy a constraint on that series, + // which is what the suffix would break. + assert.NotEmpty(t, FilterCompatiblePHP(installations, versionSetChecker{"8.3.6": true})) +} + +func TestFilterCompatiblePHP(t *testing.T) { + installations := []PHPInstallation{ + {Binary: "/usr/bin/php8.4", Version: "8.4.1"}, + {Binary: "/usr/bin/php8.3", Version: "8.3.7"}, + {Binary: "/usr/bin/php8.1", Version: "8.1.2"}, + } + + compatible := FilterCompatiblePHP(installations, versionSetChecker{"8.3.7": true, "8.4.1": true}) + assert.Len(t, compatible, 2) + assert.Equal(t, "8.4.1", compatible[0].Version) + assert.Equal(t, "8.3.7", compatible[1].Version) + + all := FilterCompatiblePHP(installations, nil) + assert.Len(t, all, 3) +} + +func TestPreferredPHPInstallation(t *testing.T) { + t.Run("empty list", func(t *testing.T) { + assert.Nil(t, PreferredPHPInstallation(nil)) + }) + + t.Run("prefers PHP_BINARY", func(t *testing.T) { + installations := []PHPInstallation{ + {Binary: "/env/php", Version: "8.2.0", Source: PHPSourceEnv}, + {Binary: "/usr/bin/php", Version: "8.4.0", Source: PHPSourcePath}, + } + assert.Equal(t, "/env/php", PreferredPHPInstallation(installations).Binary) + }) + + t.Run("falls back to PATH default", func(t *testing.T) { + installations := []PHPInstallation{ + {Binary: "/opt/homebrew/php", Version: "8.4.0", Source: phpdiscover.SourceHomebrew}, + {Binary: "/usr/bin/php8.4", Version: "8.4.0", Source: PHPSourcePath}, + {Binary: "/usr/bin/php", Version: "8.3.0", Source: PHPSourcePath, Default: true}, + } + assert.Equal(t, "/usr/bin/php", PreferredPHPInstallation(installations).Binary) + }) + + t.Run("falls back to newest", func(t *testing.T) { + installations := []PHPInstallation{ + {Binary: "/opt/homebrew/php8.4", Version: "8.4.0", Source: phpdiscover.SourceHomebrew}, + {Binary: "/opt/homebrew/php8.3", Version: "8.3.0", Source: phpdiscover.SourceHomebrew}, + } + assert.Equal(t, "/opt/homebrew/php8.4", PreferredPHPInstallation(installations).Binary) + }) +} + +func TestProbePHPBinary(t *testing.T) { + dir := t.TempDir() + writeFakePHP(t, dir+"/php", "8.3.7") + + t.Run("valid binary", func(t *testing.T) { + installation, err := ProbePHPBinary(t.Context(), dir+"/php", PHPSourceFlag) + assert.NoError(t, err) + assert.Equal(t, canonicalPath(t, dir+"/php"), installation.Binary) + assert.Equal(t, "8.3.7", installation.Version) + assert.Equal(t, PHPSourceFlag, installation.Source) + }) + + t.Run("missing binary", func(t *testing.T) { + _, err := ProbePHPBinary(t.Context(), dir+"/does-not-exist", PHPSourceFlag) + assert.ErrorContains(t, err, "is not an executable file") + }) + + t.Run("not executable", func(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("executable bits are not meaningful on windows") + } + assert.NoError(t, os.WriteFile(dir+"/data.txt", []byte("hello"), 0o644)) + _, err := ProbePHPBinary(t.Context(), dir+"/data.txt", PHPSourceFlag) + assert.ErrorContains(t, err, "is not an executable file") + }) + + t.Run("broken binary", func(t *testing.T) { + assert.NoError(t, os.WriteFile(dir+"/broken", []byte("#!/bin/sh\nexit 3\n"), 0o755)) + _, err := ProbePHPBinary(t.Context(), dir+"/broken", PHPSourceFlag) + assert.ErrorContains(t, err, "did not report a PHP version") + }) +} + +func TestPHPInstallationString(t *testing.T) { + installation := PHPInstallation{Binary: "/opt/homebrew/opt/php@8.3/bin/php", Version: "8.3.19", Source: phpdiscover.SourceHomebrew} + assert.Equal(t, fmt.Sprintf("PHP %s — %s", "8.3.19", "/opt/homebrew/opt/php@8.3/bin/php"), installation.String()) +} + +func TestFindPHPByBinary(t *testing.T) { + installations := []PHPInstallation{ + {Binary: "/php83", Version: "8.3.2"}, + {Binary: "/php82", Version: "8.2.9"}, + } + + t.Run("finds a known binary", func(t *testing.T) { + found := FindPHPByBinary(installations, "/php82") + assert.NotNil(t, found) + assert.Equal(t, "8.2.9", found.Version) + }) + + t.Run("returns nil for an unknown binary", func(t *testing.T) { + assert.Nil(t, FindPHPByBinary(installations, "/php81")) + }) + + t.Run("returns nil for an empty binary", func(t *testing.T) { + assert.Nil(t, FindPHPByBinary(installations, "")) + }) +} + +func TestFindPHPByVersionPin(t *testing.T) { + installations := []PHPInstallation{ + {Binary: "/php8-30", Version: "8.30.1"}, + {Binary: "/php85", Version: "8.5.9"}, + {Binary: "/php83-new", Version: "8.3.33"}, + {Binary: "/php83-old", Version: "8.3.19"}, + } + + t.Run("a major.minor pin takes the newest patch release", func(t *testing.T) { + found := FindPHPByVersionPin(installations, "8.3") + require.NotNil(t, found) + assert.Equal(t, "/php83-new", found.Binary) + }) + + t.Run("a full version pins that exact patch release", func(t *testing.T) { + found := FindPHPByVersionPin(installations, "8.3.19") + require.NotNil(t, found) + assert.Equal(t, "/php83-old", found.Binary) + }) + + t.Run("a major-only pin takes the newest of that major", func(t *testing.T) { + found := FindPHPByVersionPin(installations, "8") + require.NotNil(t, found) + assert.Equal(t, "/php8-30", found.Binary) + }) + + t.Run("components are compared whole, so 8.3 does not match 8.30", func(t *testing.T) { + assert.Nil(t, FindPHPByVersionPin([]PHPInstallation{{Binary: "/php8-30", Version: "8.30.1"}}, "8.3")) + }) + + t.Run("an empty pin matches nothing", func(t *testing.T) { + assert.Nil(t, FindPHPByVersionPin(installations, "")) + }) +} diff --git a/internal/system/php_pin.go b/internal/system/php_pin.go new file mode 100644 index 00000000..e0f2e4dd --- /dev/null +++ b/internal/system/php_pin.go @@ -0,0 +1,86 @@ +package system + +import ( + "context" + "fmt" + "os" + "runtime" + "strings" +) + +// PHPVersionNotFoundError reports that the PHP version pinned by the project +// config is not installed on this machine. +type PHPVersionNotFoundError struct { + Pin string + Installations []PHPInstallation +} + +func (e *PHPVersionNotFoundError) Error() string { + var b strings.Builder + + fmt.Fprintf(&b, "this project requires PHP %s (php_version in .shopware-project.yml), but no PHP %s was found on this machine", e.Pin, e.Pin) + + if len(e.Installations) > 0 { + found := make([]string, 0, len(e.Installations)) + for _, installation := range e.Installations { + found = append(found, fmt.Sprintf("PHP %s (%s)", installation.Version, installation.Source)) + } + fmt.Fprintf(&b, "; discovered %s", strings.Join(found, ", ")) + } + + if hint := installPHPHint(e.Pin); hint != "" { + fmt.Fprintf(&b, "; install it with %s", hint) + } + + return b.String() +} + +// installPHPHint returns a platform-appropriate command for installing the +// pinned PHP version, or an empty string when there is no obvious one. +func installPHPHint(pin string) string { + switch runtime.GOOS { + case "darwin": + return fmt.Sprintf("`brew install php@%s`", pin) + case "linux": + return fmt.Sprintf("`apt install php%s` (or the equivalent for your distribution)", pin) + default: + return "" + } +} + +// ResolveProjectPHPBinary returns the PHP executable a project's local commands +// run, following the precedence: PHP_BINARY > the pinned php_version > the php +// found in PATH (an empty return value). An unusable PHP_BINARY is an error +// rather than a reason to fall back to another PHP. +func ResolveProjectPHPBinary(ctx context.Context, pin string) (string, error) { + if phpBinary := os.Getenv("PHP_BINARY"); phpBinary != "" { + probed, err := ProbePHPBinary(ctx, phpBinary, PHPSourceEnv) + if err != nil { + return "", unusablePHPBinaryError(err) + } + + return probed.Binary, nil + } + + return ResolvePinnedPHPBinary(ctx, pin) +} + +// ResolvePinnedPHPBinary returns the PHP executable matching the version pinned +// by the project config, ignoring PHP_BINARY. An empty pin returns an empty +// string. A pin that matches no installed PHP fails with +// *PHPVersionNotFoundError instead of falling back to another version. +func ResolvePinnedPHPBinary(ctx context.Context, pin string) (string, error) { + if pin == "" { + return "", nil + } + + // Not phpdiscover.Discover: a pin can come from a PHP_BINARY-only install that + // the library does not scan, and only this list carries that entry. + installations := DiscoverPHPInstallations(ctx) + + if installation := FindPHPByVersionPin(installations, pin); installation != nil { + return installation.Binary, nil + } + + return "", &PHPVersionNotFoundError{Pin: PHPVersionPin(pin), Installations: installations} +} diff --git a/internal/system/php_pin_test.go b/internal/system/php_pin_test.go new file mode 100644 index 00000000..37784210 --- /dev/null +++ b/internal/system/php_pin_test.go @@ -0,0 +1,188 @@ +package system + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPHPVersionPin(t *testing.T) { + // The patch level is dropped: pinning it would break on every PHP update. + assert.Equal(t, "8.3", PHPVersionPin("8.3.19")) + assert.Equal(t, "8.3", PHPVersionPin("8.3")) + assert.Equal(t, "8.4", PHPVersionPin("8.4.24")) + assert.Equal(t, "", PHPVersionPin("")) +} + +func TestResolvePinnedPHPBinaryMatching(t *testing.T) { + // Names must match what phpdiscover accepts: php, php8, php8.3, php8.3.19. + pathDir := t.TempDir() + writeFakePHP(t, pathDir+"/php", "8.5.9") + writeFakePHP(t, pathDir+"/php8.3.19", "8.3.19") + writeFakePHP(t, pathDir+"/php8.3", "8.3.33") + + t.Setenv("PATH", pathDir) + t.Setenv("PHP_BINARY", "") + + t.Run("any patch release of the pinned series matches, newest wins", func(t *testing.T) { + binary, err := ResolvePinnedPHPBinary(t.Context(), "8.3") + assert.NoError(t, err) + assert.Equal(t, canonicalPath(t, pathDir+"/php8.3"), binary) + }) + + t.Run("a full version pins that exact patch release", func(t *testing.T) { + binary, err := ResolvePinnedPHPBinary(t.Context(), "8.3.19") + assert.NoError(t, err) + assert.Equal(t, canonicalPath(t, pathDir+"/php8.3.19"), binary) + }) + + t.Run("a major-only pin matches the newest of that major", func(t *testing.T) { + binary, err := ResolvePinnedPHPBinary(t.Context(), "8") + assert.NoError(t, err) + assert.Equal(t, canonicalPath(t, pathDir+"/php"), binary) + }) + + t.Run("a missing series reports what was discovered", func(t *testing.T) { + _, err := ResolvePinnedPHPBinary(t.Context(), "8.2") + + var notFound *PHPVersionNotFoundError + require.ErrorAs(t, err, ¬Found) + assert.Equal(t, "8.2", notFound.Pin) + assert.NotEmpty(t, notFound.Installations) + // Newest first, so the error lists the most relevant candidate up front. + assert.Equal(t, "8.5.9", notFound.Installations[0].Version) + }) +} + +func TestResolvePinnedPHPBinary(t *testing.T) { + t.Run("an empty pin defers to the caller's fallback", func(t *testing.T) { + binary, err := ResolvePinnedPHPBinary(t.Context(), "") + assert.NoError(t, err) + assert.Empty(t, binary) + }) + + t.Run("ignores PHP_BINARY", func(t *testing.T) { + t.Setenv("PHP_BINARY", "/env/php") + + binary, err := ResolvePinnedPHPBinary(t.Context(), "") + assert.NoError(t, err) + assert.Empty(t, binary) + }) +} + +func TestResolveProjectPHPBinary(t *testing.T) { + t.Run("PHP_BINARY wins over the pin", func(t *testing.T) { + dir := t.TempDir() + writeFakePHP(t, dir+"/php", "8.2.9") + t.Setenv("PHP_BINARY", dir+"/php") + + // An unsatisfiable pin proves the env var short-circuits the pin lookup + // rather than merely being preferred among matches. + binary, err := ResolveProjectPHPBinary(t.Context(), "5.6") + assert.NoError(t, err) + assert.Equal(t, canonicalPath(t, dir+"/php"), binary) + }) + + t.Run("an unusable PHP_BINARY fails instead of falling back", func(t *testing.T) { + dir := t.TempDir() + writeFakePHP(t, dir+"/php8.3", "8.3.19") + t.Setenv("PATH", dir) + t.Setenv("PHP_BINARY", "/does/not/exist/php") + + _, err := ResolveProjectPHPBinary(t.Context(), "8.3") + assert.ErrorContains(t, err, "PHP_BINARY is set but unusable") + + var binErr *PHPBinaryError + assert.ErrorAs(t, err, &binErr) + }) + + t.Run("a usable PHP_BINARY is returned canonicalized", func(t *testing.T) { + dir := t.TempDir() + writeFakePHP(t, dir+"/php", "8.3.19") + t.Setenv("PHP_BINARY", dir+"/php") + + binary, err := ResolveProjectPHPBinary(t.Context(), "8.4") + assert.NoError(t, err) + assert.Equal(t, canonicalPath(t, dir+"/php"), binary) + }) + + t.Run("without PHP_BINARY an empty pin defers to the caller's fallback", func(t *testing.T) { + t.Setenv("PHP_BINARY", "") + + binary, err := ResolveProjectPHPBinary(t.Context(), "") + assert.NoError(t, err) + assert.Empty(t, binary) + }) + + t.Run("without PHP_BINARY an unsatisfiable pin fails", func(t *testing.T) { + t.Setenv("PHP_BINARY", "") + + _, err := ResolveProjectPHPBinary(t.Context(), "5.6") + + var notFound *PHPVersionNotFoundError + assert.ErrorAs(t, err, ¬Found) + assert.Equal(t, "5.6", notFound.Pin) + }) +} + +func TestPHPVersionNotFoundError(t *testing.T) { + err := &PHPVersionNotFoundError{ + Pin: "8.3", + Installations: []PHPInstallation{ + {Binary: "/php85", Version: "8.5.9", Source: PHPSourcePath}, + }, + } + + message := err.Error() + assert.Contains(t, message, "PHP 8.3") + assert.Contains(t, message, "php_version") + // The discovered versions are listed so the user can see what exists. + assert.Contains(t, message, "8.5.9") +} + +// A pin can come from a PHP_BINARY-only install the library does not scan; +// resolving must consult the same set that wrote it. +func TestResolvePinnedPHPBinaryFindsPHPBinaryOnlyInstall(t *testing.T) { + custom := t.TempDir() + writeFakePHP(t, custom+"/php", "8.3.19") + + // Nothing on PATH, so the library alone would find nothing. + t.Setenv("PATH", t.TempDir()) + t.Setenv("PHP_BINARY", custom+"/php") + + binary, err := ResolvePinnedPHPBinary(t.Context(), "8.3") + assert.NoError(t, err) + assert.Equal(t, canonicalPath(t, custom+"/php"), binary) +} + +func TestProbePHPBinaryAcceptsBareCommandName(t *testing.T) { + dir := t.TempDir() + writeFakePHP(t, dir+"/php8.2", "8.2.9") + t.Setenv("PATH", dir) + + installation, err := ProbePHPBinary(t.Context(), "php8.2", PHPSourceEnv) + require.NoError(t, err) + assert.Equal(t, "8.2.9", installation.Version) + assert.Equal(t, canonicalPath(t, dir+"/php8.2"), installation.Binary) +} + +func TestUnusablePHPBinaryEnv(t *testing.T) { + t.Run("unset is fine", func(t *testing.T) { + t.Setenv("PHP_BINARY", "") + assert.NoError(t, UnusablePHPBinaryEnv(t.Context())) + }) + + t.Run("a usable binary is fine", func(t *testing.T) { + dir := t.TempDir() + writeFakePHP(t, dir+"/php", "8.3.19") + t.Setenv("PHP_BINARY", dir+"/php") + assert.NoError(t, UnusablePHPBinaryEnv(t.Context())) + }) + + t.Run("a missing binary is reported", func(t *testing.T) { + t.Setenv("PATH", t.TempDir()) + t.Setenv("PHP_BINARY", "/does/not/exist/php") + assert.Error(t, UnusablePHPBinaryEnv(t.Context())) + }) +} diff --git a/internal/system/php_test.go b/internal/system/php_test.go index 6393674f..c2ed70ad 100644 --- a/internal/system/php_test.go +++ b/internal/system/php_test.go @@ -97,3 +97,27 @@ fi assert.NoError(t, os.WriteFile(path, []byte(script), 0755)) } + +// writeFakePHPWithStartupWarning emits a startup warning before the banner, as +// PHP does when php.ini references a missing extension. +func writeFakePHPWithStartupWarning(t *testing.T, path string, version string) { + t.Helper() + shPath, err := exec.LookPath("sh") + assert.NoError(t, err) + + script := fmt.Sprintf(`#!%s +echo "Warning: PHP Startup: Unable to load dynamic library 'gone.so'" +echo PHP %s +`, shPath, version) + + assert.NoError(t, os.WriteFile(path, []byte(script), 0o755)) +} + +func TestGetPHPVersionOfBinaryIgnoresStartupWarnings(t *testing.T) { + dir := t.TempDir() + writeFakePHPWithStartupWarning(t, dir+"/php", "8.5.9") + + phpVersion, err := GetPHPVersionOfBinary(t.Context(), dir+"/php") + assert.NoError(t, err) + assert.Equal(t, "8.5.9", phpVersion) +} diff --git a/internal/system/setup.go b/internal/system/setup.go index e2882b8d..1af0977e 100644 --- a/internal/system/setup.go +++ b/internal/system/setup.go @@ -57,10 +57,12 @@ type PHPVersionChecker interface { // CheckProjectDependencies returns the dependencies required to set up a // Shopware project that are not currently available. When useDocker is true // and we are not already inside a container, only Docker is required; -// otherwise PHP 8.2+ and Composer must be present locally (matching the -// fallback in runComposerInstall). If phpConstraint is non-nil and the local -// PHP does not satisfy it, that mismatch is reported as well. -func CheckProjectDependencies(ctx context.Context, useDocker bool, phpConstraint PHPVersionChecker) []MissingDependency { +// otherwise PHP 8.2+ must be present locally (Composer is not required — a +// PHAR copy is downloaded on demand when it is missing, see ResolveComposer). +// If phpConstraint is non-nil and the checked PHP does not satisfy it, that +// mismatch is reported as well. phpBinary selects the PHP executable to +// check; when empty, the ambient PHP (PHP_BINARY or PATH) is checked instead. +func CheckProjectDependencies(ctx context.Context, useDocker bool, phpConstraint PHPVersionChecker, phpBinary string) []MissingDependency { var missing []MissingDependency if useDocker && !IsInsideContainer() { @@ -75,38 +77,42 @@ func CheckProjectDependencies(ctx context.Context, useDocker bool, phpConstraint return missing } - phpOk, err := IsPHPVersionAtLeast(ctx, "8.2") + installed, err := installedPHPVersion(ctx, phpBinary) + phpOk := err == nil && phpVersionAtLeast(installed, "8.2") switch { case err != nil: missing = append(missing, MissingDependency{Name: "PHP 8.2+", Reason: "not installed"}) case !phpOk: - installed, _ := GetInstalledPHPVersion(ctx) missing = append(missing, MissingDependency{Name: "PHP 8.2+", Reason: fmt.Sprintf("found PHP %s", strings.TrimSpace(installed))}) default: - if phpConstraint != nil { - installed, _ := GetInstalledPHPVersion(ctx) - if installed != "" && !phpConstraint.Check(installed) { - missing = append(missing, MissingDependency{ - Name: fmt.Sprintf("PHP %s", phpConstraint), - Reason: fmt.Sprintf("found PHP %s", strings.TrimSpace(installed)), - }) - } + if phpConstraint != nil && !phpConstraint.Check(installed) { + missing = append(missing, MissingDependency{ + Name: fmt.Sprintf("PHP %s", phpConstraint), + Reason: fmt.Sprintf("found PHP %s", strings.TrimSpace(installed)), + }) } } - if _, err := exec.LookPath("composer"); err != nil { - missing = append(missing, MissingDependency{Name: "Composer", Reason: "not installed"}) - } - return missing } +// installedPHPVersion returns the version of the given PHP binary, or of the +// ambient PHP (PHP_BINARY or PATH) when phpBinary is empty. +func installedPHPVersion(ctx context.Context, phpBinary string) (string, error) { + if phpBinary != "" { + return GetPHPVersionOfBinary(ctx, phpBinary) + } + return GetInstalledPHPVersion(ctx) +} + // ValidateProjectDependencies runs CheckProjectDependencies and, when // something is missing, prints the rendered explanation to stderr and returns // an error. action and dockerHint are passed through to // RenderMissingDependencies to phrase the help text for the calling command. -func ValidateProjectDependencies(ctx context.Context, useDocker bool, phpConstraint PHPVersionChecker, action, dockerHint string) error { - missing := CheckProjectDependencies(ctx, useDocker, phpConstraint) +// phpBinary optionally selects the PHP executable to check instead of the +// ambient one. +func ValidateProjectDependencies(ctx context.Context, useDocker bool, phpConstraint PHPVersionChecker, action, dockerHint, phpBinary string) error { + missing := CheckProjectDependencies(ctx, useDocker, phpConstraint, phpBinary) if len(missing) == 0 { return nil } @@ -127,16 +133,6 @@ func phpDependencyConstraint(missing []MissingDependency) (string, bool) { return "", false } -// composerDependency reports whether Composer is among the missing dependencies. -func composerDependency(missing []MissingDependency) bool { - for _, m := range missing { - if m.Name == "Composer" { - return true - } - } - return false -} - // phpBinaryExample returns an illustrative PHP_BINARY value for the given // constraint (e.g. "PHP_BINARY=/usr/bin/php8.3"). func phpBinaryExample(constraint string) string { @@ -179,12 +175,10 @@ func RenderMissingDependencies(useDocker bool, missing []MissingDependency, acti case insideContainer: b.WriteString(tui.BoldText.Render(fmt.Sprintf("To %s from inside this container, install:", action))) b.WriteString("\n\n") - b.WriteString(" " + arrow + " " + tui.BoldText.Render("PHP 8.2+ and Composer") + "\n") - b.WriteString(" PHP: " + tui.BlueText.Render("https://www.php.net/downloads.php") + "\n") - b.WriteString(" Composer: " + tui.BlueText.Render("https://getcomposer.org/") + "\n") + b.WriteString(" " + arrow + " " + tui.BoldText.Render("PHP 8.2+") + "\n") + b.WriteString(" PHP: " + tui.BlueText.Render("https://www.php.net/downloads.php") + "\n") default: phpConstraint, hasPHP := phpDependencyConstraint(missing) - composerMissing := composerDependency(missing) b.WriteString(tui.BoldText.Render(fmt.Sprintf("To %s, either:", action))) b.WriteString("\n\n") @@ -199,22 +193,13 @@ func RenderMissingDependencies(useDocker bool, missing []MissingDependency, acti b.WriteString("\n") if hasPHP { - var phpText string - if composerMissing { - phpText = fmt.Sprintf("Install PHP %s and Composer, or point PHP_BINARY at a matching PHP binary", phpConstraint) - } else { - phpText = fmt.Sprintf("Install a PHP version matching %s, or point PHP_BINARY at one", phpConstraint) - } + phpText := fmt.Sprintf("Install a PHP version matching %s, or point PHP_BINARY at one", phpConstraint) b.WriteString(" " + arrow + " " + tui.BoldText.Render(phpText) + "\n") b.WriteString(" " + tui.DimText.Render("(e.g. "+phpBinaryExample(phpConstraint)+")") + "\n") - b.WriteString(" PHP: " + tui.BlueText.Render("https://www.php.net/downloads.php") + "\n") - if composerMissing { - b.WriteString(" Composer: " + tui.BlueText.Render("https://getcomposer.org/") + "\n") - } + b.WriteString(" PHP: " + tui.BlueText.Render("https://www.php.net/downloads.php") + "\n") } else { - b.WriteString(" " + arrow + " " + tui.BoldText.Render("PHP 8.2+ and Composer") + "\n") - b.WriteString(" PHP: " + tui.BlueText.Render("https://www.php.net/downloads.php") + "\n") - b.WriteString(" Composer: " + tui.BlueText.Render("https://getcomposer.org/") + "\n") + b.WriteString(" " + arrow + " " + tui.BoldText.Render("PHP 8.2+") + "\n") + b.WriteString(" PHP: " + tui.BlueText.Render("https://www.php.net/downloads.php") + "\n") } } diff --git a/internal/system/setup_test.go b/internal/system/setup_test.go index 6bba85e9..ab614dca 100644 --- a/internal/system/setup_test.go +++ b/internal/system/setup_test.go @@ -31,19 +31,17 @@ func TestRenderMissingDependencies(t *testing.T) { assert.NotContains(t, out, "install one of") }) - t.Run("missing php and composer shows install links", func(t *testing.T) { + t.Run("missing php shows install links", func(t *testing.T) { out := RenderMissingDependencies(false, []MissingDependency{ {Name: "PHP 8.2+", Reason: "not installed"}, - {Name: "Composer", Reason: "not installed"}, }, "create a Shopware project", "re-run with --docker") assert.Contains(t, out, "To create a Shopware project, either:") assert.Contains(t, out, "Docker") assert.Contains(t, out, "(recommended)") assert.Contains(t, out, "re-run with --docker") - assert.Contains(t, out, "Install PHP 8.2+ and Composer, or point PHP_BINARY at a matching PHP binary") + assert.Contains(t, out, "Install a PHP version matching 8.2+, or point PHP_BINARY at one") assert.Contains(t, out, "PHP_BINARY=/usr/bin/php8.2") assert.Contains(t, out, "https://www.php.net/downloads.php") - assert.Contains(t, out, "https://getcomposer.org/") }) t.Run("php constraint mismatch mentions PHP_BINARY", func(t *testing.T) {