diff --git a/cmd/project/ci.go b/cmd/project/ci.go index 673c9eea..6aa43f6e 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) + if err != nil { + return err + } + shopCfg, err := shop.ReadConfig(cmd.Context(), projectConfigPath, true) if err != nil { return err @@ -99,7 +103,7 @@ var projectCI = &cobra.Command{ composerInstallSection := ci.Default.Section(cmd.Context(), "Composer Installation") - composer := phpexec.ComposerCommand(cmd.Context(), composerFlags...) + composer := cmdExecutor.ComposerCommand(cmd.Context(), composerFlags...) composer.Dir = args[0] composer.Stdin = os.Stdin composer.Stdout = os.Stdout @@ -214,7 +218,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) } @@ -229,7 +233,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) } } diff --git a/cmd/project/executor.go b/cmd/project/executor.go new file mode 100644 index 00000000..bed2d279 --- /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) (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(envCfg, cfg) +} diff --git a/cmd/project/platform.go b/cmd/project/platform.go index b35651fe..ee12c252 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" ) @@ -89,7 +89,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) + 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..b0b1a74c 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) + 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(commandWithRoot(cmdExecutor.ConsoleCommand(executor.AllowBinCI(cmd.Context()), "feature:dump"), projectRoot)); err != nil { return err } @@ -69,7 +74,7 @@ var projectAdminBuildCmd = &cobra.Command{ return nil } - return runTransparentCommand(commandWithRoot(phpexec.ConsoleCommand(cmd.Context(), "assets:install"), projectRoot)) + return runTransparentCommand(commandWithRoot(cmdExecutor.ConsoleCommand(cmd.Context(), "assets:install"), projectRoot)) }, } diff --git a/cmd/project/project_admin_watch.go b/cmd/project/project_admin_watch.go index be9f63bf..1dfb1024 100644 --- a/cmd/project/project_admin_watch.go +++ b/cmd/project/project_admin_watch.go @@ -9,7 +9,6 @@ import ( "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" ) @@ -36,11 +35,16 @@ var projectAdminWatchCmd = &cobra.Command{ return err } + cmdExecutor, err := resolveExecutor(cmd) + if err != nil { + return err + } + if err := filterAndWritePluginJson(cmd, projectRoot, shopCfg); err != nil { return err } - if err := runTransparentCommand(commandWithRoot(phpexec.ConsoleCommand(cmd.Context(), "feature:dump"), projectRoot)); err != nil { + if err := runTransparentCommand(commandWithRoot(cmdExecutor.ConsoleCommand(cmd.Context(), "feature:dump"), projectRoot)); err != nil { return err } @@ -68,7 +72,7 @@ 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 { + if err := runTransparentCommand(commandWithRoot(cmdExecutor.ConsoleCommand(cmd.Context(), "framework:schema", "-s", "entity-schema", path.Join(mockDirectory, "entity-schema.json")), projectRoot)); err != nil { return err } 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..02d52efa 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) + if err != nil { + return nil, cobra.ShellCompDirectiveDefault + } + + parsedCommands, err := shop.GetConsoleCompletion(cmd.Context(), projectRoot, exec.ConsoleCommand) if err != nil { return nil, cobra.ShellCompDirectiveDefault } @@ -79,7 +83,12 @@ var projectConsoleCmd = &cobra.Command{ return err } - consoleCmd := phpexec.ConsoleCommand(cmd.Context(), args...) + exec, err := resolveExecutor(cmd) + if err != nil { + return err + } + + consoleCmd := exec.ConsoleCommand(cmd.Context(), args...) consoleCmd.Dir = projectRoot consoleCmd.Stdin = cmd.InOrStdin() consoleCmd.Stdout = cmd.OutOrStdout() diff --git a/cmd/project/project_create.go b/cmd/project/project_create.go index df4d4819..a27ec93c 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" @@ -22,8 +19,10 @@ import ( "github.com/spf13/cobra" "github.com/shopware/shopware-cli/internal/color" + 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/logging" @@ -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{ @@ -70,7 +72,6 @@ var projectCreateCmd = &cobra.Command{ RunE: func(cmd *cobra.Command, args []string) error { 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") @@ -79,7 +80,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 } @@ -398,7 +399,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, @@ -461,6 +461,12 @@ var projectCreateCmd = &cobra.Command{ return err } + if useDocker { + if err := dockerpkg.WriteComposeFile(projectFolder); err != nil { + return err + } + } + if initGit { logging.FromContext(cmd.Context()).Infof("Initializing Git repository") if err := git.Init(cmd.Context(), projectFolder); err != nil { @@ -470,6 +476,15 @@ var projectCreateCmd = &cobra.Command{ logging.FromContext(cmd.Context()).Infof("Project created successfully in %s", projectFolder) + shopCfg := shop.NewConfig() + if useDocker { + shopCfg.Environments["local"].Type = "docker" + } + + if err := shop.WriteConfig(shopCfg, projectFolder); err != nil { + return err + } + if useDocker { cmdStyle := lipgloss.NewStyle().Bold(true) sectionStyle := lipgloss.NewStyle().Bold(true).Underline(true) @@ -477,9 +492,7 @@ var projectCreateCmd = &cobra.Command{ fmt.Println() fmt.Println(sectionStyle.Render("Next steps")) fmt.Println() - fmt.Printf(" %s %s\n", color.GreenText.Render("Start containers:"), cmdStyle.Render(fmt.Sprintf("cd %s && make up", projectFolder))) - fmt.Printf(" %s %s\n", color.GreenText.Render("Setup Shopware:"), cmdStyle.Render("make setup")) - fmt.Printf(" %s %s\n", color.GreenText.Render("Stop containers:"), cmdStyle.Render("make down")) + fmt.Printf(" %s %s\n", color.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() @@ -530,15 +543,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 } } @@ -657,7 +662,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 } @@ -666,7 +671,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) @@ -675,6 +687,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 } @@ -682,7 +698,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") @@ -690,34 +707,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..14b5c659 --- /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(envCfg, cfg) + if err != nil { + return err + } + + if exec.Type() == "docker" { + if err := dockerpkg.WriteComposeFile(projectRoot); 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..7e7dc299 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) + 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(commandWithRoot(cmdExecutor.ConsoleCommand(executor.AllowBinCI(cmd.Context()), "feature:dump"), projectRoot)); err != nil { return err } @@ -68,7 +73,7 @@ var projectStorefrontBuildCmd = &cobra.Command{ return nil } - return runTransparentCommand(commandWithRoot(phpexec.ConsoleCommand(phpexec.AllowBinCI(cmd.Context()), "theme:compile"), projectRoot)) + return runTransparentCommand(commandWithRoot(cmdExecutor.ConsoleCommand(executor.AllowBinCI(cmd.Context()), "theme:compile"), projectRoot)) }, } diff --git a/cmd/project/project_storefront_watch.go b/cmd/project/project_storefront_watch.go index 916f3af3..645345dc 100644 --- a/cmd/project/project_storefront_watch.go +++ b/cmd/project/project_storefront_watch.go @@ -9,7 +9,6 @@ import ( "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" ) @@ -36,11 +35,16 @@ var projectStorefrontWatchCmd = &cobra.Command{ return err } + cmdExecutor, err := resolveExecutor(cmd) + if err != nil { + return err + } + if err := filterAndWritePluginJson(cmd, projectRoot, shopCfg); err != nil { return err } - if err := runTransparentCommand(commandWithRoot(phpexec.ConsoleCommand(cmd.Context(), "feature:dump"), projectRoot)); err != nil { + if err := runTransparentCommand(commandWithRoot(cmdExecutor.ConsoleCommand(cmd.Context(), "feature:dump"), projectRoot)); err != nil { return err } @@ -50,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(commandWithRoot(cmdExecutor.ConsoleCommand(cmd.Context(), "theme:compile", activeOnly), projectRoot)); err != nil { return err } - if err := runTransparentCommand(commandWithRoot(phpexec.ConsoleCommand(cmd.Context(), "theme:dump"), projectRoot)); err != nil { + if err := runTransparentCommand(commandWithRoot(cmdExecutor.ConsoleCommand(cmd.Context(), "theme:dump"), projectRoot)); err != nil { return err } diff --git a/cmd/project/project_worker.go b/cmd/project/project_worker.go index 31ec9a74..9e6cb12c 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) + if err != nil { + return err + } + if len(args) > 0 { workerAmount, err = strconv.Atoi(args[0]) if err != nil { @@ -97,7 +101,7 @@ var projectWorkerCmd = &cobra.Command{ continue } - cmd := phpexec.ConsoleCommand(cancelCtx, consumeArgs...) + cmd := cmdExecutor.ConsoleCommand(cancelCtx, consumeArgs...) cmd.Dir = projectRoot cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr 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/go.mod b/go.mod index 73bcd8d1..5dc15da0 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.1 charm.land/lipgloss/v2 v2.0.1 dario.cat/mergo v1.0.2 @@ -11,6 +13,7 @@ require ( github.com/bep/godartsass/v2 v2.5.0 github.com/caarlos0/env/v9 v9.0.0 github.com/cespare/xxhash/v2 v2.3.0 + github.com/charmbracelet/x/ansi v0.11.6 github.com/evanw/esbuild v0.27.3 github.com/go-sql-driver/mysql v1.9.3 github.com/gorilla/schema v1.4.1 @@ -38,8 +41,6 @@ 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 @@ -47,7 +48,6 @@ require ( github.com/catppuccin/go v0.3.0 // indirect github.com/charmbracelet/colorprofile v0.4.2 // 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 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/model.go b/internal/devtui/model.go new file mode 100644 index 00000000..283b030d --- /dev/null +++ b/internal/devtui/model.go @@ -0,0 +1,799 @@ +package devtui + +import ( + "bufio" + "context" + "fmt" + "os/exec" + "strings" + + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/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" + keyY = "y" + keyYUpper = "Y" + keyN = "n" + keyNUpper = "N" + keyF = "f" + keyA = "a" + keyJ = "j" + keyK = "k" + key1 = "1" + key2 = "2" + + defaultUsername = "admin" +) + +type overlay int + +const ( + overlayNone overlay = iota + overlayStarting + overlayStopConfirm + overlayStopping + overlayInstallPrompt + overlayInstalling +) + +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 + 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 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 + 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 != overlayNone { + return m, nil + } + + return m.updateChildren(msg) +} + +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 + return m, 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:] + } + 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, username: usernameInput, password: passwordInput} + return m, nil + + case shopwareInstallDoneMsg: + if msg.err != nil { + 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 + } + + 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 == overlayInstallPrompt { + return m.updateInstallPrompt(msg) + } + + if m.overlay == overlayStopConfirm { + switch msg.String() { + case keyY, keyYUpper: + m.overlay = overlayStopping + m.overlayLines = nil + return m, m.stopContainers() + case keyN, keyNUpper: + 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 keyCtrlC, keyQ: + m.logs.StopStreaming() + if m.dockerMode { + m.overlay = overlayStopConfirm + m.overlayLines = nil + 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 + case keyF: + if m.activeTab == tabGeneral { + return m, openInBrowser(m.general.shopURL) + } + case keyA: + if m.activeTab == tabGeneral { + return m, openInBrowser(m.general.adminURL) + } + } + + 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...) +} + +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 keyY, keyYUpper: + m.install.step = installStepLanguage + m.install.cursor = 0 + case keyN, keyNUpper: + 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 + return m, m.runShopwareInstall() + default: + var cmd tea.Cmd + m.install.password, cmd = m.install.password.Update(msg) + return m, cmd + } + } + + return m, nil +} + +func (m Model) View() tea.View { + var b strings.Builder + + if m.overlay != overlayNone { + b.WriteString(m.renderOverlay()) + } else { + b.WriteString(m.renderTabBar()) + b.WriteString("\n\n") + + switch m.activeTab { + case tabGeneral: + b.WriteString(m.general.View()) + case tabLogs: + b.WriteString(m.logs.View()) + } + } + + content := appStyle.Render(b.String()) + if m.width > 0 && m.height > 0 { + content = lipgloss.Place( + m.width, + m.height, + lipgloss.Left, + lipgloss.Top, + content, + lipgloss.WithWhitespaceStyle(surfaceTextStyle), + ) + } + + v := tea.NewView(content) + v.AltScreen = true + return v +} + +func (m Model) renderOverlay() string { + var title string + switch m.overlay { + case overlayNone: + title = "" + case overlayStarting: + title = "Starting Docker containers..." + case overlayStopConfirm: + title = "Stop Docker containers?" + case overlayStopping: + title = "Stopping Docker containers..." + case overlayInstallPrompt: + title = "Shopware is not installed" + case overlayInstalling: + title = "Installing Shopware..." + } + + var content strings.Builder + content.WriteString(panelHeaderStyle.Render(title)) + content.WriteString("\n\n") + + switch m.overlay { + case overlayNone: + // No overlay content needed + case overlayStarting, overlayStopping, overlayInstalling: + for _, line := range m.overlayLines { + content.WriteString(panelTextStyle.Render(line) + "\n") + } + if len(m.overlayLines) == 0 { + content.WriteString(helpStyle.Render("Waiting for command output...")) + } + case overlayStopConfirm: + content.WriteString("Do you want to stop the Docker containers?\n\n") + content.WriteString(renderFooter( + renderKeyHint("y", "Stop containers"), + renderKeyHint("n", "Quit without stopping"), + )) + case overlayInstallPrompt: + m.renderInstallPrompt(&content) + } + + style := overlayStyle + if m.overlay == overlayStarting || m.overlay == overlayStopping || m.overlay == overlayInstalling { + if m.width > 0 && m.height > 0 { + style = style.Width(m.width - 2).Height(m.height - 2) + } + } + + modal := style.Render(content.String()) + + if m.width > 0 && m.height > 0 { + modal = lipgloss.Place( + m.width, + m.height, + lipgloss.Center, + lipgloss.Center, + modal, + lipgloss.WithWhitespaceStyle(surfaceTextStyle), + ) + } + + return modal +} + +// 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: + b.WriteString("Would you like to install Shopware now?\n\n") + b.WriteString(renderFooter( + renderKeyHint("y", "Install now"), + renderKeyHint("n", "Skip for now"), + renderKeyHint("q", "Quit"), + )) + + case installStepLanguage: + b.WriteString("Select default language:\n\n") + for i, lang := range installLanguages { + style := sidebarItemStyle + if i == m.install.cursor { + style = selectedSidebarItemStyle + } + b.WriteString(style.Render(lang.label) + "\n") + } + b.WriteString("\n") + b.WriteString(renderFooter( + renderKeyHint("↑/↓", "Select"), + renderKeyHint("enter", "Confirm"), + renderKeyHint("q", "Quit"), + )) + + case installStepCurrency: + fmt.Fprintf(b, "Language: %s\n\n", valueStyle.Render(m.install.language)) + b.WriteString("Select default currency:\n\n") + for i, curr := range installCurrencies { + style := sidebarItemStyle + if i == m.install.cursor { + style = selectedSidebarItemStyle + } + b.WriteString(style.Render(curr) + "\n") + } + b.WriteString("\n") + b.WriteString(renderFooter( + renderKeyHint("↑/↓", "Select"), + renderKeyHint("enter", "Confirm"), + renderKeyHint("q", "Quit"), + )) + + case installStepUsername: + fmt.Fprintf(b, "Language: %s\n", valueStyle.Render(m.install.language)) + fmt.Fprintf(b, "Currency: %s\n\n", valueStyle.Render(m.install.currency)) + b.WriteString("Admin username:\n\n") + b.WriteString(m.install.username.View()) + b.WriteString("\n\n") + b.WriteString(renderFooter( + renderKeyHint("enter", "Continue"), + renderKeyHint("q", "Quit"), + )) + + case installStepPassword: + fmt.Fprintf(b, "Language: %s\n", valueStyle.Render(m.install.language)) + fmt.Fprintf(b, "Currency: %s\n", valueStyle.Render(m.install.currency)) + fmt.Fprintf(b, "Username: %s\n\n", valueStyle.Render(m.install.username.Value())) + b.WriteString("Admin password:\n\n") + b.WriteString(m.install.password.View()) + b.WriteString("\n\n") + b.WriteString(renderFooter( + renderKeyHint("enter", "Install"), + renderKeyHint("q", "Quit"), + )) + } +} + +func (m Model) renderTabBar() string { + var tabs []string + for i, name := range tabNames { + label := fmt.Sprintf(" %d: %s ", i+1, name) + if activeTab(i) == m.activeTab { + tabs = append(tabs, activeTabStyle.Render(label)) + } else { + tabs = append(tabs, inactiveTabStyle.Render(label)) + } + } + return lipgloss.JoinHorizontal(lipgloss.Top, tabs...) +} + +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 + projectRoot := m.projectRoot + return func() tea.Msg { + cmd := exec.ConsoleCommand(context.Background(), "system:is-installed") + cmd.Dir = projectRoot + if err := cmd.Run(); err != nil { + return shopwareNotInstalledMsg{} + } + return shopwareInstalledMsg{} + } +} + +func (m *Model) runShopwareInstall() tea.Cmd { + e := m.executor + projectRoot := m.projectRoot + language := m.install.language + currency := m.install.currency + + ch := make(chan string, 50) + m.dockerOutChan = ch + + outputCmd := func() tea.Msg { + line, ok := <-ch + if !ok { + return dockerOutputDoneMsg{} + } + return dockerOutputLineMsg(line) + } + + username := m.install.username.Value() + password := m.install.password.Value() + + 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") + cmd.Dir = projectRoot + + pipe, err := cmd.StdoutPipe() + if err != nil { + close(ch) + return shopwareInstallDoneMsg{err: err} + } + cmd.Stderr = cmd.Stdout + + if err := cmd.Start(); err != nil { + close(ch) + return shopwareInstallDoneMsg{err: err} + } + + scanner := bufio.NewScanner(pipe) + for scanner.Scan() { + ch <- scanner.Text() + } + close(ch) + + err = cmd.Wait() + return shopwareInstallDoneMsg{err: err} + } + + return tea.Batch(outputCmd, doneCmd) +} + +func (m *Model) readNextDockerOutput() tea.Cmd { + ch := m.dockerOutChan + if ch == nil { + return nil + } + return func() tea.Msg { + line, ok := <-ch + if !ok { + return dockerOutputDoneMsg{} + } + return dockerOutputLineMsg(line) + } +} + +// 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, 50) + + outputCmd = func() tea.Msg { + line, ok := <-lineChan + if !ok { + return dockerOutputDoneMsg{} + } + return dockerOutputLineMsg(line) + } + + doneCmd = func() tea.Msg { + cmd := exec.CommandContext(ctx, "docker", args...) + cmd.Dir = projectRoot + + pipe, err := cmd.StderrPipe() + if err != nil { + close(lineChan) + return resultFn(err) + } + cmd.Stdout = cmd.Stderr + + if err := cmd.Start(); err != nil { + close(lineChan) + return resultFn(err) + } + + scanner := bufio.NewScanner(pipe) + for scanner.Scan() { + lineChan <- scanner.Text() + } + close(lineChan) + + err = cmd.Wait() + return resultFn(err) + } + + return lineChan, outputCmd, 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/styles.go b/internal/devtui/styles.go new file mode 100644 index 00000000..92998711 --- /dev/null +++ b/internal/devtui/styles.go @@ -0,0 +1,314 @@ +package devtui + +import ( + "strings" + + "charm.land/lipgloss/v2" + "charm.land/lipgloss/v2/compat" + "github.com/charmbracelet/x/ansi" +) + +const generalLabelWidth = 16 + +var ( + surfaceColor = compat.AdaptiveColor{ + Light: lipgloss.Color("#F5F7FA"), + Dark: lipgloss.Color("#12182A"), + } + panelColor = compat.AdaptiveColor{ + Light: lipgloss.Color("#FFFFFF"), + Dark: lipgloss.Color("#171E33"), + } + panelAccentColor = compat.AdaptiveColor{ + Light: lipgloss.Color("#E4EAF7"), + Dark: lipgloss.Color("#222D48"), + } + borderColor = compat.AdaptiveColor{ + Light: lipgloss.Color("#CBD5E1"), + Dark: lipgloss.Color("#46506A"), + } + mutedBorderColor = compat.AdaptiveColor{ + Light: lipgloss.Color("#E2E8F0"), + Dark: lipgloss.Color("#313A53"), + } + textColor = compat.AdaptiveColor{ + Light: lipgloss.Color("#0F172A"), + Dark: lipgloss.Color("#D7DEF5"), + } + mutedTextColor = compat.AdaptiveColor{ + Light: lipgloss.Color("#64748B"), + Dark: lipgloss.Color("#8B95B5"), + } + accentColor = compat.AdaptiveColor{ + Light: lipgloss.Color("#0F766E"), + Dark: lipgloss.Color("#36D7B7"), + } + accentBgColor = compat.AdaptiveColor{ + Light: lipgloss.Color("#D7F5F0"), + Dark: lipgloss.Color("#103D3A"), + } + warningColor = compat.AdaptiveColor{ + Light: lipgloss.Color("#A16207"), + Dark: lipgloss.Color("#F8C146"), + } + warningBgColor = compat.AdaptiveColor{ + Light: lipgloss.Color("#FEF3C7"), + Dark: lipgloss.Color("#3A2D12"), + } + errorColor = compat.AdaptiveColor{ + Light: lipgloss.Color("#DC2626"), + Dark: lipgloss.Color("#F87171"), + } + errorBgColor = compat.AdaptiveColor{ + Light: lipgloss.Color("#FEE2E2"), + Dark: lipgloss.Color("#3D1F26"), + } +) + +var ( + appStyle = lipgloss.NewStyle(). + Background(surfaceColor). + Foreground(textColor). + Padding(0, 1) + + surfaceTextStyle = lipgloss.NewStyle(). + Background(surfaceColor). + Foreground(textColor) + + surfaceMutedTextStyle = lipgloss.NewStyle(). + Background(surfaceColor). + Foreground(mutedTextColor) + + panelTextStyle = lipgloss.NewStyle(). + Background(panelColor). + Foreground(textColor) + + activeTabStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(textColor). + Background(panelAccentColor). + Border(lipgloss.RoundedBorder()). + BorderForeground(borderColor). + BorderBackground(surfaceColor). + Padding(0, 2). + MarginRight(1) + + inactiveTabStyle = lipgloss.NewStyle(). + Foreground(mutedTextColor). + Background(surfaceColor). + Padding(1, 2). + MarginRight(1) + + sectionStyle = lipgloss.NewStyle(). + Background(panelColor). + Border(lipgloss.RoundedBorder()). + BorderForeground(mutedBorderColor). + BorderBackground(surfaceColor). + Padding(1, 2) + + sectionTitleStyle = lipgloss.NewStyle(). + Background(panelColor). + Foreground(textColor). + Bold(true) + + labelStyle = lipgloss.NewStyle(). + Background(panelColor). + Foreground(mutedTextColor). + Width(generalLabelWidth) + + subLabelStyle = lipgloss.NewStyle(). + Background(panelColor). + Foreground(mutedTextColor). + Width(generalLabelWidth). + PaddingLeft(2) + + valueStyle = lipgloss.NewStyle(). + Background(panelColor). + Foreground(textColor) + + urlStyle = lipgloss.NewStyle(). + Background(panelColor). + Foreground(accentColor) + + secretStyle = lipgloss.NewStyle(). + Background(panelColor). + Foreground(warningColor) + + helpStyle = lipgloss.NewStyle(). + Background(panelColor). + Foreground(mutedTextColor) + + keyStyle = lipgloss.NewStyle(). + Foreground(textColor). + Background(panelAccentColor). + Padding(0, 1). + Bold(true) + + activeBadgeStyle = lipgloss.NewStyle(). + Foreground(accentColor). + Background(accentBgColor). + Padding(0, 1). + Bold(true) + + warningBadgeStyle = lipgloss.NewStyle(). + Foreground(warningColor). + Background(warningBgColor). + Padding(0, 1). + Bold(true) + + errorStyle = lipgloss.NewStyle(). + Background(panelColor). + Foreground(errorColor) + + errorBadgeStyle = lipgloss.NewStyle(). + Foreground(errorColor). + Background(errorBgColor). + Padding(0, 1). + Bold(true) + + sidebarStyle = lipgloss.NewStyle(). + Background(panelColor). + Border(lipgloss.RoundedBorder()). + BorderForeground(mutedBorderColor). + BorderBackground(surfaceColor). + Padding(1, 1) + + sidebarItemStyle = lipgloss.NewStyle(). + Background(panelColor). + Foreground(mutedTextColor). + Padding(0, 1) + + selectedSidebarItemStyle = lipgloss.NewStyle(). + Foreground(textColor). + Background(panelAccentColor). + Bold(true). + Padding(0, 1) + + activeSidebarItemStyle = lipgloss.NewStyle(). + Background(panelColor). + Foreground(accentColor). + Padding(0, 1) + + activeSelectedSidebarItemStyle = lipgloss.NewStyle(). + Foreground(textColor). + Background(accentBgColor). + Bold(true). + Padding(0, 1) + + contentPanelStyle = lipgloss.NewStyle(). + Background(panelColor). + Border(lipgloss.RoundedBorder()). + BorderForeground(mutedBorderColor). + BorderBackground(surfaceColor). + Padding(0, 1) + + panelHeaderStyle = lipgloss.NewStyle(). + Background(panelColor). + Foreground(textColor). + Bold(true). + Padding(0, 0, 1) + + overlayStyle = lipgloss.NewStyle(). + Background(panelColor). + Border(lipgloss.RoundedBorder()). + BorderForeground(borderColor). + BorderBackground(surfaceColor). + Padding(1, 3) +) + +func renderSection(title, body string) string { + return renderSectionWidth(title, body, 0) +} + +func renderSectionWidth(title, body string, width int) string { + contentWidth := lipgloss.Width(body) + if titleWidth := lipgloss.Width(title); titleWidth > contentWidth { + contentWidth = titleWidth + } + if contentWidth == 0 { + contentWidth = 1 + } + // If a fixed width is given, subtract sectionStyle's horizontal framing + // (border + padding) so the inner content fills the target width. + if width > 0 { + inner := width - sectionStyle.GetHorizontalFrameSize() + if inner > contentWidth { + contentWidth = inner + } + } + + header := sectionTitleStyle.Width(contentWidth).Render(title) + spacer := panelTextStyle.Width(contentWidth).Render("") + content := panelTextStyle.Width(contentWidth).Render(body) + + return sectionStyle.Render(lipgloss.JoinVertical(lipgloss.Left, header, spacer, content)) +} + +func renderKeyHint(key, action string) string { + return keyStyle.Render(strings.ToUpper(key)) + surfaceMutedTextStyle.Render(" "+action) +} + +func renderFooter(parts ...string) string { + filtered := make([]string, 0, len(parts)) + for _, part := range parts { + if strings.TrimSpace(part) == "" { + continue + } + filtered = append(filtered, part) + } + + if len(filtered) == 0 { + return "" + } + + separator := surfaceMutedTextStyle.Render(" | ") + style := surfaceMutedTextStyle.Padding(1, 0, 0) + return style.Render(strings.Join(filtered, separator)) +} + +func renderKVRow(label, value string, valueRenderer lipgloss.Style) string { + if value == "" { + value = "not configured" + valueRenderer = helpStyle + } + + return lipgloss.JoinHorizontal( + lipgloss.Top, + labelStyle.Render(label), + valueRenderer.Render(value), + ) +} + +func renderSubKVRow(label, value string, valueRenderer lipgloss.Style) string { + if value == "" { + value = "not configured" + valueRenderer = helpStyle + } + + return lipgloss.JoinHorizontal( + lipgloss.Top, + subLabelStyle.Render(label), + valueRenderer.Render(value), + ) +} + +func clampMin(value, minimum int) int { + if value < minimum { + return minimum + } + + return value +} + +// padLines pads each line of a rendered string to the given width using the +// provided style so that whitespace has the correct background color. +func padLines(s string, width int, style lipgloss.Style) string { + lines := strings.Split(s, "\n") + for i, line := range lines { + w := ansi.StringWidth(line) + if w < width { + lines[i] = line + style.Render(strings.Repeat(" ", width-w)) + } + } + return strings.Join(lines, "\n") +} diff --git a/internal/devtui/tab_general.go b/internal/devtui/tab_general.go new file mode 100644 index 00000000..2d019382 --- /dev/null +++ b/internal/devtui/tab_general.go @@ -0,0 +1,305 @@ +package devtui + +import ( + "context" + "encoding/json" + "fmt" + "os/exec" + "strings" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" +) + +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() string { + overviewRows := []string{ + renderKVRow("Environment", m.envType, activeBadgeStyle), + renderKVRow("Shop URL", m.shopURL, urlStyle), + renderKVRow("Admin URL", m.adminURL, urlStyle), + } + + credentialsRows := []string{ + renderKVRow("Username", m.username, valueStyle), + renderKVRow("Password", m.password, secretStyle), + } + + if m.username == "" && m.password == "" { + credentialsRows = []string{ + helpStyle.Render("Admin credentials will appear here once Shopware is installed."), + } + } + + contentWidth := max(m.width-2, 0) + + var content string + if m.width >= 110 { + columnWidth := contentWidth / 2 + overviewSection := renderSectionWidth("Shop", strings.Join(overviewRows, "\n"), columnWidth) + credentialsSection := renderSectionWidth("Admin Access", strings.Join(credentialsRows, "\n"), columnWidth) + servicesSection := renderSection("Services", m.renderServices()) + + leftCol := padLines(overviewSection, columnWidth, surfaceTextStyle) + rightCol := padLines(credentialsSection, columnWidth, surfaceTextStyle) + leftLines := strings.Split(leftCol, "\n") + rightLines := strings.Split(rightCol, "\n") + rowHeight := max(len(leftLines), len(rightLines)) + emptyLine := surfaceTextStyle.Render(strings.Repeat(" ", columnWidth)) + for len(leftLines) < rowHeight { + leftLines = append(leftLines, emptyLine) + } + for len(rightLines) < rowHeight { + rightLines = append(rightLines, emptyLine) + } + var topRowLines []string + for i := range rowHeight { + topRowLines = append(topRowLines, leftLines[i]+rightLines[i]) + } + topRow := strings.Join(topRowLines, "\n") + content = topRow + "\n" + padLines(servicesSection, contentWidth, surfaceTextStyle) + } else { + overviewSection := renderSection("Shop", strings.Join(overviewRows, "\n")) + credentialsSection := renderSection("Admin Access", strings.Join(credentialsRows, "\n")) + servicesSection := renderSection("Services", m.renderServices()) + content = lipgloss.JoinVertical( + lipgloss.Left, + overviewSection, + credentialsSection, + servicesSection, + ) + } + + content = padLines(content, contentWidth, surfaceTextStyle) + + var footerHints []string + if m.shopURL != "" { + footerHints = append(footerHints, renderKeyHint("f", "Open shop")) + } + if m.adminURL != "" && m.adminURL != "admin" { + footerHints = append(footerHints, renderKeyHint("a", "Open admin")) + } + + footer := padLines(renderFooter(footerHints...), contentWidth, surfaceTextStyle) + + body := "\n" + content + + // Pin footer to bottom by filling remaining height with styled whitespace + bodyHeight := lipgloss.Height(body) + footerHeight := lipgloss.Height(footer) + if gap := m.height - bodyHeight - footerHeight; gap > 0 { + emptyLine := surfaceTextStyle.Render(strings.Repeat(" ", contentWidth)) + for range gap { + body += "\n" + emptyLine + } + } + + return body + "\n" + footer +} + +func (m GeneralModel) renderServices() string { + switch { + case m.loading: + return lipgloss.JoinVertical( + lipgloss.Left, + activeBadgeStyle.Render("SCANNING"), + helpStyle.Render("Looking for published local services."), + ) + case m.err != nil: + return lipgloss.JoinVertical( + lipgloss.Left, + errorBadgeStyle.Render("DISCOVERY FAILED"), + errorStyle.Render(m.err.Error()), + ) + case len(m.services) == 0: + return helpStyle.Render("No auxiliary services detected.") + } + + blocks := make([]string, 0, len(m.services)) + for _, service := range m.services { + rows := []string{ + renderKVRow(service.Name, service.URL, urlStyle), + } + if service.Username != "" { + rows = append(rows, renderSubKVRow("Username", service.Username, valueStyle)) + rows = append(rows, renderSubKVRow("Password", service.Password, secretStyle)) + } + + blocks = append(blocks, strings.Join(rows, "\n")) + } + + return strings.Join(blocks, "\n\n") +} + +// 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..e54b0d30 --- /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() + 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..be8c187c --- /dev/null +++ b/internal/devtui/tab_logs.go @@ -0,0 +1,404 @@ +package devtui + +import ( + "bufio" + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "charm.land/bubbles/v2/viewport" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" +) + +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 { + body := lipgloss.JoinHorizontal(lipgloss.Top, m.renderSidebar(), m.renderContent()) + + followState := "off" + if m.follow { + followState = "on" + } + + footer := renderFooter( + renderKeyHint("f", fmt.Sprintf("Follow %s", followState)), + renderKeyHint("↑/↓", "Move cursor"), + renderKeyHint("enter", "Open source"), + renderKeyHint("pgup", "Scroll"), + ) + + return body + "\n" + footer +} + +func (m LogsModel) renderSidebar() string { + var b strings.Builder + b.WriteString(sectionTitleStyle.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(clampMin(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(). + Background(panelColor). + Foreground(textColor). + Bold(true). + Render(sourceName) + + header := headerText + panelTextStyle.Render(" ") + followBadge + + return contentPanelStyle.Render( + lipgloss.JoinVertical( + lipgloss.Left, + header, + "", + panelTextStyle.Render(m.viewport.View()), + ), + ) +} + +func (m *LogsModel) SetSize(width, height int) { + m.width = width + m.height = height + viewportWidth := clampMin(width-sidebarWidth-8, 20) + m.viewport.SetWidth(viewportWidth) + m.viewport.SetHeight(clampMin(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 + + ch := make(chan string, 100) + m.logChan = ch + + go func() { + defer close(ch) + + cmd := exec.CommandContext(ctx, "docker", "compose", "logs", "-f", "--tail=100", container) + cmd.Dir = m.projectRoot + + stdout, err := cmd.StdoutPipe() + if err != nil { + return + } + 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) streamFile(filePath string) tea.Cmd { + ctx, cancel := context.WithCancel(context.Background()) + m.cancel = cancel + + ch := make(chan string, 100) + m.logChan = ch + + go func() { + defer close(ch) + + cmd := exec.CommandContext(ctx, "tail", "-n", "100", "-f", filePath) + + stdout, err := cmd.StdoutPipe() + if err != nil { + return + } + + 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..72c18169 --- /dev/null +++ b/internal/docker/compose.go @@ -0,0 +1,207 @@ +package docker + +import ( + "fmt" + "os" + "path/filepath" + + "gopkg.in/yaml.v3" + + "github.com/shopware/shopware-cli/internal/packagist" +) + +func GenerateComposeFile(lock *packagist.ComposerLock) ([]byte, error) { + hasAMQP := lock.GetPackage("symfony/amqp-messenger") != nil + hasElasticsearch := lock.GetPackage("shopware/elasticsearch") != nil + + doc := buildCompose(hasAMQP, hasElasticsearch) + + 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) 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) + 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) 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) + + web := newMappingNode() + addKeyValue(web, "image", "ghcr.io/shopware/docker-dev:php8.3-node22-caddy") + 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()) + } + + 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..a1eaae5e --- /dev/null +++ b/internal/docker/compose_test.go @@ -0,0 +1,107 @@ +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) + 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") + 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") + }) + + 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) + 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) + 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("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) + 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..b0d13845 --- /dev/null +++ b/internal/executor/docker.go @@ -0,0 +1,63 @@ +package executor + +import ( + "context" + "fmt" + "os" + "os/exec" + + "github.com/mattn/go-isatty" +) + +// DockerExecutor runs commands via docker compose exec against the "web" service. +type DockerExecutor struct { + env map[string]string +} + +func (d *DockerExecutor) ConsoleCommand(ctx context.Context, args ...string) *exec.Cmd { + dockerArgs := d.baseArgs() + dockerArgs = append(dockerArgs, "php", consoleCommandName(ctx)) + dockerArgs = append(dockerArgs, args...) + + return exec.CommandContext(ctx, "docker", dockerArgs...) +} + +func (d *DockerExecutor) ComposerCommand(ctx context.Context, args ...string) *exec.Cmd { + dockerArgs := d.baseArgs() + dockerArgs = append(dockerArgs, "composer") + dockerArgs = append(dockerArgs, args...) + + return exec.CommandContext(ctx, "docker", dockerArgs...) +} + +func (d *DockerExecutor) PHPCommand(ctx context.Context, args ...string) *exec.Cmd { + dockerArgs := d.baseArgs() + dockerArgs = append(dockerArgs, "php") + dockerArgs = append(dockerArgs, args...) + + return exec.CommandContext(ctx, "docker", dockerArgs...) +} + +func (d *DockerExecutor) Type() string { + return "docker" +} + +func (d *DockerExecutor) WithEnv(env map[string]string) Executor { + return &DockerExecutor{env: env} +} + +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, "web") + + return args +} diff --git a/internal/executor/executor.go b/internal/executor/executor.go new file mode 100644 index 00000000..a3b588e2 --- /dev/null +++ b/internal/executor/executor.go @@ -0,0 +1,42 @@ +package executor + +import ( + "context" + "os" + "os/exec" + "sync" +) + +// 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 + Type() string + WithEnv(env map[string]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" +} diff --git a/internal/executor/executor_test.go b/internal/executor/executor_test.go new file mode 100644 index 00000000..17859db7 --- /dev/null +++ b/internal/executor/executor_test.go @@ -0,0 +1,176 @@ +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(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(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(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(cfg, &shop.Config{}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "unsupported environment type: unknown") +} + +func TestLocalExecutorConsoleCommand(t *testing.T) { + exec := &LocalExecutor{} + + cmd := exec.ConsoleCommand(t.Context(), "cache:clear") + assert.Equal(t, []string{"php", "bin/console", "cache:clear"}, cmd.Args) +} + +func TestLocalExecutorComposerCommand(t *testing.T) { + exec := &LocalExecutor{} + + cmd := exec.ComposerCommand(t.Context(), "install") + assert.Equal(t, []string{"composer", "install"}, cmd.Args) +} + +func TestLocalExecutorPHPCommand(t *testing.T) { + exec := &LocalExecutor{} + + cmd := exec.PHPCommand(t.Context(), "-v") + assert.Equal(t, []string{"php", "-v"}, cmd.Args) +} + +func TestSymfonyCLIExecutorConsoleCommand(t *testing.T) { + exec := &SymfonyCLIExecutor{BinaryPath: "/usr/local/bin/symfony"} + + cmd := exec.ConsoleCommand(t.Context(), "cache:clear") + assert.Equal(t, []string{"/usr/local/bin/symfony", "php", "bin/console", "cache:clear"}, cmd.Args) +} + +func TestSymfonyCLIExecutorComposerCommand(t *testing.T) { + exec := &SymfonyCLIExecutor{BinaryPath: "/usr/local/bin/symfony"} + + 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"} + + 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{} + + 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") +} + +func TestDockerExecutorComposerCommand(t *testing.T) { + exec := &DockerExecutor{} + + 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{} + + 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{} + 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{} + + cmd := exec.PHPCommand(t.Context(), "-v") + assert.Nil(t, cmd.Env) +} + +func TestDockerExecutorWithEnv(t *testing.T) { + exec := &DockerExecutor{} + 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"} + 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") +} diff --git a/internal/executor/factory.go b/internal/executor/factory.go new file mode 100644 index 00000000..1cc58cc3 --- /dev/null +++ b/internal/executor/factory.go @@ -0,0 +1,45 @@ +package executor + +import ( + "fmt" + "os" + "os/exec" + "sync" + + "github.com/shopware/shopware-cli/internal/shop" +) + +// New creates an Executor for the given environment and shop configuration. +func New(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}, nil + } + } + return &LocalExecutor{}, nil + case "symfony-cli": + path := pathToSymfonyCLI() + if path == "" { + return nil, fmt.Errorf("symfony CLI not found in PATH") + } + return &SymfonyCLIExecutor{BinaryPath: path}, nil + case "docker": + return &DockerExecutor{}, nil + default: + return nil, fmt.Errorf("unsupported environment type: %s", cfg.Type) + } +} + +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..2b0f0c88 --- /dev/null +++ b/internal/executor/local.go @@ -0,0 +1,52 @@ +package executor + +import ( + "context" + "fmt" + "os" + "os/exec" +) + +// LocalExecutor runs commands using the local PHP installation directly. +type LocalExecutor struct { + env map[string]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.env, cmd) + return cmd +} + +func (l *LocalExecutor) ComposerCommand(ctx context.Context, args ...string) *exec.Cmd { + cmd := exec.CommandContext(ctx, "composer", args...) + applyLocalEnv(l.env, cmd) + return cmd +} + +func (l *LocalExecutor) PHPCommand(ctx context.Context, args ...string) *exec.Cmd { + cmd := exec.CommandContext(ctx, "php", args...) + applyLocalEnv(l.env, cmd) + return cmd +} + +func (l *LocalExecutor) Type() string { + return "local" +} + +func (l *LocalExecutor) WithEnv(env map[string]string) Executor { + return &LocalExecutor{env: env} +} + +// applyLocalEnv sets extra environment variables on a local command. +func applyLocalEnv(env map[string]string, cmd *exec.Cmd) { + if len(env) == 0 { + return + } + cmd.Env = os.Environ() + 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..78f1dcf1 --- /dev/null +++ b/internal/executor/symfony_cli.go @@ -0,0 +1,44 @@ +package executor + +import ( + "context" + "os/exec" +) + +// SymfonyCLIExecutor runs commands through the Symfony CLI binary. +type SymfonyCLIExecutor struct { + BinaryPath string + env map[string]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.env, 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.env, 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.env, cmd) + return cmd +} + +func (s *SymfonyCLIExecutor) Type() string { + return "symfony-cli" +} + +func (s *SymfonyCLIExecutor) WithEnv(env map[string]string) Executor { + return &SymfonyCLIExecutor{BinaryPath: s.BinaryPath, env: env} +} diff --git a/internal/extension/config.go b/internal/extension/config.go index 5d3de88f..24ac32a5 100644 --- a/internal/extension/config.go +++ b/internal/extension/config.go @@ -219,6 +219,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/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 45ebfa02..59aa009b 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" ) @@ -178,8 +178,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/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 6bbf3ce8..ab2263ce 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,8 @@ type Config struct { ConfigDeployment *ConfigDeployment `yaml:"deployment,omitempty"` Validation *ConfigValidation `yaml:"validation,omitempty"` ImageProxy *ConfigImageProxy `yaml:"image_proxy,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 +45,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 +81,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"` @@ -385,6 +417,37 @@ type ConfigImageProxy struct { 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/shopware-project-schema.json b/shopware-project-schema.json index 11e8515c..4ff23f95 100644 --- a/shopware-project-schema.json +++ b/shopware-project-schema.json @@ -38,6 +38,13 @@ "image_proxy": { "$ref": "#/$defs/ConfigImageProxy" }, + "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" @@ -469,6 +476,29 @@ "additionalProperties": false, "type": "object", "description": "ConfigValidationIgnoreItem is used to ignore items from the validation." + }, + "EnvironmentConfig": { + "properties": { + "type": { + "type": "string", + "enum": [ + "local", + "docker" + ], + "description": "Type of environment: local or docker" + }, + "url": { + "type": "string", + "description": "URL of the Shopware instance for this environment" + }, + "admin_api": { + "$ref": "#/$defs/ConfigAdminApi", + "description": "Admin API credentials for this environment" + } + }, + "additionalProperties": false, + "type": "object", + "description": "EnvironmentConfig represents a single named environment." } } } \ No newline at end of file