From 070320fcd4d233ab615289a75fd771312c260912 Mon Sep 17 00:00:00 2001 From: Soner Sayakci Date: Tue, 3 Mar 2026 09:02:42 +0100 Subject: [PATCH 01/19] feat: start abstracting executors --- cmd/project/ci.go | 12 ++- cmd/project/executor.go | 24 +++++ cmd/project/platform.go | 9 +- cmd/project/project.go | 6 +- cmd/project/project_admin_build.go | 11 +- cmd/project/project_admin_watch.go | 10 +- cmd/project/project_console.go | 15 ++- cmd/project/project_storefront_build.go | 11 +- cmd/project/project_storefront_watch.go | 12 ++- cmd/project/project_worker.go | 8 +- internal/executor/docker.go | 52 +++++++++ internal/executor/executor.go | 48 +++++++++ internal/executor/executor_test.go | 136 ++++++++++++++++++++++++ internal/executor/factory.go | 44 ++++++++ internal/executor/local.go | 27 +++++ internal/executor/symfony_cli.go | 34 ++++++ internal/extension/project.go | 10 +- internal/phpexec/phpexec.go | 57 ---------- internal/phpexec/phpexec_test.go | 68 ------------ internal/shop/config.go | 35 ++++++ internal/shop/config_test.go | 108 +++++++++++++++++++ internal/shop/console.go | 15 +-- 22 files changed, 593 insertions(+), 159 deletions(-) create mode 100644 cmd/project/executor.go create mode 100644 internal/executor/docker.go create mode 100644 internal/executor/executor.go create mode 100644 internal/executor/executor_test.go create mode 100644 internal/executor/factory.go create mode 100644 internal/executor/local.go create mode 100644 internal/executor/symfony_cli.go delete mode 100644 internal/phpexec/phpexec.go delete mode 100644 internal/phpexec/phpexec_test.go 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..f81f812f --- /dev/null +++ b/cmd/project/executor.go @@ -0,0 +1,24 @@ +package project + +import ( + "github.com/spf13/cobra" + + "github.com/shopware/shopware-cli/internal/executor" + "github.com/shopware/shopware-cli/internal/shop" +) + +// resolveExecutor reads the project config, resolves the target environment, +// and returns the appropriate Executor. +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) +} 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_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_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/internal/executor/docker.go b/internal/executor/docker.go new file mode 100644 index 00000000..91a49192 --- /dev/null +++ b/internal/executor/docker.go @@ -0,0 +1,52 @@ +package executor + +import ( + "context" + "os" + "os/exec" + + "github.com/mattn/go-isatty" +) + +// DockerExecutor runs commands via docker compose exec against the "web" service. +type DockerExecutor struct{} + +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) baseArgs() []string { + args := []string{"compose", "exec"} + + if !isatty.IsTerminal(os.Stdin.Fd()) { + args = append(args, "-T") + } + + args = append(args, "web") + + return args +} diff --git a/internal/executor/executor.go b/internal/executor/executor.go new file mode 100644 index 00000000..99ead4ac --- /dev/null +++ b/internal/executor/executor.go @@ -0,0 +1,48 @@ +package executor + +import ( + "context" + "os" + "os/exec" + "sync" +) + +// Executor abstracts command execution across different environment types. +type Executor interface { + // ConsoleCommand returns an exec.Cmd for running bin/console. + ConsoleCommand(ctx context.Context, args ...string) *exec.Cmd + + // ComposerCommand returns an exec.Cmd for running composer. + ComposerCommand(ctx context.Context, args ...string) *exec.Cmd + + // PHPCommand returns an exec.Cmd for running php. + PHPCommand(ctx context.Context, args ...string) *exec.Cmd + + // Type returns the executor type name (e.g. "local", "docker"). + Type() string +} + +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..e97193c9 --- /dev/null +++ b/internal/executor/executor_test.go @@ -0,0 +1,136 @@ +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) + 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) + assert.NoError(t, err) + assert.Equal(t, "local", exec.Type()) +} + +func TestNewDockerExecutor(t *testing.T) { + cfg := &shop.EnvironmentConfig{Type: "docker"} + + exec, err := New(cfg) + assert.NoError(t, err) + assert.Equal(t, "docker", exec.Type()) +} + +func TestNewUnsupportedType(t *testing.T) { + cfg := &shop.EnvironmentConfig{Type: "unknown"} + + _, err := New(cfg) + 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)) +} diff --git a/internal/executor/factory.go b/internal/executor/factory.go new file mode 100644 index 00000000..4234048d --- /dev/null +++ b/internal/executor/factory.go @@ -0,0 +1,44 @@ +package executor + +import ( + "fmt" + "os" + "os/exec" + "sync" + + "github.com/shopware/shopware-cli/internal/shop" +) + +// New creates an Executor for the given environment configuration. +// For "local" type, it auto-detects Symfony CLI and uses it if available. +func New(cfg *shop.EnvironmentConfig) (Executor, error) { + switch cfg.Type { + case "local", "": + 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..6ebc8f92 --- /dev/null +++ b/internal/executor/local.go @@ -0,0 +1,27 @@ +package executor + +import ( + "context" + "os/exec" +) + +// LocalExecutor runs commands using the local PHP installation directly. +type LocalExecutor struct{} + +func (l *LocalExecutor) ConsoleCommand(ctx context.Context, args ...string) *exec.Cmd { + cmdArgs := []string{consoleCommandName(ctx)} + cmdArgs = append(cmdArgs, args...) + return exec.CommandContext(ctx, "php", cmdArgs...) +} + +func (l *LocalExecutor) ComposerCommand(ctx context.Context, args ...string) *exec.Cmd { + return exec.CommandContext(ctx, "composer", args...) +} + +func (l *LocalExecutor) PHPCommand(ctx context.Context, args ...string) *exec.Cmd { + return exec.CommandContext(ctx, "php", args...) +} + +func (l *LocalExecutor) Type() string { + return "local" +} diff --git a/internal/executor/symfony_cli.go b/internal/executor/symfony_cli.go new file mode 100644 index 00000000..bd2b36bf --- /dev/null +++ b/internal/executor/symfony_cli.go @@ -0,0 +1,34 @@ +package executor + +import ( + "context" + "os/exec" +) + +// SymfonyCLIExecutor runs commands through the Symfony CLI binary. +type SymfonyCLIExecutor struct { + // Path to the symfony binary. + BinaryPath string +} + +func (s *SymfonyCLIExecutor) ConsoleCommand(ctx context.Context, args ...string) *exec.Cmd { + cmdArgs := []string{"php", consoleCommandName(ctx)} + cmdArgs = append(cmdArgs, args...) + return exec.CommandContext(ctx, s.BinaryPath, cmdArgs...) +} + +func (s *SymfonyCLIExecutor) ComposerCommand(ctx context.Context, args ...string) *exec.Cmd { + cmdArgs := []string{"composer"} + cmdArgs = append(cmdArgs, args...) + return exec.CommandContext(ctx, s.BinaryPath, cmdArgs...) +} + +func (s *SymfonyCLIExecutor) PHPCommand(ctx context.Context, args ...string) *exec.Cmd { + cmdArgs := []string{"php"} + cmdArgs = append(cmdArgs, args...) + return exec.CommandContext(ctx, s.BinaryPath, cmdArgs...) +} + +func (s *SymfonyCLIExecutor) Type() string { + return "symfony-cli" +} diff --git a/internal/extension/project.go b/internal/extension/project.go index 6ab2a53d..97a36ea1 100644 --- a/internal/extension/project.go +++ b/internal/extension/project.go @@ -12,9 +12,10 @@ import ( "github.com/shyim/go-version" + "os/exec" + "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" ) @@ -151,8 +152,11 @@ 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") +// ConsoleCommandFunc is a function that creates a console command. +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/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/config.go b/internal/shop/config.go index eb3bc378..4f2b8b70 100644 --- a/internal/shop/config.go +++ b/internal/shop/config.go @@ -18,6 +18,16 @@ import ( "github.com/shopware/shopware-cli/logging" ) +// EnvironmentConfig represents a single named environment. +type EnvironmentConfig struct { + // Type of environment: local or docker + Type string `yaml:"type" jsonschema:"enum=local,enum=docker"` + // URL of the Shopware instance for this environment + URL string `yaml:"url,omitempty"` + // Admin API credentials for this environment + AdminApi *ConfigAdminApi `yaml:"admin_api,omitempty"` +} + type Config struct { AdditionalConfigs []string `yaml:"include,omitempty"` // The URL of the Shopware instance @@ -30,6 +40,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 +49,29 @@ type Config struct { foundConfig bool } +// ResolveEnvironment returns the environment config for the given name. +// If name is empty, it returns the "local" environment if configured, +// otherwise synthesizes one from top-level config fields for backward compatibility. +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 diff --git a/internal/shop/config_test.go b/internal/shop/config_test.go index 884a8b86..52b6d37b 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..c131e138 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,11 @@ func (c ConsoleResponse) GetCommandOptions(name string) []string { return nil } -func GetConsoleCompletion(ctx context.Context, projectRoot string) (*ConsoleResponse, error) { +// ConsoleCommandFunc is a function that creates a console command. +// This 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 +58,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 } From 52ab012ea9a693e9cb1b6ba67cf34dcce2fb20e5 Mon Sep 17 00:00:00 2001 From: Soner Sayakci Date: Tue, 3 Mar 2026 09:28:41 +0100 Subject: [PATCH 02/19] feat: enhance executor initialization and add compatibility date checks --- cmd/project/executor.go | 2 +- internal/compatibility/date.go | 7 +++++++ internal/executor/executor_test.go | 8 ++++---- internal/executor/factory.go | 11 +++++++---- internal/extension/config.go | 4 ++++ internal/shop/compatibility_date.go | 6 ++++++ internal/shop/config.go | 4 ++++ 7 files changed, 33 insertions(+), 9 deletions(-) create mode 100644 internal/shop/compatibility_date.go diff --git a/cmd/project/executor.go b/cmd/project/executor.go index f81f812f..300eb104 100644 --- a/cmd/project/executor.go +++ b/cmd/project/executor.go @@ -20,5 +20,5 @@ func resolveExecutor(cmd *cobra.Command) (executor.Executor, error) { return nil, err } - return executor.New(envCfg) + return executor.New(envCfg, cfg) } 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/executor/executor_test.go b/internal/executor/executor_test.go index e97193c9..402da255 100644 --- a/internal/executor/executor_test.go +++ b/internal/executor/executor_test.go @@ -13,7 +13,7 @@ func TestNewLocalExecutor(t *testing.T) { cfg := &shop.EnvironmentConfig{Type: "local"} - exec, err := New(cfg) + exec, err := New(cfg, &shop.Config{}) assert.NoError(t, err) assert.Equal(t, "local", exec.Type()) } @@ -23,7 +23,7 @@ func TestNewLocalExecutorEmptyType(t *testing.T) { cfg := &shop.EnvironmentConfig{Type: ""} - exec, err := New(cfg) + exec, err := New(cfg, &shop.Config{}) assert.NoError(t, err) assert.Equal(t, "local", exec.Type()) } @@ -31,7 +31,7 @@ func TestNewLocalExecutorEmptyType(t *testing.T) { func TestNewDockerExecutor(t *testing.T) { cfg := &shop.EnvironmentConfig{Type: "docker"} - exec, err := New(cfg) + exec, err := New(cfg, &shop.Config{}) assert.NoError(t, err) assert.Equal(t, "docker", exec.Type()) } @@ -39,7 +39,7 @@ func TestNewDockerExecutor(t *testing.T) { func TestNewUnsupportedType(t *testing.T) { cfg := &shop.EnvironmentConfig{Type: "unknown"} - _, err := New(cfg) + _, err := New(cfg, &shop.Config{}) assert.Error(t, err) assert.Contains(t, err.Error(), "unsupported environment type: unknown") } diff --git a/internal/executor/factory.go b/internal/executor/factory.go index 4234048d..b6e0b123 100644 --- a/internal/executor/factory.go +++ b/internal/executor/factory.go @@ -9,13 +9,16 @@ import ( "github.com/shopware/shopware-cli/internal/shop" ) -// New creates an Executor for the given environment configuration. +// New creates an Executor for the given environment and shop configuration. // For "local" type, it auto-detects Symfony CLI and uses it if available. -func New(cfg *shop.EnvironmentConfig) (Executor, error) { +func New(cfg *shop.EnvironmentConfig, shopCfg *shop.Config) (Executor, error) { switch cfg.Type { case "local", "": - if path := pathToSymfonyCLI(); path != "" && symfonyCliAllowed() { - return &SymfonyCLIExecutor{BinaryPath: path}, nil + // After DevMode, the user requires to explicitly need to opt-in for Symfony CLI. Before that, we auto-detect it and use it if available. + if shopCfg.IsCompatibilityDateBefore(shop.CompatibilityDevMode) { + if path := pathToSymfonyCLI(); path != "" && symfonyCliAllowed() { + return &SymfonyCLIExecutor{BinaryPath: path}, nil + } } return &LocalExecutor{}, nil case "symfony-cli": 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/shop/compatibility_date.go b/internal/shop/compatibility_date.go new file mode 100644 index 00000000..a4f8657a --- /dev/null +++ b/internal/shop/compatibility_date.go @@ -0,0 +1,6 @@ +package shop + +const ( + // DevMode breaks + CompatibilityDevMode = "2026-04-01" +) diff --git a/internal/shop/config.go b/internal/shop/config.go index 4f2b8b70..e3364471 100644 --- a/internal/shop/config.go +++ b/internal/shop/config.go @@ -88,6 +88,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"` From 86fd8b9822b6f0ec4d1f052a7bf6c9ee9f0300a3 Mon Sep 17 00:00:00 2001 From: Soner Sayakci Date: Tue, 3 Mar 2026 14:32:38 +0100 Subject: [PATCH 03/19] fix: remove duplicate import of os/exec in project.go --- internal/extension/project.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/internal/extension/project.go b/internal/extension/project.go index 97a36ea1..1acba87c 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" @@ -12,8 +13,6 @@ import ( "github.com/shyim/go-version" - "os/exec" - "github.com/shopware/shopware-cli/internal/asset" "github.com/shopware/shopware-cli/internal/packagist" "github.com/shopware/shopware-cli/internal/shop" From c9e3af4d8e65feec8f572efa89a95e1cd6245cc4 Mon Sep 17 00:00:00 2001 From: Soner Sayakci Date: Fri, 6 Mar 2026 04:29:30 +0100 Subject: [PATCH 04/19] feat: devui --- cmd/project/project_config_init.go | 9 +- cmd/project/project_create.go | 14 +- cmd/project/project_dev.go | 51 ++ go.mod | 4 +- internal/devtui/model.go | 727 ++++++++++++++++++++ internal/devtui/model_test.go | 441 ++++++++++++ internal/devtui/styles.go | 44 ++ internal/devtui/tab_extensions.go | 651 ++++++++++++++++++ internal/devtui/tab_general.go | 217 ++++++ internal/devtui/tab_general_test.go | 91 +++ internal/devtui/tab_logs.go | 363 ++++++++++ internal/executor/docker.go | 13 +- internal/executor/executor.go | 15 + internal/executor/executor_test.go | 40 ++ internal/executor/local.go | 26 +- internal/executor/symfony_cli.go | 12 +- internal/packagist/packagist.go | 5 +- internal/packagist/project_composer_json.go | 1 + internal/shop/config.go | 33 + shopware-project-schema.json | 30 + 20 files changed, 2762 insertions(+), 25 deletions(-) create mode 100644 cmd/project/project_dev.go create mode 100644 internal/devtui/model.go create mode 100644 internal/devtui/model_test.go create mode 100644 internal/devtui/styles.go create mode 100644 internal/devtui/tab_extensions.go create mode 100644 internal/devtui/tab_general.go create mode 100644 internal/devtui/tab_general_test.go create mode 100644 internal/devtui/tab_logs.go 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_create.go b/cmd/project/project_create.go index 730f32c8..0aeed26d 100644 --- a/cmd/project/project_create.go +++ b/cmd/project/project_create.go @@ -23,6 +23,7 @@ import ( "github.com/shopware/shopware-cli/internal/color" "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" @@ -460,6 +461,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) @@ -467,9 +477,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() } diff --git a/cmd/project/project_dev.go b/cmd/project/project_dev.go new file mode 100644 index 00000000..078c921d --- /dev/null +++ b/cmd/project/project_dev.go @@ -0,0 +1,51 @@ +package project + +import ( + tea "charm.land/bubbletea/v2" + "github.com/spf13/cobra" + + "github.com/shopware/shopware-cli/internal/devtui" + "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 + } + + envCfg, err := cfg.ResolveEnvironment(environmentName) + if err != nil { + return err + } + + exec, err := executor.New(envCfg, cfg) + if 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/go.mod b/go.mod index 53099043..1089c459 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,8 @@ module github.com/shopware/shopware-cli go 1.25.0 require ( + charm.land/bubbles/v2 v2.0.0 + charm.land/bubbletea/v2 v2.0.0 charm.land/huh/v2 v2.0.0-20260226141913-a8934362ea3b charm.land/lipgloss/v2 v2.0.0 dario.cat/mergo v1.0.2 @@ -38,8 +40,6 @@ require ( ) require ( - charm.land/bubbles/v2 v2.0.0 // indirect - charm.land/bubbletea/v2 v2.0.0 // 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 diff --git a/internal/devtui/model.go b/internal/devtui/model.go new file mode 100644 index 00000000..d041fd0d --- /dev/null +++ b/internal/devtui/model.go @@ -0,0 +1,727 @@ +package devtui + +import ( + "bufio" + "context" + "fmt" + "os/exec" + "strings" + + tea "charm.land/bubbletea/v2" + "charm.land/bubbles/v2/textinput" + "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 + tabExtensions +) + +var tabNames = []string{"General", "Logs", "Extensions"} + +type overlay int + +const ( + overlayNone overlay = iota + overlayStarting + overlayStopConfirm + overlayStopping + overlayInstallPrompt + overlayInstalling +) + +// installStep tracks which question the install wizard is on. +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"} +) + +// installWizard holds state for the Shopware install prompt. +type installWizard struct { + step installStep + cursor int + language string + currency string + username textinput.Model + password textinput.Model +} + +// Options configures the TUI dashboard. +type Options struct { + ProjectRoot string + Config *shop.Config + EnvConfig *shop.EnvironmentConfig + Executor executor.Executor +} + +// Model is the top-level Bubble Tea model for the dev dashboard. +type Model struct { + activeTab activeTab + general GeneralModel + logs LogsModel + extensions ExtensionsModel + 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 +} + +// docker lifecycle messages +type dockerAlreadyRunningMsg struct{} +type dockerNeedStartMsg struct{} +type dockerStartedMsg struct{ err error } +type dockerStoppedMsg struct{ err error } +type dockerOutputLineMsg string +type dockerOutputDoneMsg struct{} + +// shopware install check messages +type shopwareInstalledMsg struct{} +type shopwareNotInstalledMsg struct{} +type shopwareInstallDoneMsg struct{ err error } + +// New creates a new TUI model from the given options. +func New(opts Options) Model { + // Resolve effective admin API: environment overrides top-level config + effectiveAdminApi := opts.Config.AdminApi + if opts.EnvConfig.AdminApi != nil { + effectiveAdminApi = opts.EnvConfig.AdminApi + } + + // Build a config copy with the resolved values for the shop client + effectiveConfig := *opts.Config + if effectiveAdminApi != nil { + effectiveConfig.AdminApi = effectiveAdminApi + } + + shopURL := opts.Config.URL + if opts.EnvConfig.URL != "" { + shopURL = opts.EnvConfig.URL + } + effectiveConfig.URL = shopURL + + 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), + extensions: NewExtensionsModel(&effectiveConfig, opts.Executor, opts.ProjectRoot), + 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(), + m.extensions.Init(), + ) +} + +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 + contentHeight := m.height - 4 + m.logs.SetSize(m.width, contentHeight) + m.extensions.SetSize(m.width, contentHeight) + return m, nil + + 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)) + if len(m.overlayLines) > 10 { + m.overlayLines = m.overlayLines[len(m.overlayLines)-10:] + } + 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 = "admin" + 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 + } + + // Update config with admin credentials + 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) + + // Update general tab display + 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 + + case tea.KeyPressMsg: + if m.overlay == overlayInstallPrompt { + return m.updateInstallPrompt(msg) + } + + if m.overlay == overlayStopConfirm { + switch msg.String() { + case "y", "Y": + m.overlay = overlayStopping + m.overlayLines = nil + return m, m.stopContainers() + case "n", "N": + return m, tea.Quit + } + return m, nil + } + + // Other overlays only allow quit + if m.overlay != overlayNone { + if msg.String() == "q" || msg.String() == "ctrl+c" { + return m, tea.Quit + } + return m, nil + } + + switch msg.String() { + case "ctrl+c", "q": + m.logs.StopStreaming() + if m.dockerMode { + m.overlay = overlayStopConfirm + m.overlayLines = nil + return m, nil + } + return m, tea.Quit + case "1": + m.activeTab = tabGeneral + return m, nil + case "2": + m.activeTab = tabLogs + return m, nil + case "3": + m.activeTab = tabExtensions + return m, nil + case "tab": + m.activeTab = (m.activeTab + 1) % 3 + return m, nil + case "shift+tab": + m.activeTab = (m.activeTab + 2) % 3 + return m, nil + case "f": + if m.activeTab == tabGeneral { + return m, openInBrowser(m.general.shopURL) + } + case "a": + if m.activeTab == tabGeneral { + return m, openInBrowser(m.general.adminURL) + } + } + } + + if m.overlay != overlayNone { + return m, nil + } + + 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) + } + + newExt, cmd := m.extensions.Update(msg) + m.extensions = newExt + if cmd != nil { + cmds = append(cmds, cmd) + } + + return m, tea.Batch(cmds...) +} + +// updateInstallPrompt handles key input for the install wizard steps. +func (m Model) updateInstallPrompt(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "q", "ctrl+c": + return m, tea.Quit + } + + switch m.install.step { + case installStepAsk: + switch msg.String() { + case "y", "Y": + m.install.step = installStepLanguage + m.install.cursor = 0 + case "n", "N": + m.overlay = overlayNone + return m, m.startDashboard() + } + + case installStepLanguage: + switch msg.String() { + case "up", "k": + if m.install.cursor > 0 { + m.install.cursor-- + } + case "down", "j": + if m.install.cursor < len(installLanguages)-1 { + m.install.cursor++ + } + case "enter": + m.install.language = installLanguages[m.install.cursor].id + m.install.step = installStepCurrency + m.install.cursor = 0 + } + + case installStepCurrency: + switch msg.String() { + case "up", "k": + if m.install.cursor > 0 { + m.install.cursor-- + } + case "down", "j": + if m.install.cursor < len(installCurrencies)-1 { + m.install.cursor++ + } + case "enter": + m.install.currency = installCurrencies[m.install.cursor] + m.install.step = installStepUsername + m.install.username.SetValue("admin") + m.install.username.Focus() + return m, textinput.Blink + } + + case installStepUsername: + switch msg.String() { + case "enter": + 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 "enter": + 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") + + switch m.activeTab { + case tabGeneral: + b.WriteString(m.general.View()) + case tabLogs: + b.WriteString(m.logs.View()) + case tabExtensions: + b.WriteString(m.extensions.View()) + } + } + + v := tea.NewView(b.String()) + v.AltScreen = true + return v +} + +func (m Model) renderOverlay() string { + var title string + switch m.overlay { + 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(statusStyle.Render(title)) + content.WriteString("\n\n") + + if m.overlay == overlayStopConfirm { + content.WriteString("Do you want to stop the Docker containers?\n\n") + content.WriteString(helpStyle.Render("y: stop containers | n: quit without stopping")) + } else if m.overlay == overlayInstallPrompt { + m.renderInstallPrompt(&content) + } else { + for _, line := range m.overlayLines { + content.WriteString(line + "\n") + } + } + + modal := overlayStyle.Render(content.String()) + + if m.width > 0 && m.height > 0 { + modal = lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, modal) + } + + return modal +} + +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(helpStyle.Render("y: install | n: skip | q: quit")) + + case installStepLanguage: + b.WriteString("Select default language:\n\n") + for i, lang := range installLanguages { + cursor := " " + if i == m.install.cursor { + cursor = "> " + } + b.WriteString(cursor + lang.label + "\n") + } + b.WriteString("\n") + b.WriteString(helpStyle.Render("↑/↓: select | enter: confirm | q: quit")) + + case installStepCurrency: + b.WriteString(fmt.Sprintf("Language: %s\n\n", valueStyle.Render(m.install.language))) + b.WriteString("Select default currency:\n\n") + for i, curr := range installCurrencies { + cursor := " " + if i == m.install.cursor { + cursor = "> " + } + b.WriteString(cursor + curr + "\n") + } + b.WriteString("\n") + b.WriteString(helpStyle.Render("↑/↓: select | enter: confirm | q: quit")) + + case installStepUsername: + b.WriteString(fmt.Sprintf("Language: %s\n", valueStyle.Render(m.install.language))) + b.WriteString(fmt.Sprintf("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(helpStyle.Render("enter: confirm | q: quit")) + + case installStepPassword: + b.WriteString(fmt.Sprintf("Language: %s\n", valueStyle.Render(m.install.language))) + b.WriteString(fmt.Sprintf("Currency: %s\n", valueStyle.Render(m.install.currency))) + b.WriteString(fmt.Sprintf("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(helpStyle.Render("enter: confirm | 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...) +} + +// checkContainersRunning checks if any containers are already running. +func checkContainersRunning(projectRoot string) tea.Cmd { + return func() tea.Msg { + check := exec.Command("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{} + } +} + +// checkShopwareInstalled runs bin/console system:is-installed to check if Shopware is set up. +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{} + } +} + +// runShopwareInstall runs vendor/bin/shopware-deployment-helper run with INSTALL_LOCALE and INSTALL_CURRENCY env vars. +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 { + ctx := executor.WithEnv(context.Background(), map[string]string{ + "INSTALL_LOCALE": language, + "INSTALL_CURRENCY": currency, + "INSTALL_ADMIN_USERNAME": username, + "INSTALL_ADMIN_PASSWORD": password, + }) + cmd := e.PHPCommand(ctx, "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) +} + +// readNextDockerOutput reads the next line from the docker output channel. +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(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.Command("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 +} + +// startContainers runs docker compose up -d, streaming output. +func (m *Model) startContainers() tea.Cmd { + ch, outputCmd, doneCmd := runDockerCommandWithArgs( + m.projectRoot, + []string{"compose", "up", "-d"}, + func(err error) tea.Msg { return dockerStartedMsg{err: err} }, + ) + m.dockerOutChan = ch + return tea.Batch(outputCmd, doneCmd) +} + +// stopContainers runs docker compose down, streaming output. +func (m *Model) stopContainers() tea.Cmd { + ch, outputCmd, doneCmd := runDockerCommandWithArgs( + m.projectRoot, + []string{"compose", "down"}, + func(err error) tea.Msg { return dockerStoppedMsg{err: err} }, + ) + m.dockerOutChan = ch + return tea.Batch(outputCmd, doneCmd) +} diff --git a/internal/devtui/model_test.go b/internal/devtui/model_test.go new file mode 100644 index 00000000..8272c25f --- /dev/null +++ b/internal/devtui/model_test.go @@ -0,0 +1,441 @@ +package devtui + +import ( + "context" + "os/exec" + "testing" + + tea "charm.land/bubbletea/v2" + "github.com/stretchr/testify/assert" + + "github.com/shopware/shopware-cli/internal/shop" +) + +type mockExecutor struct { + execType string +} + +func (m *mockExecutor) ConsoleCommand(_ context.Context, _ ...string) *exec.Cmd { return nil } +func (m *mockExecutor) ComposerCommand(_ context.Context, _ ...string) *exec.Cmd { return nil } +func (m *mockExecutor) PHPCommand(_ context.Context, _ ...string) *exec.Cmd { return nil } +func (m *mockExecutor) Type() string { return m.execType } + +func TestNew(t *testing.T) { + cfg := &shop.Config{ + URL: "http://localhost:8000", + AdminApi: &shop.ConfigAdminApi{ + Username: "admin", + Password: "shopware", + }, + } + envCfg := &shop.EnvironmentConfig{ + Type: "docker", + } + + m := New(Options{ + ProjectRoot: "/tmp/project", + Config: cfg, + EnvConfig: envCfg, + Executor: &mockExecutor{execType: "docker"}, + }) + + assert.Equal(t, tabGeneral, m.activeTab) + assert.Equal(t, "docker", m.general.envType) + assert.Equal(t, "http://localhost:8000", m.general.shopURL) + assert.Equal(t, "admin", m.general.username) + assert.True(t, m.dockerMode) +} + +func TestNew_EnvAdminApiOverride(t *testing.T) { + cfg := &shop.Config{ + URL: "http://localhost:8000", + } + envCfg := &shop.EnvironmentConfig{ + Type: "docker", + URL: "http://docker-host:8000", + AdminApi: &shop.ConfigAdminApi{ + Username: "env-admin", + Password: "env-pass", + }, + } + + m := New(Options{ + ProjectRoot: "/tmp/project", + Config: cfg, + EnvConfig: envCfg, + Executor: &mockExecutor{execType: "docker"}, + }) + + assert.Equal(t, "env-admin", m.general.username) + assert.Equal(t, "env-pass", m.general.password) + assert.Equal(t, "http://docker-host:8000", m.general.shopURL) + assert.True(t, m.extensions.configured) +} + +func TestNew_LocalMode(t *testing.T) { + cfg := &shop.Config{URL: "http://localhost:8000"} + envCfg := &shop.EnvironmentConfig{Type: "local"} + + m := New(Options{ + ProjectRoot: "/tmp/project", + Config: cfg, + EnvConfig: envCfg, + Executor: &mockExecutor{execType: "local"}, + }) + + assert.False(t, m.dockerMode) +} + +func TestTabSwitching(t *testing.T) { + cfg := &shop.Config{URL: "http://localhost:8000"} + envCfg := &shop.EnvironmentConfig{Type: "local"} + + m := New(Options{ + ProjectRoot: "/tmp/project", + Config: cfg, + EnvConfig: envCfg, + Executor: &mockExecutor{execType: "local"}, + }) + + // Switch to tab 2 + result, _ := m.Update(tea.KeyPressMsg(tea.Key{Code: '2', Text: "2"})) + model := result.(Model) + assert.Equal(t, tabLogs, model.activeTab) + + // Switch to tab 3 + result, _ = model.Update(tea.KeyPressMsg(tea.Key{Code: '3', Text: "3"})) + model = result.(Model) + assert.Equal(t, tabExtensions, model.activeTab) + + // Switch to tab 1 + result, _ = model.Update(tea.KeyPressMsg(tea.Key{Code: '1', Text: "1"})) + model = result.(Model) + assert.Equal(t, tabGeneral, model.activeTab) +} + +func TestTabSwitchingBlockedDuringOverlay(t *testing.T) { + cfg := &shop.Config{URL: "http://localhost:8000"} + envCfg := &shop.EnvironmentConfig{Type: "local"} + + m := New(Options{ + ProjectRoot: "/tmp/project", + Config: cfg, + EnvConfig: envCfg, + Executor: &mockExecutor{execType: "local"}, + }) + m.overlay = overlayStarting + + result, _ := m.Update(tea.KeyPressMsg(tea.Key{Code: '2', Text: "2"})) + model := result.(Model) + assert.Equal(t, tabGeneral, model.activeTab) +} + +func TestWindowSizeMsg(t *testing.T) { + cfg := &shop.Config{URL: "http://localhost:8000"} + envCfg := &shop.EnvironmentConfig{Type: "local"} + + m := New(Options{ + ProjectRoot: "/tmp/project", + Config: cfg, + EnvConfig: envCfg, + Executor: &mockExecutor{execType: "local"}, + }) + + result, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) + model := result.(Model) + assert.Equal(t, 120, model.width) + assert.Equal(t, 40, model.height) +} + +func TestDockerAlreadyRunning(t *testing.T) { + cfg := &shop.Config{URL: "http://localhost:8000"} + envCfg := &shop.EnvironmentConfig{Type: "docker"} + + m := New(Options{ + ProjectRoot: "/tmp/project", + Config: cfg, + EnvConfig: envCfg, + Executor: &mockExecutor{execType: "docker"}, + }) + + result, cmd := m.Update(dockerAlreadyRunningMsg{}) + model := result.(Model) + assert.Equal(t, overlayNone, model.overlay) + assert.NotNil(t, cmd) +} + +func TestDockerStartedSuccess(t *testing.T) { + cfg := &shop.Config{URL: "http://localhost:8000"} + envCfg := &shop.EnvironmentConfig{Type: "docker"} + + m := New(Options{ + ProjectRoot: "/tmp/project", + Config: cfg, + EnvConfig: envCfg, + Executor: &mockExecutor{execType: "docker"}, + }) + m.overlay = overlayStarting + + result, cmd := m.Update(dockerStartedMsg{}) + model := result.(Model) + assert.Equal(t, overlayNone, model.overlay) + assert.Nil(t, model.overlayLines) + assert.NotNil(t, cmd) +} + +func TestDockerStartedFailure(t *testing.T) { + cfg := &shop.Config{URL: "http://localhost:8000"} + envCfg := &shop.EnvironmentConfig{Type: "docker"} + + m := New(Options{ + ProjectRoot: "/tmp/project", + Config: cfg, + EnvConfig: envCfg, + Executor: &mockExecutor{execType: "docker"}, + }) + m.overlay = overlayStarting + + result, cmd := m.Update(dockerStartedMsg{err: assert.AnError}) + model := result.(Model) + assert.Equal(t, overlayStarting, model.overlay) + assert.NotEmpty(t, model.overlayLines) + assert.Nil(t, cmd) +} + +func TestOverlayRendering(t *testing.T) { + cfg := &shop.Config{URL: "http://localhost:8000"} + envCfg := &shop.EnvironmentConfig{Type: "docker"} + + m := New(Options{ + ProjectRoot: "/tmp/project", + Config: cfg, + EnvConfig: envCfg, + Executor: &mockExecutor{execType: "docker"}, + }) + m.overlay = overlayStarting + m.overlayLines = []string{"Pulling images..."} + + view := m.View() + assert.Contains(t, view.Content, "Starting Docker containers...") + assert.Contains(t, view.Content, "Pulling images...") +} + +func TestShopwareNotInstalled_ShowsPrompt(t *testing.T) { + cfg := &shop.Config{URL: "http://localhost:8000"} + envCfg := &shop.EnvironmentConfig{Type: "docker"} + + m := New(Options{ + ProjectRoot: "/tmp/project", + Config: cfg, + EnvConfig: envCfg, + Executor: &mockExecutor{execType: "docker"}, + }) + + result, cmd := m.Update(shopwareNotInstalledMsg{}) + model := result.(Model) + assert.Equal(t, overlayInstallPrompt, model.overlay) + assert.Equal(t, installStepAsk, model.install.step) + assert.Nil(t, cmd) +} + +func TestShopwareInstalled_StartsDashboard(t *testing.T) { + cfg := &shop.Config{URL: "http://localhost:8000"} + envCfg := &shop.EnvironmentConfig{Type: "docker"} + + m := New(Options{ + ProjectRoot: "/tmp/project", + Config: cfg, + EnvConfig: envCfg, + Executor: &mockExecutor{execType: "docker"}, + }) + + result, cmd := m.Update(shopwareInstalledMsg{}) + model := result.(Model) + assert.Equal(t, overlayNone, model.overlay) + assert.NotNil(t, cmd) +} + +func TestInstallPrompt_DeclineSkipsToDashboard(t *testing.T) { + cfg := &shop.Config{URL: "http://localhost:8000"} + envCfg := &shop.EnvironmentConfig{Type: "docker"} + + m := New(Options{ + ProjectRoot: "/tmp/project", + Config: cfg, + EnvConfig: envCfg, + Executor: &mockExecutor{execType: "docker"}, + }) + m.overlay = overlayInstallPrompt + m.install = installWizard{step: installStepAsk} + + result, cmd := m.Update(tea.KeyPressMsg(tea.Key{Code: 'n', Text: "n"})) + model := result.(Model) + assert.Equal(t, overlayNone, model.overlay) + assert.NotNil(t, cmd) +} + +func TestInstallPromptRendering(t *testing.T) { + cfg := &shop.Config{URL: "http://localhost:8000"} + envCfg := &shop.EnvironmentConfig{Type: "docker"} + + m := New(Options{ + ProjectRoot: "/tmp/project", + Config: cfg, + EnvConfig: envCfg, + Executor: &mockExecutor{execType: "docker"}, + }) + m.overlay = overlayInstallPrompt + m.install = installWizard{step: installStepAsk} + + view := m.View() + assert.Contains(t, view.Content, "Shopware is not installed") + assert.Contains(t, view.Content, "y: install") + assert.Contains(t, view.Content, "n: skip") +} + +func TestInstallWizard_AcceptGoesToLanguage(t *testing.T) { + cfg := &shop.Config{URL: "http://localhost:8000"} + envCfg := &shop.EnvironmentConfig{Type: "docker"} + + m := New(Options{ + ProjectRoot: "/tmp/project", + Config: cfg, + EnvConfig: envCfg, + Executor: &mockExecutor{execType: "docker"}, + }) + m.overlay = overlayInstallPrompt + m.install = installWizard{step: installStepAsk} + + result, _ := m.Update(tea.KeyPressMsg(tea.Key{Code: 'y', Text: "y"})) + model := result.(Model) + assert.Equal(t, overlayInstallPrompt, model.overlay) + assert.Equal(t, installStepLanguage, model.install.step) + assert.Equal(t, 0, model.install.cursor) +} + +func TestInstallWizard_LanguageSelection(t *testing.T) { + cfg := &shop.Config{URL: "http://localhost:8000"} + envCfg := &shop.EnvironmentConfig{Type: "docker"} + + m := New(Options{ + ProjectRoot: "/tmp/project", + Config: cfg, + EnvConfig: envCfg, + Executor: &mockExecutor{execType: "docker"}, + }) + m.overlay = overlayInstallPrompt + m.install = installWizard{step: installStepLanguage, cursor: 0} + + // Move down + result, _ := m.Update(tea.KeyPressMsg(tea.Key{Code: tea.KeyDown, Text: ""})) + model := result.(Model) + assert.Equal(t, 1, model.install.cursor) + + // Confirm en-US (index 1) + result, _ = model.Update(tea.KeyPressMsg(tea.Key{Code: tea.KeyEnter, Text: ""})) + model = result.(Model) + assert.Equal(t, installStepCurrency, model.install.step) + assert.Equal(t, "en-US", model.install.language) + assert.Equal(t, 0, model.install.cursor) +} + +func TestInstallWizard_LanguageRendering(t *testing.T) { + cfg := &shop.Config{URL: "http://localhost:8000"} + envCfg := &shop.EnvironmentConfig{Type: "docker"} + + m := New(Options{ + ProjectRoot: "/tmp/project", + Config: cfg, + EnvConfig: envCfg, + Executor: &mockExecutor{execType: "docker"}, + }) + m.overlay = overlayInstallPrompt + m.install = installWizard{step: installStepLanguage, cursor: 0} + + view := m.View() + assert.Contains(t, view.Content, "Select default language") + assert.Contains(t, view.Content, "> English (UK)") + assert.Contains(t, view.Content, " Deutsch") +} + +func TestInstallWizard_CurrencyRendering(t *testing.T) { + cfg := &shop.Config{URL: "http://localhost:8000"} + envCfg := &shop.EnvironmentConfig{Type: "docker"} + + m := New(Options{ + ProjectRoot: "/tmp/project", + Config: cfg, + EnvConfig: envCfg, + Executor: &mockExecutor{execType: "docker"}, + }) + m.overlay = overlayInstallPrompt + m.install = installWizard{step: installStepCurrency, language: "en-GB", cursor: 1} + + view := m.View() + assert.Contains(t, view.Content, "Select default currency") + assert.Contains(t, view.Content, " EUR") + assert.Contains(t, view.Content, "> USD") + assert.Contains(t, view.Content, " GBP") +} + +func TestInstallWizard_CursorBounds(t *testing.T) { + cfg := &shop.Config{URL: "http://localhost:8000"} + envCfg := &shop.EnvironmentConfig{Type: "docker"} + + m := New(Options{ + ProjectRoot: "/tmp/project", + Config: cfg, + EnvConfig: envCfg, + Executor: &mockExecutor{execType: "docker"}, + }) + m.overlay = overlayInstallPrompt + m.install = installWizard{step: installStepLanguage, cursor: 0} + + // Try to go above 0 + result, _ := m.Update(tea.KeyPressMsg(tea.Key{Code: tea.KeyUp, Text: ""})) + model := result.(Model) + assert.Equal(t, 0, model.install.cursor) + + // Go to last item and try to go past + model.install.cursor = len(installLanguages) - 1 + result, _ = model.Update(tea.KeyPressMsg(tea.Key{Code: tea.KeyDown, Text: ""})) + model = result.(Model) + assert.Equal(t, len(installLanguages)-1, model.install.cursor) +} + +func TestInstallDoneSuccess(t *testing.T) { + cfg := &shop.Config{URL: "http://localhost:8000"} + envCfg := &shop.EnvironmentConfig{Type: "docker"} + + m := New(Options{ + ProjectRoot: "/tmp/project", + Config: cfg, + EnvConfig: envCfg, + Executor: &mockExecutor{execType: "docker"}, + }) + m.overlay = overlayInstalling + + result, cmd := m.Update(shopwareInstallDoneMsg{}) + model := result.(Model) + assert.Equal(t, overlayNone, model.overlay) + assert.NotNil(t, cmd) +} + +func TestInstallDoneFailure(t *testing.T) { + cfg := &shop.Config{URL: "http://localhost:8000"} + envCfg := &shop.EnvironmentConfig{Type: "docker"} + + m := New(Options{ + ProjectRoot: "/tmp/project", + Config: cfg, + EnvConfig: envCfg, + Executor: &mockExecutor{execType: "docker"}, + }) + m.overlay = overlayInstalling + + result, cmd := m.Update(shopwareInstallDoneMsg{err: assert.AnError}) + model := result.(Model) + assert.Equal(t, overlayInstalling, model.overlay) + assert.NotEmpty(t, model.overlayLines) + assert.Nil(t, cmd) +} diff --git a/internal/devtui/styles.go b/internal/devtui/styles.go new file mode 100644 index 00000000..3d08304f --- /dev/null +++ b/internal/devtui/styles.go @@ -0,0 +1,44 @@ +package devtui + +import ( + "charm.land/lipgloss/v2" + "charm.land/lipgloss/v2/compat" +) + +var ( + activeTabStyle = lipgloss.NewStyle(). + Bold(true). + Border(lipgloss.NormalBorder(), true). + Padding(0, 1) + + inactiveTabStyle = lipgloss.NewStyle(). + Border(lipgloss.HiddenBorder(), true). + Padding(0, 1) + + helpStyle = lipgloss.NewStyle().Foreground(compat.AdaptiveColor{ + Light: lipgloss.Color("#9CA3AF"), + Dark: lipgloss.Color("#6B7280"), + }) + + labelStyle = lipgloss.NewStyle().Bold(true).Width(20) + + valueStyle = lipgloss.NewStyle().Foreground(compat.AdaptiveColor{ + Light: lipgloss.Color("#047857"), + Dark: lipgloss.Color("#04B575"), + }) + + errorStyle = lipgloss.NewStyle().Foreground(compat.AdaptiveColor{ + Light: lipgloss.Color("#DC2626"), + Dark: lipgloss.Color("#EF4444"), + }) + + statusStyle = lipgloss.NewStyle().Foreground(compat.AdaptiveColor{ + Light: lipgloss.Color("#B8860B"), + Dark: lipgloss.Color("#FFD700"), + }).Bold(true) + + overlayStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder(), true). + Padding(1, 3). + Bold(true) +) diff --git a/internal/devtui/tab_extensions.go b/internal/devtui/tab_extensions.go new file mode 100644 index 00000000..f5b15bd3 --- /dev/null +++ b/internal/devtui/tab_extensions.go @@ -0,0 +1,651 @@ +package devtui + +import ( + "context" + "fmt" + "path/filepath" + "slices" + "strings" + + tea "charm.land/bubbletea/v2" + "charm.land/bubbles/v2/spinner" + "charm.land/bubbles/v2/table" + "charm.land/bubbles/v2/textinput" + "charm.land/lipgloss/v2" + + adminSdk "github.com/shopware/shopware-cli/internal/admin-api" + "github.com/shopware/shopware-cli/internal/executor" + "github.com/shopware/shopware-cli/internal/packagist" + "github.com/shopware/shopware-cli/internal/shop" +) + +type extensionInputStep int + +const ( + inputStepNone extensionInputStep = iota + inputStepToken + inputStepLoadingPackages + inputStepPackageList +) + +type ExtensionsModel struct { + table table.Model + loading bool + err error + statusMsg string + config *shop.Config + extensions adminSdk.ExtensionList + configured bool + executor executor.Executor + projectRoot string + inputStep extensionInputStep + tokenInput textinput.Model + filterInput textinput.Model + packages []packageEntry + filteredPkgs []packageEntry + packageTable table.Model + spinner spinner.Model + width int + height int +} + +type packageEntry struct { + name string + description string +} + +type packagesLoadedMsg struct { + packages []packageEntry + err error +} + +type extensionsLoadedMsg struct { + extensions adminSdk.ExtensionList + err error +} + +type extensionActionDoneMsg struct { + err error +} + +type composerRequireDoneMsg struct { + err error +} + +func NewExtensionsModel(config *shop.Config, exec executor.Executor, projectRoot string) ExtensionsModel { + columns := []table.Column{ + {Title: "Name", Width: 30}, + {Title: "Label", Width: 30}, + {Title: "Version", Width: 12}, + {Title: "Type", Width: 10}, + {Title: "Status", Width: 35}, + } + + t := table.New( + table.WithColumns(columns), + table.WithFocused(true), + table.WithHeight(10), + ) + + s := table.DefaultStyles() + s.Header = s.Header. + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color("240")). + BorderBottom(true). + Bold(true) + t.SetStyles(s) + + tokenTi := textinput.New() + tokenTi.Placeholder = "your-token-here" + tokenTi.Prompt = "Token: " + tokenTi.CharLimit = 200 + + filterTi := textinput.New() + filterTi.Placeholder = "type to filter..." + filterTi.Prompt = "Filter: " + filterTi.CharLimit = 100 + + pkgColumns := []table.Column{ + {Title: "Package", Width: 40}, + {Title: "Description", Width: 50}, + } + pkgTable := table.New( + table.WithColumns(pkgColumns), + table.WithFocused(true), + table.WithHeight(15), + ) + pkgStyles := table.DefaultStyles() + pkgStyles.Header = pkgStyles.Header. + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color("240")). + BorderBottom(true). + Bold(true) + pkgTable.SetStyles(pkgStyles) + + sp := spinner.New( + spinner.WithSpinner(spinner.Dot), + spinner.WithStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("205"))), + ) + + return ExtensionsModel{ + table: t, + loading: true, + config: config, + configured: config.IsAdminAPIConfigured(), + executor: exec, + projectRoot: projectRoot, + tokenInput: tokenTi, + filterInput: filterTi, + packageTable: pkgTable, + spinner: sp, + } +} + +func (m ExtensionsModel) Init() tea.Cmd { + if !m.configured { + return nil + } + return m.loadExtensions() +} + +func (m ExtensionsModel) Update(msg tea.Msg) (ExtensionsModel, tea.Cmd) { + switch msg := msg.(type) { + case extensionsLoadedMsg: + m.loading = false + if msg.err != nil { + m.err = msg.err + return m, nil + } + m.extensions = msg.extensions + m.statusMsg = "" + m.updateTableRows() + return m, nil + + case extensionActionDoneMsg: + if msg.err != nil { + m.statusMsg = errorStyle.Render("Error: " + msg.err.Error()) + return m, nil + } + m.statusMsg = statusStyle.Render("Action completed, reloading...") + m.loading = true + return m, m.loadExtensions() + + case spinner.TickMsg: + if m.inputStep == inputStepLoadingPackages { + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + return m, cmd + } + + case packagesLoadedMsg: + if msg.err != nil { + m.statusMsg = errorStyle.Render("Failed to load packages: " + msg.err.Error()) + m.inputStep = inputStepNone + return m, nil + } + m.packages = msg.packages + m.filteredPkgs = msg.packages + m.inputStep = inputStepPackageList + m.filterInput.SetValue("") + m.filterInput.Focus() + m.updatePackageTableRows() + return m, textinput.Blink + + case composerRequireDoneMsg: + if msg.err != nil { + m.statusMsg = errorStyle.Render("Composer require failed: " + msg.err.Error()) + m.loading = false + return m, nil + } + m.statusMsg = statusStyle.Render("Package installed, refreshing extensions...") + return m, m.refreshAndReload() + + case tea.PasteMsg: + switch m.inputStep { + case inputStepToken: + var cmd tea.Cmd + m.tokenInput, cmd = m.tokenInput.Update(msg) + return m, cmd + case inputStepPackageList: + var cmd tea.Cmd + m.filterInput, cmd = m.filterInput.Update(msg) + m.applyPackageFilter() + return m, cmd + } + + case tea.KeyPressMsg: + if m.inputStep == inputStepLoadingPackages { + if msg.String() == "esc" { + m.inputStep = inputStepNone + } + return m, nil + } + + if m.inputStep == inputStepToken { + switch msg.String() { + case "enter": + token := strings.TrimSpace(m.tokenInput.Value()) + if token != "" { + if err := m.savePackagesToken(token); err != nil { + m.statusMsg = errorStyle.Render(err.Error()) + m.inputStep = inputStepNone + return m, nil + } + m.inputStep = inputStepLoadingPackages + return m, tea.Batch(m.spinner.Tick, m.loadPackages()) + } + return m, nil + case "esc": + m.inputStep = inputStepNone + m.tokenInput.SetValue("") + return m, nil + } + var cmd tea.Cmd + m.tokenInput, cmd = m.tokenInput.Update(msg) + return m, cmd + } + + if m.inputStep == inputStepPackageList { + switch msg.String() { + case "enter": + row := m.packageTable.SelectedRow() + if row != nil { + m.inputStep = inputStepNone + m.loading = true + m.statusMsg = statusStyle.Render("Running composer require " + row[0] + "...") + return m, m.composerRequire(row[0]) + } + return m, nil + case "esc": + m.inputStep = inputStepNone + m.filterInput.SetValue("") + return m, nil + case "up", "down", "pgup", "pgdown": + var cmd tea.Cmd + m.packageTable, cmd = m.packageTable.Update(msg) + return m, cmd + default: + var cmd tea.Cmd + m.filterInput, cmd = m.filterInput.Update(msg) + m.applyPackageFilter() + return m, cmd + } + } + + if !m.configured || m.loading { + return m, nil + } + + if msg.String() == "r" { + m.err = nil + m.statusMsg = statusStyle.Render("Reloading extensions...") + m.loading = true + return m, m.loadExtensions() + } + + if msg.String() == "ctrl+n" { + // Check if packages.shopware.com token exists in auth.json + authFile := filepath.Join(m.projectRoot, "auth.json") + auth, _ := packagist.ReadComposerAuth(authFile) + if auth.BearerAuth["packages.shopware.com"] == "" { + m.inputStep = inputStepToken + m.tokenInput.SetValue("") + m.tokenInput.Focus() + return m, textinput.Blink + } + m.inputStep = inputStepLoadingPackages + return m, tea.Batch(m.spinner.Tick, m.loadPackages()) + } + + if m.err != nil { + return m, nil + } + + ext := m.selectedExtension() + if ext == nil { + break + } + + switch msg.String() { + case "a": + m.statusMsg = statusStyle.Render("Activating " + ext.Name + "...") + return m, m.extensionAction(ext, "activate") + case "d": + m.statusMsg = statusStyle.Render("Deactivating " + ext.Name + "...") + return m, m.extensionAction(ext, "deactivate") + case "i": + m.statusMsg = statusStyle.Render("Installing " + ext.Name + "...") + return m, m.extensionAction(ext, "install") + case "u": + m.statusMsg = statusStyle.Render("Uninstalling " + ext.Name + "...") + return m, m.extensionAction(ext, "uninstall") + } + } + + var cmd tea.Cmd + m.table, cmd = m.table.Update(msg) + return m, cmd +} + +func (m ExtensionsModel) renderModal(content string) string { + modalWidth := m.width * 8 / 10 + modalHeight := m.height * 8 / 10 + if modalWidth < 40 { + modalWidth = 40 + } + if modalHeight < 10 { + modalHeight = 10 + } + + modal := overlayStyle. + Width(modalWidth). + Height(modalHeight). + Render(content) + + if m.width > 0 && m.height > 0 { + modal = lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, modal) + } + return modal +} + +func (m ExtensionsModel) View() string { + if m.inputStep == inputStepToken { + var b strings.Builder + b.WriteString(statusStyle.Render("Download Extension")) + b.WriteString("\n\n") + b.WriteString("Enter your packages.shopware.com token:\n\n") + b.WriteString(m.tokenInput.View()) + b.WriteString("\n\n") + b.WriteString(helpStyle.Render("enter: save | esc: cancel")) + return m.renderModal(b.String()) + } + + if m.inputStep == inputStepLoadingPackages { + var b strings.Builder + b.WriteString(statusStyle.Render("Download Extension")) + b.WriteString("\n\n") + b.WriteString(m.spinner.View() + " Loading available packages...") + b.WriteString("\n\n") + b.WriteString(helpStyle.Render("esc: cancel")) + return m.renderModal(b.String()) + } + + if m.inputStep == inputStepPackageList { + var b strings.Builder + b.WriteString(statusStyle.Render("Download Extension")) + b.WriteString("\n\n") + b.WriteString(m.filterInput.View()) + b.WriteString("\n\n") + if len(m.filteredPkgs) == 0 { + b.WriteString(helpStyle.Render("No packages found.") + "\n\n") + } else { + b.WriteString(m.packageTable.View()) + b.WriteString("\n") + } + b.WriteString(helpStyle.Render("enter: install | ↑/↓: navigate | esc: cancel")) + return m.renderModal(b.String()) + } + + if !m.configured { + return "\n" + helpStyle.Render("Admin API not configured. Add admin_api credentials to .shopware-project.yml") + "\n" + } + + if m.loading { + var b strings.Builder + b.WriteString("\n" + helpStyle.Render("Loading extensions...") + "\n") + if m.statusMsg != "" { + b.WriteString(m.statusMsg + "\n") + } + return b.String() + } + + if m.err != nil { + return "\n" + errorStyle.Render("Failed to load extensions: "+m.err.Error()) + "\n\n" + helpStyle.Render("r: retry") + "\n" + } + + var b strings.Builder + b.WriteString("\n") + + if len(m.extensions) == 0 { + b.WriteString(helpStyle.Render("No extensions installed.") + "\n") + } else { + b.WriteString(m.table.View()) + b.WriteString("\n") + } + + if m.statusMsg != "" { + b.WriteString(m.statusMsg + "\n") + } + + b.WriteString(helpStyle.Render("a: activate | d: deactivate | i: install | u: uninstall | r: reload | ctrl+n: download extension | ↑/↓: navigate")) + + return b.String() +} + +func (m *ExtensionsModel) SetSize(width, height int) { + m.width = width + m.height = height + m.table.SetWidth(width) + // Reserve 3 lines for status and help + m.table.SetHeight(height - 4) + // Modal is 80% of terminal, subtract border/padding and title/filter/help lines + modalInnerWidth := width*8/10 - 8 + modalInnerHeight := height*8/10 - 10 + if modalInnerWidth < 40 { + modalInnerWidth = 40 + } + if modalInnerHeight < 5 { + modalInnerHeight = 5 + } + m.packageTable.SetWidth(modalInnerWidth) + m.packageTable.SetHeight(modalInnerHeight) +} + +func (m *ExtensionsModel) updateTableRows() { + rows := make([]table.Row, 0, len(m.extensions)) + for _, ext := range m.extensions { + rows = append(rows, table.Row{ + ext.Name, + ext.Label, + ext.Version, + ext.Type, + ext.Status(), + }) + } + m.table.SetRows(rows) +} + +func (m *ExtensionsModel) selectedExtension() *adminSdk.ExtensionDetail { + row := m.table.SelectedRow() + if row == nil { + return nil + } + name := row[0] + return m.extensions.GetByName(name) +} + +func (m *ExtensionsModel) loadExtensions() tea.Cmd { + config := m.config + return func() tea.Msg { + ctx := context.Background() + client, err := shop.NewShopClient(ctx, config) + if err != nil { + return extensionsLoadedMsg{err: fmt.Errorf("cannot create API client: %w", err)} + } + + apiCtx := adminSdk.NewApiContext(ctx) + extensions, _, err := client.ExtensionManager.ListAvailableExtensions(apiCtx) + if err != nil { + return extensionsLoadedMsg{err: fmt.Errorf("cannot list extensions: %w", err)} + } + + return extensionsLoadedMsg{extensions: extensions} + } +} + +func (m *ExtensionsModel) loadPackages() tea.Cmd { + projectRoot := m.projectRoot + return func() tea.Msg { + authFile := filepath.Join(projectRoot, "auth.json") + auth, err := packagist.ReadComposerAuth(authFile) + if err != nil { + return packagesLoadedMsg{err: err} + } + + token := auth.BearerAuth["packages.shopware.com"] + if token == "" { + return packagesLoadedMsg{err: fmt.Errorf("no packages.shopware.com token found")} + } + + resp, err := packagist.GetPackages(context.Background(), token) + if err != nil { + return packagesLoadedMsg{err: err} + } + + var entries []packageEntry + for name, versions := range resp.Packages { + var desc string + for _, v := range versions { + if v.Description != "" { + desc = v.Description + break + } + } + entries = append(entries, packageEntry{name: name, description: desc}) + } + + // Sort alphabetically + slices.SortFunc(entries, func(a, b packageEntry) int { + return strings.Compare(a.name, b.name) + }) + + return packagesLoadedMsg{packages: entries} + } +} + +func (m *ExtensionsModel) updatePackageTableRows() { + rows := make([]table.Row, 0, len(m.filteredPkgs)) + for _, pkg := range m.filteredPkgs { + desc := pkg.description + if len(desc) > 47 { + desc = desc[:47] + "..." + } + rows = append(rows, table.Row{pkg.name, desc}) + } + m.packageTable.SetRows(rows) +} + +func (m *ExtensionsModel) applyPackageFilter() { + filter := strings.ToLower(strings.TrimSpace(m.filterInput.Value())) + if filter == "" { + m.filteredPkgs = m.packages + } else { + m.filteredPkgs = nil + for _, pkg := range m.packages { + if strings.Contains(strings.ToLower(pkg.name), filter) || strings.Contains(strings.ToLower(pkg.description), filter) { + m.filteredPkgs = append(m.filteredPkgs, pkg) + } + } + } + m.updatePackageTableRows() +} + +func (m *ExtensionsModel) savePackagesToken(token string) error { + // Save token to auth.json + authFile := filepath.Join(m.projectRoot, "auth.json") + auth, _ := packagist.ReadComposerAuth(authFile) + auth.BearerAuth["packages.shopware.com"] = token + if err := auth.Save(); err != nil { + return fmt.Errorf("failed to save auth.json: %w", err) + } + + // Add repository to composer.json if missing + composerFile := filepath.Join(m.projectRoot, "composer.json") + composerJson, err := packagist.ReadComposerJson(composerFile) + if err != nil { + return fmt.Errorf("failed to read composer.json: %w", err) + } + + if !composerJson.Repositories.HasRepository("https://packages.shopware.com") { + composerJson.Repositories = append(composerJson.Repositories, packagist.ComposerJsonRepository{ + Type: "composer", + URL: "https://packages.shopware.com", + }) + if err := composerJson.Save(); err != nil { + return fmt.Errorf("failed to save composer.json: %w", err) + } + } + + return nil +} + +func (m *ExtensionsModel) composerRequire(pkg string) tea.Cmd { + e := m.executor + projectRoot := m.projectRoot + return func() tea.Msg { + cmd := e.ComposerCommand(context.Background(), "require", pkg) + cmd.Dir = projectRoot + + output, err := cmd.CombinedOutput() + if err != nil { + // Include last few lines of output for context + lines := strings.Split(strings.TrimSpace(string(output)), "\n") + if len(lines) > 5 { + lines = lines[len(lines)-5:] + } + return composerRequireDoneMsg{err: fmt.Errorf("%w\n%s", err, strings.Join(lines, "\n"))} + } + + return composerRequireDoneMsg{} + } +} + +func (m *ExtensionsModel) refreshAndReload() tea.Cmd { + config := m.config + return func() tea.Msg { + ctx := context.Background() + client, err := shop.NewShopClient(ctx, config) + if err != nil { + // Still reload even if refresh fails + return extensionsLoadedMsg{err: fmt.Errorf("cannot create API client for refresh: %w", err)} + } + + apiCtx := adminSdk.NewApiContext(ctx) + _, _ = client.ExtensionManager.Refresh(apiCtx) + + extensions, _, err := client.ExtensionManager.ListAvailableExtensions(apiCtx) + if err != nil { + return extensionsLoadedMsg{err: fmt.Errorf("cannot list extensions: %w", err)} + } + + return extensionsLoadedMsg{extensions: extensions} + } +} + +func (m *ExtensionsModel) extensionAction(ext *adminSdk.ExtensionDetail, action string) tea.Cmd { + config := m.config + extType := ext.Type + extName := ext.Name + return func() tea.Msg { + ctx := context.Background() + client, err := shop.NewShopClient(ctx, config) + if err != nil { + return extensionActionDoneMsg{err: err} + } + + apiCtx := adminSdk.NewApiContext(ctx) + + switch action { + case "activate": + _, err = client.ExtensionManager.ActivateExtension(apiCtx, extType, extName) + case "deactivate": + _, err = client.ExtensionManager.DeactivateExtension(apiCtx, extType, extName) + case "install": + _, err = client.ExtensionManager.InstallExtension(apiCtx, extType, extName) + case "uninstall": + _, err = client.ExtensionManager.UninstallExtension(apiCtx, extType, extName) + } + + return extensionActionDoneMsg{err: err} + } +} diff --git a/internal/devtui/tab_general.go b/internal/devtui/tab_general.go new file mode 100644 index 00000000..cc2af6a3 --- /dev/null +++ b/internal/devtui/tab_general.go @@ -0,0 +1,217 @@ +package devtui + +import ( + "encoding/json" + "fmt" + "os/exec" + "strings" + + tea "charm.land/bubbletea/v2" +) + +type GeneralModel struct { + envType string + shopURL string + adminURL string + username string + password string + services []discoveredService + projectRoot string + loading bool + err error +} + +type discoveredService struct { + Name string + URL string + Username string + Password string +} + +type servicesLoadedMsg struct { + services []discoveredService + err error +} + +// knownService defines a well-known Docker compose service with its primary UI port and default credentials. +type knownService struct { + Name string + TargetPort int + Username string + Password string +} + +// knownServices maps compose service names to their known configuration. +// The key is the compose service name (e.g. "database", "mailer", "lavinmq"). +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"}, +} + +// ignoredServices are compose services whose ports should not be listed. +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) +} + +type browserOpenedMsg struct{} + +func (m GeneralModel) Update(msg tea.Msg) (GeneralModel, tea.Cmd) { + switch msg := msg.(type) { + case servicesLoadedMsg: + 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.Command("open", url).Start() + return browserOpenedMsg{} + } +} + +func (m GeneralModel) View() string { + var b strings.Builder + + b.WriteString("\n") + b.WriteString(labelStyle.Render("Environment") + valueStyle.Render(m.envType) + "\n") + b.WriteString(labelStyle.Render("Shop URL") + valueStyle.Render(m.shopURL) + "\n") + b.WriteString(labelStyle.Render("Admin URL") + valueStyle.Render(m.adminURL) + "\n") + + if m.username != "" { + b.WriteString(labelStyle.Render("Admin User") + valueStyle.Render(m.username) + "\n") + b.WriteString(labelStyle.Render("Admin Password") + valueStyle.Render(m.password) + "\n") + } + + b.WriteString("\n") + + if m.loading { + b.WriteString(helpStyle.Render("Discovering services...") + "\n") + } else if m.err != nil { + b.WriteString(errorStyle.Render("Service discovery failed: "+m.err.Error()) + "\n") + } else if len(m.services) > 0 { + for _, s := range m.services { + b.WriteString(labelStyle.Render(s.Name) + valueStyle.Render(s.URL) + "\n") + if s.Username != "" { + b.WriteString(labelStyle.Render(" Username") + valueStyle.Render(s.Username) + "\n") + b.WriteString(labelStyle.Render(" Password") + valueStyle.Render(s.Password) + "\n") + } + } + } + + b.WriteString("\n") + b.WriteString(helpStyle.Render("f: open shop | a: open admin")) + + return b.String() +} + +// dockerComposePSOutput represents a single container from `docker compose ps --format json`. +type dockerComposePSOutput struct { + Name string `json:"Name"` + Service string `json:"Service"` + State string `json:"State"` + Publishers []struct { + URL string `json:"URL"` + TargetPort int `json:"TargetPort"` + PublishedPort int `json:"PublishedPort"` + Protocol string `json:"Protocol"` + } `json:"Publishers"` +} + +func discoverServices(projectRoot string) tea.Cmd { + return func() tea.Msg { + cmd := exec.Command("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..2b321ffb --- /dev/null +++ b/internal/devtui/tab_logs.go @@ -0,0 +1,363 @@ +package devtui + +import ( + "bufio" + "context" + "os" + "os/exec" + "path/filepath" + "strings" + + tea "charm.land/bubbletea/v2" + "charm.land/bubbles/v2/viewport" + "charm.land/lipgloss/v2" +) + +// logSource represents a selectable log source (docker container or file). +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 "up", "k": + if m.cursor > 0 { + m.cursor-- + } + return m, nil + case "down", "j": + if m.cursor < len(m.sources)-1 { + m.cursor++ + } + return m, nil + case "enter": + 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 "f": + 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 { + sidebar := m.renderSidebar() + + var content strings.Builder + content.WriteString(m.viewport.View()) + content.WriteString("\n") + + followIndicator := "off" + if m.follow { + followIndicator = "on" + } + content.WriteString(helpStyle.Render("f: toggle follow (" + followIndicator + ") | ↑/↓: select source | enter: switch | pgup/pgdn: scroll")) + + return lipgloss.JoinHorizontal(lipgloss.Top, sidebar, content.String()) +} + +func (m LogsModel) renderSidebar() string { + sidebarStyle := lipgloss.NewStyle(). + Width(sidebarWidth). + BorderRight(true). + BorderStyle(lipgloss.NormalBorder()). + PaddingRight(1) + + var b strings.Builder + + for i, src := range m.sources { + prefix := " " + if i == m.cursor { + prefix = "> " + } + + name := src.name + if i == m.active { + name = statusStyle.Render(name) + } + + b.WriteString(prefix + name + "\n") + } + + return sidebarStyle.Height(m.height - 4).Render(b.String()) +} + +func (m *LogsModel) SetSize(width, height int) { + m.width = width + m.height = height + // Subtract sidebar width + border + viewportWidth := width - sidebarWidth - 2 + if viewportWidth < 10 { + viewportWidth = 10 + } + m.viewport.SetWidth(viewportWidth) + m.viewport.SetHeight(height - 2) +} + +// StartStreaming discovers sources and starts streaming the first one. +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 { + cmd := exec.Command("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/executor/docker.go b/internal/executor/docker.go index 91a49192..d0c87aa5 100644 --- a/internal/executor/docker.go +++ b/internal/executor/docker.go @@ -2,6 +2,7 @@ package executor import ( "context" + "fmt" "os" "os/exec" @@ -12,7 +13,7 @@ import ( type DockerExecutor struct{} func (d *DockerExecutor) ConsoleCommand(ctx context.Context, args ...string) *exec.Cmd { - dockerArgs := d.baseArgs() + dockerArgs := d.baseArgs(ctx) dockerArgs = append(dockerArgs, "php", consoleCommandName(ctx)) dockerArgs = append(dockerArgs, args...) @@ -20,7 +21,7 @@ func (d *DockerExecutor) ConsoleCommand(ctx context.Context, args ...string) *ex } func (d *DockerExecutor) ComposerCommand(ctx context.Context, args ...string) *exec.Cmd { - dockerArgs := d.baseArgs() + dockerArgs := d.baseArgs(ctx) dockerArgs = append(dockerArgs, "composer") dockerArgs = append(dockerArgs, args...) @@ -28,7 +29,7 @@ func (d *DockerExecutor) ComposerCommand(ctx context.Context, args ...string) *e } func (d *DockerExecutor) PHPCommand(ctx context.Context, args ...string) *exec.Cmd { - dockerArgs := d.baseArgs() + dockerArgs := d.baseArgs(ctx) dockerArgs = append(dockerArgs, "php") dockerArgs = append(dockerArgs, args...) @@ -39,13 +40,17 @@ func (d *DockerExecutor) Type() string { return "docker" } -func (d *DockerExecutor) baseArgs() []string { +func (d *DockerExecutor) baseArgs(ctx context.Context) []string { args := []string{"compose", "exec"} if !isatty.IsTerminal(os.Stdin.Fd()) { args = append(args, "-T") } + for k, v := range getEnvVars(ctx) { + 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 index 99ead4ac..4034a104 100644 --- a/internal/executor/executor.go +++ b/internal/executor/executor.go @@ -22,6 +22,21 @@ type Executor interface { Type() string } +type envVarsKey struct{} + +// WithEnv attaches extra environment variables to the context. +// Executor implementations will apply these to the created commands. +// For Docker, they are injected as -e flags; for local/symfony, they are set on cmd.Env. +func WithEnv(ctx context.Context, env map[string]string) context.Context { + return context.WithValue(ctx, envVarsKey{}, env) +} + +// getEnvVars extracts extra environment variables from the context. +func getEnvVars(ctx context.Context) map[string]string { + env, _ := ctx.Value(envVarsKey{}).(map[string]string) + return env +} + type allowBinCIKey struct{} // AllowBinCI marks a context so that ConsoleCommand may use bin/ci instead of bin/console in CI environments. diff --git a/internal/executor/executor_test.go b/internal/executor/executor_test.go index 402da255..de5af94d 100644 --- a/internal/executor/executor_test.go +++ b/internal/executor/executor_test.go @@ -134,3 +134,43 @@ func TestConsoleCommandNameWithAllowBinCI(t *testing.T) { ctx := AllowBinCI(t.Context()) assert.Equal(t, "bin/ci", consoleCommandName(ctx)) } + +func TestLocalExecutorWithEnv(t *testing.T) { + exec := &LocalExecutor{} + ctx := WithEnv(t.Context(), map[string]string{ + "INSTALL_LOCALE": "de-DE", + "INSTALL_CURRENCY": "EUR", + }) + + cmd := exec.PHPCommand(ctx, "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{} + ctx := WithEnv(t.Context(), map[string]string{ + "INSTALL_LOCALE": "en-GB", + }) + + cmd := exec.PHPCommand(ctx, "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"} + ctx := WithEnv(t.Context(), map[string]string{ + "INSTALL_LOCALE": "de-DE", + }) + + cmd := exec.PHPCommand(ctx, "-v") + assert.Contains(t, cmd.Env, "INSTALL_LOCALE=de-DE") +} diff --git a/internal/executor/local.go b/internal/executor/local.go index 6ebc8f92..7e2ebf38 100644 --- a/internal/executor/local.go +++ b/internal/executor/local.go @@ -2,6 +2,8 @@ package executor import ( "context" + "fmt" + "os" "os/exec" ) @@ -11,17 +13,35 @@ type LocalExecutor struct{} func (l *LocalExecutor) ConsoleCommand(ctx context.Context, args ...string) *exec.Cmd { cmdArgs := []string{consoleCommandName(ctx)} cmdArgs = append(cmdArgs, args...) - return exec.CommandContext(ctx, "php", cmdArgs...) + cmd := exec.CommandContext(ctx, "php", cmdArgs...) + applyLocalEnv(ctx, cmd) + return cmd } func (l *LocalExecutor) ComposerCommand(ctx context.Context, args ...string) *exec.Cmd { - return exec.CommandContext(ctx, "composer", args...) + cmd := exec.CommandContext(ctx, "composer", args...) + applyLocalEnv(ctx, cmd) + return cmd } func (l *LocalExecutor) PHPCommand(ctx context.Context, args ...string) *exec.Cmd { - return exec.CommandContext(ctx, "php", args...) + cmd := exec.CommandContext(ctx, "php", args...) + applyLocalEnv(ctx, cmd) + return cmd } func (l *LocalExecutor) Type() string { return "local" } + +// applyLocalEnv sets extra environment variables from the context on a local command. +func applyLocalEnv(ctx context.Context, cmd *exec.Cmd) { + env := getEnvVars(ctx) + 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 index bd2b36bf..3c79833b 100644 --- a/internal/executor/symfony_cli.go +++ b/internal/executor/symfony_cli.go @@ -14,19 +14,25 @@ type SymfonyCLIExecutor struct { func (s *SymfonyCLIExecutor) ConsoleCommand(ctx context.Context, args ...string) *exec.Cmd { cmdArgs := []string{"php", consoleCommandName(ctx)} cmdArgs = append(cmdArgs, args...) - return exec.CommandContext(ctx, s.BinaryPath, cmdArgs...) + cmd := exec.CommandContext(ctx, s.BinaryPath, cmdArgs...) + applyLocalEnv(ctx, cmd) + return cmd } func (s *SymfonyCLIExecutor) ComposerCommand(ctx context.Context, args ...string) *exec.Cmd { cmdArgs := []string{"composer"} cmdArgs = append(cmdArgs, args...) - return exec.CommandContext(ctx, s.BinaryPath, cmdArgs...) + cmd := exec.CommandContext(ctx, s.BinaryPath, cmdArgs...) + applyLocalEnv(ctx, cmd) + return cmd } func (s *SymfonyCLIExecutor) PHPCommand(ctx context.Context, args ...string) *exec.Cmd { cmdArgs := []string{"php"} cmdArgs = append(cmdArgs, args...) - return exec.CommandContext(ctx, s.BinaryPath, cmdArgs...) + cmd := exec.CommandContext(ctx, s.BinaryPath, cmdArgs...) + applyLocalEnv(ctx, cmd) + return cmd } func (s *SymfonyCLIExecutor) Type() string { diff --git a/internal/packagist/packagist.go b/internal/packagist/packagist.go index b8221937..0e1737ae 100644 --- a/internal/packagist/packagist.go +++ b/internal/packagist/packagist.go @@ -23,8 +23,9 @@ 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) { diff --git a/internal/packagist/project_composer_json.go b/internal/packagist/project_composer_json.go index 646f3ff6..5bb2256d 100644 --- a/internal/packagist/project_composer_json.go +++ b/internal/packagist/project_composer_json.go @@ -61,6 +61,7 @@ func GenerateComposerJson(ctx context.Context, opts ComposerJsonOptions) (string require := newOrderedMap() require.set("composer-runtime-api", "^2.0") + require.set("shopware/deployment-helper", "*") require.set("shopware/administration", opts.DependingVersion) require.set("shopware/core", opts.Version) if opts.UseElasticsearch { diff --git a/internal/shop/config.go b/internal/shop/config.go index e3364471..3175308d 100644 --- a/internal/shop/config.go +++ b/internal/shop/config.go @@ -414,6 +414,39 @@ type ConfigImageProxy struct { URL string `yaml:"url,omitempty"` } +// NewConfig creates a new Config with the current compatibility date and a local environment. +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", + }, + }, + }, + } +} + +// WriteConfig marshals the config to YAML and writes it to dir/.shopware-project.yaml. +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/shopware-project-schema.json b/shopware-project-schema.json index cd0863b2..53bfbc2b 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" @@ -443,6 +450,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 From 00ba517158711a16e8904c80c7592a35418a5c45 Mon Sep 17 00:00:00 2001 From: Soner Sayakci Date: Fri, 6 Mar 2026 05:48:20 +0100 Subject: [PATCH 05/19] refactor --- internal/devtui/model.go | 248 +++++----- internal/devtui/model_test.go | 8 +- internal/devtui/tab_extensions.go | 651 --------------------------- internal/devtui/tab_general.go | 16 +- internal/devtui/tab_logs.go | 13 +- internal/extension/packagist.go | 44 +- internal/packagist/packagist.go | 113 +++++ internal/packagist/packagist_test.go | 151 +++++++ 8 files changed, 424 insertions(+), 820 deletions(-) delete mode 100644 internal/devtui/tab_extensions.go diff --git a/internal/devtui/model.go b/internal/devtui/model.go index d041fd0d..fbac2edd 100644 --- a/internal/devtui/model.go +++ b/internal/devtui/model.go @@ -7,8 +7,8 @@ import ( "os/exec" "strings" - tea "charm.land/bubbletea/v2" "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" "github.com/shopware/shopware-cli/internal/executor" @@ -20,10 +20,30 @@ type activeTab int const ( tabGeneral activeTab = iota tabLogs - tabExtensions ) -var tabNames = []string{"General", "Logs", "Extensions"} +var tabNames = []string{"General", "Logs"} + +// Key constants for repeated key strings +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" +) type overlay int @@ -73,12 +93,12 @@ var ( // installWizard holds state for the Shopware install prompt. type installWizard struct { - step installStep - cursor int - language string - currency string - username textinput.Model - password textinput.Model + step installStep + cursor int + language string + currency string + username textinput.Model + password textinput.Model } // Options configures the TUI dashboard. @@ -94,7 +114,6 @@ type Model struct { activeTab activeTab general GeneralModel logs LogsModel - extensions ExtensionsModel width int height int dockerMode bool @@ -129,17 +148,10 @@ func New(opts Options) Model { effectiveAdminApi = opts.EnvConfig.AdminApi } - // Build a config copy with the resolved values for the shop client - effectiveConfig := *opts.Config - if effectiveAdminApi != nil { - effectiveConfig.AdminApi = effectiveAdminApi - } - shopURL := opts.Config.URL if opts.EnvConfig.URL != "" { shopURL = opts.EnvConfig.URL } - effectiveConfig.URL = shopURL var username, password string if effectiveAdminApi != nil { @@ -153,7 +165,6 @@ func New(opts Options) Model { activeTab: tabGeneral, general: NewGeneralModel(opts.Executor.Type(), shopURL, username, password, opts.ProjectRoot), logs: NewLogsModel(opts.ProjectRoot, isDocker), - extensions: NewExtensionsModel(&effectiveConfig, opts.Executor, opts.ProjectRoot), dockerMode: isDocker, projectRoot: opts.ProjectRoot, executor: opts.Executor, @@ -173,7 +184,6 @@ func (m *Model) startDashboard() tea.Cmd { return tea.Batch( m.general.Init(), m.logs.StartStreaming(), - m.extensions.Init(), ) } @@ -182,11 +192,28 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height - contentHeight := m.height - 4 - m.logs.SetSize(m.width, contentHeight) - m.extensions.SetSize(m.width, contentHeight) + 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) +} + +// updateLifecycle handles docker and shopware lifecycle messages. +func (m Model) updateLifecycle(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { case dockerAlreadyRunningMsg: m.overlay = overlayNone return m, m.checkShopwareInstalled() @@ -267,71 +294,70 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case dockerStoppedMsg: return m, tea.Quit + } - case tea.KeyPressMsg: - if m.overlay == overlayInstallPrompt { - return m.updateInstallPrompt(msg) - } - - if m.overlay == overlayStopConfirm { - switch msg.String() { - case "y", "Y": - m.overlay = overlayStopping - m.overlayLines = nil - return m, m.stopContainers() - case "n", "N": - return m, tea.Quit - } - return m, nil - } + return m, nil +} - // Other overlays only allow quit - if m.overlay != overlayNone { - if msg.String() == "q" || msg.String() == "ctrl+c" { - return m, tea.Quit - } - return m, nil - } +// updateKeyPress handles all key press events, including overlay-specific keys. +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 "ctrl+c", "q": - m.logs.StopStreaming() - if m.dockerMode { - m.overlay = overlayStopConfirm - m.overlayLines = nil - return m, nil - } + case keyY, keyYUpper: + m.overlay = overlayStopping + m.overlayLines = nil + return m, m.stopContainers() + case keyN, keyNUpper: return m, tea.Quit - case "1": - m.activeTab = tabGeneral - return m, nil - case "2": - m.activeTab = tabLogs - return m, nil - case "3": - m.activeTab = tabExtensions - return m, nil - case "tab": - m.activeTab = (m.activeTab + 1) % 3 - return m, nil - case "shift+tab": - m.activeTab = (m.activeTab + 2) % 3 - return m, nil - case "f": - if m.activeTab == tabGeneral { - return m, openInBrowser(m.general.shopURL) - } - case "a": - if m.activeTab == tabGeneral { - return m, openInBrowser(m.general.adminURL) - } } + return m, nil } + // Other overlays only allow quit 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) +} + +// updateChildren propagates messages to child models. +func (m Model) updateChildren(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd newGeneral, cmd := m.general.Update(msg) @@ -346,44 +372,38 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, cmd) } - newExt, cmd := m.extensions.Update(msg) - m.extensions = newExt - if cmd != nil { - cmds = append(cmds, cmd) - } - return m, tea.Batch(cmds...) } // updateInstallPrompt handles key input for the install wizard steps. func (m Model) updateInstallPrompt(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { switch msg.String() { - case "q", "ctrl+c": + case keyQ, keyCtrlC: return m, tea.Quit } switch m.install.step { case installStepAsk: switch msg.String() { - case "y", "Y": + case keyY, keyYUpper: m.install.step = installStepLanguage m.install.cursor = 0 - case "n", "N": + case keyN, keyNUpper: m.overlay = overlayNone return m, m.startDashboard() } case installStepLanguage: switch msg.String() { - case "up", "k": + case keyUp, keyK: if m.install.cursor > 0 { m.install.cursor-- } - case "down", "j": + case keyDown, keyJ: if m.install.cursor < len(installLanguages)-1 { m.install.cursor++ } - case "enter": + case keyEnter: m.install.language = installLanguages[m.install.cursor].id m.install.step = installStepCurrency m.install.cursor = 0 @@ -391,15 +411,15 @@ func (m Model) updateInstallPrompt(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { case installStepCurrency: switch msg.String() { - case "up", "k": + case keyUp, keyK: if m.install.cursor > 0 { m.install.cursor-- } - case "down", "j": + case keyDown, keyJ: if m.install.cursor < len(installCurrencies)-1 { m.install.cursor++ } - case "enter": + case keyEnter: m.install.currency = installCurrencies[m.install.cursor] m.install.step = installStepUsername m.install.username.SetValue("admin") @@ -409,7 +429,7 @@ func (m Model) updateInstallPrompt(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { case installStepUsername: switch msg.String() { - case "enter": + case keyEnter: m.install.step = installStepPassword m.install.username.Blur() m.install.password.SetValue("shopware") @@ -423,7 +443,7 @@ func (m Model) updateInstallPrompt(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { case installStepPassword: switch msg.String() { - case "enter": + case keyEnter: m.install.password.Blur() m.overlay = overlayInstalling m.overlayLines = nil @@ -452,8 +472,6 @@ func (m Model) View() tea.View { b.WriteString(m.general.View()) case tabLogs: b.WriteString(m.logs.View()) - case tabExtensions: - b.WriteString(m.extensions.View()) } } @@ -465,6 +483,8 @@ func (m Model) View() tea.View { func (m Model) renderOverlay() string { var title string switch m.overlay { + case overlayNone: + title = "" case overlayStarting: title = "Starting Docker containers..." case overlayStopConfirm: @@ -481,15 +501,18 @@ func (m Model) renderOverlay() string { content.WriteString(statusStyle.Render(title)) content.WriteString("\n\n") - if m.overlay == overlayStopConfirm { - content.WriteString("Do you want to stop the Docker containers?\n\n") - content.WriteString(helpStyle.Render("y: stop containers | n: quit without stopping")) - } else if m.overlay == overlayInstallPrompt { - m.renderInstallPrompt(&content) - } else { + switch m.overlay { + case overlayNone: + // No overlay content needed + case overlayStarting, overlayStopping, overlayInstalling: for _, line := range m.overlayLines { content.WriteString(line + "\n") } + case overlayStopConfirm: + content.WriteString("Do you want to stop the Docker containers?\n\n") + content.WriteString(helpStyle.Render("y: stop containers | n: quit without stopping")) + case overlayInstallPrompt: + m.renderInstallPrompt(&content) } modal := overlayStyle.Render(content.String()) @@ -520,7 +543,7 @@ func (m Model) renderInstallPrompt(b *strings.Builder) { b.WriteString(helpStyle.Render("↑/↓: select | enter: confirm | q: quit")) case installStepCurrency: - b.WriteString(fmt.Sprintf("Language: %s\n\n", valueStyle.Render(m.install.language))) + fmt.Fprintf(b, "Language: %s\n\n", valueStyle.Render(m.install.language)) b.WriteString("Select default currency:\n\n") for i, curr := range installCurrencies { cursor := " " @@ -533,17 +556,17 @@ func (m Model) renderInstallPrompt(b *strings.Builder) { b.WriteString(helpStyle.Render("↑/↓: select | enter: confirm | q: quit")) case installStepUsername: - b.WriteString(fmt.Sprintf("Language: %s\n", valueStyle.Render(m.install.language))) - b.WriteString(fmt.Sprintf("Currency: %s\n\n", valueStyle.Render(m.install.currency))) + 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(helpStyle.Render("enter: confirm | q: quit")) case installStepPassword: - b.WriteString(fmt.Sprintf("Language: %s\n", valueStyle.Render(m.install.language))) - b.WriteString(fmt.Sprintf("Currency: %s\n", valueStyle.Render(m.install.currency))) - b.WriteString(fmt.Sprintf("Username: %s\n\n", valueStyle.Render(m.install.username.Value()))) + 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") @@ -567,7 +590,8 @@ func (m Model) renderTabBar() string { // checkContainersRunning checks if any containers are already running. func checkContainersRunning(projectRoot string) tea.Cmd { return func() tea.Msg { - check := exec.Command("docker", "compose", "ps", "--status=running", "-q") + 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 { @@ -614,8 +638,8 @@ func (m *Model) runShopwareInstall() tea.Cmd { doneCmd := func() tea.Msg { ctx := executor.WithEnv(context.Background(), map[string]string{ - "INSTALL_LOCALE": language, - "INSTALL_CURRENCY": currency, + "INSTALL_LOCALE": language, + "INSTALL_CURRENCY": currency, "INSTALL_ADMIN_USERNAME": username, "INSTALL_ADMIN_PASSWORD": password, }) @@ -664,7 +688,7 @@ func (m *Model) readNextDockerOutput() tea.Cmd { // runDockerCommandWithArgs runs a docker compose command, streaming stderr lines // through a channel for display, and returns a result message when done. -func runDockerCommandWithArgs(projectRoot string, args []string, resultFn func(error) tea.Msg) (outChan <-chan string, outputCmd tea.Cmd, doneCmd tea.Cmd) { +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 { @@ -676,7 +700,7 @@ func runDockerCommandWithArgs(projectRoot string, args []string, resultFn func(e } doneCmd = func() tea.Msg { - cmd := exec.Command("docker", args...) + cmd := exec.CommandContext(ctx, "docker", args...) cmd.Dir = projectRoot pipe, err := cmd.StderrPipe() @@ -707,6 +731,7 @@ func runDockerCommandWithArgs(projectRoot string, args []string, resultFn func(e // startContainers runs docker compose up -d, streaming output. 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} }, @@ -718,6 +743,7 @@ func (m *Model) startContainers() tea.Cmd { // stopContainers runs docker compose down, streaming output. 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} }, diff --git a/internal/devtui/model_test.go b/internal/devtui/model_test.go index 8272c25f..1d9c089e 100644 --- a/internal/devtui/model_test.go +++ b/internal/devtui/model_test.go @@ -18,7 +18,7 @@ type mockExecutor struct { func (m *mockExecutor) ConsoleCommand(_ context.Context, _ ...string) *exec.Cmd { return nil } func (m *mockExecutor) ComposerCommand(_ context.Context, _ ...string) *exec.Cmd { return nil } func (m *mockExecutor) PHPCommand(_ context.Context, _ ...string) *exec.Cmd { return nil } -func (m *mockExecutor) Type() string { return m.execType } +func (m *mockExecutor) Type() string { return m.execType } func TestNew(t *testing.T) { cfg := &shop.Config{ @@ -69,7 +69,6 @@ func TestNew_EnvAdminApiOverride(t *testing.T) { assert.Equal(t, "env-admin", m.general.username) assert.Equal(t, "env-pass", m.general.password) assert.Equal(t, "http://docker-host:8000", m.general.shopURL) - assert.True(t, m.extensions.configured) } func TestNew_LocalMode(t *testing.T) { @@ -102,11 +101,6 @@ func TestTabSwitching(t *testing.T) { model := result.(Model) assert.Equal(t, tabLogs, model.activeTab) - // Switch to tab 3 - result, _ = model.Update(tea.KeyPressMsg(tea.Key{Code: '3', Text: "3"})) - model = result.(Model) - assert.Equal(t, tabExtensions, model.activeTab) - // Switch to tab 1 result, _ = model.Update(tea.KeyPressMsg(tea.Key{Code: '1', Text: "1"})) model = result.(Model) diff --git a/internal/devtui/tab_extensions.go b/internal/devtui/tab_extensions.go deleted file mode 100644 index f5b15bd3..00000000 --- a/internal/devtui/tab_extensions.go +++ /dev/null @@ -1,651 +0,0 @@ -package devtui - -import ( - "context" - "fmt" - "path/filepath" - "slices" - "strings" - - tea "charm.land/bubbletea/v2" - "charm.land/bubbles/v2/spinner" - "charm.land/bubbles/v2/table" - "charm.land/bubbles/v2/textinput" - "charm.land/lipgloss/v2" - - adminSdk "github.com/shopware/shopware-cli/internal/admin-api" - "github.com/shopware/shopware-cli/internal/executor" - "github.com/shopware/shopware-cli/internal/packagist" - "github.com/shopware/shopware-cli/internal/shop" -) - -type extensionInputStep int - -const ( - inputStepNone extensionInputStep = iota - inputStepToken - inputStepLoadingPackages - inputStepPackageList -) - -type ExtensionsModel struct { - table table.Model - loading bool - err error - statusMsg string - config *shop.Config - extensions adminSdk.ExtensionList - configured bool - executor executor.Executor - projectRoot string - inputStep extensionInputStep - tokenInput textinput.Model - filterInput textinput.Model - packages []packageEntry - filteredPkgs []packageEntry - packageTable table.Model - spinner spinner.Model - width int - height int -} - -type packageEntry struct { - name string - description string -} - -type packagesLoadedMsg struct { - packages []packageEntry - err error -} - -type extensionsLoadedMsg struct { - extensions adminSdk.ExtensionList - err error -} - -type extensionActionDoneMsg struct { - err error -} - -type composerRequireDoneMsg struct { - err error -} - -func NewExtensionsModel(config *shop.Config, exec executor.Executor, projectRoot string) ExtensionsModel { - columns := []table.Column{ - {Title: "Name", Width: 30}, - {Title: "Label", Width: 30}, - {Title: "Version", Width: 12}, - {Title: "Type", Width: 10}, - {Title: "Status", Width: 35}, - } - - t := table.New( - table.WithColumns(columns), - table.WithFocused(true), - table.WithHeight(10), - ) - - s := table.DefaultStyles() - s.Header = s.Header. - BorderStyle(lipgloss.NormalBorder()). - BorderForeground(lipgloss.Color("240")). - BorderBottom(true). - Bold(true) - t.SetStyles(s) - - tokenTi := textinput.New() - tokenTi.Placeholder = "your-token-here" - tokenTi.Prompt = "Token: " - tokenTi.CharLimit = 200 - - filterTi := textinput.New() - filterTi.Placeholder = "type to filter..." - filterTi.Prompt = "Filter: " - filterTi.CharLimit = 100 - - pkgColumns := []table.Column{ - {Title: "Package", Width: 40}, - {Title: "Description", Width: 50}, - } - pkgTable := table.New( - table.WithColumns(pkgColumns), - table.WithFocused(true), - table.WithHeight(15), - ) - pkgStyles := table.DefaultStyles() - pkgStyles.Header = pkgStyles.Header. - BorderStyle(lipgloss.NormalBorder()). - BorderForeground(lipgloss.Color("240")). - BorderBottom(true). - Bold(true) - pkgTable.SetStyles(pkgStyles) - - sp := spinner.New( - spinner.WithSpinner(spinner.Dot), - spinner.WithStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("205"))), - ) - - return ExtensionsModel{ - table: t, - loading: true, - config: config, - configured: config.IsAdminAPIConfigured(), - executor: exec, - projectRoot: projectRoot, - tokenInput: tokenTi, - filterInput: filterTi, - packageTable: pkgTable, - spinner: sp, - } -} - -func (m ExtensionsModel) Init() tea.Cmd { - if !m.configured { - return nil - } - return m.loadExtensions() -} - -func (m ExtensionsModel) Update(msg tea.Msg) (ExtensionsModel, tea.Cmd) { - switch msg := msg.(type) { - case extensionsLoadedMsg: - m.loading = false - if msg.err != nil { - m.err = msg.err - return m, nil - } - m.extensions = msg.extensions - m.statusMsg = "" - m.updateTableRows() - return m, nil - - case extensionActionDoneMsg: - if msg.err != nil { - m.statusMsg = errorStyle.Render("Error: " + msg.err.Error()) - return m, nil - } - m.statusMsg = statusStyle.Render("Action completed, reloading...") - m.loading = true - return m, m.loadExtensions() - - case spinner.TickMsg: - if m.inputStep == inputStepLoadingPackages { - var cmd tea.Cmd - m.spinner, cmd = m.spinner.Update(msg) - return m, cmd - } - - case packagesLoadedMsg: - if msg.err != nil { - m.statusMsg = errorStyle.Render("Failed to load packages: " + msg.err.Error()) - m.inputStep = inputStepNone - return m, nil - } - m.packages = msg.packages - m.filteredPkgs = msg.packages - m.inputStep = inputStepPackageList - m.filterInput.SetValue("") - m.filterInput.Focus() - m.updatePackageTableRows() - return m, textinput.Blink - - case composerRequireDoneMsg: - if msg.err != nil { - m.statusMsg = errorStyle.Render("Composer require failed: " + msg.err.Error()) - m.loading = false - return m, nil - } - m.statusMsg = statusStyle.Render("Package installed, refreshing extensions...") - return m, m.refreshAndReload() - - case tea.PasteMsg: - switch m.inputStep { - case inputStepToken: - var cmd tea.Cmd - m.tokenInput, cmd = m.tokenInput.Update(msg) - return m, cmd - case inputStepPackageList: - var cmd tea.Cmd - m.filterInput, cmd = m.filterInput.Update(msg) - m.applyPackageFilter() - return m, cmd - } - - case tea.KeyPressMsg: - if m.inputStep == inputStepLoadingPackages { - if msg.String() == "esc" { - m.inputStep = inputStepNone - } - return m, nil - } - - if m.inputStep == inputStepToken { - switch msg.String() { - case "enter": - token := strings.TrimSpace(m.tokenInput.Value()) - if token != "" { - if err := m.savePackagesToken(token); err != nil { - m.statusMsg = errorStyle.Render(err.Error()) - m.inputStep = inputStepNone - return m, nil - } - m.inputStep = inputStepLoadingPackages - return m, tea.Batch(m.spinner.Tick, m.loadPackages()) - } - return m, nil - case "esc": - m.inputStep = inputStepNone - m.tokenInput.SetValue("") - return m, nil - } - var cmd tea.Cmd - m.tokenInput, cmd = m.tokenInput.Update(msg) - return m, cmd - } - - if m.inputStep == inputStepPackageList { - switch msg.String() { - case "enter": - row := m.packageTable.SelectedRow() - if row != nil { - m.inputStep = inputStepNone - m.loading = true - m.statusMsg = statusStyle.Render("Running composer require " + row[0] + "...") - return m, m.composerRequire(row[0]) - } - return m, nil - case "esc": - m.inputStep = inputStepNone - m.filterInput.SetValue("") - return m, nil - case "up", "down", "pgup", "pgdown": - var cmd tea.Cmd - m.packageTable, cmd = m.packageTable.Update(msg) - return m, cmd - default: - var cmd tea.Cmd - m.filterInput, cmd = m.filterInput.Update(msg) - m.applyPackageFilter() - return m, cmd - } - } - - if !m.configured || m.loading { - return m, nil - } - - if msg.String() == "r" { - m.err = nil - m.statusMsg = statusStyle.Render("Reloading extensions...") - m.loading = true - return m, m.loadExtensions() - } - - if msg.String() == "ctrl+n" { - // Check if packages.shopware.com token exists in auth.json - authFile := filepath.Join(m.projectRoot, "auth.json") - auth, _ := packagist.ReadComposerAuth(authFile) - if auth.BearerAuth["packages.shopware.com"] == "" { - m.inputStep = inputStepToken - m.tokenInput.SetValue("") - m.tokenInput.Focus() - return m, textinput.Blink - } - m.inputStep = inputStepLoadingPackages - return m, tea.Batch(m.spinner.Tick, m.loadPackages()) - } - - if m.err != nil { - return m, nil - } - - ext := m.selectedExtension() - if ext == nil { - break - } - - switch msg.String() { - case "a": - m.statusMsg = statusStyle.Render("Activating " + ext.Name + "...") - return m, m.extensionAction(ext, "activate") - case "d": - m.statusMsg = statusStyle.Render("Deactivating " + ext.Name + "...") - return m, m.extensionAction(ext, "deactivate") - case "i": - m.statusMsg = statusStyle.Render("Installing " + ext.Name + "...") - return m, m.extensionAction(ext, "install") - case "u": - m.statusMsg = statusStyle.Render("Uninstalling " + ext.Name + "...") - return m, m.extensionAction(ext, "uninstall") - } - } - - var cmd tea.Cmd - m.table, cmd = m.table.Update(msg) - return m, cmd -} - -func (m ExtensionsModel) renderModal(content string) string { - modalWidth := m.width * 8 / 10 - modalHeight := m.height * 8 / 10 - if modalWidth < 40 { - modalWidth = 40 - } - if modalHeight < 10 { - modalHeight = 10 - } - - modal := overlayStyle. - Width(modalWidth). - Height(modalHeight). - Render(content) - - if m.width > 0 && m.height > 0 { - modal = lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, modal) - } - return modal -} - -func (m ExtensionsModel) View() string { - if m.inputStep == inputStepToken { - var b strings.Builder - b.WriteString(statusStyle.Render("Download Extension")) - b.WriteString("\n\n") - b.WriteString("Enter your packages.shopware.com token:\n\n") - b.WriteString(m.tokenInput.View()) - b.WriteString("\n\n") - b.WriteString(helpStyle.Render("enter: save | esc: cancel")) - return m.renderModal(b.String()) - } - - if m.inputStep == inputStepLoadingPackages { - var b strings.Builder - b.WriteString(statusStyle.Render("Download Extension")) - b.WriteString("\n\n") - b.WriteString(m.spinner.View() + " Loading available packages...") - b.WriteString("\n\n") - b.WriteString(helpStyle.Render("esc: cancel")) - return m.renderModal(b.String()) - } - - if m.inputStep == inputStepPackageList { - var b strings.Builder - b.WriteString(statusStyle.Render("Download Extension")) - b.WriteString("\n\n") - b.WriteString(m.filterInput.View()) - b.WriteString("\n\n") - if len(m.filteredPkgs) == 0 { - b.WriteString(helpStyle.Render("No packages found.") + "\n\n") - } else { - b.WriteString(m.packageTable.View()) - b.WriteString("\n") - } - b.WriteString(helpStyle.Render("enter: install | ↑/↓: navigate | esc: cancel")) - return m.renderModal(b.String()) - } - - if !m.configured { - return "\n" + helpStyle.Render("Admin API not configured. Add admin_api credentials to .shopware-project.yml") + "\n" - } - - if m.loading { - var b strings.Builder - b.WriteString("\n" + helpStyle.Render("Loading extensions...") + "\n") - if m.statusMsg != "" { - b.WriteString(m.statusMsg + "\n") - } - return b.String() - } - - if m.err != nil { - return "\n" + errorStyle.Render("Failed to load extensions: "+m.err.Error()) + "\n\n" + helpStyle.Render("r: retry") + "\n" - } - - var b strings.Builder - b.WriteString("\n") - - if len(m.extensions) == 0 { - b.WriteString(helpStyle.Render("No extensions installed.") + "\n") - } else { - b.WriteString(m.table.View()) - b.WriteString("\n") - } - - if m.statusMsg != "" { - b.WriteString(m.statusMsg + "\n") - } - - b.WriteString(helpStyle.Render("a: activate | d: deactivate | i: install | u: uninstall | r: reload | ctrl+n: download extension | ↑/↓: navigate")) - - return b.String() -} - -func (m *ExtensionsModel) SetSize(width, height int) { - m.width = width - m.height = height - m.table.SetWidth(width) - // Reserve 3 lines for status and help - m.table.SetHeight(height - 4) - // Modal is 80% of terminal, subtract border/padding and title/filter/help lines - modalInnerWidth := width*8/10 - 8 - modalInnerHeight := height*8/10 - 10 - if modalInnerWidth < 40 { - modalInnerWidth = 40 - } - if modalInnerHeight < 5 { - modalInnerHeight = 5 - } - m.packageTable.SetWidth(modalInnerWidth) - m.packageTable.SetHeight(modalInnerHeight) -} - -func (m *ExtensionsModel) updateTableRows() { - rows := make([]table.Row, 0, len(m.extensions)) - for _, ext := range m.extensions { - rows = append(rows, table.Row{ - ext.Name, - ext.Label, - ext.Version, - ext.Type, - ext.Status(), - }) - } - m.table.SetRows(rows) -} - -func (m *ExtensionsModel) selectedExtension() *adminSdk.ExtensionDetail { - row := m.table.SelectedRow() - if row == nil { - return nil - } - name := row[0] - return m.extensions.GetByName(name) -} - -func (m *ExtensionsModel) loadExtensions() tea.Cmd { - config := m.config - return func() tea.Msg { - ctx := context.Background() - client, err := shop.NewShopClient(ctx, config) - if err != nil { - return extensionsLoadedMsg{err: fmt.Errorf("cannot create API client: %w", err)} - } - - apiCtx := adminSdk.NewApiContext(ctx) - extensions, _, err := client.ExtensionManager.ListAvailableExtensions(apiCtx) - if err != nil { - return extensionsLoadedMsg{err: fmt.Errorf("cannot list extensions: %w", err)} - } - - return extensionsLoadedMsg{extensions: extensions} - } -} - -func (m *ExtensionsModel) loadPackages() tea.Cmd { - projectRoot := m.projectRoot - return func() tea.Msg { - authFile := filepath.Join(projectRoot, "auth.json") - auth, err := packagist.ReadComposerAuth(authFile) - if err != nil { - return packagesLoadedMsg{err: err} - } - - token := auth.BearerAuth["packages.shopware.com"] - if token == "" { - return packagesLoadedMsg{err: fmt.Errorf("no packages.shopware.com token found")} - } - - resp, err := packagist.GetPackages(context.Background(), token) - if err != nil { - return packagesLoadedMsg{err: err} - } - - var entries []packageEntry - for name, versions := range resp.Packages { - var desc string - for _, v := range versions { - if v.Description != "" { - desc = v.Description - break - } - } - entries = append(entries, packageEntry{name: name, description: desc}) - } - - // Sort alphabetically - slices.SortFunc(entries, func(a, b packageEntry) int { - return strings.Compare(a.name, b.name) - }) - - return packagesLoadedMsg{packages: entries} - } -} - -func (m *ExtensionsModel) updatePackageTableRows() { - rows := make([]table.Row, 0, len(m.filteredPkgs)) - for _, pkg := range m.filteredPkgs { - desc := pkg.description - if len(desc) > 47 { - desc = desc[:47] + "..." - } - rows = append(rows, table.Row{pkg.name, desc}) - } - m.packageTable.SetRows(rows) -} - -func (m *ExtensionsModel) applyPackageFilter() { - filter := strings.ToLower(strings.TrimSpace(m.filterInput.Value())) - if filter == "" { - m.filteredPkgs = m.packages - } else { - m.filteredPkgs = nil - for _, pkg := range m.packages { - if strings.Contains(strings.ToLower(pkg.name), filter) || strings.Contains(strings.ToLower(pkg.description), filter) { - m.filteredPkgs = append(m.filteredPkgs, pkg) - } - } - } - m.updatePackageTableRows() -} - -func (m *ExtensionsModel) savePackagesToken(token string) error { - // Save token to auth.json - authFile := filepath.Join(m.projectRoot, "auth.json") - auth, _ := packagist.ReadComposerAuth(authFile) - auth.BearerAuth["packages.shopware.com"] = token - if err := auth.Save(); err != nil { - return fmt.Errorf("failed to save auth.json: %w", err) - } - - // Add repository to composer.json if missing - composerFile := filepath.Join(m.projectRoot, "composer.json") - composerJson, err := packagist.ReadComposerJson(composerFile) - if err != nil { - return fmt.Errorf("failed to read composer.json: %w", err) - } - - if !composerJson.Repositories.HasRepository("https://packages.shopware.com") { - composerJson.Repositories = append(composerJson.Repositories, packagist.ComposerJsonRepository{ - Type: "composer", - URL: "https://packages.shopware.com", - }) - if err := composerJson.Save(); err != nil { - return fmt.Errorf("failed to save composer.json: %w", err) - } - } - - return nil -} - -func (m *ExtensionsModel) composerRequire(pkg string) tea.Cmd { - e := m.executor - projectRoot := m.projectRoot - return func() tea.Msg { - cmd := e.ComposerCommand(context.Background(), "require", pkg) - cmd.Dir = projectRoot - - output, err := cmd.CombinedOutput() - if err != nil { - // Include last few lines of output for context - lines := strings.Split(strings.TrimSpace(string(output)), "\n") - if len(lines) > 5 { - lines = lines[len(lines)-5:] - } - return composerRequireDoneMsg{err: fmt.Errorf("%w\n%s", err, strings.Join(lines, "\n"))} - } - - return composerRequireDoneMsg{} - } -} - -func (m *ExtensionsModel) refreshAndReload() tea.Cmd { - config := m.config - return func() tea.Msg { - ctx := context.Background() - client, err := shop.NewShopClient(ctx, config) - if err != nil { - // Still reload even if refresh fails - return extensionsLoadedMsg{err: fmt.Errorf("cannot create API client for refresh: %w", err)} - } - - apiCtx := adminSdk.NewApiContext(ctx) - _, _ = client.ExtensionManager.Refresh(apiCtx) - - extensions, _, err := client.ExtensionManager.ListAvailableExtensions(apiCtx) - if err != nil { - return extensionsLoadedMsg{err: fmt.Errorf("cannot list extensions: %w", err)} - } - - return extensionsLoadedMsg{extensions: extensions} - } -} - -func (m *ExtensionsModel) extensionAction(ext *adminSdk.ExtensionDetail, action string) tea.Cmd { - config := m.config - extType := ext.Type - extName := ext.Name - return func() tea.Msg { - ctx := context.Background() - client, err := shop.NewShopClient(ctx, config) - if err != nil { - return extensionActionDoneMsg{err: err} - } - - apiCtx := adminSdk.NewApiContext(ctx) - - switch action { - case "activate": - _, err = client.ExtensionManager.ActivateExtension(apiCtx, extType, extName) - case "deactivate": - _, err = client.ExtensionManager.DeactivateExtension(apiCtx, extType, extName) - case "install": - _, err = client.ExtensionManager.InstallExtension(apiCtx, extType, extName) - case "uninstall": - _, err = client.ExtensionManager.UninstallExtension(apiCtx, extType, extName) - } - - return extensionActionDoneMsg{err: err} - } -} diff --git a/internal/devtui/tab_general.go b/internal/devtui/tab_general.go index cc2af6a3..76b8c185 100644 --- a/internal/devtui/tab_general.go +++ b/internal/devtui/tab_general.go @@ -1,6 +1,7 @@ package devtui import ( + "context" "encoding/json" "fmt" "os/exec" @@ -81,8 +82,7 @@ func (m GeneralModel) Init() tea.Cmd { type browserOpenedMsg struct{} func (m GeneralModel) Update(msg tea.Msg) (GeneralModel, tea.Cmd) { - switch msg := msg.(type) { - case servicesLoadedMsg: + if msg, ok := msg.(servicesLoadedMsg); ok { m.loading = false m.services = msg.services m.err = msg.err @@ -92,7 +92,7 @@ func (m GeneralModel) Update(msg tea.Msg) (GeneralModel, tea.Cmd) { func openInBrowser(url string) tea.Cmd { return func() tea.Msg { - _ = exec.Command("open", url).Start() + _ = exec.CommandContext(context.Background(), "open", url).Start() return browserOpenedMsg{} } } @@ -112,11 +112,12 @@ func (m GeneralModel) View() string { b.WriteString("\n") - if m.loading { + switch { + case m.loading: b.WriteString(helpStyle.Render("Discovering services...") + "\n") - } else if m.err != nil { + case m.err != nil: b.WriteString(errorStyle.Render("Service discovery failed: "+m.err.Error()) + "\n") - } else if len(m.services) > 0 { + case len(m.services) > 0: for _, s := range m.services { b.WriteString(labelStyle.Render(s.Name) + valueStyle.Render(s.URL) + "\n") if s.Username != "" { @@ -147,7 +148,8 @@ type dockerComposePSOutput struct { func discoverServices(projectRoot string) tea.Cmd { return func() tea.Msg { - cmd := exec.Command("docker", "compose", "ps", "--format", "json") + ctx := context.Background() + cmd := exec.CommandContext(ctx, "docker", "compose", "ps", "--format", "json") cmd.Dir = projectRoot output, err := cmd.Output() if err != nil { diff --git a/internal/devtui/tab_logs.go b/internal/devtui/tab_logs.go index 2b321ffb..38769f61 100644 --- a/internal/devtui/tab_logs.go +++ b/internal/devtui/tab_logs.go @@ -8,8 +8,8 @@ import ( "path/filepath" "strings" - tea "charm.land/bubbletea/v2" "charm.land/bubbles/v2/viewport" + tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" ) @@ -86,17 +86,17 @@ func (m LogsModel) Update(msg tea.Msg) (LogsModel, tea.Cmd) { case tea.KeyPressMsg: switch msg.String() { - case "up", "k": + case keyUp, keyK: if m.cursor > 0 { m.cursor-- } return m, nil - case "down", "j": + case keyDown, keyJ: if m.cursor < len(m.sources)-1 { m.cursor++ } return m, nil - case "enter": + case keyEnter: if m.cursor != m.active && m.cursor < len(m.sources) { m.stopStreaming() m.active = m.cursor @@ -107,7 +107,7 @@ func (m LogsModel) Update(msg tea.Msg) (LogsModel, tea.Cmd) { return m, m.startCurrentSource() } return m, nil - case "f": + case keyF: m.follow = !m.follow if m.follow { m.viewport.GotoBottom() @@ -318,7 +318,8 @@ func (m *LogsModel) discoverSources() tea.Cmd { } func discoverContainers(projectRoot string) []logSource { - cmd := exec.Command("docker", "compose", "ps", "--format", "{{.Service}}") + ctx := context.Background() + cmd := exec.CommandContext(ctx, "docker", "compose", "ps", "--format", "{{.Service}}") cmd.Dir = projectRoot output, err := cmd.Output() if err != nil { diff --git a/internal/extension/packagist.go b/internal/extension/packagist.go index 0d6b93aa..75fa67d5 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.GetPackageVersions(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/packagist/packagist.go b/internal/packagist/packagist.go index 0e1737ae..89be0b7f 100644 --- a/internal/packagist/packagist.go +++ b/internal/packagist/packagist.go @@ -1,6 +1,7 @@ package packagist import ( + "bytes" "context" "encoding/json" "fmt" @@ -28,6 +29,20 @@ type PackageVersion struct { Replace map[string]string `json:"replace"` } +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 GetPackages(ctx context.Context, token string) (*PackageResponse, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://packages.shopware.com/packages.json", nil) if err != nil { @@ -59,3 +74,101 @@ func GetPackages(ctx context.Context, token string) (*PackageResponse, error) { return &packages, nil } + +func GetPackageVersions(ctx context.Context) ([]ComposerPackageVersion, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://repo.packagist.org/p2/shopware/shopware.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/shopware"] + if !ok { + return nil, fmt.Errorf("decode package versions: package shopware/shopware 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..1a97cb7c 100644 --- a/internal/packagist/packagist_test.go +++ b/internal/packagist/packagist_test.go @@ -207,6 +207,157 @@ func TestGetPackages(t *testing.T) { }) } +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/shopware.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/shopware": []map[string]any{ + { + "name": "shopware/shopware", + "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 := GetPackageVersions(t.Context()) + + require.NoError(t, err) + require.Len(t, versions, 3) + assert.Equal(t, "shopware/shopware", 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/shopware": []map[string]any{ + { + "name": "shopware/shopware", + "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 := GetPackageVersions(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{ + "shopware/core": []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 := GetPackageVersions(t.Context()) + + assert.Error(t, err) + assert.Nil(t, versions) + assert.Contains(t, err.Error(), "package shopware/shopware 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 := GetPackageVersions(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 := GetPackageVersions(ctx) + + assert.Error(t, err) + assert.Nil(t, versions) + }) +} + // mockTransport is a custom RoundTripper that redirects all requests to a test server. type mockTransport struct { server *httptest.Server From 2c7a5f77e79cac49fdec4e6e959142eaeb5faba2 Mon Sep 17 00:00:00 2001 From: Soner Sayakci Date: Fri, 6 Mar 2026 08:00:26 +0100 Subject: [PATCH 06/19] feat: enhance devtui with improved layout and styling, remove obsolete tests --- internal/devtui/model.go | 80 ++++-- internal/devtui/model_test.go | 435 --------------------------------- internal/devtui/styles.go | 300 +++++++++++++++++++++-- internal/devtui/tab_general.go | 108 ++++++-- internal/devtui/tab_logs.go | 100 +++++--- 5 files changed, 496 insertions(+), 527 deletions(-) delete mode 100644 internal/devtui/model_test.go diff --git a/internal/devtui/model.go b/internal/devtui/model.go index fbac2edd..9ea8a5ca 100644 --- a/internal/devtui/model.go +++ b/internal/devtui/model.go @@ -192,6 +192,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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 @@ -465,7 +466,7 @@ func (m Model) View() tea.View { b.WriteString(m.renderOverlay()) } else { b.WriteString(m.renderTabBar()) - b.WriteString("\n") + b.WriteString("\n\n") switch m.activeTab { case tabGeneral: @@ -475,7 +476,19 @@ func (m Model) View() tea.View { } } - v := tea.NewView(b.String()) + 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 } @@ -498,19 +511,25 @@ func (m Model) renderOverlay() string { } var content strings.Builder - content.WriteString(statusStyle.Render(title)) + content.WriteString(panelHeaderStyle.Render(title)) content.WriteString("\n\n") switch m.overlay { case overlayNone: - // No overlay content needed + // No overlay content needed case overlayStarting, overlayStopping, overlayInstalling: for _, line := range m.overlayLines { - content.WriteString(line + "\n") + 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(helpStyle.Render("y: stop containers | n: quit without stopping")) + content.WriteString(renderFooter( + renderKeyHint("y", "Stop containers"), + renderKeyHint("n", "Quit without stopping"), + )) case overlayInstallPrompt: m.renderInstallPrompt(&content) } @@ -518,7 +537,14 @@ func (m Model) renderOverlay() string { modal := overlayStyle.Render(content.String()) if m.width > 0 && m.height > 0 { - modal = lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, modal) + modal = lipgloss.Place( + m.width, + m.height, + lipgloss.Center, + lipgloss.Center, + modal, + lipgloss.WithWhitespaceStyle(surfaceTextStyle), + ) } return modal @@ -528,32 +554,44 @@ 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(helpStyle.Render("y: install | n: skip | q: quit")) + 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 { - cursor := " " + style := sidebarItemStyle if i == m.install.cursor { - cursor = "> " + style = selectedSidebarItemStyle } - b.WriteString(cursor + lang.label + "\n") + b.WriteString(style.Render(lang.label) + "\n") } b.WriteString("\n") - b.WriteString(helpStyle.Render("↑/↓: select | enter: confirm | q: quit")) + 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 { - cursor := " " + style := sidebarItemStyle if i == m.install.cursor { - cursor = "> " + style = selectedSidebarItemStyle } - b.WriteString(cursor + curr + "\n") + b.WriteString(style.Render(curr) + "\n") } b.WriteString("\n") - b.WriteString(helpStyle.Render("↑/↓: select | enter: confirm | q: quit")) + b.WriteString(renderFooter( + renderKeyHint("↑/↓", "Select"), + renderKeyHint("enter", "Confirm"), + renderKeyHint("q", "Quit"), + )) case installStepUsername: fmt.Fprintf(b, "Language: %s\n", valueStyle.Render(m.install.language)) @@ -561,7 +599,10 @@ func (m Model) renderInstallPrompt(b *strings.Builder) { b.WriteString("Admin username:\n\n") b.WriteString(m.install.username.View()) b.WriteString("\n\n") - b.WriteString(helpStyle.Render("enter: confirm | q: quit")) + b.WriteString(renderFooter( + renderKeyHint("enter", "Continue"), + renderKeyHint("q", "Quit"), + )) case installStepPassword: fmt.Fprintf(b, "Language: %s\n", valueStyle.Render(m.install.language)) @@ -570,7 +611,10 @@ func (m Model) renderInstallPrompt(b *strings.Builder) { b.WriteString("Admin password:\n\n") b.WriteString(m.install.password.View()) b.WriteString("\n\n") - b.WriteString(helpStyle.Render("enter: confirm | q: quit")) + b.WriteString(renderFooter( + renderKeyHint("enter", "Install"), + renderKeyHint("q", "Quit"), + )) } } diff --git a/internal/devtui/model_test.go b/internal/devtui/model_test.go deleted file mode 100644 index 1d9c089e..00000000 --- a/internal/devtui/model_test.go +++ /dev/null @@ -1,435 +0,0 @@ -package devtui - -import ( - "context" - "os/exec" - "testing" - - tea "charm.land/bubbletea/v2" - "github.com/stretchr/testify/assert" - - "github.com/shopware/shopware-cli/internal/shop" -) - -type mockExecutor struct { - execType string -} - -func (m *mockExecutor) ConsoleCommand(_ context.Context, _ ...string) *exec.Cmd { return nil } -func (m *mockExecutor) ComposerCommand(_ context.Context, _ ...string) *exec.Cmd { return nil } -func (m *mockExecutor) PHPCommand(_ context.Context, _ ...string) *exec.Cmd { return nil } -func (m *mockExecutor) Type() string { return m.execType } - -func TestNew(t *testing.T) { - cfg := &shop.Config{ - URL: "http://localhost:8000", - AdminApi: &shop.ConfigAdminApi{ - Username: "admin", - Password: "shopware", - }, - } - envCfg := &shop.EnvironmentConfig{ - Type: "docker", - } - - m := New(Options{ - ProjectRoot: "/tmp/project", - Config: cfg, - EnvConfig: envCfg, - Executor: &mockExecutor{execType: "docker"}, - }) - - assert.Equal(t, tabGeneral, m.activeTab) - assert.Equal(t, "docker", m.general.envType) - assert.Equal(t, "http://localhost:8000", m.general.shopURL) - assert.Equal(t, "admin", m.general.username) - assert.True(t, m.dockerMode) -} - -func TestNew_EnvAdminApiOverride(t *testing.T) { - cfg := &shop.Config{ - URL: "http://localhost:8000", - } - envCfg := &shop.EnvironmentConfig{ - Type: "docker", - URL: "http://docker-host:8000", - AdminApi: &shop.ConfigAdminApi{ - Username: "env-admin", - Password: "env-pass", - }, - } - - m := New(Options{ - ProjectRoot: "/tmp/project", - Config: cfg, - EnvConfig: envCfg, - Executor: &mockExecutor{execType: "docker"}, - }) - - assert.Equal(t, "env-admin", m.general.username) - assert.Equal(t, "env-pass", m.general.password) - assert.Equal(t, "http://docker-host:8000", m.general.shopURL) -} - -func TestNew_LocalMode(t *testing.T) { - cfg := &shop.Config{URL: "http://localhost:8000"} - envCfg := &shop.EnvironmentConfig{Type: "local"} - - m := New(Options{ - ProjectRoot: "/tmp/project", - Config: cfg, - EnvConfig: envCfg, - Executor: &mockExecutor{execType: "local"}, - }) - - assert.False(t, m.dockerMode) -} - -func TestTabSwitching(t *testing.T) { - cfg := &shop.Config{URL: "http://localhost:8000"} - envCfg := &shop.EnvironmentConfig{Type: "local"} - - m := New(Options{ - ProjectRoot: "/tmp/project", - Config: cfg, - EnvConfig: envCfg, - Executor: &mockExecutor{execType: "local"}, - }) - - // Switch to tab 2 - result, _ := m.Update(tea.KeyPressMsg(tea.Key{Code: '2', Text: "2"})) - model := result.(Model) - assert.Equal(t, tabLogs, model.activeTab) - - // Switch to tab 1 - result, _ = model.Update(tea.KeyPressMsg(tea.Key{Code: '1', Text: "1"})) - model = result.(Model) - assert.Equal(t, tabGeneral, model.activeTab) -} - -func TestTabSwitchingBlockedDuringOverlay(t *testing.T) { - cfg := &shop.Config{URL: "http://localhost:8000"} - envCfg := &shop.EnvironmentConfig{Type: "local"} - - m := New(Options{ - ProjectRoot: "/tmp/project", - Config: cfg, - EnvConfig: envCfg, - Executor: &mockExecutor{execType: "local"}, - }) - m.overlay = overlayStarting - - result, _ := m.Update(tea.KeyPressMsg(tea.Key{Code: '2', Text: "2"})) - model := result.(Model) - assert.Equal(t, tabGeneral, model.activeTab) -} - -func TestWindowSizeMsg(t *testing.T) { - cfg := &shop.Config{URL: "http://localhost:8000"} - envCfg := &shop.EnvironmentConfig{Type: "local"} - - m := New(Options{ - ProjectRoot: "/tmp/project", - Config: cfg, - EnvConfig: envCfg, - Executor: &mockExecutor{execType: "local"}, - }) - - result, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) - model := result.(Model) - assert.Equal(t, 120, model.width) - assert.Equal(t, 40, model.height) -} - -func TestDockerAlreadyRunning(t *testing.T) { - cfg := &shop.Config{URL: "http://localhost:8000"} - envCfg := &shop.EnvironmentConfig{Type: "docker"} - - m := New(Options{ - ProjectRoot: "/tmp/project", - Config: cfg, - EnvConfig: envCfg, - Executor: &mockExecutor{execType: "docker"}, - }) - - result, cmd := m.Update(dockerAlreadyRunningMsg{}) - model := result.(Model) - assert.Equal(t, overlayNone, model.overlay) - assert.NotNil(t, cmd) -} - -func TestDockerStartedSuccess(t *testing.T) { - cfg := &shop.Config{URL: "http://localhost:8000"} - envCfg := &shop.EnvironmentConfig{Type: "docker"} - - m := New(Options{ - ProjectRoot: "/tmp/project", - Config: cfg, - EnvConfig: envCfg, - Executor: &mockExecutor{execType: "docker"}, - }) - m.overlay = overlayStarting - - result, cmd := m.Update(dockerStartedMsg{}) - model := result.(Model) - assert.Equal(t, overlayNone, model.overlay) - assert.Nil(t, model.overlayLines) - assert.NotNil(t, cmd) -} - -func TestDockerStartedFailure(t *testing.T) { - cfg := &shop.Config{URL: "http://localhost:8000"} - envCfg := &shop.EnvironmentConfig{Type: "docker"} - - m := New(Options{ - ProjectRoot: "/tmp/project", - Config: cfg, - EnvConfig: envCfg, - Executor: &mockExecutor{execType: "docker"}, - }) - m.overlay = overlayStarting - - result, cmd := m.Update(dockerStartedMsg{err: assert.AnError}) - model := result.(Model) - assert.Equal(t, overlayStarting, model.overlay) - assert.NotEmpty(t, model.overlayLines) - assert.Nil(t, cmd) -} - -func TestOverlayRendering(t *testing.T) { - cfg := &shop.Config{URL: "http://localhost:8000"} - envCfg := &shop.EnvironmentConfig{Type: "docker"} - - m := New(Options{ - ProjectRoot: "/tmp/project", - Config: cfg, - EnvConfig: envCfg, - Executor: &mockExecutor{execType: "docker"}, - }) - m.overlay = overlayStarting - m.overlayLines = []string{"Pulling images..."} - - view := m.View() - assert.Contains(t, view.Content, "Starting Docker containers...") - assert.Contains(t, view.Content, "Pulling images...") -} - -func TestShopwareNotInstalled_ShowsPrompt(t *testing.T) { - cfg := &shop.Config{URL: "http://localhost:8000"} - envCfg := &shop.EnvironmentConfig{Type: "docker"} - - m := New(Options{ - ProjectRoot: "/tmp/project", - Config: cfg, - EnvConfig: envCfg, - Executor: &mockExecutor{execType: "docker"}, - }) - - result, cmd := m.Update(shopwareNotInstalledMsg{}) - model := result.(Model) - assert.Equal(t, overlayInstallPrompt, model.overlay) - assert.Equal(t, installStepAsk, model.install.step) - assert.Nil(t, cmd) -} - -func TestShopwareInstalled_StartsDashboard(t *testing.T) { - cfg := &shop.Config{URL: "http://localhost:8000"} - envCfg := &shop.EnvironmentConfig{Type: "docker"} - - m := New(Options{ - ProjectRoot: "/tmp/project", - Config: cfg, - EnvConfig: envCfg, - Executor: &mockExecutor{execType: "docker"}, - }) - - result, cmd := m.Update(shopwareInstalledMsg{}) - model := result.(Model) - assert.Equal(t, overlayNone, model.overlay) - assert.NotNil(t, cmd) -} - -func TestInstallPrompt_DeclineSkipsToDashboard(t *testing.T) { - cfg := &shop.Config{URL: "http://localhost:8000"} - envCfg := &shop.EnvironmentConfig{Type: "docker"} - - m := New(Options{ - ProjectRoot: "/tmp/project", - Config: cfg, - EnvConfig: envCfg, - Executor: &mockExecutor{execType: "docker"}, - }) - m.overlay = overlayInstallPrompt - m.install = installWizard{step: installStepAsk} - - result, cmd := m.Update(tea.KeyPressMsg(tea.Key{Code: 'n', Text: "n"})) - model := result.(Model) - assert.Equal(t, overlayNone, model.overlay) - assert.NotNil(t, cmd) -} - -func TestInstallPromptRendering(t *testing.T) { - cfg := &shop.Config{URL: "http://localhost:8000"} - envCfg := &shop.EnvironmentConfig{Type: "docker"} - - m := New(Options{ - ProjectRoot: "/tmp/project", - Config: cfg, - EnvConfig: envCfg, - Executor: &mockExecutor{execType: "docker"}, - }) - m.overlay = overlayInstallPrompt - m.install = installWizard{step: installStepAsk} - - view := m.View() - assert.Contains(t, view.Content, "Shopware is not installed") - assert.Contains(t, view.Content, "y: install") - assert.Contains(t, view.Content, "n: skip") -} - -func TestInstallWizard_AcceptGoesToLanguage(t *testing.T) { - cfg := &shop.Config{URL: "http://localhost:8000"} - envCfg := &shop.EnvironmentConfig{Type: "docker"} - - m := New(Options{ - ProjectRoot: "/tmp/project", - Config: cfg, - EnvConfig: envCfg, - Executor: &mockExecutor{execType: "docker"}, - }) - m.overlay = overlayInstallPrompt - m.install = installWizard{step: installStepAsk} - - result, _ := m.Update(tea.KeyPressMsg(tea.Key{Code: 'y', Text: "y"})) - model := result.(Model) - assert.Equal(t, overlayInstallPrompt, model.overlay) - assert.Equal(t, installStepLanguage, model.install.step) - assert.Equal(t, 0, model.install.cursor) -} - -func TestInstallWizard_LanguageSelection(t *testing.T) { - cfg := &shop.Config{URL: "http://localhost:8000"} - envCfg := &shop.EnvironmentConfig{Type: "docker"} - - m := New(Options{ - ProjectRoot: "/tmp/project", - Config: cfg, - EnvConfig: envCfg, - Executor: &mockExecutor{execType: "docker"}, - }) - m.overlay = overlayInstallPrompt - m.install = installWizard{step: installStepLanguage, cursor: 0} - - // Move down - result, _ := m.Update(tea.KeyPressMsg(tea.Key{Code: tea.KeyDown, Text: ""})) - model := result.(Model) - assert.Equal(t, 1, model.install.cursor) - - // Confirm en-US (index 1) - result, _ = model.Update(tea.KeyPressMsg(tea.Key{Code: tea.KeyEnter, Text: ""})) - model = result.(Model) - assert.Equal(t, installStepCurrency, model.install.step) - assert.Equal(t, "en-US", model.install.language) - assert.Equal(t, 0, model.install.cursor) -} - -func TestInstallWizard_LanguageRendering(t *testing.T) { - cfg := &shop.Config{URL: "http://localhost:8000"} - envCfg := &shop.EnvironmentConfig{Type: "docker"} - - m := New(Options{ - ProjectRoot: "/tmp/project", - Config: cfg, - EnvConfig: envCfg, - Executor: &mockExecutor{execType: "docker"}, - }) - m.overlay = overlayInstallPrompt - m.install = installWizard{step: installStepLanguage, cursor: 0} - - view := m.View() - assert.Contains(t, view.Content, "Select default language") - assert.Contains(t, view.Content, "> English (UK)") - assert.Contains(t, view.Content, " Deutsch") -} - -func TestInstallWizard_CurrencyRendering(t *testing.T) { - cfg := &shop.Config{URL: "http://localhost:8000"} - envCfg := &shop.EnvironmentConfig{Type: "docker"} - - m := New(Options{ - ProjectRoot: "/tmp/project", - Config: cfg, - EnvConfig: envCfg, - Executor: &mockExecutor{execType: "docker"}, - }) - m.overlay = overlayInstallPrompt - m.install = installWizard{step: installStepCurrency, language: "en-GB", cursor: 1} - - view := m.View() - assert.Contains(t, view.Content, "Select default currency") - assert.Contains(t, view.Content, " EUR") - assert.Contains(t, view.Content, "> USD") - assert.Contains(t, view.Content, " GBP") -} - -func TestInstallWizard_CursorBounds(t *testing.T) { - cfg := &shop.Config{URL: "http://localhost:8000"} - envCfg := &shop.EnvironmentConfig{Type: "docker"} - - m := New(Options{ - ProjectRoot: "/tmp/project", - Config: cfg, - EnvConfig: envCfg, - Executor: &mockExecutor{execType: "docker"}, - }) - m.overlay = overlayInstallPrompt - m.install = installWizard{step: installStepLanguage, cursor: 0} - - // Try to go above 0 - result, _ := m.Update(tea.KeyPressMsg(tea.Key{Code: tea.KeyUp, Text: ""})) - model := result.(Model) - assert.Equal(t, 0, model.install.cursor) - - // Go to last item and try to go past - model.install.cursor = len(installLanguages) - 1 - result, _ = model.Update(tea.KeyPressMsg(tea.Key{Code: tea.KeyDown, Text: ""})) - model = result.(Model) - assert.Equal(t, len(installLanguages)-1, model.install.cursor) -} - -func TestInstallDoneSuccess(t *testing.T) { - cfg := &shop.Config{URL: "http://localhost:8000"} - envCfg := &shop.EnvironmentConfig{Type: "docker"} - - m := New(Options{ - ProjectRoot: "/tmp/project", - Config: cfg, - EnvConfig: envCfg, - Executor: &mockExecutor{execType: "docker"}, - }) - m.overlay = overlayInstalling - - result, cmd := m.Update(shopwareInstallDoneMsg{}) - model := result.(Model) - assert.Equal(t, overlayNone, model.overlay) - assert.NotNil(t, cmd) -} - -func TestInstallDoneFailure(t *testing.T) { - cfg := &shop.Config{URL: "http://localhost:8000"} - envCfg := &shop.EnvironmentConfig{Type: "docker"} - - m := New(Options{ - ProjectRoot: "/tmp/project", - Config: cfg, - EnvConfig: envCfg, - Executor: &mockExecutor{execType: "docker"}, - }) - m.overlay = overlayInstalling - - result, cmd := m.Update(shopwareInstallDoneMsg{err: assert.AnError}) - model := result.(Model) - assert.Equal(t, overlayInstalling, model.overlay) - assert.NotEmpty(t, model.overlayLines) - assert.Nil(t, cmd) -} diff --git a/internal/devtui/styles.go b/internal/devtui/styles.go index 3d08304f..ea7e6f6e 100644 --- a/internal/devtui/styles.go +++ b/internal/devtui/styles.go @@ -1,44 +1,298 @@ package devtui import ( + "strings" + "charm.land/lipgloss/v2" "charm.land/lipgloss/v2/compat" ) +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). - Border(lipgloss.NormalBorder(), true). - Padding(0, 1) + Foreground(textColor). + Background(panelAccentColor). + Border(lipgloss.RoundedBorder()). + BorderForeground(borderColor). + BorderBackground(surfaceColor). + Padding(0, 2). + MarginRight(1) inactiveTabStyle = lipgloss.NewStyle(). - Border(lipgloss.HiddenBorder(), true). + 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) + + statusStyle = lipgloss.NewStyle(). + Foreground(warningColor). + Background(surfaceColor). + 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) - helpStyle = lipgloss.NewStyle().Foreground(compat.AdaptiveColor{ - Light: lipgloss.Color("#9CA3AF"), - Dark: lipgloss.Color("#6B7280"), - }) + selectedSidebarItemStyle = lipgloss.NewStyle(). + Foreground(textColor). + Background(panelAccentColor). + Bold(true). + Padding(0, 1) - labelStyle = lipgloss.NewStyle().Bold(true).Width(20) + activeSidebarItemStyle = lipgloss.NewStyle(). + Background(panelColor). + Foreground(accentColor). + Padding(0, 1) - valueStyle = lipgloss.NewStyle().Foreground(compat.AdaptiveColor{ - Light: lipgloss.Color("#047857"), - Dark: lipgloss.Color("#04B575"), - }) + activeSelectedSidebarItemStyle = lipgloss.NewStyle(). + Foreground(textColor). + Background(accentBgColor). + Bold(true). + Padding(0, 1) - errorStyle = lipgloss.NewStyle().Foreground(compat.AdaptiveColor{ - Light: lipgloss.Color("#DC2626"), - Dark: lipgloss.Color("#EF4444"), - }) + contentPanelStyle = lipgloss.NewStyle(). + Background(panelColor). + Border(lipgloss.RoundedBorder()). + BorderForeground(mutedBorderColor). + BorderBackground(surfaceColor). + Padding(0, 1) - statusStyle = lipgloss.NewStyle().Foreground(compat.AdaptiveColor{ - Light: lipgloss.Color("#B8860B"), - Dark: lipgloss.Color("#FFD700"), - }).Bold(true) + panelHeaderStyle = lipgloss.NewStyle(). + Background(panelColor). + Foreground(textColor). + Bold(true). + Padding(0, 0, 1) overlayStyle = lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder(), true). - Padding(1, 3). - Bold(true) + Background(panelColor). + Border(lipgloss.RoundedBorder()). + BorderForeground(borderColor). + BorderBackground(surfaceColor). + Padding(1, 3) ) + +func renderSection(title, body string) string { + contentWidth := lipgloss.Width(body) + if titleWidth := lipgloss.Width(title); titleWidth > contentWidth { + contentWidth = titleWidth + } + if contentWidth == 0 { + contentWidth = 1 + } + + 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 lipgloss.JoinHorizontal( + lipgloss.Center, + 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(" | ") + return surfaceMutedTextStyle.Copy(). + Padding(1, 0, 0). + 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 +} diff --git a/internal/devtui/tab_general.go b/internal/devtui/tab_general.go index 76b8c185..a3ac4890 100644 --- a/internal/devtui/tab_general.go +++ b/internal/devtui/tab_general.go @@ -8,6 +8,7 @@ import ( "strings" tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" ) type GeneralModel struct { @@ -20,6 +21,8 @@ type GeneralModel struct { projectRoot string loading bool err error + width int + height int } type discoveredService struct { @@ -79,6 +82,11 @@ 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) { @@ -98,39 +106,95 @@ func openInBrowser(url string) tea.Cmd { } func (m GeneralModel) View() string { - var b strings.Builder + overviewRows := []string{ + renderKVRow("Environment", m.envType, activeBadgeStyle), + renderKVRow("Shop URL", m.shopURL, urlStyle), + renderKVRow("Admin URL", m.adminURL, urlStyle), + } - b.WriteString("\n") - b.WriteString(labelStyle.Render("Environment") + valueStyle.Render(m.envType) + "\n") - b.WriteString(labelStyle.Render("Shop URL") + valueStyle.Render(m.shopURL) + "\n") - b.WriteString(labelStyle.Render("Admin URL") + valueStyle.Render(m.adminURL) + "\n") + credentialsRows := []string{ + renderKVRow("Username", m.username, valueStyle), + renderKVRow("Password", m.password, secretStyle), + } - if m.username != "" { - b.WriteString(labelStyle.Render("Admin User") + valueStyle.Render(m.username) + "\n") - b.WriteString(labelStyle.Render("Admin Password") + valueStyle.Render(m.password) + "\n") + if m.username == "" && m.password == "" { + credentialsRows = []string{ + helpStyle.Render("Admin credentials will appear here once Shopware is installed."), + } } - b.WriteString("\n") + overviewSection := renderSection("Shop", strings.Join(overviewRows, "\n")) + credentialsSection := renderSection("Admin Access", strings.Join(credentialsRows, "\n")) + servicesSection := renderSection("Services", m.renderServices()) + + columnStyle := lipgloss.NewStyle().Background(surfaceColor) + + var content string + if m.width >= 110 { + columnWidth := clampMin((m.width-7)/2, 36) + topRow := lipgloss.JoinHorizontal( + lipgloss.Top, + columnStyle.Width(columnWidth).Render(overviewSection), + columnStyle.Width(columnWidth).Render(credentialsSection), + ) + content = lipgloss.JoinVertical(lipgloss.Left, topRow, servicesSection) + } else { + content = lipgloss.JoinVertical( + lipgloss.Left, + overviewSection, + credentialsSection, + servicesSection, + ) + } + + 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")) + } + return lipgloss.JoinVertical( + lipgloss.Left, + "", + content, + renderFooter(footerHints...), + ) +} + +func (m GeneralModel) renderServices() string { switch { case m.loading: - b.WriteString(helpStyle.Render("Discovering services...") + "\n") + return lipgloss.JoinVertical( + lipgloss.Left, + activeBadgeStyle.Render("SCANNING"), + helpStyle.Render("Looking for published local services."), + ) case m.err != nil: - b.WriteString(errorStyle.Render("Service discovery failed: "+m.err.Error()) + "\n") - case len(m.services) > 0: - for _, s := range m.services { - b.WriteString(labelStyle.Render(s.Name) + valueStyle.Render(s.URL) + "\n") - if s.Username != "" { - b.WriteString(labelStyle.Render(" Username") + valueStyle.Render(s.Username) + "\n") - b.WriteString(labelStyle.Render(" Password") + valueStyle.Render(s.Password) + "\n") - } - } + 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.") } - b.WriteString("\n") - b.WriteString(helpStyle.Render("f: open shop | a: open admin")) + 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 b.String() + return strings.Join(blocks, "\n\n") } // dockerComposePSOutput represents a single container from `docker compose ps --format json`. diff --git a/internal/devtui/tab_logs.go b/internal/devtui/tab_logs.go index 38769f61..721aa801 100644 --- a/internal/devtui/tab_logs.go +++ b/internal/devtui/tab_logs.go @@ -3,6 +3,7 @@ package devtui import ( "bufio" "context" + "fmt" "os" "os/exec" "path/filepath" @@ -127,57 +128,98 @@ func (m LogsModel) Update(msg tea.Msg) (LogsModel, tea.Cmd) { } func (m LogsModel) View() string { - sidebar := m.renderSidebar() + body := lipgloss.JoinHorizontal(lipgloss.Top, m.renderSidebar(), m.renderContent()) - var content strings.Builder - content.WriteString(m.viewport.View()) - content.WriteString("\n") - - followIndicator := "off" + followState := "off" if m.follow { - followIndicator = "on" + followState = "on" } - content.WriteString(helpStyle.Render("f: toggle follow (" + followIndicator + ") | ↑/↓: select source | enter: switch | pgup/pgdn: scroll")) - return lipgloss.JoinHorizontal(lipgloss.Top, sidebar, content.String()) + return lipgloss.JoinVertical( + lipgloss.Left, + body, + renderFooter( + renderKeyHint("f", fmt.Sprintf("Follow %s", followState)), + renderKeyHint("↑/↓", "Move cursor"), + renderKeyHint("enter", "Open source"), + renderKeyHint("pgup", "Scroll"), + ), + ) } func (m LogsModel) renderSidebar() string { - sidebarStyle := lipgloss.NewStyle(). - Width(sidebarWidth). - BorderRight(true). - BorderStyle(lipgloss.NormalBorder()). - PaddingRight(1) - var b strings.Builder + b.WriteString(sectionTitleStyle.Render("Sources")) + b.WriteString("\n\n") for i, src := range m.sources { - prefix := " " - if i == m.cursor { - prefix = "> " + item := src.name + if i == m.active { + item = lipgloss.JoinHorizontal( + lipgloss.Center, + item, + " ", + activeBadgeStyle.Render("LIVE"), + ) } - name := src.name - if i == m.active { - name = statusStyle.Render(name) + style := sidebarItemStyle + switch { + case i == m.cursor && i == m.active: + style = activeSelectedSidebarItemStyle + case i == m.cursor: + style = selectedSidebarItemStyle + case i == m.active: + style = activeSidebarItemStyle } - b.WriteString(prefix + name + "\n") + 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 } - return sidebarStyle.Height(m.height - 4).Render(b.String()) + followBadge := warningBadgeStyle.Render("FOLLOW OFF") + if m.follow { + followBadge = activeBadgeStyle.Render("FOLLOW ON") + } + + header := lipgloss.JoinHorizontal( + lipgloss.Center, + panelHeaderStyle.Render(sourceName), + " ", + 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 - // Subtract sidebar width + border - viewportWidth := width - sidebarWidth - 2 - if viewportWidth < 10 { - viewportWidth = 10 - } + viewportWidth := clampMin(width-sidebarWidth-8, 20) m.viewport.SetWidth(viewportWidth) - m.viewport.SetHeight(height - 2) + m.viewport.SetHeight(clampMin(height-7, 8)) } // StartStreaming discovers sources and starts streaming the first one. From 1731368384d3871af144bb3960a579aca784b46b Mon Sep 17 00:00:00 2001 From: Soner Sayakci Date: Fri, 6 Mar 2026 08:14:29 +0100 Subject: [PATCH 07/19] feat: set default username and improve style rendering in devtui --- internal/devtui/model.go | 6 ++++-- internal/devtui/styles.go | 10 ++-------- internal/devtui/tab_logs.go | 2 +- 3 files changed, 7 insertions(+), 11 deletions(-) diff --git a/internal/devtui/model.go b/internal/devtui/model.go index 9ea8a5ca..99be24f7 100644 --- a/internal/devtui/model.go +++ b/internal/devtui/model.go @@ -43,6 +43,8 @@ const ( keyK = "k" key1 = "1" key2 = "2" + + defaultUsername = "admin" ) type overlay int @@ -254,7 +256,7 @@ func (m Model) updateLifecycle(msg tea.Msg) (tea.Model, tea.Cmd) { m.overlayLines = nil usernameInput := textinput.New() - usernameInput.Placeholder = "admin" + usernameInput.Placeholder = defaultUsername usernameInput.Prompt = "Username: " usernameInput.CharLimit = 50 @@ -423,7 +425,7 @@ func (m Model) updateInstallPrompt(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { case keyEnter: m.install.currency = installCurrencies[m.install.cursor] m.install.step = installStepUsername - m.install.username.SetValue("admin") + m.install.username.SetValue(defaultUsername) m.install.username.Focus() return m, textinput.Blink } diff --git a/internal/devtui/styles.go b/internal/devtui/styles.go index ea7e6f6e..2c4e4970 100644 --- a/internal/devtui/styles.go +++ b/internal/devtui/styles.go @@ -165,11 +165,6 @@ var ( Padding(0, 1). Bold(true) - statusStyle = lipgloss.NewStyle(). - Foreground(warningColor). - Background(surfaceColor). - Bold(true) - sidebarStyle = lipgloss.NewStyle(). Background(panelColor). Border(lipgloss.RoundedBorder()). @@ -258,9 +253,8 @@ func renderFooter(parts ...string) string { } separator := surfaceMutedTextStyle.Render(" | ") - return surfaceMutedTextStyle.Copy(). - Padding(1, 0, 0). - Render(strings.Join(filtered, separator)) + style := surfaceMutedTextStyle.Padding(1, 0, 0) + return style.Render(strings.Join(filtered, separator)) } func renderKVRow(label, value string, valueRenderer lipgloss.Style) string { diff --git a/internal/devtui/tab_logs.go b/internal/devtui/tab_logs.go index 721aa801..81936946 100644 --- a/internal/devtui/tab_logs.go +++ b/internal/devtui/tab_logs.go @@ -165,7 +165,7 @@ func (m LogsModel) renderSidebar() string { style := sidebarItemStyle switch { - case i == m.cursor && i == m.active: + case i == m.cursor && m.cursor == m.active: style = activeSelectedSidebarItemStyle case i == m.cursor: style = selectedSidebarItemStyle From 1e23377ff8a757a424da86902f3822abc8d0410c Mon Sep 17 00:00:00 2001 From: Soner Sayakci Date: Fri, 6 Mar 2026 10:33:17 +0100 Subject: [PATCH 08/19] feat: implement WithEnv method for executors to manage environment variables --- internal/devtui/model.go | 4 ++-- internal/executor/docker.go | 18 ++++++++++++------ internal/executor/executor.go | 17 +++-------------- internal/executor/executor_test.go | 12 ++++++------ internal/executor/local.go | 19 ++++++++++++------- internal/executor/symfony_cli.go | 11 ++++++++--- 6 files changed, 43 insertions(+), 38 deletions(-) diff --git a/internal/devtui/model.go b/internal/devtui/model.go index 99be24f7..0ae372b3 100644 --- a/internal/devtui/model.go +++ b/internal/devtui/model.go @@ -683,13 +683,13 @@ func (m *Model) runShopwareInstall() tea.Cmd { password := m.install.password.Value() doneCmd := func() tea.Msg { - ctx := executor.WithEnv(context.Background(), map[string]string{ + withEnv := e.WithEnv(map[string]string{ "INSTALL_LOCALE": language, "INSTALL_CURRENCY": currency, "INSTALL_ADMIN_USERNAME": username, "INSTALL_ADMIN_PASSWORD": password, }) - cmd := e.PHPCommand(ctx, "vendor/bin/shopware-deployment-helper", "run") + cmd := withEnv.PHPCommand(context.Background(), "vendor/bin/shopware-deployment-helper", "run") cmd.Dir = projectRoot pipe, err := cmd.StdoutPipe() diff --git a/internal/executor/docker.go b/internal/executor/docker.go index d0c87aa5..b0d13845 100644 --- a/internal/executor/docker.go +++ b/internal/executor/docker.go @@ -10,10 +10,12 @@ import ( ) // DockerExecutor runs commands via docker compose exec against the "web" service. -type DockerExecutor struct{} +type DockerExecutor struct { + env map[string]string +} func (d *DockerExecutor) ConsoleCommand(ctx context.Context, args ...string) *exec.Cmd { - dockerArgs := d.baseArgs(ctx) + dockerArgs := d.baseArgs() dockerArgs = append(dockerArgs, "php", consoleCommandName(ctx)) dockerArgs = append(dockerArgs, args...) @@ -21,7 +23,7 @@ func (d *DockerExecutor) ConsoleCommand(ctx context.Context, args ...string) *ex } func (d *DockerExecutor) ComposerCommand(ctx context.Context, args ...string) *exec.Cmd { - dockerArgs := d.baseArgs(ctx) + dockerArgs := d.baseArgs() dockerArgs = append(dockerArgs, "composer") dockerArgs = append(dockerArgs, args...) @@ -29,7 +31,7 @@ func (d *DockerExecutor) ComposerCommand(ctx context.Context, args ...string) *e } func (d *DockerExecutor) PHPCommand(ctx context.Context, args ...string) *exec.Cmd { - dockerArgs := d.baseArgs(ctx) + dockerArgs := d.baseArgs() dockerArgs = append(dockerArgs, "php") dockerArgs = append(dockerArgs, args...) @@ -40,14 +42,18 @@ func (d *DockerExecutor) Type() string { return "docker" } -func (d *DockerExecutor) baseArgs(ctx context.Context) []string { +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 getEnvVars(ctx) { + for k, v := range d.env { args = append(args, "-e", fmt.Sprintf("%s=%s", k, v)) } diff --git a/internal/executor/executor.go b/internal/executor/executor.go index 4034a104..65621c71 100644 --- a/internal/executor/executor.go +++ b/internal/executor/executor.go @@ -20,21 +20,10 @@ type Executor interface { // Type returns the executor type name (e.g. "local", "docker"). Type() string -} - -type envVarsKey struct{} - -// WithEnv attaches extra environment variables to the context. -// Executor implementations will apply these to the created commands. -// For Docker, they are injected as -e flags; for local/symfony, they are set on cmd.Env. -func WithEnv(ctx context.Context, env map[string]string) context.Context { - return context.WithValue(ctx, envVarsKey{}, env) -} -// getEnvVars extracts extra environment variables from the context. -func getEnvVars(ctx context.Context) map[string]string { - env, _ := ctx.Value(envVarsKey{}).(map[string]string) - return env + // WithEnv returns a copy of the executor with extra environment variables set on all commands. + // For Docker, they are injected as -e flags; for local/symfony, they are set on cmd.Env. + WithEnv(env map[string]string) Executor } type allowBinCIKey struct{} diff --git a/internal/executor/executor_test.go b/internal/executor/executor_test.go index de5af94d..17859db7 100644 --- a/internal/executor/executor_test.go +++ b/internal/executor/executor_test.go @@ -137,12 +137,12 @@ func TestConsoleCommandNameWithAllowBinCI(t *testing.T) { func TestLocalExecutorWithEnv(t *testing.T) { exec := &LocalExecutor{} - ctx := WithEnv(t.Context(), map[string]string{ + withEnv := exec.WithEnv(map[string]string{ "INSTALL_LOCALE": "de-DE", "INSTALL_CURRENCY": "EUR", }) - cmd := exec.PHPCommand(ctx, "vendor/bin/shopware-deployment-helper", "run") + 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") } @@ -156,21 +156,21 @@ func TestLocalExecutorWithoutEnv(t *testing.T) { func TestDockerExecutorWithEnv(t *testing.T) { exec := &DockerExecutor{} - ctx := WithEnv(t.Context(), map[string]string{ + withEnv := exec.WithEnv(map[string]string{ "INSTALL_LOCALE": "en-GB", }) - cmd := exec.PHPCommand(ctx, "vendor/bin/shopware-deployment-helper", "run") + 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"} - ctx := WithEnv(t.Context(), map[string]string{ + withEnv := exec.WithEnv(map[string]string{ "INSTALL_LOCALE": "de-DE", }) - cmd := exec.PHPCommand(ctx, "-v") + cmd := withEnv.PHPCommand(t.Context(), "-v") assert.Contains(t, cmd.Env, "INSTALL_LOCALE=de-DE") } diff --git a/internal/executor/local.go b/internal/executor/local.go index 7e2ebf38..2b0f0c88 100644 --- a/internal/executor/local.go +++ b/internal/executor/local.go @@ -8,25 +8,27 @@ import ( ) // LocalExecutor runs commands using the local PHP installation directly. -type LocalExecutor struct{} +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(ctx, cmd) + applyLocalEnv(l.env, cmd) return cmd } func (l *LocalExecutor) ComposerCommand(ctx context.Context, args ...string) *exec.Cmd { cmd := exec.CommandContext(ctx, "composer", args...) - applyLocalEnv(ctx, cmd) + applyLocalEnv(l.env, cmd) return cmd } func (l *LocalExecutor) PHPCommand(ctx context.Context, args ...string) *exec.Cmd { cmd := exec.CommandContext(ctx, "php", args...) - applyLocalEnv(ctx, cmd) + applyLocalEnv(l.env, cmd) return cmd } @@ -34,9 +36,12 @@ func (l *LocalExecutor) Type() string { return "local" } -// applyLocalEnv sets extra environment variables from the context on a local command. -func applyLocalEnv(ctx context.Context, cmd *exec.Cmd) { - env := getEnvVars(ctx) +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 } diff --git a/internal/executor/symfony_cli.go b/internal/executor/symfony_cli.go index 3c79833b..02b414b7 100644 --- a/internal/executor/symfony_cli.go +++ b/internal/executor/symfony_cli.go @@ -9,13 +9,14 @@ import ( type SymfonyCLIExecutor struct { // Path to the symfony binary. 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(ctx, cmd) + applyLocalEnv(s.env, cmd) return cmd } @@ -23,7 +24,7 @@ func (s *SymfonyCLIExecutor) ComposerCommand(ctx context.Context, args ...string cmdArgs := []string{"composer"} cmdArgs = append(cmdArgs, args...) cmd := exec.CommandContext(ctx, s.BinaryPath, cmdArgs...) - applyLocalEnv(ctx, cmd) + applyLocalEnv(s.env, cmd) return cmd } @@ -31,10 +32,14 @@ func (s *SymfonyCLIExecutor) PHPCommand(ctx context.Context, args ...string) *ex cmdArgs := []string{"php"} cmdArgs = append(cmdArgs, args...) cmd := exec.CommandContext(ctx, s.BinaryPath, cmdArgs...) - applyLocalEnv(ctx, cmd) + 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} +} From c9ac071b2d52e1e895054b30a455df92054a6502 Mon Sep 17 00:00:00 2001 From: Soner Sayakci Date: Tue, 10 Mar 2026 04:54:28 +0100 Subject: [PATCH 09/19] feat: Add compatibility check for development mode based on compatibility date --- cmd/project/project_dev.go | 4 ++++ internal/shop/compatibility_date.go | 24 +++++++++++++++++++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/cmd/project/project_dev.go b/cmd/project/project_dev.go index 078c921d..52cf7b13 100644 --- a/cmd/project/project_dev.go +++ b/cmd/project/project_dev.go @@ -23,6 +23,10 @@ var projectDevCmd = &cobra.Command{ return err } + if cfg.IsCompatibilityDateBefore(shop.CompatibilityDevMode) { + return shop.ErrDevModeNotSupported + } + envCfg, err := cfg.ResolveEnvironment(environmentName) if err != nil { return err diff --git a/internal/shop/compatibility_date.go b/internal/shop/compatibility_date.go index a4f8657a..b58733f7 100644 --- a/internal/shop/compatibility_date.go +++ b/internal/shop/compatibility_date.go @@ -1,6 +1,28 @@ package shop +import "fmt" + const ( // DevMode breaks - CompatibilityDevMode = "2026-04-01" + 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) +} From e82d3971d0d40583b770d0d4b3a10e9c64cd1397 Mon Sep 17 00:00:00 2001 From: Soner Sayakci Date: Tue, 10 Mar 2026 05:06:40 +0100 Subject: [PATCH 10/19] feat: Adjust overlay rendering and footer positioning for improved UI layout --- internal/devtui/model.go | 9 ++++++++- internal/devtui/tab_general.go | 14 ++++++++++++-- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/internal/devtui/model.go b/internal/devtui/model.go index 0ae372b3..4513a982 100644 --- a/internal/devtui/model.go +++ b/internal/devtui/model.go @@ -536,7 +536,14 @@ func (m Model) renderOverlay() string { m.renderInstallPrompt(&content) } - modal := overlayStyle.Render(content.String()) + style := overlayStyle + if m.overlay == overlayStarting || m.overlay == overlayStopping || m.overlay == overlayInstalling { + if m.width > 0 && m.height > 0 { + style = style.Width(m.width * 80 / 100).Height(m.height * 80 / 100) + } + } + + modal := style.Render(content.String()) if m.width > 0 && m.height > 0 { modal = lipgloss.Place( diff --git a/internal/devtui/tab_general.go b/internal/devtui/tab_general.go index a3ac4890..ba5bf493 100644 --- a/internal/devtui/tab_general.go +++ b/internal/devtui/tab_general.go @@ -155,12 +155,22 @@ func (m GeneralModel) View() string { footerHints = append(footerHints, renderKeyHint("a", "Open admin")) } - return lipgloss.JoinVertical( + footer := renderFooter(footerHints...) + + body := lipgloss.JoinVertical( lipgloss.Left, "", content, - renderFooter(footerHints...), ) + + // Pin footer to bottom by filling remaining height with whitespace + bodyHeight := lipgloss.Height(body) + footerHeight := lipgloss.Height(footer) + if gap := m.height - bodyHeight - footerHeight; gap > 0 { + body += strings.Repeat("\n", gap) + } + + return body + footer } func (m GeneralModel) renderServices() string { From 8a312beff9a524cd5b3e7eebac98482a614dd023 Mon Sep 17 00:00:00 2001 From: Soner Sayakci Date: Tue, 10 Mar 2026 06:04:59 +0100 Subject: [PATCH 11/19] feat: Enhance rendering functions for improved layout and padding in logs and sections --- internal/devtui/styles.go | 32 ++++++++++++++++--- internal/devtui/tab_general.go | 58 ++++++++++++++++++++++------------ internal/devtui/tab_logs.go | 30 +++++++++--------- 3 files changed, 79 insertions(+), 41 deletions(-) diff --git a/internal/devtui/styles.go b/internal/devtui/styles.go index 2c4e4970..92998711 100644 --- a/internal/devtui/styles.go +++ b/internal/devtui/styles.go @@ -5,6 +5,7 @@ import ( "charm.land/lipgloss/v2" "charm.land/lipgloss/v2/compat" + "github.com/charmbracelet/x/ansi" ) const generalLabelWidth = 16 @@ -216,6 +217,10 @@ var ( ) 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 @@ -223,6 +228,14 @@ func renderSection(title, body string) string { 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("") @@ -232,11 +245,7 @@ func renderSection(title, body string) string { } func renderKeyHint(key, action string) string { - return lipgloss.JoinHorizontal( - lipgloss.Center, - keyStyle.Render(strings.ToUpper(key)), - surfaceMutedTextStyle.Render(" "+action), - ) + return keyStyle.Render(strings.ToUpper(key)) + surfaceMutedTextStyle.Render(" "+action) } func renderFooter(parts ...string) string { @@ -290,3 +299,16 @@ func clampMin(value, minimum int) int { 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 index ba5bf493..51ef40e8 100644 --- a/internal/devtui/tab_general.go +++ b/internal/devtui/tab_general.go @@ -123,22 +123,37 @@ func (m GeneralModel) View() string { } } - overviewSection := renderSection("Shop", strings.Join(overviewRows, "\n")) - credentialsSection := renderSection("Admin Access", strings.Join(credentialsRows, "\n")) - servicesSection := renderSection("Services", m.renderServices()) - - columnStyle := lipgloss.NewStyle().Background(surfaceColor) + contentWidth := max(m.width-2, 0) var content string if m.width >= 110 { - columnWidth := clampMin((m.width-7)/2, 36) - topRow := lipgloss.JoinHorizontal( - lipgloss.Top, - columnStyle.Width(columnWidth).Render(overviewSection), - columnStyle.Width(columnWidth).Render(credentialsSection), - ) - content = lipgloss.JoinVertical(lipgloss.Left, topRow, servicesSection) + 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, @@ -147,6 +162,8 @@ func (m GeneralModel) View() string { ) } + content = padLines(content, contentWidth, surfaceTextStyle) + var footerHints []string if m.shopURL != "" { footerHints = append(footerHints, renderKeyHint("f", "Open shop")) @@ -155,22 +172,21 @@ func (m GeneralModel) View() string { footerHints = append(footerHints, renderKeyHint("a", "Open admin")) } - footer := renderFooter(footerHints...) + footer := padLines(renderFooter(footerHints...), contentWidth, surfaceTextStyle) - body := lipgloss.JoinVertical( - lipgloss.Left, - "", - content, - ) + body := "\n" + content - // Pin footer to bottom by filling remaining height with whitespace + // 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 { - body += strings.Repeat("\n", gap) + emptyLine := surfaceTextStyle.Render(strings.Repeat(" ", contentWidth)) + for range gap { + body += "\n" + emptyLine + } } - return body + footer + return body + "\n" + footer } func (m GeneralModel) renderServices() string { diff --git a/internal/devtui/tab_logs.go b/internal/devtui/tab_logs.go index 81936946..11b804a2 100644 --- a/internal/devtui/tab_logs.go +++ b/internal/devtui/tab_logs.go @@ -135,16 +135,14 @@ func (m LogsModel) View() string { followState = "on" } - return lipgloss.JoinVertical( - lipgloss.Left, - body, - renderFooter( - renderKeyHint("f", fmt.Sprintf("Follow %s", followState)), - renderKeyHint("↑/↓", "Move cursor"), - renderKeyHint("enter", "Open source"), - renderKeyHint("pgup", "Scroll"), - ), + 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 { @@ -198,17 +196,19 @@ func (m LogsModel) renderContent() string { followBadge = activeBadgeStyle.Render("FOLLOW ON") } - header := lipgloss.JoinHorizontal( - lipgloss.Center, - panelHeaderStyle.Render(sourceName), - " ", - followBadge, - ) + 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()), ), ) From fb245bc07befcdcb0f2f3e29da603cc8bc9f2a88 Mon Sep 17 00:00:00 2001 From: Soner Sayakci Date: Tue, 10 Mar 2026 11:07:14 +0100 Subject: [PATCH 12/19] feat: let the cli manage compose file --- cmd/project/project_create.go | 8 +- cmd/project/project_dev.go | 7 + internal/docker/compose.go | 209 ++++++++++++++++++++ internal/docker/compose_test.go | 107 ++++++++++ internal/packagist/project_composer_json.go | 6 +- 5 files changed, 331 insertions(+), 6 deletions(-) create mode 100644 internal/docker/compose.go create mode 100644 internal/docker/compose_test.go diff --git a/cmd/project/project_create.go b/cmd/project/project_create.go index fcb38315..75eddb9b 100644 --- a/cmd/project/project_create.go +++ b/cmd/project/project_create.go @@ -22,6 +22,7 @@ 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" @@ -399,7 +400,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, @@ -462,6 +462,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 { diff --git a/cmd/project/project_dev.go b/cmd/project/project_dev.go index 52cf7b13..14b5c659 100644 --- a/cmd/project/project_dev.go +++ b/cmd/project/project_dev.go @@ -5,6 +5,7 @@ import ( "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" ) @@ -37,6 +38,12 @@ var projectDevCmd = &cobra.Command{ return err } + if exec.Type() == "docker" { + if err := dockerpkg.WriteComposeFile(projectRoot); err != nil { + return err + } + } + m := devtui.New(devtui.Options{ ProjectRoot: projectRoot, Config: cfg, diff --git a/internal/docker/compose.go b/internal/docker/compose.go new file mode 100644 index 00000000..460b3b58 --- /dev/null +++ b/internal/docker/compose.go @@ -0,0 +1,209 @@ +package docker + +import ( + "fmt" + "os" + "path/filepath" + + "gopkg.in/yaml.v3" + + "github.com/shopware/shopware-cli/internal/packagist" +) + +// GenerateComposeFile generates compose.yaml content +// based on the packages present in the given ComposerLock. +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\n" + + return append([]byte(header), out...), nil +} + +// WriteComposeFile reads the composer.lock in projectFolder and writes compose.yaml. +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/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", From 858ae7d446bd0c9d25660e297685d2566105dea6 Mon Sep 17 00:00:00 2001 From: Soner Sayakci Date: Tue, 10 Mar 2026 11:09:03 +0100 Subject: [PATCH 13/19] feat: update compose file header to include documentation link --- internal/docker/compose.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/docker/compose.go b/internal/docker/compose.go index 460b3b58..878dc673 100644 --- a/internal/docker/compose.go +++ b/internal/docker/compose.go @@ -24,7 +24,8 @@ func GenerateComposeFile(lock *packagist.ComposerLock) ([]byte, error) { } header := "# This file is managed by shopware-cli. Do not edit manually.\n" + - "# Create a compose.override.yaml to customize services.\n\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 } From a834d59e17693e153ff93629135e685d8618db6b Mon Sep 17 00:00:00 2001 From: Soner Sayakci Date: Tue, 10 Mar 2026 11:33:03 +0100 Subject: [PATCH 14/19] feat: refactor package fetching methods for improved clarity and consistency --- cmd/project/project_autofix_composer.go | 2 +- cmd/project/project_create.go | 48 +++++++------------------ internal/extension/packagist.go | 2 +- internal/packagist/packagist.go | 10 +++--- internal/packagist/packagist_test.go | 36 +++++++++---------- 5 files changed, 38 insertions(+), 60 deletions(-) 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_create.go b/cmd/project/project_create.go index 75eddb9b..afd48ee3 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" @@ -671,7 +668,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 } @@ -680,7 +677,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) @@ -689,6 +693,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 } @@ -705,33 +713,3 @@ func init() { 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/internal/extension/packagist.go b/internal/extension/packagist.go index 75fa67d5..76837e87 100644 --- a/internal/extension/packagist.go +++ b/internal/extension/packagist.go @@ -11,7 +11,7 @@ import ( ) func GetShopwareVersions(ctx context.Context) ([]string, error) { - packageVersions, err := packagist.GetPackageVersions(ctx) + packageVersions, err := packagist.GetShopwarePackageVersions(ctx) if err != nil { return nil, fmt.Errorf("get package versions: %w", err) } diff --git a/internal/packagist/packagist.go b/internal/packagist/packagist.go index 89be0b7f..e7965ccb 100644 --- a/internal/packagist/packagist.go +++ b/internal/packagist/packagist.go @@ -43,7 +43,7 @@ type composerPackageVersionsResponse struct { Packages map[string][]map[string]json.RawMessage `json:"packages"` } -func GetPackages(ctx context.Context, token string) (*PackageResponse, error) { +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 @@ -75,8 +75,8 @@ func GetPackages(ctx context.Context, token string) (*PackageResponse, error) { return &packages, nil } -func GetPackageVersions(ctx context.Context) ([]ComposerPackageVersion, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://repo.packagist.org/p2/shopware/shopware.json", http.NoBody) +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) } @@ -104,9 +104,9 @@ func GetPackageVersions(ctx context.Context) ([]ComposerPackageVersion, error) { return nil, fmt.Errorf("decode package versions: %w", err) } - rawVersions, ok := packageResponse.Packages["shopware/shopware"] + rawVersions, ok := packageResponse.Packages["shopware/core"] if !ok { - return nil, fmt.Errorf("decode package versions: package shopware/shopware not found") + return nil, fmt.Errorf("decode package versions: package shopware/core not found") } if packageResponse.Minified != "" { diff --git a/internal/packagist/packagist_test.go b/internal/packagist/packagist_test.go index 1a97cb7c..603f593b 100644 --- a/internal/packagist/packagist_test.go +++ b/internal/packagist/packagist_test.go @@ -114,7 +114,7 @@ func TestGetPackages(t *testing.T) { } // Call the function - packages, err := GetPackages(t.Context(), "test-token") + packages, err := GetAvailablePackagesFromShopwareStore(t.Context(), "test-token") // Assertions assert.NoError(t, err) @@ -138,7 +138,7 @@ func TestGetPackages(t *testing.T) { } // Call the function - packages, err := GetPackages(t.Context(), "invalid-token") + packages, err := GetAvailablePackagesFromShopwareStore(t.Context(), "invalid-token") // Assertions assert.Error(t, err) @@ -163,7 +163,7 @@ func TestGetPackages(t *testing.T) { } // Call the function - packages, err := GetPackages(t.Context(), "test-token") + packages, err := GetAvailablePackagesFromShopwareStore(t.Context(), "test-token") // Assertions assert.Error(t, err) @@ -185,7 +185,7 @@ func TestGetPackages(t *testing.T) { } // Call the function - packages, err := GetPackages(t.Context(), "test-token") + packages, err := GetAvailablePackagesFromShopwareStore(t.Context(), "test-token") // Assertions assert.Error(t, err) @@ -199,7 +199,7 @@ func TestGetPackages(t *testing.T) { cancel() // Cancel the context immediately // Call the function with canceled context - packages, err := GetPackages(ctx, "test-token") + packages, err := GetAvailablePackagesFromShopwareStore(ctx, "test-token") // Assertions assert.Error(t, err) @@ -215,15 +215,15 @@ func TestGetPackageVersions(t *testing.T) { 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/shopware.json", r.URL.Path) + 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/shopware": []map[string]any{ + "shopware/core": []map[string]any{ { - "name": "shopware/shopware", + "name": "shopware/core", "version": "v1.0.0", "version_normalized": "1.0.0.0", "description": "Base description", @@ -257,11 +257,11 @@ func TestGetPackageVersions(t *testing.T) { }, } - versions, err := GetPackageVersions(t.Context()) + versions, err := GetShopwarePackageVersions(t.Context()) require.NoError(t, err) require.Len(t, versions, 3) - assert.Equal(t, "shopware/shopware", versions[0].Name) + 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) @@ -272,9 +272,9 @@ func TestGetPackageVersions(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { response := map[string]any{ "packages": map[string]any{ - "shopware/shopware": []map[string]any{ + "shopware/core": []map[string]any{ { - "name": "shopware/shopware", + "name": "shopware/core", "version": "v2.0.0", "version_normalized": "2.0.0.0", }, @@ -294,7 +294,7 @@ func TestGetPackageVersions(t *testing.T) { }, } - versions, err := GetPackageVersions(t.Context()) + versions, err := GetShopwarePackageVersions(t.Context()) require.NoError(t, err) require.Len(t, versions, 1) @@ -305,7 +305,7 @@ func TestGetPackageVersions(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{}, + "some/other-package": []map[string]any{}, }, } @@ -321,11 +321,11 @@ func TestGetPackageVersions(t *testing.T) { }, } - versions, err := GetPackageVersions(t.Context()) + versions, err := GetShopwarePackageVersions(t.Context()) assert.Error(t, err) assert.Nil(t, versions) - assert.Contains(t, err.Error(), "package shopware/shopware not found") + assert.Contains(t, err.Error(), "package shopware/core not found") }) t.Run("server error", func(t *testing.T) { @@ -340,7 +340,7 @@ func TestGetPackageVersions(t *testing.T) { }, } - versions, err := GetPackageVersions(t.Context()) + versions, err := GetShopwarePackageVersions(t.Context()) assert.Error(t, err) assert.Nil(t, versions) @@ -351,7 +351,7 @@ func TestGetPackageVersions(t *testing.T) { ctx, cancel := context.WithCancel(t.Context()) cancel() - versions, err := GetPackageVersions(ctx) + versions, err := GetShopwarePackageVersions(ctx) assert.Error(t, err) assert.Nil(t, versions) From 8eb8ab7d2d26bc8db3ae6ef192755921caf63dc4 Mon Sep 17 00:00:00 2001 From: Soner Sayakci Date: Tue, 10 Mar 2026 11:36:47 +0100 Subject: [PATCH 15/19] feat: simplify comments in executor and config files for clarity --- cmd/project/executor.go | 3 +-- internal/executor/factory.go | 2 -- internal/packagist/packagist_test.go | 29 +--------------------------- internal/shop/config.go | 2 -- 4 files changed, 2 insertions(+), 34 deletions(-) diff --git a/cmd/project/executor.go b/cmd/project/executor.go index 300eb104..bed2d279 100644 --- a/cmd/project/executor.go +++ b/cmd/project/executor.go @@ -7,8 +7,7 @@ import ( "github.com/shopware/shopware-cli/internal/shop" ) -// resolveExecutor reads the project config, resolves the target environment, -// and returns the appropriate Executor. +// 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 { diff --git a/internal/executor/factory.go b/internal/executor/factory.go index b6e0b123..1cc58cc3 100644 --- a/internal/executor/factory.go +++ b/internal/executor/factory.go @@ -10,11 +10,9 @@ import ( ) // New creates an Executor for the given environment and shop configuration. -// For "local" type, it auto-detects Symfony CLI and uses it if available. func New(cfg *shop.EnvironmentConfig, shopCfg *shop.Config) (Executor, error) { switch cfg.Type { case "local", "": - // After DevMode, the user requires to explicitly need to opt-in for Symfony CLI. Before that, we auto-detect it and use it if available. if shopCfg.IsCompatibilityDateBefore(shop.CompatibilityDevMode) { if path := pathToSymfonyCLI(); path != "" && symfonyCliAllowed() { return &SymfonyCLIExecutor{BinaryPath: path}, nil diff --git a/internal/packagist/packagist_test.go b/internal/packagist/packagist_test.go index 603f593b..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 := 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 := 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,53 +143,43 @@ 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 := 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 := 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 := GetAvailablePackagesFromShopwareStore(ctx, "test-token") - // Assertions assert.Error(t, err) assert.Nil(t, packages) }) @@ -358,16 +336,13 @@ func TestGetPackageVersions(t *testing.T) { }) } -// mockTransport is a custom RoundTripper that redirects all requests to a test server. 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, @@ -378,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/shop/config.go b/internal/shop/config.go index 60a88129..6b242e1d 100644 --- a/internal/shop/config.go +++ b/internal/shop/config.go @@ -50,8 +50,6 @@ type Config struct { } // ResolveEnvironment returns the environment config for the given name. -// If name is empty, it returns the "local" environment if configured, -// otherwise synthesizes one from top-level config fields for backward compatibility. func (c *Config) ResolveEnvironment(name string) (*EnvironmentConfig, error) { if name != "" { env, ok := c.Environments[name] From 18ce915508228b1538b429514626a754a21d81f4 Mon Sep 17 00:00:00 2001 From: Soner Sayakci Date: Tue, 10 Mar 2026 11:42:35 +0100 Subject: [PATCH 16/19] feat: remove redundant comments for improved code clarity --- internal/devtui/model.go | 22 ---------------------- internal/devtui/tab_general.go | 4 ---- internal/devtui/tab_logs.go | 2 -- internal/docker/compose.go | 3 --- internal/executor/executor.go | 10 ---------- internal/executor/symfony_cli.go | 1 - internal/extension/project.go | 1 - internal/shop/compatibility_date.go | 1 - internal/shop/config.go | 11 ++--------- internal/shop/console.go | 3 +-- 10 files changed, 3 insertions(+), 55 deletions(-) diff --git a/internal/devtui/model.go b/internal/devtui/model.go index 4513a982..17ba73cf 100644 --- a/internal/devtui/model.go +++ b/internal/devtui/model.go @@ -24,7 +24,6 @@ const ( var tabNames = []string{"General", "Logs"} -// Key constants for repeated key strings const ( keyCtrlC = "ctrl+c" keyDown = "down" @@ -58,7 +57,6 @@ const ( overlayInstalling ) -// installStep tracks which question the install wizard is on. type installStep int const ( @@ -93,7 +91,6 @@ var ( installCurrencies = []string{"EUR", "USD", "GBP", "PLN", "CHF", "SEK", "DKK", "NOK", "CZK"} ) -// installWizard holds state for the Shopware install prompt. type installWizard struct { step installStep cursor int @@ -103,7 +100,6 @@ type installWizard struct { password textinput.Model } -// Options configures the TUI dashboard. type Options struct { ProjectRoot string Config *shop.Config @@ -111,7 +107,6 @@ type Options struct { Executor executor.Executor } -// Model is the top-level Bubble Tea model for the dev dashboard. type Model struct { activeTab activeTab general GeneralModel @@ -129,7 +124,6 @@ type Model struct { envConfig *shop.EnvironmentConfig } -// docker lifecycle messages type dockerAlreadyRunningMsg struct{} type dockerNeedStartMsg struct{} type dockerStartedMsg struct{ err error } @@ -137,14 +131,11 @@ type dockerStoppedMsg struct{ err error } type dockerOutputLineMsg string type dockerOutputDoneMsg struct{} -// shopware install check messages type shopwareInstalledMsg struct{} type shopwareNotInstalledMsg struct{} type shopwareInstallDoneMsg struct{ err error } -// New creates a new TUI model from the given options. func New(opts Options) Model { - // Resolve effective admin API: environment overrides top-level config effectiveAdminApi := opts.Config.AdminApi if opts.EnvConfig.AdminApi != nil { effectiveAdminApi = opts.EnvConfig.AdminApi @@ -214,7 +205,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.updateChildren(msg) } -// updateLifecycle handles docker and shopware lifecycle messages. func (m Model) updateLifecycle(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case dockerAlreadyRunningMsg: @@ -275,7 +265,6 @@ func (m Model) updateLifecycle(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } - // Update config with admin credentials username := m.install.username.Value() password := m.install.password.Value() @@ -286,7 +275,6 @@ func (m Model) updateLifecycle(msg tea.Msg) (tea.Model, tea.Cmd) { m.envConfig.AdminApi = adminApi _ = shop.WriteConfig(m.config, m.projectRoot) - // Update general tab display m.general.username = username m.general.password = password @@ -302,7 +290,6 @@ func (m Model) updateLifecycle(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } -// updateKeyPress handles all key press events, including overlay-specific keys. func (m Model) updateKeyPress(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { if m.overlay == overlayInstallPrompt { return m.updateInstallPrompt(msg) @@ -320,7 +307,6 @@ func (m Model) updateKeyPress(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { return m, nil } - // Other overlays only allow quit if m.overlay != overlayNone { if msg.String() == keyQ || msg.String() == keyCtrlC { return m, tea.Quit @@ -359,7 +345,6 @@ func (m Model) updateKeyPress(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { return m.updateChildren(msg) } -// updateChildren propagates messages to child models. func (m Model) updateChildren(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd @@ -378,7 +363,6 @@ func (m Model) updateChildren(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Batch(cmds...) } -// updateInstallPrompt handles key input for the install wizard steps. func (m Model) updateInstallPrompt(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { switch msg.String() { case keyQ, keyCtrlC: @@ -640,7 +624,6 @@ func (m Model) renderTabBar() string { return lipgloss.JoinHorizontal(lipgloss.Top, tabs...) } -// checkContainersRunning checks if any containers are already running. func checkContainersRunning(projectRoot string) tea.Cmd { return func() tea.Msg { ctx := context.Background() @@ -654,7 +637,6 @@ func checkContainersRunning(projectRoot string) tea.Cmd { } } -// checkShopwareInstalled runs bin/console system:is-installed to check if Shopware is set up. func (m *Model) checkShopwareInstalled() tea.Cmd { exec := m.executor projectRoot := m.projectRoot @@ -668,7 +650,6 @@ func (m *Model) checkShopwareInstalled() tea.Cmd { } } -// runShopwareInstall runs vendor/bin/shopware-deployment-helper run with INSTALL_LOCALE and INSTALL_CURRENCY env vars. func (m *Model) runShopwareInstall() tea.Cmd { e := m.executor projectRoot := m.projectRoot @@ -724,7 +705,6 @@ func (m *Model) runShopwareInstall() tea.Cmd { return tea.Batch(outputCmd, doneCmd) } -// readNextDockerOutput reads the next line from the docker output channel. func (m *Model) readNextDockerOutput() tea.Cmd { ch := m.dockerOutChan if ch == nil { @@ -781,7 +761,6 @@ func runDockerCommandWithArgs(ctx context.Context, projectRoot string, args []st return lineChan, outputCmd, doneCmd } -// startContainers runs docker compose up -d, streaming output. func (m *Model) startContainers() tea.Cmd { ch, outputCmd, doneCmd := runDockerCommandWithArgs( context.Background(), @@ -793,7 +772,6 @@ func (m *Model) startContainers() tea.Cmd { return tea.Batch(outputCmd, doneCmd) } -// stopContainers runs docker compose down, streaming output. func (m *Model) stopContainers() tea.Cmd { ch, outputCmd, doneCmd := runDockerCommandWithArgs( context.Background(), diff --git a/internal/devtui/tab_general.go b/internal/devtui/tab_general.go index 51ef40e8..2d019382 100644 --- a/internal/devtui/tab_general.go +++ b/internal/devtui/tab_general.go @@ -37,7 +37,6 @@ type servicesLoadedMsg struct { err error } -// knownService defines a well-known Docker compose service with its primary UI port and default credentials. type knownService struct { Name string TargetPort int @@ -45,8 +44,6 @@ type knownService struct { Password string } -// knownServices maps compose service names to their known configuration. -// The key is the compose service name (e.g. "database", "mailer", "lavinmq"). var knownServices = map[string]knownService{ "adminer": {Name: "Adminer", TargetPort: 8080, Username: "root", Password: "root"}, "mailer": {Name: "Mailpit", TargetPort: 8025}, @@ -54,7 +51,6 @@ var knownServices = map[string]knownService{ "rabbitmq": {Name: "Queue (RabbitMQ)", TargetPort: 15672, Username: "guest", Password: "guest"}, } -// ignoredServices are compose services whose ports should not be listed. var ignoredServices = map[string]bool{ "web": true, "database": true, diff --git a/internal/devtui/tab_logs.go b/internal/devtui/tab_logs.go index 11b804a2..be8c187c 100644 --- a/internal/devtui/tab_logs.go +++ b/internal/devtui/tab_logs.go @@ -14,7 +14,6 @@ import ( "charm.land/lipgloss/v2" ) -// logSource represents a selectable log source (docker container or file). type logSource struct { name string container string // non-empty for docker containers @@ -222,7 +221,6 @@ func (m *LogsModel) SetSize(width, height int) { m.viewport.SetHeight(clampMin(height-7, 8)) } -// StartStreaming discovers sources and starts streaming the first one. func (m *LogsModel) StartStreaming() tea.Cmd { return m.discoverSources() } diff --git a/internal/docker/compose.go b/internal/docker/compose.go index 878dc673..72c18169 100644 --- a/internal/docker/compose.go +++ b/internal/docker/compose.go @@ -10,8 +10,6 @@ import ( "github.com/shopware/shopware-cli/internal/packagist" ) -// GenerateComposeFile generates compose.yaml content -// based on the packages present in the given ComposerLock. func GenerateComposeFile(lock *packagist.ComposerLock) ([]byte, error) { hasAMQP := lock.GetPackage("symfony/amqp-messenger") != nil hasElasticsearch := lock.GetPackage("shopware/elasticsearch") != nil @@ -30,7 +28,6 @@ func GenerateComposeFile(lock *packagist.ComposerLock) ([]byte, error) { return append([]byte(header), out...), nil } -// WriteComposeFile reads the composer.lock in projectFolder and writes compose.yaml. func WriteComposeFile(projectFolder string) error { lock, err := packagist.ReadComposerLock(filepath.Join(projectFolder, "composer.lock")) if err != nil { diff --git a/internal/executor/executor.go b/internal/executor/executor.go index 65621c71..a3b588e2 100644 --- a/internal/executor/executor.go +++ b/internal/executor/executor.go @@ -9,20 +9,10 @@ import ( // Executor abstracts command execution across different environment types. type Executor interface { - // ConsoleCommand returns an exec.Cmd for running bin/console. ConsoleCommand(ctx context.Context, args ...string) *exec.Cmd - - // ComposerCommand returns an exec.Cmd for running composer. ComposerCommand(ctx context.Context, args ...string) *exec.Cmd - - // PHPCommand returns an exec.Cmd for running php. PHPCommand(ctx context.Context, args ...string) *exec.Cmd - - // Type returns the executor type name (e.g. "local", "docker"). Type() string - - // WithEnv returns a copy of the executor with extra environment variables set on all commands. - // For Docker, they are injected as -e flags; for local/symfony, they are set on cmd.Env. WithEnv(env map[string]string) Executor } diff --git a/internal/executor/symfony_cli.go b/internal/executor/symfony_cli.go index 02b414b7..78f1dcf1 100644 --- a/internal/executor/symfony_cli.go +++ b/internal/executor/symfony_cli.go @@ -7,7 +7,6 @@ import ( // SymfonyCLIExecutor runs commands through the Symfony CLI binary. type SymfonyCLIExecutor struct { - // Path to the symfony binary. BinaryPath string env map[string]string } diff --git a/internal/extension/project.go b/internal/extension/project.go index 4f2ccf60..59aa009b 100644 --- a/internal/extension/project.go +++ b/internal/extension/project.go @@ -178,7 +178,6 @@ func FindAssetSourcesOfProject(ctx context.Context, project string, shopCfg *sho return sources } -// ConsoleCommandFunc is a function that creates a console command. 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) { diff --git a/internal/shop/compatibility_date.go b/internal/shop/compatibility_date.go index b58733f7..8dfbc46d 100644 --- a/internal/shop/compatibility_date.go +++ b/internal/shop/compatibility_date.go @@ -3,7 +3,6 @@ package shop import "fmt" const ( - // DevMode breaks CompatibilityDevMode = "2026-03-01" ) diff --git a/internal/shop/config.go b/internal/shop/config.go index 6b242e1d..ab2263ce 100644 --- a/internal/shop/config.go +++ b/internal/shop/config.go @@ -18,13 +18,9 @@ import ( "github.com/shopware/shopware-cli/logging" ) -// EnvironmentConfig represents a single named environment. type EnvironmentConfig struct { - // Type of environment: local or docker - Type string `yaml:"type" jsonschema:"enum=local,enum=docker"` - // URL of the Shopware instance for this environment - URL string `yaml:"url,omitempty"` - // Admin API credentials for this environment + Type string `yaml:"type" jsonschema:"enum=local,enum=docker"` + URL string `yaml:"url,omitempty"` AdminApi *ConfigAdminApi `yaml:"admin_api,omitempty"` } @@ -49,7 +45,6 @@ type Config struct { foundConfig bool } -// ResolveEnvironment returns the environment config for the given name. func (c *Config) ResolveEnvironment(name string) (*EnvironmentConfig, error) { if name != "" { env, ok := c.Environments[name] @@ -422,7 +417,6 @@ type ConfigImageProxy struct { URL string `yaml:"url,omitempty"` } -// NewConfig creates a new Config with the current compatibility date and a local environment. func NewConfig() *Config { return &Config{ CompatibilityDate: compatibility.TodayDate(), @@ -439,7 +433,6 @@ func NewConfig() *Config { } } -// WriteConfig marshals the config to YAML and writes it to dir/.shopware-project.yaml. func WriteConfig(cfg *Config, dir string) error { data, err := yaml.Marshal(cfg) if err != nil { diff --git a/internal/shop/console.go b/internal/shop/console.go index c131e138..74dbfd11 100644 --- a/internal/shop/console.go +++ b/internal/shop/console.go @@ -36,8 +36,7 @@ func (c ConsoleResponse) GetCommandOptions(name string) []string { return nil } -// ConsoleCommandFunc is a function that creates a console command. -// This avoids a circular dependency between shop and executor packages. +// 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) { From d7f336f4091a8ad3bb1797d52b76ea809f9bc4a7 Mon Sep 17 00:00:00 2001 From: Soner Sayakci Date: Tue, 10 Mar 2026 11:43:54 +0100 Subject: [PATCH 17/19] feat: remove unnecessary whitespace in project_create.go --- cmd/project/project_create.go | 1 - 1 file changed, 1 deletion(-) diff --git a/cmd/project/project_create.go b/cmd/project/project_create.go index afd48ee3..404a6032 100644 --- a/cmd/project/project_create.go +++ b/cmd/project/project_create.go @@ -712,4 +712,3 @@ func init() { projectCreateCmd.PersistentFlags().String("deployment", "", "Deployment method: none, deployer, platformsh, shopware-paas") projectCreateCmd.PersistentFlags().String("ci", "", "CI/CD system: none, github, gitlab") } - From e4f605a312f5a6f4954bfc05c41312542591a69a Mon Sep 17 00:00:00 2001 From: Soner Sayakci Date: Wed, 11 Mar 2026 06:16:57 +0100 Subject: [PATCH 18/19] feat: add shopware PaaS application template and update command flags --- cmd/project/project_create.go | 19 +++++++------------ .../static/shopware-paas-application.yaml | 6 ++++++ 2 files changed, 13 insertions(+), 12 deletions(-) create mode 100644 cmd/project/static/shopware-paas-application.yaml diff --git a/cmd/project/project_create.go b/cmd/project/project_create.go index 404a6032..a27ec93c 100644 --- a/cmd/project/project_create.go +++ b/cmd/project/project_create.go @@ -40,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{ @@ -69,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") @@ -78,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 } @@ -541,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 } } @@ -704,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") 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" From 20525ead70a25fc20f1292ba60b7411c4b29b08e Mon Sep 17 00:00:00 2001 From: Soner Sayakci Date: Thu, 12 Mar 2026 08:56:59 +0100 Subject: [PATCH 19/19] feat: make overlay buffer dynamic based on terminal height - Calculate max overlay lines based on terminal height instead of fixed 10 - Change overlay sizing from percentage (80%) to absolute dimensions - Add overlayMaxLines() function for responsive log buffer --- internal/devtui/model.go | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/internal/devtui/model.go b/internal/devtui/model.go index 17ba73cf..283b030d 100644 --- a/internal/devtui/model.go +++ b/internal/devtui/model.go @@ -218,8 +218,9 @@ func (m Model) updateLifecycle(msg tea.Msg) (tea.Model, tea.Cmd) { case dockerOutputLineMsg: m.overlayLines = append(m.overlayLines, string(msg)) - if len(m.overlayLines) > 10 { - m.overlayLines = m.overlayLines[len(m.overlayLines)-10:] + maxLines := m.overlayMaxLines() + if len(m.overlayLines) > maxLines { + m.overlayLines = m.overlayLines[len(m.overlayLines)-maxLines:] } return m, m.readNextDockerOutput() @@ -523,7 +524,7 @@ func (m Model) renderOverlay() string { style := overlayStyle if m.overlay == overlayStarting || m.overlay == overlayStopping || m.overlay == overlayInstalling { if m.width > 0 && m.height > 0 { - style = style.Width(m.width * 80 / 100).Height(m.height * 80 / 100) + style = style.Width(m.width - 2).Height(m.height - 2) } } @@ -543,6 +544,20 @@ func (m Model) renderOverlay() string { 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: