From 2be4d47d49a00d5e97b986ec6abf006a07ca7446 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 26 May 2026 13:41:28 +0000 Subject: [PATCH 01/14] feat(project): add `project upgrade` command Mirrors the shopware/web-installer Update flow so projects can be upgraded from the command line: - Reads the current Shopware version from composer.lock - Filters available releases the same way as ReleaseInfoProvider (next major + remaining patches of the current major, no RCs) - Prompts for the target version (or `--to` flag), then runs the existing extension compatibility check before continuing - Backs up composer.json, cleans up recipe-managed stale files by MD5, removes incompatible symlinked custom plugins, rewrites composer.json (shopware/core + administration/storefront/elasticsearch when required, minimum-stability for RC targets, symfony/runtime constraint relax) - Runs `composer update --with-all-dependencies --no-scripts` and restores the backup on failure - Runs `bin/console system:update:prepare` and `system:update:finish` - Tracks the outcome Extracts the MD5-based cleanup map from `flexmigrator.Cleanup` into a new `flexmigrator.CleanupByHash` helper so the upgrade flow can reuse it without also deleting flex-migration-specific files. --- cmd/project/project_upgrade.go | 315 +++++++++++++++++++++++ internal/flexmigrator/cleanup.go | 7 + internal/projectupgrade/composer.go | 81 ++++++ internal/projectupgrade/composer_test.go | 115 +++++++++ internal/projectupgrade/errors.go | 5 + internal/projectupgrade/plugins.go | 187 ++++++++++++++ internal/projectupgrade/plugins_test.go | 97 +++++++ internal/projectupgrade/releases.go | 84 ++++++ internal/projectupgrade/releases_test.go | 59 +++++ 9 files changed, 950 insertions(+) create mode 100644 cmd/project/project_upgrade.go create mode 100644 internal/projectupgrade/composer.go create mode 100644 internal/projectupgrade/composer_test.go create mode 100644 internal/projectupgrade/errors.go create mode 100644 internal/projectupgrade/plugins.go create mode 100644 internal/projectupgrade/plugins_test.go create mode 100644 internal/projectupgrade/releases.go create mode 100644 internal/projectupgrade/releases_test.go diff --git a/cmd/project/project_upgrade.go b/cmd/project/project_upgrade.go new file mode 100644 index 00000000..29f6a89a --- /dev/null +++ b/cmd/project/project_upgrade.go @@ -0,0 +1,315 @@ +package project + +import ( + "context" + "fmt" + "os" + "path" + "strconv" + "time" + + "charm.land/huh/v2" + "charm.land/lipgloss/v2" + "charm.land/lipgloss/v2/table" + "github.com/shyim/go-version" + "github.com/spf13/cobra" + + account_api "github.com/shopware/shopware-cli/internal/account-api" + "github.com/shopware/shopware-cli/internal/extension" + "github.com/shopware/shopware-cli/internal/flexmigrator" + "github.com/shopware/shopware-cli/internal/projectupgrade" + "github.com/shopware/shopware-cli/internal/system" + "github.com/shopware/shopware-cli/internal/tracking" + "github.com/shopware/shopware-cli/internal/tui" + "github.com/shopware/shopware-cli/logging" +) + +var projectUpgradeCmd = &cobra.Command{ + Use: "upgrade", + Short: "Upgrade the Shopware version of this project", + Long: `Upgrade the Shopware project to a newer version. This command mirrors +the behaviour of the shopware/web-installer: it picks an upgrade target, +removes incompatible custom plugins, rewrites composer.json for the new +version, runs composer update --with-all-dependencies, and finally runs +bin/console system:update:prepare and system:update:finish.`, + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := cmd.Context() + log := logging.FromContext(ctx) + + projectRoot, err := findClosestShopwareProject() + if err != nil { + return err + } + + composerJsonPath := path.Join(projectRoot, "composer.json") + composerLockPath := path.Join(projectRoot, "composer.lock") + + if _, err := os.Stat(composerLockPath); err != nil { + return fmt.Errorf("composer.lock not found in %s. Run composer install first", projectRoot) + } + + currentVersion, err := projectupgrade.CurrentShopwareVersion(projectRoot) + if err != nil { + return fmt.Errorf("failed to determine current Shopware version: %w", err) + } + + log.Infof("Current Shopware version: %s", currentVersion.String()) + + allVersions, err := extension.GetShopwareVersions(ctx) + if err != nil { + return fmt.Errorf("failed to fetch available Shopware versions: %w", err) + } + + updateVersions := projectupgrade.FilterUpdateVersions(currentVersion, allVersions) + if len(updateVersions) == 0 { + fmt.Println("You are on the latest version of Shopware") + return nil + } + + targetVersion, err := selectTargetVersion(cmd, updateVersions) + if err != nil { + return err + } + + if err := runCompatibilityCheck(ctx, projectRoot, currentVersion, targetVersion); err != nil { + return err + } + + confirmed := !system.IsInteractionEnabled(ctx) + if !confirmed { + if err := huh.NewConfirm(). + Title(fmt.Sprintf("Upgrade Shopware from %s to %s?", currentVersion.String(), targetVersion)). + Description("This will modify composer.json, run composer update --with-all-dependencies, and execute system:update:prepare/finish. Commit your changes before running this command."). + Value(&confirmed). + Run(); err != nil { + return err + } + } + + if !confirmed { + return fmt.Errorf("upgrade cancelled") + } + + log.Infof("Backing up composer.json") + backup, err := os.ReadFile(composerJsonPath) + if err != nil { + return fmt.Errorf("failed to backup composer.json: %w", err) + } + + log.Infof("Cleaning up stale recipe files") + if err := flexmigrator.CleanupByHash(projectRoot); err != nil { + return fmt.Errorf("cleanup stale files: %w", err) + } + + log.Infof("Checking custom plugins for incompatibilities") + removed, err := projectupgrade.RemoveIncompatiblePlugins(composerJsonPath, targetVersion) + if err != nil { + restoreComposerJson(ctx, composerJsonPath, backup) + return fmt.Errorf("remove incompatible plugins: %w", err) + } + + for _, name := range removed { + log.Infof("Removed incompatible plugin %s from composer.json. Re-require it once a compatible version is published.", tui.YellowText.Render(name)) + } + + log.Infof("Updating composer.json to %s", targetVersion) + if err := projectupgrade.UpdateComposerJson(composerJsonPath, targetVersion); err != nil { + restoreComposerJson(ctx, composerJsonPath, backup) + return fmt.Errorf("update composer.json: %w", err) + } + + cmdExecutor, err := resolveExecutor(cmd, projectRoot) + if err != nil { + restoreComposerJson(ctx, composerJsonPath, backup) + return err + } + + log.Infof("Running composer update") + composerArgs := []string{ + "update", + "--no-interaction", + "--no-scripts", + "--with-all-dependencies", + "-v", + } + + composerCmd := cmdExecutor.ComposerCommand(ctx, composerArgs...) + composerCmd.Cmd.Stdin = cmd.InOrStdin() + composerCmd.Cmd.Stdout = cmd.OutOrStdout() + composerCmd.Cmd.Stderr = cmd.ErrOrStderr() + + composerSuccess := true + if err := composerCmd.Run(); err != nil { + composerSuccess = false + log.Errorf("composer update failed: %v", err) + restoreComposerJson(ctx, composerJsonPath, backup) + trackUpgrade(ctx, currentVersion.String(), targetVersion, "composer_update_failed") + return fmt.Errorf("composer update failed, composer.json was restored: %w", err) + } + + log.Infof("Running bin/console system:update:prepare") + prepareCmd := cmdExecutor.ConsoleCommand(ctx, "system:update:prepare", "--no-interaction") + prepareCmd.Cmd.Stdin = cmd.InOrStdin() + prepareCmd.Cmd.Stdout = cmd.OutOrStdout() + prepareCmd.Cmd.Stderr = cmd.ErrOrStderr() + + if err := prepareCmd.Run(); err != nil { + trackUpgrade(ctx, currentVersion.String(), targetVersion, "system_update_prepare_failed") + return fmt.Errorf("system:update:prepare failed: %w", err) + } + + log.Infof("Running bin/console system:update:finish") + finishCmd := cmdExecutor.ConsoleCommand(ctx, "system:update:finish", "--no-interaction") + finishCmd.Cmd.Stdin = cmd.InOrStdin() + finishCmd.Cmd.Stdout = cmd.OutOrStdout() + finishCmd.Cmd.Stderr = cmd.ErrOrStderr() + + if err := finishCmd.Run(); err != nil { + trackUpgrade(ctx, currentVersion.String(), targetVersion, "system_update_finish_failed") + return fmt.Errorf("system:update:finish failed: %w", err) + } + + status := "ok" + if !composerSuccess { + status = "composer_update_failed" + } + + trackUpgrade(ctx, currentVersion.String(), targetVersion, status) + + fmt.Printf("\n%s\n", tui.GreenText.Render(fmt.Sprintf("Shopware was upgraded from %s to %s", currentVersion.String(), targetVersion))) + + return nil + }, +} + +func selectTargetVersion(cmd *cobra.Command, updateVersions []string) (string, error) { + target, _ := cmd.Flags().GetString("to") + if target != "" { + for _, v := range updateVersions { + if v == target { + return target, nil + } + } + return "", fmt.Errorf("requested target version %s is not in the list of available upgrade versions", target) + } + + if !system.IsInteractionEnabled(cmd.Context()) { + logging.FromContext(cmd.Context()).Infof("Auto selected version %s", updateVersions[0]) + return updateVersions[0], nil + } + + var selected string + prompt := huh.NewSelect[string](). + Height(10). + Title("Select the Shopware version to upgrade to"). + Options(huh.NewOptions(updateVersions...)...). + Value(&selected) + + if err := prompt.Run(); err != nil { + return "", err + } + + if selected == "" { + return "", fmt.Errorf("no version selected") + } + + return selected, nil +} + +func runCompatibilityCheck(ctx context.Context, projectRoot string, currentVersion *version.Version, targetVersion string) error { + log := logging.FromContext(ctx) + + _, extensions, err := getLocalExtensions() + if err != nil { + log.Warnf("Skipping extension compatibility check: %v", err) + return nil + } + + if len(extensions) == 0 { + return nil + } + + requests := make([]account_api.UpdateCheckExtension, 0, len(extensions)) + for name, v := range extensions { + requests = append(requests, account_api.UpdateCheckExtension{Name: name, Version: v}) + } + + updates, err := account_api.GetFutureExtensionUpdates(ctx, currentVersion.String(), targetVersion, requests) + if err != nil { + log.Warnf("Skipping extension compatibility check: %v", err) + return nil + } + + for _, name := range requests { + found := false + for _, update := range updates { + if update.Name == name.Name { + found = true + break + } + } + + if !found { + updates = append(updates, account_api.UpdateCheckExtensionCompatibility{ + Name: name.Name, + Status: account_api.UpdateCheckExtensionCompatibilityStatus{ + Label: "Not available in Store", + }, + }) + } + } + + t := table.New().Border(lipgloss.NormalBorder()).Headers("Extension Name", "Compatible") + for _, update := range updates { + t.Row(update.Name, update.Status.Label) + } + fmt.Println(t.Render()) + + hasBlockers := false + for _, update := range updates { + if update.Status.IsBlocker() { + hasBlockers = true + break + } + } + + if hasBlockers && system.IsInteractionEnabled(ctx) { + var proceed bool + if err := huh.NewConfirm(). + Title("Some installed extensions are not yet compatible with the target version"). + Description("Continuing may break those extensions. Proceed anyway?"). + Value(&proceed). + Run(); err != nil { + return err + } + + if !proceed { + return fmt.Errorf("upgrade cancelled due to incompatible extensions") + } + } + + return nil +} + +func restoreComposerJson(ctx context.Context, composerJsonPath string, backup []byte) { + if err := os.WriteFile(composerJsonPath, backup, 0o644); err != nil { + logging.FromContext(ctx).Errorf("failed to restore composer.json from backup: %v", err) + } +} + +func trackUpgrade(ctx context.Context, fromVersion, toVersion, status string) { + trackCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 300*time.Millisecond) + defer cancel() + + tracking.Track(trackCtx, "project.upgrade", map[string]string{ + "from_version": fromVersion, + "to_version": toVersion, + "status": status, + "success": strconv.FormatBool(status == "ok"), + }) +} + +func init() { + projectRootCmd.AddCommand(projectUpgradeCmd) + projectUpgradeCmd.Flags().String("to", "", "Target Shopware version. Skips the interactive selection prompt.") +} diff --git a/internal/flexmigrator/cleanup.go b/internal/flexmigrator/cleanup.go index b3722e6d..d25bfb09 100644 --- a/internal/flexmigrator/cleanup.go +++ b/internal/flexmigrator/cleanup.go @@ -211,6 +211,13 @@ func Cleanup(project string) error { } } + return CleanupByHash(project) +} + +// CleanupByHash removes recipe-managed files that still match a known stale +// template hash. This makes sure the recipe can recreate them on the next +// composer install. +func CleanupByHash(project string) error { for file, md5s := range cleanupByMd5 { content, err := os.ReadFile(path.Join(project, file)) if err != nil { diff --git a/internal/projectupgrade/composer.go b/internal/projectupgrade/composer.go new file mode 100644 index 00000000..4f56d322 --- /dev/null +++ b/internal/projectupgrade/composer.go @@ -0,0 +1,81 @@ +// Package projectupgrade implements the Shopware project upgrade flow used +// by the `shopware-cli project upgrade` command. The logic mirrors the +// shopware/web-installer Update flow so projects can be upgraded the same +// way from the command line. +package projectupgrade + +import ( + "strings" + + "github.com/shyim/go-version" + + "github.com/shopware/shopware-cli/internal/packagist" +) + +// ShopwarePackages are the first-party Shopware packages whose constraint is +// rewritten to the new target version in composer.json. This matches the list +// in shopware/web-installer ProjectComposerJsonUpdater. +var ShopwarePackages = []string{ + "shopware/core", + "shopware/administration", + "shopware/storefront", + "shopware/elasticsearch", +} + +// UpdateComposerJson rewrites the project composer.json so that composer can +// resolve dependencies for targetVersion. It mirrors the logic of +// `Shopware\WebInstaller\Services\ProjectComposerJsonUpdater`. +func UpdateComposerJson(composerJsonPath, targetVersion string) error { + composerJson, err := packagist.ReadComposerJson(composerJsonPath) + if err != nil { + return err + } + + if isPreRelease(targetVersion) { + composerJson.MinimumStability = "RC" + } else { + composerJson.MinimumStability = "" + } + + if composerJson.Require == nil { + composerJson.Require = packagist.ComposerPackageLink{} + } + + if _, ok := composerJson.Require["symfony/runtime"]; ok { + composerJson.Require["symfony/runtime"] = ">=5" + } + + for _, pkg := range ShopwarePackages { + if _, ok := composerJson.Require[pkg]; ok { + composerJson.Require[pkg] = targetVersion + } + } + + return composerJson.Save() +} + +func isPreRelease(targetVersion string) bool { + v := strings.ToLower(targetVersion) + return strings.Contains(v, "rc") || strings.Contains(v, "beta") || strings.Contains(v, "alpha") +} + +// CurrentShopwareVersion returns the installed Shopware version reported by +// the composer.lock at projectDir. Returns an error when no Shopware package +// is recorded in the lock file. +func CurrentShopwareVersion(projectDir string) (*version.Version, error) { + lock, err := packagist.ReadComposerLock(projectDir + "/composer.lock") + if err != nil { + return nil, err + } + + for _, name := range []string{"shopware/core", "shopware/platform"} { + pkg := lock.GetPackage(name) + if pkg == nil { + continue + } + + return version.NewVersion(strings.TrimPrefix(pkg.Version, "v")) + } + + return nil, errNoShopwareInLock +} diff --git a/internal/projectupgrade/composer_test.go b/internal/projectupgrade/composer_test.go new file mode 100644 index 00000000..8eadc74d --- /dev/null +++ b/internal/projectupgrade/composer_test.go @@ -0,0 +1,115 @@ +package projectupgrade + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func writeJSON(t *testing.T, file string, content map[string]any) { + t.Helper() + + data, err := json.MarshalIndent(content, "", " ") + require.NoError(t, err) + require.NoError(t, os.WriteFile(file, data, 0o644)) +} + +func readJSON(t *testing.T, file string) map[string]any { + t.Helper() + + raw, err := os.ReadFile(file) + require.NoError(t, err) + + var out map[string]any + require.NoError(t, json.Unmarshal(raw, &out)) + return out +} + +func TestUpdateComposerJsonRewritesShopwarePackages(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + composerJsonPath := filepath.Join(dir, "composer.json") + + writeJSON(t, composerJsonPath, map[string]any{ + "name": "shopware/production", + "require": map[string]any{ + "shopware/core": "6.5.8.0", + "shopware/administration": "6.5.8.0", + "shopware/storefront": "6.5.8.0", + "unrelated/package": "^1.0", + }, + }) + + require.NoError(t, UpdateComposerJson(composerJsonPath, "6.6.4.0")) + + out := readJSON(t, composerJsonPath) + requireMap := out["require"].(map[string]any) + assert.Equal(t, "6.6.4.0", requireMap["shopware/core"]) + assert.Equal(t, "6.6.4.0", requireMap["shopware/administration"]) + assert.Equal(t, "6.6.4.0", requireMap["shopware/storefront"]) + assert.Equal(t, "^1.0", requireMap["unrelated/package"]) + assert.NotContains(t, requireMap, "shopware/elasticsearch", "should not add packages that were not already required") +} + +func TestUpdateComposerJsonSetsRCStability(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + composerJsonPath := filepath.Join(dir, "composer.json") + + writeJSON(t, composerJsonPath, map[string]any{ + "name": "shopware/production", + "require": map[string]any{ + "shopware/core": "6.5.8.0", + }, + }) + + require.NoError(t, UpdateComposerJson(composerJsonPath, "6.6.0.0-rc1")) + out := readJSON(t, composerJsonPath) + assert.Equal(t, "RC", out["minimum-stability"]) +} + +func TestUpdateComposerJsonClearsRCStabilityForStableTarget(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + composerJsonPath := filepath.Join(dir, "composer.json") + + writeJSON(t, composerJsonPath, map[string]any{ + "name": "shopware/production", + "minimum-stability": "RC", + "require": map[string]any{ + "shopware/core": "6.5.8.0", + }, + }) + + require.NoError(t, UpdateComposerJson(composerJsonPath, "6.6.4.0")) + out := readJSON(t, composerJsonPath) + _, hasStability := out["minimum-stability"] + assert.False(t, hasStability, "minimum-stability should be cleared for stable upgrades") +} + +func TestUpdateComposerJsonRewritesSymfonyRuntimeConstraint(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + composerJsonPath := filepath.Join(dir, "composer.json") + + writeJSON(t, composerJsonPath, map[string]any{ + "name": "shopware/production", + "require": map[string]any{ + "shopware/core": "6.5.8.0", + "symfony/runtime": "^5.4|^6.0", + }, + }) + + require.NoError(t, UpdateComposerJson(composerJsonPath, "6.6.4.0")) + out := readJSON(t, composerJsonPath) + requireMap := out["require"].(map[string]any) + assert.Equal(t, ">=5", requireMap["symfony/runtime"]) +} diff --git a/internal/projectupgrade/errors.go b/internal/projectupgrade/errors.go new file mode 100644 index 00000000..ea56d8ef --- /dev/null +++ b/internal/projectupgrade/errors.go @@ -0,0 +1,5 @@ +package projectupgrade + +import "errors" + +var errNoShopwareInLock = errors.New("no shopware/core or shopware/platform entry found in composer.lock") diff --git a/internal/projectupgrade/plugins.go b/internal/projectupgrade/plugins.go new file mode 100644 index 00000000..aaf7d8f9 --- /dev/null +++ b/internal/projectupgrade/plugins.go @@ -0,0 +1,187 @@ +package projectupgrade + +import ( + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + + "github.com/shyim/go-version" + + "github.com/shopware/shopware-cli/internal/packagist" +) + +// composerPluginType is the composer "type" used by Shopware platform plugins. +const composerPluginType = "shopware-platform-plugin" + +// pluginShopwarePackages are the Shopware first-party packages a plugin can +// declare a constraint against. If any constraint cannot be satisfied by the +// target version, the plugin is considered incompatible. +var pluginShopwarePackages = []string{ + "shopware/core", + "shopware/administration", + "shopware/storefront", + "shopware/elasticsearch", +} + +type installedPackage struct { + Name string `json:"name"` + Type string `json:"type"` + Require map[string]string `json:"require"` + InstallPath string `json:"install-path"` +} + +type installedJSON struct { + Packages []installedPackage `json:"packages"` +} + +// RemoveIncompatiblePlugins drops symlinked custom/plugins/* entries from +// composer.json when their declared Shopware constraint is not satisfied by +// targetVersion. Composer would otherwise fail the update because the plugin +// pins us to an older shopware/core. Mirrors PluginCompatibility from the +// shopware/web-installer. +// +// Returns the list of removed plugin names so the caller can report what was +// removed. +func RemoveIncompatiblePlugins(composerJsonPath, targetVersion string) ([]string, error) { + projectDir := filepath.Dir(composerJsonPath) + + installedPath := filepath.Join(projectDir, "vendor", "composer", "installed.json") + + data, err := os.ReadFile(installedPath) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil, nil + } + + return nil, fmt.Errorf("read installed.json: %w", err) + } + + var installed installedJSON + + if err := json.Unmarshal(data, &installed); err != nil { + return nil, fmt.Errorf("parse installed.json: %w", err) + } + + target, err := version.NewVersion(strings.TrimPrefix(targetVersion, "v")) + if err != nil { + return nil, fmt.Errorf("parse target version: %w", err) + } + + incompatible := make([]string, 0) + + for _, pkg := range installed.Packages { + if pkg.Type != composerPluginType { + continue + } + + if !isInstalledUnderCustomPlugins(projectDir, pkg.InstallPath) { + continue + } + + if pluginSatisfies(pkg.Require, target) { + continue + } + + incompatible = append(incompatible, pkg.Name) + } + + if len(incompatible) == 0 { + return nil, nil + } + + composerJson, err := packagist.ReadComposerJson(composerJsonPath) + if err != nil { + return nil, err + } + + removed := make([]string, 0, len(incompatible)) + + for _, name := range incompatible { + if _, ok := composerJson.Require[name]; ok { + delete(composerJson.Require, name) + removed = append(removed, name) + } + } + + if len(removed) == 0 { + return nil, nil + } + + if err := composerJson.Save(); err != nil { + return nil, err + } + + return removed, nil +} + +func isInstalledUnderCustomPlugins(projectDir, installPath string) bool { + if installPath == "" { + return false + } + + // install-path is recorded relative to vendor/composer. + absPath := installPath + if !filepath.IsAbs(absPath) { + absPath = filepath.Join(projectDir, "vendor", "composer", installPath) + } + + resolved, err := filepath.EvalSymlinks(absPath) + if err != nil { + resolved = filepath.Clean(absPath) + } + + customPlugins := filepath.Join(projectDir, "custom", "plugins") + resolvedCustom, err := filepath.EvalSymlinks(customPlugins) + if err != nil { + resolvedCustom = filepath.Clean(customPlugins) + } + + rel, err := filepath.Rel(resolvedCustom, resolved) + if err != nil { + return false + } + + if rel == "." || rel == "" { + return false + } + + if strings.HasPrefix(rel, "..") { + return false + } + + // Direct child of custom/plugins (a single plugin directory). + return !strings.ContainsRune(rel, filepath.Separator) +} + +func pluginSatisfies(requires map[string]string, target *version.Version) bool { + for dep, constraint := range requires { + if !containsString(pluginShopwarePackages, dep) { + continue + } + + c, err := version.NewConstraint(constraint) + if err != nil { + continue + } + + if !c.Check(target) { + return false + } + } + + return true +} + +func containsString(haystack []string, needle string) bool { + for _, item := range haystack { + if item == needle { + return true + } + } + + return false +} diff --git a/internal/projectupgrade/plugins_test.go b/internal/projectupgrade/plugins_test.go new file mode 100644 index 00000000..e2dc0330 --- /dev/null +++ b/internal/projectupgrade/plugins_test.go @@ -0,0 +1,97 @@ +package projectupgrade + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func writeInstalledJSON(t *testing.T, projectDir string, packages []installedPackage) { + t.Helper() + + installedDir := filepath.Join(projectDir, "vendor", "composer") + require.NoError(t, os.MkdirAll(installedDir, 0o755)) + + data, err := json.MarshalIndent(installedJSON{Packages: packages}, "", " ") + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(installedDir, "installed.json"), data, 0o644)) +} + +func TestRemoveIncompatiblePluginsRemovesCustomPluginsThatDontSatisfyTarget(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + composerJsonPath := filepath.Join(dir, "composer.json") + + require.NoError(t, os.MkdirAll(filepath.Join(dir, "custom", "plugins", "Incompatible"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(dir, "custom", "plugins", "Compatible"), 0o755)) + + writeJSON(t, composerJsonPath, map[string]any{ + "name": "shopware/production", + "require": map[string]any{ + "shopware/core": "6.5.8.0", + "vendor/incompat": "*", + "vendor/compat": "*", + "unrelated/package": "^1.0", + }, + }) + + writeInstalledJSON(t, dir, []installedPackage{ + { + Name: "vendor/incompat", + Type: composerPluginType, + InstallPath: "../../custom/plugins/Incompatible", + Require: map[string]string{ + "shopware/core": "~6.5.0", + }, + }, + { + Name: "vendor/compat", + Type: composerPluginType, + InstallPath: "../../custom/plugins/Compatible", + Require: map[string]string{ + "shopware/core": "^6.5", + }, + }, + { + Name: "vendor/composer-installed", + Type: composerPluginType, + InstallPath: "../vendor/installed", + Require: map[string]string{ + "shopware/core": "~6.5.0", + }, + }, + }) + + removed, err := RemoveIncompatiblePlugins(composerJsonPath, "6.6.4.0") + require.NoError(t, err) + assert.Equal(t, []string{"vendor/incompat"}, removed) + + out := readJSON(t, composerJsonPath) + requireMap := out["require"].(map[string]any) + _, stillThere := requireMap["vendor/incompat"] + assert.False(t, stillThere, "incompatible plugin should be removed from composer.json") + assert.Contains(t, requireMap, "vendor/compat", "compatible plugin must remain") + assert.Contains(t, requireMap, "unrelated/package", "unrelated package must remain") +} + +func TestRemoveIncompatiblePluginsNoInstalledJSONReturnsNil(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + composerJsonPath := filepath.Join(dir, "composer.json") + writeJSON(t, composerJsonPath, map[string]any{ + "name": "shopware/production", + "require": map[string]any{ + "shopware/core": "6.5.8.0", + }, + }) + + removed, err := RemoveIncompatiblePlugins(composerJsonPath, "6.6.4.0") + require.NoError(t, err) + assert.Empty(t, removed) +} diff --git a/internal/projectupgrade/releases.go b/internal/projectupgrade/releases.go new file mode 100644 index 00000000..fdbd0bea --- /dev/null +++ b/internal/projectupgrade/releases.go @@ -0,0 +1,84 @@ +package projectupgrade + +import ( + "sort" + "strconv" + "strings" + + "github.com/shyim/go-version" +) + +// FilterUpdateVersions returns the upgrade target versions appropriate for +// currentVersion: the next major version's releases first (newest first), +// followed by the remaining patches of the current major. This mirrors the +// version filtering applied by `ReleaseInfoProvider::fetchUpdateVersions` in +// shopware/web-installer. +// +// Release candidates and any version older than or equal to currentVersion +// are dropped. +func FilterUpdateVersions(currentVersion *version.Version, allVersions []string) []string { + parsed := make([]*version.Version, 0, len(allVersions)) + + for _, raw := range allVersions { + if strings.Contains(strings.ToLower(raw), "rc") { + continue + } + + v, err := version.NewVersion(raw) + if err != nil { + continue + } + + if !v.GreaterThan(currentVersion) { + continue + } + + parsed = append(parsed, v) + } + + sort.Slice(parsed, func(i, j int) bool { + return parsed[i].GreaterThan(parsed[j]) + }) + + byMajor := map[string][]string{} + for _, v := range parsed { + major := majorBranch(v) + byMajor[major] = append(byMajor[major], v.String()) + } + + currentMajor := majorBranch(currentVersion) + nextMajor := nextMajor(currentMajor) + + result := make([]string, 0) + if list, ok := byMajor[nextMajor]; ok { + result = append(result, list...) + } + if list, ok := byMajor[currentMajor]; ok { + result = append(result, list...) + } + + return result +} + +func majorBranch(v *version.Version) string { + segments := v.Segments() + if len(segments) < 2 { + return v.String() + } + + return strconv.Itoa(segments[0]) + "." + strconv.Itoa(segments[1]) +} + +func nextMajor(currentMajor string) string { + parts := strings.SplitN(currentMajor, ".", 2) + if len(parts) != 2 { + return currentMajor + } + + minor, err := strconv.Atoi(parts[1]) + if err != nil { + return currentMajor + } + + return parts[0] + "." + strconv.Itoa(minor+1) +} diff --git a/internal/projectupgrade/releases_test.go b/internal/projectupgrade/releases_test.go new file mode 100644 index 00000000..80438ba5 --- /dev/null +++ b/internal/projectupgrade/releases_test.go @@ -0,0 +1,59 @@ +package projectupgrade + +import ( + "testing" + + "github.com/shyim/go-version" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFilterUpdateVersionsReturnsCurrentAndNextMajor(t *testing.T) { + t.Parallel() + + current, err := version.NewVersion("6.5.8.0") + require.NoError(t, err) + + all := []string{ + "6.4.20.0", // older, dropped + "6.5.8.0", // equal, dropped + "6.5.8.1", + "6.5.9.0", + "6.6.0.0", + "6.6.1.0", + "6.7.0.0", // two majors away, dropped + } + + result := FilterUpdateVersions(current, all) + + // next major (6.6) versions come first, descending, then current major (6.5) versions descending. + assert.Equal(t, []string{"6.6.1.0", "6.6.0.0", "6.5.9.0", "6.5.8.1"}, result) +} + +func TestFilterUpdateVersionsDropsReleaseCandidates(t *testing.T) { + t.Parallel() + + current, err := version.NewVersion("6.5.8.0") + require.NoError(t, err) + + all := []string{ + "6.5.9.0", + "6.6.0.0-rc1", + "6.6.0.0-RC2", + "6.6.0.0", + } + + result := FilterUpdateVersions(current, all) + assert.Equal(t, []string{"6.6.0.0", "6.5.9.0"}, result) +} + +func TestFilterUpdateVersionsReturnsEmptyWhenLatest(t *testing.T) { + t.Parallel() + + current, err := version.NewVersion("6.6.5.0") + require.NoError(t, err) + + all := []string{"6.5.0.0", "6.5.8.0", "6.6.5.0"} + result := FilterUpdateVersions(current, all) + assert.Empty(t, result) +} From afdb998dd26151e1988df3f1684e43d27f49503c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 26 May 2026 14:12:21 +0000 Subject: [PATCH 02/14] feat(project): add devtui-styled wizard to `project upgrade` The interactive flow is now a small bubbletea Program that mirrors the install-wizard / setup-guide visual idiom: - Welcome card (cowsay mascot) with current version + project root - Step 1: select target version (RenderSelectList) - Step 2 (when extensions are installed): compatibility lookup with spinner, then per-extension checkmark/blocker icons - Step 3: review card with from/to/executor and the full task list - Step 4: running phase with per-task spinner / checkmark / failure icons and a live tail of the composer/console output - Done card summarising success or failure, restored composer.json on failure, and listing any plugins that were dropped Non-interactive mode (`-n`) and `--to ` continue to use the existing headless flow so CI runs are unchanged. --- cmd/project/project_upgrade.go | 211 +++--- internal/projectupgrade/wizard.go | 970 +++++++++++++++++++++++++ internal/projectupgrade/wizard_test.go | 208 ++++++ 3 files changed, 1302 insertions(+), 87 deletions(-) create mode 100644 internal/projectupgrade/wizard.go create mode 100644 internal/projectupgrade/wizard_test.go diff --git a/cmd/project/project_upgrade.go b/cmd/project/project_upgrade.go index 29f6a89a..e7b69c34 100644 --- a/cmd/project/project_upgrade.go +++ b/cmd/project/project_upgrade.go @@ -2,6 +2,7 @@ package project import ( "context" + "errors" "fmt" "os" "path" @@ -15,6 +16,7 @@ import ( "github.com/spf13/cobra" account_api "github.com/shopware/shopware-cli/internal/account-api" + "github.com/shopware/shopware-cli/internal/executor" "github.com/shopware/shopware-cli/internal/extension" "github.com/shopware/shopware-cli/internal/flexmigrator" "github.com/shopware/shopware-cli/internal/projectupgrade" @@ -66,120 +68,155 @@ bin/console system:update:prepare and system:update:finish.`, return nil } - targetVersion, err := selectTargetVersion(cmd, updateVersions) + cmdExecutor, err := resolveExecutor(cmd, projectRoot) if err != nil { return err } - if err := runCompatibilityCheck(ctx, projectRoot, currentVersion, targetVersion); err != nil { - return err + // Non-interactive: keep the headless flow so CI runs stay unchanged. + if !system.IsInteractionEnabled(ctx) || cmd.Flag("to").Value.String() != "" { + return runUpgradeHeadless(cmd, projectRoot, composerJsonPath, currentVersion, updateVersions, cmdExecutor) } - confirmed := !system.IsInteractionEnabled(ctx) - if !confirmed { - if err := huh.NewConfirm(). - Title(fmt.Sprintf("Upgrade Shopware from %s to %s?", currentVersion.String(), targetVersion)). - Description("This will modify composer.json, run composer update --with-all-dependencies, and execute system:update:prepare/finish. Commit your changes before running this command."). - Value(&confirmed). - Run(); err != nil { - return err - } + // Interactive: hand off to the devtui-styled wizard. + _, extensions, err := getLocalExtensions() + if err != nil { + log.Warnf("Could not gather local extensions for compatibility check: %v", err) + extensions = nil } - if !confirmed { - return fmt.Errorf("upgrade cancelled") - } + target, success, err := projectupgrade.RunWizard(projectupgrade.WizardOptions{ + ProjectRoot: projectRoot, + ComposerJSONPath: composerJsonPath, + CurrentVersion: currentVersion, + UpdateVersions: updateVersions, + Extensions: extensions, + Executor: cmdExecutor, + }) - log.Infof("Backing up composer.json") - backup, err := os.ReadFile(composerJsonPath) - if err != nil { - return fmt.Errorf("failed to backup composer.json: %w", err) + status := "ok" + switch { + case errors.Is(err, projectupgrade.ErrCancelled): + status = "cancelled" + case err != nil: + status = "failed" + case !success: + status = "failed" } - log.Infof("Cleaning up stale recipe files") - if err := flexmigrator.CleanupByHash(projectRoot); err != nil { - return fmt.Errorf("cleanup stale files: %w", err) - } + trackUpgrade(ctx, currentVersion.String(), target, status) - log.Infof("Checking custom plugins for incompatibilities") - removed, err := projectupgrade.RemoveIncompatiblePlugins(composerJsonPath, targetVersion) - if err != nil { - restoreComposerJson(ctx, composerJsonPath, backup) - return fmt.Errorf("remove incompatible plugins: %w", err) + if errors.Is(err, projectupgrade.ErrCancelled) { + fmt.Println("Upgrade cancelled.") + return nil } - for _, name := range removed { - log.Infof("Removed incompatible plugin %s from composer.json. Re-require it once a compatible version is published.", tui.YellowText.Render(name)) - } + return err + }, +} - log.Infof("Updating composer.json to %s", targetVersion) - if err := projectupgrade.UpdateComposerJson(composerJsonPath, targetVersion); err != nil { - restoreComposerJson(ctx, composerJsonPath, backup) - return fmt.Errorf("update composer.json: %w", err) - } +func runUpgradeHeadless(cmd *cobra.Command, projectRoot, composerJsonPath string, currentVersion *version.Version, updateVersions []string, cmdExecutor executor.Executor) error { + ctx := cmd.Context() + log := logging.FromContext(ctx) - cmdExecutor, err := resolveExecutor(cmd, projectRoot) - if err != nil { - restoreComposerJson(ctx, composerJsonPath, backup) + targetVersion, err := selectTargetVersion(cmd, updateVersions) + if err != nil { + return err + } + + if err := runCompatibilityCheck(ctx, projectRoot, currentVersion, targetVersion); err != nil { + return err + } + + confirmed := !system.IsInteractionEnabled(ctx) + if !confirmed { + if err := huh.NewConfirm(). + Title(fmt.Sprintf("Upgrade Shopware from %s to %s?", currentVersion.String(), targetVersion)). + Description("This will modify composer.json, run composer update --with-all-dependencies, and execute system:update:prepare/finish. Commit your changes before running this command."). + Value(&confirmed). + Run(); err != nil { return err } + } - log.Infof("Running composer update") - composerArgs := []string{ - "update", - "--no-interaction", - "--no-scripts", - "--with-all-dependencies", - "-v", - } + if !confirmed { + return fmt.Errorf("upgrade cancelled") + } - composerCmd := cmdExecutor.ComposerCommand(ctx, composerArgs...) - composerCmd.Cmd.Stdin = cmd.InOrStdin() - composerCmd.Cmd.Stdout = cmd.OutOrStdout() - composerCmd.Cmd.Stderr = cmd.ErrOrStderr() - - composerSuccess := true - if err := composerCmd.Run(); err != nil { - composerSuccess = false - log.Errorf("composer update failed: %v", err) - restoreComposerJson(ctx, composerJsonPath, backup) - trackUpgrade(ctx, currentVersion.String(), targetVersion, "composer_update_failed") - return fmt.Errorf("composer update failed, composer.json was restored: %w", err) - } + log.Infof("Backing up composer.json") + backup, err := os.ReadFile(composerJsonPath) + if err != nil { + return fmt.Errorf("failed to backup composer.json: %w", err) + } - log.Infof("Running bin/console system:update:prepare") - prepareCmd := cmdExecutor.ConsoleCommand(ctx, "system:update:prepare", "--no-interaction") - prepareCmd.Cmd.Stdin = cmd.InOrStdin() - prepareCmd.Cmd.Stdout = cmd.OutOrStdout() - prepareCmd.Cmd.Stderr = cmd.ErrOrStderr() + log.Infof("Cleaning up stale recipe files") + if err := flexmigrator.CleanupByHash(projectRoot); err != nil { + return fmt.Errorf("cleanup stale files: %w", err) + } - if err := prepareCmd.Run(); err != nil { - trackUpgrade(ctx, currentVersion.String(), targetVersion, "system_update_prepare_failed") - return fmt.Errorf("system:update:prepare failed: %w", err) - } + log.Infof("Checking custom plugins for incompatibilities") + removed, err := projectupgrade.RemoveIncompatiblePlugins(composerJsonPath, targetVersion) + if err != nil { + restoreComposerJson(ctx, composerJsonPath, backup) + return fmt.Errorf("remove incompatible plugins: %w", err) + } - log.Infof("Running bin/console system:update:finish") - finishCmd := cmdExecutor.ConsoleCommand(ctx, "system:update:finish", "--no-interaction") - finishCmd.Cmd.Stdin = cmd.InOrStdin() - finishCmd.Cmd.Stdout = cmd.OutOrStdout() - finishCmd.Cmd.Stderr = cmd.ErrOrStderr() + for _, name := range removed { + log.Infof("Removed incompatible plugin %s from composer.json. Re-require it once a compatible version is published.", tui.YellowText.Render(name)) + } - if err := finishCmd.Run(); err != nil { - trackUpgrade(ctx, currentVersion.String(), targetVersion, "system_update_finish_failed") - return fmt.Errorf("system:update:finish failed: %w", err) - } + log.Infof("Updating composer.json to %s", targetVersion) + if err := projectupgrade.UpdateComposerJson(composerJsonPath, targetVersion); err != nil { + restoreComposerJson(ctx, composerJsonPath, backup) + return fmt.Errorf("update composer.json: %w", err) + } - status := "ok" - if !composerSuccess { - status = "composer_update_failed" - } + log.Infof("Running composer update") + composerArgs := []string{ + "update", + "--no-interaction", + "--no-scripts", + "--with-all-dependencies", + "-v", + } + + composerCmd := cmdExecutor.ComposerCommand(ctx, composerArgs...) + composerCmd.Cmd.Stdin = cmd.InOrStdin() + composerCmd.Cmd.Stdout = cmd.OutOrStdout() + composerCmd.Cmd.Stderr = cmd.ErrOrStderr() - trackUpgrade(ctx, currentVersion.String(), targetVersion, status) + if err := composerCmd.Run(); err != nil { + log.Errorf("composer update failed: %v", err) + restoreComposerJson(ctx, composerJsonPath, backup) + trackUpgrade(ctx, currentVersion.String(), targetVersion, "composer_update_failed") + return fmt.Errorf("composer update failed, composer.json was restored: %w", err) + } - fmt.Printf("\n%s\n", tui.GreenText.Render(fmt.Sprintf("Shopware was upgraded from %s to %s", currentVersion.String(), targetVersion))) + log.Infof("Running bin/console system:update:prepare") + prepareCmd := cmdExecutor.ConsoleCommand(ctx, "system:update:prepare", "--no-interaction") + prepareCmd.Cmd.Stdin = cmd.InOrStdin() + prepareCmd.Cmd.Stdout = cmd.OutOrStdout() + prepareCmd.Cmd.Stderr = cmd.ErrOrStderr() - return nil - }, + if err := prepareCmd.Run(); err != nil { + trackUpgrade(ctx, currentVersion.String(), targetVersion, "system_update_prepare_failed") + return fmt.Errorf("system:update:prepare failed: %w", err) + } + + log.Infof("Running bin/console system:update:finish") + finishCmd := cmdExecutor.ConsoleCommand(ctx, "system:update:finish", "--no-interaction") + finishCmd.Cmd.Stdin = cmd.InOrStdin() + finishCmd.Cmd.Stdout = cmd.OutOrStdout() + finishCmd.Cmd.Stderr = cmd.ErrOrStderr() + + if err := finishCmd.Run(); err != nil { + trackUpgrade(ctx, currentVersion.String(), targetVersion, "system_update_finish_failed") + return fmt.Errorf("system:update:finish failed: %w", err) + } + + trackUpgrade(ctx, currentVersion.String(), targetVersion, "ok") + fmt.Printf("\n%s\n", tui.GreenText.Render(fmt.Sprintf("Shopware was upgraded from %s to %s", currentVersion.String(), targetVersion))) + return nil } func selectTargetVersion(cmd *cobra.Command, updateVersions []string) (string, error) { @@ -311,5 +348,5 @@ func trackUpgrade(ctx context.Context, fromVersion, toVersion, status string) { func init() { projectRootCmd.AddCommand(projectUpgradeCmd) - projectUpgradeCmd.Flags().String("to", "", "Target Shopware version. Skips the interactive selection prompt.") + projectUpgradeCmd.Flags().String("to", "", "Target Shopware version. Skips the interactive wizard.") } diff --git a/internal/projectupgrade/wizard.go b/internal/projectupgrade/wizard.go new file mode 100644 index 00000000..7cb4f85d --- /dev/null +++ b/internal/projectupgrade/wizard.go @@ -0,0 +1,970 @@ +package projectupgrade + +import ( + "bufio" + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "strings" + "time" + + "charm.land/bubbles/v2/spinner" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/shyim/go-version" + + account_api "github.com/shopware/shopware-cli/internal/account-api" + "github.com/shopware/shopware-cli/internal/executor" + "github.com/shopware/shopware-cli/internal/flexmigrator" + "github.com/shopware/shopware-cli/internal/tui" +) + +// streamBufferSize is the buffered channel size used when streaming +// subprocess output to the wizard. +const streamBufferSize = 50 + +// maxLogLines caps how many output lines we keep in the running phase so the +// view doesn't grow unbounded. +const maxLogLines = 18 + +// WizardOptions configures a single run of the upgrade wizard. +type WizardOptions struct { + ProjectRoot string + ComposerJSONPath string + CurrentVersion *version.Version + UpdateVersions []string + Extensions map[string]string + Executor executor.Executor +} + +type phase int + +const ( + phaseWelcome phase = iota + phaseSelectVersion + phaseCompatCheck + phaseCompatResult + phaseReview + phaseRunning + phaseDone +) + +type taskStatus int + +const ( + taskPending taskStatus = iota + taskRunning + taskDone + taskFailed + taskSkipped +) + +// task tracks one of the upgrade stages displayed in the running phase. +type task struct { + label string + status taskStatus + detail string +} + +// taskCleanup, taskPlugins, ... are stable indices into model.tasks. +const ( + taskBackup = iota + taskCleanup + taskPlugins + taskComposerJSON + taskComposerUpdate + taskSystemPrepare + taskSystemFinish +) + +// wizardMsg variants advance the upgrade state machine. +type ( + compatLoadedMsg struct { + updates []account_api.UpdateCheckExtensionCompatibility + err error + } + taskCompleteMsg struct { + task int + err error + detail string + composerBackup []byte + pluginsRemoved []string + } + startNextTaskMsg struct{} + logLineMsg string + logDoneMsg struct{} + upgradeDoneMsg struct { + err error + } +) + +// wizardModel is a small standalone bubbletea Program that walks the user +// through the Shopware upgrade in the same visual idiom as devtui's setup +// guide and install wizard. +type wizardModel struct { + opts WizardOptions + + phase phase + + versionCursor int + targetVersion string + confirmYes bool + composerBackup []byte + pluginsRemoved []string + compatUpdates []account_api.UpdateCheckExtensionCompatibility + compatHasBlock bool + compatErr error + tasks []task + currentTask int + logLines []string + logChan chan string + finalErr error + finished bool + spinner spinner.Model + compatLoading bool + cancelExecution context.CancelFunc +} + +// RunWizard runs the interactive upgrade wizard. It returns the selected +// target version, whether the upgrade completed successfully, and any error +// encountered. A user cancellation returns ErrCancelled. +func RunWizard(opts WizardOptions) (string, bool, error) { + s := spinner.New( + spinner.WithSpinner(spinner.Dot), + spinner.WithStyle(lipgloss.NewStyle().Foreground(tui.BrandColor)), + ) + + m := wizardModel{ + opts: opts, + phase: phaseWelcome, + confirmYes: true, + versionCursor: 0, + spinner: s, + tasks: defaultTasks(), + } + + prog := tea.NewProgram(m) + final, err := prog.Run() + if err != nil { + return "", false, err + } + + fm, _ := final.(wizardModel) + if fm.cancelExecution != nil { + fm.cancelExecution() + } + + if !fm.finished { + return fm.targetVersion, false, ErrCancelled + } + + return fm.targetVersion, fm.finalErr == nil, fm.finalErr +} + +// ErrCancelled is returned when the user exits the wizard before the upgrade +// completes (e.g. via q / ctrl+c or selecting the cancel button). +var ErrCancelled = errors.New("upgrade cancelled by user") + +func defaultTasks() []task { + return []task{ + {label: "Back up composer.json"}, + {label: "Clean up stale recipe files"}, + {label: "Remove incompatible custom plugins"}, + {label: "Rewrite composer.json"}, + {label: "composer update --with-all-dependencies"}, + {label: "bin/console system:update:prepare"}, + {label: "bin/console system:update:finish"}, + } +} + +func (m wizardModel) Init() tea.Cmd { + return m.spinner.Tick +} + +func (m wizardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyPressMsg: + return m.updateKey(msg) + + case spinner.TickMsg: + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + return m, cmd + + case compatLoadedMsg: + m.compatLoading = false + m.compatErr = msg.err + m.compatUpdates = msg.updates + for _, u := range msg.updates { + if u.Status.IsBlocker() { + m.compatHasBlock = true + break + } + } + m.phase = phaseCompatResult + m.confirmYes = !m.compatHasBlock + return m, nil + + case startNextTaskMsg: + return m.startTask() + + case taskCompleteMsg: + if msg.composerBackup != nil { + m.composerBackup = msg.composerBackup + } + if msg.pluginsRemoved != nil { + m.pluginsRemoved = msg.pluginsRemoved + } + if msg.task < len(m.tasks) { + if msg.err != nil { + m.tasks[msg.task].status = taskFailed + m.tasks[msg.task].detail = msg.err.Error() + } else { + m.tasks[msg.task].status = taskDone + if msg.detail != "" { + m.tasks[msg.task].detail = msg.detail + } + } + } + if msg.err != nil { + m.finalErr = msg.err + m.finished = true + m.phase = phaseDone + return m, nil + } + m.currentTask++ + if m.currentTask >= len(m.tasks) { + m.finished = true + m.phase = phaseDone + return m, nil + } + return m, func() tea.Msg { return startNextTaskMsg{} } + + case logLineMsg: + m.appendLog(string(msg)) + return m, m.readNextLog() + + case logDoneMsg: + m.logChan = nil + return m, nil + } + + return m, nil +} + +func (m wizardModel) updateKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + key := msg.String() + if key == "ctrl+c" { + if m.cancelExecution != nil { + m.cancelExecution() + } + return m, tea.Quit + } + + switch m.phase { + case phaseWelcome: + return m.updateWelcome(key) + case phaseSelectVersion: + return m.updateSelectVersion(key) + case phaseCompatCheck: + return m, nil + case phaseCompatResult: + return m.updateCompatResult(key) + case phaseReview: + return m.updateReview(key) + case phaseRunning: + return m, nil + case phaseDone: + if key == "q" || key == "enter" || key == "esc" { + return m, tea.Quit + } + } + return m, nil +} + +func (m wizardModel) updateWelcome(key string) (tea.Model, tea.Cmd) { + switch key { + case "left", "h": + m.confirmYes = true + case "right", "l": + m.confirmYes = false + case "tab": + m.confirmYes = !m.confirmYes + case "q", "esc": + return m, tea.Quit + case "enter": + if !m.confirmYes { + return m, tea.Quit + } + m.phase = phaseSelectVersion + return m, nil + } + return m, nil +} + +func (m wizardModel) updateSelectVersion(key string) (tea.Model, tea.Cmd) { + switch key { + case "up", "k": + if m.versionCursor > 0 { + m.versionCursor-- + } + case "down", "j": + if m.versionCursor < len(m.opts.UpdateVersions)-1 { + m.versionCursor++ + } + case "q", "esc": + return m, tea.Quit + case "enter": + m.targetVersion = m.opts.UpdateVersions[m.versionCursor] + if len(m.opts.Extensions) == 0 { + m.phase = phaseReview + m.confirmYes = true + return m, nil + } + m.phase = phaseCompatCheck + m.compatLoading = true + return m, tea.Batch(m.spinner.Tick, m.loadCompatibility()) + } + return m, nil +} + +func (m wizardModel) updateCompatResult(key string) (tea.Model, tea.Cmd) { + switch key { + case "left", "h": + m.confirmYes = true + case "right", "l": + m.confirmYes = false + case "tab": + m.confirmYes = !m.confirmYes + case "q", "esc": + return m, tea.Quit + case "enter": + if !m.confirmYes { + return m, tea.Quit + } + m.phase = phaseReview + m.confirmYes = true + return m, nil + } + return m, nil +} + +func (m wizardModel) updateReview(key string) (tea.Model, tea.Cmd) { + switch key { + case "left", "h": + m.confirmYes = true + case "right", "l": + m.confirmYes = false + case "tab": + m.confirmYes = !m.confirmYes + case "q", "esc": + return m, tea.Quit + case "enter": + if !m.confirmYes { + return m, tea.Quit + } + m.phase = phaseRunning + m.currentTask = 0 + return m, func() tea.Msg { return startNextTaskMsg{} } + } + return m, nil +} + +func (m *wizardModel) appendLog(line string) { + m.logLines = append(m.logLines, line) + if len(m.logLines) > maxLogLines { + m.logLines = m.logLines[len(m.logLines)-maxLogLines:] + } +} + +func (m wizardModel) readNextLog() tea.Cmd { + ch := m.logChan + if ch == nil { + return nil + } + return func() tea.Msg { + line, ok := <-ch + if !ok { + return logDoneMsg{} + } + return logLineMsg(line) + } +} + +// loadCompatibility queries the Shopware account API for extension +// compatibility against the chosen target version. +func (m wizardModel) loadCompatibility() tea.Cmd { + requests := make([]account_api.UpdateCheckExtension, 0, len(m.opts.Extensions)) + for name, v := range m.opts.Extensions { + requests = append(requests, account_api.UpdateCheckExtension{Name: name, Version: v}) + } + currentVersion := m.opts.CurrentVersion.String() + targetVersion := m.targetVersion + + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + updates, err := account_api.GetFutureExtensionUpdates(ctx, currentVersion, targetVersion, requests) + if err != nil { + return compatLoadedMsg{err: err} + } + + for _, name := range requests { + found := false + for _, update := range updates { + if update.Name == name.Name { + found = true + break + } + } + + if !found { + updates = append(updates, account_api.UpdateCheckExtensionCompatibility{ + Name: name.Name, + Status: account_api.UpdateCheckExtensionCompatibilityStatus{ + Label: "Not available in Store", + }, + }) + } + } + + return compatLoadedMsg{updates: updates} + } +} + +func (m wizardModel) startTask() (tea.Model, tea.Cmd) { + if m.currentTask >= len(m.tasks) { + m.finished = true + m.phase = phaseDone + return m, nil + } + + m.tasks[m.currentTask].status = taskRunning + + switch m.currentTask { + case taskBackup: + return m, m.runBackup() + case taskCleanup: + return m, m.runCleanup() + case taskPlugins: + return m, m.runRemovePlugins() + case taskComposerJSON: + return m, m.runUpdateComposer() + case taskComposerUpdate: + return m.startComposerUpdate() + case taskSystemPrepare: + return m.startSystemUpdate("system:update:prepare", taskSystemPrepare) + case taskSystemFinish: + return m.startSystemUpdate("system:update:finish", taskSystemFinish) + } + + return m, nil +} + +func (m wizardModel) runBackup() tea.Cmd { + composerJSONPath := m.opts.ComposerJSONPath + idx := taskBackup + return func() tea.Msg { + data, err := os.ReadFile(composerJSONPath) + if err != nil { + return taskCompleteMsg{task: idx, err: fmt.Errorf("read composer.json: %w", err)} + } + return taskCompleteMsg{task: idx, detail: fmt.Sprintf("%d bytes", len(data)), composerBackup: data} + } +} + +func (m wizardModel) runCleanup() tea.Cmd { + projectRoot := m.opts.ProjectRoot + idx := taskCleanup + return func() tea.Msg { + if err := flexmigrator.CleanupByHash(projectRoot); err != nil { + return taskCompleteMsg{task: idx, err: err} + } + return taskCompleteMsg{task: idx} + } +} + +func (m wizardModel) runRemovePlugins() tea.Cmd { + composerJSONPath := m.opts.ComposerJSONPath + target := m.targetVersion + idx := taskPlugins + restore := m.composerBackup + return func() tea.Msg { + removed, err := RemoveIncompatiblePlugins(composerJSONPath, target) + if err != nil { + _ = os.WriteFile(composerJSONPath, restore, 0o644) + return taskCompleteMsg{task: idx, err: err} + } + detail := "no incompatibilities" + if len(removed) > 0 { + detail = fmt.Sprintf("removed %d incompatible plugin(s)", len(removed)) + } + if removed == nil { + removed = []string{} + } + return taskCompleteMsg{task: idx, detail: detail, pluginsRemoved: removed} + } +} + +func (m wizardModel) runUpdateComposer() tea.Cmd { + composerJSONPath := m.opts.ComposerJSONPath + target := m.targetVersion + idx := taskComposerJSON + restore := m.composerBackup + return func() tea.Msg { + if err := UpdateComposerJson(composerJSONPath, target); err != nil { + _ = os.WriteFile(composerJSONPath, restore, 0o644) + return taskCompleteMsg{task: idx, err: err} + } + return taskCompleteMsg{task: idx, detail: "pinned to " + target} + } +} + +func (m wizardModel) startComposerUpdate() (tea.Model, tea.Cmd) { + ctx, cancel := context.WithCancel(context.Background()) + m.cancelExecution = cancel + + ch := make(chan string, streamBufferSize) + m.logChan = ch + m.logLines = nil + + args := []string{ + "update", + "--no-interaction", + "--no-scripts", + "--with-all-dependencies", + "-v", + } + p := m.opts.Executor.ComposerCommand(ctx, args...) + + restore := m.composerBackup + composerJSONPath := m.opts.ComposerJSONPath + idx := taskComposerUpdate + + doneCmd := func() tea.Msg { + err := streamCmdOutput(p.Cmd, ch, true) + if err != nil { + _ = os.WriteFile(composerJSONPath, restore, 0o644) + } + return taskCompleteMsg{task: idx, err: err} + } + + return m, tea.Batch(m.readNextLog(), doneCmd) +} + +func (m wizardModel) startSystemUpdate(consoleCmd string, idx int) (tea.Model, tea.Cmd) { + ctx, cancel := context.WithCancel(context.Background()) + m.cancelExecution = cancel + + ch := make(chan string, streamBufferSize) + m.logChan = ch + m.logLines = nil + + p := m.opts.Executor.ConsoleCommand(ctx, consoleCmd, "--no-interaction") + + doneCmd := func() tea.Msg { + err := streamCmdOutput(p.Cmd, ch, true) + return taskCompleteMsg{task: idx, err: err} + } + + return m, tea.Batch(m.readNextLog(), doneCmd) +} + +// streamCmdOutput starts cmd, fans stdout (or stderr) lines into ch, and +// closes ch when done. The returned error is the process exit error, if any. +func streamCmdOutput(cmd *exec.Cmd, ch chan<- string, useStdout bool) error { + var pipe io.Reader + var err error + if useStdout { + pipe, err = cmd.StdoutPipe() + if err == nil { + cmd.Stderr = cmd.Stdout + } + } else { + pipe, err = cmd.StderrPipe() + if err == nil { + cmd.Stdout = cmd.Stderr + } + } + if err != nil { + close(ch) + return err + } + + if err := cmd.Start(); err != nil { + close(ch) + return err + } + + scanner := bufio.NewScanner(pipe) + scanner.Buffer(make([]byte, 64*1024), 1024*1024) + for scanner.Scan() { + ch <- scanner.Text() + } + close(ch) + + if err := scanner.Err(); err != nil { + _ = cmd.Wait() + return err + } + return cmd.Wait() +} + +// --- View --- + +func (m wizardModel) View() tea.View { + v := tea.NewView(m.viewContent()) + v.AltScreen = true + return v +} + +func (m wizardModel) viewContent() string { + switch m.phase { + case phaseWelcome: + return m.viewWelcome() + case phaseSelectVersion: + return m.viewSelectVersion() + case phaseCompatCheck: + return m.viewCompatCheck() + case phaseCompatResult: + return m.viewCompatResult() + case phaseReview: + return m.viewReview() + case phaseRunning: + return m.viewRunning() + case phaseDone: + return m.viewDone() + } + return "" +} + +func (m wizardModel) totalSteps() int { + if len(m.opts.Extensions) == 0 { + return 3 // Select version, Review, Run + } + return 4 // + Compatibility check +} + +func (m wizardModel) stepNum(p phase) int { + switch p { + case phaseSelectVersion: + return 1 + case phaseCompatCheck, phaseCompatResult: + return 2 + case phaseReview: + if len(m.opts.Extensions) == 0 { + return 2 + } + return 3 + case phaseRunning: + if len(m.opts.Extensions) == 0 { + return 3 + } + return 4 + } + return 0 +} + +func (m wizardModel) viewWelcome() string { + var b strings.Builder + b.WriteString(tui.TextBadge("Upgrade")) + b.WriteString("\n\n") + b.WriteString(lipgloss.NewStyle().Bold(true).Foreground(tui.BrandColor).Render("Upgrade Shopware to a newer version")) + b.WriteString("\n\n") + b.WriteString(tui.DimStyle.Render("This wizard mirrors the shopware/web-installer flow:")) + b.WriteString("\n\n") + for _, line := range []string{ + "Back up composer.json before any change", + "Clean up stale recipe-managed files (md5-matched)", + "Drop incompatible custom plugins from composer.json", + "Rewrite composer.json to pin the target version", + "Run composer update --with-all-dependencies --no-scripts", + "Run bin/console system:update:prepare + finish", + } { + b.WriteString(tui.DimStyle.Render(" • ")) + b.WriteString(tui.LabelStyle.Render(line)) + b.WriteString("\n") + } + b.WriteString("\n") + b.WriteString(tui.SectionDivider(tui.PhaseCardWidth - 6)) + b.WriteString(tui.KVRow("Current version", tui.BoldText.Render(m.opts.CurrentVersion.String()))) + b.WriteString(tui.KVRow("Project root", tui.DimStyle.Render(m.opts.ProjectRoot))) + if len(m.opts.Extensions) > 0 { + b.WriteString(tui.KVRow("Installed extensions", tui.LabelStyle.Render(fmt.Sprintf("%d", len(m.opts.Extensions))))) + } + b.WriteString("\n") + b.WriteString(renderConfirmButtons("Begin upgrade", "Cancel", m.confirmYes)) + b.WriteString("\n\n") + b.WriteString(m.footer( + tui.Shortcut{Key: "←/→", Label: "Select"}, + tui.Shortcut{Key: "enter", Label: "Confirm"}, + tui.Shortcut{Key: "ctrl+c", Label: "Exit"}, + )) + return tui.RenderPhaseCardCowsay("Let's get this Shopware up to date!", b.String()) +} + +func (m wizardModel) viewSelectVersion() string { + var b strings.Builder + b.WriteString(stepBadge(m.stepNum(phaseSelectVersion), m.totalSteps())) + b.WriteString("\n\n") + + opts := make([]tui.SelectOption, len(m.opts.UpdateVersions)) + for i, v := range m.opts.UpdateVersions { + detail := "" + if i == 0 { + detail = "latest" + } + opts[i] = tui.SelectOption{Label: v, Detail: detail} + } + b.WriteString(tui.RenderSelectList( + "Select target version", + "Pick the Shopware version to upgrade to. Next-major releases are listed first.", + opts, + m.versionCursor, + )) + b.WriteString("\n\n") + b.WriteString(m.footer( + tui.Shortcut{Key: "↑/↓", Label: "Select"}, + tui.Shortcut{Key: "enter", Label: "Continue"}, + tui.Shortcut{Key: "ctrl+c", Label: "Exit"}, + )) + return tui.RenderPhaseCard(b.String()) +} + +func (m wizardModel) viewCompatCheck() string { + var b strings.Builder + b.WriteString(stepBadge(m.stepNum(phaseCompatCheck), m.totalSteps())) + b.WriteString("\n\n") + b.WriteString(tui.TitleStyle.Render("Checking extension compatibility")) + b.WriteString("\n") + b.WriteString(tui.DimStyle.Render(fmt.Sprintf("Asking the Shopware store about %d installed extension(s) against %s…", len(m.opts.Extensions), m.targetVersion))) + b.WriteString("\n\n") + b.WriteString(m.spinner.View() + " " + tui.DimStyle.Render("fetching compatibility")) + b.WriteString("\n\n") + b.WriteString(m.footer(tui.Shortcut{Key: "ctrl+c", Label: "Cancel"})) + return tui.RenderPhaseCard(b.String()) +} + +func (m wizardModel) viewCompatResult() string { + var b strings.Builder + b.WriteString(stepBadge(m.stepNum(phaseCompatResult), m.totalSteps())) + b.WriteString("\n\n") + b.WriteString(tui.TitleStyle.Render("Extension compatibility")) + b.WriteString("\n") + b.WriteString(tui.DimStyle.Render(fmt.Sprintf("Upgrade to %s", m.targetVersion))) + b.WriteString("\n\n") + + if m.compatErr != nil { + b.WriteString(lipgloss.NewStyle().Foreground(tui.ErrorColor).Render("Compatibility lookup failed: " + m.compatErr.Error())) + b.WriteString("\n") + b.WriteString(tui.DimStyle.Render("You may still proceed; the wizard cannot guarantee extensions will install.")) + b.WriteString("\n\n") + } else if len(m.compatUpdates) == 0 { + b.WriteString(tui.DimStyle.Render("No store-managed extensions to check.")) + b.WriteString("\n\n") + } else { + for _, u := range m.compatUpdates { + icon := tui.Checkmark + if u.Status.IsBlocker() { + icon = lipgloss.NewStyle().Foreground(tui.ErrorColor).Bold(true).Render("✗") + } + b.WriteString(" ") + b.WriteString(icon) + b.WriteString(" ") + b.WriteString(tui.LabelStyle.Render(u.Name)) + b.WriteString(tui.DimStyle.Render(" — " + u.Status.Label)) + b.WriteString("\n") + } + b.WriteString("\n") + } + + if m.compatHasBlock { + b.WriteString(lipgloss.NewStyle().Foreground(tui.WarnColor).Bold(true).Render("⚠ Some extensions are not compatible yet.")) + b.WriteString("\n") + b.WriteString(tui.DimStyle.Render("Continuing may break those extensions until they release updates.")) + b.WriteString("\n\n") + } + + b.WriteString(renderConfirmButtons("Continue", "Cancel", m.confirmYes)) + b.WriteString("\n\n") + b.WriteString(m.footer( + tui.Shortcut{Key: "←/→", Label: "Select"}, + tui.Shortcut{Key: "enter", Label: "Confirm"}, + tui.Shortcut{Key: "ctrl+c", Label: "Exit"}, + )) + return tui.RenderPhaseCard(b.String()) +} + +func (m wizardModel) viewReview() string { + var b strings.Builder + b.WriteString(stepBadge(m.stepNum(phaseReview), m.totalSteps())) + b.WriteString("\n\n") + b.WriteString(tui.TitleStyle.Render("Review")) + b.WriteString("\n") + b.WriteString(tui.DimStyle.Render("Confirm to apply the following changes.")) + b.WriteString("\n\n") + b.WriteString(tui.KVRow("From", tui.BoldText.Render(m.opts.CurrentVersion.String()))) + b.WriteString(tui.KVRow("To", lipgloss.NewStyle().Foreground(tui.SuccessColor).Bold(true).Render(m.targetVersion))) + if m.opts.Executor != nil { + b.WriteString(tui.KVRow("Executor", tui.LabelStyle.Render(m.opts.Executor.Type()))) + } + b.WriteString(tui.SectionDivider(tui.PhaseCardWidth - 6)) + b.WriteString(tui.DimStyle.Render("Tasks to be executed:")) + b.WriteString("\n") + for _, t := range m.tasks { + b.WriteString(tui.DimStyle.Render(" • ")) + b.WriteString(tui.LabelStyle.Render(t.label)) + b.WriteString("\n") + } + b.WriteString("\n") + b.WriteString(lipgloss.NewStyle().Foreground(tui.WarnColor).Render("⚠ Commit your changes before continuing.")) + b.WriteString("\n\n") + b.WriteString(renderConfirmButtons("Start upgrade", "Cancel", m.confirmYes)) + b.WriteString("\n\n") + b.WriteString(m.footer( + tui.Shortcut{Key: "←/→", Label: "Select"}, + tui.Shortcut{Key: "enter", Label: "Confirm"}, + tui.Shortcut{Key: "ctrl+c", Label: "Exit"}, + )) + return tui.RenderPhaseCard(b.String()) +} + +func (m wizardModel) viewRunning() string { + var b strings.Builder + b.WriteString(stepBadge(m.stepNum(phaseRunning), m.totalSteps())) + b.WriteString("\n\n") + b.WriteString(tui.TitleStyle.Render(fmt.Sprintf("Upgrading to %s", m.targetVersion))) + b.WriteString("\n") + b.WriteString(tui.DimStyle.Render("This may take a few minutes. Live output shown below.")) + b.WriteString("\n\n") + + for i, t := range m.tasks { + b.WriteString(m.renderTaskLine(i, t)) + b.WriteString("\n") + } + + if len(m.logLines) > 0 { + b.WriteString("\n") + b.WriteString(tui.SectionDivider(tui.PhaseCardWidth - 6)) + b.WriteString(tui.DimStyle.Render("Output:")) + b.WriteString("\n") + for _, line := range m.logLines { + b.WriteString(tui.DimStyle.Render(" " + truncate(line, tui.PhaseCardWidth-10))) + b.WriteString("\n") + } + } + + b.WriteString("\n") + b.WriteString(m.footer(tui.Shortcut{Key: "ctrl+c", Label: "Cancel"})) + return tui.RenderPhaseCard(b.String()) +} + +func (m wizardModel) viewDone() string { + var b strings.Builder + + if m.finalErr != nil { + b.WriteString(lipgloss.NewStyle().Bold(true).Foreground(tui.ErrorColor).Render("✗ Upgrade failed")) + b.WriteString("\n\n") + b.WriteString(lipgloss.NewStyle().Foreground(tui.ErrorColor).Render(m.finalErr.Error())) + b.WriteString("\n\n") + b.WriteString(tui.DimStyle.Render("composer.json was restored from the backup taken before the upgrade.")) + } else { + b.WriteString(lipgloss.NewStyle().Bold(true).Foreground(tui.SuccessColor).Render(fmt.Sprintf("✓ Upgraded to Shopware %s", m.targetVersion))) + b.WriteString("\n\n") + b.WriteString(tui.DimStyle.Render("All tasks completed. Verify your shop and run your test suite.")) + + if len(m.pluginsRemoved) > 0 { + b.WriteString("\n\n") + b.WriteString(tui.BoldText.Render("Removed incompatible custom plugins:")) + b.WriteString("\n") + for _, name := range m.pluginsRemoved { + b.WriteString(tui.DimStyle.Render(" • ")) + b.WriteString(tui.LabelStyle.Render(name)) + b.WriteString("\n") + } + b.WriteString(tui.DimStyle.Render("Re-require them in composer.json once they publish compatible versions.")) + } + } + + b.WriteString("\n\n") + b.WriteString(tui.SectionDivider(tui.PhaseCardWidth - 6)) + for i, t := range m.tasks { + b.WriteString(m.renderTaskLine(i, t)) + b.WriteString("\n") + } + + b.WriteString("\n") + b.WriteString(m.footer(tui.Shortcut{Key: "enter", Label: "Close"})) + return tui.RenderPhaseCard(b.String()) +} + +func (m wizardModel) renderTaskLine(i int, t task) string { + var icon string + switch t.status { + case taskRunning: + icon = m.spinner.View() + case taskDone: + icon = tui.Checkmark + case taskFailed: + icon = lipgloss.NewStyle().Foreground(tui.ErrorColor).Bold(true).Render("✗") + case taskSkipped: + icon = tui.DimStyle.Render("·") + default: + icon = tui.DimStyle.Render("○") + } + + style := tui.LabelStyle + if t.status == taskPending { + style = tui.DimStyle + } + + line := fmt.Sprintf(" %s %s", icon, style.Render(t.label)) + if t.detail != "" { + line += " " + tui.DimStyle.Render("("+t.detail+")") + } + if i == m.currentTask && t.status == taskRunning { + line = lipgloss.NewStyle().Bold(true).Render(line) + } + return line +} + +func (m wizardModel) footer(shortcuts ...tui.Shortcut) string { + return tui.ShortcutBar(shortcuts...) +} + +func stepBadge(stepNum, totalSteps int) string { + if stepNum == 0 { + return tui.TextBadge("Upgrade") + } + return tui.TextBadge(fmt.Sprintf("Step %d/%d", stepNum, totalSteps)) +} + +func renderConfirmButtons(yesLabel, noLabel string, yesActive bool) string { + yesStyle := lipgloss.NewStyle().Foreground(tui.TextColor).Background(tui.BrandColor).Padding(0, 2) + noStyle := lipgloss.NewStyle().Foreground(tui.MutedColor).Background(tui.SubtleBgColor).Padding(0, 2) + + var yes, no string + if yesActive { + yes = yesStyle.Render(yesLabel) + no = noStyle.Render(noLabel) + } else { + yes = noStyle.Render(yesLabel) + no = yesStyle.Render(noLabel) + } + return yes + " " + no +} + +func truncate(s string, max int) string { + if max <= 0 { + return s + } + if len([]rune(s)) <= max { + return s + } + r := []rune(s) + return string(r[:max-1]) + "…" +} diff --git a/internal/projectupgrade/wizard_test.go b/internal/projectupgrade/wizard_test.go new file mode 100644 index 00000000..3fa5863a --- /dev/null +++ b/internal/projectupgrade/wizard_test.go @@ -0,0 +1,208 @@ +package projectupgrade + +import ( + "testing" + + tea "charm.land/bubbletea/v2" + "github.com/shyim/go-version" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + account_api "github.com/shopware/shopware-cli/internal/account-api" +) + +func newTestModel(t *testing.T) wizardModel { + t.Helper() + + current, err := version.NewVersion("6.5.8.0") + require.NoError(t, err) + + m := wizardModel{ + opts: WizardOptions{ + ProjectRoot: "/tmp/example", + ComposerJSONPath: "/tmp/example/composer.json", + CurrentVersion: current, + UpdateVersions: []string{"6.6.4.0", "6.6.3.0", "6.5.9.0"}, + }, + phase: phaseWelcome, + confirmYes: true, + tasks: defaultTasks(), + } + return m +} + +func TestWizardWelcomeConfirmGoesToVersionSelect(t *testing.T) { + t.Parallel() + + m := newTestModel(t) + updated, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + wm := updated.(wizardModel) + assert.Equal(t, phaseSelectVersion, wm.phase) +} + +func TestWizardWelcomeCancelQuits(t *testing.T) { + t.Parallel() + + m := newTestModel(t) + m.confirmYes = false + _, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + require.NotNil(t, cmd) + msg := cmd() + _, ok := msg.(tea.QuitMsg) + assert.True(t, ok, "cancel should produce QuitMsg") +} + +func TestWizardSelectVersionMovesCursor(t *testing.T) { + t.Parallel() + + m := newTestModel(t) + m.phase = phaseSelectVersion + + updated, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyDown}) + wm := updated.(wizardModel) + assert.Equal(t, 1, wm.versionCursor) + + updated, _ = wm.Update(tea.KeyPressMsg{Code: tea.KeyDown}) + wm = updated.(wizardModel) + assert.Equal(t, 2, wm.versionCursor) + + // Past end should not wrap. + updated, _ = wm.Update(tea.KeyPressMsg{Code: tea.KeyDown}) + wm = updated.(wizardModel) + assert.Equal(t, 2, wm.versionCursor) + + updated, _ = wm.Update(tea.KeyPressMsg{Code: tea.KeyUp}) + wm = updated.(wizardModel) + assert.Equal(t, 1, wm.versionCursor) +} + +func TestWizardSelectVersionWithoutExtensionsSkipsToReview(t *testing.T) { + t.Parallel() + + m := newTestModel(t) + m.phase = phaseSelectVersion + m.versionCursor = 1 + + updated, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + wm := updated.(wizardModel) + assert.Equal(t, phaseReview, wm.phase) + assert.Equal(t, "6.6.3.0", wm.targetVersion) +} + +func TestWizardSelectVersionWithExtensionsGoesToCompatCheck(t *testing.T) { + t.Parallel() + + m := newTestModel(t) + m.opts.Extensions = map[string]string{"AcmeExtension": "1.0.0"} + m.phase = phaseSelectVersion + + updated, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + wm := updated.(wizardModel) + assert.Equal(t, phaseCompatCheck, wm.phase) + assert.True(t, wm.compatLoading) + assert.Equal(t, "6.6.4.0", wm.targetVersion) +} + +func TestWizardCompatLoadedSetsBlockerFlag(t *testing.T) { + t.Parallel() + + m := newTestModel(t) + m.phase = phaseCompatCheck + m.compatLoading = true + + updated, _ := m.Update(compatLoadedMsg{ + updates: []account_api.UpdateCheckExtensionCompatibility{ + { + Name: "Blocker", + Status: account_api.UpdateCheckExtensionCompatibilityStatus{ + Type: "violation", + Label: "Not compatible", + }, + }, + }, + }) + wm := updated.(wizardModel) + assert.False(t, wm.compatLoading) + assert.Equal(t, phaseCompatResult, wm.phase) + assert.True(t, wm.compatHasBlock) + assert.False(t, wm.confirmYes, "blocker should default the confirm to No") +} + +func TestWizardTaskCompletePersistsBackupAcrossUpdates(t *testing.T) { + t.Parallel() + + m := newTestModel(t) + m.phase = phaseRunning + m.currentTask = taskBackup + + // First task: backup captures composer.json bytes. + updated, _ := m.Update(taskCompleteMsg{ + task: taskBackup, + composerBackup: []byte(`{"name":"shopware/production"}`), + detail: "30 bytes", + }) + wm := updated.(wizardModel) + assert.Equal(t, []byte(`{"name":"shopware/production"}`), wm.composerBackup, "backup must persist for later restore-on-failure") + assert.Equal(t, taskCleanup, wm.currentTask) + assert.Equal(t, taskDone, wm.tasks[taskBackup].status) +} + +func TestWizardTaskCompleteErrorEndsRun(t *testing.T) { + t.Parallel() + + m := newTestModel(t) + m.phase = phaseRunning + m.currentTask = taskComposerUpdate + + updated, _ := m.Update(taskCompleteMsg{ + task: taskComposerUpdate, + err: assertErr("composer update failed"), + }) + wm := updated.(wizardModel) + assert.Equal(t, phaseDone, wm.phase) + assert.True(t, wm.finished) + require.Error(t, wm.finalErr) + assert.Equal(t, taskFailed, wm.tasks[taskComposerUpdate].status) +} + +func TestWizardLogLineMsgAppendsAndTrims(t *testing.T) { + t.Parallel() + + m := newTestModel(t) + for i := 0; i < maxLogLines+5; i++ { + updated, _ := m.Update(logLineMsg("line")) + m = updated.(wizardModel) + } + assert.LessOrEqual(t, len(m.logLines), maxLogLines) +} + +type stringErr string + +func (e stringErr) Error() string { return string(e) } + +func assertErr(s string) error { return stringErr(s) } + +func TestWizardRendersAllPhases(t *testing.T) { + t.Parallel() + + phases := []phase{ + phaseWelcome, + phaseSelectVersion, + phaseCompatCheck, + phaseCompatResult, + phaseReview, + phaseRunning, + phaseDone, + } + + for _, p := range phases { + p := p + t.Run(t.Name(), func(t *testing.T) { + m := newTestModel(t) + m.phase = p + m.targetVersion = "6.6.4.0" + out := m.viewContent() + assert.NotEmpty(t, out, "phase %d should render content", p) + }) + } +} From 3bfc5936f4b7752277d2dc0117c31fc30db69170 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 26 May 2026 15:03:41 +0000 Subject: [PATCH 03/14] feat(project): require clean git tree before `project upgrade` The upgrade rewrites composer.json, deletes recipe-managed files, and drops incompatible plugins. Mixing those rewrites with unrelated uncommitted changes makes it hard to review the diff or roll back, so the command now refuses to run with a dirty working tree. - Adds `git.IsRepository` and `git.WorkingTreeStatus` helpers so other commands can reuse the same checks. - When the project directory is not inside a git working tree the check is skipped (greenfield projects, tarball-installed copies). - The error message lists up to ten changed paths and points at `--allow-dirty` as the explicit override. --- cmd/project/project_upgrade.go | 48 +++++++++++++++++++++ cmd/project/project_upgrade_test.go | 65 +++++++++++++++++++++++++++++ internal/git/git.go | 30 +++++++++++++ internal/git/git_test.go | 42 +++++++++++++++++++ 4 files changed, 185 insertions(+) create mode 100644 cmd/project/project_upgrade_test.go diff --git a/cmd/project/project_upgrade.go b/cmd/project/project_upgrade.go index e7b69c34..00f897f0 100644 --- a/cmd/project/project_upgrade.go +++ b/cmd/project/project_upgrade.go @@ -7,6 +7,7 @@ import ( "os" "path" "strconv" + "strings" "time" "charm.land/huh/v2" @@ -19,6 +20,7 @@ import ( "github.com/shopware/shopware-cli/internal/executor" "github.com/shopware/shopware-cli/internal/extension" "github.com/shopware/shopware-cli/internal/flexmigrator" + "github.com/shopware/shopware-cli/internal/git" "github.com/shopware/shopware-cli/internal/projectupgrade" "github.com/shopware/shopware-cli/internal/system" "github.com/shopware/shopware-cli/internal/tracking" @@ -57,6 +59,11 @@ bin/console system:update:prepare and system:update:finish.`, log.Infof("Current Shopware version: %s", currentVersion.String()) + allowDirty, _ := cmd.Flags().GetBool("allow-dirty") + if err := ensureCleanGitTree(ctx, projectRoot, allowDirty); err != nil { + return err + } + allVersions, err := extension.GetShopwareVersions(ctx) if err != nil { return fmt.Errorf("failed to fetch available Shopware versions: %w", err) @@ -346,7 +353,48 @@ func trackUpgrade(ctx context.Context, fromVersion, toVersion, status string) { }) } +// ensureCleanGitTree aborts the upgrade if projectRoot is inside a git +// working tree that has uncommitted changes. The check is skipped when the +// directory is not a git repository (greenfield projects, vendored copies) +// or when --allow-dirty was passed. +func ensureCleanGitTree(ctx context.Context, projectRoot string, allowDirty bool) error { + if allowDirty { + return nil + } + + if !git.IsRepository(ctx, projectRoot) { + return nil + } + + changes, err := git.WorkingTreeStatus(ctx, projectRoot) + if err != nil { + return fmt.Errorf("could not read git working tree status: %w", err) + } + + if len(changes) == 0 { + return nil + } + + preview := changes + const maxPreview = 10 + suffix := "" + if len(preview) > maxPreview { + preview = preview[:maxPreview] + suffix = fmt.Sprintf("\n … and %d more", len(changes)-maxPreview) + } + + return fmt.Errorf( + "the upgrade rewrites composer.json and removes recipe-managed files, so the working tree must be clean.\n"+ + "%d uncommitted change(s) detected in %s:\n %s%s\n\nCommit or stash your changes, or rerun with --allow-dirty to override.", + len(changes), + projectRoot, + strings.Join(preview, "\n "), + suffix, + ) +} + func init() { projectRootCmd.AddCommand(projectUpgradeCmd) projectUpgradeCmd.Flags().String("to", "", "Target Shopware version. Skips the interactive wizard.") + projectUpgradeCmd.Flags().Bool("allow-dirty", false, "Allow running the upgrade even when the git working tree has uncommitted changes.") } diff --git a/cmd/project/project_upgrade_test.go b/cmd/project/project_upgrade_test.go new file mode 100644 index 00000000..9d871c96 --- /dev/null +++ b/cmd/project/project_upgrade_test.go @@ -0,0 +1,65 @@ +package project + +import ( + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func gitCmd(t *testing.T, dir string, args ...string) { + t.Helper() + c := exec.CommandContext(t.Context(), "git", args...) + c.Dir = dir + out, err := c.CombinedOutput() + if err != nil { + t.Fatalf("git %v failed: %s", args, string(out)) + } +} + +func setupTestRepo(t *testing.T) string { + t.Helper() + dir := t.TempDir() + gitCmd(t, dir, "init") + gitCmd(t, dir, "config", "commit.gpgsign", "false") + gitCmd(t, dir, "config", "user.name", "test") + gitCmd(t, dir, "config", "user.email", "test@example.com") + require.NoError(t, os.WriteFile(filepath.Join(dir, "seed"), []byte("seed"), 0o644)) + gitCmd(t, dir, "add", "seed") + gitCmd(t, dir, "commit", "-m", "seed", "--no-verify", "--no-gpg-sign") + return dir +} + +func TestEnsureCleanGitTreeSkipsNonRepo(t *testing.T) { + t.Parallel() + dir := t.TempDir() + assert.NoError(t, ensureCleanGitTree(t.Context(), dir, false)) +} + +func TestEnsureCleanGitTreeAllowsCleanRepo(t *testing.T) { + t.Parallel() + dir := setupTestRepo(t) + assert.NoError(t, ensureCleanGitTree(t.Context(), dir, false)) +} + +func TestEnsureCleanGitTreeRejectsDirtyRepo(t *testing.T) { + t.Parallel() + dir := setupTestRepo(t) + require.NoError(t, os.WriteFile(filepath.Join(dir, "untracked"), []byte("x"), 0o644)) + + err := ensureCleanGitTree(t.Context(), dir, false) + require.Error(t, err) + assert.Contains(t, err.Error(), "working tree must be clean") + assert.Contains(t, err.Error(), "untracked") +} + +func TestEnsureCleanGitTreeAllowDirtyFlagBypassesCheck(t *testing.T) { + t.Parallel() + dir := setupTestRepo(t) + require.NoError(t, os.WriteFile(filepath.Join(dir, "untracked"), []byte("x"), 0o644)) + + assert.NoError(t, ensureCleanGitTree(t.Context(), dir, true)) +} diff --git a/internal/git/git.go b/internal/git/git.go index d6e96883..d7de18c7 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -200,3 +200,33 @@ func Init(ctx context.Context, repo string) error { _, err := runGit(ctx, repo, "init") return err } + +// IsRepository reports whether path is inside a git working tree. +// Returns false (no error) when git is not installed or the directory is not +// tracked by git. +func IsRepository(ctx context.Context, path string) bool { + cmd := exec.CommandContext(ctx, "git", "rev-parse", "--is-inside-work-tree") + cmd.Dir = path + out, err := cmd.Output() + if err != nil { + return false + } + return strings.TrimSpace(string(out)) == "true" +} + +// WorkingTreeStatus reports the porcelain status of the working tree at repo. +// It returns the raw lines of `git status --porcelain`, one entry per changed +// file. An empty slice means the working tree is clean. +func WorkingTreeStatus(ctx context.Context, repo string) ([]string, error) { + cmd := exec.CommandContext(ctx, "git", "status", "--porcelain") + cmd.Dir = repo + out, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("git status: %w", err) + } + trimmed := strings.TrimRight(string(out), "\n") + if trimmed == "" { + return nil, nil + } + return strings.Split(trimmed, "\n"), nil +} diff --git a/internal/git/git_test.go b/internal/git/git_test.go index bfba1e6e..baadde9f 100644 --- a/internal/git/git_test.go +++ b/internal/git/git_test.go @@ -115,3 +115,45 @@ func prepareRepository(t *testing.T, tmpDir string) { runCommand(t, tmpDir, "config", "user.name", "test") runCommand(t, tmpDir, "config", "user.email", "test@test.de") } + +func TestIsRepositoryFalseForPlainDir(t *testing.T) { + t.Parallel() + tmpDir := t.TempDir() + assert.False(t, IsRepository(t.Context(), tmpDir)) +} + +func TestIsRepositoryTrueForInitializedRepo(t *testing.T) { + t.Parallel() + tmpDir := t.TempDir() + prepareRepository(t, tmpDir) + assert.True(t, IsRepository(t.Context(), tmpDir)) +} + +func TestWorkingTreeStatusCleanRepo(t *testing.T) { + t.Parallel() + tmpDir := t.TempDir() + prepareRepository(t, tmpDir) + _ = os.WriteFile(filepath.Join(tmpDir, "a"), []byte("hi"), 0o644) + runCommand(t, tmpDir, "add", "a") + runCommand(t, tmpDir, "commit", "-m", "initial", "--no-verify", "--no-gpg-sign") + + lines, err := WorkingTreeStatus(t.Context(), tmpDir) + assert.NoError(t, err) + assert.Empty(t, lines, "freshly committed repo should be clean") +} + +func TestWorkingTreeStatusReportsUntrackedAndModified(t *testing.T) { + t.Parallel() + tmpDir := t.TempDir() + prepareRepository(t, tmpDir) + _ = os.WriteFile(filepath.Join(tmpDir, "tracked.txt"), []byte("hi"), 0o644) + runCommand(t, tmpDir, "add", "tracked.txt") + runCommand(t, tmpDir, "commit", "-m", "initial", "--no-verify", "--no-gpg-sign") + + _ = os.WriteFile(filepath.Join(tmpDir, "tracked.txt"), []byte("modified"), 0o644) + _ = os.WriteFile(filepath.Join(tmpDir, "untracked.txt"), []byte("new"), 0o644) + + lines, err := WorkingTreeStatus(t.Context(), tmpDir) + assert.NoError(t, err) + assert.Len(t, lines, 2) +} From 54bf9c9e944032afb6c2e4df0ff872a5b25bd435 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 26 May 2026 15:34:09 +0000 Subject: [PATCH 04/14] feat(project): bump plugin constraints + require composer-managed plugins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before doing anything destructive, `project upgrade` now requires every directory under custom/plugins/ to be tracked by composer (i.e. appear in vendor/composer/installed.json). When plain file-drop plugins are detected the upgrade aborts with a pointer at `project autofix composer-plugins`. The `--allow-non-composer` flag opts out for projects that have not migrated yet. When a composer-managed plugin's declared shopware/core constraint is not satisfied by the upgrade target, the resolver now queries a package registry (repo.packagist.org for plain composer packages, packages.shopware.com for store.shopware.com/* packages) for the newest release whose require.shopware/core does satisfy the target and rewrites the composer.json constraint to "^". Only when no compatible release is found does the plugin fall back to being dropped, matching the old behaviour. The Shopware Packages token is read from SHOPWARE_PACKAGES_TOKEN or the project's auth.json. When neither is present and the project has store plugins the interactive flow prompts for the token (and skips store lookups gracefully if the prompt is left empty). The wizard's "Done" card now lists bumped constraints (old → new) in addition to the removed plugins, so users can see exactly what shifted. Tests: 9 new tests covering the resolver (bump, remove, registry error, no installed.json), FindNonComposerPlugins, and the ensureAllPluginsAreComposerManaged pre-flight check. All packages pass. --- cmd/project/project_upgrade.go | 107 ++++++++- cmd/project/project_upgrade_test.go | 49 ++++ internal/projectupgrade/plugins.go | 292 ++++++++++++++++++++---- internal/projectupgrade/plugins_test.go | 223 +++++++++++++++--- internal/projectupgrade/registry.go | 230 +++++++++++++++++++ internal/projectupgrade/wizard.go | 78 +++++-- 6 files changed, 886 insertions(+), 93 deletions(-) create mode 100644 internal/projectupgrade/registry.go diff --git a/cmd/project/project_upgrade.go b/cmd/project/project_upgrade.go index 00f897f0..95ecfc2f 100644 --- a/cmd/project/project_upgrade.go +++ b/cmd/project/project_upgrade.go @@ -21,6 +21,7 @@ import ( "github.com/shopware/shopware-cli/internal/extension" "github.com/shopware/shopware-cli/internal/flexmigrator" "github.com/shopware/shopware-cli/internal/git" + "github.com/shopware/shopware-cli/internal/packagist" "github.com/shopware/shopware-cli/internal/projectupgrade" "github.com/shopware/shopware-cli/internal/system" "github.com/shopware/shopware-cli/internal/tracking" @@ -64,6 +65,11 @@ bin/console system:update:prepare and system:update:finish.`, return err } + allowNonComposer, _ := cmd.Flags().GetBool("allow-non-composer") + if err := ensureAllPluginsAreComposerManaged(projectRoot, allowNonComposer); err != nil { + return err + } + allVersions, err := extension.GetShopwareVersions(ctx) if err != nil { return fmt.Errorf("failed to fetch available Shopware versions: %w", err) @@ -92,6 +98,11 @@ bin/console system:update:prepare and system:update:finish.`, extensions = nil } + registry, err := buildRegistry(cmd, projectRoot) + if err != nil { + return err + } + target, success, err := projectupgrade.RunWizard(projectupgrade.WizardOptions{ ProjectRoot: projectRoot, ComposerJSONPath: composerJsonPath, @@ -99,6 +110,7 @@ bin/console system:update:prepare and system:update:finish.`, UpdateVersions: updateVersions, Extensions: extensions, Executor: cmdExecutor, + Registry: registry, }) status := "ok" @@ -162,14 +174,20 @@ func runUpgradeHeadless(cmd *cobra.Command, projectRoot, composerJsonPath string } log.Infof("Checking custom plugins for incompatibilities") - removed, err := projectupgrade.RemoveIncompatiblePlugins(composerJsonPath, targetVersion) + registry, _ := buildRegistry(cmd, projectRoot) + result, err := projectupgrade.ResolveIncompatiblePlugins(ctx, composerJsonPath, targetVersion, registry) if err != nil { restoreComposerJson(ctx, composerJsonPath, backup) - return fmt.Errorf("remove incompatible plugins: %w", err) + return fmt.Errorf("resolve incompatible plugins: %w", err) } - for _, name := range removed { - log.Infof("Removed incompatible plugin %s from composer.json. Re-require it once a compatible version is published.", tui.YellowText.Render(name)) + if result != nil { + for _, action := range result.Bumped() { + log.Infof("Bumped %s: %s → %s", tui.YellowText.Render(action.Name), action.OldConstraint, action.NewConstraint) + } + for _, action := range result.Removed() { + log.Infof("Removed incompatible plugin %s (%s). Re-require it once a compatible version is published.", tui.YellowText.Render(action.Name), action.Reason) + } } log.Infof("Updating composer.json to %s", targetVersion) @@ -393,8 +411,89 @@ func ensureCleanGitTree(ctx context.Context, projectRoot string, allowDirty bool ) } +// ensureAllPluginsAreComposerManaged aborts when custom/plugins/ contains +// directories that are not tracked by composer. The upgrade can only bump +// plugin constraints in composer.json, so out-of-band drops would otherwise +// silently keep stale code on disk. +func ensureAllPluginsAreComposerManaged(projectRoot string, allow bool) error { + if allow { + return nil + } + + orphans, err := projectupgrade.FindNonComposerPlugins(projectRoot) + if err != nil { + return fmt.Errorf("scan custom/plugins: %w", err) + } + if len(orphans) == 0 { + return nil + } + + return fmt.Errorf( + "the upgrade can only bump composer-managed plugins, but %d directory/ies in custom/plugins/ are not tracked by composer:\n %s\n\nRun `shopware-cli project autofix composer-plugins` to migrate them, or rerun with --allow-non-composer to override.", + len(orphans), + strings.Join(orphans, "\n "), + ) +} + +// buildRegistry constructs the package registry used to look up newer +// compatible plugin versions. The Shopware Packages token is read from the +// SHOPWARE_PACKAGES_TOKEN env var, the project's auth.json, or — in +// interactive mode — prompted from the user if the project has store +// plugins. Missing tokens degrade gracefully: store lookups fall back to the +// "remove plugin" behaviour. +func buildRegistry(cmd *cobra.Command, projectRoot string) (projectupgrade.Registry, error) { + token := storeTokenFromAuthJSON(projectRoot) + + hasStorePlugins, err := projectHasStorePlugins(projectRoot) + if err != nil { + logging.FromContext(cmd.Context()).Debugf("could not inspect installed.json: %v", err) + } + + if token == "" && hasStorePlugins && system.IsInteractionEnabled(cmd.Context()) { + var entered string + if err := huh.NewInput(). + Title("Shopware Packages token (packages.shopware.com)"). + Description("Used to look up newer compatible versions of store plugins. Leave empty to skip store lookups."). + Value(&entered). + EchoMode(huh.EchoModePassword). + Run(); err != nil { + return nil, err + } + token = strings.TrimSpace(entered) + } + + return projectupgrade.DefaultRegistry(token), nil +} + +func storeTokenFromAuthJSON(projectRoot string) string { + if v := strings.TrimSpace(os.Getenv("SHOPWARE_PACKAGES_TOKEN")); v != "" { + return v + } + + authPath := path.Join(projectRoot, "auth.json") + auth, err := packagist.ReadComposerAuth(authPath) + if err != nil { + return "" + } + return strings.TrimSpace(auth.BearerAuth["packages.shopware.com"]) +} + +func projectHasStorePlugins(projectRoot string) (bool, error) { + composerJson, err := packagist.ReadComposerJson(path.Join(projectRoot, "composer.json")) + if err != nil { + return false, err + } + for name := range composerJson.Require { + if strings.HasPrefix(name, "store.shopware.com/") { + return true, nil + } + } + return false, nil +} + func init() { projectRootCmd.AddCommand(projectUpgradeCmd) projectUpgradeCmd.Flags().String("to", "", "Target Shopware version. Skips the interactive wizard.") projectUpgradeCmd.Flags().Bool("allow-dirty", false, "Allow running the upgrade even when the git working tree has uncommitted changes.") + projectUpgradeCmd.Flags().Bool("allow-non-composer", false, "Allow running the upgrade even when custom/plugins/ contains plugins not managed by composer.") } diff --git a/cmd/project/project_upgrade_test.go b/cmd/project/project_upgrade_test.go index 9d871c96..d7127912 100644 --- a/cmd/project/project_upgrade_test.go +++ b/cmd/project/project_upgrade_test.go @@ -1,6 +1,7 @@ package project import ( + "encoding/json" "os" "os/exec" "path/filepath" @@ -10,6 +11,10 @@ import ( "github.com/stretchr/testify/require" ) +func jsonMarshal(v any) ([]byte, error) { + return json.MarshalIndent(v, "", " ") +} + func gitCmd(t *testing.T, dir string, args ...string) { t.Helper() c := exec.CommandContext(t.Context(), "git", args...) @@ -63,3 +68,47 @@ func TestEnsureCleanGitTreeAllowDirtyFlagBypassesCheck(t *testing.T) { assert.NoError(t, ensureCleanGitTree(t.Context(), dir, true)) } + +func writeInstalledJSON(t *testing.T, projectDir string, packages []map[string]any) { + t.Helper() + installedDir := filepath.Join(projectDir, "vendor", "composer") + require.NoError(t, os.MkdirAll(installedDir, 0o755)) + body, err := jsonMarshal(map[string]any{"packages": packages}) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(installedDir, "installed.json"), body, 0o644)) +} + +func TestEnsureAllPluginsAreComposerManagedAllowsTrackedDirectories(t *testing.T) { + t.Parallel() + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, "custom", "plugins", "Tracked"), 0o755)) + writeInstalledJSON(t, dir, []map[string]any{ + { + "name": "vendor/tracked", + "type": "shopware-platform-plugin", + "install-path": "../../custom/plugins/Tracked", + }, + }) + + assert.NoError(t, ensureAllPluginsAreComposerManaged(dir, false)) +} + +func TestEnsureAllPluginsAreComposerManagedRejectsOrphanedDirectory(t *testing.T) { + t.Parallel() + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, "custom", "plugins", "Orphan"), 0o755)) + + err := ensureAllPluginsAreComposerManaged(dir, false) + require.Error(t, err) + assert.Contains(t, err.Error(), "not tracked by composer") + assert.Contains(t, err.Error(), "Orphan") + assert.Contains(t, err.Error(), "autofix composer-plugins") +} + +func TestEnsureAllPluginsAreComposerManagedAllowFlagBypasses(t *testing.T) { + t.Parallel() + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, "custom", "plugins", "Orphan"), 0o755)) + + assert.NoError(t, ensureAllPluginsAreComposerManaged(dir, true)) +} diff --git a/internal/projectupgrade/plugins.go b/internal/projectupgrade/plugins.go index aaf7d8f9..b06adfde 100644 --- a/internal/projectupgrade/plugins.go +++ b/internal/projectupgrade/plugins.go @@ -1,12 +1,14 @@ package projectupgrade import ( + "context" "encoding/json" "errors" "fmt" "io/fs" "os" "path/filepath" + "sort" "strings" "github.com/shyim/go-version" @@ -38,15 +40,64 @@ type installedJSON struct { Packages []installedPackage `json:"packages"` } -// RemoveIncompatiblePlugins drops symlinked custom/plugins/* entries from -// composer.json when their declared Shopware constraint is not satisfied by -// targetVersion. Composer would otherwise fail the update because the plugin -// pins us to an older shopware/core. Mirrors PluginCompatibility from the -// shopware/web-installer. +// PluginAction describes how the resolver dealt with one incompatible plugin. +type PluginAction struct { + // Name is the composer package name (e.g. "store.shopware.com/swagcms"). + Name string + // OldConstraint is the constraint that was in composer.json before + // resolution. + OldConstraint string + // NewConstraint is the constraint that was written to composer.json. + // Empty when Removed is true. + NewConstraint string + // NewVersion is the package version the new constraint points at. + // Empty when Removed is true. + NewVersion string + // Removed is true when no compatible version could be found and the + // plugin was dropped from composer.json. + Removed bool + // Reason is a short human-readable explanation surfaced in the UI. + Reason string +} + +// ResolveResult summarises the actions the resolver took. +type ResolveResult struct { + Actions []PluginAction +} + +// Bumped returns the actions that resulted in a constraint bump. +func (r *ResolveResult) Bumped() []PluginAction { + out := make([]PluginAction, 0, len(r.Actions)) + for _, a := range r.Actions { + if !a.Removed { + out = append(out, a) + } + } + return out +} + +// Removed returns the actions that resulted in the plugin being dropped. +func (r *ResolveResult) Removed() []PluginAction { + out := make([]PluginAction, 0, len(r.Actions)) + for _, a := range r.Actions { + if a.Removed { + out = append(out, a) + } + } + return out +} + +// ResolveIncompatiblePlugins inspects every shopware platform plugin under +// custom/plugins/* (as listed in vendor/composer/installed.json). For each +// plugin whose installed Shopware constraint is not satisfied by +// targetVersion the resolver tries to find a newer release on the supplied +// registry; if one exists, the composer.json constraint is bumped to +// "^". When no compatible version is available the plugin is +// removed from composer.json so composer update doesn't fail. // -// Returns the list of removed plugin names so the caller can report what was -// removed. -func RemoveIncompatiblePlugins(composerJsonPath, targetVersion string) ([]string, error) { +// registry may be nil, in which case every incompatible plugin is removed +// (the previous behaviour). +func ResolveIncompatiblePlugins(ctx context.Context, composerJsonPath, targetVersion string, registry Registry) (*ResolveResult, error) { projectDir := filepath.Dir(composerJsonPath) installedPath := filepath.Join(projectDir, "vendor", "composer", "installed.json") @@ -54,14 +105,12 @@ func RemoveIncompatiblePlugins(composerJsonPath, targetVersion string) ([]string data, err := os.ReadFile(installedPath) if err != nil { if errors.Is(err, fs.ErrNotExist) { - return nil, nil + return &ResolveResult{}, nil } - return nil, fmt.Errorf("read installed.json: %w", err) } var installed installedJSON - if err := json.Unmarshal(data, &installed); err != nil { return nil, fmt.Errorf("parse installed.json: %w", err) } @@ -71,26 +120,22 @@ func RemoveIncompatiblePlugins(composerJsonPath, targetVersion string) ([]string return nil, fmt.Errorf("parse target version: %w", err) } - incompatible := make([]string, 0) - + incompatible := make([]installedPackage, 0) for _, pkg := range installed.Packages { if pkg.Type != composerPluginType { continue } - if !isInstalledUnderCustomPlugins(projectDir, pkg.InstallPath) { continue } - if pluginSatisfies(pkg.Require, target) { continue } - - incompatible = append(incompatible, pkg.Name) + incompatible = append(incompatible, pkg) } if len(incompatible) == 0 { - return nil, nil + return &ResolveResult{}, nil } composerJson, err := packagist.ReadComposerJson(composerJsonPath) @@ -98,62 +143,227 @@ func RemoveIncompatiblePlugins(composerJsonPath, targetVersion string) ([]string return nil, err } - removed := make([]string, 0, len(incompatible)) + result := &ResolveResult{} + + for _, pkg := range incompatible { + old, ok := composerJson.Require[pkg.Name] + if !ok { + continue + } - for _, name := range incompatible { - if _, ok := composerJson.Require[name]; ok { - delete(composerJson.Require, name) - removed = append(removed, name) + action := PluginAction{Name: pkg.Name, OldConstraint: old} + + newVersion, err := findCompatibleVersion(ctx, registry, pkg.Name, target) + if err != nil || newVersion == "" { + delete(composerJson.Require, pkg.Name) + action.Removed = true + action.Reason = "no compatible release found" + if err != nil && !errors.Is(err, ErrRegistryUnavailable) { + action.Reason = "registry lookup failed: " + err.Error() + } + result.Actions = append(result.Actions, action) + continue } + + newConstraint := bumpConstraint(newVersion) + composerJson.Require[pkg.Name] = newConstraint + action.NewConstraint = newConstraint + action.NewVersion = newVersion + action.Reason = fmt.Sprintf("bumped to %s", newConstraint) + result.Actions = append(result.Actions, action) } - if len(removed) == 0 { - return nil, nil + if len(result.Actions) == 0 { + return result, nil } if err := composerJson.Save(); err != nil { return nil, err } + return result, nil +} + +func findCompatibleVersion(ctx context.Context, registry Registry, name string, target *version.Version) (string, error) { + if registry == nil { + return "", ErrRegistryUnavailable + } + + versions, err := registry.GetPackageVersions(ctx, name) + if err != nil { + return "", err + } + if len(versions) == 0 { + return "", nil + } + + parsed := make([]packagist.ComposerPackageVersion, 0, len(versions)) + for _, v := range versions { + if isPreReleaseVersion(v.Version) { + continue + } + if !satisfiesShopwareTarget(v.Require, target) { + continue + } + parsed = append(parsed, v) + } - return removed, nil + if len(parsed) == 0 { + return "", nil + } + + sort.Slice(parsed, func(i, j int) bool { + vi, errI := version.NewVersion(strings.TrimPrefix(parsed[i].Version, "v")) + vj, errJ := version.NewVersion(strings.TrimPrefix(parsed[j].Version, "v")) + if errI != nil || errJ != nil { + return parsed[i].Version > parsed[j].Version + } + return vi.GreaterThan(vj) + }) + + return strings.TrimPrefix(parsed[0].Version, "v"), nil +} + +func isPreReleaseVersion(v string) bool { + lower := strings.ToLower(v) + for _, marker := range []string{"-rc", "-beta", "-alpha", "-dev"} { + if strings.Contains(lower, marker) { + return true + } + } + return false +} + +func satisfiesShopwareTarget(requires map[string]string, target *version.Version) bool { + if len(requires) == 0 { + // No shopware/core constraint declared — assume compatible. + return true + } + for dep, constraint := range requires { + if !containsString(pluginShopwarePackages, dep) { + continue + } + c, err := version.NewConstraint(constraint) + if err != nil { + return false + } + if !c.Check(target) { + return false + } + } + return true +} + +// bumpConstraint converts a concrete version (e.g. "2.3.4") into a caret +// constraint ("^2.3.4") suitable for composer.json. Versions that already +// look like a constraint are passed through unchanged. +func bumpConstraint(version string) string { + if version == "" { + return version + } + if strings.ContainsAny(version, "^~><*|, ") { + return version + } + return "^" + version +} + +// FindNonComposerPlugins returns directories under custom/plugins/ that are +// not tracked by composer (no entry in vendor/composer/installed.json). +// Returns an empty slice when no installed.json is present. +func FindNonComposerPlugins(projectRoot string) ([]string, error) { + customPlugins := filepath.Join(projectRoot, "custom", "plugins") + entries, err := os.ReadDir(customPlugins) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil, nil + } + return nil, fmt.Errorf("read %s: %w", customPlugins, err) + } + + installedPath := filepath.Join(projectRoot, "vendor", "composer", "installed.json") + composerTracked := make(map[string]struct{}) + if data, err := os.ReadFile(installedPath); err == nil { + var installed installedJSON + if jsonErr := json.Unmarshal(data, &installed); jsonErr == nil { + for _, pkg := range installed.Packages { + if dir, ok := installedDirName(projectRoot, pkg.InstallPath); ok { + composerTracked[dir] = struct{}{} + } + } + } + } + + orphans := make([]string, 0) + for _, entry := range entries { + if !entry.IsDir() { + continue + } + if strings.HasPrefix(entry.Name(), ".") { + continue + } + if _, tracked := composerTracked[entry.Name()]; tracked { + continue + } + orphans = append(orphans, entry.Name()) + } + + sort.Strings(orphans) + return orphans, nil +} + +func installedDirName(projectRoot, installPath string) (string, bool) { + if installPath == "" { + return "", false + } + abs := installPath + if !filepath.IsAbs(abs) { + abs = filepath.Join(projectRoot, "vendor", "composer", installPath) + } + resolved, err := filepath.EvalSymlinks(abs) + if err != nil { + resolved = filepath.Clean(abs) + } + customPlugins := filepath.Join(projectRoot, "custom", "plugins") + resolvedCustom, err := filepath.EvalSymlinks(customPlugins) + if err != nil { + resolvedCustom = filepath.Clean(customPlugins) + } + rel, err := filepath.Rel(resolvedCustom, resolved) + if err != nil { + return "", false + } + if rel == "." || rel == "" || strings.HasPrefix(rel, "..") { + return "", false + } + if strings.ContainsRune(rel, filepath.Separator) { + return "", false + } + return rel, true } func isInstalledUnderCustomPlugins(projectDir, installPath string) bool { if installPath == "" { return false } - - // install-path is recorded relative to vendor/composer. absPath := installPath if !filepath.IsAbs(absPath) { absPath = filepath.Join(projectDir, "vendor", "composer", installPath) } - resolved, err := filepath.EvalSymlinks(absPath) if err != nil { resolved = filepath.Clean(absPath) } - customPlugins := filepath.Join(projectDir, "custom", "plugins") resolvedCustom, err := filepath.EvalSymlinks(customPlugins) if err != nil { resolvedCustom = filepath.Clean(customPlugins) } - rel, err := filepath.Rel(resolvedCustom, resolved) if err != nil { return false } - - if rel == "." || rel == "" { - return false - } - - if strings.HasPrefix(rel, "..") { + if rel == "." || rel == "" || strings.HasPrefix(rel, "..") { return false } - - // Direct child of custom/plugins (a single plugin directory). return !strings.ContainsRune(rel, filepath.Separator) } @@ -162,17 +372,14 @@ func pluginSatisfies(requires map[string]string, target *version.Version) bool { if !containsString(pluginShopwarePackages, dep) { continue } - c, err := version.NewConstraint(constraint) if err != nil { continue } - if !c.Check(target) { return false } } - return true } @@ -182,6 +389,5 @@ func containsString(haystack []string, needle string) bool { return true } } - return false } diff --git a/internal/projectupgrade/plugins_test.go b/internal/projectupgrade/plugins_test.go index e2dc0330..5c3a313d 100644 --- a/internal/projectupgrade/plugins_test.go +++ b/internal/projectupgrade/plugins_test.go @@ -1,6 +1,7 @@ package projectupgrade import ( + "context" "encoding/json" "os" "path/filepath" @@ -8,6 +9,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/shopware/shopware-cli/internal/packagist" ) func writeInstalledJSON(t *testing.T, projectDir string, packages []installedPackage) { @@ -21,21 +24,33 @@ func writeInstalledJSON(t *testing.T, projectDir string, packages []installedPac require.NoError(t, os.WriteFile(filepath.Join(installedDir, "installed.json"), data, 0o644)) } -func TestRemoveIncompatiblePluginsRemovesCustomPluginsThatDontSatisfyTarget(t *testing.T) { +// fakeRegistry is a test double for Registry that returns whatever the test +// configures. +type fakeRegistry struct { + versions map[string][]packagist.ComposerPackageVersion + err error +} + +func (f *fakeRegistry) GetPackageVersions(_ context.Context, name string) ([]packagist.ComposerPackageVersion, error) { + if f.err != nil { + return nil, f.err + } + return f.versions[name], nil +} + +func TestResolveIncompatiblePluginsRemovesWhenNoRegistry(t *testing.T) { t.Parallel() dir := t.TempDir() composerJsonPath := filepath.Join(dir, "composer.json") require.NoError(t, os.MkdirAll(filepath.Join(dir, "custom", "plugins", "Incompatible"), 0o755)) - require.NoError(t, os.MkdirAll(filepath.Join(dir, "custom", "plugins", "Compatible"), 0o755)) writeJSON(t, composerJsonPath, map[string]any{ "name": "shopware/production", "require": map[string]any{ "shopware/core": "6.5.8.0", "vendor/incompat": "*", - "vendor/compat": "*", "unrelated/package": "^1.0", }, }) @@ -45,41 +60,150 @@ func TestRemoveIncompatiblePluginsRemovesCustomPluginsThatDontSatisfyTarget(t *t Name: "vendor/incompat", Type: composerPluginType, InstallPath: "../../custom/plugins/Incompatible", - Require: map[string]string{ - "shopware/core": "~6.5.0", - }, + Require: map[string]string{"shopware/core": "~6.5.0"}, + }, + }) + + result, err := ResolveIncompatiblePlugins(t.Context(), composerJsonPath, "6.6.4.0", nil) + require.NoError(t, err) + require.Len(t, result.Removed(), 1) + assert.Empty(t, result.Bumped()) + assert.Equal(t, "vendor/incompat", result.Removed()[0].Name) + + out := readJSON(t, composerJsonPath) + requireMap := out["require"].(map[string]any) + _, stillThere := requireMap["vendor/incompat"] + assert.False(t, stillThere) +} + +func TestResolveIncompatiblePluginsBumpsConstraintWhenRegistryHasCompatibleVersion(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + composerJsonPath := filepath.Join(dir, "composer.json") + + require.NoError(t, os.MkdirAll(filepath.Join(dir, "custom", "plugins", "Incompatible"), 0o755)) + + writeJSON(t, composerJsonPath, map[string]any{ + "name": "shopware/production", + "require": map[string]any{ + "shopware/core": "6.5.8.0", + "vendor/incompat": "^1.0", }, + }) + + writeInstalledJSON(t, dir, []installedPackage{ { - Name: "vendor/compat", + Name: "vendor/incompat", Type: composerPluginType, - InstallPath: "../../custom/plugins/Compatible", - Require: map[string]string{ - "shopware/core": "^6.5", + InstallPath: "../../custom/plugins/Incompatible", + Require: map[string]string{"shopware/core": "~6.5.0"}, + }, + }) + + registry := &fakeRegistry{ + versions: map[string][]packagist.ComposerPackageVersion{ + "vendor/incompat": { + {Version: "1.0.0", Require: map[string]string{"shopware/core": "~6.5.0"}}, + {Version: "2.0.0", Require: map[string]string{"shopware/core": "^6.5 | ^6.6"}}, + {Version: "2.1.0", Require: map[string]string{"shopware/core": "^6.6"}}, + {Version: "3.0.0-rc1", Require: map[string]string{"shopware/core": "^6.6"}}, // skipped: prerelease }, }, + } + + result, err := ResolveIncompatiblePlugins(t.Context(), composerJsonPath, "6.6.4.0", registry) + require.NoError(t, err) + require.Len(t, result.Bumped(), 1) + assert.Empty(t, result.Removed()) + + bumped := result.Bumped()[0] + assert.Equal(t, "vendor/incompat", bumped.Name) + assert.Equal(t, "^1.0", bumped.OldConstraint) + assert.Equal(t, "2.1.0", bumped.NewVersion) + assert.Equal(t, "^2.1.0", bumped.NewConstraint) + + out := readJSON(t, composerJsonPath) + requireMap := out["require"].(map[string]any) + assert.Equal(t, "^2.1.0", requireMap["vendor/incompat"]) +} + +func TestResolveIncompatiblePluginsRemovesWhenNoCompatibleRelease(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + composerJsonPath := filepath.Join(dir, "composer.json") + + require.NoError(t, os.MkdirAll(filepath.Join(dir, "custom", "plugins", "Incompatible"), 0o755)) + + writeJSON(t, composerJsonPath, map[string]any{ + "name": "shopware/production", + "require": map[string]any{ + "shopware/core": "6.5.8.0", + "vendor/incompat": "^1.0", + }, + }) + + writeInstalledJSON(t, dir, []installedPackage{ { - Name: "vendor/composer-installed", + Name: "vendor/incompat", Type: composerPluginType, - InstallPath: "../vendor/installed", - Require: map[string]string{ - "shopware/core": "~6.5.0", - }, + InstallPath: "../../custom/plugins/Incompatible", + Require: map[string]string{"shopware/core": "~6.5.0"}, }, }) - removed, err := RemoveIncompatiblePlugins(composerJsonPath, "6.6.4.0") + // Only old versions, none compatible with 6.6.4.0. + registry := &fakeRegistry{ + versions: map[string][]packagist.ComposerPackageVersion{ + "vendor/incompat": { + {Version: "1.0.0", Require: map[string]string{"shopware/core": "~6.5.0"}}, + {Version: "1.1.0", Require: map[string]string{"shopware/core": "~6.5.0"}}, + }, + }, + } + + result, err := ResolveIncompatiblePlugins(t.Context(), composerJsonPath, "6.6.4.0", registry) require.NoError(t, err) - assert.Equal(t, []string{"vendor/incompat"}, removed) + assert.Empty(t, result.Bumped()) + require.Len(t, result.Removed(), 1) + assert.Equal(t, "vendor/incompat", result.Removed()[0].Name) + assert.Equal(t, "no compatible release found", result.Removed()[0].Reason) +} - out := readJSON(t, composerJsonPath) - requireMap := out["require"].(map[string]any) - _, stillThere := requireMap["vendor/incompat"] - assert.False(t, stillThere, "incompatible plugin should be removed from composer.json") - assert.Contains(t, requireMap, "vendor/compat", "compatible plugin must remain") - assert.Contains(t, requireMap, "unrelated/package", "unrelated package must remain") +func TestResolveIncompatiblePluginsRegistryErrorFallsBackToRemove(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + composerJsonPath := filepath.Join(dir, "composer.json") + + require.NoError(t, os.MkdirAll(filepath.Join(dir, "custom", "plugins", "Incompatible"), 0o755)) + + writeJSON(t, composerJsonPath, map[string]any{ + "name": "shopware/production", + "require": map[string]any{ + "shopware/core": "6.5.8.0", + "vendor/incompat": "^1.0", + }, + }) + + writeInstalledJSON(t, dir, []installedPackage{ + { + Name: "vendor/incompat", + Type: composerPluginType, + InstallPath: "../../custom/plugins/Incompatible", + Require: map[string]string{"shopware/core": "~6.5.0"}, + }, + }) + + registry := &fakeRegistry{err: assertErr("network down")} + result, err := ResolveIncompatiblePlugins(t.Context(), composerJsonPath, "6.6.4.0", registry) + require.NoError(t, err) + require.Len(t, result.Removed(), 1) + assert.Contains(t, result.Removed()[0].Reason, "network down") } -func TestRemoveIncompatiblePluginsNoInstalledJSONReturnsNil(t *testing.T) { +func TestResolveIncompatiblePluginsNoInstalledJSONReturnsEmpty(t *testing.T) { t.Parallel() dir := t.TempDir() @@ -91,7 +215,54 @@ func TestRemoveIncompatiblePluginsNoInstalledJSONReturnsNil(t *testing.T) { }, }) - removed, err := RemoveIncompatiblePlugins(composerJsonPath, "6.6.4.0") + result, err := ResolveIncompatiblePlugins(t.Context(), composerJsonPath, "6.6.4.0", nil) + require.NoError(t, err) + assert.Empty(t, result.Actions) +} + +func TestFindNonComposerPluginsReportsUntrackedDirectories(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, "custom", "plugins", "TrackedPlugin"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(dir, "custom", "plugins", "UntrackedPlugin"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(dir, "custom", "plugins", "AnotherUntracked"), 0o755)) + + writeInstalledJSON(t, dir, []installedPackage{ + { + Name: "vendor/tracked", + Type: composerPluginType, + InstallPath: "../../custom/plugins/TrackedPlugin", + }, + }) + + orphans, err := FindNonComposerPlugins(dir) + require.NoError(t, err) + assert.Equal(t, []string{"AnotherUntracked", "UntrackedPlugin"}, orphans) +} + +func TestFindNonComposerPluginsNoCustomPluginsDirectory(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + orphans, err := FindNonComposerPlugins(dir) + require.NoError(t, err) + assert.Empty(t, orphans) +} + +func TestFindNonComposerPluginsAllTracked(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, "custom", "plugins", "A"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(dir, "custom", "plugins", "B"), 0o755)) + + writeInstalledJSON(t, dir, []installedPackage{ + {Name: "vendor/a", Type: composerPluginType, InstallPath: "../../custom/plugins/A"}, + {Name: "vendor/b", Type: composerPluginType, InstallPath: "../../custom/plugins/B"}, + }) + + orphans, err := FindNonComposerPlugins(dir) require.NoError(t, err) - assert.Empty(t, removed) + assert.Empty(t, orphans) } diff --git a/internal/projectupgrade/registry.go b/internal/projectupgrade/registry.go new file mode 100644 index 00000000..3ad6b9c8 --- /dev/null +++ b/internal/projectupgrade/registry.go @@ -0,0 +1,230 @@ +package projectupgrade + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "sync" + "time" + + "github.com/shopware/shopware-cli/internal/packagist" +) + +// Registry resolves a composer package name to its available versions. +// Implementations are expected to be safe for use from multiple goroutines. +type Registry interface { + GetPackageVersions(ctx context.Context, name string) ([]packagist.ComposerPackageVersion, error) +} + +// ErrRegistryUnavailable is returned when no backend can resolve the package +// (e.g. a store.shopware.com package when no token is configured). +var ErrRegistryUnavailable = errors.New("registry unavailable for this package") + +// CombinedRegistry routes lookups to the appropriate backend based on the +// package name prefix. +type CombinedRegistry struct { + // Store handles store.shopware.com/* packages. May be nil. + Store Registry + // Packagist handles every other vendor/name combination. Required. + Packagist Registry +} + +func (c *CombinedRegistry) GetPackageVersions(ctx context.Context, name string) ([]packagist.ComposerPackageVersion, error) { + if strings.HasPrefix(name, "store.shopware.com/") { + if c.Store == nil { + return nil, ErrRegistryUnavailable + } + return c.Store.GetPackageVersions(ctx, name) + } + + if c.Packagist == nil { + return nil, ErrRegistryUnavailable + } + return c.Packagist.GetPackageVersions(ctx, name) +} + +var registryHTTPClient = &http.Client{Timeout: 30 * time.Second} + +// PackagistRegistry queries https://repo.packagist.org for any composer +// package's available versions. The package metadata is returned with full +// require/replace info so we can pick a Shopware-compatible release. +type PackagistRegistry struct{} + +type packagistResponse struct { + Minified string `json:"minified"` + Packages map[string][]map[string]json.RawMessage `json:"packages"` +} + +func (p PackagistRegistry) GetPackageVersions(ctx context.Context, name string) ([]packagist.ComposerPackageVersion, error) { + url := fmt.Sprintf("https://repo.packagist.org/p2/%s.json", name) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody) + if err != nil { + return nil, err + } + req.Header.Set("User-Agent", "Shopware CLI") + + resp, err := registryHTTPClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return nil, nil + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("packagist returned %s for %s", resp.Status, name) + } + + var body packagistResponse + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + return nil, fmt.Errorf("decode packagist response: %w", err) + } + + raw, ok := body.Packages[name] + if !ok || len(raw) == 0 { + return nil, nil + } + + if body.Minified != "" { + raw = unminify(raw) + } + + versions := make([]packagist.ComposerPackageVersion, 0, len(raw)) + for _, m := range raw { + payload, err := json.Marshal(m) + if err != nil { + continue + } + var v packagist.ComposerPackageVersion + if err := json.Unmarshal(payload, &v); err != nil { + continue + } + versions = append(versions, v) + } + return versions, nil +} + +// unminify expands the composer v2 minified packages format ("__unset" +// markers and inheritance from the previous entry) into independent records. +func unminify(versions []map[string]json.RawMessage) []map[string]json.RawMessage { + if len(versions) == 0 { + return nil + } + expanded := make([]map[string]json.RawMessage, 0, len(versions)) + var current map[string]json.RawMessage + for _, v := range versions { + if current == nil { + current = cloneRaw(v) + expanded = append(expanded, cloneRaw(current)) + continue + } + for k, val := range v { + if bytes.Equal(val, []byte(`"__unset"`)) { + delete(current, k) + } else { + current[k] = val + } + } + expanded = append(expanded, cloneRaw(current)) + } + return expanded +} + +func cloneRaw(in map[string]json.RawMessage) map[string]json.RawMessage { + out := make(map[string]json.RawMessage, len(in)) + for k, v := range in { + out[k] = v + } + return out +} + +// ShopwareStoreRegistry queries https://packages.shopware.com/packages.json +// for store-managed plugins. The full listing is fetched once and cached for +// the lifetime of the registry instance. +type ShopwareStoreRegistry struct { + Token string + + once sync.Once + loadErr error + packages map[string][]packagist.ComposerPackageVersion +} + +type shopwareStoreResponse struct { + Packages map[string]map[string]packagist.ComposerPackageVersion `json:"packages"` +} + +func (s *ShopwareStoreRegistry) load(ctx context.Context) error { + s.once.Do(func() { + if s.Token == "" { + s.loadErr = ErrRegistryUnavailable + return + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://packages.shopware.com/packages.json", http.NoBody) + if err != nil { + s.loadErr = err + return + } + req.Header.Set("User-Agent", "Shopware CLI") + req.Header.Set("Authorization", "Bearer "+s.Token) + + resp, err := registryHTTPClient.Do(req) + if err != nil { + s.loadErr = err + return + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + s.loadErr = fmt.Errorf("shopware packages returned %s", resp.Status) + return + } + + var body shopwareStoreResponse + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + s.loadErr = fmt.Errorf("decode shopware packages: %w", err) + return + } + + s.packages = make(map[string][]packagist.ComposerPackageVersion, len(body.Packages)) + for name, versions := range body.Packages { + list := make([]packagist.ComposerPackageVersion, 0, len(versions)) + for _, v := range versions { + list = append(list, v) + } + s.packages[name] = list + } + }) + return s.loadErr +} + +func (s *ShopwareStoreRegistry) GetPackageVersions(ctx context.Context, name string) ([]packagist.ComposerPackageVersion, error) { + if err := s.load(ctx); err != nil { + return nil, err + } + + versions, ok := s.packages[name] + if !ok { + return nil, nil + } + return versions, nil +} + +// DefaultRegistry builds a CombinedRegistry that uses packages.shopware.com +// when a store token is provided and falls back to repo.packagist.org for +// everything else. token may be empty; in that case store lookups return +// ErrRegistryUnavailable. +func DefaultRegistry(token string) Registry { + combined := &CombinedRegistry{ + Packagist: PackagistRegistry{}, + } + if token != "" { + combined.Store = &ShopwareStoreRegistry{Token: token} + } + return combined +} diff --git a/internal/projectupgrade/wizard.go b/internal/projectupgrade/wizard.go index 7cb4f85d..d8205ace 100644 --- a/internal/projectupgrade/wizard.go +++ b/internal/projectupgrade/wizard.go @@ -38,6 +38,10 @@ type WizardOptions struct { UpdateVersions []string Extensions map[string]string Executor executor.Executor + // Registry is consulted to find newer compatible versions of plugins + // whose installed shopware/core constraint is no longer satisfied. May + // be nil, in which case incompatible plugins are simply removed. + Registry Registry } type phase int @@ -91,7 +95,7 @@ type ( err error detail string composerBackup []byte - pluginsRemoved []string + pluginActions *ResolveResult } startNextTaskMsg struct{} logLineMsg string @@ -113,7 +117,7 @@ type wizardModel struct { targetVersion string confirmYes bool composerBackup []byte - pluginsRemoved []string + pluginActions *ResolveResult compatUpdates []account_api.UpdateCheckExtensionCompatibility compatHasBlock bool compatErr error @@ -172,7 +176,7 @@ func defaultTasks() []task { return []task{ {label: "Back up composer.json"}, {label: "Clean up stale recipe files"}, - {label: "Remove incompatible custom plugins"}, + {label: "Resolve incompatible custom plugins"}, {label: "Rewrite composer.json"}, {label: "composer update --with-all-dependencies"}, {label: "bin/console system:update:prepare"}, @@ -215,8 +219,8 @@ func (m wizardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.composerBackup != nil { m.composerBackup = msg.composerBackup } - if msg.pluginsRemoved != nil { - m.pluginsRemoved = msg.pluginsRemoved + if msg.pluginActions != nil { + m.pluginActions = msg.pluginActions } if msg.task < len(m.tasks) { if msg.err != nil { @@ -493,20 +497,31 @@ func (m wizardModel) runRemovePlugins() tea.Cmd { target := m.targetVersion idx := taskPlugins restore := m.composerBackup + registry := m.opts.Registry return func() tea.Msg { - removed, err := RemoveIncompatiblePlugins(composerJSONPath, target) + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + result, err := ResolveIncompatiblePlugins(ctx, composerJSONPath, target, registry) if err != nil { _ = os.WriteFile(composerJSONPath, restore, 0o644) return taskCompleteMsg{task: idx, err: err} } - detail := "no incompatibilities" - if len(removed) > 0 { - detail = fmt.Sprintf("removed %d incompatible plugin(s)", len(removed)) + if result == nil { + result = &ResolveResult{} } - if removed == nil { - removed = []string{} + bumped := len(result.Bumped()) + removed := len(result.Removed()) + detail := "no incompatibilities" + switch { + case bumped > 0 && removed > 0: + detail = fmt.Sprintf("bumped %d, removed %d", bumped, removed) + case bumped > 0: + detail = fmt.Sprintf("bumped %d to a compatible version", bumped) + case removed > 0: + detail = fmt.Sprintf("removed %d (no compatible release)", removed) } - return taskCompleteMsg{task: idx, detail: detail, pluginsRemoved: removed} + return taskCompleteMsg{task: idx, detail: detail, pluginActions: result} } } @@ -877,16 +892,39 @@ func (m wizardModel) viewDone() string { b.WriteString("\n\n") b.WriteString(tui.DimStyle.Render("All tasks completed. Verify your shop and run your test suite.")) - if len(m.pluginsRemoved) > 0 { - b.WriteString("\n\n") - b.WriteString(tui.BoldText.Render("Removed incompatible custom plugins:")) - b.WriteString("\n") - for _, name := range m.pluginsRemoved { - b.WriteString(tui.DimStyle.Render(" • ")) - b.WriteString(tui.LabelStyle.Render(name)) + if m.pluginActions != nil { + bumped := m.pluginActions.Bumped() + removed := m.pluginActions.Removed() + + if len(bumped) > 0 { + b.WriteString("\n\n") + b.WriteString(tui.BoldText.Render("Bumped plugin constraints:")) b.WriteString("\n") + for _, action := range bumped { + b.WriteString(tui.DimStyle.Render(" • ")) + b.WriteString(tui.LabelStyle.Render(action.Name)) + b.WriteString(" ") + b.WriteString(tui.DimStyle.Render(action.OldConstraint)) + b.WriteString(" → ") + b.WriteString(lipgloss.NewStyle().Foreground(tui.SuccessColor).Render(action.NewConstraint)) + b.WriteString("\n") + } + } + + if len(removed) > 0 { + b.WriteString("\n\n") + b.WriteString(tui.BoldText.Render("Removed incompatible custom plugins:")) + b.WriteString("\n") + for _, action := range removed { + b.WriteString(tui.DimStyle.Render(" • ")) + b.WriteString(tui.LabelStyle.Render(action.Name)) + if action.Reason != "" { + b.WriteString(tui.DimStyle.Render(" (" + action.Reason + ")")) + } + b.WriteString("\n") + } + b.WriteString(tui.DimStyle.Render("Re-require them in composer.json once they publish compatible versions.")) } - b.WriteString(tui.DimStyle.Render("Re-require them in composer.json once they publish compatible versions.")) } } From 74b51f8c1f44ff5ae658f3089599eee31511c0aa Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 26 May 2026 15:46:54 +0000 Subject: [PATCH 05/14] fix(project): clear golangci-lint findings on upgrade wizard - Drop trailing punctuation from the dirty-git-tree and non-composer-plugin error strings (ST1005). - Make the phase / task-status switches exhaustive (exhaustive). - Rewrite the compat-result if/else chain as a tagless switch (gocritic). - Rename the `max` parameter in `truncate` to `maxRunes` so it stops shadowing the predeclared builtin (predeclared). - Wrap `resp.Body.Close()` in a small `closeBody` helper so we don't ignore its error inline (errcheck). - Rename the test-only `stringErr` type to `testError` to match the `xxxError` naming convention (errname). - Add `t.Parallel()` to the render-smoke subtest (tparallel). - Drop the unused `upgradeDoneMsg` type (unused). - Drop the unused `projectRoot` parameter from `runCompatibilityCheck` (unparam). --- cmd/project/project_upgrade.go | 10 +++++----- internal/projectupgrade/registry.go | 10 ++++++++-- internal/projectupgrade/wizard.go | 22 ++++++++++++---------- internal/projectupgrade/wizard_test.go | 7 ++++--- 4 files changed, 29 insertions(+), 20 deletions(-) diff --git a/cmd/project/project_upgrade.go b/cmd/project/project_upgrade.go index 95ecfc2f..31023e2d 100644 --- a/cmd/project/project_upgrade.go +++ b/cmd/project/project_upgrade.go @@ -143,7 +143,7 @@ func runUpgradeHeadless(cmd *cobra.Command, projectRoot, composerJsonPath string return err } - if err := runCompatibilityCheck(ctx, projectRoot, currentVersion, targetVersion); err != nil { + if err := runCompatibilityCheck(ctx, currentVersion, targetVersion); err != nil { return err } @@ -278,7 +278,7 @@ func selectTargetVersion(cmd *cobra.Command, updateVersions []string) (string, e return selected, nil } -func runCompatibilityCheck(ctx context.Context, projectRoot string, currentVersion *version.Version, targetVersion string) error { +func runCompatibilityCheck(ctx context.Context, currentVersion *version.Version, targetVersion string) error { log := logging.FromContext(ctx) _, extensions, err := getLocalExtensions() @@ -402,8 +402,8 @@ func ensureCleanGitTree(ctx context.Context, projectRoot string, allowDirty bool } return fmt.Errorf( - "the upgrade rewrites composer.json and removes recipe-managed files, so the working tree must be clean.\n"+ - "%d uncommitted change(s) detected in %s:\n %s%s\n\nCommit or stash your changes, or rerun with --allow-dirty to override.", + "the upgrade rewrites composer.json and removes recipe-managed files, so the working tree must be clean - "+ + "%d uncommitted change(s) detected in %s:\n %s%s\n\ncommit or stash your changes, or rerun with --allow-dirty to override", len(changes), projectRoot, strings.Join(preview, "\n "), @@ -429,7 +429,7 @@ func ensureAllPluginsAreComposerManaged(projectRoot string, allow bool) error { } return fmt.Errorf( - "the upgrade can only bump composer-managed plugins, but %d directory/ies in custom/plugins/ are not tracked by composer:\n %s\n\nRun `shopware-cli project autofix composer-plugins` to migrate them, or rerun with --allow-non-composer to override.", + "the upgrade can only bump composer-managed plugins, but %d director(ies) in custom/plugins/ are not tracked by composer:\n %s\n\nrun `shopware-cli project autofix composer-plugins` to migrate them, or rerun with --allow-non-composer to override", len(orphans), strings.Join(orphans, "\n "), ) diff --git a/internal/projectupgrade/registry.go b/internal/projectupgrade/registry.go index 3ad6b9c8..e099500b 100644 --- a/internal/projectupgrade/registry.go +++ b/internal/projectupgrade/registry.go @@ -71,7 +71,7 @@ func (p PackagistRegistry) GetPackageVersions(ctx context.Context, name string) if err != nil { return nil, err } - defer resp.Body.Close() + defer closeBody(resp) if resp.StatusCode == http.StatusNotFound { return nil, nil @@ -178,7 +178,7 @@ func (s *ShopwareStoreRegistry) load(ctx context.Context) error { s.loadErr = err return } - defer resp.Body.Close() + defer closeBody(resp) if resp.StatusCode != http.StatusOK { s.loadErr = fmt.Errorf("shopware packages returned %s", resp.Status) @@ -215,6 +215,12 @@ func (s *ShopwareStoreRegistry) GetPackageVersions(ctx context.Context, name str return versions, nil } +// closeBody drains and closes an HTTP response body, swallowing any close +// error since callers can't act on it once they've already read the payload. +func closeBody(resp *http.Response) { + _ = resp.Body.Close() +} + // DefaultRegistry builds a CombinedRegistry that uses packages.shopware.com // when a store token is provided and falls back to repo.packagist.org for // everything else. token may be empty; in that case store lookups return diff --git a/internal/projectupgrade/wizard.go b/internal/projectupgrade/wizard.go index d8205ace..23a2380e 100644 --- a/internal/projectupgrade/wizard.go +++ b/internal/projectupgrade/wizard.go @@ -100,9 +100,6 @@ type ( startNextTaskMsg struct{} logLineMsg string logDoneMsg struct{} - upgradeDoneMsg struct { - err error - } ) // wizardModel is a small standalone bubbletea Program that walks the user @@ -680,6 +677,8 @@ func (m wizardModel) stepNum(p phase) int { return 3 } return 4 + case phaseWelcome, phaseDone: + return 0 } return 0 } @@ -773,15 +772,16 @@ func (m wizardModel) viewCompatResult() string { b.WriteString(tui.DimStyle.Render(fmt.Sprintf("Upgrade to %s", m.targetVersion))) b.WriteString("\n\n") - if m.compatErr != nil { + switch { + case m.compatErr != nil: b.WriteString(lipgloss.NewStyle().Foreground(tui.ErrorColor).Render("Compatibility lookup failed: " + m.compatErr.Error())) b.WriteString("\n") b.WriteString(tui.DimStyle.Render("You may still proceed; the wizard cannot guarantee extensions will install.")) b.WriteString("\n\n") - } else if len(m.compatUpdates) == 0 { + case len(m.compatUpdates) == 0: b.WriteString(tui.DimStyle.Render("No store-managed extensions to check.")) b.WriteString("\n\n") - } else { + default: for _, u := range m.compatUpdates { icon := tui.Checkmark if u.Status.IsBlocker() { @@ -951,6 +951,8 @@ func (m wizardModel) renderTaskLine(i int, t task) string { icon = lipgloss.NewStyle().Foreground(tui.ErrorColor).Bold(true).Render("✗") case taskSkipped: icon = tui.DimStyle.Render("·") + case taskPending: + icon = tui.DimStyle.Render("○") default: icon = tui.DimStyle.Render("○") } @@ -996,13 +998,13 @@ func renderConfirmButtons(yesLabel, noLabel string, yesActive bool) string { return yes + " " + no } -func truncate(s string, max int) string { - if max <= 0 { +func truncate(s string, maxRunes int) string { + if maxRunes <= 0 { return s } - if len([]rune(s)) <= max { + if len([]rune(s)) <= maxRunes { return s } r := []rune(s) - return string(r[:max-1]) + "…" + return string(r[:maxRunes-1]) + "…" } diff --git a/internal/projectupgrade/wizard_test.go b/internal/projectupgrade/wizard_test.go index 3fa5863a..4d47d3b0 100644 --- a/internal/projectupgrade/wizard_test.go +++ b/internal/projectupgrade/wizard_test.go @@ -176,11 +176,11 @@ func TestWizardLogLineMsgAppendsAndTrims(t *testing.T) { assert.LessOrEqual(t, len(m.logLines), maxLogLines) } -type stringErr string +type testError string -func (e stringErr) Error() string { return string(e) } +func (e testError) Error() string { return string(e) } -func assertErr(s string) error { return stringErr(s) } +func assertErr(s string) error { return testError(s) } func TestWizardRendersAllPhases(t *testing.T) { t.Parallel() @@ -198,6 +198,7 @@ func TestWizardRendersAllPhases(t *testing.T) { for _, p := range phases { p := p t.Run(t.Name(), func(t *testing.T) { + t.Parallel() m := newTestModel(t) m.phase = p m.targetVersion = "6.6.4.0" From b93724d80e691e1c9aee6bd1cb82613cba11e3be Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 03:46:24 +0000 Subject: [PATCH 06/14] refactor(projectupgrade): reuse packagist package for registry lookups The registry duplicated the packagist HTTP client, the composer v2 minified-metadata unminifier, the response-body closer, and the packages.json fetch. Move the generic lookups into the packagist package instead: - Add packagist.GetComposerPackageVersions(ctx, name) for any composer package and rebuild GetShopwarePackageVersions on top of it. - Add a Require field to packagist.PackageVersion so store-package metadata carries its shopware/core constraint. PackagistRegistry and ShopwareStoreRegistry now delegate to the packagist package, dropping ~120 lines of duplicated logic. --- internal/packagist/packagist.go | 29 +++++- internal/projectupgrade/registry.go | 156 +++------------------------- 2 files changed, 43 insertions(+), 142 deletions(-) diff --git a/internal/packagist/packagist.go b/internal/packagist/packagist.go index d335c624..2ca8a40b 100644 --- a/internal/packagist/packagist.go +++ b/internal/packagist/packagist.go @@ -33,6 +33,7 @@ type PackageVersion struct { Version string `json:"version"` Description string `json:"description"` Replace map[string]string `json:"replace"` + Require map[string]string `json:"require"` } type ComposerPackageVersion struct { @@ -109,7 +110,25 @@ func PHPConstraintForShopwareVersion(releases []ComposerPackageVersion, chosenVe } func GetShopwarePackageVersions(ctx context.Context) ([]ComposerPackageVersion, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://repo.packagist.org/p2/shopware/core.json", http.NoBody) + versions, err := GetComposerPackageVersions(ctx, "shopware/core") + if err != nil { + return nil, err + } + + if len(versions) == 0 { + return nil, fmt.Errorf("decode package versions: package shopware/core not found") + } + + return versions, nil +} + +// GetComposerPackageVersions fetches every published version of a composer +// package from repo.packagist.org. An empty slice (and no error) is returned +// when the package does not exist. +func GetComposerPackageVersions(ctx context.Context, name string) ([]ComposerPackageVersion, error) { + url := fmt.Sprintf("https://repo.packagist.org/p2/%s.json", name) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody) if err != nil { return nil, fmt.Errorf("create package versions request: %w", err) } @@ -122,6 +141,10 @@ func GetShopwarePackageVersions(ctx context.Context) ([]ComposerPackageVersion, } defer closeResponseBody(ctx, resp) + if resp.StatusCode == http.StatusNotFound { + return nil, nil + } + if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("fetch package versions: %s", resp.Status) } @@ -132,9 +155,9 @@ func GetShopwarePackageVersions(ctx context.Context) ([]ComposerPackageVersion, return nil, fmt.Errorf("decode package versions: %w", err) } - rawVersions, ok := packageResponse.Packages["shopware/core"] + rawVersions, ok := packageResponse.Packages[name] if !ok { - return nil, fmt.Errorf("decode package versions: package shopware/core not found") + return nil, nil } if packageResponse.Minified != "" { diff --git a/internal/projectupgrade/registry.go b/internal/projectupgrade/registry.go index e099500b..1668d008 100644 --- a/internal/projectupgrade/registry.go +++ b/internal/projectupgrade/registry.go @@ -1,15 +1,10 @@ package projectupgrade import ( - "bytes" "context" - "encoding/json" "errors" - "fmt" - "net/http" "strings" "sync" - "time" "github.com/shopware/shopware-cli/internal/packagist" ) @@ -47,105 +42,16 @@ func (c *CombinedRegistry) GetPackageVersions(ctx context.Context, name string) return c.Packagist.GetPackageVersions(ctx, name) } -var registryHTTPClient = &http.Client{Timeout: 30 * time.Second} - -// PackagistRegistry queries https://repo.packagist.org for any composer -// package's available versions. The package metadata is returned with full -// require/replace info so we can pick a Shopware-compatible release. +// PackagistRegistry resolves package versions via repo.packagist.org. type PackagistRegistry struct{} -type packagistResponse struct { - Minified string `json:"minified"` - Packages map[string][]map[string]json.RawMessage `json:"packages"` -} - -func (p PackagistRegistry) GetPackageVersions(ctx context.Context, name string) ([]packagist.ComposerPackageVersion, error) { - url := fmt.Sprintf("https://repo.packagist.org/p2/%s.json", name) - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody) - if err != nil { - return nil, err - } - req.Header.Set("User-Agent", "Shopware CLI") - - resp, err := registryHTTPClient.Do(req) - if err != nil { - return nil, err - } - defer closeBody(resp) - - if resp.StatusCode == http.StatusNotFound { - return nil, nil - } - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("packagist returned %s for %s", resp.Status, name) - } - - var body packagistResponse - if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { - return nil, fmt.Errorf("decode packagist response: %w", err) - } - - raw, ok := body.Packages[name] - if !ok || len(raw) == 0 { - return nil, nil - } - - if body.Minified != "" { - raw = unminify(raw) - } - - versions := make([]packagist.ComposerPackageVersion, 0, len(raw)) - for _, m := range raw { - payload, err := json.Marshal(m) - if err != nil { - continue - } - var v packagist.ComposerPackageVersion - if err := json.Unmarshal(payload, &v); err != nil { - continue - } - versions = append(versions, v) - } - return versions, nil -} - -// unminify expands the composer v2 minified packages format ("__unset" -// markers and inheritance from the previous entry) into independent records. -func unminify(versions []map[string]json.RawMessage) []map[string]json.RawMessage { - if len(versions) == 0 { - return nil - } - expanded := make([]map[string]json.RawMessage, 0, len(versions)) - var current map[string]json.RawMessage - for _, v := range versions { - if current == nil { - current = cloneRaw(v) - expanded = append(expanded, cloneRaw(current)) - continue - } - for k, val := range v { - if bytes.Equal(val, []byte(`"__unset"`)) { - delete(current, k) - } else { - current[k] = val - } - } - expanded = append(expanded, cloneRaw(current)) - } - return expanded -} - -func cloneRaw(in map[string]json.RawMessage) map[string]json.RawMessage { - out := make(map[string]json.RawMessage, len(in)) - for k, v := range in { - out[k] = v - } - return out +func (PackagistRegistry) GetPackageVersions(ctx context.Context, name string) ([]packagist.ComposerPackageVersion, error) { + return packagist.GetComposerPackageVersions(ctx, name) } -// ShopwareStoreRegistry queries https://packages.shopware.com/packages.json -// for store-managed plugins. The full listing is fetched once and cached for -// the lifetime of the registry instance. +// ShopwareStoreRegistry resolves store-managed plugins via +// packages.shopware.com. The full listing is fetched once and cached for the +// lifetime of the registry instance. type ShopwareStoreRegistry struct { Token string @@ -154,10 +60,6 @@ type ShopwareStoreRegistry struct { packages map[string][]packagist.ComposerPackageVersion } -type shopwareStoreResponse struct { - Packages map[string]map[string]packagist.ComposerPackageVersion `json:"packages"` -} - func (s *ShopwareStoreRegistry) load(ctx context.Context) error { s.once.Do(func() { if s.Token == "" { @@ -165,37 +67,23 @@ func (s *ShopwareStoreRegistry) load(ctx context.Context) error { return } - req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://packages.shopware.com/packages.json", http.NoBody) - if err != nil { - s.loadErr = err - return - } - req.Header.Set("User-Agent", "Shopware CLI") - req.Header.Set("Authorization", "Bearer "+s.Token) - - resp, err := registryHTTPClient.Do(req) + response, err := packagist.GetAvailablePackagesFromShopwareStore(ctx, s.Token) if err != nil { s.loadErr = err return } - defer closeBody(resp) - - if resp.StatusCode != http.StatusOK { - s.loadErr = fmt.Errorf("shopware packages returned %s", resp.Status) - return - } - var body shopwareStoreResponse - if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { - s.loadErr = fmt.Errorf("decode shopware packages: %w", err) - return - } - - s.packages = make(map[string][]packagist.ComposerPackageVersion, len(body.Packages)) - for name, versions := range body.Packages { + s.packages = make(map[string][]packagist.ComposerPackageVersion, len(response.Packages)) + for name, versions := range response.Packages { list := make([]packagist.ComposerPackageVersion, 0, len(versions)) for _, v := range versions { - list = append(list, v) + list = append(list, packagist.ComposerPackageVersion{ + Name: name, + Version: v.Version, + Description: v.Description, + Replace: v.Replace, + Require: v.Require, + }) } s.packages[name] = list } @@ -208,17 +96,7 @@ func (s *ShopwareStoreRegistry) GetPackageVersions(ctx context.Context, name str return nil, err } - versions, ok := s.packages[name] - if !ok { - return nil, nil - } - return versions, nil -} - -// closeBody drains and closes an HTTP response body, swallowing any close -// error since callers can't act on it once they've already read the payload. -func closeBody(resp *http.Response) { - _ = resp.Body.Close() + return s.packages[name], nil } // DefaultRegistry builds a CombinedRegistry that uses packages.shopware.com From 17e8d3d5908ac5d98bd081937fda0438147ef2f5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 03:51:59 +0000 Subject: [PATCH 07/14] refactor(packagist): own composer installed.json and constraint helpers The upgrade flow parsed vendor/composer/installed.json by hand, resolved install paths, and re-implemented composer version-constraint checks. Move that composer logic into the packagist package where the rest of the composer model (composer.json, composer.lock, auth.json) already lives: - packagist.InstalledJson / InstalledPackage / ReadInstalledJson model and read vendor/composer/installed.json. - InstalledPackage.InstallDirName resolves a package's install location (symlinks included) to its directory name under a given base dir. - packagist.ConstraintsSatisfiedBy reports whether a require map's constraints for a set of packages are satisfied by a target version. - packagist.BumpConstraint turns a concrete version into a caret constraint. projectupgrade now consumes these helpers and keeps only the upgrade policy (which Shopware packages matter, registry resolution). Its duplicate ShopwarePackages list is reused in place of the former pluginShopwarePackages. plugins.go drops ~145 lines. --- internal/packagist/constraints.go | 48 +++++++ internal/packagist/constraints_test.go | 60 ++++++++ internal/packagist/installed.go | 85 +++++++++++ internal/packagist/installed_test.go | 92 ++++++++++++ internal/projectupgrade/plugins.go | 178 +++--------------------- internal/projectupgrade/plugins_test.go | 16 +-- internal/projectupgrade/releases.go | 74 ++++------ 7 files changed, 339 insertions(+), 214 deletions(-) create mode 100644 internal/packagist/constraints.go create mode 100644 internal/packagist/constraints_test.go create mode 100644 internal/packagist/installed.go create mode 100644 internal/packagist/installed_test.go diff --git a/internal/packagist/constraints.go b/internal/packagist/constraints.go new file mode 100644 index 00000000..bc2c3d73 --- /dev/null +++ b/internal/packagist/constraints.go @@ -0,0 +1,48 @@ +package packagist + +import ( + "strings" + + "github.com/shyim/go-version" +) + +// ConstraintsSatisfiedBy reports whether every constraint that requires +// declares for a package named in packages is satisfied by target. +// Constraints for packages not listed in packages are ignored, and packages +// that declare no constraint are treated as satisfied. An unparseable +// constraint is treated as not satisfied. +func ConstraintsSatisfiedBy(requires map[string]string, packages []string, target *version.Version) bool { + for _, name := range packages { + constraint, ok := requires[name] + if !ok { + continue + } + + c, err := version.NewConstraint(constraint) + if err != nil { + return false + } + + if !c.Check(target) { + return false + } + } + + return true +} + +// BumpConstraint turns a concrete version (e.g. "2.3.4") into a caret +// constraint ("^2.3.4") suitable for a composer.json require entry. Values +// that already look like a constraint (containing range/wildcard operators) +// are returned unchanged. +func BumpConstraint(version string) string { + if version == "" { + return version + } + + if strings.ContainsAny(version, "^~><*|, ") { + return version + } + + return "^" + version +} diff --git a/internal/packagist/constraints_test.go b/internal/packagist/constraints_test.go new file mode 100644 index 00000000..c71e53d2 --- /dev/null +++ b/internal/packagist/constraints_test.go @@ -0,0 +1,60 @@ +package packagist + +import ( + "testing" + + "github.com/shyim/go-version" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestConstraintsSatisfiedBy(t *testing.T) { + t.Parallel() + + target, err := version.NewVersion("6.6.4.0") + require.NoError(t, err) + + packages := []string{"shopware/core", "shopware/storefront"} + + tests := []struct { + name string + requires map[string]string + want bool + }{ + {"no requires", nil, true}, + {"unrelated package ignored", map[string]string{"symfony/console": "^6.0"}, true}, + {"satisfied", map[string]string{"shopware/core": "^6.6"}, true}, + {"not satisfied", map[string]string{"shopware/core": "~6.5.0"}, false}, + {"one of many not satisfied", map[string]string{"shopware/core": "^6.6", "shopware/storefront": "~6.5.0"}, false}, + {"unparseable treated as unsatisfied", map[string]string{"shopware/core": "not-a-constraint"}, false}, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.want, ConstraintsSatisfiedBy(tt.requires, packages, target)) + }) + } +} + +func TestBumpConstraint(t *testing.T) { + t.Parallel() + + tests := []struct { + in string + want string + }{ + {"2.3.4", "^2.3.4"}, + {"1.0.0", "^1.0.0"}, + {"^2.0", "^2.0"}, + {"~1.2", "~1.2"}, + {">=3.0", ">=3.0"}, + {"1.0 | 2.0", "1.0 | 2.0"}, + {"", ""}, + } + + for _, tt := range tests { + assert.Equal(t, tt.want, BumpConstraint(tt.in), "BumpConstraint(%q)", tt.in) + } +} diff --git a/internal/packagist/installed.go b/internal/packagist/installed.go new file mode 100644 index 00000000..6c842da5 --- /dev/null +++ b/internal/packagist/installed.go @@ -0,0 +1,85 @@ +package packagist + +import ( + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" +) + +// InstalledPackage is a single entry of vendor/composer/installed.json. +type InstalledPackage struct { + Name string `json:"name"` + Type string `json:"type"` + Require map[string]string `json:"require"` + InstallPath string `json:"install-path"` +} + +// InstalledJson models vendor/composer/installed.json, composer's record of +// every package actually installed into the project. +type InstalledJson struct { + Packages []InstalledPackage `json:"packages"` +} + +// ReadInstalledJson reads vendor/composer/installed.json relative to +// projectRoot. An empty InstalledJson is returned when the file does not +// exist, so callers can treat "no composer install yet" as "no packages". +func ReadInstalledJson(projectRoot string) (*InstalledJson, error) { + installedPath := filepath.Join(projectRoot, "vendor", "composer", "installed.json") + + content, err := os.ReadFile(installedPath) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return &InstalledJson{}, nil + } + return nil, fmt.Errorf("read installed.json: %w", err) + } + + var installed InstalledJson + if err := json.Unmarshal(content, &installed); err != nil { + return nil, fmt.Errorf("parse installed.json: %w", err) + } + + return &installed, nil +} + +// InstallDirName resolves the package's install location (install-path is +// recorded relative to vendor/composer) and, when it sits directly inside +// baseDir, returns the child directory name. ok is false when the package is +// installed anywhere else. Symlinks are resolved on both sides so symlinked +// packages still match. +func (p InstalledPackage) InstallDirName(projectRoot, baseDir string) (string, bool) { + if p.InstallPath == "" { + return "", false + } + + abs := p.InstallPath + if !filepath.IsAbs(abs) { + abs = filepath.Join(projectRoot, "vendor", "composer", p.InstallPath) + } + + rel, err := filepath.Rel(resolveSymlinks(baseDir), resolveSymlinks(abs)) + if err != nil { + return "", false + } + + if rel == "." || rel == "" || strings.HasPrefix(rel, "..") { + return "", false + } + + if strings.ContainsRune(rel, filepath.Separator) { + return "", false + } + + return rel, true +} + +func resolveSymlinks(path string) string { + if resolved, err := filepath.EvalSymlinks(path); err == nil { + return resolved + } + return filepath.Clean(path) +} diff --git a/internal/packagist/installed_test.go b/internal/packagist/installed_test.go new file mode 100644 index 00000000..bf26ca7e --- /dev/null +++ b/internal/packagist/installed_test.go @@ -0,0 +1,92 @@ +package packagist + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func writeInstalled(t *testing.T, projectRoot string, installed InstalledJson) { + t.Helper() + dir := filepath.Join(projectRoot, "vendor", "composer") + require.NoError(t, os.MkdirAll(dir, 0o755)) + data, err := json.MarshalIndent(installed, "", " ") + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(dir, "installed.json"), data, 0o644)) +} + +func TestReadInstalledJsonMissingReturnsEmpty(t *testing.T) { + t.Parallel() + installed, err := ReadInstalledJson(t.TempDir()) + require.NoError(t, err) + require.NotNil(t, installed) + assert.Empty(t, installed.Packages) +} + +func TestReadInstalledJsonParsesPackages(t *testing.T) { + t.Parallel() + dir := t.TempDir() + writeInstalled(t, dir, InstalledJson{Packages: []InstalledPackage{ + {Name: "vendor/a", Type: "shopware-platform-plugin", InstallPath: "../../custom/plugins/A"}, + }}) + + installed, err := ReadInstalledJson(dir) + require.NoError(t, err) + require.NotNil(t, installed) + require.Len(t, installed.Packages, 1) + assert.Equal(t, "vendor/a", installed.Packages[0].Name) +} + +func TestReadInstalledJsonMalformedReturnsError(t *testing.T) { + t.Parallel() + dir := t.TempDir() + composerDir := filepath.Join(dir, "vendor", "composer") + require.NoError(t, os.MkdirAll(composerDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(composerDir, "installed.json"), []byte("{not json"), 0o644)) + + _, err := ReadInstalledJson(dir) + require.Error(t, err) +} + +func TestInstallDirNameDirectChild(t *testing.T) { + t.Parallel() + dir := t.TempDir() + base := filepath.Join(dir, "custom", "plugins") + require.NoError(t, os.MkdirAll(filepath.Join(base, "MyPlugin"), 0o755)) + + pkg := InstalledPackage{InstallPath: "../../custom/plugins/MyPlugin"} + name, ok := pkg.InstallDirName(dir, base) + assert.True(t, ok) + assert.Equal(t, "MyPlugin", name) +} + +func TestInstallDirNameNotUnderBase(t *testing.T) { + t.Parallel() + dir := t.TempDir() + base := filepath.Join(dir, "custom", "plugins") + + pkg := InstalledPackage{InstallPath: "../vendor/installed"} + _, ok := pkg.InstallDirName(dir, base) + assert.False(t, ok) +} + +func TestInstallDirNameNestedIsNotDirectChild(t *testing.T) { + t.Parallel() + dir := t.TempDir() + base := filepath.Join(dir, "custom", "plugins") + + pkg := InstalledPackage{InstallPath: "../../custom/plugins/Group/Nested"} + _, ok := pkg.InstallDirName(dir, base) + assert.False(t, ok) +} + +func TestInstallDirNameEmptyPath(t *testing.T) { + t.Parallel() + pkg := InstalledPackage{} + _, ok := pkg.InstallDirName("/project", "/project/custom/plugins") + assert.False(t, ok) +} diff --git a/internal/projectupgrade/plugins.go b/internal/projectupgrade/plugins.go index b06adfde..d84a9c95 100644 --- a/internal/projectupgrade/plugins.go +++ b/internal/projectupgrade/plugins.go @@ -2,7 +2,6 @@ package projectupgrade import ( "context" - "encoding/json" "errors" "fmt" "io/fs" @@ -19,27 +18,6 @@ import ( // composerPluginType is the composer "type" used by Shopware platform plugins. const composerPluginType = "shopware-platform-plugin" -// pluginShopwarePackages are the Shopware first-party packages a plugin can -// declare a constraint against. If any constraint cannot be satisfied by the -// target version, the plugin is considered incompatible. -var pluginShopwarePackages = []string{ - "shopware/core", - "shopware/administration", - "shopware/storefront", - "shopware/elasticsearch", -} - -type installedPackage struct { - Name string `json:"name"` - Type string `json:"type"` - Require map[string]string `json:"require"` - InstallPath string `json:"install-path"` -} - -type installedJSON struct { - Packages []installedPackage `json:"packages"` -} - // PluginAction describes how the resolver dealt with one incompatible plugin. type PluginAction struct { // Name is the composer package name (e.g. "store.shopware.com/swagcms"). @@ -100,19 +78,9 @@ func (r *ResolveResult) Removed() []PluginAction { func ResolveIncompatiblePlugins(ctx context.Context, composerJsonPath, targetVersion string, registry Registry) (*ResolveResult, error) { projectDir := filepath.Dir(composerJsonPath) - installedPath := filepath.Join(projectDir, "vendor", "composer", "installed.json") - - data, err := os.ReadFile(installedPath) + installed, err := packagist.ReadInstalledJson(projectDir) if err != nil { - if errors.Is(err, fs.ErrNotExist) { - return &ResolveResult{}, nil - } - return nil, fmt.Errorf("read installed.json: %w", err) - } - - var installed installedJSON - if err := json.Unmarshal(data, &installed); err != nil { - return nil, fmt.Errorf("parse installed.json: %w", err) + return nil, err } target, err := version.NewVersion(strings.TrimPrefix(targetVersion, "v")) @@ -120,15 +88,17 @@ func ResolveIncompatiblePlugins(ctx context.Context, composerJsonPath, targetVer return nil, fmt.Errorf("parse target version: %w", err) } - incompatible := make([]installedPackage, 0) + customPlugins := filepath.Join(projectDir, "custom", "plugins") + + incompatible := make([]packagist.InstalledPackage, 0) for _, pkg := range installed.Packages { if pkg.Type != composerPluginType { continue } - if !isInstalledUnderCustomPlugins(projectDir, pkg.InstallPath) { + if _, ok := pkg.InstallDirName(projectDir, customPlugins); !ok { continue } - if pluginSatisfies(pkg.Require, target) { + if packagist.ConstraintsSatisfiedBy(pkg.Require, ShopwarePackages, target) { continue } incompatible = append(incompatible, pkg) @@ -165,7 +135,7 @@ func ResolveIncompatiblePlugins(ctx context.Context, composerJsonPath, targetVer continue } - newConstraint := bumpConstraint(newVersion) + newConstraint := packagist.BumpConstraint(newVersion) composerJson.Require[pkg.Name] = newConstraint action.NewConstraint = newConstraint action.NewVersion = newVersion @@ -201,7 +171,7 @@ func findCompatibleVersion(ctx context.Context, registry Registry, name string, if isPreReleaseVersion(v.Version) { continue } - if !satisfiesShopwareTarget(v.Require, target) { + if !packagist.ConstraintsSatisfiedBy(v.Require, ShopwarePackages, target) { continue } parsed = append(parsed, v) @@ -233,39 +203,6 @@ func isPreReleaseVersion(v string) bool { return false } -func satisfiesShopwareTarget(requires map[string]string, target *version.Version) bool { - if len(requires) == 0 { - // No shopware/core constraint declared — assume compatible. - return true - } - for dep, constraint := range requires { - if !containsString(pluginShopwarePackages, dep) { - continue - } - c, err := version.NewConstraint(constraint) - if err != nil { - return false - } - if !c.Check(target) { - return false - } - } - return true -} - -// bumpConstraint converts a concrete version (e.g. "2.3.4") into a caret -// constraint ("^2.3.4") suitable for composer.json. Versions that already -// look like a constraint are passed through unchanged. -func bumpConstraint(version string) string { - if version == "" { - return version - } - if strings.ContainsAny(version, "^~><*|, ") { - return version - } - return "^" + version -} - // FindNonComposerPlugins returns directories under custom/plugins/ that are // not tracked by composer (no entry in vendor/composer/installed.json). // Returns an empty slice when no installed.json is present. @@ -279,15 +216,14 @@ func FindNonComposerPlugins(projectRoot string) ([]string, error) { return nil, fmt.Errorf("read %s: %w", customPlugins, err) } - installedPath := filepath.Join(projectRoot, "vendor", "composer", "installed.json") composerTracked := make(map[string]struct{}) - if data, err := os.ReadFile(installedPath); err == nil { - var installed installedJSON - if jsonErr := json.Unmarshal(data, &installed); jsonErr == nil { - for _, pkg := range installed.Packages { - if dir, ok := installedDirName(projectRoot, pkg.InstallPath); ok { - composerTracked[dir] = struct{}{} - } + // Best-effort: a missing or malformed installed.json simply means nothing + // is tracked, so every plugin directory is reported. + installed, _ := packagist.ReadInstalledJson(projectRoot) + if installed != nil { + for _, pkg := range installed.Packages { + if dir, ok := pkg.InstallDirName(projectRoot, customPlugins); ok { + composerTracked[dir] = struct{}{} } } } @@ -309,85 +245,3 @@ func FindNonComposerPlugins(projectRoot string) ([]string, error) { sort.Strings(orphans) return orphans, nil } - -func installedDirName(projectRoot, installPath string) (string, bool) { - if installPath == "" { - return "", false - } - abs := installPath - if !filepath.IsAbs(abs) { - abs = filepath.Join(projectRoot, "vendor", "composer", installPath) - } - resolved, err := filepath.EvalSymlinks(abs) - if err != nil { - resolved = filepath.Clean(abs) - } - customPlugins := filepath.Join(projectRoot, "custom", "plugins") - resolvedCustom, err := filepath.EvalSymlinks(customPlugins) - if err != nil { - resolvedCustom = filepath.Clean(customPlugins) - } - rel, err := filepath.Rel(resolvedCustom, resolved) - if err != nil { - return "", false - } - if rel == "." || rel == "" || strings.HasPrefix(rel, "..") { - return "", false - } - if strings.ContainsRune(rel, filepath.Separator) { - return "", false - } - return rel, true -} - -func isInstalledUnderCustomPlugins(projectDir, installPath string) bool { - if installPath == "" { - return false - } - absPath := installPath - if !filepath.IsAbs(absPath) { - absPath = filepath.Join(projectDir, "vendor", "composer", installPath) - } - resolved, err := filepath.EvalSymlinks(absPath) - if err != nil { - resolved = filepath.Clean(absPath) - } - customPlugins := filepath.Join(projectDir, "custom", "plugins") - resolvedCustom, err := filepath.EvalSymlinks(customPlugins) - if err != nil { - resolvedCustom = filepath.Clean(customPlugins) - } - rel, err := filepath.Rel(resolvedCustom, resolved) - if err != nil { - return false - } - if rel == "." || rel == "" || strings.HasPrefix(rel, "..") { - return false - } - return !strings.ContainsRune(rel, filepath.Separator) -} - -func pluginSatisfies(requires map[string]string, target *version.Version) bool { - for dep, constraint := range requires { - if !containsString(pluginShopwarePackages, dep) { - continue - } - c, err := version.NewConstraint(constraint) - if err != nil { - continue - } - if !c.Check(target) { - return false - } - } - return true -} - -func containsString(haystack []string, needle string) bool { - for _, item := range haystack { - if item == needle { - return true - } - } - return false -} diff --git a/internal/projectupgrade/plugins_test.go b/internal/projectupgrade/plugins_test.go index 5c3a313d..768fe175 100644 --- a/internal/projectupgrade/plugins_test.go +++ b/internal/projectupgrade/plugins_test.go @@ -13,13 +13,13 @@ import ( "github.com/shopware/shopware-cli/internal/packagist" ) -func writeInstalledJSON(t *testing.T, projectDir string, packages []installedPackage) { +func writeInstalledJSON(t *testing.T, projectDir string, packages []packagist.InstalledPackage) { t.Helper() installedDir := filepath.Join(projectDir, "vendor", "composer") require.NoError(t, os.MkdirAll(installedDir, 0o755)) - data, err := json.MarshalIndent(installedJSON{Packages: packages}, "", " ") + data, err := json.MarshalIndent(packagist.InstalledJson{Packages: packages}, "", " ") require.NoError(t, err) require.NoError(t, os.WriteFile(filepath.Join(installedDir, "installed.json"), data, 0o644)) } @@ -55,7 +55,7 @@ func TestResolveIncompatiblePluginsRemovesWhenNoRegistry(t *testing.T) { }, }) - writeInstalledJSON(t, dir, []installedPackage{ + writeInstalledJSON(t, dir, []packagist.InstalledPackage{ { Name: "vendor/incompat", Type: composerPluginType, @@ -92,7 +92,7 @@ func TestResolveIncompatiblePluginsBumpsConstraintWhenRegistryHasCompatibleVersi }, }) - writeInstalledJSON(t, dir, []installedPackage{ + writeInstalledJSON(t, dir, []packagist.InstalledPackage{ { Name: "vendor/incompat", Type: composerPluginType, @@ -144,7 +144,7 @@ func TestResolveIncompatiblePluginsRemovesWhenNoCompatibleRelease(t *testing.T) }, }) - writeInstalledJSON(t, dir, []installedPackage{ + writeInstalledJSON(t, dir, []packagist.InstalledPackage{ { Name: "vendor/incompat", Type: composerPluginType, @@ -187,7 +187,7 @@ func TestResolveIncompatiblePluginsRegistryErrorFallsBackToRemove(t *testing.T) }, }) - writeInstalledJSON(t, dir, []installedPackage{ + writeInstalledJSON(t, dir, []packagist.InstalledPackage{ { Name: "vendor/incompat", Type: composerPluginType, @@ -228,7 +228,7 @@ func TestFindNonComposerPluginsReportsUntrackedDirectories(t *testing.T) { require.NoError(t, os.MkdirAll(filepath.Join(dir, "custom", "plugins", "UntrackedPlugin"), 0o755)) require.NoError(t, os.MkdirAll(filepath.Join(dir, "custom", "plugins", "AnotherUntracked"), 0o755)) - writeInstalledJSON(t, dir, []installedPackage{ + writeInstalledJSON(t, dir, []packagist.InstalledPackage{ { Name: "vendor/tracked", Type: composerPluginType, @@ -257,7 +257,7 @@ func TestFindNonComposerPluginsAllTracked(t *testing.T) { require.NoError(t, os.MkdirAll(filepath.Join(dir, "custom", "plugins", "A"), 0o755)) require.NoError(t, os.MkdirAll(filepath.Join(dir, "custom", "plugins", "B"), 0o755)) - writeInstalledJSON(t, dir, []installedPackage{ + writeInstalledJSON(t, dir, []packagist.InstalledPackage{ {Name: "vendor/a", Type: composerPluginType, InstallPath: "../../custom/plugins/A"}, {Name: "vendor/b", Type: composerPluginType, InstallPath: "../../custom/plugins/B"}, }) diff --git a/internal/projectupgrade/releases.go b/internal/projectupgrade/releases.go index fdbd0bea..c2c44f36 100644 --- a/internal/projectupgrade/releases.go +++ b/internal/projectupgrade/releases.go @@ -2,30 +2,44 @@ package projectupgrade import ( "sort" - "strconv" - "strings" "github.com/shyim/go-version" ) +// branch identifies a Shopware release branch by its major and minor segment +// (e.g. 6.6). Shopware ships feature releases on the minor segment, so the +// "next" branch is the current one with its minor incremented. +type branch struct { + major int + minor int +} + +func branchOf(v *version.Version) branch { + return branch{major: v.Major(), minor: v.Minor()} +} + +func (b branch) next() branch { + return branch{major: b.major, minor: b.minor + 1} +} + // FilterUpdateVersions returns the upgrade target versions appropriate for -// currentVersion: the next major version's releases first (newest first), -// followed by the remaining patches of the current major. This mirrors the -// version filtering applied by `ReleaseInfoProvider::fetchUpdateVersions` in +// currentVersion: the next branch's releases first (newest first), followed +// by the remaining patches of the current branch. This mirrors the version +// filtering applied by `ReleaseInfoProvider::fetchUpdateVersions` in // shopware/web-installer. // -// Release candidates and any version older than or equal to currentVersion -// are dropped. +// Pre-releases (RC/beta/alpha) and any version older than or equal to +// currentVersion are dropped. func FilterUpdateVersions(currentVersion *version.Version, allVersions []string) []string { parsed := make([]*version.Version, 0, len(allVersions)) for _, raw := range allVersions { - if strings.Contains(strings.ToLower(raw), "rc") { + v, err := version.NewVersion(raw) + if err != nil { continue } - v, err := version.NewVersion(raw) - if err != nil { + if v.IsPrerelease() { continue } @@ -40,45 +54,17 @@ func FilterUpdateVersions(currentVersion *version.Version, allVersions []string) return parsed[i].GreaterThan(parsed[j]) }) - byMajor := map[string][]string{} + byBranch := map[branch][]string{} for _, v := range parsed { - major := majorBranch(v) - byMajor[major] = append(byMajor[major], v.String()) + b := branchOf(v) + byBranch[b] = append(byBranch[b], v.String()) } - currentMajor := majorBranch(currentVersion) - nextMajor := nextMajor(currentMajor) + currentBranch := branchOf(currentVersion) result := make([]string, 0) - if list, ok := byMajor[nextMajor]; ok { - result = append(result, list...) - } - if list, ok := byMajor[currentMajor]; ok { - result = append(result, list...) - } + result = append(result, byBranch[currentBranch.next()]...) + result = append(result, byBranch[currentBranch]...) return result } - -func majorBranch(v *version.Version) string { - segments := v.Segments() - if len(segments) < 2 { - return v.String() - } - - return strconv.Itoa(segments[0]) + "." + strconv.Itoa(segments[1]) -} - -func nextMajor(currentMajor string) string { - parts := strings.SplitN(currentMajor, ".", 2) - if len(parts) != 2 { - return currentMajor - } - - minor, err := strconv.Atoi(parts[1]) - if err != nil { - return currentMajor - } - - return parts[0] + "." + strconv.Itoa(minor+1) -} From 951109aed378d271b9892868ec3e77390821f679 Mon Sep 17 00:00:00 2001 From: Soner Sayakci Date: Fri, 29 May 2026 08:03:34 +0200 Subject: [PATCH 08/14] fix(projectupgrade): resolve plugin constraints and reorder upgrade steps Several fixes to the project upgrade flow surfaced by real upgrades: - Treat store "with new Shopware version" status as resolvable, not a blocker. Classification now keys on the semantic status name (notCompatible) instead of the display color, matching the platform's ExtensionCompatibility constants. - Resolve plugin constraints for vendor-installed plugins, not just those under custom/plugins/. Scope candidates by the root composer.json require so store plugins (swag/*, frosh/*) installed into vendor/ get bumped too. - Look up store-owned, vendor-named plugins via the store registry first (falling back to Packagist) instead of routing only by name prefix. - Run system:update:prepare before composer update so it executes on the still-installed Shopware; restore composer.json if prepare fails. - Center the wizard in the terminal and replay the full failed-step log after the alt-screen tears down. Adds tests for status classification, vendor-installed resolution, registry routing, and upgrade step ordering. --- cmd/project/project_upgrade.go | 47 ++++-- internal/account-api/updates.go | 32 +++- internal/account-api/updates_test.go | 55 +++++++ internal/projectupgrade/plugins.go | 18 ++- internal/projectupgrade/plugins_test.go | 81 ++++++++++ internal/projectupgrade/registry.go | 25 +-- internal/projectupgrade/registry_test.go | 85 ++++++++++ internal/projectupgrade/wizard.go | 193 +++++++++++++++++------ internal/projectupgrade/wizard_test.go | 72 ++++++++- 9 files changed, 522 insertions(+), 86 deletions(-) create mode 100644 internal/account-api/updates_test.go create mode 100644 internal/projectupgrade/registry_test.go diff --git a/cmd/project/project_upgrade.go b/cmd/project/project_upgrade.go index 31023e2d..9e22457c 100644 --- a/cmd/project/project_upgrade.go +++ b/cmd/project/project_upgrade.go @@ -103,7 +103,7 @@ bin/console system:update:prepare and system:update:finish.`, return err } - target, success, err := projectupgrade.RunWizard(projectupgrade.WizardOptions{ + result, err := projectupgrade.RunWizard(projectupgrade.WizardOptions{ ProjectRoot: projectRoot, ComposerJSONPath: composerJsonPath, CurrentVersion: currentVersion, @@ -119,17 +119,28 @@ bin/console system:update:prepare and system:update:finish.`, status = "cancelled" case err != nil: status = "failed" - case !success: + case !result.Success: status = "failed" } - trackUpgrade(ctx, currentVersion.String(), target, status) + trackUpgrade(ctx, currentVersion.String(), result.TargetVersion, status) if errors.Is(err, projectupgrade.ErrCancelled) { fmt.Println("Upgrade cancelled.") return nil } + // The wizard runs in the alt-screen, so its live log is gone once it + // exits. Replay the full output of the failed task to the terminal so + // the user keeps the complete error in their scrollback. + if len(result.FailureLog) > 0 { + out := cmd.ErrOrStderr() + _, _ = fmt.Fprintln(out, "\nUpgrade failed. Full output of the failed step:") + for _, line := range result.FailureLog { + _, _ = fmt.Fprintln(out, line) + } + } + return err }, } @@ -196,6 +207,21 @@ func runUpgradeHeadless(cmd *cobra.Command, projectRoot, composerJsonPath string return fmt.Errorf("update composer.json: %w", err) } + // system:update:prepare must run on the still-installed (old) Shopware so + // it can enter maintenance mode and record the pending update before the + // vendor code is swapped by composer update. + log.Infof("Running bin/console system:update:prepare") + prepareCmd := cmdExecutor.ConsoleCommand(ctx, "system:update:prepare", "--no-interaction") + prepareCmd.Cmd.Stdin = cmd.InOrStdin() + prepareCmd.Cmd.Stdout = cmd.OutOrStdout() + prepareCmd.Cmd.Stderr = cmd.ErrOrStderr() + + if err := prepareCmd.Run(); err != nil { + restoreComposerJson(ctx, composerJsonPath, backup) + trackUpgrade(ctx, currentVersion.String(), targetVersion, "system_update_prepare_failed") + return fmt.Errorf("system:update:prepare failed, composer.json was restored: %w", err) + } + log.Infof("Running composer update") composerArgs := []string{ "update", @@ -217,17 +243,6 @@ func runUpgradeHeadless(cmd *cobra.Command, projectRoot, composerJsonPath string return fmt.Errorf("composer update failed, composer.json was restored: %w", err) } - log.Infof("Running bin/console system:update:prepare") - prepareCmd := cmdExecutor.ConsoleCommand(ctx, "system:update:prepare", "--no-interaction") - prepareCmd.Cmd.Stdin = cmd.InOrStdin() - prepareCmd.Cmd.Stdout = cmd.OutOrStdout() - prepareCmd.Cmd.Stderr = cmd.ErrOrStderr() - - if err := prepareCmd.Run(); err != nil { - trackUpgrade(ctx, currentVersion.String(), targetVersion, "system_update_prepare_failed") - return fmt.Errorf("system:update:prepare failed: %w", err) - } - log.Infof("Running bin/console system:update:finish") finishCmd := cmdExecutor.ConsoleCommand(ctx, "system:update:finish", "--no-interaction") finishCmd.Cmd.Stdin = cmd.InOrStdin() @@ -338,8 +353,8 @@ func runCompatibilityCheck(ctx context.Context, currentVersion *version.Version, if hasBlockers && system.IsInteractionEnabled(ctx) { var proceed bool if err := huh.NewConfirm(). - Title("Some installed extensions are not yet compatible with the target version"). - Description("Continuing may break those extensions. Proceed anyway?"). + Title("Some installed extensions have no compatible version for the target version"). + Description("They will be removed from composer.json so the upgrade can proceed. Re-require them once they publish a compatible release. Proceed anyway?"). Value(&proceed). Run(); err != nil { return err diff --git a/internal/account-api/updates.go b/internal/account-api/updates.go index 62005f82..280d868b 100644 --- a/internal/account-api/updates.go +++ b/internal/account-api/updates.go @@ -23,14 +23,44 @@ type UpdateCheckExtensionCompatibility struct { Status UpdateCheckExtensionCompatibilityStatus `json:"status"` } +// Plugin compatibility status names returned by the store autoupdate +// endpoint. These mirror the constants in Shopware's +// Core\Framework\Update\Services\ExtensionCompatibility. The status `type` +// field is only a display color (green/red/…), so classification must key on +// the semantic `name` instead. +const ( + // CompatibilityCompatible means the installed version already works with + // the target Shopware version. + CompatibilityCompatible = "compatible" + // CompatibilityUpdatableNow / CompatibilityUpdatableFuture mean the + // extension has a compatible release available ("With new Shopware + // version"): not a blocker, the constraint just needs to be bumped. + CompatibilityUpdatableNow = "updatableNow" + CompatibilityUpdatableFuture = "updatableFuture" + // CompatibilityNotCompatible means no compatible successor exists. This + // is the only genuine blocker. + CompatibilityNotCompatible = "notCompatible" + // CompatibilityNotInStore means the extension is not managed by the store. + CompatibilityNotInStore = "notInStore" +) + type UpdateCheckExtensionCompatibilityStatus struct { Name string `json:"name"` Label string `json:"label"` Type string `json:"type"` } +// IsBlocker reports whether this status prevents the upgrade. Only +// notCompatible (no compatible successor) blocks; updatableNow/updatableFuture +// are resolvable by bumping the extension constraint, so they are not blockers. func (s UpdateCheckExtensionCompatibilityStatus) IsBlocker() bool { - return s.Type != "success" && s.Type != "" + return s.Name == CompatibilityNotCompatible +} + +// IsUpdatable reports whether a compatible release exists that the installed +// version must be bumped to ("With new Shopware version"). +func (s UpdateCheckExtensionCompatibilityStatus) IsUpdatable() bool { + return s.Name == CompatibilityUpdatableNow || s.Name == CompatibilityUpdatableFuture } func GetFutureExtensionUpdates(ctx context.Context, currentVersion string, futureVersion string, extensions []UpdateCheckExtension) ([]UpdateCheckExtensionCompatibility, error) { diff --git a/internal/account-api/updates_test.go b/internal/account-api/updates_test.go new file mode 100644 index 00000000..489b4620 --- /dev/null +++ b/internal/account-api/updates_test.go @@ -0,0 +1,55 @@ +package account_api + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestUpdateCheckExtensionCompatibilityStatusClassification(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + status UpdateCheckExtensionCompatibilityStatus + isBlocker bool + isUpdatable bool + }{ + { + name: "compatible", + status: UpdateCheckExtensionCompatibilityStatus{Name: CompatibilityCompatible, Type: "green"}, + }, + { + name: "updatable now is not a blocker", + status: UpdateCheckExtensionCompatibilityStatus{Name: CompatibilityUpdatableNow, Type: "yellow"}, + isUpdatable: true, + }, + { + name: "updatable future is not a blocker", + status: UpdateCheckExtensionCompatibilityStatus{Name: CompatibilityUpdatableFuture, Type: "yellow"}, + isUpdatable: true, + }, + { + name: "not compatible blocks", + status: UpdateCheckExtensionCompatibilityStatus{Name: CompatibilityNotCompatible, Type: "red"}, + isBlocker: true, + }, + { + name: "not in store is informational", + status: UpdateCheckExtensionCompatibilityStatus{Name: CompatibilityNotInStore}, + }, + { + name: "empty status is not a blocker", + status: UpdateCheckExtensionCompatibilityStatus{}, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.isBlocker, tt.status.IsBlocker()) + assert.Equal(t, tt.isUpdatable, tt.status.IsUpdatable()) + }) + } +} diff --git a/internal/projectupgrade/plugins.go b/internal/projectupgrade/plugins.go index d84a9c95..0559463e 100644 --- a/internal/projectupgrade/plugins.go +++ b/internal/projectupgrade/plugins.go @@ -88,14 +88,23 @@ func ResolveIncompatiblePlugins(ctx context.Context, composerJsonPath, targetVer return nil, fmt.Errorf("parse target version: %w", err) } - customPlugins := filepath.Join(projectDir, "custom", "plugins") + composerJson, err := packagist.ReadComposerJson(composerJsonPath) + if err != nil { + return nil, err + } + // Consider every shopware platform plugin that the root composer.json + // requires and whose installed shopware/* constraint is not satisfied by + // the target version - regardless of where composer installed it. Store + // plugins (swag/*, frosh/*, …) install into vendor/, not custom/plugins/, + // so restricting to custom/plugins/ would leave their stale constraints in + // place and break `composer update`. incompatible := make([]packagist.InstalledPackage, 0) for _, pkg := range installed.Packages { if pkg.Type != composerPluginType { continue } - if _, ok := pkg.InstallDirName(projectDir, customPlugins); !ok { + if _, ok := composerJson.Require[pkg.Name]; !ok { continue } if packagist.ConstraintsSatisfiedBy(pkg.Require, ShopwarePackages, target) { @@ -108,11 +117,6 @@ func ResolveIncompatiblePlugins(ctx context.Context, composerJsonPath, targetVer return &ResolveResult{}, nil } - composerJson, err := packagist.ReadComposerJson(composerJsonPath) - if err != nil { - return nil, err - } - result := &ResolveResult{} for _, pkg := range incompatible { diff --git a/internal/projectupgrade/plugins_test.go b/internal/projectupgrade/plugins_test.go index 768fe175..e28ac97e 100644 --- a/internal/projectupgrade/plugins_test.go +++ b/internal/projectupgrade/plugins_test.go @@ -128,6 +128,87 @@ func TestResolveIncompatiblePluginsBumpsConstraintWhenRegistryHasCompatibleVersi assert.Equal(t, "^2.1.0", requireMap["vendor/incompat"]) } +// Store/composer plugins install into vendor/, not custom/plugins/. They must +// still be resolved, otherwise their stale shopware/core constraint breaks +// `composer update` (regression: frosh/tools ^1.4 / swag/paypal ^8.11 left +// untouched on a 6.5 → 6.6 upgrade). +func TestResolveIncompatiblePluginsBumpsVendorInstalledPlugin(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + composerJsonPath := filepath.Join(dir, "composer.json") + + writeJSON(t, composerJsonPath, map[string]any{ + "name": "shopware/production", + "require": map[string]any{ + "shopware/core": "6.5.8.0", + "swag/paypal": "^8.11", + }, + }) + + // Installed under vendor/ — no custom/plugins/ entry at all. + writeInstalledJSON(t, dir, []packagist.InstalledPackage{ + { + Name: "swag/paypal", + Type: composerPluginType, + InstallPath: "../swag/paypal", + Require: map[string]string{"shopware/core": "~6.5.5"}, + }, + }) + + registry := &fakeRegistry{ + versions: map[string][]packagist.ComposerPackageVersion{ + "swag/paypal": { + {Version: "8.11.0", Require: map[string]string{"shopware/core": "~6.5.5"}}, + {Version: "9.0.0", Require: map[string]string{"shopware/core": "^6.6"}}, + }, + }, + } + + result, err := ResolveIncompatiblePlugins(t.Context(), composerJsonPath, "6.6.4.0", registry) + require.NoError(t, err) + require.Len(t, result.Bumped(), 1) + assert.Empty(t, result.Removed()) + + bumped := result.Bumped()[0] + assert.Equal(t, "swag/paypal", bumped.Name) + assert.Equal(t, "9.0.0", bumped.NewVersion) + + out := readJSON(t, composerJsonPath) + requireMap := out["require"].(map[string]any) + assert.Equal(t, "^9.0.0", requireMap["swag/paypal"]) +} + +// A plugin installed in vendor/ but NOT listed in the root composer.json +// require (e.g. a transitive dependency) must be left alone — the resolver can +// only rewrite root constraints. +func TestResolveIncompatiblePluginsIgnoresPluginsNotInRootRequire(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + composerJsonPath := filepath.Join(dir, "composer.json") + + writeJSON(t, composerJsonPath, map[string]any{ + "name": "shopware/production", + "require": map[string]any{ + "shopware/core": "6.5.8.0", + }, + }) + + writeInstalledJSON(t, dir, []packagist.InstalledPackage{ + { + Name: "transitive/plugin", + Type: composerPluginType, + InstallPath: "../transitive/plugin", + Require: map[string]string{"shopware/core": "~6.5.0"}, + }, + }) + + result, err := ResolveIncompatiblePlugins(t.Context(), composerJsonPath, "6.6.4.0", &fakeRegistry{}) + require.NoError(t, err) + assert.Empty(t, result.Actions) +} + func TestResolveIncompatiblePluginsRemovesWhenNoCompatibleRelease(t *testing.T) { t.Parallel() diff --git a/internal/projectupgrade/registry.go b/internal/projectupgrade/registry.go index 1668d008..9118748c 100644 --- a/internal/projectupgrade/registry.go +++ b/internal/projectupgrade/registry.go @@ -3,7 +3,6 @@ package projectupgrade import ( "context" "errors" - "strings" "sync" "github.com/shopware/shopware-cli/internal/packagist" @@ -19,21 +18,29 @@ type Registry interface { // (e.g. a store.shopware.com package when no token is configured). var ErrRegistryUnavailable = errors.New("registry unavailable for this package") -// CombinedRegistry routes lookups to the appropriate backend based on the -// package name prefix. +// CombinedRegistry resolves a package against the Shopware store first (when +// configured) and falls back to Packagist. Commercial store plugins are +// required under ordinary vendor names (e.g. swag/paypal, not +// store.shopware.com/…) and only exist on packages.shopware.com, so routing +// cannot be decided from the name alone: the store listing is the only source +// that knows whether it owns a package. type CombinedRegistry struct { - // Store handles store.shopware.com/* packages. May be nil. + // Store handles packages published on packages.shopware.com. May be nil + // when no store token is configured. Store Registry - // Packagist handles every other vendor/name combination. Required. + // Packagist handles everything the store does not provide. Required. Packagist Registry } func (c *CombinedRegistry) GetPackageVersions(ctx context.Context, name string) ([]packagist.ComposerPackageVersion, error) { - if strings.HasPrefix(name, "store.shopware.com/") { - if c.Store == nil { - return nil, ErrRegistryUnavailable + if c.Store != nil { + versions, err := c.Store.GetPackageVersions(ctx, name) + // A configured store that knows this package is authoritative. Any + // other outcome (unknown package, or store unavailable) falls through + // to Packagist so public packages still resolve. + if err == nil && len(versions) > 0 { + return versions, nil } - return c.Store.GetPackageVersions(ctx, name) } if c.Packagist == nil { diff --git a/internal/projectupgrade/registry_test.go b/internal/projectupgrade/registry_test.go new file mode 100644 index 00000000..60405771 --- /dev/null +++ b/internal/projectupgrade/registry_test.go @@ -0,0 +1,85 @@ +package projectupgrade + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/shopware/shopware-cli/internal/packagist" +) + +func TestCombinedRegistryPrefersStoreForOwnedPackages(t *testing.T) { + t.Parallel() + + // swag/paypal is a commercial store plugin: vendor-named, only on the + // store. The store must answer even though the name has no + // store.shopware.com/ prefix. + store := &fakeRegistry{versions: map[string][]packagist.ComposerPackageVersion{ + "swag/paypal": {{Version: "9.0.0"}}, + }} + packagistReg := &fakeRegistry{versions: map[string][]packagist.ComposerPackageVersion{}} + + combined := &CombinedRegistry{Store: store, Packagist: packagistReg} + + versions, err := combined.GetPackageVersions(t.Context(), "swag/paypal") + require.NoError(t, err) + require.Len(t, versions, 1) + assert.Equal(t, "9.0.0", versions[0].Version) +} + +func TestCombinedRegistryFallsBackToPackagistForPublicPackages(t *testing.T) { + t.Parallel() + + // Store knows nothing about a public package; Packagist resolves it. + store := &fakeRegistry{versions: map[string][]packagist.ComposerPackageVersion{}} + packagistReg := &fakeRegistry{versions: map[string][]packagist.ComposerPackageVersion{ + "frosh/tools": {{Version: "2.0.0"}}, + }} + + combined := &CombinedRegistry{Store: store, Packagist: packagistReg} + + versions, err := combined.GetPackageVersions(t.Context(), "frosh/tools") + require.NoError(t, err) + require.Len(t, versions, 1) + assert.Equal(t, "2.0.0", versions[0].Version) +} + +func TestCombinedRegistryFallsBackWhenStoreUnavailable(t *testing.T) { + t.Parallel() + + // No token -> store load fails with ErrRegistryUnavailable; public + // packages must still resolve via Packagist. + store := &fakeRegistry{err: ErrRegistryUnavailable} + packagistReg := &fakeRegistry{versions: map[string][]packagist.ComposerPackageVersion{ + "frosh/tools": {{Version: "2.0.0"}}, + }} + + combined := &CombinedRegistry{Store: store, Packagist: packagistReg} + + versions, err := combined.GetPackageVersions(t.Context(), "frosh/tools") + require.NoError(t, err) + require.Len(t, versions, 1) +} + +func TestCombinedRegistryNilStoreUsesPackagist(t *testing.T) { + t.Parallel() + + packagistReg := &fakeRegistry{versions: map[string][]packagist.ComposerPackageVersion{ + "frosh/tools": {{Version: "2.0.0"}}, + }} + combined := &CombinedRegistry{Packagist: packagistReg} + + versions, err := combined.GetPackageVersions(t.Context(), "frosh/tools") + require.NoError(t, err) + require.Len(t, versions, 1) +} + +func TestCombinedRegistryNilPackagistReturnsUnavailable(t *testing.T) { + t.Parallel() + + combined := &CombinedRegistry{} + _, err := combined.GetPackageVersions(context.Background(), "frosh/tools") + assert.ErrorIs(t, err, ErrRegistryUnavailable) +} diff --git a/internal/projectupgrade/wizard.go b/internal/projectupgrade/wizard.go index 23a2380e..b04fd95d 100644 --- a/internal/projectupgrade/wizard.go +++ b/internal/projectupgrade/wizard.go @@ -74,13 +74,16 @@ type task struct { } // taskCleanup, taskPlugins, ... are stable indices into model.tasks. +// system:update:prepare runs on the still-installed (old) Shopware so it can +// enter maintenance and record the update before the vendor code changes; +// composer update then swaps the code, and system:update:finish migrates. const ( taskBackup = iota taskCleanup taskPlugins taskComposerJSON - taskComposerUpdate taskSystemPrepare + taskComposerUpdate taskSystemFinish ) @@ -96,6 +99,10 @@ type ( detail string composerBackup []byte pluginActions *ResolveResult + // output is the full captured subprocess output, set for streaming + // tasks so the complete log survives independent of log-line event + // ordering. Used to render the error tail on failure. + output []string } startNextTaskMsg struct{} logLineMsg string @@ -110,29 +117,46 @@ type wizardModel struct { phase phase - versionCursor int - targetVersion string - confirmYes bool - composerBackup []byte - pluginActions *ResolveResult - compatUpdates []account_api.UpdateCheckExtensionCompatibility - compatHasBlock bool - compatErr error - tasks []task - currentTask int - logLines []string - logChan chan string - finalErr error - finished bool - spinner spinner.Model - compatLoading bool - cancelExecution context.CancelFunc -} - -// RunWizard runs the interactive upgrade wizard. It returns the selected -// target version, whether the upgrade completed successfully, and any error -// encountered. A user cancellation returns ErrCancelled. -func RunWizard(opts WizardOptions) (string, bool, error) { + versionCursor int + targetVersion string + confirmYes bool + composerBackup []byte + pluginActions *ResolveResult + compatUpdates []account_api.UpdateCheckExtensionCompatibility + compatHasBlock bool + compatHasUpdatable bool + compatErr error + tasks []task + currentTask int + logLines []string + fullLog []string + logChan chan string + finalErr error + finished bool + spinner spinner.Model + compatLoading bool + cancelExecution context.CancelFunc + width int + height int +} + +// WizardResult is the outcome of a single RunWizard invocation. +type WizardResult struct { + // TargetVersion is the version the user selected (empty if cancelled + // before selecting). + TargetVersion string + // Success is true when every upgrade task completed. + Success bool + // FailureLog holds the full captured output of the task that failed. It + // is nil on success or when the failure produced no subprocess output, so + // callers can print it verbatim to give the user the complete log the + // alt-screen could not retain. + FailureLog []string +} + +// RunWizard runs the interactive upgrade wizard. It returns the result and any +// error encountered. A user cancellation returns ErrCancelled. +func RunWizard(opts WizardOptions) (WizardResult, error) { s := spinner.New( spinner.WithSpinner(spinner.Dot), spinner.WithStyle(lipgloss.NewStyle().Foreground(tui.BrandColor)), @@ -150,7 +174,7 @@ func RunWizard(opts WizardOptions) (string, bool, error) { prog := tea.NewProgram(m) final, err := prog.Run() if err != nil { - return "", false, err + return WizardResult{}, err } fm, _ := final.(wizardModel) @@ -159,10 +183,17 @@ func RunWizard(opts WizardOptions) (string, bool, error) { } if !fm.finished { - return fm.targetVersion, false, ErrCancelled + return WizardResult{TargetVersion: fm.targetVersion}, ErrCancelled } - return fm.targetVersion, fm.finalErr == nil, fm.finalErr + result := WizardResult{ + TargetVersion: fm.targetVersion, + Success: fm.finalErr == nil, + } + if fm.finalErr != nil { + result.FailureLog = fm.fullLog + } + return result, fm.finalErr } // ErrCancelled is returned when the user exits the wizard before the upgrade @@ -175,8 +206,8 @@ func defaultTasks() []task { {label: "Clean up stale recipe files"}, {label: "Resolve incompatible custom plugins"}, {label: "Rewrite composer.json"}, - {label: "composer update --with-all-dependencies"}, {label: "bin/console system:update:prepare"}, + {label: "composer update --with-all-dependencies"}, {label: "bin/console system:update:finish"}, } } @@ -187,6 +218,11 @@ func (m wizardModel) Init() tea.Cmd { func (m wizardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + return m, nil + case tea.KeyPressMsg: return m.updateKey(msg) @@ -202,7 +238,9 @@ func (m wizardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { for _, u := range msg.updates { if u.Status.IsBlocker() { m.compatHasBlock = true - break + } + if u.Status.IsUpdatable() { + m.compatHasUpdatable = true } } m.phase = phaseCompatResult @@ -219,6 +257,9 @@ func (m wizardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.pluginActions != nil { m.pluginActions = msg.pluginActions } + if msg.output != nil { + m.fullLog = msg.output + } if msg.task < len(m.tasks) { if msg.err != nil { m.tasks[msg.task].status = taskFailed @@ -375,6 +416,10 @@ func (m wizardModel) updateReview(key string) (tea.Model, tea.Cmd) { } func (m *wizardModel) appendLog(line string) { + // fullLog keeps the complete subprocess output so it can be surfaced on + // failure (both in the done view and printed to the terminal after the + // alt-screen tears down). logLines is the capped window shown live. + m.fullLog = append(m.fullLog, line) m.logLines = append(m.logLines, line) if len(m.logLines) > maxLogLines { m.logLines = m.logLines[len(m.logLines)-maxLogLines:] @@ -458,9 +503,13 @@ func (m wizardModel) startTask() (tea.Model, tea.Cmd) { case taskComposerUpdate: return m.startComposerUpdate() case taskSystemPrepare: - return m.startSystemUpdate("system:update:prepare", taskSystemPrepare) + // prepare runs before composer update, while composer.json is already + // rewritten - restore it on failure so the project is left untouched. + return m.startSystemUpdate("system:update:prepare", taskSystemPrepare, true) case taskSystemFinish: - return m.startSystemUpdate("system:update:finish", taskSystemFinish) + // finish runs after a successful composer update; restoring the old + // composer.json here would undo the upgrade, so leave it in place. + return m.startSystemUpdate("system:update:finish", taskSystemFinish, false) } return m, nil @@ -543,6 +592,7 @@ func (m wizardModel) startComposerUpdate() (tea.Model, tea.Cmd) { ch := make(chan string, streamBufferSize) m.logChan = ch m.logLines = nil + m.fullLog = nil args := []string{ "update", @@ -558,37 +608,46 @@ func (m wizardModel) startComposerUpdate() (tea.Model, tea.Cmd) { idx := taskComposerUpdate doneCmd := func() tea.Msg { - err := streamCmdOutput(p.Cmd, ch, true) + output, err := streamCmdOutput(p.Cmd, ch, true) if err != nil { _ = os.WriteFile(composerJSONPath, restore, 0o644) } - return taskCompleteMsg{task: idx, err: err} + return taskCompleteMsg{task: idx, err: err, output: output} } return m, tea.Batch(m.readNextLog(), doneCmd) } -func (m wizardModel) startSystemUpdate(consoleCmd string, idx int) (tea.Model, tea.Cmd) { +func (m wizardModel) startSystemUpdate(consoleCmd string, idx int, restoreOnFail bool) (tea.Model, tea.Cmd) { ctx, cancel := context.WithCancel(context.Background()) m.cancelExecution = cancel ch := make(chan string, streamBufferSize) m.logChan = ch m.logLines = nil + m.fullLog = nil p := m.opts.Executor.ConsoleCommand(ctx, consoleCmd, "--no-interaction") + restore := m.composerBackup + composerJSONPath := m.opts.ComposerJSONPath + doneCmd := func() tea.Msg { - err := streamCmdOutput(p.Cmd, ch, true) - return taskCompleteMsg{task: idx, err: err} + output, err := streamCmdOutput(p.Cmd, ch, true) + if err != nil && restoreOnFail { + _ = os.WriteFile(composerJSONPath, restore, 0o644) + } + return taskCompleteMsg{task: idx, err: err, output: output} } return m, tea.Batch(m.readNextLog(), doneCmd) } // streamCmdOutput starts cmd, fans stdout (or stderr) lines into ch, and -// closes ch when done. The returned error is the process exit error, if any. -func streamCmdOutput(cmd *exec.Cmd, ch chan<- string, useStdout bool) error { +// closes ch when done. It also returns the complete captured output so the +// caller can surface it on failure regardless of how many lines the live +// view kept. The returned error is the process exit error, if any. +func streamCmdOutput(cmd *exec.Cmd, ch chan<- string, useStdout bool) ([]string, error) { var pipe io.Reader var err error if useStdout { @@ -604,32 +663,39 @@ func streamCmdOutput(cmd *exec.Cmd, ch chan<- string, useStdout bool) error { } if err != nil { close(ch) - return err + return nil, err } if err := cmd.Start(); err != nil { close(ch) - return err + return nil, err } + var captured []string scanner := bufio.NewScanner(pipe) scanner.Buffer(make([]byte, 64*1024), 1024*1024) for scanner.Scan() { - ch <- scanner.Text() + line := scanner.Text() + captured = append(captured, line) + ch <- line } close(ch) if err := scanner.Err(); err != nil { _ = cmd.Wait() - return err + return captured, err } - return cmd.Wait() + return captured, cmd.Wait() } // --- View --- func (m wizardModel) View() tea.View { - v := tea.NewView(m.viewContent()) + content := m.viewContent() + if m.width > 0 && m.height > 0 { + content = lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, content) + } + v := tea.NewView(content) v.AltScreen = true return v } @@ -783,9 +849,14 @@ func (m wizardModel) viewCompatResult() string { b.WriteString("\n\n") default: for _, u := range m.compatUpdates { - icon := tui.Checkmark - if u.Status.IsBlocker() { + var icon string + switch { + case u.Status.IsBlocker(): icon = lipgloss.NewStyle().Foreground(tui.ErrorColor).Bold(true).Render("✗") + case u.Status.IsUpdatable(): + icon = lipgloss.NewStyle().Foreground(tui.WarnColor).Bold(true).Render("↑") + default: + icon = tui.Checkmark } b.WriteString(" ") b.WriteString(icon) @@ -797,10 +868,15 @@ func (m wizardModel) viewCompatResult() string { b.WriteString("\n") } + if m.compatHasUpdatable { + b.WriteString(lipgloss.NewStyle().Foreground(tui.WarnColor).Render("↑ Extensions marked with ↑ have a compatible release; their constraints will be bumped during the upgrade.")) + b.WriteString("\n\n") + } + if m.compatHasBlock { - b.WriteString(lipgloss.NewStyle().Foreground(tui.WarnColor).Bold(true).Render("⚠ Some extensions are not compatible yet.")) + b.WriteString(lipgloss.NewStyle().Foreground(tui.ErrorColor).Bold(true).Render("✗ Some extensions have no compatible version yet.")) b.WriteString("\n") - b.WriteString(tui.DimStyle.Render("Continuing may break those extensions until they release updates.")) + b.WriteString(tui.DimStyle.Render("They will be removed from composer.json so the upgrade can proceed. Re-require them once they publish a compatible release.")) b.WriteString("\n\n") } @@ -886,6 +962,19 @@ func (m wizardModel) viewDone() string { b.WriteString("\n\n") b.WriteString(lipgloss.NewStyle().Foreground(tui.ErrorColor).Render(m.finalErr.Error())) b.WriteString("\n\n") + + if tail := lastLines(m.fullLog, maxLogLines); len(tail) > 0 { + b.WriteString(tui.BoldText.Render("Last output:")) + b.WriteString("\n") + for _, line := range tail { + b.WriteString(tui.DimStyle.Render(" " + truncate(line, tui.PhaseCardWidth-10))) + b.WriteString("\n") + } + b.WriteString("\n") + b.WriteString(tui.DimStyle.Render("The full log is printed below the wizard after you close it.")) + b.WriteString("\n\n") + } + b.WriteString(tui.DimStyle.Render("composer.json was restored from the backup taken before the upgrade.")) } else { b.WriteString(lipgloss.NewStyle().Bold(true).Foreground(tui.SuccessColor).Render(fmt.Sprintf("✓ Upgraded to Shopware %s", m.targetVersion))) @@ -1008,3 +1097,11 @@ func truncate(s string, maxRunes int) string { r := []rune(s) return string(r[:maxRunes-1]) + "…" } + +// lastLines returns up to n trailing lines of lines. +func lastLines(lines []string, n int) []string { + if n <= 0 || len(lines) <= n { + return lines + } + return lines[len(lines)-n:] +} diff --git a/internal/projectupgrade/wizard_test.go b/internal/projectupgrade/wizard_test.go index 4d47d3b0..32a1430c 100644 --- a/internal/projectupgrade/wizard_test.go +++ b/internal/projectupgrade/wizard_test.go @@ -115,7 +115,8 @@ func TestWizardCompatLoadedSetsBlockerFlag(t *testing.T) { { Name: "Blocker", Status: account_api.UpdateCheckExtensionCompatibilityStatus{ - Type: "violation", + Name: account_api.CompatibilityNotCompatible, + Type: "red", Label: "Not compatible", }, }, @@ -128,6 +129,49 @@ func TestWizardCompatLoadedSetsBlockerFlag(t *testing.T) { assert.False(t, wm.confirmYes, "blocker should default the confirm to No") } +func TestWizardCompatLoadedUpdatableIsNotBlocker(t *testing.T) { + t.Parallel() + + m := newTestModel(t) + m.phase = phaseCompatCheck + m.compatLoading = true + + // "With new Shopware version" — a compatible release exists, so this must + // not block the upgrade; the resolver bumps the constraint. + updated, _ := m.Update(compatLoadedMsg{ + updates: []account_api.UpdateCheckExtensionCompatibility{ + { + Name: "SwagPayPal", + Status: account_api.UpdateCheckExtensionCompatibilityStatus{ + Name: account_api.CompatibilityUpdatableNow, + Type: "yellow", + Label: "With new Shopware version", + }, + }, + }, + }) + wm := updated.(wizardModel) + assert.Equal(t, phaseCompatResult, wm.phase) + assert.False(t, wm.compatHasBlock, "updatable extension must not block") + assert.True(t, wm.compatHasUpdatable) + assert.True(t, wm.confirmYes, "no blocker means confirm defaults to Yes") +} + +func TestUpgradeTaskOrderRunsPrepareBeforeComposerUpdate(t *testing.T) { + t.Parallel() + + // system:update:prepare must run on the old, still-installed Shopware, + // i.e. before composer update swaps the vendor code; finish runs last. + assert.Less(t, taskSystemPrepare, taskComposerUpdate, "prepare must precede composer update") + assert.Less(t, taskComposerUpdate, taskSystemFinish, "finish must run after composer update") + + tasks := defaultTasks() + require.Len(t, tasks, taskSystemFinish+1) + assert.Equal(t, "bin/console system:update:prepare", tasks[taskSystemPrepare].label) + assert.Equal(t, "composer update --with-all-dependencies", tasks[taskComposerUpdate].label) + assert.Equal(t, "bin/console system:update:finish", tasks[taskSystemFinish].label) +} + func TestWizardTaskCompletePersistsBackupAcrossUpdates(t *testing.T) { t.Parallel() @@ -155,25 +199,43 @@ func TestWizardTaskCompleteErrorEndsRun(t *testing.T) { m.currentTask = taskComposerUpdate updated, _ := m.Update(taskCompleteMsg{ - task: taskComposerUpdate, - err: assertErr("composer update failed"), + task: taskComposerUpdate, + err: assertErr("exit status 2"), + output: []string{"Loading composer repositories", "Your requirements could not be resolved"}, }) wm := updated.(wizardModel) assert.Equal(t, phaseDone, wm.phase) assert.True(t, wm.finished) require.Error(t, wm.finalErr) assert.Equal(t, taskFailed, wm.tasks[taskComposerUpdate].status) + + // The full subprocess output must be retained so the user can see what + // actually failed instead of just "exit status 2". + assert.Equal(t, []string{"Loading composer repositories", "Your requirements could not be resolved"}, wm.fullLog) + + out := wm.viewContent() + assert.Contains(t, out, "Your requirements could not be resolved", "failed step output should be shown on the done screen") } func TestWizardLogLineMsgAppendsAndTrims(t *testing.T) { t.Parallel() m := newTestModel(t) - for i := 0; i < maxLogLines+5; i++ { + total := maxLogLines + 5 + for i := 0; i < total; i++ { updated, _ := m.Update(logLineMsg("line")) m = updated.(wizardModel) } - assert.LessOrEqual(t, len(m.logLines), maxLogLines) + assert.LessOrEqual(t, len(m.logLines), maxLogLines, "live view stays capped") + assert.Len(t, m.fullLog, total, "full log keeps every line for failure reporting") +} + +func TestLastLines(t *testing.T) { + t.Parallel() + + assert.Equal(t, []string{"c", "d"}, lastLines([]string{"a", "b", "c", "d"}, 2)) + assert.Equal(t, []string{"a", "b"}, lastLines([]string{"a", "b"}, 5), "fewer than n returns all") + assert.Nil(t, lastLines(nil, 3)) } type testError string From 26283490cfe3ff7c357d8d9d4d5010971d9aca7d Mon Sep 17 00:00:00 2001 From: Soner Sayakci Date: Fri, 29 May 2026 10:33:22 +0200 Subject: [PATCH 09/14] feat(projectupgrade): use tui.SelectList for version selection Replace the wizard's hand-rolled version-list cursor and rendering with the reusable tui.SelectList component: it owns the cursor, windowing, paging (PgUp/PgDn, Home/End) and the navigation shortcuts, so the wizard only forwards keys and renders. --- internal/projectupgrade/wizard.go | 72 ++++++++++++++------------ internal/projectupgrade/wizard_test.go | 41 ++++++++------- 2 files changed, 60 insertions(+), 53 deletions(-) diff --git a/internal/projectupgrade/wizard.go b/internal/projectupgrade/wizard.go index b04fd95d..26e4ca96 100644 --- a/internal/projectupgrade/wizard.go +++ b/internal/projectupgrade/wizard.go @@ -30,6 +30,10 @@ const streamBufferSize = 50 // view doesn't grow unbounded. const maxLogLines = 18 +// maxVisibleVersions caps how many upgrade targets the version select list +// shows at once so the card keeps a fixed height when many versions exist. +const maxVisibleVersions = 10 + // WizardOptions configures a single run of the upgrade wizard. type WizardOptions struct { ProjectRoot string @@ -117,7 +121,7 @@ type wizardModel struct { phase phase - versionCursor int + versionList *tui.SelectList targetVersion string confirmYes bool composerBackup []byte @@ -162,13 +166,27 @@ func RunWizard(opts WizardOptions) (WizardResult, error) { spinner.WithStyle(lipgloss.NewStyle().Foreground(tui.BrandColor)), ) + versionOptions := make([]tui.SelectOption, len(opts.UpdateVersions)) + for i, v := range opts.UpdateVersions { + detail := "" + if i == 0 { + detail = "latest" + } + versionOptions[i] = tui.SelectOption{Label: v, Detail: detail} + } + m := wizardModel{ - opts: opts, - phase: phaseWelcome, - confirmYes: true, - versionCursor: 0, - spinner: s, - tasks: defaultTasks(), + opts: opts, + phase: phaseWelcome, + confirmYes: true, + versionList: tui.NewSelectList( + "Select target version", + "Pick the Shopware version to upgrade to. Next-major releases are listed first.", + versionOptions, + maxVisibleVersions, + ), + spinner: s, + tasks: defaultTasks(), } prog := tea.NewProgram(m) @@ -348,19 +366,19 @@ func (m wizardModel) updateWelcome(key string) (tea.Model, tea.Cmd) { } func (m wizardModel) updateSelectVersion(key string) (tea.Model, tea.Cmd) { + if m.versionList.HandleKey(key) { + return m, nil + } + switch key { - case "up", "k": - if m.versionCursor > 0 { - m.versionCursor-- - } - case "down", "j": - if m.versionCursor < len(m.opts.UpdateVersions)-1 { - m.versionCursor++ - } case "q", "esc": return m, tea.Quit case "enter": - m.targetVersion = m.opts.UpdateVersions[m.versionCursor] + selected, ok := m.versionList.Selected() + if !ok { + return m, nil + } + m.targetVersion = selected.Label if len(m.opts.Extensions) == 0 { m.phase = phaseReview m.confirmYes = true @@ -792,26 +810,14 @@ func (m wizardModel) viewSelectVersion() string { b.WriteString(stepBadge(m.stepNum(phaseSelectVersion), m.totalSteps())) b.WriteString("\n\n") - opts := make([]tui.SelectOption, len(m.opts.UpdateVersions)) - for i, v := range m.opts.UpdateVersions { - detail := "" - if i == 0 { - detail = "latest" - } - opts[i] = tui.SelectOption{Label: v, Detail: detail} - } - b.WriteString(tui.RenderSelectList( - "Select target version", - "Pick the Shopware version to upgrade to. Next-major releases are listed first.", - opts, - m.versionCursor, - )) + b.WriteString(m.versionList.View()) b.WriteString("\n\n") - b.WriteString(m.footer( - tui.Shortcut{Key: "↑/↓", Label: "Select"}, + + shortcuts := append(m.versionList.Shortcuts(), tui.Shortcut{Key: "enter", Label: "Continue"}, tui.Shortcut{Key: "ctrl+c", Label: "Exit"}, - )) + ) + b.WriteString(m.footer(shortcuts...)) return tui.RenderPhaseCard(b.String()) } diff --git a/internal/projectupgrade/wizard_test.go b/internal/projectupgrade/wizard_test.go index 32a1430c..45841d39 100644 --- a/internal/projectupgrade/wizard_test.go +++ b/internal/projectupgrade/wizard_test.go @@ -9,24 +9,36 @@ import ( "github.com/stretchr/testify/require" account_api "github.com/shopware/shopware-cli/internal/account-api" + "github.com/shopware/shopware-cli/internal/tui" ) func newTestModel(t *testing.T) wizardModel { t.Helper() + return newTestModelWithVersions(t, []string{"6.6.4.0", "6.6.3.0", "6.5.9.0"}) +} + +func newTestModelWithVersions(t *testing.T, versions []string) wizardModel { + t.Helper() current, err := version.NewVersion("6.5.8.0") require.NoError(t, err) + opts := make([]tui.SelectOption, len(versions)) + for i, v := range versions { + opts[i] = tui.SelectOption{Label: v} + } + m := wizardModel{ opts: WizardOptions{ ProjectRoot: "/tmp/example", ComposerJSONPath: "/tmp/example/composer.json", CurrentVersion: current, - UpdateVersions: []string{"6.6.4.0", "6.6.3.0", "6.5.9.0"}, + UpdateVersions: versions, }, - phase: phaseWelcome, - confirmYes: true, - tasks: defaultTasks(), + phase: phaseWelcome, + confirmYes: true, + versionList: tui.NewSelectList("Select target version", "", opts, maxVisibleVersions), + tasks: defaultTasks(), } return m } @@ -52,7 +64,9 @@ func TestWizardWelcomeCancelQuits(t *testing.T) { assert.True(t, ok, "cancel should produce QuitMsg") } -func TestWizardSelectVersionMovesCursor(t *testing.T) { +// Navigation/paging is owned and tested by tui.SelectList; here we only verify +// the wizard forwards keys to it. +func TestWizardSelectVersionForwardsNavigationToList(t *testing.T) { t.Parallel() m := newTestModel(t) @@ -60,20 +74,7 @@ func TestWizardSelectVersionMovesCursor(t *testing.T) { updated, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyDown}) wm := updated.(wizardModel) - assert.Equal(t, 1, wm.versionCursor) - - updated, _ = wm.Update(tea.KeyPressMsg{Code: tea.KeyDown}) - wm = updated.(wizardModel) - assert.Equal(t, 2, wm.versionCursor) - - // Past end should not wrap. - updated, _ = wm.Update(tea.KeyPressMsg{Code: tea.KeyDown}) - wm = updated.(wizardModel) - assert.Equal(t, 2, wm.versionCursor) - - updated, _ = wm.Update(tea.KeyPressMsg{Code: tea.KeyUp}) - wm = updated.(wizardModel) - assert.Equal(t, 1, wm.versionCursor) + assert.Equal(t, 1, wm.versionList.Cursor()) } func TestWizardSelectVersionWithoutExtensionsSkipsToReview(t *testing.T) { @@ -81,7 +82,7 @@ func TestWizardSelectVersionWithoutExtensionsSkipsToReview(t *testing.T) { m := newTestModel(t) m.phase = phaseSelectVersion - m.versionCursor = 1 + m.versionList.HandleKey("down") // move to "6.6.3.0" updated, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) wm := updated.(wizardModel) From 3b0452fa460b36e80dbfc0ecd348f22d9629a20d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 1 Jun 2026 12:17:33 +0000 Subject: [PATCH 10/14] feat(project): delegate post-update to shopware-deployment-helper The upgrade used to drive the lifecycle by hand: stash a composer.json backup, run system:update:prepare on the old code, swap vendor via composer update, run system:update:finish on the new code, and rewind composer.json on any failure. shopware-deployment-helper already owns that lifecycle (prepare, migrations, finish, theme compile, plugin refresh) and is what production deployments run, so the wizard now delegates to it instead of orchestrating each step itself. - UpdateComposerJson additionally calls EnsureRequire to add shopware/deployment-helper to composer.json when the project does not require it yet. The subsequent composer update pulls it in. - The wizard's task list drops the dedicated "Back up composer.json", "system:update:prepare" and "system:update:finish" tasks and gains a single "vendor/bin/shopware-deployment-helper run" task that runs after composer update. Five tasks total instead of seven. - All composer.json backup / restore plumbing is removed from both the wizard and the headless flow. The pre-flight clean-git-tree check already guarantees `git checkout composer.json composer.lock` is a one-liner if the user wants to revert. - packagist.ComposerJson.EnsureRequire is the new shared helper for "add this require entry if missing"; the devtui setup guide reuses it in place of its private branch. --- cmd/project/project_upgrade.go | 62 +++++------------ internal/devtui/setup_guide_apply.go | 7 +- internal/packagist/composer.go | 15 +++++ internal/packagist/composer_test.go | 32 +++++++++ internal/projectupgrade/composer.go | 6 +- internal/projectupgrade/composer_test.go | 21 ++++++ internal/projectupgrade/wizard.go | 84 ++++++------------------ internal/projectupgrade/wizard_test.go | 35 +++------- 8 files changed, 119 insertions(+), 143 deletions(-) diff --git a/cmd/project/project_upgrade.go b/cmd/project/project_upgrade.go index 9e22457c..af056188 100644 --- a/cmd/project/project_upgrade.go +++ b/cmd/project/project_upgrade.go @@ -32,11 +32,13 @@ import ( var projectUpgradeCmd = &cobra.Command{ Use: "upgrade", Short: "Upgrade the Shopware version of this project", - Long: `Upgrade the Shopware project to a newer version. This command mirrors -the behaviour of the shopware/web-installer: it picks an upgrade target, -removes incompatible custom plugins, rewrites composer.json for the new -version, runs composer update --with-all-dependencies, and finally runs -bin/console system:update:prepare and system:update:finish.`, + Long: `Upgrade the Shopware project to a newer version. The command picks an +upgrade target, resolves incompatible custom plugins, rewrites composer.json +for the new version, runs composer update --with-all-dependencies, and then +invokes vendor/bin/shopware-deployment-helper to run system:update:prepare, +migrations, system:update:finish and the rest of the deployment lifecycle. +shopware/deployment-helper is added to composer.json automatically when the +project doesn't require it yet.`, RunE: func(cmd *cobra.Command, _ []string) error { ctx := cmd.Context() log := logging.FromContext(ctx) @@ -162,7 +164,7 @@ func runUpgradeHeadless(cmd *cobra.Command, projectRoot, composerJsonPath string if !confirmed { if err := huh.NewConfirm(). Title(fmt.Sprintf("Upgrade Shopware from %s to %s?", currentVersion.String(), targetVersion)). - Description("This will modify composer.json, run composer update --with-all-dependencies, and execute system:update:prepare/finish. Commit your changes before running this command."). + Description("This will modify composer.json, run composer update --with-all-dependencies, and invoke vendor/bin/shopware-deployment-helper. Commit your changes before running this command."). Value(&confirmed). Run(); err != nil { return err @@ -173,12 +175,6 @@ func runUpgradeHeadless(cmd *cobra.Command, projectRoot, composerJsonPath string return fmt.Errorf("upgrade cancelled") } - log.Infof("Backing up composer.json") - backup, err := os.ReadFile(composerJsonPath) - if err != nil { - return fmt.Errorf("failed to backup composer.json: %w", err) - } - log.Infof("Cleaning up stale recipe files") if err := flexmigrator.CleanupByHash(projectRoot); err != nil { return fmt.Errorf("cleanup stale files: %w", err) @@ -188,7 +184,6 @@ func runUpgradeHeadless(cmd *cobra.Command, projectRoot, composerJsonPath string registry, _ := buildRegistry(cmd, projectRoot) result, err := projectupgrade.ResolveIncompatiblePlugins(ctx, composerJsonPath, targetVersion, registry) if err != nil { - restoreComposerJson(ctx, composerJsonPath, backup) return fmt.Errorf("resolve incompatible plugins: %w", err) } @@ -203,25 +198,9 @@ func runUpgradeHeadless(cmd *cobra.Command, projectRoot, composerJsonPath string log.Infof("Updating composer.json to %s", targetVersion) if err := projectupgrade.UpdateComposerJson(composerJsonPath, targetVersion); err != nil { - restoreComposerJson(ctx, composerJsonPath, backup) return fmt.Errorf("update composer.json: %w", err) } - // system:update:prepare must run on the still-installed (old) Shopware so - // it can enter maintenance mode and record the pending update before the - // vendor code is swapped by composer update. - log.Infof("Running bin/console system:update:prepare") - prepareCmd := cmdExecutor.ConsoleCommand(ctx, "system:update:prepare", "--no-interaction") - prepareCmd.Cmd.Stdin = cmd.InOrStdin() - prepareCmd.Cmd.Stdout = cmd.OutOrStdout() - prepareCmd.Cmd.Stderr = cmd.ErrOrStderr() - - if err := prepareCmd.Run(); err != nil { - restoreComposerJson(ctx, composerJsonPath, backup) - trackUpgrade(ctx, currentVersion.String(), targetVersion, "system_update_prepare_failed") - return fmt.Errorf("system:update:prepare failed, composer.json was restored: %w", err) - } - log.Infof("Running composer update") composerArgs := []string{ "update", @@ -238,20 +217,19 @@ func runUpgradeHeadless(cmd *cobra.Command, projectRoot, composerJsonPath string if err := composerCmd.Run(); err != nil { log.Errorf("composer update failed: %v", err) - restoreComposerJson(ctx, composerJsonPath, backup) trackUpgrade(ctx, currentVersion.String(), targetVersion, "composer_update_failed") - return fmt.Errorf("composer update failed, composer.json was restored: %w", err) + return fmt.Errorf("composer update failed: %w", err) } - log.Infof("Running bin/console system:update:finish") - finishCmd := cmdExecutor.ConsoleCommand(ctx, "system:update:finish", "--no-interaction") - finishCmd.Cmd.Stdin = cmd.InOrStdin() - finishCmd.Cmd.Stdout = cmd.OutOrStdout() - finishCmd.Cmd.Stderr = cmd.ErrOrStderr() + log.Infof("Running vendor/bin/shopware-deployment-helper run") + deployCmd := cmdExecutor.PHPCommand(ctx, "vendor/bin/shopware-deployment-helper", "run") + deployCmd.Cmd.Stdin = cmd.InOrStdin() + deployCmd.Cmd.Stdout = cmd.OutOrStdout() + deployCmd.Cmd.Stderr = cmd.ErrOrStderr() - if err := finishCmd.Run(); err != nil { - trackUpgrade(ctx, currentVersion.String(), targetVersion, "system_update_finish_failed") - return fmt.Errorf("system:update:finish failed: %w", err) + if err := deployCmd.Run(); err != nil { + trackUpgrade(ctx, currentVersion.String(), targetVersion, "deployment_helper_failed") + return fmt.Errorf("shopware-deployment-helper run failed: %w", err) } trackUpgrade(ctx, currentVersion.String(), targetVersion, "ok") @@ -368,12 +346,6 @@ func runCompatibilityCheck(ctx context.Context, currentVersion *version.Version, return nil } -func restoreComposerJson(ctx context.Context, composerJsonPath string, backup []byte) { - if err := os.WriteFile(composerJsonPath, backup, 0o644); err != nil { - logging.FromContext(ctx).Errorf("failed to restore composer.json from backup: %v", err) - } -} - func trackUpgrade(ctx context.Context, fromVersion, toVersion, status string) { trackCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 300*time.Millisecond) defer cancel() diff --git a/internal/devtui/setup_guide_apply.go b/internal/devtui/setup_guide_apply.go index 75376f8a..e5678cd2 100644 --- a/internal/devtui/setup_guide_apply.go +++ b/internal/devtui/setup_guide_apply.go @@ -70,15 +70,10 @@ func ensureDeploymentHelper(projectRoot string) (changed bool, err error) { return false, err } - if cj.HasPackage("shopware/deployment-helper") || cj.HasPackageDev("shopware/deployment-helper") { + if !cj.EnsureRequire("shopware/deployment-helper", "*") { return false, nil } - if cj.Require == nil { - cj.Require = packagist.ComposerPackageLink{} - } - cj.Require["shopware/deployment-helper"] = "*" - if err := cj.Save(); err != nil { return false, err } diff --git a/internal/packagist/composer.go b/internal/packagist/composer.go index 749c3154..2eb460ba 100644 --- a/internal/packagist/composer.go +++ b/internal/packagist/composer.go @@ -126,6 +126,21 @@ func (c *ComposerJson) HasPackageDev(name string) bool { return ok } +// EnsureRequire adds name=constraint to the require block when the package is +// not already present in either require or require-dev. Returns true when +// composer.json was modified; callers must Save() to persist the change. +func (c *ComposerJson) EnsureRequire(name, constraint string) bool { + if c.HasPackage(name) || c.HasPackageDev(name) { + return false + } + + if c.Require == nil { + c.Require = ComposerPackageLink{} + } + c.Require[name] = constraint + return true +} + func (c *ComposerJson) HasConfig(key string) bool { _, ok := c.Config[key] return ok diff --git a/internal/packagist/composer_test.go b/internal/packagist/composer_test.go index e0d34039..83a06bc0 100644 --- a/internal/packagist/composer_test.go +++ b/internal/packagist/composer_test.go @@ -248,3 +248,35 @@ func TestReadComposerJsonDifferentRepositoryWritings(t *testing.T) { assert.ElementsMatch(t, expectedRepos, composer.Repositories) }) } + +func TestEnsureRequireAddsWhenMissing(t *testing.T) { + t.Parallel() + + cj := &ComposerJson{Require: ComposerPackageLink{"shopware/core": "^6.6"}} + assert.True(t, cj.EnsureRequire("shopware/deployment-helper", "*")) + assert.Equal(t, "*", cj.Require["shopware/deployment-helper"]) +} + +func TestEnsureRequireNoOpWhenAlreadyRequired(t *testing.T) { + t.Parallel() + + cj := &ComposerJson{Require: ComposerPackageLink{"shopware/deployment-helper": "^1.0"}} + assert.False(t, cj.EnsureRequire("shopware/deployment-helper", "*")) + assert.Equal(t, "^1.0", cj.Require["shopware/deployment-helper"], "an existing constraint must not be overwritten") +} + +func TestEnsureRequireNoOpWhenPresentInRequireDev(t *testing.T) { + t.Parallel() + + cj := &ComposerJson{RequireDev: ComposerPackageLink{"shopware/deployment-helper": "^1.0"}} + assert.False(t, cj.EnsureRequire("shopware/deployment-helper", "*")) + assert.NotContains(t, cj.Require, "shopware/deployment-helper", "must not duplicate into require when present in require-dev") +} + +func TestEnsureRequireInitializesRequireMap(t *testing.T) { + t.Parallel() + + cj := &ComposerJson{} + assert.True(t, cj.EnsureRequire("shopware/deployment-helper", "*")) + assert.Equal(t, "*", cj.Require["shopware/deployment-helper"]) +} diff --git a/internal/projectupgrade/composer.go b/internal/projectupgrade/composer.go index 4f56d322..ee4a48f5 100644 --- a/internal/projectupgrade/composer.go +++ b/internal/projectupgrade/composer.go @@ -24,7 +24,9 @@ var ShopwarePackages = []string{ // UpdateComposerJson rewrites the project composer.json so that composer can // resolve dependencies for targetVersion. It mirrors the logic of -// `Shopware\WebInstaller\Services\ProjectComposerJsonUpdater`. +// `Shopware\WebInstaller\Services\ProjectComposerJsonUpdater` and additionally +// ensures shopware/deployment-helper is required so the post-update step can +// invoke vendor/bin/shopware-deployment-helper. func UpdateComposerJson(composerJsonPath, targetVersion string) error { composerJson, err := packagist.ReadComposerJson(composerJsonPath) if err != nil { @@ -51,6 +53,8 @@ func UpdateComposerJson(composerJsonPath, targetVersion string) error { } } + composerJson.EnsureRequire("shopware/deployment-helper", "*") + return composerJson.Save() } diff --git a/internal/projectupgrade/composer_test.go b/internal/projectupgrade/composer_test.go index 8eadc74d..b59a26b7 100644 --- a/internal/projectupgrade/composer_test.go +++ b/internal/projectupgrade/composer_test.go @@ -54,6 +54,27 @@ func TestUpdateComposerJsonRewritesShopwarePackages(t *testing.T) { assert.Equal(t, "6.6.4.0", requireMap["shopware/storefront"]) assert.Equal(t, "^1.0", requireMap["unrelated/package"]) assert.NotContains(t, requireMap, "shopware/elasticsearch", "should not add packages that were not already required") + assert.Equal(t, "*", requireMap["shopware/deployment-helper"], "deployment-helper is added so the upgrade can invoke it after composer update") +} + +func TestUpdateComposerJsonLeavesExistingDeploymentHelperConstraint(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + composerJsonPath := filepath.Join(dir, "composer.json") + + writeJSON(t, composerJsonPath, map[string]any{ + "name": "shopware/production", + "require": map[string]any{ + "shopware/core": "6.5.8.0", + "shopware/deployment-helper": "^1.0", + }, + }) + + require.NoError(t, UpdateComposerJson(composerJsonPath, "6.6.4.0")) + + requireMap := readJSON(t, composerJsonPath)["require"].(map[string]any) + assert.Equal(t, "^1.0", requireMap["shopware/deployment-helper"], "an existing constraint must not be overwritten") } func TestUpdateComposerJsonSetsRCStability(t *testing.T) { diff --git a/internal/projectupgrade/wizard.go b/internal/projectupgrade/wizard.go index 26e4ca96..f0f5af23 100644 --- a/internal/projectupgrade/wizard.go +++ b/internal/projectupgrade/wizard.go @@ -6,7 +6,6 @@ import ( "errors" "fmt" "io" - "os" "os/exec" "strings" "time" @@ -77,18 +76,16 @@ type task struct { detail string } -// taskCleanup, taskPlugins, ... are stable indices into model.tasks. -// system:update:prepare runs on the still-installed (old) Shopware so it can -// enter maintenance and record the update before the vendor code changes; -// composer update then swaps the code, and system:update:finish migrates. +// Stable indices into model.tasks. The flow is: rewrite composer.json, +// have composer pull the new vendor code, then let shopware-deployment-helper +// drive the install/update lifecycle (system:update:prepare, migrations, +// system:update:finish, theme compile, etc.) in one pass. const ( - taskBackup = iota - taskCleanup + taskCleanup = iota taskPlugins taskComposerJSON - taskSystemPrepare taskComposerUpdate - taskSystemFinish + taskDeploymentHelper ) // wizardMsg variants advance the upgrade state machine. @@ -98,11 +95,10 @@ type ( err error } taskCompleteMsg struct { - task int - err error - detail string - composerBackup []byte - pluginActions *ResolveResult + task int + err error + detail string + pluginActions *ResolveResult // output is the full captured subprocess output, set for streaming // tasks so the complete log survives independent of log-line event // ordering. Used to render the error tail on failure. @@ -124,7 +120,6 @@ type wizardModel struct { versionList *tui.SelectList targetVersion string confirmYes bool - composerBackup []byte pluginActions *ResolveResult compatUpdates []account_api.UpdateCheckExtensionCompatibility compatHasBlock bool @@ -220,13 +215,11 @@ var ErrCancelled = errors.New("upgrade cancelled by user") func defaultTasks() []task { return []task{ - {label: "Back up composer.json"}, {label: "Clean up stale recipe files"}, {label: "Resolve incompatible custom plugins"}, {label: "Rewrite composer.json"}, - {label: "bin/console system:update:prepare"}, {label: "composer update --with-all-dependencies"}, - {label: "bin/console system:update:finish"}, + {label: "vendor/bin/shopware-deployment-helper run"}, } } @@ -269,9 +262,6 @@ func (m wizardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.startTask() case taskCompleteMsg: - if msg.composerBackup != nil { - m.composerBackup = msg.composerBackup - } if msg.pluginActions != nil { m.pluginActions = msg.pluginActions } @@ -510,8 +500,6 @@ func (m wizardModel) startTask() (tea.Model, tea.Cmd) { m.tasks[m.currentTask].status = taskRunning switch m.currentTask { - case taskBackup: - return m, m.runBackup() case taskCleanup: return m, m.runCleanup() case taskPlugins: @@ -520,31 +508,13 @@ func (m wizardModel) startTask() (tea.Model, tea.Cmd) { return m, m.runUpdateComposer() case taskComposerUpdate: return m.startComposerUpdate() - case taskSystemPrepare: - // prepare runs before composer update, while composer.json is already - // rewritten - restore it on failure so the project is left untouched. - return m.startSystemUpdate("system:update:prepare", taskSystemPrepare, true) - case taskSystemFinish: - // finish runs after a successful composer update; restoring the old - // composer.json here would undo the upgrade, so leave it in place. - return m.startSystemUpdate("system:update:finish", taskSystemFinish, false) + case taskDeploymentHelper: + return m.startDeploymentHelper() } return m, nil } -func (m wizardModel) runBackup() tea.Cmd { - composerJSONPath := m.opts.ComposerJSONPath - idx := taskBackup - return func() tea.Msg { - data, err := os.ReadFile(composerJSONPath) - if err != nil { - return taskCompleteMsg{task: idx, err: fmt.Errorf("read composer.json: %w", err)} - } - return taskCompleteMsg{task: idx, detail: fmt.Sprintf("%d bytes", len(data)), composerBackup: data} - } -} - func (m wizardModel) runCleanup() tea.Cmd { projectRoot := m.opts.ProjectRoot idx := taskCleanup @@ -560,7 +530,6 @@ func (m wizardModel) runRemovePlugins() tea.Cmd { composerJSONPath := m.opts.ComposerJSONPath target := m.targetVersion idx := taskPlugins - restore := m.composerBackup registry := m.opts.Registry return func() tea.Msg { ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) @@ -568,7 +537,6 @@ func (m wizardModel) runRemovePlugins() tea.Cmd { result, err := ResolveIncompatiblePlugins(ctx, composerJSONPath, target, registry) if err != nil { - _ = os.WriteFile(composerJSONPath, restore, 0o644) return taskCompleteMsg{task: idx, err: err} } if result == nil { @@ -593,10 +561,8 @@ func (m wizardModel) runUpdateComposer() tea.Cmd { composerJSONPath := m.opts.ComposerJSONPath target := m.targetVersion idx := taskComposerJSON - restore := m.composerBackup return func() tea.Msg { if err := UpdateComposerJson(composerJSONPath, target); err != nil { - _ = os.WriteFile(composerJSONPath, restore, 0o644) return taskCompleteMsg{task: idx, err: err} } return taskCompleteMsg{task: idx, detail: "pinned to " + target} @@ -621,22 +587,17 @@ func (m wizardModel) startComposerUpdate() (tea.Model, tea.Cmd) { } p := m.opts.Executor.ComposerCommand(ctx, args...) - restore := m.composerBackup - composerJSONPath := m.opts.ComposerJSONPath idx := taskComposerUpdate doneCmd := func() tea.Msg { output, err := streamCmdOutput(p.Cmd, ch, true) - if err != nil { - _ = os.WriteFile(composerJSONPath, restore, 0o644) - } return taskCompleteMsg{task: idx, err: err, output: output} } return m, tea.Batch(m.readNextLog(), doneCmd) } -func (m wizardModel) startSystemUpdate(consoleCmd string, idx int, restoreOnFail bool) (tea.Model, tea.Cmd) { +func (m wizardModel) startDeploymentHelper() (tea.Model, tea.Cmd) { ctx, cancel := context.WithCancel(context.Background()) m.cancelExecution = cancel @@ -645,16 +606,12 @@ func (m wizardModel) startSystemUpdate(consoleCmd string, idx int, restoreOnFail m.logLines = nil m.fullLog = nil - p := m.opts.Executor.ConsoleCommand(ctx, consoleCmd, "--no-interaction") + p := m.opts.Executor.PHPCommand(ctx, "vendor/bin/shopware-deployment-helper", "run") - restore := m.composerBackup - composerJSONPath := m.opts.ComposerJSONPath + idx := taskDeploymentHelper doneCmd := func() tea.Msg { output, err := streamCmdOutput(p.Cmd, ch, true) - if err != nil && restoreOnFail { - _ = os.WriteFile(composerJSONPath, restore, 0o644) - } return taskCompleteMsg{task: idx, err: err, output: output} } @@ -776,12 +733,11 @@ func (m wizardModel) viewWelcome() string { b.WriteString(tui.DimStyle.Render("This wizard mirrors the shopware/web-installer flow:")) b.WriteString("\n\n") for _, line := range []string{ - "Back up composer.json before any change", "Clean up stale recipe-managed files (md5-matched)", - "Drop incompatible custom plugins from composer.json", - "Rewrite composer.json to pin the target version", + "Resolve incompatible custom plugins (bump or drop)", + "Rewrite composer.json to pin the target version and ensure shopware/deployment-helper", "Run composer update --with-all-dependencies --no-scripts", - "Run bin/console system:update:prepare + finish", + "Run vendor/bin/shopware-deployment-helper run", } { b.WriteString(tui.DimStyle.Render(" • ")) b.WriteString(tui.LabelStyle.Render(line)) @@ -981,7 +937,7 @@ func (m wizardModel) viewDone() string { b.WriteString("\n\n") } - b.WriteString(tui.DimStyle.Render("composer.json was restored from the backup taken before the upgrade.")) + b.WriteString(tui.DimStyle.Render("composer.json and vendor are left as-is; run `git checkout composer.json composer.lock` to revert.")) } else { b.WriteString(lipgloss.NewStyle().Bold(true).Foreground(tui.SuccessColor).Render(fmt.Sprintf("✓ Upgraded to Shopware %s", m.targetVersion))) b.WriteString("\n\n") diff --git a/internal/projectupgrade/wizard_test.go b/internal/projectupgrade/wizard_test.go index 45841d39..bd271fcc 100644 --- a/internal/projectupgrade/wizard_test.go +++ b/internal/projectupgrade/wizard_test.go @@ -158,38 +158,19 @@ func TestWizardCompatLoadedUpdatableIsNotBlocker(t *testing.T) { assert.True(t, wm.confirmYes, "no blocker means confirm defaults to Yes") } -func TestUpgradeTaskOrderRunsPrepareBeforeComposerUpdate(t *testing.T) { +func TestUpgradeTaskOrderRunsDeploymentHelperLast(t *testing.T) { t.Parallel() - // system:update:prepare must run on the old, still-installed Shopware, - // i.e. before composer update swaps the vendor code; finish runs last. - assert.Less(t, taskSystemPrepare, taskComposerUpdate, "prepare must precede composer update") - assert.Less(t, taskComposerUpdate, taskSystemFinish, "finish must run after composer update") + // composer update must rewrite vendor before shopware-deployment-helper + // runs the install/update lifecycle that drives system:update:prepare, + // migrations, system:update:finish and theme compilation. + assert.Less(t, taskComposerJSON, taskComposerUpdate, "composer.json must be rewritten before composer update") + assert.Less(t, taskComposerUpdate, taskDeploymentHelper, "deployment-helper runs after composer update") tasks := defaultTasks() - require.Len(t, tasks, taskSystemFinish+1) - assert.Equal(t, "bin/console system:update:prepare", tasks[taskSystemPrepare].label) + require.Len(t, tasks, taskDeploymentHelper+1) assert.Equal(t, "composer update --with-all-dependencies", tasks[taskComposerUpdate].label) - assert.Equal(t, "bin/console system:update:finish", tasks[taskSystemFinish].label) -} - -func TestWizardTaskCompletePersistsBackupAcrossUpdates(t *testing.T) { - t.Parallel() - - m := newTestModel(t) - m.phase = phaseRunning - m.currentTask = taskBackup - - // First task: backup captures composer.json bytes. - updated, _ := m.Update(taskCompleteMsg{ - task: taskBackup, - composerBackup: []byte(`{"name":"shopware/production"}`), - detail: "30 bytes", - }) - wm := updated.(wizardModel) - assert.Equal(t, []byte(`{"name":"shopware/production"}`), wm.composerBackup, "backup must persist for later restore-on-failure") - assert.Equal(t, taskCleanup, wm.currentTask) - assert.Equal(t, taskDone, wm.tasks[taskBackup].status) + assert.Equal(t, "vendor/bin/shopware-deployment-helper run", tasks[taskDeploymentHelper].label) } func TestWizardTaskCompleteErrorEndsRun(t *testing.T) { From b64adef061574c8577447f88a834e69dd97f7979 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 1 Jun 2026 12:33:02 +0000 Subject: [PATCH 11/14] feat(projectupgrade): replace account-api compat with composer data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The compatibility phase used to call the Shopware account API to ask "which extensions are blocked by this Shopware version?". The same question is already answered by the composer-managed plugin metadata the resolver consults right after: for each platform plugin, its require.shopware/* tells us whether it satisfies the target, and the registry (Packagist + Shopware store) tells us whether a newer release would. Use that consistent source instead and drop the account API dependency from the upgrade flow: - projectupgrade.CheckPluginCompatibility is a dry-run of the resolver: for every shopware-platform-plugin in composer.json's require it reports CompatCompatible / CompatUpdatable (with the target version) / CompatBlocker (no compatible release) / CompatUnknown (registry unreachable - e.g. store token missing). - The wizard's loadCompatibility now calls that instead of account_api.GetFutureExtensionUpdates, and renders rows as `name — current → new` (Updatable) or `name — current — status`. - The headless runCompatibilityCheck mirrors the wizard's logic and renders a Plugin / Current / Status table. - packagist.InstalledPackage gains a Version field so the report can show the installed version. - WizardOptions.Extensions and the "skip compat phase when no extensions" branch are removed - the phase always runs against the composer data, and the step counter is always 4. The account-api package itself stays in the codebase; it is still used by `project upgrade-check` and other commands that genuinely need the store's extension catalog. --- cmd/project/project_upgrade.go | 76 +++------ internal/packagist/installed.go | 1 + internal/projectupgrade/compatibility.go | 120 +++++++++++++++ internal/projectupgrade/compatibility_test.go | 144 ++++++++++++++++++ internal/projectupgrade/wizard.go | 82 +++------- internal/projectupgrade/wizard_test.go | 43 +----- 6 files changed, 315 insertions(+), 151 deletions(-) create mode 100644 internal/projectupgrade/compatibility.go create mode 100644 internal/projectupgrade/compatibility_test.go diff --git a/cmd/project/project_upgrade.go b/cmd/project/project_upgrade.go index af056188..cd69df06 100644 --- a/cmd/project/project_upgrade.go +++ b/cmd/project/project_upgrade.go @@ -16,7 +16,6 @@ import ( "github.com/shyim/go-version" "github.com/spf13/cobra" - account_api "github.com/shopware/shopware-cli/internal/account-api" "github.com/shopware/shopware-cli/internal/executor" "github.com/shopware/shopware-cli/internal/extension" "github.com/shopware/shopware-cli/internal/flexmigrator" @@ -94,12 +93,6 @@ project doesn't require it yet.`, } // Interactive: hand off to the devtui-styled wizard. - _, extensions, err := getLocalExtensions() - if err != nil { - log.Warnf("Could not gather local extensions for compatibility check: %v", err) - extensions = nil - } - registry, err := buildRegistry(cmd, projectRoot) if err != nil { return err @@ -110,7 +103,6 @@ project doesn't require it yet.`, ComposerJSONPath: composerJsonPath, CurrentVersion: currentVersion, UpdateVersions: updateVersions, - Extensions: extensions, Executor: cmdExecutor, Registry: registry, }) @@ -156,7 +148,12 @@ func runUpgradeHeadless(cmd *cobra.Command, projectRoot, composerJsonPath string return err } - if err := runCompatibilityCheck(ctx, currentVersion, targetVersion); err != nil { + registry, err := buildRegistry(cmd, projectRoot) + if err != nil { + return err + } + + if err := runCompatibilityCheck(ctx, composerJsonPath, targetVersion, registry); err != nil { return err } @@ -181,7 +178,6 @@ func runUpgradeHeadless(cmd *cobra.Command, projectRoot, composerJsonPath string } log.Infof("Checking custom plugins for incompatibilities") - registry, _ := buildRegistry(cmd, projectRoot) result, err := projectupgrade.ResolveIncompatiblePlugins(ctx, composerJsonPath, targetVersion, registry) if err != nil { return fmt.Errorf("resolve incompatible plugins: %w", err) @@ -271,67 +267,37 @@ func selectTargetVersion(cmd *cobra.Command, updateVersions []string) (string, e return selected, nil } -func runCompatibilityCheck(ctx context.Context, currentVersion *version.Version, targetVersion string) error { +func runCompatibilityCheck(ctx context.Context, composerJsonPath, targetVersion string, registry projectupgrade.Registry) error { log := logging.FromContext(ctx) - _, extensions, err := getLocalExtensions() + results, err := projectupgrade.CheckPluginCompatibility(ctx, composerJsonPath, targetVersion, registry) if err != nil { - log.Warnf("Skipping extension compatibility check: %v", err) - return nil - } - - if len(extensions) == 0 { + log.Warnf("Skipping plugin compatibility check: %v", err) return nil } - requests := make([]account_api.UpdateCheckExtension, 0, len(extensions)) - for name, v := range extensions { - requests = append(requests, account_api.UpdateCheckExtension{Name: name, Version: v}) - } - - updates, err := account_api.GetFutureExtensionUpdates(ctx, currentVersion.String(), targetVersion, requests) - if err != nil { - log.Warnf("Skipping extension compatibility check: %v", err) + if len(results) == 0 { return nil } - for _, name := range requests { - found := false - for _, update := range updates { - if update.Name == name.Name { - found = true - break - } - } - - if !found { - updates = append(updates, account_api.UpdateCheckExtensionCompatibility{ - Name: name.Name, - Status: account_api.UpdateCheckExtensionCompatibilityStatus{ - Label: "Not available in Store", - }, - }) - } - } - - t := table.New().Border(lipgloss.NormalBorder()).Headers("Extension Name", "Compatible") - for _, update := range updates { - t.Row(update.Name, update.Status.Label) - } - fmt.Println(t.Render()) - + t := table.New().Border(lipgloss.NormalBorder()).Headers("Plugin", "Current", "Status") hasBlockers := false - for _, update := range updates { - if update.Status.IsBlocker() { + for _, row := range results { + status := row.Status.Label() + if row.Status == projectupgrade.CompatUpdatable && row.NewVersion != "" { + status = fmt.Sprintf("%s → %s", row.Status.Label(), row.NewVersion) + } + t.Row(row.Name, row.CurrentVersion, status) + if row.Status.IsBlocker() { hasBlockers = true - break } } + fmt.Println(t.Render()) if hasBlockers && system.IsInteractionEnabled(ctx) { var proceed bool if err := huh.NewConfirm(). - Title("Some installed extensions have no compatible version for the target version"). + Title("Some plugins have no compatible version for the target version"). Description("They will be removed from composer.json so the upgrade can proceed. Re-require them once they publish a compatible release. Proceed anyway?"). Value(&proceed). Run(); err != nil { @@ -339,7 +305,7 @@ func runCompatibilityCheck(ctx context.Context, currentVersion *version.Version, } if !proceed { - return fmt.Errorf("upgrade cancelled due to incompatible extensions") + return fmt.Errorf("upgrade cancelled due to incompatible plugins") } } diff --git a/internal/packagist/installed.go b/internal/packagist/installed.go index 6c842da5..15338a0f 100644 --- a/internal/packagist/installed.go +++ b/internal/packagist/installed.go @@ -14,6 +14,7 @@ import ( type InstalledPackage struct { Name string `json:"name"` Type string `json:"type"` + Version string `json:"version"` Require map[string]string `json:"require"` InstallPath string `json:"install-path"` } diff --git a/internal/projectupgrade/compatibility.go b/internal/projectupgrade/compatibility.go new file mode 100644 index 00000000..98e9c998 --- /dev/null +++ b/internal/projectupgrade/compatibility.go @@ -0,0 +1,120 @@ +package projectupgrade + +import ( + "context" + "fmt" + "path/filepath" + "strings" + + "github.com/shyim/go-version" + + "github.com/shopware/shopware-cli/internal/packagist" +) + +// CompatStatus describes the upgrade plan for one composer-managed plugin. +type CompatStatus int + +const ( + // CompatCompatible: the installed version already satisfies the target. + CompatCompatible CompatStatus = iota + // CompatUpdatable: a newer compatible release exists; the resolver will + // bump the constraint. + CompatUpdatable + // CompatBlocker: no compatible release is published; the resolver will + // remove the plugin from composer.json so the upgrade can proceed. + CompatBlocker + // CompatUnknown: the registry could not be consulted (e.g. no token for a + // store plugin); the resolver will drop the plugin too, but the user may + // want to retry with credentials. + CompatUnknown +) + +// IsBlocker reports whether this status means the upgrade will drop the plugin. +func (s CompatStatus) IsBlocker() bool { return s == CompatBlocker || s == CompatUnknown } + +// IsUpdatable reports whether this status means the constraint will be bumped. +func (s CompatStatus) IsUpdatable() bool { return s == CompatUpdatable } + +// Label returns a short human-readable label. +func (s CompatStatus) Label() string { + switch s { + case CompatCompatible: + return "Compatible" + case CompatUpdatable: + return "Update available" + case CompatBlocker: + return "No compatible release" + case CompatUnknown: + return "Not in registry" + } + return "" +} + +// PluginCompat is one row of the compatibility preview. +type PluginCompat struct { + // Name is the composer package name (e.g. "swag/paypal"). + Name string + // CurrentVersion is the version recorded in installed.json. + CurrentVersion string + // NewVersion is the version the resolver would bump to. Populated when + // Status is CompatUpdatable. + NewVersion string + // Status classifies how the resolver will treat this plugin. + Status CompatStatus +} + +// CheckPluginCompatibility consults the registry for every composer-managed +// shopware platform plugin and reports how the upgrade will treat it. The +// composer.json is not modified; this is a dry-run of +// ResolveIncompatiblePlugins so callers can preview the plan before applying +// it. +func CheckPluginCompatibility(ctx context.Context, composerJsonPath, targetVersion string, registry Registry) ([]PluginCompat, error) { + projectDir := filepath.Dir(composerJsonPath) + + installed, err := packagist.ReadInstalledJson(projectDir) + if err != nil { + return nil, err + } + + composerJson, err := packagist.ReadComposerJson(composerJsonPath) + if err != nil { + return nil, err + } + + target, err := version.NewVersion(strings.TrimPrefix(targetVersion, "v")) + if err != nil { + return nil, fmt.Errorf("parse target version: %w", err) + } + + results := make([]PluginCompat, 0) + for _, pkg := range installed.Packages { + if pkg.Type != composerPluginType { + continue + } + if _, ok := composerJson.Require[pkg.Name]; !ok { + continue + } + + row := PluginCompat{Name: pkg.Name, CurrentVersion: strings.TrimPrefix(pkg.Version, "v")} + + if packagist.ConstraintsSatisfiedBy(pkg.Require, ShopwarePackages, target) { + row.Status = CompatCompatible + results = append(results, row) + continue + } + + newVersion, lookupErr := findCompatibleVersion(ctx, registry, pkg.Name, target) + switch { + case newVersion != "": + row.Status = CompatUpdatable + row.NewVersion = newVersion + case lookupErr != nil: + row.Status = CompatUnknown + default: + row.Status = CompatBlocker + } + results = append(results, row) + } + + return results, nil +} diff --git a/internal/projectupgrade/compatibility_test.go b/internal/projectupgrade/compatibility_test.go new file mode 100644 index 00000000..9ee035f9 --- /dev/null +++ b/internal/projectupgrade/compatibility_test.go @@ -0,0 +1,144 @@ +package projectupgrade + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/shopware/shopware-cli/internal/packagist" +) + +func TestCheckPluginCompatibilityClassifiesEachPlugin(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + composerJsonPath := filepath.Join(dir, "composer.json") + + writeJSON(t, composerJsonPath, map[string]any{ + "name": "shopware/production", + "require": map[string]any{ + "shopware/core": "6.5.8.0", + "vendor/compat": "^1.0", + "vendor/updates": "^1.0", + "vendor/blocker": "^1.0", + "transitive/skip": "^1.0", // not in installed.json -> ignored + }, + }) + + writeInstalledJSON(t, dir, []packagist.InstalledPackage{ + { + Name: "vendor/compat", + Type: composerPluginType, + Version: "1.2.0", + InstallPath: "../../custom/plugins/Compat", + Require: map[string]string{"shopware/core": "^6.5 | ^6.6"}, + }, + { + Name: "vendor/updates", + Type: composerPluginType, + Version: "1.0.0", + InstallPath: "../../custom/plugins/Updates", + Require: map[string]string{"shopware/core": "~6.5.0"}, + }, + { + Name: "vendor/blocker", + Type: composerPluginType, + Version: "1.0.0", + InstallPath: "../../custom/plugins/Blocker", + Require: map[string]string{"shopware/core": "~6.5.0"}, + }, + }) + + registry := &fakeRegistry{ + versions: map[string][]packagist.ComposerPackageVersion{ + "vendor/updates": { + {Version: "2.0.0", Require: map[string]string{"shopware/core": "^6.6"}}, + }, + "vendor/blocker": { + {Version: "1.1.0", Require: map[string]string{"shopware/core": "~6.5.0"}}, + }, + }, + } + + results, err := CheckPluginCompatibility(t.Context(), composerJsonPath, "6.6.4.0", registry) + require.NoError(t, err) + require.Len(t, results, 3, "transitive/skip must be ignored - it is not in installed.json") + + byName := map[string]PluginCompat{} + for _, r := range results { + byName[r.Name] = r + } + + assert.Equal(t, CompatCompatible, byName["vendor/compat"].Status) + assert.Equal(t, "1.2.0", byName["vendor/compat"].CurrentVersion) + + assert.Equal(t, CompatUpdatable, byName["vendor/updates"].Status) + assert.Equal(t, "1.0.0", byName["vendor/updates"].CurrentVersion) + assert.Equal(t, "2.0.0", byName["vendor/updates"].NewVersion) + + assert.Equal(t, CompatBlocker, byName["vendor/blocker"].Status) + assert.Equal(t, "1.0.0", byName["vendor/blocker"].CurrentVersion) +} + +func TestCheckPluginCompatibilityReportsUnknownOnRegistryError(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + composerJsonPath := filepath.Join(dir, "composer.json") + + writeJSON(t, composerJsonPath, map[string]any{ + "name": "shopware/production", + "require": map[string]any{ + "shopware/core": "6.5.8.0", + "store/mysteryx": "^1.0", + }, + }) + + writeInstalledJSON(t, dir, []packagist.InstalledPackage{ + { + Name: "store/mysteryx", + Type: composerPluginType, + Version: "1.0.0", + InstallPath: "../store/mysteryx", + Require: map[string]string{"shopware/core": "~6.5.0"}, + }, + }) + + registry := &fakeRegistry{err: assertErr("no token configured")} + + results, err := CheckPluginCompatibility(t.Context(), composerJsonPath, "6.6.4.0", registry) + require.NoError(t, err) + require.Len(t, results, 1) + assert.Equal(t, CompatUnknown, results[0].Status, "registry errors surface as 'unknown' so the user can retry with credentials") + assert.True(t, results[0].Status.IsBlocker(), "unknown counts as a blocker - the resolver will drop the plugin") +} + +func TestCheckPluginCompatibilityIgnoresNonPlatformPlugins(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + composerJsonPath := filepath.Join(dir, "composer.json") + + writeJSON(t, composerJsonPath, map[string]any{ + "name": "shopware/production", + "require": map[string]any{ + "shopware/core": "6.5.8.0", + "symfony/console": "^6.0", + }, + }) + + writeInstalledJSON(t, dir, []packagist.InstalledPackage{ + { + Name: "symfony/console", + Type: "library", // not a shopware-platform-plugin + Version: "6.4.0", + InstallPath: "../symfony/console", + }, + }) + + results, err := CheckPluginCompatibility(t.Context(), composerJsonPath, "6.6.4.0", nil) + require.NoError(t, err) + assert.Empty(t, results, "non-platform-plugin libraries are not part of the upgrade plan") +} diff --git a/internal/projectupgrade/wizard.go b/internal/projectupgrade/wizard.go index f0f5af23..1e64c1f7 100644 --- a/internal/projectupgrade/wizard.go +++ b/internal/projectupgrade/wizard.go @@ -15,7 +15,6 @@ import ( "charm.land/lipgloss/v2" "github.com/shyim/go-version" - account_api "github.com/shopware/shopware-cli/internal/account-api" "github.com/shopware/shopware-cli/internal/executor" "github.com/shopware/shopware-cli/internal/flexmigrator" "github.com/shopware/shopware-cli/internal/tui" @@ -39,7 +38,6 @@ type WizardOptions struct { ComposerJSONPath string CurrentVersion *version.Version UpdateVersions []string - Extensions map[string]string Executor executor.Executor // Registry is consulted to find newer compatible versions of plugins // whose installed shopware/core constraint is no longer satisfied. May @@ -91,7 +89,7 @@ const ( // wizardMsg variants advance the upgrade state machine. type ( compatLoadedMsg struct { - updates []account_api.UpdateCheckExtensionCompatibility + updates []PluginCompat err error } taskCompleteMsg struct { @@ -121,7 +119,7 @@ type wizardModel struct { targetVersion string confirmYes bool pluginActions *ResolveResult - compatUpdates []account_api.UpdateCheckExtensionCompatibility + compatUpdates []PluginCompat compatHasBlock bool compatHasUpdatable bool compatErr error @@ -369,11 +367,6 @@ func (m wizardModel) updateSelectVersion(key string) (tea.Model, tea.Cmd) { return m, nil } m.targetVersion = selected.Label - if len(m.opts.Extensions) == 0 { - m.phase = phaseReview - m.confirmYes = true - return m, nil - } m.phase = phaseCompatCheck m.compatLoading = true return m, tea.Batch(m.spinner.Tick, m.loadCompatibility()) @@ -448,44 +441,21 @@ func (m wizardModel) readNextLog() tea.Cmd { } } -// loadCompatibility queries the Shopware account API for extension -// compatibility against the chosen target version. +// loadCompatibility runs CheckPluginCompatibility against the registry to +// preview how each composer-managed plugin will be treated by the upgrade. func (m wizardModel) loadCompatibility() tea.Cmd { - requests := make([]account_api.UpdateCheckExtension, 0, len(m.opts.Extensions)) - for name, v := range m.opts.Extensions { - requests = append(requests, account_api.UpdateCheckExtension{Name: name, Version: v}) - } - currentVersion := m.opts.CurrentVersion.String() + composerJsonPath := m.opts.ComposerJSONPath targetVersion := m.targetVersion + registry := m.opts.Registry return func() tea.Msg { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() - updates, err := account_api.GetFutureExtensionUpdates(ctx, currentVersion, targetVersion, requests) + updates, err := CheckPluginCompatibility(ctx, composerJsonPath, targetVersion, registry) if err != nil { return compatLoadedMsg{err: err} } - - for _, name := range requests { - found := false - for _, update := range updates { - if update.Name == name.Name { - found = true - break - } - } - - if !found { - updates = append(updates, account_api.UpdateCheckExtensionCompatibility{ - Name: name.Name, - Status: account_api.UpdateCheckExtensionCompatibilityStatus{ - Label: "Not available in Store", - }, - }) - } - } - return compatLoadedMsg{updates: updates} } } @@ -696,10 +666,7 @@ func (m wizardModel) viewContent() string { } func (m wizardModel) totalSteps() int { - if len(m.opts.Extensions) == 0 { - return 3 // Select version, Review, Run - } - return 4 // + Compatibility check + return 4 // Select version, Compatibility check, Review, Run } func (m wizardModel) stepNum(p phase) int { @@ -709,14 +676,8 @@ func (m wizardModel) stepNum(p phase) int { case phaseCompatCheck, phaseCompatResult: return 2 case phaseReview: - if len(m.opts.Extensions) == 0 { - return 2 - } return 3 case phaseRunning: - if len(m.opts.Extensions) == 0 { - return 3 - } return 4 case phaseWelcome, phaseDone: return 0 @@ -747,9 +708,6 @@ func (m wizardModel) viewWelcome() string { b.WriteString(tui.SectionDivider(tui.PhaseCardWidth - 6)) b.WriteString(tui.KVRow("Current version", tui.BoldText.Render(m.opts.CurrentVersion.String()))) b.WriteString(tui.KVRow("Project root", tui.DimStyle.Render(m.opts.ProjectRoot))) - if len(m.opts.Extensions) > 0 { - b.WriteString(tui.KVRow("Installed extensions", tui.LabelStyle.Render(fmt.Sprintf("%d", len(m.opts.Extensions))))) - } b.WriteString("\n") b.WriteString(renderConfirmButtons("Begin upgrade", "Cancel", m.confirmYes)) b.WriteString("\n\n") @@ -781,9 +739,9 @@ func (m wizardModel) viewCompatCheck() string { var b strings.Builder b.WriteString(stepBadge(m.stepNum(phaseCompatCheck), m.totalSteps())) b.WriteString("\n\n") - b.WriteString(tui.TitleStyle.Render("Checking extension compatibility")) + b.WriteString(tui.TitleStyle.Render("Checking plugin compatibility")) b.WriteString("\n") - b.WriteString(tui.DimStyle.Render(fmt.Sprintf("Asking the Shopware store about %d installed extension(s) against %s…", len(m.opts.Extensions), m.targetVersion))) + b.WriteString(tui.DimStyle.Render(fmt.Sprintf("Looking up composer-managed plugins for %s…", m.targetVersion))) b.WriteString("\n\n") b.WriteString(m.spinner.View() + " " + tui.DimStyle.Render("fetching compatibility")) b.WriteString("\n\n") @@ -795,7 +753,7 @@ func (m wizardModel) viewCompatResult() string { var b strings.Builder b.WriteString(stepBadge(m.stepNum(phaseCompatResult), m.totalSteps())) b.WriteString("\n\n") - b.WriteString(tui.TitleStyle.Render("Extension compatibility")) + b.WriteString(tui.TitleStyle.Render("Plugin compatibility")) b.WriteString("\n") b.WriteString(tui.DimStyle.Render(fmt.Sprintf("Upgrade to %s", m.targetVersion))) b.WriteString("\n\n") @@ -804,10 +762,10 @@ func (m wizardModel) viewCompatResult() string { case m.compatErr != nil: b.WriteString(lipgloss.NewStyle().Foreground(tui.ErrorColor).Render("Compatibility lookup failed: " + m.compatErr.Error())) b.WriteString("\n") - b.WriteString(tui.DimStyle.Render("You may still proceed; the wizard cannot guarantee extensions will install.")) + b.WriteString(tui.DimStyle.Render("You may still proceed; the wizard cannot guarantee plugins will install.")) b.WriteString("\n\n") case len(m.compatUpdates) == 0: - b.WriteString(tui.DimStyle.Render("No store-managed extensions to check.")) + b.WriteString(tui.DimStyle.Render("No composer-managed plugins to check.")) b.WriteString("\n\n") default: for _, u := range m.compatUpdates { @@ -824,19 +782,25 @@ func (m wizardModel) viewCompatResult() string { b.WriteString(icon) b.WriteString(" ") b.WriteString(tui.LabelStyle.Render(u.Name)) - b.WriteString(tui.DimStyle.Render(" — " + u.Status.Label)) + detail := u.Status.Label() + if u.Status == CompatUpdatable && u.NewVersion != "" { + detail = fmt.Sprintf("%s → %s", u.CurrentVersion, u.NewVersion) + } else if u.CurrentVersion != "" { + detail = u.CurrentVersion + " — " + detail + } + b.WriteString(tui.DimStyle.Render(" — " + detail)) b.WriteString("\n") } b.WriteString("\n") } if m.compatHasUpdatable { - b.WriteString(lipgloss.NewStyle().Foreground(tui.WarnColor).Render("↑ Extensions marked with ↑ have a compatible release; their constraints will be bumped during the upgrade.")) + b.WriteString(lipgloss.NewStyle().Foreground(tui.WarnColor).Render("↑ Plugins marked with ↑ have a compatible release; their constraints will be bumped during the upgrade.")) b.WriteString("\n\n") } if m.compatHasBlock { - b.WriteString(lipgloss.NewStyle().Foreground(tui.ErrorColor).Bold(true).Render("✗ Some extensions have no compatible version yet.")) + b.WriteString(lipgloss.NewStyle().Foreground(tui.ErrorColor).Bold(true).Render("✗ Some plugins have no compatible version yet.")) b.WriteString("\n") b.WriteString(tui.DimStyle.Render("They will be removed from composer.json so the upgrade can proceed. Re-require them once they publish a compatible release.")) b.WriteString("\n\n") diff --git a/internal/projectupgrade/wizard_test.go b/internal/projectupgrade/wizard_test.go index bd271fcc..2908913c 100644 --- a/internal/projectupgrade/wizard_test.go +++ b/internal/projectupgrade/wizard_test.go @@ -8,7 +8,6 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - account_api "github.com/shopware/shopware-cli/internal/account-api" "github.com/shopware/shopware-cli/internal/tui" ) @@ -77,25 +76,11 @@ func TestWizardSelectVersionForwardsNavigationToList(t *testing.T) { assert.Equal(t, 1, wm.versionList.Cursor()) } -func TestWizardSelectVersionWithoutExtensionsSkipsToReview(t *testing.T) { +func TestWizardSelectVersionGoesToCompatCheck(t *testing.T) { t.Parallel() m := newTestModel(t) m.phase = phaseSelectVersion - m.versionList.HandleKey("down") // move to "6.6.3.0" - - updated, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) - wm := updated.(wizardModel) - assert.Equal(t, phaseReview, wm.phase) - assert.Equal(t, "6.6.3.0", wm.targetVersion) -} - -func TestWizardSelectVersionWithExtensionsGoesToCompatCheck(t *testing.T) { - t.Parallel() - - m := newTestModel(t) - m.opts.Extensions = map[string]string{"AcmeExtension": "1.0.0"} - m.phase = phaseSelectVersion updated, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) wm := updated.(wizardModel) @@ -112,15 +97,8 @@ func TestWizardCompatLoadedSetsBlockerFlag(t *testing.T) { m.compatLoading = true updated, _ := m.Update(compatLoadedMsg{ - updates: []account_api.UpdateCheckExtensionCompatibility{ - { - Name: "Blocker", - Status: account_api.UpdateCheckExtensionCompatibilityStatus{ - Name: account_api.CompatibilityNotCompatible, - Type: "red", - Label: "Not compatible", - }, - }, + updates: []PluginCompat{ + {Name: "vendor/incompat", CurrentVersion: "1.0.0", Status: CompatBlocker}, }, }) wm := updated.(wizardModel) @@ -137,23 +115,14 @@ func TestWizardCompatLoadedUpdatableIsNotBlocker(t *testing.T) { m.phase = phaseCompatCheck m.compatLoading = true - // "With new Shopware version" — a compatible release exists, so this must - // not block the upgrade; the resolver bumps the constraint. updated, _ := m.Update(compatLoadedMsg{ - updates: []account_api.UpdateCheckExtensionCompatibility{ - { - Name: "SwagPayPal", - Status: account_api.UpdateCheckExtensionCompatibilityStatus{ - Name: account_api.CompatibilityUpdatableNow, - Type: "yellow", - Label: "With new Shopware version", - }, - }, + updates: []PluginCompat{ + {Name: "swag/paypal", CurrentVersion: "8.11.0", NewVersion: "9.0.0", Status: CompatUpdatable}, }, }) wm := updated.(wizardModel) assert.Equal(t, phaseCompatResult, wm.phase) - assert.False(t, wm.compatHasBlock, "updatable extension must not block") + assert.False(t, wm.compatHasBlock, "updatable plugin must not block") assert.True(t, wm.compatHasUpdatable) assert.True(t, wm.confirmYes, "no blocker means confirm defaults to Yes") } From 5f9d921de07a6b0dd3dca7f4b913ed85e7b22a84 Mon Sep 17 00:00:00 2001 From: Soner Sayakci Date: Tue, 2 Jun 2026 14:08:42 +0200 Subject: [PATCH 12/14] feat(projectupgrade): let composer resolve plugin compatibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the homegrown registry + constraint resolver with composer itself. The upgrade now runs `composer require --no-install -W shopware/core: ` and reads composer's verdict: success means the upgrade resolves, a non-zero exit with "could not be resolved" means it doesn't, and the blocking plugin(s) are dropped and retried. - new composer_resolve.go: DryRunRequire (compat preview, --dry-run, nothing written) and ApplyRequire (real require, drops unresolvable plugins and retries). Store plugins resolve via the project's own composer config / auth.json - no token plumbing. - delete registry.go, compatibility.go and the version-finding half of plugins.go; keep FindNonComposerPlugins and the ResolveResult reporting type. - wizard + command rewired off Registry; compat phase shows composer's own resolution and the plugins it cannot resolve. - drop now-unused packagist.ConstraintsSatisfiedBy / BumpConstraint. --- cmd/project/project_upgrade.go | 108 ++----- internal/packagist/constraints.go | 48 --- internal/packagist/constraints_test.go | 60 ---- internal/projectupgrade/compatibility.go | 120 -------- internal/projectupgrade/compatibility_test.go | 144 --------- internal/projectupgrade/composer_resolve.go | 254 ++++++++++++++++ .../projectupgrade/composer_resolve_test.go | 84 ++++++ internal/projectupgrade/plugins.go | 195 ------------ internal/projectupgrade/plugins_test.go | 278 ------------------ internal/projectupgrade/registry.go | 121 -------- internal/projectupgrade/registry_test.go | 85 ------ internal/projectupgrade/wizard.go | 168 ++++------- internal/projectupgrade/wizard_test.go | 21 +- 13 files changed, 426 insertions(+), 1260 deletions(-) delete mode 100644 internal/packagist/constraints.go delete mode 100644 internal/packagist/constraints_test.go delete mode 100644 internal/projectupgrade/compatibility.go delete mode 100644 internal/projectupgrade/compatibility_test.go create mode 100644 internal/projectupgrade/composer_resolve.go create mode 100644 internal/projectupgrade/composer_resolve_test.go delete mode 100644 internal/projectupgrade/registry.go delete mode 100644 internal/projectupgrade/registry_test.go diff --git a/cmd/project/project_upgrade.go b/cmd/project/project_upgrade.go index c47fceca..07d4146c 100644 --- a/cmd/project/project_upgrade.go +++ b/cmd/project/project_upgrade.go @@ -20,7 +20,6 @@ import ( "github.com/shopware/shopware-cli/internal/extension" "github.com/shopware/shopware-cli/internal/flexmigrator" "github.com/shopware/shopware-cli/internal/git" - "github.com/shopware/shopware-cli/internal/packagist" "github.com/shopware/shopware-cli/internal/projectupgrade" "github.com/shopware/shopware-cli/internal/system" "github.com/shopware/shopware-cli/internal/tracking" @@ -93,18 +92,12 @@ project doesn't require it yet.`, } // Interactive: hand off to the devtui-styled wizard. - registry, err := buildRegistry(cmd, projectRoot) - if err != nil { - return err - } - result, err := projectupgrade.RunWizard(projectupgrade.WizardOptions{ ProjectRoot: projectRoot, ComposerJSONPath: composerJsonPath, CurrentVersion: currentVersion, UpdateVersions: updateVersions, Executor: cmdExecutor, - Registry: registry, }) status := "ok" @@ -148,12 +141,7 @@ func runUpgradeHeadless(cmd *cobra.Command, projectRoot, composerJsonPath string return err } - registry, err := buildRegistry(cmd, projectRoot) - if err != nil { - return err - } - - if err := runCompatibilityCheck(ctx, composerJsonPath, targetVersion, registry); err != nil { + if err := runCompatibilityCheck(ctx, cmdExecutor, composerJsonPath, targetVersion); err != nil { return err } @@ -177,16 +165,13 @@ func runUpgradeHeadless(cmd *cobra.Command, projectRoot, composerJsonPath string return fmt.Errorf("cleanup stale files: %w", err) } - log.Infof("Checking custom plugins for incompatibilities") - result, err := projectupgrade.ResolveIncompatiblePlugins(ctx, composerJsonPath, targetVersion, registry) + log.Infof("Resolving plugins with composer for %s", targetVersion) + result, err := projectupgrade.ApplyRequire(ctx, cmdExecutor, composerJsonPath, targetVersion) if err != nil { return fmt.Errorf("resolve incompatible plugins: %w", err) } if result != nil { - for _, action := range result.Bumped() { - log.Infof("Bumped %s: %s → %s", tui.YellowText.Render(action.Name), action.OldConstraint, action.NewConstraint) - } for _, action := range result.Removed() { log.Infof("Removed incompatible plugin %s (%s). Re-require it once a compatible version is published.", tui.YellowText.Render(action.Name), action.Reason) } @@ -267,38 +252,35 @@ func selectTargetVersion(cmd *cobra.Command, updateVersions []string) (string, e return selected, nil } -func runCompatibilityCheck(ctx context.Context, composerJsonPath, targetVersion string, registry projectupgrade.Registry) error { +func runCompatibilityCheck(ctx context.Context, cmdExecutor executor.Executor, composerJsonPath, targetVersion string) error { log := logging.FromContext(ctx) - results, err := projectupgrade.CheckPluginCompatibility(ctx, composerJsonPath, targetVersion, registry) + report, err := projectupgrade.DryRunRequire(ctx, cmdExecutor, composerJsonPath, targetVersion) if err != nil { log.Warnf("Skipping plugin compatibility check: %v", err) return nil } - if len(results) == 0 { + if report.OK { + log.Infof("composer can resolve the upgrade to %s", targetVersion) return nil } - t := table.New().Border(lipgloss.NormalBorder()).Headers("Plugin", "Current", "Status") - hasBlockers := false - for _, row := range results { - status := row.Status.Label() - if row.Status == projectupgrade.CompatUpdatable && row.NewVersion != "" { - status = fmt.Sprintf("%s → %s", row.Status.Label(), row.NewVersion) - } - t.Row(row.Name, row.CurrentVersion, status) - if row.Status.IsBlocker() { - hasBlockers = true + if len(report.BlockingPlugins) > 0 { + t := table.New().Border(lipgloss.NormalBorder()).Headers("Plugin", "Status") + for _, name := range report.BlockingPlugins { + t.Row(name, "no compatible release") } + fmt.Println(t.Render()) + } else { + fmt.Println(strings.Join(report.Output, "\n")) } - fmt.Println(t.Render()) - if hasBlockers && system.IsInteractionEnabled(ctx) { + if system.IsInteractionEnabled(ctx) { var proceed bool if err := huh.NewConfirm(). - Title("Some plugins have no compatible version for the target version"). - Description("They will be removed from composer.json so the upgrade can proceed. Re-require them once they publish a compatible release. Proceed anyway?"). + Title("composer could not resolve the upgrade with all plugins"). + Description("Incompatible plugins will be removed from composer.json so the upgrade can proceed. Re-require them once they publish a compatible release. Proceed anyway?"). Value(&proceed). Run(); err != nil { return err @@ -373,62 +355,6 @@ func ensureAllPluginsAreComposerManaged(projectRoot string, allow bool) error { ) } -// buildRegistry constructs the package registry used to look up newer -// compatible plugin versions. The Shopware Packages token is read from the -// SHOPWARE_PACKAGES_TOKEN env var, the project's auth.json, or — in -// interactive mode — prompted from the user if the project has store -// plugins. Missing tokens degrade gracefully: store lookups fall back to the -// "remove plugin" behaviour. -func buildRegistry(cmd *cobra.Command, projectRoot string) (projectupgrade.Registry, error) { - token := storeTokenFromAuthJSON(projectRoot) - - hasStorePlugins, err := projectHasStorePlugins(projectRoot) - if err != nil { - logging.FromContext(cmd.Context()).Debugf("could not inspect installed.json: %v", err) - } - - if token == "" && hasStorePlugins && system.IsInteractionEnabled(cmd.Context()) { - var entered string - if err := huh.NewInput(). - Title("Shopware Packages token (packages.shopware.com)"). - Description("Used to look up newer compatible versions of store plugins. Leave empty to skip store lookups."). - Value(&entered). - EchoMode(huh.EchoModePassword). - Run(); err != nil { - return nil, err - } - token = strings.TrimSpace(entered) - } - - return projectupgrade.DefaultRegistry(token), nil -} - -func storeTokenFromAuthJSON(projectRoot string) string { - if v := strings.TrimSpace(os.Getenv("SHOPWARE_PACKAGES_TOKEN")); v != "" { - return v - } - - authPath := path.Join(projectRoot, "auth.json") - auth, err := packagist.ReadComposerAuth(authPath) - if err != nil { - return "" - } - return strings.TrimSpace(auth.BearerAuth["packages.shopware.com"]) -} - -func projectHasStorePlugins(projectRoot string) (bool, error) { - composerJson, err := packagist.ReadComposerJson(path.Join(projectRoot, "composer.json")) - if err != nil { - return false, err - } - for name := range composerJson.Require { - if strings.HasPrefix(name, "store.shopware.com/") { - return true, nil - } - } - return false, nil -} - func init() { projectRootCmd.AddCommand(projectUpgradeCmd) projectUpgradeCmd.Flags().String("to", "", "Target Shopware version. Skips the interactive wizard.") diff --git a/internal/packagist/constraints.go b/internal/packagist/constraints.go deleted file mode 100644 index bc2c3d73..00000000 --- a/internal/packagist/constraints.go +++ /dev/null @@ -1,48 +0,0 @@ -package packagist - -import ( - "strings" - - "github.com/shyim/go-version" -) - -// ConstraintsSatisfiedBy reports whether every constraint that requires -// declares for a package named in packages is satisfied by target. -// Constraints for packages not listed in packages are ignored, and packages -// that declare no constraint are treated as satisfied. An unparseable -// constraint is treated as not satisfied. -func ConstraintsSatisfiedBy(requires map[string]string, packages []string, target *version.Version) bool { - for _, name := range packages { - constraint, ok := requires[name] - if !ok { - continue - } - - c, err := version.NewConstraint(constraint) - if err != nil { - return false - } - - if !c.Check(target) { - return false - } - } - - return true -} - -// BumpConstraint turns a concrete version (e.g. "2.3.4") into a caret -// constraint ("^2.3.4") suitable for a composer.json require entry. Values -// that already look like a constraint (containing range/wildcard operators) -// are returned unchanged. -func BumpConstraint(version string) string { - if version == "" { - return version - } - - if strings.ContainsAny(version, "^~><*|, ") { - return version - } - - return "^" + version -} diff --git a/internal/packagist/constraints_test.go b/internal/packagist/constraints_test.go deleted file mode 100644 index c71e53d2..00000000 --- a/internal/packagist/constraints_test.go +++ /dev/null @@ -1,60 +0,0 @@ -package packagist - -import ( - "testing" - - "github.com/shyim/go-version" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestConstraintsSatisfiedBy(t *testing.T) { - t.Parallel() - - target, err := version.NewVersion("6.6.4.0") - require.NoError(t, err) - - packages := []string{"shopware/core", "shopware/storefront"} - - tests := []struct { - name string - requires map[string]string - want bool - }{ - {"no requires", nil, true}, - {"unrelated package ignored", map[string]string{"symfony/console": "^6.0"}, true}, - {"satisfied", map[string]string{"shopware/core": "^6.6"}, true}, - {"not satisfied", map[string]string{"shopware/core": "~6.5.0"}, false}, - {"one of many not satisfied", map[string]string{"shopware/core": "^6.6", "shopware/storefront": "~6.5.0"}, false}, - {"unparseable treated as unsatisfied", map[string]string{"shopware/core": "not-a-constraint"}, false}, - } - - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - assert.Equal(t, tt.want, ConstraintsSatisfiedBy(tt.requires, packages, target)) - }) - } -} - -func TestBumpConstraint(t *testing.T) { - t.Parallel() - - tests := []struct { - in string - want string - }{ - {"2.3.4", "^2.3.4"}, - {"1.0.0", "^1.0.0"}, - {"^2.0", "^2.0"}, - {"~1.2", "~1.2"}, - {">=3.0", ">=3.0"}, - {"1.0 | 2.0", "1.0 | 2.0"}, - {"", ""}, - } - - for _, tt := range tests { - assert.Equal(t, tt.want, BumpConstraint(tt.in), "BumpConstraint(%q)", tt.in) - } -} diff --git a/internal/projectupgrade/compatibility.go b/internal/projectupgrade/compatibility.go deleted file mode 100644 index 98e9c998..00000000 --- a/internal/projectupgrade/compatibility.go +++ /dev/null @@ -1,120 +0,0 @@ -package projectupgrade - -import ( - "context" - "fmt" - "path/filepath" - "strings" - - "github.com/shyim/go-version" - - "github.com/shopware/shopware-cli/internal/packagist" -) - -// CompatStatus describes the upgrade plan for one composer-managed plugin. -type CompatStatus int - -const ( - // CompatCompatible: the installed version already satisfies the target. - CompatCompatible CompatStatus = iota - // CompatUpdatable: a newer compatible release exists; the resolver will - // bump the constraint. - CompatUpdatable - // CompatBlocker: no compatible release is published; the resolver will - // remove the plugin from composer.json so the upgrade can proceed. - CompatBlocker - // CompatUnknown: the registry could not be consulted (e.g. no token for a - // store plugin); the resolver will drop the plugin too, but the user may - // want to retry with credentials. - CompatUnknown -) - -// IsBlocker reports whether this status means the upgrade will drop the plugin. -func (s CompatStatus) IsBlocker() bool { return s == CompatBlocker || s == CompatUnknown } - -// IsUpdatable reports whether this status means the constraint will be bumped. -func (s CompatStatus) IsUpdatable() bool { return s == CompatUpdatable } - -// Label returns a short human-readable label. -func (s CompatStatus) Label() string { - switch s { - case CompatCompatible: - return "Compatible" - case CompatUpdatable: - return "Update available" - case CompatBlocker: - return "No compatible release" - case CompatUnknown: - return "Not in registry" - } - return "" -} - -// PluginCompat is one row of the compatibility preview. -type PluginCompat struct { - // Name is the composer package name (e.g. "swag/paypal"). - Name string - // CurrentVersion is the version recorded in installed.json. - CurrentVersion string - // NewVersion is the version the resolver would bump to. Populated when - // Status is CompatUpdatable. - NewVersion string - // Status classifies how the resolver will treat this plugin. - Status CompatStatus -} - -// CheckPluginCompatibility consults the registry for every composer-managed -// shopware platform plugin and reports how the upgrade will treat it. The -// composer.json is not modified; this is a dry-run of -// ResolveIncompatiblePlugins so callers can preview the plan before applying -// it. -func CheckPluginCompatibility(ctx context.Context, composerJsonPath, targetVersion string, registry Registry) ([]PluginCompat, error) { - projectDir := filepath.Dir(composerJsonPath) - - installed, err := packagist.ReadInstalledJson(projectDir) - if err != nil { - return nil, err - } - - composerJson, err := packagist.ReadComposerJson(composerJsonPath) - if err != nil { - return nil, err - } - - target, err := version.NewVersion(strings.TrimPrefix(targetVersion, "v")) - if err != nil { - return nil, fmt.Errorf("parse target version: %w", err) - } - - results := make([]PluginCompat, 0) - for _, pkg := range installed.Packages { - if pkg.Type != composerPluginType { - continue - } - if _, ok := composerJson.Require[pkg.Name]; !ok { - continue - } - - row := PluginCompat{Name: pkg.Name, CurrentVersion: strings.TrimPrefix(pkg.Version, "v")} - - if packagist.ConstraintsSatisfiedBy(pkg.Require, ShopwarePackages, target) { - row.Status = CompatCompatible - results = append(results, row) - continue - } - - newVersion, lookupErr := findCompatibleVersion(ctx, registry, pkg.Name, target) - switch { - case newVersion != "": - row.Status = CompatUpdatable - row.NewVersion = newVersion - case lookupErr != nil: - row.Status = CompatUnknown - default: - row.Status = CompatBlocker - } - results = append(results, row) - } - - return results, nil -} diff --git a/internal/projectupgrade/compatibility_test.go b/internal/projectupgrade/compatibility_test.go deleted file mode 100644 index 9ee035f9..00000000 --- a/internal/projectupgrade/compatibility_test.go +++ /dev/null @@ -1,144 +0,0 @@ -package projectupgrade - -import ( - "path/filepath" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/shopware/shopware-cli/internal/packagist" -) - -func TestCheckPluginCompatibilityClassifiesEachPlugin(t *testing.T) { - t.Parallel() - - dir := t.TempDir() - composerJsonPath := filepath.Join(dir, "composer.json") - - writeJSON(t, composerJsonPath, map[string]any{ - "name": "shopware/production", - "require": map[string]any{ - "shopware/core": "6.5.8.0", - "vendor/compat": "^1.0", - "vendor/updates": "^1.0", - "vendor/blocker": "^1.0", - "transitive/skip": "^1.0", // not in installed.json -> ignored - }, - }) - - writeInstalledJSON(t, dir, []packagist.InstalledPackage{ - { - Name: "vendor/compat", - Type: composerPluginType, - Version: "1.2.0", - InstallPath: "../../custom/plugins/Compat", - Require: map[string]string{"shopware/core": "^6.5 | ^6.6"}, - }, - { - Name: "vendor/updates", - Type: composerPluginType, - Version: "1.0.0", - InstallPath: "../../custom/plugins/Updates", - Require: map[string]string{"shopware/core": "~6.5.0"}, - }, - { - Name: "vendor/blocker", - Type: composerPluginType, - Version: "1.0.0", - InstallPath: "../../custom/plugins/Blocker", - Require: map[string]string{"shopware/core": "~6.5.0"}, - }, - }) - - registry := &fakeRegistry{ - versions: map[string][]packagist.ComposerPackageVersion{ - "vendor/updates": { - {Version: "2.0.0", Require: map[string]string{"shopware/core": "^6.6"}}, - }, - "vendor/blocker": { - {Version: "1.1.0", Require: map[string]string{"shopware/core": "~6.5.0"}}, - }, - }, - } - - results, err := CheckPluginCompatibility(t.Context(), composerJsonPath, "6.6.4.0", registry) - require.NoError(t, err) - require.Len(t, results, 3, "transitive/skip must be ignored - it is not in installed.json") - - byName := map[string]PluginCompat{} - for _, r := range results { - byName[r.Name] = r - } - - assert.Equal(t, CompatCompatible, byName["vendor/compat"].Status) - assert.Equal(t, "1.2.0", byName["vendor/compat"].CurrentVersion) - - assert.Equal(t, CompatUpdatable, byName["vendor/updates"].Status) - assert.Equal(t, "1.0.0", byName["vendor/updates"].CurrentVersion) - assert.Equal(t, "2.0.0", byName["vendor/updates"].NewVersion) - - assert.Equal(t, CompatBlocker, byName["vendor/blocker"].Status) - assert.Equal(t, "1.0.0", byName["vendor/blocker"].CurrentVersion) -} - -func TestCheckPluginCompatibilityReportsUnknownOnRegistryError(t *testing.T) { - t.Parallel() - - dir := t.TempDir() - composerJsonPath := filepath.Join(dir, "composer.json") - - writeJSON(t, composerJsonPath, map[string]any{ - "name": "shopware/production", - "require": map[string]any{ - "shopware/core": "6.5.8.0", - "store/mysteryx": "^1.0", - }, - }) - - writeInstalledJSON(t, dir, []packagist.InstalledPackage{ - { - Name: "store/mysteryx", - Type: composerPluginType, - Version: "1.0.0", - InstallPath: "../store/mysteryx", - Require: map[string]string{"shopware/core": "~6.5.0"}, - }, - }) - - registry := &fakeRegistry{err: assertErr("no token configured")} - - results, err := CheckPluginCompatibility(t.Context(), composerJsonPath, "6.6.4.0", registry) - require.NoError(t, err) - require.Len(t, results, 1) - assert.Equal(t, CompatUnknown, results[0].Status, "registry errors surface as 'unknown' so the user can retry with credentials") - assert.True(t, results[0].Status.IsBlocker(), "unknown counts as a blocker - the resolver will drop the plugin") -} - -func TestCheckPluginCompatibilityIgnoresNonPlatformPlugins(t *testing.T) { - t.Parallel() - - dir := t.TempDir() - composerJsonPath := filepath.Join(dir, "composer.json") - - writeJSON(t, composerJsonPath, map[string]any{ - "name": "shopware/production", - "require": map[string]any{ - "shopware/core": "6.5.8.0", - "symfony/console": "^6.0", - }, - }) - - writeInstalledJSON(t, dir, []packagist.InstalledPackage{ - { - Name: "symfony/console", - Type: "library", // not a shopware-platform-plugin - Version: "6.4.0", - InstallPath: "../symfony/console", - }, - }) - - results, err := CheckPluginCompatibility(t.Context(), composerJsonPath, "6.6.4.0", nil) - require.NoError(t, err) - assert.Empty(t, results, "non-platform-plugin libraries are not part of the upgrade plan") -} diff --git a/internal/projectupgrade/composer_resolve.go b/internal/projectupgrade/composer_resolve.go new file mode 100644 index 00000000..7b58483f --- /dev/null +++ b/internal/projectupgrade/composer_resolve.go @@ -0,0 +1,254 @@ +package projectupgrade + +import ( + "context" + "fmt" + "path/filepath" + "sort" + "strings" + + "github.com/shopware/shopware-cli/internal/executor" + "github.com/shopware/shopware-cli/internal/packagist" +) + +// composerPluginType is the composer "type" used by Shopware platform plugins. +const composerPluginType = "shopware-platform-plugin" + +// PluginAction describes how the resolver dealt with one plugin that blocked +// the upgrade. +type PluginAction struct { + // Name is the composer package name (e.g. "swag/paypal"). + Name string + // Removed is true when the plugin was dropped from composer.json because + // composer could not resolve it against the target version. + Removed bool + // Reason is a short human-readable explanation surfaced in the UI. + Reason string +} + +// ResolveResult summarises the actions the resolver took. +type ResolveResult struct { + Actions []PluginAction +} + +// Removed returns the actions that resulted in the plugin being dropped. +func (r *ResolveResult) Removed() []PluginAction { + out := make([]PluginAction, 0, len(r.Actions)) + for _, a := range r.Actions { + if a.Removed { + out = append(out, a) + } + } + return out +} + +// CompatReport is the outcome of a dry-run `composer require` against the +// target version. It is composer's own verdict, not a homegrown constraint +// check. +type CompatReport struct { + // OK is true when composer could resolve the requested upgrade. + OK bool + // Output is the captured composer output. On failure it contains the + // "Your requirements could not be resolved" block. + Output []string + // BlockingPlugins are the plugin names from the upgrade set that appear in + // composer's conflict output. Best-effort; may be empty when composer's + // message cannot be attributed to a specific plugin. + BlockingPlugins []string +} + +// requirePackages enumerates the composer require arguments for the upgrade: +// the first-party Shopware packages present in composer.json pinned to the +// target version, plus every required shopware-platform-plugin (passed without +// a constraint so composer picks the newest release compatible with the pinned +// core). +func requirePackages(composerJsonPath, targetVersion string) ([]string, []string, error) { + composerJson, err := packagist.ReadComposerJson(composerJsonPath) + if err != nil { + return nil, nil, err + } + + args := make([]string, 0, len(ShopwarePackages)) + for _, pkg := range ShopwarePackages { + if _, ok := composerJson.Require[pkg]; ok { + args = append(args, pkg+":"+targetVersion) + } + } + + plugins, err := requiredPlugins(filepath.Dir(composerJsonPath), composerJson) + if err != nil { + return nil, nil, err + } + + args = append(args, plugins...) + return args, plugins, nil +} + +// requiredPlugins returns the composer package names of every required +// shopware-platform-plugin, sorted for stable output. +func requiredPlugins(projectDir string, composerJson *packagist.ComposerJson) ([]string, error) { + installed, err := packagist.ReadInstalledJson(projectDir) + if err != nil { + return nil, err + } + + plugins := make([]string, 0) + for _, pkg := range installed.Packages { + if pkg.Type != composerPluginType { + continue + } + if _, ok := composerJson.Require[pkg.Name]; !ok { + continue + } + plugins = append(plugins, pkg.Name) + } + + sort.Strings(plugins) + return plugins, nil +} + +// composerRequire runs `composer require --no-install -W ` (optionally a +// dry run) through the executor and returns the combined output. The error is +// the process exit error, which composer returns non-zero on an unresolvable +// requirement. +func composerRequire(ctx context.Context, exec executor.Executor, dryRun bool, pkgs []string) ([]string, error) { + args := []string{ + "require", + "--no-interaction", + "--no-install", + "--no-scripts", + "--update-with-all-dependencies", + } + if dryRun { + args = append(args, "--dry-run") + } + args = append(args, pkgs...) + + out, err := exec.ComposerCommand(ctx, args...).CombinedOutput() + return splitLines(out), err +} + +// DryRunRequire asks composer whether the project can be upgraded to +// targetVersion without changing anything on disk. It runs +// `composer require --no-install --dry-run -W shopware/core: ` +// and reports composer's verdict. +func DryRunRequire(ctx context.Context, exec executor.Executor, composerJsonPath, targetVersion string) (CompatReport, error) { + pkgs, plugins, err := requirePackages(composerJsonPath, targetVersion) + if err != nil { + return CompatReport{}, err + } + + output, runErr := composerRequire(ctx, exec, true, pkgs) + if runErr == nil { + return CompatReport{OK: true, Output: output}, nil + } + + return CompatReport{ + OK: false, + Output: output, + BlockingPlugins: blockingPlugins(output, plugins), + }, nil +} + +// ApplyRequire performs the real upgrade resolution: it runs +// `composer require --no-install -W shopware/core: `, letting +// composer rewrite composer.json/composer.lock with the bumped constraints. +// When composer cannot resolve the set, the plugin(s) it names are removed from +// composer.json and the require is retried until it resolves or no plugin can +// be attributed to the failure. +func ApplyRequire(ctx context.Context, exec executor.Executor, composerJsonPath, targetVersion string) (*ResolveResult, error) { + pkgs, plugins, err := requirePackages(composerJsonPath, targetVersion) + if err != nil { + return nil, err + } + + result := &ResolveResult{} + dropped := make(map[string]struct{}) + + // Bounded by the number of plugins: each failed attempt drops at least one. + for attempt := 0; attempt <= len(plugins); attempt++ { + output, runErr := composerRequire(ctx, exec, false, pkgs) + if runErr == nil { + return result, nil + } + + blockers := blockingPlugins(output, plugins) + newlyDropped := false + for _, name := range blockers { + if _, ok := dropped[name]; ok { + continue + } + if err := removePluginFromComposer(composerJsonPath, name); err != nil { + return nil, err + } + dropped[name] = struct{}{} + result.Actions = append(result.Actions, PluginAction{ + Name: name, + Removed: true, + Reason: "composer could not resolve it for " + targetVersion, + }) + newlyDropped = true + } + + if !newlyDropped { + // Composer failed but the conflict is not attributable to a plugin + // we can drop (e.g. a platform requirement). Surface the output. + return result, fmt.Errorf("composer could not resolve the upgrade:\n%s", strings.Join(output, "\n")) + } + + pkgs = filterPackages(pkgs, dropped) + } + + return result, fmt.Errorf("composer could not resolve the upgrade after dropping all incompatible plugins") +} + +// removePluginFromComposer drops a plugin from the root composer.json require +// block so composer can resolve the remaining set. +func removePluginFromComposer(composerJsonPath, name string) error { + composerJson, err := packagist.ReadComposerJson(composerJsonPath) + if err != nil { + return err + } + if _, ok := composerJson.Require[name]; !ok { + return nil + } + delete(composerJson.Require, name) + return composerJson.Save() +} + +// filterPackages returns pkgs without the require arguments for any dropped +// plugin. Plugin args are bare names; core args carry a ":constraint" suffix +// and are never dropped. +func filterPackages(pkgs []string, dropped map[string]struct{}) []string { + out := make([]string, 0, len(pkgs)) + for _, p := range pkgs { + if _, ok := dropped[p]; ok { + continue + } + out = append(out, p) + } + return out +} + +// blockingPlugins returns the plugin names that appear anywhere in composer's +// output. Composer names the conflicting packages in its "could not be +// resolved" block; we only attribute failures to plugins we asked for so the +// resolver never drops first-party packages. +func blockingPlugins(output, plugins []string) []string { + joined := strings.Join(output, "\n") + blockers := make([]string, 0) + for _, name := range plugins { + if strings.Contains(joined, name) { + blockers = append(blockers, name) + } + } + return blockers +} + +func splitLines(out []byte) []string { + trimmed := strings.TrimRight(string(out), "\n") + if trimmed == "" { + return nil + } + return strings.Split(trimmed, "\n") +} diff --git a/internal/projectupgrade/composer_resolve_test.go b/internal/projectupgrade/composer_resolve_test.go new file mode 100644 index 00000000..6c3dec91 --- /dev/null +++ b/internal/projectupgrade/composer_resolve_test.go @@ -0,0 +1,84 @@ +package projectupgrade + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/shopware/shopware-cli/internal/packagist" +) + +func TestBlockingPluginsAttributesOnlyRequestedPlugins(t *testing.T) { + t.Parallel() + + output := []string{ + "Loading composer repositories with package information", + "Your requirements could not be resolved to an installable set of packages.", + " Problem 1", + " - swag/paypal 9.0.0 requires shopware/core ^6.7 -> found shopware/core[6.6.4.0]", + " - root composer.json requires swag/paypal ^9.0", + } + + // shopware/core appears in the output too, but it is not a plugin we asked + // for, so it must never be attributed as a blocker. + blockers := blockingPlugins(output, []string{"swag/paypal", "frosh/tools"}) + + assert.Equal(t, []string{"swag/paypal"}, blockers) +} + +func TestBlockingPluginsEmptyWhenNoPluginNamed(t *testing.T) { + t.Parallel() + + output := []string{"Your requirements could not be resolved", "ext-foo is missing"} + assert.Empty(t, blockingPlugins(output, []string{"swag/paypal"})) +} + +func TestFilterPackagesDropsOnlyDroppedNames(t *testing.T) { + t.Parallel() + + pkgs := []string{"shopware/core:6.6.4.0", "swag/paypal", "frosh/tools"} + dropped := map[string]struct{}{"swag/paypal": {}} + + assert.Equal(t, []string{"shopware/core:6.6.4.0", "frosh/tools"}, filterPackages(pkgs, dropped)) +} + +func TestRequirePackagesPinsCoreAndListsPlugins(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + composerJsonPath := filepath.Join(dir, "composer.json") + + writeJSON(t, composerJsonPath, map[string]any{ + "name": "shopware/production", + "require": map[string]any{ + "shopware/core": "6.5.8.0", + "shopware/storefront": "6.5.8.0", + "swag/paypal": "^8.11", + "frosh/tools": "^1.4", + "unrelated/library": "^1.0", + }, + }) + + writeInstalledJSON(t, dir, []packagist.InstalledPackage{ + {Name: "swag/paypal", Type: composerPluginType, InstallPath: "../swag/paypal"}, + {Name: "frosh/tools", Type: composerPluginType, InstallPath: "../frosh/tools"}, + {Name: "unrelated/library", Type: "library", InstallPath: "../unrelated/library"}, + }) + + args, plugins, err := requirePackages(composerJsonPath, "6.6.4.0") + require.NoError(t, err) + + // First-party packages present in require are pinned to the target. + assert.Contains(t, args, "shopware/core:6.6.4.0") + assert.Contains(t, args, "shopware/storefront:6.6.4.0") + // administration/elasticsearch are not required, so they are not pinned. + assert.NotContains(t, args, "shopware/administration:6.6.4.0") + + // Only shopware-platform-plugins from require are listed, without a constraint. + assert.Equal(t, []string{"frosh/tools", "swag/paypal"}, plugins) + assert.Contains(t, args, "swag/paypal") + assert.Contains(t, args, "frosh/tools") + assert.NotContains(t, args, "unrelated/library") +} diff --git a/internal/projectupgrade/plugins.go b/internal/projectupgrade/plugins.go index 0559463e..0e18da7f 100644 --- a/internal/projectupgrade/plugins.go +++ b/internal/projectupgrade/plugins.go @@ -1,7 +1,6 @@ package projectupgrade import ( - "context" "errors" "fmt" "io/fs" @@ -10,203 +9,9 @@ import ( "sort" "strings" - "github.com/shyim/go-version" - "github.com/shopware/shopware-cli/internal/packagist" ) -// composerPluginType is the composer "type" used by Shopware platform plugins. -const composerPluginType = "shopware-platform-plugin" - -// PluginAction describes how the resolver dealt with one incompatible plugin. -type PluginAction struct { - // Name is the composer package name (e.g. "store.shopware.com/swagcms"). - Name string - // OldConstraint is the constraint that was in composer.json before - // resolution. - OldConstraint string - // NewConstraint is the constraint that was written to composer.json. - // Empty when Removed is true. - NewConstraint string - // NewVersion is the package version the new constraint points at. - // Empty when Removed is true. - NewVersion string - // Removed is true when no compatible version could be found and the - // plugin was dropped from composer.json. - Removed bool - // Reason is a short human-readable explanation surfaced in the UI. - Reason string -} - -// ResolveResult summarises the actions the resolver took. -type ResolveResult struct { - Actions []PluginAction -} - -// Bumped returns the actions that resulted in a constraint bump. -func (r *ResolveResult) Bumped() []PluginAction { - out := make([]PluginAction, 0, len(r.Actions)) - for _, a := range r.Actions { - if !a.Removed { - out = append(out, a) - } - } - return out -} - -// Removed returns the actions that resulted in the plugin being dropped. -func (r *ResolveResult) Removed() []PluginAction { - out := make([]PluginAction, 0, len(r.Actions)) - for _, a := range r.Actions { - if a.Removed { - out = append(out, a) - } - } - return out -} - -// ResolveIncompatiblePlugins inspects every shopware platform plugin under -// custom/plugins/* (as listed in vendor/composer/installed.json). For each -// plugin whose installed Shopware constraint is not satisfied by -// targetVersion the resolver tries to find a newer release on the supplied -// registry; if one exists, the composer.json constraint is bumped to -// "^". When no compatible version is available the plugin is -// removed from composer.json so composer update doesn't fail. -// -// registry may be nil, in which case every incompatible plugin is removed -// (the previous behaviour). -func ResolveIncompatiblePlugins(ctx context.Context, composerJsonPath, targetVersion string, registry Registry) (*ResolveResult, error) { - projectDir := filepath.Dir(composerJsonPath) - - installed, err := packagist.ReadInstalledJson(projectDir) - if err != nil { - return nil, err - } - - target, err := version.NewVersion(strings.TrimPrefix(targetVersion, "v")) - if err != nil { - return nil, fmt.Errorf("parse target version: %w", err) - } - - composerJson, err := packagist.ReadComposerJson(composerJsonPath) - if err != nil { - return nil, err - } - - // Consider every shopware platform plugin that the root composer.json - // requires and whose installed shopware/* constraint is not satisfied by - // the target version - regardless of where composer installed it. Store - // plugins (swag/*, frosh/*, …) install into vendor/, not custom/plugins/, - // so restricting to custom/plugins/ would leave their stale constraints in - // place and break `composer update`. - incompatible := make([]packagist.InstalledPackage, 0) - for _, pkg := range installed.Packages { - if pkg.Type != composerPluginType { - continue - } - if _, ok := composerJson.Require[pkg.Name]; !ok { - continue - } - if packagist.ConstraintsSatisfiedBy(pkg.Require, ShopwarePackages, target) { - continue - } - incompatible = append(incompatible, pkg) - } - - if len(incompatible) == 0 { - return &ResolveResult{}, nil - } - - result := &ResolveResult{} - - for _, pkg := range incompatible { - old, ok := composerJson.Require[pkg.Name] - if !ok { - continue - } - - action := PluginAction{Name: pkg.Name, OldConstraint: old} - - newVersion, err := findCompatibleVersion(ctx, registry, pkg.Name, target) - if err != nil || newVersion == "" { - delete(composerJson.Require, pkg.Name) - action.Removed = true - action.Reason = "no compatible release found" - if err != nil && !errors.Is(err, ErrRegistryUnavailable) { - action.Reason = "registry lookup failed: " + err.Error() - } - result.Actions = append(result.Actions, action) - continue - } - - newConstraint := packagist.BumpConstraint(newVersion) - composerJson.Require[pkg.Name] = newConstraint - action.NewConstraint = newConstraint - action.NewVersion = newVersion - action.Reason = fmt.Sprintf("bumped to %s", newConstraint) - result.Actions = append(result.Actions, action) - } - - if len(result.Actions) == 0 { - return result, nil - } - - if err := composerJson.Save(); err != nil { - return nil, err - } - return result, nil -} - -func findCompatibleVersion(ctx context.Context, registry Registry, name string, target *version.Version) (string, error) { - if registry == nil { - return "", ErrRegistryUnavailable - } - - versions, err := registry.GetPackageVersions(ctx, name) - if err != nil { - return "", err - } - if len(versions) == 0 { - return "", nil - } - - parsed := make([]packagist.ComposerPackageVersion, 0, len(versions)) - for _, v := range versions { - if isPreReleaseVersion(v.Version) { - continue - } - if !packagist.ConstraintsSatisfiedBy(v.Require, ShopwarePackages, target) { - continue - } - parsed = append(parsed, v) - } - - if len(parsed) == 0 { - return "", nil - } - - sort.Slice(parsed, func(i, j int) bool { - vi, errI := version.NewVersion(strings.TrimPrefix(parsed[i].Version, "v")) - vj, errJ := version.NewVersion(strings.TrimPrefix(parsed[j].Version, "v")) - if errI != nil || errJ != nil { - return parsed[i].Version > parsed[j].Version - } - return vi.GreaterThan(vj) - }) - - return strings.TrimPrefix(parsed[0].Version, "v"), nil -} - -func isPreReleaseVersion(v string) bool { - lower := strings.ToLower(v) - for _, marker := range []string{"-rc", "-beta", "-alpha", "-dev"} { - if strings.Contains(lower, marker) { - return true - } - } - return false -} - // FindNonComposerPlugins returns directories under custom/plugins/ that are // not tracked by composer (no entry in vendor/composer/installed.json). // Returns an empty slice when no installed.json is present. diff --git a/internal/projectupgrade/plugins_test.go b/internal/projectupgrade/plugins_test.go index e28ac97e..f45d4139 100644 --- a/internal/projectupgrade/plugins_test.go +++ b/internal/projectupgrade/plugins_test.go @@ -1,7 +1,6 @@ package projectupgrade import ( - "context" "encoding/json" "os" "path/filepath" @@ -24,283 +23,6 @@ func writeInstalledJSON(t *testing.T, projectDir string, packages []packagist.In require.NoError(t, os.WriteFile(filepath.Join(installedDir, "installed.json"), data, 0o644)) } -// fakeRegistry is a test double for Registry that returns whatever the test -// configures. -type fakeRegistry struct { - versions map[string][]packagist.ComposerPackageVersion - err error -} - -func (f *fakeRegistry) GetPackageVersions(_ context.Context, name string) ([]packagist.ComposerPackageVersion, error) { - if f.err != nil { - return nil, f.err - } - return f.versions[name], nil -} - -func TestResolveIncompatiblePluginsRemovesWhenNoRegistry(t *testing.T) { - t.Parallel() - - dir := t.TempDir() - composerJsonPath := filepath.Join(dir, "composer.json") - - require.NoError(t, os.MkdirAll(filepath.Join(dir, "custom", "plugins", "Incompatible"), 0o755)) - - writeJSON(t, composerJsonPath, map[string]any{ - "name": "shopware/production", - "require": map[string]any{ - "shopware/core": "6.5.8.0", - "vendor/incompat": "*", - "unrelated/package": "^1.0", - }, - }) - - writeInstalledJSON(t, dir, []packagist.InstalledPackage{ - { - Name: "vendor/incompat", - Type: composerPluginType, - InstallPath: "../../custom/plugins/Incompatible", - Require: map[string]string{"shopware/core": "~6.5.0"}, - }, - }) - - result, err := ResolveIncompatiblePlugins(t.Context(), composerJsonPath, "6.6.4.0", nil) - require.NoError(t, err) - require.Len(t, result.Removed(), 1) - assert.Empty(t, result.Bumped()) - assert.Equal(t, "vendor/incompat", result.Removed()[0].Name) - - out := readJSON(t, composerJsonPath) - requireMap := out["require"].(map[string]any) - _, stillThere := requireMap["vendor/incompat"] - assert.False(t, stillThere) -} - -func TestResolveIncompatiblePluginsBumpsConstraintWhenRegistryHasCompatibleVersion(t *testing.T) { - t.Parallel() - - dir := t.TempDir() - composerJsonPath := filepath.Join(dir, "composer.json") - - require.NoError(t, os.MkdirAll(filepath.Join(dir, "custom", "plugins", "Incompatible"), 0o755)) - - writeJSON(t, composerJsonPath, map[string]any{ - "name": "shopware/production", - "require": map[string]any{ - "shopware/core": "6.5.8.0", - "vendor/incompat": "^1.0", - }, - }) - - writeInstalledJSON(t, dir, []packagist.InstalledPackage{ - { - Name: "vendor/incompat", - Type: composerPluginType, - InstallPath: "../../custom/plugins/Incompatible", - Require: map[string]string{"shopware/core": "~6.5.0"}, - }, - }) - - registry := &fakeRegistry{ - versions: map[string][]packagist.ComposerPackageVersion{ - "vendor/incompat": { - {Version: "1.0.0", Require: map[string]string{"shopware/core": "~6.5.0"}}, - {Version: "2.0.0", Require: map[string]string{"shopware/core": "^6.5 | ^6.6"}}, - {Version: "2.1.0", Require: map[string]string{"shopware/core": "^6.6"}}, - {Version: "3.0.0-rc1", Require: map[string]string{"shopware/core": "^6.6"}}, // skipped: prerelease - }, - }, - } - - result, err := ResolveIncompatiblePlugins(t.Context(), composerJsonPath, "6.6.4.0", registry) - require.NoError(t, err) - require.Len(t, result.Bumped(), 1) - assert.Empty(t, result.Removed()) - - bumped := result.Bumped()[0] - assert.Equal(t, "vendor/incompat", bumped.Name) - assert.Equal(t, "^1.0", bumped.OldConstraint) - assert.Equal(t, "2.1.0", bumped.NewVersion) - assert.Equal(t, "^2.1.0", bumped.NewConstraint) - - out := readJSON(t, composerJsonPath) - requireMap := out["require"].(map[string]any) - assert.Equal(t, "^2.1.0", requireMap["vendor/incompat"]) -} - -// Store/composer plugins install into vendor/, not custom/plugins/. They must -// still be resolved, otherwise their stale shopware/core constraint breaks -// `composer update` (regression: frosh/tools ^1.4 / swag/paypal ^8.11 left -// untouched on a 6.5 → 6.6 upgrade). -func TestResolveIncompatiblePluginsBumpsVendorInstalledPlugin(t *testing.T) { - t.Parallel() - - dir := t.TempDir() - composerJsonPath := filepath.Join(dir, "composer.json") - - writeJSON(t, composerJsonPath, map[string]any{ - "name": "shopware/production", - "require": map[string]any{ - "shopware/core": "6.5.8.0", - "swag/paypal": "^8.11", - }, - }) - - // Installed under vendor/ — no custom/plugins/ entry at all. - writeInstalledJSON(t, dir, []packagist.InstalledPackage{ - { - Name: "swag/paypal", - Type: composerPluginType, - InstallPath: "../swag/paypal", - Require: map[string]string{"shopware/core": "~6.5.5"}, - }, - }) - - registry := &fakeRegistry{ - versions: map[string][]packagist.ComposerPackageVersion{ - "swag/paypal": { - {Version: "8.11.0", Require: map[string]string{"shopware/core": "~6.5.5"}}, - {Version: "9.0.0", Require: map[string]string{"shopware/core": "^6.6"}}, - }, - }, - } - - result, err := ResolveIncompatiblePlugins(t.Context(), composerJsonPath, "6.6.4.0", registry) - require.NoError(t, err) - require.Len(t, result.Bumped(), 1) - assert.Empty(t, result.Removed()) - - bumped := result.Bumped()[0] - assert.Equal(t, "swag/paypal", bumped.Name) - assert.Equal(t, "9.0.0", bumped.NewVersion) - - out := readJSON(t, composerJsonPath) - requireMap := out["require"].(map[string]any) - assert.Equal(t, "^9.0.0", requireMap["swag/paypal"]) -} - -// A plugin installed in vendor/ but NOT listed in the root composer.json -// require (e.g. a transitive dependency) must be left alone — the resolver can -// only rewrite root constraints. -func TestResolveIncompatiblePluginsIgnoresPluginsNotInRootRequire(t *testing.T) { - t.Parallel() - - dir := t.TempDir() - composerJsonPath := filepath.Join(dir, "composer.json") - - writeJSON(t, composerJsonPath, map[string]any{ - "name": "shopware/production", - "require": map[string]any{ - "shopware/core": "6.5.8.0", - }, - }) - - writeInstalledJSON(t, dir, []packagist.InstalledPackage{ - { - Name: "transitive/plugin", - Type: composerPluginType, - InstallPath: "../transitive/plugin", - Require: map[string]string{"shopware/core": "~6.5.0"}, - }, - }) - - result, err := ResolveIncompatiblePlugins(t.Context(), composerJsonPath, "6.6.4.0", &fakeRegistry{}) - require.NoError(t, err) - assert.Empty(t, result.Actions) -} - -func TestResolveIncompatiblePluginsRemovesWhenNoCompatibleRelease(t *testing.T) { - t.Parallel() - - dir := t.TempDir() - composerJsonPath := filepath.Join(dir, "composer.json") - - require.NoError(t, os.MkdirAll(filepath.Join(dir, "custom", "plugins", "Incompatible"), 0o755)) - - writeJSON(t, composerJsonPath, map[string]any{ - "name": "shopware/production", - "require": map[string]any{ - "shopware/core": "6.5.8.0", - "vendor/incompat": "^1.0", - }, - }) - - writeInstalledJSON(t, dir, []packagist.InstalledPackage{ - { - Name: "vendor/incompat", - Type: composerPluginType, - InstallPath: "../../custom/plugins/Incompatible", - Require: map[string]string{"shopware/core": "~6.5.0"}, - }, - }) - - // Only old versions, none compatible with 6.6.4.0. - registry := &fakeRegistry{ - versions: map[string][]packagist.ComposerPackageVersion{ - "vendor/incompat": { - {Version: "1.0.0", Require: map[string]string{"shopware/core": "~6.5.0"}}, - {Version: "1.1.0", Require: map[string]string{"shopware/core": "~6.5.0"}}, - }, - }, - } - - result, err := ResolveIncompatiblePlugins(t.Context(), composerJsonPath, "6.6.4.0", registry) - require.NoError(t, err) - assert.Empty(t, result.Bumped()) - require.Len(t, result.Removed(), 1) - assert.Equal(t, "vendor/incompat", result.Removed()[0].Name) - assert.Equal(t, "no compatible release found", result.Removed()[0].Reason) -} - -func TestResolveIncompatiblePluginsRegistryErrorFallsBackToRemove(t *testing.T) { - t.Parallel() - - dir := t.TempDir() - composerJsonPath := filepath.Join(dir, "composer.json") - - require.NoError(t, os.MkdirAll(filepath.Join(dir, "custom", "plugins", "Incompatible"), 0o755)) - - writeJSON(t, composerJsonPath, map[string]any{ - "name": "shopware/production", - "require": map[string]any{ - "shopware/core": "6.5.8.0", - "vendor/incompat": "^1.0", - }, - }) - - writeInstalledJSON(t, dir, []packagist.InstalledPackage{ - { - Name: "vendor/incompat", - Type: composerPluginType, - InstallPath: "../../custom/plugins/Incompatible", - Require: map[string]string{"shopware/core": "~6.5.0"}, - }, - }) - - registry := &fakeRegistry{err: assertErr("network down")} - result, err := ResolveIncompatiblePlugins(t.Context(), composerJsonPath, "6.6.4.0", registry) - require.NoError(t, err) - require.Len(t, result.Removed(), 1) - assert.Contains(t, result.Removed()[0].Reason, "network down") -} - -func TestResolveIncompatiblePluginsNoInstalledJSONReturnsEmpty(t *testing.T) { - t.Parallel() - - dir := t.TempDir() - composerJsonPath := filepath.Join(dir, "composer.json") - writeJSON(t, composerJsonPath, map[string]any{ - "name": "shopware/production", - "require": map[string]any{ - "shopware/core": "6.5.8.0", - }, - }) - - result, err := ResolveIncompatiblePlugins(t.Context(), composerJsonPath, "6.6.4.0", nil) - require.NoError(t, err) - assert.Empty(t, result.Actions) -} - func TestFindNonComposerPluginsReportsUntrackedDirectories(t *testing.T) { t.Parallel() diff --git a/internal/projectupgrade/registry.go b/internal/projectupgrade/registry.go deleted file mode 100644 index 9118748c..00000000 --- a/internal/projectupgrade/registry.go +++ /dev/null @@ -1,121 +0,0 @@ -package projectupgrade - -import ( - "context" - "errors" - "sync" - - "github.com/shopware/shopware-cli/internal/packagist" -) - -// Registry resolves a composer package name to its available versions. -// Implementations are expected to be safe for use from multiple goroutines. -type Registry interface { - GetPackageVersions(ctx context.Context, name string) ([]packagist.ComposerPackageVersion, error) -} - -// ErrRegistryUnavailable is returned when no backend can resolve the package -// (e.g. a store.shopware.com package when no token is configured). -var ErrRegistryUnavailable = errors.New("registry unavailable for this package") - -// CombinedRegistry resolves a package against the Shopware store first (when -// configured) and falls back to Packagist. Commercial store plugins are -// required under ordinary vendor names (e.g. swag/paypal, not -// store.shopware.com/…) and only exist on packages.shopware.com, so routing -// cannot be decided from the name alone: the store listing is the only source -// that knows whether it owns a package. -type CombinedRegistry struct { - // Store handles packages published on packages.shopware.com. May be nil - // when no store token is configured. - Store Registry - // Packagist handles everything the store does not provide. Required. - Packagist Registry -} - -func (c *CombinedRegistry) GetPackageVersions(ctx context.Context, name string) ([]packagist.ComposerPackageVersion, error) { - if c.Store != nil { - versions, err := c.Store.GetPackageVersions(ctx, name) - // A configured store that knows this package is authoritative. Any - // other outcome (unknown package, or store unavailable) falls through - // to Packagist so public packages still resolve. - if err == nil && len(versions) > 0 { - return versions, nil - } - } - - if c.Packagist == nil { - return nil, ErrRegistryUnavailable - } - return c.Packagist.GetPackageVersions(ctx, name) -} - -// PackagistRegistry resolves package versions via repo.packagist.org. -type PackagistRegistry struct{} - -func (PackagistRegistry) GetPackageVersions(ctx context.Context, name string) ([]packagist.ComposerPackageVersion, error) { - return packagist.GetComposerPackageVersions(ctx, name) -} - -// ShopwareStoreRegistry resolves store-managed plugins via -// packages.shopware.com. The full listing is fetched once and cached for the -// lifetime of the registry instance. -type ShopwareStoreRegistry struct { - Token string - - once sync.Once - loadErr error - packages map[string][]packagist.ComposerPackageVersion -} - -func (s *ShopwareStoreRegistry) load(ctx context.Context) error { - s.once.Do(func() { - if s.Token == "" { - s.loadErr = ErrRegistryUnavailable - return - } - - response, err := packagist.GetAvailablePackagesFromShopwareStore(ctx, s.Token) - if err != nil { - s.loadErr = err - return - } - - s.packages = make(map[string][]packagist.ComposerPackageVersion, len(response.Packages)) - for name, versions := range response.Packages { - list := make([]packagist.ComposerPackageVersion, 0, len(versions)) - for _, v := range versions { - list = append(list, packagist.ComposerPackageVersion{ - Name: name, - Version: v.Version, - Description: v.Description, - Replace: v.Replace, - Require: v.Require, - }) - } - s.packages[name] = list - } - }) - return s.loadErr -} - -func (s *ShopwareStoreRegistry) GetPackageVersions(ctx context.Context, name string) ([]packagist.ComposerPackageVersion, error) { - if err := s.load(ctx); err != nil { - return nil, err - } - - return s.packages[name], nil -} - -// DefaultRegistry builds a CombinedRegistry that uses packages.shopware.com -// when a store token is provided and falls back to repo.packagist.org for -// everything else. token may be empty; in that case store lookups return -// ErrRegistryUnavailable. -func DefaultRegistry(token string) Registry { - combined := &CombinedRegistry{ - Packagist: PackagistRegistry{}, - } - if token != "" { - combined.Store = &ShopwareStoreRegistry{Token: token} - } - return combined -} diff --git a/internal/projectupgrade/registry_test.go b/internal/projectupgrade/registry_test.go deleted file mode 100644 index 60405771..00000000 --- a/internal/projectupgrade/registry_test.go +++ /dev/null @@ -1,85 +0,0 @@ -package projectupgrade - -import ( - "context" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/shopware/shopware-cli/internal/packagist" -) - -func TestCombinedRegistryPrefersStoreForOwnedPackages(t *testing.T) { - t.Parallel() - - // swag/paypal is a commercial store plugin: vendor-named, only on the - // store. The store must answer even though the name has no - // store.shopware.com/ prefix. - store := &fakeRegistry{versions: map[string][]packagist.ComposerPackageVersion{ - "swag/paypal": {{Version: "9.0.0"}}, - }} - packagistReg := &fakeRegistry{versions: map[string][]packagist.ComposerPackageVersion{}} - - combined := &CombinedRegistry{Store: store, Packagist: packagistReg} - - versions, err := combined.GetPackageVersions(t.Context(), "swag/paypal") - require.NoError(t, err) - require.Len(t, versions, 1) - assert.Equal(t, "9.0.0", versions[0].Version) -} - -func TestCombinedRegistryFallsBackToPackagistForPublicPackages(t *testing.T) { - t.Parallel() - - // Store knows nothing about a public package; Packagist resolves it. - store := &fakeRegistry{versions: map[string][]packagist.ComposerPackageVersion{}} - packagistReg := &fakeRegistry{versions: map[string][]packagist.ComposerPackageVersion{ - "frosh/tools": {{Version: "2.0.0"}}, - }} - - combined := &CombinedRegistry{Store: store, Packagist: packagistReg} - - versions, err := combined.GetPackageVersions(t.Context(), "frosh/tools") - require.NoError(t, err) - require.Len(t, versions, 1) - assert.Equal(t, "2.0.0", versions[0].Version) -} - -func TestCombinedRegistryFallsBackWhenStoreUnavailable(t *testing.T) { - t.Parallel() - - // No token -> store load fails with ErrRegistryUnavailable; public - // packages must still resolve via Packagist. - store := &fakeRegistry{err: ErrRegistryUnavailable} - packagistReg := &fakeRegistry{versions: map[string][]packagist.ComposerPackageVersion{ - "frosh/tools": {{Version: "2.0.0"}}, - }} - - combined := &CombinedRegistry{Store: store, Packagist: packagistReg} - - versions, err := combined.GetPackageVersions(t.Context(), "frosh/tools") - require.NoError(t, err) - require.Len(t, versions, 1) -} - -func TestCombinedRegistryNilStoreUsesPackagist(t *testing.T) { - t.Parallel() - - packagistReg := &fakeRegistry{versions: map[string][]packagist.ComposerPackageVersion{ - "frosh/tools": {{Version: "2.0.0"}}, - }} - combined := &CombinedRegistry{Packagist: packagistReg} - - versions, err := combined.GetPackageVersions(t.Context(), "frosh/tools") - require.NoError(t, err) - require.Len(t, versions, 1) -} - -func TestCombinedRegistryNilPackagistReturnsUnavailable(t *testing.T) { - t.Parallel() - - combined := &CombinedRegistry{} - _, err := combined.GetPackageVersions(context.Background(), "frosh/tools") - assert.ErrorIs(t, err, ErrRegistryUnavailable) -} diff --git a/internal/projectupgrade/wizard.go b/internal/projectupgrade/wizard.go index 1e64c1f7..5e6eda1f 100644 --- a/internal/projectupgrade/wizard.go +++ b/internal/projectupgrade/wizard.go @@ -39,10 +39,6 @@ type WizardOptions struct { CurrentVersion *version.Version UpdateVersions []string Executor executor.Executor - // Registry is consulted to find newer compatible versions of plugins - // whose installed shopware/core constraint is no longer satisfied. May - // be nil, in which case incompatible plugins are simply removed. - Registry Registry } type phase int @@ -89,8 +85,8 @@ const ( // wizardMsg variants advance the upgrade state machine. type ( compatLoadedMsg struct { - updates []PluginCompat - err error + report CompatReport + err error } taskCompleteMsg struct { task int @@ -115,26 +111,25 @@ type wizardModel struct { phase phase - versionList *tui.SelectList - targetVersion string - confirmYes bool - pluginActions *ResolveResult - compatUpdates []PluginCompat - compatHasBlock bool - compatHasUpdatable bool - compatErr error - tasks []task - currentTask int - logLines []string - fullLog []string - logChan chan string - finalErr error - finished bool - spinner spinner.Model - compatLoading bool - cancelExecution context.CancelFunc - width int - height int + versionList *tui.SelectList + targetVersion string + confirmYes bool + pluginActions *ResolveResult + compatReport CompatReport + compatHasBlock bool + compatErr error + tasks []task + currentTask int + logLines []string + fullLog []string + logChan chan string + finalErr error + finished bool + spinner spinner.Model + compatLoading bool + cancelExecution context.CancelFunc + width int + height int } // WizardResult is the outcome of a single RunWizard invocation. @@ -243,15 +238,8 @@ func (m wizardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case compatLoadedMsg: m.compatLoading = false m.compatErr = msg.err - m.compatUpdates = msg.updates - for _, u := range msg.updates { - if u.Status.IsBlocker() { - m.compatHasBlock = true - } - if u.Status.IsUpdatable() { - m.compatHasUpdatable = true - } - } + m.compatReport = msg.report + m.compatHasBlock = !msg.report.OK m.phase = phaseCompatResult m.confirmYes = !m.compatHasBlock return m, nil @@ -441,22 +429,23 @@ func (m wizardModel) readNextLog() tea.Cmd { } } -// loadCompatibility runs CheckPluginCompatibility against the registry to -// preview how each composer-managed plugin will be treated by the upgrade. +// loadCompatibility asks composer (dry run) whether the project can be upgraded +// to the target version, so the user sees composer's own verdict before +// applying anything. func (m wizardModel) loadCompatibility() tea.Cmd { composerJsonPath := m.opts.ComposerJSONPath targetVersion := m.targetVersion - registry := m.opts.Registry + exec := m.opts.Executor return func() tea.Msg { - ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) defer cancel() - updates, err := CheckPluginCompatibility(ctx, composerJsonPath, targetVersion, registry) + report, err := DryRunRequire(ctx, exec, composerJsonPath, targetVersion) if err != nil { return compatLoadedMsg{err: err} } - return compatLoadedMsg{updates: updates} + return compatLoadedMsg{report: report} } } @@ -500,28 +489,22 @@ func (m wizardModel) runRemovePlugins() tea.Cmd { composerJSONPath := m.opts.ComposerJSONPath target := m.targetVersion idx := taskPlugins - registry := m.opts.Registry + exec := m.opts.Executor return func() tea.Msg { - ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) defer cancel() - result, err := ResolveIncompatiblePlugins(ctx, composerJSONPath, target, registry) + result, err := ApplyRequire(ctx, exec, composerJSONPath, target) if err != nil { return taskCompleteMsg{task: idx, err: err} } if result == nil { result = &ResolveResult{} } - bumped := len(result.Bumped()) removed := len(result.Removed()) - detail := "no incompatibilities" - switch { - case bumped > 0 && removed > 0: - detail = fmt.Sprintf("bumped %d, removed %d", bumped, removed) - case bumped > 0: - detail = fmt.Sprintf("bumped %d to a compatible version", bumped) - case removed > 0: - detail = fmt.Sprintf("removed %d (no compatible release)", removed) + detail := "composer resolved all plugins" + if removed > 0 { + detail = fmt.Sprintf("removed %d (composer could not resolve)", removed) } return taskCompleteMsg{task: idx, detail: detail, pluginActions: result} } @@ -695,7 +678,7 @@ func (m wizardModel) viewWelcome() string { b.WriteString("\n\n") for _, line := range []string{ "Clean up stale recipe-managed files (md5-matched)", - "Resolve incompatible custom plugins (bump or drop)", + "Let composer require the target version (dropping plugins it can't resolve)", "Rewrite composer.json to pin the target version and ensure shopware/deployment-helper", "Run composer update --with-all-dependencies --no-scripts", "Run vendor/bin/shopware-deployment-helper run", @@ -741,9 +724,9 @@ func (m wizardModel) viewCompatCheck() string { b.WriteString("\n\n") b.WriteString(tui.TitleStyle.Render("Checking plugin compatibility")) b.WriteString("\n") - b.WriteString(tui.DimStyle.Render(fmt.Sprintf("Looking up composer-managed plugins for %s…", m.targetVersion))) + b.WriteString(tui.DimStyle.Render(fmt.Sprintf("Asking composer to resolve %s…", m.targetVersion))) b.WriteString("\n\n") - b.WriteString(m.spinner.View() + " " + tui.DimStyle.Render("fetching compatibility")) + b.WriteString(m.spinner.View() + " " + tui.DimStyle.Render("composer require --dry-run")) b.WriteString("\n\n") b.WriteString(m.footer(tui.Shortcut{Key: "ctrl+c", Label: "Cancel"})) return tui.RenderPhaseCard(b.String()) @@ -760,52 +743,39 @@ func (m wizardModel) viewCompatResult() string { switch { case m.compatErr != nil: - b.WriteString(lipgloss.NewStyle().Foreground(tui.ErrorColor).Render("Compatibility lookup failed: " + m.compatErr.Error())) + b.WriteString(lipgloss.NewStyle().Foreground(tui.ErrorColor).Render("Compatibility check failed: " + m.compatErr.Error())) b.WriteString("\n") b.WriteString(tui.DimStyle.Render("You may still proceed; the wizard cannot guarantee plugins will install.")) b.WriteString("\n\n") - case len(m.compatUpdates) == 0: - b.WriteString(tui.DimStyle.Render("No composer-managed plugins to check.")) + case m.compatReport.OK: + b.WriteString(" ") + b.WriteString(tui.Checkmark) + b.WriteString(" ") + b.WriteString(tui.LabelStyle.Render("composer can resolve this upgrade")) b.WriteString("\n\n") default: - for _, u := range m.compatUpdates { - var icon string - switch { - case u.Status.IsBlocker(): - icon = lipgloss.NewStyle().Foreground(tui.ErrorColor).Bold(true).Render("✗") - case u.Status.IsUpdatable(): - icon = lipgloss.NewStyle().Foreground(tui.WarnColor).Bold(true).Render("↑") - default: - icon = tui.Checkmark - } - b.WriteString(" ") - b.WriteString(icon) - b.WriteString(" ") - b.WriteString(tui.LabelStyle.Render(u.Name)) - detail := u.Status.Label() - if u.Status == CompatUpdatable && u.NewVersion != "" { - detail = fmt.Sprintf("%s → %s", u.CurrentVersion, u.NewVersion) - } else if u.CurrentVersion != "" { - detail = u.CurrentVersion + " — " + detail + b.WriteString(lipgloss.NewStyle().Foreground(tui.ErrorColor).Bold(true).Render("✗ composer could not resolve this upgrade.")) + b.WriteString("\n\n") + if len(m.compatReport.BlockingPlugins) > 0 { + b.WriteString(tui.DimStyle.Render("These plugins block the upgrade and will be removed from composer.json so it can proceed:")) + b.WriteString("\n") + for _, name := range m.compatReport.BlockingPlugins { + b.WriteString(" ") + b.WriteString(lipgloss.NewStyle().Foreground(tui.ErrorColor).Render("✗")) + b.WriteString(" ") + b.WriteString(tui.LabelStyle.Render(name)) + b.WriteString("\n") } - b.WriteString(tui.DimStyle.Render(" — " + detail)) + b.WriteString(tui.DimStyle.Render("Re-require them once they publish a compatible release.")) + b.WriteString("\n\n") + } + for _, line := range lastLines(m.compatReport.Output, 12) { + b.WriteString(tui.DimStyle.Render(" " + line)) b.WriteString("\n") } b.WriteString("\n") } - if m.compatHasUpdatable { - b.WriteString(lipgloss.NewStyle().Foreground(tui.WarnColor).Render("↑ Plugins marked with ↑ have a compatible release; their constraints will be bumped during the upgrade.")) - b.WriteString("\n\n") - } - - if m.compatHasBlock { - b.WriteString(lipgloss.NewStyle().Foreground(tui.ErrorColor).Bold(true).Render("✗ Some plugins have no compatible version yet.")) - b.WriteString("\n") - b.WriteString(tui.DimStyle.Render("They will be removed from composer.json so the upgrade can proceed. Re-require them once they publish a compatible release.")) - b.WriteString("\n\n") - } - b.WriteString(renderConfirmButtons("Continue", "Cancel", m.confirmYes)) b.WriteString("\n\n") b.WriteString(m.footer( @@ -908,24 +878,8 @@ func (m wizardModel) viewDone() string { b.WriteString(tui.DimStyle.Render("All tasks completed. Verify your shop and run your test suite.")) if m.pluginActions != nil { - bumped := m.pluginActions.Bumped() removed := m.pluginActions.Removed() - if len(bumped) > 0 { - b.WriteString("\n\n") - b.WriteString(tui.BoldText.Render("Bumped plugin constraints:")) - b.WriteString("\n") - for _, action := range bumped { - b.WriteString(tui.DimStyle.Render(" • ")) - b.WriteString(tui.LabelStyle.Render(action.Name)) - b.WriteString(" ") - b.WriteString(tui.DimStyle.Render(action.OldConstraint)) - b.WriteString(" → ") - b.WriteString(lipgloss.NewStyle().Foreground(tui.SuccessColor).Render(action.NewConstraint)) - b.WriteString("\n") - } - } - if len(removed) > 0 { b.WriteString("\n\n") b.WriteString(tui.BoldText.Render("Removed incompatible custom plugins:")) diff --git a/internal/projectupgrade/wizard_test.go b/internal/projectupgrade/wizard_test.go index 2908913c..a2bda5b2 100644 --- a/internal/projectupgrade/wizard_test.go +++ b/internal/projectupgrade/wizard_test.go @@ -89,7 +89,7 @@ func TestWizardSelectVersionGoesToCompatCheck(t *testing.T) { assert.Equal(t, "6.6.4.0", wm.targetVersion) } -func TestWizardCompatLoadedSetsBlockerFlag(t *testing.T) { +func TestWizardCompatLoadedConflictSetsBlockerFlag(t *testing.T) { t.Parallel() m := newTestModel(t) @@ -97,18 +97,20 @@ func TestWizardCompatLoadedSetsBlockerFlag(t *testing.T) { m.compatLoading = true updated, _ := m.Update(compatLoadedMsg{ - updates: []PluginCompat{ - {Name: "vendor/incompat", CurrentVersion: "1.0.0", Status: CompatBlocker}, + report: CompatReport{ + OK: false, + Output: []string{"Your requirements could not be resolved"}, + BlockingPlugins: []string{"vendor/incompat"}, }, }) wm := updated.(wizardModel) assert.False(t, wm.compatLoading) assert.Equal(t, phaseCompatResult, wm.phase) assert.True(t, wm.compatHasBlock) - assert.False(t, wm.confirmYes, "blocker should default the confirm to No") + assert.False(t, wm.confirmYes, "a composer conflict should default the confirm to No") } -func TestWizardCompatLoadedUpdatableIsNotBlocker(t *testing.T) { +func TestWizardCompatLoadedResolvableIsNotBlocker(t *testing.T) { t.Parallel() m := newTestModel(t) @@ -116,15 +118,12 @@ func TestWizardCompatLoadedUpdatableIsNotBlocker(t *testing.T) { m.compatLoading = true updated, _ := m.Update(compatLoadedMsg{ - updates: []PluginCompat{ - {Name: "swag/paypal", CurrentVersion: "8.11.0", NewVersion: "9.0.0", Status: CompatUpdatable}, - }, + report: CompatReport{OK: true}, }) wm := updated.(wizardModel) assert.Equal(t, phaseCompatResult, wm.phase) - assert.False(t, wm.compatHasBlock, "updatable plugin must not block") - assert.True(t, wm.compatHasUpdatable) - assert.True(t, wm.confirmYes, "no blocker means confirm defaults to Yes") + assert.False(t, wm.compatHasBlock, "a resolvable upgrade must not block") + assert.True(t, wm.confirmYes, "no conflict means confirm defaults to Yes") } func TestUpgradeTaskOrderRunsDeploymentHelperLast(t *testing.T) { From 89e6be96eab76493d1b063ee6684d3756909194c Mon Sep 17 00:00:00 2001 From: Soner Sayakci Date: Tue, 7 Jul 2026 10:57:04 +0200 Subject: [PATCH 13/14] feat(projectupgrade): implement multi-panel upgrade wizard flow Implements the local upgrade wizard flow from shopware/shopware-cli#1167: - Preflight checklist panel with visible, re-runnable safety checks (composer.lock, clean git tree, composer-managed plugins, running web environment, Packagist reachability) that block the flow until fixed - Version selection with release-notes links - Prepare panel with BLOCKED/READY state, system preparation checks and a risk-ranked extension compatibility queue (State | Name | Current -> Target | Result) derived from composer's dry-run verdict, including deprecated/replaced detection from composer abandoned warnings - Per-extension detail overlays with contextual user actions; blocked extensions must be updated or explicitly marked for removal before the upgrade can start - Exportable Markdown support report (OK / needed review / was blocked blocks, PHP requirement of the target, composer.json, raw composer output on blockers) from the review and done panels - Full-terminal-width live log during the upgrade and a post-upgrade validation checklist on completion - Consistent header bar: current version | environment | target + docs Headless (--to / non-interactive) behaviour is unchanged; its hard safety checks now run only on that path since the wizard owns them interactively. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KFmjkiNERLCC1fE45wS7cj --- cmd/project/project_upgrade.go | 50 +- internal/projectupgrade/extensions.go | 197 ++++++ internal/projectupgrade/extensions_test.go | 137 ++++ internal/projectupgrade/preflight.go | 217 ++++++ internal/projectupgrade/preflight_test.go | 118 +++ internal/projectupgrade/report.go | 139 ++++ internal/projectupgrade/report_test.go | 89 +++ internal/projectupgrade/wizard.go | 787 +++++++++------------ internal/projectupgrade/wizard_test.go | 257 ++++++- internal/projectupgrade/wizard_views.go | 770 ++++++++++++++++++++ 10 files changed, 2282 insertions(+), 479 deletions(-) create mode 100644 internal/projectupgrade/extensions.go create mode 100644 internal/projectupgrade/extensions_test.go create mode 100644 internal/projectupgrade/preflight.go create mode 100644 internal/projectupgrade/preflight_test.go create mode 100644 internal/projectupgrade/report.go create mode 100644 internal/projectupgrade/report_test.go create mode 100644 internal/projectupgrade/wizard_views.go diff --git a/cmd/project/project_upgrade.go b/cmd/project/project_upgrade.go index 07d4146c..1d611cac 100644 --- a/cmd/project/project_upgrade.go +++ b/cmd/project/project_upgrade.go @@ -30,13 +30,26 @@ import ( 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.`, + Long: `Upgrade the Shopware project to a newer version. + +In an interactive terminal this starts the upgrade wizard: it runs preflight +safety checks (clean git tree, composer-managed plugins, running environment, +Packagist reachability), lets you pick a target version with release notes, +shows an extension compatibility queue derived from composer's own dry-run +verdict (compatible / will update / deprecated / blocked), and blocks the +upgrade until every blocked extension is either updated or explicitly marked +for removal. A Markdown support report with the findings, composer.json, and +raw composer output can be exported at any time from the review and result +panels. + +The upgrade itself 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. + +With --to or in non-interactive environments the headless flow runs instead.`, RunE: func(cmd *cobra.Command, _ []string) error { ctx := cmd.Context() log := logging.FromContext(ctx) @@ -61,14 +74,7 @@ project doesn't require it yet.`, 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 { @@ -87,7 +93,15 @@ project doesn't require it yet.`, } // Non-interactive: keep the headless flow so CI runs stay unchanged. + // The safety checks abort hard here; interactively they run as the + // wizard's preflight checklist instead, with a visible recheck. if !system.IsInteractionEnabled(ctx) || cmd.Flag("to").Value.String() != "" { + if err := ensureCleanGitTree(ctx, projectRoot, allowDirty); err != nil { + return err + } + if err := ensureAllPluginsAreComposerManaged(projectRoot, allowNonComposer); err != nil { + return err + } return runUpgradeHeadless(cmd, projectRoot, composerJsonPath, currentVersion, updateVersions, cmdExecutor) } @@ -98,6 +112,8 @@ project doesn't require it yet.`, CurrentVersion: currentVersion, UpdateVersions: updateVersions, Executor: cmdExecutor, + AllowDirty: allowDirty, + AllowNonComposer: allowNonComposer, }) status := "ok" @@ -112,6 +128,12 @@ project doesn't require it yet.`, trackUpgrade(ctx, currentVersion.String(), result.TargetVersion, status) + // Keep the exported report path in the scrollback; the alt-screen + // view that showed it is gone once the wizard exits. + if result.ReportPath != "" { + fmt.Printf("Upgrade report written to %s\n", result.ReportPath) + } + if errors.Is(err, projectupgrade.ErrCancelled) { fmt.Println("Upgrade cancelled.") return nil diff --git a/internal/projectupgrade/extensions.go b/internal/projectupgrade/extensions.go new file mode 100644 index 00000000..48afddda --- /dev/null +++ b/internal/projectupgrade/extensions.go @@ -0,0 +1,197 @@ +package projectupgrade + +import ( + "path/filepath" + "regexp" + "sort" + "strings" + + "github.com/shopware/shopware-cli/internal/packagist" +) + +// ExtensionState classifies how an installed extension relates to the +// selected upgrade target. The order encodes risk: lower values are riskier +// and sort to the top of the queue. +type ExtensionState int + +const ( + // ExtensionBlocked means composer found no release of the extension that + // is compatible with the target version. Blockers prevent the upgrade + // from starting until the user resolves them. + ExtensionBlocked ExtensionState = iota + // ExtensionDeprecated means the package is abandoned upstream and should + // be replaced rather than updated. + ExtensionDeprecated + // ExtensionUpdate means composer will move the extension to a newer + // release as part of the upgrade. + ExtensionUpdate + // ExtensionOK means the installed release already works with the target + // version; composer keeps it as-is. + ExtensionOK + // ExtensionRemove means the user decided to drop the extension from + // composer.json during the upgrade (the only way past a blocker without + // a vendor release). + ExtensionRemove +) + +// ExtensionRow is one entry of the upgrade wizard's extension queue. +type ExtensionRow struct { + // Name is the composer package name (e.g. "swag/paypal"). + Name string + // Current is the installed version. + Current string + // Target is the version composer resolves for the upgrade. Equal to + // Current when the extension is kept as-is; empty when composer found no + // compatible release. + Target string + // State classifies the row; see ExtensionState. + State ExtensionState + // Result is a short human-readable outcome shown in the queue table. + Result string + // Replacement is the package suggested by upstream when the extension is + // abandoned (empty when none was suggested). + Replacement string +} + +// RequiredPluginVersions returns the installed version of every required +// shopware-platform-plugin, keyed by composer package name. +func RequiredPluginVersions(composerJsonPath string) (map[string]string, error) { + composerJson, err := packagist.ReadComposerJson(composerJsonPath) + if err != nil { + return nil, err + } + + installed, err := packagist.ReadInstalledJson(filepath.Dir(composerJsonPath)) + if err != nil { + return nil, err + } + + plugins := make(map[string]string) + for _, pkg := range installed.Packages { + if pkg.Type != composerPluginType { + continue + } + if _, ok := composerJson.Require[pkg.Name]; !ok { + continue + } + plugins[pkg.Name] = strings.TrimPrefix(pkg.Version, "v") + } + + return plugins, nil +} + +var ( + // composerOperationRe matches composer's lock/package operation lines, + // e.g. " - Upgrading swag/paypal (v8.0.0 => v9.2.0)". + composerOperationRe = regexp.MustCompile(`^\s*- (Upgrading|Downgrading|Installing|Removing) (\S+) \((.+)\)`) + // composerAbandonedRe matches composer's abandoned-package warnings, e.g. + // "Package swag/legacy is abandoned, you should avoid using it. Use swag/new instead." + composerAbandonedRe = regexp.MustCompile(`Package (\S+) is abandoned, you should avoid using it\.(?: Use (.+) instead\.?| No replacement was suggested\.?)?`) +) + +// composerOperations extracts the resolved version movements and abandoned +// warnings for the given packages from composer output lines. +func composerOperations(output []string, packages map[string]string) (targets map[string]string, abandoned map[string]string) { + targets = make(map[string]string) + abandoned = make(map[string]string) + + for _, line := range output { + if m := composerOperationRe.FindStringSubmatch(line); m != nil { + name := m[2] + if _, ok := packages[name]; !ok { + continue + } + switch m[1] { + case "Upgrading", "Downgrading": + if parts := strings.SplitN(m[3], " => ", 2); len(parts) == 2 { + targets[name] = strings.TrimPrefix(strings.TrimSpace(parts[1]), "v") + } + } + continue + } + + if m := composerAbandonedRe.FindStringSubmatch(line); m != nil { + name := m[1] + if _, ok := packages[name]; !ok { + continue + } + abandoned[name] = strings.TrimSuffix(strings.TrimSpace(m[2]), ".") + } + } + + return targets, abandoned +} + +// BuildExtensionQueue derives the wizard's extension compatibility queue from +// the installed plugin versions and composer's dry-run verdict. Rows are +// sorted by risk (blocked first, then deprecated, updates, ok) and +// alphabetically within the same state. +func BuildExtensionQueue(installed map[string]string, report CompatReport) []ExtensionRow { + blocked := make(map[string]struct{}, len(report.BlockingPlugins)) + for _, name := range report.BlockingPlugins { + blocked[name] = struct{}{} + } + + targets, abandoned := composerOperations(report.Output, installed) + + rows := make([]ExtensionRow, 0, len(installed)) + for name, current := range installed { + row := ExtensionRow{Name: name, Current: current} + + replacement, isAbandoned := abandoned[name] + target, hasUpdate := targets[name] + + switch { + case !report.OK && isBlocked(blocked, name): + row.State = ExtensionBlocked + row.Result = "no compatible release" + case isAbandoned: + row.State = ExtensionDeprecated + row.Replacement = replacement + row.Target = current + if hasUpdate { + row.Target = target + } + if replacement != "" { + row.Result = "deprecated, replaced by " + replacement + } else { + row.Result = "deprecated, no replacement" + } + case hasUpdate: + row.State = ExtensionUpdate + row.Target = target + row.Result = "will be updated" + default: + row.State = ExtensionOK + row.Target = current + row.Result = "compatible as installed" + } + + rows = append(rows, row) + } + + sort.Slice(rows, func(i, j int) bool { + if rows[i].State != rows[j].State { + return rows[i].State < rows[j].State + } + return rows[i].Name < rows[j].Name + }) + + return rows +} + +func isBlocked(blocked map[string]struct{}, name string) bool { + _, ok := blocked[name] + return ok +} + +// CountBlockers returns how many rows still block the upgrade. +func CountBlockers(rows []ExtensionRow) int { + n := 0 + for _, r := range rows { + if r.State == ExtensionBlocked { + n++ + } + } + return n +} diff --git a/internal/projectupgrade/extensions_test.go b/internal/projectupgrade/extensions_test.go new file mode 100644 index 00000000..519d80c7 --- /dev/null +++ b/internal/projectupgrade/extensions_test.go @@ -0,0 +1,137 @@ +package projectupgrade + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestComposerOperationsParsesUpgradesAndAbandoned(t *testing.T) { + t.Parallel() + + output := []string{ + "Loading composer repositories with package information", + "Lock file operations: 1 install, 3 updates, 0 removals", + " - Upgrading shopware/core (v6.5.8.0 => v6.6.4.0)", + " - Upgrading swag/paypal (v8.0.0 => v9.2.0)", + " - Downgrading acme/downgraded (2.0.0 => 1.9.0)", + " - Installing new/dependency (1.0.0)", + "Package swag/legacy is abandoned, you should avoid using it. Use swag/replacement instead.", + "Package acme/dead is abandoned, you should avoid using it. No replacement was suggested.", + } + + plugins := map[string]string{ + "swag/paypal": "8.0.0", + "acme/downgraded": "2.0.0", + "swag/legacy": "1.0.0", + "acme/dead": "3.0.0", + } + + targets, abandoned := composerOperations(output, plugins) + + assert.Equal(t, map[string]string{ + "swag/paypal": "9.2.0", + "acme/downgraded": "1.9.0", + }, targets) + assert.Equal(t, map[string]string{ + "swag/legacy": "swag/replacement", + "acme/dead": "", + }, abandoned) +} + +func TestComposerOperationsIgnoresNonPluginPackages(t *testing.T) { + t.Parallel() + + output := []string{ + " - Upgrading shopware/core (v6.5.8.0 => v6.6.4.0)", + "Package symfony/old is abandoned, you should avoid using it. No replacement was suggested.", + } + + targets, abandoned := composerOperations(output, map[string]string{"swag/paypal": "8.0.0"}) + assert.Empty(t, targets) + assert.Empty(t, abandoned) +} + +func TestBuildExtensionQueueStatesAndRiskOrder(t *testing.T) { + t.Parallel() + + installed := map[string]string{ + "swag/ok": "1.0.0", + "swag/update": "2.0.0", + "swag/blocked": "3.0.0", + "swag/deprecated": "4.0.0", + } + + report := CompatReport{ + OK: false, + Output: []string{ + " - Upgrading swag/update (v2.0.0 => v2.5.0)", + "Package swag/deprecated is abandoned, you should avoid using it. Use swag/new instead.", + }, + BlockingPlugins: []string{"swag/blocked"}, + } + + rows := BuildExtensionQueue(installed, report) + require.Len(t, rows, 4) + + // Risk order: blocked, deprecated, update, ok. + assert.Equal(t, "swag/blocked", rows[0].Name) + assert.Equal(t, ExtensionBlocked, rows[0].State) + assert.Empty(t, rows[0].Target) + + assert.Equal(t, "swag/deprecated", rows[1].Name) + assert.Equal(t, ExtensionDeprecated, rows[1].State) + assert.Equal(t, "swag/new", rows[1].Replacement) + + assert.Equal(t, "swag/update", rows[2].Name) + assert.Equal(t, ExtensionUpdate, rows[2].State) + assert.Equal(t, "2.5.0", rows[2].Target) + + assert.Equal(t, "swag/ok", rows[3].Name) + assert.Equal(t, ExtensionOK, rows[3].State) + assert.Equal(t, "1.0.0", rows[3].Target, "unchanged extensions keep their version as target") +} + +func TestBuildExtensionQueueAllResolvable(t *testing.T) { + t.Parallel() + + installed := map[string]string{"swag/a": "1.0.0", "swag/b": "1.1.0"} + report := CompatReport{OK: true, Output: []string{" - Upgrading swag/b (v1.1.0 => v1.2.0)"}} + + rows := BuildExtensionQueue(installed, report) + require.Len(t, rows, 2) + assert.Equal(t, 0, CountBlockers(rows)) + assert.Equal(t, ExtensionUpdate, rows[0].State) + assert.Equal(t, "swag/b", rows[0].Name) + assert.Equal(t, ExtensionOK, rows[1].State) +} + +func TestRequiredPluginVersions(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + composerJson := filepath.Join(dir, "composer.json") + require.NoError(t, os.WriteFile(composerJson, []byte(`{ + "require": { + "shopware/core": "6.5.8.0", + "swag/paypal": "*", + "swag/unrelated-lib": "*" + } + }`), 0o644)) + + require.NoError(t, os.MkdirAll(filepath.Join(dir, "vendor", "composer"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "vendor", "composer", "installed.json"), []byte(`{ + "packages": [ + {"name": "swag/paypal", "type": "shopware-platform-plugin", "version": "v8.0.0"}, + {"name": "swag/not-required", "type": "shopware-platform-plugin", "version": "v1.0.0"}, + {"name": "swag/unrelated-lib", "type": "library", "version": "v2.0.0"} + ] + }`), 0o644)) + + plugins, err := RequiredPluginVersions(composerJson) + require.NoError(t, err) + assert.Equal(t, map[string]string{"swag/paypal": "8.0.0"}, plugins) +} diff --git a/internal/projectupgrade/preflight.go b/internal/projectupgrade/preflight.go new file mode 100644 index 00000000..57ed082c --- /dev/null +++ b/internal/projectupgrade/preflight.go @@ -0,0 +1,217 @@ +package projectupgrade + +import ( + "context" + "errors" + "fmt" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "github.com/shopware/shopware-cli/internal/executor" + "github.com/shopware/shopware-cli/internal/git" +) + +// PreflightStatus is the outcome of a single preflight check. +type PreflightStatus int + +const ( + PreflightOK PreflightStatus = iota + PreflightFailed + PreflightSkipped +) + +// PreflightResult is the outcome of one environment/safety check shown in the +// wizard's preflight checklist. +type PreflightResult struct { + // Label is the short check name shown in the checklist. + Label string + // Status is the check outcome. + Status PreflightStatus + // Detail is a short outcome note shown next to the label (e.g. "clean", + // "skipped via --allow-dirty"). + Detail string + // Explanation says why a failed check blocks the wizard and how to fix + // it. Only rendered when Status is PreflightFailed. + Explanation string +} + +// PreflightBlocked reports whether any check failed. Failed checks block the +// wizard until they pass a recheck. +func PreflightBlocked(results []PreflightResult) bool { + for _, r := range results { + if r.Status == PreflightFailed { + return true + } + } + return false +} + +// packagistPingURL is hit to verify the composer package repository is +// reachable. Overridable in tests. +var packagistPingURL = "https://repo.packagist.org/packages.json" + +// preflightHTTPClient is used for reachability checks. Short timeout so a +// broken network fails the check quickly instead of hanging the wizard. +var preflightHTTPClient = &http.Client{Timeout: 10 * time.Second} + +// RunPreflightChecks executes every preflight check and returns the results +// in display order. It never returns an error; failures are encoded in the +// results so the wizard can show them and offer a recheck. +func RunPreflightChecks(ctx context.Context, opts WizardOptions) []PreflightResult { + return []PreflightResult{ + checkComposerLock(opts.ProjectRoot), + checkGitClean(ctx, opts.ProjectRoot, opts.AllowDirty), + checkComposerManagedPlugins(opts.ProjectRoot, opts.AllowNonComposer), + checkEnvironmentRunning(ctx, opts.Executor), + checkPackagistReachable(ctx), + } +} + +func checkComposerLock(projectRoot string) PreflightResult { + r := PreflightResult{Label: "composer.lock present"} + + if _, err := os.Stat(filepath.Join(projectRoot, "composer.lock")); err != nil { + r.Status = PreflightFailed + r.Detail = "not found" + r.Explanation = "The upgrade needs composer.lock to determine installed packages. Run `composer install` first." + return r + } + + r.Status = PreflightOK + return r +} + +func checkGitClean(ctx context.Context, projectRoot string, allowDirty bool) PreflightResult { + r := PreflightResult{Label: "Git working tree clean"} + + if allowDirty { + r.Status = PreflightSkipped + r.Detail = "skipped via --allow-dirty" + return r + } + + dirty, isRepo, err := git.IsWorkingTreeDirty(ctx, projectRoot) + if err != nil { + r.Status = PreflightFailed + r.Detail = "check failed" + r.Explanation = fmt.Sprintf("Could not read the git working tree status: %v", err) + return r + } + + if !isRepo { + r.Status = PreflightOK + r.Detail = "not a git repository" + return r + } + + if dirty { + r.Status = PreflightFailed + r.Detail = "uncommitted changes" + r.Explanation = "The upgrade rewrites composer.json and removes recipe-managed files. Commit or stash your changes so you can roll back, or rerun with --allow-dirty." + return r + } + + r.Status = PreflightOK + r.Detail = "clean" + return r +} + +func checkComposerManagedPlugins(projectRoot string, allowNonComposer bool) PreflightResult { + r := PreflightResult{Label: "Plugins managed by Composer"} + + if allowNonComposer { + r.Status = PreflightSkipped + r.Detail = "skipped via --allow-non-composer" + return r + } + + orphans, err := FindNonComposerPlugins(projectRoot) + if err != nil { + r.Status = PreflightFailed + r.Detail = "check failed" + r.Explanation = fmt.Sprintf("Could not scan custom/plugins: %v", err) + return r + } + + if len(orphans) > 0 { + r.Status = PreflightFailed + r.Detail = fmt.Sprintf("%d not tracked by composer", len(orphans)) + r.Explanation = fmt.Sprintf( + "The upgrade can only bump composer-managed plugins, but these directories in custom/plugins/ are not tracked by composer: %s. Run `shopware-cli project autofix composer-plugins` to migrate them, or rerun with --allow-non-composer.", + strings.Join(orphans, ", "), + ) + return r + } + + r.Status = PreflightOK + return r +} + +func checkEnvironmentRunning(ctx context.Context, exec executor.Executor) PreflightResult { + r := PreflightResult{Label: "Web environment running"} + + if exec == nil { + r.Status = PreflightSkipped + r.Detail = "no executor" + return r + } + + running, err := exec.EnvironmentStatus(ctx) + if errors.Is(err, executor.ErrNotSupported) { + r.Status = PreflightOK + r.Detail = exec.Type() + " (no managed services)" + return r + } + if err != nil { + r.Status = PreflightFailed + r.Detail = "status unknown" + r.Explanation = fmt.Sprintf("Could not determine whether the %s environment is running: %v", exec.Type(), err) + return r + } + + if !running { + r.Status = PreflightFailed + r.Detail = exec.Type() + " services stopped" + r.Explanation = "The upgrade runs composer and the deployment helper inside your environment. Start it (e.g. `docker compose up -d`) and recheck." + return r + } + + r.Status = PreflightOK + r.Detail = exec.Type() + " services running" + return r +} + +func checkPackagistReachable(ctx context.Context) PreflightResult { + r := PreflightResult{Label: "Packagist reachable"} + + req, err := http.NewRequestWithContext(ctx, http.MethodHead, packagistPingURL, http.NoBody) + if err != nil { + r.Status = PreflightFailed + r.Detail = "check failed" + r.Explanation = err.Error() + return r + } + req.Header.Set("User-Agent", "Shopware CLI") + + resp, err := preflightHTTPClient.Do(req) + if err != nil { + r.Status = PreflightFailed + r.Detail = "unreachable" + r.Explanation = "Composer needs to reach the package repository to resolve the upgrade. Check your network, proxy, or hosting firewall, then recheck. Details: " + err.Error() + return r + } + _ = resp.Body.Close() + + if resp.StatusCode >= 500 { + r.Status = PreflightFailed + r.Detail = resp.Status + r.Explanation = "The package repository responded with a server error. Try the recheck in a moment." + return r + } + + r.Status = PreflightOK + return r +} diff --git a/internal/projectupgrade/preflight_test.go b/internal/projectupgrade/preflight_test.go new file mode 100644 index 00000000..142faf45 --- /dev/null +++ b/internal/projectupgrade/preflight_test.go @@ -0,0 +1,118 @@ +package projectupgrade + +import ( + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCheckComposerLock(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + + r := checkComposerLock(dir) + assert.Equal(t, PreflightFailed, r.Status) + assert.NotEmpty(t, r.Explanation) + + require.NoError(t, os.WriteFile(filepath.Join(dir, "composer.lock"), []byte("{}"), 0o644)) + r = checkComposerLock(dir) + assert.Equal(t, PreflightOK, r.Status) +} + +func TestCheckGitCleanSkippedViaAllowDirty(t *testing.T) { + t.Parallel() + + r := checkGitClean(t.Context(), t.TempDir(), true) + assert.Equal(t, PreflightSkipped, r.Status) +} + +func TestCheckGitCleanNonRepoIsOK(t *testing.T) { + t.Parallel() + + r := checkGitClean(t.Context(), t.TempDir(), false) + assert.Equal(t, PreflightOK, r.Status) + assert.Equal(t, "not a git repository", r.Detail) +} + +func TestCheckGitCleanDirtyRepoFails(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + gitCmd := exec.CommandContext(t.Context(), "git", "-C", dir, "init") + require.NoError(t, gitCmd.Run()) + require.NoError(t, os.WriteFile(filepath.Join(dir, "untracked.txt"), []byte("x"), 0o644)) + + r := checkGitClean(t.Context(), dir, false) + assert.Equal(t, PreflightFailed, r.Status) + assert.Contains(t, r.Explanation, "--allow-dirty") +} + +func TestCheckComposerManagedPlugins(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + + // No custom/plugins directory at all: nothing to complain about. + r := checkComposerManagedPlugins(dir, false) + assert.Equal(t, PreflightOK, r.Status) + + // An orphan plugin directory not tracked by composer fails the check. + require.NoError(t, os.MkdirAll(filepath.Join(dir, "custom", "plugins", "MyOrphan"), 0o755)) + r = checkComposerManagedPlugins(dir, false) + assert.Equal(t, PreflightFailed, r.Status) + assert.Contains(t, r.Explanation, "MyOrphan") + + // The override flag downgrades it to skipped. + r = checkComposerManagedPlugins(dir, true) + assert.Equal(t, PreflightSkipped, r.Status) +} + +func TestCheckEnvironmentRunningWithoutExecutor(t *testing.T) { + t.Parallel() + + r := checkEnvironmentRunning(t.Context(), nil) + assert.Equal(t, PreflightSkipped, r.Status) +} + +func TestCheckPackagistReachable(t *testing.T) { + okServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer okServer.Close() + + failServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer failServer.Close() + + orig := packagistPingURL + defer func() { packagistPingURL = orig }() + + packagistPingURL = okServer.URL + r := checkPackagistReachable(t.Context()) + assert.Equal(t, PreflightOK, r.Status) + + packagistPingURL = failServer.URL + r = checkPackagistReachable(t.Context()) + assert.Equal(t, PreflightFailed, r.Status) + + packagistPingURL = "http://127.0.0.1:1" + r = checkPackagistReachable(t.Context()) + assert.Equal(t, PreflightFailed, r.Status) + assert.Contains(t, r.Explanation, "Composer needs to reach") +} + +func TestPreflightBlocked(t *testing.T) { + t.Parallel() + + assert.False(t, PreflightBlocked(nil)) + assert.False(t, PreflightBlocked([]PreflightResult{{Status: PreflightOK}, {Status: PreflightSkipped}})) + assert.True(t, PreflightBlocked([]PreflightResult{{Status: PreflightOK}, {Status: PreflightFailed}})) +} diff --git a/internal/projectupgrade/report.go b/internal/projectupgrade/report.go new file mode 100644 index 00000000..3203ac17 --- /dev/null +++ b/internal/projectupgrade/report.go @@ -0,0 +1,139 @@ +package projectupgrade + +import ( + "fmt" + "strings" +) + +// ReleaseNotesURL returns the GitHub release notes page for a Shopware +// version, so version pickers and reports can link to the changelog. +func ReleaseNotesURL(shopwareVersion string) string { + return "https://github.com/shopware/shopware/releases/tag/v" + strings.TrimPrefix(shopwareVersion, "v") +} + +// systemRequirementsURL documents Shopware's per-version system requirements +// (PHP, database, Redis, …). +const systemRequirementsURL = "https://developer.shopware.com/docs/guides/installation/requirements.html" + +// ReportData collects everything that goes into the exportable upgrade +// support report. +type ReportData struct { + CurrentVersion string + TargetVersion string + // Environment is the executor type the upgrade runs in (local, docker, …). + Environment string + // PHPConstraint is the PHP requirement of the target version ("" when it + // could not be determined). + PHPConstraint string + Preflight []PreflightResult + Extensions []ExtensionRow + // ComposerJSON is the raw contents of the project composer.json. + ComposerJSON string + // ComposerOutput is the raw output of the composer compatibility check. + // It is attached to the report when dependency blockers were detected. + ComposerOutput []string +} + +// BuildMarkdownReport renders a support-ready Markdown report of the upgrade +// plan: context, system requirement changes, extension findings grouped into +// OK / needed review / was blocked, the project composer.json, and — when +// blockers exist — the raw composer output. +func BuildMarkdownReport(d ReportData) string { + var b strings.Builder + + b.WriteString("# Shopware Upgrade Report\n\n") + b.WriteString("Generated by `shopware-cli project upgrade`. Attach this report to support or extension vendor tickets.\n\n") + + b.WriteString("## Upgrade context\n\n") + fmt.Fprintf(&b, "- Current Shopware version: `%s`\n", d.CurrentVersion) + fmt.Fprintf(&b, "- Target Shopware version: `%s` ([release notes](%s))\n", d.TargetVersion, ReleaseNotesURL(d.TargetVersion)) + if d.Environment != "" { + fmt.Fprintf(&b, "- Environment: `%s`\n", d.Environment) + } + b.WriteString("\n") + + b.WriteString("## System requirements\n\n") + if d.PHPConstraint != "" { + fmt.Fprintf(&b, "- PHP requirement of Shopware %s: `%s`\n", d.TargetVersion, d.PHPConstraint) + } else { + fmt.Fprintf(&b, "- PHP requirement of Shopware %s could not be determined.\n", d.TargetVersion) + } + fmt.Fprintf(&b, "- Verify database, Redis, and other platform requirements against the official list: %s\n\n", systemRequirementsURL) + + if len(d.Preflight) > 0 { + b.WriteString("## Preflight checks\n\n") + for _, r := range d.Preflight { + marker := "x" + suffix := "" + switch r.Status { + case PreflightFailed: + marker = " " + suffix = " — FAILED" + if r.Explanation != "" { + suffix += ": " + r.Explanation + } + case PreflightSkipped: + suffix = " — skipped" + if r.Detail != "" { + suffix = " — " + r.Detail + } + case PreflightOK: + if r.Detail != "" { + suffix = " — " + r.Detail + } + } + fmt.Fprintf(&b, "- [%s] %s%s\n", marker, r.Label, suffix) + } + b.WriteString("\n") + } + + writeExtensionBlock(&b, "OK", "These extensions are compatible with the target version.", d.Extensions, ExtensionOK) + writeExtensionBlock(&b, "Needed review", "These extensions need attention: they will be updated, are deprecated, or were manually marked for removal.", d.Extensions, ExtensionUpdate, ExtensionDeprecated, ExtensionRemove) + writeExtensionBlock(&b, "Was blocked", "No release compatible with the target version was found. Contact the extension vendor or remove the extension before upgrading.", d.Extensions, ExtensionBlocked) + + if d.ComposerJSON != "" { + b.WriteString("## composer.json\n\n") + b.WriteString("```json\n") + b.WriteString(strings.TrimRight(d.ComposerJSON, "\n")) + b.WriteString("\n```\n\n") + } + + if CountBlockers(d.Extensions) > 0 && len(d.ComposerOutput) > 0 { + b.WriteString("## Raw composer output\n\n") + b.WriteString("```\n") + b.WriteString(strings.Join(d.ComposerOutput, "\n")) + b.WriteString("\n```\n") + } + + return b.String() +} + +func writeExtensionBlock(b *strings.Builder, title, description string, rows []ExtensionRow, states ...ExtensionState) { + matching := make([]ExtensionRow, 0, len(rows)) + for _, row := range rows { + for _, state := range states { + if row.State == state { + matching = append(matching, row) + break + } + } + } + + fmt.Fprintf(b, "## Extensions: %s\n\n", title) + if len(matching) == 0 { + b.WriteString("None.\n\n") + return + } + + b.WriteString(description + "\n\n") + b.WriteString("| Extension | Installed | Target | Result |\n") + b.WriteString("| --- | --- | --- | --- |\n") + for _, row := range matching { + target := row.Target + if target == "" { + target = "—" + } + fmt.Fprintf(b, "| `%s` | %s | %s | %s |\n", row.Name, row.Current, target, row.Result) + } + b.WriteString("\n") +} diff --git a/internal/projectupgrade/report_test.go b/internal/projectupgrade/report_test.go new file mode 100644 index 00000000..892ee51e --- /dev/null +++ b/internal/projectupgrade/report_test.go @@ -0,0 +1,89 @@ +package projectupgrade + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestReleaseNotesURL(t *testing.T) { + t.Parallel() + + assert.Equal(t, "https://github.com/shopware/shopware/releases/tag/v6.6.4.0", ReleaseNotesURL("6.6.4.0")) + assert.Equal(t, "https://github.com/shopware/shopware/releases/tag/v6.6.4.0", ReleaseNotesURL("v6.6.4.0"), "an existing v prefix is not doubled") +} + +func TestBuildMarkdownReportGroupsExtensions(t *testing.T) { + t.Parallel() + + report := BuildMarkdownReport(ReportData{ + CurrentVersion: "6.5.8.0", + TargetVersion: "6.6.4.0", + Environment: "docker", + PHPConstraint: ">=8.2", + Preflight: []PreflightResult{ + {Label: "composer.lock present", Status: PreflightOK}, + {Label: "Git working tree clean", Status: PreflightSkipped, Detail: "skipped via --allow-dirty"}, + }, + Extensions: []ExtensionRow{ + {Name: "swag/blocked", Current: "3.0.0", State: ExtensionBlocked, Result: "no compatible release"}, + {Name: "swag/update", Current: "2.0.0", Target: "2.5.0", State: ExtensionUpdate, Result: "will be updated"}, + {Name: "swag/removed", Current: "1.0.0", State: ExtensionRemove, Result: "will be removed during the upgrade"}, + {Name: "swag/ok", Current: "1.0.0", Target: "1.0.0", State: ExtensionOK, Result: "compatible as installed"}, + }, + ComposerJSON: `{"require": {}}`, + ComposerOutput: []string{"Your requirements could not be resolved to an installable set of packages."}, + }) + + assert.Contains(t, report, "Current Shopware version: `6.5.8.0`") + assert.Contains(t, report, "releases/tag/v6.6.4.0") + assert.Contains(t, report, "PHP requirement of Shopware 6.6.4.0: `>=8.2`") + + // The three blocks required by the issue. + assert.Contains(t, report, "## Extensions: OK") + assert.Contains(t, report, "## Extensions: Needed review") + assert.Contains(t, report, "## Extensions: Was blocked") + + // Rows land in the right block (ordering: blocked block comes last). + okIdx := indexOf(t, report, "## Extensions: OK") + reviewIdx := indexOf(t, report, "## Extensions: Needed review") + blockedIdx := indexOf(t, report, "## Extensions: Was blocked") + assert.Less(t, okIdx, reviewIdx) + assert.Less(t, reviewIdx, blockedIdx) + + assert.Contains(t, report[reviewIdx:blockedIdx], "swag/update") + assert.Contains(t, report[reviewIdx:blockedIdx], "swag/removed") + assert.Contains(t, report[blockedIdx:], "swag/blocked") + + // composer.json and the raw composer output are attached because a + // blocker exists. + assert.Contains(t, report, "## composer.json") + assert.Contains(t, report, "## Raw composer output") + assert.Contains(t, report, "Your requirements could not be resolved") +} + +func TestBuildMarkdownReportOmitsRawComposerOutputWithoutBlockers(t *testing.T) { + t.Parallel() + + report := BuildMarkdownReport(ReportData{ + CurrentVersion: "6.5.8.0", + TargetVersion: "6.6.4.0", + Extensions: []ExtensionRow{ + {Name: "swag/ok", Current: "1.0.0", Target: "1.0.0", State: ExtensionOK, Result: "compatible as installed"}, + }, + ComposerOutput: []string{"Lock file operations: 0 installs, 2 updates, 0 removals"}, + }) + + assert.NotContains(t, report, "## Raw composer output") + assert.Contains(t, report, "None.", "empty blocks state that nothing was found") +} + +func indexOf(t *testing.T, haystack, needle string) int { + t.Helper() + idx := strings.Index(haystack, needle) + if idx < 0 { + t.Fatalf("expected %q in report", needle) + } + return idx +} diff --git a/internal/projectupgrade/wizard.go b/internal/projectupgrade/wizard.go index 5e6eda1f..bfb940ae 100644 --- a/internal/projectupgrade/wizard.go +++ b/internal/projectupgrade/wizard.go @@ -6,7 +6,9 @@ import ( "errors" "fmt" "io" + "os" "os/exec" + "path/filepath" "strings" "time" @@ -17,6 +19,7 @@ import ( "github.com/shopware/shopware-cli/internal/executor" "github.com/shopware/shopware-cli/internal/flexmigrator" + "github.com/shopware/shopware-cli/internal/packagist" "github.com/shopware/shopware-cli/internal/tui" ) @@ -32,6 +35,10 @@ const maxLogLines = 18 // shows at once so the card keeps a fixed height when many versions exist. const maxVisibleVersions = 10 +// maxVisibleExtensions caps how many extension queue rows are shown at once; +// the window scrolls with the cursor. +const maxVisibleExtensions = 8 + // WizardOptions configures a single run of the upgrade wizard. type WizardOptions struct { ProjectRoot string @@ -39,15 +46,21 @@ type WizardOptions struct { CurrentVersion *version.Version UpdateVersions []string Executor executor.Executor + // AllowDirty downgrades the "git working tree clean" preflight check to + // skipped (set via --allow-dirty). + AllowDirty bool + // AllowNonComposer downgrades the "plugins managed by composer" preflight + // check to skipped (set via --allow-non-composer). + AllowNonComposer bool } type phase int const ( phaseWelcome phase = iota + phasePreflight phaseSelectVersion - phaseCompatCheck - phaseCompatResult + phasePrepare phaseReview phaseRunning phaseDone @@ -84,9 +97,18 @@ const ( // wizardMsg variants advance the upgrade state machine. type ( + preflightDoneMsg struct { + results []PreflightResult + } compatLoadedMsg struct { - report CompatReport - err error + report CompatReport + queue []ExtensionRow + phpRequirement string + err error + } + reportWrittenMsg struct { + path string + err error } taskCompleteMsg struct { task int @@ -111,13 +133,30 @@ type wizardModel struct { phase phase - versionList *tui.SelectList - targetVersion string - confirmYes bool + preflight []PreflightResult + preflightLoading bool + + versionList *tui.SelectList + targetVersion string + confirmYes bool + + compatLoading bool + compatErr error + compatReport CompatReport + + extQueue []ExtensionRow + extCursor int + // overlayOpen shows the detail overlay for the extension under the cursor. + overlayOpen bool + // markedRemove records the user's decision to drop a blocked extension + // from composer.json during the upgrade. Survives rechecks. + markedRemove map[string]bool + + phpRequirement string + reportPath string + reportErr error + pluginActions *ResolveResult - compatReport CompatReport - compatHasBlock bool - compatErr error tasks []task currentTask int logLines []string @@ -126,7 +165,6 @@ type wizardModel struct { finalErr error finished bool spinner spinner.Model - compatLoading bool cancelExecution context.CancelFunc width int height int @@ -139,6 +177,8 @@ type WizardResult struct { TargetVersion string // Success is true when every upgrade task completed. Success bool + // ReportPath is the upgrade report file the user exported, if any. + ReportPath string // 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 @@ -149,6 +189,35 @@ type WizardResult struct { // 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) { + m := newWizardModel(opts) + + 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, ReportPath: fm.reportPath}, ErrCancelled + } + + result := WizardResult{ + TargetVersion: fm.targetVersion, + Success: fm.finalErr == nil, + ReportPath: fm.reportPath, + } + if fm.finalErr != nil { + result.FailureLog = fm.fullLog + } + return result, fm.finalErr +} + +func newWizardModel(opts WizardOptions) wizardModel { s := spinner.New( spinner.WithSpinner(spinner.Dot), spinner.WithStyle(lipgloss.NewStyle().Foreground(tui.BrandColor)), @@ -163,7 +232,7 @@ func RunWizard(opts WizardOptions) (WizardResult, error) { versionOptions[i] = tui.SelectOption{Label: v, Detail: detail} } - m := wizardModel{ + return wizardModel{ opts: opts, phase: phaseWelcome, confirmYes: true, @@ -173,33 +242,10 @@ func RunWizard(opts WizardOptions) (WizardResult, error) { 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, + spinner: s, + tasks: defaultTasks(), + markedRemove: map[string]bool{}, } - if fm.finalErr != nil { - result.FailureLog = fm.fullLog - } - return result, fm.finalErr } // ErrCancelled is returned when the user exits the wizard before the upgrade @@ -235,13 +281,30 @@ func (m wizardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.spinner, cmd = m.spinner.Update(msg) return m, cmd + case preflightDoneMsg: + m.preflightLoading = false + m.preflight = msg.results + return m, nil + 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 + m.extQueue = msg.queue + m.extCursor = 0 + m.overlayOpen = false + if msg.phpRequirement != "" { + m.phpRequirement = msg.phpRequirement + } + m.applyRemovalMarks() + return m, nil + + case reportWrittenMsg: + m.reportPath = msg.path + m.reportErr = msg.err + if msg.err != nil { + m.reportPath = "" + } return m, nil case startNextTaskMsg: @@ -291,6 +354,32 @@ func (m wizardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } +// applyRemovalMarks re-applies the user's remove decisions to a freshly built +// queue. A mark only sticks while the extension still blocks the upgrade; if +// a recheck finds a compatible release, the decision is obsolete and dropped. +func (m *wizardModel) applyRemovalMarks() { + for i := range m.extQueue { + row := &m.extQueue[i] + if m.markedRemove[row.Name] { + if row.State == ExtensionBlocked { + markRowRemoved(row) + } else { + delete(m.markedRemove, row.Name) + } + } + } +} + +func markRowRemoved(row *ExtensionRow) { + row.State = ExtensionRemove + row.Result = "will be removed during the upgrade" +} + +func unmarkRowRemoved(row *ExtensionRow) { + row.State = ExtensionBlocked + row.Result = "no compatible release" +} + func (m wizardModel) updateKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { key := msg.String() if key == "ctrl+c" { @@ -303,18 +392,21 @@ func (m wizardModel) updateKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { switch m.phase { case phaseWelcome: return m.updateWelcome(key) + case phasePreflight: + return m.updatePreflight(key) case phaseSelectVersion: return m.updateSelectVersion(key) - case phaseCompatCheck: - return m, nil - case phaseCompatResult: - return m.updateCompatResult(key) + case phasePrepare: + return m.updatePrepare(key) case phaseReview: return m.updateReview(key) case phaseRunning: return m, nil case phaseDone: - if key == "q" || key == "enter" || key == "esc" { + switch key { + case "e": + return m, m.writeReport() + case "q", "enter", "esc": return m, tea.Quit } } @@ -335,6 +427,41 @@ func (m wizardModel) updateWelcome(key string) (tea.Model, tea.Cmd) { if !m.confirmYes { return m, tea.Quit } + return m.startPreflight() + } + return m, nil +} + +func (m wizardModel) startPreflight() (tea.Model, tea.Cmd) { + m.phase = phasePreflight + m.preflightLoading = true + m.preflight = nil + return m, tea.Batch(m.spinner.Tick, m.runPreflight()) +} + +func (m wizardModel) runPreflight() tea.Cmd { + opts := m.opts + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + return preflightDoneMsg{results: RunPreflightChecks(ctx, opts)} + } +} + +func (m wizardModel) updatePreflight(key string) (tea.Model, tea.Cmd) { + if m.preflightLoading { + return m, nil + } + + switch key { + case "r": + return m.startPreflight() + case "q", "esc": + return m, tea.Quit + case "enter": + if PreflightBlocked(m.preflight) { + return m, nil + } m.phase = phaseSelectVersion return m, nil } @@ -355,34 +482,99 @@ func (m wizardModel) updateSelectVersion(key string) (tea.Model, tea.Cmd) { return m, nil } m.targetVersion = selected.Label - m.phase = phaseCompatCheck - m.compatLoading = true - return m, tea.Batch(m.spinner.Tick, m.loadCompatibility()) + return m.startCompatCheck() } return m, nil } -func (m wizardModel) updateCompatResult(key string) (tea.Model, tea.Cmd) { +func (m wizardModel) startCompatCheck() (tea.Model, tea.Cmd) { + m.phase = phasePrepare + m.compatLoading = true + m.compatErr = nil + m.overlayOpen = false + return m, tea.Batch(m.spinner.Tick, m.loadCompatibility()) +} + +func (m wizardModel) updatePrepare(key string) (tea.Model, tea.Cmd) { + if m.compatLoading { + return m, nil + } + + if m.overlayOpen { + return m.updatePrepareOverlay(key) + } + 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 "up", "k": + if m.extCursor > 0 { + m.extCursor-- + } + case "down", "j": + if m.extCursor < len(m.extQueue)-1 { + m.extCursor++ + } case "enter": - if !m.confirmYes { - return m, tea.Quit + if len(m.extQueue) > 0 { + m.overlayOpen = true + } + case "r": + return m.startCompatCheck() + case "c": + if m.prepareBlocked() { + return m, nil } m.phase = phaseReview m.confirmYes = true return m, nil + case "q", "esc": + return m, tea.Quit + } + return m, nil +} + +func (m wizardModel) updatePrepareOverlay(key string) (tea.Model, tea.Cmd) { + switch key { + case "esc", "enter", "q": + m.overlayOpen = false + case "r": + return m.startCompatCheck() + case "d": + if m.extCursor < len(m.extQueue) { + row := &m.extQueue[m.extCursor] + switch row.State { + case ExtensionBlocked: + markRowRemoved(row) + m.markedRemove[row.Name] = true + case ExtensionRemove: + unmarkRowRemoved(row) + delete(m.markedRemove, row.Name) + case ExtensionDeprecated, ExtensionUpdate, ExtensionOK: + // The remove decision only applies to blocked extensions. + } + } } return m, nil } +// prepareBlocked reports whether the upgrade may not continue yet: composer +// found extensions without a compatible release and the user has not decided +// what to do with them. +func (m wizardModel) prepareBlocked() bool { + return CountBlockers(m.extQueue) > 0 +} + +// plannedRemovals returns the extensions the user decided to drop during the +// upgrade. +func (m wizardModel) plannedRemovals() []ExtensionRow { + out := make([]ExtensionRow, 0) + for _, row := range m.extQueue { + if row.State == ExtensionRemove { + out = append(out, row) + } + } + return out +} + func (m wizardModel) updateReview(key string) (tea.Model, tea.Cmd) { switch key { case "left", "h": @@ -391,6 +583,8 @@ func (m wizardModel) updateReview(key string) (tea.Model, tea.Cmd) { m.confirmYes = false case "tab": m.confirmYes = !m.confirmYes + case "e": + return m, m.writeReport() case "q", "esc": return m, tea.Quit case "enter": @@ -429,9 +623,10 @@ func (m wizardModel) readNextLog() tea.Cmd { } } -// 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. +// loadCompatibility asks composer (dry run) whether the project can be +// upgraded to the target version and derives the extension queue from its +// verdict, so the user sees composer's own verdict before applying anything. +// It also resolves the target version's PHP requirement for the report. func (m wizardModel) loadCompatibility() tea.Cmd { composerJsonPath := m.opts.ComposerJSONPath targetVersion := m.targetVersion @@ -445,7 +640,75 @@ func (m wizardModel) loadCompatibility() tea.Cmd { if err != nil { return compatLoadedMsg{err: err} } - return compatLoadedMsg{report: report} + + installed, err := RequiredPluginVersions(composerJsonPath) + if err != nil { + return compatLoadedMsg{report: report, err: err} + } + + // Best-effort: the report simply omits the PHP requirement when + // packagist cannot be queried. + phpRequirement := "" + if releases, relErr := packagist.GetShopwarePackageVersions(ctx); relErr == nil { + phpRequirement = phpRequirementForVersion(releases, targetVersion) + } + + return compatLoadedMsg{ + report: report, + queue: BuildExtensionQueue(installed, report), + phpRequirement: phpRequirement, + } + } +} + +// phpRequirementForVersion returns the `require.php` constraint shopware/core +// declares for the given version, or "" when unknown. +func phpRequirementForVersion(releases []packagist.ComposerPackageVersion, target string) string { + normalized := strings.TrimPrefix(target, "v") + for _, release := range releases { + if strings.TrimPrefix(release.Version, "v") == normalized { + return release.Require["php"] + } + } + return "" +} + +// reportData assembles the exportable support report from the wizard state. +func (m wizardModel) reportData(composerJSON string) ReportData { + env := "" + if m.opts.Executor != nil { + env = m.opts.Executor.Type() + } + return ReportData{ + CurrentVersion: m.opts.CurrentVersion.String(), + TargetVersion: m.targetVersion, + Environment: env, + PHPConstraint: m.phpRequirement, + Preflight: m.preflight, + Extensions: m.extQueue, + ComposerJSON: composerJSON, + ComposerOutput: m.compatReport.Output, + } +} + +// writeReport exports the Markdown support report next to the project's +// composer.json. +func (m wizardModel) writeReport() tea.Cmd { + model := m + path := filepath.Join(m.opts.ProjectRoot, fmt.Sprintf("shopware-upgrade-report-%s.md", m.targetVersion)) + composerJSONPath := m.opts.ComposerJSONPath + + return func() tea.Msg { + raw, err := os.ReadFile(composerJSONPath) + if err != nil { + return reportWrittenMsg{err: err} + } + + content := BuildMarkdownReport(model.reportData(string(raw))) + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + return reportWrittenMsg{err: err} + } + return reportWrittenMsg{path: path} } } @@ -490,21 +753,39 @@ func (m wizardModel) runRemovePlugins() tea.Cmd { target := m.targetVersion idx := taskPlugins exec := m.opts.Executor + removals := m.plannedRemovals() + return func() tea.Msg { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) defer cancel() - result, err := ApplyRequire(ctx, exec, composerJSONPath, target) + result := &ResolveResult{} + + // Apply the removal decisions the user made in the extension queue + // before composer resolves the rest. + for _, row := range removals { + if err := removePluginFromComposer(composerJSONPath, row.Name); err != nil { + return taskCompleteMsg{task: idx, err: err} + } + result.Actions = append(result.Actions, PluginAction{ + Name: row.Name, + Removed: true, + Reason: "removed by user decision (no compatible release)", + }) + } + + applied, err := ApplyRequire(ctx, exec, composerJSONPath, target) if err != nil { return taskCompleteMsg{task: idx, err: err} } - if result == nil { - result = &ResolveResult{} + if applied != nil { + result.Actions = append(result.Actions, applied.Actions...) } + removed := len(result.Removed()) detail := "composer resolved all plugins" if removed > 0 { - detail = fmt.Sprintf("removed %d (composer could not resolve)", removed) + detail = fmt.Sprintf("removed %d plugin(s)", removed) } return taskCompleteMsg{task: idx, detail: detail, pluginActions: result} } @@ -615,373 +896,3 @@ func streamCmdOutput(cmd *exec.Cmd, ch chan<- string, useStdout bool) ([]string, } 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 index a2bda5b2..5b72105d 100644 --- a/internal/projectupgrade/wizard_test.go +++ b/internal/projectupgrade/wizard_test.go @@ -1,6 +1,8 @@ package projectupgrade import ( + "os" + "path/filepath" "testing" tea "charm.land/bubbletea/v2" @@ -34,21 +36,28 @@ func newTestModelWithVersions(t *testing.T, versions []string) wizardModel { CurrentVersion: current, UpdateVersions: versions, }, - phase: phaseWelcome, - confirmYes: true, - versionList: tui.NewSelectList("Select target version", "", opts, maxVisibleVersions), - tasks: defaultTasks(), + phase: phaseWelcome, + confirmYes: true, + versionList: tui.NewSelectList("Select target version", "", opts, maxVisibleVersions), + tasks: defaultTasks(), + markedRemove: map[string]bool{}, } return m } -func TestWizardWelcomeConfirmGoesToVersionSelect(t *testing.T) { +func keyPress(c rune) tea.KeyPressMsg { + return tea.KeyPressMsg{Code: c, Text: string(c)} +} + +func TestWizardWelcomeConfirmGoesToPreflight(t *testing.T) { t.Parallel() m := newTestModel(t) - updated, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + updated, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) wm := updated.(wizardModel) - assert.Equal(t, phaseSelectVersion, wm.phase) + assert.Equal(t, phasePreflight, wm.phase) + assert.True(t, wm.preflightLoading) + assert.NotNil(t, cmd, "entering preflight must schedule the checks") } func TestWizardWelcomeCancelQuits(t *testing.T) { @@ -63,6 +72,42 @@ func TestWizardWelcomeCancelQuits(t *testing.T) { assert.True(t, ok, "cancel should produce QuitMsg") } +func TestWizardPreflightBlocksUntilChecksPass(t *testing.T) { + t.Parallel() + + m := newTestModel(t) + m.phase = phasePreflight + m.preflightLoading = true + + updated, _ := m.Update(preflightDoneMsg{results: []PreflightResult{ + {Label: "Git working tree clean", Status: PreflightFailed, Explanation: "commit your changes"}, + }}) + wm := updated.(wizardModel) + assert.False(t, wm.preflightLoading) + + // Enter must not advance while a check fails. + updated, _ = wm.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + wm = updated.(wizardModel) + assert.Equal(t, phasePreflight, wm.phase) + + // The failure and its explanation are visible. + out := wm.viewContent() + assert.Contains(t, out, "commit your changes") + + // A recheck that passes unblocks the flow. + updated, _ = wm.Update(keyPress('r')) + wm = updated.(wizardModel) + assert.True(t, wm.preflightLoading, "r must rerun the checks") + + updated, _ = wm.Update(preflightDoneMsg{results: []PreflightResult{ + {Label: "Git working tree clean", Status: PreflightOK}, + }}) + wm = updated.(wizardModel) + updated, _ = wm.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + wm = updated.(wizardModel) + assert.Equal(t, phaseSelectVersion, wm.phase) +} + // Navigation/paging is owned and tested by tui.SelectList; here we only verify // the wizard forwards keys to it. func TestWizardSelectVersionForwardsNavigationToList(t *testing.T) { @@ -76,7 +121,7 @@ func TestWizardSelectVersionForwardsNavigationToList(t *testing.T) { assert.Equal(t, 1, wm.versionList.Cursor()) } -func TestWizardSelectVersionGoesToCompatCheck(t *testing.T) { +func TestWizardSelectVersionGoesToPrepare(t *testing.T) { t.Parallel() m := newTestModel(t) @@ -84,46 +129,188 @@ func TestWizardSelectVersionGoesToCompatCheck(t *testing.T) { updated, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) wm := updated.(wizardModel) - assert.Equal(t, phaseCompatCheck, wm.phase) + assert.Equal(t, phasePrepare, wm.phase) assert.True(t, wm.compatLoading) assert.Equal(t, "6.6.4.0", wm.targetVersion) } -func TestWizardCompatLoadedConflictSetsBlockerFlag(t *testing.T) { +func TestWizardSelectVersionShowsReleaseNotesLink(t *testing.T) { + t.Parallel() + + m := newTestModel(t) + m.phase = phaseSelectVersion + + out := m.viewContent() + assert.Contains(t, out, "releases/tag/v6.6.4.0") +} + +func TestWizardPrepareBlockedPreventsContinue(t *testing.T) { t.Parallel() m := newTestModel(t) - m.phase = phaseCompatCheck + m.phase = phasePrepare m.compatLoading = true updated, _ := m.Update(compatLoadedMsg{ - report: CompatReport{ - OK: false, - Output: []string{"Your requirements could not be resolved"}, - BlockingPlugins: []string{"vendor/incompat"}, + report: CompatReport{OK: false, BlockingPlugins: []string{"swag/blocked"}}, + queue: []ExtensionRow{ + {Name: "swag/blocked", Current: "1.0.0", State: ExtensionBlocked, Result: "no compatible release"}, + {Name: "swag/ok", Current: "1.0.0", Target: "1.0.0", State: ExtensionOK}, }, }) 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") + assert.True(t, wm.prepareBlocked()) + + out := wm.viewContent() + assert.Contains(t, out, "BLOCKED") + assert.Contains(t, out, "1/2 extensions need fixes") + + // c must not advance while blocked. + updated, _ = wm.Update(keyPress('c')) + wm = updated.(wizardModel) + assert.Equal(t, phasePrepare, wm.phase) } -func TestWizardCompatLoadedResolvableIsNotBlocker(t *testing.T) { +func TestWizardPrepareReadyContinuesToReview(t *testing.T) { t.Parallel() m := newTestModel(t) - m.phase = phaseCompatCheck - m.compatLoading = true + m.phase = phasePrepare + m.targetVersion = "6.6.4.0" + + updated, _ := m.Update(compatLoadedMsg{ + report: CompatReport{OK: true}, + queue: []ExtensionRow{ + {Name: "swag/ok", Current: "1.0.0", Target: "1.0.0", State: ExtensionOK}, + }, + }) + wm := updated.(wizardModel) + + out := wm.viewContent() + assert.Contains(t, out, "READY") + + updated, _ = wm.Update(keyPress('c')) + wm = updated.(wizardModel) + assert.Equal(t, phaseReview, wm.phase) +} + +func TestWizardPrepareOverlayShowsDetailsAndMarksRemoval(t *testing.T) { + t.Parallel() + + m := newTestModel(t) + m.phase = phasePrepare + m.targetVersion = "6.6.4.0" + m.extQueue = []ExtensionRow{ + {Name: "swag/blocked", Current: "1.0.0", State: ExtensionBlocked, Result: "no compatible release"}, + } + + // Enter opens the detail overlay. + updated, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + wm := updated.(wizardModel) + assert.True(t, wm.overlayOpen) + + out := wm.viewContent() + assert.Contains(t, out, "swag/blocked") + assert.Contains(t, out, "User action") + + // d marks the blocked extension for removal, which unblocks the queue. + updated, _ = wm.Update(keyPress('d')) + wm = updated.(wizardModel) + assert.Equal(t, ExtensionRemove, wm.extQueue[0].State) + assert.False(t, wm.prepareBlocked()) + assert.True(t, wm.markedRemove["swag/blocked"]) + + // d again undoes the decision. + updated, _ = wm.Update(keyPress('d')) + wm = updated.(wizardModel) + assert.Equal(t, ExtensionBlocked, wm.extQueue[0].State) + assert.False(t, wm.markedRemove["swag/blocked"]) + + // esc closes the overlay. + updated, _ = wm.Update(tea.KeyPressMsg{Code: tea.KeyEscape}) + wm = updated.(wizardModel) + assert.False(t, wm.overlayOpen) +} +func TestWizardRemovalMarkSurvivesRecheckWhileBlocked(t *testing.T) { + t.Parallel() + + m := newTestModel(t) + m.phase = phasePrepare + m.markedRemove = map[string]bool{"swag/blocked": true} + + // Recheck still reports the extension as blocked: the mark is re-applied. updated, _ := m.Update(compatLoadedMsg{ + report: CompatReport{OK: false, BlockingPlugins: []string{"swag/blocked"}}, + queue: []ExtensionRow{ + {Name: "swag/blocked", Current: "1.0.0", State: ExtensionBlocked, Result: "no compatible release"}, + }, + }) + wm := updated.(wizardModel) + assert.Equal(t, ExtensionRemove, wm.extQueue[0].State) + + // A later recheck finds a compatible release: the stale mark is dropped. + updated, _ = wm.Update(compatLoadedMsg{ report: CompatReport{OK: true}, + queue: []ExtensionRow{ + {Name: "swag/blocked", Current: "1.0.0", Target: "2.0.0", State: ExtensionUpdate, Result: "will be updated"}, + }, }) + wm = updated.(wizardModel) + assert.Equal(t, ExtensionUpdate, wm.extQueue[0].State) + assert.False(t, wm.markedRemove["swag/blocked"]) +} + +func TestWizardReviewShowsPlannedRemovals(t *testing.T) { + t.Parallel() + + m := newTestModel(t) + m.phase = phaseReview + m.targetVersion = "6.6.4.0" + m.extQueue = []ExtensionRow{ + {Name: "swag/drop-me", Current: "1.0.0", State: ExtensionRemove, Result: "will be removed during the upgrade"}, + } + + out := m.viewContent() + assert.Contains(t, out, "swag/drop-me") + assert.Contains(t, out, "removed from composer.json") +} + +func TestWizardReportWrittenShownInView(t *testing.T) { + t.Parallel() + + m := newTestModel(t) + m.phase = phaseReview + m.targetVersion = "6.6.4.0" + + updated, _ := m.Update(reportWrittenMsg{path: "/tmp/example/shopware-upgrade-report-6.6.4.0.md"}) 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") + assert.Contains(t, wm.viewContent(), "shopware-upgrade-report-6.6.4.0.md") +} + +func TestWizardWriteReportCreatesFile(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + m := newTestModel(t) + m.opts.ProjectRoot = dir + m.opts.ComposerJSONPath = filepath.Join(dir, "composer.json") + m.targetVersion = "6.6.4.0" + m.extQueue = []ExtensionRow{ + {Name: "swag/ok", Current: "1.0.0", Target: "1.0.0", State: ExtensionOK, Result: "compatible as installed"}, + } + require.NoError(t, os.WriteFile(m.opts.ComposerJSONPath, []byte(`{"require":{}}`), 0o644)) + + msg := m.writeReport()() + written, ok := msg.(reportWrittenMsg) + require.True(t, ok) + require.NoError(t, written.err) + + content, err := os.ReadFile(written.path) + require.NoError(t, err) + assert.Contains(t, string(content), "# Shopware Upgrade Report") + assert.Contains(t, string(content), "swag/ok") } func TestUpgradeTaskOrderRunsDeploymentHelperLast(t *testing.T) { @@ -167,6 +354,20 @@ func TestWizardTaskCompleteErrorEndsRun(t *testing.T) { assert.Contains(t, out, "Your requirements could not be resolved", "failed step output should be shown on the done screen") } +func TestWizardDoneSuccessShowsPostUpgradeChecklist(t *testing.T) { + t.Parallel() + + m := newTestModel(t) + m.phase = phaseDone + m.finished = true + m.targetVersion = "6.6.4.0" + + out := m.viewContent() + assert.Contains(t, out, "Post-upgrade validation checklist") + assert.Contains(t, out, "storefront") + assert.Contains(t, out, "test suite") +} + func TestWizardLogLineMsgAppendsAndTrims(t *testing.T) { t.Parallel() @@ -199,16 +400,15 @@ func TestWizardRendersAllPhases(t *testing.T) { phases := []phase{ phaseWelcome, + phasePreflight, phaseSelectVersion, - phaseCompatCheck, - phaseCompatResult, + phasePrepare, phaseReview, phaseRunning, phaseDone, } for _, p := range phases { - p := p t.Run(t.Name(), func(t *testing.T) { t.Parallel() m := newTestModel(t) @@ -216,6 +416,9 @@ func TestWizardRendersAllPhases(t *testing.T) { m.targetVersion = "6.6.4.0" out := m.viewContent() assert.NotEmpty(t, out, "phase %d should render content", p) + + header := m.headerBar() + assert.Contains(t, header, "Shopware 6.5.8.0", "header shows the current project context") }) } } diff --git a/internal/projectupgrade/wizard_views.go b/internal/projectupgrade/wizard_views.go new file mode 100644 index 00000000..35b40677 --- /dev/null +++ b/internal/projectupgrade/wizard_views.go @@ -0,0 +1,770 @@ +package projectupgrade + +import ( + "fmt" + "strings" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "github.com/shopware/shopware-cli/internal/tui" +) + +// upgradeDocsURL is the doc hint shown in the wizard header. +const upgradeDocsURL = "https://developer.shopware.com/docs/guides/installation/requirements.html" + +func (m wizardModel) View() tea.View { + content := lipgloss.JoinVertical(lipgloss.Left, m.headerBar(), "", 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 +} + +// headerBar renders the consistent wizard header: current project context on +// the left, the environment indicator in the center, and the doc/source hint +// plus the selected target version on the right. +func (m wizardModel) headerBar() string { + w := m.headerWidth() + + left := tui.BoldText.Render("Shopware " + m.opts.CurrentVersion.String()) + + env := "local" + if m.opts.Executor != nil { + env = m.opts.Executor.Type() + } + center := tui.TextBadge(env) + + right := tui.StyledLink(upgradeDocsURL, "Docs", tui.LinkStyle) + if m.targetVersion != "" { + right = lipgloss.NewStyle().Foreground(tui.SuccessColor).Bold(true).Render("→ "+m.targetVersion) + tui.DimStyle.Render(" · ") + right + } + + third := w / 3 + leftCell := lipgloss.NewStyle().Width(third).Render(left) + centerCell := lipgloss.PlaceHorizontal(w-2*third, lipgloss.Center, center) + rightCell := lipgloss.PlaceHorizontal(third, lipgloss.Right, right) + + return lipgloss.JoinHorizontal(lipgloss.Top, leftCell, centerCell, rightCell) +} + +// headerWidth is the width the header aligns to: the phase card, or the full +// terminal in the running phase where the log spans the whole screen. +func (m wizardModel) headerWidth() int { + if m.phase == phaseRunning && m.width > tui.PhaseCardWidth { + return m.width + } + return tui.PhaseCardWidth +} + +func (m wizardModel) viewContent() string { + switch m.phase { + case phaseWelcome: + return m.viewWelcome() + case phasePreflight: + return m.viewPreflight() + case phaseSelectVersion: + return m.viewSelectVersion() + case phasePrepare: + return m.viewPrepare() + case phaseReview: + return m.viewReview() + case phaseRunning: + return m.viewRunning() + case phaseDone: + return m.viewDone() + } + return "" +} + +func (m wizardModel) totalSteps() int { + return 5 // Preflight, Select version, Prepare, Review, Run +} + +func (m wizardModel) stepNum(p phase) int { + switch p { + case phasePreflight: + return 1 + case phaseSelectVersion: + return 2 + case phasePrepare: + return 3 + case phaseReview: + return 4 + case phaseRunning: + return 5 + 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("The wizard makes upgrade risk visible before downtime:")) + b.WriteString("\n\n") + for _, line := range []string{ + "Check that this project can safely attempt the upgrade", + "Show which extensions are compatible, need review, or block the upgrade", + "Let composer resolve the target version and update your project", + "Run vendor/bin/shopware-deployment-helper for migrations and theme compile", + "Export a Markdown report you can share with support or extension vendors", + } { + b.WriteString(tui.DimStyle.Render(" • ")) + b.WriteString(tui.LabelStyle.Render(line)) + b.WriteString("\n") + } + b.WriteString("\n") + b.WriteString(tui.DimStyle.Render("It will not make incompatible extensions compatible, rewrite")) + b.WriteString("\n") + b.WriteString(tui.DimStyle.Render("custom plugin code, resolve missing vendor releases, or guarantee")) + b.WriteString("\n") + b.WriteString(tui.DimStyle.Render("a production upgrade succeeds without testing.")) + b.WriteString("\n") + b.WriteString(tui.SectionDivider(tui.PhaseCardWidth - 8)) + 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) viewPreflight() string { + var b strings.Builder + b.WriteString(stepBadge(m.stepNum(phasePreflight), m.totalSteps())) + b.WriteString("\n\n") + b.WriteString(tui.TitleStyle.Render("Preflight checks")) + b.WriteString("\n") + b.WriteString(tui.DimStyle.Render("The project must pass these checks before the upgrade flow starts.")) + b.WriteString("\n\n") + + if m.preflightLoading { + b.WriteString(m.spinner.View() + " " + tui.DimStyle.Render("Running checks…")) + b.WriteString("\n\n") + b.WriteString(m.footer(tui.Shortcut{Key: "ctrl+c", Label: "Exit"})) + return tui.RenderPhaseCard(b.String()) + } + + for _, r := range m.preflight { + b.WriteString(renderPreflightLine(r)) + } + + if PreflightBlocked(m.preflight) { + b.WriteString("\n") + b.WriteString(lipgloss.NewStyle().Foreground(tui.ErrorColor).Bold(true).Render("Fix the failing checks, then recheck.")) + b.WriteString("\n") + for _, r := range m.preflight { + if r.Status != PreflightFailed || r.Explanation == "" { + continue + } + b.WriteString(lipgloss.NewStyle().Foreground(tui.ErrorColor).Render(" ✗ " + r.Label + ": ")) + b.WriteString(tui.DimStyle.Render(r.Explanation)) + b.WriteString("\n") + } + b.WriteString("\n") + b.WriteString(m.footer( + tui.Shortcut{Key: "r", Label: "Recheck"}, + tui.Shortcut{Key: "q", Label: "Exit"}, + )) + } else { + b.WriteString("\n") + b.WriteString(tui.GreenText.Render("All checks passed.")) + b.WriteString("\n\n") + b.WriteString(m.footer( + tui.Shortcut{Key: "enter", Label: "Continue"}, + tui.Shortcut{Key: "r", Label: "Recheck"}, + tui.Shortcut{Key: "q", Label: "Exit"}, + )) + } + + return tui.RenderPhaseCard(b.String()) +} + +func renderPreflightLine(r PreflightResult) string { + var icon string + switch r.Status { + case PreflightOK: + icon = tui.Checkmark + case PreflightFailed: + icon = lipgloss.NewStyle().Foreground(tui.ErrorColor).Bold(true).Render("✗") + case PreflightSkipped: + icon = tui.DimStyle.Render("·") + default: + icon = tui.DimStyle.Render("○") + } + + line := " " + icon + " " + tui.LabelStyle.Render(r.Label) + if r.Detail != "" { + line += " " + tui.DimStyle.Render("("+r.Detail+")") + } + return line + "\n" +} + +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") + + if selected, ok := m.versionList.Selected(); ok { + b.WriteString(tui.DimStyle.Render("Release notes: ")) + b.WriteString(tui.StyledLink(ReleaseNotesURL(selected.Label), ReleaseNotesURL(selected.Label), tui.LinkStyle)) + 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) viewPrepare() string { + if m.compatLoading { + return m.viewPrepareLoading() + } + if m.overlayOpen { + return m.viewExtensionOverlay() + } + + var b strings.Builder + b.WriteString(stepBadge(m.stepNum(phasePrepare), m.totalSteps())) + b.WriteString("\n\n") + + // Overall preparation state. + blockers := CountBlockers(m.extQueue) + switch { + case blockers > 0: + b.WriteString(tui.StatusBadge("BLOCKED", tui.ErrorColor)) + b.WriteString(" ") + b.WriteString(lipgloss.NewStyle().Foreground(tui.ErrorColor).Render(fmt.Sprintf("%d/%d extensions need fixes before the upgrade can continue.", blockers, len(m.extQueue)))) + b.WriteString("\n") + b.WriteString(tui.DimStyle.Render(" TODO: Update or remove blocked extensions, then recheck (r).")) + b.WriteString("\n\n") + case m.compatErr != nil: + b.WriteString(tui.StatusBadge("REVIEW", tui.WarnColor)) + b.WriteString(" ") + b.WriteString(lipgloss.NewStyle().Foreground(tui.WarnColor).Render("Compatibility check failed: " + m.compatErr.Error())) + b.WriteString("\n") + b.WriteString(tui.DimStyle.Render(" You may still continue; the wizard cannot guarantee extensions will install.")) + b.WriteString("\n\n") + default: + b.WriteString(tui.StatusBadge("READY", tui.SuccessColor)) + b.WriteString(" ") + b.WriteString(tui.GreenText.Render("All extensions allow the upgrade to continue.")) + b.WriteString("\n\n") + } + + // System preparation checks. + b.WriteString(renderSystemCheck(m.preflightStatusByLabel("Web environment running"), "Web services running")) + b.WriteString(renderSystemCheck(m.preflightStatusByLabel("Packagist reachable"), "Packagist reachable")) + b.WriteString(renderSystemCheck(m.compatErr == nil && m.compatReport.OK, "Composer can resolve this upgrade")) + b.WriteString(renderSystemCheck(blockers == 0, "Extension compatibility")) + + if len(m.extQueue) > 0 { + b.WriteString("\n") + b.WriteString(m.renderExtensionTable()) + } else { + b.WriteString("\n") + b.WriteString(tui.DimStyle.Render("No extensions installed.")) + b.WriteString("\n") + } + + b.WriteString("\n") + shortcuts := []tui.Shortcut{ + {Key: "↑/↓", Label: "Rows"}, + {Key: "enter", Label: "Details"}, + {Key: "r", Label: "Recheck"}, + } + if blockers == 0 { + shortcuts = append(shortcuts, tui.Shortcut{Key: "c", Label: "Continue"}) + } + shortcuts = append(shortcuts, tui.Shortcut{Key: "q", Label: "Exit"}) + b.WriteString(m.footer(shortcuts...)) + return tui.RenderPhaseCard(b.String()) +} + +func (m wizardModel) viewPrepareLoading() string { + var b strings.Builder + b.WriteString(stepBadge(m.stepNum(phasePrepare), m.totalSteps())) + b.WriteString("\n\n") + b.WriteString(tui.TitleStyle.Render("Preparing upgrade")) + 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()) +} + +// preflightStatusByLabel reports whether the named preflight check passed +// (or was skipped/absent, which does not block preparation). +func (m wizardModel) preflightStatusByLabel(label string) bool { + for _, r := range m.preflight { + if r.Label == label { + return r.Status != PreflightFailed + } + } + return true +} + +func renderSystemCheck(ok bool, label string) string { + icon := tui.Checkmark + if !ok { + icon = lipgloss.NewStyle().Foreground(tui.ErrorColor).Bold(true).Render("✗") + } + return " " + icon + " " + tui.LabelStyle.Render(label) + "\n" +} + +// extensionStateBadge renders the semantic state cell used in the extension +// queue and detail overlays. +func extensionStateBadge(state ExtensionState) string { + switch state { + case ExtensionBlocked: + return lipgloss.NewStyle().Foreground(tui.ErrorColor).Bold(true).Render("BLOCKED") + case ExtensionDeprecated: + return lipgloss.NewStyle().Foreground(tui.WarnColor).Bold(true).Render("REPLACE") + case ExtensionUpdate: + return lipgloss.NewStyle().Foreground(tui.WarnColor).Render("UPDATE") + case ExtensionRemove: + return tui.DimStyle.Render("REMOVE") + case ExtensionOK: + return lipgloss.NewStyle().Foreground(tui.SuccessColor).Render("OK") + } + return "" +} + +func (m wizardModel) renderExtensionTable() string { + var b strings.Builder + + // The card interior is PhaseCardWidth minus borders (2) and padding (6); + // each row additionally spends 2 columns on the cursor. + innerW := tui.PhaseCardWidth - 10 + stateW, nameW, versionW := 9, 24, 19 + resultW := innerW - stateW - nameW - versionW + + header := lipgloss.NewStyle().Bold(true).Render( + padCell("State", stateW) + padCell("Name", nameW) + padCell("Current → Target", versionW) + padCell("Result", resultW), + ) + b.WriteString(" " + header + "\n") + b.WriteString(" " + tui.TableDivider(innerW) + "\n") + + start, end := 0, len(m.extQueue) + windowed := len(m.extQueue) > maxVisibleExtensions + if windowed { + start = m.extCursor - maxVisibleExtensions/2 + if start < 0 { + start = 0 + } + if start > len(m.extQueue)-maxVisibleExtensions { + start = len(m.extQueue) - maxVisibleExtensions + } + end = start + maxVisibleExtensions + } + + for i := start; i < end; i++ { + row := m.extQueue[i] + versions := row.Current + if row.Target != "" && row.Target != row.Current { + versions += " → " + row.Target + } + if row.Target == "" { + versions += " → —" + } + + cursor := " " + if i == m.extCursor { + cursor = lipgloss.NewStyle().Foreground(tui.BrandColor).Render("● ") + } + + nameStyle := tui.LabelStyle + if i == m.extCursor { + nameStyle = nameStyle.Bold(true) + } + + b.WriteString(cursor) + b.WriteString(padCell(extensionStateBadge(row.State), stateW)) + b.WriteString(nameStyle.Render(padCellPlain(row.Name, nameW))) + b.WriteString(tui.DimStyle.Render(padCellPlain(versions, versionW))) + b.WriteString(tui.DimStyle.Render(padCellPlain(row.Result, resultW))) + b.WriteString("\n") + } + + if windowed { + b.WriteString(" " + tui.DimStyle.Render(fmt.Sprintf("Showing %d–%d of %d (↑/↓ to scroll)", start+1, end, len(m.extQueue)))) + b.WriteString("\n") + } + + return b.String() +} + +// padCell pads styled content to a fixed visual width. +func padCell(content string, width int) string { + gap := width - lipgloss.Width(content) + if gap < 1 { + gap = 1 + } + return content + strings.Repeat(" ", gap) +} + +// padCellPlain truncates and pads unstyled text to a fixed width. +func padCellPlain(content string, width int) string { + return padCell(tui.TruncateToWidth(content, width-1), width) +} + +func (m wizardModel) viewExtensionOverlay() string { + if m.extCursor >= len(m.extQueue) { + return "" + } + row := m.extQueue[m.extCursor] + + var b strings.Builder + b.WriteString(tui.TextBadge("Extension details")) + b.WriteString(" ") + b.WriteString(extensionStateBadge(row.State)) + b.WriteString("\n\n") + b.WriteString(tui.TitleStyle.Render(row.Name)) + b.WriteString("\n\n") + + b.WriteString(tui.KVRow("Installed", tui.BoldText.Render(row.Current))) + target := row.Target + if target == "" { + target = "no compatible release" + } + b.WriteString(tui.KVRow("Target", tui.LabelStyle.Render(target))) + b.WriteString(tui.KVRow("Result", tui.LabelStyle.Render(row.Result))) + if row.Replacement != "" { + b.WriteString(tui.KVRow("Replacement", tui.YellowText.Render(row.Replacement))) + } + b.WriteString(tui.SectionDivider(tui.PhaseCardWidth - 8)) + + b.WriteString(tui.BlueText.Bold(true).Render("User action")) + b.WriteString("\n") + for _, line := range extensionGuidance(row) { + b.WriteString(tui.DimStyle.Render(" " + line)) + b.WriteString("\n") + } + b.WriteString("\n") + + shortcuts := []tui.Shortcut{{Key: "esc", Label: "Close"}, {Key: "r", Label: "Recheck"}} + if row.State == ExtensionBlocked { + shortcuts = append(shortcuts, tui.Shortcut{Key: "d", Label: "Remove during upgrade"}) + } + if row.State == ExtensionRemove { + shortcuts = append(shortcuts, tui.Shortcut{Key: "d", Label: "Keep (undo remove)"}) + } + b.WriteString(m.footer(shortcuts...)) + return tui.RenderPhaseCard(b.String()) +} + +// extensionGuidance is the contextual help shown in the detail overlay, +// mirroring the issue's per-state detail panels. +func extensionGuidance(row ExtensionRow) []string { + switch row.State { + case ExtensionOK: + return []string{"No action required. The installed release is compatible with the target version."} + case ExtensionUpdate: + return []string{ + "Composer will update this extension during the upgrade.", + "Review the extension changelog before continuing, then test the affected features after the upgrade.", + } + case ExtensionDeprecated: + guidance := []string{"This extension is abandoned by its vendor and will not receive further updates."} + if row.Replacement != "" { + guidance = append(guidance, "Replace it with "+row.Replacement+" instead of updating it.") + } else { + guidance = append(guidance, "No replacement was suggested. Plan to remove or replace it.") + } + return guidance + case ExtensionBlocked: + return []string{ + "No release of this extension is compatible with the target version.", + "Update the extension once the vendor publishes a compatible release, then recheck.", + "Or press d to remove it from composer.json during the upgrade — its features will be gone until you re-require it.", + } + case ExtensionRemove: + return []string{ + "You chose to remove this extension from composer.json during the upgrade.", + "Re-require it once the vendor publishes a compatible release. Press d to keep it instead.", + } + } + return nil +} + +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 upgrade plan")) + 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()))) + } + if m.phpRequirement != "" { + b.WriteString(tui.KVRow("PHP requirement", tui.LabelStyle.Render(m.phpRequirement))) + } + b.WriteString(tui.SectionDivider(tui.PhaseCardWidth - 8)) + 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") + } + + if removals := m.plannedRemovals(); len(removals) > 0 { + b.WriteString("\n") + b.WriteString(lipgloss.NewStyle().Foreground(tui.WarnColor).Render("These extensions will be removed from composer.json:")) + b.WriteString("\n") + for _, row := range removals { + b.WriteString(tui.DimStyle.Render(" • ")) + b.WriteString(tui.LabelStyle.Render(row.Name)) + b.WriteString("\n") + } + } + + b.WriteString(m.reportStatusLine()) + 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: "e", Label: "Report"}, + tui.Shortcut{Key: "ctrl+c", Label: "Exit"}, + )) + return tui.RenderPhaseCard(b.String()) +} + +// reportStatusLine renders the outcome of the last report export, if any. +func (m wizardModel) reportStatusLine() string { + if m.reportErr != nil { + return "\n" + lipgloss.NewStyle().Foreground(tui.ErrorColor).Render("Report export failed: "+m.reportErr.Error()) + "\n" + } + if m.reportPath != "" { + return "\n" + tui.GreenText.Render("Report written to "+m.reportPath) + "\n" + } + return "" +} + +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") + } + + // The live log spans the full terminal width so long composer lines stay + // readable, unlike the fixed-width card above it. + logWidth := m.width + if logWidth <= 0 { + logWidth = tui.PhaseCardWidth + } + + var log strings.Builder + if len(m.logLines) > 0 { + log.WriteString(lipgloss.NewStyle().Foreground(tui.BorderColor).Render(strings.Repeat("─", logWidth))) + log.WriteString("\n") + for _, line := range m.logLines { + log.WriteString(tui.DimStyle.Render(truncate(line, logWidth))) + log.WriteString("\n") + } + } + + b.WriteString("\n") + b.WriteString(m.footer(tui.Shortcut{Key: "ctrl+c", Label: "Cancel"})) + + card := tui.RenderPhaseCard(b.String()) + if log.Len() == 0 { + return card + } + return lipgloss.JoinVertical(lipgloss.Left, card, "", strings.TrimRight(log.String(), "\n")) +} + +// postUpgradeChecklist is shown after a successful upgrade so users know what +// to validate before going to production. +var postUpgradeChecklist = []string{ + "Open the storefront and click through key pages", + "Log in to the administration", + "Verify your theme renders correctly (recompile if needed)", + "Run a test order through checkout and payment", + "Exercise the critical features of your extensions", + "Check var/log/ for new errors", + "Run your test suite before deploying to production", +} + +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.BoldText.Render("Post-upgrade validation checklist:")) + b.WriteString("\n") + for _, item := range postUpgradeChecklist { + b.WriteString(tui.DimStyle.Render(" ☐ ")) + b.WriteString(tui.LabelStyle.Render(item)) + b.WriteString("\n") + } + + if m.pluginActions != nil { + removed := m.pluginActions.Removed() + + if len(removed) > 0 { + b.WriteString("\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") + } + } + } + + b.WriteString(m.reportStatusLine()) + b.WriteString("\n") + b.WriteString(tui.SectionDivider(tui.PhaseCardWidth - 8)) + 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: "e", Label: "Export report"}, + 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:] +} From 132df3f61001a1b57ccc85d6c0dfcc70822d14fd Mon Sep 17 00:00:00 2001 From: Soner Sayakci Date: Tue, 7 Jul 2026 14:21:12 +0200 Subject: [PATCH 14/14] style(projectupgrade): align wizard with devtui's newer UI conventions Adopt the wizard UI polish that landed on next for the devtui migration wizard: drop the 'Step X/Y' badges in favor of clear per-panel titles (#1153) and keep the terminal window title in sync with the current step or view (#1159). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KFmjkiNERLCC1fE45wS7cj --- internal/projectupgrade/wizard_test.go | 24 +++++++++ internal/projectupgrade/wizard_views.go | 72 +++++++++++-------------- 2 files changed, 54 insertions(+), 42 deletions(-) diff --git a/internal/projectupgrade/wizard_test.go b/internal/projectupgrade/wizard_test.go index 5b72105d..33b94c5f 100644 --- a/internal/projectupgrade/wizard_test.go +++ b/internal/projectupgrade/wizard_test.go @@ -395,6 +395,30 @@ func (e testError) Error() string { return string(e) } func assertErr(s string) error { return testError(s) } +func TestWizardWindowTitleFollowsStep(t *testing.T) { + t.Parallel() + + m := newTestModel(t) + + cases := map[phase]string{ + phaseWelcome: "[example] · Upgrade", + phasePreflight: "[example] · Preflight checks", + phaseSelectVersion: "[example] · Select target version", + phasePrepare: "[example] · Prepare upgrade", + phaseReview: "[example] · Review upgrade plan", + phaseRunning: "[example] · Upgrading...", + phaseDone: "[example] · Upgrade complete", + } + for p, want := range cases { + m.phase = p + assert.Equal(t, want, m.windowTitle()) + } + + m.phase = phaseDone + m.finalErr = assertErr("boom") + assert.Equal(t, "[example] · Upgrade failed", m.windowTitle()) +} + func TestWizardRendersAllPhases(t *testing.T) { t.Parallel() diff --git a/internal/projectupgrade/wizard_views.go b/internal/projectupgrade/wizard_views.go index 35b40677..17a333ec 100644 --- a/internal/projectupgrade/wizard_views.go +++ b/internal/projectupgrade/wizard_views.go @@ -2,6 +2,7 @@ package projectupgrade import ( "fmt" + "path/filepath" "strings" tea "charm.land/bubbletea/v2" @@ -20,9 +21,36 @@ func (m wizardModel) View() tea.View { } v := tea.NewView(content) v.AltScreen = true + v.WindowTitle = m.windowTitle() return v } +// windowTitle mirrors devtui's convention of keeping the terminal window +// title in sync with the current step or view. +func (m wizardModel) windowTitle() string { + dir := "[" + filepath.Base(m.opts.ProjectRoot) + "] · " + switch m.phase { + case phaseWelcome: + return dir + "Upgrade" + case phasePreflight: + return dir + "Preflight checks" + case phaseSelectVersion: + return dir + "Select target version" + case phasePrepare: + return dir + "Prepare upgrade" + case phaseReview: + return dir + "Review upgrade plan" + case phaseRunning: + return dir + "Upgrading..." + case phaseDone: + if m.finalErr != nil { + return dir + "Upgrade failed" + } + return dir + "Upgrade complete" + } + return dir + "Upgrade" +} + // headerBar renders the consistent wizard header: current project context on // the left, the environment indicator in the center, and the doc/source hint // plus the selected target version on the right. @@ -79,28 +107,6 @@ func (m wizardModel) viewContent() string { return "" } -func (m wizardModel) totalSteps() int { - return 5 // Preflight, Select version, Prepare, Review, Run -} - -func (m wizardModel) stepNum(p phase) int { - switch p { - case phasePreflight: - return 1 - case phaseSelectVersion: - return 2 - case phasePrepare: - return 3 - case phaseReview: - return 4 - case phaseRunning: - return 5 - case phaseWelcome, phaseDone: - return 0 - } - return 0 -} - func (m wizardModel) viewWelcome() string { var b strings.Builder b.WriteString(tui.TextBadge("Upgrade")) @@ -143,8 +149,6 @@ func (m wizardModel) viewWelcome() string { func (m wizardModel) viewPreflight() string { var b strings.Builder - b.WriteString(stepBadge(m.stepNum(phasePreflight), m.totalSteps())) - b.WriteString("\n\n") b.WriteString(tui.TitleStyle.Render("Preflight checks")) b.WriteString("\n") b.WriteString(tui.DimStyle.Render("The project must pass these checks before the upgrade flow starts.")) @@ -214,9 +218,6 @@ func renderPreflightLine(r PreflightResult) 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") @@ -243,7 +244,7 @@ func (m wizardModel) viewPrepare() string { } var b strings.Builder - b.WriteString(stepBadge(m.stepNum(phasePrepare), m.totalSteps())) + b.WriteString(tui.TitleStyle.Render("Prepare upgrade")) b.WriteString("\n\n") // Overall preparation state. @@ -301,9 +302,7 @@ func (m wizardModel) viewPrepare() string { func (m wizardModel) viewPrepareLoading() string { var b strings.Builder - b.WriteString(stepBadge(m.stepNum(phasePrepare), m.totalSteps())) - b.WriteString("\n\n") - b.WriteString(tui.TitleStyle.Render("Preparing upgrade")) + b.WriteString(tui.TitleStyle.Render("Prepare upgrade")) b.WriteString("\n") b.WriteString(tui.DimStyle.Render(fmt.Sprintf("Asking composer to resolve %s…", m.targetVersion))) b.WriteString("\n\n") @@ -509,8 +508,6 @@ func extensionGuidance(row ExtensionRow) []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 upgrade plan")) b.WriteString("\n") b.WriteString(tui.DimStyle.Render("Confirm to apply the following changes.")) @@ -571,8 +568,6 @@ func (m wizardModel) reportStatusLine() 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.")) @@ -728,13 +723,6 @@ 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)