diff --git a/cmd/extension/extension_admin_watch.go b/cmd/extension/extension_admin_watch.go index 5cccbe7f..448219dd 100644 --- a/cmd/extension/extension_admin_watch.go +++ b/cmd/extension/extension_admin_watch.go @@ -76,7 +76,7 @@ var extensionAdminWatchCmd = &cobra.Command{ return fmt.Errorf("found nothing to compile") } - if _, err := extension.InstallNodeModulesOfConfigs(cmd.Context(), cfgs, false); err != nil { + if _, err := extension.InstallNodeModulesOfConfigs(cmd.Context(), cfgs, extension.AssetBuildConfig{}); err != nil { return err } diff --git a/cmd/project/ci.go b/cmd/project/ci.go index 6e1cf85f..ae140c63 100644 --- a/cmd/project/ci.go +++ b/cmd/project/ci.go @@ -18,7 +18,6 @@ import ( "github.com/shopware/shopware-cli/internal/extension" "github.com/shopware/shopware-cli/internal/mjml" "github.com/shopware/shopware-cli/internal/packagist" - "github.com/shopware/shopware-cli/internal/phpexec" "github.com/shopware/shopware-cli/internal/shop" "github.com/shopware/shopware-cli/logging" ) @@ -62,6 +61,11 @@ var projectCI = &cobra.Command{ // Remove annoying cache invalidation errors while asset install _ = os.Setenv("SHOPWARE_SKIP_ASSET_INSTALL_CACHE_INVALIDATION", "1") + cmdExecutor, err := resolveExecutor(cmd, args[0]) + if err != nil { + return err + } + shopCfg, err := shop.ReadConfig(cmd.Context(), projectConfigPath, true) if err != nil { return err @@ -99,8 +103,7 @@ var projectCI = &cobra.Command{ composerInstallSection := ci.Default.Section(cmd.Context(), "Composer Installation") - composer := phpexec.ComposerCommand(cmd.Context(), composerFlags...) - composer.Dir = args[0] + composer := cmdExecutor.ComposerCommand(cmd.Context(), composerFlags...) composer.Stdin = os.Stdin composer.Stdout = os.Stdout composer.Stderr = os.Stderr @@ -152,6 +155,7 @@ var projectCI = &cobra.Command{ ForceExtensionBuild: convertForceExtensionBuild(shopCfg.Build.ForceExtensionBuild), ForceAdminBuild: shopCfg.Build.ForceAdminBuild, KeepNodeModules: shopCfg.Build.KeepNodeModules, + Executor: cmdExecutor, } if shopCfg.Build.Hooks != nil && len(shopCfg.Build.Hooks.PreAssets) > 0 { @@ -215,7 +219,7 @@ var projectCI = &cobra.Command{ warumupSection := ci.Default.Section(cmd.Context(), "Warming up container cache") - if err := runTransparentCommand(phpexec.PHPCommand(cmd.Context(), path.Join(args[0], "bin", "ci"), "--version")); err != nil { //nolint: gosec + if err := runTransparentCommand(cmdExecutor.PHPCommand(cmd.Context(), path.Join(args[0], "bin", "ci"), "--version")); err != nil { //nolint: gosec return fmt.Errorf("failed to warmup container cache (php bin/ci --version): %w", err) } @@ -230,7 +234,7 @@ var projectCI = &cobra.Command{ } } - if err := runTransparentCommand(phpexec.PHPCommand(cmd.Context(), path.Join(args[0], "bin", "ci"), "asset:install")); err != nil { //nolint: gosec + if err := runTransparentCommand(cmdExecutor.PHPCommand(cmd.Context(), path.Join(args[0], "bin", "ci"), "asset:install")); err != nil { //nolint: gosec return fmt.Errorf("failed to install assets (php bin/ci asset:install): %w", err) } } @@ -344,12 +348,6 @@ func init() { projectCI.PersistentFlags().Bool("with-dev-dependencies", false, "Install dev dependencies") } -func commandWithRoot(cmd *exec.Cmd, root string) *exec.Cmd { - cmd.Dir = root - - return cmd -} - func runTransparentCommand(cmd *exec.Cmd) error { cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout diff --git a/cmd/project/executor.go b/cmd/project/executor.go new file mode 100644 index 00000000..d09fdd7d --- /dev/null +++ b/cmd/project/executor.go @@ -0,0 +1,23 @@ +package project + +import ( + "github.com/spf13/cobra" + + "github.com/shopware/shopware-cli/internal/executor" + "github.com/shopware/shopware-cli/internal/shop" +) + +// resolveExecutor returns the Executor for the current environment. +func resolveExecutor(cmd *cobra.Command, projectRoot string) (executor.Executor, error) { + cfg, err := shop.ReadConfig(cmd.Context(), projectConfigPath, true) + if err != nil { + return nil, err + } + + envCfg, err := cfg.ResolveEnvironment(environmentName) + if err != nil { + return nil, err + } + + return executor.New(projectRoot, envCfg, cfg) +} diff --git a/cmd/project/platform.go b/cmd/project/platform.go index b35651fe..59570c01 100644 --- a/cmd/project/platform.go +++ b/cmd/project/platform.go @@ -12,8 +12,8 @@ import ( "github.com/spf13/cobra" "github.com/shopware/shopware-cli/internal/asset" + "github.com/shopware/shopware-cli/internal/executor" "github.com/shopware/shopware-cli/internal/extension" - "github.com/shopware/shopware-cli/internal/phpexec" "github.com/shopware/shopware-cli/internal/shop" "github.com/shopware/shopware-cli/logging" ) @@ -64,18 +64,33 @@ func findClosestShopwareProject() (string, error) { return "", fmt.Errorf("cannot find Shopware project in current directory") } -func filterAndWritePluginJson(cmd *cobra.Command, projectRoot string, shopCfg *shop.Config) error { +func filterAndWritePluginJson(cmd *cobra.Command, projectRoot string, shopCfg *shop.Config, cmdExecutor executor.Executor) error { sources, err := filterAndGetSources(cmd, projectRoot, shopCfg) if err != nil { return err } - cfgs := extension.BuildAssetConfigFromExtensions(cmd.Context(), sources, extension.AssetBuildConfig{}) + assetConfig := extension.AssetBuildConfig{ + ShopwareRoot: projectRoot, + Executor: cmdExecutor, + } + + cfgs := extension.BuildAssetConfigFromExtensions(cmd.Context(), sources, assetConfig) - if _, err := extension.InstallNodeModulesOfConfigs(cmd.Context(), cfgs, false); err != nil { + if _, err := extension.InstallNodeModulesOfConfigs(cmd.Context(), cfgs, assetConfig); err != nil { return err } + // Normalize paths for the execution environment (e.g. Docker container). + for _, cfg := range cfgs { + cfg.BasePath = cmdExecutor.NormalizePath(cfg.BasePath) + for i, v := range cfg.Views { + cfg.Views[i] = cmdExecutor.NormalizePath(v) + } + } + + fmt.Println(cfgs) + pluginJson, err := json.MarshalIndent(cfgs, "", " ") if err != nil { return err @@ -89,7 +104,12 @@ func filterAndWritePluginJson(cmd *cobra.Command, projectRoot string, shopCfg *s } func filterAndGetSources(cmd *cobra.Command, projectRoot string, shopCfg *shop.Config) ([]asset.Source, error) { - sources, err := extension.DumpAndLoadAssetSourcesOfProject(phpexec.AllowBinCI(cmd.Context()), projectRoot, shopCfg) + cmdExecutor, err := resolveExecutor(cmd, projectRoot) + if err != nil { + return nil, err + } + + sources, err := extension.DumpAndLoadAssetSourcesOfProject(executor.AllowBinCI(cmd.Context()), projectRoot, shopCfg, cmdExecutor.ConsoleCommand) if err != nil { return nil, err } diff --git a/cmd/project/project.go b/cmd/project/project.go index 98093caf..a7741895 100644 --- a/cmd/project/project.go +++ b/cmd/project/project.go @@ -6,7 +6,10 @@ import ( "github.com/shopware/shopware-cli/internal/shop" ) -var projectConfigPath string +var ( + projectConfigPath string + environmentName string +) var projectRootCmd = &cobra.Command{ Use: "project", @@ -16,4 +19,5 @@ var projectRootCmd = &cobra.Command{ func Register(rootCmd *cobra.Command) { rootCmd.AddCommand(projectRootCmd) projectRootCmd.PersistentFlags().StringVar(&projectConfigPath, "project-config", shop.DefaultConfigFileName(), "Path to config") + projectRootCmd.PersistentFlags().StringVarP(&environmentName, "env", "e", "", "Target environment name") } diff --git a/cmd/project/project_admin_build.go b/cmd/project/project_admin_build.go index 05b71083..0a6dd63f 100644 --- a/cmd/project/project_admin_build.go +++ b/cmd/project/project_admin_build.go @@ -5,8 +5,8 @@ import ( "github.com/spf13/cobra" + "github.com/shopware/shopware-cli/internal/executor" "github.com/shopware/shopware-cli/internal/extension" - "github.com/shopware/shopware-cli/internal/phpexec" "github.com/shopware/shopware-cli/internal/shop" "github.com/shopware/shopware-cli/logging" ) @@ -34,9 +34,14 @@ var projectAdminBuildCmd = &cobra.Command{ return err } + cmdExecutor, err := resolveExecutor(cmd, projectRoot) + if err != nil { + return err + } + logging.FromContext(cmd.Context()).Infof("Looking for extensions to build assets in project") - if err := runTransparentCommand(commandWithRoot(phpexec.ConsoleCommand(phpexec.AllowBinCI(cmd.Context()), "feature:dump"), projectRoot)); err != nil { + if err := runTransparentCommand(cmdExecutor.ConsoleCommand(executor.AllowBinCI(cmd.Context()), "feature:dump")); err != nil { return err } @@ -58,6 +63,7 @@ var projectAdminBuildCmd = &cobra.Command{ ShopwareVersion: shopwareConstraint, NPMForceInstall: forceInstall, ForceAdminBuild: shopCfg.Build.ForceAdminBuild, + Executor: cmdExecutor, } if err := extension.BuildAssetsForExtensions(cmd.Context(), sources, assetCfg); err != nil { @@ -69,7 +75,7 @@ var projectAdminBuildCmd = &cobra.Command{ return nil } - return runTransparentCommand(commandWithRoot(phpexec.ConsoleCommand(cmd.Context(), "assets:install"), projectRoot)) + return runTransparentCommand(cmdExecutor.ConsoleCommand(cmd.Context(), "assets:install")) }, } diff --git a/cmd/project/project_admin_watch.go b/cmd/project/project_admin_watch.go index 9e84a3f8..306da68a 100644 --- a/cmd/project/project_admin_watch.go +++ b/cmd/project/project_admin_watch.go @@ -2,15 +2,13 @@ package project import ( "os" - "os/exec" - "path" + "path/filepath" "github.com/spf13/cobra" "github.com/shopware/shopware-cli/internal/envfile" "github.com/shopware/shopware-cli/internal/extension" "github.com/shopware/shopware-cli/internal/npm" - "github.com/shopware/shopware-cli/internal/phpexec" "github.com/shopware/shopware-cli/internal/shop" ) @@ -37,11 +35,16 @@ var projectAdminWatchCmd = &cobra.Command{ return err } - if err := filterAndWritePluginJson(cmd, projectRoot, shopCfg); err != nil { + cmdExecutor, err := resolveExecutor(cmd, projectRoot) + if err != nil { return err } - if err := runTransparentCommand(commandWithRoot(phpexec.ConsoleCommand(cmd.Context(), "feature:dump"), projectRoot)); err != nil { + if err := filterAndWritePluginJson(cmd, projectRoot, shopCfg, cmdExecutor); err != nil { + return err + } + + if err := runTransparentCommand(cmdExecutor.ConsoleCommand(cmd.Context(), "feature:dump")); err != nil { return err } @@ -49,14 +52,15 @@ var projectAdminWatchCmd = &cobra.Command{ return err } + adminRelPath := extension.PlatformRelPath(projectRoot, "Administration", "Resources/app/administration") + adminExecutor := cmdExecutor.WithRelDir(adminRelPath) + if _, err := os.Stat(extension.PlatformPath(projectRoot, "Administration", "Resources/app/administration/node_modules/webpack-dev-server")); os.IsNotExist(err) { - if err := npm.InstallDependencies(cmd.Context(), extension.PlatformPath(projectRoot, "Administration", "Resources/app/administration"), npm.NonEmptyPackage); err != nil { + if err := npm.InstallDependencies(cmd.Context(), adminExecutor, npm.NonEmptyPackage); err != nil { return err } } - adminRoot := extension.PlatformPath(projectRoot, "Administration", "Resources/app/administration") - if err := os.Setenv("ADMIN_ROOT", extension.PlatformPath(projectRoot, "Administration", "")); err != nil { return err } @@ -69,16 +73,22 @@ var projectAdminWatchCmd = &cobra.Command{ } } - if err := runTransparentCommand(commandWithRoot(phpexec.ConsoleCommand(cmd.Context(), "framework:schema", "-s", "entity-schema", path.Join(mockDirectory, "entity-schema.json")), projectRoot)); err != nil { + relMockDir, err := filepath.Rel(projectRoot, mockDirectory) + + if err != nil { + return err + } + + if err := runTransparentCommand(cmdExecutor.ConsoleCommand(cmd.Context(), "framework:schema", "-s", "entity-schema", filepath.Join(relMockDir, "entity-schema.json"))); err != nil { return err } - if err := runTransparentCommand(commandWithRoot(exec.CommandContext(cmd.Context(), "npm", "run", "convert-entity-schema"), adminRoot)); err != nil { + if err := runTransparentCommand(adminExecutor.NPMCommand(cmd.Context(), "run", "convert-entity-schema")); err != nil { return err } } - return runTransparentCommand(commandWithRoot(exec.CommandContext(cmd.Context(), "npm", "run", "dev"), adminRoot)) + return runTransparentCommand(adminExecutor.NPMCommand(cmd.Context(), "run", "dev")) }, } diff --git a/cmd/project/project_autofix_composer.go b/cmd/project/project_autofix_composer.go index 2a65a686..3a352675 100644 --- a/cmd/project/project_autofix_composer.go +++ b/cmd/project/project_autofix_composer.go @@ -54,7 +54,7 @@ var projectAutofixComposerCmd = &cobra.Command{ _ = spinner.New().Context(ctx).Title("Fetching packages").Run() }() - packagistResponse, err := packagist.GetPackages(cmd.Context(), token) + packagistResponse, err := packagist.GetAvailablePackagesFromShopwareStore(cmd.Context(), token) cancel() diff --git a/cmd/project/project_config_init.go b/cmd/project/project_config_init.go index f44eebef..745b1d16 100644 --- a/cmd/project/project_config_init.go +++ b/cmd/project/project_config_init.go @@ -2,11 +2,9 @@ package project import ( "fmt" - "os" "charm.land/huh/v2" "github.com/spf13/cobra" - "gopkg.in/yaml.v3" "github.com/shopware/shopware-cli/internal/compatibility" "github.com/shopware/shopware-cli/internal/shop" @@ -30,12 +28,7 @@ var projectConfigInitCmd = &cobra.Command{ return err } - content, err := yaml.Marshal(config) - if err != nil { - return err - } - - if err := os.WriteFile(".shopware-project.yml", content, os.ModePerm); err != nil { + if err := shop.WriteConfig(config, "."); err != nil { return err } diff --git a/cmd/project/project_console.go b/cmd/project/project_console.go index 3ac6b0a7..55792ba6 100644 --- a/cmd/project/project_console.go +++ b/cmd/project/project_console.go @@ -6,7 +6,6 @@ import ( "github.com/spf13/cobra" "github.com/shopware/shopware-cli/internal/extension" - "github.com/shopware/shopware-cli/internal/phpexec" "github.com/shopware/shopware-cli/internal/shop" ) @@ -26,7 +25,12 @@ var projectConsoleCmd = &cobra.Command{ return nil, cobra.ShellCompDirectiveDefault } - parsedCommands, err := shop.GetConsoleCompletion(cmd.Context(), projectRoot) + exec, err := resolveExecutor(cmd, projectRoot) + if err != nil { + return nil, cobra.ShellCompDirectiveDefault + } + + parsedCommands, err := shop.GetConsoleCompletion(cmd.Context(), projectRoot, exec.ConsoleCommand) if err != nil { return nil, cobra.ShellCompDirectiveDefault } @@ -79,8 +83,12 @@ var projectConsoleCmd = &cobra.Command{ return err } - consoleCmd := phpexec.ConsoleCommand(cmd.Context(), args...) - consoleCmd.Dir = projectRoot + exec, err := resolveExecutor(cmd, projectRoot) + if err != nil { + return err + } + + consoleCmd := exec.ConsoleCommand(cmd.Context(), args...) consoleCmd.Stdin = cmd.InOrStdin() consoleCmd.Stdout = cmd.OutOrStdout() consoleCmd.Stderr = cmd.ErrOrStderr() diff --git a/cmd/project/project_create.go b/cmd/project/project_create.go index cd8d1cf0..b32175ec 100644 --- a/cmd/project/project_create.go +++ b/cmd/project/project_create.go @@ -4,10 +4,7 @@ import ( "bytes" "context" _ "embed" - "encoding/json" "fmt" - "io" - "net/http" "os" "os/exec" "path/filepath" @@ -21,8 +18,10 @@ import ( "github.com/shyim/go-version" "github.com/spf13/cobra" + dockerpkg "github.com/shopware/shopware-cli/internal/docker" "github.com/shopware/shopware-cli/internal/git" "github.com/shopware/shopware-cli/internal/packagist" + "github.com/shopware/shopware-cli/internal/shop" "github.com/shopware/shopware-cli/internal/system" "github.com/shopware/shopware-cli/internal/tracking" "github.com/shopware/shopware-cli/internal/tui" @@ -41,6 +40,9 @@ var githubDeployTemplate string //go:embed static/gitlab-ci.yml.tmpl var gitlabCITemplate string +//go:embed static/shopware-paas-application.yaml +var shopwarePaasAppTemplate string + const versionLatest = "latest" var projectCreateCmd = &cobra.Command{ @@ -76,7 +78,6 @@ var projectCreateCmd = &cobra.Command{ useDocker, _ := cmd.PersistentFlags().GetBool("docker") withElasticsearch, _ := cmd.PersistentFlags().GetBool("with-elasticsearch") - withoutElasticsearch, _ := cmd.PersistentFlags().GetBool("without-elasticsearch") withAMQP, _ := cmd.PersistentFlags().GetBool("with-amqp") noAudit, _ := cmd.PersistentFlags().GetBool("no-audit") initGit, _ := cmd.PersistentFlags().GetBool("git") @@ -85,7 +86,7 @@ var projectCreateCmd = &cobra.Command{ ciSystem, _ := cmd.PersistentFlags().GetString("ci") if cmd.PersistentFlags().Changed("without-elasticsearch") { - logging.FromContext(cmd.Context()).Warnf("Flag --without-elasticsearch is deprecated, use --with-elasticsearch instead") + withoutElasticsearch, _ := cmd.PersistentFlags().GetBool("without-elasticsearch") withElasticsearch = !withoutElasticsearch } @@ -462,7 +463,6 @@ var projectCreateCmd = &cobra.Command{ composerJson, err := packagist.GenerateComposerJson(cmd.Context(), packagist.ComposerJsonOptions{ Version: chooseVersion, RC: strings.Contains(chooseVersion, "rc"), - UseDocker: useDocker, UseElasticsearch: withElasticsearch, UseAMQP: withAMQP, NoAudit: noAudit, @@ -525,6 +525,12 @@ var projectCreateCmd = &cobra.Command{ return err } + if useDocker { + if err := dockerpkg.WriteComposeFile(projectFolder, nil); err != nil { + return err + } + } + if initGit { logging.FromContext(cmd.Context()).Infof("Initializing Git repository") if err := git.Init(cmd.Context(), projectFolder); err != nil { @@ -532,6 +538,15 @@ var projectCreateCmd = &cobra.Command{ } } + shopCfg := shop.NewConfig() + if useDocker { + shopCfg.Environments["local"].Type = "docker" + } + + if err := shop.WriteConfig(shopCfg, projectFolder); err != nil { + return err + } + if interactive { cmdStyle := lipgloss.NewStyle().Bold(true) sectionStyle := lipgloss.NewStyle().Bold(true).Underline(true) @@ -543,9 +558,7 @@ var projectCreateCmd = &cobra.Command{ fmt.Println() fmt.Println(sectionStyle.Render("Next steps")) fmt.Println() - fmt.Printf(" %s %s\n", tui.GreenText.Render("Start containers:"), cmdStyle.Render(fmt.Sprintf("cd %q && make up", projectFolder))) - fmt.Printf(" %s %s\n", tui.GreenText.Render("Set up Shopware:"), cmdStyle.Render("make setup")) - fmt.Printf(" %s %s\n", tui.GreenText.Render("Stop containers:"), cmdStyle.Render("make down")) + fmt.Printf(" %s %s\n", tui.GreenText.Render("Start developing:"), cmdStyle.Render(fmt.Sprintf("cd %s && shopware-cli project dev", projectFolder))) fmt.Println() fmt.Println(sectionStyle.Render("Access your shop (after make setup)")) fmt.Println() @@ -600,15 +613,7 @@ func setupDeployment(projectFolder, deploymentMethod string) error { } case packagist.DeploymentShopwarePaaS: - shopwarePaasApp := `app: - php: - version: "8.4" -services: - mysql: - version: "8.0" -` - - if err := os.WriteFile(filepath.Join(projectFolder, "application.yaml"), []byte(shopwarePaasApp), os.ModePerm); err != nil { + if err := os.WriteFile(filepath.Join(projectFolder, "application.yaml"), []byte(shopwarePaasAppTemplate), os.ModePerm); err != nil { return err } } @@ -733,7 +738,7 @@ func runComposerInstall(ctx context.Context, projectFolder string, useDocker boo } func getFilteredInstallVersions(ctx context.Context) ([]*version.Version, error) { - releases, err := fetchAvailableShopwareVersions(ctx) + releases, err := packagist.GetShopwarePackageVersions(ctx) if err != nil { return nil, err } @@ -742,7 +747,14 @@ func getFilteredInstallVersions(ctx context.Context) ([]*version.Version, error) constraint, _ := version.NewConstraint(">=6.4.18.0") for _, release := range releases { - parsed := version.Must(version.NewVersion(release)) + if strings.HasPrefix(release.Version, "dev-") { + continue + } + + parsed, err := version.NewVersion(release.Version) + if err != nil { + continue + } if constraint.Check(parsed) { filteredVersions = append(filteredVersions, parsed) @@ -751,6 +763,10 @@ func getFilteredInstallVersions(ctx context.Context) ([]*version.Version, error) sort.Sort(sort.Reverse(version.Collection(filteredVersions))) + for i, v := range filteredVersions { + filteredVersions[i], _ = version.NewVersion(strings.TrimPrefix(v.String(), "v")) + } + return filteredVersions, nil } @@ -758,7 +774,8 @@ func init() { projectRootCmd.AddCommand(projectCreateCmd) projectCreateCmd.PersistentFlags().Bool("docker", false, "Use Docker to run Composer instead of local installation") projectCreateCmd.PersistentFlags().Bool("with-elasticsearch", false, "Include Elasticsearch/OpenSearch support") - projectCreateCmd.PersistentFlags().Bool("without-elasticsearch", false, "Remove Elasticsearch from the installation (deprecated: use --with-elasticsearch)") + projectCreateCmd.PersistentFlags().Bool("without-elasticsearch", false, "Remove Elasticsearch from the installation") + _ = projectCreateCmd.PersistentFlags().MarkDeprecated("without-elasticsearch", "use --with-elasticsearch instead") projectCreateCmd.PersistentFlags().Bool("with-amqp", false, "Include AMQP queue support (symfony/amqp-messenger)") projectCreateCmd.PersistentFlags().Bool("no-audit", false, "Disable composer audit blocking insecure packages") projectCreateCmd.PersistentFlags().Bool("git", false, "Initialize a Git repository") @@ -766,34 +783,3 @@ func init() { projectCreateCmd.PersistentFlags().String("deployment", "", "Deployment method: none, deployer, platformsh, shopware-paas") projectCreateCmd.PersistentFlags().String("ci", "", "CI/CD system: none, github, gitlab") } - -func fetchAvailableShopwareVersions(ctx context.Context) ([]string, error) { - r, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://releases.shopware.com/changelog/index.json", http.NoBody) - if err != nil { - return nil, err - } - - resp, err := http.DefaultClient.Do(r) - if err != nil { - return nil, err - } - - defer func() { - if err := resp.Body.Close(); err != nil { - logging.FromContext(ctx).Errorf("fetchAvailableShopwareVersions: %v", err) - } - }() - - content, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - - var releases []string - - if err := json.Unmarshal(content, &releases); err != nil { - return nil, err - } - - return releases, nil -} diff --git a/cmd/project/project_dev.go b/cmd/project/project_dev.go new file mode 100644 index 00000000..ae716221 --- /dev/null +++ b/cmd/project/project_dev.go @@ -0,0 +1,62 @@ +package project + +import ( + tea "charm.land/bubbletea/v2" + "github.com/spf13/cobra" + + "github.com/shopware/shopware-cli/internal/devtui" + dockerpkg "github.com/shopware/shopware-cli/internal/docker" + "github.com/shopware/shopware-cli/internal/executor" + "github.com/shopware/shopware-cli/internal/shop" +) + +var projectDevCmd = &cobra.Command{ + Use: "dev", + Short: "Start the interactive development dashboard", + RunE: func(cmd *cobra.Command, args []string) error { + projectRoot, err := findClosestShopwareProject() + if err != nil { + return err + } + + cfg, err := shop.ReadConfig(cmd.Context(), projectConfigPath, true) + if err != nil { + return err + } + + if cfg.IsCompatibilityDateBefore(shop.CompatibilityDevMode) { + return shop.ErrDevModeNotSupported + } + + envCfg, err := cfg.ResolveEnvironment(environmentName) + if err != nil { + return err + } + + exec, err := executor.New(projectRoot, envCfg, cfg) + if err != nil { + return err + } + + if exec.Type() == "docker" { + if err := dockerpkg.WriteComposeFile(projectRoot, dockerpkg.ComposeOptionsFromConfig(cfg)); err != nil { + return err + } + } + + m := devtui.New(devtui.Options{ + ProjectRoot: projectRoot, + Config: cfg, + EnvConfig: envCfg, + Executor: exec, + }) + + p := tea.NewProgram(m) + _, err = p.Run() + return err + }, +} + +func init() { + projectRootCmd.AddCommand(projectDevCmd) +} diff --git a/cmd/project/project_storefront_build.go b/cmd/project/project_storefront_build.go index ce8e9317..73b05167 100644 --- a/cmd/project/project_storefront_build.go +++ b/cmd/project/project_storefront_build.go @@ -5,8 +5,8 @@ import ( "github.com/spf13/cobra" + "github.com/shopware/shopware-cli/internal/executor" "github.com/shopware/shopware-cli/internal/extension" - "github.com/shopware/shopware-cli/internal/phpexec" "github.com/shopware/shopware-cli/internal/shop" "github.com/shopware/shopware-cli/logging" ) @@ -34,9 +34,14 @@ var projectStorefrontBuildCmd = &cobra.Command{ return err } + cmdExecutor, err := resolveExecutor(cmd, projectRoot) + if err != nil { + return err + } + logging.FromContext(cmd.Context()).Infof("Looking for extensions to build assets in project") - if err := runTransparentCommand(commandWithRoot(phpexec.ConsoleCommand(phpexec.AllowBinCI(cmd.Context()), "feature:dump"), projectRoot)); err != nil { + if err := runTransparentCommand(cmdExecutor.ConsoleCommand(executor.AllowBinCI(cmd.Context()), "feature:dump")); err != nil { return err } @@ -57,6 +62,7 @@ var projectStorefrontBuildCmd = &cobra.Command{ ShopwareRoot: projectRoot, ShopwareVersion: shopwareConstraint, NPMForceInstall: forceInstall, + Executor: cmdExecutor, } if err := extension.BuildAssetsForExtensions(cmd.Context(), sources, assetCfg); err != nil { @@ -68,7 +74,7 @@ var projectStorefrontBuildCmd = &cobra.Command{ return nil } - return runTransparentCommand(commandWithRoot(phpexec.ConsoleCommand(phpexec.AllowBinCI(cmd.Context()), "theme:compile"), projectRoot)) + return runTransparentCommand(cmdExecutor.ConsoleCommand(executor.AllowBinCI(cmd.Context()), "theme:compile")) }, } diff --git a/cmd/project/project_storefront_watch.go b/cmd/project/project_storefront_watch.go index fbf28840..abf10e37 100644 --- a/cmd/project/project_storefront_watch.go +++ b/cmd/project/project_storefront_watch.go @@ -2,7 +2,6 @@ package project import ( "os" - "os/exec" "strings" "github.com/spf13/cobra" @@ -10,7 +9,6 @@ import ( "github.com/shopware/shopware-cli/internal/envfile" "github.com/shopware/shopware-cli/internal/extension" "github.com/shopware/shopware-cli/internal/npm" - "github.com/shopware/shopware-cli/internal/phpexec" "github.com/shopware/shopware-cli/internal/shop" ) @@ -37,11 +35,16 @@ var projectStorefrontWatchCmd = &cobra.Command{ return err } - if err := filterAndWritePluginJson(cmd, projectRoot, shopCfg); err != nil { + cmdExecutor, err := resolveExecutor(cmd, projectRoot) + if err != nil { return err } - if err := runTransparentCommand(commandWithRoot(phpexec.ConsoleCommand(cmd.Context(), "feature:dump"), projectRoot)); err != nil { + if err := filterAndWritePluginJson(cmd, projectRoot, shopCfg, cmdExecutor); err != nil { + return err + } + + if err := runTransparentCommand(cmdExecutor.ConsoleCommand(cmd.Context(), "feature:dump")); err != nil { return err } @@ -51,11 +54,11 @@ var projectStorefrontWatchCmd = &cobra.Command{ activeOnly = "-v" } - if err := runTransparentCommand(commandWithRoot(phpexec.ConsoleCommand(cmd.Context(), "theme:compile", activeOnly), projectRoot)); err != nil { + if err := runTransparentCommand(cmdExecutor.ConsoleCommand(cmd.Context(), "theme:compile", activeOnly)); err != nil { return err } - if err := runTransparentCommand(commandWithRoot(phpexec.ConsoleCommand(cmd.Context(), "theme:dump"), projectRoot)); err != nil { + if err := runTransparentCommand(cmdExecutor.ConsoleCommand(cmd.Context(), "theme:dump")); err != nil { return err } @@ -67,13 +70,16 @@ var projectStorefrontWatchCmd = &cobra.Command{ return err } + storefrontRelPath := extension.PlatformRelPath(projectRoot, "Storefront", "Resources/app/storefront") + storefrontExecutor := cmdExecutor.WithRelDir(storefrontRelPath) + if _, err := os.Stat(extension.PlatformPath(projectRoot, "Storefront", "Resources/app/storefront/node_modules/webpack-dev-server")); os.IsNotExist(err) { - if err := npm.InstallDependencies(cmd.Context(), extension.PlatformPath(projectRoot, "Storefront", "Resources/app/storefront"), npm.NonEmptyPackage); err != nil { + if err := npm.InstallDependencies(cmd.Context(), storefrontExecutor, npm.NonEmptyPackage); err != nil { return err } } - return runTransparentCommand(commandWithRoot(exec.CommandContext(cmd.Context(), "npm", "run-script", "hot-proxy"), extension.PlatformPath(projectRoot, "Storefront", "Resources/app/storefront"))) + return runTransparentCommand(storefrontExecutor.NPMCommand(cmd.Context(), "run-script", "hot-proxy")) }, } diff --git a/cmd/project/project_worker.go b/cmd/project/project_worker.go index 31ec9a74..9d0e84a0 100644 --- a/cmd/project/project_worker.go +++ b/cmd/project/project_worker.go @@ -15,7 +15,6 @@ import ( "github.com/spf13/cobra" "golang.org/x/time/rate" - "github.com/shopware/shopware-cli/internal/phpexec" "github.com/shopware/shopware-cli/internal/shop" "github.com/shopware/shopware-cli/logging" ) @@ -39,6 +38,11 @@ var projectWorkerCmd = &cobra.Command{ return err } + cmdExecutor, err := resolveExecutor(cobraCmd, projectRoot) + if err != nil { + return err + } + if len(args) > 0 { workerAmount, err = strconv.Atoi(args[0]) if err != nil { @@ -97,8 +101,7 @@ var projectWorkerCmd = &cobra.Command{ continue } - cmd := phpexec.ConsoleCommand(cancelCtx, consumeArgs...) - cmd.Dir = projectRoot + cmd := cmdExecutor.ConsoleCommand(cancelCtx, consumeArgs...) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr cmd.Env = append(os.Environ(), fmt.Sprintf("MESSENGER_CONSUMER_NAME=%s-%d", baseName, index)) diff --git a/cmd/project/static/shopware-paas-application.yaml b/cmd/project/static/shopware-paas-application.yaml new file mode 100644 index 00000000..3b261f8a --- /dev/null +++ b/cmd/project/static/shopware-paas-application.yaml @@ -0,0 +1,6 @@ +app: + php: + version: "8.4" +services: + mysql: + version: "8.0" diff --git a/cmd/root.go b/cmd/root.go index cc92a978..c58cec3f 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -15,6 +15,7 @@ import ( "github.com/shopware/shopware-cli/cmd/project" accountApi "github.com/shopware/shopware-cli/internal/account-api" "github.com/shopware/shopware-cli/internal/system" + "github.com/shopware/shopware-cli/internal/tui" "github.com/shopware/shopware-cli/logging" ) @@ -33,6 +34,7 @@ func Execute(ctx context.Context) { ctx = logging.WithLogger(ctx, logging.NewLogger(slices.Contains(os.Args, "--verbose"))) ctx = system.WithInteraction(ctx, !slices.Contains(os.Args, "--no-interaction") && !slices.Contains(os.Args, "-n") && isatty.IsTerminal(os.Stdin.Fd())) + tui.AppVersion = version accountApi.SetUserAgent("shopware-cli/" + version) if err := rootCmd.ExecuteContext(ctx); err != nil { diff --git a/go.mod b/go.mod index c3984680..f47da905 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,8 @@ module github.com/shopware/shopware-cli go 1.25.8 require ( + charm.land/bubbles/v2 v2.0.0 + charm.land/bubbletea/v2 v2.0.2 charm.land/huh/v2 v2.0.3 charm.land/lipgloss/v2 v2.0.2 dario.cat/mergo v1.0.2 @@ -10,6 +12,7 @@ require ( github.com/NYTimes/gziphandler v1.1.1 github.com/bep/godartsass/v2 v2.5.0 github.com/cespare/xxhash/v2 v2.3.0 + github.com/charmbracelet/x/term v0.2.2 github.com/evanw/esbuild v0.27.4 github.com/go-sql-driver/mysql v1.9.3 github.com/gorilla/schema v1.4.1 @@ -37,19 +40,17 @@ require ( ) require ( - charm.land/bubbles/v2 v2.0.0 // indirect - charm.land/bubbletea/v2 v2.0.2 // indirect github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.4 // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/catppuccin/go v0.3.0 // indirect github.com/charmbracelet/colorprofile v0.4.2 // indirect + github.com/charmbracelet/harmonica v0.2.0 // indirect github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect github.com/charmbracelet/x/ansi v0.11.6 // indirect github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect github.com/charmbracelet/x/exp/strings v0.1.0 // indirect - github.com/charmbracelet/x/term v0.2.2 // indirect github.com/charmbracelet/x/termios v0.1.1 // indirect github.com/charmbracelet/x/windows v0.2.2 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect diff --git a/go.sum b/go.sum index 5a28411e..039aef0b 100644 --- a/go.sum +++ b/go.sum @@ -46,6 +46,8 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY= github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8= +github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ= +github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA= github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8/go.mod h1:SQpCTRNBtzJkwku5ye4S3HEuthAlGy2n9VXZnWkEW98= github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= diff --git a/internal/compatibility/date.go b/internal/compatibility/date.go index 3f20bb64..034f4bd7 100644 --- a/internal/compatibility/date.go +++ b/internal/compatibility/date.go @@ -43,6 +43,13 @@ func IsAtLeast(compatibilityDate, requiredDate string) (bool, error) { return !currentDate.Before(minDate), nil } +// IsBefore checks whether compatibilityDate is strictly before requiredDate. +// An empty compatibilityDate falls back to the default compatibility date. +func IsBefore(compatibilityDate, requiredDate string) bool { + ok, _ := IsAtLeast(compatibilityDate, requiredDate) + return !ok +} + func parseDate(value string) (time.Time, error) { return time.Parse(dateLayout, value) } diff --git a/internal/devtui/command_palette.go b/internal/devtui/command_palette.go new file mode 100644 index 00000000..4a774f93 --- /dev/null +++ b/internal/devtui/command_palette.go @@ -0,0 +1,154 @@ +package devtui + +import ( + "strings" + + "charm.land/bubbles/v2/textinput" + "charm.land/lipgloss/v2" + + "github.com/shopware/shopware-cli/internal/tui" +) + +type paletteCommand struct { + Label string + Shortcut string + ID string +} + +var paletteCommands = []paletteCommand{ + {Label: "Open Storefront", ID: "open-shop"}, + {Label: "Open Admin", ID: "open-admin"}, + {Label: "Clear Cache", ID: "cache-clear"}, + {Label: "Toggle Logs Tab", Shortcut: "2", ID: "tab-logs"}, + {Label: "Toggle General Tab", Shortcut: "1", ID: "tab-general"}, + {Label: "Quit", Shortcut: "ctrl+c", ID: "quit"}, +} + +type commandPalette struct { + filter textinput.Model + cursor int + filtered []int // indices into paletteCommands +} + +func newCommandPalette() commandPalette { + ti := textinput.New() + ti.Prompt = lipgloss.NewStyle().Foreground(tui.BrandColor).Render("> ") + ti.Placeholder = "Type to filter" + ti.CharLimit = 64 + ti.Focus() + + cp := commandPalette{ + filter: ti, + } + cp.applyFilter() + return cp +} + +func (cp *commandPalette) applyFilter() { + query := strings.ToLower(cp.filter.Value()) + cp.filtered = nil + for i, cmd := range paletteCommands { + if query == "" || strings.Contains(strings.ToLower(cmd.Label), query) { + cp.filtered = append(cp.filtered, i) + } + } + if cp.cursor >= len(cp.filtered) { + cp.cursor = max(len(cp.filtered)-1, 0) + } +} + +func (cp *commandPalette) moveUp() { + if cp.cursor > 0 { + cp.cursor-- + } +} + +func (cp *commandPalette) moveDown() { + if cp.cursor < len(cp.filtered)-1 { + cp.cursor++ + } +} + +func (cp commandPalette) selectedID() string { + if len(cp.filtered) == 0 { + return "" + } + return paletteCommands[cp.filtered[cp.cursor]].ID +} + +func (cp commandPalette) view(width, height int) string { + paletteWidth := min(width-4, 70) + innerWidth := paletteWidth - 6 // border(2) + padding(4) + + titleStyle := lipgloss.NewStyle(). + Bold(true). + Foreground(tui.BrandColor) + + var b strings.Builder + + b.WriteString(titleStyle.Render("Commands")) + b.WriteString("\n\n") + b.WriteString(cp.filter.View()) + b.WriteString("\n\n") + + selectedStyle := lipgloss.NewStyle(). + Foreground(tui.TextColor). + Background(tui.BrandColor). + Width(innerWidth) + + normalStyle := lipgloss.NewStyle(). + Foreground(tui.TextColor). + Width(innerWidth) + + shortcutStyle := lipgloss.NewStyle(). + Foreground(tui.MutedColor) + + selectedShortcutStyle := lipgloss.NewStyle(). + Foreground(tui.TextColor). + Background(tui.BrandColor) + + for i, idx := range cp.filtered { + cmd := paletteCommands[idx] + rowStyle, scStyle := normalStyle, shortcutStyle + if i == cp.cursor { + rowStyle, scStyle = selectedStyle, selectedShortcutStyle + } + + if cmd.Shortcut != "" { + sc := scStyle.Render(cmd.Shortcut) + gap := max(innerWidth-lipgloss.Width(cmd.Label)-lipgloss.Width(cmd.Shortcut), 1) + b.WriteString(rowStyle.Render(cmd.Label + strings.Repeat(" ", gap) + sc)) + } else { + b.WriteString(rowStyle.Render(cmd.Label)) + } + b.WriteString("\n") + } + + if len(cp.filtered) == 0 { + b.WriteString(lipgloss.NewStyle().Foreground(tui.MutedColor).Render("No matching commands")) + b.WriteString("\n") + } + + b.WriteString("\n") + b.WriteString(tui.ShortcutBar( + tui.Shortcut{Key: "↑/↓", Label: "Choose"}, + tui.Shortcut{Key: "enter", Label: "Confirm"}, + tui.Shortcut{Key: "esc", Label: "Cancel"}, + )) + + box := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(tui.BrandColor). + Padding(1, 2). + Width(paletteWidth) + + modal := box.Render(b.String()) + + return lipgloss.Place( + width, + height, + lipgloss.Center, + lipgloss.Center, + modal, + ) +} diff --git a/internal/devtui/model.go b/internal/devtui/model.go new file mode 100644 index 00000000..5d5b9ac4 --- /dev/null +++ b/internal/devtui/model.go @@ -0,0 +1,264 @@ +package devtui + +import ( + "charm.land/bubbles/v2/progress" + "charm.land/bubbles/v2/spinner" + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + + "github.com/shopware/shopware-cli/internal/executor" + "github.com/shopware/shopware-cli/internal/shop" +) + +type activeTab int + +const ( + tabGeneral activeTab = iota + tabLogs +) + +var tabNames = []string{"General", "Logs"} + +const ( + keyCtrlC = "ctrl+c" + keyDown = "down" + keyEnter = "enter" + keyUp = "up" + keyTab = "tab" + keyShiftTab = "shift+tab" + keyQ = "q" + keyF = "f" + keyJ = "j" + keyK = "k" + key1 = "1" + key2 = "2" + + defaultUsername = "admin" +) + +type overlay int + +const ( + overlayNone overlay = iota + overlayStarting + overlayStopConfirm + overlayStopping + overlayInstallPrompt + overlayInstalling + overlayCommandPalette +) + +type installStep int + +const ( + installStepAsk installStep = iota + installStepLanguage + installStepCurrency + installStepUsername + installStepPassword +) + +type installLanguage struct { + id string + label string +} + +var ( + installLanguages = []installLanguage{ + {"en-GB", "English (UK)"}, + {"en-US", "English (US)"}, + {"de-DE", "Deutsch"}, + {"cs-CZ", "Čeština"}, + {"da-DK", "Dansk"}, + {"es-ES", "Español"}, + {"fr-FR", "Français"}, + {"it-IT", "Italiano"}, + {"nl-NL", "Nederlands"}, + {"nn-NO", "Norsk"}, + {"pl-PL", "Język polski"}, + {"pt-PT", "Português"}, + {"sv-SE", "Svenska"}, + } + installCurrencies = []string{"EUR", "USD", "GBP", "PLN", "CHF", "SEK", "DKK", "NOK", "CZK"} +) + +type installWizard struct { + step installStep + cursor int + confirmYes bool + language string + currency string + username textinput.Model + password textinput.Model +} + +type Options struct { + ProjectRoot string + Config *shop.Config + EnvConfig *shop.EnvironmentConfig + Executor executor.Executor +} + +type installProgress struct { + currentStep int + done bool + showLogs bool + spinner spinner.Model + progress progress.Model +} + +var installStepPatterns = []struct { + pattern string + label string +}{ + {"system:install", "Installing Shopware"}, + {"user:create", "Creating admin account"}, + {"messenger:setup-transports", "Setting up message transports"}, + {"sales-channel:create:storefront", "Creating storefront"}, + {"theme:change", "Compiling theme"}, + {"plugin:refresh", "Refreshing plugins"}, +} + +type Model struct { + activeTab activeTab + general GeneralModel + logs LogsModel + width int + height int + dockerMode bool + overlay overlay + overlayLines []string + projectRoot string + executor executor.Executor + dockerOutChan <-chan string + install installWizard + installProg installProgress + stopConfirmYes bool + dockerSpinner spinner.Model + dockerShowLogs bool + palette commandPalette + config *shop.Config + envConfig *shop.EnvironmentConfig +} + +type dockerAlreadyRunningMsg struct{} +type dockerNeedStartMsg struct{} +type dockerStartedMsg struct{ err error } +type dockerStoppedMsg struct{ err error } +type dockerOutputLineMsg string +type dockerOutputDoneMsg struct{} + +type shopwareInstalledMsg struct{} +type shopwareNotInstalledMsg struct{} +type shopwareInstallDoneMsg struct{ err error } + +func New(opts Options) Model { + effectiveAdminApi := opts.Config.AdminApi + if opts.EnvConfig.AdminApi != nil { + effectiveAdminApi = opts.EnvConfig.AdminApi + } + + shopURL := opts.Config.URL + if opts.EnvConfig.URL != "" { + shopURL = opts.EnvConfig.URL + } + + var username, password string + if effectiveAdminApi != nil { + username = effectiveAdminApi.Username + password = effectiveAdminApi.Password + } + + isDocker := opts.Executor.Type() == "docker" + + return Model{ + activeTab: tabGeneral, + general: NewGeneralModel(opts.Executor.Type(), shopURL, username, password, opts.ProjectRoot), + logs: NewLogsModel(opts.ProjectRoot, isDocker), + dockerMode: isDocker, + projectRoot: opts.ProjectRoot, + executor: opts.Executor, + config: opts.Config, + envConfig: opts.EnvConfig, + } +} + +func (m Model) Init() tea.Cmd { + if m.dockerMode { + return checkContainersRunning(m.projectRoot) + } + return m.checkShopwareInstalled() +} + +func (m *Model) startDashboard() tea.Cmd { + return tea.Batch( + m.general.Init(), + m.logs.StartStreaming(), + ) +} + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + m.general.SetSize(m.width, m.height-4) + m.logs.SetSize(m.width, m.height-4) + return m, nil + + case dockerAlreadyRunningMsg, dockerNeedStartMsg, dockerOutputLineMsg, + dockerOutputDoneMsg, dockerStartedMsg, dockerStoppedMsg, + shopwareInstalledMsg, shopwareNotInstalledMsg, shopwareInstallDoneMsg: + return m.updateLifecycle(msg) + + case tea.KeyPressMsg: + return m.updateKeyPress(msg) + } + + if m.overlay == overlayInstalling { + switch msg := msg.(type) { + case spinner.TickMsg: + var cmd tea.Cmd + m.installProg.spinner, cmd = m.installProg.spinner.Update(msg) + return m, cmd + case progress.FrameMsg: + var cmd tea.Cmd + m.installProg.progress, cmd = m.installProg.progress.Update(msg) + return m, cmd + } + return m, nil + } + + if m.overlay == overlayStarting || m.overlay == overlayStopping { + if msg, ok := msg.(spinner.TickMsg); ok { + var cmd tea.Cmd + m.dockerSpinner, cmd = m.dockerSpinner.Update(msg) + return m, cmd + } + return m, nil + } + + if m.overlay != overlayNone { + return m, nil + } + + return m.updateChildren(msg) +} + +func (m Model) updateChildren(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + + newGeneral, cmd := m.general.Update(msg) + m.general = newGeneral + if cmd != nil { + cmds = append(cmds, cmd) + } + + newLogs, cmd := m.logs.Update(msg) + m.logs = newLogs + if cmd != nil { + cmds = append(cmds, cmd) + } + + return m, tea.Batch(cmds...) +} diff --git a/internal/devtui/model_commands.go b/internal/devtui/model_commands.go new file mode 100644 index 00000000..102d1f34 --- /dev/null +++ b/internal/devtui/model_commands.go @@ -0,0 +1,177 @@ +package devtui + +import ( + "bufio" + "context" + "io" + "os/exec" + "strings" + + "charm.land/bubbles/v2/progress" + "charm.land/bubbles/v2/spinner" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "github.com/shopware/shopware-cli/internal/tui" +) + +func newBrandSpinner() spinner.Model { + return spinner.New( + spinner.WithSpinner(spinner.Dot), + spinner.WithStyle(lipgloss.NewStyle().Foreground(tui.BrandColor)), + ) +} + +func newInstallProgress() progress.Model { + return progress.New( + progress.WithColors(tui.BrandColor), + progress.WithWidth(tui.PhaseCardWidth-15), + progress.WithoutPercentage(), + ) +} + +func checkContainersRunning(projectRoot string) tea.Cmd { + return func() tea.Msg { + ctx := context.Background() + check := exec.CommandContext(ctx, "docker", "compose", "ps", "--status=running", "-q") + check.Dir = projectRoot + output, err := check.Output() + if err == nil && len(strings.TrimSpace(string(output))) > 0 { + return dockerAlreadyRunningMsg{} + } + return dockerNeedStartMsg{} + } +} + +func (m *Model) checkShopwareInstalled() tea.Cmd { + exec := m.executor + return func() tea.Msg { + cmd := exec.ConsoleCommand(context.Background(), "system:is-installed") + if err := cmd.Run(); err != nil { + return shopwareNotInstalledMsg{} + } + return shopwareInstalledMsg{} + } +} + +func (m *Model) runShopwareInstall() tea.Cmd { + e := m.executor + language := m.install.language + currency := m.install.currency + username := m.install.username.Value() + password := m.install.password.Value() + + ch := make(chan string, streamBufferSize) + m.dockerOutChan = ch + + doneCmd := func() tea.Msg { + withEnv := e.WithEnv(map[string]string{ + "INSTALL_LOCALE": language, + "INSTALL_CURRENCY": currency, + "INSTALL_ADMIN_USERNAME": username, + "INSTALL_ADMIN_PASSWORD": password, + }) + cmd := withEnv.PHPCommand(context.Background(), "vendor/bin/shopware-deployment-helper", "run") + + err := streamCmdOutput(cmd, ch, true) + return shopwareInstallDoneMsg{err: err} + } + + return tea.Batch(readFromChan(ch), doneCmd) +} + +func (m *Model) readNextDockerOutput() tea.Cmd { + ch := m.dockerOutChan + if ch == nil { + return nil + } + return readFromChan(ch) +} + +// streamBufferSize is the channel buffer size used for streaming command output. +const streamBufferSize = 50 + +// readFromChan returns a tea.Cmd that reads one line from ch and produces +// a dockerOutputLineMsg, or dockerOutputDoneMsg when the channel is closed. +func readFromChan(ch <-chan string) tea.Cmd { + return func() tea.Msg { + line, ok := <-ch + if !ok { + return dockerOutputDoneMsg{} + } + return dockerOutputLineMsg(line) + } +} + +// streamCmdOutput starts cmd, scans its output line by line into ch, then +// closes ch. If useStdout is true it pipes stdout (merging stderr into it); +// otherwise it pipes stderr (merging stdout into it). +// Returns the error from cmd.Wait. +func streamCmdOutput(cmd *exec.Cmd, ch chan<- string, useStdout bool) error { + var pipe io.Reader + var err error + if useStdout { + pipe, err = cmd.StdoutPipe() + if err == nil { + cmd.Stderr = cmd.Stdout + } + } else { + pipe, err = cmd.StderrPipe() + if err == nil { + cmd.Stdout = cmd.Stderr + } + } + if err != nil { + close(ch) + return err + } + + if err := cmd.Start(); err != nil { + close(ch) + return err + } + + scanner := bufio.NewScanner(pipe) + for scanner.Scan() { + ch <- scanner.Text() + } + close(ch) + + return cmd.Wait() +} + +// runDockerCommandWithArgs runs a docker compose command, streaming stderr lines +// through a channel for display, and returns a result message when done. +func runDockerCommandWithArgs(ctx context.Context, projectRoot string, args []string, resultFn func(error) tea.Msg) (outChan <-chan string, outputCmd tea.Cmd, doneCmd tea.Cmd) { + lineChan := make(chan string, streamBufferSize) + + doneCmd = func() tea.Msg { + cmd := exec.CommandContext(ctx, "docker", args...) + cmd.Dir = projectRoot + return resultFn(streamCmdOutput(cmd, lineChan, false)) + } + + return lineChan, readFromChan(lineChan), doneCmd +} + +func (m *Model) startContainers() tea.Cmd { + ch, outputCmd, doneCmd := runDockerCommandWithArgs( + context.Background(), + m.projectRoot, + []string{"compose", "up", "-d"}, + func(err error) tea.Msg { return dockerStartedMsg{err: err} }, + ) + m.dockerOutChan = ch + return tea.Batch(outputCmd, doneCmd) +} + +func (m *Model) stopContainers() tea.Cmd { + ch, outputCmd, doneCmd := runDockerCommandWithArgs( + context.Background(), + m.projectRoot, + []string{"compose", "down"}, + func(err error) tea.Msg { return dockerStoppedMsg{err: err} }, + ) + m.dockerOutChan = ch + return tea.Batch(outputCmd, doneCmd) +} diff --git a/internal/devtui/model_update.go b/internal/devtui/model_update.go new file mode 100644 index 00000000..85b98859 --- /dev/null +++ b/internal/devtui/model_update.go @@ -0,0 +1,350 @@ +package devtui + +import ( + "context" + "strings" + + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + + "github.com/shopware/shopware-cli/internal/shop" +) + +func (m Model) updateLifecycle(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case dockerAlreadyRunningMsg: + m.overlay = overlayNone + return m, m.checkShopwareInstalled() + + case dockerNeedStartMsg: + m.overlay = overlayStarting + m.overlayLines = nil + m.dockerShowLogs = false + m.dockerSpinner = newBrandSpinner() + return m, tea.Batch(m.dockerSpinner.Tick, m.startContainers()) + + case dockerOutputLineMsg: + m.overlayLines = append(m.overlayLines, string(msg)) + maxLines := m.overlayMaxLines() + if len(m.overlayLines) > maxLines { + m.overlayLines = m.overlayLines[len(m.overlayLines)-maxLines:] + } + if m.overlay == overlayInstalling { + line := string(msg) + if strings.HasPrefix(line, "Start: ") { + for i, sp := range installStepPatterns { + if strings.Contains(line, sp.pattern) && i >= m.installProg.currentStep { + m.installProg.currentStep = i + pct := float64(i) / float64(len(installStepPatterns)) + cmd := m.installProg.progress.SetPercent(pct) + return m, tea.Batch(cmd, m.readNextDockerOutput()) + } + } + } + } + return m, m.readNextDockerOutput() + + case dockerOutputDoneMsg: + return m, nil + + case dockerStartedMsg: + if msg.err != nil { + m.overlayLines = append(m.overlayLines, errorStyle.Render("Failed: "+msg.err.Error())) + m.overlayLines = append(m.overlayLines, "", helpStyle.Render("Press q to exit")) + return m, nil + } + m.overlay = overlayNone + m.overlayLines = nil + m.dockerOutChan = nil + return m, m.checkShopwareInstalled() + + case shopwareInstalledMsg: + m.overlay = overlayNone + return m, m.startDashboard() + + case shopwareNotInstalledMsg: + m.overlay = overlayInstallPrompt + m.overlayLines = nil + + usernameInput := textinput.New() + usernameInput.Placeholder = defaultUsername + usernameInput.Prompt = "Username: " + usernameInput.CharLimit = 50 + + passwordInput := textinput.New() + passwordInput.Placeholder = "shopware" + passwordInput.Prompt = "Password: " + passwordInput.CharLimit = 50 + + m.install = installWizard{step: installStepAsk, confirmYes: true, username: usernameInput, password: passwordInput} + return m, nil + + case shopwareInstallDoneMsg: + if msg.err != nil { + m.installProg.showLogs = true + m.overlayLines = append(m.overlayLines, "", errorStyle.Render("Installation failed: "+msg.err.Error())) + m.overlayLines = append(m.overlayLines, "", helpStyle.Render("Press q to exit")) + return m, nil + } + m.installProg.done = true + m.installProg.currentStep = len(installStepPatterns) + + username := m.install.username.Value() + password := m.install.password.Value() + + adminApi := &shop.ConfigAdminApi{ + Username: username, + Password: password, + } + m.envConfig.AdminApi = adminApi + _ = shop.WriteConfig(m.config, m.projectRoot) + + m.general.username = username + m.general.password = password + + m.overlay = overlayNone + m.overlayLines = nil + m.dockerOutChan = nil + return m, m.startDashboard() + + case dockerStoppedMsg: + return m, tea.Quit + } + + return m, nil +} + +func (m Model) updateKeyPress(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + if m.overlay == overlayCommandPalette { + return m.updateCommandPalette(msg) + } + + if m.overlay == overlayInstallPrompt { + return m.updateInstallPrompt(msg) + } + + if m.overlay == overlayStopConfirm { + switch msg.String() { + case "left", "h": + m.stopConfirmYes = true + case "right", "l": + m.stopConfirmYes = false + case keyTab: + m.stopConfirmYes = !m.stopConfirmYes + case keyEnter: + if m.stopConfirmYes { + m.overlay = overlayStopping + m.overlayLines = nil + m.dockerShowLogs = false + m.dockerSpinner = newBrandSpinner() + return m, tea.Batch(m.dockerSpinner.Tick, m.stopContainers()) + } + return m, tea.Quit + } + return m, nil + } + + if m.overlay == overlayStarting || m.overlay == overlayStopping { + switch msg.String() { + case "l": + m.dockerShowLogs = !m.dockerShowLogs + case keyQ, keyCtrlC: + return m, tea.Quit + } + return m, nil + } + + if m.overlay == overlayInstalling { + switch msg.String() { + case "l": + m.installProg.showLogs = !m.installProg.showLogs + case keyQ, keyCtrlC: + return m, tea.Quit + } + return m, nil + } + + if m.overlay != overlayNone { + if msg.String() == keyQ || msg.String() == keyCtrlC { + return m, tea.Quit + } + return m, nil + } + + switch msg.String() { + case "ctrl+p": + m.overlay = overlayCommandPalette + m.palette = newCommandPalette() + return m, textinput.Blink + case keyCtrlC, keyQ: + m.logs.StopStreaming() + if m.dockerMode { + m.overlay = overlayStopConfirm + m.overlayLines = nil + m.stopConfirmYes = true + return m, nil + } + return m, tea.Quit + case key1: + m.activeTab = tabGeneral + return m, nil + case key2: + m.activeTab = tabLogs + return m, nil + case keyTab, keyShiftTab: + m.activeTab = (m.activeTab + 1) % 2 + return m, nil + } + + return m.updateChildren(msg) +} + +func (m Model) updateCommandPalette(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "esc", "ctrl+p": + m.overlay = overlayNone + return m, nil + case keyUp, keyK: + m.palette.moveUp() + return m, nil + case keyDown, keyJ: + m.palette.moveDown() + return m, nil + case keyEnter: + id := m.palette.selectedID() + m.overlay = overlayNone + return m.executeCommand(id) + } + + var cmd tea.Cmd + m.palette.filter, cmd = m.palette.filter.Update(msg) + m.palette.applyFilter() + return m, cmd +} + +func (m Model) executeCommand(id string) (tea.Model, tea.Cmd) { + switch id { + case "open-shop": + return m, openInBrowser(m.general.shopURL) + case "open-admin": + return m, openInBrowser(m.general.adminURL) + case "cache-clear": + return m, m.runCacheClear() + case "tab-logs": + m.activeTab = tabLogs + case "tab-general": + m.activeTab = tabGeneral + case "quit": + m.logs.StopStreaming() + if m.dockerMode { + m.overlay = overlayStopConfirm + m.stopConfirmYes = true + return m, nil + } + return m, tea.Quit + } + return m, nil +} + +func (m *Model) runCacheClear() tea.Cmd { + e := m.executor + return func() tea.Msg { + cmd := e.ConsoleCommand(context.Background(), "cache:clear") + _ = cmd.Run() + return nil + } +} + +func (m Model) updateInstallPrompt(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case keyQ, keyCtrlC: + return m, tea.Quit + } + + switch m.install.step { + case installStepAsk: + switch msg.String() { + case "left", "h": + m.install.confirmYes = true + case "right", "l": + m.install.confirmYes = false + case keyTab: + m.install.confirmYes = !m.install.confirmYes + case keyEnter: + if m.install.confirmYes { + m.install.step = installStepLanguage + m.install.cursor = 0 + } else { + m.overlay = overlayNone + return m, m.startDashboard() + } + } + + case installStepLanguage: + switch msg.String() { + case keyUp, keyK: + if m.install.cursor > 0 { + m.install.cursor-- + } + case keyDown, keyJ: + if m.install.cursor < len(installLanguages)-1 { + m.install.cursor++ + } + case keyEnter: + m.install.language = installLanguages[m.install.cursor].id + m.install.step = installStepCurrency + m.install.cursor = 0 + } + + case installStepCurrency: + switch msg.String() { + case keyUp, keyK: + if m.install.cursor > 0 { + m.install.cursor-- + } + case keyDown, keyJ: + if m.install.cursor < len(installCurrencies)-1 { + m.install.cursor++ + } + case keyEnter: + m.install.currency = installCurrencies[m.install.cursor] + m.install.step = installStepUsername + m.install.username.SetValue(defaultUsername) + m.install.username.Focus() + return m, textinput.Blink + } + + case installStepUsername: + switch msg.String() { + case keyEnter: + m.install.step = installStepPassword + m.install.username.Blur() + m.install.password.SetValue("shopware") + m.install.password.Focus() + return m, textinput.Blink + default: + var cmd tea.Cmd + m.install.username, cmd = m.install.username.Update(msg) + return m, cmd + } + + case installStepPassword: + switch msg.String() { + case keyEnter: + m.install.password.Blur() + m.overlay = overlayInstalling + m.overlayLines = nil + m.installProg = installProgress{ + spinner: newBrandSpinner(), + progress: newInstallProgress(), + } + return m, tea.Batch(m.installProg.spinner.Tick, m.runShopwareInstall()) + default: + var cmd tea.Cmd + m.install.password, cmd = m.install.password.Update(msg) + return m, cmd + } + } + + return m, nil +} diff --git a/internal/devtui/model_view.go b/internal/devtui/model_view.go new file mode 100644 index 00000000..1fb4ea11 --- /dev/null +++ b/internal/devtui/model_view.go @@ -0,0 +1,319 @@ +package devtui + +import ( + "fmt" + "strings" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "github.com/shopware/shopware-cli/internal/tui" +) + +func (m Model) View() tea.View { + if m.width == 0 || m.height == 0 { + return tea.NewView("") + } + + v := tea.NewView("") + v.AltScreen = true + + switch m.overlay { + case overlayNone: + v.Content = m.renderDashboard() + case overlayCommandPalette: + v.Content = m.palette.view(m.width, m.height) + case overlayStarting, overlayStopConfirm, overlayStopping, overlayInstallPrompt, overlayInstalling: + v.Content = m.renderOverlay() + } + + return v +} + +func (m Model) renderDashboard() string { + tabHeader := buildTabHeader(int(m.activeTab), m.width) + footer := m.renderDashboardFooter() + + footerHeight := lipgloss.Height(footer) + boxHeight := m.height - 3 - footerHeight + + padV := 1 + padH := 3 + if m.activeTab == tabLogs { + padV = 0 + padH = 1 + } + + contentH := boxHeight - padV*2 - 1 + contentW := m.width - padH*2 - 2 + + var content string + switch m.activeTab { + case tabGeneral: + content = m.general.View(m.width, boxHeight) + case tabLogs: + m.logs.SetSize(contentW, contentH) + content = m.logs.View() + } + + contentBox := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderTop(false). + BorderForeground(tui.BorderColor). + Padding(padV, padH). + Width(m.width). + Height(boxHeight) + + return tabHeader + "\n" + contentBox.Render(content) + "\n" + footer +} + +func (m Model) renderDashboardFooter() string { + if m.activeTab == tabLogs { + followState := "Follow" + shortcuts := []tui.Shortcut{ + {Key: "↑/↓", Label: "Move cursor"}, + {Key: "enter", Label: "Open source"}, + {Key: "f", Label: followState}, + {Key: "tab", Label: "Next tab"}, + {Key: "ctrl+c", Label: "Exit"}, + } + return tui.ShortcutBar(shortcuts...) + } + + return tui.ShortcutBar( + tui.Shortcut{Key: "ctrl+p", Label: "Commands"}, + tui.Shortcut{Key: "tab", Label: "Next tab"}, + tui.Shortcut{Key: "ctrl+c", Label: "Exit"}, + ) +} + +func (m Model) renderOverlay() string { + var content strings.Builder + var footerHint string + + switch m.overlay { + case overlayStarting: + footerHint = tui.ShortcutBadge("l", "Toggle logs") + if m.dockerShowLogs { + return m.renderDockerLogs("Starting Docker containers...", footerHint) + } + cardContent := fmt.Sprintf("%s Starting Docker containers...", m.dockerSpinner.View()) + content.WriteString(tui.RenderPhaseCard(cardContent)) + case overlayStopConfirm: + var card strings.Builder + warnStyle := lipgloss.NewStyle().Bold(true).Foreground(tui.ErrorColor) + card.WriteString(warnStyle.Render("Stop Docker containers?")) + card.WriteString("\n") + card.WriteString(tui.DimStyle.Render("Do you want to stop the running Docker containers?\nThey can be restarted with shopware-cli project dev.")) + card.WriteString("\n\n") + card.WriteString(renderConfirmButtons("Yes, stop", "No, quit", m.stopConfirmYes)) + content.WriteString(tui.RenderPhaseCard(card.String())) + footerHint = tui.ShortcutBar( + tui.Shortcut{Key: "←/→", Label: "Select"}, + tui.Shortcut{Key: "enter", Label: "Confirm"}, + ) + case overlayStopping: + footerHint = tui.ShortcutBadge("l", "Toggle logs") + if m.dockerShowLogs { + return m.renderDockerLogs("Stopping Docker containers...", footerHint) + } + cardContent := fmt.Sprintf("%s Stopping Docker containers...", m.dockerSpinner.View()) + content.WriteString(tui.RenderPhaseCard(cardContent)) + case overlayInstallPrompt: + var card strings.Builder + m.renderInstallPrompt(&card) + content.WriteString(tui.RenderPhaseCard(card.String())) + footerHint = m.installFooterHint() + case overlayInstalling: + if m.installProg.showLogs { + footerHint = tui.ShortcutBadge("l", "Toggle logs") + return m.renderDockerLogs("Installing Shopware...", footerHint) + } + var card strings.Builder + total := len(installStepPatterns) + pctText := fmt.Sprintf(" %d%%", int(float64(m.installProg.currentStep)/float64(total)*100)) + card.WriteString(m.installProg.progress.View() + tui.DimStyle.Render(pctText) + "\n\n") + + for i, sp := range installStepPatterns { + switch { + case i < m.installProg.currentStep: + card.WriteString(tui.StepDone(sp.label)) + case i == m.installProg.currentStep && !m.installProg.done: + card.WriteString(tui.StepActive(m.installProg.spinner.View(), sp.label)) + case i == m.installProg.currentStep && m.installProg.done: + card.WriteString(tui.StepDone(sp.label)) + default: + card.WriteString(tui.StepPending(tui.DimStyle.Render(sp.label))) + } + } + content.WriteString(tui.RenderPhaseCard(strings.TrimRight(card.String(), "\n"))) + footerHint = tui.ShortcutBadge("l", "Toggle logs") + case overlayNone, overlayCommandPalette: + } + + return renderPhaseLayout(content.String(), m.width, m.height, footerHint) +} + +// phaseHeaderFooter builds the branding header and shortcut footer used by +// full-screen phase views, returning the rendered strings and the remaining +// box height. +func phaseHeaderFooter(width, height int, footerHint string) (header, footer string, boxHeight int) { + branding := tui.BrandingLine() + fill := width - tui.BrandingLineWidth() + if fill < 0 { + fill = 0 + } + header = strings.Repeat(" ", fill) + branding + + exit := tui.ShortcutBadge("ctrl+c", "Exit") + if footerHint != "" { + sep := lipgloss.NewStyle().Foreground(tui.BorderColor).Render(" │ ") + footer = footerHint + sep + exit + } else { + footer = exit + } + + boxHeight = height - lipgloss.Height(header) - lipgloss.Height(footer) + return header, footer, boxHeight +} + +// renderPhaseLayout renders a full-screen phase view: branding line at top, +// content centered in a bordered box, shortcut footer at bottom. +func renderPhaseLayout(content string, width, height int, footerHint string) string { + header, footer, boxHeight := phaseHeaderFooter(width, height, footerHint) + + contentBox := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(tui.BorderColor). + Padding(1, 3). + Width(width). + Height(boxHeight). + AlignVertical(lipgloss.Center). + AlignHorizontal(lipgloss.Center) + + contentWidth := lipgloss.Width(content) + normalized := lipgloss.NewStyle().Width(contentWidth).Render(content) + + return header + "\n" + contentBox.Render(normalized) + "\n" + footer +} + +// renderDockerLogs renders a full-screen log view without the mascot card. +func (m Model) renderDockerLogs(title, footerHint string) string { + header, footer, boxHeight := phaseHeaderFooter(m.width, m.height, footerHint) + + // border (2) + padding (2) + title (1) + blank (1) = 6 lines overhead + visibleLines := boxHeight - 6 + if visibleLines < 1 { + visibleLines = 1 + } + + var body strings.Builder + body.WriteString(panelHeaderStyle.Render(title)) + body.WriteString("\n\n") + + start := 0 + if len(m.overlayLines) > visibleLines { + start = len(m.overlayLines) - visibleLines + } + for _, line := range m.overlayLines[start:] { + body.WriteString(line + "\n") + } + if len(m.overlayLines) == 0 { + body.WriteString(helpStyle.Render("Waiting for command output...")) + } + + contentBox := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(tui.BorderColor). + Padding(1, 3). + Width(m.width). + Height(boxHeight) + + return header + "\n" + contentBox.Render(body.String()) + "\n" + footer +} + +// overlayMaxLines returns the maximum number of log lines that fit in the overlay. +func (m Model) overlayMaxLines() int { + if m.height <= 0 { + return 10 + } + // Account for border (2), padding (2), title (1), blank line after title (1) + const overhead = 6 + maxLines := m.height - 2 - overhead + if maxLines < 10 { + return 10 + } + return maxLines +} + +func (m Model) renderInstallPrompt(b *strings.Builder) { + switch m.install.step { + case installStepAsk: + warnStyle := lipgloss.NewStyle().Bold(true).Foreground(tui.ErrorColor) + b.WriteString(warnStyle.Render("Shopware is not installed")) + b.WriteString("\n") + b.WriteString(tui.DimStyle.Render("This project has not been set up yet. The installation\nwill create the database, run migrations and configure\nyour local development environment.")) + b.WriteString("\n\n") + b.WriteString(renderConfirmButtons("Yes, install now", "No, skip", m.install.confirmYes)) + + case installStepLanguage: + b.WriteString(tui.TextBadge("Step 1/4")) + b.WriteString("\n\n") + opts := make([]tui.SelectOption, len(installLanguages)) + for i, lang := range installLanguages { + opts[i] = tui.SelectOption{Label: lang.label, Detail: lang.id} + } + b.WriteString(tui.RenderSelectList("Default Language", "Select the primary language for your storefront", opts, m.install.cursor)) + + case installStepCurrency: + b.WriteString(tui.TextBadge("Step 2/4")) + b.WriteString("\n\n") + opts := make([]tui.SelectOption, len(installCurrencies)) + for i, curr := range installCurrencies { + opts[i] = tui.SelectOption{Label: curr} + } + b.WriteString(tui.RenderSelectList("Default Currency", "Select the default currency for pricing", opts, m.install.cursor)) + + case installStepUsername: + b.WriteString(tui.TextBadge("Step 3/4")) + b.WriteString("\n\n") + b.WriteString(tui.TitleStyle.Render("Admin Username")) + b.WriteString("\n") + b.WriteString(tui.DimStyle.Render("Enter the username for the admin account")) + b.WriteString("\n\n") + b.WriteString(m.install.username.View()) + + case installStepPassword: + b.WriteString(tui.TextBadge("Step 4/4")) + b.WriteString("\n\n") + b.WriteString(tui.TitleStyle.Render("Admin Password")) + b.WriteString("\n") + b.WriteString(tui.DimStyle.Render("Enter the password for the admin account")) + b.WriteString("\n\n") + b.WriteString(m.install.password.View()) + } +} + +func (m Model) installFooterHint() string { + switch m.install.step { + case installStepAsk: + return tui.ShortcutBar( + tui.Shortcut{Key: "←/→", Label: "Select"}, + tui.Shortcut{Key: "enter", Label: "Confirm"}, + ) + case installStepLanguage, installStepCurrency: + return tui.ShortcutBar( + tui.Shortcut{Key: "↑/↓", Label: "Select"}, + tui.Shortcut{Key: "enter", Label: "Confirm"}, + ) + case installStepUsername: + return tui.ShortcutBar( + tui.Shortcut{Key: "enter", Label: "Continue"}, + ) + case installStepPassword: + return tui.ShortcutBar( + tui.Shortcut{Key: "enter", Label: "Install"}, + ) + } + return "" +} diff --git a/internal/devtui/styles.go b/internal/devtui/styles.go new file mode 100644 index 00000000..d40b9079 --- /dev/null +++ b/internal/devtui/styles.go @@ -0,0 +1,201 @@ +package devtui + +import ( + "fmt" + "strings" + + "charm.land/lipgloss/v2" + + "github.com/shopware/shopware-cli/internal/tui" +) + +var ( + valueStyle = lipgloss.NewStyle(). + Foreground(tui.TextColor) + + urlStyle = lipgloss.NewStyle(). + Foreground(tui.LinkColor) + + secretStyle = lipgloss.NewStyle(). + Foreground(tui.WarnColor) + + helpStyle = lipgloss.NewStyle(). + Foreground(tui.MutedColor) + + activeBadgeStyle = lipgloss.NewStyle(). + Foreground(tui.SuccessColor). + Bold(true). + Padding(0, 1) + + warningBadgeStyle = lipgloss.NewStyle(). + Foreground(tui.WarnColor). + Bold(true). + Padding(0, 1) + + errorStyle = lipgloss.NewStyle(). + Foreground(tui.ErrorColor) + + sidebarStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(tui.BorderColor). + Padding(1, 1) + + sidebarItemStyle = lipgloss.NewStyle(). + Foreground(tui.MutedColor). + Padding(0, 1) + + selectedSidebarItemStyle = lipgloss.NewStyle(). + Foreground(tui.TextColor). + Background(tui.SubtleBgColor). + Bold(true). + Padding(0, 1) + + activeSidebarItemStyle = lipgloss.NewStyle(). + Foreground(tui.SuccessColor). + Padding(0, 1) + + activeSelectedSidebarItemStyle = lipgloss.NewStyle(). + Foreground(tui.TextColor). + Background(tui.SelectedBgColor). + Bold(true). + Padding(0, 1) + + contentPanelStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(tui.BorderColor). + Padding(0, 1) + + panelHeaderStyle = lipgloss.NewStyle(). + Foreground(tui.TextColor). + Bold(true). + Padding(0, 0, 1) + + activeBtnStyle = lipgloss.NewStyle(). + Foreground(tui.TextColor). + Background(tui.BrandColor). + Padding(0, 2) + + inactiveBtnStyle = lipgloss.NewStyle(). + Foreground(tui.MutedColor). + Background(tui.SubtleBgColor). + Padding(0, 2) +) + +// renderConfirmButtons renders a yes/no button pair where the active button +// is highlighted with the brand color. +func renderConfirmButtons(yesLabel, noLabel string, yesActive bool) string { + var yes, no string + if yesActive { + yes = activeBtnStyle.Render(yesLabel) + no = inactiveBtnStyle.Render(noLabel) + } else { + yes = inactiveBtnStyle.Render(yesLabel) + no = activeBtnStyle.Render(noLabel) + } + return yes + " " + no +} + +// buildTabHeader renders the tui-example-style tab header with numbered tabs +// and a right-aligned branding line. The active tab's bottom border is open +// so it flows into the content box below. +func buildTabHeader(activeTab int, width int) string { + tabWidths := make([]int, len(tabNames)) + for i, name := range tabNames { + tabWidths[i] = 8 + len(name) + } + + activeNumStyle := lipgloss.NewStyle(). + Bold(true). + Foreground(tui.TextColor). + Background(tui.BrandColor) + activeLabelStyle := lipgloss.NewStyle(). + Bold(true). + Foreground(tui.BrandColor) + inactiveNumStyle := lipgloss.NewStyle(). + Foreground(tui.MutedColor). + Background(tui.SubtleBgColor) + inactiveLabelStyle := lipgloss.NewStyle(). + Foreground(tui.MutedColor) + + bc := tui.BorderColor + bdr := func(s string) string { + return lipgloss.NewStyle().Foreground(bc).Render(s) + } + + tabAreaWidth := 1 + for _, w := range tabWidths { + tabAreaWidth += w + 1 + } + + // Row 1: tab top border + var r1 strings.Builder + r1.WriteString(bdr("╭")) + for i, w := range tabWidths { + r1.WriteString(bdr(strings.Repeat("─", w))) + if i < len(tabWidths)-1 { + r1.WriteString(bdr("┬")) + } + } + r1.WriteString(bdr("╮")) + + // Row 2: tab labels + right-aligned branding + var r2 strings.Builder + for i, name := range tabNames { + r2.WriteString(bdr("│")) + num := fmt.Sprintf(" %d ", i+1) + if i == activeTab { + r2.WriteString(" " + activeNumStyle.Render(num) + " " + activeLabelStyle.Render(name) + " ") + } else { + r2.WriteString(" " + inactiveNumStyle.Render(num) + " " + inactiveLabelStyle.Render(name) + " ") + } + } + r2.WriteString(bdr("│")) + + branding := tui.BrandingLine() + fill := width - tabAreaWidth - tui.BrandingLineWidth() + if fill < 0 { + fill = 0 + } + r2.WriteString(strings.Repeat(" ", fill) + branding) + + // Row 3: junction — active tab open bottom meets content box top border + var r3 strings.Builder + if activeTab == 0 { + r3.WriteString(bdr("│")) + } else { + r3.WriteString(bdr("├")) + } + + for i, w := range tabWidths { + if i == activeTab { + r3.WriteString(strings.Repeat(" ", w)) + } else { + r3.WriteString(bdr(strings.Repeat("─", w))) + } + + if i < len(tabWidths)-1 { + switch { + case i == activeTab: + r3.WriteString(bdr("└")) + case i+1 == activeTab: + r3.WriteString(bdr("┘")) + default: + r3.WriteString(bdr("┴")) + } + } + } + + if activeTab == len(tabWidths)-1 { + r3.WriteString(bdr("└")) + } else { + r3.WriteString(bdr("┴")) + } + + remaining := width - tabAreaWidth - 1 + if remaining > 0 { + r3.WriteString(bdr(strings.Repeat("─", remaining))) + } + r3.WriteString(bdr("╮")) + + return r1.String() + "\n" + r2.String() + "\n" + r3.String() +} diff --git a/internal/devtui/tab_general.go b/internal/devtui/tab_general.go new file mode 100644 index 00000000..212938c4 --- /dev/null +++ b/internal/devtui/tab_general.go @@ -0,0 +1,242 @@ +package devtui + +import ( + "context" + "encoding/json" + "fmt" + "os/exec" + "strings" + + tea "charm.land/bubbletea/v2" + + "github.com/shopware/shopware-cli/internal/tui" +) + +type GeneralModel struct { + envType string + shopURL string + adminURL string + username string + password string + services []discoveredService + projectRoot string + loading bool + err error + width int + height int +} + +type discoveredService struct { + Name string + URL string + Username string + Password string +} + +type servicesLoadedMsg struct { + services []discoveredService + err error +} + +type knownService struct { + Name string + TargetPort int + Username string + Password string +} + +var knownServices = map[string]knownService{ + "adminer": {Name: "Adminer", TargetPort: 8080, Username: "root", Password: "root"}, + "mailer": {Name: "Mailpit", TargetPort: 8025}, + "lavinmq": {Name: "Queue (LavinMQ)", TargetPort: 15672, Username: "guest", Password: "guest"}, + "rabbitmq": {Name: "Queue (RabbitMQ)", TargetPort: 15672, Username: "guest", Password: "guest"}, +} + +var ignoredServices = map[string]bool{ + "web": true, + "database": true, +} + +func NewGeneralModel(envType, shopURL, username, password, projectRoot string) GeneralModel { + adminURL := shopURL + if adminURL != "" && !strings.HasSuffix(adminURL, "/") { + adminURL += "/" + } + adminURL += "admin" + + return GeneralModel{ + envType: envType, + shopURL: shopURL, + adminURL: adminURL, + username: username, + password: password, + projectRoot: projectRoot, + loading: true, + } +} + +func (m GeneralModel) Init() tea.Cmd { + return discoverServices(m.projectRoot) +} + +func (m *GeneralModel) SetSize(width, height int) { + m.width = width + m.height = height +} + +type browserOpenedMsg struct{} + +func (m GeneralModel) Update(msg tea.Msg) (GeneralModel, tea.Cmd) { + if msg, ok := msg.(servicesLoadedMsg); ok { + m.loading = false + m.services = msg.services + m.err = msg.err + } + return m, nil +} + +func openInBrowser(url string) tea.Cmd { + return func() tea.Msg { + _ = exec.CommandContext(context.Background(), "open", url).Start() + return browserOpenedMsg{} + } +} + +func (m GeneralModel) View(width, height int) string { + var s strings.Builder + + divider := tui.SectionDivider(width - 8) + + s.WriteString(tui.TitleStyle.Render("Shop")) + s.WriteString("\n") + s.WriteString(tui.KVRow("Environment", activeBadgeStyle.Render(m.envType))) + s.WriteString(tui.KVRow("Shop URL", urlStyle.Render(m.shopURL))) + s.WriteString(tui.KVRow("Admin URL", urlStyle.Render(m.adminURL))) + + s.WriteString(divider) + + s.WriteString(tui.TitleStyle.Render("Admin Access")) + s.WriteString("\n") + if m.username == "" && m.password == "" { + s.WriteString(" " + helpStyle.Render("Credentials will appear here once Shopware is installed.") + "\n") + } else { + s.WriteString(tui.KVRow("Username", valueStyle.Render(m.username))) + s.WriteString(tui.KVRow("Password", secretStyle.Render(m.password))) + } + + s.WriteString(divider) + + s.WriteString(tui.TitleStyle.Render("Services")) + s.WriteString("\n") + s.WriteString(m.renderServices()) + + return s.String() +} + +func (m GeneralModel) renderServices() string { + switch { + case m.loading: + return " " + tui.StatusBadge("scanning", tui.BrandColor) + "\n" + + " " + helpStyle.Render("Looking for published local services.") + "\n" + case m.err != nil: + return " " + tui.StatusBadge("failed", tui.ErrorColor) + "\n" + + " " + errorStyle.Render(m.err.Error()) + "\n" + case len(m.services) == 0: + return " " + helpStyle.Render("No auxiliary services detected.") + "\n" + } + + var s strings.Builder + for _, service := range m.services { + s.WriteString(tui.KVRow(service.Name, urlStyle.Render(service.URL))) + if service.Username != "" { + s.WriteString(tui.KVRow(" Username", valueStyle.Render(service.Username))) + s.WriteString(tui.KVRow(" Password", secretStyle.Render(service.Password))) + } + } + return s.String() +} + +// dockerComposePSOutput represents a single container from `docker compose ps --format json`. +type dockerComposePSOutput struct { + Name string `json:"Name"` + Service string `json:"Service"` + State string `json:"State"` + Publishers []struct { + URL string `json:"URL"` + TargetPort int `json:"TargetPort"` + PublishedPort int `json:"PublishedPort"` + Protocol string `json:"Protocol"` + } `json:"Publishers"` +} + +func discoverServices(projectRoot string) tea.Cmd { + return func() tea.Msg { + ctx := context.Background() + cmd := exec.CommandContext(ctx, "docker", "compose", "ps", "--format", "json") + cmd.Dir = projectRoot + output, err := cmd.Output() + if err != nil { + return servicesLoadedMsg{err: fmt.Errorf("docker compose ps: %w", err)} + } + + var services []discoveredService + + // Collect all containers with their published ports + type containerInfo struct { + service string + publishers map[int]int // targetPort -> publishedPort + } + var containers []containerInfo + + for _, line := range strings.Split(strings.TrimSpace(string(output)), "\n") { + if line == "" { + continue + } + + var container dockerComposePSOutput + if err := json.Unmarshal([]byte(line), &container); err != nil { + continue + } + + ports := make(map[int]int) + for _, pub := range container.Publishers { + if pub.PublishedPort != 0 { + ports[pub.TargetPort] = pub.PublishedPort + } + } + + if len(ports) > 0 { + containers = append(containers, containerInfo{ + service: container.Service, + publishers: ports, + }) + } + } + + // Match containers against known services or skip ignored ones + for _, c := range containers { + if ignoredServices[c.service] { + continue + } + + known, ok := knownServices[c.service] + if !ok { + continue + } + + publishedPort, hasPort := c.publishers[known.TargetPort] + if !hasPort { + continue + } + + services = append(services, discoveredService{ + Name: known.Name, + URL: fmt.Sprintf("http://127.0.0.1:%d", publishedPort), + Username: known.Username, + Password: known.Password, + }) + } + + return servicesLoadedMsg{services: services} + } +} diff --git a/internal/devtui/tab_general_test.go b/internal/devtui/tab_general_test.go new file mode 100644 index 00000000..f5a44c93 --- /dev/null +++ b/internal/devtui/tab_general_test.go @@ -0,0 +1,91 @@ +package devtui + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNewGeneralModel(t *testing.T) { + m := NewGeneralModel("docker", "http://localhost:8000", "admin", "shopware", "/tmp/project") + + assert.Equal(t, "docker", m.envType) + assert.Equal(t, "http://localhost:8000", m.shopURL) + assert.Equal(t, "http://localhost:8000/admin", m.adminURL) + assert.Equal(t, "admin", m.username) + assert.Equal(t, "shopware", m.password) + assert.Equal(t, "/tmp/project", m.projectRoot) + assert.True(t, m.loading) +} + +func TestNewGeneralModel_AdminURLTrailingSlash(t *testing.T) { + m := NewGeneralModel("local", "http://localhost:8000/", "", "", "/tmp/project") + + assert.Equal(t, "http://localhost:8000/admin", m.adminURL) +} + +func TestNewGeneralModel_EmptyURL(t *testing.T) { + m := NewGeneralModel("local", "", "", "", "/tmp/project") + + assert.Equal(t, "admin", m.adminURL) +} + +func TestServicesLoadedMsg(t *testing.T) { + m := NewGeneralModel("docker", "http://localhost:8000", "", "", "/tmp/project") + + services := []discoveredService{ + {Name: "Adminer", URL: "http://127.0.0.1:9080", Username: "root", Password: "root"}, + {Name: "Shopware", URL: "http://localhost:8000"}, + } + + updated, cmd := m.Update(servicesLoadedMsg{services: services}) + assert.Nil(t, cmd) + assert.False(t, updated.loading) + assert.Nil(t, updated.err) + assert.Len(t, updated.services, 2) + assert.Equal(t, "Adminer", updated.services[0].Name) + assert.Equal(t, "root", updated.services[0].Username) + assert.Equal(t, "Shopware", updated.services[1].Name) +} + +func TestServicesLoadedMsg_WithError(t *testing.T) { + m := NewGeneralModel("docker", "http://localhost:8000", "", "", "/tmp/project") + + updated, _ := m.Update(servicesLoadedMsg{err: assert.AnError}) + assert.False(t, updated.loading) + assert.Error(t, updated.err) + assert.Empty(t, updated.services) +} + +func TestKnownServices(t *testing.T) { + adminer := knownServices["adminer"] + assert.Equal(t, "Adminer", adminer.Name) + assert.Equal(t, 8080, adminer.TargetPort) + assert.Equal(t, "root", adminer.Username) + + mailer := knownServices["mailer"] + assert.Equal(t, "Mailpit", mailer.Name) + assert.Equal(t, 8025, mailer.TargetPort) + + lavinmq := knownServices["lavinmq"] + assert.Equal(t, "Queue (LavinMQ)", lavinmq.Name) + assert.Equal(t, 15672, lavinmq.TargetPort) + assert.Equal(t, "guest", lavinmq.Username) + + rabbitmq := knownServices["rabbitmq"] + assert.Equal(t, "Queue (RabbitMQ)", rabbitmq.Name) + assert.Equal(t, 15672, rabbitmq.TargetPort) +} + +func TestViewShowsCredentials(t *testing.T) { + m := NewGeneralModel("docker", "http://localhost:8000", "", "", "/tmp/project") + m.loading = false + m.services = []discoveredService{ + {Name: "Adminer", URL: "http://127.0.0.1:9080", Username: "root", Password: "root"}, + } + + view := m.View(120, 40) + assert.Contains(t, view, "Adminer") + assert.Contains(t, view, "http://127.0.0.1:9080") + assert.Contains(t, view, "root") +} diff --git a/internal/devtui/tab_logs.go b/internal/devtui/tab_logs.go new file mode 100644 index 00000000..b0ed85cd --- /dev/null +++ b/internal/devtui/tab_logs.go @@ -0,0 +1,370 @@ +package devtui + +import ( + "bufio" + "context" + "os" + "os/exec" + "path/filepath" + "strings" + + "charm.land/bubbles/v2/viewport" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "github.com/shopware/shopware-cli/internal/tui" +) + +type logSource struct { + name string + container string // non-empty for docker containers + filePath string // non-empty for log files +} + +type LogsModel struct { + viewport viewport.Model + sources []logSource + cursor int + active int // index of currently streaming source + lines []string + follow bool + cancel context.CancelFunc + logChan <-chan string + projectRoot string + dockerMode bool + width int + height int +} + +type logLineMsg string +type logDoneMsg struct{} +type logErrMsg struct{ err error } +type logSourcesLoadedMsg struct{ sources []logSource } + +const sidebarWidth = 28 + +func NewLogsModel(projectRoot string, dockerMode bool) LogsModel { + return LogsModel{ + projectRoot: projectRoot, + dockerMode: dockerMode, + follow: true, + active: -1, + } +} + +func (m LogsModel) Init() tea.Cmd { + return nil +} + +func (m LogsModel) Update(msg tea.Msg) (LogsModel, tea.Cmd) { + switch msg := msg.(type) { + case logSourcesLoadedMsg: + m.sources = msg.sources + if len(m.sources) > 0 && m.active == -1 { + m.active = 0 + m.cursor = 0 + return m, m.startCurrentSource() + } + return m, nil + + case logLineMsg: + m.lines = append(m.lines, string(msg)) + m.viewport.SetContent(strings.Join(m.lines, "\n")) + if m.follow { + m.viewport.GotoBottom() + } + return m, m.waitForNextLine() + + case logDoneMsg: + m.lines = append(m.lines, helpStyle.Render("--- log stream ended ---")) + m.viewport.SetContent(strings.Join(m.lines, "\n")) + return m, nil + + case logErrMsg: + m.lines = append(m.lines, errorStyle.Render("Log stream error: "+msg.err.Error())) + m.viewport.SetContent(strings.Join(m.lines, "\n")) + return m, nil + + case tea.KeyPressMsg: + switch msg.String() { + case keyUp, keyK: + if m.cursor > 0 { + m.cursor-- + } + return m, nil + case keyDown, keyJ: + if m.cursor < len(m.sources)-1 { + m.cursor++ + } + return m, nil + case keyEnter: + if m.cursor != m.active && m.cursor < len(m.sources) { + m.stopStreaming() + m.active = m.cursor + m.lines = nil + m.follow = true + m.viewport.SetContent("") + m.viewport.GotoTop() + return m, m.startCurrentSource() + } + return m, nil + case keyF: + m.follow = !m.follow + if m.follow { + m.viewport.GotoBottom() + } + return m, nil + } + } + + var cmd tea.Cmd + m.viewport, cmd = m.viewport.Update(msg) + + if !m.viewport.AtBottom() { + m.follow = false + } + + return m, cmd +} + +func (m LogsModel) View() string { + return lipgloss.JoinHorizontal(lipgloss.Top, m.renderSidebar(), m.renderContent()) +} + +func (m LogsModel) renderSidebar() string { + var b strings.Builder + b.WriteString(tui.TitleStyle.Render("Sources")) + b.WriteString("\n\n") + + for i, src := range m.sources { + item := src.name + if i == m.active { + item = lipgloss.JoinHorizontal( + lipgloss.Center, + item, + " ", + activeBadgeStyle.Render("LIVE"), + ) + } + + style := sidebarItemStyle + switch { + case i == m.cursor && m.cursor == m.active: + style = activeSelectedSidebarItemStyle + case i == m.cursor: + style = selectedSidebarItemStyle + case i == m.active: + style = activeSidebarItemStyle + } + + b.WriteString(style.Width(sidebarWidth - 4).Render(item)) + b.WriteString("\n") + } + + if len(m.sources) == 0 { + b.WriteString(helpStyle.Render("No log sources found yet.")) + } + + return sidebarStyle. + Width(sidebarWidth). + Height(max(m.height-3, 8)). + Render(b.String()) +} + +func (m LogsModel) renderContent() string { + sourceName := "No source selected" + if m.active >= 0 && m.active < len(m.sources) { + sourceName = m.sources[m.active].name + } + + followBadge := warningBadgeStyle.Render("FOLLOW OFF") + if m.follow { + followBadge = activeBadgeStyle.Render("FOLLOW ON") + } + + headerText := lipgloss.NewStyle(). + Foreground(tui.TextColor). + Bold(true). + Render(sourceName) + + header := headerText + " " + followBadge + + return contentPanelStyle.Render( + lipgloss.JoinVertical( + lipgloss.Left, + header, + "", + m.viewport.View(), + ), + ) +} + +func (m *LogsModel) SetSize(width, height int) { + m.width = width + m.height = height + viewportWidth := max(width-sidebarWidth-8, 20) + m.viewport.SetWidth(viewportWidth) + m.viewport.SetHeight(max(height-7, 8)) +} + +func (m *LogsModel) StartStreaming() tea.Cmd { + return m.discoverSources() +} + +func (m *LogsModel) StopStreaming() { + m.stopStreaming() +} + +func (m *LogsModel) stopStreaming() { + if m.cancel != nil { + m.cancel() + m.cancel = nil + } + m.logChan = nil +} + +func (m *LogsModel) startCurrentSource() tea.Cmd { + if m.active < 0 || m.active >= len(m.sources) { + return nil + } + + src := m.sources[m.active] + + if src.container != "" { + return m.streamContainer(src.container) + } + + return m.streamFile(src.filePath) +} + +func (m *LogsModel) streamContainer(container string) tea.Cmd { + ctx, cancel := context.WithCancel(context.Background()) + m.cancel = cancel + + cmd := exec.CommandContext(ctx, "docker", "compose", "logs", "-f", "--tail=100", container) + cmd.Dir = m.projectRoot + + return m.streamCommand(ctx, cmd, true) +} + +func (m *LogsModel) streamFile(filePath string) tea.Cmd { + ctx, cancel := context.WithCancel(context.Background()) + m.cancel = cancel + + cmd := exec.CommandContext(ctx, "tail", "-n", "100", "-f", filePath) + + return m.streamCommand(ctx, cmd, false) +} + +// streamCommand runs cmd in a goroutine, scanning its stdout into a channel. +// If mergeStderr is true, stderr is merged into stdout. +func (m *LogsModel) streamCommand(ctx context.Context, cmd *exec.Cmd, mergeStderr bool) tea.Cmd { + ch := make(chan string, 100) + m.logChan = ch + + go func() { + defer close(ch) + + stdout, err := cmd.StdoutPipe() + if err != nil { + return + } + if mergeStderr { + cmd.Stderr = cmd.Stdout + } + + if err := cmd.Start(); err != nil { + return + } + + scanner := bufio.NewScanner(stdout) + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + for scanner.Scan() { + select { + case <-ctx.Done(): + return + case ch <- scanner.Text(): + } + } + + _ = cmd.Wait() + }() + + return m.waitForNextLine() +} + +func (m *LogsModel) waitForNextLine() tea.Cmd { + ch := m.logChan + if ch == nil { + return nil + } + return func() tea.Msg { + line, ok := <-ch + if !ok { + return logDoneMsg{} + } + return logLineMsg(line) + } +} + +func (m *LogsModel) discoverSources() tea.Cmd { + projectRoot := m.projectRoot + dockerMode := m.dockerMode + return func() tea.Msg { + var sources []logSource + + if dockerMode { + sources = append(sources, discoverContainers(projectRoot)...) + } + + sources = append(sources, discoverLogFiles(projectRoot)...) + + return logSourcesLoadedMsg{sources: sources} + } +} + +func discoverContainers(projectRoot string) []logSource { + ctx := context.Background() + cmd := exec.CommandContext(ctx, "docker", "compose", "ps", "--format", "{{.Service}}") + cmd.Dir = projectRoot + output, err := cmd.Output() + if err != nil { + return nil + } + + var sources []logSource + for _, line := range strings.Split(strings.TrimSpace(string(output)), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + sources = append(sources, logSource{ + name: line, + container: line, + }) + } + return sources +} + +func discoverLogFiles(projectRoot string) []logSource { + logDir := filepath.Join(projectRoot, "var", "log") + entries, err := os.ReadDir(logDir) + if err != nil { + return nil + } + + var sources []logSource + for _, entry := range entries { + if entry.IsDir() { + continue + } + if !strings.HasSuffix(entry.Name(), ".log") { + continue + } + sources = append(sources, logSource{ + name: entry.Name(), + filePath: filepath.Join(logDir, entry.Name()), + }) + } + return sources +} diff --git a/internal/docker/compose.go b/internal/docker/compose.go new file mode 100644 index 00000000..2e9a59bc --- /dev/null +++ b/internal/docker/compose.go @@ -0,0 +1,281 @@ +package docker + +import ( + "fmt" + "os" + "path/filepath" + + "gopkg.in/yaml.v3" + + "github.com/shopware/shopware-cli/internal/packagist" + "github.com/shopware/shopware-cli/internal/shop" +) + +// ComposeOptions holds configuration for generating the compose file. +type ComposeOptions struct { + PHPVersion string + NodeVersion string + PHPProfiler string + BlackfireServerID string + BlackfireServerToken string + TidewaysAPIKey string +} + +func (o *ComposeOptions) phpVersion() string { + if o != nil && o.PHPVersion != "" { + return o.PHPVersion + } + return "8.3" +} + +func (o *ComposeOptions) nodeVersion() string { + if o != nil && o.NodeVersion != "" { + return o.NodeVersion + } + return "22" +} + +// ComposeOptionsFromConfig creates ComposeOptions from a shop.Config. +func ComposeOptionsFromConfig(cfg *shop.Config) *ComposeOptions { + if cfg == nil || cfg.Docker == nil { + return nil + } + opts := &ComposeOptions{} + if cfg.Docker.PHP != nil { + opts.PHPVersion = cfg.Docker.PHP.Version + opts.PHPProfiler = cfg.Docker.PHP.Profiler + opts.BlackfireServerID = cfg.Docker.PHP.BlackfireServerID + opts.BlackfireServerToken = cfg.Docker.PHP.BlackfireServerToken + opts.TidewaysAPIKey = cfg.Docker.PHP.TidewaysAPIKey + } + if cfg.Docker.Node != nil { + opts.NodeVersion = cfg.Docker.Node.Version + } + return opts +} + +func GenerateComposeFile(lock *packagist.ComposerLock, opts *ComposeOptions) ([]byte, error) { + hasAMQP := lock.GetPackage("symfony/amqp-messenger") != nil + hasElasticsearch := lock.GetPackage("shopware/elasticsearch") != nil + + doc := buildCompose(hasAMQP, hasElasticsearch, opts) + + out, err := yaml.Marshal(&doc) + if err != nil { + return nil, err + } + + header := "# This file is managed by shopware-cli. Do not edit manually.\n" + + "# Create a compose.override.yaml to customize services.\n" + + "# See https://docs.docker.com/compose/how-tos/multiple-compose-files/merge/\n\n" + + return append([]byte(header), out...), nil +} + +func WriteComposeFile(projectFolder string, opts *ComposeOptions) error { + lock, err := packagist.ReadComposerLock(filepath.Join(projectFolder, "composer.lock")) + if err != nil { + return fmt.Errorf("failed to read composer.lock: %w", err) + } + + composeBytes, err := GenerateComposeFile(lock, opts) + if err != nil { + return fmt.Errorf("failed to generate compose.yaml: %w", err) + } + + return os.WriteFile(filepath.Join(projectFolder, "compose.yaml"), composeBytes, os.ModePerm) +} + +func buildCompose(hasAMQP, hasElasticsearch bool, opts *ComposeOptions) yaml.Node { + webEnv := newMappingNode() + addKeyValue(webEnv, "HOST", "0.0.0.0") + addKeyValue(webEnv, "DATABASE_URL", "mysql://root:root@database/shopware") + addKeyValue(webEnv, "MAILER_DSN", "smtp://mailer:1025") + addKeyValue(webEnv, "TRUSTED_PROXIES", "REMOTE_ADDR") + addKeyValue(webEnv, "SYMFONY_TRUSTED_PROXIES", "REMOTE_ADDR") + + if hasAMQP { + addKeyValue(webEnv, "MESSENGER_TRANSPORT_DSN", "amqp://guest:guest@lavinmq:5672") + } + + if hasElasticsearch { + addKeyValue(webEnv, "OPENSEARCH_URL", "http://opensearch:9200") + addKeyValue(webEnv, "SHOPWARE_ES_ENABLED", "1") + addKeyValue(webEnv, "SHOPWARE_ES_INDEXING_ENABLED", "1") + addKeyValue(webEnv, "SHOPWARE_ES_INDEX_PREFIX", "sw") + } + + webDependsOn := newMappingNode() + dbCondition := newMappingNode() + addKeyValue(dbCondition, "condition", "service_healthy") + addKeyValueNode(webDependsOn, "database", dbCondition) + + if opts != nil && opts.PHPProfiler != "" { + addKeyValue(webEnv, "PHP_PROFILER", opts.PHPProfiler) + switch opts.PHPProfiler { + case "xdebug": + addKeyValue(webEnv, "XDEBUG_MODE", "debug") + addKeyValue(webEnv, "XDEBUG_CONFIG", "client_host=host.docker.internal") + case "tideways": + if opts.TidewaysAPIKey != "" { + addKeyValue(webEnv, "TIDEWAYS_APIKEY", opts.TidewaysAPIKey) + } + } + } + + web := newMappingNode() + addKeyValue(web, "image", fmt.Sprintf("ghcr.io/shopware/docker-dev:php%s-node%s-caddy", opts.phpVersion(), opts.nodeVersion())) + addKeyValueNode(web, "ports", newSequenceNode( + "8000:8000", "8080:8080", "9999:9999", "9998:9998", "5173:5173", "5773:5773", + )) + addKeyValueNode(web, "env_file", newSequenceNode(".env.local")) + addKeyValueNode(web, "environment", webEnv) + addKeyValueNode(web, "volumes", newSequenceNode(".:/var/www/html")) + addKeyValueNode(web, "depends_on", webDependsOn) + + dbEnv := newMappingNode() + addKeyValue(dbEnv, "MARIADB_DATABASE", "shopware") + addKeyValue(dbEnv, "MARIADB_ROOT_PASSWORD", "root") + addKeyValue(dbEnv, "MARIADB_USER", "shopware") + addKeyValue(dbEnv, "MARIADB_PASSWORD", "shopware") + + healthTest := newSequenceNode("CMD", "mariadb-admin", "ping", "-h", "localhost", "-proot") + + healthcheck := newMappingNode() + addKeyValueNode(healthcheck, "test", healthTest) + addKeyValue(healthcheck, "start_period", "10s") + addKeyValue(healthcheck, "start_interval", "3s") + addKeyValue(healthcheck, "interval", "5s") + addKeyValue(healthcheck, "timeout", "1s") + addKeyValueNode(healthcheck, "retries", &yaml.Node{Kind: yaml.ScalarNode, Value: "10", Tag: "!!int"}) + + database := newMappingNode() + addKeyValue(database, "image", "mariadb:11.8") + addKeyValueNode(database, "environment", dbEnv) + addKeyValueNode(database, "volumes", newSequenceNode("db-data:/var/lib/mysql:rw")) + addKeyValueNode(database, "command", newSequenceNode( + "--sql_mode=STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION", + "--log_bin_trust_function_creators=1", + "--binlog_cache_size=16M", + "--key_buffer_size=0", + "--join_buffer_size=1024M", + "--innodb_log_file_size=128M", + "--innodb_buffer_pool_size=1024M", + "--innodb_buffer_pool_instances=1", + "--group_concat_max_len=320000", + "--default-time-zone=+00:00", + "--max_binlog_size=512M", + "--binlog_expire_logs_seconds=86400", + )) + addKeyValueNode(database, "healthcheck", healthcheck) + + adminerEnv := newMappingNode() + addKeyValue(adminerEnv, "ADMINER_DEFAULT_SERVER", "database") + + adminer := newMappingNode() + addKeyValue(adminer, "image", "adminer") + addKeyValue(adminer, "stop_signal", "SIGKILL") + addKeyValueNode(adminer, "depends_on", newSequenceNode("database")) + addKeyValueNode(adminer, "environment", adminerEnv) + addKeyValueNode(adminer, "ports", newSequenceNode("9080:8080")) + + mailerEnv := newMappingNode() + addKeyValue(mailerEnv, "MP_SMTP_AUTH_ACCEPT_ANY", "1") + addKeyValue(mailerEnv, "MP_SMTP_AUTH_ALLOW_INSECURE", "1") + + mailer := newMappingNode() + addKeyValue(mailer, "image", "axllent/mailpit") + addKeyValueNode(mailer, "ports", newSequenceNode("1025:1025", "8025:8025")) + addKeyValueNode(mailer, "environment", mailerEnv) + + services := newMappingNode() + addKeyValueNode(services, "web", web) + addKeyValueNode(services, "database", database) + addKeyValueNode(services, "adminer", adminer) + addKeyValueNode(services, "mailer", mailer) + + volumes := newMappingNode() + addKeyValueNode(volumes, "db-data", newNullNode()) + + if hasAMQP { + lavinmq := newMappingNode() + addKeyValue(lavinmq, "image", "cloudamqp/lavinmq") + addKeyValueNode(lavinmq, "ports", newSequenceNode("15672:15672", "5672:5672")) + addKeyValueNode(lavinmq, "volumes", newSequenceNode("lavinmq-data:/var/lib/lavinmq:rw")) + addKeyValueNode(services, "lavinmq", lavinmq) + addKeyValueNode(volumes, "lavinmq-data", newNullNode()) + } + + if hasElasticsearch { + osEnv := newMappingNode() + addKeyValue(osEnv, "OPENSEARCH_INITIAL_ADMIN_PASSWORD", "Shopware123!") + addKeyValue(osEnv, "discovery.type", "single-node") + addKeyValue(osEnv, "plugins.security.disabled", "true") + + opensearch := newMappingNode() + addKeyValue(opensearch, "image", "opensearchproject/opensearch:2") + addKeyValueNode(opensearch, "environment", osEnv) + addKeyValueNode(opensearch, "ports", newSequenceNode("9200:9200")) + addKeyValueNode(opensearch, "volumes", newSequenceNode("opensearch-data:/usr/share/opensearch/data")) + addKeyValueNode(services, "opensearch", opensearch) + addKeyValueNode(volumes, "opensearch-data", newNullNode()) + } + + if opts != nil && opts.PHPProfiler == "blackfire" && opts.BlackfireServerID != "" && opts.BlackfireServerToken != "" { + bfEnv := newMappingNode() + addKeyValue(bfEnv, "BLACKFIRE_SERVER_ID", opts.BlackfireServerID) + addKeyValue(bfEnv, "BLACKFIRE_SERVER_TOKEN", opts.BlackfireServerToken) + + blackfire := newMappingNode() + addKeyValue(blackfire, "image", "blackfire/blackfire:2") + addKeyValueNode(blackfire, "environment", bfEnv) + addKeyValueNode(services, "blackfire", blackfire) + } + + if opts != nil && opts.PHPProfiler == "tideways" && opts.TidewaysAPIKey != "" { + tideways := newMappingNode() + addKeyValue(tideways, "image", "ghcr.io/tideways/daemon") + addKeyValueNode(services, "tideways-daemon", tideways) + } + + root := newMappingNode() + addKeyValueNode(root, "services", services) + addKeyValueNode(root, "volumes", volumes) + + return yaml.Node{ + Kind: yaml.DocumentNode, + Content: []*yaml.Node{root}, + } +} + +// YAML node helpers to preserve insertion order. + +func newMappingNode() *yaml.Node { + return &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"} +} + +func newSequenceNode(values ...string) *yaml.Node { + seq := &yaml.Node{Kind: yaml.SequenceNode, Tag: "!!seq"} + for _, v := range values { + seq.Content = append(seq.Content, &yaml.Node{Kind: yaml.ScalarNode, Value: v, Tag: "!!str"}) + } + return seq +} + +func newNullNode() *yaml.Node { + return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!null"} +} + +func addKeyValue(m *yaml.Node, key, value string) { + m.Content = append(m.Content, + &yaml.Node{Kind: yaml.ScalarNode, Value: key, Tag: "!!str"}, + &yaml.Node{Kind: yaml.ScalarNode, Value: value, Tag: "!!str"}, + ) +} + +func addKeyValueNode(m *yaml.Node, key string, value *yaml.Node) { + m.Content = append(m.Content, + &yaml.Node{Kind: yaml.ScalarNode, Value: key, Tag: "!!str"}, + value, + ) +} diff --git a/internal/docker/compose_test.go b/internal/docker/compose_test.go new file mode 100644 index 00000000..5042d275 --- /dev/null +++ b/internal/docker/compose_test.go @@ -0,0 +1,256 @@ +package docker + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/shopware/shopware-cli/internal/packagist" +) + +func TestGenerateComposeFile(t *testing.T) { + t.Parallel() + + t.Run("base only", func(t *testing.T) { + t.Parallel() + lock := &packagist.ComposerLock{ + Packages: []packagist.ComposerLockPackage{ + {Name: "shopware/core", Version: "6.6.0.0"}, + }, + } + + result, err := GenerateComposeFile(lock, nil) + assert.NoError(t, err) + + compose := string(result) + assert.Contains(t, compose, "web:") + assert.Contains(t, compose, "database:") + assert.Contains(t, compose, "adminer:") + assert.Contains(t, compose, "mailer:") + assert.Contains(t, compose, "db-data:") + assert.Contains(t, compose, "ghcr.io/shopware/docker-dev:php8.3-node22-caddy") + assert.Contains(t, compose, "mariadb:11.8") + assert.Contains(t, compose, "mailpit") + assert.NotContains(t, compose, "lavinmq") + assert.NotContains(t, compose, "opensearch") + assert.NotContains(t, compose, "MESSENGER_TRANSPORT_DSN") + assert.NotContains(t, compose, "OPENSEARCH_URL") + assert.NotContains(t, compose, "PHP_PROFILER") + }) + + t.Run("with amqp", func(t *testing.T) { + t.Parallel() + lock := &packagist.ComposerLock{ + Packages: []packagist.ComposerLockPackage{ + {Name: "shopware/core", Version: "6.6.0.0"}, + {Name: "symfony/amqp-messenger", Version: "v7.0.0"}, + }, + } + + result, err := GenerateComposeFile(lock, nil) + assert.NoError(t, err) + + compose := string(result) + assert.Contains(t, compose, "lavinmq:") + assert.Contains(t, compose, "cloudamqp/lavinmq") + assert.Contains(t, compose, "lavinmq-data:") + assert.Contains(t, compose, "MESSENGER_TRANSPORT_DSN") + assert.Contains(t, compose, "15672:15672") + assert.Contains(t, compose, "5672:5672") + assert.NotContains(t, compose, "opensearch") + }) + + t.Run("with elasticsearch", func(t *testing.T) { + t.Parallel() + lock := &packagist.ComposerLock{ + Packages: []packagist.ComposerLockPackage{ + {Name: "shopware/core", Version: "6.6.0.0"}, + {Name: "shopware/elasticsearch", Version: "6.6.0.0"}, + }, + } + + result, err := GenerateComposeFile(lock, nil) + assert.NoError(t, err) + + compose := string(result) + assert.Contains(t, compose, "opensearch:") + assert.Contains(t, compose, "opensearchproject/opensearch:2") + assert.Contains(t, compose, "opensearch-data:") + assert.Contains(t, compose, "OPENSEARCH_URL") + assert.Contains(t, compose, "SHOPWARE_ES_ENABLED") + assert.Contains(t, compose, "9200:9200") + assert.NotContains(t, compose, "lavinmq") + }) + + t.Run("custom php version", func(t *testing.T) { + t.Parallel() + lock := &packagist.ComposerLock{ + Packages: []packagist.ComposerLockPackage{ + {Name: "shopware/core", Version: "6.6.0.0"}, + }, + } + + result, err := GenerateComposeFile(lock, &ComposeOptions{PHPVersion: "8.2"}) + assert.NoError(t, err) + + compose := string(result) + assert.Contains(t, compose, "ghcr.io/shopware/docker-dev:php8.2-node22-caddy") + assert.NotContains(t, compose, "php8.3") + }) + + t.Run("custom node version", func(t *testing.T) { + t.Parallel() + lock := &packagist.ComposerLock{ + Packages: []packagist.ComposerLockPackage{ + {Name: "shopware/core", Version: "6.6.0.0"}, + }, + } + + result, err := GenerateComposeFile(lock, &ComposeOptions{NodeVersion: "24"}) + assert.NoError(t, err) + + compose := string(result) + assert.Contains(t, compose, "ghcr.io/shopware/docker-dev:php8.3-node24-caddy") + assert.NotContains(t, compose, "node22") + }) + + t.Run("with php profiler", func(t *testing.T) { + t.Parallel() + lock := &packagist.ComposerLock{ + Packages: []packagist.ComposerLockPackage{ + {Name: "shopware/core", Version: "6.6.0.0"}, + }, + } + + result, err := GenerateComposeFile(lock, &ComposeOptions{PHPProfiler: "xdebug"}) + assert.NoError(t, err) + + compose := string(result) + assert.Contains(t, compose, "PHP_PROFILER: xdebug") + assert.Contains(t, compose, "XDEBUG_MODE: debug") + assert.Contains(t, compose, "XDEBUG_CONFIG: client_host=host.docker.internal") + }) + + t.Run("with blackfire profiler and credentials", func(t *testing.T) { + t.Parallel() + lock := &packagist.ComposerLock{ + Packages: []packagist.ComposerLockPackage{ + {Name: "shopware/core", Version: "6.6.0.0"}, + }, + } + + result, err := GenerateComposeFile(lock, &ComposeOptions{ + PHPProfiler: "blackfire", + BlackfireServerID: "my-server-id", + BlackfireServerToken: "my-server-token", + }) + assert.NoError(t, err) + + compose := string(result) + assert.Contains(t, compose, "PHP_PROFILER: blackfire") + assert.Contains(t, compose, "blackfire:") + assert.Contains(t, compose, "blackfire/blackfire:2") + assert.Contains(t, compose, "BLACKFIRE_SERVER_ID: my-server-id") + assert.Contains(t, compose, "BLACKFIRE_SERVER_TOKEN: my-server-token") + assert.NotContains(t, compose, "XDEBUG_MODE") + assert.NotContains(t, compose, "XDEBUG_CONFIG") + }) + + t.Run("blackfire without credentials skips container", func(t *testing.T) { + t.Parallel() + lock := &packagist.ComposerLock{ + Packages: []packagist.ComposerLockPackage{ + {Name: "shopware/core", Version: "6.6.0.0"}, + }, + } + + result, err := GenerateComposeFile(lock, &ComposeOptions{PHPProfiler: "blackfire"}) + assert.NoError(t, err) + + compose := string(result) + assert.Contains(t, compose, "PHP_PROFILER: blackfire") + assert.NotContains(t, compose, "blackfire/blackfire:2") + assert.NotContains(t, compose, "BLACKFIRE_SERVER_ID") + }) + + t.Run("with tideways profiler and api key", func(t *testing.T) { + t.Parallel() + lock := &packagist.ComposerLock{ + Packages: []packagist.ComposerLockPackage{ + {Name: "shopware/core", Version: "6.6.0.0"}, + }, + } + + result, err := GenerateComposeFile(lock, &ComposeOptions{ + PHPProfiler: "tideways", + TidewaysAPIKey: "my-api-key", + }) + assert.NoError(t, err) + + compose := string(result) + assert.Contains(t, compose, "PHP_PROFILER: tideways") + assert.Contains(t, compose, "TIDEWAYS_APIKEY: my-api-key") + assert.Contains(t, compose, "tideways-daemon:") + assert.Contains(t, compose, "ghcr.io/tideways/daemon") + assert.NotContains(t, compose, "XDEBUG_MODE") + assert.NotContains(t, compose, "blackfire/blackfire") + }) + + t.Run("tideways without api key skips container", func(t *testing.T) { + t.Parallel() + lock := &packagist.ComposerLock{ + Packages: []packagist.ComposerLockPackage{ + {Name: "shopware/core", Version: "6.6.0.0"}, + }, + } + + result, err := GenerateComposeFile(lock, &ComposeOptions{PHPProfiler: "tideways"}) + assert.NoError(t, err) + + compose := string(result) + assert.Contains(t, compose, "PHP_PROFILER: tideways") + assert.NotContains(t, compose, "ghcr.io/tideways/daemon") + assert.NotContains(t, compose, "TIDEWAYS_APIKEY") + }) + + t.Run("without php profiler", func(t *testing.T) { + t.Parallel() + lock := &packagist.ComposerLock{ + Packages: []packagist.ComposerLockPackage{ + {Name: "shopware/core", Version: "6.6.0.0"}, + }, + } + + result, err := GenerateComposeFile(lock, nil) + assert.NoError(t, err) + + compose := string(result) + assert.NotContains(t, compose, "PHP_PROFILER") + assert.NotContains(t, compose, "XDEBUG_MODE") + assert.NotContains(t, compose, "XDEBUG_CONFIG") + }) + + t.Run("with all optional services", func(t *testing.T) { + t.Parallel() + lock := &packagist.ComposerLock{ + Packages: []packagist.ComposerLockPackage{ + {Name: "shopware/core", Version: "6.6.0.0"}, + {Name: "symfony/amqp-messenger", Version: "v7.0.0"}, + {Name: "shopware/elasticsearch", Version: "6.6.0.0"}, + }, + } + + result, err := GenerateComposeFile(lock, nil) + assert.NoError(t, err) + + compose := string(result) + assert.Contains(t, compose, "web:") + assert.Contains(t, compose, "database:") + assert.Contains(t, compose, "adminer:") + assert.Contains(t, compose, "mailer:") + assert.Contains(t, compose, "lavinmq:") + assert.Contains(t, compose, "opensearch:") + assert.Contains(t, compose, "MESSENGER_TRANSPORT_DSN") + assert.Contains(t, compose, "OPENSEARCH_URL") + }) +} diff --git a/internal/executor/docker.go b/internal/executor/docker.go new file mode 100644 index 00000000..81abd901 --- /dev/null +++ b/internal/executor/docker.go @@ -0,0 +1,116 @@ +package executor + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + + "github.com/mattn/go-isatty" +) + +// DockerExecutor runs commands via docker compose exec against the "web" service. +type DockerExecutor struct { + env map[string]string + projectRoot string + relDir string +} + +func (d *DockerExecutor) ConsoleCommand(ctx context.Context, args ...string) *exec.Cmd { + dockerArgs := d.baseArgs() + dockerArgs = append(dockerArgs, "env-bridge", "php", consoleCommandName(ctx)) + dockerArgs = append(dockerArgs, args...) + + cmd := exec.CommandContext(ctx, "docker", dockerArgs...) + applyDir(d.projectRoot, cmd) + logCmd(ctx, cmd) + return cmd +} + +func (d *DockerExecutor) ComposerCommand(ctx context.Context, args ...string) *exec.Cmd { + dockerArgs := d.baseArgs() + dockerArgs = append(dockerArgs, "composer") + dockerArgs = append(dockerArgs, args...) + + cmd := exec.CommandContext(ctx, "docker", dockerArgs...) + applyDir(d.projectRoot, cmd) + logCmd(ctx, cmd) + return cmd +} + +func (d *DockerExecutor) PHPCommand(ctx context.Context, args ...string) *exec.Cmd { + dockerArgs := d.baseArgs() + dockerArgs = append(dockerArgs, "env-bridge", "php") + dockerArgs = append(dockerArgs, args...) + + cmd := exec.CommandContext(ctx, "docker", dockerArgs...) + applyDir(d.projectRoot, cmd) + logCmd(ctx, cmd) + return cmd +} + +func (d *DockerExecutor) NPMCommand(ctx context.Context, args ...string) *exec.Cmd { + dockerArgs := d.baseArgs() + dockerArgs = append(dockerArgs, "env-bridge", "npm") + dockerArgs = append(dockerArgs, args...) + + cmd := exec.CommandContext(ctx, "docker", dockerArgs...) + applyDir(d.projectRoot, cmd) + logCmd(ctx, cmd) + return cmd +} + +func (d *DockerExecutor) NormalizePath(hostPath string) string { + if d.projectRoot == "" { + return hostPath + } + + rel, err := filepath.Rel(d.projectRoot, hostPath) + if err != nil { + return hostPath + } + + return filepath.Join("/var/www/html", rel) +} + +func (d *DockerExecutor) Type() string { + return "docker" +} + +func (d *DockerExecutor) WithEnv(env map[string]string) Executor { + return &DockerExecutor{env: env, projectRoot: d.projectRoot, relDir: d.relDir} +} + +func (d *DockerExecutor) WithRelDir(relDir string) Executor { + return &DockerExecutor{env: d.env, projectRoot: d.projectRoot, relDir: relDir} +} + +// containerWorkdir returns the container-side working directory. +func (d *DockerExecutor) containerWorkdir() string { + if d.relDir == "" { + return "/var/www/html" + } + + return filepath.Join("/var/www/html", d.relDir) +} + +func (d *DockerExecutor) baseArgs() []string { + args := []string{"compose", "exec"} + + if !isatty.IsTerminal(os.Stdin.Fd()) { + args = append(args, "-T") + } + + for k, v := range d.env { + args = append(args, "-e", fmt.Sprintf("%s=%s", k, v)) + } + + args = append(args, "-e", fmt.Sprintf("PROJECT_ROOT=%s", "/var/www/html")) + + args = append(args, "--workdir", d.containerWorkdir()) + + args = append(args, "web") + + return args +} diff --git a/internal/executor/executor.go b/internal/executor/executor.go new file mode 100644 index 00000000..1183280f --- /dev/null +++ b/internal/executor/executor.go @@ -0,0 +1,73 @@ +package executor + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + + "github.com/shopware/shopware-cli/logging" +) + +// Executor abstracts command execution across different environment types. +type Executor interface { + ConsoleCommand(ctx context.Context, args ...string) *exec.Cmd + ComposerCommand(ctx context.Context, args ...string) *exec.Cmd + PHPCommand(ctx context.Context, args ...string) *exec.Cmd + NPMCommand(ctx context.Context, args ...string) *exec.Cmd + // NormalizePath converts a host-absolute path to the path seen by the execution + // environment. For local executors the path is returned unchanged; for Docker it + // is translated to the container mount (e.g. /var/www/html/...). + NormalizePath(hostPath string) string + Type() string + WithEnv(env map[string]string) Executor + WithRelDir(relDir string) Executor +} + +type allowBinCIKey struct{} + +// AllowBinCI marks a context so that ConsoleCommand may use bin/ci instead of bin/console in CI environments. +func AllowBinCI(ctx context.Context) context.Context { + return context.WithValue(ctx, allowBinCIKey{}, true) +} + +// IsBinCIAllowed returns true if the context has AllowBinCI set and the CI env var is detected. +func IsBinCIAllowed(ctx context.Context) bool { + _, ok := ctx.Value(allowBinCIKey{}).(bool) + return ok && isCI() +} + +var isCI = sync.OnceValue(func() bool { + return os.Getenv("CI") != "" +}) + +// consoleCommandName returns "bin/ci" or "bin/console" depending on context and CI detection. +func consoleCommandName(ctx context.Context) string { + if IsBinCIAllowed(ctx) { + return "bin/ci" + } + return "bin/console" +} + +// resolveDir returns the absolute directory from projectRoot and relDir. +func resolveDir(projectRoot, relDir string) string { + if relDir == "" { + return projectRoot + } + + return filepath.Join(projectRoot, relDir) +} + +// applyDir sets the working directory on a command if dir is non-empty. +func applyDir(dir string, cmd *exec.Cmd) { + if dir != "" { + cmd.Dir = dir + } +} + +// logCmd logs the command that will be executed at debug level. +func logCmd(ctx context.Context, cmd *exec.Cmd) { + logging.FromContext(ctx).Debugf("exec: %s (dir: %s)", strings.Join(cmd.Args, " "), cmd.Dir) +} diff --git a/internal/executor/executor_test.go b/internal/executor/executor_test.go new file mode 100644 index 00000000..b6a02674 --- /dev/null +++ b/internal/executor/executor_test.go @@ -0,0 +1,293 @@ +package executor + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/shopware/shopware-cli/internal/shop" +) + +func TestNewLocalExecutor(t *testing.T) { + t.Setenv("SHOPWARE_CLI_NO_SYMFONY_CLI", "1") + + cfg := &shop.EnvironmentConfig{Type: "local"} + + exec, err := New("/project", cfg, &shop.Config{}) + assert.NoError(t, err) + assert.Equal(t, "local", exec.Type()) +} + +func TestNewLocalExecutorEmptyType(t *testing.T) { + t.Setenv("SHOPWARE_CLI_NO_SYMFONY_CLI", "1") + + cfg := &shop.EnvironmentConfig{Type: ""} + + exec, err := New("/project", cfg, &shop.Config{}) + assert.NoError(t, err) + assert.Equal(t, "local", exec.Type()) +} + +func TestNewDockerExecutor(t *testing.T) { + cfg := &shop.EnvironmentConfig{Type: "docker"} + + exec, err := New("/project", cfg, &shop.Config{}) + assert.NoError(t, err) + assert.Equal(t, "docker", exec.Type()) +} + +func TestNewUnsupportedType(t *testing.T) { + cfg := &shop.EnvironmentConfig{Type: "unknown"} + + _, err := New("/project", cfg, &shop.Config{}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "unsupported environment type: unknown") +} + +func TestLocalExecutorConsoleCommand(t *testing.T) { + exec := &LocalExecutor{projectRoot: "/project"} + + cmd := exec.ConsoleCommand(t.Context(), "cache:clear") + assert.Equal(t, []string{"php", "bin/console", "cache:clear"}, cmd.Args) + assert.Equal(t, "/project", cmd.Dir) +} + +func TestLocalExecutorComposerCommand(t *testing.T) { + exec := &LocalExecutor{projectRoot: "/project"} + + cmd := exec.ComposerCommand(t.Context(), "install") + assert.Equal(t, []string{"composer", "install"}, cmd.Args) + assert.Equal(t, "/project", cmd.Dir) +} + +func TestLocalExecutorPHPCommand(t *testing.T) { + exec := &LocalExecutor{projectRoot: "/project"} + + cmd := exec.PHPCommand(t.Context(), "-v") + assert.Equal(t, []string{"php", "-v"}, cmd.Args) + assert.Equal(t, "/project", cmd.Dir) +} + +func TestSymfonyCLIExecutorConsoleCommand(t *testing.T) { + exec := &SymfonyCLIExecutor{BinaryPath: "/usr/local/bin/symfony", projectRoot: "/project"} + + cmd := exec.ConsoleCommand(t.Context(), "cache:clear") + assert.Equal(t, []string{"/usr/local/bin/symfony", "php", "bin/console", "cache:clear"}, cmd.Args) + assert.Equal(t, "/project", cmd.Dir) +} + +func TestSymfonyCLIExecutorComposerCommand(t *testing.T) { + exec := &SymfonyCLIExecutor{BinaryPath: "/usr/local/bin/symfony", projectRoot: "/project"} + + cmd := exec.ComposerCommand(t.Context(), "install") + assert.Equal(t, []string{"/usr/local/bin/symfony", "composer", "install"}, cmd.Args) +} + +func TestSymfonyCLIExecutorPHPCommand(t *testing.T) { + exec := &SymfonyCLIExecutor{BinaryPath: "/usr/local/bin/symfony", projectRoot: "/project"} + + cmd := exec.PHPCommand(t.Context(), "-v") + assert.Equal(t, []string{"/usr/local/bin/symfony", "php", "-v"}, cmd.Args) +} + +func TestDockerExecutorConsoleCommand(t *testing.T) { + exec := &DockerExecutor{projectRoot: "/project"} + + cmd := exec.ConsoleCommand(t.Context(), "cache:clear") + assert.Contains(t, cmd.Path, "docker") + assert.Contains(t, cmd.Args, "compose") + assert.Contains(t, cmd.Args, "exec") + assert.Contains(t, cmd.Args, "web") + assert.Contains(t, cmd.Args, "php") + assert.Contains(t, cmd.Args, "bin/console") + assert.Contains(t, cmd.Args, "cache:clear") + assert.Equal(t, "/project", cmd.Dir) + assert.Contains(t, cmd.Args, "--workdir") + assert.Contains(t, cmd.Args, "/var/www/html") +} + +func TestDockerExecutorComposerCommand(t *testing.T) { + exec := &DockerExecutor{projectRoot: "/project"} + + cmd := exec.ComposerCommand(t.Context(), "install", "--no-interaction") + assert.Contains(t, cmd.Path, "docker") + assert.Contains(t, cmd.Args, "compose") + assert.Contains(t, cmd.Args, "exec") + assert.Contains(t, cmd.Args, "web") + assert.Contains(t, cmd.Args, "composer") + assert.Contains(t, cmd.Args, "install") + assert.Contains(t, cmd.Args, "--no-interaction") +} + +func TestDockerExecutorPHPCommand(t *testing.T) { + exec := &DockerExecutor{projectRoot: "/project"} + + cmd := exec.PHPCommand(t.Context(), "-v") + assert.Contains(t, cmd.Path, "docker") + assert.Contains(t, cmd.Args, "compose") + assert.Contains(t, cmd.Args, "exec") + assert.Contains(t, cmd.Args, "web") + assert.Contains(t, cmd.Args, "php") + assert.Contains(t, cmd.Args, "-v") +} + +func TestConsoleCommandNameDefault(t *testing.T) { + assert.Equal(t, "bin/console", consoleCommandName(t.Context())) +} + +func TestConsoleCommandNameWithAllowBinCI(t *testing.T) { + t.Setenv("CI", "true") + + ctx := AllowBinCI(t.Context()) + assert.Equal(t, "bin/ci", consoleCommandName(ctx)) +} + +func TestLocalExecutorWithEnv(t *testing.T) { + exec := &LocalExecutor{projectRoot: "/project"} + withEnv := exec.WithEnv(map[string]string{ + "INSTALL_LOCALE": "de-DE", + "INSTALL_CURRENCY": "EUR", + }) + + cmd := withEnv.PHPCommand(t.Context(), "vendor/bin/shopware-deployment-helper", "run") + assert.Contains(t, cmd.Env, "INSTALL_LOCALE=de-DE") + assert.Contains(t, cmd.Env, "INSTALL_CURRENCY=EUR") +} + +func TestLocalExecutorWithoutEnv(t *testing.T) { + exec := &LocalExecutor{projectRoot: "/project"} + + cmd := exec.PHPCommand(t.Context(), "-v") + assert.NotNil(t, cmd.Env) + assert.Contains(t, cmd.Env, "PROJECT_ROOT=/project") +} + +func TestDockerExecutorWithEnv(t *testing.T) { + exec := &DockerExecutor{projectRoot: "/project"} + withEnv := exec.WithEnv(map[string]string{ + "INSTALL_LOCALE": "en-GB", + }) + + cmd := withEnv.PHPCommand(t.Context(), "vendor/bin/shopware-deployment-helper", "run") + assert.Contains(t, cmd.Args, "-e") + assert.Contains(t, cmd.Args, "INSTALL_LOCALE=en-GB") +} + +func TestSymfonyCLIExecutorWithEnv(t *testing.T) { + exec := &SymfonyCLIExecutor{BinaryPath: "/usr/local/bin/symfony", projectRoot: "/project"} + withEnv := exec.WithEnv(map[string]string{ + "INSTALL_LOCALE": "de-DE", + }) + + cmd := withEnv.PHPCommand(t.Context(), "-v") + assert.Contains(t, cmd.Env, "INSTALL_LOCALE=de-DE") +} + +func TestLocalExecutorNPMCommand(t *testing.T) { + exec := &LocalExecutor{projectRoot: "/project"} + + cmd := exec.NPMCommand(t.Context(), "run", "dev") + assert.Equal(t, []string{"npm", "run", "dev"}, cmd.Args) + assert.Equal(t, "/project", cmd.Dir) +} + +func TestDockerExecutorNPMCommand(t *testing.T) { + exec := &DockerExecutor{projectRoot: "/project"} + + cmd := exec.NPMCommand(t.Context(), "run", "dev") + assert.Contains(t, cmd.Args, "compose") + assert.Contains(t, cmd.Args, "exec") + assert.Contains(t, cmd.Args, "web") + assert.Contains(t, cmd.Args, "npm") + assert.Contains(t, cmd.Args, "run") + assert.Contains(t, cmd.Args, "dev") +} + +func TestSymfonyCLIExecutorNPMCommand(t *testing.T) { + exec := &SymfonyCLIExecutor{BinaryPath: "/usr/local/bin/symfony", projectRoot: "/project"} + + cmd := exec.NPMCommand(t.Context(), "run", "dev") + assert.Equal(t, []string{"npm", "run", "dev"}, cmd.Args) +} + +func TestLocalExecutorWithRelDir(t *testing.T) { + exec := &LocalExecutor{projectRoot: "/project"} + withDir := exec.WithRelDir("vendor/shopware/administration/Resources/app/administration") + + cmd := withDir.ConsoleCommand(t.Context(), "cache:clear") + assert.Equal(t, "/project/vendor/shopware/administration/Resources/app/administration", cmd.Dir) + + cmd = withDir.NPMCommand(t.Context(), "run", "dev") + assert.Equal(t, "/project/vendor/shopware/administration/Resources/app/administration", cmd.Dir) +} + +func TestDockerExecutorWithRelDir(t *testing.T) { + exec := &DockerExecutor{projectRoot: "/project"} + + cmd := exec.ConsoleCommand(t.Context(), "cache:clear") + assert.Equal(t, "/project", cmd.Dir) + assert.Contains(t, cmd.Args, "--workdir") + assert.Contains(t, cmd.Args, "/var/www/html") + + withDir := exec.WithRelDir("vendor/shopware/administration/Resources/app/administration") + + cmd = withDir.NPMCommand(t.Context(), "run", "dev") + assert.Equal(t, "/project", cmd.Dir) + assert.Contains(t, cmd.Args, "--workdir") + assert.Contains(t, cmd.Args, "/var/www/html/vendor/shopware/administration/Resources/app/administration") +} + +func TestSymfonyCLIExecutorWithRelDir(t *testing.T) { + exec := &SymfonyCLIExecutor{BinaryPath: "/usr/local/bin/symfony", projectRoot: "/project"} + withDir := exec.WithRelDir("vendor/shopware/administration/Resources/app/administration") + + cmd := withDir.ConsoleCommand(t.Context(), "cache:clear") + assert.Equal(t, "/project/vendor/shopware/administration/Resources/app/administration", cmd.Dir) + + cmd = withDir.NPMCommand(t.Context(), "run", "dev") + assert.Equal(t, "/project/vendor/shopware/administration/Resources/app/administration", cmd.Dir) +} + +func TestWithRelDirPreservesEnv(t *testing.T) { + exec := &LocalExecutor{projectRoot: "/project"} + withEnv := exec.WithEnv(map[string]string{"FOO": "bar"}) + withDirAndEnv := withEnv.WithRelDir("subdir") + + cmd := withDirAndEnv.PHPCommand(t.Context(), "-v") + assert.Equal(t, "/project/subdir", cmd.Dir) + assert.Contains(t, cmd.Env, "FOO=bar") +} + +func TestWithEnvPreservesRelDir(t *testing.T) { + exec := &LocalExecutor{projectRoot: "/project"} + withDir := exec.WithRelDir("subdir") + withDirAndEnv := withDir.WithEnv(map[string]string{"FOO": "bar"}) + + cmd := withDirAndEnv.PHPCommand(t.Context(), "-v") + assert.Equal(t, "/project/subdir", cmd.Dir) + assert.Contains(t, cmd.Env, "FOO=bar") +} + +func TestNewLocal(t *testing.T) { + exec := NewLocal("/my/project") + + cmd := exec.NPMCommand(t.Context(), "install") + assert.Equal(t, "/my/project", cmd.Dir) + assert.Equal(t, []string{"npm", "install"}, cmd.Args) +} + +func TestLocalNormalizePath(t *testing.T) { + exec := &LocalExecutor{projectRoot: "/host/project"} + assert.Equal(t, "/host/project/custom/plugins/MyPlugin", exec.NormalizePath("/host/project/custom/plugins/MyPlugin")) +} + +func TestDockerNormalizePath(t *testing.T) { + exec := &DockerExecutor{projectRoot: "/host/project"} + assert.Equal(t, "/var/www/html/custom/plugins/MyPlugin", exec.NormalizePath("/host/project/custom/plugins/MyPlugin")) + assert.Equal(t, "/var/www/html", exec.NormalizePath("/host/project")) +} + +func TestSymfonyCLINormalizePath(t *testing.T) { + exec := &SymfonyCLIExecutor{BinaryPath: "/usr/local/bin/symfony", projectRoot: "/host/project"} + assert.Equal(t, "/host/project/custom/plugins/MyPlugin", exec.NormalizePath("/host/project/custom/plugins/MyPlugin")) +} diff --git a/internal/executor/factory.go b/internal/executor/factory.go new file mode 100644 index 00000000..ec493bf2 --- /dev/null +++ b/internal/executor/factory.go @@ -0,0 +1,50 @@ +package executor + +import ( + "fmt" + "os" + "os/exec" + "sync" + + "github.com/shopware/shopware-cli/internal/shop" +) + +// New creates an Executor for the given environment, shop configuration, and project root directory. +func New(projectRoot string, cfg *shop.EnvironmentConfig, shopCfg *shop.Config) (Executor, error) { + switch cfg.Type { + case "local", "": + if shopCfg.IsCompatibilityDateBefore(shop.CompatibilityDevMode) { + if path := pathToSymfonyCLI(); path != "" && symfonyCliAllowed() { + return &SymfonyCLIExecutor{BinaryPath: path, projectRoot: projectRoot}, nil + } + } + return &LocalExecutor{projectRoot: projectRoot}, nil + case "symfony-cli": + path := pathToSymfonyCLI() + if path == "" { + return nil, fmt.Errorf("symfony CLI not found in PATH") + } + return &SymfonyCLIExecutor{BinaryPath: path, projectRoot: projectRoot}, nil + case "docker": + return &DockerExecutor{projectRoot: projectRoot}, nil + default: + return nil, fmt.Errorf("unsupported environment type: %s", cfg.Type) + } +} + +// NewLocal creates a LocalExecutor with the given project root directory. +func NewLocal(projectRoot string) Executor { + return &LocalExecutor{projectRoot: projectRoot} +} + +var pathToSymfonyCLI = sync.OnceValue(func() string { + path, err := exec.LookPath("symfony") + if err != nil { + return "" + } + return path +}) + +func symfonyCliAllowed() bool { + return os.Getenv("SHOPWARE_CLI_NO_SYMFONY_CLI") != "1" +} diff --git a/internal/executor/local.go b/internal/executor/local.go new file mode 100644 index 00000000..6ba1f339 --- /dev/null +++ b/internal/executor/local.go @@ -0,0 +1,78 @@ +package executor + +import ( + "context" + "fmt" + "os" + "os/exec" +) + +// LocalExecutor runs commands using the local PHP installation directly. +type LocalExecutor struct { + env map[string]string + projectRoot string + relDir string +} + +func (l *LocalExecutor) ConsoleCommand(ctx context.Context, args ...string) *exec.Cmd { + cmdArgs := []string{consoleCommandName(ctx)} + cmdArgs = append(cmdArgs, args...) + cmd := exec.CommandContext(ctx, "php", cmdArgs...) + applyLocalEnv(l.projectRoot, l.env, cmd) + applyDir(resolveDir(l.projectRoot, l.relDir), cmd) + logCmd(ctx, cmd) + return cmd +} + +func (l *LocalExecutor) ComposerCommand(ctx context.Context, args ...string) *exec.Cmd { + cmd := exec.CommandContext(ctx, "composer", args...) + applyLocalEnv(l.projectRoot, l.env, cmd) + applyDir(resolveDir(l.projectRoot, l.relDir), cmd) + logCmd(ctx, cmd) + return cmd +} + +func (l *LocalExecutor) PHPCommand(ctx context.Context, args ...string) *exec.Cmd { + cmd := exec.CommandContext(ctx, "php", args...) + applyLocalEnv(l.projectRoot, l.env, cmd) + applyDir(resolveDir(l.projectRoot, l.relDir), cmd) + logCmd(ctx, cmd) + return cmd +} + +func (l *LocalExecutor) NPMCommand(ctx context.Context, args ...string) *exec.Cmd { + cmd := exec.CommandContext(ctx, "npm", args...) + applyLocalEnv(l.projectRoot, l.env, cmd) + applyDir(resolveDir(l.projectRoot, l.relDir), cmd) + logCmd(ctx, cmd) + return cmd +} + +func (l *LocalExecutor) NormalizePath(hostPath string) string { + return hostPath +} + +func (l *LocalExecutor) Type() string { + return "local" +} + +func (l *LocalExecutor) WithEnv(env map[string]string) Executor { + return &LocalExecutor{env: env, projectRoot: l.projectRoot, relDir: l.relDir} +} + +func (l *LocalExecutor) WithRelDir(relDir string) Executor { + return &LocalExecutor{env: l.env, projectRoot: l.projectRoot, relDir: relDir} +} + +// applyLocalEnv sets PROJECT_ROOT and extra environment variables on a local command. +func applyLocalEnv(projectRoot string, env map[string]string, cmd *exec.Cmd) { + cmd.Env = os.Environ() + + if projectRoot != "" { + cmd.Env = append(cmd.Env, fmt.Sprintf("PROJECT_ROOT=%s", projectRoot)) + } + + for k, v := range env { + cmd.Env = append(cmd.Env, fmt.Sprintf("%s=%s", k, v)) + } +} diff --git a/internal/executor/symfony_cli.go b/internal/executor/symfony_cli.go new file mode 100644 index 00000000..b825f9f9 --- /dev/null +++ b/internal/executor/symfony_cli.go @@ -0,0 +1,68 @@ +package executor + +import ( + "context" + "os/exec" +) + +// SymfonyCLIExecutor runs commands through the Symfony CLI binary. +type SymfonyCLIExecutor struct { + BinaryPath string + env map[string]string + projectRoot string + relDir string +} + +func (s *SymfonyCLIExecutor) ConsoleCommand(ctx context.Context, args ...string) *exec.Cmd { + cmdArgs := []string{"php", consoleCommandName(ctx)} + cmdArgs = append(cmdArgs, args...) + cmd := exec.CommandContext(ctx, s.BinaryPath, cmdArgs...) + applyLocalEnv(s.projectRoot, s.env, cmd) + applyDir(resolveDir(s.projectRoot, s.relDir), cmd) + logCmd(ctx, cmd) + return cmd +} + +func (s *SymfonyCLIExecutor) ComposerCommand(ctx context.Context, args ...string) *exec.Cmd { + cmdArgs := []string{"composer"} + cmdArgs = append(cmdArgs, args...) + cmd := exec.CommandContext(ctx, s.BinaryPath, cmdArgs...) + applyLocalEnv(s.projectRoot, s.env, cmd) + applyDir(resolveDir(s.projectRoot, s.relDir), cmd) + logCmd(ctx, cmd) + return cmd +} + +func (s *SymfonyCLIExecutor) PHPCommand(ctx context.Context, args ...string) *exec.Cmd { + cmdArgs := []string{"php"} + cmdArgs = append(cmdArgs, args...) + cmd := exec.CommandContext(ctx, s.BinaryPath, cmdArgs...) + applyLocalEnv(s.projectRoot, s.env, cmd) + applyDir(resolveDir(s.projectRoot, s.relDir), cmd) + logCmd(ctx, cmd) + return cmd +} + +func (s *SymfonyCLIExecutor) NPMCommand(ctx context.Context, args ...string) *exec.Cmd { + cmd := exec.CommandContext(ctx, "npm", args...) + applyLocalEnv(s.projectRoot, s.env, cmd) + applyDir(resolveDir(s.projectRoot, s.relDir), cmd) + logCmd(ctx, cmd) + return cmd +} + +func (s *SymfonyCLIExecutor) NormalizePath(hostPath string) string { + return hostPath +} + +func (s *SymfonyCLIExecutor) Type() string { + return "symfony-cli" +} + +func (s *SymfonyCLIExecutor) WithEnv(env map[string]string) Executor { + return &SymfonyCLIExecutor{BinaryPath: s.BinaryPath, env: env, projectRoot: s.projectRoot, relDir: s.relDir} +} + +func (s *SymfonyCLIExecutor) WithRelDir(relDir string) Executor { + return &SymfonyCLIExecutor{BinaryPath: s.BinaryPath, env: s.env, projectRoot: s.projectRoot, relDir: relDir} +} diff --git a/internal/extension/asset_config.go b/internal/extension/asset_config.go index a6238812..5a55fd15 100644 --- a/internal/extension/asset_config.go +++ b/internal/extension/asset_config.go @@ -17,6 +17,7 @@ import ( "github.com/shopware/shopware-cli/internal/asset" "github.com/shopware/shopware-cli/internal/esbuild" + "github.com/shopware/shopware-cli/internal/executor" "github.com/shopware/shopware-cli/logging" ) @@ -45,6 +46,17 @@ type AssetBuildConfig struct { ForceExtensionBuild []string ForceAdminBuild bool KeepNodeModules []string + Executor executor.Executor +} + +// ExecutorWithRelDir returns the configured executor with the given relative dir, +// or a local executor if none is configured. +func (c AssetBuildConfig) ExecutorWithRelDir(relDir string) executor.Executor { + if c.Executor != nil { + return c.Executor.WithRelDir(relDir) + } + + return executor.NewLocal(filepath.Join(c.ShopwareRoot, relDir)) } type ExtensionAssetConfig map[string]*ExtensionAssetConfigEntry diff --git a/internal/extension/asset_platform.go b/internal/extension/asset_platform.go index f7d7ef2e..cfc71621 100644 --- a/internal/extension/asset_platform.go +++ b/internal/extension/asset_platform.go @@ -5,7 +5,6 @@ import ( "encoding/json" "fmt" "os" - "os/exec" "path" "path/filepath" "slices" @@ -55,7 +54,7 @@ func BuildAssetsForExtensions(ctx context.Context, sources []asset.Source, asset nodeInstallSection := ci.Default.Section(ctx, "Installing node_modules for extensions") - paths, err := InstallNodeModulesOfConfigs(ctx, cfgs, assetConfig.NPMForceInstall) + paths, err := InstallNodeModulesOfConfigs(ctx, cfgs, assetConfig) if err != nil { return err } @@ -132,6 +131,7 @@ func BuildAssetsForExtensions(ctx context.Context, sources []asset.Source, asset } administrationRoot := PlatformPath(shopwareRoot, "Administration", "Resources/app/administration") + adminRelPath := PlatformRelPath(shopwareRoot, "Administration", "Resources/app/administration") if assetConfig.NPMForceInstall || !npm.NodeModulesExists(administrationRoot) { var additionalNpmParameters []string @@ -145,26 +145,29 @@ func BuildAssetsForExtensions(ctx context.Context, sources []asset.Source, asset additionalNpmParameters = []string{"--production"} } - if err := npm.InstallDependencies(ctx, administrationRoot, npmPackage, additionalNpmParameters...); err != nil { + if err := npm.InstallDependencies(ctx, assetConfig.ExecutorWithRelDir(adminRelPath), npmPackage, additionalNpmParameters...); err != nil { return err } } - envList := []string{fmt.Sprintf("PROJECT_ROOT=%s", shopwareRoot), fmt.Sprintf("ADMIN_ROOT=%s", PlatformPath(shopwareRoot, "Administration", ""))} + envMap := map[string]string{ + "PROJECT_ROOT": shopwareRoot, + "ADMIN_ROOT": PlatformPath(shopwareRoot, "Administration", ""), + } if !projectRequiresBuild(shopwareRoot) && !assetConfig.ForceAdminBuild { logging.FromContext(ctx).Debugf("Building only administration assets for plugins") - envList = append(envList, "SHOPWARE_ADMIN_BUILD_ONLY_EXTENSIONS=1", "SHOPWARE_ADMIN_SKIP_SOURCEMAP_GENERATION=1") + envMap["SHOPWARE_ADMIN_BUILD_ONLY_EXTENSIONS"] = "1" + envMap["SHOPWARE_ADMIN_SKIP_SOURCEMAP_GENERATION"] = "1" } else { logging.FromContext(ctx).Debugf("Building also the administration itself") } - err = npm.RunScript( - ctx, - administrationRoot, - "build", - envList, - ) + adminExec := assetConfig.ExecutorWithRelDir(adminRelPath).WithEnv(envMap) + npmBuild := adminExec.NPMCommand(ctx, "run", "build") + npmBuild.Stdout = os.Stdout + npmBuild.Stderr = os.Stderr + err = npmBuild.Run() if assetConfig.CleanupNodeModules { defer deletePaths(ctx, path.Join(administrationRoot, "node_modules"), path.Join(administrationRoot, "twigVuePlugin")) @@ -237,6 +240,8 @@ func BuildAssetsForExtensions(ctx context.Context, sources []asset.Source, asset } storefrontRoot := PlatformPath(shopwareRoot, "Storefront", "Resources/app/storefront") + storefrontRelPath := PlatformRelPath(shopwareRoot, "Storefront", "Resources/app/storefront") + sfExec := assetConfig.ExecutorWithRelDir(storefrontRelPath) if assetConfig.NPMForceInstall || !npm.NodeModulesExists(storefrontRoot) { if err := npm.PatchPackageLockToRemoveCanIUse(path.Join(storefrontRoot, "package-lock.json")); err != nil { @@ -254,14 +259,13 @@ func BuildAssetsForExtensions(ctx context.Context, sources []asset.Source, asset additionalNpmParameters = append(additionalNpmParameters, "--production") } - if err := npm.InstallDependencies(ctx, storefrontRoot, npmPackage, additionalNpmParameters...); err != nil { + if err := npm.InstallDependencies(ctx, sfExec, npmPackage, additionalNpmParameters...); err != nil { return err } // As we call npm install caniuse-lite, we need to run the postinstall script manually. if npmPackage.HasScript("postinstall") { - npmRunPostInstall := exec.CommandContext(ctx, "npm", "run", "postinstall") - npmRunPostInstall.Dir = storefrontRoot + npmRunPostInstall := sfExec.NPMCommand(ctx, "run", "postinstall") npmRunPostInstall.Stdout = os.Stdout npmRunPostInstall.Stderr = os.Stderr @@ -271,8 +275,7 @@ func BuildAssetsForExtensions(ctx context.Context, sources []asset.Source, asset } if _, err := os.Stat(path.Join(storefrontRoot, "vendor/bootstrap")); os.IsNotExist(err) { - npmVendor := exec.CommandContext(ctx, "node", path.Join(storefrontRoot, "copy-to-vendor.js")) - npmVendor.Dir = storefrontRoot + npmVendor := sfExec.NPMCommand(ctx, "exec", "--", "node", "copy-to-vendor.js") npmVendor.Stdout = os.Stdout npmVendor.Stderr = os.Stderr if err := npmVendor.Run(); err != nil { @@ -281,20 +284,18 @@ func BuildAssetsForExtensions(ctx context.Context, sources []asset.Source, asset } } - envList := []string{ - "NODE_ENV=production", - fmt.Sprintf("PROJECT_ROOT=%s", shopwareRoot), - fmt.Sprintf("STOREFRONT_ROOT=%s", storefrontRoot), + sfEnvMap := map[string]string{ + "NODE_ENV": "production", + "PROJECT_ROOT": shopwareRoot, + "STOREFRONT_ROOT": storefrontRoot, } if assetConfig.Browserslist != "" { - envList = append(envList, fmt.Sprintf("BROWSERSLIST=%s", assetConfig.Browserslist)) + sfEnvMap["BROWSERSLIST"] = assetConfig.Browserslist } - nodeWebpackCmd := exec.CommandContext(ctx, "node", "node_modules/.bin/webpack", "--config", "webpack.config.js") - nodeWebpackCmd.Dir = storefrontRoot - nodeWebpackCmd.Env = os.Environ() - nodeWebpackCmd.Env = append(nodeWebpackCmd.Env, envList...) + webpackExec := sfExec.WithEnv(sfEnvMap) + nodeWebpackCmd := webpackExec.NPMCommand(ctx, "exec", "--", "webpack", "--config", "webpack.config.js") nodeWebpackCmd.Stdout = os.Stdout nodeWebpackCmd.Stderr = os.Stderr diff --git a/internal/extension/config.go b/internal/extension/config.go index a51c71a2..c2db67a0 100644 --- a/internal/extension/config.go +++ b/internal/extension/config.go @@ -229,6 +229,10 @@ func (c *Config) IsCompatibilityDateAtLeast(requiredDate string) (bool, error) { return compatibility.IsAtLeast(c.CompatibilityDate, requiredDate) } +func (c *Config) IsCompatibilityDateBefore(requiredDate string) bool { + return compatibility.IsBefore(c.CompatibilityDate, requiredDate) +} + func readExtensionConfig(ctx context.Context, dir string) (*Config, error) { config := &Config{} config.Build.Zip.Assets.Enabled = true diff --git a/internal/extension/npm.go b/internal/extension/npm.go index bb972c3f..1c94eaa0 100644 --- a/internal/extension/npm.go +++ b/internal/extension/npm.go @@ -4,15 +4,18 @@ import ( "context" "os" "path" + "path/filepath" "runtime" "sync" + "github.com/shopware/shopware-cli/internal/executor" "github.com/shopware/shopware-cli/internal/npm" "github.com/shopware/shopware-cli/logging" ) type npmInstallJob struct { npmPath string + relDir string additionalNpmParams []string additionalText string } @@ -22,7 +25,7 @@ type npmInstallResult struct { err error } -func InstallNodeModulesOfConfigs(ctx context.Context, cfgs ExtensionAssetConfig, force bool) ([]string, error) { +func InstallNodeModulesOfConfigs(ctx context.Context, cfgs ExtensionAssetConfig, assetConfig AssetBuildConfig) ([]string, error) { // Collect all npm install jobs jobs := make([]npmInstallJob, 0) @@ -39,7 +42,7 @@ func InstallNodeModulesOfConfigs(ctx context.Context, cfgs ExtensionAssetConfig, for _, possibleNodePath := range entry.getPossibleNodePaths() { npmPath := path.Dir(possibleNodePath) - if !force && npm.NodeModulesExists(npmPath) { + if !assetConfig.NPMForceInstall && npm.NodeModulesExists(npmPath) { continue } @@ -54,8 +57,14 @@ func InstallNodeModulesOfConfigs(ctx context.Context, cfgs ExtensionAssetConfig, continue } + var relDir string + if assetConfig.ShopwareRoot != "" { + relDir, _ = filepath.Rel(assetConfig.ShopwareRoot, npmPath) + } + jobs = append(jobs, npmInstallJob{ npmPath: npmPath, + relDir: relDir, additionalNpmParams: additionalNpmParameters, additionalText: additionalText, }) @@ -78,7 +87,7 @@ func InstallNodeModulesOfConfigs(ctx context.Context, cfgs ExtensionAssetConfig, go func() { defer wg.Done() for job := range jobChan { - result := processNpmInstallJob(ctx, job) + result := processNpmInstallJob(ctx, assetConfig, job) resultChan <- result } }() @@ -110,7 +119,7 @@ func InstallNodeModulesOfConfigs(ctx context.Context, cfgs ExtensionAssetConfig, return paths, nil } -func processNpmInstallJob(ctx context.Context, job npmInstallJob) npmInstallResult { +func processNpmInstallJob(ctx context.Context, assetConfig AssetBuildConfig, job npmInstallJob) npmInstallResult { npmPackage, err := npm.ReadPackage(job.npmPath) if err != nil { return npmInstallResult{err: err} @@ -118,7 +127,14 @@ func processNpmInstallJob(ctx context.Context, job npmInstallJob) npmInstallResu logging.FromContext(ctx).Infof("Installing npm dependencies in %s %s\n", job.npmPath, job.additionalText) - if err := npm.InstallDependencies(ctx, job.npmPath, npmPackage, job.additionalNpmParams...); err != nil { + var exec executor.Executor + if job.relDir != "" { + exec = assetConfig.ExecutorWithRelDir(job.relDir) + } else { + exec = executor.NewLocal(job.npmPath) + } + + if err := npm.InstallDependencies(ctx, exec, npmPackage, job.additionalNpmParams...); err != nil { return npmInstallResult{err: err} } diff --git a/internal/extension/packagist.go b/internal/extension/packagist.go index 0d6b93aa..76837e87 100644 --- a/internal/extension/packagist.go +++ b/internal/extension/packagist.go @@ -2,56 +2,24 @@ package extension import ( "context" - "encoding/json" "fmt" - "net/http" "sort" "github.com/shyim/go-version" - "github.com/shopware/shopware-cli/logging" + "github.com/shopware/shopware-cli/internal/packagist" ) -type packagistResponse struct { - Packages struct { - Core []struct { - Version string `json:"version_normalized"` - } `json:"shopware/core"` - } `json:"packages"` -} - func GetShopwareVersions(ctx context.Context) ([]string, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://repo.packagist.org/p2/shopware/core.json", http.NoBody) - if err != nil { - return nil, fmt.Errorf("create composer version request: %w", err) - } - - req.Header.Set("User-Agent", "Shopware CLI") - - resp, err := http.DefaultClient.Do(req) + packageVersions, err := packagist.GetShopwarePackageVersions(ctx) if err != nil { - return nil, fmt.Errorf("fetch composer versions: %w", err) - } - defer func() { - if err := resp.Body.Close(); err != nil { - logging.FromContext(ctx).Errorf("lookupForMinMatchingVersion: %v", err) - } - }() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("fetch composer versions: %s", resp.Status) + return nil, fmt.Errorf("get package versions: %w", err) } - var pckResponse packagistResponse - - var versions []string - - if err := json.NewDecoder(resp.Body).Decode(&pckResponse); err != nil { - return nil, fmt.Errorf("decode composer versions: %w", err) - } + versions := make([]string, 0, len(packageVersions)) - for _, v := range pckResponse.Packages.Core { - versions = append(versions, v.Version) + for _, packageVersion := range packageVersions { + versions = append(versions, packageVersion.VersionNormalized) } return versions, nil diff --git a/internal/extension/project.go b/internal/extension/project.go index 6d6d8e2b..a093d753 100644 --- a/internal/extension/project.go +++ b/internal/extension/project.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "os" + "os/exec" "path" "path/filepath" "regexp" @@ -14,7 +15,6 @@ import ( "github.com/shopware/shopware-cli/internal/asset" "github.com/shopware/shopware-cli/internal/packagist" - "github.com/shopware/shopware-cli/internal/phpexec" "github.com/shopware/shopware-cli/internal/shop" "github.com/shopware/shopware-cli/logging" ) @@ -180,8 +180,10 @@ func FindAssetSourcesOfProject(ctx context.Context, project string, shopCfg *sho return sources } -func DumpAndLoadAssetSourcesOfProject(ctx context.Context, project string, shopCfg *shop.Config) ([]asset.Source, error) { - dumpExec := phpexec.ConsoleCommand(ctx, "bundle:dump") +type ConsoleCommandFunc func(ctx context.Context, args ...string) *exec.Cmd + +func DumpAndLoadAssetSourcesOfProject(ctx context.Context, project string, shopCfg *shop.Config, consoleCommand ConsoleCommandFunc) ([]asset.Source, error) { + dumpExec := consoleCommand(ctx, "bundle:dump") dumpExec.Dir = project dumpExec.Stdin = os.Stdin dumpExec.Stdout = os.Stdout diff --git a/internal/extension/util.go b/internal/extension/util.go index 335645be..5210ada4 100644 --- a/internal/extension/util.go +++ b/internal/extension/util.go @@ -6,14 +6,19 @@ import ( "strings" ) -func PlatformPath(projectRoot, component, path string) string { +func PlatformPath(projectRoot, component, subPath string) string { + return filepath.Join(projectRoot, PlatformRelPath(projectRoot, component, subPath)) +} + +// PlatformRelPath returns the platform component path relative to the project root. +func PlatformRelPath(projectRoot, component, subPath string) string { if _, err := os.Stat(filepath.Join(projectRoot, "src", "Core", "composer.json")); err == nil { - return filepath.Join(projectRoot, "src", component, path) + return filepath.Join("src", component, subPath) } else if _, err := os.Stat(filepath.Join(projectRoot, "vendor", "shopware", "platform")); err == nil { - return filepath.Join(projectRoot, "vendor", "shopware", "platform", "src", component, path) + return filepath.Join("vendor", "shopware", "platform", "src", component, subPath) } - return filepath.Join(projectRoot, "vendor", "shopware", strings.ToLower(component), path) + return filepath.Join("vendor", "shopware", strings.ToLower(component), subPath) } // projectRequiresBuild checks if the project is a contribution project aka shopware/shopware. diff --git a/internal/npm/install.go b/internal/npm/install.go index 368a1b50..4584995a 100644 --- a/internal/npm/install.go +++ b/internal/npm/install.go @@ -3,15 +3,14 @@ package npm import ( "context" "fmt" - "os" - "os/exec" + "github.com/shopware/shopware-cli/internal/executor" "github.com/shopware/shopware-cli/logging" ) -// InstallDependencies runs npm install in the given directory. +// InstallDependencies runs npm install using the given executor. // Additional parameters can be passed to customize the install behavior. -func InstallDependencies(ctx context.Context, dir string, pkg *Package, additionalParams ...string) error { +func InstallDependencies(ctx context.Context, exec executor.Executor, pkg *Package, additionalParams ...string) error { isProductionMode := false for _, param := range additionalParams { @@ -24,34 +23,24 @@ func InstallDependencies(ctx context.Context, dir string, pkg *Package, addition return nil } - installCmd := exec.CommandContext(ctx, "npm", "install", "--no-audit", "--no-fund", "--prefer-offline", "--loglevel=error") - installCmd.Args = append(installCmd.Args, additionalParams...) - installCmd.Dir = dir - installCmd.Env = os.Environ() - installCmd.Env = append(installCmd.Env, - "PUPPETEER_SKIP_DOWNLOAD=1", - "NPM_CONFIG_ENGINE_STRICT=false", - "NPM_CONFIG_FUND=false", - "NPM_CONFIG_AUDIT=false", - "NPM_CONFIG_UPDATE_NOTIFIER=false", - ) + args := []string{"install", "--no-audit", "--no-fund", "--prefer-offline", "--loglevel=error"} + args = append(args, additionalParams...) + + withEnv := exec.WithEnv(map[string]string{ + "PUPPETEER_SKIP_DOWNLOAD": "1", + "NPM_CONFIG_ENGINE_STRICT": "false", + "NPM_CONFIG_FUND": "false", + "NPM_CONFIG_AUDIT": "false", + "NPM_CONFIG_UPDATE_NOTIFIER": "false", + }) + + installCmd := withEnv.NPMCommand(ctx, args...) combinedOutput, err := installCmd.CombinedOutput() if err != nil { - logging.FromContext(context.Background()).Errorf("npm install failed in %s: %s", dir, string(combinedOutput)) - return fmt.Errorf("installing dependencies for %s failed with error: %w", dir, err) + logging.FromContext(context.Background()).Errorf("npm install failed: %s", string(combinedOutput)) + return fmt.Errorf("installing dependencies failed with error: %w", err) } return nil } - -// RunScript runs an npm script in the given directory with optional environment variables. -func RunScript(ctx context.Context, dir string, script string, env []string) error { - npmCmd := exec.CommandContext(ctx, "npm", "--prefix", dir, "run", script) //nolint:gosec - npmCmd.Env = os.Environ() - npmCmd.Env = append(npmCmd.Env, env...) - npmCmd.Stdout = os.Stdout - npmCmd.Stderr = os.Stderr - - return npmCmd.Run() -} diff --git a/internal/packagist/packagist.go b/internal/packagist/packagist.go index b8221937..e7965ccb 100644 --- a/internal/packagist/packagist.go +++ b/internal/packagist/packagist.go @@ -1,6 +1,7 @@ package packagist import ( + "bytes" "context" "encoding/json" "fmt" @@ -23,11 +24,26 @@ func (p *PackageResponse) HasPackage(name string) bool { } type PackageVersion struct { - Version string `json:"version"` - Replace map[string]string `json:"replace"` + Version string `json:"version"` + Description string `json:"description"` + Replace map[string]string `json:"replace"` } -func GetPackages(ctx context.Context, token string) (*PackageResponse, error) { +type ComposerPackageVersion struct { + Name string `json:"name"` + Version string `json:"version"` + VersionNormalized string `json:"version_normalized"` + Description string `json:"description"` + Time string `json:"time"` + Replace map[string]string `json:"replace"` +} + +type composerPackageVersionsResponse struct { + Minified string `json:"minified"` + Packages map[string][]map[string]json.RawMessage `json:"packages"` +} + +func GetAvailablePackagesFromShopwareStore(ctx context.Context, token string) (*PackageResponse, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://packages.shopware.com/packages.json", nil) if err != nil { return nil, err @@ -58,3 +74,101 @@ func GetPackages(ctx context.Context, token string) (*PackageResponse, error) { return &packages, nil } + +func GetShopwarePackageVersions(ctx context.Context) ([]ComposerPackageVersion, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://repo.packagist.org/p2/shopware/core.json", http.NoBody) + if err != nil { + return nil, fmt.Errorf("create package versions request: %w", err) + } + + req.Header.Set("User-Agent", "Shopware CLI") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("fetch package versions: %w", err) + } + + defer func() { + if err := resp.Body.Close(); err != nil { + logging.FromContext(ctx).Errorf("Cannot close response body: %v", err) + } + }() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("fetch package versions: %s", resp.Status) + } + + var packageResponse composerPackageVersionsResponse + + if err := json.NewDecoder(resp.Body).Decode(&packageResponse); err != nil { + return nil, fmt.Errorf("decode package versions: %w", err) + } + + rawVersions, ok := packageResponse.Packages["shopware/core"] + if !ok { + return nil, fmt.Errorf("decode package versions: package shopware/core not found") + } + + if packageResponse.Minified != "" { + rawVersions = unminifyComposerMetadata(rawVersions) + } + + versions := make([]ComposerPackageVersion, 0, len(rawVersions)) + + for _, rawVersion := range rawVersions { + payload, err := json.Marshal(rawVersion) + if err != nil { + return nil, fmt.Errorf("decode package versions: %w", err) + } + + var version ComposerPackageVersion + + if err := json.Unmarshal(payload, &version); err != nil { + return nil, fmt.Errorf("decode package versions: %w", err) + } + + versions = append(versions, version) + } + + return versions, nil +} + +func unminifyComposerMetadata(versions []map[string]json.RawMessage) []map[string]json.RawMessage { + if len(versions) == 0 { + return nil + } + + expanded := make([]map[string]json.RawMessage, 0, len(versions)) + var expandedVersion map[string]json.RawMessage + + for _, versionData := range versions { + if expandedVersion == nil { + expandedVersion = cloneRawMessageMap(versionData) + expanded = append(expanded, cloneRawMessageMap(expandedVersion)) + + continue + } + + for key, val := range versionData { + if bytes.Equal(val, []byte(`"__unset"`)) { + delete(expandedVersion, key) + } else { + expandedVersion[key] = val + } + } + + expanded = append(expanded, cloneRawMessageMap(expandedVersion)) + } + + return expanded +} + +func cloneRawMessageMap(in map[string]json.RawMessage) map[string]json.RawMessage { + out := make(map[string]json.RawMessage, len(in)) + + for key, val := range in { + out[key] = val + } + + return out +} diff --git a/internal/packagist/packagist_test.go b/internal/packagist/packagist_test.go index b2bb0dc8..f7b7597d 100644 --- a/internal/packagist/packagist_test.go +++ b/internal/packagist/packagist_test.go @@ -74,20 +74,16 @@ func TestPackageResponseHasPackage(t *testing.T) { } func TestGetPackages(t *testing.T) { - // Save the original HTTP client to restore it after tests originalClient := http.DefaultClient defer func() { http.DefaultClient = originalClient }() t.Run("successful request", func(t *testing.T) { - // Setup mock server server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Check request assert.Equal(t, "Shopware CLI", r.Header.Get("User-Agent")) assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization")) - // Return successful response response := PackageResponse{ Packages: map[string]map[string]PackageVersion{ "store.shopware.com/swagextensionstore": { @@ -106,17 +102,14 @@ func TestGetPackages(t *testing.T) { })) defer server.Close() - // Create a custom client that redirects requests to the test server http.DefaultClient = &http.Client{ Transport: &mockTransport{ server: server, }, } - // Call the function - packages, err := GetPackages(t.Context(), "test-token") + packages, err := GetAvailablePackagesFromShopwareStore(t.Context(), "test-token") - // Assertions assert.NoError(t, err) assert.NotNil(t, packages) assert.True(t, packages.HasPackage("SwagExtensionStore")) @@ -124,30 +117,25 @@ func TestGetPackages(t *testing.T) { }) t.Run("unauthorized request", func(t *testing.T) { - // Setup mock server that returns 401 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusUnauthorized) })) defer server.Close() - // Create a custom client that redirects requests to the test server http.DefaultClient = &http.Client{ Transport: &mockTransport{ server: server, }, } - // Call the function - packages, err := GetPackages(t.Context(), "invalid-token") + packages, err := GetAvailablePackagesFromShopwareStore(t.Context(), "invalid-token") - // Assertions assert.Error(t, err) assert.Nil(t, packages) assert.Contains(t, err.Error(), "failed to get packages") }) t.Run("invalid JSON response", func(t *testing.T) { - // Setup mock server that returns invalid JSON server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _, err := w.Write([]byte("invalid json")) @@ -155,68 +143,206 @@ func TestGetPackages(t *testing.T) { })) defer server.Close() - // Create a custom client that redirects requests to the test server http.DefaultClient = &http.Client{ Transport: &mockTransport{ server: server, }, } - // Call the function - packages, err := GetPackages(t.Context(), "test-token") + packages, err := GetAvailablePackagesFromShopwareStore(t.Context(), "test-token") - // Assertions assert.Error(t, err) assert.Nil(t, packages) }) t.Run("server error", func(t *testing.T) { - // Setup mock server that returns 500 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) })) defer server.Close() - // Create a custom client that redirects requests to the test server http.DefaultClient = &http.Client{ Transport: &mockTransport{ server: server, }, } - // Call the function - packages, err := GetPackages(t.Context(), "test-token") + packages, err := GetAvailablePackagesFromShopwareStore(t.Context(), "test-token") - // Assertions assert.Error(t, err) assert.Nil(t, packages) assert.Contains(t, err.Error(), "failed to get packages") }) t.Run("context canceled", func(t *testing.T) { - // Use a canceled context ctx, cancel := context.WithCancel(t.Context()) - cancel() // Cancel the context immediately + cancel() - // Call the function with canceled context - packages, err := GetPackages(ctx, "test-token") + packages, err := GetAvailablePackagesFromShopwareStore(ctx, "test-token") - // Assertions assert.Error(t, err) assert.Nil(t, packages) }) } -// mockTransport is a custom RoundTripper that redirects all requests to a test server. +func TestGetPackageVersions(t *testing.T) { + originalClient := http.DefaultClient + defer func() { + http.DefaultClient = originalClient + }() + + t.Run("successful request with composer unminify", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/p2/shopware/core.json", r.URL.Path) + assert.Equal(t, "Shopware CLI", r.Header.Get("User-Agent")) + + response := map[string]any{ + "minified": "composer/2.0", + "packages": map[string]any{ + "shopware/core": []map[string]any{ + { + "name": "shopware/core", + "version": "v1.0.0", + "version_normalized": "1.0.0.0", + "description": "Base description", + "replace": map[string]string{ + "shopware/core": "*", + }, + }, + { + "version": "v1.0.1", + "version_normalized": "1.0.1.0", + }, + { + "version": "v1.0.2", + "version_normalized": "1.0.2.0", + "description": "__unset", + "replace": "__unset", + }, + }, + }, + } + + w.Header().Set("Content-Type", "application/json") + err := json.NewEncoder(w).Encode(response) + require.NoError(t, err) + })) + defer server.Close() + + http.DefaultClient = &http.Client{ + Transport: &mockTransport{ + server: server, + }, + } + + versions, err := GetShopwarePackageVersions(t.Context()) + + require.NoError(t, err) + require.Len(t, versions, 3) + assert.Equal(t, "shopware/core", versions[0].Name) + assert.Equal(t, "Base description", versions[1].Description) + assert.Equal(t, map[string]string{"shopware/core": "*"}, versions[1].Replace) + assert.Empty(t, versions[2].Description) + assert.Nil(t, versions[2].Replace) + }) + + t.Run("successful request without minified payload", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + response := map[string]any{ + "packages": map[string]any{ + "shopware/core": []map[string]any{ + { + "name": "shopware/core", + "version": "v2.0.0", + "version_normalized": "2.0.0.0", + }, + }, + }, + } + + w.Header().Set("Content-Type", "application/json") + err := json.NewEncoder(w).Encode(response) + require.NoError(t, err) + })) + defer server.Close() + + http.DefaultClient = &http.Client{ + Transport: &mockTransport{ + server: server, + }, + } + + versions, err := GetShopwarePackageVersions(t.Context()) + + require.NoError(t, err) + require.Len(t, versions, 1) + assert.Equal(t, "2.0.0.0", versions[0].VersionNormalized) + }) + + t.Run("package missing", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + response := map[string]any{ + "packages": map[string]any{ + "some/other-package": []map[string]any{}, + }, + } + + w.Header().Set("Content-Type", "application/json") + err := json.NewEncoder(w).Encode(response) + require.NoError(t, err) + })) + defer server.Close() + + http.DefaultClient = &http.Client{ + Transport: &mockTransport{ + server: server, + }, + } + + versions, err := GetShopwarePackageVersions(t.Context()) + + assert.Error(t, err) + assert.Nil(t, versions) + assert.Contains(t, err.Error(), "package shopware/core not found") + }) + + t.Run("server error", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + http.DefaultClient = &http.Client{ + Transport: &mockTransport{ + server: server, + }, + } + + versions, err := GetShopwarePackageVersions(t.Context()) + + assert.Error(t, err) + assert.Nil(t, versions) + assert.Contains(t, err.Error(), "fetch package versions") + }) + + t.Run("context canceled", func(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + cancel() + + versions, err := GetShopwarePackageVersions(ctx) + + assert.Error(t, err) + assert.Nil(t, versions) + }) +} + type mockTransport struct { server *httptest.Server } func (m *mockTransport) RoundTrip(req *http.Request) (*http.Response, error) { - // Replace the request URL with the test server URL, but keep the same path url := m.server.URL + req.URL.Path - // Create a new request to the test server newReq, err := http.NewRequestWithContext( req.Context(), req.Method, @@ -227,9 +353,7 @@ func (m *mockTransport) RoundTrip(req *http.Request) (*http.Response, error) { return nil, err } - // Copy all headers newReq.Header = req.Header - // Send request to the test server return m.server.Client().Transport.RoundTrip(newReq) } diff --git a/internal/packagist/project_composer_json.go b/internal/packagist/project_composer_json.go index 5bb2256d..f5c7391f 100644 --- a/internal/packagist/project_composer_json.go +++ b/internal/packagist/project_composer_json.go @@ -23,7 +23,6 @@ type ComposerJsonOptions struct { Version string DependingVersion string RC bool - UseDocker bool UseElasticsearch bool UseAMQP bool NoAudit bool @@ -71,9 +70,6 @@ func GenerateComposerJson(ctx context.Context, opts ComposerJsonOptions) (string if opts.UseAMQP { require.set("symfony/amqp-messenger", "*") } - if opts.UseDocker { - require.set("shopware/docker-dev", "*") - } if opts.IsDeployer() { require.set("deployer/deployer", "*") } @@ -146,7 +142,7 @@ func GenerateComposerJson(ctx context.Context, opts ComposerJsonOptions) (string composer.set("scripts", scripts) symfony := newOrderedMap() symfony.set("allow-contrib", true) - symfony.set("docker", opts.UseDocker) + symfony.set("docker", false) symfony.set("endpoint", []string{ "https://raw.githubusercontent.com/shopware/recipes/flex/main/index.json", "flex://defaults", diff --git a/internal/phpexec/phpexec.go b/internal/phpexec/phpexec.go deleted file mode 100644 index 959e3850..00000000 --- a/internal/phpexec/phpexec.go +++ /dev/null @@ -1,57 +0,0 @@ -package phpexec - -import ( - "context" - "os" - "os/exec" - "sync" -) - -type allowBinCIKey struct{} - -func AllowBinCI(ctx context.Context) context.Context { - return context.WithValue(ctx, allowBinCIKey{}, true) -} - -var isCI = sync.OnceValue(func() bool { - return os.Getenv("CI") != "" -}) - -var pathToSymfonyCLI = sync.OnceValue(func() string { - path, err := exec.LookPath("symfony") - if err != nil { - return "" - } - return path -}) - -func symfonyCliAllowed() bool { - return os.Getenv("SHOPWARE_CLI_NO_SYMFONY_CLI") != "1" -} - -func ConsoleCommand(ctx context.Context, args ...string) *exec.Cmd { - consoleCommand := "bin/console" - - if _, ok := ctx.Value(allowBinCIKey{}).(bool); ok && isCI() { - consoleCommand = "bin/ci" - } - - if path := pathToSymfonyCLI(); path != "" && symfonyCliAllowed() { - return exec.CommandContext(ctx, path, append([]string{"php", consoleCommand}, args...)...) - } - return exec.CommandContext(ctx, "php", append([]string{consoleCommand}, args...)...) -} - -func ComposerCommand(ctx context.Context, args ...string) *exec.Cmd { - if path := pathToSymfonyCLI(); path != "" && symfonyCliAllowed() { - return exec.CommandContext(ctx, path, append([]string{"composer"}, args...)...) - } - return exec.CommandContext(ctx, "composer", args...) -} - -func PHPCommand(ctx context.Context, args ...string) *exec.Cmd { - if path := pathToSymfonyCLI(); path != "" && symfonyCliAllowed() { - return exec.CommandContext(ctx, path, append([]string{"php"}, args...)...) - } - return exec.CommandContext(ctx, "php", args...) -} diff --git a/internal/phpexec/phpexec_test.go b/internal/phpexec/phpexec_test.go deleted file mode 100644 index ea3d7dbd..00000000 --- a/internal/phpexec/phpexec_test.go +++ /dev/null @@ -1,68 +0,0 @@ -package phpexec - -import ( - "context" - "os/exec" - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestSymfonyDetection(t *testing.T) { - testCases := []struct { - Name string - Func func(context.Context, ...string) *exec.Cmd - Args []string - SymfonyArgs []string - }{ - { - Name: "Composer", - Func: ComposerCommand, - Args: []string{"composer"}, - SymfonyArgs: []string{"/test/symfony", "composer"}, - }, - { - Name: "Console", - Func: ConsoleCommand, - Args: []string{"php", "bin/console"}, - SymfonyArgs: []string{"/test/symfony", "php", "bin/console"}, - }, - { - Name: "PHP", - Func: PHPCommand, - Args: []string{"php"}, - SymfonyArgs: []string{"/test/symfony", "php"}, - }, - } - - ctx := t.Context() - - for _, tc := range testCases { - tc := tc - - t.Run(tc.Name, func(t *testing.T) { - t.Run("Default", func(t *testing.T) { - pathToSymfonyCLI = func() string { return "" } - - cmd := tc.Func(ctx, "some", "arguments") - assert.Equal(t, append(tc.Args, "some", "arguments"), cmd.Args) - }) - - t.Run("Symfony", func(t *testing.T) { - pathToSymfonyCLI = func() string { return "/test/symfony" } - - cmd := tc.Func(ctx, "some", "arguments") - assert.Equal(t, append(tc.SymfonyArgs, "some", "arguments"), cmd.Args) - }) - - t.Run("Symfony disabled", func(t *testing.T) { - t.Setenv("SHOPWARE_CLI_NO_SYMFONY_CLI", "1") - - pathToSymfonyCLI = func() string { return "/test/symfony" } - - cmd := tc.Func(ctx, "some", "arguments") - assert.Equal(t, append(tc.Args, "some", "arguments"), cmd.Args) - }) - }) - } -} diff --git a/internal/shop/compatibility_date.go b/internal/shop/compatibility_date.go new file mode 100644 index 00000000..8dfbc46d --- /dev/null +++ b/internal/shop/compatibility_date.go @@ -0,0 +1,27 @@ +package shop + +import "fmt" + +const ( + CompatibilityDevMode = "2026-03-01" +) + +var ( + ErrDevModeNotSupported = NewCompatibilityError("development mode is not supported for this compatibility date", CompatibilityDevMode) +) + +func NewCompatibilityError(message string, date string) error { + return &CompatibilityError{ + Message: message, + date: date, + } +} + +type CompatibilityError struct { + Message string + date string +} + +func (e *CompatibilityError) Error() string { + return fmt.Sprintf("%s, requires compatibility date: %s. see https://developer.shopware.com/docs/products/cli/project-commands/build.html#compatibility-date for more", e.Message, e.date) +} diff --git a/internal/shop/config.go b/internal/shop/config.go index 74323b20..f9d01138 100644 --- a/internal/shop/config.go +++ b/internal/shop/config.go @@ -18,6 +18,12 @@ import ( "github.com/shopware/shopware-cli/logging" ) +type EnvironmentConfig struct { + Type string `yaml:"type" jsonschema:"enum=local,enum=docker"` + URL string `yaml:"url,omitempty"` + AdminApi *ConfigAdminApi `yaml:"admin_api,omitempty"` +} + type Config struct { AdditionalConfigs []string `yaml:"include,omitempty"` // The URL of the Shopware instance @@ -30,6 +36,10 @@ type Config struct { 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 + Environments map[string]*EnvironmentConfig `yaml:"environments,omitempty"` // When enabled, composer scripts will be disabled during CI builds DisableComposerScripts bool `yaml:"disable_composer_scripts,omitempty"` // When enabled, composer install will be skipped during CI builds @@ -37,6 +47,26 @@ type Config struct { foundConfig bool } +func (c *Config) ResolveEnvironment(name string) (*EnvironmentConfig, error) { + if name != "" { + env, ok := c.Environments[name] + if !ok { + return nil, fmt.Errorf("environment %q not found in config", name) + } + return env, nil + } + + if env, ok := c.Environments["local"]; ok { + return env, nil + } + + return &EnvironmentConfig{ + Type: "local", + URL: c.URL, + AdminApi: c.AdminApi, + }, nil +} + func (c *Config) IsAdminAPIConfigured() bool { if c.AdminApi == nil { return false @@ -53,6 +83,10 @@ func (c *Config) IsCompatibilityDateAtLeast(requiredDate string) (bool, error) { return compatibility.IsAtLeast(c.CompatibilityDate, requiredDate) } +func (c *Config) IsCompatibilityDateBefore(requiredDate string) bool { + return compatibility.IsBefore(c.CompatibilityDate, requiredDate) +} + type ConfigBuild struct { // When enabled, the assets will not be copied to the public folder DisableAssetCopy bool `yaml:"disable_asset_copy,omitempty"` @@ -382,11 +416,129 @@ type ConfigValidationIgnoreExtension struct { Name string `yaml:"name"` } +type ConfigDocker struct { + // PHP configuration for the Docker dev image + PHP *ConfigDockerPHP `yaml:"php,omitempty"` + // Node.js configuration for the Docker dev image + Node *ConfigDockerNode `yaml:"node,omitempty"` +} + +type ConfigDockerPHP struct { + // PHP version (e.g. "8.3", "8.2"). Defaults to "8.3". + Version string `yaml:"version,omitempty"` + // Profiler to enable. Possible values: xdebug, blackfire, tideways, pcov, spx. + Profiler string `yaml:"profiler,omitempty" jsonschema:"enum=xdebug,enum=blackfire,enum=tideways,enum=pcov,enum=spx"` + // Blackfire server ID from your Blackfire account. Required when profiler is "blackfire". + BlackfireServerID string `yaml:"blackfire_server_id,omitempty"` + // Blackfire server token from your Blackfire account. Required when profiler is "blackfire". + BlackfireServerToken string `yaml:"blackfire_server_token,omitempty"` + // Tideways API key from your Tideways account. Required when profiler is "tideways". + TidewaysAPIKey string `yaml:"tideways_api_key,omitempty"` +} + +func (ConfigDockerPHP) JSONSchema() *jsonschema.Schema { + properties := orderedmap.New[string, *jsonschema.Schema]() + + properties.Set("version", &jsonschema.Schema{ + Type: "string", + Description: "PHP version (e.g. \"8.3\", \"8.2\"). Defaults to \"8.3\".", + }) + + properties.Set("profiler", &jsonschema.Schema{ + Type: "string", + Enum: []any{"xdebug", "blackfire", "tideways", "pcov", "spx"}, + Description: "Profiler to enable. Possible values: xdebug, blackfire, tideways, pcov, spx.", + }) + + properties.Set("blackfire_server_id", &jsonschema.Schema{ + Type: "string", + Description: "Blackfire server ID from your Blackfire account. Required when profiler is \"blackfire\".", + }) + + properties.Set("blackfire_server_token", &jsonschema.Schema{ + Type: "string", + Description: "Blackfire server token from your Blackfire account. Required when profiler is \"blackfire\".", + }) + + properties.Set("tideways_api_key", &jsonschema.Schema{ + Type: "string", + Description: "Tideways API key from your Tideways account. Required when profiler is \"tideways\".", + }) + + profilerConst := func(value string) *orderedmap.OrderedMap[string, *jsonschema.Schema] { + m := orderedmap.New[string, *jsonschema.Schema]() + m.Set("profiler", &jsonschema.Schema{Const: value}) + return m + } + + return &jsonschema.Schema{ + Type: "object", + Properties: properties, + AdditionalProperties: jsonschema.FalseSchema, + AllOf: []*jsonschema.Schema{ + { + If: &jsonschema.Schema{ + Properties: profilerConst("blackfire"), + Required: []string{"profiler"}, + }, + Then: &jsonschema.Schema{ + Required: []string{"blackfire_server_id", "blackfire_server_token"}, + }, + }, + { + If: &jsonschema.Schema{ + Properties: profilerConst("tideways"), + Required: []string{"profiler"}, + }, + Then: &jsonschema.Schema{ + Required: []string{"tideways_api_key"}, + }, + }, + }, + } +} + +type ConfigDockerNode struct { + // Node.js version (e.g. "22", "24"). Defaults to "22". + Version string `yaml:"version,omitempty" jsonschema:"enum=22,enum=24"` +} + type ConfigImageProxy struct { // The URL of the upstream server to proxy requests to when files are not found locally URL string `yaml:"url,omitempty"` } +func NewConfig() *Config { + return &Config{ + CompatibilityDate: compatibility.TodayDate(), + Environments: map[string]*EnvironmentConfig{ + "local": { + Type: "local", + URL: "http://127.0.0.1:8000", + AdminApi: &ConfigAdminApi{ + Username: "admin", + Password: "shopware", + }, + }, + }, + } +} + +func WriteConfig(cfg *Config, dir string) error { + data, err := yaml.Marshal(cfg) + if err != nil { + return fmt.Errorf("failed to marshal shop configuration: %w", err) + } + + filePath := filepath.Join(dir, ".shopware-project.yml") + + if err := os.WriteFile(filePath, data, os.ModePerm); err != nil { + return fmt.Errorf("failed to write shop configuration to %s: %w", filePath, err) + } + + return nil +} + func ReadConfig(ctx context.Context, fileName string, allowFallback bool) (*Config, error) { config := &Config{foundConfig: false} diff --git a/internal/shop/config_test.go b/internal/shop/config_test.go index 7de06e9b..651cc80a 100644 --- a/internal/shop/config_test.go +++ b/internal/shop/config_test.go @@ -94,6 +94,114 @@ func TestReadConfigFallbackSetsCompatibilityDate(t *testing.T) { assert.NoError(t, compatibility.ValidateDate(cfg.CompatibilityDate)) } +func TestResolveEnvironment(t *testing.T) { + t.Run("returns named environment", func(t *testing.T) { + cfg := &Config{ + Environments: map[string]*EnvironmentConfig{ + "staging": {Type: "docker", URL: "https://staging.example.com"}, + }, + } + + env, err := cfg.ResolveEnvironment("staging") + assert.NoError(t, err) + assert.Equal(t, "docker", env.Type) + assert.Equal(t, "https://staging.example.com", env.URL) + }) + + t.Run("error on missing named environment", func(t *testing.T) { + cfg := &Config{ + Environments: map[string]*EnvironmentConfig{ + "staging": {Type: "docker"}, + }, + } + + _, err := cfg.ResolveEnvironment("production") + assert.Error(t, err) + assert.Contains(t, err.Error(), `environment "production" not found`) + }) + + t.Run("returns local environment when no name given", func(t *testing.T) { + cfg := &Config{ + Environments: map[string]*EnvironmentConfig{ + "local": {Type: "docker", URL: "http://localhost:8000"}, + "staging": {Type: "docker", URL: "https://staging.example.com"}, + }, + } + + env, err := cfg.ResolveEnvironment("") + assert.NoError(t, err) + assert.Equal(t, "docker", env.Type) + assert.Equal(t, "http://localhost:8000", env.URL) + }) + + t.Run("synthesizes from top-level when no environments configured", func(t *testing.T) { + cfg := &Config{ + URL: "https://myshop.com", + AdminApi: &ConfigAdminApi{ + Username: "admin", + Password: "shopware", + }, + } + + env, err := cfg.ResolveEnvironment("") + assert.NoError(t, err) + assert.Equal(t, "local", env.Type) + assert.Equal(t, "https://myshop.com", env.URL) + assert.Equal(t, "admin", env.AdminApi.Username) + }) + + t.Run("synthesizes with nil admin api", func(t *testing.T) { + cfg := &Config{} + + env, err := cfg.ResolveEnvironment("") + assert.NoError(t, err) + assert.Equal(t, "local", env.Type) + assert.Nil(t, env.AdminApi) + }) + + t.Run("error on named environment with nil map", func(t *testing.T) { + cfg := &Config{} + + _, err := cfg.ResolveEnvironment("staging") + assert.Error(t, err) + }) +} + +func TestReadConfigWithEnvironments(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, ".shopware-project.yml") + + content := []byte(` +url: https://example.com +compatibility_date: "2026-01-01" +environments: + local: + type: docker + url: http://localhost:8000 + admin_api: + username: admin + password: shopware + staging: + type: docker + url: https://staging.example.com +`) + + assert.NoError(t, os.WriteFile(configPath, content, 0o644)) + + config, err := ReadConfig(t.Context(), configPath, false) + assert.NoError(t, err) + assert.Len(t, config.Environments, 2) + + local := config.Environments["local"] + assert.Equal(t, "docker", local.Type) + assert.Equal(t, "http://localhost:8000", local.URL) + assert.Equal(t, "admin", local.AdminApi.Username) + + staging := config.Environments["staging"] + assert.Equal(t, "docker", staging.Type) + assert.Equal(t, "https://staging.example.com", staging.URL) +} + func TestConfigDump_EnableAnonymization(t *testing.T) { t.Run("empty config", func(t *testing.T) { config := &ConfigDump{} diff --git a/internal/shop/console.go b/internal/shop/console.go index 9ef77fa5..74dbfd11 100644 --- a/internal/shop/console.go +++ b/internal/shop/console.go @@ -5,9 +5,8 @@ import ( "encoding/json" "fmt" "os" + "os/exec" "path" - - "github.com/shopware/shopware-cli/internal/phpexec" ) type ConsoleResponse struct { @@ -37,7 +36,10 @@ func (c ConsoleResponse) GetCommandOptions(name string) []string { return nil } -func GetConsoleCompletion(ctx context.Context, projectRoot string) (*ConsoleResponse, error) { +// ConsoleCommandFunc avoids a circular dependency between shop and executor packages. +type ConsoleCommandFunc func(ctx context.Context, args ...string) *exec.Cmd + +func GetConsoleCompletion(ctx context.Context, projectRoot string, consoleCommand ConsoleCommandFunc) (*ConsoleResponse, error) { cachePath := path.Join(projectRoot, "var", "cache", "console_commands.json") if _, err := os.Stat(cachePath); err == nil { @@ -55,10 +57,10 @@ func GetConsoleCompletion(ctx context.Context, projectRoot string) (*ConsoleResp return &resp, nil } - consoleCommand := phpexec.ConsoleCommand(ctx, "list", "--format=json") - consoleCommand.Dir = projectRoot + cmd := consoleCommand(ctx, "list", "--format=json") + cmd.Dir = projectRoot - commandJson, err := consoleCommand.Output() + commandJson, err := cmd.Output() if err != nil { return nil, err } diff --git a/internal/tui/badge.go b/internal/tui/badge.go new file mode 100644 index 00000000..13d09d77 --- /dev/null +++ b/internal/tui/badge.go @@ -0,0 +1,22 @@ +package tui + +import ( + "image/color" + "strings" + + "charm.land/lipgloss/v2" +) + +// StatusBadge renders a status indicator in the form "● STATUS". +func StatusBadge(status string, c color.Color) string { + return lipgloss.NewStyle().Foreground(c).Bold(true).Render("● " + strings.ToUpper(status)) +} + +// TextBadge renders text on a subtle background with horizontal padding. +func TextBadge(text string) string { + return lipgloss.NewStyle(). + Background(SubtleBgColor). + Foreground(TextColor). + Padding(0, 1). + Render(text) +} diff --git a/internal/tui/banner.go b/internal/tui/banner.go index cec8ed72..c9bf9f44 100644 --- a/internal/tui/banner.go +++ b/internal/tui/banner.go @@ -2,25 +2,9 @@ package tui import "fmt" +// PrintBanner prints the Shopware CLI header box to stdout. func PrintBanner() { - banner := ` @@@@@@@@@@ - @@@@@@@@@@@@@@@@@@ - @@@@@@@@@@@@@@@@@@@@@@@ - @@@@@@@@@@@@@@@ - @@@@@@@@@@ - @@@@@@@@@ @@@@@@@ - @@@@@@@@@@ @@@@@@@@@@@@ - @@@@@@@@@@ @@@@@@@@@@@@@ - @@@@@@@@@@@ @@@@@@@@@@ - @@@@@@@@@@@@ @@@@@ - @@@@@@@@@@@@@@ - @@@@@@@@@@@@@@@@@@ - @@@@@@@@@@@@@@@@@@@@ - @@@@@@@@@@@@@@@@@@@ - @@@@@@@@@@@@@` - - fmt.Println(BlueText.Render(banner)) fmt.Println() - fmt.Println(BlueText.Bold(true).Render(" Welcome to Shopware!")) + fmt.Println(RenderHeader()) fmt.Println() } diff --git a/internal/tui/header.go b/internal/tui/header.go new file mode 100644 index 00000000..35c8445b --- /dev/null +++ b/internal/tui/header.go @@ -0,0 +1,72 @@ +package tui + +import ( + "charm.land/lipgloss/v2" +) + +const ( + appTitle = "Shopware CLI" + docsURL = "https://developer.shopware.com/docs/products/cli/" + githubURL = "https://github.com/shopware/shopware-cli" +) + +// AppVersion is the CLI version displayed in headers and branding lines. +// It is set from cmd/root.go at startup. +var AppVersion = "dev" + +// BrandingLine returns the fully styled branding string: +// "● Shopware CLI v1.0.0 · Documentation · GitHub" +func BrandingLine() string { + icon := lipgloss.NewStyle().Foreground(BrandColor).Render("●") + title := lipgloss.NewStyle().Bold(true).Foreground(TextColor).Render(appTitle) + version := DimStyle.Render(AppVersion) + + lnkStyle := lipgloss.NewStyle().Foreground(LinkColor).Underline(true) + docsLink := StyledLink(docsURL, "Documentation", lnkStyle) + ghLink := StyledLink(githubURL, "GitHub", lnkStyle) + + sep := DimStyle.Render(" · ") + + return icon + " " + title + " " + version + sep + docsLink + sep + ghLink +} + +// BrandingLineWidth returns the visual width of the branding line in terminal columns. +func BrandingLineWidth() int { + return lipgloss.Width("●") + 1 + + lipgloss.Width(appTitle) + 1 + + lipgloss.Width(AppVersion) + + lipgloss.Width(" · ") + + lipgloss.Width("Documentation") + + lipgloss.Width(" · ") + + lipgloss.Width("GitHub") +} + +// RenderHeader renders the full CLI header box containing the logo, branding +// line, description, and documentation/bug-report links. +func RenderHeader() string { + arrow := lipgloss.NewStyle().Foreground(BrandColor).Render("●") + title := lipgloss.NewStyle().Bold(true).Foreground(TextColor).Render(appTitle) + version := DimStyle.Render(AppVersion) + titleLine := arrow + " " + title + " " + version + + desc := DimStyle.Render("Manage your Shopware projects, extensions, and local development environments.") + + help := DimStyle.Render("Need help? Visit the ") + lnkStyle := lipgloss.NewStyle().Foreground(LinkColor).Underline(true) + link := StyledLink(docsURL, "Shopware Documentation", lnkStyle) + dot := DimStyle.Render(".") + + bugText := DimStyle.Render("Found a bug? Create an issue on ") + bugLink := StyledLink(githubURL, "GitHub", lnkStyle) + bugDot := DimStyle.Render(".") + + content := titleLine + "\n" + desc + "\n" + help + link + dot + "\n" + bugText + bugLink + bugDot + + w := TerminalWidth() + return lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(BorderColor). + Padding(1, 6). + Width(w). + Render(content) +} diff --git a/internal/tui/labels.go b/internal/tui/labels.go new file mode 100644 index 00000000..86ab9c76 --- /dev/null +++ b/internal/tui/labels.go @@ -0,0 +1,54 @@ +package tui + +import ( + "fmt" + "strings" + + "charm.land/lipgloss/v2" +) + +var ( + // LabelStyle renders text in the primary text color. + LabelStyle = lipgloss.NewStyle().Foreground(TextColor) + + // kvKeyStyle renders the key column in key-value pair rows with a fixed width. + kvKeyStyle = lipgloss.NewStyle().Width(22).Foreground(TextColor) + + // LinkStyle renders clickable hyperlinks in a muted blue with an underline. + LinkStyle = lipgloss.NewStyle().Foreground(LinkColor).Underline(true) + + // TitleStyle renders section headings in bold with the primary text color. + TitleStyle = lipgloss.NewStyle().Bold(true).Foreground(TextColor) +) + +// FormatLabel renders a "Label (Detail)" string where the label uses the +// primary text color and the detail is dimmed in parentheses. +func FormatLabel(label, detail string) string { + if detail == "" { + return LabelStyle.Render(label) + } + return LabelStyle.Render(label) + " " + DimStyle.Render("("+detail+")") +} + +// FormatLabelDim renders a "Label (Detail)" string entirely in dimmed style. +func FormatLabelDim(label, detail string) string { + if detail == "" { + return DimStyle.Render(label) + } + return DimStyle.Render(label + " (" + detail + ")") +} + +// KVRow renders a single key-value pair as a line with consistent alignment. +func KVRow(key, value string) string { + return fmt.Sprintf(" %s%s\n", kvKeyStyle.Render(key), value) +} + +// RenderStyledLink renders a URL as a clickable terminal hyperlink using LinkStyle. +func RenderStyledLink(url string) string { + return StyledLink(url, url, LinkStyle) +} + +// SectionDivider renders a full-width horizontal line in the border color. +func SectionDivider(width int) string { + return "\n" + lipgloss.NewStyle().Foreground(BorderColor).Render(strings.Repeat("─", width)) + "\n\n" +} diff --git a/internal/tui/panels.go b/internal/tui/panels.go new file mode 100644 index 00000000..bddeca4e --- /dev/null +++ b/internal/tui/panels.go @@ -0,0 +1,57 @@ +package tui + +import ( + "image/color" + "strings" + + "charm.land/lipgloss/v2" +) + +// RenderPanel renders content inside a full-width bordered box with an optional title. +func RenderPanel(title, content string, titleColor color.Color) string { + w := TerminalWidth() + boxStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(BorderColor). + Padding(1, 3). + Width(w) + + box := boxStyle.Render(strings.TrimRight(content, "\n")) + + if title == "" { + return box + } + + lines := strings.Split(box, "\n") + + bStyle := lipgloss.NewStyle().Foreground(BorderColor) + tStyle := lipgloss.NewStyle().Bold(true).Foreground(titleColor) + + styledTitle := tStyle.Render(title) + fill := w - 5 - lipgloss.Width(styledTitle) + if fill < 0 { + fill = 0 + } + + lines[0] = bStyle.Render("╭─ ") + styledTitle + bStyle.Render(" "+strings.Repeat("─", fill)+"╮") + + return strings.Join(lines, "\n") +} + +// RenderSuccessPanel renders a panel with a green "Project Created" title. +func RenderSuccessPanel(content string) string { + return RenderPanel("✓ Project Created", content, SuccessColor) +} + +// RenderCancelledMessage renders a dimmed cancellation notice. +func RenderCancelledMessage() string { + return "\n" + DimStyle.Render(" Project creation cancelled.") + "\n" +} + +// DividerLine renders a horizontal rule spanning most of the terminal width. +func DividerLine() string { + w := TerminalWidth() - 8 + return lipgloss.NewStyle(). + Foreground(BorderColor). + Render(strings.Repeat("─", w)) +} diff --git a/internal/tui/phasecard.go b/internal/tui/phasecard.go new file mode 100644 index 00000000..47cdae8a --- /dev/null +++ b/internal/tui/phasecard.go @@ -0,0 +1,136 @@ +package tui + +import ( + "strings" + + "charm.land/lipgloss/v2" +) + +const mascotArt = `____________________________ +____________________________ +____________▓▓▓▓██████______ +______████▓▓▓▓▓▓████████____ +____██████▓▓▓▓▓▓████████____ +__████████▓▓▓▓▓▓██▒█████____ +▓▓██████████▓▓▓▓████████____ +__██████████████████████____ +__████████████████__██████__ +__██████____██████______████ +__▓▓▓▓▓▓____▓▓▓▓▓▓__________ +____________________________ +____________________________` + +const PhaseCardWidth = 79 + +func renderMascotArt(targetWidth int) string { + const dashChar = '·' + + dotStyle := lipgloss.NewStyle().Foreground(BorderColor) + bodyStyle := lipgloss.NewStyle().Foreground(BrandColor) + shadeStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#0450A0")) + eyeStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")) + + classOf := func(ch rune) int { + switch ch { + case dashChar: + return 0 + case '▓': + return 1 + case '▒': + return 2 + default: + return 3 + } + } + + styleOf := func(ch rune) lipgloss.Style { + switch classOf(ch) { + case 0: + return dotStyle + case 1: + return shadeStyle + case 2: + return eyeStyle + default: + return bodyStyle + } + } + + lines := strings.Split(mascotArt, "\n") + + maxW := 0 + for _, line := range lines { + if w := len([]rune(line)); w > maxW { + maxW = w + } + } + + padTotal := targetWidth - maxW + leftPad, rightPad := 0, 0 + if padTotal > 0 { + leftPad = padTotal / 2 + rightPad = padTotal - leftPad + } + + var result strings.Builder + for i, line := range lines { + runes := []rune(strings.ReplaceAll(line, "_", string(dashChar))) + for len(runes) < maxW { + runes = append(runes, dashChar) + } + if leftPad > 0 { + pad := make([]rune, 0, leftPad+len(runes)) + for j := 0; j < leftPad; j++ { + pad = append(pad, dashChar) + } + runes = append(pad, runes...) + } + for j := 0; j < rightPad; j++ { + runes = append(runes, dashChar) + } + + var batch []rune + curCls := classOf(runes[0]) + for _, ch := range runes { + cls := classOf(ch) + if cls != curCls && len(batch) > 0 { + result.WriteString(styleOf(batch[0]).Render(string(batch))) + batch = batch[:0] + } + curCls = cls + batch = append(batch, ch) + } + if len(batch) > 0 { + result.WriteString(styleOf(batch[0]).Render(string(batch))) + } + if i < len(lines)-1 { + result.WriteByte('\n') + } + } + return result.String() +} + +// RenderPhaseCard renders content inside a fixed-width card with the Shopware +// mascot art at the top, a divider, and the content below. +func RenderPhaseCard(content string) string { + innerW := PhaseCardWidth - 2 + + logoSection := lipgloss.NewStyle(). + Width(innerW). + Render(renderMascotArt(innerW)) + + divider := lipgloss.NewStyle().Foreground(BorderColor).Render(strings.Repeat("─", innerW)) + + contentSection := lipgloss.NewStyle(). + Width(innerW). + Padding(1, 3). + Render(content) + + inner := lipgloss.JoinVertical(lipgloss.Left, logoSection, divider, contentSection) + + return lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(BorderColor). + Width(PhaseCardWidth). + Render(inner) +} diff --git a/internal/tui/selectlist.go b/internal/tui/selectlist.go new file mode 100644 index 00000000..17870b5c --- /dev/null +++ b/internal/tui/selectlist.go @@ -0,0 +1,46 @@ +package tui + +import ( + "strings" + + "charm.land/lipgloss/v2" +) + +// SelectOption describes a single option in a select list. +type SelectOption struct { + Label string + Detail string +} + +// RenderSelectList renders a titled option list with a ● selector on the active item. +func RenderSelectList(title, description string, options []SelectOption, cursor int) string { + var s strings.Builder + + selectorStyle := lipgloss.NewStyle().Foreground(BrandColor) + selectedStyle := lipgloss.NewStyle().Foreground(BrandColor) + + s.WriteString(TitleStyle.Render(title)) + s.WriteString("\n") + if description != "" { + s.WriteString(lipgloss.NewStyle().Foreground(MutedColor).Render(description)) + s.WriteString("\n") + } + s.WriteString("\n") + + for i, opt := range options { + detail := "" + if opt.Detail != "" { + detail = " " + DimStyle.Render("("+opt.Detail+")") + } + if i == cursor { + s.WriteString(selectorStyle.Render("● ") + selectedStyle.Render(opt.Label) + detail) + } else { + s.WriteString(" " + FormatLabel(opt.Label, opt.Detail)) + } + if i < len(options)-1 { + s.WriteString("\n") + } + } + + return s.String() +} diff --git a/internal/tui/shortcuts.go b/internal/tui/shortcuts.go new file mode 100644 index 00000000..28637ad3 --- /dev/null +++ b/internal/tui/shortcuts.go @@ -0,0 +1,40 @@ +package tui + +import ( + "charm.land/lipgloss/v2" +) + +// Shortcut describes a single keyboard shortcut to display in a footer bar. +type Shortcut struct { + Key string + Label string +} + +var ( + badgeKeyStyle = lipgloss.NewStyle(). + Foreground(TextColor). + Background(SubtleBgColor). + Padding(0, 1) + + badgeDescStyle = lipgloss.NewStyle(). + Foreground(MutedColor) +) + +// ShortcutBadge renders a single keyboard shortcut as a styled badge. +func ShortcutBadge(key, label string) string { + return badgeKeyStyle.Render(key) + badgeDescStyle.Render(" "+label) +} + +// ShortcutBar joins multiple shortcuts into a horizontal bar separated by dividers. +func ShortcutBar(shortcuts ...Shortcut) string { + if len(shortcuts) == 0 { + return "" + } + + sep := lipgloss.NewStyle().Foreground(BorderColor).Render(" │ ") + result := ShortcutBadge(shortcuts[0].Key, shortcuts[0].Label) + for _, s := range shortcuts[1:] { + result += sep + ShortcutBadge(s.Key, s.Label) + } + return result +} diff --git a/internal/tui/steps.go b/internal/tui/steps.go new file mode 100644 index 00000000..d290343c --- /dev/null +++ b/internal/tui/steps.go @@ -0,0 +1,26 @@ +package tui + +import ( + "charm.land/lipgloss/v2" +) + +// fixedIndicator renders an indicator character inside a fixed 2-character-wide column. +func fixedIndicator(indicator string) string { + return lipgloss.NewStyle().Width(2).Render(indicator) +} + +// StepDone renders a completed step with a green checkmark indicator. +func StepDone(label string) string { + return fixedIndicator(Checkmark) + label + "\n" +} + +// StepActive renders an in-progress step with the provided spinner frame. +func StepActive(spinnerView, label string) string { + return fixedIndicator(spinnerView) + label + "\n" +} + +// StepPending renders a step that hasn't started yet with a dimmed dot indicator. +func StepPending(label string) string { + pending := DimStyle.Render("·") + return fixedIndicator(pending) + label + "\n" +} diff --git a/internal/tui/table.go b/internal/tui/table.go new file mode 100644 index 00000000..59eeb7f6 --- /dev/null +++ b/internal/tui/table.go @@ -0,0 +1,41 @@ +package tui + +import ( + "image/color" + "strings" + + "charm.land/lipgloss/v2" +) + +// TruncateToWidth truncates a string to fit within maxW rune positions, +// appending "…" if truncation occurs. +func TruncateToWidth(s string, maxW int) string { + if maxW <= 0 { + return "" + } + runes := []rune(s) + if len(runes) <= maxW { + return s + } + if maxW <= 1 { + return "…" + } + return string(runes[:maxW-1]) + "…" +} + +// TableCell renders a fixed-width cell with horizontal padding of 1 on each side. +func TableCell(content string, width int, fg color.Color, selected bool) string { + s := lipgloss.NewStyle().Padding(0, 1).Width(width) + if selected { + s = s.Background(SelectedBgColor) + } + if fg != nil { + s = s.Foreground(fg) + } + return s.Render(TruncateToWidth(content, width-2)) +} + +// TableDivider renders a full-width horizontal line in the border color. +func TableDivider(width int) string { + return lipgloss.NewStyle().Foreground(BorderColor).Render(strings.Repeat("─", width)) +} diff --git a/internal/tui/theme.go b/internal/tui/theme.go new file mode 100644 index 00000000..8f2490b5 --- /dev/null +++ b/internal/tui/theme.go @@ -0,0 +1,138 @@ +package tui + +import ( + "fmt" + "image/color" + "os" + + "charm.land/huh/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/term" +) + +var hasDarkBG = lipgloss.HasDarkBackground(os.Stdin, os.Stdout) + +const maxPanelWidth = 80 + +// TerminalWidth returns the current terminal width, falling back to +// maxPanelWidth if it cannot be determined. +func TerminalWidth() int { + w, _, err := term.GetSize(os.Stdout.Fd()) + if err != nil || w <= 0 { + return maxPanelWidth + } + return w +} + +// PanelWidth returns the terminal width capped at maxPanelWidth. +func PanelWidth() int { + w := TerminalWidth() + if w > maxPanelWidth { + return maxPanelWidth + } + return w +} + +var ( + BrandColor = lipgloss.Color("#076FFF") + SuccessColor = lipgloss.Color("#04B575") + MutedColor = adaptiveMuted() + ErrorColor = lipgloss.Color("#FF4D4D") + + // TextColor is the primary foreground for labels, headings, and prominent content. + TextColor = adaptiveColor("#FFFFFF", "#1A1A1A") + + // BorderColor is used for box borders, dividers, and separator lines. + BorderColor = adaptiveColor("#303030", "#BCBCBC") + + // SubtleBgColor is used for inactive tab backgrounds, badges, and other subtle background fills. + SubtleBgColor = adaptiveColor("#444444", "#D0D0D0") + + // LinkColor is used for clickable hyperlinks. + LinkColor = adaptiveColor("#5F87FF", "#0550AE") + + // WarnColor is used for warning-level indicators. + WarnColor = adaptiveColor("#FFAA00", "#B35800") + + // SelectedBgColor is used for highlighted/selected rows in lists and tables. + SelectedBgColor = adaptiveColor("#303030", "#E4E4E4") + + Checkmark = lipgloss.NewStyle().Foreground(SuccessColor).Bold(true).Render("✓") + + DimStyle = lipgloss.NewStyle().Foreground(MutedColor) + BoldStyle = lipgloss.NewStyle().Bold(true) + + SectionTitleStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(BrandColor) +) + +func adaptiveColor(dark, light string) color.Color { + if hasDarkBG { + return lipgloss.Color(dark) + } + return lipgloss.Color(light) +} + +func adaptiveMuted() color.Color { + return adaptiveColor("#999999", "#666666") +} + +// Hyperlink returns an OSC 8 hyperlink sequence wrapping label with the given URL. +func Hyperlink(url, label string) string { + return fmt.Sprintf("\x1b]8;;%s\x1b\\%s\x1b]8;;\x1b\\", url, label) +} + +// StyledLink renders a clickable terminal hyperlink with the given lipgloss style. +func StyledLink(url, label string, style lipgloss.Style) string { + return Hyperlink(url, style.Render(label)) +} + +// ShopwareTheme returns a huh form theme styled with the Shopware brand colors. +func ShopwareTheme() huh.Theme { + return huh.ThemeFunc(func(isDark bool) *huh.Styles { + t := huh.ThemeCharm(isDark) + + brand := lipgloss.Color("#076FFF") + + var green, cream, muted color.Color + if isDark { + green = lipgloss.Color("#02BF87") + cream = lipgloss.Color("#FFFDF5") + muted = lipgloss.Color("#999999") + } else { + green = lipgloss.Color("#02BA84") + cream = lipgloss.Color("#FFFDF5") + muted = lipgloss.Color("#666666") + } + + t.Focused.Title = t.Focused.Title.Foreground(brand).Bold(true).MarginBottom(0) + t.Focused.NoteTitle = t.Focused.NoteTitle.Foreground(brand).Bold(true).MarginBottom(1) + t.Focused.Directory = t.Focused.Directory.Foreground(brand) + t.Focused.SelectSelector = t.Focused.SelectSelector.Foreground(brand) + t.Focused.NextIndicator = t.Focused.NextIndicator.Foreground(brand) + t.Focused.PrevIndicator = t.Focused.PrevIndicator.Foreground(brand) + t.Focused.MultiSelectSelector = t.Focused.MultiSelectSelector.Foreground(brand) + t.Focused.SelectedOption = t.Focused.SelectedOption.Foreground(green) + t.Focused.SelectedPrefix = lipgloss.NewStyle().Foreground(green).SetString("✓ ") + t.Focused.FocusedButton = t.Focused.FocusedButton.Foreground(cream).Background(brand) + t.Focused.Next = t.Focused.FocusedButton + t.Focused.TextInput.Prompt = t.Focused.TextInput.Prompt.Foreground(brand) + t.Focused.TextInput.Cursor = t.Focused.TextInput.Cursor.Foreground(brand) + t.Focused.Base = t.Focused.Base.BorderForeground(brand).PaddingLeft(2) + t.Focused.Card = t.Focused.Base + + t.Focused.Description = t.Focused.Description.Foreground(muted) + + t.Blurred = t.Focused + t.Blurred.Base = t.Focused.Base.BorderStyle(lipgloss.HiddenBorder()) + t.Blurred.Card = t.Blurred.Base + t.Blurred.NextIndicator = lipgloss.NewStyle() + t.Blurred.PrevIndicator = lipgloss.NewStyle() + + t.Group.Title = t.Focused.Title + t.Group.Description = t.Focused.Description + + return t + }) +} diff --git a/shopware-project-schema.json b/shopware-project-schema.json index 38fff6bd..8509a8a9 100644 --- a/shopware-project-schema.json +++ b/shopware-project-schema.json @@ -38,6 +38,17 @@ "image_proxy": { "$ref": "#/$defs/ConfigImageProxy" }, + "docker": { + "$ref": "#/$defs/ConfigDocker", + "description": "Docker dev environment configuration" + }, + "environments": { + "additionalProperties": { + "$ref": "#/$defs/EnvironmentConfig" + }, + "type": "object", + "description": "Named environments for multi-environment management" + }, "disable_composer_scripts": { "type": "boolean", "description": "When enabled, composer scripts will be disabled during CI builds" @@ -360,6 +371,104 @@ "type": "object", "title": "Extension overrides" }, + "ConfigDocker": { + "properties": { + "php": { + "$ref": "#/$defs/ConfigDockerPHP", + "description": "PHP configuration for the Docker dev image" + }, + "node": { + "$ref": "#/$defs/ConfigDockerNode", + "description": "Node.js configuration for the Docker dev image" + } + }, + "additionalProperties": false, + "type": "object" + }, + "ConfigDockerNode": { + "properties": { + "version": { + "type": "string", + "enum": [ + "22", + "24" + ], + "description": "Node.js version (e.g. \"22\", \"24\"). Defaults to \"22\"." + } + }, + "additionalProperties": false, + "type": "object" + }, + "ConfigDockerPHP": { + "allOf": [ + { + "if": { + "properties": { + "profiler": { + "const": "blackfire" + } + }, + "required": [ + "profiler" + ] + }, + "then": { + "required": [ + "blackfire_server_id", + "blackfire_server_token" + ] + } + }, + { + "if": { + "properties": { + "profiler": { + "const": "tideways" + } + }, + "required": [ + "profiler" + ] + }, + "then": { + "required": [ + "tideways_api_key" + ] + } + } + ], + "properties": { + "version": { + "type": "string", + "description": "PHP version (e.g. \"8.3\", \"8.2\"). Defaults to \"8.3\"." + }, + "profiler": { + "type": "string", + "enum": [ + "xdebug", + "blackfire", + "tideways", + "pcov", + "spx" + ], + "description": "Profiler to enable. Possible values: xdebug, blackfire, tideways, pcov, spx." + }, + "blackfire_server_id": { + "type": "string", + "description": "Blackfire server ID from your Blackfire account. Required when profiler is \"blackfire\"." + }, + "blackfire_server_token": { + "type": "string", + "description": "Blackfire server token from your Blackfire account. Required when profiler is \"blackfire\"." + }, + "tideways_api_key": { + "type": "string", + "description": "Tideways API key from your Tideways account. Required when profiler is \"tideways\"." + } + }, + "additionalProperties": false, + "type": "object" + }, "ConfigDump": { "properties": { "rewrite": { @@ -473,6 +582,25 @@ "additionalProperties": false, "type": "object", "description": "ConfigValidationIgnoreItem is used to ignore items from the validation." + }, + "EnvironmentConfig": { + "properties": { + "type": { + "type": "string", + "enum": [ + "local", + "docker" + ] + }, + "url": { + "type": "string" + }, + "admin_api": { + "$ref": "#/$defs/ConfigAdminApi" + } + }, + "additionalProperties": false, + "type": "object" } } } \ No newline at end of file