Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 41 additions & 6 deletions cmd/project/project_create.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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")
Expand All @@ -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,
Expand Down Expand Up @@ -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
})
}
141 changes: 134 additions & 7 deletions cmd/project/project_create_form.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -161,11 +201,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(
Expand Down Expand Up @@ -242,6 +338,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(sectionStyle.Render("Summary"))
Expand All @@ -257,6 +377,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))
Expand Down
38 changes: 30 additions & 8 deletions cmd/project/project_create_install.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,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
}
Expand All @@ -44,7 +52,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
}
}
Expand All @@ -68,6 +76,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 {
Expand Down Expand Up @@ -157,7 +169,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() {
Expand Down Expand Up @@ -192,16 +209,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")
}

Expand Down
Loading
Loading