From 67e6f1e65e7178a5f2beee5bcade95db5c54c4f1 Mon Sep 17 00:00:00 2001 From: Benny Poensgen Date: Mon, 18 May 2026 16:46:58 +0200 Subject: [PATCH 01/38] feat(mjml): allow mj-include by opting in via build config MJML 5 ignores by default for security reasons and sandboxes resolved include paths to the file's own parent directory, which silently breaks templates that share partials from a sibling _includes folder. Expose two new project-config knobs: build: mjml: enabled: true search_paths: - custom/static-plugins allow_includes: true For the common layout, where partials live somewhere inside an existing search_path tree, allow_includes: true is enough: each search_path is automatically added to the mj-include allowlist for files compiled within it, so from a sibling mail-type dir just works. For partials living outside any search_path, include_paths is an escape hatch: build: mjml: allow_includes: true include_paths: - shared/email/_includes Relative include_paths entries are resolved against the project root, absolute entries are used as-is. Any value in include_paths implies allow_includes. Under the hood, allow_includes forwards --config.allowIncludes=true to mjml, and the merged allowlist (search_path root + resolved extras) is JSON-encoded and forwarded as --config.includePath=[...]. Compile() and ProcessDirectory() now take a CompileOptions struct. mjml.NewCompileOptions() builds per-search-path options with the auto search_path allowlisting. ConfigBuildMJML.ResolveIncludePaths() mirrors GetPaths semantics for the user-supplied extras. Schema regenerated. Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/project/ci.go | 5 +- internal/mjml/compiler.go | 60 +++++++++- internal/mjml/compiler_test.go | 200 ++++++++++++++++++++++++++++++- internal/shop/config.go | 32 +++++ internal/shop/config_schema.json | 11 ++ internal/shop/config_test.go | 37 ++++++ 6 files changed, 334 insertions(+), 11 deletions(-) diff --git a/cmd/project/ci.go b/cmd/project/ci.go index cfb5da23..98b3e7df 100644 --- a/cmd/project/ci.go +++ b/cmd/project/ci.go @@ -240,10 +240,13 @@ var projectCI = &cobra.Command{ if shopCfg.Build.IsMjmlEnabled() { mjmlSection := ci.Default.Section(cmd.Context(), "Compiling MJML templates") + extraIncludePaths := shopCfg.Build.MJML.ResolveIncludePaths(args[0]) + for _, searchPath := range shopCfg.Build.MJML.GetPaths(args[0]) { if _, err := os.Stat(searchPath); !os.IsNotExist(err) { logging.FromContext(cmd.Context()).Infof("Processing MJML files in: %s", searchPath) - if err := mjml.ProcessDirectory(cmd.Context(), searchPath); err != nil { + mjmlOpts := mjml.NewCompileOptions(searchPath, shopCfg.Build.MJML.AllowIncludes, extraIncludePaths) + if err := mjml.ProcessDirectory(cmd.Context(), searchPath, mjmlOpts); err != nil { logging.FromContext(cmd.Context()).Warnf("MJML compilation had issues in %s: %v", searchPath, err) } } else { diff --git a/internal/mjml/compiler.go b/internal/mjml/compiler.go index 04bda390..4062af03 100644 --- a/internal/mjml/compiler.go +++ b/internal/mjml/compiler.go @@ -3,6 +3,7 @@ package mjml import ( "bytes" "context" + "encoding/json" "fmt" "os" "os/exec" @@ -12,9 +13,58 @@ import ( "github.com/shopware/shopware-cli/logging" ) -func Compile(ctx context.Context, mjmlPath string) (string, error) { - // Run mjml command with the file - cmd := exec.CommandContext(ctx, "npx", "mjml", mjmlPath, "--stdout") +// CompileOptions controls how the underlying mjml CLI is invoked. +type CompileOptions struct { + // When true, passes --config.allowIncludes=true to mjml so that + // directives are processed. MJML 5 ignores them by default. + AllowIncludes bool + // Allowlist of directories that mj-include is permitted to read from, on + // top of the directory of the file being compiled. Values are forwarded to + // mjml verbatim as --config.includePath=[...]; relative paths are resolved + // by mjml against its own working directory, so callers should pass + // absolute paths. Setting any value implies AllowIncludes. + IncludePaths []string +} + +// NewCompileOptions builds CompileOptions for files compiled inside the given +// search-path root. When mj-include is in use, searchPathRoot is automatically +// allowlisted so that templates can include any partial inside the same search +// path (e.g. a sibling _includes/ folder) without having to enumerate each +// directory. extraIncludePaths are appended after the search-path root. +// +// When both allowIncludes is false and extraIncludePaths is empty, the +// returned CompileOptions is the zero value and no include flags are +// forwarded to mjml. +func NewCompileOptions(searchPathRoot string, allowIncludes bool, extraIncludePaths []string) CompileOptions { + if !allowIncludes && len(extraIncludePaths) == 0 { + return CompileOptions{} + } + + paths := make([]string, 0, 1+len(extraIncludePaths)) + if searchPathRoot != "" { + paths = append(paths, searchPathRoot) + } + paths = append(paths, extraIncludePaths...) + + return CompileOptions{AllowIncludes: true, IncludePaths: paths} +} + +func Compile(ctx context.Context, mjmlPath string, opts CompileOptions) (string, error) { + args := []string{"mjml", mjmlPath, "--stdout"} + + if opts.AllowIncludes || len(opts.IncludePaths) > 0 { + args = append(args, "--config.allowIncludes=true") + } + + if len(opts.IncludePaths) > 0 { + encoded, err := json.Marshal(opts.IncludePaths) + if err != nil { + return "", fmt.Errorf("failed to encode mjml include paths: %w", err) + } + args = append(args, fmt.Sprintf("--config.includePath=%s", string(encoded))) + } + + cmd := exec.CommandContext(ctx, "npx", args...) var stdout, stderr bytes.Buffer cmd.Stdout = &stdout @@ -32,7 +82,7 @@ func Compile(ctx context.Context, mjmlPath string) (string, error) { } // ProcessDirectory walks through a directory and compiles all MJML files -func ProcessDirectory(ctx context.Context, rootDir string) error { +func ProcessDirectory(ctx context.Context, rootDir string, opts CompileOptions) error { var processedCount int var errorCount int var skippedCount int @@ -69,7 +119,7 @@ func ProcessDirectory(ctx context.Context, rootDir string) error { logging.FromContext(ctx).Infof("Processing MJML file: %s", relPath) // Always compile to check for errors - compiled, err := Compile(ctx, path) + compiled, err := Compile(ctx, path, opts) if err != nil { errorCount++ logging.FromContext(ctx).Errorf("Failed to compile MJML %s: %v", relPath, err) diff --git a/internal/mjml/compiler_test.go b/internal/mjml/compiler_test.go index fba6e7e9..d851ab94 100644 --- a/internal/mjml/compiler_test.go +++ b/internal/mjml/compiler_test.go @@ -63,6 +63,18 @@ func newMockExec(t *testing.T, scriptContent string) { t.Setenv("PATH", fmt.Sprintf("%s%c%s", binDir, filepath.ListSeparator, originalPath)) } +// newArgRecordingMockExec creates a mock npx that writes its full argv to a +// file so tests can assert which CLI flags Compile forwarded. +func newArgRecordingMockExec(t *testing.T, argsFile string) { + t.Helper() + script := fmt.Sprintf(`#!/bin/sh +printf '%%s\n' "$@" > %q +echo "

Hello

" +exit 0 +`, argsFile) + newMockExec(t, script) +} + func TestCompile(t *testing.T) { ctx := t.Context() @@ -82,7 +94,7 @@ exit 0` } }() - output, err := Compile(ctx, tmpFile.Name()) + output, err := Compile(ctx, tmpFile.Name(), CompileOptions{}) if err != nil { t.Errorf("expected no error, got %v", err) } @@ -108,7 +120,7 @@ exit 1` } }() - _, err = Compile(ctx, tmpFile.Name()) + _, err = Compile(ctx, tmpFile.Name(), CompileOptions{}) if err == nil { t.Error("expected an error, got nil") } @@ -137,7 +149,7 @@ exit 0` } }() - output, err := Compile(ctx, tmpFile.Name()) + output, err := Compile(ctx, tmpFile.Name(), CompileOptions{}) if err != nil { t.Errorf("expected no error, got %v", err) } @@ -146,6 +158,76 @@ exit 0` t.Errorf("unexpected output: got %q", output) } }) + + t.Run("default options pass no include flags", func(t *testing.T) { + argsFile := filepath.Join(t.TempDir(), "args.txt") + newArgRecordingMockExec(t, argsFile) + + tmpFile, err := os.CreateTemp(t.TempDir(), "test.mjml") + if err != nil { + t.Fatalf("failed to create temp file: %v", err) + } + defer os.Remove(tmpFile.Name()) + + if _, err := Compile(ctx, tmpFile.Name(), CompileOptions{}); err != nil { + t.Fatalf("compile failed: %v", err) + } + + args := readArgs(t, argsFile) + if containsArg(args, "--config.allowIncludes=true") { + t.Errorf("did not expect --config.allowIncludes=true with default options, got %v", args) + } + for _, a := range args { + if strings.HasPrefix(a, "--config.includePath=") { + t.Errorf("did not expect --config.includePath flag with default options, got %q", a) + } + } + }) + + t.Run("allow_includes forwards --config.allowIncludes=true", func(t *testing.T) { + argsFile := filepath.Join(t.TempDir(), "args.txt") + newArgRecordingMockExec(t, argsFile) + + tmpFile, err := os.CreateTemp(t.TempDir(), "test.mjml") + if err != nil { + t.Fatalf("failed to create temp file: %v", err) + } + defer os.Remove(tmpFile.Name()) + + if _, err := Compile(ctx, tmpFile.Name(), CompileOptions{AllowIncludes: true}); err != nil { + t.Fatalf("compile failed: %v", err) + } + + args := readArgs(t, argsFile) + if !containsArg(args, "--config.allowIncludes=true") { + t.Errorf("expected --config.allowIncludes=true in args, got %v", args) + } + }) + + t.Run("include_paths forwards JSON-encoded array and implies allow_includes", func(t *testing.T) { + argsFile := filepath.Join(t.TempDir(), "args.txt") + newArgRecordingMockExec(t, argsFile) + + tmpFile, err := os.CreateTemp(t.TempDir(), "test.mjml") + if err != nil { + t.Fatalf("failed to create temp file: %v", err) + } + defer os.Remove(tmpFile.Name()) + + opts := CompileOptions{IncludePaths: []string{"../_includes", "../_shared"}} + if _, err := Compile(ctx, tmpFile.Name(), opts); err != nil { + t.Fatalf("compile failed: %v", err) + } + + args := readArgs(t, argsFile) + if !containsArg(args, "--config.allowIncludes=true") { + t.Errorf("expected include_paths to imply --config.allowIncludes=true, got %v", args) + } + want := `--config.includePath=["../_includes","../_shared"]` + if !containsArg(args, want) { + t.Errorf("expected %q in args, got %v", want, args) + } + }) } func TestProcessDirectory(t *testing.T) { @@ -194,7 +276,7 @@ exit 0` t.Fatalf("failed to write test file: %v", err) } - err := ProcessDirectory(ctx, tmpDir) + err := ProcessDirectory(ctx, tmpDir, CompileOptions{}) if err != nil { t.Fatalf("ProcessDirectory failed: %v", err) } @@ -254,7 +336,7 @@ exit 1` t.Fatalf("failed to write test file: %v", err) } - err := ProcessDirectory(ctx, tmpDir) + err := ProcessDirectory(ctx, tmpDir, CompileOptions{}) if err == nil { t.Fatal("expected ProcessDirectory to return an error, but it didn't") } @@ -267,4 +349,112 @@ exit 1` t.Error("original mjml file should still exist after compilation failure") } }) + + t.Run("forwards include options to each file", func(t *testing.T) { + argsFile := filepath.Join(t.TempDir(), "args.txt") + newArgRecordingMockExec(t, argsFile) + + tmpDir := t.TempDir() + mailDir := filepath.Join(tmpDir, "mail", "welcome") + if err := os.MkdirAll(mailDir, 0755); err != nil { + t.Fatalf("failed to create test dir: %v", err) + } + mjmlFile := filepath.Join(mailDir, "html.mjml") + if err := os.WriteFile(mjmlFile, []byte(""), 0644); err != nil { + t.Fatalf("failed to write test file: %v", err) + } + + opts := CompileOptions{AllowIncludes: true, IncludePaths: []string{"../_includes"}} + if err := ProcessDirectory(ctx, tmpDir, opts); err != nil { + t.Fatalf("ProcessDirectory failed: %v", err) + } + + args := readArgs(t, argsFile) + if !containsArg(args, "--config.allowIncludes=true") { + t.Errorf("expected --config.allowIncludes=true, got %v", args) + } + if !containsArg(args, `--config.includePath=["../_includes"]`) { + t.Errorf("expected JSON-encoded includePath, got %v", args) + } + }) +} + +func readArgs(t *testing.T, path string) []string { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("failed to read args file: %v", err) + } + return strings.Split(strings.TrimRight(string(data), "\n"), "\n") +} + +func containsArg(args []string, want string) bool { + for _, a := range args { + if a == want { + return true + } + } + return false +} + +func TestNewCompileOptions(t *testing.T) { + t.Run("returns zero value when nothing is opted in", func(t *testing.T) { + opts := NewCompileOptions("/abs/search", false, nil) + if opts.AllowIncludes { + t.Errorf("expected AllowIncludes=false, got true") + } + if len(opts.IncludePaths) != 0 { + t.Errorf("expected no IncludePaths, got %v", opts.IncludePaths) + } + }) + + t.Run("allow_includes auto-allowlists the search path root", func(t *testing.T) { + opts := NewCompileOptions("/abs/search", true, nil) + if !opts.AllowIncludes { + t.Errorf("expected AllowIncludes=true") + } + want := []string{"/abs/search"} + if !slicesEqual(opts.IncludePaths, want) { + t.Errorf("expected IncludePaths=%v, got %v", want, opts.IncludePaths) + } + }) + + t.Run("extra include paths are appended after the search path root", func(t *testing.T) { + opts := NewCompileOptions("/abs/search", true, []string{"/abs/other", "/abs/shared"}) + want := []string{"/abs/search", "/abs/other", "/abs/shared"} + if !slicesEqual(opts.IncludePaths, want) { + t.Errorf("expected IncludePaths=%v, got %v", want, opts.IncludePaths) + } + }) + + t.Run("extra include paths alone imply allow_includes", func(t *testing.T) { + opts := NewCompileOptions("/abs/search", false, []string{"/abs/shared"}) + if !opts.AllowIncludes { + t.Errorf("expected extras to imply AllowIncludes=true") + } + want := []string{"/abs/search", "/abs/shared"} + if !slicesEqual(opts.IncludePaths, want) { + t.Errorf("expected IncludePaths=%v, got %v", want, opts.IncludePaths) + } + }) + + t.Run("empty search path root is skipped", func(t *testing.T) { + opts := NewCompileOptions("", true, []string{"/abs/shared"}) + want := []string{"/abs/shared"} + if !slicesEqual(opts.IncludePaths, want) { + t.Errorf("expected IncludePaths=%v, got %v", want, opts.IncludePaths) + } + }) +} + +func slicesEqual(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true } diff --git a/internal/shop/config.go b/internal/shop/config.go index 0c9a4b24..bdc43607 100644 --- a/internal/shop/config.go +++ b/internal/shop/config.go @@ -130,6 +130,19 @@ type ConfigBuildMJML struct { Enabled bool `yaml:"enabled,omitempty"` // Directories to search for MJML files SearchPaths []string `yaml:"search_paths,omitempty"` + // When enabled, mj-include directives in MJML templates are processed. + // MJML 5 ignores mj-include by default for security reasons; set this to + // true to opt back in. Each search_path is automatically added to the + // mj-include allowlist for files compiled inside it, so templates can + // include siblings under the same search_path (e.g. a shared _includes/ + // folder) without further configuration. + AllowIncludes bool `yaml:"allow_includes,omitempty"` + // Extra directories outside any search_path that mj-include is allowed to + // read from. Relative paths are resolved against the project root. + // Absolute paths are used as-is. Most projects do not need this — set it + // only when partials live outside the search_path tree. Implies + // allow_includes. + IncludePaths []string `yaml:"include_paths,omitempty"` } func (c ConfigBuildMJML) GetPaths(projectRoot string) []string { @@ -152,6 +165,25 @@ func (c ConfigBuildMJML) GetPaths(projectRoot string) []string { } } +// ResolveIncludePaths returns IncludePaths as absolute paths. Relative entries +// are resolved against projectRoot; absolute entries are returned unchanged. +// Returns nil when no paths are configured. +func (c ConfigBuildMJML) ResolveIncludePaths(projectRoot string) []string { + if len(c.IncludePaths) == 0 { + return nil + } + + resolved := make([]string, len(c.IncludePaths)) + for i, p := range c.IncludePaths { + if filepath.IsAbs(p) { + resolved[i] = p + } else { + resolved[i] = filepath.Join(projectRoot, p) + } + } + return resolved +} + type ConfigAdminApi struct { // Client ID of integration ClientId string `yaml:"client_id,omitempty"` diff --git a/internal/shop/config_schema.json b/internal/shop/config_schema.json index 60cceff5..99fb13c1 100644 --- a/internal/shop/config_schema.json +++ b/internal/shop/config_schema.json @@ -232,6 +232,17 @@ }, "type": "array", "description": "Directories to search for MJML files" + }, + "allow_includes": { + "type": "boolean", + "description": "When enabled, mj-include directives in MJML templates are processed.\nMJML 5 ignores mj-include by default for security reasons; set this to\ntrue to opt back in. Each search_path is automatically added to the\nmj-include allowlist for files compiled inside it, so templates can\ninclude siblings under the same search_path (e.g. a shared _includes/\nfolder) without further configuration." + }, + "include_paths": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Extra directories outside any search_path that mj-include is allowed to\nread from. Relative paths are resolved against the project root.\nAbsolute paths are used as-is. Most projects do not need this — set it\nonly when partials live outside the search_path tree. Implies\nallow_includes." } }, "additionalProperties": false, diff --git a/internal/shop/config_test.go b/internal/shop/config_test.go index 7de06e9b..cf89b332 100644 --- a/internal/shop/config_test.go +++ b/internal/shop/config_test.go @@ -505,3 +505,40 @@ func TestConfigDump_EnableClean(t *testing.T) { assert.Equal(t, "cart", config.NoData[3]) }) } + +func TestConfigBuildMJMLResolveIncludePaths(t *testing.T) { + projectRoot := filepath.FromSlash("/abs/project") + + t.Run("returns nil when no paths configured", func(t *testing.T) { + c := ConfigBuildMJML{} + assert.Nil(t, c.ResolveIncludePaths(projectRoot)) + }) + + t.Run("relative paths are joined with project root", func(t *testing.T) { + c := ConfigBuildMJML{IncludePaths: []string{ + "shared/email", + filepath.FromSlash("custom/static-plugins/Other/Resources/views/email/_includes"), + }} + got := c.ResolveIncludePaths(projectRoot) + want := []string{ + filepath.Join(projectRoot, "shared/email"), + filepath.Join(projectRoot, "custom/static-plugins/Other/Resources/views/email/_includes"), + } + assert.Equal(t, want, got) + }) + + t.Run("absolute paths are returned unchanged", func(t *testing.T) { + abs := filepath.FromSlash("/somewhere/else/_includes") + c := ConfigBuildMJML{IncludePaths: []string{abs}} + got := c.ResolveIncludePaths(projectRoot) + assert.Equal(t, []string{abs}, got) + }) + + t.Run("mixed entries are resolved independently", func(t *testing.T) { + abs := filepath.FromSlash("/somewhere/else/_includes") + c := ConfigBuildMJML{IncludePaths: []string{"shared/email", abs}} + got := c.ResolveIncludePaths(projectRoot) + want := []string{filepath.Join(projectRoot, "shared/email"), abs} + assert.Equal(t, want, got) + }) +} From 649678f95516fb2fc1f9427b7be9945563c20041 Mon Sep 17 00:00:00 2001 From: Benny Poensgen Date: Mon, 18 May 2026 19:33:30 +0200 Subject: [PATCH 02/38] test(mjml): drop redundant defer os.Remove on t.TempDir files t.TempDir() cleans up its directory automatically, so the explicit os.Remove was a no-op that also tripped golangci-lint's errcheck. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/mjml/compiler_test.go | 3 --- 1 file changed, 3 deletions(-) diff --git a/internal/mjml/compiler_test.go b/internal/mjml/compiler_test.go index d851ab94..b5b2d911 100644 --- a/internal/mjml/compiler_test.go +++ b/internal/mjml/compiler_test.go @@ -167,7 +167,6 @@ exit 0` if err != nil { t.Fatalf("failed to create temp file: %v", err) } - defer os.Remove(tmpFile.Name()) if _, err := Compile(ctx, tmpFile.Name(), CompileOptions{}); err != nil { t.Fatalf("compile failed: %v", err) @@ -192,7 +191,6 @@ exit 0` if err != nil { t.Fatalf("failed to create temp file: %v", err) } - defer os.Remove(tmpFile.Name()) if _, err := Compile(ctx, tmpFile.Name(), CompileOptions{AllowIncludes: true}); err != nil { t.Fatalf("compile failed: %v", err) @@ -212,7 +210,6 @@ exit 0` if err != nil { t.Fatalf("failed to create temp file: %v", err) } - defer os.Remove(tmpFile.Name()) opts := CompileOptions{IncludePaths: []string{"../_includes", "../_shared"}} if _, err := Compile(ctx, tmpFile.Name(), opts); err != nil { From bfc1531f7e0698b6310076883c7d4edc85b780df Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 25 May 2026 20:43:32 +0000 Subject: [PATCH 03/38] fix(deps): bump the all group with 2 updates Bumps the all group with 2 updates: [golang.org/x/image](https://github.com/golang/image) and [golang.org/x/net](https://github.com/golang/net). Updates `golang.org/x/image` from 0.40.0 to 0.41.0 - [Commits](https://github.com/golang/image/compare/v0.40.0...v0.41.0) Updates `golang.org/x/net` from 0.54.0 to 0.55.0 - [Commits](https://github.com/golang/net/compare/v0.54.0...v0.55.0) --- updated-dependencies: - dependency-name: golang.org/x/image dependency-version: 0.41.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: all - dependency-name: golang.org/x/net dependency-version: 0.55.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: all ... Signed-off-by: dependabot[bot] --- go.mod | 6 +++--- go.sum | 12 ++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/go.mod b/go.mod index 0693fb4a..7b89f0c6 100644 --- a/go.mod +++ b/go.mod @@ -30,7 +30,7 @@ require ( github.com/yuin/goldmark v1.8.2 github.com/zeebo/xxh3 v1.1.0 go.uber.org/zap v1.28.0 - golang.org/x/image v0.40.0 + golang.org/x/image v0.41.0 golang.org/x/text v0.37.0 golang.org/x/time v0.15.0 gopkg.in/yaml.v3 v3.0.1 @@ -87,9 +87,9 @@ require ( github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/sjson v1.2.5 // indirect go.uber.org/multierr v1.11.0 // indirect - golang.org/x/net v0.54.0 + golang.org/x/net v0.55.0 golang.org/x/oauth2 v0.36.0 golang.org/x/sync v0.20.0 - golang.org/x/sys v0.44.0 // indirect + golang.org/x/sys v0.45.0 // indirect google.golang.org/protobuf v1.36.11 // indirect ) diff --git a/go.sum b/go.sum index dd57bfde..31e42872 100644 --- a/go.sum +++ b/go.sum @@ -202,17 +202,17 @@ golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= -golang.org/x/image v0.40.0 h1:Tw4GyDXMo+daZN1znreBRC3VayR1aLFUyUEOLUdW1a8= -golang.org/x/image v0.40.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA= -golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= -golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= +golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo= +golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= -golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= From 9e5bb41d47570c3af89a9364914164812eea1188 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 25 May 2026 20:45:13 +0000 Subject: [PATCH 04/38] fix(deps): bump the all group with 8 updates Bumps the all group with 8 updates: | Package | From | To | | --- | --- | --- | | [docker/github-builder/.github/workflows/build.yml](https://github.com/docker/github-builder) | `1.8.0` | `1.9.0` | | [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) | `4.0.0` | `4.1.0` | | [docker/login-action](https://github.com/docker/login-action) | `4.1.0` | `4.2.0` | | [docker/metadata-action](https://github.com/docker/metadata-action) | `6.0.0` | `6.1.0` | | [docker/build-push-action](https://github.com/docker/build-push-action) | `7.1.0` | `7.2.0` | | [step-security/harden-runner](https://github.com/step-security/harden-runner) | `2.19.3` | `2.19.4` | | [golangci/golangci-lint-action](https://github.com/golangci/golangci-lint-action) | `9.2.0` | `9.2.1` | | [goreleaser/goreleaser-action](https://github.com/goreleaser/goreleaser-action) | `7.2.1` | `7.2.2` | Updates `docker/github-builder/.github/workflows/build.yml` from 1.8.0 to 1.9.0 - [Release notes](https://github.com/docker/github-builder/releases) - [Commits](https://github.com/docker/github-builder/compare/c2782c55efa56a01b9c30021db8f5ec3993228a3...073833262a23a17675c95c4541ab063b7646756b) Updates `docker/setup-buildx-action` from 4.0.0 to 4.1.0 - [Release notes](https://github.com/docker/setup-buildx-action/releases) - [Commits](https://github.com/docker/setup-buildx-action/compare/4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd...d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5) Updates `docker/login-action` from 4.1.0 to 4.2.0 - [Release notes](https://github.com/docker/login-action/releases) - [Commits](https://github.com/docker/login-action/compare/4907a6ddec9925e35a0a9e82d7399ccc52663121...650006c6eb7dba73a995cc03b0b2d7f5ca915bee) Updates `docker/metadata-action` from 6.0.0 to 6.1.0 - [Release notes](https://github.com/docker/metadata-action/releases) - [Commits](https://github.com/docker/metadata-action/compare/030e881283bb7a6894de51c315a6bfe6a94e05cf...80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9) Updates `docker/build-push-action` from 7.1.0 to 7.2.0 - [Release notes](https://github.com/docker/build-push-action/releases) - [Commits](https://github.com/docker/build-push-action/compare/bcafcacb16a39f128d818304e6c9c0c18556b85f...f9f3042f7e2789586610d6e8b85c8f03e5195baf) Updates `step-security/harden-runner` from 2.19.3 to 2.19.4 - [Release notes](https://github.com/step-security/harden-runner/releases) - [Commits](https://github.com/step-security/harden-runner/compare/ab7a9404c0f3da075243ca237b5fac12c98deaa5...9af89fc71515a100421586dfdb3dc9c984fbf411) Updates `golangci/golangci-lint-action` from 9.2.0 to 9.2.1 - [Release notes](https://github.com/golangci/golangci-lint-action/releases) - [Commits](https://github.com/golangci/golangci-lint-action/compare/1e7e51e771db61008b38414a730f564565cf7c20...82606bf257cbaff209d206a39f5134f0cfbfd2ee) Updates `goreleaser/goreleaser-action` from 7.2.1 to 7.2.2 - [Release notes](https://github.com/goreleaser/goreleaser-action/releases) - [Commits](https://github.com/goreleaser/goreleaser-action/compare/1a80836c5c9d9e5755a25cb59ec6f45a3b5f41a8...5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89) --- updated-dependencies: - dependency-name: docker/github-builder/.github/workflows/build.yml dependency-version: 1.9.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: all - dependency-name: docker/setup-buildx-action dependency-version: 4.1.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: all - dependency-name: docker/login-action dependency-version: 4.2.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: all - dependency-name: docker/metadata-action dependency-version: 6.1.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: all - dependency-name: docker/build-push-action dependency-version: 7.2.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: all - dependency-name: step-security/harden-runner dependency-version: 2.19.4 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: all - dependency-name: golangci/golangci-lint-action dependency-version: 9.2.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: all - dependency-name: goreleaser/goreleaser-action dependency-version: 7.2.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: all ... Signed-off-by: dependabot[bot] --- .github/workflows/base-docker.yml | 2 +- .github/workflows/env-bridge-docker.yml | 8 ++++---- .github/workflows/go_test.yml | 2 +- .github/workflows/lint.yml | 4 ++-- .github/workflows/release.yml | 6 +++--- .github/workflows/schema-pages.yml | 2 +- .github/workflows/smoke-test.yml | 2 +- 7 files changed, 13 insertions(+), 13 deletions(-) diff --git a/.github/workflows/base-docker.yml b/.github/workflows/base-docker.yml index ef2cbd53..31d0a037 100644 --- a/.github/workflows/base-docker.yml +++ b/.github/workflows/base-docker.yml @@ -26,7 +26,7 @@ jobs: strategy: matrix: php-version: ["8.5", "8.4", "8.3", "8.2"] - uses: docker/github-builder/.github/workflows/build.yml@c2782c55efa56a01b9c30021db8f5ec3993228a3 # ratchet:docker/github-builder/.github/workflows/build.yml@v1 + uses: docker/github-builder/.github/workflows/build.yml@073833262a23a17675c95c4541ab063b7646756b # ratchet:docker/github-builder/.github/workflows/build.yml@v1 permissions: contents: read id-token: write diff --git a/.github/workflows/env-bridge-docker.yml b/.github/workflows/env-bridge-docker.yml index 35270c87..04204b5d 100644 --- a/.github/workflows/env-bridge-docker.yml +++ b/.github/workflows/env-bridge-docker.yml @@ -31,11 +31,11 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # ratchet:actions/checkout@v6 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # ratchet:docker/setup-buildx-action@v4.0.0 + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # ratchet:docker/setup-buildx-action@v4.1.0 - name: Login to GitHub Container Registry if: github.event_name != 'pull_request' - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # ratchet:docker/login-action@v4.1.0 + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # ratchet:docker/login-action@v4.2.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -43,7 +43,7 @@ jobs: - name: Docker meta id: meta - uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # ratchet:docker/metadata-action@v6.0.0 + uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # ratchet:docker/metadata-action@v6.1.0 with: images: ghcr.io/shopware/shopware-cli/env-bridge tags: | @@ -51,7 +51,7 @@ jobs: type=sha - name: Build and push - uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # ratchet:docker/build-push-action@v7.1.0 + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # ratchet:docker/build-push-action@v7.2.0 with: context: . file: Dockerfile.env-bridge diff --git a/.github/workflows/go_test.yml b/.github/workflows/go_test.yml index 582091a4..ed3481fa 100644 --- a/.github/workflows/go_test.yml +++ b/.github/workflows/go_test.yml @@ -22,7 +22,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # ratchet:step-security/harden-runner@v2.19.3 + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # ratchet:step-security/harden-runner@v2.19.4 with: egress-policy: block allowed-endpoints: > diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 2c9cd1e4..87f96d68 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -25,7 +25,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # ratchet:step-security/harden-runner@v2.19.3 + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # ratchet:step-security/harden-runner@v2.19.4 with: egress-policy: block disable-sudo: true @@ -49,7 +49,7 @@ jobs: cache: true - name: golangci-lint - uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # ratchet:golangci/golangci-lint-action@v9 + uses: golangci/golangci-lint-action@82606bf257cbaff209d206a39f5134f0cfbfd2ee # ratchet:golangci/golangci-lint-action@v9 with: version: latest args: --timeout 4m diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b427be1c..07927e7d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,7 +18,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # ratchet:step-security/harden-runner@v2.19.3 + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # ratchet:step-security/harden-runner@v2.19.4 with: egress-policy: audit @@ -44,7 +44,7 @@ jobs: uses: DeterminateSystems/nix-installer-action@00199f951aeb9404028a6e4b95ad42546f73296a # ratchet:DeterminateSystems/nix-installer-action@main - name: Set up Docker Buildx - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # ratchet:docker/setup-buildx-action@v4.0.0 + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # ratchet:docker/setup-buildx-action@v4.1.0 - name: Install Cosign uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # ratchet:sigstore/cosign-installer@v4.1.2 @@ -76,7 +76,7 @@ jobs: run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin - name: Run GoReleaser - uses: goreleaser/goreleaser-action@1a80836c5c9d9e5755a25cb59ec6f45a3b5f41a8 # ratchet:goreleaser/goreleaser-action@v7.2.1 + uses: goreleaser/goreleaser-action@5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89 # ratchet:goreleaser/goreleaser-action@v7.2.2 with: distribution: goreleaser-pro version: '~> v2' diff --git a/.github/workflows/schema-pages.yml b/.github/workflows/schema-pages.yml index 3a432aef..6b86b066 100644 --- a/.github/workflows/schema-pages.yml +++ b/.github/workflows/schema-pages.yml @@ -26,7 +26,7 @@ jobs: url: ${{ steps.deployment.outputs.page_url }} steps: - name: Harden Runner - uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # ratchet:step-security/harden-runner@v2.19.3 + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # ratchet:step-security/harden-runner@v2.19.4 with: egress-policy: audit diff --git a/.github/workflows/smoke-test.yml b/.github/workflows/smoke-test.yml index 171410bc..201eeb53 100644 --- a/.github/workflows/smoke-test.yml +++ b/.github/workflows/smoke-test.yml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # ratchet:step-security/harden-runner@v2.19.3 + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # ratchet:step-security/harden-runner@v2.19.4 with: egress-policy: audit From eb556418c9fa7a61a897f1bf410142c428e42a56 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 25 May 2026 20:46:23 +0000 Subject: [PATCH 05/38] fix(deps): bump the all group in /internal/verifier/php with 2 updates Bumps the all group in /internal/verifier/php with 2 updates: [shopwarelabs/phpstan-shopware](https://github.com/shopwareLabs/phpstan-shopware) and [rector/rector](https://github.com/rectorphp/rector). Updates `shopwarelabs/phpstan-shopware` from 0.2.0 to 0.2.2 - [Release notes](https://github.com/shopwareLabs/phpstan-shopware/releases) - [Commits](https://github.com/shopwareLabs/phpstan-shopware/compare/0.2.0...0.2.2) Updates `rector/rector` from 2.4.3 to 2.4.4 - [Release notes](https://github.com/rectorphp/rector/releases) - [Commits](https://github.com/rectorphp/rector/compare/2.4.3...2.4.4) --- updated-dependencies: - dependency-name: shopwarelabs/phpstan-shopware dependency-version: 0.2.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: all - dependency-name: rector/rector dependency-version: 2.4.4 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: all ... Signed-off-by: dependabot[bot] --- internal/verifier/php/composer.lock | 40 ++++++++++++++--------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/internal/verifier/php/composer.lock b/internal/verifier/php/composer.lock index b467c1ba..5acc32f3 100644 --- a/internal/verifier/php/composer.lock +++ b/internal/verifier/php/composer.lock @@ -1531,16 +1531,16 @@ }, { "name": "rector/rector", - "version": "2.4.3", + "version": "2.4.4", "source": { "type": "git", "url": "https://github.com/rectorphp/rector.git", - "reference": "891824c6c59f02a56a5dd58ea8edc44e6c0ece29" + "reference": "4661c582a20f03df585d2e3fdc4af1b83d67a091" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/rectorphp/rector/zipball/891824c6c59f02a56a5dd58ea8edc44e6c0ece29", - "reference": "891824c6c59f02a56a5dd58ea8edc44e6c0ece29", + "url": "https://api.github.com/repos/rectorphp/rector/zipball/4661c582a20f03df585d2e3fdc4af1b83d67a091", + "reference": "4661c582a20f03df585d2e3fdc4af1b83d67a091", "shasum": "" }, "require": { @@ -1579,7 +1579,7 @@ ], "support": { "issues": "https://github.com/rectorphp/rector/issues", - "source": "https://github.com/rectorphp/rector/tree/2.4.3" + "source": "https://github.com/rectorphp/rector/tree/2.4.4" }, "funding": [ { @@ -1587,7 +1587,7 @@ "type": "github" } ], - "time": "2026-05-12T11:17:24+00:00" + "time": "2026-05-20T19:30:21+00:00" }, { "name": "sebastian/diff", @@ -1658,16 +1658,16 @@ }, { "name": "shopwarelabs/phpstan-shopware", - "version": "0.2.0", + "version": "0.2.2", "source": { "type": "git", - "url": "https://github.com/shopwareLabs/phpstan-shopware.git", - "reference": "c0310e9797cf5ce63fb55d53acc427f12c972c4f" + "url": "https://github.com/shopware/phpstan-shopware.git", + "reference": "e89c880017ff98457877f94599913fdc44453b78" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/shopwareLabs/phpstan-shopware/zipball/c0310e9797cf5ce63fb55d53acc427f12c972c4f", - "reference": "c0310e9797cf5ce63fb55d53acc427f12c972c4f", + "url": "https://api.github.com/repos/shopware/phpstan-shopware/zipball/e89c880017ff98457877f94599913fdc44453b78", + "reference": "e89c880017ff98457877f94599913fdc44453b78", "shasum": "" }, "require": { @@ -1700,10 +1700,10 @@ ], "description": "PhpStan Rules for Shopware", "support": { - "issues": "https://github.com/shopwareLabs/phpstan-shopware/issues", - "source": "https://github.com/shopwareLabs/phpstan-shopware/tree/0.2.0" + "issues": "https://github.com/shopware/phpstan-shopware/issues", + "source": "https://github.com/shopware/phpstan-shopware/tree/0.2.2" }, - "time": "2026-05-06T09:59:12+00:00" + "time": "2026-05-25T06:00:21+00:00" }, { "name": "spaze/phpstan-disallowed-calls", @@ -2317,16 +2317,16 @@ }, { "name": "symfony/polyfill-php84", - "version": "v1.37.0", + "version": "v1.38.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php84.git", - "reference": "88486db2c389b290bf87ff1de7ebc1e13e42bb06" + "reference": "a0e0aca0368801ec79f8791dea9a7c12af527c93" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/88486db2c389b290bf87ff1de7ebc1e13e42bb06", - "reference": "88486db2c389b290bf87ff1de7ebc1e13e42bb06", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/a0e0aca0368801ec79f8791dea9a7c12af527c93", + "reference": "a0e0aca0368801ec79f8791dea9a7c12af527c93", "shasum": "" }, "require": { @@ -2373,7 +2373,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php84/tree/v1.37.0" + "source": "https://github.com/symfony/polyfill-php84/tree/v1.38.0" }, "funding": [ { @@ -2393,7 +2393,7 @@ "type": "tidelift" } ], - "time": "2026-04-10T18:47:49+00:00" + "time": "2026-05-25T12:12:52+00:00" }, { "name": "symfony/process", From e0dbd7b8590a2334538edab9f19cbb62fcd360f3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 25 May 2026 20:46:55 +0000 Subject: [PATCH 06/38] fix(deps): bump the all group in /internal/verifier/js with 3 updates Bumps the all group in /internal/verifier/js with 3 updates: [stylelint](https://github.com/stylelint/stylelint), [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) and [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest). Updates `stylelint` from 17.11.1 to 17.12.0 - [Release notes](https://github.com/stylelint/stylelint/releases) - [Changelog](https://github.com/stylelint/stylelint/blob/main/CHANGELOG.md) - [Commits](https://github.com/stylelint/stylelint/compare/17.11.1...17.12.0) Updates `typescript-eslint` from 8.59.4 to 8.60.0 - [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases) - [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md) - [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.60.0/packages/typescript-eslint) Updates `vitest` from 4.1.6 to 4.1.7 - [Release notes](https://github.com/vitest-dev/vitest/releases) - [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md) - [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.7/packages/vitest) --- updated-dependencies: - dependency-name: stylelint dependency-version: 17.12.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: all - dependency-name: typescript-eslint dependency-version: 8.60.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: all - dependency-name: vitest dependency-version: 4.1.7 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: all ... Signed-off-by: dependabot[bot] --- internal/verifier/js/package-lock.json | 461 +++++++++++++------------ 1 file changed, 234 insertions(+), 227 deletions(-) diff --git a/internal/verifier/js/package-lock.json b/internal/verifier/js/package-lock.json index daefb9d0..4996a13c 100644 --- a/internal/verifier/js/package-lock.json +++ b/internal/verifier/js/package-lock.json @@ -55,16 +55,16 @@ } }, "node_modules/@cacheable/memory": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.0.7.tgz", - "integrity": "sha512-RbxnxAMf89Tp1dLhXMS7ceft/PGsDl1Ip7T20z5nZ+pwIAsQ1p2izPjVG69oCLv/jfQ7HDPHTWK0c9rcAWXN3A==", + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.0.9.tgz", + "integrity": "sha512-HdMx6DoGywB30vacDbBsITbIX4pgFqj1zsrV58jZBUw3klzkNoXhj7qOqAgledhxG7YZI5rBSJg7Zp8/VG0DuA==", "dev": true, "license": "MIT", "dependencies": { - "@cacheable/utils": "^2.3.3", - "@keyv/bigmap": "^1.3.0", - "hookified": "^1.14.0", - "keyv": "^5.5.5" + "@cacheable/utils": "^2.4.1", + "@keyv/bigmap": "^1.3.1", + "hookified": "^1.15.1", + "keyv": "^5.6.0" } }, "node_modules/@cacheable/memory/node_modules/@keyv/bigmap": { @@ -95,13 +95,13 @@ } }, "node_modules/@cacheable/utils": { - "version": "2.3.4", - "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.3.4.tgz", - "integrity": "sha512-knwKUJEYgIfwShABS1BX6JyJJTglAFcEU7EXqzTdiGCXur4voqkiJkdgZIQtWNFhynzDWERcTYv/sETMu3uJWA==", + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.4.1.tgz", + "integrity": "sha512-eiFgzCbIneyMlLOmNG4g9xzF7Hv3Mga4LjxjcSC/ues6VYq2+gUbQI8JqNuw/ZM8tJIeIaBGpswAsqV2V7ApgA==", "dev": true, "license": "MIT", "dependencies": { - "hashery": "^1.3.0", + "hashery": "^1.5.1", "keyv": "^5.6.0" } }, @@ -573,9 +573,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.129.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.129.0.tgz", - "integrity": "sha512-3oz8m3FGdr2nDXVqmFUw7jolKliC4MoyXYIG2c7gpjBnzUWQpUGIYcXYKxTdTi+N2jusvt610ckTMkxdwHkYEg==", + "version": "0.132.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.132.0.tgz", + "integrity": "sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==", "dev": true, "license": "MIT", "funding": { @@ -583,9 +583,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0.tgz", - "integrity": "sha512-TWMZnRLMe63C2Lhyicviu7ZHaU4kxa6PS3rofvc9GmcvptzNN11BcfQ4Sl7MwTOsisQoa2keB/EBdNCAnUo8vA==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz", + "integrity": "sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==", "cpu": [ "arm64" ], @@ -600,9 +600,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0.tgz", - "integrity": "sha512-6XcD+8k0gPVItNagEw78/qqcBDwKcwDYS8V2hRmVsfUSIrd8cWe/CBvRDI5toqFyPfj+FJr6t8U6Xj2P2prEew==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.2.tgz", + "integrity": "sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w==", "cpu": [ "arm64" ], @@ -617,9 +617,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0.tgz", - "integrity": "sha512-iN/tWVXRQDWvmZlKdceP1Dwug9GDpEymhb9p4xnEe6zvCg5lFmzVljl+1qR1NVx3yfGpr2Na+CuLmv5IU8uzfQ==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.2.tgz", + "integrity": "sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA==", "cpu": [ "x64" ], @@ -634,9 +634,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0.tgz", - "integrity": "sha512-jjQMDvvwSOuhOwMszD/klSOjyWMM3zI64hWTj9KT5x4MxRbZAf+7vLQ6qouRhtsLVFHr3f0ILaJAfgENPiQdAQ==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.2.tgz", + "integrity": "sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA==", "cpu": [ "x64" ], @@ -651,9 +651,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0.tgz", - "integrity": "sha512-d//Dtg2x6/m3mbV64yUGNnDGNZaDGRpDLLNGerHQUVObuNaIQaaDp25yUiqGXtHEXX+NP2d0wAlmKgpYgIAJ2A==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.2.tgz", + "integrity": "sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w==", "cpu": [ "arm" ], @@ -668,9 +668,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0.tgz", - "integrity": "sha512-n7Ofp0mx+aB2cC+Sdy5YtMnXtY9lchnHbY+3Yt0uq9JsWQExf4f5Whu0tK0R8Jdc9S6RchTHjIFY7uc92puOVQ==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.2.tgz", + "integrity": "sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==", "cpu": [ "arm64" ], @@ -685,9 +685,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0.tgz", - "integrity": "sha512-EIVjy2cgd7uuMMo94FVkBp7F6DhcZAUwNURkSG3RwUmvAXR6s0ISxM81U+IydcZByPG0pZIHsf1b6kTxoFDgJA==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.2.tgz", + "integrity": "sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==", "cpu": [ "arm64" ], @@ -702,9 +702,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0.tgz", - "integrity": "sha512-JEwwOPcwTLAcpDQlqSmjEmfs63xJnSiUNIGvLcDLUHCWK4XowpS/7c7tUsUH6uT/ct6bMUTdXKfI8967FYj6mg==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.2.tgz", + "integrity": "sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==", "cpu": [ "ppc64" ], @@ -719,9 +719,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0.tgz", - "integrity": "sha512-0wjCFhLrihtAubnT9iA0N++0pSV0z5Hg7tNGdNJ4RFaINceHadoF+kiFGyY1qSSNVIAZtLotG8Ju1bgDPkjnFA==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.2.tgz", + "integrity": "sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==", "cpu": [ "s390x" ], @@ -736,9 +736,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0.tgz", - "integrity": "sha512-Dfn7iak9BcMMePxcoJfpSbWqnEyrp/dRF63/8qW/eHBdOZov6x5aShLLEYGYdIeSJ6vMLK/XCVB+lGIxm41bQA==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.2.tgz", + "integrity": "sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==", "cpu": [ "x64" ], @@ -753,9 +753,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0.tgz", - "integrity": "sha512-5/utzzDmD/pD/bmuaUcbTf/sZYy0aztwIVlfpoW1fTjCZ0BaPOMVWGZL1zvgxyi7ZIVYWlxKONHmSbHuiOh8Jw==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.2.tgz", + "integrity": "sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==", "cpu": [ "x64" ], @@ -770,9 +770,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0.tgz", - "integrity": "sha512-ouJs8VcUomfLfpbUECqFMRqdV4x6aeAK3MA4m6vTrJJjKyWTV5KnxZx7Jd9G+GlDaQQxubcba00x16OyJ1meig==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.2.tgz", + "integrity": "sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==", "cpu": [ "arm64" ], @@ -787,9 +787,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0.tgz", - "integrity": "sha512-E+oHKGiDA+lsKMmFtffDDw91EryDT7uJocrIuCHqhm6bCTM6xFK+3gaCkYOHfPwQr0cCNarSM2xaELoQDz9jJg==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.2.tgz", + "integrity": "sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ==", "cpu": [ "wasm32" ], @@ -806,9 +806,9 @@ } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0.tgz", - "integrity": "sha512-yYK02n8Rngo+gbm1y6G0+7jk1sJ/2Wt7K0me0Y7k/ErBpyf+LJ2gFpqWVTcRV1rUepBlQRmpgWkTQCiiwrK0Ow==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.2.tgz", + "integrity": "sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A==", "cpu": [ "arm64" ], @@ -823,9 +823,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0.tgz", - "integrity": "sha512-14bpChMahXRRXiTwahSl+zzHPW6qQTXtkMuJBFlbo+pqSAews2d4BdCSHfrJ/MBsCZtpmTafsY+1QhBzitcmdg==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.2.tgz", + "integrity": "sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ==", "cpu": [ "x64" ], @@ -840,9 +840,9 @@ } }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0.tgz", - "integrity": "sha512-aKs/3GSWyV0mrhNmt/96/Z3yczC3yvrzYATCiCXQebBsGyYzjNdUphRVLeJQ67ySKVXRfMxt2lm12pmXvbPFQQ==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", "dev": true, "license": "MIT" }, @@ -948,17 +948,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.59.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.4.tgz", - "integrity": "sha512-PegsU+XfyJJNjd4+u/k6f9yTyp0lEXXiPopUNobZcIAUJFGICFLN+sP0Rb3JehVmiij1Ph0dFGYqODoRo/2+6A==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.0.tgz", + "integrity": "sha512-QYb/sa74/s7OKMbACMjrYnGspj9Hs5YI5aaffSL65UfeBUzVzBJfVo3oWSpbzPurvm7yaCCo2Lk7lVj610HqKw==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.59.4", - "@typescript-eslint/type-utils": "8.59.4", - "@typescript-eslint/utils": "8.59.4", - "@typescript-eslint/visitor-keys": "8.59.4", + "@typescript-eslint/scope-manager": "8.60.0", + "@typescript-eslint/type-utils": "8.60.0", + "@typescript-eslint/utils": "8.60.0", + "@typescript-eslint/visitor-keys": "8.60.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -971,7 +971,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.59.4", + "@typescript-eslint/parser": "^8.60.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } @@ -987,16 +987,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.59.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.4.tgz", - "integrity": "sha512-zORHqO/tuhxY1zWuTvMUqddRxpiFJ72xVfcNoWpqdLjs6lfPbuQBJuW4pk+49/uBMy7Ssr4bzgjiKmmDB1UbZQ==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.60.0.tgz", + "integrity": "sha512-fcqpj/MyK4sxDPcbe7STNPbpQL4RLZOPWuaTmwZYuc+hJKzRf58yRxfhqGpc6PIq9ZyfSBpfHgmUHmHs0KwHwg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.59.4", - "@typescript-eslint/types": "8.59.4", - "@typescript-eslint/typescript-estree": "8.59.4", - "@typescript-eslint/visitor-keys": "8.59.4", + "@typescript-eslint/scope-manager": "8.60.0", + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/typescript-estree": "8.60.0", + "@typescript-eslint/visitor-keys": "8.60.0", "debug": "^4.4.3" }, "engines": { @@ -1012,14 +1012,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.59.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.4.tgz", - "integrity": "sha512-Ly00Vu4oAacfDeHp2Zg85ioNG6l8HG+tN1D7J+xTHSxu9y0awYKJ2zH1rFBn8ZSfuGK+7FxK3Cgl3uAz0aZZLg==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.60.0.tgz", + "integrity": "sha512-aZu74NNKJeUWqCjDddzdiKaS82dgYgV/vmf+Ui3ZdZejmgfXR/q+pRumgobnQ2cCJTgGTWp4ypiwsuofFubavg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.59.4", - "@typescript-eslint/types": "^8.59.4", + "@typescript-eslint/tsconfig-utils": "^8.60.0", + "@typescript-eslint/types": "^8.60.0", "debug": "^4.4.3" }, "engines": { @@ -1034,14 +1034,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.59.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.4.tgz", - "integrity": "sha512-mUeR/3H1WrTAddJrwut8OoPjfauaztMQmRwV5fQTUyNVJCLiUXXe4lGEyYIL2oFDpP7UtgbGJXCt72wT0z2S3Q==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.60.0.tgz", + "integrity": "sha512-pFzqhllJMs+jghLQWzV00ds39xLzuyqPSev5pd8f4Ir0rtKR3ZLUB4/4dhjOFighWb9larvtfJvqL+4yKDI3Xw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.4", - "@typescript-eslint/visitor-keys": "8.59.4" + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/visitor-keys": "8.60.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1052,9 +1052,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.59.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.4.tgz", - "integrity": "sha512-DLCpnKgD4alVxTBSKulK+gU1KCqOgUXfDRDXh2mZgzokQKa/70ax93I2uVO3m/LLvIAtWZIFoiifudmIqAxpMA==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.60.0.tgz", + "integrity": "sha512-BZPR3RGYlAXnly6ymAxfkVn5rCbZzQNou0rxv3GfWZ8cTQp+hhVd73khbGLAd8k1TlAPLISH337M+tAgAnaJDQ==", "dev": true, "license": "MIT", "engines": { @@ -1069,15 +1069,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.59.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.4.tgz", - "integrity": "sha512-uonTuPAAKr9XaBGqJ3LjYTh72zy5DyGesljO9gtmk/eFW0W1fRHjnwVYKB35Lm8d5Q5CluEW3gPHjTvZTmgrfA==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.60.0.tgz", + "integrity": "sha512-SX46wEUtitCpq7AN38HkUU/+zvUpdKf7ephtWAFgckH8O7PQIyL5gvrhQgBLuEYgLfuKWOVvWVskMbuFHAz5xg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.4", - "@typescript-eslint/typescript-estree": "8.59.4", - "@typescript-eslint/utils": "8.59.4", + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/typescript-estree": "8.60.0", + "@typescript-eslint/utils": "8.60.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -1094,9 +1094,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.59.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.4.tgz", - "integrity": "sha512-F1o7WJcCq+bc8dwcO/YsSEOudAH8RDtaOhM6wcAQhcUsFhnWQl81JKy48q1hoxAU0qrzM89+31GYh1515Zde3Q==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.60.0.tgz", + "integrity": "sha512-AsE7x2XaAK+CVbeih0Fvbn+r1qHxtpLDJ3XUuFcIinT318T90yHMJC+Zgv+jUuDjQQd06HKwxnDu6sz1IcTilA==", "dev": true, "license": "MIT", "engines": { @@ -1108,16 +1108,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.59.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.4.tgz", - "integrity": "sha512-F+RuOmcDXo4+TPdfd/TCLS3m2nw8gE9XXyZLrA3JBfaA5tz9TtdkyD3YJFmPxulyc2cKbEok/CvFE3MgSLWnag==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.60.0.tgz", + "integrity": "sha512-3AcZNBGMClm6CXDyo8kYvVGT/sx29sS0oBsIb9oZI2gunA4Vm2M3YHzRLPvsUBBsl+yB5FPtltq7gGH0iTlp9g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.59.4", - "@typescript-eslint/tsconfig-utils": "8.59.4", - "@typescript-eslint/types": "8.59.4", - "@typescript-eslint/visitor-keys": "8.59.4", + "@typescript-eslint/project-service": "8.60.0", + "@typescript-eslint/tsconfig-utils": "8.60.0", + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/visitor-keys": "8.60.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -1136,16 +1136,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.59.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.4.tgz", - "integrity": "sha512-cYXeNAUsG4lJo5dbc1FcKm+JwIWrj1/UpTORsC6tGMjEZ81DYcvIr9/ueikhMa/Y/gDQYGp+YX9/xQrXje5BJw==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.60.0.tgz", + "integrity": "sha512-HtXuPfrHTyBDkameWpl+vJb1Uevu2tznAyahM1Oc4AENidCLTPiZDWIo4GfcxNdC/RcfGcadzzkqbRG87dUrQA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.59.4", - "@typescript-eslint/types": "8.59.4", - "@typescript-eslint/typescript-estree": "8.59.4" + "@typescript-eslint/scope-manager": "8.60.0", + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/typescript-estree": "8.60.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1160,13 +1160,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.59.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.4.tgz", - "integrity": "sha512-U3gxVaDVnuZKhSspW/MzMxE1kq7zOdc072FcSNoqA1I9p8HyKbBFfEHoWckBAMgNMph4MamwS5iTVzFmrnt8TQ==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.60.0.tgz", + "integrity": "sha512-9WI52t8ZGLVGrPMBet25yAftqY/n95+zmoUUtJBBQTKDSKUu7OsPTroT2op7U9JatkoRccL0YkWDNMFfC4Sjxg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.4", + "@typescript-eslint/types": "8.60.0", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -1178,16 +1178,16 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.6.tgz", - "integrity": "sha512-7EHDquPthALSV0jhhjgEW8FXaviMx7rSqu8W6oqCoAuOhKov814P99QDV1pxMA3QPv21YudvJngIhjrNI4opLg==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.7.tgz", + "integrity": "sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.6", - "@vitest/utils": "4.1.6", + "@vitest/spy": "4.1.7", + "@vitest/utils": "4.1.7", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -1196,13 +1196,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.6.tgz", - "integrity": "sha512-MCFc63czMjEInOlcY2cpQCvCN+KgbAn+60xu9cMgP4sKaLC5JNAKw7JH8QdAnoAC88hW1IiSNZ+GgVXlN1UcMQ==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.7.tgz", + "integrity": "sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.6", + "@vitest/spy": "4.1.7", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -1223,9 +1223,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.6.tgz", - "integrity": "sha512-h5SxD/IzNhZYnrSZRsUZQIC+vD0GY8cUvq0iwsmkFKixRCKLLWqCXa/FIQ4S1R+sI+PGoojkHsdNrbZiM9Qpgw==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.7.tgz", + "integrity": "sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==", "dev": true, "license": "MIT", "dependencies": { @@ -1236,13 +1236,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.6.tgz", - "integrity": "sha512-nOPCmn2+yD0ZNmKdsXGv/UxMMWbMuKeD6GyYncNwdkYDxpQvrPSKYj2rWuDjC2Y4b6w6hjip5dBKFzEUuZe3vA==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.7.tgz", + "integrity": "sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.6", + "@vitest/utils": "4.1.7", "pathe": "^2.0.3" }, "funding": { @@ -1250,14 +1250,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.6.tgz", - "integrity": "sha512-YhsdE6xAVfTDmzjxL2ZDUvjj+ZsgyOKe+TdQzqkD72wIOmHka8NuGQ6NpTNZv9D2Z63fbwWKJPeVpEw4EQgYxw==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.7.tgz", + "integrity": "sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.6", - "@vitest/utils": "4.1.6", + "@vitest/pretty-format": "4.1.7", + "@vitest/utils": "4.1.7", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -1266,9 +1266,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.6.tgz", - "integrity": "sha512-JFKxMx6udhwKh/Ldo270e17QX710vgunMkuPAvXjHSvC6oqLWAHhVhjg/I71q0u0CBSErIODV1Kjv0FQNSWjdg==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.7.tgz", + "integrity": "sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==", "dev": true, "license": "MIT", "funding": { @@ -1276,13 +1276,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.6.tgz", - "integrity": "sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.7.tgz", + "integrity": "sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.6", + "@vitest/pretty-format": "4.1.7", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -1443,17 +1443,17 @@ } }, "node_modules/cacheable": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.3.2.tgz", - "integrity": "sha512-w+ZuRNmex9c1TR9RcsxbfTKCjSL0rh1WA5SABbrWprIHeNBdmyQLSYonlDy9gpD+63XT8DgZ/wNh1Smvc9WnJA==", + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.3.5.tgz", + "integrity": "sha512-EQfaKe09tl615iNvq/TBRWTFf1AKJNXYQSsMx0Z3EI0nA+pVsVPS8wJhnRlkbdacKPh1d0qVIhwTc2zsQNFEEg==", "dev": true, "license": "MIT", "dependencies": { - "@cacheable/memory": "^2.0.7", - "@cacheable/utils": "^2.3.3", + "@cacheable/memory": "^2.0.8", + "@cacheable/utils": "^2.4.1", "hookified": "^1.15.0", - "keyv": "^5.5.5", - "qified": "^0.6.0" + "keyv": "^5.6.0", + "qified": "^0.10.1" } }, "node_modules/cacheable/node_modules/keyv": { @@ -2235,13 +2235,13 @@ } }, "node_modules/hashery": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.4.0.tgz", - "integrity": "sha512-Wn2i1In6XFxl8Az55kkgnFRiAlIAushzh26PTjL2AKtQcEfXrcLa7Hn5QOWGZEf3LU057P9TwwZjFyxfS1VuvQ==", + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", + "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==", "dev": true, "license": "MIT", "dependencies": { - "hookified": "^1.14.0" + "hookified": "^1.15.0" }, "engines": { "node": ">=20" @@ -2894,9 +2894,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", "dev": true, "funding": [ { @@ -3083,9 +3083,9 @@ } }, "node_modules/postcss": { - "version": "8.5.14", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", - "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", "dev": true, "funding": [ { @@ -3103,7 +3103,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -3237,18 +3237,25 @@ } }, "node_modules/qified": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/qified/-/qified-0.6.0.tgz", - "integrity": "sha512-tsSGN1x3h569ZSU1u6diwhltLyfUWDp3YbFHedapTmpBl0B3P6U3+Qptg7xu+v+1io1EwhdPyyRHYbEw0KN2FA==", + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz", + "integrity": "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==", "dev": true, "license": "MIT", "dependencies": { - "hookified": "^1.14.0" + "hookified": "^2.1.1" }, "engines": { "node": ">=20" } }, + "node_modules/qified/node_modules/hookified": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-2.2.0.tgz", + "integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==", + "dev": true, + "license": "MIT" + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -3302,14 +3309,14 @@ } }, "node_modules/rolldown": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0.tgz", - "integrity": "sha512-yD986aXDESFGS95spT1LAv0jssywP4npMEjmMHyN2/5+eE8qQJUype2AaKkRiLgBgyD0LFlubwAht7VmY8rGoA==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz", + "integrity": "sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.129.0", - "@rolldown/pluginutils": "1.0.0" + "@oxc-project/types": "=0.132.0", + "@rolldown/pluginutils": "^1.0.0" }, "bin": { "rolldown": "bin/cli.mjs" @@ -3318,21 +3325,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0", - "@rolldown/binding-darwin-arm64": "1.0.0", - "@rolldown/binding-darwin-x64": "1.0.0", - "@rolldown/binding-freebsd-x64": "1.0.0", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0", - "@rolldown/binding-linux-arm64-gnu": "1.0.0", - "@rolldown/binding-linux-arm64-musl": "1.0.0", - "@rolldown/binding-linux-ppc64-gnu": "1.0.0", - "@rolldown/binding-linux-s390x-gnu": "1.0.0", - "@rolldown/binding-linux-x64-gnu": "1.0.0", - "@rolldown/binding-linux-x64-musl": "1.0.0", - "@rolldown/binding-openharmony-arm64": "1.0.0", - "@rolldown/binding-wasm32-wasi": "1.0.0", - "@rolldown/binding-win32-arm64-msvc": "1.0.0", - "@rolldown/binding-win32-x64-msvc": "1.0.0" + "@rolldown/binding-android-arm64": "1.0.2", + "@rolldown/binding-darwin-arm64": "1.0.2", + "@rolldown/binding-darwin-x64": "1.0.2", + "@rolldown/binding-freebsd-x64": "1.0.2", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.2", + "@rolldown/binding-linux-arm64-gnu": "1.0.2", + "@rolldown/binding-linux-arm64-musl": "1.0.2", + "@rolldown/binding-linux-ppc64-gnu": "1.0.2", + "@rolldown/binding-linux-s390x-gnu": "1.0.2", + "@rolldown/binding-linux-x64-gnu": "1.0.2", + "@rolldown/binding-linux-x64-musl": "1.0.2", + "@rolldown/binding-openharmony-arm64": "1.0.2", + "@rolldown/binding-wasm32-wasi": "1.0.2", + "@rolldown/binding-win32-arm64-msvc": "1.0.2", + "@rolldown/binding-win32-x64-msvc": "1.0.2" } }, "node_modules/run-parallel": { @@ -3504,9 +3511,9 @@ } }, "node_modules/stylelint": { - "version": "17.11.1", - "resolved": "https://registry.npmjs.org/stylelint/-/stylelint-17.11.1.tgz", - "integrity": "sha512-+smN/HqVTggUx3iuAzOi9fPh8SrH+cJWlZrYVldXoJ06orWBhZ4Ue/QEp64oei6pVrAh4w3tG+Y12Vw7MbCFRQ==", + "version": "17.12.0", + "resolved": "https://registry.npmjs.org/stylelint/-/stylelint-17.12.0.tgz", + "integrity": "sha512-KIlzWXMHUvgfPUR0R7TK3H80yCIi0uoivUwf+6Az4yrHJD1Q3c1qIkh/H5Z0i/K3QXgtq/UMEkWyBUSUwnpnOg==", "dev": true, "funding": [ { @@ -3534,7 +3541,7 @@ "debug": "^4.4.3", "fast-glob": "^3.3.3", "fastest-levenshtein": "^1.0.16", - "file-entry-cache": "^11.1.2", + "file-entry-cache": "^11.1.3", "global-modules": "^2.0.0", "globby": "^16.2.0", "globjoin": "^0.1.4", @@ -3663,24 +3670,24 @@ } }, "node_modules/stylelint/node_modules/file-entry-cache": { - "version": "11.1.2", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.2.tgz", - "integrity": "sha512-N2WFfK12gmrK1c1GXOqiAJ1tc5YE+R53zvQ+t5P8S5XhnmKYVB5eZEiLNZKDSmoG8wqqbF9EXYBBW/nef19log==", + "version": "11.1.3", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.3.tgz", + "integrity": "sha512-oMbq0PD6VIiIwMF6LIa7MEwd/l9huKwmqRKXqmrkqIZv8CvRbfowL+L0ryAl8h//HfAS0zS+4SbYoRyAoA6BJA==", "dev": true, "license": "MIT", "dependencies": { - "flat-cache": "^6.1.20" + "flat-cache": "^6.1.22" } }, "node_modules/stylelint/node_modules/flat-cache": { - "version": "6.1.20", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.20.tgz", - "integrity": "sha512-AhHYqwvN62NVLp4lObVXGVluiABTHapoB57EyegZVmazN+hhGhLTn3uZbOofoTw4DSDvVCadzzyChXhOAvy8uQ==", + "version": "6.1.22", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.22.tgz", + "integrity": "sha512-N2dnzVJIphnNsjHcrxGW7DePckJ6haPrSFqpsBUhHYgwtKGVq4JrBGielEGD2fCVnsGm1zlBVZ8wGhkyuetgug==", "dev": true, "license": "MIT", "dependencies": { - "cacheable": "^2.3.2", - "flatted": "^3.3.3", + "cacheable": "^2.3.4", + "flatted": "^3.4.2", "hookified": "^1.15.0" } }, @@ -3947,16 +3954,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.59.4", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.4.tgz", - "integrity": "sha512-Rw6+44QNFaXtgHSjPy+Kw8hrJniMYzR85E9yLmOLcfZ91/rz+JXQbDTCmc6ccxMPY6K6PgAq26f0JCBfR7LIPQ==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.60.0.tgz", + "integrity": "sha512-9f65qWLZdAW9m1JaxBDUHcqRUfL8bkxxXL7XxEfI+F09q56PkBvIfCjLF3yInsDM/BBmwkqmCQdCZe/RYlIWEw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.59.4", - "@typescript-eslint/parser": "8.59.4", - "@typescript-eslint/typescript-estree": "8.59.4", - "@typescript-eslint/utils": "8.59.4" + "@typescript-eslint/eslint-plugin": "8.60.0", + "@typescript-eslint/parser": "8.60.0", + "@typescript-eslint/typescript-estree": "8.60.0", + "@typescript-eslint/utils": "8.60.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4001,16 +4008,16 @@ "license": "MIT" }, "node_modules/vite": { - "version": "8.0.12", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.12.tgz", - "integrity": "sha512-w2dDofOWv2QB09ZITZBsvKTVAlYvPR4IAmrY/v0ir9KvLs0xybR7i48wxhM1/oyBWO34wPns+bPGw5ZrZqDpZg==", + "version": "8.0.14", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.14.tgz", + "integrity": "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", - "postcss": "^8.5.14", - "rolldown": "1.0.0", + "postcss": "^8.5.15", + "rolldown": "1.0.2", "tinyglobby": "^0.2.16" }, "bin": { @@ -4092,19 +4099,19 @@ } }, "node_modules/vitest": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.6.tgz", - "integrity": "sha512-6lvjbS3p9b4CrdCmguzbh2/4uoXhGE2q71R4OX5sqF9R1bo9Xd6fGrMAfvp5wnCzlBnFVdCOp6onuTQVbo8iUQ==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.7.tgz", + "integrity": "sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.6", - "@vitest/mocker": "4.1.6", - "@vitest/pretty-format": "4.1.6", - "@vitest/runner": "4.1.6", - "@vitest/snapshot": "4.1.6", - "@vitest/spy": "4.1.6", - "@vitest/utils": "4.1.6", + "@vitest/expect": "4.1.7", + "@vitest/mocker": "4.1.7", + "@vitest/pretty-format": "4.1.7", + "@vitest/runner": "4.1.7", + "@vitest/snapshot": "4.1.7", + "@vitest/spy": "4.1.7", + "@vitest/utils": "4.1.7", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -4132,12 +4139,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.6", - "@vitest/browser-preview": "4.1.6", - "@vitest/browser-webdriverio": "4.1.6", - "@vitest/coverage-istanbul": "4.1.6", - "@vitest/coverage-v8": "4.1.6", - "@vitest/ui": "4.1.6", + "@vitest/browser-playwright": "4.1.7", + "@vitest/browser-preview": "4.1.7", + "@vitest/browser-webdriverio": "4.1.7", + "@vitest/coverage-istanbul": "4.1.7", + "@vitest/coverage-v8": "4.1.7", + "@vitest/ui": "4.1.7", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" From f22b6f6a5a1977050e0cdfd0ccc21cf0446a09c4 Mon Sep 17 00:00:00 2001 From: Soner Sayakci Date: Tue, 26 May 2026 08:20:59 +0200 Subject: [PATCH 07/38] feat: generate extension checksums during project CI builds Automatically generate checksum.json for all extensions after asset builds during shopware-cli project ci. This enables integrity verification for extensions deployed via CI pipelines. - Skips extensions that already have checksum.json - Non-fatal: warns on failure but continues with other extensions - Reuses existing GenerateChecksumJSON with XXH128 algorithm Closes #1058 --- cmd/project/ci.go | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/cmd/project/ci.go b/cmd/project/ci.go index cfb5da23..134af9d6 100644 --- a/cmd/project/ci.go +++ b/cmd/project/ci.go @@ -213,6 +213,26 @@ var projectCI = &cobra.Command{ optimizeSection.End(cmd.Context()) + checksumSection := ci.Default.Section(cmd.Context(), "Generating extension checksums") + + extensions := extension.FindExtensionsFromProject(cmd.Context(), args[0], false) + + for _, ext := range extensions { + extPath := ext.GetPath() + checksumPath := filepath.Join(extPath, "checksum.json") + + if _, err := os.Stat(checksumPath); err == nil { + logging.FromContext(cmd.Context()).Infof("Skipping checksum generation for %s: checksum.json already exists", extPath) + continue + } + + if err := extension.GenerateChecksumJSON(cmd.Context(), extPath, ext); err != nil { + logging.FromContext(cmd.Context()).Warnf("Failed to generate checksum for %s: %v", extPath, err) + } + } + + checksumSection.End(cmd.Context()) + 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 From 006a4749b1f0e8ab88d129e46b059f041ddd2e74 Mon Sep 17 00:00:00 2001 From: Soner Sayakci Date: Tue, 26 May 2026 13:39:58 +0200 Subject: [PATCH 08/38] feat: add code coverage upload via actions/upload-code-coverage - Generate coverage profile during test run via COVERAGE_FILE env var - Convert Go coverage to Cobertura XML using gocover-cobertura - Upload coverage report to GitHub's code coverage API - Add code-quality: write permission to test workflow --- .github/workflows/go_test.yml | 11 +++++++++++ scripts/run-tests.sh | 25 ++++++++++++++++++++++--- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/.github/workflows/go_test.yml b/.github/workflows/go_test.yml index ed3481fa..97c56649 100644 --- a/.github/workflows/go_test.yml +++ b/.github/workflows/go_test.yml @@ -10,6 +10,7 @@ on: permissions: contents: read + code-quality: write env: GOTOOLCHAIN: local @@ -53,3 +54,13 @@ jobs: - name: Test (sandboxed, no network) run: ./scripts/run-tests.sh + env: + COVERAGE_FILE: coverage.out + + - name: Upload code coverage + uses: actions/upload-code-coverage@v1 + with: + file: coverage.xml + language: Go + label: code-coverage/go-test + fail-on-error: false diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh index 7f6dc154..e21f5af8 100755 --- a/scripts/run-tests.sh +++ b/scripts/run-tests.sh @@ -6,6 +6,8 @@ REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" cd "$REPO_DIR" +COVERPROFILE="${COVERAGE_FILE:-}" + if [ $# -eq 0 ]; then set -- -v ./... fi @@ -14,6 +16,11 @@ fi # does not need to fetch or compile anything from the module proxy. go test -run='^$' ./... +# If a coverage file is requested, install gocover-cobertura while we have network. +if [ -n "$COVERPROFILE" ]; then + go install github.com/boumenot/gocover-cobertura@v1.5.0 +fi + # Inside the sandbox, force the toolchain to fail fast on any cache miss # instead of attempting (and hanging on) a network fetch. export GOFLAGS="${GOFLAGS:-} -mod=readonly" @@ -23,13 +30,18 @@ export GOPROXY=off # binaries or dart-sass) to skip themselves instead of failing on DNS. export SHOPWARE_CLI_NO_NETWORK=1 +COVER_FLAG="" +if [ -n "$COVERPROFILE" ]; then + COVER_FLAG="-coverprofile=$COVERPROFILE" +fi + case "$(uname -s)" in Darwin) if ! command -v sandbox-exec >/dev/null 2>&1; then echo "error: sandbox-exec not found" >&2 exit 1 fi - exec sandbox-exec -f "$REPO_DIR/sandbox-no-network.sb" go test "$@" + sandbox-exec -f "$REPO_DIR/sandbox-no-network.sb" go test $COVER_FLAG "$@" ;; Linux) if ! command -v unshare >/dev/null 2>&1; then @@ -39,7 +51,7 @@ case "$(uname -s)" in # Bring loopback up inside the new netns so tests that use # httptest.NewServer (127.0.0.1) keep working; only external # network is blocked, matching the nix-build sandbox. - exec unshare --user --map-root-user --net -- bash -c ' + unshare --user --map-root-user --net -- bash -c ' if command -v ip >/dev/null 2>&1; then ip link set dev lo up elif command -v ifconfig >/dev/null 2>&1; then @@ -48,7 +60,7 @@ case "$(uname -s)" in echo "error: need either ip (iproute2) or ifconfig to bring up loopback" >&2 exit 1 fi - exec go test "$@" + exec go test '"$COVER_FLAG"' "$@" ' bash "$@" ;; *) @@ -56,3 +68,10 @@ case "$(uname -s)" in exit 1 ;; esac + +# Convert Go coverage profile to Cobertura XML if requested. +if [ -n "$COVERPROFILE" ]; then + echo "Converting coverage to Cobertura XML..." + gocover-cobertura < "$COVERPROFILE" > "${COVERPROFILE%.out}.xml" + echo "Coverage report: ${COVERPROFILE%.out}.xml" +fi From 2be4d47d49a00d5e97b986ec6abf006a07ca7446 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 26 May 2026 13:41:28 +0000 Subject: [PATCH 09/38] feat(project): add `project upgrade` command Mirrors the shopware/web-installer Update flow so projects can be upgraded from the command line: - Reads the current Shopware version from composer.lock - Filters available releases the same way as ReleaseInfoProvider (next major + remaining patches of the current major, no RCs) - Prompts for the target version (or `--to` flag), then runs the existing extension compatibility check before continuing - Backs up composer.json, cleans up recipe-managed stale files by MD5, removes incompatible symlinked custom plugins, rewrites composer.json (shopware/core + administration/storefront/elasticsearch when required, minimum-stability for RC targets, symfony/runtime constraint relax) - Runs `composer update --with-all-dependencies --no-scripts` and restores the backup on failure - Runs `bin/console system:update:prepare` and `system:update:finish` - Tracks the outcome Extracts the MD5-based cleanup map from `flexmigrator.Cleanup` into a new `flexmigrator.CleanupByHash` helper so the upgrade flow can reuse it without also deleting flex-migration-specific files. --- cmd/project/project_upgrade.go | 315 +++++++++++++++++++++++ internal/flexmigrator/cleanup.go | 7 + internal/projectupgrade/composer.go | 81 ++++++ internal/projectupgrade/composer_test.go | 115 +++++++++ internal/projectupgrade/errors.go | 5 + internal/projectupgrade/plugins.go | 187 ++++++++++++++ internal/projectupgrade/plugins_test.go | 97 +++++++ internal/projectupgrade/releases.go | 84 ++++++ internal/projectupgrade/releases_test.go | 59 +++++ 9 files changed, 950 insertions(+) create mode 100644 cmd/project/project_upgrade.go create mode 100644 internal/projectupgrade/composer.go create mode 100644 internal/projectupgrade/composer_test.go create mode 100644 internal/projectupgrade/errors.go create mode 100644 internal/projectupgrade/plugins.go create mode 100644 internal/projectupgrade/plugins_test.go create mode 100644 internal/projectupgrade/releases.go create mode 100644 internal/projectupgrade/releases_test.go diff --git a/cmd/project/project_upgrade.go b/cmd/project/project_upgrade.go new file mode 100644 index 00000000..29f6a89a --- /dev/null +++ b/cmd/project/project_upgrade.go @@ -0,0 +1,315 @@ +package project + +import ( + "context" + "fmt" + "os" + "path" + "strconv" + "time" + + "charm.land/huh/v2" + "charm.land/lipgloss/v2" + "charm.land/lipgloss/v2/table" + "github.com/shyim/go-version" + "github.com/spf13/cobra" + + account_api "github.com/shopware/shopware-cli/internal/account-api" + "github.com/shopware/shopware-cli/internal/extension" + "github.com/shopware/shopware-cli/internal/flexmigrator" + "github.com/shopware/shopware-cli/internal/projectupgrade" + "github.com/shopware/shopware-cli/internal/system" + "github.com/shopware/shopware-cli/internal/tracking" + "github.com/shopware/shopware-cli/internal/tui" + "github.com/shopware/shopware-cli/logging" +) + +var projectUpgradeCmd = &cobra.Command{ + Use: "upgrade", + Short: "Upgrade the Shopware version of this project", + Long: `Upgrade the Shopware project to a newer version. This command mirrors +the behaviour of the shopware/web-installer: it picks an upgrade target, +removes incompatible custom plugins, rewrites composer.json for the new +version, runs composer update --with-all-dependencies, and finally runs +bin/console system:update:prepare and system:update:finish.`, + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := cmd.Context() + log := logging.FromContext(ctx) + + projectRoot, err := findClosestShopwareProject() + if err != nil { + return err + } + + composerJsonPath := path.Join(projectRoot, "composer.json") + composerLockPath := path.Join(projectRoot, "composer.lock") + + if _, err := os.Stat(composerLockPath); err != nil { + return fmt.Errorf("composer.lock not found in %s. Run composer install first", projectRoot) + } + + currentVersion, err := projectupgrade.CurrentShopwareVersion(projectRoot) + if err != nil { + return fmt.Errorf("failed to determine current Shopware version: %w", err) + } + + log.Infof("Current Shopware version: %s", currentVersion.String()) + + allVersions, err := extension.GetShopwareVersions(ctx) + if err != nil { + return fmt.Errorf("failed to fetch available Shopware versions: %w", err) + } + + updateVersions := projectupgrade.FilterUpdateVersions(currentVersion, allVersions) + if len(updateVersions) == 0 { + fmt.Println("You are on the latest version of Shopware") + return nil + } + + targetVersion, err := selectTargetVersion(cmd, updateVersions) + if err != nil { + return err + } + + if err := runCompatibilityCheck(ctx, projectRoot, currentVersion, targetVersion); err != nil { + return err + } + + confirmed := !system.IsInteractionEnabled(ctx) + if !confirmed { + if err := huh.NewConfirm(). + Title(fmt.Sprintf("Upgrade Shopware from %s to %s?", currentVersion.String(), targetVersion)). + Description("This will modify composer.json, run composer update --with-all-dependencies, and execute system:update:prepare/finish. Commit your changes before running this command."). + Value(&confirmed). + Run(); err != nil { + return err + } + } + + if !confirmed { + return fmt.Errorf("upgrade cancelled") + } + + log.Infof("Backing up composer.json") + backup, err := os.ReadFile(composerJsonPath) + if err != nil { + return fmt.Errorf("failed to backup composer.json: %w", err) + } + + log.Infof("Cleaning up stale recipe files") + if err := flexmigrator.CleanupByHash(projectRoot); err != nil { + return fmt.Errorf("cleanup stale files: %w", err) + } + + log.Infof("Checking custom plugins for incompatibilities") + removed, err := projectupgrade.RemoveIncompatiblePlugins(composerJsonPath, targetVersion) + if err != nil { + restoreComposerJson(ctx, composerJsonPath, backup) + return fmt.Errorf("remove incompatible plugins: %w", err) + } + + for _, name := range removed { + log.Infof("Removed incompatible plugin %s from composer.json. Re-require it once a compatible version is published.", tui.YellowText.Render(name)) + } + + log.Infof("Updating composer.json to %s", targetVersion) + if err := projectupgrade.UpdateComposerJson(composerJsonPath, targetVersion); err != nil { + restoreComposerJson(ctx, composerJsonPath, backup) + return fmt.Errorf("update composer.json: %w", err) + } + + cmdExecutor, err := resolveExecutor(cmd, projectRoot) + if err != nil { + restoreComposerJson(ctx, composerJsonPath, backup) + return err + } + + log.Infof("Running composer update") + composerArgs := []string{ + "update", + "--no-interaction", + "--no-scripts", + "--with-all-dependencies", + "-v", + } + + composerCmd := cmdExecutor.ComposerCommand(ctx, composerArgs...) + composerCmd.Cmd.Stdin = cmd.InOrStdin() + composerCmd.Cmd.Stdout = cmd.OutOrStdout() + composerCmd.Cmd.Stderr = cmd.ErrOrStderr() + + composerSuccess := true + if err := composerCmd.Run(); err != nil { + composerSuccess = false + log.Errorf("composer update failed: %v", err) + restoreComposerJson(ctx, composerJsonPath, backup) + trackUpgrade(ctx, currentVersion.String(), targetVersion, "composer_update_failed") + return fmt.Errorf("composer update failed, composer.json was restored: %w", err) + } + + log.Infof("Running bin/console system:update:prepare") + prepareCmd := cmdExecutor.ConsoleCommand(ctx, "system:update:prepare", "--no-interaction") + prepareCmd.Cmd.Stdin = cmd.InOrStdin() + prepareCmd.Cmd.Stdout = cmd.OutOrStdout() + prepareCmd.Cmd.Stderr = cmd.ErrOrStderr() + + if err := prepareCmd.Run(); err != nil { + trackUpgrade(ctx, currentVersion.String(), targetVersion, "system_update_prepare_failed") + return fmt.Errorf("system:update:prepare failed: %w", err) + } + + log.Infof("Running bin/console system:update:finish") + finishCmd := cmdExecutor.ConsoleCommand(ctx, "system:update:finish", "--no-interaction") + finishCmd.Cmd.Stdin = cmd.InOrStdin() + finishCmd.Cmd.Stdout = cmd.OutOrStdout() + finishCmd.Cmd.Stderr = cmd.ErrOrStderr() + + if err := finishCmd.Run(); err != nil { + trackUpgrade(ctx, currentVersion.String(), targetVersion, "system_update_finish_failed") + return fmt.Errorf("system:update:finish failed: %w", err) + } + + status := "ok" + if !composerSuccess { + status = "composer_update_failed" + } + + trackUpgrade(ctx, currentVersion.String(), targetVersion, status) + + fmt.Printf("\n%s\n", tui.GreenText.Render(fmt.Sprintf("Shopware was upgraded from %s to %s", currentVersion.String(), targetVersion))) + + return nil + }, +} + +func selectTargetVersion(cmd *cobra.Command, updateVersions []string) (string, error) { + target, _ := cmd.Flags().GetString("to") + if target != "" { + for _, v := range updateVersions { + if v == target { + return target, nil + } + } + return "", fmt.Errorf("requested target version %s is not in the list of available upgrade versions", target) + } + + if !system.IsInteractionEnabled(cmd.Context()) { + logging.FromContext(cmd.Context()).Infof("Auto selected version %s", updateVersions[0]) + return updateVersions[0], nil + } + + var selected string + prompt := huh.NewSelect[string](). + Height(10). + Title("Select the Shopware version to upgrade to"). + Options(huh.NewOptions(updateVersions...)...). + Value(&selected) + + if err := prompt.Run(); err != nil { + return "", err + } + + if selected == "" { + return "", fmt.Errorf("no version selected") + } + + return selected, nil +} + +func runCompatibilityCheck(ctx context.Context, projectRoot string, currentVersion *version.Version, targetVersion string) error { + log := logging.FromContext(ctx) + + _, extensions, err := getLocalExtensions() + if err != nil { + log.Warnf("Skipping extension compatibility check: %v", err) + return nil + } + + if len(extensions) == 0 { + return nil + } + + requests := make([]account_api.UpdateCheckExtension, 0, len(extensions)) + for name, v := range extensions { + requests = append(requests, account_api.UpdateCheckExtension{Name: name, Version: v}) + } + + updates, err := account_api.GetFutureExtensionUpdates(ctx, currentVersion.String(), targetVersion, requests) + if err != nil { + log.Warnf("Skipping extension compatibility check: %v", err) + return nil + } + + for _, name := range requests { + found := false + for _, update := range updates { + if update.Name == name.Name { + found = true + break + } + } + + if !found { + updates = append(updates, account_api.UpdateCheckExtensionCompatibility{ + Name: name.Name, + Status: account_api.UpdateCheckExtensionCompatibilityStatus{ + Label: "Not available in Store", + }, + }) + } + } + + t := table.New().Border(lipgloss.NormalBorder()).Headers("Extension Name", "Compatible") + for _, update := range updates { + t.Row(update.Name, update.Status.Label) + } + fmt.Println(t.Render()) + + hasBlockers := false + for _, update := range updates { + if update.Status.IsBlocker() { + hasBlockers = true + break + } + } + + if hasBlockers && system.IsInteractionEnabled(ctx) { + var proceed bool + if err := huh.NewConfirm(). + Title("Some installed extensions are not yet compatible with the target version"). + Description("Continuing may break those extensions. Proceed anyway?"). + Value(&proceed). + Run(); err != nil { + return err + } + + if !proceed { + return fmt.Errorf("upgrade cancelled due to incompatible extensions") + } + } + + return nil +} + +func restoreComposerJson(ctx context.Context, composerJsonPath string, backup []byte) { + if err := os.WriteFile(composerJsonPath, backup, 0o644); err != nil { + logging.FromContext(ctx).Errorf("failed to restore composer.json from backup: %v", err) + } +} + +func trackUpgrade(ctx context.Context, fromVersion, toVersion, status string) { + trackCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 300*time.Millisecond) + defer cancel() + + tracking.Track(trackCtx, "project.upgrade", map[string]string{ + "from_version": fromVersion, + "to_version": toVersion, + "status": status, + "success": strconv.FormatBool(status == "ok"), + }) +} + +func init() { + projectRootCmd.AddCommand(projectUpgradeCmd) + projectUpgradeCmd.Flags().String("to", "", "Target Shopware version. Skips the interactive selection prompt.") +} diff --git a/internal/flexmigrator/cleanup.go b/internal/flexmigrator/cleanup.go index b3722e6d..d25bfb09 100644 --- a/internal/flexmigrator/cleanup.go +++ b/internal/flexmigrator/cleanup.go @@ -211,6 +211,13 @@ func Cleanup(project string) error { } } + return CleanupByHash(project) +} + +// CleanupByHash removes recipe-managed files that still match a known stale +// template hash. This makes sure the recipe can recreate them on the next +// composer install. +func CleanupByHash(project string) error { for file, md5s := range cleanupByMd5 { content, err := os.ReadFile(path.Join(project, file)) if err != nil { diff --git a/internal/projectupgrade/composer.go b/internal/projectupgrade/composer.go new file mode 100644 index 00000000..4f56d322 --- /dev/null +++ b/internal/projectupgrade/composer.go @@ -0,0 +1,81 @@ +// Package projectupgrade implements the Shopware project upgrade flow used +// by the `shopware-cli project upgrade` command. The logic mirrors the +// shopware/web-installer Update flow so projects can be upgraded the same +// way from the command line. +package projectupgrade + +import ( + "strings" + + "github.com/shyim/go-version" + + "github.com/shopware/shopware-cli/internal/packagist" +) + +// ShopwarePackages are the first-party Shopware packages whose constraint is +// rewritten to the new target version in composer.json. This matches the list +// in shopware/web-installer ProjectComposerJsonUpdater. +var ShopwarePackages = []string{ + "shopware/core", + "shopware/administration", + "shopware/storefront", + "shopware/elasticsearch", +} + +// UpdateComposerJson rewrites the project composer.json so that composer can +// resolve dependencies for targetVersion. It mirrors the logic of +// `Shopware\WebInstaller\Services\ProjectComposerJsonUpdater`. +func UpdateComposerJson(composerJsonPath, targetVersion string) error { + composerJson, err := packagist.ReadComposerJson(composerJsonPath) + if err != nil { + return err + } + + if isPreRelease(targetVersion) { + composerJson.MinimumStability = "RC" + } else { + composerJson.MinimumStability = "" + } + + if composerJson.Require == nil { + composerJson.Require = packagist.ComposerPackageLink{} + } + + if _, ok := composerJson.Require["symfony/runtime"]; ok { + composerJson.Require["symfony/runtime"] = ">=5" + } + + for _, pkg := range ShopwarePackages { + if _, ok := composerJson.Require[pkg]; ok { + composerJson.Require[pkg] = targetVersion + } + } + + return composerJson.Save() +} + +func isPreRelease(targetVersion string) bool { + v := strings.ToLower(targetVersion) + return strings.Contains(v, "rc") || strings.Contains(v, "beta") || strings.Contains(v, "alpha") +} + +// CurrentShopwareVersion returns the installed Shopware version reported by +// the composer.lock at projectDir. Returns an error when no Shopware package +// is recorded in the lock file. +func CurrentShopwareVersion(projectDir string) (*version.Version, error) { + lock, err := packagist.ReadComposerLock(projectDir + "/composer.lock") + if err != nil { + return nil, err + } + + for _, name := range []string{"shopware/core", "shopware/platform"} { + pkg := lock.GetPackage(name) + if pkg == nil { + continue + } + + return version.NewVersion(strings.TrimPrefix(pkg.Version, "v")) + } + + return nil, errNoShopwareInLock +} diff --git a/internal/projectupgrade/composer_test.go b/internal/projectupgrade/composer_test.go new file mode 100644 index 00000000..8eadc74d --- /dev/null +++ b/internal/projectupgrade/composer_test.go @@ -0,0 +1,115 @@ +package projectupgrade + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func writeJSON(t *testing.T, file string, content map[string]any) { + t.Helper() + + data, err := json.MarshalIndent(content, "", " ") + require.NoError(t, err) + require.NoError(t, os.WriteFile(file, data, 0o644)) +} + +func readJSON(t *testing.T, file string) map[string]any { + t.Helper() + + raw, err := os.ReadFile(file) + require.NoError(t, err) + + var out map[string]any + require.NoError(t, json.Unmarshal(raw, &out)) + return out +} + +func TestUpdateComposerJsonRewritesShopwarePackages(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + composerJsonPath := filepath.Join(dir, "composer.json") + + writeJSON(t, composerJsonPath, map[string]any{ + "name": "shopware/production", + "require": map[string]any{ + "shopware/core": "6.5.8.0", + "shopware/administration": "6.5.8.0", + "shopware/storefront": "6.5.8.0", + "unrelated/package": "^1.0", + }, + }) + + require.NoError(t, UpdateComposerJson(composerJsonPath, "6.6.4.0")) + + out := readJSON(t, composerJsonPath) + requireMap := out["require"].(map[string]any) + assert.Equal(t, "6.6.4.0", requireMap["shopware/core"]) + assert.Equal(t, "6.6.4.0", requireMap["shopware/administration"]) + assert.Equal(t, "6.6.4.0", requireMap["shopware/storefront"]) + assert.Equal(t, "^1.0", requireMap["unrelated/package"]) + assert.NotContains(t, requireMap, "shopware/elasticsearch", "should not add packages that were not already required") +} + +func TestUpdateComposerJsonSetsRCStability(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + composerJsonPath := filepath.Join(dir, "composer.json") + + writeJSON(t, composerJsonPath, map[string]any{ + "name": "shopware/production", + "require": map[string]any{ + "shopware/core": "6.5.8.0", + }, + }) + + require.NoError(t, UpdateComposerJson(composerJsonPath, "6.6.0.0-rc1")) + out := readJSON(t, composerJsonPath) + assert.Equal(t, "RC", out["minimum-stability"]) +} + +func TestUpdateComposerJsonClearsRCStabilityForStableTarget(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + composerJsonPath := filepath.Join(dir, "composer.json") + + writeJSON(t, composerJsonPath, map[string]any{ + "name": "shopware/production", + "minimum-stability": "RC", + "require": map[string]any{ + "shopware/core": "6.5.8.0", + }, + }) + + require.NoError(t, UpdateComposerJson(composerJsonPath, "6.6.4.0")) + out := readJSON(t, composerJsonPath) + _, hasStability := out["minimum-stability"] + assert.False(t, hasStability, "minimum-stability should be cleared for stable upgrades") +} + +func TestUpdateComposerJsonRewritesSymfonyRuntimeConstraint(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + composerJsonPath := filepath.Join(dir, "composer.json") + + writeJSON(t, composerJsonPath, map[string]any{ + "name": "shopware/production", + "require": map[string]any{ + "shopware/core": "6.5.8.0", + "symfony/runtime": "^5.4|^6.0", + }, + }) + + require.NoError(t, UpdateComposerJson(composerJsonPath, "6.6.4.0")) + out := readJSON(t, composerJsonPath) + requireMap := out["require"].(map[string]any) + assert.Equal(t, ">=5", requireMap["symfony/runtime"]) +} diff --git a/internal/projectupgrade/errors.go b/internal/projectupgrade/errors.go new file mode 100644 index 00000000..ea56d8ef --- /dev/null +++ b/internal/projectupgrade/errors.go @@ -0,0 +1,5 @@ +package projectupgrade + +import "errors" + +var errNoShopwareInLock = errors.New("no shopware/core or shopware/platform entry found in composer.lock") diff --git a/internal/projectupgrade/plugins.go b/internal/projectupgrade/plugins.go new file mode 100644 index 00000000..aaf7d8f9 --- /dev/null +++ b/internal/projectupgrade/plugins.go @@ -0,0 +1,187 @@ +package projectupgrade + +import ( + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + + "github.com/shyim/go-version" + + "github.com/shopware/shopware-cli/internal/packagist" +) + +// composerPluginType is the composer "type" used by Shopware platform plugins. +const composerPluginType = "shopware-platform-plugin" + +// pluginShopwarePackages are the Shopware first-party packages a plugin can +// declare a constraint against. If any constraint cannot be satisfied by the +// target version, the plugin is considered incompatible. +var pluginShopwarePackages = []string{ + "shopware/core", + "shopware/administration", + "shopware/storefront", + "shopware/elasticsearch", +} + +type installedPackage struct { + Name string `json:"name"` + Type string `json:"type"` + Require map[string]string `json:"require"` + InstallPath string `json:"install-path"` +} + +type installedJSON struct { + Packages []installedPackage `json:"packages"` +} + +// RemoveIncompatiblePlugins drops symlinked custom/plugins/* entries from +// composer.json when their declared Shopware constraint is not satisfied by +// targetVersion. Composer would otherwise fail the update because the plugin +// pins us to an older shopware/core. Mirrors PluginCompatibility from the +// shopware/web-installer. +// +// Returns the list of removed plugin names so the caller can report what was +// removed. +func RemoveIncompatiblePlugins(composerJsonPath, targetVersion string) ([]string, error) { + projectDir := filepath.Dir(composerJsonPath) + + installedPath := filepath.Join(projectDir, "vendor", "composer", "installed.json") + + data, err := os.ReadFile(installedPath) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil, nil + } + + return nil, fmt.Errorf("read installed.json: %w", err) + } + + var installed installedJSON + + if err := json.Unmarshal(data, &installed); err != nil { + return nil, fmt.Errorf("parse installed.json: %w", err) + } + + target, err := version.NewVersion(strings.TrimPrefix(targetVersion, "v")) + if err != nil { + return nil, fmt.Errorf("parse target version: %w", err) + } + + incompatible := make([]string, 0) + + for _, pkg := range installed.Packages { + if pkg.Type != composerPluginType { + continue + } + + if !isInstalledUnderCustomPlugins(projectDir, pkg.InstallPath) { + continue + } + + if pluginSatisfies(pkg.Require, target) { + continue + } + + incompatible = append(incompatible, pkg.Name) + } + + if len(incompatible) == 0 { + return nil, nil + } + + composerJson, err := packagist.ReadComposerJson(composerJsonPath) + if err != nil { + return nil, err + } + + removed := make([]string, 0, len(incompatible)) + + for _, name := range incompatible { + if _, ok := composerJson.Require[name]; ok { + delete(composerJson.Require, name) + removed = append(removed, name) + } + } + + if len(removed) == 0 { + return nil, nil + } + + if err := composerJson.Save(); err != nil { + return nil, err + } + + return removed, nil +} + +func isInstalledUnderCustomPlugins(projectDir, installPath string) bool { + if installPath == "" { + return false + } + + // install-path is recorded relative to vendor/composer. + absPath := installPath + if !filepath.IsAbs(absPath) { + absPath = filepath.Join(projectDir, "vendor", "composer", installPath) + } + + resolved, err := filepath.EvalSymlinks(absPath) + if err != nil { + resolved = filepath.Clean(absPath) + } + + customPlugins := filepath.Join(projectDir, "custom", "plugins") + resolvedCustom, err := filepath.EvalSymlinks(customPlugins) + if err != nil { + resolvedCustom = filepath.Clean(customPlugins) + } + + rel, err := filepath.Rel(resolvedCustom, resolved) + if err != nil { + return false + } + + if rel == "." || rel == "" { + return false + } + + if strings.HasPrefix(rel, "..") { + return false + } + + // Direct child of custom/plugins (a single plugin directory). + return !strings.ContainsRune(rel, filepath.Separator) +} + +func pluginSatisfies(requires map[string]string, target *version.Version) bool { + for dep, constraint := range requires { + if !containsString(pluginShopwarePackages, dep) { + continue + } + + c, err := version.NewConstraint(constraint) + if err != nil { + continue + } + + if !c.Check(target) { + return false + } + } + + return true +} + +func containsString(haystack []string, needle string) bool { + for _, item := range haystack { + if item == needle { + return true + } + } + + return false +} diff --git a/internal/projectupgrade/plugins_test.go b/internal/projectupgrade/plugins_test.go new file mode 100644 index 00000000..e2dc0330 --- /dev/null +++ b/internal/projectupgrade/plugins_test.go @@ -0,0 +1,97 @@ +package projectupgrade + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func writeInstalledJSON(t *testing.T, projectDir string, packages []installedPackage) { + t.Helper() + + installedDir := filepath.Join(projectDir, "vendor", "composer") + require.NoError(t, os.MkdirAll(installedDir, 0o755)) + + data, err := json.MarshalIndent(installedJSON{Packages: packages}, "", " ") + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(installedDir, "installed.json"), data, 0o644)) +} + +func TestRemoveIncompatiblePluginsRemovesCustomPluginsThatDontSatisfyTarget(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + composerJsonPath := filepath.Join(dir, "composer.json") + + require.NoError(t, os.MkdirAll(filepath.Join(dir, "custom", "plugins", "Incompatible"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(dir, "custom", "plugins", "Compatible"), 0o755)) + + writeJSON(t, composerJsonPath, map[string]any{ + "name": "shopware/production", + "require": map[string]any{ + "shopware/core": "6.5.8.0", + "vendor/incompat": "*", + "vendor/compat": "*", + "unrelated/package": "^1.0", + }, + }) + + writeInstalledJSON(t, dir, []installedPackage{ + { + Name: "vendor/incompat", + Type: composerPluginType, + InstallPath: "../../custom/plugins/Incompatible", + Require: map[string]string{ + "shopware/core": "~6.5.0", + }, + }, + { + Name: "vendor/compat", + Type: composerPluginType, + InstallPath: "../../custom/plugins/Compatible", + Require: map[string]string{ + "shopware/core": "^6.5", + }, + }, + { + Name: "vendor/composer-installed", + Type: composerPluginType, + InstallPath: "../vendor/installed", + Require: map[string]string{ + "shopware/core": "~6.5.0", + }, + }, + }) + + removed, err := RemoveIncompatiblePlugins(composerJsonPath, "6.6.4.0") + require.NoError(t, err) + assert.Equal(t, []string{"vendor/incompat"}, removed) + + out := readJSON(t, composerJsonPath) + requireMap := out["require"].(map[string]any) + _, stillThere := requireMap["vendor/incompat"] + assert.False(t, stillThere, "incompatible plugin should be removed from composer.json") + assert.Contains(t, requireMap, "vendor/compat", "compatible plugin must remain") + assert.Contains(t, requireMap, "unrelated/package", "unrelated package must remain") +} + +func TestRemoveIncompatiblePluginsNoInstalledJSONReturnsNil(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + composerJsonPath := filepath.Join(dir, "composer.json") + writeJSON(t, composerJsonPath, map[string]any{ + "name": "shopware/production", + "require": map[string]any{ + "shopware/core": "6.5.8.0", + }, + }) + + removed, err := RemoveIncompatiblePlugins(composerJsonPath, "6.6.4.0") + require.NoError(t, err) + assert.Empty(t, removed) +} diff --git a/internal/projectupgrade/releases.go b/internal/projectupgrade/releases.go new file mode 100644 index 00000000..fdbd0bea --- /dev/null +++ b/internal/projectupgrade/releases.go @@ -0,0 +1,84 @@ +package projectupgrade + +import ( + "sort" + "strconv" + "strings" + + "github.com/shyim/go-version" +) + +// FilterUpdateVersions returns the upgrade target versions appropriate for +// currentVersion: the next major version's releases first (newest first), +// followed by the remaining patches of the current major. This mirrors the +// version filtering applied by `ReleaseInfoProvider::fetchUpdateVersions` in +// shopware/web-installer. +// +// Release candidates and any version older than or equal to currentVersion +// are dropped. +func FilterUpdateVersions(currentVersion *version.Version, allVersions []string) []string { + parsed := make([]*version.Version, 0, len(allVersions)) + + for _, raw := range allVersions { + if strings.Contains(strings.ToLower(raw), "rc") { + continue + } + + v, err := version.NewVersion(raw) + if err != nil { + continue + } + + if !v.GreaterThan(currentVersion) { + continue + } + + parsed = append(parsed, v) + } + + sort.Slice(parsed, func(i, j int) bool { + return parsed[i].GreaterThan(parsed[j]) + }) + + byMajor := map[string][]string{} + for _, v := range parsed { + major := majorBranch(v) + byMajor[major] = append(byMajor[major], v.String()) + } + + currentMajor := majorBranch(currentVersion) + nextMajor := nextMajor(currentMajor) + + result := make([]string, 0) + if list, ok := byMajor[nextMajor]; ok { + result = append(result, list...) + } + if list, ok := byMajor[currentMajor]; ok { + result = append(result, list...) + } + + return result +} + +func majorBranch(v *version.Version) string { + segments := v.Segments() + if len(segments) < 2 { + return v.String() + } + + return strconv.Itoa(segments[0]) + "." + strconv.Itoa(segments[1]) +} + +func nextMajor(currentMajor string) string { + parts := strings.SplitN(currentMajor, ".", 2) + if len(parts) != 2 { + return currentMajor + } + + minor, err := strconv.Atoi(parts[1]) + if err != nil { + return currentMajor + } + + return parts[0] + "." + strconv.Itoa(minor+1) +} diff --git a/internal/projectupgrade/releases_test.go b/internal/projectupgrade/releases_test.go new file mode 100644 index 00000000..80438ba5 --- /dev/null +++ b/internal/projectupgrade/releases_test.go @@ -0,0 +1,59 @@ +package projectupgrade + +import ( + "testing" + + "github.com/shyim/go-version" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFilterUpdateVersionsReturnsCurrentAndNextMajor(t *testing.T) { + t.Parallel() + + current, err := version.NewVersion("6.5.8.0") + require.NoError(t, err) + + all := []string{ + "6.4.20.0", // older, dropped + "6.5.8.0", // equal, dropped + "6.5.8.1", + "6.5.9.0", + "6.6.0.0", + "6.6.1.0", + "6.7.0.0", // two majors away, dropped + } + + result := FilterUpdateVersions(current, all) + + // next major (6.6) versions come first, descending, then current major (6.5) versions descending. + assert.Equal(t, []string{"6.6.1.0", "6.6.0.0", "6.5.9.0", "6.5.8.1"}, result) +} + +func TestFilterUpdateVersionsDropsReleaseCandidates(t *testing.T) { + t.Parallel() + + current, err := version.NewVersion("6.5.8.0") + require.NoError(t, err) + + all := []string{ + "6.5.9.0", + "6.6.0.0-rc1", + "6.6.0.0-RC2", + "6.6.0.0", + } + + result := FilterUpdateVersions(current, all) + assert.Equal(t, []string{"6.6.0.0", "6.5.9.0"}, result) +} + +func TestFilterUpdateVersionsReturnsEmptyWhenLatest(t *testing.T) { + t.Parallel() + + current, err := version.NewVersion("6.6.5.0") + require.NoError(t, err) + + all := []string{"6.5.0.0", "6.5.8.0", "6.6.5.0"} + result := FilterUpdateVersions(current, all) + assert.Empty(t, result) +} From afdb998dd26151e1988df3f1684e43d27f49503c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 26 May 2026 14:12:21 +0000 Subject: [PATCH 10/38] feat(project): add devtui-styled wizard to `project upgrade` The interactive flow is now a small bubbletea Program that mirrors the install-wizard / setup-guide visual idiom: - Welcome card (cowsay mascot) with current version + project root - Step 1: select target version (RenderSelectList) - Step 2 (when extensions are installed): compatibility lookup with spinner, then per-extension checkmark/blocker icons - Step 3: review card with from/to/executor and the full task list - Step 4: running phase with per-task spinner / checkmark / failure icons and a live tail of the composer/console output - Done card summarising success or failure, restored composer.json on failure, and listing any plugins that were dropped Non-interactive mode (`-n`) and `--to ` continue to use the existing headless flow so CI runs are unchanged. --- cmd/project/project_upgrade.go | 211 +++--- internal/projectupgrade/wizard.go | 970 +++++++++++++++++++++++++ internal/projectupgrade/wizard_test.go | 208 ++++++ 3 files changed, 1302 insertions(+), 87 deletions(-) create mode 100644 internal/projectupgrade/wizard.go create mode 100644 internal/projectupgrade/wizard_test.go diff --git a/cmd/project/project_upgrade.go b/cmd/project/project_upgrade.go index 29f6a89a..e7b69c34 100644 --- a/cmd/project/project_upgrade.go +++ b/cmd/project/project_upgrade.go @@ -2,6 +2,7 @@ package project import ( "context" + "errors" "fmt" "os" "path" @@ -15,6 +16,7 @@ import ( "github.com/spf13/cobra" account_api "github.com/shopware/shopware-cli/internal/account-api" + "github.com/shopware/shopware-cli/internal/executor" "github.com/shopware/shopware-cli/internal/extension" "github.com/shopware/shopware-cli/internal/flexmigrator" "github.com/shopware/shopware-cli/internal/projectupgrade" @@ -66,120 +68,155 @@ bin/console system:update:prepare and system:update:finish.`, return nil } - targetVersion, err := selectTargetVersion(cmd, updateVersions) + cmdExecutor, err := resolveExecutor(cmd, projectRoot) if err != nil { return err } - if err := runCompatibilityCheck(ctx, projectRoot, currentVersion, targetVersion); err != nil { - return err + // Non-interactive: keep the headless flow so CI runs stay unchanged. + if !system.IsInteractionEnabled(ctx) || cmd.Flag("to").Value.String() != "" { + return runUpgradeHeadless(cmd, projectRoot, composerJsonPath, currentVersion, updateVersions, cmdExecutor) } - confirmed := !system.IsInteractionEnabled(ctx) - if !confirmed { - if err := huh.NewConfirm(). - Title(fmt.Sprintf("Upgrade Shopware from %s to %s?", currentVersion.String(), targetVersion)). - Description("This will modify composer.json, run composer update --with-all-dependencies, and execute system:update:prepare/finish. Commit your changes before running this command."). - Value(&confirmed). - Run(); err != nil { - return err - } + // Interactive: hand off to the devtui-styled wizard. + _, extensions, err := getLocalExtensions() + if err != nil { + log.Warnf("Could not gather local extensions for compatibility check: %v", err) + extensions = nil } - if !confirmed { - return fmt.Errorf("upgrade cancelled") - } + target, success, err := projectupgrade.RunWizard(projectupgrade.WizardOptions{ + ProjectRoot: projectRoot, + ComposerJSONPath: composerJsonPath, + CurrentVersion: currentVersion, + UpdateVersions: updateVersions, + Extensions: extensions, + Executor: cmdExecutor, + }) - log.Infof("Backing up composer.json") - backup, err := os.ReadFile(composerJsonPath) - if err != nil { - return fmt.Errorf("failed to backup composer.json: %w", err) + status := "ok" + switch { + case errors.Is(err, projectupgrade.ErrCancelled): + status = "cancelled" + case err != nil: + status = "failed" + case !success: + status = "failed" } - log.Infof("Cleaning up stale recipe files") - if err := flexmigrator.CleanupByHash(projectRoot); err != nil { - return fmt.Errorf("cleanup stale files: %w", err) - } + trackUpgrade(ctx, currentVersion.String(), target, status) - log.Infof("Checking custom plugins for incompatibilities") - removed, err := projectupgrade.RemoveIncompatiblePlugins(composerJsonPath, targetVersion) - if err != nil { - restoreComposerJson(ctx, composerJsonPath, backup) - return fmt.Errorf("remove incompatible plugins: %w", err) + if errors.Is(err, projectupgrade.ErrCancelled) { + fmt.Println("Upgrade cancelled.") + return nil } - for _, name := range removed { - log.Infof("Removed incompatible plugin %s from composer.json. Re-require it once a compatible version is published.", tui.YellowText.Render(name)) - } + return err + }, +} - log.Infof("Updating composer.json to %s", targetVersion) - if err := projectupgrade.UpdateComposerJson(composerJsonPath, targetVersion); err != nil { - restoreComposerJson(ctx, composerJsonPath, backup) - return fmt.Errorf("update composer.json: %w", err) - } +func runUpgradeHeadless(cmd *cobra.Command, projectRoot, composerJsonPath string, currentVersion *version.Version, updateVersions []string, cmdExecutor executor.Executor) error { + ctx := cmd.Context() + log := logging.FromContext(ctx) - cmdExecutor, err := resolveExecutor(cmd, projectRoot) - if err != nil { - restoreComposerJson(ctx, composerJsonPath, backup) + targetVersion, err := selectTargetVersion(cmd, updateVersions) + if err != nil { + return err + } + + if err := runCompatibilityCheck(ctx, projectRoot, currentVersion, targetVersion); err != nil { + return err + } + + confirmed := !system.IsInteractionEnabled(ctx) + if !confirmed { + if err := huh.NewConfirm(). + Title(fmt.Sprintf("Upgrade Shopware from %s to %s?", currentVersion.String(), targetVersion)). + Description("This will modify composer.json, run composer update --with-all-dependencies, and execute system:update:prepare/finish. Commit your changes before running this command."). + Value(&confirmed). + Run(); err != nil { return err } + } - log.Infof("Running composer update") - composerArgs := []string{ - "update", - "--no-interaction", - "--no-scripts", - "--with-all-dependencies", - "-v", - } + if !confirmed { + return fmt.Errorf("upgrade cancelled") + } - composerCmd := cmdExecutor.ComposerCommand(ctx, composerArgs...) - composerCmd.Cmd.Stdin = cmd.InOrStdin() - composerCmd.Cmd.Stdout = cmd.OutOrStdout() - composerCmd.Cmd.Stderr = cmd.ErrOrStderr() - - composerSuccess := true - if err := composerCmd.Run(); err != nil { - composerSuccess = false - log.Errorf("composer update failed: %v", err) - restoreComposerJson(ctx, composerJsonPath, backup) - trackUpgrade(ctx, currentVersion.String(), targetVersion, "composer_update_failed") - return fmt.Errorf("composer update failed, composer.json was restored: %w", err) - } + log.Infof("Backing up composer.json") + backup, err := os.ReadFile(composerJsonPath) + if err != nil { + return fmt.Errorf("failed to backup composer.json: %w", err) + } - log.Infof("Running bin/console system:update:prepare") - prepareCmd := cmdExecutor.ConsoleCommand(ctx, "system:update:prepare", "--no-interaction") - prepareCmd.Cmd.Stdin = cmd.InOrStdin() - prepareCmd.Cmd.Stdout = cmd.OutOrStdout() - prepareCmd.Cmd.Stderr = cmd.ErrOrStderr() + log.Infof("Cleaning up stale recipe files") + if err := flexmigrator.CleanupByHash(projectRoot); err != nil { + return fmt.Errorf("cleanup stale files: %w", err) + } - if err := prepareCmd.Run(); err != nil { - trackUpgrade(ctx, currentVersion.String(), targetVersion, "system_update_prepare_failed") - return fmt.Errorf("system:update:prepare failed: %w", err) - } + log.Infof("Checking custom plugins for incompatibilities") + removed, err := projectupgrade.RemoveIncompatiblePlugins(composerJsonPath, targetVersion) + if err != nil { + restoreComposerJson(ctx, composerJsonPath, backup) + return fmt.Errorf("remove incompatible plugins: %w", err) + } - log.Infof("Running bin/console system:update:finish") - finishCmd := cmdExecutor.ConsoleCommand(ctx, "system:update:finish", "--no-interaction") - finishCmd.Cmd.Stdin = cmd.InOrStdin() - finishCmd.Cmd.Stdout = cmd.OutOrStdout() - finishCmd.Cmd.Stderr = cmd.ErrOrStderr() + for _, name := range removed { + log.Infof("Removed incompatible plugin %s from composer.json. Re-require it once a compatible version is published.", tui.YellowText.Render(name)) + } - if err := finishCmd.Run(); err != nil { - trackUpgrade(ctx, currentVersion.String(), targetVersion, "system_update_finish_failed") - return fmt.Errorf("system:update:finish failed: %w", err) - } + log.Infof("Updating composer.json to %s", targetVersion) + if err := projectupgrade.UpdateComposerJson(composerJsonPath, targetVersion); err != nil { + restoreComposerJson(ctx, composerJsonPath, backup) + return fmt.Errorf("update composer.json: %w", err) + } - status := "ok" - if !composerSuccess { - status = "composer_update_failed" - } + log.Infof("Running composer update") + composerArgs := []string{ + "update", + "--no-interaction", + "--no-scripts", + "--with-all-dependencies", + "-v", + } + + composerCmd := cmdExecutor.ComposerCommand(ctx, composerArgs...) + composerCmd.Cmd.Stdin = cmd.InOrStdin() + composerCmd.Cmd.Stdout = cmd.OutOrStdout() + composerCmd.Cmd.Stderr = cmd.ErrOrStderr() - trackUpgrade(ctx, currentVersion.String(), targetVersion, status) + if err := composerCmd.Run(); err != nil { + log.Errorf("composer update failed: %v", err) + restoreComposerJson(ctx, composerJsonPath, backup) + trackUpgrade(ctx, currentVersion.String(), targetVersion, "composer_update_failed") + return fmt.Errorf("composer update failed, composer.json was restored: %w", err) + } - fmt.Printf("\n%s\n", tui.GreenText.Render(fmt.Sprintf("Shopware was upgraded from %s to %s", currentVersion.String(), targetVersion))) + log.Infof("Running bin/console system:update:prepare") + prepareCmd := cmdExecutor.ConsoleCommand(ctx, "system:update:prepare", "--no-interaction") + prepareCmd.Cmd.Stdin = cmd.InOrStdin() + prepareCmd.Cmd.Stdout = cmd.OutOrStdout() + prepareCmd.Cmd.Stderr = cmd.ErrOrStderr() - return nil - }, + if err := prepareCmd.Run(); err != nil { + trackUpgrade(ctx, currentVersion.String(), targetVersion, "system_update_prepare_failed") + return fmt.Errorf("system:update:prepare failed: %w", err) + } + + log.Infof("Running bin/console system:update:finish") + finishCmd := cmdExecutor.ConsoleCommand(ctx, "system:update:finish", "--no-interaction") + finishCmd.Cmd.Stdin = cmd.InOrStdin() + finishCmd.Cmd.Stdout = cmd.OutOrStdout() + finishCmd.Cmd.Stderr = cmd.ErrOrStderr() + + if err := finishCmd.Run(); err != nil { + trackUpgrade(ctx, currentVersion.String(), targetVersion, "system_update_finish_failed") + return fmt.Errorf("system:update:finish failed: %w", err) + } + + trackUpgrade(ctx, currentVersion.String(), targetVersion, "ok") + fmt.Printf("\n%s\n", tui.GreenText.Render(fmt.Sprintf("Shopware was upgraded from %s to %s", currentVersion.String(), targetVersion))) + return nil } func selectTargetVersion(cmd *cobra.Command, updateVersions []string) (string, error) { @@ -311,5 +348,5 @@ func trackUpgrade(ctx context.Context, fromVersion, toVersion, status string) { func init() { projectRootCmd.AddCommand(projectUpgradeCmd) - projectUpgradeCmd.Flags().String("to", "", "Target Shopware version. Skips the interactive selection prompt.") + projectUpgradeCmd.Flags().String("to", "", "Target Shopware version. Skips the interactive wizard.") } diff --git a/internal/projectupgrade/wizard.go b/internal/projectupgrade/wizard.go new file mode 100644 index 00000000..7cb4f85d --- /dev/null +++ b/internal/projectupgrade/wizard.go @@ -0,0 +1,970 @@ +package projectupgrade + +import ( + "bufio" + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "strings" + "time" + + "charm.land/bubbles/v2/spinner" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/shyim/go-version" + + account_api "github.com/shopware/shopware-cli/internal/account-api" + "github.com/shopware/shopware-cli/internal/executor" + "github.com/shopware/shopware-cli/internal/flexmigrator" + "github.com/shopware/shopware-cli/internal/tui" +) + +// streamBufferSize is the buffered channel size used when streaming +// subprocess output to the wizard. +const streamBufferSize = 50 + +// maxLogLines caps how many output lines we keep in the running phase so the +// view doesn't grow unbounded. +const maxLogLines = 18 + +// WizardOptions configures a single run of the upgrade wizard. +type WizardOptions struct { + ProjectRoot string + ComposerJSONPath string + CurrentVersion *version.Version + UpdateVersions []string + Extensions map[string]string + Executor executor.Executor +} + +type phase int + +const ( + phaseWelcome phase = iota + phaseSelectVersion + phaseCompatCheck + phaseCompatResult + phaseReview + phaseRunning + phaseDone +) + +type taskStatus int + +const ( + taskPending taskStatus = iota + taskRunning + taskDone + taskFailed + taskSkipped +) + +// task tracks one of the upgrade stages displayed in the running phase. +type task struct { + label string + status taskStatus + detail string +} + +// taskCleanup, taskPlugins, ... are stable indices into model.tasks. +const ( + taskBackup = iota + taskCleanup + taskPlugins + taskComposerJSON + taskComposerUpdate + taskSystemPrepare + taskSystemFinish +) + +// wizardMsg variants advance the upgrade state machine. +type ( + compatLoadedMsg struct { + updates []account_api.UpdateCheckExtensionCompatibility + err error + } + taskCompleteMsg struct { + task int + err error + detail string + composerBackup []byte + pluginsRemoved []string + } + startNextTaskMsg struct{} + logLineMsg string + logDoneMsg struct{} + upgradeDoneMsg struct { + err error + } +) + +// wizardModel is a small standalone bubbletea Program that walks the user +// through the Shopware upgrade in the same visual idiom as devtui's setup +// guide and install wizard. +type wizardModel struct { + opts WizardOptions + + phase phase + + versionCursor int + targetVersion string + confirmYes bool + composerBackup []byte + pluginsRemoved []string + compatUpdates []account_api.UpdateCheckExtensionCompatibility + compatHasBlock bool + compatErr error + tasks []task + currentTask int + logLines []string + logChan chan string + finalErr error + finished bool + spinner spinner.Model + compatLoading bool + cancelExecution context.CancelFunc +} + +// RunWizard runs the interactive upgrade wizard. It returns the selected +// target version, whether the upgrade completed successfully, and any error +// encountered. A user cancellation returns ErrCancelled. +func RunWizard(opts WizardOptions) (string, bool, error) { + s := spinner.New( + spinner.WithSpinner(spinner.Dot), + spinner.WithStyle(lipgloss.NewStyle().Foreground(tui.BrandColor)), + ) + + m := wizardModel{ + opts: opts, + phase: phaseWelcome, + confirmYes: true, + versionCursor: 0, + spinner: s, + tasks: defaultTasks(), + } + + prog := tea.NewProgram(m) + final, err := prog.Run() + if err != nil { + return "", false, err + } + + fm, _ := final.(wizardModel) + if fm.cancelExecution != nil { + fm.cancelExecution() + } + + if !fm.finished { + return fm.targetVersion, false, ErrCancelled + } + + return fm.targetVersion, fm.finalErr == nil, fm.finalErr +} + +// ErrCancelled is returned when the user exits the wizard before the upgrade +// completes (e.g. via q / ctrl+c or selecting the cancel button). +var ErrCancelled = errors.New("upgrade cancelled by user") + +func defaultTasks() []task { + return []task{ + {label: "Back up composer.json"}, + {label: "Clean up stale recipe files"}, + {label: "Remove incompatible custom plugins"}, + {label: "Rewrite composer.json"}, + {label: "composer update --with-all-dependencies"}, + {label: "bin/console system:update:prepare"}, + {label: "bin/console system:update:finish"}, + } +} + +func (m wizardModel) Init() tea.Cmd { + return m.spinner.Tick +} + +func (m wizardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyPressMsg: + return m.updateKey(msg) + + case spinner.TickMsg: + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + return m, cmd + + case compatLoadedMsg: + m.compatLoading = false + m.compatErr = msg.err + m.compatUpdates = msg.updates + for _, u := range msg.updates { + if u.Status.IsBlocker() { + m.compatHasBlock = true + break + } + } + m.phase = phaseCompatResult + m.confirmYes = !m.compatHasBlock + return m, nil + + case startNextTaskMsg: + return m.startTask() + + case taskCompleteMsg: + if msg.composerBackup != nil { + m.composerBackup = msg.composerBackup + } + if msg.pluginsRemoved != nil { + m.pluginsRemoved = msg.pluginsRemoved + } + if msg.task < len(m.tasks) { + if msg.err != nil { + m.tasks[msg.task].status = taskFailed + m.tasks[msg.task].detail = msg.err.Error() + } else { + m.tasks[msg.task].status = taskDone + if msg.detail != "" { + m.tasks[msg.task].detail = msg.detail + } + } + } + if msg.err != nil { + m.finalErr = msg.err + m.finished = true + m.phase = phaseDone + return m, nil + } + m.currentTask++ + if m.currentTask >= len(m.tasks) { + m.finished = true + m.phase = phaseDone + return m, nil + } + return m, func() tea.Msg { return startNextTaskMsg{} } + + case logLineMsg: + m.appendLog(string(msg)) + return m, m.readNextLog() + + case logDoneMsg: + m.logChan = nil + return m, nil + } + + return m, nil +} + +func (m wizardModel) updateKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + key := msg.String() + if key == "ctrl+c" { + if m.cancelExecution != nil { + m.cancelExecution() + } + return m, tea.Quit + } + + switch m.phase { + case phaseWelcome: + return m.updateWelcome(key) + case phaseSelectVersion: + return m.updateSelectVersion(key) + case phaseCompatCheck: + return m, nil + case phaseCompatResult: + return m.updateCompatResult(key) + case phaseReview: + return m.updateReview(key) + case phaseRunning: + return m, nil + case phaseDone: + if key == "q" || key == "enter" || key == "esc" { + return m, tea.Quit + } + } + return m, nil +} + +func (m wizardModel) updateWelcome(key string) (tea.Model, tea.Cmd) { + switch key { + case "left", "h": + m.confirmYes = true + case "right", "l": + m.confirmYes = false + case "tab": + m.confirmYes = !m.confirmYes + case "q", "esc": + return m, tea.Quit + case "enter": + if !m.confirmYes { + return m, tea.Quit + } + m.phase = phaseSelectVersion + return m, nil + } + return m, nil +} + +func (m wizardModel) updateSelectVersion(key string) (tea.Model, tea.Cmd) { + switch key { + case "up", "k": + if m.versionCursor > 0 { + m.versionCursor-- + } + case "down", "j": + if m.versionCursor < len(m.opts.UpdateVersions)-1 { + m.versionCursor++ + } + case "q", "esc": + return m, tea.Quit + case "enter": + m.targetVersion = m.opts.UpdateVersions[m.versionCursor] + if len(m.opts.Extensions) == 0 { + m.phase = phaseReview + m.confirmYes = true + return m, nil + } + m.phase = phaseCompatCheck + m.compatLoading = true + return m, tea.Batch(m.spinner.Tick, m.loadCompatibility()) + } + return m, nil +} + +func (m wizardModel) updateCompatResult(key string) (tea.Model, tea.Cmd) { + switch key { + case "left", "h": + m.confirmYes = true + case "right", "l": + m.confirmYes = false + case "tab": + m.confirmYes = !m.confirmYes + case "q", "esc": + return m, tea.Quit + case "enter": + if !m.confirmYes { + return m, tea.Quit + } + m.phase = phaseReview + m.confirmYes = true + return m, nil + } + return m, nil +} + +func (m wizardModel) updateReview(key string) (tea.Model, tea.Cmd) { + switch key { + case "left", "h": + m.confirmYes = true + case "right", "l": + m.confirmYes = false + case "tab": + m.confirmYes = !m.confirmYes + case "q", "esc": + return m, tea.Quit + case "enter": + if !m.confirmYes { + return m, tea.Quit + } + m.phase = phaseRunning + m.currentTask = 0 + return m, func() tea.Msg { return startNextTaskMsg{} } + } + return m, nil +} + +func (m *wizardModel) appendLog(line string) { + m.logLines = append(m.logLines, line) + if len(m.logLines) > maxLogLines { + m.logLines = m.logLines[len(m.logLines)-maxLogLines:] + } +} + +func (m wizardModel) readNextLog() tea.Cmd { + ch := m.logChan + if ch == nil { + return nil + } + return func() tea.Msg { + line, ok := <-ch + if !ok { + return logDoneMsg{} + } + return logLineMsg(line) + } +} + +// loadCompatibility queries the Shopware account API for extension +// compatibility against the chosen target version. +func (m wizardModel) loadCompatibility() tea.Cmd { + requests := make([]account_api.UpdateCheckExtension, 0, len(m.opts.Extensions)) + for name, v := range m.opts.Extensions { + requests = append(requests, account_api.UpdateCheckExtension{Name: name, Version: v}) + } + currentVersion := m.opts.CurrentVersion.String() + targetVersion := m.targetVersion + + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + updates, err := account_api.GetFutureExtensionUpdates(ctx, currentVersion, targetVersion, requests) + if err != nil { + return compatLoadedMsg{err: err} + } + + for _, name := range requests { + found := false + for _, update := range updates { + if update.Name == name.Name { + found = true + break + } + } + + if !found { + updates = append(updates, account_api.UpdateCheckExtensionCompatibility{ + Name: name.Name, + Status: account_api.UpdateCheckExtensionCompatibilityStatus{ + Label: "Not available in Store", + }, + }) + } + } + + return compatLoadedMsg{updates: updates} + } +} + +func (m wizardModel) startTask() (tea.Model, tea.Cmd) { + if m.currentTask >= len(m.tasks) { + m.finished = true + m.phase = phaseDone + return m, nil + } + + m.tasks[m.currentTask].status = taskRunning + + switch m.currentTask { + case taskBackup: + return m, m.runBackup() + case taskCleanup: + return m, m.runCleanup() + case taskPlugins: + return m, m.runRemovePlugins() + case taskComposerJSON: + return m, m.runUpdateComposer() + case taskComposerUpdate: + return m.startComposerUpdate() + case taskSystemPrepare: + return m.startSystemUpdate("system:update:prepare", taskSystemPrepare) + case taskSystemFinish: + return m.startSystemUpdate("system:update:finish", taskSystemFinish) + } + + return m, nil +} + +func (m wizardModel) runBackup() tea.Cmd { + composerJSONPath := m.opts.ComposerJSONPath + idx := taskBackup + return func() tea.Msg { + data, err := os.ReadFile(composerJSONPath) + if err != nil { + return taskCompleteMsg{task: idx, err: fmt.Errorf("read composer.json: %w", err)} + } + return taskCompleteMsg{task: idx, detail: fmt.Sprintf("%d bytes", len(data)), composerBackup: data} + } +} + +func (m wizardModel) runCleanup() tea.Cmd { + projectRoot := m.opts.ProjectRoot + idx := taskCleanup + return func() tea.Msg { + if err := flexmigrator.CleanupByHash(projectRoot); err != nil { + return taskCompleteMsg{task: idx, err: err} + } + return taskCompleteMsg{task: idx} + } +} + +func (m wizardModel) runRemovePlugins() tea.Cmd { + composerJSONPath := m.opts.ComposerJSONPath + target := m.targetVersion + idx := taskPlugins + restore := m.composerBackup + return func() tea.Msg { + removed, err := RemoveIncompatiblePlugins(composerJSONPath, target) + if err != nil { + _ = os.WriteFile(composerJSONPath, restore, 0o644) + return taskCompleteMsg{task: idx, err: err} + } + detail := "no incompatibilities" + if len(removed) > 0 { + detail = fmt.Sprintf("removed %d incompatible plugin(s)", len(removed)) + } + if removed == nil { + removed = []string{} + } + return taskCompleteMsg{task: idx, detail: detail, pluginsRemoved: removed} + } +} + +func (m wizardModel) runUpdateComposer() tea.Cmd { + composerJSONPath := m.opts.ComposerJSONPath + target := m.targetVersion + idx := taskComposerJSON + restore := m.composerBackup + return func() tea.Msg { + if err := UpdateComposerJson(composerJSONPath, target); err != nil { + _ = os.WriteFile(composerJSONPath, restore, 0o644) + return taskCompleteMsg{task: idx, err: err} + } + return taskCompleteMsg{task: idx, detail: "pinned to " + target} + } +} + +func (m wizardModel) startComposerUpdate() (tea.Model, tea.Cmd) { + ctx, cancel := context.WithCancel(context.Background()) + m.cancelExecution = cancel + + ch := make(chan string, streamBufferSize) + m.logChan = ch + m.logLines = nil + + args := []string{ + "update", + "--no-interaction", + "--no-scripts", + "--with-all-dependencies", + "-v", + } + p := m.opts.Executor.ComposerCommand(ctx, args...) + + restore := m.composerBackup + composerJSONPath := m.opts.ComposerJSONPath + idx := taskComposerUpdate + + doneCmd := func() tea.Msg { + err := streamCmdOutput(p.Cmd, ch, true) + if err != nil { + _ = os.WriteFile(composerJSONPath, restore, 0o644) + } + return taskCompleteMsg{task: idx, err: err} + } + + return m, tea.Batch(m.readNextLog(), doneCmd) +} + +func (m wizardModel) startSystemUpdate(consoleCmd string, idx int) (tea.Model, tea.Cmd) { + ctx, cancel := context.WithCancel(context.Background()) + m.cancelExecution = cancel + + ch := make(chan string, streamBufferSize) + m.logChan = ch + m.logLines = nil + + p := m.opts.Executor.ConsoleCommand(ctx, consoleCmd, "--no-interaction") + + doneCmd := func() tea.Msg { + err := streamCmdOutput(p.Cmd, ch, true) + return taskCompleteMsg{task: idx, err: err} + } + + return m, tea.Batch(m.readNextLog(), doneCmd) +} + +// streamCmdOutput starts cmd, fans stdout (or stderr) lines into ch, and +// closes ch when done. The returned error is the process exit error, if any. +func streamCmdOutput(cmd *exec.Cmd, ch chan<- string, useStdout bool) error { + var pipe io.Reader + var err error + if useStdout { + pipe, err = cmd.StdoutPipe() + if err == nil { + cmd.Stderr = cmd.Stdout + } + } else { + pipe, err = cmd.StderrPipe() + if err == nil { + cmd.Stdout = cmd.Stderr + } + } + if err != nil { + close(ch) + return err + } + + if err := cmd.Start(); err != nil { + close(ch) + return err + } + + scanner := bufio.NewScanner(pipe) + scanner.Buffer(make([]byte, 64*1024), 1024*1024) + for scanner.Scan() { + ch <- scanner.Text() + } + close(ch) + + if err := scanner.Err(); err != nil { + _ = cmd.Wait() + return err + } + return cmd.Wait() +} + +// --- View --- + +func (m wizardModel) View() tea.View { + v := tea.NewView(m.viewContent()) + v.AltScreen = true + return v +} + +func (m wizardModel) viewContent() string { + switch m.phase { + case phaseWelcome: + return m.viewWelcome() + case phaseSelectVersion: + return m.viewSelectVersion() + case phaseCompatCheck: + return m.viewCompatCheck() + case phaseCompatResult: + return m.viewCompatResult() + case phaseReview: + return m.viewReview() + case phaseRunning: + return m.viewRunning() + case phaseDone: + return m.viewDone() + } + return "" +} + +func (m wizardModel) totalSteps() int { + if len(m.opts.Extensions) == 0 { + return 3 // Select version, Review, Run + } + return 4 // + Compatibility check +} + +func (m wizardModel) stepNum(p phase) int { + switch p { + case phaseSelectVersion: + return 1 + case phaseCompatCheck, phaseCompatResult: + return 2 + case phaseReview: + if len(m.opts.Extensions) == 0 { + return 2 + } + return 3 + case phaseRunning: + if len(m.opts.Extensions) == 0 { + return 3 + } + return 4 + } + return 0 +} + +func (m wizardModel) viewWelcome() string { + var b strings.Builder + b.WriteString(tui.TextBadge("Upgrade")) + b.WriteString("\n\n") + b.WriteString(lipgloss.NewStyle().Bold(true).Foreground(tui.BrandColor).Render("Upgrade Shopware to a newer version")) + b.WriteString("\n\n") + b.WriteString(tui.DimStyle.Render("This wizard mirrors the shopware/web-installer flow:")) + b.WriteString("\n\n") + for _, line := range []string{ + "Back up composer.json before any change", + "Clean up stale recipe-managed files (md5-matched)", + "Drop incompatible custom plugins from composer.json", + "Rewrite composer.json to pin the target version", + "Run composer update --with-all-dependencies --no-scripts", + "Run bin/console system:update:prepare + finish", + } { + b.WriteString(tui.DimStyle.Render(" • ")) + b.WriteString(tui.LabelStyle.Render(line)) + b.WriteString("\n") + } + b.WriteString("\n") + b.WriteString(tui.SectionDivider(tui.PhaseCardWidth - 6)) + b.WriteString(tui.KVRow("Current version", tui.BoldText.Render(m.opts.CurrentVersion.String()))) + b.WriteString(tui.KVRow("Project root", tui.DimStyle.Render(m.opts.ProjectRoot))) + if len(m.opts.Extensions) > 0 { + b.WriteString(tui.KVRow("Installed extensions", tui.LabelStyle.Render(fmt.Sprintf("%d", len(m.opts.Extensions))))) + } + b.WriteString("\n") + b.WriteString(renderConfirmButtons("Begin upgrade", "Cancel", m.confirmYes)) + b.WriteString("\n\n") + b.WriteString(m.footer( + tui.Shortcut{Key: "←/→", Label: "Select"}, + tui.Shortcut{Key: "enter", Label: "Confirm"}, + tui.Shortcut{Key: "ctrl+c", Label: "Exit"}, + )) + return tui.RenderPhaseCardCowsay("Let's get this Shopware up to date!", b.String()) +} + +func (m wizardModel) viewSelectVersion() string { + var b strings.Builder + b.WriteString(stepBadge(m.stepNum(phaseSelectVersion), m.totalSteps())) + b.WriteString("\n\n") + + opts := make([]tui.SelectOption, len(m.opts.UpdateVersions)) + for i, v := range m.opts.UpdateVersions { + detail := "" + if i == 0 { + detail = "latest" + } + opts[i] = tui.SelectOption{Label: v, Detail: detail} + } + b.WriteString(tui.RenderSelectList( + "Select target version", + "Pick the Shopware version to upgrade to. Next-major releases are listed first.", + opts, + m.versionCursor, + )) + b.WriteString("\n\n") + b.WriteString(m.footer( + tui.Shortcut{Key: "↑/↓", Label: "Select"}, + tui.Shortcut{Key: "enter", Label: "Continue"}, + tui.Shortcut{Key: "ctrl+c", Label: "Exit"}, + )) + return tui.RenderPhaseCard(b.String()) +} + +func (m wizardModel) viewCompatCheck() string { + var b strings.Builder + b.WriteString(stepBadge(m.stepNum(phaseCompatCheck), m.totalSteps())) + b.WriteString("\n\n") + b.WriteString(tui.TitleStyle.Render("Checking extension compatibility")) + b.WriteString("\n") + b.WriteString(tui.DimStyle.Render(fmt.Sprintf("Asking the Shopware store about %d installed extension(s) against %s…", len(m.opts.Extensions), m.targetVersion))) + b.WriteString("\n\n") + b.WriteString(m.spinner.View() + " " + tui.DimStyle.Render("fetching compatibility")) + b.WriteString("\n\n") + b.WriteString(m.footer(tui.Shortcut{Key: "ctrl+c", Label: "Cancel"})) + return tui.RenderPhaseCard(b.String()) +} + +func (m wizardModel) viewCompatResult() string { + var b strings.Builder + b.WriteString(stepBadge(m.stepNum(phaseCompatResult), m.totalSteps())) + b.WriteString("\n\n") + b.WriteString(tui.TitleStyle.Render("Extension compatibility")) + b.WriteString("\n") + b.WriteString(tui.DimStyle.Render(fmt.Sprintf("Upgrade to %s", m.targetVersion))) + b.WriteString("\n\n") + + if m.compatErr != nil { + b.WriteString(lipgloss.NewStyle().Foreground(tui.ErrorColor).Render("Compatibility lookup failed: " + m.compatErr.Error())) + b.WriteString("\n") + b.WriteString(tui.DimStyle.Render("You may still proceed; the wizard cannot guarantee extensions will install.")) + b.WriteString("\n\n") + } else if len(m.compatUpdates) == 0 { + b.WriteString(tui.DimStyle.Render("No store-managed extensions to check.")) + b.WriteString("\n\n") + } else { + for _, u := range m.compatUpdates { + icon := tui.Checkmark + if u.Status.IsBlocker() { + icon = lipgloss.NewStyle().Foreground(tui.ErrorColor).Bold(true).Render("✗") + } + b.WriteString(" ") + b.WriteString(icon) + b.WriteString(" ") + b.WriteString(tui.LabelStyle.Render(u.Name)) + b.WriteString(tui.DimStyle.Render(" — " + u.Status.Label)) + b.WriteString("\n") + } + b.WriteString("\n") + } + + if m.compatHasBlock { + b.WriteString(lipgloss.NewStyle().Foreground(tui.WarnColor).Bold(true).Render("⚠ Some extensions are not compatible yet.")) + b.WriteString("\n") + b.WriteString(tui.DimStyle.Render("Continuing may break those extensions until they release updates.")) + b.WriteString("\n\n") + } + + b.WriteString(renderConfirmButtons("Continue", "Cancel", m.confirmYes)) + b.WriteString("\n\n") + b.WriteString(m.footer( + tui.Shortcut{Key: "←/→", Label: "Select"}, + tui.Shortcut{Key: "enter", Label: "Confirm"}, + tui.Shortcut{Key: "ctrl+c", Label: "Exit"}, + )) + return tui.RenderPhaseCard(b.String()) +} + +func (m wizardModel) viewReview() string { + var b strings.Builder + b.WriteString(stepBadge(m.stepNum(phaseReview), m.totalSteps())) + b.WriteString("\n\n") + b.WriteString(tui.TitleStyle.Render("Review")) + b.WriteString("\n") + b.WriteString(tui.DimStyle.Render("Confirm to apply the following changes.")) + b.WriteString("\n\n") + b.WriteString(tui.KVRow("From", tui.BoldText.Render(m.opts.CurrentVersion.String()))) + b.WriteString(tui.KVRow("To", lipgloss.NewStyle().Foreground(tui.SuccessColor).Bold(true).Render(m.targetVersion))) + if m.opts.Executor != nil { + b.WriteString(tui.KVRow("Executor", tui.LabelStyle.Render(m.opts.Executor.Type()))) + } + b.WriteString(tui.SectionDivider(tui.PhaseCardWidth - 6)) + b.WriteString(tui.DimStyle.Render("Tasks to be executed:")) + b.WriteString("\n") + for _, t := range m.tasks { + b.WriteString(tui.DimStyle.Render(" • ")) + b.WriteString(tui.LabelStyle.Render(t.label)) + b.WriteString("\n") + } + b.WriteString("\n") + b.WriteString(lipgloss.NewStyle().Foreground(tui.WarnColor).Render("⚠ Commit your changes before continuing.")) + b.WriteString("\n\n") + b.WriteString(renderConfirmButtons("Start upgrade", "Cancel", m.confirmYes)) + b.WriteString("\n\n") + b.WriteString(m.footer( + tui.Shortcut{Key: "←/→", Label: "Select"}, + tui.Shortcut{Key: "enter", Label: "Confirm"}, + tui.Shortcut{Key: "ctrl+c", Label: "Exit"}, + )) + return tui.RenderPhaseCard(b.String()) +} + +func (m wizardModel) viewRunning() string { + var b strings.Builder + b.WriteString(stepBadge(m.stepNum(phaseRunning), m.totalSteps())) + b.WriteString("\n\n") + b.WriteString(tui.TitleStyle.Render(fmt.Sprintf("Upgrading to %s", m.targetVersion))) + b.WriteString("\n") + b.WriteString(tui.DimStyle.Render("This may take a few minutes. Live output shown below.")) + b.WriteString("\n\n") + + for i, t := range m.tasks { + b.WriteString(m.renderTaskLine(i, t)) + b.WriteString("\n") + } + + if len(m.logLines) > 0 { + b.WriteString("\n") + b.WriteString(tui.SectionDivider(tui.PhaseCardWidth - 6)) + b.WriteString(tui.DimStyle.Render("Output:")) + b.WriteString("\n") + for _, line := range m.logLines { + b.WriteString(tui.DimStyle.Render(" " + truncate(line, tui.PhaseCardWidth-10))) + b.WriteString("\n") + } + } + + b.WriteString("\n") + b.WriteString(m.footer(tui.Shortcut{Key: "ctrl+c", Label: "Cancel"})) + return tui.RenderPhaseCard(b.String()) +} + +func (m wizardModel) viewDone() string { + var b strings.Builder + + if m.finalErr != nil { + b.WriteString(lipgloss.NewStyle().Bold(true).Foreground(tui.ErrorColor).Render("✗ Upgrade failed")) + b.WriteString("\n\n") + b.WriteString(lipgloss.NewStyle().Foreground(tui.ErrorColor).Render(m.finalErr.Error())) + b.WriteString("\n\n") + b.WriteString(tui.DimStyle.Render("composer.json was restored from the backup taken before the upgrade.")) + } else { + b.WriteString(lipgloss.NewStyle().Bold(true).Foreground(tui.SuccessColor).Render(fmt.Sprintf("✓ Upgraded to Shopware %s", m.targetVersion))) + b.WriteString("\n\n") + b.WriteString(tui.DimStyle.Render("All tasks completed. Verify your shop and run your test suite.")) + + if len(m.pluginsRemoved) > 0 { + b.WriteString("\n\n") + b.WriteString(tui.BoldText.Render("Removed incompatible custom plugins:")) + b.WriteString("\n") + for _, name := range m.pluginsRemoved { + b.WriteString(tui.DimStyle.Render(" • ")) + b.WriteString(tui.LabelStyle.Render(name)) + b.WriteString("\n") + } + b.WriteString(tui.DimStyle.Render("Re-require them in composer.json once they publish compatible versions.")) + } + } + + b.WriteString("\n\n") + b.WriteString(tui.SectionDivider(tui.PhaseCardWidth - 6)) + for i, t := range m.tasks { + b.WriteString(m.renderTaskLine(i, t)) + b.WriteString("\n") + } + + b.WriteString("\n") + b.WriteString(m.footer(tui.Shortcut{Key: "enter", Label: "Close"})) + return tui.RenderPhaseCard(b.String()) +} + +func (m wizardModel) renderTaskLine(i int, t task) string { + var icon string + switch t.status { + case taskRunning: + icon = m.spinner.View() + case taskDone: + icon = tui.Checkmark + case taskFailed: + icon = lipgloss.NewStyle().Foreground(tui.ErrorColor).Bold(true).Render("✗") + case taskSkipped: + icon = tui.DimStyle.Render("·") + default: + icon = tui.DimStyle.Render("○") + } + + style := tui.LabelStyle + if t.status == taskPending { + style = tui.DimStyle + } + + line := fmt.Sprintf(" %s %s", icon, style.Render(t.label)) + if t.detail != "" { + line += " " + tui.DimStyle.Render("("+t.detail+")") + } + if i == m.currentTask && t.status == taskRunning { + line = lipgloss.NewStyle().Bold(true).Render(line) + } + return line +} + +func (m wizardModel) footer(shortcuts ...tui.Shortcut) string { + return tui.ShortcutBar(shortcuts...) +} + +func stepBadge(stepNum, totalSteps int) string { + if stepNum == 0 { + return tui.TextBadge("Upgrade") + } + return tui.TextBadge(fmt.Sprintf("Step %d/%d", stepNum, totalSteps)) +} + +func renderConfirmButtons(yesLabel, noLabel string, yesActive bool) string { + yesStyle := lipgloss.NewStyle().Foreground(tui.TextColor).Background(tui.BrandColor).Padding(0, 2) + noStyle := lipgloss.NewStyle().Foreground(tui.MutedColor).Background(tui.SubtleBgColor).Padding(0, 2) + + var yes, no string + if yesActive { + yes = yesStyle.Render(yesLabel) + no = noStyle.Render(noLabel) + } else { + yes = noStyle.Render(yesLabel) + no = yesStyle.Render(noLabel) + } + return yes + " " + no +} + +func truncate(s string, max int) string { + if max <= 0 { + return s + } + if len([]rune(s)) <= max { + return s + } + r := []rune(s) + return string(r[:max-1]) + "…" +} diff --git a/internal/projectupgrade/wizard_test.go b/internal/projectupgrade/wizard_test.go new file mode 100644 index 00000000..3fa5863a --- /dev/null +++ b/internal/projectupgrade/wizard_test.go @@ -0,0 +1,208 @@ +package projectupgrade + +import ( + "testing" + + tea "charm.land/bubbletea/v2" + "github.com/shyim/go-version" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + account_api "github.com/shopware/shopware-cli/internal/account-api" +) + +func newTestModel(t *testing.T) wizardModel { + t.Helper() + + current, err := version.NewVersion("6.5.8.0") + require.NoError(t, err) + + m := wizardModel{ + opts: WizardOptions{ + ProjectRoot: "/tmp/example", + ComposerJSONPath: "/tmp/example/composer.json", + CurrentVersion: current, + UpdateVersions: []string{"6.6.4.0", "6.6.3.0", "6.5.9.0"}, + }, + phase: phaseWelcome, + confirmYes: true, + tasks: defaultTasks(), + } + return m +} + +func TestWizardWelcomeConfirmGoesToVersionSelect(t *testing.T) { + t.Parallel() + + m := newTestModel(t) + updated, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + wm := updated.(wizardModel) + assert.Equal(t, phaseSelectVersion, wm.phase) +} + +func TestWizardWelcomeCancelQuits(t *testing.T) { + t.Parallel() + + m := newTestModel(t) + m.confirmYes = false + _, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + require.NotNil(t, cmd) + msg := cmd() + _, ok := msg.(tea.QuitMsg) + assert.True(t, ok, "cancel should produce QuitMsg") +} + +func TestWizardSelectVersionMovesCursor(t *testing.T) { + t.Parallel() + + m := newTestModel(t) + m.phase = phaseSelectVersion + + updated, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyDown}) + wm := updated.(wizardModel) + assert.Equal(t, 1, wm.versionCursor) + + updated, _ = wm.Update(tea.KeyPressMsg{Code: tea.KeyDown}) + wm = updated.(wizardModel) + assert.Equal(t, 2, wm.versionCursor) + + // Past end should not wrap. + updated, _ = wm.Update(tea.KeyPressMsg{Code: tea.KeyDown}) + wm = updated.(wizardModel) + assert.Equal(t, 2, wm.versionCursor) + + updated, _ = wm.Update(tea.KeyPressMsg{Code: tea.KeyUp}) + wm = updated.(wizardModel) + assert.Equal(t, 1, wm.versionCursor) +} + +func TestWizardSelectVersionWithoutExtensionsSkipsToReview(t *testing.T) { + t.Parallel() + + m := newTestModel(t) + m.phase = phaseSelectVersion + m.versionCursor = 1 + + updated, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + wm := updated.(wizardModel) + assert.Equal(t, phaseReview, wm.phase) + assert.Equal(t, "6.6.3.0", wm.targetVersion) +} + +func TestWizardSelectVersionWithExtensionsGoesToCompatCheck(t *testing.T) { + t.Parallel() + + m := newTestModel(t) + m.opts.Extensions = map[string]string{"AcmeExtension": "1.0.0"} + m.phase = phaseSelectVersion + + updated, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + wm := updated.(wizardModel) + assert.Equal(t, phaseCompatCheck, wm.phase) + assert.True(t, wm.compatLoading) + assert.Equal(t, "6.6.4.0", wm.targetVersion) +} + +func TestWizardCompatLoadedSetsBlockerFlag(t *testing.T) { + t.Parallel() + + m := newTestModel(t) + m.phase = phaseCompatCheck + m.compatLoading = true + + updated, _ := m.Update(compatLoadedMsg{ + updates: []account_api.UpdateCheckExtensionCompatibility{ + { + Name: "Blocker", + Status: account_api.UpdateCheckExtensionCompatibilityStatus{ + Type: "violation", + Label: "Not compatible", + }, + }, + }, + }) + wm := updated.(wizardModel) + assert.False(t, wm.compatLoading) + assert.Equal(t, phaseCompatResult, wm.phase) + assert.True(t, wm.compatHasBlock) + assert.False(t, wm.confirmYes, "blocker should default the confirm to No") +} + +func TestWizardTaskCompletePersistsBackupAcrossUpdates(t *testing.T) { + t.Parallel() + + m := newTestModel(t) + m.phase = phaseRunning + m.currentTask = taskBackup + + // First task: backup captures composer.json bytes. + updated, _ := m.Update(taskCompleteMsg{ + task: taskBackup, + composerBackup: []byte(`{"name":"shopware/production"}`), + detail: "30 bytes", + }) + wm := updated.(wizardModel) + assert.Equal(t, []byte(`{"name":"shopware/production"}`), wm.composerBackup, "backup must persist for later restore-on-failure") + assert.Equal(t, taskCleanup, wm.currentTask) + assert.Equal(t, taskDone, wm.tasks[taskBackup].status) +} + +func TestWizardTaskCompleteErrorEndsRun(t *testing.T) { + t.Parallel() + + m := newTestModel(t) + m.phase = phaseRunning + m.currentTask = taskComposerUpdate + + updated, _ := m.Update(taskCompleteMsg{ + task: taskComposerUpdate, + err: assertErr("composer update failed"), + }) + wm := updated.(wizardModel) + assert.Equal(t, phaseDone, wm.phase) + assert.True(t, wm.finished) + require.Error(t, wm.finalErr) + assert.Equal(t, taskFailed, wm.tasks[taskComposerUpdate].status) +} + +func TestWizardLogLineMsgAppendsAndTrims(t *testing.T) { + t.Parallel() + + m := newTestModel(t) + for i := 0; i < maxLogLines+5; i++ { + updated, _ := m.Update(logLineMsg("line")) + m = updated.(wizardModel) + } + assert.LessOrEqual(t, len(m.logLines), maxLogLines) +} + +type stringErr string + +func (e stringErr) Error() string { return string(e) } + +func assertErr(s string) error { return stringErr(s) } + +func TestWizardRendersAllPhases(t *testing.T) { + t.Parallel() + + phases := []phase{ + phaseWelcome, + phaseSelectVersion, + phaseCompatCheck, + phaseCompatResult, + phaseReview, + phaseRunning, + phaseDone, + } + + for _, p := range phases { + p := p + t.Run(t.Name(), func(t *testing.T) { + m := newTestModel(t) + m.phase = p + m.targetVersion = "6.6.4.0" + out := m.viewContent() + assert.NotEmpty(t, out, "phase %d should render content", p) + }) + } +} From 3bfc5936f4b7752277d2dc0117c31fc30db69170 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 26 May 2026 15:03:41 +0000 Subject: [PATCH 11/38] feat(project): require clean git tree before `project upgrade` The upgrade rewrites composer.json, deletes recipe-managed files, and drops incompatible plugins. Mixing those rewrites with unrelated uncommitted changes makes it hard to review the diff or roll back, so the command now refuses to run with a dirty working tree. - Adds `git.IsRepository` and `git.WorkingTreeStatus` helpers so other commands can reuse the same checks. - When the project directory is not inside a git working tree the check is skipped (greenfield projects, tarball-installed copies). - The error message lists up to ten changed paths and points at `--allow-dirty` as the explicit override. --- cmd/project/project_upgrade.go | 48 +++++++++++++++++++++ cmd/project/project_upgrade_test.go | 65 +++++++++++++++++++++++++++++ internal/git/git.go | 30 +++++++++++++ internal/git/git_test.go | 42 +++++++++++++++++++ 4 files changed, 185 insertions(+) create mode 100644 cmd/project/project_upgrade_test.go diff --git a/cmd/project/project_upgrade.go b/cmd/project/project_upgrade.go index e7b69c34..00f897f0 100644 --- a/cmd/project/project_upgrade.go +++ b/cmd/project/project_upgrade.go @@ -7,6 +7,7 @@ import ( "os" "path" "strconv" + "strings" "time" "charm.land/huh/v2" @@ -19,6 +20,7 @@ import ( "github.com/shopware/shopware-cli/internal/executor" "github.com/shopware/shopware-cli/internal/extension" "github.com/shopware/shopware-cli/internal/flexmigrator" + "github.com/shopware/shopware-cli/internal/git" "github.com/shopware/shopware-cli/internal/projectupgrade" "github.com/shopware/shopware-cli/internal/system" "github.com/shopware/shopware-cli/internal/tracking" @@ -57,6 +59,11 @@ bin/console system:update:prepare and system:update:finish.`, log.Infof("Current Shopware version: %s", currentVersion.String()) + allowDirty, _ := cmd.Flags().GetBool("allow-dirty") + if err := ensureCleanGitTree(ctx, projectRoot, allowDirty); err != nil { + return err + } + allVersions, err := extension.GetShopwareVersions(ctx) if err != nil { return fmt.Errorf("failed to fetch available Shopware versions: %w", err) @@ -346,7 +353,48 @@ func trackUpgrade(ctx context.Context, fromVersion, toVersion, status string) { }) } +// ensureCleanGitTree aborts the upgrade if projectRoot is inside a git +// working tree that has uncommitted changes. The check is skipped when the +// directory is not a git repository (greenfield projects, vendored copies) +// or when --allow-dirty was passed. +func ensureCleanGitTree(ctx context.Context, projectRoot string, allowDirty bool) error { + if allowDirty { + return nil + } + + if !git.IsRepository(ctx, projectRoot) { + return nil + } + + changes, err := git.WorkingTreeStatus(ctx, projectRoot) + if err != nil { + return fmt.Errorf("could not read git working tree status: %w", err) + } + + if len(changes) == 0 { + return nil + } + + preview := changes + const maxPreview = 10 + suffix := "" + if len(preview) > maxPreview { + preview = preview[:maxPreview] + suffix = fmt.Sprintf("\n … and %d more", len(changes)-maxPreview) + } + + return fmt.Errorf( + "the upgrade rewrites composer.json and removes recipe-managed files, so the working tree must be clean.\n"+ + "%d uncommitted change(s) detected in %s:\n %s%s\n\nCommit or stash your changes, or rerun with --allow-dirty to override.", + len(changes), + projectRoot, + strings.Join(preview, "\n "), + suffix, + ) +} + func init() { projectRootCmd.AddCommand(projectUpgradeCmd) projectUpgradeCmd.Flags().String("to", "", "Target Shopware version. Skips the interactive wizard.") + projectUpgradeCmd.Flags().Bool("allow-dirty", false, "Allow running the upgrade even when the git working tree has uncommitted changes.") } diff --git a/cmd/project/project_upgrade_test.go b/cmd/project/project_upgrade_test.go new file mode 100644 index 00000000..9d871c96 --- /dev/null +++ b/cmd/project/project_upgrade_test.go @@ -0,0 +1,65 @@ +package project + +import ( + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func gitCmd(t *testing.T, dir string, args ...string) { + t.Helper() + c := exec.CommandContext(t.Context(), "git", args...) + c.Dir = dir + out, err := c.CombinedOutput() + if err != nil { + t.Fatalf("git %v failed: %s", args, string(out)) + } +} + +func setupTestRepo(t *testing.T) string { + t.Helper() + dir := t.TempDir() + gitCmd(t, dir, "init") + gitCmd(t, dir, "config", "commit.gpgsign", "false") + gitCmd(t, dir, "config", "user.name", "test") + gitCmd(t, dir, "config", "user.email", "test@example.com") + require.NoError(t, os.WriteFile(filepath.Join(dir, "seed"), []byte("seed"), 0o644)) + gitCmd(t, dir, "add", "seed") + gitCmd(t, dir, "commit", "-m", "seed", "--no-verify", "--no-gpg-sign") + return dir +} + +func TestEnsureCleanGitTreeSkipsNonRepo(t *testing.T) { + t.Parallel() + dir := t.TempDir() + assert.NoError(t, ensureCleanGitTree(t.Context(), dir, false)) +} + +func TestEnsureCleanGitTreeAllowsCleanRepo(t *testing.T) { + t.Parallel() + dir := setupTestRepo(t) + assert.NoError(t, ensureCleanGitTree(t.Context(), dir, false)) +} + +func TestEnsureCleanGitTreeRejectsDirtyRepo(t *testing.T) { + t.Parallel() + dir := setupTestRepo(t) + require.NoError(t, os.WriteFile(filepath.Join(dir, "untracked"), []byte("x"), 0o644)) + + err := ensureCleanGitTree(t.Context(), dir, false) + require.Error(t, err) + assert.Contains(t, err.Error(), "working tree must be clean") + assert.Contains(t, err.Error(), "untracked") +} + +func TestEnsureCleanGitTreeAllowDirtyFlagBypassesCheck(t *testing.T) { + t.Parallel() + dir := setupTestRepo(t) + require.NoError(t, os.WriteFile(filepath.Join(dir, "untracked"), []byte("x"), 0o644)) + + assert.NoError(t, ensureCleanGitTree(t.Context(), dir, true)) +} diff --git a/internal/git/git.go b/internal/git/git.go index d6e96883..d7de18c7 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -200,3 +200,33 @@ func Init(ctx context.Context, repo string) error { _, err := runGit(ctx, repo, "init") return err } + +// IsRepository reports whether path is inside a git working tree. +// Returns false (no error) when git is not installed or the directory is not +// tracked by git. +func IsRepository(ctx context.Context, path string) bool { + cmd := exec.CommandContext(ctx, "git", "rev-parse", "--is-inside-work-tree") + cmd.Dir = path + out, err := cmd.Output() + if err != nil { + return false + } + return strings.TrimSpace(string(out)) == "true" +} + +// WorkingTreeStatus reports the porcelain status of the working tree at repo. +// It returns the raw lines of `git status --porcelain`, one entry per changed +// file. An empty slice means the working tree is clean. +func WorkingTreeStatus(ctx context.Context, repo string) ([]string, error) { + cmd := exec.CommandContext(ctx, "git", "status", "--porcelain") + cmd.Dir = repo + out, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("git status: %w", err) + } + trimmed := strings.TrimRight(string(out), "\n") + if trimmed == "" { + return nil, nil + } + return strings.Split(trimmed, "\n"), nil +} diff --git a/internal/git/git_test.go b/internal/git/git_test.go index bfba1e6e..baadde9f 100644 --- a/internal/git/git_test.go +++ b/internal/git/git_test.go @@ -115,3 +115,45 @@ func prepareRepository(t *testing.T, tmpDir string) { runCommand(t, tmpDir, "config", "user.name", "test") runCommand(t, tmpDir, "config", "user.email", "test@test.de") } + +func TestIsRepositoryFalseForPlainDir(t *testing.T) { + t.Parallel() + tmpDir := t.TempDir() + assert.False(t, IsRepository(t.Context(), tmpDir)) +} + +func TestIsRepositoryTrueForInitializedRepo(t *testing.T) { + t.Parallel() + tmpDir := t.TempDir() + prepareRepository(t, tmpDir) + assert.True(t, IsRepository(t.Context(), tmpDir)) +} + +func TestWorkingTreeStatusCleanRepo(t *testing.T) { + t.Parallel() + tmpDir := t.TempDir() + prepareRepository(t, tmpDir) + _ = os.WriteFile(filepath.Join(tmpDir, "a"), []byte("hi"), 0o644) + runCommand(t, tmpDir, "add", "a") + runCommand(t, tmpDir, "commit", "-m", "initial", "--no-verify", "--no-gpg-sign") + + lines, err := WorkingTreeStatus(t.Context(), tmpDir) + assert.NoError(t, err) + assert.Empty(t, lines, "freshly committed repo should be clean") +} + +func TestWorkingTreeStatusReportsUntrackedAndModified(t *testing.T) { + t.Parallel() + tmpDir := t.TempDir() + prepareRepository(t, tmpDir) + _ = os.WriteFile(filepath.Join(tmpDir, "tracked.txt"), []byte("hi"), 0o644) + runCommand(t, tmpDir, "add", "tracked.txt") + runCommand(t, tmpDir, "commit", "-m", "initial", "--no-verify", "--no-gpg-sign") + + _ = os.WriteFile(filepath.Join(tmpDir, "tracked.txt"), []byte("modified"), 0o644) + _ = os.WriteFile(filepath.Join(tmpDir, "untracked.txt"), []byte("new"), 0o644) + + lines, err := WorkingTreeStatus(t.Context(), tmpDir) + assert.NoError(t, err) + assert.Len(t, lines, 2) +} From 611131ebe3c14da5c6ebd5b67cd005e7855fe236 Mon Sep 17 00:00:00 2001 From: Soner Date: Tue, 26 May 2026 17:32:08 +0200 Subject: [PATCH 12/38] Add sum.golang.org to allowed hosts in go_test.yml --- .github/workflows/go_test.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/go_test.yml b/.github/workflows/go_test.yml index 97c56649..b71796cc 100644 --- a/.github/workflows/go_test.yml +++ b/.github/workflows/go_test.yml @@ -32,6 +32,7 @@ jobs: proxy.golang.org:443 release-assets.githubusercontent.com:443 storage.googleapis.com:443 + sum.golang.org:443 - name: Checkout Repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # ratchet:actions/checkout@v6 From 54bf9c9e944032afb6c2e4df0ff872a5b25bd435 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 26 May 2026 15:34:09 +0000 Subject: [PATCH 13/38] feat(project): bump plugin constraints + require composer-managed plugins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before doing anything destructive, `project upgrade` now requires every directory under custom/plugins/ to be tracked by composer (i.e. appear in vendor/composer/installed.json). When plain file-drop plugins are detected the upgrade aborts with a pointer at `project autofix composer-plugins`. The `--allow-non-composer` flag opts out for projects that have not migrated yet. When a composer-managed plugin's declared shopware/core constraint is not satisfied by the upgrade target, the resolver now queries a package registry (repo.packagist.org for plain composer packages, packages.shopware.com for store.shopware.com/* packages) for the newest release whose require.shopware/core does satisfy the target and rewrites the composer.json constraint to "^". Only when no compatible release is found does the plugin fall back to being dropped, matching the old behaviour. The Shopware Packages token is read from SHOPWARE_PACKAGES_TOKEN or the project's auth.json. When neither is present and the project has store plugins the interactive flow prompts for the token (and skips store lookups gracefully if the prompt is left empty). The wizard's "Done" card now lists bumped constraints (old → new) in addition to the removed plugins, so users can see exactly what shifted. Tests: 9 new tests covering the resolver (bump, remove, registry error, no installed.json), FindNonComposerPlugins, and the ensureAllPluginsAreComposerManaged pre-flight check. All packages pass. --- cmd/project/project_upgrade.go | 107 ++++++++- cmd/project/project_upgrade_test.go | 49 ++++ internal/projectupgrade/plugins.go | 292 ++++++++++++++++++++---- internal/projectupgrade/plugins_test.go | 223 +++++++++++++++--- internal/projectupgrade/registry.go | 230 +++++++++++++++++++ internal/projectupgrade/wizard.go | 78 +++++-- 6 files changed, 886 insertions(+), 93 deletions(-) create mode 100644 internal/projectupgrade/registry.go diff --git a/cmd/project/project_upgrade.go b/cmd/project/project_upgrade.go index 00f897f0..95ecfc2f 100644 --- a/cmd/project/project_upgrade.go +++ b/cmd/project/project_upgrade.go @@ -21,6 +21,7 @@ import ( "github.com/shopware/shopware-cli/internal/extension" "github.com/shopware/shopware-cli/internal/flexmigrator" "github.com/shopware/shopware-cli/internal/git" + "github.com/shopware/shopware-cli/internal/packagist" "github.com/shopware/shopware-cli/internal/projectupgrade" "github.com/shopware/shopware-cli/internal/system" "github.com/shopware/shopware-cli/internal/tracking" @@ -64,6 +65,11 @@ bin/console system:update:prepare and system:update:finish.`, return err } + allowNonComposer, _ := cmd.Flags().GetBool("allow-non-composer") + if err := ensureAllPluginsAreComposerManaged(projectRoot, allowNonComposer); err != nil { + return err + } + allVersions, err := extension.GetShopwareVersions(ctx) if err != nil { return fmt.Errorf("failed to fetch available Shopware versions: %w", err) @@ -92,6 +98,11 @@ bin/console system:update:prepare and system:update:finish.`, extensions = nil } + registry, err := buildRegistry(cmd, projectRoot) + if err != nil { + return err + } + target, success, err := projectupgrade.RunWizard(projectupgrade.WizardOptions{ ProjectRoot: projectRoot, ComposerJSONPath: composerJsonPath, @@ -99,6 +110,7 @@ bin/console system:update:prepare and system:update:finish.`, UpdateVersions: updateVersions, Extensions: extensions, Executor: cmdExecutor, + Registry: registry, }) status := "ok" @@ -162,14 +174,20 @@ func runUpgradeHeadless(cmd *cobra.Command, projectRoot, composerJsonPath string } log.Infof("Checking custom plugins for incompatibilities") - removed, err := projectupgrade.RemoveIncompatiblePlugins(composerJsonPath, targetVersion) + registry, _ := buildRegistry(cmd, projectRoot) + result, err := projectupgrade.ResolveIncompatiblePlugins(ctx, composerJsonPath, targetVersion, registry) if err != nil { restoreComposerJson(ctx, composerJsonPath, backup) - return fmt.Errorf("remove incompatible plugins: %w", err) + return fmt.Errorf("resolve incompatible plugins: %w", err) } - for _, name := range removed { - log.Infof("Removed incompatible plugin %s from composer.json. Re-require it once a compatible version is published.", tui.YellowText.Render(name)) + if result != nil { + for _, action := range result.Bumped() { + log.Infof("Bumped %s: %s → %s", tui.YellowText.Render(action.Name), action.OldConstraint, action.NewConstraint) + } + for _, action := range result.Removed() { + log.Infof("Removed incompatible plugin %s (%s). Re-require it once a compatible version is published.", tui.YellowText.Render(action.Name), action.Reason) + } } log.Infof("Updating composer.json to %s", targetVersion) @@ -393,8 +411,89 @@ func ensureCleanGitTree(ctx context.Context, projectRoot string, allowDirty bool ) } +// ensureAllPluginsAreComposerManaged aborts when custom/plugins/ contains +// directories that are not tracked by composer. The upgrade can only bump +// plugin constraints in composer.json, so out-of-band drops would otherwise +// silently keep stale code on disk. +func ensureAllPluginsAreComposerManaged(projectRoot string, allow bool) error { + if allow { + return nil + } + + orphans, err := projectupgrade.FindNonComposerPlugins(projectRoot) + if err != nil { + return fmt.Errorf("scan custom/plugins: %w", err) + } + if len(orphans) == 0 { + return nil + } + + return fmt.Errorf( + "the upgrade can only bump composer-managed plugins, but %d directory/ies in custom/plugins/ are not tracked by composer:\n %s\n\nRun `shopware-cli project autofix composer-plugins` to migrate them, or rerun with --allow-non-composer to override.", + len(orphans), + strings.Join(orphans, "\n "), + ) +} + +// buildRegistry constructs the package registry used to look up newer +// compatible plugin versions. The Shopware Packages token is read from the +// SHOPWARE_PACKAGES_TOKEN env var, the project's auth.json, or — in +// interactive mode — prompted from the user if the project has store +// plugins. Missing tokens degrade gracefully: store lookups fall back to the +// "remove plugin" behaviour. +func buildRegistry(cmd *cobra.Command, projectRoot string) (projectupgrade.Registry, error) { + token := storeTokenFromAuthJSON(projectRoot) + + hasStorePlugins, err := projectHasStorePlugins(projectRoot) + if err != nil { + logging.FromContext(cmd.Context()).Debugf("could not inspect installed.json: %v", err) + } + + if token == "" && hasStorePlugins && system.IsInteractionEnabled(cmd.Context()) { + var entered string + if err := huh.NewInput(). + Title("Shopware Packages token (packages.shopware.com)"). + Description("Used to look up newer compatible versions of store plugins. Leave empty to skip store lookups."). + Value(&entered). + EchoMode(huh.EchoModePassword). + Run(); err != nil { + return nil, err + } + token = strings.TrimSpace(entered) + } + + return projectupgrade.DefaultRegistry(token), nil +} + +func storeTokenFromAuthJSON(projectRoot string) string { + if v := strings.TrimSpace(os.Getenv("SHOPWARE_PACKAGES_TOKEN")); v != "" { + return v + } + + authPath := path.Join(projectRoot, "auth.json") + auth, err := packagist.ReadComposerAuth(authPath) + if err != nil { + return "" + } + return strings.TrimSpace(auth.BearerAuth["packages.shopware.com"]) +} + +func projectHasStorePlugins(projectRoot string) (bool, error) { + composerJson, err := packagist.ReadComposerJson(path.Join(projectRoot, "composer.json")) + if err != nil { + return false, err + } + for name := range composerJson.Require { + if strings.HasPrefix(name, "store.shopware.com/") { + return true, nil + } + } + return false, nil +} + func init() { projectRootCmd.AddCommand(projectUpgradeCmd) projectUpgradeCmd.Flags().String("to", "", "Target Shopware version. Skips the interactive wizard.") projectUpgradeCmd.Flags().Bool("allow-dirty", false, "Allow running the upgrade even when the git working tree has uncommitted changes.") + projectUpgradeCmd.Flags().Bool("allow-non-composer", false, "Allow running the upgrade even when custom/plugins/ contains plugins not managed by composer.") } diff --git a/cmd/project/project_upgrade_test.go b/cmd/project/project_upgrade_test.go index 9d871c96..d7127912 100644 --- a/cmd/project/project_upgrade_test.go +++ b/cmd/project/project_upgrade_test.go @@ -1,6 +1,7 @@ package project import ( + "encoding/json" "os" "os/exec" "path/filepath" @@ -10,6 +11,10 @@ import ( "github.com/stretchr/testify/require" ) +func jsonMarshal(v any) ([]byte, error) { + return json.MarshalIndent(v, "", " ") +} + func gitCmd(t *testing.T, dir string, args ...string) { t.Helper() c := exec.CommandContext(t.Context(), "git", args...) @@ -63,3 +68,47 @@ func TestEnsureCleanGitTreeAllowDirtyFlagBypassesCheck(t *testing.T) { assert.NoError(t, ensureCleanGitTree(t.Context(), dir, true)) } + +func writeInstalledJSON(t *testing.T, projectDir string, packages []map[string]any) { + t.Helper() + installedDir := filepath.Join(projectDir, "vendor", "composer") + require.NoError(t, os.MkdirAll(installedDir, 0o755)) + body, err := jsonMarshal(map[string]any{"packages": packages}) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(installedDir, "installed.json"), body, 0o644)) +} + +func TestEnsureAllPluginsAreComposerManagedAllowsTrackedDirectories(t *testing.T) { + t.Parallel() + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, "custom", "plugins", "Tracked"), 0o755)) + writeInstalledJSON(t, dir, []map[string]any{ + { + "name": "vendor/tracked", + "type": "shopware-platform-plugin", + "install-path": "../../custom/plugins/Tracked", + }, + }) + + assert.NoError(t, ensureAllPluginsAreComposerManaged(dir, false)) +} + +func TestEnsureAllPluginsAreComposerManagedRejectsOrphanedDirectory(t *testing.T) { + t.Parallel() + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, "custom", "plugins", "Orphan"), 0o755)) + + err := ensureAllPluginsAreComposerManaged(dir, false) + require.Error(t, err) + assert.Contains(t, err.Error(), "not tracked by composer") + assert.Contains(t, err.Error(), "Orphan") + assert.Contains(t, err.Error(), "autofix composer-plugins") +} + +func TestEnsureAllPluginsAreComposerManagedAllowFlagBypasses(t *testing.T) { + t.Parallel() + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, "custom", "plugins", "Orphan"), 0o755)) + + assert.NoError(t, ensureAllPluginsAreComposerManaged(dir, true)) +} diff --git a/internal/projectupgrade/plugins.go b/internal/projectupgrade/plugins.go index aaf7d8f9..b06adfde 100644 --- a/internal/projectupgrade/plugins.go +++ b/internal/projectupgrade/plugins.go @@ -1,12 +1,14 @@ package projectupgrade import ( + "context" "encoding/json" "errors" "fmt" "io/fs" "os" "path/filepath" + "sort" "strings" "github.com/shyim/go-version" @@ -38,15 +40,64 @@ type installedJSON struct { Packages []installedPackage `json:"packages"` } -// RemoveIncompatiblePlugins drops symlinked custom/plugins/* entries from -// composer.json when their declared Shopware constraint is not satisfied by -// targetVersion. Composer would otherwise fail the update because the plugin -// pins us to an older shopware/core. Mirrors PluginCompatibility from the -// shopware/web-installer. +// PluginAction describes how the resolver dealt with one incompatible plugin. +type PluginAction struct { + // Name is the composer package name (e.g. "store.shopware.com/swagcms"). + Name string + // OldConstraint is the constraint that was in composer.json before + // resolution. + OldConstraint string + // NewConstraint is the constraint that was written to composer.json. + // Empty when Removed is true. + NewConstraint string + // NewVersion is the package version the new constraint points at. + // Empty when Removed is true. + NewVersion string + // Removed is true when no compatible version could be found and the + // plugin was dropped from composer.json. + Removed bool + // Reason is a short human-readable explanation surfaced in the UI. + Reason string +} + +// ResolveResult summarises the actions the resolver took. +type ResolveResult struct { + Actions []PluginAction +} + +// Bumped returns the actions that resulted in a constraint bump. +func (r *ResolveResult) Bumped() []PluginAction { + out := make([]PluginAction, 0, len(r.Actions)) + for _, a := range r.Actions { + if !a.Removed { + out = append(out, a) + } + } + return out +} + +// Removed returns the actions that resulted in the plugin being dropped. +func (r *ResolveResult) Removed() []PluginAction { + out := make([]PluginAction, 0, len(r.Actions)) + for _, a := range r.Actions { + if a.Removed { + out = append(out, a) + } + } + return out +} + +// ResolveIncompatiblePlugins inspects every shopware platform plugin under +// custom/plugins/* (as listed in vendor/composer/installed.json). For each +// plugin whose installed Shopware constraint is not satisfied by +// targetVersion the resolver tries to find a newer release on the supplied +// registry; if one exists, the composer.json constraint is bumped to +// "^". When no compatible version is available the plugin is +// removed from composer.json so composer update doesn't fail. // -// Returns the list of removed plugin names so the caller can report what was -// removed. -func RemoveIncompatiblePlugins(composerJsonPath, targetVersion string) ([]string, error) { +// registry may be nil, in which case every incompatible plugin is removed +// (the previous behaviour). +func ResolveIncompatiblePlugins(ctx context.Context, composerJsonPath, targetVersion string, registry Registry) (*ResolveResult, error) { projectDir := filepath.Dir(composerJsonPath) installedPath := filepath.Join(projectDir, "vendor", "composer", "installed.json") @@ -54,14 +105,12 @@ func RemoveIncompatiblePlugins(composerJsonPath, targetVersion string) ([]string data, err := os.ReadFile(installedPath) if err != nil { if errors.Is(err, fs.ErrNotExist) { - return nil, nil + return &ResolveResult{}, nil } - return nil, fmt.Errorf("read installed.json: %w", err) } var installed installedJSON - if err := json.Unmarshal(data, &installed); err != nil { return nil, fmt.Errorf("parse installed.json: %w", err) } @@ -71,26 +120,22 @@ func RemoveIncompatiblePlugins(composerJsonPath, targetVersion string) ([]string return nil, fmt.Errorf("parse target version: %w", err) } - incompatible := make([]string, 0) - + incompatible := make([]installedPackage, 0) for _, pkg := range installed.Packages { if pkg.Type != composerPluginType { continue } - if !isInstalledUnderCustomPlugins(projectDir, pkg.InstallPath) { continue } - if pluginSatisfies(pkg.Require, target) { continue } - - incompatible = append(incompatible, pkg.Name) + incompatible = append(incompatible, pkg) } if len(incompatible) == 0 { - return nil, nil + return &ResolveResult{}, nil } composerJson, err := packagist.ReadComposerJson(composerJsonPath) @@ -98,62 +143,227 @@ func RemoveIncompatiblePlugins(composerJsonPath, targetVersion string) ([]string return nil, err } - removed := make([]string, 0, len(incompatible)) + result := &ResolveResult{} + + for _, pkg := range incompatible { + old, ok := composerJson.Require[pkg.Name] + if !ok { + continue + } - for _, name := range incompatible { - if _, ok := composerJson.Require[name]; ok { - delete(composerJson.Require, name) - removed = append(removed, name) + action := PluginAction{Name: pkg.Name, OldConstraint: old} + + newVersion, err := findCompatibleVersion(ctx, registry, pkg.Name, target) + if err != nil || newVersion == "" { + delete(composerJson.Require, pkg.Name) + action.Removed = true + action.Reason = "no compatible release found" + if err != nil && !errors.Is(err, ErrRegistryUnavailable) { + action.Reason = "registry lookup failed: " + err.Error() + } + result.Actions = append(result.Actions, action) + continue } + + newConstraint := bumpConstraint(newVersion) + composerJson.Require[pkg.Name] = newConstraint + action.NewConstraint = newConstraint + action.NewVersion = newVersion + action.Reason = fmt.Sprintf("bumped to %s", newConstraint) + result.Actions = append(result.Actions, action) } - if len(removed) == 0 { - return nil, nil + if len(result.Actions) == 0 { + return result, nil } if err := composerJson.Save(); err != nil { return nil, err } + return result, nil +} + +func findCompatibleVersion(ctx context.Context, registry Registry, name string, target *version.Version) (string, error) { + if registry == nil { + return "", ErrRegistryUnavailable + } + + versions, err := registry.GetPackageVersions(ctx, name) + if err != nil { + return "", err + } + if len(versions) == 0 { + return "", nil + } + + parsed := make([]packagist.ComposerPackageVersion, 0, len(versions)) + for _, v := range versions { + if isPreReleaseVersion(v.Version) { + continue + } + if !satisfiesShopwareTarget(v.Require, target) { + continue + } + parsed = append(parsed, v) + } - return removed, nil + if len(parsed) == 0 { + return "", nil + } + + sort.Slice(parsed, func(i, j int) bool { + vi, errI := version.NewVersion(strings.TrimPrefix(parsed[i].Version, "v")) + vj, errJ := version.NewVersion(strings.TrimPrefix(parsed[j].Version, "v")) + if errI != nil || errJ != nil { + return parsed[i].Version > parsed[j].Version + } + return vi.GreaterThan(vj) + }) + + return strings.TrimPrefix(parsed[0].Version, "v"), nil +} + +func isPreReleaseVersion(v string) bool { + lower := strings.ToLower(v) + for _, marker := range []string{"-rc", "-beta", "-alpha", "-dev"} { + if strings.Contains(lower, marker) { + return true + } + } + return false +} + +func satisfiesShopwareTarget(requires map[string]string, target *version.Version) bool { + if len(requires) == 0 { + // No shopware/core constraint declared — assume compatible. + return true + } + for dep, constraint := range requires { + if !containsString(pluginShopwarePackages, dep) { + continue + } + c, err := version.NewConstraint(constraint) + if err != nil { + return false + } + if !c.Check(target) { + return false + } + } + return true +} + +// bumpConstraint converts a concrete version (e.g. "2.3.4") into a caret +// constraint ("^2.3.4") suitable for composer.json. Versions that already +// look like a constraint are passed through unchanged. +func bumpConstraint(version string) string { + if version == "" { + return version + } + if strings.ContainsAny(version, "^~><*|, ") { + return version + } + return "^" + version +} + +// FindNonComposerPlugins returns directories under custom/plugins/ that are +// not tracked by composer (no entry in vendor/composer/installed.json). +// Returns an empty slice when no installed.json is present. +func FindNonComposerPlugins(projectRoot string) ([]string, error) { + customPlugins := filepath.Join(projectRoot, "custom", "plugins") + entries, err := os.ReadDir(customPlugins) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil, nil + } + return nil, fmt.Errorf("read %s: %w", customPlugins, err) + } + + installedPath := filepath.Join(projectRoot, "vendor", "composer", "installed.json") + composerTracked := make(map[string]struct{}) + if data, err := os.ReadFile(installedPath); err == nil { + var installed installedJSON + if jsonErr := json.Unmarshal(data, &installed); jsonErr == nil { + for _, pkg := range installed.Packages { + if dir, ok := installedDirName(projectRoot, pkg.InstallPath); ok { + composerTracked[dir] = struct{}{} + } + } + } + } + + orphans := make([]string, 0) + for _, entry := range entries { + if !entry.IsDir() { + continue + } + if strings.HasPrefix(entry.Name(), ".") { + continue + } + if _, tracked := composerTracked[entry.Name()]; tracked { + continue + } + orphans = append(orphans, entry.Name()) + } + + sort.Strings(orphans) + return orphans, nil +} + +func installedDirName(projectRoot, installPath string) (string, bool) { + if installPath == "" { + return "", false + } + abs := installPath + if !filepath.IsAbs(abs) { + abs = filepath.Join(projectRoot, "vendor", "composer", installPath) + } + resolved, err := filepath.EvalSymlinks(abs) + if err != nil { + resolved = filepath.Clean(abs) + } + customPlugins := filepath.Join(projectRoot, "custom", "plugins") + resolvedCustom, err := filepath.EvalSymlinks(customPlugins) + if err != nil { + resolvedCustom = filepath.Clean(customPlugins) + } + rel, err := filepath.Rel(resolvedCustom, resolved) + if err != nil { + return "", false + } + if rel == "." || rel == "" || strings.HasPrefix(rel, "..") { + return "", false + } + if strings.ContainsRune(rel, filepath.Separator) { + return "", false + } + return rel, true } func isInstalledUnderCustomPlugins(projectDir, installPath string) bool { if installPath == "" { return false } - - // install-path is recorded relative to vendor/composer. absPath := installPath if !filepath.IsAbs(absPath) { absPath = filepath.Join(projectDir, "vendor", "composer", installPath) } - resolved, err := filepath.EvalSymlinks(absPath) if err != nil { resolved = filepath.Clean(absPath) } - customPlugins := filepath.Join(projectDir, "custom", "plugins") resolvedCustom, err := filepath.EvalSymlinks(customPlugins) if err != nil { resolvedCustom = filepath.Clean(customPlugins) } - rel, err := filepath.Rel(resolvedCustom, resolved) if err != nil { return false } - - if rel == "." || rel == "" { - return false - } - - if strings.HasPrefix(rel, "..") { + if rel == "." || rel == "" || strings.HasPrefix(rel, "..") { return false } - - // Direct child of custom/plugins (a single plugin directory). return !strings.ContainsRune(rel, filepath.Separator) } @@ -162,17 +372,14 @@ func pluginSatisfies(requires map[string]string, target *version.Version) bool { if !containsString(pluginShopwarePackages, dep) { continue } - c, err := version.NewConstraint(constraint) if err != nil { continue } - if !c.Check(target) { return false } } - return true } @@ -182,6 +389,5 @@ func containsString(haystack []string, needle string) bool { return true } } - return false } diff --git a/internal/projectupgrade/plugins_test.go b/internal/projectupgrade/plugins_test.go index e2dc0330..5c3a313d 100644 --- a/internal/projectupgrade/plugins_test.go +++ b/internal/projectupgrade/plugins_test.go @@ -1,6 +1,7 @@ package projectupgrade import ( + "context" "encoding/json" "os" "path/filepath" @@ -8,6 +9,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/shopware/shopware-cli/internal/packagist" ) func writeInstalledJSON(t *testing.T, projectDir string, packages []installedPackage) { @@ -21,21 +24,33 @@ func writeInstalledJSON(t *testing.T, projectDir string, packages []installedPac require.NoError(t, os.WriteFile(filepath.Join(installedDir, "installed.json"), data, 0o644)) } -func TestRemoveIncompatiblePluginsRemovesCustomPluginsThatDontSatisfyTarget(t *testing.T) { +// fakeRegistry is a test double for Registry that returns whatever the test +// configures. +type fakeRegistry struct { + versions map[string][]packagist.ComposerPackageVersion + err error +} + +func (f *fakeRegistry) GetPackageVersions(_ context.Context, name string) ([]packagist.ComposerPackageVersion, error) { + if f.err != nil { + return nil, f.err + } + return f.versions[name], nil +} + +func TestResolveIncompatiblePluginsRemovesWhenNoRegistry(t *testing.T) { t.Parallel() dir := t.TempDir() composerJsonPath := filepath.Join(dir, "composer.json") require.NoError(t, os.MkdirAll(filepath.Join(dir, "custom", "plugins", "Incompatible"), 0o755)) - require.NoError(t, os.MkdirAll(filepath.Join(dir, "custom", "plugins", "Compatible"), 0o755)) writeJSON(t, composerJsonPath, map[string]any{ "name": "shopware/production", "require": map[string]any{ "shopware/core": "6.5.8.0", "vendor/incompat": "*", - "vendor/compat": "*", "unrelated/package": "^1.0", }, }) @@ -45,41 +60,150 @@ func TestRemoveIncompatiblePluginsRemovesCustomPluginsThatDontSatisfyTarget(t *t Name: "vendor/incompat", Type: composerPluginType, InstallPath: "../../custom/plugins/Incompatible", - Require: map[string]string{ - "shopware/core": "~6.5.0", - }, + Require: map[string]string{"shopware/core": "~6.5.0"}, + }, + }) + + result, err := ResolveIncompatiblePlugins(t.Context(), composerJsonPath, "6.6.4.0", nil) + require.NoError(t, err) + require.Len(t, result.Removed(), 1) + assert.Empty(t, result.Bumped()) + assert.Equal(t, "vendor/incompat", result.Removed()[0].Name) + + out := readJSON(t, composerJsonPath) + requireMap := out["require"].(map[string]any) + _, stillThere := requireMap["vendor/incompat"] + assert.False(t, stillThere) +} + +func TestResolveIncompatiblePluginsBumpsConstraintWhenRegistryHasCompatibleVersion(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + composerJsonPath := filepath.Join(dir, "composer.json") + + require.NoError(t, os.MkdirAll(filepath.Join(dir, "custom", "plugins", "Incompatible"), 0o755)) + + writeJSON(t, composerJsonPath, map[string]any{ + "name": "shopware/production", + "require": map[string]any{ + "shopware/core": "6.5.8.0", + "vendor/incompat": "^1.0", }, + }) + + writeInstalledJSON(t, dir, []installedPackage{ { - Name: "vendor/compat", + Name: "vendor/incompat", Type: composerPluginType, - InstallPath: "../../custom/plugins/Compatible", - Require: map[string]string{ - "shopware/core": "^6.5", + InstallPath: "../../custom/plugins/Incompatible", + Require: map[string]string{"shopware/core": "~6.5.0"}, + }, + }) + + registry := &fakeRegistry{ + versions: map[string][]packagist.ComposerPackageVersion{ + "vendor/incompat": { + {Version: "1.0.0", Require: map[string]string{"shopware/core": "~6.5.0"}}, + {Version: "2.0.0", Require: map[string]string{"shopware/core": "^6.5 | ^6.6"}}, + {Version: "2.1.0", Require: map[string]string{"shopware/core": "^6.6"}}, + {Version: "3.0.0-rc1", Require: map[string]string{"shopware/core": "^6.6"}}, // skipped: prerelease }, }, + } + + result, err := ResolveIncompatiblePlugins(t.Context(), composerJsonPath, "6.6.4.0", registry) + require.NoError(t, err) + require.Len(t, result.Bumped(), 1) + assert.Empty(t, result.Removed()) + + bumped := result.Bumped()[0] + assert.Equal(t, "vendor/incompat", bumped.Name) + assert.Equal(t, "^1.0", bumped.OldConstraint) + assert.Equal(t, "2.1.0", bumped.NewVersion) + assert.Equal(t, "^2.1.0", bumped.NewConstraint) + + out := readJSON(t, composerJsonPath) + requireMap := out["require"].(map[string]any) + assert.Equal(t, "^2.1.0", requireMap["vendor/incompat"]) +} + +func TestResolveIncompatiblePluginsRemovesWhenNoCompatibleRelease(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + composerJsonPath := filepath.Join(dir, "composer.json") + + require.NoError(t, os.MkdirAll(filepath.Join(dir, "custom", "plugins", "Incompatible"), 0o755)) + + writeJSON(t, composerJsonPath, map[string]any{ + "name": "shopware/production", + "require": map[string]any{ + "shopware/core": "6.5.8.0", + "vendor/incompat": "^1.0", + }, + }) + + writeInstalledJSON(t, dir, []installedPackage{ { - Name: "vendor/composer-installed", + Name: "vendor/incompat", Type: composerPluginType, - InstallPath: "../vendor/installed", - Require: map[string]string{ - "shopware/core": "~6.5.0", - }, + InstallPath: "../../custom/plugins/Incompatible", + Require: map[string]string{"shopware/core": "~6.5.0"}, }, }) - removed, err := RemoveIncompatiblePlugins(composerJsonPath, "6.6.4.0") + // Only old versions, none compatible with 6.6.4.0. + registry := &fakeRegistry{ + versions: map[string][]packagist.ComposerPackageVersion{ + "vendor/incompat": { + {Version: "1.0.0", Require: map[string]string{"shopware/core": "~6.5.0"}}, + {Version: "1.1.0", Require: map[string]string{"shopware/core": "~6.5.0"}}, + }, + }, + } + + result, err := ResolveIncompatiblePlugins(t.Context(), composerJsonPath, "6.6.4.0", registry) require.NoError(t, err) - assert.Equal(t, []string{"vendor/incompat"}, removed) + assert.Empty(t, result.Bumped()) + require.Len(t, result.Removed(), 1) + assert.Equal(t, "vendor/incompat", result.Removed()[0].Name) + assert.Equal(t, "no compatible release found", result.Removed()[0].Reason) +} - out := readJSON(t, composerJsonPath) - requireMap := out["require"].(map[string]any) - _, stillThere := requireMap["vendor/incompat"] - assert.False(t, stillThere, "incompatible plugin should be removed from composer.json") - assert.Contains(t, requireMap, "vendor/compat", "compatible plugin must remain") - assert.Contains(t, requireMap, "unrelated/package", "unrelated package must remain") +func TestResolveIncompatiblePluginsRegistryErrorFallsBackToRemove(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + composerJsonPath := filepath.Join(dir, "composer.json") + + require.NoError(t, os.MkdirAll(filepath.Join(dir, "custom", "plugins", "Incompatible"), 0o755)) + + writeJSON(t, composerJsonPath, map[string]any{ + "name": "shopware/production", + "require": map[string]any{ + "shopware/core": "6.5.8.0", + "vendor/incompat": "^1.0", + }, + }) + + writeInstalledJSON(t, dir, []installedPackage{ + { + Name: "vendor/incompat", + Type: composerPluginType, + InstallPath: "../../custom/plugins/Incompatible", + Require: map[string]string{"shopware/core": "~6.5.0"}, + }, + }) + + registry := &fakeRegistry{err: assertErr("network down")} + result, err := ResolveIncompatiblePlugins(t.Context(), composerJsonPath, "6.6.4.0", registry) + require.NoError(t, err) + require.Len(t, result.Removed(), 1) + assert.Contains(t, result.Removed()[0].Reason, "network down") } -func TestRemoveIncompatiblePluginsNoInstalledJSONReturnsNil(t *testing.T) { +func TestResolveIncompatiblePluginsNoInstalledJSONReturnsEmpty(t *testing.T) { t.Parallel() dir := t.TempDir() @@ -91,7 +215,54 @@ func TestRemoveIncompatiblePluginsNoInstalledJSONReturnsNil(t *testing.T) { }, }) - removed, err := RemoveIncompatiblePlugins(composerJsonPath, "6.6.4.0") + result, err := ResolveIncompatiblePlugins(t.Context(), composerJsonPath, "6.6.4.0", nil) + require.NoError(t, err) + assert.Empty(t, result.Actions) +} + +func TestFindNonComposerPluginsReportsUntrackedDirectories(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, "custom", "plugins", "TrackedPlugin"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(dir, "custom", "plugins", "UntrackedPlugin"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(dir, "custom", "plugins", "AnotherUntracked"), 0o755)) + + writeInstalledJSON(t, dir, []installedPackage{ + { + Name: "vendor/tracked", + Type: composerPluginType, + InstallPath: "../../custom/plugins/TrackedPlugin", + }, + }) + + orphans, err := FindNonComposerPlugins(dir) + require.NoError(t, err) + assert.Equal(t, []string{"AnotherUntracked", "UntrackedPlugin"}, orphans) +} + +func TestFindNonComposerPluginsNoCustomPluginsDirectory(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + orphans, err := FindNonComposerPlugins(dir) + require.NoError(t, err) + assert.Empty(t, orphans) +} + +func TestFindNonComposerPluginsAllTracked(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, "custom", "plugins", "A"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(dir, "custom", "plugins", "B"), 0o755)) + + writeInstalledJSON(t, dir, []installedPackage{ + {Name: "vendor/a", Type: composerPluginType, InstallPath: "../../custom/plugins/A"}, + {Name: "vendor/b", Type: composerPluginType, InstallPath: "../../custom/plugins/B"}, + }) + + orphans, err := FindNonComposerPlugins(dir) require.NoError(t, err) - assert.Empty(t, removed) + assert.Empty(t, orphans) } diff --git a/internal/projectupgrade/registry.go b/internal/projectupgrade/registry.go new file mode 100644 index 00000000..3ad6b9c8 --- /dev/null +++ b/internal/projectupgrade/registry.go @@ -0,0 +1,230 @@ +package projectupgrade + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "sync" + "time" + + "github.com/shopware/shopware-cli/internal/packagist" +) + +// Registry resolves a composer package name to its available versions. +// Implementations are expected to be safe for use from multiple goroutines. +type Registry interface { + GetPackageVersions(ctx context.Context, name string) ([]packagist.ComposerPackageVersion, error) +} + +// ErrRegistryUnavailable is returned when no backend can resolve the package +// (e.g. a store.shopware.com package when no token is configured). +var ErrRegistryUnavailable = errors.New("registry unavailable for this package") + +// CombinedRegistry routes lookups to the appropriate backend based on the +// package name prefix. +type CombinedRegistry struct { + // Store handles store.shopware.com/* packages. May be nil. + Store Registry + // Packagist handles every other vendor/name combination. Required. + Packagist Registry +} + +func (c *CombinedRegistry) GetPackageVersions(ctx context.Context, name string) ([]packagist.ComposerPackageVersion, error) { + if strings.HasPrefix(name, "store.shopware.com/") { + if c.Store == nil { + return nil, ErrRegistryUnavailable + } + return c.Store.GetPackageVersions(ctx, name) + } + + if c.Packagist == nil { + return nil, ErrRegistryUnavailable + } + return c.Packagist.GetPackageVersions(ctx, name) +} + +var registryHTTPClient = &http.Client{Timeout: 30 * time.Second} + +// PackagistRegistry queries https://repo.packagist.org for any composer +// package's available versions. The package metadata is returned with full +// require/replace info so we can pick a Shopware-compatible release. +type PackagistRegistry struct{} + +type packagistResponse struct { + Minified string `json:"minified"` + Packages map[string][]map[string]json.RawMessage `json:"packages"` +} + +func (p PackagistRegistry) GetPackageVersions(ctx context.Context, name string) ([]packagist.ComposerPackageVersion, error) { + url := fmt.Sprintf("https://repo.packagist.org/p2/%s.json", name) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody) + if err != nil { + return nil, err + } + req.Header.Set("User-Agent", "Shopware CLI") + + resp, err := registryHTTPClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return nil, nil + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("packagist returned %s for %s", resp.Status, name) + } + + var body packagistResponse + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + return nil, fmt.Errorf("decode packagist response: %w", err) + } + + raw, ok := body.Packages[name] + if !ok || len(raw) == 0 { + return nil, nil + } + + if body.Minified != "" { + raw = unminify(raw) + } + + versions := make([]packagist.ComposerPackageVersion, 0, len(raw)) + for _, m := range raw { + payload, err := json.Marshal(m) + if err != nil { + continue + } + var v packagist.ComposerPackageVersion + if err := json.Unmarshal(payload, &v); err != nil { + continue + } + versions = append(versions, v) + } + return versions, nil +} + +// unminify expands the composer v2 minified packages format ("__unset" +// markers and inheritance from the previous entry) into independent records. +func unminify(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 current map[string]json.RawMessage + for _, v := range versions { + if current == nil { + current = cloneRaw(v) + expanded = append(expanded, cloneRaw(current)) + continue + } + for k, val := range v { + if bytes.Equal(val, []byte(`"__unset"`)) { + delete(current, k) + } else { + current[k] = val + } + } + expanded = append(expanded, cloneRaw(current)) + } + return expanded +} + +func cloneRaw(in map[string]json.RawMessage) map[string]json.RawMessage { + out := make(map[string]json.RawMessage, len(in)) + for k, v := range in { + out[k] = v + } + return out +} + +// ShopwareStoreRegistry queries https://packages.shopware.com/packages.json +// for store-managed plugins. The full listing is fetched once and cached for +// the lifetime of the registry instance. +type ShopwareStoreRegistry struct { + Token string + + once sync.Once + loadErr error + packages map[string][]packagist.ComposerPackageVersion +} + +type shopwareStoreResponse struct { + Packages map[string]map[string]packagist.ComposerPackageVersion `json:"packages"` +} + +func (s *ShopwareStoreRegistry) load(ctx context.Context) error { + s.once.Do(func() { + if s.Token == "" { + s.loadErr = ErrRegistryUnavailable + return + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://packages.shopware.com/packages.json", http.NoBody) + if err != nil { + s.loadErr = err + return + } + req.Header.Set("User-Agent", "Shopware CLI") + req.Header.Set("Authorization", "Bearer "+s.Token) + + resp, err := registryHTTPClient.Do(req) + if err != nil { + s.loadErr = err + return + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + s.loadErr = fmt.Errorf("shopware packages returned %s", resp.Status) + return + } + + var body shopwareStoreResponse + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + s.loadErr = fmt.Errorf("decode shopware packages: %w", err) + return + } + + s.packages = make(map[string][]packagist.ComposerPackageVersion, len(body.Packages)) + for name, versions := range body.Packages { + list := make([]packagist.ComposerPackageVersion, 0, len(versions)) + for _, v := range versions { + list = append(list, v) + } + s.packages[name] = list + } + }) + return s.loadErr +} + +func (s *ShopwareStoreRegistry) GetPackageVersions(ctx context.Context, name string) ([]packagist.ComposerPackageVersion, error) { + if err := s.load(ctx); err != nil { + return nil, err + } + + versions, ok := s.packages[name] + if !ok { + return nil, nil + } + return versions, nil +} + +// DefaultRegistry builds a CombinedRegistry that uses packages.shopware.com +// when a store token is provided and falls back to repo.packagist.org for +// everything else. token may be empty; in that case store lookups return +// ErrRegistryUnavailable. +func DefaultRegistry(token string) Registry { + combined := &CombinedRegistry{ + Packagist: PackagistRegistry{}, + } + if token != "" { + combined.Store = &ShopwareStoreRegistry{Token: token} + } + return combined +} diff --git a/internal/projectupgrade/wizard.go b/internal/projectupgrade/wizard.go index 7cb4f85d..d8205ace 100644 --- a/internal/projectupgrade/wizard.go +++ b/internal/projectupgrade/wizard.go @@ -38,6 +38,10 @@ type WizardOptions struct { UpdateVersions []string Extensions map[string]string Executor executor.Executor + // Registry is consulted to find newer compatible versions of plugins + // whose installed shopware/core constraint is no longer satisfied. May + // be nil, in which case incompatible plugins are simply removed. + Registry Registry } type phase int @@ -91,7 +95,7 @@ type ( err error detail string composerBackup []byte - pluginsRemoved []string + pluginActions *ResolveResult } startNextTaskMsg struct{} logLineMsg string @@ -113,7 +117,7 @@ type wizardModel struct { targetVersion string confirmYes bool composerBackup []byte - pluginsRemoved []string + pluginActions *ResolveResult compatUpdates []account_api.UpdateCheckExtensionCompatibility compatHasBlock bool compatErr error @@ -172,7 +176,7 @@ func defaultTasks() []task { return []task{ {label: "Back up composer.json"}, {label: "Clean up stale recipe files"}, - {label: "Remove incompatible custom plugins"}, + {label: "Resolve incompatible custom plugins"}, {label: "Rewrite composer.json"}, {label: "composer update --with-all-dependencies"}, {label: "bin/console system:update:prepare"}, @@ -215,8 +219,8 @@ func (m wizardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.composerBackup != nil { m.composerBackup = msg.composerBackup } - if msg.pluginsRemoved != nil { - m.pluginsRemoved = msg.pluginsRemoved + if msg.pluginActions != nil { + m.pluginActions = msg.pluginActions } if msg.task < len(m.tasks) { if msg.err != nil { @@ -493,20 +497,31 @@ func (m wizardModel) runRemovePlugins() tea.Cmd { target := m.targetVersion idx := taskPlugins restore := m.composerBackup + registry := m.opts.Registry return func() tea.Msg { - removed, err := RemoveIncompatiblePlugins(composerJSONPath, target) + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + result, err := ResolveIncompatiblePlugins(ctx, composerJSONPath, target, registry) if err != nil { _ = os.WriteFile(composerJSONPath, restore, 0o644) return taskCompleteMsg{task: idx, err: err} } - detail := "no incompatibilities" - if len(removed) > 0 { - detail = fmt.Sprintf("removed %d incompatible plugin(s)", len(removed)) + if result == nil { + result = &ResolveResult{} } - if removed == nil { - removed = []string{} + bumped := len(result.Bumped()) + removed := len(result.Removed()) + detail := "no incompatibilities" + switch { + case bumped > 0 && removed > 0: + detail = fmt.Sprintf("bumped %d, removed %d", bumped, removed) + case bumped > 0: + detail = fmt.Sprintf("bumped %d to a compatible version", bumped) + case removed > 0: + detail = fmt.Sprintf("removed %d (no compatible release)", removed) } - return taskCompleteMsg{task: idx, detail: detail, pluginsRemoved: removed} + return taskCompleteMsg{task: idx, detail: detail, pluginActions: result} } } @@ -877,16 +892,39 @@ func (m wizardModel) viewDone() string { b.WriteString("\n\n") b.WriteString(tui.DimStyle.Render("All tasks completed. Verify your shop and run your test suite.")) - if len(m.pluginsRemoved) > 0 { - b.WriteString("\n\n") - b.WriteString(tui.BoldText.Render("Removed incompatible custom plugins:")) - b.WriteString("\n") - for _, name := range m.pluginsRemoved { - b.WriteString(tui.DimStyle.Render(" • ")) - b.WriteString(tui.LabelStyle.Render(name)) + if m.pluginActions != nil { + bumped := m.pluginActions.Bumped() + removed := m.pluginActions.Removed() + + if len(bumped) > 0 { + b.WriteString("\n\n") + b.WriteString(tui.BoldText.Render("Bumped plugin constraints:")) b.WriteString("\n") + for _, action := range bumped { + b.WriteString(tui.DimStyle.Render(" • ")) + b.WriteString(tui.LabelStyle.Render(action.Name)) + b.WriteString(" ") + b.WriteString(tui.DimStyle.Render(action.OldConstraint)) + b.WriteString(" → ") + b.WriteString(lipgloss.NewStyle().Foreground(tui.SuccessColor).Render(action.NewConstraint)) + b.WriteString("\n") + } + } + + if len(removed) > 0 { + b.WriteString("\n\n") + b.WriteString(tui.BoldText.Render("Removed incompatible custom plugins:")) + b.WriteString("\n") + for _, action := range removed { + b.WriteString(tui.DimStyle.Render(" • ")) + b.WriteString(tui.LabelStyle.Render(action.Name)) + if action.Reason != "" { + b.WriteString(tui.DimStyle.Render(" (" + action.Reason + ")")) + } + b.WriteString("\n") + } + b.WriteString(tui.DimStyle.Render("Re-require them in composer.json once they publish compatible versions.")) } - b.WriteString(tui.DimStyle.Render("Re-require them in composer.json once they publish compatible versions.")) } } From 74b51f8c1f44ff5ae658f3089599eee31511c0aa Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 26 May 2026 15:46:54 +0000 Subject: [PATCH 14/38] fix(project): clear golangci-lint findings on upgrade wizard - Drop trailing punctuation from the dirty-git-tree and non-composer-plugin error strings (ST1005). - Make the phase / task-status switches exhaustive (exhaustive). - Rewrite the compat-result if/else chain as a tagless switch (gocritic). - Rename the `max` parameter in `truncate` to `maxRunes` so it stops shadowing the predeclared builtin (predeclared). - Wrap `resp.Body.Close()` in a small `closeBody` helper so we don't ignore its error inline (errcheck). - Rename the test-only `stringErr` type to `testError` to match the `xxxError` naming convention (errname). - Add `t.Parallel()` to the render-smoke subtest (tparallel). - Drop the unused `upgradeDoneMsg` type (unused). - Drop the unused `projectRoot` parameter from `runCompatibilityCheck` (unparam). --- cmd/project/project_upgrade.go | 10 +++++----- internal/projectupgrade/registry.go | 10 ++++++++-- internal/projectupgrade/wizard.go | 22 ++++++++++++---------- internal/projectupgrade/wizard_test.go | 7 ++++--- 4 files changed, 29 insertions(+), 20 deletions(-) diff --git a/cmd/project/project_upgrade.go b/cmd/project/project_upgrade.go index 95ecfc2f..31023e2d 100644 --- a/cmd/project/project_upgrade.go +++ b/cmd/project/project_upgrade.go @@ -143,7 +143,7 @@ func runUpgradeHeadless(cmd *cobra.Command, projectRoot, composerJsonPath string return err } - if err := runCompatibilityCheck(ctx, projectRoot, currentVersion, targetVersion); err != nil { + if err := runCompatibilityCheck(ctx, currentVersion, targetVersion); err != nil { return err } @@ -278,7 +278,7 @@ func selectTargetVersion(cmd *cobra.Command, updateVersions []string) (string, e return selected, nil } -func runCompatibilityCheck(ctx context.Context, projectRoot string, currentVersion *version.Version, targetVersion string) error { +func runCompatibilityCheck(ctx context.Context, currentVersion *version.Version, targetVersion string) error { log := logging.FromContext(ctx) _, extensions, err := getLocalExtensions() @@ -402,8 +402,8 @@ func ensureCleanGitTree(ctx context.Context, projectRoot string, allowDirty bool } return fmt.Errorf( - "the upgrade rewrites composer.json and removes recipe-managed files, so the working tree must be clean.\n"+ - "%d uncommitted change(s) detected in %s:\n %s%s\n\nCommit or stash your changes, or rerun with --allow-dirty to override.", + "the upgrade rewrites composer.json and removes recipe-managed files, so the working tree must be clean - "+ + "%d uncommitted change(s) detected in %s:\n %s%s\n\ncommit or stash your changes, or rerun with --allow-dirty to override", len(changes), projectRoot, strings.Join(preview, "\n "), @@ -429,7 +429,7 @@ func ensureAllPluginsAreComposerManaged(projectRoot string, allow bool) error { } return fmt.Errorf( - "the upgrade can only bump composer-managed plugins, but %d directory/ies in custom/plugins/ are not tracked by composer:\n %s\n\nRun `shopware-cli project autofix composer-plugins` to migrate them, or rerun with --allow-non-composer to override.", + "the upgrade can only bump composer-managed plugins, but %d director(ies) in custom/plugins/ are not tracked by composer:\n %s\n\nrun `shopware-cli project autofix composer-plugins` to migrate them, or rerun with --allow-non-composer to override", len(orphans), strings.Join(orphans, "\n "), ) diff --git a/internal/projectupgrade/registry.go b/internal/projectupgrade/registry.go index 3ad6b9c8..e099500b 100644 --- a/internal/projectupgrade/registry.go +++ b/internal/projectupgrade/registry.go @@ -71,7 +71,7 @@ func (p PackagistRegistry) GetPackageVersions(ctx context.Context, name string) if err != nil { return nil, err } - defer resp.Body.Close() + defer closeBody(resp) if resp.StatusCode == http.StatusNotFound { return nil, nil @@ -178,7 +178,7 @@ func (s *ShopwareStoreRegistry) load(ctx context.Context) error { s.loadErr = err return } - defer resp.Body.Close() + defer closeBody(resp) if resp.StatusCode != http.StatusOK { s.loadErr = fmt.Errorf("shopware packages returned %s", resp.Status) @@ -215,6 +215,12 @@ func (s *ShopwareStoreRegistry) GetPackageVersions(ctx context.Context, name str return versions, nil } +// closeBody drains and closes an HTTP response body, swallowing any close +// error since callers can't act on it once they've already read the payload. +func closeBody(resp *http.Response) { + _ = resp.Body.Close() +} + // DefaultRegistry builds a CombinedRegistry that uses packages.shopware.com // when a store token is provided and falls back to repo.packagist.org for // everything else. token may be empty; in that case store lookups return diff --git a/internal/projectupgrade/wizard.go b/internal/projectupgrade/wizard.go index d8205ace..23a2380e 100644 --- a/internal/projectupgrade/wizard.go +++ b/internal/projectupgrade/wizard.go @@ -100,9 +100,6 @@ type ( startNextTaskMsg struct{} logLineMsg string logDoneMsg struct{} - upgradeDoneMsg struct { - err error - } ) // wizardModel is a small standalone bubbletea Program that walks the user @@ -680,6 +677,8 @@ func (m wizardModel) stepNum(p phase) int { return 3 } return 4 + case phaseWelcome, phaseDone: + return 0 } return 0 } @@ -773,15 +772,16 @@ func (m wizardModel) viewCompatResult() string { b.WriteString(tui.DimStyle.Render(fmt.Sprintf("Upgrade to %s", m.targetVersion))) b.WriteString("\n\n") - if m.compatErr != nil { + switch { + case m.compatErr != nil: b.WriteString(lipgloss.NewStyle().Foreground(tui.ErrorColor).Render("Compatibility lookup failed: " + m.compatErr.Error())) b.WriteString("\n") b.WriteString(tui.DimStyle.Render("You may still proceed; the wizard cannot guarantee extensions will install.")) b.WriteString("\n\n") - } else if len(m.compatUpdates) == 0 { + case len(m.compatUpdates) == 0: b.WriteString(tui.DimStyle.Render("No store-managed extensions to check.")) b.WriteString("\n\n") - } else { + default: for _, u := range m.compatUpdates { icon := tui.Checkmark if u.Status.IsBlocker() { @@ -951,6 +951,8 @@ func (m wizardModel) renderTaskLine(i int, t task) string { icon = lipgloss.NewStyle().Foreground(tui.ErrorColor).Bold(true).Render("✗") case taskSkipped: icon = tui.DimStyle.Render("·") + case taskPending: + icon = tui.DimStyle.Render("○") default: icon = tui.DimStyle.Render("○") } @@ -996,13 +998,13 @@ func renderConfirmButtons(yesLabel, noLabel string, yesActive bool) string { return yes + " " + no } -func truncate(s string, max int) string { - if max <= 0 { +func truncate(s string, maxRunes int) string { + if maxRunes <= 0 { return s } - if len([]rune(s)) <= max { + if len([]rune(s)) <= maxRunes { return s } r := []rune(s) - return string(r[:max-1]) + "…" + return string(r[:maxRunes-1]) + "…" } diff --git a/internal/projectupgrade/wizard_test.go b/internal/projectupgrade/wizard_test.go index 3fa5863a..4d47d3b0 100644 --- a/internal/projectupgrade/wizard_test.go +++ b/internal/projectupgrade/wizard_test.go @@ -176,11 +176,11 @@ func TestWizardLogLineMsgAppendsAndTrims(t *testing.T) { assert.LessOrEqual(t, len(m.logLines), maxLogLines) } -type stringErr string +type testError string -func (e stringErr) Error() string { return string(e) } +func (e testError) Error() string { return string(e) } -func assertErr(s string) error { return stringErr(s) } +func assertErr(s string) error { return testError(s) } func TestWizardRendersAllPhases(t *testing.T) { t.Parallel() @@ -198,6 +198,7 @@ func TestWizardRendersAllPhases(t *testing.T) { for _, p := range phases { p := p t.Run(t.Name(), func(t *testing.T) { + t.Parallel() m := newTestModel(t) m.phase = p m.targetVersion = "6.6.4.0" From 19dcc428bb78c8c4e4b3b2f129c78fa3e7bf0b6a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 26 May 2026 17:46:14 +0000 Subject: [PATCH 15/38] Initial plan From 354f43dc5fd0218e3374e63f0850ccf050abaf6f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 26 May 2026 17:48:51 +0000 Subject: [PATCH 16/38] fix(shop): make EnableClean idempotent across repeated calls Agent-Logs-Url: https://github.com/shopware/shopware-cli/sessions/2411433d-f0f1-44d6-a812-3b5a8ab56b4b Co-authored-by: shyim <6224096+shyim@users.noreply.github.com> --- internal/shop/config.go | 14 +++++++++++++- internal/shop/config_test.go | 8 +++++--- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/internal/shop/config.go b/internal/shop/config.go index 1be5d757..3cf28a92 100644 --- a/internal/shop/config.go +++ b/internal/shop/config.go @@ -229,7 +229,19 @@ func (c *ConfigDump) EnableClean() { "version_commit_data", "webhook_event_log", } - c.NoData = append(c.NoData, cleanTables...) + existingNoData := make(map[string]struct{}, len(c.NoData)) + for _, table := range c.NoData { + existingNoData[table] = struct{}{} + } + + for _, table := range cleanTables { + if _, exists := existingNoData[table]; exists { + continue + } + + c.NoData = append(c.NoData, table) + existingNoData[table] = struct{}{} + } } // EnableAnonymization adds default column rewrites for anonymizing customer data diff --git a/internal/shop/config_test.go b/internal/shop/config_test.go index a874eb38..520da21a 100644 --- a/internal/shop/config_test.go +++ b/internal/shop/config_test.go @@ -456,15 +456,17 @@ func TestConfigDump_EnableClean(t *testing.T) { assert.Contains(t, config.NoData, "webhook_event_log") }) - t.Run("multiple calls append duplicates", func(t *testing.T) { + t.Run("multiple calls are idempotent", func(t *testing.T) { config := &ConfigDump{} config.EnableClean() firstCallLength := len(config.NoData) config.EnableClean() - // Second call will append again (duplicates) - assert.Len(t, config.NoData, firstCallLength*2) + // Second call should not add duplicates + assert.Len(t, config.NoData, firstCallLength) + assert.Contains(t, config.NoData, "cart") + assert.Contains(t, config.NoData, "version") }) t.Run("does not affect other fields", func(t *testing.T) { From 8ab1bf1decafcc64c6093f9584d927cd59d938f4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 26 May 2026 17:56:15 +0000 Subject: [PATCH 17/38] refactor(shop): use slices.Contains in EnableClean instead of manual map lookup Agent-Logs-Url: https://github.com/shopware/shopware-cli/sessions/09a4e827-7b12-4968-aeeb-f4872cd777be Co-authored-by: shyim <6224096+shyim@users.noreply.github.com> --- internal/shop/config.go | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/internal/shop/config.go b/internal/shop/config.go index 3cf28a92..1f1ec98c 100644 --- a/internal/shop/config.go +++ b/internal/shop/config.go @@ -6,6 +6,7 @@ import ( "os" "path" "path/filepath" + "slices" "strings" "dario.cat/mergo" @@ -229,18 +230,10 @@ func (c *ConfigDump) EnableClean() { "version_commit_data", "webhook_event_log", } - existingNoData := make(map[string]struct{}, len(c.NoData)) - for _, table := range c.NoData { - existingNoData[table] = struct{}{} - } - for _, table := range cleanTables { - if _, exists := existingNoData[table]; exists { - continue + if !slices.Contains(c.NoData, table) { + c.NoData = append(c.NoData, table) } - - c.NoData = append(c.NoData, table) - existingNoData[table] = struct{}{} } } From 65ae1f173f6199ee1aad5584001851848777e026 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 26 May 2026 18:58:58 +0000 Subject: [PATCH 18/38] Initial plan From fd68d2e7f17ec93f7fad46bf35667424164d0341 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 26 May 2026 19:01:28 +0000 Subject: [PATCH 19/38] test: remove redundant temp dir cleanup in config test Agent-Logs-Url: https://github.com/shopware/shopware-cli/sessions/b99c4bba-ac68-4b6f-aa8d-6c5432a9339c Co-authored-by: shyim <6224096+shyim@users.noreply.github.com> --- internal/shop/config_test.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/internal/shop/config_test.go b/internal/shop/config_test.go index 520da21a..2758f799 100644 --- a/internal/shop/config_test.go +++ b/internal/shop/config_test.go @@ -42,8 +42,6 @@ include: assert.NoError(t, err) assert.NotNil(t, config.ConfigDump.Where) - - assert.NoError(t, os.RemoveAll(tmpDir)) } func TestReadConfigCompatibilityDateValidation(t *testing.T) { From 93a50a06d24da167009f4d3807a5d3179f8c037e Mon Sep 17 00:00:00 2001 From: Soner Sayakci Date: Wed, 27 May 2026 05:17:15 +0200 Subject: [PATCH 20/38] fix: use array for COVER_FLAGS to safely handle empty case Replace unquoted $COVER_FLAG string variable with a bash array COVER_FLAGS=() so that when coverage is not requested, the empty array expands to nothing instead of an empty-string argument to go test. --- .claude/scheduled_tasks.lock | 1 + internal/shop/config_test.go | 33 +++++++++++++++++++++++++++++++++ scripts/run-tests.sh | 8 ++++---- 3 files changed, 38 insertions(+), 4 deletions(-) create mode 100644 .claude/scheduled_tasks.lock diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock new file mode 100644 index 00000000..ef892dd7 --- /dev/null +++ b/.claude/scheduled_tasks.lock @@ -0,0 +1 @@ +{"sessionId":"f1eff485-16cd-4641-950b-79e0a0a10769","pid":68060,"procStart":"Tue May 26 12:24:31 2026","acquiredAt":1779799762311} \ No newline at end of file diff --git a/internal/shop/config_test.go b/internal/shop/config_test.go index 2758f799..01e1780d 100644 --- a/internal/shop/config_test.go +++ b/internal/shop/config_test.go @@ -430,6 +430,39 @@ func TestConfigDump_EnableClean(t *testing.T) { assert.Len(t, config.NoData, 19) }) + t.Run("no duplicates when pre-existing entry overlaps with defaults", func(t *testing.T) { + config := &ConfigDump{ + NoData: []string{"my_custom_table", "cart", "version"}, + } + + config.EnableClean() + + // Should not introduce duplicates for 'cart' and 'version' + count := 0 + for _, table := range config.NoData { + if table == "cart" { + count++ + } + } + assert.Equal(t, 1, count, "cart should appear exactly once") + + count = 0 + for _, table := range config.NoData { + if table == "version" { + count++ + } + } + assert.Equal(t, 1, count, "version should appear exactly once") + + // Total should be custom tables (3) + remaining clean tables (15) + assert.Len(t, config.NoData, 18) + + // Pre-existing tables should be preserved in their original positions + assert.Equal(t, "my_custom_table", config.NoData[0]) + assert.Equal(t, "cart", config.NoData[1]) + assert.Equal(t, "version", config.NoData[2]) + }) + t.Run("verify all expected tables", func(t *testing.T) { config := &ConfigDump{} config.EnableClean() diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh index e21f5af8..363bce8c 100755 --- a/scripts/run-tests.sh +++ b/scripts/run-tests.sh @@ -30,9 +30,9 @@ export GOPROXY=off # binaries or dart-sass) to skip themselves instead of failing on DNS. export SHOPWARE_CLI_NO_NETWORK=1 -COVER_FLAG="" +COVER_FLAGS=() if [ -n "$COVERPROFILE" ]; then - COVER_FLAG="-coverprofile=$COVERPROFILE" + COVER_FLAGS=("-coverprofile=$COVERPROFILE") fi case "$(uname -s)" in @@ -41,7 +41,7 @@ case "$(uname -s)" in echo "error: sandbox-exec not found" >&2 exit 1 fi - sandbox-exec -f "$REPO_DIR/sandbox-no-network.sb" go test $COVER_FLAG "$@" + sandbox-exec -f "$REPO_DIR/sandbox-no-network.sb" go test "${COVER_FLAGS[@]}" "$@" ;; Linux) if ! command -v unshare >/dev/null 2>&1; then @@ -60,7 +60,7 @@ case "$(uname -s)" in echo "error: need either ip (iproute2) or ifconfig to bring up loopback" >&2 exit 1 fi - exec go test '"$COVER_FLAG"' "$@" + exec go test "${COVER_FLAGS[@]}" "$@" ' bash "$@" ;; *) From 503ad0ec181e16e68852a18aced83b78f2831a95 Mon Sep 17 00:00:00 2001 From: Soner Sayakci Date: Wed, 27 May 2026 05:20:47 +0200 Subject: [PATCH 21/38] fix: expand COVER_FLAGS array in outer shell on Linux path The bash -c single-quoted block needs to break out of quotes to expand ${COVER_FLAGS[@]} in the outer shell, same as the original $COVER_FLAG did. Otherwise the inner shell sees the literal and has no variable. --- scripts/run-tests.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh index 363bce8c..d2b3c2e0 100755 --- a/scripts/run-tests.sh +++ b/scripts/run-tests.sh @@ -60,7 +60,7 @@ case "$(uname -s)" in echo "error: need either ip (iproute2) or ifconfig to bring up loopback" >&2 exit 1 fi - exec go test "${COVER_FLAGS[@]}" "$@" + exec go test '"${COVER_FLAGS[@]}"' "$@" ' bash "$@" ;; *) From a028799d29c5b3492deec7ea3a1ee7403e554847 Mon Sep 17 00:00:00 2001 From: Soner Sayakci Date: Wed, 27 May 2026 05:22:33 +0200 Subject: [PATCH 22/38] fix: pass COVER_FLAGS as args to bash -c, remove stale lock file - Pass COVER_FLAGS as positional arguments to the inner bash on Linux instead of interpolating into the script string. This avoids re-interpretation of spaces and metacharacters in coverage paths. - Remove .claude/scheduled_tasks.lock from tracking and add to .gitignore. --- .claude/scheduled_tasks.lock | 1 - .gitignore | 1 + scripts/run-tests.sh | 4 ++-- 3 files changed, 3 insertions(+), 3 deletions(-) delete mode 100644 .claude/scheduled_tasks.lock diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock deleted file mode 100644 index ef892dd7..00000000 --- a/.claude/scheduled_tasks.lock +++ /dev/null @@ -1 +0,0 @@ -{"sessionId":"f1eff485-16cd-4641-950b-79e0a0a10769","pid":68060,"procStart":"Tue May 26 12:24:31 2026","acquiredAt":1779799762311} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 7f3f21ce..7067c05b 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,4 @@ devenv.local.nix /.gomodcache /.gopath .soulforge +.claude/scheduled_tasks.lock diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh index d2b3c2e0..9793d7b3 100755 --- a/scripts/run-tests.sh +++ b/scripts/run-tests.sh @@ -60,8 +60,8 @@ case "$(uname -s)" in echo "error: need either ip (iproute2) or ifconfig to bring up loopback" >&2 exit 1 fi - exec go test '"${COVER_FLAGS[@]}"' "$@" - ' bash "$@" + exec go test "$@" + ' bash "${COVER_FLAGS[@]}" "$@" ;; *) echo "error: unsupported OS: $(uname -s)" >&2 From 4476528ddfe74b40d3909d359eb653f15ff63e30 Mon Sep 17 00:00:00 2001 From: Soner Sayakci Date: Wed, 27 May 2026 05:38:50 +0200 Subject: [PATCH 23/38] refactor: move cleanup functions to extension package with proper tests - Move cleanup CI logic from cmd/project/ci.go to internal/extension/cleanup_ci.go - Add comprehensive tests for cleanup functions - Clean up checksum test file --- cmd/project/ci.go | 207 +++----------------------- cmd/project/ci_test.go | 51 ------- internal/extension/checksum_test.go | 52 +++++++ internal/extension/cleanup_ci.go | 182 ++++++++++++++++++++++ internal/extension/cleanup_ci_test.go | 144 ++++++++++++++++++ 5 files changed, 399 insertions(+), 237 deletions(-) delete mode 100644 cmd/project/ci_test.go create mode 100644 internal/extension/checksum_test.go create mode 100644 internal/extension/cleanup_ci.go create mode 100644 internal/extension/cleanup_ci_test.go diff --git a/cmd/project/ci.go b/cmd/project/ci.go index dfec17ac..514e1df5 100644 --- a/cmd/project/ci.go +++ b/cmd/project/ci.go @@ -10,9 +10,7 @@ import ( "path/filepath" "strings" - "dario.cat/mergo" "github.com/spf13/cobra" - "golang.org/x/text/language" "github.com/shopware/shopware-cli/internal/ci" "github.com/shopware/shopware-cli/internal/extension" @@ -171,7 +169,8 @@ var projectCI = &cobra.Command{ } optimizeSection := ci.Default.Section(cmd.Context(), "Optimizing Administration Assets") - if err := cleanupAdministrationFiles(cmd.Context(), path.Join(args[0], "vendor", "shopware", "administration")); err != nil { + + if err := extension.CleanupAdministrationFiles(cmd.Context(), path.Join(args[0], "vendor", "shopware", "administration")); err != nil { return err } @@ -181,19 +180,19 @@ var projectCI = &cobra.Command{ if !shopCfg.Build.KeepExtensionSource { for _, source := range sources { - if err := cleanupAdministrationFiles(cmd.Context(), source.Path); err != nil { + if err := extension.CleanupAdministrationFiles(cmd.Context(), source.Path); err != nil { return err } } } if !shopCfg.Build.KeepSourceMaps { - if err := cleanupJavaScriptSourceMaps(path.Join(args[0], "vendor", "shopware", "administration", "Resources", "public")); err != nil { + if err := extension.CleanupJavaScriptSourceMaps(path.Join(args[0], "vendor", "shopware", "administration", "Resources", "public")); err != nil { return err } for _, source := range sources { - if err := cleanupJavaScriptSourceMaps(path.Join(source.Path, "Resources", "public")); err != nil { + if err := extension.CleanupJavaScriptSourceMaps(path.Join(source.Path, "Resources", "public")); err != nil { return err } } @@ -201,8 +200,8 @@ var projectCI = &cobra.Command{ for _, removePath := range cleanupPaths { logging.FromContext(cmd.Context()).Infof("Removing %s", removePath) - - if err := os.RemoveAll(path.Join(args[0], removePath)); err != nil { + fullPath := path.Join(args[0], removePath) + if err := os.RemoveAll(fullPath); err != nil { return err } } @@ -213,26 +212,6 @@ var projectCI = &cobra.Command{ optimizeSection.End(cmd.Context()) - checksumSection := ci.Default.Section(cmd.Context(), "Generating extension checksums") - - extensions := extension.FindExtensionsFromProject(cmd.Context(), args[0], false) - - for _, ext := range extensions { - extPath := ext.GetPath() - checksumPath := filepath.Join(extPath, "checksum.json") - - if _, err := os.Stat(checksumPath); err == nil { - logging.FromContext(cmd.Context()).Infof("Skipping checksum generation for %s: checksum.json already exists", extPath) - continue - } - - if err := extension.GenerateChecksumJSON(cmd.Context(), extPath, ext); err != nil { - logging.FromContext(cmd.Context()).Warnf("Failed to generate checksum for %s: %v", extPath, err) - } - } - - checksumSection.End(cmd.Context()) - 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 @@ -313,6 +292,20 @@ var projectCI = &cobra.Command{ deleteAssetsSection.End(cmd.Context()) } + checksumSection := ci.Default.Section(cmd.Context(), "Generating extension checksums") + + extensions := extension.FindExtensionsFromProject(cmd.Context(), args[0], false) + + for _, ext := range extensions { + extPath := ext.GetPath() + + if err := extension.GenerateChecksumJSON(cmd.Context(), extPath, ext); err != nil { + logging.FromContext(cmd.Context()).Warnf("Failed to generate checksum for %s: %v", extPath, err) + } + } + + checksumSection.End(cmd.Context()) + if shopCfg.Build.Hooks != nil && len(shopCfg.Build.Hooks.Post) > 0 { if err := executeCIHooks(cmd.Context(), "Running post hooks", shopCfg.Build.Hooks.Post, args[0]); err != nil { return err @@ -414,164 +407,6 @@ func cleanupTcpdf(folder string, ctx context.Context) error { }) } -func cleanupAdministrationFiles(ctx context.Context, folder string) error { - adminFolder := path.Join(folder, "Resources", "app", "administration") - - if _, err := os.Stat(adminFolder); err == nil { - logging.FromContext(ctx).Infof("Merging Administration snippet for %s", folder) - - snippetFiles := make(map[string][]string) - - err = filepath.WalkDir(adminFolder, func(path string, d os.DirEntry, err error) error { - if d.IsDir() { - return nil - } - - fileExt := filepath.Ext(path) - - if fileExt != ".json" { - return nil - } - - languageName := strings.TrimSuffix(filepath.Base(path), fileExt) - - if _, err := language.Parse(languageName); err != nil { - logging.FromContext(ctx).Infof("Ignoring invalid locale filename %s", path) - // we can safely ignore the error from language.Parse as we use language.Parse to check and stop processing this file - // thus checking for the error is the point of this condition - return nil //nolint:nilerr - } - - if language.Make(languageName).IsRoot() { - return nil - } - - if _, ok := snippetFiles[languageName]; !ok { - snippetFiles[languageName] = []string{} - } - - snippetFiles[languageName] = append(snippetFiles[languageName], path) - - return nil - }) - if err != nil { - return err - } - - for language, files := range snippetFiles { - if len(files) == 1 { - data, err := os.ReadFile(files[0]) - if err != nil { - return err - } - - if err := os.WriteFile(path.Join(folder, language), data, 0o644); err != nil { - return err - } - - continue - } - - merged := make(map[string]interface{}) - - for _, file := range files { - snippetFile := make(map[string]interface{}) - - data, err := os.ReadFile(file) - if err != nil { - return err - } - - if err := json.Unmarshal(data, &snippetFile); err != nil { - return fmt.Errorf("unable to parse %s: %w", file, err) - } - - if err := mergo.Merge(&merged, snippetFile, mergo.WithOverride); err != nil { - return err - } - } - - mergedData, err := json.Marshal(merged) - if err != nil { - return err - } - - if err := os.WriteFile(path.Join(folder, language), mergedData, 0o644); err != nil { - return err - } - } - - logging.FromContext(ctx).Infof("Deleting Administration source files for %s", folder) - - if err := os.RemoveAll(adminFolder); err != nil { - return err - } - - logging.FromContext(ctx).Infof("Migrating generated snippet file for %s", folder) - - snippetFolder := path.Join(adminFolder, "src", "app", "snippet") - if err := os.MkdirAll(snippetFolder, 0o755); err != nil { - return err - } - - for language := range snippetFiles { - if err := os.Rename(path.Join(folder, language), path.Join(snippetFolder, language+".json")); err != nil { - return err - } - } - - logging.FromContext(ctx).Infof("Creating empty main.js for %s", folder) - return os.WriteFile(path.Join(adminFolder, "src", "main.js"), []byte(""), 0o644) - } - - return nil -} - -func cleanupJavaScriptSourceMaps(folder string) error { - if _, err := os.Stat(folder); err != nil { - if os.IsNotExist(err) { - return nil - } - - return err - } - - return filepath.WalkDir(folder, func(path string, d os.DirEntry, err error) error { - if d.IsDir() { - return nil - } - - if !strings.HasSuffix(path, ".js.map") { - return nil - } - - if err := os.Remove(path); err != nil { - return err - } - - expectedJsFile := path[0 : len(path)-4] - - if _, err := os.Stat(expectedJsFile); err != nil { - if os.IsNotExist(err) { - return nil - } - - return err - } - - content, readErr := os.ReadFile(expectedJsFile) - if readErr != nil { - return fmt.Errorf("could not open file %s: %w", expectedJsFile, readErr) - } - - expectedSourceMapComment := fmt.Sprintf("//# sourceMappingURL=%s", filepath.Base(path)) - - overwrittenContent := strings.ReplaceAll(string(content), expectedSourceMapComment, "") - - return os.WriteFile(expectedJsFile, []byte(overwrittenContent), 0o644) - }) -} - func convertForceExtensionBuild(configExtensions []shop.ConfigBuildExtension) []string { extensionConfigs := make([]string, len(configExtensions)) for i, ext := range configExtensions { diff --git a/cmd/project/ci_test.go b/cmd/project/ci_test.go deleted file mode 100644 index 27c431ac..00000000 --- a/cmd/project/ci_test.go +++ /dev/null @@ -1,51 +0,0 @@ -package project - -import ( - "os" - "path/filepath" - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestSourceMapCleanup(t *testing.T) { - t.Run("invalid directory", func(t *testing.T) { - assert.NoError(t, cleanupJavaScriptSourceMaps("invalid-directory")) - }) - - t.Run("does not touch js", func(t *testing.T) { - tmpDir := t.TempDir() - - assert.NoError(t, cleanupJavaScriptSourceMaps(tmpDir)) - - assert.NoError(t, os.WriteFile(filepath.Join(tmpDir, "random.js"), []byte("test"), 0o644)) - - assert.NoError(t, cleanupJavaScriptSourceMaps(tmpDir)) - - assert.FileExists(t, filepath.Join(tmpDir, "random.js")) - }) - - t.Run("removes map files", func(t *testing.T) { - tmpDir := t.TempDir() - - assert.NoError(t, os.WriteFile(filepath.Join(tmpDir, "foo.js.map"), []byte("test"), 0o644)) - - assert.NoError(t, cleanupJavaScriptSourceMaps(tmpDir)) - - assert.NoFileExists(t, filepath.Join(tmpDir, "foo.js.map")) - }) - - t.Run("remove sourcemap comments", func(t *testing.T) { - tmpDir := t.TempDir() - - assert.NoError(t, os.WriteFile(filepath.Join(tmpDir, "test.js"), []byte("console.log//# sourceMappingURL=test.js.map"), 0o644)) - assert.NoError(t, os.WriteFile(filepath.Join(tmpDir, "test.js.map"), []byte("test"), 0o644)) - - assert.NoError(t, cleanupJavaScriptSourceMaps(tmpDir)) - - content, err := os.ReadFile(filepath.Join(tmpDir, "test.js")) - assert.NoError(t, err) - - assert.Equal(t, "console.log", string(content)) - }) -} diff --git a/internal/extension/checksum_test.go b/internal/extension/checksum_test.go new file mode 100644 index 00000000..5ca1d522 --- /dev/null +++ b/internal/extension/checksum_test.go @@ -0,0 +1,52 @@ +package extension + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/shyim/go-version" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGenerateChecksumJSONOverwritesExistingChecksum(t *testing.T) { + extensionDir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(extensionDir, "composer.json"), []byte(`{"name":"test/test-ext","version":"1.0.0"}`), 0o644)) + require.NoError(t, os.MkdirAll(filepath.Join(extensionDir, "src", "Resources"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(extensionDir, "src", "Resources", "changed.js"), []byte("before"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(extensionDir, "src", "Resources", "removed.js"), []byte("removed"), 0o644)) + + mockExt := &mockExtension{ + name: "TestExt", + extVersion: version.Must(version.NewVersion("1.0.0")), + config: &Config{}, + } + + require.NoError(t, GenerateChecksumJSON(t.Context(), extensionDir, mockExt)) + + firstContent, err := os.ReadFile(filepath.Join(extensionDir, "checksum.json")) + require.NoError(t, err) + + var first ChecksumJSON + require.NoError(t, json.Unmarshal(firstContent, &first)) + originalHash := first.Hashes["src/Resources/changed.js"] + assert.NotEmpty(t, originalHash) + assert.Contains(t, first.Hashes, "src/Resources/removed.js") + + require.NoError(t, os.WriteFile(filepath.Join(extensionDir, "src", "Resources", "changed.js"), []byte("after"), 0o644)) + require.NoError(t, os.Remove(filepath.Join(extensionDir, "src", "Resources", "removed.js"))) + require.NoError(t, os.WriteFile(filepath.Join(extensionDir, "src", "Resources", "added.js"), []byte("added"), 0o644)) + + require.NoError(t, GenerateChecksumJSON(t.Context(), extensionDir, mockExt)) + + secondContent, err := os.ReadFile(filepath.Join(extensionDir, "checksum.json")) + require.NoError(t, err) + + var second ChecksumJSON + require.NoError(t, json.Unmarshal(secondContent, &second)) + assert.NotEqual(t, originalHash, second.Hashes["src/Resources/changed.js"]) + assert.NotContains(t, second.Hashes, "src/Resources/removed.js") + assert.Contains(t, second.Hashes, "src/Resources/added.js") +} diff --git a/internal/extension/cleanup_ci.go b/internal/extension/cleanup_ci.go new file mode 100644 index 00000000..53c210db --- /dev/null +++ b/internal/extension/cleanup_ci.go @@ -0,0 +1,182 @@ +package extension + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "dario.cat/mergo" + "golang.org/x/text/language" + + "github.com/shopware/shopware-cli/logging" +) + +// CleanupAdministrationFiles merges snippet files, deletes the admin source folder, +// and recreates it with only the merged snippets and an empty main.js. +func CleanupAdministrationFiles(ctx context.Context, folder string) error { + adminFolder := filepath.Join(folder, "Resources", "app", "administration") + + if _, err := os.Stat(adminFolder); err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + + logging.FromContext(ctx).Infof("Merging Administration snippet for %s", folder) + + snippetFiles := make(map[string][]string) + + err := filepath.WalkDir(adminFolder, func(path string, d os.DirEntry, err error) error { + if d.IsDir() { + return nil + } + + fileExt := filepath.Ext(path) + + if fileExt != ".json" { + return nil + } + + languageName := strings.TrimSuffix(filepath.Base(path), fileExt) + + if _, err := language.Parse(languageName); err != nil { + logging.FromContext(ctx).Infof("Ignoring invalid locale filename %s", path) + return nil //nolint:nilerr + } + + if language.Make(languageName).IsRoot() { + return nil + } + + if _, ok := snippetFiles[languageName]; !ok { + snippetFiles[languageName] = []string{} + } + + snippetFiles[languageName] = append(snippetFiles[languageName], path) + + return nil + }) + if err != nil { + return err + } + + for language, files := range snippetFiles { + if len(files) == 1 { + data, err := os.ReadFile(files[0]) + if err != nil { + return err + } + + if err := os.WriteFile(filepath.Join(folder, language), data, 0o644); err != nil { + return err + } + + continue + } + + merged := make(map[string]interface{}) + + for _, file := range files { + snippetFile := make(map[string]interface{}) + + data, err := os.ReadFile(file) + if err != nil { + return err + } + + if err := json.Unmarshal(data, &snippetFile); err != nil { + return fmt.Errorf("unable to parse %s: %w", file, err) + } + + if err := mergo.Merge(&merged, snippetFile, mergo.WithOverride); err != nil { + return err + } + } + + mergedData, err := json.Marshal(merged) + if err != nil { + return err + } + + if err := os.WriteFile(filepath.Join(folder, language), mergedData, 0o644); err != nil { + return err + } + } + + logging.FromContext(ctx).Infof("Deleting Administration source files for %s", folder) + + if err := os.RemoveAll(adminFolder); err != nil { + return err + } + + logging.FromContext(ctx).Infof("Migrating generated snippet file for %s", folder) + + snippetFolder := filepath.Join(adminFolder, "src", "app", "snippet") + if err := os.MkdirAll(snippetFolder, 0o755); err != nil { + return err + } + + for language := range snippetFiles { + if err := os.Rename(filepath.Join(folder, language), filepath.Join(snippetFolder, language+".json")); err != nil { + return err + } + } + + logging.FromContext(ctx).Infof("Creating empty main.js for %s", folder) + if err := os.WriteFile(filepath.Join(adminFolder, "src", "main.js"), []byte(""), 0o644); err != nil { + return err + } + + return nil +} + +// CleanupJavaScriptSourceMaps removes .js.map files and their corresponding +// sourceMappingURL comments from .js files. +func CleanupJavaScriptSourceMaps(folder string) error { + if _, err := os.Stat(folder); err != nil { + if os.IsNotExist(err) { + return nil + } + + return err + } + + return filepath.WalkDir(folder, func(path string, d os.DirEntry, err error) error { + if d.IsDir() { + return nil + } + + if !strings.HasSuffix(path, ".js.map") { + return nil + } + + if err := os.Remove(path); err != nil { + return err + } + + expectedJsFile := path[0 : len(path)-4] + + if _, err := os.Stat(expectedJsFile); err != nil { + if os.IsNotExist(err) { + return nil + } + + return err + } + + content, readErr := os.ReadFile(expectedJsFile) + if readErr != nil { + return fmt.Errorf("could not open file %s: %w", expectedJsFile, readErr) + } + + expectedSourceMapComment := fmt.Sprintf("//# sourceMappingURL=%s", filepath.Base(path)) + + overwrittenContent := strings.ReplaceAll(string(content), expectedSourceMapComment, "") + + return os.WriteFile(expectedJsFile, []byte(overwrittenContent), 0o644) + }) +} diff --git a/internal/extension/cleanup_ci_test.go b/internal/extension/cleanup_ci_test.go new file mode 100644 index 00000000..f6ae41ef --- /dev/null +++ b/internal/extension/cleanup_ci_test.go @@ -0,0 +1,144 @@ +package extension + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCleanupAdministrationFiles_RemovesSourceFiles(t *testing.T) { + tmpDir := t.TempDir() + adminDir := filepath.Join(tmpDir, "Resources", "app", "administration") + + // Create admin source structure + require.NoError(t, os.MkdirAll(filepath.Join(adminDir, "src", "module", "test"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(adminDir, "src", "main.js"), []byte("import './module/test'"), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(adminDir, "src", "module", "test", "index.js"), []byte("export default {}"), 0644)) + + err := CleanupAdministrationFiles(context.Background(), tmpDir) + require.NoError(t, err) + + // Verify new empty main.js exists + mainJsPath := filepath.Join(adminDir, "src", "main.js") + assert.FileExists(t, mainJsPath) + + content, err := os.ReadFile(mainJsPath) + require.NoError(t, err) + assert.Empty(t, content) +} + +func TestCleanupAdministrationFiles_MergesSnippetFiles(t *testing.T) { + tmpDir := t.TempDir() + adminDir := filepath.Join(tmpDir, "Resources", "app", "administration") + + // Create snippet structure with multiple locale files + snippetDir1 := filepath.Join(adminDir, "src", "app", "snippet") + snippetDir2 := filepath.Join(adminDir, "src", "module", "test", "snippet") + + require.NoError(t, os.MkdirAll(snippetDir1, 0755)) + require.NoError(t, os.MkdirAll(snippetDir2, 0755)) + + // Create locale snippet files + snippet1 := map[string]string{"key1": "value1"} + snippet2 := map[string]string{"key2": "value2"} + + data1, _ := json.Marshal(snippet1) + data2, _ := json.Marshal(snippet2) + + require.NoError(t, os.WriteFile(filepath.Join(snippetDir1, "en-GB.json"), data1, 0644)) + require.NoError(t, os.WriteFile(filepath.Join(snippetDir2, "en-GB.json"), data2, 0644)) + + err := CleanupAdministrationFiles(context.Background(), tmpDir) + require.NoError(t, err) + + // Verify merged snippet file exists + mergedSnippetPath := filepath.Join(snippetDir1, "en-GB.json") + assert.FileExists(t, mergedSnippetPath) + + // Verify merged content contains both keys + mergedContent, err := os.ReadFile(mergedSnippetPath) + require.NoError(t, err) + + var merged map[string]string + require.NoError(t, json.Unmarshal(mergedContent, &merged)) + + assert.Equal(t, "value1", merged["key1"]) + assert.Equal(t, "value2", merged["key2"]) +} + +func TestCleanupAdministrationFiles_NoAdminFolder(t *testing.T) { + tmpDir := t.TempDir() + + require.NoError(t, CleanupAdministrationFiles(context.Background(), tmpDir)) +} + +func TestCleanupJavaScriptSourceMaps_RemovesMapFiles(t *testing.T) { + tmpDir := t.TempDir() + + // Create .js and .js.map files + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "app.js"), []byte("console.log('test')//# sourceMappingURL=app.js.map"), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "app.js.map"), []byte("{}"), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "vendor.js"), []byte("var x = 1"), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "vendor.js.map"), []byte("{}"), 0644)) + + require.NoError(t, CleanupJavaScriptSourceMaps(tmpDir)) + + // Verify .js.map files are deleted + assert.NoFileExists(t, filepath.Join(tmpDir, "app.js.map")) + assert.NoFileExists(t, filepath.Join(tmpDir, "vendor.js.map")) + + // Verify sourceMappingURL comments are removed from .js files + appJs, err := os.ReadFile(filepath.Join(tmpDir, "app.js")) + require.NoError(t, err) + assert.NotContains(t, string(appJs), "sourceMappingURL") + + vendorJs, err := os.ReadFile(filepath.Join(tmpDir, "vendor.js")) + require.NoError(t, err) + assert.Equal(t, "var x = 1", string(vendorJs)) +} + +func TestCleanupJavaScriptSourceMaps_PreservesNonMapFiles(t *testing.T) { + tmpDir := t.TempDir() + + // Create various files + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "app.js"), []byte("console.log('test')"), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "style.css"), []byte("body {}"), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "image.png"), []byte{}, 0644)) + + require.NoError(t, CleanupJavaScriptSourceMaps(tmpDir)) + + // All files should still exist + assert.FileExists(t, filepath.Join(tmpDir, "app.js")) + assert.FileExists(t, filepath.Join(tmpDir, "style.css")) + assert.FileExists(t, filepath.Join(tmpDir, "image.png")) +} + +func TestCleanupJavaScriptSourceMaps_NestedDirectories(t *testing.T) { + tmpDir := t.TempDir() + + // Create nested directory structure + nestedDir := filepath.Join(tmpDir, "assets", "js") + require.NoError(t, os.MkdirAll(nestedDir, 0755)) + + require.NoError(t, os.WriteFile(filepath.Join(nestedDir, "app.js"), []byte("console.log('test')//# sourceMappingURL=app.js.map"), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(nestedDir, "app.js.map"), []byte("{}"), 0644)) + + require.NoError(t, CleanupJavaScriptSourceMaps(tmpDir)) + + assert.NoFileExists(t, filepath.Join(nestedDir, "app.js.map")) +} + +func TestCleanupJavaScriptSourceMaps_EmptyDirectory(t *testing.T) { + tmpDir := t.TempDir() + + require.NoError(t, CleanupJavaScriptSourceMaps(tmpDir)) +} + +func TestCleanupJavaScriptSourceMaps_NonExistentDirectory(t *testing.T) { + require.NoError(t, CleanupJavaScriptSourceMaps("/non/existent/path")) +} From b93724d80e691e1c9aee6bd1cb82613cba11e3be Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 03:46:24 +0000 Subject: [PATCH 24/38] refactor(projectupgrade): reuse packagist package for registry lookups The registry duplicated the packagist HTTP client, the composer v2 minified-metadata unminifier, the response-body closer, and the packages.json fetch. Move the generic lookups into the packagist package instead: - Add packagist.GetComposerPackageVersions(ctx, name) for any composer package and rebuild GetShopwarePackageVersions on top of it. - Add a Require field to packagist.PackageVersion so store-package metadata carries its shopware/core constraint. PackagistRegistry and ShopwareStoreRegistry now delegate to the packagist package, dropping ~120 lines of duplicated logic. --- internal/packagist/packagist.go | 29 +++++- internal/projectupgrade/registry.go | 156 +++------------------------- 2 files changed, 43 insertions(+), 142 deletions(-) diff --git a/internal/packagist/packagist.go b/internal/packagist/packagist.go index d335c624..2ca8a40b 100644 --- a/internal/packagist/packagist.go +++ b/internal/packagist/packagist.go @@ -33,6 +33,7 @@ type PackageVersion struct { Version string `json:"version"` Description string `json:"description"` Replace map[string]string `json:"replace"` + Require map[string]string `json:"require"` } type ComposerPackageVersion struct { @@ -109,7 +110,25 @@ func PHPConstraintForShopwareVersion(releases []ComposerPackageVersion, chosenVe } func GetShopwarePackageVersions(ctx context.Context) ([]ComposerPackageVersion, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://repo.packagist.org/p2/shopware/core.json", http.NoBody) + versions, err := GetComposerPackageVersions(ctx, "shopware/core") + if err != nil { + return nil, err + } + + if len(versions) == 0 { + return nil, fmt.Errorf("decode package versions: package shopware/core not found") + } + + return versions, nil +} + +// GetComposerPackageVersions fetches every published version of a composer +// package from repo.packagist.org. An empty slice (and no error) is returned +// when the package does not exist. +func GetComposerPackageVersions(ctx context.Context, name string) ([]ComposerPackageVersion, error) { + url := fmt.Sprintf("https://repo.packagist.org/p2/%s.json", name) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody) if err != nil { return nil, fmt.Errorf("create package versions request: %w", err) } @@ -122,6 +141,10 @@ func GetShopwarePackageVersions(ctx context.Context) ([]ComposerPackageVersion, } defer closeResponseBody(ctx, resp) + if resp.StatusCode == http.StatusNotFound { + return nil, nil + } + if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("fetch package versions: %s", resp.Status) } @@ -132,9 +155,9 @@ func GetShopwarePackageVersions(ctx context.Context) ([]ComposerPackageVersion, return nil, fmt.Errorf("decode package versions: %w", err) } - rawVersions, ok := packageResponse.Packages["shopware/core"] + rawVersions, ok := packageResponse.Packages[name] if !ok { - return nil, fmt.Errorf("decode package versions: package shopware/core not found") + return nil, nil } if packageResponse.Minified != "" { diff --git a/internal/projectupgrade/registry.go b/internal/projectupgrade/registry.go index e099500b..1668d008 100644 --- a/internal/projectupgrade/registry.go +++ b/internal/projectupgrade/registry.go @@ -1,15 +1,10 @@ package projectupgrade import ( - "bytes" "context" - "encoding/json" "errors" - "fmt" - "net/http" "strings" "sync" - "time" "github.com/shopware/shopware-cli/internal/packagist" ) @@ -47,105 +42,16 @@ func (c *CombinedRegistry) GetPackageVersions(ctx context.Context, name string) return c.Packagist.GetPackageVersions(ctx, name) } -var registryHTTPClient = &http.Client{Timeout: 30 * time.Second} - -// PackagistRegistry queries https://repo.packagist.org for any composer -// package's available versions. The package metadata is returned with full -// require/replace info so we can pick a Shopware-compatible release. +// PackagistRegistry resolves package versions via repo.packagist.org. type PackagistRegistry struct{} -type packagistResponse struct { - Minified string `json:"minified"` - Packages map[string][]map[string]json.RawMessage `json:"packages"` -} - -func (p PackagistRegistry) GetPackageVersions(ctx context.Context, name string) ([]packagist.ComposerPackageVersion, error) { - url := fmt.Sprintf("https://repo.packagist.org/p2/%s.json", name) - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody) - if err != nil { - return nil, err - } - req.Header.Set("User-Agent", "Shopware CLI") - - resp, err := registryHTTPClient.Do(req) - if err != nil { - return nil, err - } - defer closeBody(resp) - - if resp.StatusCode == http.StatusNotFound { - return nil, nil - } - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("packagist returned %s for %s", resp.Status, name) - } - - var body packagistResponse - if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { - return nil, fmt.Errorf("decode packagist response: %w", err) - } - - raw, ok := body.Packages[name] - if !ok || len(raw) == 0 { - return nil, nil - } - - if body.Minified != "" { - raw = unminify(raw) - } - - versions := make([]packagist.ComposerPackageVersion, 0, len(raw)) - for _, m := range raw { - payload, err := json.Marshal(m) - if err != nil { - continue - } - var v packagist.ComposerPackageVersion - if err := json.Unmarshal(payload, &v); err != nil { - continue - } - versions = append(versions, v) - } - return versions, nil -} - -// unminify expands the composer v2 minified packages format ("__unset" -// markers and inheritance from the previous entry) into independent records. -func unminify(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 current map[string]json.RawMessage - for _, v := range versions { - if current == nil { - current = cloneRaw(v) - expanded = append(expanded, cloneRaw(current)) - continue - } - for k, val := range v { - if bytes.Equal(val, []byte(`"__unset"`)) { - delete(current, k) - } else { - current[k] = val - } - } - expanded = append(expanded, cloneRaw(current)) - } - return expanded -} - -func cloneRaw(in map[string]json.RawMessage) map[string]json.RawMessage { - out := make(map[string]json.RawMessage, len(in)) - for k, v := range in { - out[k] = v - } - return out +func (PackagistRegistry) GetPackageVersions(ctx context.Context, name string) ([]packagist.ComposerPackageVersion, error) { + return packagist.GetComposerPackageVersions(ctx, name) } -// ShopwareStoreRegistry queries https://packages.shopware.com/packages.json -// for store-managed plugins. The full listing is fetched once and cached for -// the lifetime of the registry instance. +// ShopwareStoreRegistry resolves store-managed plugins via +// packages.shopware.com. The full listing is fetched once and cached for the +// lifetime of the registry instance. type ShopwareStoreRegistry struct { Token string @@ -154,10 +60,6 @@ type ShopwareStoreRegistry struct { packages map[string][]packagist.ComposerPackageVersion } -type shopwareStoreResponse struct { - Packages map[string]map[string]packagist.ComposerPackageVersion `json:"packages"` -} - func (s *ShopwareStoreRegistry) load(ctx context.Context) error { s.once.Do(func() { if s.Token == "" { @@ -165,37 +67,23 @@ func (s *ShopwareStoreRegistry) load(ctx context.Context) error { return } - req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://packages.shopware.com/packages.json", http.NoBody) - if err != nil { - s.loadErr = err - return - } - req.Header.Set("User-Agent", "Shopware CLI") - req.Header.Set("Authorization", "Bearer "+s.Token) - - resp, err := registryHTTPClient.Do(req) + response, err := packagist.GetAvailablePackagesFromShopwareStore(ctx, s.Token) if err != nil { s.loadErr = err return } - defer closeBody(resp) - - if resp.StatusCode != http.StatusOK { - s.loadErr = fmt.Errorf("shopware packages returned %s", resp.Status) - return - } - var body shopwareStoreResponse - if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { - s.loadErr = fmt.Errorf("decode shopware packages: %w", err) - return - } - - s.packages = make(map[string][]packagist.ComposerPackageVersion, len(body.Packages)) - for name, versions := range body.Packages { + s.packages = make(map[string][]packagist.ComposerPackageVersion, len(response.Packages)) + for name, versions := range response.Packages { list := make([]packagist.ComposerPackageVersion, 0, len(versions)) for _, v := range versions { - list = append(list, v) + list = append(list, packagist.ComposerPackageVersion{ + Name: name, + Version: v.Version, + Description: v.Description, + Replace: v.Replace, + Require: v.Require, + }) } s.packages[name] = list } @@ -208,17 +96,7 @@ func (s *ShopwareStoreRegistry) GetPackageVersions(ctx context.Context, name str return nil, err } - versions, ok := s.packages[name] - if !ok { - return nil, nil - } - return versions, nil -} - -// closeBody drains and closes an HTTP response body, swallowing any close -// error since callers can't act on it once they've already read the payload. -func closeBody(resp *http.Response) { - _ = resp.Body.Close() + return s.packages[name], nil } // DefaultRegistry builds a CombinedRegistry that uses packages.shopware.com From 6725342224a8b0e2962751e23a3f79861004addd Mon Sep 17 00:00:00 2001 From: Soner Sayakci Date: Wed, 27 May 2026 08:02:05 +0200 Subject: [PATCH 25/38] fix: prevent snippet merge from overwriting root locale files Write merged snippet files to a temp directory instead of directly into the extension root folder. This avoids clobbering existing locale files (e.g. en-GB) that happen to share the same name as a snippet language. --- internal/extension/cleanup_ci.go | 18 +++++++++++++++--- internal/extension/cleanup_ci_test.go | 27 +++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/internal/extension/cleanup_ci.go b/internal/extension/cleanup_ci.go index 53c210db..a2394bde 100644 --- a/internal/extension/cleanup_ci.go +++ b/internal/extension/cleanup_ci.go @@ -64,14 +64,26 @@ func CleanupAdministrationFiles(ctx context.Context, folder string) error { return err } + var tmpSnippetFolder string + + if len(snippetFiles) > 0 { + tmpSnippetFolder, err = os.MkdirTemp(folder, ".shopware-admin-snippets-*") + if err != nil { + return err + } + defer func() { _ = os.RemoveAll(tmpSnippetFolder) }() + } + for language, files := range snippetFiles { + tmpSnippetPath := filepath.Join(tmpSnippetFolder, language+".json") + if len(files) == 1 { data, err := os.ReadFile(files[0]) if err != nil { return err } - if err := os.WriteFile(filepath.Join(folder, language), data, 0o644); err != nil { + if err := os.WriteFile(tmpSnippetPath, data, 0o644); err != nil { return err } @@ -102,7 +114,7 @@ func CleanupAdministrationFiles(ctx context.Context, folder string) error { return err } - if err := os.WriteFile(filepath.Join(folder, language), mergedData, 0o644); err != nil { + if err := os.WriteFile(tmpSnippetPath, mergedData, 0o644); err != nil { return err } } @@ -121,7 +133,7 @@ func CleanupAdministrationFiles(ctx context.Context, folder string) error { } for language := range snippetFiles { - if err := os.Rename(filepath.Join(folder, language), filepath.Join(snippetFolder, language+".json")); err != nil { + if err := os.Rename(filepath.Join(tmpSnippetFolder, language+".json"), filepath.Join(snippetFolder, language+".json")); err != nil { return err } } diff --git a/internal/extension/cleanup_ci_test.go b/internal/extension/cleanup_ci_test.go index f6ae41ef..61770c5b 100644 --- a/internal/extension/cleanup_ci_test.go +++ b/internal/extension/cleanup_ci_test.go @@ -71,6 +71,33 @@ func TestCleanupAdministrationFiles_MergesSnippetFiles(t *testing.T) { assert.Equal(t, "value2", merged["key2"]) } +func TestCleanupAdministrationFiles_DoesNotOverwriteRootLocaleFileWhenMergingSnippets(t *testing.T) { + tmpDir := t.TempDir() + adminDir := filepath.Join(tmpDir, "Resources", "app", "administration") + + snippetDir1 := filepath.Join(adminDir, "src", "app", "snippet") + snippetDir2 := filepath.Join(adminDir, "src", "module", "test", "snippet") + + require.NoError(t, os.MkdirAll(snippetDir1, 0755)) + require.NoError(t, os.MkdirAll(snippetDir2, 0755)) + + rootLocalePath := filepath.Join(tmpDir, "en-GB") + require.NoError(t, os.WriteFile(rootLocalePath, []byte("keep me"), 0644)) + + require.NoError(t, os.WriteFile(filepath.Join(snippetDir1, "en-GB.json"), []byte(`{"key1":"value1"}`), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(snippetDir2, "en-GB.json"), []byte(`{"key2":"value2"}`), 0644)) + + err := CleanupAdministrationFiles(context.Background(), tmpDir) + require.NoError(t, err) + + rootLocaleContent, err := os.ReadFile(rootLocalePath) + require.NoError(t, err) + assert.Equal(t, "keep me", string(rootLocaleContent)) + + assert.FileExists(t, filepath.Join(snippetDir1, "en-GB.json")) + assert.NoFileExists(t, filepath.Join(snippetDir1, "en-GB")) +} + func TestCleanupAdministrationFiles_NoAdminFolder(t *testing.T) { tmpDir := t.TempDir() From b37e3c7c227f3aec18d213428b3b7290142b1998 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 29 May 2026 02:39:40 +0000 Subject: [PATCH 26/38] fix(deps): bump the all group in /internal/verifier/php with 2 updates --- updated-dependencies: - dependency-name: phpstan/phpstan dependency-version: 2.2.1 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: all - dependency-name: rector/rector dependency-version: 2.4.5 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: all ... Signed-off-by: dependabot[bot] --- internal/verifier/php/composer.lock | 33 +++++++++++++++++++---------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/internal/verifier/php/composer.lock b/internal/verifier/php/composer.lock index 5acc32f3..efc05954 100644 --- a/internal/verifier/php/composer.lock +++ b/internal/verifier/php/composer.lock @@ -675,11 +675,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.1.55", + "version": "2.2.1", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/9eaac3826ed5e9b8427350a43cac825eeca3f566", - "reference": "9eaac3826ed5e9b8427350a43cac825eeca3f566", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/dea9c8f2d25cc849391042b71e429c1a4bf82660", + "reference": "dea9c8f2d25cc849391042b71e429c1a4bf82660", "shasum": "" }, "require": { @@ -702,6 +702,17 @@ "license": [ "MIT" ], + "authors": [ + { + "name": "Ondřej Mirtes" + }, + { + "name": "Markus Staab" + }, + { + "name": "Vincent Langlet" + } + ], "description": "PHPStan - PHP Static Analysis Tool", "keywords": [ "dev", @@ -724,7 +735,7 @@ "type": "github" } ], - "time": "2026-05-18T11:57:34+00:00" + "time": "2026-05-28T14:44:12+00:00" }, { "name": "phpstan/phpstan-deprecation-rules", @@ -1531,21 +1542,21 @@ }, { "name": "rector/rector", - "version": "2.4.4", + "version": "2.4.5", "source": { "type": "git", "url": "https://github.com/rectorphp/rector.git", - "reference": "4661c582a20f03df585d2e3fdc4af1b83d67a091" + "reference": "cbd86024be5014d3c14d9f0b3f7aae8ecbffd62c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/rectorphp/rector/zipball/4661c582a20f03df585d2e3fdc4af1b83d67a091", - "reference": "4661c582a20f03df585d2e3fdc4af1b83d67a091", + "url": "https://api.github.com/repos/rectorphp/rector/zipball/cbd86024be5014d3c14d9f0b3f7aae8ecbffd62c", + "reference": "cbd86024be5014d3c14d9f0b3f7aae8ecbffd62c", "shasum": "" }, "require": { "php": "^7.4|^8.0", - "phpstan/phpstan": "^2.1.48" + "phpstan/phpstan": "^2.1.56" }, "conflict": { "rector/rector-doctrine": "*", @@ -1579,7 +1590,7 @@ ], "support": { "issues": "https://github.com/rectorphp/rector/issues", - "source": "https://github.com/rectorphp/rector/tree/2.4.4" + "source": "https://github.com/rectorphp/rector/tree/2.4.5" }, "funding": [ { @@ -1587,7 +1598,7 @@ "type": "github" } ], - "time": "2026-05-20T19:30:21+00:00" + "time": "2026-05-26T21:03:22+00:00" }, { "name": "sebastian/diff", From 17e8d3d5908ac5d98bd081937fda0438147ef2f5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 03:51:59 +0000 Subject: [PATCH 27/38] refactor(packagist): own composer installed.json and constraint helpers The upgrade flow parsed vendor/composer/installed.json by hand, resolved install paths, and re-implemented composer version-constraint checks. Move that composer logic into the packagist package where the rest of the composer model (composer.json, composer.lock, auth.json) already lives: - packagist.InstalledJson / InstalledPackage / ReadInstalledJson model and read vendor/composer/installed.json. - InstalledPackage.InstallDirName resolves a package's install location (symlinks included) to its directory name under a given base dir. - packagist.ConstraintsSatisfiedBy reports whether a require map's constraints for a set of packages are satisfied by a target version. - packagist.BumpConstraint turns a concrete version into a caret constraint. projectupgrade now consumes these helpers and keeps only the upgrade policy (which Shopware packages matter, registry resolution). Its duplicate ShopwarePackages list is reused in place of the former pluginShopwarePackages. plugins.go drops ~145 lines. --- internal/packagist/constraints.go | 48 +++++++ internal/packagist/constraints_test.go | 60 ++++++++ internal/packagist/installed.go | 85 +++++++++++ internal/packagist/installed_test.go | 92 ++++++++++++ internal/projectupgrade/plugins.go | 178 +++--------------------- internal/projectupgrade/plugins_test.go | 16 +-- internal/projectupgrade/releases.go | 74 ++++------ 7 files changed, 339 insertions(+), 214 deletions(-) create mode 100644 internal/packagist/constraints.go create mode 100644 internal/packagist/constraints_test.go create mode 100644 internal/packagist/installed.go create mode 100644 internal/packagist/installed_test.go diff --git a/internal/packagist/constraints.go b/internal/packagist/constraints.go new file mode 100644 index 00000000..bc2c3d73 --- /dev/null +++ b/internal/packagist/constraints.go @@ -0,0 +1,48 @@ +package packagist + +import ( + "strings" + + "github.com/shyim/go-version" +) + +// ConstraintsSatisfiedBy reports whether every constraint that requires +// declares for a package named in packages is satisfied by target. +// Constraints for packages not listed in packages are ignored, and packages +// that declare no constraint are treated as satisfied. An unparseable +// constraint is treated as not satisfied. +func ConstraintsSatisfiedBy(requires map[string]string, packages []string, target *version.Version) bool { + for _, name := range packages { + constraint, ok := requires[name] + if !ok { + continue + } + + c, err := version.NewConstraint(constraint) + if err != nil { + return false + } + + if !c.Check(target) { + return false + } + } + + return true +} + +// BumpConstraint turns a concrete version (e.g. "2.3.4") into a caret +// constraint ("^2.3.4") suitable for a composer.json require entry. Values +// that already look like a constraint (containing range/wildcard operators) +// are returned unchanged. +func BumpConstraint(version string) string { + if version == "" { + return version + } + + if strings.ContainsAny(version, "^~><*|, ") { + return version + } + + return "^" + version +} diff --git a/internal/packagist/constraints_test.go b/internal/packagist/constraints_test.go new file mode 100644 index 00000000..c71e53d2 --- /dev/null +++ b/internal/packagist/constraints_test.go @@ -0,0 +1,60 @@ +package packagist + +import ( + "testing" + + "github.com/shyim/go-version" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestConstraintsSatisfiedBy(t *testing.T) { + t.Parallel() + + target, err := version.NewVersion("6.6.4.0") + require.NoError(t, err) + + packages := []string{"shopware/core", "shopware/storefront"} + + tests := []struct { + name string + requires map[string]string + want bool + }{ + {"no requires", nil, true}, + {"unrelated package ignored", map[string]string{"symfony/console": "^6.0"}, true}, + {"satisfied", map[string]string{"shopware/core": "^6.6"}, true}, + {"not satisfied", map[string]string{"shopware/core": "~6.5.0"}, false}, + {"one of many not satisfied", map[string]string{"shopware/core": "^6.6", "shopware/storefront": "~6.5.0"}, false}, + {"unparseable treated as unsatisfied", map[string]string{"shopware/core": "not-a-constraint"}, false}, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.want, ConstraintsSatisfiedBy(tt.requires, packages, target)) + }) + } +} + +func TestBumpConstraint(t *testing.T) { + t.Parallel() + + tests := []struct { + in string + want string + }{ + {"2.3.4", "^2.3.4"}, + {"1.0.0", "^1.0.0"}, + {"^2.0", "^2.0"}, + {"~1.2", "~1.2"}, + {">=3.0", ">=3.0"}, + {"1.0 | 2.0", "1.0 | 2.0"}, + {"", ""}, + } + + for _, tt := range tests { + assert.Equal(t, tt.want, BumpConstraint(tt.in), "BumpConstraint(%q)", tt.in) + } +} diff --git a/internal/packagist/installed.go b/internal/packagist/installed.go new file mode 100644 index 00000000..6c842da5 --- /dev/null +++ b/internal/packagist/installed.go @@ -0,0 +1,85 @@ +package packagist + +import ( + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" +) + +// InstalledPackage is a single entry of vendor/composer/installed.json. +type InstalledPackage struct { + Name string `json:"name"` + Type string `json:"type"` + Require map[string]string `json:"require"` + InstallPath string `json:"install-path"` +} + +// InstalledJson models vendor/composer/installed.json, composer's record of +// every package actually installed into the project. +type InstalledJson struct { + Packages []InstalledPackage `json:"packages"` +} + +// ReadInstalledJson reads vendor/composer/installed.json relative to +// projectRoot. An empty InstalledJson is returned when the file does not +// exist, so callers can treat "no composer install yet" as "no packages". +func ReadInstalledJson(projectRoot string) (*InstalledJson, error) { + installedPath := filepath.Join(projectRoot, "vendor", "composer", "installed.json") + + content, err := os.ReadFile(installedPath) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return &InstalledJson{}, nil + } + return nil, fmt.Errorf("read installed.json: %w", err) + } + + var installed InstalledJson + if err := json.Unmarshal(content, &installed); err != nil { + return nil, fmt.Errorf("parse installed.json: %w", err) + } + + return &installed, nil +} + +// InstallDirName resolves the package's install location (install-path is +// recorded relative to vendor/composer) and, when it sits directly inside +// baseDir, returns the child directory name. ok is false when the package is +// installed anywhere else. Symlinks are resolved on both sides so symlinked +// packages still match. +func (p InstalledPackage) InstallDirName(projectRoot, baseDir string) (string, bool) { + if p.InstallPath == "" { + return "", false + } + + abs := p.InstallPath + if !filepath.IsAbs(abs) { + abs = filepath.Join(projectRoot, "vendor", "composer", p.InstallPath) + } + + rel, err := filepath.Rel(resolveSymlinks(baseDir), resolveSymlinks(abs)) + if err != nil { + return "", false + } + + if rel == "." || rel == "" || strings.HasPrefix(rel, "..") { + return "", false + } + + if strings.ContainsRune(rel, filepath.Separator) { + return "", false + } + + return rel, true +} + +func resolveSymlinks(path string) string { + if resolved, err := filepath.EvalSymlinks(path); err == nil { + return resolved + } + return filepath.Clean(path) +} diff --git a/internal/packagist/installed_test.go b/internal/packagist/installed_test.go new file mode 100644 index 00000000..bf26ca7e --- /dev/null +++ b/internal/packagist/installed_test.go @@ -0,0 +1,92 @@ +package packagist + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func writeInstalled(t *testing.T, projectRoot string, installed InstalledJson) { + t.Helper() + dir := filepath.Join(projectRoot, "vendor", "composer") + require.NoError(t, os.MkdirAll(dir, 0o755)) + data, err := json.MarshalIndent(installed, "", " ") + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(dir, "installed.json"), data, 0o644)) +} + +func TestReadInstalledJsonMissingReturnsEmpty(t *testing.T) { + t.Parallel() + installed, err := ReadInstalledJson(t.TempDir()) + require.NoError(t, err) + require.NotNil(t, installed) + assert.Empty(t, installed.Packages) +} + +func TestReadInstalledJsonParsesPackages(t *testing.T) { + t.Parallel() + dir := t.TempDir() + writeInstalled(t, dir, InstalledJson{Packages: []InstalledPackage{ + {Name: "vendor/a", Type: "shopware-platform-plugin", InstallPath: "../../custom/plugins/A"}, + }}) + + installed, err := ReadInstalledJson(dir) + require.NoError(t, err) + require.NotNil(t, installed) + require.Len(t, installed.Packages, 1) + assert.Equal(t, "vendor/a", installed.Packages[0].Name) +} + +func TestReadInstalledJsonMalformedReturnsError(t *testing.T) { + t.Parallel() + dir := t.TempDir() + composerDir := filepath.Join(dir, "vendor", "composer") + require.NoError(t, os.MkdirAll(composerDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(composerDir, "installed.json"), []byte("{not json"), 0o644)) + + _, err := ReadInstalledJson(dir) + require.Error(t, err) +} + +func TestInstallDirNameDirectChild(t *testing.T) { + t.Parallel() + dir := t.TempDir() + base := filepath.Join(dir, "custom", "plugins") + require.NoError(t, os.MkdirAll(filepath.Join(base, "MyPlugin"), 0o755)) + + pkg := InstalledPackage{InstallPath: "../../custom/plugins/MyPlugin"} + name, ok := pkg.InstallDirName(dir, base) + assert.True(t, ok) + assert.Equal(t, "MyPlugin", name) +} + +func TestInstallDirNameNotUnderBase(t *testing.T) { + t.Parallel() + dir := t.TempDir() + base := filepath.Join(dir, "custom", "plugins") + + pkg := InstalledPackage{InstallPath: "../vendor/installed"} + _, ok := pkg.InstallDirName(dir, base) + assert.False(t, ok) +} + +func TestInstallDirNameNestedIsNotDirectChild(t *testing.T) { + t.Parallel() + dir := t.TempDir() + base := filepath.Join(dir, "custom", "plugins") + + pkg := InstalledPackage{InstallPath: "../../custom/plugins/Group/Nested"} + _, ok := pkg.InstallDirName(dir, base) + assert.False(t, ok) +} + +func TestInstallDirNameEmptyPath(t *testing.T) { + t.Parallel() + pkg := InstalledPackage{} + _, ok := pkg.InstallDirName("/project", "/project/custom/plugins") + assert.False(t, ok) +} diff --git a/internal/projectupgrade/plugins.go b/internal/projectupgrade/plugins.go index b06adfde..d84a9c95 100644 --- a/internal/projectupgrade/plugins.go +++ b/internal/projectupgrade/plugins.go @@ -2,7 +2,6 @@ package projectupgrade import ( "context" - "encoding/json" "errors" "fmt" "io/fs" @@ -19,27 +18,6 @@ import ( // composerPluginType is the composer "type" used by Shopware platform plugins. const composerPluginType = "shopware-platform-plugin" -// pluginShopwarePackages are the Shopware first-party packages a plugin can -// declare a constraint against. If any constraint cannot be satisfied by the -// target version, the plugin is considered incompatible. -var pluginShopwarePackages = []string{ - "shopware/core", - "shopware/administration", - "shopware/storefront", - "shopware/elasticsearch", -} - -type installedPackage struct { - Name string `json:"name"` - Type string `json:"type"` - Require map[string]string `json:"require"` - InstallPath string `json:"install-path"` -} - -type installedJSON struct { - Packages []installedPackage `json:"packages"` -} - // PluginAction describes how the resolver dealt with one incompatible plugin. type PluginAction struct { // Name is the composer package name (e.g. "store.shopware.com/swagcms"). @@ -100,19 +78,9 @@ func (r *ResolveResult) Removed() []PluginAction { func ResolveIncompatiblePlugins(ctx context.Context, composerJsonPath, targetVersion string, registry Registry) (*ResolveResult, error) { projectDir := filepath.Dir(composerJsonPath) - installedPath := filepath.Join(projectDir, "vendor", "composer", "installed.json") - - data, err := os.ReadFile(installedPath) + installed, err := packagist.ReadInstalledJson(projectDir) if err != nil { - if errors.Is(err, fs.ErrNotExist) { - return &ResolveResult{}, nil - } - return nil, fmt.Errorf("read installed.json: %w", err) - } - - var installed installedJSON - if err := json.Unmarshal(data, &installed); err != nil { - return nil, fmt.Errorf("parse installed.json: %w", err) + return nil, err } target, err := version.NewVersion(strings.TrimPrefix(targetVersion, "v")) @@ -120,15 +88,17 @@ func ResolveIncompatiblePlugins(ctx context.Context, composerJsonPath, targetVer return nil, fmt.Errorf("parse target version: %w", err) } - incompatible := make([]installedPackage, 0) + customPlugins := filepath.Join(projectDir, "custom", "plugins") + + incompatible := make([]packagist.InstalledPackage, 0) for _, pkg := range installed.Packages { if pkg.Type != composerPluginType { continue } - if !isInstalledUnderCustomPlugins(projectDir, pkg.InstallPath) { + if _, ok := pkg.InstallDirName(projectDir, customPlugins); !ok { continue } - if pluginSatisfies(pkg.Require, target) { + if packagist.ConstraintsSatisfiedBy(pkg.Require, ShopwarePackages, target) { continue } incompatible = append(incompatible, pkg) @@ -165,7 +135,7 @@ func ResolveIncompatiblePlugins(ctx context.Context, composerJsonPath, targetVer continue } - newConstraint := bumpConstraint(newVersion) + newConstraint := packagist.BumpConstraint(newVersion) composerJson.Require[pkg.Name] = newConstraint action.NewConstraint = newConstraint action.NewVersion = newVersion @@ -201,7 +171,7 @@ func findCompatibleVersion(ctx context.Context, registry Registry, name string, if isPreReleaseVersion(v.Version) { continue } - if !satisfiesShopwareTarget(v.Require, target) { + if !packagist.ConstraintsSatisfiedBy(v.Require, ShopwarePackages, target) { continue } parsed = append(parsed, v) @@ -233,39 +203,6 @@ func isPreReleaseVersion(v string) bool { return false } -func satisfiesShopwareTarget(requires map[string]string, target *version.Version) bool { - if len(requires) == 0 { - // No shopware/core constraint declared — assume compatible. - return true - } - for dep, constraint := range requires { - if !containsString(pluginShopwarePackages, dep) { - continue - } - c, err := version.NewConstraint(constraint) - if err != nil { - return false - } - if !c.Check(target) { - return false - } - } - return true -} - -// bumpConstraint converts a concrete version (e.g. "2.3.4") into a caret -// constraint ("^2.3.4") suitable for composer.json. Versions that already -// look like a constraint are passed through unchanged. -func bumpConstraint(version string) string { - if version == "" { - return version - } - if strings.ContainsAny(version, "^~><*|, ") { - return version - } - return "^" + version -} - // FindNonComposerPlugins returns directories under custom/plugins/ that are // not tracked by composer (no entry in vendor/composer/installed.json). // Returns an empty slice when no installed.json is present. @@ -279,15 +216,14 @@ func FindNonComposerPlugins(projectRoot string) ([]string, error) { return nil, fmt.Errorf("read %s: %w", customPlugins, err) } - installedPath := filepath.Join(projectRoot, "vendor", "composer", "installed.json") composerTracked := make(map[string]struct{}) - if data, err := os.ReadFile(installedPath); err == nil { - var installed installedJSON - if jsonErr := json.Unmarshal(data, &installed); jsonErr == nil { - for _, pkg := range installed.Packages { - if dir, ok := installedDirName(projectRoot, pkg.InstallPath); ok { - composerTracked[dir] = struct{}{} - } + // Best-effort: a missing or malformed installed.json simply means nothing + // is tracked, so every plugin directory is reported. + installed, _ := packagist.ReadInstalledJson(projectRoot) + if installed != nil { + for _, pkg := range installed.Packages { + if dir, ok := pkg.InstallDirName(projectRoot, customPlugins); ok { + composerTracked[dir] = struct{}{} } } } @@ -309,85 +245,3 @@ func FindNonComposerPlugins(projectRoot string) ([]string, error) { sort.Strings(orphans) return orphans, nil } - -func installedDirName(projectRoot, installPath string) (string, bool) { - if installPath == "" { - return "", false - } - abs := installPath - if !filepath.IsAbs(abs) { - abs = filepath.Join(projectRoot, "vendor", "composer", installPath) - } - resolved, err := filepath.EvalSymlinks(abs) - if err != nil { - resolved = filepath.Clean(abs) - } - customPlugins := filepath.Join(projectRoot, "custom", "plugins") - resolvedCustom, err := filepath.EvalSymlinks(customPlugins) - if err != nil { - resolvedCustom = filepath.Clean(customPlugins) - } - rel, err := filepath.Rel(resolvedCustom, resolved) - if err != nil { - return "", false - } - if rel == "." || rel == "" || strings.HasPrefix(rel, "..") { - return "", false - } - if strings.ContainsRune(rel, filepath.Separator) { - return "", false - } - return rel, true -} - -func isInstalledUnderCustomPlugins(projectDir, installPath string) bool { - if installPath == "" { - return false - } - absPath := installPath - if !filepath.IsAbs(absPath) { - absPath = filepath.Join(projectDir, "vendor", "composer", installPath) - } - resolved, err := filepath.EvalSymlinks(absPath) - if err != nil { - resolved = filepath.Clean(absPath) - } - customPlugins := filepath.Join(projectDir, "custom", "plugins") - resolvedCustom, err := filepath.EvalSymlinks(customPlugins) - if err != nil { - resolvedCustom = filepath.Clean(customPlugins) - } - rel, err := filepath.Rel(resolvedCustom, resolved) - if err != nil { - return false - } - if rel == "." || rel == "" || strings.HasPrefix(rel, "..") { - return false - } - return !strings.ContainsRune(rel, filepath.Separator) -} - -func pluginSatisfies(requires map[string]string, target *version.Version) bool { - for dep, constraint := range requires { - if !containsString(pluginShopwarePackages, dep) { - continue - } - c, err := version.NewConstraint(constraint) - if err != nil { - continue - } - if !c.Check(target) { - return false - } - } - return true -} - -func containsString(haystack []string, needle string) bool { - for _, item := range haystack { - if item == needle { - return true - } - } - return false -} diff --git a/internal/projectupgrade/plugins_test.go b/internal/projectupgrade/plugins_test.go index 5c3a313d..768fe175 100644 --- a/internal/projectupgrade/plugins_test.go +++ b/internal/projectupgrade/plugins_test.go @@ -13,13 +13,13 @@ import ( "github.com/shopware/shopware-cli/internal/packagist" ) -func writeInstalledJSON(t *testing.T, projectDir string, packages []installedPackage) { +func writeInstalledJSON(t *testing.T, projectDir string, packages []packagist.InstalledPackage) { t.Helper() installedDir := filepath.Join(projectDir, "vendor", "composer") require.NoError(t, os.MkdirAll(installedDir, 0o755)) - data, err := json.MarshalIndent(installedJSON{Packages: packages}, "", " ") + data, err := json.MarshalIndent(packagist.InstalledJson{Packages: packages}, "", " ") require.NoError(t, err) require.NoError(t, os.WriteFile(filepath.Join(installedDir, "installed.json"), data, 0o644)) } @@ -55,7 +55,7 @@ func TestResolveIncompatiblePluginsRemovesWhenNoRegistry(t *testing.T) { }, }) - writeInstalledJSON(t, dir, []installedPackage{ + writeInstalledJSON(t, dir, []packagist.InstalledPackage{ { Name: "vendor/incompat", Type: composerPluginType, @@ -92,7 +92,7 @@ func TestResolveIncompatiblePluginsBumpsConstraintWhenRegistryHasCompatibleVersi }, }) - writeInstalledJSON(t, dir, []installedPackage{ + writeInstalledJSON(t, dir, []packagist.InstalledPackage{ { Name: "vendor/incompat", Type: composerPluginType, @@ -144,7 +144,7 @@ func TestResolveIncompatiblePluginsRemovesWhenNoCompatibleRelease(t *testing.T) }, }) - writeInstalledJSON(t, dir, []installedPackage{ + writeInstalledJSON(t, dir, []packagist.InstalledPackage{ { Name: "vendor/incompat", Type: composerPluginType, @@ -187,7 +187,7 @@ func TestResolveIncompatiblePluginsRegistryErrorFallsBackToRemove(t *testing.T) }, }) - writeInstalledJSON(t, dir, []installedPackage{ + writeInstalledJSON(t, dir, []packagist.InstalledPackage{ { Name: "vendor/incompat", Type: composerPluginType, @@ -228,7 +228,7 @@ func TestFindNonComposerPluginsReportsUntrackedDirectories(t *testing.T) { require.NoError(t, os.MkdirAll(filepath.Join(dir, "custom", "plugins", "UntrackedPlugin"), 0o755)) require.NoError(t, os.MkdirAll(filepath.Join(dir, "custom", "plugins", "AnotherUntracked"), 0o755)) - writeInstalledJSON(t, dir, []installedPackage{ + writeInstalledJSON(t, dir, []packagist.InstalledPackage{ { Name: "vendor/tracked", Type: composerPluginType, @@ -257,7 +257,7 @@ func TestFindNonComposerPluginsAllTracked(t *testing.T) { require.NoError(t, os.MkdirAll(filepath.Join(dir, "custom", "plugins", "A"), 0o755)) require.NoError(t, os.MkdirAll(filepath.Join(dir, "custom", "plugins", "B"), 0o755)) - writeInstalledJSON(t, dir, []installedPackage{ + writeInstalledJSON(t, dir, []packagist.InstalledPackage{ {Name: "vendor/a", Type: composerPluginType, InstallPath: "../../custom/plugins/A"}, {Name: "vendor/b", Type: composerPluginType, InstallPath: "../../custom/plugins/B"}, }) diff --git a/internal/projectupgrade/releases.go b/internal/projectupgrade/releases.go index fdbd0bea..c2c44f36 100644 --- a/internal/projectupgrade/releases.go +++ b/internal/projectupgrade/releases.go @@ -2,30 +2,44 @@ package projectupgrade import ( "sort" - "strconv" - "strings" "github.com/shyim/go-version" ) +// branch identifies a Shopware release branch by its major and minor segment +// (e.g. 6.6). Shopware ships feature releases on the minor segment, so the +// "next" branch is the current one with its minor incremented. +type branch struct { + major int + minor int +} + +func branchOf(v *version.Version) branch { + return branch{major: v.Major(), minor: v.Minor()} +} + +func (b branch) next() branch { + return branch{major: b.major, minor: b.minor + 1} +} + // FilterUpdateVersions returns the upgrade target versions appropriate for -// currentVersion: the next major version's releases first (newest first), -// followed by the remaining patches of the current major. This mirrors the -// version filtering applied by `ReleaseInfoProvider::fetchUpdateVersions` in +// currentVersion: the next branch's releases first (newest first), followed +// by the remaining patches of the current branch. This mirrors the version +// filtering applied by `ReleaseInfoProvider::fetchUpdateVersions` in // shopware/web-installer. // -// Release candidates and any version older than or equal to currentVersion -// are dropped. +// Pre-releases (RC/beta/alpha) and any version older than or equal to +// currentVersion are dropped. func FilterUpdateVersions(currentVersion *version.Version, allVersions []string) []string { parsed := make([]*version.Version, 0, len(allVersions)) for _, raw := range allVersions { - if strings.Contains(strings.ToLower(raw), "rc") { + v, err := version.NewVersion(raw) + if err != nil { continue } - v, err := version.NewVersion(raw) - if err != nil { + if v.IsPrerelease() { continue } @@ -40,45 +54,17 @@ func FilterUpdateVersions(currentVersion *version.Version, allVersions []string) return parsed[i].GreaterThan(parsed[j]) }) - byMajor := map[string][]string{} + byBranch := map[branch][]string{} for _, v := range parsed { - major := majorBranch(v) - byMajor[major] = append(byMajor[major], v.String()) + b := branchOf(v) + byBranch[b] = append(byBranch[b], v.String()) } - currentMajor := majorBranch(currentVersion) - nextMajor := nextMajor(currentMajor) + currentBranch := branchOf(currentVersion) result := make([]string, 0) - if list, ok := byMajor[nextMajor]; ok { - result = append(result, list...) - } - if list, ok := byMajor[currentMajor]; ok { - result = append(result, list...) - } + result = append(result, byBranch[currentBranch.next()]...) + result = append(result, byBranch[currentBranch]...) return result } - -func majorBranch(v *version.Version) string { - segments := v.Segments() - if len(segments) < 2 { - return v.String() - } - - return strconv.Itoa(segments[0]) + "." + strconv.Itoa(segments[1]) -} - -func nextMajor(currentMajor string) string { - parts := strings.SplitN(currentMajor, ".", 2) - if len(parts) != 2 { - return currentMajor - } - - minor, err := strconv.Atoi(parts[1]) - if err != nil { - return currentMajor - } - - return parts[0] + "." + strconv.Itoa(minor+1) -} From 951109aed378d271b9892868ec3e77390821f679 Mon Sep 17 00:00:00 2001 From: Soner Sayakci Date: Fri, 29 May 2026 08:03:34 +0200 Subject: [PATCH 28/38] fix(projectupgrade): resolve plugin constraints and reorder upgrade steps Several fixes to the project upgrade flow surfaced by real upgrades: - Treat store "with new Shopware version" status as resolvable, not a blocker. Classification now keys on the semantic status name (notCompatible) instead of the display color, matching the platform's ExtensionCompatibility constants. - Resolve plugin constraints for vendor-installed plugins, not just those under custom/plugins/. Scope candidates by the root composer.json require so store plugins (swag/*, frosh/*) installed into vendor/ get bumped too. - Look up store-owned, vendor-named plugins via the store registry first (falling back to Packagist) instead of routing only by name prefix. - Run system:update:prepare before composer update so it executes on the still-installed Shopware; restore composer.json if prepare fails. - Center the wizard in the terminal and replay the full failed-step log after the alt-screen tears down. Adds tests for status classification, vendor-installed resolution, registry routing, and upgrade step ordering. --- cmd/project/project_upgrade.go | 47 ++++-- internal/account-api/updates.go | 32 +++- internal/account-api/updates_test.go | 55 +++++++ internal/projectupgrade/plugins.go | 18 ++- internal/projectupgrade/plugins_test.go | 81 ++++++++++ internal/projectupgrade/registry.go | 25 +-- internal/projectupgrade/registry_test.go | 85 ++++++++++ internal/projectupgrade/wizard.go | 193 +++++++++++++++++------ internal/projectupgrade/wizard_test.go | 72 ++++++++- 9 files changed, 522 insertions(+), 86 deletions(-) create mode 100644 internal/account-api/updates_test.go create mode 100644 internal/projectupgrade/registry_test.go diff --git a/cmd/project/project_upgrade.go b/cmd/project/project_upgrade.go index 31023e2d..9e22457c 100644 --- a/cmd/project/project_upgrade.go +++ b/cmd/project/project_upgrade.go @@ -103,7 +103,7 @@ bin/console system:update:prepare and system:update:finish.`, return err } - target, success, err := projectupgrade.RunWizard(projectupgrade.WizardOptions{ + result, err := projectupgrade.RunWizard(projectupgrade.WizardOptions{ ProjectRoot: projectRoot, ComposerJSONPath: composerJsonPath, CurrentVersion: currentVersion, @@ -119,17 +119,28 @@ bin/console system:update:prepare and system:update:finish.`, status = "cancelled" case err != nil: status = "failed" - case !success: + case !result.Success: status = "failed" } - trackUpgrade(ctx, currentVersion.String(), target, status) + trackUpgrade(ctx, currentVersion.String(), result.TargetVersion, status) if errors.Is(err, projectupgrade.ErrCancelled) { fmt.Println("Upgrade cancelled.") return nil } + // The wizard runs in the alt-screen, so its live log is gone once it + // exits. Replay the full output of the failed task to the terminal so + // the user keeps the complete error in their scrollback. + if len(result.FailureLog) > 0 { + out := cmd.ErrOrStderr() + _, _ = fmt.Fprintln(out, "\nUpgrade failed. Full output of the failed step:") + for _, line := range result.FailureLog { + _, _ = fmt.Fprintln(out, line) + } + } + return err }, } @@ -196,6 +207,21 @@ func runUpgradeHeadless(cmd *cobra.Command, projectRoot, composerJsonPath string return fmt.Errorf("update composer.json: %w", err) } + // system:update:prepare must run on the still-installed (old) Shopware so + // it can enter maintenance mode and record the pending update before the + // vendor code is swapped by composer update. + log.Infof("Running bin/console system:update:prepare") + prepareCmd := cmdExecutor.ConsoleCommand(ctx, "system:update:prepare", "--no-interaction") + prepareCmd.Cmd.Stdin = cmd.InOrStdin() + prepareCmd.Cmd.Stdout = cmd.OutOrStdout() + prepareCmd.Cmd.Stderr = cmd.ErrOrStderr() + + if err := prepareCmd.Run(); err != nil { + restoreComposerJson(ctx, composerJsonPath, backup) + trackUpgrade(ctx, currentVersion.String(), targetVersion, "system_update_prepare_failed") + return fmt.Errorf("system:update:prepare failed, composer.json was restored: %w", err) + } + log.Infof("Running composer update") composerArgs := []string{ "update", @@ -217,17 +243,6 @@ func runUpgradeHeadless(cmd *cobra.Command, projectRoot, composerJsonPath string return fmt.Errorf("composer update failed, composer.json was restored: %w", err) } - log.Infof("Running bin/console system:update:prepare") - prepareCmd := cmdExecutor.ConsoleCommand(ctx, "system:update:prepare", "--no-interaction") - prepareCmd.Cmd.Stdin = cmd.InOrStdin() - prepareCmd.Cmd.Stdout = cmd.OutOrStdout() - prepareCmd.Cmd.Stderr = cmd.ErrOrStderr() - - if err := prepareCmd.Run(); err != nil { - trackUpgrade(ctx, currentVersion.String(), targetVersion, "system_update_prepare_failed") - return fmt.Errorf("system:update:prepare failed: %w", err) - } - log.Infof("Running bin/console system:update:finish") finishCmd := cmdExecutor.ConsoleCommand(ctx, "system:update:finish", "--no-interaction") finishCmd.Cmd.Stdin = cmd.InOrStdin() @@ -338,8 +353,8 @@ func runCompatibilityCheck(ctx context.Context, currentVersion *version.Version, if hasBlockers && system.IsInteractionEnabled(ctx) { var proceed bool if err := huh.NewConfirm(). - Title("Some installed extensions are not yet compatible with the target version"). - Description("Continuing may break those extensions. Proceed anyway?"). + Title("Some installed extensions have no compatible version for the target version"). + Description("They will be removed from composer.json so the upgrade can proceed. Re-require them once they publish a compatible release. Proceed anyway?"). Value(&proceed). Run(); err != nil { return err diff --git a/internal/account-api/updates.go b/internal/account-api/updates.go index 62005f82..280d868b 100644 --- a/internal/account-api/updates.go +++ b/internal/account-api/updates.go @@ -23,14 +23,44 @@ type UpdateCheckExtensionCompatibility struct { Status UpdateCheckExtensionCompatibilityStatus `json:"status"` } +// Plugin compatibility status names returned by the store autoupdate +// endpoint. These mirror the constants in Shopware's +// Core\Framework\Update\Services\ExtensionCompatibility. The status `type` +// field is only a display color (green/red/…), so classification must key on +// the semantic `name` instead. +const ( + // CompatibilityCompatible means the installed version already works with + // the target Shopware version. + CompatibilityCompatible = "compatible" + // CompatibilityUpdatableNow / CompatibilityUpdatableFuture mean the + // extension has a compatible release available ("With new Shopware + // version"): not a blocker, the constraint just needs to be bumped. + CompatibilityUpdatableNow = "updatableNow" + CompatibilityUpdatableFuture = "updatableFuture" + // CompatibilityNotCompatible means no compatible successor exists. This + // is the only genuine blocker. + CompatibilityNotCompatible = "notCompatible" + // CompatibilityNotInStore means the extension is not managed by the store. + CompatibilityNotInStore = "notInStore" +) + type UpdateCheckExtensionCompatibilityStatus struct { Name string `json:"name"` Label string `json:"label"` Type string `json:"type"` } +// IsBlocker reports whether this status prevents the upgrade. Only +// notCompatible (no compatible successor) blocks; updatableNow/updatableFuture +// are resolvable by bumping the extension constraint, so they are not blockers. func (s UpdateCheckExtensionCompatibilityStatus) IsBlocker() bool { - return s.Type != "success" && s.Type != "" + return s.Name == CompatibilityNotCompatible +} + +// IsUpdatable reports whether a compatible release exists that the installed +// version must be bumped to ("With new Shopware version"). +func (s UpdateCheckExtensionCompatibilityStatus) IsUpdatable() bool { + return s.Name == CompatibilityUpdatableNow || s.Name == CompatibilityUpdatableFuture } func GetFutureExtensionUpdates(ctx context.Context, currentVersion string, futureVersion string, extensions []UpdateCheckExtension) ([]UpdateCheckExtensionCompatibility, error) { diff --git a/internal/account-api/updates_test.go b/internal/account-api/updates_test.go new file mode 100644 index 00000000..489b4620 --- /dev/null +++ b/internal/account-api/updates_test.go @@ -0,0 +1,55 @@ +package account_api + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestUpdateCheckExtensionCompatibilityStatusClassification(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + status UpdateCheckExtensionCompatibilityStatus + isBlocker bool + isUpdatable bool + }{ + { + name: "compatible", + status: UpdateCheckExtensionCompatibilityStatus{Name: CompatibilityCompatible, Type: "green"}, + }, + { + name: "updatable now is not a blocker", + status: UpdateCheckExtensionCompatibilityStatus{Name: CompatibilityUpdatableNow, Type: "yellow"}, + isUpdatable: true, + }, + { + name: "updatable future is not a blocker", + status: UpdateCheckExtensionCompatibilityStatus{Name: CompatibilityUpdatableFuture, Type: "yellow"}, + isUpdatable: true, + }, + { + name: "not compatible blocks", + status: UpdateCheckExtensionCompatibilityStatus{Name: CompatibilityNotCompatible, Type: "red"}, + isBlocker: true, + }, + { + name: "not in store is informational", + status: UpdateCheckExtensionCompatibilityStatus{Name: CompatibilityNotInStore}, + }, + { + name: "empty status is not a blocker", + status: UpdateCheckExtensionCompatibilityStatus{}, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.isBlocker, tt.status.IsBlocker()) + assert.Equal(t, tt.isUpdatable, tt.status.IsUpdatable()) + }) + } +} diff --git a/internal/projectupgrade/plugins.go b/internal/projectupgrade/plugins.go index d84a9c95..0559463e 100644 --- a/internal/projectupgrade/plugins.go +++ b/internal/projectupgrade/plugins.go @@ -88,14 +88,23 @@ func ResolveIncompatiblePlugins(ctx context.Context, composerJsonPath, targetVer return nil, fmt.Errorf("parse target version: %w", err) } - customPlugins := filepath.Join(projectDir, "custom", "plugins") + composerJson, err := packagist.ReadComposerJson(composerJsonPath) + if err != nil { + return nil, err + } + // Consider every shopware platform plugin that the root composer.json + // requires and whose installed shopware/* constraint is not satisfied by + // the target version - regardless of where composer installed it. Store + // plugins (swag/*, frosh/*, …) install into vendor/, not custom/plugins/, + // so restricting to custom/plugins/ would leave their stale constraints in + // place and break `composer update`. incompatible := make([]packagist.InstalledPackage, 0) for _, pkg := range installed.Packages { if pkg.Type != composerPluginType { continue } - if _, ok := pkg.InstallDirName(projectDir, customPlugins); !ok { + if _, ok := composerJson.Require[pkg.Name]; !ok { continue } if packagist.ConstraintsSatisfiedBy(pkg.Require, ShopwarePackages, target) { @@ -108,11 +117,6 @@ func ResolveIncompatiblePlugins(ctx context.Context, composerJsonPath, targetVer return &ResolveResult{}, nil } - composerJson, err := packagist.ReadComposerJson(composerJsonPath) - if err != nil { - return nil, err - } - result := &ResolveResult{} for _, pkg := range incompatible { diff --git a/internal/projectupgrade/plugins_test.go b/internal/projectupgrade/plugins_test.go index 768fe175..e28ac97e 100644 --- a/internal/projectupgrade/plugins_test.go +++ b/internal/projectupgrade/plugins_test.go @@ -128,6 +128,87 @@ func TestResolveIncompatiblePluginsBumpsConstraintWhenRegistryHasCompatibleVersi assert.Equal(t, "^2.1.0", requireMap["vendor/incompat"]) } +// Store/composer plugins install into vendor/, not custom/plugins/. They must +// still be resolved, otherwise their stale shopware/core constraint breaks +// `composer update` (regression: frosh/tools ^1.4 / swag/paypal ^8.11 left +// untouched on a 6.5 → 6.6 upgrade). +func TestResolveIncompatiblePluginsBumpsVendorInstalledPlugin(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + composerJsonPath := filepath.Join(dir, "composer.json") + + writeJSON(t, composerJsonPath, map[string]any{ + "name": "shopware/production", + "require": map[string]any{ + "shopware/core": "6.5.8.0", + "swag/paypal": "^8.11", + }, + }) + + // Installed under vendor/ — no custom/plugins/ entry at all. + writeInstalledJSON(t, dir, []packagist.InstalledPackage{ + { + Name: "swag/paypal", + Type: composerPluginType, + InstallPath: "../swag/paypal", + Require: map[string]string{"shopware/core": "~6.5.5"}, + }, + }) + + registry := &fakeRegistry{ + versions: map[string][]packagist.ComposerPackageVersion{ + "swag/paypal": { + {Version: "8.11.0", Require: map[string]string{"shopware/core": "~6.5.5"}}, + {Version: "9.0.0", Require: map[string]string{"shopware/core": "^6.6"}}, + }, + }, + } + + result, err := ResolveIncompatiblePlugins(t.Context(), composerJsonPath, "6.6.4.0", registry) + require.NoError(t, err) + require.Len(t, result.Bumped(), 1) + assert.Empty(t, result.Removed()) + + bumped := result.Bumped()[0] + assert.Equal(t, "swag/paypal", bumped.Name) + assert.Equal(t, "9.0.0", bumped.NewVersion) + + out := readJSON(t, composerJsonPath) + requireMap := out["require"].(map[string]any) + assert.Equal(t, "^9.0.0", requireMap["swag/paypal"]) +} + +// A plugin installed in vendor/ but NOT listed in the root composer.json +// require (e.g. a transitive dependency) must be left alone — the resolver can +// only rewrite root constraints. +func TestResolveIncompatiblePluginsIgnoresPluginsNotInRootRequire(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + composerJsonPath := filepath.Join(dir, "composer.json") + + writeJSON(t, composerJsonPath, map[string]any{ + "name": "shopware/production", + "require": map[string]any{ + "shopware/core": "6.5.8.0", + }, + }) + + writeInstalledJSON(t, dir, []packagist.InstalledPackage{ + { + Name: "transitive/plugin", + Type: composerPluginType, + InstallPath: "../transitive/plugin", + Require: map[string]string{"shopware/core": "~6.5.0"}, + }, + }) + + result, err := ResolveIncompatiblePlugins(t.Context(), composerJsonPath, "6.6.4.0", &fakeRegistry{}) + require.NoError(t, err) + assert.Empty(t, result.Actions) +} + func TestResolveIncompatiblePluginsRemovesWhenNoCompatibleRelease(t *testing.T) { t.Parallel() diff --git a/internal/projectupgrade/registry.go b/internal/projectupgrade/registry.go index 1668d008..9118748c 100644 --- a/internal/projectupgrade/registry.go +++ b/internal/projectupgrade/registry.go @@ -3,7 +3,6 @@ package projectupgrade import ( "context" "errors" - "strings" "sync" "github.com/shopware/shopware-cli/internal/packagist" @@ -19,21 +18,29 @@ type Registry interface { // (e.g. a store.shopware.com package when no token is configured). var ErrRegistryUnavailable = errors.New("registry unavailable for this package") -// CombinedRegistry routes lookups to the appropriate backend based on the -// package name prefix. +// CombinedRegistry resolves a package against the Shopware store first (when +// configured) and falls back to Packagist. Commercial store plugins are +// required under ordinary vendor names (e.g. swag/paypal, not +// store.shopware.com/…) and only exist on packages.shopware.com, so routing +// cannot be decided from the name alone: the store listing is the only source +// that knows whether it owns a package. type CombinedRegistry struct { - // Store handles store.shopware.com/* packages. May be nil. + // Store handles packages published on packages.shopware.com. May be nil + // when no store token is configured. Store Registry - // Packagist handles every other vendor/name combination. Required. + // Packagist handles everything the store does not provide. Required. Packagist Registry } func (c *CombinedRegistry) GetPackageVersions(ctx context.Context, name string) ([]packagist.ComposerPackageVersion, error) { - if strings.HasPrefix(name, "store.shopware.com/") { - if c.Store == nil { - return nil, ErrRegistryUnavailable + if c.Store != nil { + versions, err := c.Store.GetPackageVersions(ctx, name) + // A configured store that knows this package is authoritative. Any + // other outcome (unknown package, or store unavailable) falls through + // to Packagist so public packages still resolve. + if err == nil && len(versions) > 0 { + return versions, nil } - return c.Store.GetPackageVersions(ctx, name) } if c.Packagist == nil { diff --git a/internal/projectupgrade/registry_test.go b/internal/projectupgrade/registry_test.go new file mode 100644 index 00000000..60405771 --- /dev/null +++ b/internal/projectupgrade/registry_test.go @@ -0,0 +1,85 @@ +package projectupgrade + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/shopware/shopware-cli/internal/packagist" +) + +func TestCombinedRegistryPrefersStoreForOwnedPackages(t *testing.T) { + t.Parallel() + + // swag/paypal is a commercial store plugin: vendor-named, only on the + // store. The store must answer even though the name has no + // store.shopware.com/ prefix. + store := &fakeRegistry{versions: map[string][]packagist.ComposerPackageVersion{ + "swag/paypal": {{Version: "9.0.0"}}, + }} + packagistReg := &fakeRegistry{versions: map[string][]packagist.ComposerPackageVersion{}} + + combined := &CombinedRegistry{Store: store, Packagist: packagistReg} + + versions, err := combined.GetPackageVersions(t.Context(), "swag/paypal") + require.NoError(t, err) + require.Len(t, versions, 1) + assert.Equal(t, "9.0.0", versions[0].Version) +} + +func TestCombinedRegistryFallsBackToPackagistForPublicPackages(t *testing.T) { + t.Parallel() + + // Store knows nothing about a public package; Packagist resolves it. + store := &fakeRegistry{versions: map[string][]packagist.ComposerPackageVersion{}} + packagistReg := &fakeRegistry{versions: map[string][]packagist.ComposerPackageVersion{ + "frosh/tools": {{Version: "2.0.0"}}, + }} + + combined := &CombinedRegistry{Store: store, Packagist: packagistReg} + + versions, err := combined.GetPackageVersions(t.Context(), "frosh/tools") + require.NoError(t, err) + require.Len(t, versions, 1) + assert.Equal(t, "2.0.0", versions[0].Version) +} + +func TestCombinedRegistryFallsBackWhenStoreUnavailable(t *testing.T) { + t.Parallel() + + // No token -> store load fails with ErrRegistryUnavailable; public + // packages must still resolve via Packagist. + store := &fakeRegistry{err: ErrRegistryUnavailable} + packagistReg := &fakeRegistry{versions: map[string][]packagist.ComposerPackageVersion{ + "frosh/tools": {{Version: "2.0.0"}}, + }} + + combined := &CombinedRegistry{Store: store, Packagist: packagistReg} + + versions, err := combined.GetPackageVersions(t.Context(), "frosh/tools") + require.NoError(t, err) + require.Len(t, versions, 1) +} + +func TestCombinedRegistryNilStoreUsesPackagist(t *testing.T) { + t.Parallel() + + packagistReg := &fakeRegistry{versions: map[string][]packagist.ComposerPackageVersion{ + "frosh/tools": {{Version: "2.0.0"}}, + }} + combined := &CombinedRegistry{Packagist: packagistReg} + + versions, err := combined.GetPackageVersions(t.Context(), "frosh/tools") + require.NoError(t, err) + require.Len(t, versions, 1) +} + +func TestCombinedRegistryNilPackagistReturnsUnavailable(t *testing.T) { + t.Parallel() + + combined := &CombinedRegistry{} + _, err := combined.GetPackageVersions(context.Background(), "frosh/tools") + assert.ErrorIs(t, err, ErrRegistryUnavailable) +} diff --git a/internal/projectupgrade/wizard.go b/internal/projectupgrade/wizard.go index 23a2380e..b04fd95d 100644 --- a/internal/projectupgrade/wizard.go +++ b/internal/projectupgrade/wizard.go @@ -74,13 +74,16 @@ type task struct { } // taskCleanup, taskPlugins, ... are stable indices into model.tasks. +// system:update:prepare runs on the still-installed (old) Shopware so it can +// enter maintenance and record the update before the vendor code changes; +// composer update then swaps the code, and system:update:finish migrates. const ( taskBackup = iota taskCleanup taskPlugins taskComposerJSON - taskComposerUpdate taskSystemPrepare + taskComposerUpdate taskSystemFinish ) @@ -96,6 +99,10 @@ type ( detail string composerBackup []byte pluginActions *ResolveResult + // output is the full captured subprocess output, set for streaming + // tasks so the complete log survives independent of log-line event + // ordering. Used to render the error tail on failure. + output []string } startNextTaskMsg struct{} logLineMsg string @@ -110,29 +117,46 @@ type wizardModel struct { phase phase - versionCursor int - targetVersion string - confirmYes bool - composerBackup []byte - pluginActions *ResolveResult - compatUpdates []account_api.UpdateCheckExtensionCompatibility - compatHasBlock bool - compatErr error - tasks []task - currentTask int - logLines []string - logChan chan string - finalErr error - finished bool - spinner spinner.Model - compatLoading bool - cancelExecution context.CancelFunc -} - -// RunWizard runs the interactive upgrade wizard. It returns the selected -// target version, whether the upgrade completed successfully, and any error -// encountered. A user cancellation returns ErrCancelled. -func RunWizard(opts WizardOptions) (string, bool, error) { + versionCursor int + targetVersion string + confirmYes bool + composerBackup []byte + pluginActions *ResolveResult + compatUpdates []account_api.UpdateCheckExtensionCompatibility + compatHasBlock bool + compatHasUpdatable bool + compatErr error + tasks []task + currentTask int + logLines []string + fullLog []string + logChan chan string + finalErr error + finished bool + spinner spinner.Model + compatLoading bool + cancelExecution context.CancelFunc + width int + height int +} + +// WizardResult is the outcome of a single RunWizard invocation. +type WizardResult struct { + // TargetVersion is the version the user selected (empty if cancelled + // before selecting). + TargetVersion string + // Success is true when every upgrade task completed. + Success bool + // FailureLog holds the full captured output of the task that failed. It + // is nil on success or when the failure produced no subprocess output, so + // callers can print it verbatim to give the user the complete log the + // alt-screen could not retain. + FailureLog []string +} + +// RunWizard runs the interactive upgrade wizard. It returns the result and any +// error encountered. A user cancellation returns ErrCancelled. +func RunWizard(opts WizardOptions) (WizardResult, error) { s := spinner.New( spinner.WithSpinner(spinner.Dot), spinner.WithStyle(lipgloss.NewStyle().Foreground(tui.BrandColor)), @@ -150,7 +174,7 @@ func RunWizard(opts WizardOptions) (string, bool, error) { prog := tea.NewProgram(m) final, err := prog.Run() if err != nil { - return "", false, err + return WizardResult{}, err } fm, _ := final.(wizardModel) @@ -159,10 +183,17 @@ func RunWizard(opts WizardOptions) (string, bool, error) { } if !fm.finished { - return fm.targetVersion, false, ErrCancelled + return WizardResult{TargetVersion: fm.targetVersion}, ErrCancelled } - return fm.targetVersion, fm.finalErr == nil, fm.finalErr + result := WizardResult{ + TargetVersion: fm.targetVersion, + Success: fm.finalErr == nil, + } + if fm.finalErr != nil { + result.FailureLog = fm.fullLog + } + return result, fm.finalErr } // ErrCancelled is returned when the user exits the wizard before the upgrade @@ -175,8 +206,8 @@ func defaultTasks() []task { {label: "Clean up stale recipe files"}, {label: "Resolve incompatible custom plugins"}, {label: "Rewrite composer.json"}, - {label: "composer update --with-all-dependencies"}, {label: "bin/console system:update:prepare"}, + {label: "composer update --with-all-dependencies"}, {label: "bin/console system:update:finish"}, } } @@ -187,6 +218,11 @@ func (m wizardModel) Init() tea.Cmd { func (m wizardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + return m, nil + case tea.KeyPressMsg: return m.updateKey(msg) @@ -202,7 +238,9 @@ func (m wizardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { for _, u := range msg.updates { if u.Status.IsBlocker() { m.compatHasBlock = true - break + } + if u.Status.IsUpdatable() { + m.compatHasUpdatable = true } } m.phase = phaseCompatResult @@ -219,6 +257,9 @@ func (m wizardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.pluginActions != nil { m.pluginActions = msg.pluginActions } + if msg.output != nil { + m.fullLog = msg.output + } if msg.task < len(m.tasks) { if msg.err != nil { m.tasks[msg.task].status = taskFailed @@ -375,6 +416,10 @@ func (m wizardModel) updateReview(key string) (tea.Model, tea.Cmd) { } func (m *wizardModel) appendLog(line string) { + // fullLog keeps the complete subprocess output so it can be surfaced on + // failure (both in the done view and printed to the terminal after the + // alt-screen tears down). logLines is the capped window shown live. + m.fullLog = append(m.fullLog, line) m.logLines = append(m.logLines, line) if len(m.logLines) > maxLogLines { m.logLines = m.logLines[len(m.logLines)-maxLogLines:] @@ -458,9 +503,13 @@ func (m wizardModel) startTask() (tea.Model, tea.Cmd) { case taskComposerUpdate: return m.startComposerUpdate() case taskSystemPrepare: - return m.startSystemUpdate("system:update:prepare", taskSystemPrepare) + // prepare runs before composer update, while composer.json is already + // rewritten - restore it on failure so the project is left untouched. + return m.startSystemUpdate("system:update:prepare", taskSystemPrepare, true) case taskSystemFinish: - return m.startSystemUpdate("system:update:finish", taskSystemFinish) + // finish runs after a successful composer update; restoring the old + // composer.json here would undo the upgrade, so leave it in place. + return m.startSystemUpdate("system:update:finish", taskSystemFinish, false) } return m, nil @@ -543,6 +592,7 @@ func (m wizardModel) startComposerUpdate() (tea.Model, tea.Cmd) { ch := make(chan string, streamBufferSize) m.logChan = ch m.logLines = nil + m.fullLog = nil args := []string{ "update", @@ -558,37 +608,46 @@ func (m wizardModel) startComposerUpdate() (tea.Model, tea.Cmd) { idx := taskComposerUpdate doneCmd := func() tea.Msg { - err := streamCmdOutput(p.Cmd, ch, true) + output, err := streamCmdOutput(p.Cmd, ch, true) if err != nil { _ = os.WriteFile(composerJSONPath, restore, 0o644) } - return taskCompleteMsg{task: idx, err: err} + return taskCompleteMsg{task: idx, err: err, output: output} } return m, tea.Batch(m.readNextLog(), doneCmd) } -func (m wizardModel) startSystemUpdate(consoleCmd string, idx int) (tea.Model, tea.Cmd) { +func (m wizardModel) startSystemUpdate(consoleCmd string, idx int, restoreOnFail bool) (tea.Model, tea.Cmd) { ctx, cancel := context.WithCancel(context.Background()) m.cancelExecution = cancel ch := make(chan string, streamBufferSize) m.logChan = ch m.logLines = nil + m.fullLog = nil p := m.opts.Executor.ConsoleCommand(ctx, consoleCmd, "--no-interaction") + restore := m.composerBackup + composerJSONPath := m.opts.ComposerJSONPath + doneCmd := func() tea.Msg { - err := streamCmdOutput(p.Cmd, ch, true) - return taskCompleteMsg{task: idx, err: err} + output, err := streamCmdOutput(p.Cmd, ch, true) + if err != nil && restoreOnFail { + _ = os.WriteFile(composerJSONPath, restore, 0o644) + } + return taskCompleteMsg{task: idx, err: err, output: output} } return m, tea.Batch(m.readNextLog(), doneCmd) } // streamCmdOutput starts cmd, fans stdout (or stderr) lines into ch, and -// closes ch when done. The returned error is the process exit error, if any. -func streamCmdOutput(cmd *exec.Cmd, ch chan<- string, useStdout bool) error { +// closes ch when done. It also returns the complete captured output so the +// caller can surface it on failure regardless of how many lines the live +// view kept. The returned error is the process exit error, if any. +func streamCmdOutput(cmd *exec.Cmd, ch chan<- string, useStdout bool) ([]string, error) { var pipe io.Reader var err error if useStdout { @@ -604,32 +663,39 @@ func streamCmdOutput(cmd *exec.Cmd, ch chan<- string, useStdout bool) error { } if err != nil { close(ch) - return err + return nil, err } if err := cmd.Start(); err != nil { close(ch) - return err + return nil, err } + var captured []string scanner := bufio.NewScanner(pipe) scanner.Buffer(make([]byte, 64*1024), 1024*1024) for scanner.Scan() { - ch <- scanner.Text() + line := scanner.Text() + captured = append(captured, line) + ch <- line } close(ch) if err := scanner.Err(); err != nil { _ = cmd.Wait() - return err + return captured, err } - return cmd.Wait() + return captured, cmd.Wait() } // --- View --- func (m wizardModel) View() tea.View { - v := tea.NewView(m.viewContent()) + content := m.viewContent() + if m.width > 0 && m.height > 0 { + content = lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, content) + } + v := tea.NewView(content) v.AltScreen = true return v } @@ -783,9 +849,14 @@ func (m wizardModel) viewCompatResult() string { b.WriteString("\n\n") default: for _, u := range m.compatUpdates { - icon := tui.Checkmark - if u.Status.IsBlocker() { + var icon string + switch { + case u.Status.IsBlocker(): icon = lipgloss.NewStyle().Foreground(tui.ErrorColor).Bold(true).Render("✗") + case u.Status.IsUpdatable(): + icon = lipgloss.NewStyle().Foreground(tui.WarnColor).Bold(true).Render("↑") + default: + icon = tui.Checkmark } b.WriteString(" ") b.WriteString(icon) @@ -797,10 +868,15 @@ func (m wizardModel) viewCompatResult() string { b.WriteString("\n") } + if m.compatHasUpdatable { + b.WriteString(lipgloss.NewStyle().Foreground(tui.WarnColor).Render("↑ Extensions marked with ↑ have a compatible release; their constraints will be bumped during the upgrade.")) + b.WriteString("\n\n") + } + if m.compatHasBlock { - b.WriteString(lipgloss.NewStyle().Foreground(tui.WarnColor).Bold(true).Render("⚠ Some extensions are not compatible yet.")) + b.WriteString(lipgloss.NewStyle().Foreground(tui.ErrorColor).Bold(true).Render("✗ Some extensions have no compatible version yet.")) b.WriteString("\n") - b.WriteString(tui.DimStyle.Render("Continuing may break those extensions until they release updates.")) + b.WriteString(tui.DimStyle.Render("They will be removed from composer.json so the upgrade can proceed. Re-require them once they publish a compatible release.")) b.WriteString("\n\n") } @@ -886,6 +962,19 @@ func (m wizardModel) viewDone() string { b.WriteString("\n\n") b.WriteString(lipgloss.NewStyle().Foreground(tui.ErrorColor).Render(m.finalErr.Error())) b.WriteString("\n\n") + + if tail := lastLines(m.fullLog, maxLogLines); len(tail) > 0 { + b.WriteString(tui.BoldText.Render("Last output:")) + b.WriteString("\n") + for _, line := range tail { + b.WriteString(tui.DimStyle.Render(" " + truncate(line, tui.PhaseCardWidth-10))) + b.WriteString("\n") + } + b.WriteString("\n") + b.WriteString(tui.DimStyle.Render("The full log is printed below the wizard after you close it.")) + b.WriteString("\n\n") + } + b.WriteString(tui.DimStyle.Render("composer.json was restored from the backup taken before the upgrade.")) } else { b.WriteString(lipgloss.NewStyle().Bold(true).Foreground(tui.SuccessColor).Render(fmt.Sprintf("✓ Upgraded to Shopware %s", m.targetVersion))) @@ -1008,3 +1097,11 @@ func truncate(s string, maxRunes int) string { r := []rune(s) return string(r[:maxRunes-1]) + "…" } + +// lastLines returns up to n trailing lines of lines. +func lastLines(lines []string, n int) []string { + if n <= 0 || len(lines) <= n { + return lines + } + return lines[len(lines)-n:] +} diff --git a/internal/projectupgrade/wizard_test.go b/internal/projectupgrade/wizard_test.go index 4d47d3b0..32a1430c 100644 --- a/internal/projectupgrade/wizard_test.go +++ b/internal/projectupgrade/wizard_test.go @@ -115,7 +115,8 @@ func TestWizardCompatLoadedSetsBlockerFlag(t *testing.T) { { Name: "Blocker", Status: account_api.UpdateCheckExtensionCompatibilityStatus{ - Type: "violation", + Name: account_api.CompatibilityNotCompatible, + Type: "red", Label: "Not compatible", }, }, @@ -128,6 +129,49 @@ func TestWizardCompatLoadedSetsBlockerFlag(t *testing.T) { assert.False(t, wm.confirmYes, "blocker should default the confirm to No") } +func TestWizardCompatLoadedUpdatableIsNotBlocker(t *testing.T) { + t.Parallel() + + m := newTestModel(t) + m.phase = phaseCompatCheck + m.compatLoading = true + + // "With new Shopware version" — a compatible release exists, so this must + // not block the upgrade; the resolver bumps the constraint. + updated, _ := m.Update(compatLoadedMsg{ + updates: []account_api.UpdateCheckExtensionCompatibility{ + { + Name: "SwagPayPal", + Status: account_api.UpdateCheckExtensionCompatibilityStatus{ + Name: account_api.CompatibilityUpdatableNow, + Type: "yellow", + Label: "With new Shopware version", + }, + }, + }, + }) + wm := updated.(wizardModel) + assert.Equal(t, phaseCompatResult, wm.phase) + assert.False(t, wm.compatHasBlock, "updatable extension must not block") + assert.True(t, wm.compatHasUpdatable) + assert.True(t, wm.confirmYes, "no blocker means confirm defaults to Yes") +} + +func TestUpgradeTaskOrderRunsPrepareBeforeComposerUpdate(t *testing.T) { + t.Parallel() + + // system:update:prepare must run on the old, still-installed Shopware, + // i.e. before composer update swaps the vendor code; finish runs last. + assert.Less(t, taskSystemPrepare, taskComposerUpdate, "prepare must precede composer update") + assert.Less(t, taskComposerUpdate, taskSystemFinish, "finish must run after composer update") + + tasks := defaultTasks() + require.Len(t, tasks, taskSystemFinish+1) + assert.Equal(t, "bin/console system:update:prepare", tasks[taskSystemPrepare].label) + assert.Equal(t, "composer update --with-all-dependencies", tasks[taskComposerUpdate].label) + assert.Equal(t, "bin/console system:update:finish", tasks[taskSystemFinish].label) +} + func TestWizardTaskCompletePersistsBackupAcrossUpdates(t *testing.T) { t.Parallel() @@ -155,25 +199,43 @@ func TestWizardTaskCompleteErrorEndsRun(t *testing.T) { m.currentTask = taskComposerUpdate updated, _ := m.Update(taskCompleteMsg{ - task: taskComposerUpdate, - err: assertErr("composer update failed"), + task: taskComposerUpdate, + err: assertErr("exit status 2"), + output: []string{"Loading composer repositories", "Your requirements could not be resolved"}, }) wm := updated.(wizardModel) assert.Equal(t, phaseDone, wm.phase) assert.True(t, wm.finished) require.Error(t, wm.finalErr) assert.Equal(t, taskFailed, wm.tasks[taskComposerUpdate].status) + + // The full subprocess output must be retained so the user can see what + // actually failed instead of just "exit status 2". + assert.Equal(t, []string{"Loading composer repositories", "Your requirements could not be resolved"}, wm.fullLog) + + out := wm.viewContent() + assert.Contains(t, out, "Your requirements could not be resolved", "failed step output should be shown on the done screen") } func TestWizardLogLineMsgAppendsAndTrims(t *testing.T) { t.Parallel() m := newTestModel(t) - for i := 0; i < maxLogLines+5; i++ { + total := maxLogLines + 5 + for i := 0; i < total; i++ { updated, _ := m.Update(logLineMsg("line")) m = updated.(wizardModel) } - assert.LessOrEqual(t, len(m.logLines), maxLogLines) + assert.LessOrEqual(t, len(m.logLines), maxLogLines, "live view stays capped") + assert.Len(t, m.fullLog, total, "full log keeps every line for failure reporting") +} + +func TestLastLines(t *testing.T) { + t.Parallel() + + assert.Equal(t, []string{"c", "d"}, lastLines([]string{"a", "b", "c", "d"}, 2)) + assert.Equal(t, []string{"a", "b"}, lastLines([]string{"a", "b"}, 5), "fewer than n returns all") + assert.Nil(t, lastLines(nil, 3)) } type testError string From e0500280cff3d0becfbe568ffcc69bd854f4c233 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 29 May 2026 08:15:28 +0000 Subject: [PATCH 29/38] feat(project): reject project names invalid as compose project name Project creation now validates the project name against the rules for a Docker Compose project name. Names containing umlauts, spaces, dots or other characters that Docker Compose would silently strip or reject (it only allows alphanumerics, dashes and underscores, starting with a letter or digit) are now rejected up front, both in the interactive form and when the name is passed as an argument. --- cmd/project/project_create.go | 29 ++++++++++++++++++ cmd/project/project_create_test.go | 47 ++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+) diff --git a/cmd/project/project_create.go b/cmd/project/project_create.go index 5b218980..e45304ad 100644 --- a/cmd/project/project_create.go +++ b/cmd/project/project_create.go @@ -11,6 +11,7 @@ import ( "os" "os/exec" "path/filepath" + "regexp" "slices" "sort" "strings" @@ -44,6 +45,27 @@ var gitlabCITemplate string const versionLatest = "latest" +// composeProjectNameRegexp matches names that are valid as a Docker Compose +// project name. Docker Compose derives the project name from the project +// directory and only allows alphanumeric characters, dashes and underscores, +// and the name must start with a letter or digit. Anything else (umlauts, +// spaces, dots, …) gets silently stripped or rejected by Docker Compose, so we +// reject such project names up front. +var composeProjectNameRegexp = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_-]*$`) + +// validateProjectName ensures the project folder name can be used as a Docker +// Compose project name. Only the final path element is relevant, as that is +// what Docker Compose uses to derive the project name. +func validateProjectName(name string) error { + base := filepath.Base(name) + + if !composeProjectNameRegexp.MatchString(base) { + return fmt.Errorf("invalid project name %q: a project name may only contain letters, digits, dashes (-) and underscores (_), and must start with a letter or digit so it can be used as a Docker Compose project name", base) + } + + return nil +} + var projectCreateCmd = &cobra.Command{ Use: "create [name] [version]", Short: "Create a new Shopware 6 project", @@ -227,6 +249,9 @@ var projectCreateCmd = &cobra.Command{ if s == "" { return fmt.Errorf("project name is required") } + if err := validateProjectName(s); err != nil { + return err + } if info, err := os.Stat(s); err == nil && info.IsDir() { empty, err := system.IsDirEmpty(s) if err != nil { @@ -402,6 +427,10 @@ var projectCreateCmd = &cobra.Command{ } } + if err := validateProjectName(projectFolder); err != nil { + return err + } + missingDeps := system.CheckProjectDependencies(cmd.Context(), useDocker) validDeployments := map[string]bool{ diff --git a/cmd/project/project_create_test.go b/cmd/project/project_create_test.go index 8e3a6bf1..d7361f9a 100644 --- a/cmd/project/project_create_test.go +++ b/cmd/project/project_create_test.go @@ -67,6 +67,53 @@ func TestResolveVersion(t *testing.T) { }) } +func TestValidateProjectName(t *testing.T) { + t.Parallel() + + validNames := []string{ + "my-shopware-project", + "myshop", + "my_shop", + "shop123", + "123shop", + "MyShop", + "a", + "path/to/my-shop", + } + + for _, name := range validNames { + t.Run("valid: "+name, func(t *testing.T) { + t.Parallel() + assert.NoError(t, validateProjectName(name)) + }) + } + + invalidNames := []string{ + "müller", + "über-shop", + "Müller-Shop", + "café", + "straße", + "my shop", + "my.shop", + "shop!", + "-shop", + "_shop", + "ä", + "", + "path/to/müller", + } + + for _, name := range invalidNames { + t.Run("invalid: "+name, func(t *testing.T) { + t.Parallel() + err := validateProjectName(name) + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid project name") + }) + } +} + func TestSetupDeployment(t *testing.T) { t.Parallel() t.Run("none creates no files", func(t *testing.T) { From 9a40e32d11d6a35afe8c7e0c030fe56568c5a626 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 29 May 2026 08:24:37 +0000 Subject: [PATCH 30/38] fix(project): reject uppercase letters in compose project names Docker Compose requires project names to contain only lowercase letters, digits, dashes and underscores and to start with a lowercase letter or digit. The previous regex also accepted uppercase letters (e.g. MyShop), which would later fail once the generated Docker setup runs from the project directory. Tighten the regex to lowercase-only and treat uppercase names as invalid. --- cmd/project/project_create.go | 14 +++++++------- cmd/project/project_create_test.go | 5 ++++- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/cmd/project/project_create.go b/cmd/project/project_create.go index e45304ad..6c6728da 100644 --- a/cmd/project/project_create.go +++ b/cmd/project/project_create.go @@ -46,12 +46,12 @@ var gitlabCITemplate string const versionLatest = "latest" // composeProjectNameRegexp matches names that are valid as a Docker Compose -// project name. Docker Compose derives the project name from the project -// directory and only allows alphanumeric characters, dashes and underscores, -// and the name must start with a letter or digit. Anything else (umlauts, -// spaces, dots, …) gets silently stripped or rejected by Docker Compose, so we -// reject such project names up front. -var composeProjectNameRegexp = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_-]*$`) +// project name. Docker Compose only allows lowercase letters, digits, dashes +// and underscores, and the name must start with a lowercase letter or digit. +// Anything else (uppercase letters, umlauts, spaces, dots, …) is rejected by +// Docker Compose once the generated Docker setup runs from the project +// directory, so we reject such project names up front. +var composeProjectNameRegexp = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`) // validateProjectName ensures the project folder name can be used as a Docker // Compose project name. Only the final path element is relevant, as that is @@ -60,7 +60,7 @@ func validateProjectName(name string) error { base := filepath.Base(name) if !composeProjectNameRegexp.MatchString(base) { - return fmt.Errorf("invalid project name %q: a project name may only contain letters, digits, dashes (-) and underscores (_), and must start with a letter or digit so it can be used as a Docker Compose project name", base) + return fmt.Errorf("invalid project name %q: a project name may only contain lowercase letters, digits, dashes (-) and underscores (_), and must start with a lowercase letter or digit so it can be used as a Docker Compose project name", base) } return nil diff --git a/cmd/project/project_create_test.go b/cmd/project/project_create_test.go index d7361f9a..b27903dd 100644 --- a/cmd/project/project_create_test.go +++ b/cmd/project/project_create_test.go @@ -76,7 +76,6 @@ func TestValidateProjectName(t *testing.T) { "my_shop", "shop123", "123shop", - "MyShop", "a", "path/to/my-shop", } @@ -89,6 +88,9 @@ func TestValidateProjectName(t *testing.T) { } invalidNames := []string{ + "MyShop", + "myShop", + "SHOP", "müller", "über-shop", "Müller-Shop", @@ -102,6 +104,7 @@ func TestValidateProjectName(t *testing.T) { "ä", "", "path/to/müller", + "path/to/MyShop", } for _, name := range invalidNames { From 9e941bc7604b73415ddf0c8314f536fba99c8241 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 29 May 2026 08:25:40 +0000 Subject: [PATCH 31/38] feat(project): validate project name live while typing The interactive create form now validates the project name as the user types instead of only on submit. The input description switches to a red-highlighted hint describing the allowed characters whenever the current value is not a valid Docker Compose project name, and reverts to the normal help text once it is valid. The allowed-character rule is shared between this live hint and the submit-time validation error. --- cmd/project/project_create.go | 29 +++++++++++++++++++++++++++-- cmd/project/project_create_test.go | 28 ++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/cmd/project/project_create.go b/cmd/project/project_create.go index 6c6728da..db5c3d93 100644 --- a/cmd/project/project_create.go +++ b/cmd/project/project_create.go @@ -45,6 +45,15 @@ var gitlabCITemplate string const versionLatest = "latest" +const ( + // projectNameHelp is the help text shown under the project name input. + projectNameHelp = "The name of the project directory to create" + // projectNameRule describes which characters are allowed in a project name. + // It is shared between the up-front validation error and the live form hint + // so both stay in sync. + projectNameRule = "only lowercase letters, digits, dashes (-) and underscores (_) are allowed, and it must start with a lowercase letter or digit" +) + // composeProjectNameRegexp matches names that are valid as a Docker Compose // project name. Docker Compose only allows lowercase letters, digits, dashes // and underscores, and the name must start with a lowercase letter or digit. @@ -60,12 +69,26 @@ func validateProjectName(name string) error { base := filepath.Base(name) if !composeProjectNameRegexp.MatchString(base) { - return fmt.Errorf("invalid project name %q: a project name may only contain lowercase letters, digits, dashes (-) and underscores (_), and must start with a lowercase letter or digit so it can be used as a Docker Compose project name", base) + return fmt.Errorf("invalid project name %q: %s, so it can be used as a Docker Compose project name", base, projectNameRule) } return nil } +// projectNameFieldDescription returns the description shown under the project +// name input in the interactive form. While the typed name is invalid it +// returns the rule highlighted in red, validating the input live; otherwise it +// returns the regular help text. +func projectNameFieldDescription(name string) string { + if name != "" { + if err := validateProjectName(name); err != nil { + return tui.RedText.Render(projectNameRule) + } + } + + return projectNameHelp +} + var projectCreateCmd = &cobra.Command{ Use: "create [name] [version]", Short: "Create a new Shopware 6 project", @@ -242,7 +265,9 @@ var projectCreateCmd = &cobra.Command{ formGroups = append(formGroups, huh.NewGroup( huh.NewInput(). Title("Project Name"). - Description("The name of the project directory to create"). + DescriptionFunc(func() string { + return projectNameFieldDescription(projectFolder) + }, &projectFolder). Placeholder("my-shopware-project"). Value(&projectFolder). Validate(func(s string) error { diff --git a/cmd/project/project_create_test.go b/cmd/project/project_create_test.go index b27903dd..29a9f40f 100644 --- a/cmd/project/project_create_test.go +++ b/cmd/project/project_create_test.go @@ -117,6 +117,34 @@ func TestValidateProjectName(t *testing.T) { } } +func TestProjectNameFieldDescription(t *testing.T) { + t.Parallel() + + t.Run("empty shows help text", func(t *testing.T) { + t.Parallel() + assert.Equal(t, projectNameHelp, projectNameFieldDescription("")) + }) + + t.Run("valid name shows help text", func(t *testing.T) { + t.Parallel() + assert.Equal(t, projectNameHelp, projectNameFieldDescription("my-shop")) + }) + + t.Run("uppercase name shows the rule", func(t *testing.T) { + t.Parallel() + desc := projectNameFieldDescription("MyShop") + assert.NotEqual(t, projectNameHelp, desc) + assert.Contains(t, desc, projectNameRule) + }) + + t.Run("umlaut name shows the rule", func(t *testing.T) { + t.Parallel() + desc := projectNameFieldDescription("müller") + assert.NotEqual(t, projectNameHelp, desc) + assert.Contains(t, desc, projectNameRule) + }) +} + func TestSetupDeployment(t *testing.T) { t.Parallel() t.Run("none creates no files", func(t *testing.T) { From 26283490cfe3ff7c357d8d9d4d5010971d9aca7d Mon Sep 17 00:00:00 2001 From: Soner Sayakci Date: Fri, 29 May 2026 10:33:22 +0200 Subject: [PATCH 32/38] feat(projectupgrade): use tui.SelectList for version selection Replace the wizard's hand-rolled version-list cursor and rendering with the reusable tui.SelectList component: it owns the cursor, windowing, paging (PgUp/PgDn, Home/End) and the navigation shortcuts, so the wizard only forwards keys and renders. --- internal/projectupgrade/wizard.go | 72 ++++++++++++++------------ internal/projectupgrade/wizard_test.go | 41 ++++++++------- 2 files changed, 60 insertions(+), 53 deletions(-) diff --git a/internal/projectupgrade/wizard.go b/internal/projectupgrade/wizard.go index b04fd95d..26e4ca96 100644 --- a/internal/projectupgrade/wizard.go +++ b/internal/projectupgrade/wizard.go @@ -30,6 +30,10 @@ const streamBufferSize = 50 // view doesn't grow unbounded. const maxLogLines = 18 +// maxVisibleVersions caps how many upgrade targets the version select list +// shows at once so the card keeps a fixed height when many versions exist. +const maxVisibleVersions = 10 + // WizardOptions configures a single run of the upgrade wizard. type WizardOptions struct { ProjectRoot string @@ -117,7 +121,7 @@ type wizardModel struct { phase phase - versionCursor int + versionList *tui.SelectList targetVersion string confirmYes bool composerBackup []byte @@ -162,13 +166,27 @@ func RunWizard(opts WizardOptions) (WizardResult, error) { spinner.WithStyle(lipgloss.NewStyle().Foreground(tui.BrandColor)), ) + versionOptions := make([]tui.SelectOption, len(opts.UpdateVersions)) + for i, v := range opts.UpdateVersions { + detail := "" + if i == 0 { + detail = "latest" + } + versionOptions[i] = tui.SelectOption{Label: v, Detail: detail} + } + m := wizardModel{ - opts: opts, - phase: phaseWelcome, - confirmYes: true, - versionCursor: 0, - spinner: s, - tasks: defaultTasks(), + opts: opts, + phase: phaseWelcome, + confirmYes: true, + versionList: tui.NewSelectList( + "Select target version", + "Pick the Shopware version to upgrade to. Next-major releases are listed first.", + versionOptions, + maxVisibleVersions, + ), + spinner: s, + tasks: defaultTasks(), } prog := tea.NewProgram(m) @@ -348,19 +366,19 @@ func (m wizardModel) updateWelcome(key string) (tea.Model, tea.Cmd) { } func (m wizardModel) updateSelectVersion(key string) (tea.Model, tea.Cmd) { + if m.versionList.HandleKey(key) { + return m, nil + } + switch key { - case "up", "k": - if m.versionCursor > 0 { - m.versionCursor-- - } - case "down", "j": - if m.versionCursor < len(m.opts.UpdateVersions)-1 { - m.versionCursor++ - } case "q", "esc": return m, tea.Quit case "enter": - m.targetVersion = m.opts.UpdateVersions[m.versionCursor] + selected, ok := m.versionList.Selected() + if !ok { + return m, nil + } + m.targetVersion = selected.Label if len(m.opts.Extensions) == 0 { m.phase = phaseReview m.confirmYes = true @@ -792,26 +810,14 @@ func (m wizardModel) viewSelectVersion() string { b.WriteString(stepBadge(m.stepNum(phaseSelectVersion), m.totalSteps())) b.WriteString("\n\n") - opts := make([]tui.SelectOption, len(m.opts.UpdateVersions)) - for i, v := range m.opts.UpdateVersions { - detail := "" - if i == 0 { - detail = "latest" - } - opts[i] = tui.SelectOption{Label: v, Detail: detail} - } - b.WriteString(tui.RenderSelectList( - "Select target version", - "Pick the Shopware version to upgrade to. Next-major releases are listed first.", - opts, - m.versionCursor, - )) + b.WriteString(m.versionList.View()) b.WriteString("\n\n") - b.WriteString(m.footer( - tui.Shortcut{Key: "↑/↓", Label: "Select"}, + + shortcuts := append(m.versionList.Shortcuts(), tui.Shortcut{Key: "enter", Label: "Continue"}, tui.Shortcut{Key: "ctrl+c", Label: "Exit"}, - )) + ) + b.WriteString(m.footer(shortcuts...)) return tui.RenderPhaseCard(b.String()) } diff --git a/internal/projectupgrade/wizard_test.go b/internal/projectupgrade/wizard_test.go index 32a1430c..45841d39 100644 --- a/internal/projectupgrade/wizard_test.go +++ b/internal/projectupgrade/wizard_test.go @@ -9,24 +9,36 @@ import ( "github.com/stretchr/testify/require" account_api "github.com/shopware/shopware-cli/internal/account-api" + "github.com/shopware/shopware-cli/internal/tui" ) func newTestModel(t *testing.T) wizardModel { t.Helper() + return newTestModelWithVersions(t, []string{"6.6.4.0", "6.6.3.0", "6.5.9.0"}) +} + +func newTestModelWithVersions(t *testing.T, versions []string) wizardModel { + t.Helper() current, err := version.NewVersion("6.5.8.0") require.NoError(t, err) + opts := make([]tui.SelectOption, len(versions)) + for i, v := range versions { + opts[i] = tui.SelectOption{Label: v} + } + m := wizardModel{ opts: WizardOptions{ ProjectRoot: "/tmp/example", ComposerJSONPath: "/tmp/example/composer.json", CurrentVersion: current, - UpdateVersions: []string{"6.6.4.0", "6.6.3.0", "6.5.9.0"}, + UpdateVersions: versions, }, - phase: phaseWelcome, - confirmYes: true, - tasks: defaultTasks(), + phase: phaseWelcome, + confirmYes: true, + versionList: tui.NewSelectList("Select target version", "", opts, maxVisibleVersions), + tasks: defaultTasks(), } return m } @@ -52,7 +64,9 @@ func TestWizardWelcomeCancelQuits(t *testing.T) { assert.True(t, ok, "cancel should produce QuitMsg") } -func TestWizardSelectVersionMovesCursor(t *testing.T) { +// Navigation/paging is owned and tested by tui.SelectList; here we only verify +// the wizard forwards keys to it. +func TestWizardSelectVersionForwardsNavigationToList(t *testing.T) { t.Parallel() m := newTestModel(t) @@ -60,20 +74,7 @@ func TestWizardSelectVersionMovesCursor(t *testing.T) { updated, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyDown}) wm := updated.(wizardModel) - assert.Equal(t, 1, wm.versionCursor) - - updated, _ = wm.Update(tea.KeyPressMsg{Code: tea.KeyDown}) - wm = updated.(wizardModel) - assert.Equal(t, 2, wm.versionCursor) - - // Past end should not wrap. - updated, _ = wm.Update(tea.KeyPressMsg{Code: tea.KeyDown}) - wm = updated.(wizardModel) - assert.Equal(t, 2, wm.versionCursor) - - updated, _ = wm.Update(tea.KeyPressMsg{Code: tea.KeyUp}) - wm = updated.(wizardModel) - assert.Equal(t, 1, wm.versionCursor) + assert.Equal(t, 1, wm.versionList.Cursor()) } func TestWizardSelectVersionWithoutExtensionsSkipsToReview(t *testing.T) { @@ -81,7 +82,7 @@ func TestWizardSelectVersionWithoutExtensionsSkipsToReview(t *testing.T) { m := newTestModel(t) m.phase = phaseSelectVersion - m.versionCursor = 1 + m.versionList.HandleKey("down") // move to "6.6.3.0" updated, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) wm := updated.(wizardModel) From 3b0452fa460b36e80dbfc0ecd348f22d9629a20d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 1 Jun 2026 12:17:33 +0000 Subject: [PATCH 33/38] feat(project): delegate post-update to shopware-deployment-helper The upgrade used to drive the lifecycle by hand: stash a composer.json backup, run system:update:prepare on the old code, swap vendor via composer update, run system:update:finish on the new code, and rewind composer.json on any failure. shopware-deployment-helper already owns that lifecycle (prepare, migrations, finish, theme compile, plugin refresh) and is what production deployments run, so the wizard now delegates to it instead of orchestrating each step itself. - UpdateComposerJson additionally calls EnsureRequire to add shopware/deployment-helper to composer.json when the project does not require it yet. The subsequent composer update pulls it in. - The wizard's task list drops the dedicated "Back up composer.json", "system:update:prepare" and "system:update:finish" tasks and gains a single "vendor/bin/shopware-deployment-helper run" task that runs after composer update. Five tasks total instead of seven. - All composer.json backup / restore plumbing is removed from both the wizard and the headless flow. The pre-flight clean-git-tree check already guarantees `git checkout composer.json composer.lock` is a one-liner if the user wants to revert. - packagist.ComposerJson.EnsureRequire is the new shared helper for "add this require entry if missing"; the devtui setup guide reuses it in place of its private branch. --- cmd/project/project_upgrade.go | 62 +++++------------ internal/devtui/setup_guide_apply.go | 7 +- internal/packagist/composer.go | 15 +++++ internal/packagist/composer_test.go | 32 +++++++++ internal/projectupgrade/composer.go | 6 +- internal/projectupgrade/composer_test.go | 21 ++++++ internal/projectupgrade/wizard.go | 84 ++++++------------------ internal/projectupgrade/wizard_test.go | 35 +++------- 8 files changed, 119 insertions(+), 143 deletions(-) diff --git a/cmd/project/project_upgrade.go b/cmd/project/project_upgrade.go index 9e22457c..af056188 100644 --- a/cmd/project/project_upgrade.go +++ b/cmd/project/project_upgrade.go @@ -32,11 +32,13 @@ import ( var projectUpgradeCmd = &cobra.Command{ Use: "upgrade", Short: "Upgrade the Shopware version of this project", - Long: `Upgrade the Shopware project to a newer version. This command mirrors -the behaviour of the shopware/web-installer: it picks an upgrade target, -removes incompatible custom plugins, rewrites composer.json for the new -version, runs composer update --with-all-dependencies, and finally runs -bin/console system:update:prepare and system:update:finish.`, + Long: `Upgrade the Shopware project to a newer version. The command picks an +upgrade target, resolves incompatible custom plugins, rewrites composer.json +for the new version, runs composer update --with-all-dependencies, and then +invokes vendor/bin/shopware-deployment-helper to run system:update:prepare, +migrations, system:update:finish and the rest of the deployment lifecycle. +shopware/deployment-helper is added to composer.json automatically when the +project doesn't require it yet.`, RunE: func(cmd *cobra.Command, _ []string) error { ctx := cmd.Context() log := logging.FromContext(ctx) @@ -162,7 +164,7 @@ func runUpgradeHeadless(cmd *cobra.Command, projectRoot, composerJsonPath string if !confirmed { if err := huh.NewConfirm(). Title(fmt.Sprintf("Upgrade Shopware from %s to %s?", currentVersion.String(), targetVersion)). - Description("This will modify composer.json, run composer update --with-all-dependencies, and execute system:update:prepare/finish. Commit your changes before running this command."). + Description("This will modify composer.json, run composer update --with-all-dependencies, and invoke vendor/bin/shopware-deployment-helper. Commit your changes before running this command."). Value(&confirmed). Run(); err != nil { return err @@ -173,12 +175,6 @@ func runUpgradeHeadless(cmd *cobra.Command, projectRoot, composerJsonPath string return fmt.Errorf("upgrade cancelled") } - log.Infof("Backing up composer.json") - backup, err := os.ReadFile(composerJsonPath) - if err != nil { - return fmt.Errorf("failed to backup composer.json: %w", err) - } - log.Infof("Cleaning up stale recipe files") if err := flexmigrator.CleanupByHash(projectRoot); err != nil { return fmt.Errorf("cleanup stale files: %w", err) @@ -188,7 +184,6 @@ func runUpgradeHeadless(cmd *cobra.Command, projectRoot, composerJsonPath string registry, _ := buildRegistry(cmd, projectRoot) result, err := projectupgrade.ResolveIncompatiblePlugins(ctx, composerJsonPath, targetVersion, registry) if err != nil { - restoreComposerJson(ctx, composerJsonPath, backup) return fmt.Errorf("resolve incompatible plugins: %w", err) } @@ -203,25 +198,9 @@ func runUpgradeHeadless(cmd *cobra.Command, projectRoot, composerJsonPath string log.Infof("Updating composer.json to %s", targetVersion) if err := projectupgrade.UpdateComposerJson(composerJsonPath, targetVersion); err != nil { - restoreComposerJson(ctx, composerJsonPath, backup) return fmt.Errorf("update composer.json: %w", err) } - // system:update:prepare must run on the still-installed (old) Shopware so - // it can enter maintenance mode and record the pending update before the - // vendor code is swapped by composer update. - log.Infof("Running bin/console system:update:prepare") - prepareCmd := cmdExecutor.ConsoleCommand(ctx, "system:update:prepare", "--no-interaction") - prepareCmd.Cmd.Stdin = cmd.InOrStdin() - prepareCmd.Cmd.Stdout = cmd.OutOrStdout() - prepareCmd.Cmd.Stderr = cmd.ErrOrStderr() - - if err := prepareCmd.Run(); err != nil { - restoreComposerJson(ctx, composerJsonPath, backup) - trackUpgrade(ctx, currentVersion.String(), targetVersion, "system_update_prepare_failed") - return fmt.Errorf("system:update:prepare failed, composer.json was restored: %w", err) - } - log.Infof("Running composer update") composerArgs := []string{ "update", @@ -238,20 +217,19 @@ func runUpgradeHeadless(cmd *cobra.Command, projectRoot, composerJsonPath string if err := composerCmd.Run(); err != nil { log.Errorf("composer update failed: %v", err) - restoreComposerJson(ctx, composerJsonPath, backup) trackUpgrade(ctx, currentVersion.String(), targetVersion, "composer_update_failed") - return fmt.Errorf("composer update failed, composer.json was restored: %w", err) + return fmt.Errorf("composer update failed: %w", err) } - log.Infof("Running bin/console system:update:finish") - finishCmd := cmdExecutor.ConsoleCommand(ctx, "system:update:finish", "--no-interaction") - finishCmd.Cmd.Stdin = cmd.InOrStdin() - finishCmd.Cmd.Stdout = cmd.OutOrStdout() - finishCmd.Cmd.Stderr = cmd.ErrOrStderr() + log.Infof("Running vendor/bin/shopware-deployment-helper run") + deployCmd := cmdExecutor.PHPCommand(ctx, "vendor/bin/shopware-deployment-helper", "run") + deployCmd.Cmd.Stdin = cmd.InOrStdin() + deployCmd.Cmd.Stdout = cmd.OutOrStdout() + deployCmd.Cmd.Stderr = cmd.ErrOrStderr() - if err := finishCmd.Run(); err != nil { - trackUpgrade(ctx, currentVersion.String(), targetVersion, "system_update_finish_failed") - return fmt.Errorf("system:update:finish failed: %w", err) + if err := deployCmd.Run(); err != nil { + trackUpgrade(ctx, currentVersion.String(), targetVersion, "deployment_helper_failed") + return fmt.Errorf("shopware-deployment-helper run failed: %w", err) } trackUpgrade(ctx, currentVersion.String(), targetVersion, "ok") @@ -368,12 +346,6 @@ func runCompatibilityCheck(ctx context.Context, currentVersion *version.Version, return nil } -func restoreComposerJson(ctx context.Context, composerJsonPath string, backup []byte) { - if err := os.WriteFile(composerJsonPath, backup, 0o644); err != nil { - logging.FromContext(ctx).Errorf("failed to restore composer.json from backup: %v", err) - } -} - func trackUpgrade(ctx context.Context, fromVersion, toVersion, status string) { trackCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 300*time.Millisecond) defer cancel() diff --git a/internal/devtui/setup_guide_apply.go b/internal/devtui/setup_guide_apply.go index 75376f8a..e5678cd2 100644 --- a/internal/devtui/setup_guide_apply.go +++ b/internal/devtui/setup_guide_apply.go @@ -70,15 +70,10 @@ func ensureDeploymentHelper(projectRoot string) (changed bool, err error) { return false, err } - if cj.HasPackage("shopware/deployment-helper") || cj.HasPackageDev("shopware/deployment-helper") { + if !cj.EnsureRequire("shopware/deployment-helper", "*") { return false, nil } - if cj.Require == nil { - cj.Require = packagist.ComposerPackageLink{} - } - cj.Require["shopware/deployment-helper"] = "*" - if err := cj.Save(); err != nil { return false, err } diff --git a/internal/packagist/composer.go b/internal/packagist/composer.go index 749c3154..2eb460ba 100644 --- a/internal/packagist/composer.go +++ b/internal/packagist/composer.go @@ -126,6 +126,21 @@ func (c *ComposerJson) HasPackageDev(name string) bool { return ok } +// EnsureRequire adds name=constraint to the require block when the package is +// not already present in either require or require-dev. Returns true when +// composer.json was modified; callers must Save() to persist the change. +func (c *ComposerJson) EnsureRequire(name, constraint string) bool { + if c.HasPackage(name) || c.HasPackageDev(name) { + return false + } + + if c.Require == nil { + c.Require = ComposerPackageLink{} + } + c.Require[name] = constraint + return true +} + func (c *ComposerJson) HasConfig(key string) bool { _, ok := c.Config[key] return ok diff --git a/internal/packagist/composer_test.go b/internal/packagist/composer_test.go index e0d34039..83a06bc0 100644 --- a/internal/packagist/composer_test.go +++ b/internal/packagist/composer_test.go @@ -248,3 +248,35 @@ func TestReadComposerJsonDifferentRepositoryWritings(t *testing.T) { assert.ElementsMatch(t, expectedRepos, composer.Repositories) }) } + +func TestEnsureRequireAddsWhenMissing(t *testing.T) { + t.Parallel() + + cj := &ComposerJson{Require: ComposerPackageLink{"shopware/core": "^6.6"}} + assert.True(t, cj.EnsureRequire("shopware/deployment-helper", "*")) + assert.Equal(t, "*", cj.Require["shopware/deployment-helper"]) +} + +func TestEnsureRequireNoOpWhenAlreadyRequired(t *testing.T) { + t.Parallel() + + cj := &ComposerJson{Require: ComposerPackageLink{"shopware/deployment-helper": "^1.0"}} + assert.False(t, cj.EnsureRequire("shopware/deployment-helper", "*")) + assert.Equal(t, "^1.0", cj.Require["shopware/deployment-helper"], "an existing constraint must not be overwritten") +} + +func TestEnsureRequireNoOpWhenPresentInRequireDev(t *testing.T) { + t.Parallel() + + cj := &ComposerJson{RequireDev: ComposerPackageLink{"shopware/deployment-helper": "^1.0"}} + assert.False(t, cj.EnsureRequire("shopware/deployment-helper", "*")) + assert.NotContains(t, cj.Require, "shopware/deployment-helper", "must not duplicate into require when present in require-dev") +} + +func TestEnsureRequireInitializesRequireMap(t *testing.T) { + t.Parallel() + + cj := &ComposerJson{} + assert.True(t, cj.EnsureRequire("shopware/deployment-helper", "*")) + assert.Equal(t, "*", cj.Require["shopware/deployment-helper"]) +} diff --git a/internal/projectupgrade/composer.go b/internal/projectupgrade/composer.go index 4f56d322..ee4a48f5 100644 --- a/internal/projectupgrade/composer.go +++ b/internal/projectupgrade/composer.go @@ -24,7 +24,9 @@ var ShopwarePackages = []string{ // UpdateComposerJson rewrites the project composer.json so that composer can // resolve dependencies for targetVersion. It mirrors the logic of -// `Shopware\WebInstaller\Services\ProjectComposerJsonUpdater`. +// `Shopware\WebInstaller\Services\ProjectComposerJsonUpdater` and additionally +// ensures shopware/deployment-helper is required so the post-update step can +// invoke vendor/bin/shopware-deployment-helper. func UpdateComposerJson(composerJsonPath, targetVersion string) error { composerJson, err := packagist.ReadComposerJson(composerJsonPath) if err != nil { @@ -51,6 +53,8 @@ func UpdateComposerJson(composerJsonPath, targetVersion string) error { } } + composerJson.EnsureRequire("shopware/deployment-helper", "*") + return composerJson.Save() } diff --git a/internal/projectupgrade/composer_test.go b/internal/projectupgrade/composer_test.go index 8eadc74d..b59a26b7 100644 --- a/internal/projectupgrade/composer_test.go +++ b/internal/projectupgrade/composer_test.go @@ -54,6 +54,27 @@ func TestUpdateComposerJsonRewritesShopwarePackages(t *testing.T) { assert.Equal(t, "6.6.4.0", requireMap["shopware/storefront"]) assert.Equal(t, "^1.0", requireMap["unrelated/package"]) assert.NotContains(t, requireMap, "shopware/elasticsearch", "should not add packages that were not already required") + assert.Equal(t, "*", requireMap["shopware/deployment-helper"], "deployment-helper is added so the upgrade can invoke it after composer update") +} + +func TestUpdateComposerJsonLeavesExistingDeploymentHelperConstraint(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + composerJsonPath := filepath.Join(dir, "composer.json") + + writeJSON(t, composerJsonPath, map[string]any{ + "name": "shopware/production", + "require": map[string]any{ + "shopware/core": "6.5.8.0", + "shopware/deployment-helper": "^1.0", + }, + }) + + require.NoError(t, UpdateComposerJson(composerJsonPath, "6.6.4.0")) + + requireMap := readJSON(t, composerJsonPath)["require"].(map[string]any) + assert.Equal(t, "^1.0", requireMap["shopware/deployment-helper"], "an existing constraint must not be overwritten") } func TestUpdateComposerJsonSetsRCStability(t *testing.T) { diff --git a/internal/projectupgrade/wizard.go b/internal/projectupgrade/wizard.go index 26e4ca96..f0f5af23 100644 --- a/internal/projectupgrade/wizard.go +++ b/internal/projectupgrade/wizard.go @@ -6,7 +6,6 @@ import ( "errors" "fmt" "io" - "os" "os/exec" "strings" "time" @@ -77,18 +76,16 @@ type task struct { detail string } -// taskCleanup, taskPlugins, ... are stable indices into model.tasks. -// system:update:prepare runs on the still-installed (old) Shopware so it can -// enter maintenance and record the update before the vendor code changes; -// composer update then swaps the code, and system:update:finish migrates. +// Stable indices into model.tasks. The flow is: rewrite composer.json, +// have composer pull the new vendor code, then let shopware-deployment-helper +// drive the install/update lifecycle (system:update:prepare, migrations, +// system:update:finish, theme compile, etc.) in one pass. const ( - taskBackup = iota - taskCleanup + taskCleanup = iota taskPlugins taskComposerJSON - taskSystemPrepare taskComposerUpdate - taskSystemFinish + taskDeploymentHelper ) // wizardMsg variants advance the upgrade state machine. @@ -98,11 +95,10 @@ type ( err error } taskCompleteMsg struct { - task int - err error - detail string - composerBackup []byte - pluginActions *ResolveResult + task int + err error + detail string + pluginActions *ResolveResult // output is the full captured subprocess output, set for streaming // tasks so the complete log survives independent of log-line event // ordering. Used to render the error tail on failure. @@ -124,7 +120,6 @@ type wizardModel struct { versionList *tui.SelectList targetVersion string confirmYes bool - composerBackup []byte pluginActions *ResolveResult compatUpdates []account_api.UpdateCheckExtensionCompatibility compatHasBlock bool @@ -220,13 +215,11 @@ var ErrCancelled = errors.New("upgrade cancelled by user") func defaultTasks() []task { return []task{ - {label: "Back up composer.json"}, {label: "Clean up stale recipe files"}, {label: "Resolve incompatible custom plugins"}, {label: "Rewrite composer.json"}, - {label: "bin/console system:update:prepare"}, {label: "composer update --with-all-dependencies"}, - {label: "bin/console system:update:finish"}, + {label: "vendor/bin/shopware-deployment-helper run"}, } } @@ -269,9 +262,6 @@ func (m wizardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.startTask() case taskCompleteMsg: - if msg.composerBackup != nil { - m.composerBackup = msg.composerBackup - } if msg.pluginActions != nil { m.pluginActions = msg.pluginActions } @@ -510,8 +500,6 @@ func (m wizardModel) startTask() (tea.Model, tea.Cmd) { m.tasks[m.currentTask].status = taskRunning switch m.currentTask { - case taskBackup: - return m, m.runBackup() case taskCleanup: return m, m.runCleanup() case taskPlugins: @@ -520,31 +508,13 @@ func (m wizardModel) startTask() (tea.Model, tea.Cmd) { return m, m.runUpdateComposer() case taskComposerUpdate: return m.startComposerUpdate() - case taskSystemPrepare: - // prepare runs before composer update, while composer.json is already - // rewritten - restore it on failure so the project is left untouched. - return m.startSystemUpdate("system:update:prepare", taskSystemPrepare, true) - case taskSystemFinish: - // finish runs after a successful composer update; restoring the old - // composer.json here would undo the upgrade, so leave it in place. - return m.startSystemUpdate("system:update:finish", taskSystemFinish, false) + case taskDeploymentHelper: + return m.startDeploymentHelper() } return m, nil } -func (m wizardModel) runBackup() tea.Cmd { - composerJSONPath := m.opts.ComposerJSONPath - idx := taskBackup - return func() tea.Msg { - data, err := os.ReadFile(composerJSONPath) - if err != nil { - return taskCompleteMsg{task: idx, err: fmt.Errorf("read composer.json: %w", err)} - } - return taskCompleteMsg{task: idx, detail: fmt.Sprintf("%d bytes", len(data)), composerBackup: data} - } -} - func (m wizardModel) runCleanup() tea.Cmd { projectRoot := m.opts.ProjectRoot idx := taskCleanup @@ -560,7 +530,6 @@ func (m wizardModel) runRemovePlugins() tea.Cmd { composerJSONPath := m.opts.ComposerJSONPath target := m.targetVersion idx := taskPlugins - restore := m.composerBackup registry := m.opts.Registry return func() tea.Msg { ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) @@ -568,7 +537,6 @@ func (m wizardModel) runRemovePlugins() tea.Cmd { result, err := ResolveIncompatiblePlugins(ctx, composerJSONPath, target, registry) if err != nil { - _ = os.WriteFile(composerJSONPath, restore, 0o644) return taskCompleteMsg{task: idx, err: err} } if result == nil { @@ -593,10 +561,8 @@ func (m wizardModel) runUpdateComposer() tea.Cmd { composerJSONPath := m.opts.ComposerJSONPath target := m.targetVersion idx := taskComposerJSON - restore := m.composerBackup return func() tea.Msg { if err := UpdateComposerJson(composerJSONPath, target); err != nil { - _ = os.WriteFile(composerJSONPath, restore, 0o644) return taskCompleteMsg{task: idx, err: err} } return taskCompleteMsg{task: idx, detail: "pinned to " + target} @@ -621,22 +587,17 @@ func (m wizardModel) startComposerUpdate() (tea.Model, tea.Cmd) { } p := m.opts.Executor.ComposerCommand(ctx, args...) - restore := m.composerBackup - composerJSONPath := m.opts.ComposerJSONPath idx := taskComposerUpdate doneCmd := func() tea.Msg { output, err := streamCmdOutput(p.Cmd, ch, true) - if err != nil { - _ = os.WriteFile(composerJSONPath, restore, 0o644) - } return taskCompleteMsg{task: idx, err: err, output: output} } return m, tea.Batch(m.readNextLog(), doneCmd) } -func (m wizardModel) startSystemUpdate(consoleCmd string, idx int, restoreOnFail bool) (tea.Model, tea.Cmd) { +func (m wizardModel) startDeploymentHelper() (tea.Model, tea.Cmd) { ctx, cancel := context.WithCancel(context.Background()) m.cancelExecution = cancel @@ -645,16 +606,12 @@ func (m wizardModel) startSystemUpdate(consoleCmd string, idx int, restoreOnFail m.logLines = nil m.fullLog = nil - p := m.opts.Executor.ConsoleCommand(ctx, consoleCmd, "--no-interaction") + p := m.opts.Executor.PHPCommand(ctx, "vendor/bin/shopware-deployment-helper", "run") - restore := m.composerBackup - composerJSONPath := m.opts.ComposerJSONPath + idx := taskDeploymentHelper doneCmd := func() tea.Msg { output, err := streamCmdOutput(p.Cmd, ch, true) - if err != nil && restoreOnFail { - _ = os.WriteFile(composerJSONPath, restore, 0o644) - } return taskCompleteMsg{task: idx, err: err, output: output} } @@ -776,12 +733,11 @@ func (m wizardModel) viewWelcome() string { b.WriteString(tui.DimStyle.Render("This wizard mirrors the shopware/web-installer flow:")) b.WriteString("\n\n") for _, line := range []string{ - "Back up composer.json before any change", "Clean up stale recipe-managed files (md5-matched)", - "Drop incompatible custom plugins from composer.json", - "Rewrite composer.json to pin the target version", + "Resolve incompatible custom plugins (bump or drop)", + "Rewrite composer.json to pin the target version and ensure shopware/deployment-helper", "Run composer update --with-all-dependencies --no-scripts", - "Run bin/console system:update:prepare + finish", + "Run vendor/bin/shopware-deployment-helper run", } { b.WriteString(tui.DimStyle.Render(" • ")) b.WriteString(tui.LabelStyle.Render(line)) @@ -981,7 +937,7 @@ func (m wizardModel) viewDone() string { b.WriteString("\n\n") } - b.WriteString(tui.DimStyle.Render("composer.json was restored from the backup taken before the upgrade.")) + b.WriteString(tui.DimStyle.Render("composer.json and vendor are left as-is; run `git checkout composer.json composer.lock` to revert.")) } else { b.WriteString(lipgloss.NewStyle().Bold(true).Foreground(tui.SuccessColor).Render(fmt.Sprintf("✓ Upgraded to Shopware %s", m.targetVersion))) b.WriteString("\n\n") diff --git a/internal/projectupgrade/wizard_test.go b/internal/projectupgrade/wizard_test.go index 45841d39..bd271fcc 100644 --- a/internal/projectupgrade/wizard_test.go +++ b/internal/projectupgrade/wizard_test.go @@ -158,38 +158,19 @@ func TestWizardCompatLoadedUpdatableIsNotBlocker(t *testing.T) { assert.True(t, wm.confirmYes, "no blocker means confirm defaults to Yes") } -func TestUpgradeTaskOrderRunsPrepareBeforeComposerUpdate(t *testing.T) { +func TestUpgradeTaskOrderRunsDeploymentHelperLast(t *testing.T) { t.Parallel() - // system:update:prepare must run on the old, still-installed Shopware, - // i.e. before composer update swaps the vendor code; finish runs last. - assert.Less(t, taskSystemPrepare, taskComposerUpdate, "prepare must precede composer update") - assert.Less(t, taskComposerUpdate, taskSystemFinish, "finish must run after composer update") + // composer update must rewrite vendor before shopware-deployment-helper + // runs the install/update lifecycle that drives system:update:prepare, + // migrations, system:update:finish and theme compilation. + assert.Less(t, taskComposerJSON, taskComposerUpdate, "composer.json must be rewritten before composer update") + assert.Less(t, taskComposerUpdate, taskDeploymentHelper, "deployment-helper runs after composer update") tasks := defaultTasks() - require.Len(t, tasks, taskSystemFinish+1) - assert.Equal(t, "bin/console system:update:prepare", tasks[taskSystemPrepare].label) + require.Len(t, tasks, taskDeploymentHelper+1) assert.Equal(t, "composer update --with-all-dependencies", tasks[taskComposerUpdate].label) - assert.Equal(t, "bin/console system:update:finish", tasks[taskSystemFinish].label) -} - -func TestWizardTaskCompletePersistsBackupAcrossUpdates(t *testing.T) { - t.Parallel() - - m := newTestModel(t) - m.phase = phaseRunning - m.currentTask = taskBackup - - // First task: backup captures composer.json bytes. - updated, _ := m.Update(taskCompleteMsg{ - task: taskBackup, - composerBackup: []byte(`{"name":"shopware/production"}`), - detail: "30 bytes", - }) - wm := updated.(wizardModel) - assert.Equal(t, []byte(`{"name":"shopware/production"}`), wm.composerBackup, "backup must persist for later restore-on-failure") - assert.Equal(t, taskCleanup, wm.currentTask) - assert.Equal(t, taskDone, wm.tasks[taskBackup].status) + assert.Equal(t, "vendor/bin/shopware-deployment-helper run", tasks[taskDeploymentHelper].label) } func TestWizardTaskCompleteErrorEndsRun(t *testing.T) { From b64adef061574c8577447f88a834e69dd97f7979 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 1 Jun 2026 12:33:02 +0000 Subject: [PATCH 34/38] feat(projectupgrade): replace account-api compat with composer data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The compatibility phase used to call the Shopware account API to ask "which extensions are blocked by this Shopware version?". The same question is already answered by the composer-managed plugin metadata the resolver consults right after: for each platform plugin, its require.shopware/* tells us whether it satisfies the target, and the registry (Packagist + Shopware store) tells us whether a newer release would. Use that consistent source instead and drop the account API dependency from the upgrade flow: - projectupgrade.CheckPluginCompatibility is a dry-run of the resolver: for every shopware-platform-plugin in composer.json's require it reports CompatCompatible / CompatUpdatable (with the target version) / CompatBlocker (no compatible release) / CompatUnknown (registry unreachable - e.g. store token missing). - The wizard's loadCompatibility now calls that instead of account_api.GetFutureExtensionUpdates, and renders rows as `name — current → new` (Updatable) or `name — current — status`. - The headless runCompatibilityCheck mirrors the wizard's logic and renders a Plugin / Current / Status table. - packagist.InstalledPackage gains a Version field so the report can show the installed version. - WizardOptions.Extensions and the "skip compat phase when no extensions" branch are removed - the phase always runs against the composer data, and the step counter is always 4. The account-api package itself stays in the codebase; it is still used by `project upgrade-check` and other commands that genuinely need the store's extension catalog. --- cmd/project/project_upgrade.go | 76 +++------ internal/packagist/installed.go | 1 + internal/projectupgrade/compatibility.go | 120 +++++++++++++++ internal/projectupgrade/compatibility_test.go | 144 ++++++++++++++++++ internal/projectupgrade/wizard.go | 82 +++------- internal/projectupgrade/wizard_test.go | 43 +----- 6 files changed, 315 insertions(+), 151 deletions(-) create mode 100644 internal/projectupgrade/compatibility.go create mode 100644 internal/projectupgrade/compatibility_test.go diff --git a/cmd/project/project_upgrade.go b/cmd/project/project_upgrade.go index af056188..cd69df06 100644 --- a/cmd/project/project_upgrade.go +++ b/cmd/project/project_upgrade.go @@ -16,7 +16,6 @@ import ( "github.com/shyim/go-version" "github.com/spf13/cobra" - account_api "github.com/shopware/shopware-cli/internal/account-api" "github.com/shopware/shopware-cli/internal/executor" "github.com/shopware/shopware-cli/internal/extension" "github.com/shopware/shopware-cli/internal/flexmigrator" @@ -94,12 +93,6 @@ project doesn't require it yet.`, } // Interactive: hand off to the devtui-styled wizard. - _, extensions, err := getLocalExtensions() - if err != nil { - log.Warnf("Could not gather local extensions for compatibility check: %v", err) - extensions = nil - } - registry, err := buildRegistry(cmd, projectRoot) if err != nil { return err @@ -110,7 +103,6 @@ project doesn't require it yet.`, ComposerJSONPath: composerJsonPath, CurrentVersion: currentVersion, UpdateVersions: updateVersions, - Extensions: extensions, Executor: cmdExecutor, Registry: registry, }) @@ -156,7 +148,12 @@ func runUpgradeHeadless(cmd *cobra.Command, projectRoot, composerJsonPath string return err } - if err := runCompatibilityCheck(ctx, currentVersion, targetVersion); err != nil { + registry, err := buildRegistry(cmd, projectRoot) + if err != nil { + return err + } + + if err := runCompatibilityCheck(ctx, composerJsonPath, targetVersion, registry); err != nil { return err } @@ -181,7 +178,6 @@ func runUpgradeHeadless(cmd *cobra.Command, projectRoot, composerJsonPath string } log.Infof("Checking custom plugins for incompatibilities") - registry, _ := buildRegistry(cmd, projectRoot) result, err := projectupgrade.ResolveIncompatiblePlugins(ctx, composerJsonPath, targetVersion, registry) if err != nil { return fmt.Errorf("resolve incompatible plugins: %w", err) @@ -271,67 +267,37 @@ func selectTargetVersion(cmd *cobra.Command, updateVersions []string) (string, e return selected, nil } -func runCompatibilityCheck(ctx context.Context, currentVersion *version.Version, targetVersion string) error { +func runCompatibilityCheck(ctx context.Context, composerJsonPath, targetVersion string, registry projectupgrade.Registry) error { log := logging.FromContext(ctx) - _, extensions, err := getLocalExtensions() + results, err := projectupgrade.CheckPluginCompatibility(ctx, composerJsonPath, targetVersion, registry) if err != nil { - log.Warnf("Skipping extension compatibility check: %v", err) - return nil - } - - if len(extensions) == 0 { + log.Warnf("Skipping plugin compatibility check: %v", err) return nil } - requests := make([]account_api.UpdateCheckExtension, 0, len(extensions)) - for name, v := range extensions { - requests = append(requests, account_api.UpdateCheckExtension{Name: name, Version: v}) - } - - updates, err := account_api.GetFutureExtensionUpdates(ctx, currentVersion.String(), targetVersion, requests) - if err != nil { - log.Warnf("Skipping extension compatibility check: %v", err) + if len(results) == 0 { return nil } - for _, name := range requests { - found := false - for _, update := range updates { - if update.Name == name.Name { - found = true - break - } - } - - if !found { - updates = append(updates, account_api.UpdateCheckExtensionCompatibility{ - Name: name.Name, - Status: account_api.UpdateCheckExtensionCompatibilityStatus{ - Label: "Not available in Store", - }, - }) - } - } - - t := table.New().Border(lipgloss.NormalBorder()).Headers("Extension Name", "Compatible") - for _, update := range updates { - t.Row(update.Name, update.Status.Label) - } - fmt.Println(t.Render()) - + t := table.New().Border(lipgloss.NormalBorder()).Headers("Plugin", "Current", "Status") hasBlockers := false - for _, update := range updates { - if update.Status.IsBlocker() { + for _, row := range results { + status := row.Status.Label() + if row.Status == projectupgrade.CompatUpdatable && row.NewVersion != "" { + status = fmt.Sprintf("%s → %s", row.Status.Label(), row.NewVersion) + } + t.Row(row.Name, row.CurrentVersion, status) + if row.Status.IsBlocker() { hasBlockers = true - break } } + fmt.Println(t.Render()) if hasBlockers && system.IsInteractionEnabled(ctx) { var proceed bool if err := huh.NewConfirm(). - Title("Some installed extensions have no compatible version for the target version"). + Title("Some plugins have no compatible version for the target version"). Description("They will be removed from composer.json so the upgrade can proceed. Re-require them once they publish a compatible release. Proceed anyway?"). Value(&proceed). Run(); err != nil { @@ -339,7 +305,7 @@ func runCompatibilityCheck(ctx context.Context, currentVersion *version.Version, } if !proceed { - return fmt.Errorf("upgrade cancelled due to incompatible extensions") + return fmt.Errorf("upgrade cancelled due to incompatible plugins") } } diff --git a/internal/packagist/installed.go b/internal/packagist/installed.go index 6c842da5..15338a0f 100644 --- a/internal/packagist/installed.go +++ b/internal/packagist/installed.go @@ -14,6 +14,7 @@ import ( type InstalledPackage struct { Name string `json:"name"` Type string `json:"type"` + Version string `json:"version"` Require map[string]string `json:"require"` InstallPath string `json:"install-path"` } diff --git a/internal/projectupgrade/compatibility.go b/internal/projectupgrade/compatibility.go new file mode 100644 index 00000000..98e9c998 --- /dev/null +++ b/internal/projectupgrade/compatibility.go @@ -0,0 +1,120 @@ +package projectupgrade + +import ( + "context" + "fmt" + "path/filepath" + "strings" + + "github.com/shyim/go-version" + + "github.com/shopware/shopware-cli/internal/packagist" +) + +// CompatStatus describes the upgrade plan for one composer-managed plugin. +type CompatStatus int + +const ( + // CompatCompatible: the installed version already satisfies the target. + CompatCompatible CompatStatus = iota + // CompatUpdatable: a newer compatible release exists; the resolver will + // bump the constraint. + CompatUpdatable + // CompatBlocker: no compatible release is published; the resolver will + // remove the plugin from composer.json so the upgrade can proceed. + CompatBlocker + // CompatUnknown: the registry could not be consulted (e.g. no token for a + // store plugin); the resolver will drop the plugin too, but the user may + // want to retry with credentials. + CompatUnknown +) + +// IsBlocker reports whether this status means the upgrade will drop the plugin. +func (s CompatStatus) IsBlocker() bool { return s == CompatBlocker || s == CompatUnknown } + +// IsUpdatable reports whether this status means the constraint will be bumped. +func (s CompatStatus) IsUpdatable() bool { return s == CompatUpdatable } + +// Label returns a short human-readable label. +func (s CompatStatus) Label() string { + switch s { + case CompatCompatible: + return "Compatible" + case CompatUpdatable: + return "Update available" + case CompatBlocker: + return "No compatible release" + case CompatUnknown: + return "Not in registry" + } + return "" +} + +// PluginCompat is one row of the compatibility preview. +type PluginCompat struct { + // Name is the composer package name (e.g. "swag/paypal"). + Name string + // CurrentVersion is the version recorded in installed.json. + CurrentVersion string + // NewVersion is the version the resolver would bump to. Populated when + // Status is CompatUpdatable. + NewVersion string + // Status classifies how the resolver will treat this plugin. + Status CompatStatus +} + +// CheckPluginCompatibility consults the registry for every composer-managed +// shopware platform plugin and reports how the upgrade will treat it. The +// composer.json is not modified; this is a dry-run of +// ResolveIncompatiblePlugins so callers can preview the plan before applying +// it. +func CheckPluginCompatibility(ctx context.Context, composerJsonPath, targetVersion string, registry Registry) ([]PluginCompat, error) { + projectDir := filepath.Dir(composerJsonPath) + + installed, err := packagist.ReadInstalledJson(projectDir) + if err != nil { + return nil, err + } + + composerJson, err := packagist.ReadComposerJson(composerJsonPath) + if err != nil { + return nil, err + } + + target, err := version.NewVersion(strings.TrimPrefix(targetVersion, "v")) + if err != nil { + return nil, fmt.Errorf("parse target version: %w", err) + } + + results := make([]PluginCompat, 0) + for _, pkg := range installed.Packages { + if pkg.Type != composerPluginType { + continue + } + if _, ok := composerJson.Require[pkg.Name]; !ok { + continue + } + + row := PluginCompat{Name: pkg.Name, CurrentVersion: strings.TrimPrefix(pkg.Version, "v")} + + if packagist.ConstraintsSatisfiedBy(pkg.Require, ShopwarePackages, target) { + row.Status = CompatCompatible + results = append(results, row) + continue + } + + newVersion, lookupErr := findCompatibleVersion(ctx, registry, pkg.Name, target) + switch { + case newVersion != "": + row.Status = CompatUpdatable + row.NewVersion = newVersion + case lookupErr != nil: + row.Status = CompatUnknown + default: + row.Status = CompatBlocker + } + results = append(results, row) + } + + return results, nil +} diff --git a/internal/projectupgrade/compatibility_test.go b/internal/projectupgrade/compatibility_test.go new file mode 100644 index 00000000..9ee035f9 --- /dev/null +++ b/internal/projectupgrade/compatibility_test.go @@ -0,0 +1,144 @@ +package projectupgrade + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/shopware/shopware-cli/internal/packagist" +) + +func TestCheckPluginCompatibilityClassifiesEachPlugin(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + composerJsonPath := filepath.Join(dir, "composer.json") + + writeJSON(t, composerJsonPath, map[string]any{ + "name": "shopware/production", + "require": map[string]any{ + "shopware/core": "6.5.8.0", + "vendor/compat": "^1.0", + "vendor/updates": "^1.0", + "vendor/blocker": "^1.0", + "transitive/skip": "^1.0", // not in installed.json -> ignored + }, + }) + + writeInstalledJSON(t, dir, []packagist.InstalledPackage{ + { + Name: "vendor/compat", + Type: composerPluginType, + Version: "1.2.0", + InstallPath: "../../custom/plugins/Compat", + Require: map[string]string{"shopware/core": "^6.5 | ^6.6"}, + }, + { + Name: "vendor/updates", + Type: composerPluginType, + Version: "1.0.0", + InstallPath: "../../custom/plugins/Updates", + Require: map[string]string{"shopware/core": "~6.5.0"}, + }, + { + Name: "vendor/blocker", + Type: composerPluginType, + Version: "1.0.0", + InstallPath: "../../custom/plugins/Blocker", + Require: map[string]string{"shopware/core": "~6.5.0"}, + }, + }) + + registry := &fakeRegistry{ + versions: map[string][]packagist.ComposerPackageVersion{ + "vendor/updates": { + {Version: "2.0.0", Require: map[string]string{"shopware/core": "^6.6"}}, + }, + "vendor/blocker": { + {Version: "1.1.0", Require: map[string]string{"shopware/core": "~6.5.0"}}, + }, + }, + } + + results, err := CheckPluginCompatibility(t.Context(), composerJsonPath, "6.6.4.0", registry) + require.NoError(t, err) + require.Len(t, results, 3, "transitive/skip must be ignored - it is not in installed.json") + + byName := map[string]PluginCompat{} + for _, r := range results { + byName[r.Name] = r + } + + assert.Equal(t, CompatCompatible, byName["vendor/compat"].Status) + assert.Equal(t, "1.2.0", byName["vendor/compat"].CurrentVersion) + + assert.Equal(t, CompatUpdatable, byName["vendor/updates"].Status) + assert.Equal(t, "1.0.0", byName["vendor/updates"].CurrentVersion) + assert.Equal(t, "2.0.0", byName["vendor/updates"].NewVersion) + + assert.Equal(t, CompatBlocker, byName["vendor/blocker"].Status) + assert.Equal(t, "1.0.0", byName["vendor/blocker"].CurrentVersion) +} + +func TestCheckPluginCompatibilityReportsUnknownOnRegistryError(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + composerJsonPath := filepath.Join(dir, "composer.json") + + writeJSON(t, composerJsonPath, map[string]any{ + "name": "shopware/production", + "require": map[string]any{ + "shopware/core": "6.5.8.0", + "store/mysteryx": "^1.0", + }, + }) + + writeInstalledJSON(t, dir, []packagist.InstalledPackage{ + { + Name: "store/mysteryx", + Type: composerPluginType, + Version: "1.0.0", + InstallPath: "../store/mysteryx", + Require: map[string]string{"shopware/core": "~6.5.0"}, + }, + }) + + registry := &fakeRegistry{err: assertErr("no token configured")} + + results, err := CheckPluginCompatibility(t.Context(), composerJsonPath, "6.6.4.0", registry) + require.NoError(t, err) + require.Len(t, results, 1) + assert.Equal(t, CompatUnknown, results[0].Status, "registry errors surface as 'unknown' so the user can retry with credentials") + assert.True(t, results[0].Status.IsBlocker(), "unknown counts as a blocker - the resolver will drop the plugin") +} + +func TestCheckPluginCompatibilityIgnoresNonPlatformPlugins(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + composerJsonPath := filepath.Join(dir, "composer.json") + + writeJSON(t, composerJsonPath, map[string]any{ + "name": "shopware/production", + "require": map[string]any{ + "shopware/core": "6.5.8.0", + "symfony/console": "^6.0", + }, + }) + + writeInstalledJSON(t, dir, []packagist.InstalledPackage{ + { + Name: "symfony/console", + Type: "library", // not a shopware-platform-plugin + Version: "6.4.0", + InstallPath: "../symfony/console", + }, + }) + + results, err := CheckPluginCompatibility(t.Context(), composerJsonPath, "6.6.4.0", nil) + require.NoError(t, err) + assert.Empty(t, results, "non-platform-plugin libraries are not part of the upgrade plan") +} diff --git a/internal/projectupgrade/wizard.go b/internal/projectupgrade/wizard.go index f0f5af23..1e64c1f7 100644 --- a/internal/projectupgrade/wizard.go +++ b/internal/projectupgrade/wizard.go @@ -15,7 +15,6 @@ import ( "charm.land/lipgloss/v2" "github.com/shyim/go-version" - account_api "github.com/shopware/shopware-cli/internal/account-api" "github.com/shopware/shopware-cli/internal/executor" "github.com/shopware/shopware-cli/internal/flexmigrator" "github.com/shopware/shopware-cli/internal/tui" @@ -39,7 +38,6 @@ type WizardOptions struct { ComposerJSONPath string CurrentVersion *version.Version UpdateVersions []string - Extensions map[string]string Executor executor.Executor // Registry is consulted to find newer compatible versions of plugins // whose installed shopware/core constraint is no longer satisfied. May @@ -91,7 +89,7 @@ const ( // wizardMsg variants advance the upgrade state machine. type ( compatLoadedMsg struct { - updates []account_api.UpdateCheckExtensionCompatibility + updates []PluginCompat err error } taskCompleteMsg struct { @@ -121,7 +119,7 @@ type wizardModel struct { targetVersion string confirmYes bool pluginActions *ResolveResult - compatUpdates []account_api.UpdateCheckExtensionCompatibility + compatUpdates []PluginCompat compatHasBlock bool compatHasUpdatable bool compatErr error @@ -369,11 +367,6 @@ func (m wizardModel) updateSelectVersion(key string) (tea.Model, tea.Cmd) { return m, nil } m.targetVersion = selected.Label - if len(m.opts.Extensions) == 0 { - m.phase = phaseReview - m.confirmYes = true - return m, nil - } m.phase = phaseCompatCheck m.compatLoading = true return m, tea.Batch(m.spinner.Tick, m.loadCompatibility()) @@ -448,44 +441,21 @@ func (m wizardModel) readNextLog() tea.Cmd { } } -// loadCompatibility queries the Shopware account API for extension -// compatibility against the chosen target version. +// loadCompatibility runs CheckPluginCompatibility against the registry to +// preview how each composer-managed plugin will be treated by the upgrade. func (m wizardModel) loadCompatibility() tea.Cmd { - requests := make([]account_api.UpdateCheckExtension, 0, len(m.opts.Extensions)) - for name, v := range m.opts.Extensions { - requests = append(requests, account_api.UpdateCheckExtension{Name: name, Version: v}) - } - currentVersion := m.opts.CurrentVersion.String() + composerJsonPath := m.opts.ComposerJSONPath targetVersion := m.targetVersion + registry := m.opts.Registry return func() tea.Msg { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() - updates, err := account_api.GetFutureExtensionUpdates(ctx, currentVersion, targetVersion, requests) + updates, err := CheckPluginCompatibility(ctx, composerJsonPath, targetVersion, registry) if err != nil { return compatLoadedMsg{err: err} } - - for _, name := range requests { - found := false - for _, update := range updates { - if update.Name == name.Name { - found = true - break - } - } - - if !found { - updates = append(updates, account_api.UpdateCheckExtensionCompatibility{ - Name: name.Name, - Status: account_api.UpdateCheckExtensionCompatibilityStatus{ - Label: "Not available in Store", - }, - }) - } - } - return compatLoadedMsg{updates: updates} } } @@ -696,10 +666,7 @@ func (m wizardModel) viewContent() string { } func (m wizardModel) totalSteps() int { - if len(m.opts.Extensions) == 0 { - return 3 // Select version, Review, Run - } - return 4 // + Compatibility check + return 4 // Select version, Compatibility check, Review, Run } func (m wizardModel) stepNum(p phase) int { @@ -709,14 +676,8 @@ func (m wizardModel) stepNum(p phase) int { case phaseCompatCheck, phaseCompatResult: return 2 case phaseReview: - if len(m.opts.Extensions) == 0 { - return 2 - } return 3 case phaseRunning: - if len(m.opts.Extensions) == 0 { - return 3 - } return 4 case phaseWelcome, phaseDone: return 0 @@ -747,9 +708,6 @@ func (m wizardModel) viewWelcome() string { b.WriteString(tui.SectionDivider(tui.PhaseCardWidth - 6)) b.WriteString(tui.KVRow("Current version", tui.BoldText.Render(m.opts.CurrentVersion.String()))) b.WriteString(tui.KVRow("Project root", tui.DimStyle.Render(m.opts.ProjectRoot))) - if len(m.opts.Extensions) > 0 { - b.WriteString(tui.KVRow("Installed extensions", tui.LabelStyle.Render(fmt.Sprintf("%d", len(m.opts.Extensions))))) - } b.WriteString("\n") b.WriteString(renderConfirmButtons("Begin upgrade", "Cancel", m.confirmYes)) b.WriteString("\n\n") @@ -781,9 +739,9 @@ func (m wizardModel) viewCompatCheck() string { var b strings.Builder b.WriteString(stepBadge(m.stepNum(phaseCompatCheck), m.totalSteps())) b.WriteString("\n\n") - b.WriteString(tui.TitleStyle.Render("Checking extension compatibility")) + b.WriteString(tui.TitleStyle.Render("Checking plugin compatibility")) b.WriteString("\n") - b.WriteString(tui.DimStyle.Render(fmt.Sprintf("Asking the Shopware store about %d installed extension(s) against %s…", len(m.opts.Extensions), m.targetVersion))) + b.WriteString(tui.DimStyle.Render(fmt.Sprintf("Looking up composer-managed plugins for %s…", m.targetVersion))) b.WriteString("\n\n") b.WriteString(m.spinner.View() + " " + tui.DimStyle.Render("fetching compatibility")) b.WriteString("\n\n") @@ -795,7 +753,7 @@ func (m wizardModel) viewCompatResult() string { var b strings.Builder b.WriteString(stepBadge(m.stepNum(phaseCompatResult), m.totalSteps())) b.WriteString("\n\n") - b.WriteString(tui.TitleStyle.Render("Extension compatibility")) + b.WriteString(tui.TitleStyle.Render("Plugin compatibility")) b.WriteString("\n") b.WriteString(tui.DimStyle.Render(fmt.Sprintf("Upgrade to %s", m.targetVersion))) b.WriteString("\n\n") @@ -804,10 +762,10 @@ func (m wizardModel) viewCompatResult() string { case m.compatErr != nil: b.WriteString(lipgloss.NewStyle().Foreground(tui.ErrorColor).Render("Compatibility lookup failed: " + m.compatErr.Error())) b.WriteString("\n") - b.WriteString(tui.DimStyle.Render("You may still proceed; the wizard cannot guarantee extensions will install.")) + b.WriteString(tui.DimStyle.Render("You may still proceed; the wizard cannot guarantee plugins will install.")) b.WriteString("\n\n") case len(m.compatUpdates) == 0: - b.WriteString(tui.DimStyle.Render("No store-managed extensions to check.")) + b.WriteString(tui.DimStyle.Render("No composer-managed plugins to check.")) b.WriteString("\n\n") default: for _, u := range m.compatUpdates { @@ -824,19 +782,25 @@ func (m wizardModel) viewCompatResult() string { b.WriteString(icon) b.WriteString(" ") b.WriteString(tui.LabelStyle.Render(u.Name)) - b.WriteString(tui.DimStyle.Render(" — " + u.Status.Label)) + detail := u.Status.Label() + if u.Status == CompatUpdatable && u.NewVersion != "" { + detail = fmt.Sprintf("%s → %s", u.CurrentVersion, u.NewVersion) + } else if u.CurrentVersion != "" { + detail = u.CurrentVersion + " — " + detail + } + b.WriteString(tui.DimStyle.Render(" — " + detail)) b.WriteString("\n") } b.WriteString("\n") } if m.compatHasUpdatable { - b.WriteString(lipgloss.NewStyle().Foreground(tui.WarnColor).Render("↑ Extensions marked with ↑ have a compatible release; their constraints will be bumped during the upgrade.")) + b.WriteString(lipgloss.NewStyle().Foreground(tui.WarnColor).Render("↑ Plugins marked with ↑ have a compatible release; their constraints will be bumped during the upgrade.")) b.WriteString("\n\n") } if m.compatHasBlock { - b.WriteString(lipgloss.NewStyle().Foreground(tui.ErrorColor).Bold(true).Render("✗ Some extensions have no compatible version yet.")) + b.WriteString(lipgloss.NewStyle().Foreground(tui.ErrorColor).Bold(true).Render("✗ Some plugins have no compatible version yet.")) b.WriteString("\n") b.WriteString(tui.DimStyle.Render("They will be removed from composer.json so the upgrade can proceed. Re-require them once they publish a compatible release.")) b.WriteString("\n\n") diff --git a/internal/projectupgrade/wizard_test.go b/internal/projectupgrade/wizard_test.go index bd271fcc..2908913c 100644 --- a/internal/projectupgrade/wizard_test.go +++ b/internal/projectupgrade/wizard_test.go @@ -8,7 +8,6 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - account_api "github.com/shopware/shopware-cli/internal/account-api" "github.com/shopware/shopware-cli/internal/tui" ) @@ -77,25 +76,11 @@ func TestWizardSelectVersionForwardsNavigationToList(t *testing.T) { assert.Equal(t, 1, wm.versionList.Cursor()) } -func TestWizardSelectVersionWithoutExtensionsSkipsToReview(t *testing.T) { +func TestWizardSelectVersionGoesToCompatCheck(t *testing.T) { t.Parallel() m := newTestModel(t) m.phase = phaseSelectVersion - m.versionList.HandleKey("down") // move to "6.6.3.0" - - updated, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) - wm := updated.(wizardModel) - assert.Equal(t, phaseReview, wm.phase) - assert.Equal(t, "6.6.3.0", wm.targetVersion) -} - -func TestWizardSelectVersionWithExtensionsGoesToCompatCheck(t *testing.T) { - t.Parallel() - - m := newTestModel(t) - m.opts.Extensions = map[string]string{"AcmeExtension": "1.0.0"} - m.phase = phaseSelectVersion updated, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) wm := updated.(wizardModel) @@ -112,15 +97,8 @@ func TestWizardCompatLoadedSetsBlockerFlag(t *testing.T) { m.compatLoading = true updated, _ := m.Update(compatLoadedMsg{ - updates: []account_api.UpdateCheckExtensionCompatibility{ - { - Name: "Blocker", - Status: account_api.UpdateCheckExtensionCompatibilityStatus{ - Name: account_api.CompatibilityNotCompatible, - Type: "red", - Label: "Not compatible", - }, - }, + updates: []PluginCompat{ + {Name: "vendor/incompat", CurrentVersion: "1.0.0", Status: CompatBlocker}, }, }) wm := updated.(wizardModel) @@ -137,23 +115,14 @@ func TestWizardCompatLoadedUpdatableIsNotBlocker(t *testing.T) { m.phase = phaseCompatCheck m.compatLoading = true - // "With new Shopware version" — a compatible release exists, so this must - // not block the upgrade; the resolver bumps the constraint. updated, _ := m.Update(compatLoadedMsg{ - updates: []account_api.UpdateCheckExtensionCompatibility{ - { - Name: "SwagPayPal", - Status: account_api.UpdateCheckExtensionCompatibilityStatus{ - Name: account_api.CompatibilityUpdatableNow, - Type: "yellow", - Label: "With new Shopware version", - }, - }, + updates: []PluginCompat{ + {Name: "swag/paypal", CurrentVersion: "8.11.0", NewVersion: "9.0.0", Status: CompatUpdatable}, }, }) wm := updated.(wizardModel) assert.Equal(t, phaseCompatResult, wm.phase) - assert.False(t, wm.compatHasBlock, "updatable extension must not block") + assert.False(t, wm.compatHasBlock, "updatable plugin must not block") assert.True(t, wm.compatHasUpdatable) assert.True(t, wm.confirmYes, "no blocker means confirm defaults to Yes") } From 9d06f522f199f8798d789309e5b4da831035ff1c Mon Sep 17 00:00:00 2001 From: Roboshyim Date: Mon, 1 Jun 2026 16:29:30 +0000 Subject: [PATCH 35/38] fix(project): guard project ci on dirty local worktrees --- cmd/project/ci.go | 79 ++++++++++++++++++++++++++++++++ cmd/project/ci_test.go | 100 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 179 insertions(+) create mode 100644 cmd/project/ci_test.go diff --git a/cmd/project/ci.go b/cmd/project/ci.go index 514e1df5..2bf9a78a 100644 --- a/cmd/project/ci.go +++ b/cmd/project/ci.go @@ -44,6 +44,15 @@ var projectCI = &cobra.Command{ return err } + force, err := cmd.Flags().GetBool("force") + if err != nil { + return err + } + + if err := projectCISafetyCheck(cmd.Context(), args[0], force, os.Getenv); err != nil { + return err + } + if os.Getenv("APP_ENV") == "" { if err := os.Setenv("APP_ENV", "prod"); err != nil { return err @@ -358,6 +367,76 @@ func prepareComposerAuth(ctx context.Context, root string) (string, error) { func init() { projectRootCmd.AddCommand(projectCI) projectCI.PersistentFlags().Bool("with-dev-dependencies", false, "Install dev dependencies") + projectCI.PersistentFlags().Bool("force", false, "Run project ci outside CI even when the git working tree has local changes") +} + +func projectCISafetyCheck(ctx context.Context, root string, force bool, getenv func(string) string) error { + if force || isCIEnvironment(getenv) { + return nil + } + + dirty, isGitRepository, err := isGitWorkingTreeDirty(ctx, root) + if err != nil { + return err + } + + if !isGitRepository { + logging.FromContext(ctx).Warnf("Running project ci outside a CI environment; this command removes source files and should usually only be used in CI") + return nil + } + + if dirty { + return fmt.Errorf("project ci removes source files and creates build stubs; refusing to run outside CI with a dirty git working tree. Commit, stash, or clean local changes, or pass --force if you intentionally want to run it") + } + + logging.FromContext(ctx).Warnf("Running project ci outside a CI environment; this command removes source files and should usually only be used in CI") + + return nil +} + +func isCIEnvironment(getenv func(string) string) bool { + if strings.EqualFold(getenv("CI"), "true") { + return true + } + + ciEnvVars := []string{ + "GITHUB_ACTIONS", + "GITLAB_CI", + "JENKINS_URL", + "BUILDKITE", + "CIRCLECI", + "DRONE", + "TEAMCITY_VERSION", + "TF_BUILD", + } + + for _, envVar := range ciEnvVars { + if getenv(envVar) != "" { + return true + } + } + + return false +} + +func isGitWorkingTreeDirty(ctx context.Context, root string) (bool, bool, error) { + cmd := exec.CommandContext(ctx, "git", "-C", root, "rev-parse", "--is-inside-work-tree") //nolint:gosec + output, err := cmd.Output() + if err != nil { + return false, false, nil + } + + if strings.TrimSpace(string(output)) != "true" { + return false, false, nil + } + + statusCmd := exec.CommandContext(ctx, "git", "-C", root, "status", "--porcelain", "--untracked-files=all") //nolint:gosec + status, err := statusCmd.Output() + if err != nil { + return false, true, fmt.Errorf("checking git working tree status: %w", err) + } + + return strings.TrimSpace(string(status)) != "", true, nil } func commandWithRoot(cmd *exec.Cmd, root string) *exec.Cmd { diff --git a/cmd/project/ci_test.go b/cmd/project/ci_test.go new file mode 100644 index 00000000..fc396ef8 --- /dev/null +++ b/cmd/project/ci_test.go @@ -0,0 +1,100 @@ +package project + +import ( + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestProjectCISafetyCheck(t *testing.T) { + t.Run("allows dirty git working tree in CI", func(t *testing.T) { + root := newDirtyGitRepository(t) + + err := projectCISafetyCheck(t.Context(), root, false, mapGetenv(map[string]string{"CI": "true"})) + + assert.NoError(t, err) + }) + + t.Run("allows dirty git working tree with force", func(t *testing.T) { + root := newDirtyGitRepository(t) + + err := projectCISafetyCheck(t.Context(), root, true, mapGetenv(nil)) + + assert.NoError(t, err) + }) + + t.Run("rejects dirty git working tree outside CI without force", func(t *testing.T) { + root := newDirtyGitRepository(t) + + err := projectCISafetyCheck(t.Context(), root, false, mapGetenv(nil)) + + require.Error(t, err) + assert.Contains(t, err.Error(), "project ci removes source files") + assert.Contains(t, err.Error(), "--force") + }) + + t.Run("allows clean git working tree outside CI without force", func(t *testing.T) { + root := newCleanGitRepository(t) + + err := projectCISafetyCheck(t.Context(), root, false, mapGetenv(nil)) + + assert.NoError(t, err) + }) +} + +func TestIsCIEnvironment(t *testing.T) { + tests := []struct { + name string + env map[string]string + want bool + }{ + {name: "CI true", env: map[string]string{"CI": "true"}, want: true}, + {name: "GitHub Actions", env: map[string]string{"GITHUB_ACTIONS": "true"}, want: true}, + {name: "GitLab CI", env: map[string]string{"GITLAB_CI": "true"}, want: true}, + {name: "Jenkins", env: map[string]string{"JENKINS_URL": "https://jenkins.example"}, want: true}, + {name: "no CI", env: nil, want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, isCIEnvironment(mapGetenv(tt.env))) + }) + } +} + +func newDirtyGitRepository(t *testing.T) string { + t.Helper() + + root := newCleanGitRepository(t) + require.NoError(t, os.WriteFile(filepath.Join(root, "untracked.txt"), []byte("local work"), 0o644)) + + return root +} + +func newCleanGitRepository(t *testing.T) string { + t.Helper() + + root := t.TempDir() + runGit(t, root, "init") + + return root +} + +func runGit(t *testing.T, dir string, args ...string) { + t.Helper() + + cmd := exec.Command("git", args...) + cmd.Dir = dir + output, err := cmd.CombinedOutput() + require.NoErrorf(t, err, "git %v failed: %s", args, output) +} + +func mapGetenv(env map[string]string) func(string) string { + return func(key string) string { + return env[key] + } +} From 6625f2af057466a177ab5de8aa19efd04400b05c Mon Sep 17 00:00:00 2001 From: Roboshyim Date: Mon, 1 Jun 2026 16:34:06 +0000 Subject: [PATCH 36/38] fix(project): address ci safety lint issues --- cmd/project/ci.go | 8 +++++++- cmd/project/ci_test.go | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/cmd/project/ci.go b/cmd/project/ci.go index 2bf9a78a..de65e55e 100644 --- a/cmd/project/ci.go +++ b/cmd/project/ci.go @@ -3,6 +3,7 @@ package project import ( "context" "encoding/json" + "errors" "fmt" "os" "os/exec" @@ -423,7 +424,12 @@ func isGitWorkingTreeDirty(ctx context.Context, root string) (bool, bool, error) cmd := exec.CommandContext(ctx, "git", "-C", root, "rev-parse", "--is-inside-work-tree") //nolint:gosec output, err := cmd.Output() if err != nil { - return false, false, nil + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + return false, false, nil + } + + return false, false, fmt.Errorf("checking git repository: %w", err) } if strings.TrimSpace(string(output)) != "true" { diff --git a/cmd/project/ci_test.go b/cmd/project/ci_test.go index fc396ef8..5bb2b8c8 100644 --- a/cmd/project/ci_test.go +++ b/cmd/project/ci_test.go @@ -87,7 +87,7 @@ func newCleanGitRepository(t *testing.T) string { func runGit(t *testing.T, dir string, args ...string) { t.Helper() - cmd := exec.Command("git", args...) + cmd := exec.CommandContext(t.Context(), "git", args...) cmd.Dir = dir output, err := cmd.CombinedOutput() require.NoErrorf(t, err, "git %v failed: %s", args, output) From 7e3a9337bdc071dcdce8d6b52d901913918935ba Mon Sep 17 00:00:00 2001 From: Roboshyim Date: Mon, 1 Jun 2026 16:40:17 +0000 Subject: [PATCH 37/38] refactor(project): move ci safety helpers to shared packages --- cmd/project/ci.go | 57 +++----------------------------------- cmd/project/ci_test.go | 20 ------------- internal/git/git.go | 26 +++++++++++++++++ internal/git/git_test.go | 33 ++++++++++++++++++++++ internal/system/ci.go | 28 +++++++++++++++++++ internal/system/ci_test.go | 33 ++++++++++++++++++++++ 6 files changed, 124 insertions(+), 73 deletions(-) create mode 100644 internal/system/ci.go create mode 100644 internal/system/ci_test.go diff --git a/cmd/project/ci.go b/cmd/project/ci.go index de65e55e..4e07edea 100644 --- a/cmd/project/ci.go +++ b/cmd/project/ci.go @@ -3,7 +3,6 @@ package project import ( "context" "encoding/json" - "errors" "fmt" "os" "os/exec" @@ -15,10 +14,12 @@ import ( "github.com/shopware/shopware-cli/internal/ci" "github.com/shopware/shopware-cli/internal/extension" + internalgit "github.com/shopware/shopware-cli/internal/git" "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/internal/system" "github.com/shopware/shopware-cli/logging" ) @@ -372,11 +373,11 @@ func init() { } func projectCISafetyCheck(ctx context.Context, root string, force bool, getenv func(string) string) error { - if force || isCIEnvironment(getenv) { + if force || system.IsCIEnvironment(getenv) { return nil } - dirty, isGitRepository, err := isGitWorkingTreeDirty(ctx, root) + dirty, isGitRepository, err := internalgit.IsWorkingTreeDirty(ctx, root) if err != nil { return err } @@ -395,56 +396,6 @@ func projectCISafetyCheck(ctx context.Context, root string, force bool, getenv f return nil } -func isCIEnvironment(getenv func(string) string) bool { - if strings.EqualFold(getenv("CI"), "true") { - return true - } - - ciEnvVars := []string{ - "GITHUB_ACTIONS", - "GITLAB_CI", - "JENKINS_URL", - "BUILDKITE", - "CIRCLECI", - "DRONE", - "TEAMCITY_VERSION", - "TF_BUILD", - } - - for _, envVar := range ciEnvVars { - if getenv(envVar) != "" { - return true - } - } - - return false -} - -func isGitWorkingTreeDirty(ctx context.Context, root string) (bool, bool, error) { - cmd := exec.CommandContext(ctx, "git", "-C", root, "rev-parse", "--is-inside-work-tree") //nolint:gosec - output, err := cmd.Output() - if err != nil { - var exitErr *exec.ExitError - if errors.As(err, &exitErr) { - return false, false, nil - } - - return false, false, fmt.Errorf("checking git repository: %w", err) - } - - if strings.TrimSpace(string(output)) != "true" { - return false, false, nil - } - - statusCmd := exec.CommandContext(ctx, "git", "-C", root, "status", "--porcelain", "--untracked-files=all") //nolint:gosec - status, err := statusCmd.Output() - if err != nil { - return false, true, fmt.Errorf("checking git working tree status: %w", err) - } - - return strings.TrimSpace(string(status)) != "", true, nil -} - func commandWithRoot(cmd *exec.Cmd, root string) *exec.Cmd { cmd.Dir = root diff --git a/cmd/project/ci_test.go b/cmd/project/ci_test.go index 5bb2b8c8..bd193baa 100644 --- a/cmd/project/ci_test.go +++ b/cmd/project/ci_test.go @@ -46,26 +46,6 @@ func TestProjectCISafetyCheck(t *testing.T) { }) } -func TestIsCIEnvironment(t *testing.T) { - tests := []struct { - name string - env map[string]string - want bool - }{ - {name: "CI true", env: map[string]string{"CI": "true"}, want: true}, - {name: "GitHub Actions", env: map[string]string{"GITHUB_ACTIONS": "true"}, want: true}, - {name: "GitLab CI", env: map[string]string{"GITLAB_CI": "true"}, want: true}, - {name: "Jenkins", env: map[string]string{"JENKINS_URL": "https://jenkins.example"}, want: true}, - {name: "no CI", env: nil, want: false}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, tt.want, isCIEnvironment(mapGetenv(tt.env))) - }) - } -} - func newDirtyGitRepository(t *testing.T) string { t.Helper() diff --git a/internal/git/git.go b/internal/git/git.go index d6e96883..798e004e 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -2,6 +2,7 @@ package git import ( "context" + "errors" "fmt" "os" "os/exec" @@ -196,6 +197,31 @@ func unshallowRepository(ctx context.Context, repo string) error { return err } +func IsWorkingTreeDirty(ctx context.Context, repo string) (bool, bool, error) { + cmd := exec.CommandContext(ctx, "git", "-C", repo, "rev-parse", "--is-inside-work-tree") //nolint:gosec + output, err := cmd.Output() + if err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + return false, false, nil + } + + return false, false, fmt.Errorf("checking git repository: %w", err) + } + + if strings.TrimSpace(string(output)) != "true" { + return false, false, nil + } + + statusCmd := exec.CommandContext(ctx, "git", "-C", repo, "status", "--porcelain", "--untracked-files=all") //nolint:gosec + status, err := statusCmd.Output() + if err != nil { + return false, true, fmt.Errorf("checking git working tree status: %w", err) + } + + return strings.TrimSpace(string(status)) != "", true, nil +} + func Init(ctx context.Context, repo string) error { _, err := runGit(ctx, repo, "init") return err diff --git a/internal/git/git_test.go b/internal/git/git_test.go index bfba1e6e..a5779218 100644 --- a/internal/git/git_test.go +++ b/internal/git/git_test.go @@ -94,6 +94,39 @@ func TestGetPublicVCSURL(t *testing.T) { assert.NoError(t, err) } +func TestIsWorkingTreeDirty(t *testing.T) { + t.Run("clean git working tree", func(t *testing.T) { + tmpDir := t.TempDir() + prepareRepository(t, tmpDir) + + dirty, isGitRepository, err := IsWorkingTreeDirty(t.Context(), tmpDir) + + assert.NoError(t, err) + assert.True(t, isGitRepository) + assert.False(t, dirty) + }) + + t.Run("dirty git working tree", func(t *testing.T) { + tmpDir := t.TempDir() + prepareRepository(t, tmpDir) + assert.NoError(t, os.WriteFile(filepath.Join(tmpDir, "untracked.txt"), []byte("local work"), 0o644)) + + dirty, isGitRepository, err := IsWorkingTreeDirty(t.Context(), tmpDir) + + assert.NoError(t, err) + assert.True(t, isGitRepository) + assert.True(t, dirty) + }) + + t.Run("not a git repository", func(t *testing.T) { + dirty, isGitRepository, err := IsWorkingTreeDirty(t.Context(), t.TempDir()) + + assert.NoError(t, err) + assert.False(t, isGitRepository) + assert.False(t, dirty) + }) +} + func runCommand(t *testing.T, tmpDir string, args ...string) { t.Helper() diff --git a/internal/system/ci.go b/internal/system/ci.go new file mode 100644 index 00000000..417457f3 --- /dev/null +++ b/internal/system/ci.go @@ -0,0 +1,28 @@ +package system + +import "strings" + +func IsCIEnvironment(getenv func(string) string) bool { + if strings.EqualFold(getenv("CI"), "true") { + return true + } + + ciEnvVars := []string{ + "GITHUB_ACTIONS", + "GITLAB_CI", + "JENKINS_URL", + "BUILDKITE", + "CIRCLECI", + "DRONE", + "TEAMCITY_VERSION", + "TF_BUILD", + } + + for _, envVar := range ciEnvVars { + if getenv(envVar) != "" { + return true + } + } + + return false +} diff --git a/internal/system/ci_test.go b/internal/system/ci_test.go new file mode 100644 index 00000000..008c12ee --- /dev/null +++ b/internal/system/ci_test.go @@ -0,0 +1,33 @@ +package system + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestIsCIEnvironment(t *testing.T) { + tests := []struct { + name string + env map[string]string + want bool + }{ + {name: "CI true", env: map[string]string{"CI": "true"}, want: true}, + {name: "GitHub Actions", env: map[string]string{"GITHUB_ACTIONS": "true"}, want: true}, + {name: "GitLab CI", env: map[string]string{"GITLAB_CI": "true"}, want: true}, + {name: "Jenkins", env: map[string]string{"JENKINS_URL": "https://jenkins.example"}, want: true}, + {name: "no CI", env: nil, want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, IsCIEnvironment(mapGetenv(tt.env))) + }) + } +} + +func mapGetenv(env map[string]string) func(string) string { + return func(key string) string { + return env[key] + } +} From 5f9d921de07a6b0dd3dca7f4b913ed85e7b22a84 Mon Sep 17 00:00:00 2001 From: Soner Sayakci Date: Tue, 2 Jun 2026 14:08:42 +0200 Subject: [PATCH 38/38] feat(projectupgrade): let composer resolve plugin compatibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the homegrown registry + constraint resolver with composer itself. The upgrade now runs `composer require --no-install -W shopware/core: ` and reads composer's verdict: success means the upgrade resolves, a non-zero exit with "could not be resolved" means it doesn't, and the blocking plugin(s) are dropped and retried. - new composer_resolve.go: DryRunRequire (compat preview, --dry-run, nothing written) and ApplyRequire (real require, drops unresolvable plugins and retries). Store plugins resolve via the project's own composer config / auth.json - no token plumbing. - delete registry.go, compatibility.go and the version-finding half of plugins.go; keep FindNonComposerPlugins and the ResolveResult reporting type. - wizard + command rewired off Registry; compat phase shows composer's own resolution and the plugins it cannot resolve. - drop now-unused packagist.ConstraintsSatisfiedBy / BumpConstraint. --- cmd/project/project_upgrade.go | 108 ++----- internal/packagist/constraints.go | 48 --- internal/packagist/constraints_test.go | 60 ---- internal/projectupgrade/compatibility.go | 120 -------- internal/projectupgrade/compatibility_test.go | 144 --------- internal/projectupgrade/composer_resolve.go | 254 ++++++++++++++++ .../projectupgrade/composer_resolve_test.go | 84 ++++++ internal/projectupgrade/plugins.go | 195 ------------ internal/projectupgrade/plugins_test.go | 278 ------------------ internal/projectupgrade/registry.go | 121 -------- internal/projectupgrade/registry_test.go | 85 ------ internal/projectupgrade/wizard.go | 168 ++++------- internal/projectupgrade/wizard_test.go | 21 +- 13 files changed, 426 insertions(+), 1260 deletions(-) delete mode 100644 internal/packagist/constraints.go delete mode 100644 internal/packagist/constraints_test.go delete mode 100644 internal/projectupgrade/compatibility.go delete mode 100644 internal/projectupgrade/compatibility_test.go create mode 100644 internal/projectupgrade/composer_resolve.go create mode 100644 internal/projectupgrade/composer_resolve_test.go delete mode 100644 internal/projectupgrade/registry.go delete mode 100644 internal/projectupgrade/registry_test.go diff --git a/cmd/project/project_upgrade.go b/cmd/project/project_upgrade.go index c47fceca..07d4146c 100644 --- a/cmd/project/project_upgrade.go +++ b/cmd/project/project_upgrade.go @@ -20,7 +20,6 @@ import ( "github.com/shopware/shopware-cli/internal/extension" "github.com/shopware/shopware-cli/internal/flexmigrator" "github.com/shopware/shopware-cli/internal/git" - "github.com/shopware/shopware-cli/internal/packagist" "github.com/shopware/shopware-cli/internal/projectupgrade" "github.com/shopware/shopware-cli/internal/system" "github.com/shopware/shopware-cli/internal/tracking" @@ -93,18 +92,12 @@ project doesn't require it yet.`, } // Interactive: hand off to the devtui-styled wizard. - registry, err := buildRegistry(cmd, projectRoot) - if err != nil { - return err - } - result, err := projectupgrade.RunWizard(projectupgrade.WizardOptions{ ProjectRoot: projectRoot, ComposerJSONPath: composerJsonPath, CurrentVersion: currentVersion, UpdateVersions: updateVersions, Executor: cmdExecutor, - Registry: registry, }) status := "ok" @@ -148,12 +141,7 @@ func runUpgradeHeadless(cmd *cobra.Command, projectRoot, composerJsonPath string return err } - registry, err := buildRegistry(cmd, projectRoot) - if err != nil { - return err - } - - if err := runCompatibilityCheck(ctx, composerJsonPath, targetVersion, registry); err != nil { + if err := runCompatibilityCheck(ctx, cmdExecutor, composerJsonPath, targetVersion); err != nil { return err } @@ -177,16 +165,13 @@ func runUpgradeHeadless(cmd *cobra.Command, projectRoot, composerJsonPath string return fmt.Errorf("cleanup stale files: %w", err) } - log.Infof("Checking custom plugins for incompatibilities") - result, err := projectupgrade.ResolveIncompatiblePlugins(ctx, composerJsonPath, targetVersion, registry) + log.Infof("Resolving plugins with composer for %s", targetVersion) + result, err := projectupgrade.ApplyRequire(ctx, cmdExecutor, composerJsonPath, targetVersion) if err != nil { return fmt.Errorf("resolve incompatible plugins: %w", err) } if result != nil { - for _, action := range result.Bumped() { - log.Infof("Bumped %s: %s → %s", tui.YellowText.Render(action.Name), action.OldConstraint, action.NewConstraint) - } for _, action := range result.Removed() { log.Infof("Removed incompatible plugin %s (%s). Re-require it once a compatible version is published.", tui.YellowText.Render(action.Name), action.Reason) } @@ -267,38 +252,35 @@ func selectTargetVersion(cmd *cobra.Command, updateVersions []string) (string, e return selected, nil } -func runCompatibilityCheck(ctx context.Context, composerJsonPath, targetVersion string, registry projectupgrade.Registry) error { +func runCompatibilityCheck(ctx context.Context, cmdExecutor executor.Executor, composerJsonPath, targetVersion string) error { log := logging.FromContext(ctx) - results, err := projectupgrade.CheckPluginCompatibility(ctx, composerJsonPath, targetVersion, registry) + report, err := projectupgrade.DryRunRequire(ctx, cmdExecutor, composerJsonPath, targetVersion) if err != nil { log.Warnf("Skipping plugin compatibility check: %v", err) return nil } - if len(results) == 0 { + if report.OK { + log.Infof("composer can resolve the upgrade to %s", targetVersion) return nil } - t := table.New().Border(lipgloss.NormalBorder()).Headers("Plugin", "Current", "Status") - hasBlockers := false - for _, row := range results { - status := row.Status.Label() - if row.Status == projectupgrade.CompatUpdatable && row.NewVersion != "" { - status = fmt.Sprintf("%s → %s", row.Status.Label(), row.NewVersion) - } - t.Row(row.Name, row.CurrentVersion, status) - if row.Status.IsBlocker() { - hasBlockers = true + if len(report.BlockingPlugins) > 0 { + t := table.New().Border(lipgloss.NormalBorder()).Headers("Plugin", "Status") + for _, name := range report.BlockingPlugins { + t.Row(name, "no compatible release") } + fmt.Println(t.Render()) + } else { + fmt.Println(strings.Join(report.Output, "\n")) } - fmt.Println(t.Render()) - if hasBlockers && system.IsInteractionEnabled(ctx) { + if system.IsInteractionEnabled(ctx) { var proceed bool if err := huh.NewConfirm(). - Title("Some plugins have no compatible version for the target version"). - Description("They will be removed from composer.json so the upgrade can proceed. Re-require them once they publish a compatible release. Proceed anyway?"). + Title("composer could not resolve the upgrade with all plugins"). + Description("Incompatible plugins will be removed from composer.json so the upgrade can proceed. Re-require them once they publish a compatible release. Proceed anyway?"). Value(&proceed). Run(); err != nil { return err @@ -373,62 +355,6 @@ func ensureAllPluginsAreComposerManaged(projectRoot string, allow bool) error { ) } -// buildRegistry constructs the package registry used to look up newer -// compatible plugin versions. The Shopware Packages token is read from the -// SHOPWARE_PACKAGES_TOKEN env var, the project's auth.json, or — in -// interactive mode — prompted from the user if the project has store -// plugins. Missing tokens degrade gracefully: store lookups fall back to the -// "remove plugin" behaviour. -func buildRegistry(cmd *cobra.Command, projectRoot string) (projectupgrade.Registry, error) { - token := storeTokenFromAuthJSON(projectRoot) - - hasStorePlugins, err := projectHasStorePlugins(projectRoot) - if err != nil { - logging.FromContext(cmd.Context()).Debugf("could not inspect installed.json: %v", err) - } - - if token == "" && hasStorePlugins && system.IsInteractionEnabled(cmd.Context()) { - var entered string - if err := huh.NewInput(). - Title("Shopware Packages token (packages.shopware.com)"). - Description("Used to look up newer compatible versions of store plugins. Leave empty to skip store lookups."). - Value(&entered). - EchoMode(huh.EchoModePassword). - Run(); err != nil { - return nil, err - } - token = strings.TrimSpace(entered) - } - - return projectupgrade.DefaultRegistry(token), nil -} - -func storeTokenFromAuthJSON(projectRoot string) string { - if v := strings.TrimSpace(os.Getenv("SHOPWARE_PACKAGES_TOKEN")); v != "" { - return v - } - - authPath := path.Join(projectRoot, "auth.json") - auth, err := packagist.ReadComposerAuth(authPath) - if err != nil { - return "" - } - return strings.TrimSpace(auth.BearerAuth["packages.shopware.com"]) -} - -func projectHasStorePlugins(projectRoot string) (bool, error) { - composerJson, err := packagist.ReadComposerJson(path.Join(projectRoot, "composer.json")) - if err != nil { - return false, err - } - for name := range composerJson.Require { - if strings.HasPrefix(name, "store.shopware.com/") { - return true, nil - } - } - return false, nil -} - func init() { projectRootCmd.AddCommand(projectUpgradeCmd) projectUpgradeCmd.Flags().String("to", "", "Target Shopware version. Skips the interactive wizard.") diff --git a/internal/packagist/constraints.go b/internal/packagist/constraints.go deleted file mode 100644 index bc2c3d73..00000000 --- a/internal/packagist/constraints.go +++ /dev/null @@ -1,48 +0,0 @@ -package packagist - -import ( - "strings" - - "github.com/shyim/go-version" -) - -// ConstraintsSatisfiedBy reports whether every constraint that requires -// declares for a package named in packages is satisfied by target. -// Constraints for packages not listed in packages are ignored, and packages -// that declare no constraint are treated as satisfied. An unparseable -// constraint is treated as not satisfied. -func ConstraintsSatisfiedBy(requires map[string]string, packages []string, target *version.Version) bool { - for _, name := range packages { - constraint, ok := requires[name] - if !ok { - continue - } - - c, err := version.NewConstraint(constraint) - if err != nil { - return false - } - - if !c.Check(target) { - return false - } - } - - return true -} - -// BumpConstraint turns a concrete version (e.g. "2.3.4") into a caret -// constraint ("^2.3.4") suitable for a composer.json require entry. Values -// that already look like a constraint (containing range/wildcard operators) -// are returned unchanged. -func BumpConstraint(version string) string { - if version == "" { - return version - } - - if strings.ContainsAny(version, "^~><*|, ") { - return version - } - - return "^" + version -} diff --git a/internal/packagist/constraints_test.go b/internal/packagist/constraints_test.go deleted file mode 100644 index c71e53d2..00000000 --- a/internal/packagist/constraints_test.go +++ /dev/null @@ -1,60 +0,0 @@ -package packagist - -import ( - "testing" - - "github.com/shyim/go-version" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestConstraintsSatisfiedBy(t *testing.T) { - t.Parallel() - - target, err := version.NewVersion("6.6.4.0") - require.NoError(t, err) - - packages := []string{"shopware/core", "shopware/storefront"} - - tests := []struct { - name string - requires map[string]string - want bool - }{ - {"no requires", nil, true}, - {"unrelated package ignored", map[string]string{"symfony/console": "^6.0"}, true}, - {"satisfied", map[string]string{"shopware/core": "^6.6"}, true}, - {"not satisfied", map[string]string{"shopware/core": "~6.5.0"}, false}, - {"one of many not satisfied", map[string]string{"shopware/core": "^6.6", "shopware/storefront": "~6.5.0"}, false}, - {"unparseable treated as unsatisfied", map[string]string{"shopware/core": "not-a-constraint"}, false}, - } - - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - assert.Equal(t, tt.want, ConstraintsSatisfiedBy(tt.requires, packages, target)) - }) - } -} - -func TestBumpConstraint(t *testing.T) { - t.Parallel() - - tests := []struct { - in string - want string - }{ - {"2.3.4", "^2.3.4"}, - {"1.0.0", "^1.0.0"}, - {"^2.0", "^2.0"}, - {"~1.2", "~1.2"}, - {">=3.0", ">=3.0"}, - {"1.0 | 2.0", "1.0 | 2.0"}, - {"", ""}, - } - - for _, tt := range tests { - assert.Equal(t, tt.want, BumpConstraint(tt.in), "BumpConstraint(%q)", tt.in) - } -} diff --git a/internal/projectupgrade/compatibility.go b/internal/projectupgrade/compatibility.go deleted file mode 100644 index 98e9c998..00000000 --- a/internal/projectupgrade/compatibility.go +++ /dev/null @@ -1,120 +0,0 @@ -package projectupgrade - -import ( - "context" - "fmt" - "path/filepath" - "strings" - - "github.com/shyim/go-version" - - "github.com/shopware/shopware-cli/internal/packagist" -) - -// CompatStatus describes the upgrade plan for one composer-managed plugin. -type CompatStatus int - -const ( - // CompatCompatible: the installed version already satisfies the target. - CompatCompatible CompatStatus = iota - // CompatUpdatable: a newer compatible release exists; the resolver will - // bump the constraint. - CompatUpdatable - // CompatBlocker: no compatible release is published; the resolver will - // remove the plugin from composer.json so the upgrade can proceed. - CompatBlocker - // CompatUnknown: the registry could not be consulted (e.g. no token for a - // store plugin); the resolver will drop the plugin too, but the user may - // want to retry with credentials. - CompatUnknown -) - -// IsBlocker reports whether this status means the upgrade will drop the plugin. -func (s CompatStatus) IsBlocker() bool { return s == CompatBlocker || s == CompatUnknown } - -// IsUpdatable reports whether this status means the constraint will be bumped. -func (s CompatStatus) IsUpdatable() bool { return s == CompatUpdatable } - -// Label returns a short human-readable label. -func (s CompatStatus) Label() string { - switch s { - case CompatCompatible: - return "Compatible" - case CompatUpdatable: - return "Update available" - case CompatBlocker: - return "No compatible release" - case CompatUnknown: - return "Not in registry" - } - return "" -} - -// PluginCompat is one row of the compatibility preview. -type PluginCompat struct { - // Name is the composer package name (e.g. "swag/paypal"). - Name string - // CurrentVersion is the version recorded in installed.json. - CurrentVersion string - // NewVersion is the version the resolver would bump to. Populated when - // Status is CompatUpdatable. - NewVersion string - // Status classifies how the resolver will treat this plugin. - Status CompatStatus -} - -// CheckPluginCompatibility consults the registry for every composer-managed -// shopware platform plugin and reports how the upgrade will treat it. The -// composer.json is not modified; this is a dry-run of -// ResolveIncompatiblePlugins so callers can preview the plan before applying -// it. -func CheckPluginCompatibility(ctx context.Context, composerJsonPath, targetVersion string, registry Registry) ([]PluginCompat, error) { - projectDir := filepath.Dir(composerJsonPath) - - installed, err := packagist.ReadInstalledJson(projectDir) - if err != nil { - return nil, err - } - - composerJson, err := packagist.ReadComposerJson(composerJsonPath) - if err != nil { - return nil, err - } - - target, err := version.NewVersion(strings.TrimPrefix(targetVersion, "v")) - if err != nil { - return nil, fmt.Errorf("parse target version: %w", err) - } - - results := make([]PluginCompat, 0) - for _, pkg := range installed.Packages { - if pkg.Type != composerPluginType { - continue - } - if _, ok := composerJson.Require[pkg.Name]; !ok { - continue - } - - row := PluginCompat{Name: pkg.Name, CurrentVersion: strings.TrimPrefix(pkg.Version, "v")} - - if packagist.ConstraintsSatisfiedBy(pkg.Require, ShopwarePackages, target) { - row.Status = CompatCompatible - results = append(results, row) - continue - } - - newVersion, lookupErr := findCompatibleVersion(ctx, registry, pkg.Name, target) - switch { - case newVersion != "": - row.Status = CompatUpdatable - row.NewVersion = newVersion - case lookupErr != nil: - row.Status = CompatUnknown - default: - row.Status = CompatBlocker - } - results = append(results, row) - } - - return results, nil -} diff --git a/internal/projectupgrade/compatibility_test.go b/internal/projectupgrade/compatibility_test.go deleted file mode 100644 index 9ee035f9..00000000 --- a/internal/projectupgrade/compatibility_test.go +++ /dev/null @@ -1,144 +0,0 @@ -package projectupgrade - -import ( - "path/filepath" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/shopware/shopware-cli/internal/packagist" -) - -func TestCheckPluginCompatibilityClassifiesEachPlugin(t *testing.T) { - t.Parallel() - - dir := t.TempDir() - composerJsonPath := filepath.Join(dir, "composer.json") - - writeJSON(t, composerJsonPath, map[string]any{ - "name": "shopware/production", - "require": map[string]any{ - "shopware/core": "6.5.8.0", - "vendor/compat": "^1.0", - "vendor/updates": "^1.0", - "vendor/blocker": "^1.0", - "transitive/skip": "^1.0", // not in installed.json -> ignored - }, - }) - - writeInstalledJSON(t, dir, []packagist.InstalledPackage{ - { - Name: "vendor/compat", - Type: composerPluginType, - Version: "1.2.0", - InstallPath: "../../custom/plugins/Compat", - Require: map[string]string{"shopware/core": "^6.5 | ^6.6"}, - }, - { - Name: "vendor/updates", - Type: composerPluginType, - Version: "1.0.0", - InstallPath: "../../custom/plugins/Updates", - Require: map[string]string{"shopware/core": "~6.5.0"}, - }, - { - Name: "vendor/blocker", - Type: composerPluginType, - Version: "1.0.0", - InstallPath: "../../custom/plugins/Blocker", - Require: map[string]string{"shopware/core": "~6.5.0"}, - }, - }) - - registry := &fakeRegistry{ - versions: map[string][]packagist.ComposerPackageVersion{ - "vendor/updates": { - {Version: "2.0.0", Require: map[string]string{"shopware/core": "^6.6"}}, - }, - "vendor/blocker": { - {Version: "1.1.0", Require: map[string]string{"shopware/core": "~6.5.0"}}, - }, - }, - } - - results, err := CheckPluginCompatibility(t.Context(), composerJsonPath, "6.6.4.0", registry) - require.NoError(t, err) - require.Len(t, results, 3, "transitive/skip must be ignored - it is not in installed.json") - - byName := map[string]PluginCompat{} - for _, r := range results { - byName[r.Name] = r - } - - assert.Equal(t, CompatCompatible, byName["vendor/compat"].Status) - assert.Equal(t, "1.2.0", byName["vendor/compat"].CurrentVersion) - - assert.Equal(t, CompatUpdatable, byName["vendor/updates"].Status) - assert.Equal(t, "1.0.0", byName["vendor/updates"].CurrentVersion) - assert.Equal(t, "2.0.0", byName["vendor/updates"].NewVersion) - - assert.Equal(t, CompatBlocker, byName["vendor/blocker"].Status) - assert.Equal(t, "1.0.0", byName["vendor/blocker"].CurrentVersion) -} - -func TestCheckPluginCompatibilityReportsUnknownOnRegistryError(t *testing.T) { - t.Parallel() - - dir := t.TempDir() - composerJsonPath := filepath.Join(dir, "composer.json") - - writeJSON(t, composerJsonPath, map[string]any{ - "name": "shopware/production", - "require": map[string]any{ - "shopware/core": "6.5.8.0", - "store/mysteryx": "^1.0", - }, - }) - - writeInstalledJSON(t, dir, []packagist.InstalledPackage{ - { - Name: "store/mysteryx", - Type: composerPluginType, - Version: "1.0.0", - InstallPath: "../store/mysteryx", - Require: map[string]string{"shopware/core": "~6.5.0"}, - }, - }) - - registry := &fakeRegistry{err: assertErr("no token configured")} - - results, err := CheckPluginCompatibility(t.Context(), composerJsonPath, "6.6.4.0", registry) - require.NoError(t, err) - require.Len(t, results, 1) - assert.Equal(t, CompatUnknown, results[0].Status, "registry errors surface as 'unknown' so the user can retry with credentials") - assert.True(t, results[0].Status.IsBlocker(), "unknown counts as a blocker - the resolver will drop the plugin") -} - -func TestCheckPluginCompatibilityIgnoresNonPlatformPlugins(t *testing.T) { - t.Parallel() - - dir := t.TempDir() - composerJsonPath := filepath.Join(dir, "composer.json") - - writeJSON(t, composerJsonPath, map[string]any{ - "name": "shopware/production", - "require": map[string]any{ - "shopware/core": "6.5.8.0", - "symfony/console": "^6.0", - }, - }) - - writeInstalledJSON(t, dir, []packagist.InstalledPackage{ - { - Name: "symfony/console", - Type: "library", // not a shopware-platform-plugin - Version: "6.4.0", - InstallPath: "../symfony/console", - }, - }) - - results, err := CheckPluginCompatibility(t.Context(), composerJsonPath, "6.6.4.0", nil) - require.NoError(t, err) - assert.Empty(t, results, "non-platform-plugin libraries are not part of the upgrade plan") -} diff --git a/internal/projectupgrade/composer_resolve.go b/internal/projectupgrade/composer_resolve.go new file mode 100644 index 00000000..7b58483f --- /dev/null +++ b/internal/projectupgrade/composer_resolve.go @@ -0,0 +1,254 @@ +package projectupgrade + +import ( + "context" + "fmt" + "path/filepath" + "sort" + "strings" + + "github.com/shopware/shopware-cli/internal/executor" + "github.com/shopware/shopware-cli/internal/packagist" +) + +// composerPluginType is the composer "type" used by Shopware platform plugins. +const composerPluginType = "shopware-platform-plugin" + +// PluginAction describes how the resolver dealt with one plugin that blocked +// the upgrade. +type PluginAction struct { + // Name is the composer package name (e.g. "swag/paypal"). + Name string + // Removed is true when the plugin was dropped from composer.json because + // composer could not resolve it against the target version. + Removed bool + // Reason is a short human-readable explanation surfaced in the UI. + Reason string +} + +// ResolveResult summarises the actions the resolver took. +type ResolveResult struct { + Actions []PluginAction +} + +// Removed returns the actions that resulted in the plugin being dropped. +func (r *ResolveResult) Removed() []PluginAction { + out := make([]PluginAction, 0, len(r.Actions)) + for _, a := range r.Actions { + if a.Removed { + out = append(out, a) + } + } + return out +} + +// CompatReport is the outcome of a dry-run `composer require` against the +// target version. It is composer's own verdict, not a homegrown constraint +// check. +type CompatReport struct { + // OK is true when composer could resolve the requested upgrade. + OK bool + // Output is the captured composer output. On failure it contains the + // "Your requirements could not be resolved" block. + Output []string + // BlockingPlugins are the plugin names from the upgrade set that appear in + // composer's conflict output. Best-effort; may be empty when composer's + // message cannot be attributed to a specific plugin. + BlockingPlugins []string +} + +// requirePackages enumerates the composer require arguments for the upgrade: +// the first-party Shopware packages present in composer.json pinned to the +// target version, plus every required shopware-platform-plugin (passed without +// a constraint so composer picks the newest release compatible with the pinned +// core). +func requirePackages(composerJsonPath, targetVersion string) ([]string, []string, error) { + composerJson, err := packagist.ReadComposerJson(composerJsonPath) + if err != nil { + return nil, nil, err + } + + args := make([]string, 0, len(ShopwarePackages)) + for _, pkg := range ShopwarePackages { + if _, ok := composerJson.Require[pkg]; ok { + args = append(args, pkg+":"+targetVersion) + } + } + + plugins, err := requiredPlugins(filepath.Dir(composerJsonPath), composerJson) + if err != nil { + return nil, nil, err + } + + args = append(args, plugins...) + return args, plugins, nil +} + +// requiredPlugins returns the composer package names of every required +// shopware-platform-plugin, sorted for stable output. +func requiredPlugins(projectDir string, composerJson *packagist.ComposerJson) ([]string, error) { + installed, err := packagist.ReadInstalledJson(projectDir) + if err != nil { + return nil, err + } + + plugins := make([]string, 0) + for _, pkg := range installed.Packages { + if pkg.Type != composerPluginType { + continue + } + if _, ok := composerJson.Require[pkg.Name]; !ok { + continue + } + plugins = append(plugins, pkg.Name) + } + + sort.Strings(plugins) + return plugins, nil +} + +// composerRequire runs `composer require --no-install -W ` (optionally a +// dry run) through the executor and returns the combined output. The error is +// the process exit error, which composer returns non-zero on an unresolvable +// requirement. +func composerRequire(ctx context.Context, exec executor.Executor, dryRun bool, pkgs []string) ([]string, error) { + args := []string{ + "require", + "--no-interaction", + "--no-install", + "--no-scripts", + "--update-with-all-dependencies", + } + if dryRun { + args = append(args, "--dry-run") + } + args = append(args, pkgs...) + + out, err := exec.ComposerCommand(ctx, args...).CombinedOutput() + return splitLines(out), err +} + +// DryRunRequire asks composer whether the project can be upgraded to +// targetVersion without changing anything on disk. It runs +// `composer require --no-install --dry-run -W shopware/core: ` +// and reports composer's verdict. +func DryRunRequire(ctx context.Context, exec executor.Executor, composerJsonPath, targetVersion string) (CompatReport, error) { + pkgs, plugins, err := requirePackages(composerJsonPath, targetVersion) + if err != nil { + return CompatReport{}, err + } + + output, runErr := composerRequire(ctx, exec, true, pkgs) + if runErr == nil { + return CompatReport{OK: true, Output: output}, nil + } + + return CompatReport{ + OK: false, + Output: output, + BlockingPlugins: blockingPlugins(output, plugins), + }, nil +} + +// ApplyRequire performs the real upgrade resolution: it runs +// `composer require --no-install -W shopware/core: `, letting +// composer rewrite composer.json/composer.lock with the bumped constraints. +// When composer cannot resolve the set, the plugin(s) it names are removed from +// composer.json and the require is retried until it resolves or no plugin can +// be attributed to the failure. +func ApplyRequire(ctx context.Context, exec executor.Executor, composerJsonPath, targetVersion string) (*ResolveResult, error) { + pkgs, plugins, err := requirePackages(composerJsonPath, targetVersion) + if err != nil { + return nil, err + } + + result := &ResolveResult{} + dropped := make(map[string]struct{}) + + // Bounded by the number of plugins: each failed attempt drops at least one. + for attempt := 0; attempt <= len(plugins); attempt++ { + output, runErr := composerRequire(ctx, exec, false, pkgs) + if runErr == nil { + return result, nil + } + + blockers := blockingPlugins(output, plugins) + newlyDropped := false + for _, name := range blockers { + if _, ok := dropped[name]; ok { + continue + } + if err := removePluginFromComposer(composerJsonPath, name); err != nil { + return nil, err + } + dropped[name] = struct{}{} + result.Actions = append(result.Actions, PluginAction{ + Name: name, + Removed: true, + Reason: "composer could not resolve it for " + targetVersion, + }) + newlyDropped = true + } + + if !newlyDropped { + // Composer failed but the conflict is not attributable to a plugin + // we can drop (e.g. a platform requirement). Surface the output. + return result, fmt.Errorf("composer could not resolve the upgrade:\n%s", strings.Join(output, "\n")) + } + + pkgs = filterPackages(pkgs, dropped) + } + + return result, fmt.Errorf("composer could not resolve the upgrade after dropping all incompatible plugins") +} + +// removePluginFromComposer drops a plugin from the root composer.json require +// block so composer can resolve the remaining set. +func removePluginFromComposer(composerJsonPath, name string) error { + composerJson, err := packagist.ReadComposerJson(composerJsonPath) + if err != nil { + return err + } + if _, ok := composerJson.Require[name]; !ok { + return nil + } + delete(composerJson.Require, name) + return composerJson.Save() +} + +// filterPackages returns pkgs without the require arguments for any dropped +// plugin. Plugin args are bare names; core args carry a ":constraint" suffix +// and are never dropped. +func filterPackages(pkgs []string, dropped map[string]struct{}) []string { + out := make([]string, 0, len(pkgs)) + for _, p := range pkgs { + if _, ok := dropped[p]; ok { + continue + } + out = append(out, p) + } + return out +} + +// blockingPlugins returns the plugin names that appear anywhere in composer's +// output. Composer names the conflicting packages in its "could not be +// resolved" block; we only attribute failures to plugins we asked for so the +// resolver never drops first-party packages. +func blockingPlugins(output, plugins []string) []string { + joined := strings.Join(output, "\n") + blockers := make([]string, 0) + for _, name := range plugins { + if strings.Contains(joined, name) { + blockers = append(blockers, name) + } + } + return blockers +} + +func splitLines(out []byte) []string { + trimmed := strings.TrimRight(string(out), "\n") + if trimmed == "" { + return nil + } + return strings.Split(trimmed, "\n") +} diff --git a/internal/projectupgrade/composer_resolve_test.go b/internal/projectupgrade/composer_resolve_test.go new file mode 100644 index 00000000..6c3dec91 --- /dev/null +++ b/internal/projectupgrade/composer_resolve_test.go @@ -0,0 +1,84 @@ +package projectupgrade + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/shopware/shopware-cli/internal/packagist" +) + +func TestBlockingPluginsAttributesOnlyRequestedPlugins(t *testing.T) { + t.Parallel() + + output := []string{ + "Loading composer repositories with package information", + "Your requirements could not be resolved to an installable set of packages.", + " Problem 1", + " - swag/paypal 9.0.0 requires shopware/core ^6.7 -> found shopware/core[6.6.4.0]", + " - root composer.json requires swag/paypal ^9.0", + } + + // shopware/core appears in the output too, but it is not a plugin we asked + // for, so it must never be attributed as a blocker. + blockers := blockingPlugins(output, []string{"swag/paypal", "frosh/tools"}) + + assert.Equal(t, []string{"swag/paypal"}, blockers) +} + +func TestBlockingPluginsEmptyWhenNoPluginNamed(t *testing.T) { + t.Parallel() + + output := []string{"Your requirements could not be resolved", "ext-foo is missing"} + assert.Empty(t, blockingPlugins(output, []string{"swag/paypal"})) +} + +func TestFilterPackagesDropsOnlyDroppedNames(t *testing.T) { + t.Parallel() + + pkgs := []string{"shopware/core:6.6.4.0", "swag/paypal", "frosh/tools"} + dropped := map[string]struct{}{"swag/paypal": {}} + + assert.Equal(t, []string{"shopware/core:6.6.4.0", "frosh/tools"}, filterPackages(pkgs, dropped)) +} + +func TestRequirePackagesPinsCoreAndListsPlugins(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + composerJsonPath := filepath.Join(dir, "composer.json") + + writeJSON(t, composerJsonPath, map[string]any{ + "name": "shopware/production", + "require": map[string]any{ + "shopware/core": "6.5.8.0", + "shopware/storefront": "6.5.8.0", + "swag/paypal": "^8.11", + "frosh/tools": "^1.4", + "unrelated/library": "^1.0", + }, + }) + + writeInstalledJSON(t, dir, []packagist.InstalledPackage{ + {Name: "swag/paypal", Type: composerPluginType, InstallPath: "../swag/paypal"}, + {Name: "frosh/tools", Type: composerPluginType, InstallPath: "../frosh/tools"}, + {Name: "unrelated/library", Type: "library", InstallPath: "../unrelated/library"}, + }) + + args, plugins, err := requirePackages(composerJsonPath, "6.6.4.0") + require.NoError(t, err) + + // First-party packages present in require are pinned to the target. + assert.Contains(t, args, "shopware/core:6.6.4.0") + assert.Contains(t, args, "shopware/storefront:6.6.4.0") + // administration/elasticsearch are not required, so they are not pinned. + assert.NotContains(t, args, "shopware/administration:6.6.4.0") + + // Only shopware-platform-plugins from require are listed, without a constraint. + assert.Equal(t, []string{"frosh/tools", "swag/paypal"}, plugins) + assert.Contains(t, args, "swag/paypal") + assert.Contains(t, args, "frosh/tools") + assert.NotContains(t, args, "unrelated/library") +} diff --git a/internal/projectupgrade/plugins.go b/internal/projectupgrade/plugins.go index 0559463e..0e18da7f 100644 --- a/internal/projectupgrade/plugins.go +++ b/internal/projectupgrade/plugins.go @@ -1,7 +1,6 @@ package projectupgrade import ( - "context" "errors" "fmt" "io/fs" @@ -10,203 +9,9 @@ import ( "sort" "strings" - "github.com/shyim/go-version" - "github.com/shopware/shopware-cli/internal/packagist" ) -// composerPluginType is the composer "type" used by Shopware platform plugins. -const composerPluginType = "shopware-platform-plugin" - -// PluginAction describes how the resolver dealt with one incompatible plugin. -type PluginAction struct { - // Name is the composer package name (e.g. "store.shopware.com/swagcms"). - Name string - // OldConstraint is the constraint that was in composer.json before - // resolution. - OldConstraint string - // NewConstraint is the constraint that was written to composer.json. - // Empty when Removed is true. - NewConstraint string - // NewVersion is the package version the new constraint points at. - // Empty when Removed is true. - NewVersion string - // Removed is true when no compatible version could be found and the - // plugin was dropped from composer.json. - Removed bool - // Reason is a short human-readable explanation surfaced in the UI. - Reason string -} - -// ResolveResult summarises the actions the resolver took. -type ResolveResult struct { - Actions []PluginAction -} - -// Bumped returns the actions that resulted in a constraint bump. -func (r *ResolveResult) Bumped() []PluginAction { - out := make([]PluginAction, 0, len(r.Actions)) - for _, a := range r.Actions { - if !a.Removed { - out = append(out, a) - } - } - return out -} - -// Removed returns the actions that resulted in the plugin being dropped. -func (r *ResolveResult) Removed() []PluginAction { - out := make([]PluginAction, 0, len(r.Actions)) - for _, a := range r.Actions { - if a.Removed { - out = append(out, a) - } - } - return out -} - -// ResolveIncompatiblePlugins inspects every shopware platform plugin under -// custom/plugins/* (as listed in vendor/composer/installed.json). For each -// plugin whose installed Shopware constraint is not satisfied by -// targetVersion the resolver tries to find a newer release on the supplied -// registry; if one exists, the composer.json constraint is bumped to -// "^". When no compatible version is available the plugin is -// removed from composer.json so composer update doesn't fail. -// -// registry may be nil, in which case every incompatible plugin is removed -// (the previous behaviour). -func ResolveIncompatiblePlugins(ctx context.Context, composerJsonPath, targetVersion string, registry Registry) (*ResolveResult, error) { - projectDir := filepath.Dir(composerJsonPath) - - installed, err := packagist.ReadInstalledJson(projectDir) - if err != nil { - return nil, err - } - - target, err := version.NewVersion(strings.TrimPrefix(targetVersion, "v")) - if err != nil { - return nil, fmt.Errorf("parse target version: %w", err) - } - - composerJson, err := packagist.ReadComposerJson(composerJsonPath) - if err != nil { - return nil, err - } - - // Consider every shopware platform plugin that the root composer.json - // requires and whose installed shopware/* constraint is not satisfied by - // the target version - regardless of where composer installed it. Store - // plugins (swag/*, frosh/*, …) install into vendor/, not custom/plugins/, - // so restricting to custom/plugins/ would leave their stale constraints in - // place and break `composer update`. - incompatible := make([]packagist.InstalledPackage, 0) - for _, pkg := range installed.Packages { - if pkg.Type != composerPluginType { - continue - } - if _, ok := composerJson.Require[pkg.Name]; !ok { - continue - } - if packagist.ConstraintsSatisfiedBy(pkg.Require, ShopwarePackages, target) { - continue - } - incompatible = append(incompatible, pkg) - } - - if len(incompatible) == 0 { - return &ResolveResult{}, nil - } - - result := &ResolveResult{} - - for _, pkg := range incompatible { - old, ok := composerJson.Require[pkg.Name] - if !ok { - continue - } - - action := PluginAction{Name: pkg.Name, OldConstraint: old} - - newVersion, err := findCompatibleVersion(ctx, registry, pkg.Name, target) - if err != nil || newVersion == "" { - delete(composerJson.Require, pkg.Name) - action.Removed = true - action.Reason = "no compatible release found" - if err != nil && !errors.Is(err, ErrRegistryUnavailable) { - action.Reason = "registry lookup failed: " + err.Error() - } - result.Actions = append(result.Actions, action) - continue - } - - newConstraint := packagist.BumpConstraint(newVersion) - composerJson.Require[pkg.Name] = newConstraint - action.NewConstraint = newConstraint - action.NewVersion = newVersion - action.Reason = fmt.Sprintf("bumped to %s", newConstraint) - result.Actions = append(result.Actions, action) - } - - if len(result.Actions) == 0 { - return result, nil - } - - if err := composerJson.Save(); err != nil { - return nil, err - } - return result, nil -} - -func findCompatibleVersion(ctx context.Context, registry Registry, name string, target *version.Version) (string, error) { - if registry == nil { - return "", ErrRegistryUnavailable - } - - versions, err := registry.GetPackageVersions(ctx, name) - if err != nil { - return "", err - } - if len(versions) == 0 { - return "", nil - } - - parsed := make([]packagist.ComposerPackageVersion, 0, len(versions)) - for _, v := range versions { - if isPreReleaseVersion(v.Version) { - continue - } - if !packagist.ConstraintsSatisfiedBy(v.Require, ShopwarePackages, target) { - continue - } - parsed = append(parsed, v) - } - - if len(parsed) == 0 { - return "", nil - } - - sort.Slice(parsed, func(i, j int) bool { - vi, errI := version.NewVersion(strings.TrimPrefix(parsed[i].Version, "v")) - vj, errJ := version.NewVersion(strings.TrimPrefix(parsed[j].Version, "v")) - if errI != nil || errJ != nil { - return parsed[i].Version > parsed[j].Version - } - return vi.GreaterThan(vj) - }) - - return strings.TrimPrefix(parsed[0].Version, "v"), nil -} - -func isPreReleaseVersion(v string) bool { - lower := strings.ToLower(v) - for _, marker := range []string{"-rc", "-beta", "-alpha", "-dev"} { - if strings.Contains(lower, marker) { - return true - } - } - return false -} - // FindNonComposerPlugins returns directories under custom/plugins/ that are // not tracked by composer (no entry in vendor/composer/installed.json). // Returns an empty slice when no installed.json is present. diff --git a/internal/projectupgrade/plugins_test.go b/internal/projectupgrade/plugins_test.go index e28ac97e..f45d4139 100644 --- a/internal/projectupgrade/plugins_test.go +++ b/internal/projectupgrade/plugins_test.go @@ -1,7 +1,6 @@ package projectupgrade import ( - "context" "encoding/json" "os" "path/filepath" @@ -24,283 +23,6 @@ func writeInstalledJSON(t *testing.T, projectDir string, packages []packagist.In require.NoError(t, os.WriteFile(filepath.Join(installedDir, "installed.json"), data, 0o644)) } -// fakeRegistry is a test double for Registry that returns whatever the test -// configures. -type fakeRegistry struct { - versions map[string][]packagist.ComposerPackageVersion - err error -} - -func (f *fakeRegistry) GetPackageVersions(_ context.Context, name string) ([]packagist.ComposerPackageVersion, error) { - if f.err != nil { - return nil, f.err - } - return f.versions[name], nil -} - -func TestResolveIncompatiblePluginsRemovesWhenNoRegistry(t *testing.T) { - t.Parallel() - - dir := t.TempDir() - composerJsonPath := filepath.Join(dir, "composer.json") - - require.NoError(t, os.MkdirAll(filepath.Join(dir, "custom", "plugins", "Incompatible"), 0o755)) - - writeJSON(t, composerJsonPath, map[string]any{ - "name": "shopware/production", - "require": map[string]any{ - "shopware/core": "6.5.8.0", - "vendor/incompat": "*", - "unrelated/package": "^1.0", - }, - }) - - writeInstalledJSON(t, dir, []packagist.InstalledPackage{ - { - Name: "vendor/incompat", - Type: composerPluginType, - InstallPath: "../../custom/plugins/Incompatible", - Require: map[string]string{"shopware/core": "~6.5.0"}, - }, - }) - - result, err := ResolveIncompatiblePlugins(t.Context(), composerJsonPath, "6.6.4.0", nil) - require.NoError(t, err) - require.Len(t, result.Removed(), 1) - assert.Empty(t, result.Bumped()) - assert.Equal(t, "vendor/incompat", result.Removed()[0].Name) - - out := readJSON(t, composerJsonPath) - requireMap := out["require"].(map[string]any) - _, stillThere := requireMap["vendor/incompat"] - assert.False(t, stillThere) -} - -func TestResolveIncompatiblePluginsBumpsConstraintWhenRegistryHasCompatibleVersion(t *testing.T) { - t.Parallel() - - dir := t.TempDir() - composerJsonPath := filepath.Join(dir, "composer.json") - - require.NoError(t, os.MkdirAll(filepath.Join(dir, "custom", "plugins", "Incompatible"), 0o755)) - - writeJSON(t, composerJsonPath, map[string]any{ - "name": "shopware/production", - "require": map[string]any{ - "shopware/core": "6.5.8.0", - "vendor/incompat": "^1.0", - }, - }) - - writeInstalledJSON(t, dir, []packagist.InstalledPackage{ - { - Name: "vendor/incompat", - Type: composerPluginType, - InstallPath: "../../custom/plugins/Incompatible", - Require: map[string]string{"shopware/core": "~6.5.0"}, - }, - }) - - registry := &fakeRegistry{ - versions: map[string][]packagist.ComposerPackageVersion{ - "vendor/incompat": { - {Version: "1.0.0", Require: map[string]string{"shopware/core": "~6.5.0"}}, - {Version: "2.0.0", Require: map[string]string{"shopware/core": "^6.5 | ^6.6"}}, - {Version: "2.1.0", Require: map[string]string{"shopware/core": "^6.6"}}, - {Version: "3.0.0-rc1", Require: map[string]string{"shopware/core": "^6.6"}}, // skipped: prerelease - }, - }, - } - - result, err := ResolveIncompatiblePlugins(t.Context(), composerJsonPath, "6.6.4.0", registry) - require.NoError(t, err) - require.Len(t, result.Bumped(), 1) - assert.Empty(t, result.Removed()) - - bumped := result.Bumped()[0] - assert.Equal(t, "vendor/incompat", bumped.Name) - assert.Equal(t, "^1.0", bumped.OldConstraint) - assert.Equal(t, "2.1.0", bumped.NewVersion) - assert.Equal(t, "^2.1.0", bumped.NewConstraint) - - out := readJSON(t, composerJsonPath) - requireMap := out["require"].(map[string]any) - assert.Equal(t, "^2.1.0", requireMap["vendor/incompat"]) -} - -// Store/composer plugins install into vendor/, not custom/plugins/. They must -// still be resolved, otherwise their stale shopware/core constraint breaks -// `composer update` (regression: frosh/tools ^1.4 / swag/paypal ^8.11 left -// untouched on a 6.5 → 6.6 upgrade). -func TestResolveIncompatiblePluginsBumpsVendorInstalledPlugin(t *testing.T) { - t.Parallel() - - dir := t.TempDir() - composerJsonPath := filepath.Join(dir, "composer.json") - - writeJSON(t, composerJsonPath, map[string]any{ - "name": "shopware/production", - "require": map[string]any{ - "shopware/core": "6.5.8.0", - "swag/paypal": "^8.11", - }, - }) - - // Installed under vendor/ — no custom/plugins/ entry at all. - writeInstalledJSON(t, dir, []packagist.InstalledPackage{ - { - Name: "swag/paypal", - Type: composerPluginType, - InstallPath: "../swag/paypal", - Require: map[string]string{"shopware/core": "~6.5.5"}, - }, - }) - - registry := &fakeRegistry{ - versions: map[string][]packagist.ComposerPackageVersion{ - "swag/paypal": { - {Version: "8.11.0", Require: map[string]string{"shopware/core": "~6.5.5"}}, - {Version: "9.0.0", Require: map[string]string{"shopware/core": "^6.6"}}, - }, - }, - } - - result, err := ResolveIncompatiblePlugins(t.Context(), composerJsonPath, "6.6.4.0", registry) - require.NoError(t, err) - require.Len(t, result.Bumped(), 1) - assert.Empty(t, result.Removed()) - - bumped := result.Bumped()[0] - assert.Equal(t, "swag/paypal", bumped.Name) - assert.Equal(t, "9.0.0", bumped.NewVersion) - - out := readJSON(t, composerJsonPath) - requireMap := out["require"].(map[string]any) - assert.Equal(t, "^9.0.0", requireMap["swag/paypal"]) -} - -// A plugin installed in vendor/ but NOT listed in the root composer.json -// require (e.g. a transitive dependency) must be left alone — the resolver can -// only rewrite root constraints. -func TestResolveIncompatiblePluginsIgnoresPluginsNotInRootRequire(t *testing.T) { - t.Parallel() - - dir := t.TempDir() - composerJsonPath := filepath.Join(dir, "composer.json") - - writeJSON(t, composerJsonPath, map[string]any{ - "name": "shopware/production", - "require": map[string]any{ - "shopware/core": "6.5.8.0", - }, - }) - - writeInstalledJSON(t, dir, []packagist.InstalledPackage{ - { - Name: "transitive/plugin", - Type: composerPluginType, - InstallPath: "../transitive/plugin", - Require: map[string]string{"shopware/core": "~6.5.0"}, - }, - }) - - result, err := ResolveIncompatiblePlugins(t.Context(), composerJsonPath, "6.6.4.0", &fakeRegistry{}) - require.NoError(t, err) - assert.Empty(t, result.Actions) -} - -func TestResolveIncompatiblePluginsRemovesWhenNoCompatibleRelease(t *testing.T) { - t.Parallel() - - dir := t.TempDir() - composerJsonPath := filepath.Join(dir, "composer.json") - - require.NoError(t, os.MkdirAll(filepath.Join(dir, "custom", "plugins", "Incompatible"), 0o755)) - - writeJSON(t, composerJsonPath, map[string]any{ - "name": "shopware/production", - "require": map[string]any{ - "shopware/core": "6.5.8.0", - "vendor/incompat": "^1.0", - }, - }) - - writeInstalledJSON(t, dir, []packagist.InstalledPackage{ - { - Name: "vendor/incompat", - Type: composerPluginType, - InstallPath: "../../custom/plugins/Incompatible", - Require: map[string]string{"shopware/core": "~6.5.0"}, - }, - }) - - // Only old versions, none compatible with 6.6.4.0. - registry := &fakeRegistry{ - versions: map[string][]packagist.ComposerPackageVersion{ - "vendor/incompat": { - {Version: "1.0.0", Require: map[string]string{"shopware/core": "~6.5.0"}}, - {Version: "1.1.0", Require: map[string]string{"shopware/core": "~6.5.0"}}, - }, - }, - } - - result, err := ResolveIncompatiblePlugins(t.Context(), composerJsonPath, "6.6.4.0", registry) - require.NoError(t, err) - assert.Empty(t, result.Bumped()) - require.Len(t, result.Removed(), 1) - assert.Equal(t, "vendor/incompat", result.Removed()[0].Name) - assert.Equal(t, "no compatible release found", result.Removed()[0].Reason) -} - -func TestResolveIncompatiblePluginsRegistryErrorFallsBackToRemove(t *testing.T) { - t.Parallel() - - dir := t.TempDir() - composerJsonPath := filepath.Join(dir, "composer.json") - - require.NoError(t, os.MkdirAll(filepath.Join(dir, "custom", "plugins", "Incompatible"), 0o755)) - - writeJSON(t, composerJsonPath, map[string]any{ - "name": "shopware/production", - "require": map[string]any{ - "shopware/core": "6.5.8.0", - "vendor/incompat": "^1.0", - }, - }) - - writeInstalledJSON(t, dir, []packagist.InstalledPackage{ - { - Name: "vendor/incompat", - Type: composerPluginType, - InstallPath: "../../custom/plugins/Incompatible", - Require: map[string]string{"shopware/core": "~6.5.0"}, - }, - }) - - registry := &fakeRegistry{err: assertErr("network down")} - result, err := ResolveIncompatiblePlugins(t.Context(), composerJsonPath, "6.6.4.0", registry) - require.NoError(t, err) - require.Len(t, result.Removed(), 1) - assert.Contains(t, result.Removed()[0].Reason, "network down") -} - -func TestResolveIncompatiblePluginsNoInstalledJSONReturnsEmpty(t *testing.T) { - t.Parallel() - - dir := t.TempDir() - composerJsonPath := filepath.Join(dir, "composer.json") - writeJSON(t, composerJsonPath, map[string]any{ - "name": "shopware/production", - "require": map[string]any{ - "shopware/core": "6.5.8.0", - }, - }) - - result, err := ResolveIncompatiblePlugins(t.Context(), composerJsonPath, "6.6.4.0", nil) - require.NoError(t, err) - assert.Empty(t, result.Actions) -} - func TestFindNonComposerPluginsReportsUntrackedDirectories(t *testing.T) { t.Parallel() diff --git a/internal/projectupgrade/registry.go b/internal/projectupgrade/registry.go deleted file mode 100644 index 9118748c..00000000 --- a/internal/projectupgrade/registry.go +++ /dev/null @@ -1,121 +0,0 @@ -package projectupgrade - -import ( - "context" - "errors" - "sync" - - "github.com/shopware/shopware-cli/internal/packagist" -) - -// Registry resolves a composer package name to its available versions. -// Implementations are expected to be safe for use from multiple goroutines. -type Registry interface { - GetPackageVersions(ctx context.Context, name string) ([]packagist.ComposerPackageVersion, error) -} - -// ErrRegistryUnavailable is returned when no backend can resolve the package -// (e.g. a store.shopware.com package when no token is configured). -var ErrRegistryUnavailable = errors.New("registry unavailable for this package") - -// CombinedRegistry resolves a package against the Shopware store first (when -// configured) and falls back to Packagist. Commercial store plugins are -// required under ordinary vendor names (e.g. swag/paypal, not -// store.shopware.com/…) and only exist on packages.shopware.com, so routing -// cannot be decided from the name alone: the store listing is the only source -// that knows whether it owns a package. -type CombinedRegistry struct { - // Store handles packages published on packages.shopware.com. May be nil - // when no store token is configured. - Store Registry - // Packagist handles everything the store does not provide. Required. - Packagist Registry -} - -func (c *CombinedRegistry) GetPackageVersions(ctx context.Context, name string) ([]packagist.ComposerPackageVersion, error) { - if c.Store != nil { - versions, err := c.Store.GetPackageVersions(ctx, name) - // A configured store that knows this package is authoritative. Any - // other outcome (unknown package, or store unavailable) falls through - // to Packagist so public packages still resolve. - if err == nil && len(versions) > 0 { - return versions, nil - } - } - - if c.Packagist == nil { - return nil, ErrRegistryUnavailable - } - return c.Packagist.GetPackageVersions(ctx, name) -} - -// PackagistRegistry resolves package versions via repo.packagist.org. -type PackagistRegistry struct{} - -func (PackagistRegistry) GetPackageVersions(ctx context.Context, name string) ([]packagist.ComposerPackageVersion, error) { - return packagist.GetComposerPackageVersions(ctx, name) -} - -// ShopwareStoreRegistry resolves store-managed plugins via -// packages.shopware.com. The full listing is fetched once and cached for the -// lifetime of the registry instance. -type ShopwareStoreRegistry struct { - Token string - - once sync.Once - loadErr error - packages map[string][]packagist.ComposerPackageVersion -} - -func (s *ShopwareStoreRegistry) load(ctx context.Context) error { - s.once.Do(func() { - if s.Token == "" { - s.loadErr = ErrRegistryUnavailable - return - } - - response, err := packagist.GetAvailablePackagesFromShopwareStore(ctx, s.Token) - if err != nil { - s.loadErr = err - return - } - - s.packages = make(map[string][]packagist.ComposerPackageVersion, len(response.Packages)) - for name, versions := range response.Packages { - list := make([]packagist.ComposerPackageVersion, 0, len(versions)) - for _, v := range versions { - list = append(list, packagist.ComposerPackageVersion{ - Name: name, - Version: v.Version, - Description: v.Description, - Replace: v.Replace, - Require: v.Require, - }) - } - s.packages[name] = list - } - }) - return s.loadErr -} - -func (s *ShopwareStoreRegistry) GetPackageVersions(ctx context.Context, name string) ([]packagist.ComposerPackageVersion, error) { - if err := s.load(ctx); err != nil { - return nil, err - } - - return s.packages[name], nil -} - -// DefaultRegistry builds a CombinedRegistry that uses packages.shopware.com -// when a store token is provided and falls back to repo.packagist.org for -// everything else. token may be empty; in that case store lookups return -// ErrRegistryUnavailable. -func DefaultRegistry(token string) Registry { - combined := &CombinedRegistry{ - Packagist: PackagistRegistry{}, - } - if token != "" { - combined.Store = &ShopwareStoreRegistry{Token: token} - } - return combined -} diff --git a/internal/projectupgrade/registry_test.go b/internal/projectupgrade/registry_test.go deleted file mode 100644 index 60405771..00000000 --- a/internal/projectupgrade/registry_test.go +++ /dev/null @@ -1,85 +0,0 @@ -package projectupgrade - -import ( - "context" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/shopware/shopware-cli/internal/packagist" -) - -func TestCombinedRegistryPrefersStoreForOwnedPackages(t *testing.T) { - t.Parallel() - - // swag/paypal is a commercial store plugin: vendor-named, only on the - // store. The store must answer even though the name has no - // store.shopware.com/ prefix. - store := &fakeRegistry{versions: map[string][]packagist.ComposerPackageVersion{ - "swag/paypal": {{Version: "9.0.0"}}, - }} - packagistReg := &fakeRegistry{versions: map[string][]packagist.ComposerPackageVersion{}} - - combined := &CombinedRegistry{Store: store, Packagist: packagistReg} - - versions, err := combined.GetPackageVersions(t.Context(), "swag/paypal") - require.NoError(t, err) - require.Len(t, versions, 1) - assert.Equal(t, "9.0.0", versions[0].Version) -} - -func TestCombinedRegistryFallsBackToPackagistForPublicPackages(t *testing.T) { - t.Parallel() - - // Store knows nothing about a public package; Packagist resolves it. - store := &fakeRegistry{versions: map[string][]packagist.ComposerPackageVersion{}} - packagistReg := &fakeRegistry{versions: map[string][]packagist.ComposerPackageVersion{ - "frosh/tools": {{Version: "2.0.0"}}, - }} - - combined := &CombinedRegistry{Store: store, Packagist: packagistReg} - - versions, err := combined.GetPackageVersions(t.Context(), "frosh/tools") - require.NoError(t, err) - require.Len(t, versions, 1) - assert.Equal(t, "2.0.0", versions[0].Version) -} - -func TestCombinedRegistryFallsBackWhenStoreUnavailable(t *testing.T) { - t.Parallel() - - // No token -> store load fails with ErrRegistryUnavailable; public - // packages must still resolve via Packagist. - store := &fakeRegistry{err: ErrRegistryUnavailable} - packagistReg := &fakeRegistry{versions: map[string][]packagist.ComposerPackageVersion{ - "frosh/tools": {{Version: "2.0.0"}}, - }} - - combined := &CombinedRegistry{Store: store, Packagist: packagistReg} - - versions, err := combined.GetPackageVersions(t.Context(), "frosh/tools") - require.NoError(t, err) - require.Len(t, versions, 1) -} - -func TestCombinedRegistryNilStoreUsesPackagist(t *testing.T) { - t.Parallel() - - packagistReg := &fakeRegistry{versions: map[string][]packagist.ComposerPackageVersion{ - "frosh/tools": {{Version: "2.0.0"}}, - }} - combined := &CombinedRegistry{Packagist: packagistReg} - - versions, err := combined.GetPackageVersions(t.Context(), "frosh/tools") - require.NoError(t, err) - require.Len(t, versions, 1) -} - -func TestCombinedRegistryNilPackagistReturnsUnavailable(t *testing.T) { - t.Parallel() - - combined := &CombinedRegistry{} - _, err := combined.GetPackageVersions(context.Background(), "frosh/tools") - assert.ErrorIs(t, err, ErrRegistryUnavailable) -} diff --git a/internal/projectupgrade/wizard.go b/internal/projectupgrade/wizard.go index 1e64c1f7..5e6eda1f 100644 --- a/internal/projectupgrade/wizard.go +++ b/internal/projectupgrade/wizard.go @@ -39,10 +39,6 @@ type WizardOptions struct { CurrentVersion *version.Version UpdateVersions []string Executor executor.Executor - // Registry is consulted to find newer compatible versions of plugins - // whose installed shopware/core constraint is no longer satisfied. May - // be nil, in which case incompatible plugins are simply removed. - Registry Registry } type phase int @@ -89,8 +85,8 @@ const ( // wizardMsg variants advance the upgrade state machine. type ( compatLoadedMsg struct { - updates []PluginCompat - err error + report CompatReport + err error } taskCompleteMsg struct { task int @@ -115,26 +111,25 @@ type wizardModel struct { phase phase - versionList *tui.SelectList - targetVersion string - confirmYes bool - pluginActions *ResolveResult - compatUpdates []PluginCompat - compatHasBlock bool - compatHasUpdatable bool - compatErr error - tasks []task - currentTask int - logLines []string - fullLog []string - logChan chan string - finalErr error - finished bool - spinner spinner.Model - compatLoading bool - cancelExecution context.CancelFunc - width int - height int + versionList *tui.SelectList + targetVersion string + confirmYes bool + pluginActions *ResolveResult + compatReport CompatReport + compatHasBlock bool + compatErr error + tasks []task + currentTask int + logLines []string + fullLog []string + logChan chan string + finalErr error + finished bool + spinner spinner.Model + compatLoading bool + cancelExecution context.CancelFunc + width int + height int } // WizardResult is the outcome of a single RunWizard invocation. @@ -243,15 +238,8 @@ func (m wizardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case compatLoadedMsg: m.compatLoading = false m.compatErr = msg.err - m.compatUpdates = msg.updates - for _, u := range msg.updates { - if u.Status.IsBlocker() { - m.compatHasBlock = true - } - if u.Status.IsUpdatable() { - m.compatHasUpdatable = true - } - } + m.compatReport = msg.report + m.compatHasBlock = !msg.report.OK m.phase = phaseCompatResult m.confirmYes = !m.compatHasBlock return m, nil @@ -441,22 +429,23 @@ func (m wizardModel) readNextLog() tea.Cmd { } } -// loadCompatibility runs CheckPluginCompatibility against the registry to -// preview how each composer-managed plugin will be treated by the upgrade. +// loadCompatibility asks composer (dry run) whether the project can be upgraded +// to the target version, so the user sees composer's own verdict before +// applying anything. func (m wizardModel) loadCompatibility() tea.Cmd { composerJsonPath := m.opts.ComposerJSONPath targetVersion := m.targetVersion - registry := m.opts.Registry + exec := m.opts.Executor return func() tea.Msg { - ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) defer cancel() - updates, err := CheckPluginCompatibility(ctx, composerJsonPath, targetVersion, registry) + report, err := DryRunRequire(ctx, exec, composerJsonPath, targetVersion) if err != nil { return compatLoadedMsg{err: err} } - return compatLoadedMsg{updates: updates} + return compatLoadedMsg{report: report} } } @@ -500,28 +489,22 @@ func (m wizardModel) runRemovePlugins() tea.Cmd { composerJSONPath := m.opts.ComposerJSONPath target := m.targetVersion idx := taskPlugins - registry := m.opts.Registry + exec := m.opts.Executor return func() tea.Msg { - ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) defer cancel() - result, err := ResolveIncompatiblePlugins(ctx, composerJSONPath, target, registry) + result, err := ApplyRequire(ctx, exec, composerJSONPath, target) if err != nil { return taskCompleteMsg{task: idx, err: err} } if result == nil { result = &ResolveResult{} } - bumped := len(result.Bumped()) removed := len(result.Removed()) - detail := "no incompatibilities" - switch { - case bumped > 0 && removed > 0: - detail = fmt.Sprintf("bumped %d, removed %d", bumped, removed) - case bumped > 0: - detail = fmt.Sprintf("bumped %d to a compatible version", bumped) - case removed > 0: - detail = fmt.Sprintf("removed %d (no compatible release)", removed) + detail := "composer resolved all plugins" + if removed > 0 { + detail = fmt.Sprintf("removed %d (composer could not resolve)", removed) } return taskCompleteMsg{task: idx, detail: detail, pluginActions: result} } @@ -695,7 +678,7 @@ func (m wizardModel) viewWelcome() string { b.WriteString("\n\n") for _, line := range []string{ "Clean up stale recipe-managed files (md5-matched)", - "Resolve incompatible custom plugins (bump or drop)", + "Let composer require the target version (dropping plugins it can't resolve)", "Rewrite composer.json to pin the target version and ensure shopware/deployment-helper", "Run composer update --with-all-dependencies --no-scripts", "Run vendor/bin/shopware-deployment-helper run", @@ -741,9 +724,9 @@ func (m wizardModel) viewCompatCheck() string { b.WriteString("\n\n") b.WriteString(tui.TitleStyle.Render("Checking plugin compatibility")) b.WriteString("\n") - b.WriteString(tui.DimStyle.Render(fmt.Sprintf("Looking up composer-managed plugins for %s…", m.targetVersion))) + b.WriteString(tui.DimStyle.Render(fmt.Sprintf("Asking composer to resolve %s…", m.targetVersion))) b.WriteString("\n\n") - b.WriteString(m.spinner.View() + " " + tui.DimStyle.Render("fetching compatibility")) + b.WriteString(m.spinner.View() + " " + tui.DimStyle.Render("composer require --dry-run")) b.WriteString("\n\n") b.WriteString(m.footer(tui.Shortcut{Key: "ctrl+c", Label: "Cancel"})) return tui.RenderPhaseCard(b.String()) @@ -760,52 +743,39 @@ func (m wizardModel) viewCompatResult() string { switch { case m.compatErr != nil: - b.WriteString(lipgloss.NewStyle().Foreground(tui.ErrorColor).Render("Compatibility lookup failed: " + m.compatErr.Error())) + b.WriteString(lipgloss.NewStyle().Foreground(tui.ErrorColor).Render("Compatibility check failed: " + m.compatErr.Error())) b.WriteString("\n") b.WriteString(tui.DimStyle.Render("You may still proceed; the wizard cannot guarantee plugins will install.")) b.WriteString("\n\n") - case len(m.compatUpdates) == 0: - b.WriteString(tui.DimStyle.Render("No composer-managed plugins to check.")) + case m.compatReport.OK: + b.WriteString(" ") + b.WriteString(tui.Checkmark) + b.WriteString(" ") + b.WriteString(tui.LabelStyle.Render("composer can resolve this upgrade")) b.WriteString("\n\n") default: - for _, u := range m.compatUpdates { - var icon string - switch { - case u.Status.IsBlocker(): - icon = lipgloss.NewStyle().Foreground(tui.ErrorColor).Bold(true).Render("✗") - case u.Status.IsUpdatable(): - icon = lipgloss.NewStyle().Foreground(tui.WarnColor).Bold(true).Render("↑") - default: - icon = tui.Checkmark - } - b.WriteString(" ") - b.WriteString(icon) - b.WriteString(" ") - b.WriteString(tui.LabelStyle.Render(u.Name)) - detail := u.Status.Label() - if u.Status == CompatUpdatable && u.NewVersion != "" { - detail = fmt.Sprintf("%s → %s", u.CurrentVersion, u.NewVersion) - } else if u.CurrentVersion != "" { - detail = u.CurrentVersion + " — " + detail + b.WriteString(lipgloss.NewStyle().Foreground(tui.ErrorColor).Bold(true).Render("✗ composer could not resolve this upgrade.")) + b.WriteString("\n\n") + if len(m.compatReport.BlockingPlugins) > 0 { + b.WriteString(tui.DimStyle.Render("These plugins block the upgrade and will be removed from composer.json so it can proceed:")) + b.WriteString("\n") + for _, name := range m.compatReport.BlockingPlugins { + b.WriteString(" ") + b.WriteString(lipgloss.NewStyle().Foreground(tui.ErrorColor).Render("✗")) + b.WriteString(" ") + b.WriteString(tui.LabelStyle.Render(name)) + b.WriteString("\n") } - b.WriteString(tui.DimStyle.Render(" — " + detail)) + b.WriteString(tui.DimStyle.Render("Re-require them once they publish a compatible release.")) + b.WriteString("\n\n") + } + for _, line := range lastLines(m.compatReport.Output, 12) { + b.WriteString(tui.DimStyle.Render(" " + line)) b.WriteString("\n") } b.WriteString("\n") } - if m.compatHasUpdatable { - b.WriteString(lipgloss.NewStyle().Foreground(tui.WarnColor).Render("↑ Plugins marked with ↑ have a compatible release; their constraints will be bumped during the upgrade.")) - b.WriteString("\n\n") - } - - if m.compatHasBlock { - b.WriteString(lipgloss.NewStyle().Foreground(tui.ErrorColor).Bold(true).Render("✗ Some plugins have no compatible version yet.")) - b.WriteString("\n") - b.WriteString(tui.DimStyle.Render("They will be removed from composer.json so the upgrade can proceed. Re-require them once they publish a compatible release.")) - b.WriteString("\n\n") - } - b.WriteString(renderConfirmButtons("Continue", "Cancel", m.confirmYes)) b.WriteString("\n\n") b.WriteString(m.footer( @@ -908,24 +878,8 @@ func (m wizardModel) viewDone() string { b.WriteString(tui.DimStyle.Render("All tasks completed. Verify your shop and run your test suite.")) if m.pluginActions != nil { - bumped := m.pluginActions.Bumped() removed := m.pluginActions.Removed() - if len(bumped) > 0 { - b.WriteString("\n\n") - b.WriteString(tui.BoldText.Render("Bumped plugin constraints:")) - b.WriteString("\n") - for _, action := range bumped { - b.WriteString(tui.DimStyle.Render(" • ")) - b.WriteString(tui.LabelStyle.Render(action.Name)) - b.WriteString(" ") - b.WriteString(tui.DimStyle.Render(action.OldConstraint)) - b.WriteString(" → ") - b.WriteString(lipgloss.NewStyle().Foreground(tui.SuccessColor).Render(action.NewConstraint)) - b.WriteString("\n") - } - } - if len(removed) > 0 { b.WriteString("\n\n") b.WriteString(tui.BoldText.Render("Removed incompatible custom plugins:")) diff --git a/internal/projectupgrade/wizard_test.go b/internal/projectupgrade/wizard_test.go index 2908913c..a2bda5b2 100644 --- a/internal/projectupgrade/wizard_test.go +++ b/internal/projectupgrade/wizard_test.go @@ -89,7 +89,7 @@ func TestWizardSelectVersionGoesToCompatCheck(t *testing.T) { assert.Equal(t, "6.6.4.0", wm.targetVersion) } -func TestWizardCompatLoadedSetsBlockerFlag(t *testing.T) { +func TestWizardCompatLoadedConflictSetsBlockerFlag(t *testing.T) { t.Parallel() m := newTestModel(t) @@ -97,18 +97,20 @@ func TestWizardCompatLoadedSetsBlockerFlag(t *testing.T) { m.compatLoading = true updated, _ := m.Update(compatLoadedMsg{ - updates: []PluginCompat{ - {Name: "vendor/incompat", CurrentVersion: "1.0.0", Status: CompatBlocker}, + report: CompatReport{ + OK: false, + Output: []string{"Your requirements could not be resolved"}, + BlockingPlugins: []string{"vendor/incompat"}, }, }) wm := updated.(wizardModel) assert.False(t, wm.compatLoading) assert.Equal(t, phaseCompatResult, wm.phase) assert.True(t, wm.compatHasBlock) - assert.False(t, wm.confirmYes, "blocker should default the confirm to No") + assert.False(t, wm.confirmYes, "a composer conflict should default the confirm to No") } -func TestWizardCompatLoadedUpdatableIsNotBlocker(t *testing.T) { +func TestWizardCompatLoadedResolvableIsNotBlocker(t *testing.T) { t.Parallel() m := newTestModel(t) @@ -116,15 +118,12 @@ func TestWizardCompatLoadedUpdatableIsNotBlocker(t *testing.T) { m.compatLoading = true updated, _ := m.Update(compatLoadedMsg{ - updates: []PluginCompat{ - {Name: "swag/paypal", CurrentVersion: "8.11.0", NewVersion: "9.0.0", Status: CompatUpdatable}, - }, + report: CompatReport{OK: true}, }) wm := updated.(wizardModel) assert.Equal(t, phaseCompatResult, wm.phase) - assert.False(t, wm.compatHasBlock, "updatable plugin must not block") - assert.True(t, wm.compatHasUpdatable) - assert.True(t, wm.confirmYes, "no blocker means confirm defaults to Yes") + assert.False(t, wm.compatHasBlock, "a resolvable upgrade must not block") + assert.True(t, wm.confirmYes, "no conflict means confirm defaults to Yes") } func TestUpgradeTaskOrderRunsDeploymentHelperLast(t *testing.T) {