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..b71796cc 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 @@ -22,7 +23,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: > @@ -31,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 @@ -53,3 +55,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/.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 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/cmd/project/ci.go b/cmd/project/ci.go index 7066d972..9d7348e4 100644 --- a/cmd/project/ci.go +++ b/cmd/project/ci.go @@ -10,17 +10,17 @@ 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/executor" "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/sbom" "github.com/shopware/shopware-cli/internal/shop" + "github.com/shopware/shopware-cli/internal/system" "github.com/shopware/shopware-cli/internal/tui" "github.com/shopware/shopware-cli/logging" ) @@ -48,6 +48,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 @@ -184,7 +193,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 } @@ -194,19 +204,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 } } @@ -214,8 +224,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 } } @@ -253,10 +263,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 { @@ -303,6 +316,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 @@ -415,6 +442,31 @@ 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 || system.IsCIEnvironment(getenv) { + return nil + } + + dirty, isGitRepository, err := internalgit.IsWorkingTreeDirty(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 runTransparentCommand(p *executor.Process) error { @@ -458,164 +510,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 index 756fe30a..23adfbac 100644 --- a/cmd/project/ci_test.go +++ b/cmd/project/ci_test.go @@ -3,51 +3,47 @@ package project import ( "encoding/json" "os" + "os/exec" "path/filepath" "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) -func TestSourceMapCleanup(t *testing.T) { - t.Run("invalid directory", func(t *testing.T) { - assert.NoError(t, cleanupJavaScriptSourceMaps("invalid-directory")) - }) +func TestProjectCISafetyCheck(t *testing.T) { + t.Run("allows dirty git working tree in CI", func(t *testing.T) { + root := newDirtyGitRepository(t) - t.Run("does not touch js", func(t *testing.T) { - tmpDir := t.TempDir() + err := projectCISafetyCheck(t.Context(), root, false, mapGetenv(map[string]string{"CI": "true"})) - assert.NoError(t, cleanupJavaScriptSourceMaps(tmpDir)) + assert.NoError(t, err) + }) - assert.NoError(t, os.WriteFile(filepath.Join(tmpDir, "random.js"), []byte("test"), 0o644)) + t.Run("allows dirty git working tree with force", func(t *testing.T) { + root := newDirtyGitRepository(t) - assert.NoError(t, cleanupJavaScriptSourceMaps(tmpDir)) + err := projectCISafetyCheck(t.Context(), root, true, mapGetenv(nil)) - assert.FileExists(t, filepath.Join(tmpDir, "random.js")) + assert.NoError(t, err) }) - 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)) + t.Run("rejects dirty git working tree outside CI without force", func(t *testing.T) { + root := newDirtyGitRepository(t) - assert.NoError(t, cleanupJavaScriptSourceMaps(tmpDir)) + err := projectCISafetyCheck(t.Context(), root, false, mapGetenv(nil)) - assert.NoFileExists(t, filepath.Join(tmpDir, "foo.js.map")) + require.Error(t, err) + assert.Contains(t, err.Error(), "project ci removes source files") + assert.Contains(t, err.Error(), "--force") }) - 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)) + t.Run("allows clean git working tree outside CI without force", func(t *testing.T) { + root := newCleanGitRepository(t) - assert.NoError(t, cleanupJavaScriptSourceMaps(tmpDir)) + err := projectCISafetyCheck(t.Context(), root, false, mapGetenv(nil)) - content, err := os.ReadFile(filepath.Join(tmpDir, "test.js")) assert.NoError(t, err) - - assert.Equal(t, "console.log", string(content)) }) } @@ -102,3 +98,36 @@ func TestGenerateProjectSBOMSkipsWhenLockMissing(t *testing.T) { _, err := os.Stat(filepath.Join(root, "sbom.cdx.json")) assert.True(t, os.IsNotExist(err), "no SBOM should be written when composer.lock is absent") } + +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.CommandContext(t.Context(), "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] + } +} diff --git a/cmd/project/project_create.go b/cmd/project/project_create.go index d62cdd9d..2a214ede 100644 --- a/cmd/project/project_create.go +++ b/cmd/project/project_create.go @@ -2,6 +2,8 @@ package project import ( "fmt" + "path/filepath" + "regexp" "github.com/spf13/cobra" @@ -34,6 +36,50 @@ type createOptions struct { isVerbose bool } +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. +// 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 +// 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: %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", diff --git a/cmd/project/project_create_test.go b/cmd/project/project_create_test.go index 8e3a6bf1..29a9f40f 100644 --- a/cmd/project/project_create_test.go +++ b/cmd/project/project_create_test.go @@ -67,6 +67,84 @@ func TestResolveVersion(t *testing.T) { }) } +func TestValidateProjectName(t *testing.T) { + t.Parallel() + + validNames := []string{ + "my-shopware-project", + "myshop", + "my_shop", + "shop123", + "123shop", + "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{ + "MyShop", + "myShop", + "SHOP", + "müller", + "über-shop", + "Müller-Shop", + "café", + "straße", + "my shop", + "my.shop", + "shop!", + "-shop", + "_shop", + "ä", + "", + "path/to/müller", + "path/to/MyShop", + } + + 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 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) { diff --git a/cmd/project/project_upgrade.go b/cmd/project/project_upgrade.go new file mode 100644 index 00000000..07d4146c --- /dev/null +++ b/cmd/project/project_upgrade.go @@ -0,0 +1,363 @@ +package project + +import ( + "context" + "errors" + "fmt" + "os" + "path" + "strconv" + "strings" + "time" + + "charm.land/huh/v2" + "charm.land/lipgloss/v2" + "charm.land/lipgloss/v2/table" + "github.com/shyim/go-version" + "github.com/spf13/cobra" + + "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" + "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. 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) + + 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()) + + allowDirty, _ := cmd.Flags().GetBool("allow-dirty") + if err := ensureCleanGitTree(ctx, projectRoot, allowDirty); err != nil { + 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) + } + + updateVersions := projectupgrade.FilterUpdateVersions(currentVersion, allVersions) + if len(updateVersions) == 0 { + fmt.Println("You are on the latest version of Shopware") + return nil + } + + cmdExecutor, err := resolveExecutor(cmd, projectRoot) + if 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) + } + + // Interactive: hand off to the devtui-styled wizard. + result, err := projectupgrade.RunWizard(projectupgrade.WizardOptions{ + ProjectRoot: projectRoot, + ComposerJSONPath: composerJsonPath, + CurrentVersion: currentVersion, + UpdateVersions: updateVersions, + Executor: cmdExecutor, + }) + + status := "ok" + switch { + case errors.Is(err, projectupgrade.ErrCancelled): + status = "cancelled" + case err != nil: + status = "failed" + case !result.Success: + status = "failed" + } + + 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 + }, +} + +func runUpgradeHeadless(cmd *cobra.Command, projectRoot, composerJsonPath string, currentVersion *version.Version, updateVersions []string, cmdExecutor executor.Executor) error { + ctx := cmd.Context() + log := logging.FromContext(ctx) + + targetVersion, err := selectTargetVersion(cmd, updateVersions) + if err != nil { + return err + } + + if err := runCompatibilityCheck(ctx, cmdExecutor, composerJsonPath, 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 invoke vendor/bin/shopware-deployment-helper. Commit your changes before running this command."). + Value(&confirmed). + Run(); err != nil { + return err + } + } + + if !confirmed { + return fmt.Errorf("upgrade cancelled") + } + + log.Infof("Cleaning up stale recipe files") + if err := flexmigrator.CleanupByHash(projectRoot); err != nil { + return fmt.Errorf("cleanup stale files: %w", err) + } + + 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.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) + if err := projectupgrade.UpdateComposerJson(composerJsonPath, targetVersion); err != nil { + return fmt.Errorf("update composer.json: %w", 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() + + if err := composerCmd.Run(); err != nil { + log.Errorf("composer update failed: %v", err) + trackUpgrade(ctx, currentVersion.String(), targetVersion, "composer_update_failed") + return fmt.Errorf("composer update failed: %w", err) + } + + 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 := 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") + 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, cmdExecutor executor.Executor, composerJsonPath, targetVersion string) error { + log := logging.FromContext(ctx) + + report, err := projectupgrade.DryRunRequire(ctx, cmdExecutor, composerJsonPath, targetVersion) + if err != nil { + log.Warnf("Skipping plugin compatibility check: %v", err) + return nil + } + + if report.OK { + log.Infof("composer can resolve the upgrade to %s", targetVersion) + return nil + } + + 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")) + } + + if system.IsInteractionEnabled(ctx) { + var proceed bool + if err := huh.NewConfirm(). + 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 + } + + if !proceed { + return fmt.Errorf("upgrade cancelled due to incompatible plugins") + } + } + + return nil +} + +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"), + }) +} + +// 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 + } + + dirty, isRepo, err := git.IsWorkingTreeDirty(ctx, projectRoot) + if err != nil { + return fmt.Errorf("could not read git working tree status: %w", err) + } + + if !isRepo || !dirty { + return nil + } + + return fmt.Errorf( + "the upgrade rewrites composer.json and removes recipe-managed files, so the working tree must be clean in %s - "+ + "commit or stash your changes, or rerun with --allow-dirty to override", + projectRoot, + ) +} + +// 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 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 "), + ) +} + +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 new file mode 100644 index 00000000..4ea45359 --- /dev/null +++ b/cmd/project/project_upgrade_test.go @@ -0,0 +1,113 @@ +package project + +import ( + "encoding/json" + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "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...) + 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") +} + +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)) +} + +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/go.mod b/go.mod index 7649b22a..3e5d818b 100644 --- a/go.mod +++ b/go.mod @@ -33,7 +33,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 @@ -88,9 +88,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 f00785f9..91a76a31 100644 --- a/go.sum +++ b/go.sum @@ -204,17 +204,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= 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/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/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..a2394bde --- /dev/null +++ b/internal/extension/cleanup_ci.go @@ -0,0 +1,194 @@ +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 + } + + 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(tmpSnippetPath, 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(tmpSnippetPath, 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(tmpSnippetFolder, language+".json"), 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..61770c5b --- /dev/null +++ b/internal/extension/cleanup_ci_test.go @@ -0,0 +1,171 @@ +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_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() + + 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")) +} 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/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/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..b5b2d911 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,73 @@ 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) + } + + 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) + } + + 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) + } + + 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 +273,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 +333,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 +346,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/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/packagist/installed.go b/internal/packagist/installed.go new file mode 100644 index 00000000..15338a0f --- /dev/null +++ b/internal/packagist/installed.go @@ -0,0 +1,86 @@ +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"` + Version string `json:"version"` + 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/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/composer.go b/internal/projectupgrade/composer.go new file mode 100644 index 00000000..ee4a48f5 --- /dev/null +++ b/internal/projectupgrade/composer.go @@ -0,0 +1,85 @@ +// 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` 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 { + 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 + } + } + + composerJson.EnsureRequire("shopware/deployment-helper", "*") + + 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_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/composer_test.go b/internal/projectupgrade/composer_test.go new file mode 100644 index 00000000..b59a26b7 --- /dev/null +++ b/internal/projectupgrade/composer_test.go @@ -0,0 +1,136 @@ +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") + 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) { + 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..0e18da7f --- /dev/null +++ b/internal/projectupgrade/plugins.go @@ -0,0 +1,56 @@ +package projectupgrade + +import ( + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/shopware/shopware-cli/internal/packagist" +) + +// 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) + } + + composerTracked := make(map[string]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{}{} + } + } + } + + 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 +} diff --git a/internal/projectupgrade/plugins_test.go b/internal/projectupgrade/plugins_test.go new file mode 100644 index 00000000..f45d4139 --- /dev/null +++ b/internal/projectupgrade/plugins_test.go @@ -0,0 +1,71 @@ +package projectupgrade + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "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 []packagist.InstalledPackage) { + t.Helper() + + installedDir := filepath.Join(projectDir, "vendor", "composer") + require.NoError(t, os.MkdirAll(installedDir, 0o755)) + + data, err := json.MarshalIndent(packagist.InstalledJson{Packages: packages}, "", " ") + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(installedDir, "installed.json"), data, 0o644)) +} + +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, []packagist.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, []packagist.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, orphans) +} diff --git a/internal/projectupgrade/releases.go b/internal/projectupgrade/releases.go new file mode 100644 index 00000000..c2c44f36 --- /dev/null +++ b/internal/projectupgrade/releases.go @@ -0,0 +1,70 @@ +package projectupgrade + +import ( + "sort" + + "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 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. +// +// 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 { + v, err := version.NewVersion(raw) + if err != nil { + continue + } + + if v.IsPrerelease() { + continue + } + + if !v.GreaterThan(currentVersion) { + continue + } + + parsed = append(parsed, v) + } + + sort.Slice(parsed, func(i, j int) bool { + return parsed[i].GreaterThan(parsed[j]) + }) + + byBranch := map[branch][]string{} + for _, v := range parsed { + b := branchOf(v) + byBranch[b] = append(byBranch[b], v.String()) + } + + currentBranch := branchOf(currentVersion) + + result := make([]string, 0) + result = append(result, byBranch[currentBranch.next()]...) + result = append(result, byBranch[currentBranch]...) + + return result +} 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) +} diff --git a/internal/projectupgrade/wizard.go b/internal/projectupgrade/wizard.go new file mode 100644 index 00000000..5e6eda1f --- /dev/null +++ b/internal/projectupgrade/wizard.go @@ -0,0 +1,987 @@ +package projectupgrade + +import ( + "bufio" + "context" + "errors" + "fmt" + "io" + "os/exec" + "strings" + "time" + + "charm.land/bubbles/v2/spinner" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/shyim/go-version" + + "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 + +// 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 + ComposerJSONPath string + CurrentVersion *version.Version + UpdateVersions []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 +} + +// 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 ( + taskCleanup = iota + taskPlugins + taskComposerJSON + taskComposerUpdate + taskDeploymentHelper +) + +// wizardMsg variants advance the upgrade state machine. +type ( + compatLoadedMsg struct { + report CompatReport + err error + } + taskCompleteMsg struct { + 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. + output []string + } + startNextTaskMsg struct{} + logLineMsg string + logDoneMsg struct{} +) + +// 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 + + 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. +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)), + ) + + 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, + 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) + final, err := prog.Run() + if err != nil { + return WizardResult{}, err + } + + fm, _ := final.(wizardModel) + if fm.cancelExecution != nil { + fm.cancelExecution() + } + + if !fm.finished { + return WizardResult{TargetVersion: fm.targetVersion}, ErrCancelled + } + + 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 +// 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: "Clean up stale recipe files"}, + {label: "Resolve incompatible custom plugins"}, + {label: "Rewrite composer.json"}, + {label: "composer update --with-all-dependencies"}, + {label: "vendor/bin/shopware-deployment-helper run"}, + } +} + +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.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + return m, nil + + 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.compatReport = msg.report + m.compatHasBlock = !msg.report.OK + m.phase = phaseCompatResult + m.confirmYes = !m.compatHasBlock + return m, nil + + case startNextTaskMsg: + return m.startTask() + + case taskCompleteMsg: + 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 + 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) { + if m.versionList.HandleKey(key) { + return m, nil + } + + switch key { + case "q", "esc": + return m, tea.Quit + case "enter": + selected, ok := m.versionList.Selected() + if !ok { + return m, nil + } + m.targetVersion = selected.Label + 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) { + // 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:] + } +} + +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 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 + exec := m.opts.Executor + + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + report, err := DryRunRequire(ctx, exec, composerJsonPath, targetVersion) + if err != nil { + return compatLoadedMsg{err: err} + } + return compatLoadedMsg{report: report} + } +} + +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 taskCleanup: + return m, m.runCleanup() + case taskPlugins: + return m, m.runRemovePlugins() + case taskComposerJSON: + return m, m.runUpdateComposer() + case taskComposerUpdate: + return m.startComposerUpdate() + case taskDeploymentHelper: + return m.startDeploymentHelper() + } + + return m, nil +} + +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 + exec := m.opts.Executor + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + result, err := ApplyRequire(ctx, exec, composerJSONPath, target) + if err != nil { + return taskCompleteMsg{task: idx, err: err} + } + if result == nil { + result = &ResolveResult{} + } + removed := len(result.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} + } +} + +func (m wizardModel) runUpdateComposer() tea.Cmd { + composerJSONPath := m.opts.ComposerJSONPath + target := m.targetVersion + idx := taskComposerJSON + return func() tea.Msg { + if err := UpdateComposerJson(composerJSONPath, target); err != nil { + 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 + m.fullLog = nil + + args := []string{ + "update", + "--no-interaction", + "--no-scripts", + "--with-all-dependencies", + "-v", + } + p := m.opts.Executor.ComposerCommand(ctx, args...) + + idx := taskComposerUpdate + + doneCmd := func() tea.Msg { + output, err := streamCmdOutput(p.Cmd, ch, true) + return taskCompleteMsg{task: idx, err: err, output: output} + } + + return m, tea.Batch(m.readNextLog(), doneCmd) +} + +func (m wizardModel) startDeploymentHelper() (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.PHPCommand(ctx, "vendor/bin/shopware-deployment-helper", "run") + + idx := taskDeploymentHelper + + doneCmd := func() tea.Msg { + output, err := streamCmdOutput(p.Cmd, ch, true) + 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. 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 { + 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 nil, err + } + + if err := cmd.Start(); err != nil { + close(ch) + return nil, err + } + + var captured []string + scanner := bufio.NewScanner(pipe) + scanner.Buffer(make([]byte, 64*1024), 1024*1024) + for scanner.Scan() { + line := scanner.Text() + captured = append(captured, line) + ch <- line + } + close(ch) + + if err := scanner.Err(); err != nil { + _ = cmd.Wait() + return captured, err + } + return captured, cmd.Wait() +} + +// --- View --- + +func (m wizardModel) View() tea.View { + 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 +} + +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 { + return 4 // Select version, Compatibility check, Review, Run +} + +func (m wizardModel) stepNum(p phase) int { + switch p { + case phaseSelectVersion: + return 1 + case phaseCompatCheck, phaseCompatResult: + return 2 + case phaseReview: + return 3 + case phaseRunning: + return 4 + case phaseWelcome, phaseDone: + return 0 + } + 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{ + "Clean up stale recipe-managed files (md5-matched)", + "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", + } { + 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))) + 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") + + b.WriteString(m.versionList.View()) + b.WriteString("\n\n") + + 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()) +} + +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 plugin compatibility")) + b.WriteString("\n") + 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("composer require --dry-run")) + 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("Plugin compatibility")) + b.WriteString("\n") + b.WriteString(tui.DimStyle.Render(fmt.Sprintf("Upgrade to %s", m.targetVersion))) + b.WriteString("\n\n") + + switch { + case m.compatErr != nil: + 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 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: + 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("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") + } + + 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") + + 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 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") + b.WriteString(tui.DimStyle.Render("All tasks completed. Verify your shop and run your test suite.")) + + if m.pluginActions != nil { + removed := m.pluginActions.Removed() + + 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("\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("·") + case taskPending: + 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, maxRunes int) string { + if maxRunes <= 0 { + return s + } + if len([]rune(s)) <= maxRunes { + return s + } + 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 new file mode 100644 index 00000000..a2bda5b2 --- /dev/null +++ b/internal/projectupgrade/wizard_test.go @@ -0,0 +1,221 @@ +package projectupgrade + +import ( + "testing" + + tea "charm.land/bubbletea/v2" + "github.com/shyim/go-version" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "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: versions, + }, + phase: phaseWelcome, + confirmYes: true, + versionList: tui.NewSelectList("Select target version", "", opts, maxVisibleVersions), + 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") +} + +// 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) + m.phase = phaseSelectVersion + + updated, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyDown}) + wm := updated.(wizardModel) + assert.Equal(t, 1, wm.versionList.Cursor()) +} + +func TestWizardSelectVersionGoesToCompatCheck(t *testing.T) { + t.Parallel() + + m := newTestModel(t) + 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 TestWizardCompatLoadedConflictSetsBlockerFlag(t *testing.T) { + t.Parallel() + + m := newTestModel(t) + m.phase = phaseCompatCheck + m.compatLoading = true + + updated, _ := m.Update(compatLoadedMsg{ + 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, "a composer conflict should default the confirm to No") +} + +func TestWizardCompatLoadedResolvableIsNotBlocker(t *testing.T) { + t.Parallel() + + m := newTestModel(t) + m.phase = phaseCompatCheck + m.compatLoading = true + + updated, _ := m.Update(compatLoadedMsg{ + report: CompatReport{OK: true}, + }) + wm := updated.(wizardModel) + assert.Equal(t, phaseCompatResult, wm.phase) + 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) { + t.Parallel() + + // 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, taskDeploymentHelper+1) + assert.Equal(t, "composer update --with-all-dependencies", tasks[taskComposerUpdate].label) + assert.Equal(t, "vendor/bin/shopware-deployment-helper run", tasks[taskDeploymentHelper].label) +} + +func TestWizardTaskCompleteErrorEndsRun(t *testing.T) { + t.Parallel() + + m := newTestModel(t) + m.phase = phaseRunning + m.currentTask = taskComposerUpdate + + updated, _ := m.Update(taskCompleteMsg{ + 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) + total := maxLogLines + 5 + for i := 0; i < total; i++ { + updated, _ := m.Update(logLineMsg("line")) + m = updated.(wizardModel) + } + 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 + +func (e testError) Error() string { return string(e) } + +func assertErr(s string) error { return testError(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) { + t.Parallel() + 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) + }) + } +} diff --git a/internal/shop/config.go b/internal/shop/config.go index 319666d8..81aad98e 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" @@ -164,6 +165,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 { @@ -186,6 +200,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"` @@ -231,7 +264,11 @@ func (c *ConfigDump) EnableClean() { "version_commit_data", "webhook_event_log", } - c.NoData = append(c.NoData, cleanTables...) + for _, table := range cleanTables { + if !slices.Contains(c.NoData, table) { + c.NoData = append(c.NoData, table) + } + } } // EnableAnonymization adds default column rewrites for anonymizing customer data diff --git a/internal/shop/config_schema.json b/internal/shop/config_schema.json index 952fb16e..e69a9653 100644 --- a/internal/shop/config_schema.json +++ b/internal/shop/config_schema.json @@ -243,6 +243,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 78a60130..42dc910d 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) { @@ -540,6 +538,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() @@ -564,15 +595,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) { @@ -614,3 +647,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) + }) +} 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] + } +} 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" diff --git a/internal/verifier/php/composer.lock b/internal/verifier/php/composer.lock index b467c1ba..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.3", + "version": "2.4.5", "source": { "type": "git", "url": "https://github.com/rectorphp/rector.git", - "reference": "891824c6c59f02a56a5dd58ea8edc44e6c0ece29" + "reference": "cbd86024be5014d3c14d9f0b3f7aae8ecbffd62c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/rectorphp/rector/zipball/891824c6c59f02a56a5dd58ea8edc44e6c0ece29", - "reference": "891824c6c59f02a56a5dd58ea8edc44e6c0ece29", + "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.3" + "source": "https://github.com/rectorphp/rector/tree/2.4.5" }, "funding": [ { @@ -1587,7 +1598,7 @@ "type": "github" } ], - "time": "2026-05-12T11:17:24+00:00" + "time": "2026-05-26T21:03:22+00:00" }, { "name": "sebastian/diff", @@ -1658,16 +1669,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 +1711,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 +2328,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 +2384,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 +2404,7 @@ "type": "tidelift" } ], - "time": "2026-04-10T18:47:49+00:00" + "time": "2026-05-25T12:12:52+00:00" }, { "name": "symfony/process", diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh index 7f6dc154..9793d7b3 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_FLAGS=() +if [ -n "$COVERPROFILE" ]; then + COVER_FLAGS=("-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_FLAGS[@]}" "$@" ;; 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 @@ -49,10 +61,17 @@ case "$(uname -s)" in exit 1 fi exec go test "$@" - ' bash "$@" + ' bash "${COVER_FLAGS[@]}" "$@" ;; *) echo "error: unsupported OS: $(uname -s)" >&2 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