diff --git a/cmd/project/project_upgrade.go b/cmd/project/project_upgrade.go new file mode 100644 index 00000000..1d611cac --- /dev/null +++ b/cmd/project/project_upgrade.go @@ -0,0 +1,385 @@ +package project + +import ( + "context" + "errors" + "fmt" + "os" + "path" + "strconv" + "strings" + "time" + + "charm.land/huh/v2" + "charm.land/lipgloss/v2" + "charm.land/lipgloss/v2/table" + "github.com/shyim/go-version" + "github.com/spf13/cobra" + + "github.com/shopware/shopware-cli/internal/executor" + "github.com/shopware/shopware-cli/internal/extension" + "github.com/shopware/shopware-cli/internal/flexmigrator" + "github.com/shopware/shopware-cli/internal/git" + "github.com/shopware/shopware-cli/internal/projectupgrade" + "github.com/shopware/shopware-cli/internal/system" + "github.com/shopware/shopware-cli/internal/tracking" + "github.com/shopware/shopware-cli/internal/tui" + "github.com/shopware/shopware-cli/logging" +) + +var projectUpgradeCmd = &cobra.Command{ + Use: "upgrade", + Short: "Upgrade the Shopware version of this project", + Long: `Upgrade the Shopware project to a newer version. + +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) + + projectRoot, err := findClosestShopwareProject() + if err != nil { + return err + } + + composerJsonPath := path.Join(projectRoot, "composer.json") + composerLockPath := path.Join(projectRoot, "composer.lock") + + if _, err := os.Stat(composerLockPath); err != nil { + return fmt.Errorf("composer.lock not found in %s. Run composer install first", projectRoot) + } + + currentVersion, err := projectupgrade.CurrentShopwareVersion(projectRoot) + if err != nil { + return fmt.Errorf("failed to determine current Shopware version: %w", err) + } + + log.Infof("Current Shopware version: %s", currentVersion.String()) + + allowDirty, _ := cmd.Flags().GetBool("allow-dirty") + allowNonComposer, _ := cmd.Flags().GetBool("allow-non-composer") + + allVersions, err := extension.GetShopwareVersions(ctx) + if err != nil { + return fmt.Errorf("failed to fetch available Shopware versions: %w", err) + } + + updateVersions := projectupgrade.FilterUpdateVersions(currentVersion, allVersions) + if len(updateVersions) == 0 { + fmt.Println("You are on the latest version of Shopware") + return nil + } + + cmdExecutor, err := resolveExecutor(cmd, projectRoot) + if err != nil { + return err + } + + // Non-interactive: keep the headless flow so CI runs stay unchanged. + // 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) + } + + // Interactive: hand off to the devtui-styled wizard. + result, err := projectupgrade.RunWizard(projectupgrade.WizardOptions{ + ProjectRoot: projectRoot, + ComposerJSONPath: composerJsonPath, + CurrentVersion: currentVersion, + UpdateVersions: updateVersions, + Executor: cmdExecutor, + AllowDirty: allowDirty, + AllowNonComposer: allowNonComposer, + }) + + status := "ok" + switch { + case errors.Is(err, projectupgrade.ErrCancelled): + status = "cancelled" + case err != nil: + status = "failed" + case !result.Success: + status = "failed" + } + + trackUpgrade(ctx, currentVersion.String(), result.TargetVersion, status) + + // 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 + } + + // The wizard runs in the alt-screen, so its live log is gone once it + // exits. Replay the full output of the failed task to the terminal so + // the user keeps the complete error in their scrollback. + if len(result.FailureLog) > 0 { + out := cmd.ErrOrStderr() + _, _ = fmt.Fprintln(out, "\nUpgrade failed. Full output of the failed step:") + for _, line := range result.FailureLog { + _, _ = fmt.Fprintln(out, line) + } + } + + return err + }, +} + +func runUpgradeHeadless(cmd *cobra.Command, projectRoot, composerJsonPath string, currentVersion *version.Version, updateVersions []string, cmdExecutor executor.Executor) error { + ctx := cmd.Context() + log := logging.FromContext(ctx) + + targetVersion, err := selectTargetVersion(cmd, updateVersions) + if err != nil { + return err + } + + if err := runCompatibilityCheck(ctx, cmdExecutor, composerJsonPath, targetVersion); err != nil { + return err + } + + confirmed := !system.IsInteractionEnabled(ctx) + if !confirmed { + if err := huh.NewConfirm(). + Title(fmt.Sprintf("Upgrade Shopware from %s to %s?", currentVersion.String(), targetVersion)). + Description("This will modify composer.json, run composer update --with-all-dependencies, and invoke vendor/bin/shopware-deployment-helper. Commit your changes before running this command."). + Value(&confirmed). + Run(); err != nil { + return err + } + } + + if !confirmed { + return fmt.Errorf("upgrade cancelled") + } + + log.Infof("Cleaning up stale recipe files") + if err := flexmigrator.CleanupByHash(projectRoot); err != nil { + return fmt.Errorf("cleanup stale files: %w", err) + } + + log.Infof("Resolving plugins with composer for %s", targetVersion) + result, err := projectupgrade.ApplyRequire(ctx, cmdExecutor, composerJsonPath, targetVersion) + if err != nil { + return fmt.Errorf("resolve incompatible plugins: %w", err) + } + + if result != nil { + for _, action := range result.Removed() { + log.Infof("Removed incompatible plugin %s (%s). Re-require it once a compatible version is published.", tui.YellowText.Render(action.Name), action.Reason) + } + } + + log.Infof("Updating composer.json to %s", targetVersion) + if err := projectupgrade.UpdateComposerJson(composerJsonPath, targetVersion); err != nil { + return fmt.Errorf("update composer.json: %w", err) + } + + log.Infof("Running composer update") + composerArgs := []string{ + "update", + "--no-interaction", + "--no-scripts", + "--with-all-dependencies", + "-v", + } + + composerCmd := cmdExecutor.ComposerCommand(ctx, composerArgs...) + composerCmd.Cmd.Stdin = cmd.InOrStdin() + composerCmd.Cmd.Stdout = cmd.OutOrStdout() + composerCmd.Cmd.Stderr = cmd.ErrOrStderr() + + if err := composerCmd.Run(); err != nil { + log.Errorf("composer update failed: %v", err) + trackUpgrade(ctx, currentVersion.String(), targetVersion, "composer_update_failed") + return fmt.Errorf("composer update failed: %w", err) + } + + log.Infof("Running vendor/bin/shopware-deployment-helper run") + deployCmd := cmdExecutor.PHPCommand(ctx, "vendor/bin/shopware-deployment-helper", "run") + deployCmd.Cmd.Stdin = cmd.InOrStdin() + deployCmd.Cmd.Stdout = cmd.OutOrStdout() + deployCmd.Cmd.Stderr = cmd.ErrOrStderr() + + if err := deployCmd.Run(); err != nil { + trackUpgrade(ctx, currentVersion.String(), targetVersion, "deployment_helper_failed") + return fmt.Errorf("shopware-deployment-helper run failed: %w", err) + } + + trackUpgrade(ctx, currentVersion.String(), targetVersion, "ok") + fmt.Printf("\n%s\n", tui.GreenText.Render(fmt.Sprintf("Shopware was upgraded from %s to %s", currentVersion.String(), targetVersion))) + return nil +} + +func selectTargetVersion(cmd *cobra.Command, updateVersions []string) (string, error) { + target, _ := cmd.Flags().GetString("to") + if target != "" { + for _, v := range updateVersions { + if v == target { + return target, nil + } + } + return "", fmt.Errorf("requested target version %s is not in the list of available upgrade versions", target) + } + + if !system.IsInteractionEnabled(cmd.Context()) { + logging.FromContext(cmd.Context()).Infof("Auto selected version %s", updateVersions[0]) + return updateVersions[0], nil + } + + var selected string + prompt := huh.NewSelect[string](). + Height(10). + Title("Select the Shopware version to upgrade to"). + Options(huh.NewOptions(updateVersions...)...). + Value(&selected) + + if err := prompt.Run(); err != nil { + return "", err + } + + if selected == "" { + return "", fmt.Errorf("no version selected") + } + + return selected, nil +} + +func runCompatibilityCheck(ctx context.Context, cmdExecutor executor.Executor, composerJsonPath, targetVersion string) error { + log := logging.FromContext(ctx) + + report, err := projectupgrade.DryRunRequire(ctx, cmdExecutor, composerJsonPath, targetVersion) + if err != nil { + log.Warnf("Skipping plugin compatibility check: %v", err) + return nil + } + + if report.OK { + log.Infof("composer can resolve the upgrade to %s", targetVersion) + return nil + } + + if len(report.BlockingPlugins) > 0 { + t := table.New().Border(lipgloss.NormalBorder()).Headers("Plugin", "Status") + for _, name := range report.BlockingPlugins { + t.Row(name, "no compatible release") + } + fmt.Println(t.Render()) + } else { + fmt.Println(strings.Join(report.Output, "\n")) + } + + if system.IsInteractionEnabled(ctx) { + var proceed bool + if err := huh.NewConfirm(). + Title("composer could not resolve the upgrade with all plugins"). + Description("Incompatible plugins will be removed from composer.json so the upgrade can proceed. Re-require them once they publish a compatible release. Proceed anyway?"). + Value(&proceed). + Run(); err != nil { + return err + } + + if !proceed { + return fmt.Errorf("upgrade cancelled due to incompatible plugins") + } + } + + return nil +} + +func trackUpgrade(ctx context.Context, fromVersion, toVersion, status string) { + trackCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 300*time.Millisecond) + defer cancel() + + tracking.Track(trackCtx, "project.upgrade", map[string]string{ + "from_version": fromVersion, + "to_version": toVersion, + "status": status, + "success": strconv.FormatBool(status == "ok"), + }) +} + +// ensureCleanGitTree aborts the upgrade if projectRoot is inside a git +// working tree that has uncommitted changes. The check is skipped when the +// directory is not a git repository (greenfield projects, vendored copies) +// or when --allow-dirty was passed. +func ensureCleanGitTree(ctx context.Context, projectRoot string, allowDirty bool) error { + if allowDirty { + return nil + } + + dirty, isRepo, err := git.IsWorkingTreeDirty(ctx, projectRoot) + if err != nil { + return fmt.Errorf("could not read git working tree status: %w", err) + } + + if !isRepo || !dirty { + return nil + } + + return fmt.Errorf( + "the upgrade rewrites composer.json and removes recipe-managed files, so the working tree must be clean in %s - "+ + "commit or stash your changes, or rerun with --allow-dirty to override", + projectRoot, + ) +} + +// ensureAllPluginsAreComposerManaged aborts when custom/plugins/ contains +// directories that are not tracked by composer. The upgrade can only bump +// plugin constraints in composer.json, so out-of-band drops would otherwise +// silently keep stale code on disk. +func ensureAllPluginsAreComposerManaged(projectRoot string, allow bool) error { + if allow { + return nil + } + + orphans, err := projectupgrade.FindNonComposerPlugins(projectRoot) + if err != nil { + return fmt.Errorf("scan custom/plugins: %w", err) + } + if len(orphans) == 0 { + return nil + } + + return fmt.Errorf( + "the upgrade can only bump composer-managed plugins, but %d director(ies) in custom/plugins/ are not tracked by composer:\n %s\n\nrun `shopware-cli project autofix composer-plugins` to migrate them, or rerun with --allow-non-composer to override", + len(orphans), + strings.Join(orphans, "\n "), + ) +} + +func init() { + projectRootCmd.AddCommand(projectUpgradeCmd) + projectUpgradeCmd.Flags().String("to", "", "Target Shopware version. Skips the interactive wizard.") + projectUpgradeCmd.Flags().Bool("allow-dirty", false, "Allow running the upgrade even when the git working tree has uncommitted changes.") + projectUpgradeCmd.Flags().Bool("allow-non-composer", false, "Allow running the upgrade even when custom/plugins/ contains plugins not managed by composer.") +} diff --git a/cmd/project/project_upgrade_test.go b/cmd/project/project_upgrade_test.go new file mode 100644 index 00000000..4ea45359 --- /dev/null +++ b/cmd/project/project_upgrade_test.go @@ -0,0 +1,113 @@ +package project + +import ( + "encoding/json" + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func jsonMarshal(v any) ([]byte, error) { + return json.MarshalIndent(v, "", " ") +} + +func gitCmd(t *testing.T, dir string, args ...string) { + t.Helper() + c := exec.CommandContext(t.Context(), "git", args...) + c.Dir = dir + out, err := c.CombinedOutput() + if err != nil { + t.Fatalf("git %v failed: %s", args, string(out)) + } +} + +func setupTestRepo(t *testing.T) string { + t.Helper() + dir := t.TempDir() + gitCmd(t, dir, "init") + gitCmd(t, dir, "config", "commit.gpgsign", "false") + gitCmd(t, dir, "config", "user.name", "test") + gitCmd(t, dir, "config", "user.email", "test@example.com") + require.NoError(t, os.WriteFile(filepath.Join(dir, "seed"), []byte("seed"), 0o644)) + gitCmd(t, dir, "add", "seed") + gitCmd(t, dir, "commit", "-m", "seed", "--no-verify", "--no-gpg-sign") + return dir +} + +func TestEnsureCleanGitTreeSkipsNonRepo(t *testing.T) { + t.Parallel() + dir := t.TempDir() + assert.NoError(t, ensureCleanGitTree(t.Context(), dir, false)) +} + +func TestEnsureCleanGitTreeAllowsCleanRepo(t *testing.T) { + t.Parallel() + dir := setupTestRepo(t) + assert.NoError(t, ensureCleanGitTree(t.Context(), dir, false)) +} + +func TestEnsureCleanGitTreeRejectsDirtyRepo(t *testing.T) { + t.Parallel() + dir := setupTestRepo(t) + require.NoError(t, os.WriteFile(filepath.Join(dir, "untracked"), []byte("x"), 0o644)) + + err := ensureCleanGitTree(t.Context(), dir, false) + require.Error(t, err) + assert.Contains(t, err.Error(), "working tree must be clean") +} + +func TestEnsureCleanGitTreeAllowDirtyFlagBypassesCheck(t *testing.T) { + t.Parallel() + dir := setupTestRepo(t) + require.NoError(t, os.WriteFile(filepath.Join(dir, "untracked"), []byte("x"), 0o644)) + + assert.NoError(t, ensureCleanGitTree(t.Context(), dir, true)) +} + +func writeInstalledJSON(t *testing.T, projectDir string, packages []map[string]any) { + t.Helper() + installedDir := filepath.Join(projectDir, "vendor", "composer") + require.NoError(t, os.MkdirAll(installedDir, 0o755)) + body, err := jsonMarshal(map[string]any{"packages": packages}) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(installedDir, "installed.json"), body, 0o644)) +} + +func TestEnsureAllPluginsAreComposerManagedAllowsTrackedDirectories(t *testing.T) { + t.Parallel() + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, "custom", "plugins", "Tracked"), 0o755)) + writeInstalledJSON(t, dir, []map[string]any{ + { + "name": "vendor/tracked", + "type": "shopware-platform-plugin", + "install-path": "../../custom/plugins/Tracked", + }, + }) + + assert.NoError(t, ensureAllPluginsAreComposerManaged(dir, false)) +} + +func TestEnsureAllPluginsAreComposerManagedRejectsOrphanedDirectory(t *testing.T) { + t.Parallel() + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, "custom", "plugins", "Orphan"), 0o755)) + + err := ensureAllPluginsAreComposerManaged(dir, false) + require.Error(t, err) + assert.Contains(t, err.Error(), "not tracked by composer") + assert.Contains(t, err.Error(), "Orphan") + assert.Contains(t, err.Error(), "autofix composer-plugins") +} + +func TestEnsureAllPluginsAreComposerManagedAllowFlagBypasses(t *testing.T) { + t.Parallel() + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, "custom", "plugins", "Orphan"), 0o755)) + + assert.NoError(t, ensureAllPluginsAreComposerManaged(dir, true)) +} diff --git a/internal/account-api/updates.go b/internal/account-api/updates.go index 62005f82..280d868b 100644 --- a/internal/account-api/updates.go +++ b/internal/account-api/updates.go @@ -23,14 +23,44 @@ type UpdateCheckExtensionCompatibility struct { Status UpdateCheckExtensionCompatibilityStatus `json:"status"` } +// Plugin compatibility status names returned by the store autoupdate +// endpoint. These mirror the constants in Shopware's +// Core\Framework\Update\Services\ExtensionCompatibility. The status `type` +// field is only a display color (green/red/…), so classification must key on +// the semantic `name` instead. +const ( + // CompatibilityCompatible means the installed version already works with + // the target Shopware version. + CompatibilityCompatible = "compatible" + // CompatibilityUpdatableNow / CompatibilityUpdatableFuture mean the + // extension has a compatible release available ("With new Shopware + // version"): not a blocker, the constraint just needs to be bumped. + CompatibilityUpdatableNow = "updatableNow" + CompatibilityUpdatableFuture = "updatableFuture" + // CompatibilityNotCompatible means no compatible successor exists. This + // is the only genuine blocker. + CompatibilityNotCompatible = "notCompatible" + // CompatibilityNotInStore means the extension is not managed by the store. + CompatibilityNotInStore = "notInStore" +) + type UpdateCheckExtensionCompatibilityStatus struct { Name string `json:"name"` Label string `json:"label"` Type string `json:"type"` } +// IsBlocker reports whether this status prevents the upgrade. Only +// notCompatible (no compatible successor) blocks; updatableNow/updatableFuture +// are resolvable by bumping the extension constraint, so they are not blockers. func (s UpdateCheckExtensionCompatibilityStatus) IsBlocker() bool { - return s.Type != "success" && s.Type != "" + return s.Name == CompatibilityNotCompatible +} + +// IsUpdatable reports whether a compatible release exists that the installed +// version must be bumped to ("With new Shopware version"). +func (s UpdateCheckExtensionCompatibilityStatus) IsUpdatable() bool { + return s.Name == CompatibilityUpdatableNow || s.Name == CompatibilityUpdatableFuture } func GetFutureExtensionUpdates(ctx context.Context, currentVersion string, futureVersion string, extensions []UpdateCheckExtension) ([]UpdateCheckExtensionCompatibility, error) { diff --git a/internal/account-api/updates_test.go b/internal/account-api/updates_test.go new file mode 100644 index 00000000..489b4620 --- /dev/null +++ b/internal/account-api/updates_test.go @@ -0,0 +1,55 @@ +package account_api + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestUpdateCheckExtensionCompatibilityStatusClassification(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + status UpdateCheckExtensionCompatibilityStatus + isBlocker bool + isUpdatable bool + }{ + { + name: "compatible", + status: UpdateCheckExtensionCompatibilityStatus{Name: CompatibilityCompatible, Type: "green"}, + }, + { + name: "updatable now is not a blocker", + status: UpdateCheckExtensionCompatibilityStatus{Name: CompatibilityUpdatableNow, Type: "yellow"}, + isUpdatable: true, + }, + { + name: "updatable future is not a blocker", + status: UpdateCheckExtensionCompatibilityStatus{Name: CompatibilityUpdatableFuture, Type: "yellow"}, + isUpdatable: true, + }, + { + name: "not compatible blocks", + status: UpdateCheckExtensionCompatibilityStatus{Name: CompatibilityNotCompatible, Type: "red"}, + isBlocker: true, + }, + { + name: "not in store is informational", + status: UpdateCheckExtensionCompatibilityStatus{Name: CompatibilityNotInStore}, + }, + { + name: "empty status is not a blocker", + status: UpdateCheckExtensionCompatibilityStatus{}, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.isBlocker, tt.status.IsBlocker()) + assert.Equal(t, tt.isUpdatable, tt.status.IsUpdatable()) + }) + } +} diff --git a/internal/devtui/migration_wizard_apply.go b/internal/devtui/migration_wizard_apply.go index 5662e251..a16a7429 100644 --- a/internal/devtui/migration_wizard_apply.go +++ b/internal/devtui/migration_wizard_apply.go @@ -69,15 +69,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/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/packagist/composer.go b/internal/packagist/composer.go index 749c3154..2eb460ba 100644 --- a/internal/packagist/composer.go +++ b/internal/packagist/composer.go @@ -126,6 +126,21 @@ func (c *ComposerJson) HasPackageDev(name string) bool { return ok } +// EnsureRequire adds name=constraint to the require block when the package is +// not already present in either require or require-dev. Returns true when +// composer.json was modified; callers must Save() to persist the change. +func (c *ComposerJson) EnsureRequire(name, constraint string) bool { + if c.HasPackage(name) || c.HasPackageDev(name) { + return false + } + + if c.Require == nil { + c.Require = ComposerPackageLink{} + } + c.Require[name] = constraint + return true +} + func (c *ComposerJson) HasConfig(key string) bool { _, ok := c.Config[key] return ok diff --git a/internal/packagist/composer_test.go b/internal/packagist/composer_test.go index e0d34039..83a06bc0 100644 --- a/internal/packagist/composer_test.go +++ b/internal/packagist/composer_test.go @@ -248,3 +248,35 @@ func TestReadComposerJsonDifferentRepositoryWritings(t *testing.T) { assert.ElementsMatch(t, expectedRepos, composer.Repositories) }) } + +func TestEnsureRequireAddsWhenMissing(t *testing.T) { + t.Parallel() + + cj := &ComposerJson{Require: ComposerPackageLink{"shopware/core": "^6.6"}} + assert.True(t, cj.EnsureRequire("shopware/deployment-helper", "*")) + assert.Equal(t, "*", cj.Require["shopware/deployment-helper"]) +} + +func TestEnsureRequireNoOpWhenAlreadyRequired(t *testing.T) { + t.Parallel() + + cj := &ComposerJson{Require: ComposerPackageLink{"shopware/deployment-helper": "^1.0"}} + assert.False(t, cj.EnsureRequire("shopware/deployment-helper", "*")) + assert.Equal(t, "^1.0", cj.Require["shopware/deployment-helper"], "an existing constraint must not be overwritten") +} + +func TestEnsureRequireNoOpWhenPresentInRequireDev(t *testing.T) { + t.Parallel() + + cj := &ComposerJson{RequireDev: ComposerPackageLink{"shopware/deployment-helper": "^1.0"}} + assert.False(t, cj.EnsureRequire("shopware/deployment-helper", "*")) + assert.NotContains(t, cj.Require, "shopware/deployment-helper", "must not duplicate into require when present in require-dev") +} + +func TestEnsureRequireInitializesRequireMap(t *testing.T) { + t.Parallel() + + cj := &ComposerJson{} + assert.True(t, cj.EnsureRequire("shopware/deployment-helper", "*")) + assert.Equal(t, "*", cj.Require["shopware/deployment-helper"]) +} diff --git a/internal/packagist/installed.go b/internal/packagist/installed.go new file mode 100644 index 00000000..15338a0f --- /dev/null +++ b/internal/packagist/installed.go @@ -0,0 +1,86 @@ +package packagist + +import ( + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" +) + +// InstalledPackage is a single entry of vendor/composer/installed.json. +type InstalledPackage struct { + Name string `json:"name"` + Type string `json:"type"` + Version string `json:"version"` + Require map[string]string `json:"require"` + InstallPath string `json:"install-path"` +} + +// InstalledJson models vendor/composer/installed.json, composer's record of +// every package actually installed into the project. +type InstalledJson struct { + Packages []InstalledPackage `json:"packages"` +} + +// ReadInstalledJson reads vendor/composer/installed.json relative to +// projectRoot. An empty InstalledJson is returned when the file does not +// exist, so callers can treat "no composer install yet" as "no packages". +func ReadInstalledJson(projectRoot string) (*InstalledJson, error) { + installedPath := filepath.Join(projectRoot, "vendor", "composer", "installed.json") + + content, err := os.ReadFile(installedPath) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return &InstalledJson{}, nil + } + return nil, fmt.Errorf("read installed.json: %w", err) + } + + var installed InstalledJson + if err := json.Unmarshal(content, &installed); err != nil { + return nil, fmt.Errorf("parse installed.json: %w", err) + } + + return &installed, nil +} + +// InstallDirName resolves the package's install location (install-path is +// recorded relative to vendor/composer) and, when it sits directly inside +// baseDir, returns the child directory name. ok is false when the package is +// installed anywhere else. Symlinks are resolved on both sides so symlinked +// packages still match. +func (p InstalledPackage) InstallDirName(projectRoot, baseDir string) (string, bool) { + if p.InstallPath == "" { + return "", false + } + + abs := p.InstallPath + if !filepath.IsAbs(abs) { + abs = filepath.Join(projectRoot, "vendor", "composer", p.InstallPath) + } + + rel, err := filepath.Rel(resolveSymlinks(baseDir), resolveSymlinks(abs)) + if err != nil { + return "", false + } + + if rel == "." || rel == "" || strings.HasPrefix(rel, "..") { + return "", false + } + + if strings.ContainsRune(rel, filepath.Separator) { + return "", false + } + + return rel, true +} + +func resolveSymlinks(path string) string { + if resolved, err := filepath.EvalSymlinks(path); err == nil { + return resolved + } + return filepath.Clean(path) +} diff --git a/internal/packagist/installed_test.go b/internal/packagist/installed_test.go new file mode 100644 index 00000000..bf26ca7e --- /dev/null +++ b/internal/packagist/installed_test.go @@ -0,0 +1,92 @@ +package packagist + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func writeInstalled(t *testing.T, projectRoot string, installed InstalledJson) { + t.Helper() + dir := filepath.Join(projectRoot, "vendor", "composer") + require.NoError(t, os.MkdirAll(dir, 0o755)) + data, err := json.MarshalIndent(installed, "", " ") + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(dir, "installed.json"), data, 0o644)) +} + +func TestReadInstalledJsonMissingReturnsEmpty(t *testing.T) { + t.Parallel() + installed, err := ReadInstalledJson(t.TempDir()) + require.NoError(t, err) + require.NotNil(t, installed) + assert.Empty(t, installed.Packages) +} + +func TestReadInstalledJsonParsesPackages(t *testing.T) { + t.Parallel() + dir := t.TempDir() + writeInstalled(t, dir, InstalledJson{Packages: []InstalledPackage{ + {Name: "vendor/a", Type: "shopware-platform-plugin", InstallPath: "../../custom/plugins/A"}, + }}) + + installed, err := ReadInstalledJson(dir) + require.NoError(t, err) + require.NotNil(t, installed) + require.Len(t, installed.Packages, 1) + assert.Equal(t, "vendor/a", installed.Packages[0].Name) +} + +func TestReadInstalledJsonMalformedReturnsError(t *testing.T) { + t.Parallel() + dir := t.TempDir() + composerDir := filepath.Join(dir, "vendor", "composer") + require.NoError(t, os.MkdirAll(composerDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(composerDir, "installed.json"), []byte("{not json"), 0o644)) + + _, err := ReadInstalledJson(dir) + require.Error(t, err) +} + +func TestInstallDirNameDirectChild(t *testing.T) { + t.Parallel() + dir := t.TempDir() + base := filepath.Join(dir, "custom", "plugins") + require.NoError(t, os.MkdirAll(filepath.Join(base, "MyPlugin"), 0o755)) + + pkg := InstalledPackage{InstallPath: "../../custom/plugins/MyPlugin"} + name, ok := pkg.InstallDirName(dir, base) + assert.True(t, ok) + assert.Equal(t, "MyPlugin", name) +} + +func TestInstallDirNameNotUnderBase(t *testing.T) { + t.Parallel() + dir := t.TempDir() + base := filepath.Join(dir, "custom", "plugins") + + pkg := InstalledPackage{InstallPath: "../vendor/installed"} + _, ok := pkg.InstallDirName(dir, base) + assert.False(t, ok) +} + +func TestInstallDirNameNestedIsNotDirectChild(t *testing.T) { + t.Parallel() + dir := t.TempDir() + base := filepath.Join(dir, "custom", "plugins") + + pkg := InstalledPackage{InstallPath: "../../custom/plugins/Group/Nested"} + _, ok := pkg.InstallDirName(dir, base) + assert.False(t, ok) +} + +func TestInstallDirNameEmptyPath(t *testing.T) { + t.Parallel() + pkg := InstalledPackage{} + _, ok := pkg.InstallDirName("/project", "/project/custom/plugins") + assert.False(t, ok) +} diff --git a/internal/packagist/packagist.go b/internal/packagist/packagist.go index d335c624..2ca8a40b 100644 --- a/internal/packagist/packagist.go +++ b/internal/packagist/packagist.go @@ -33,6 +33,7 @@ type PackageVersion struct { Version string `json:"version"` Description string `json:"description"` Replace map[string]string `json:"replace"` + Require map[string]string `json:"require"` } type ComposerPackageVersion struct { @@ -109,7 +110,25 @@ func PHPConstraintForShopwareVersion(releases []ComposerPackageVersion, chosenVe } func GetShopwarePackageVersions(ctx context.Context) ([]ComposerPackageVersion, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://repo.packagist.org/p2/shopware/core.json", http.NoBody) + versions, err := GetComposerPackageVersions(ctx, "shopware/core") + if err != nil { + return nil, err + } + + if len(versions) == 0 { + return nil, fmt.Errorf("decode package versions: package shopware/core not found") + } + + return versions, nil +} + +// GetComposerPackageVersions fetches every published version of a composer +// package from repo.packagist.org. An empty slice (and no error) is returned +// when the package does not exist. +func GetComposerPackageVersions(ctx context.Context, name string) ([]ComposerPackageVersion, error) { + url := fmt.Sprintf("https://repo.packagist.org/p2/%s.json", name) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody) if err != nil { return nil, fmt.Errorf("create package versions request: %w", err) } @@ -122,6 +141,10 @@ func GetShopwarePackageVersions(ctx context.Context) ([]ComposerPackageVersion, } defer closeResponseBody(ctx, resp) + if resp.StatusCode == http.StatusNotFound { + return nil, nil + } + if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("fetch package versions: %s", resp.Status) } @@ -132,9 +155,9 @@ func GetShopwarePackageVersions(ctx context.Context) ([]ComposerPackageVersion, return nil, fmt.Errorf("decode package versions: %w", err) } - rawVersions, ok := packageResponse.Packages["shopware/core"] + rawVersions, ok := packageResponse.Packages[name] if !ok { - return nil, fmt.Errorf("decode package versions: package shopware/core not found") + return nil, nil } if packageResponse.Minified != "" { diff --git a/internal/projectupgrade/composer.go b/internal/projectupgrade/composer.go new file mode 100644 index 00000000..ee4a48f5 --- /dev/null +++ b/internal/projectupgrade/composer.go @@ -0,0 +1,85 @@ +// Package projectupgrade implements the Shopware project upgrade flow used +// by the `shopware-cli project upgrade` command. The logic mirrors the +// shopware/web-installer Update flow so projects can be upgraded the same +// way from the command line. +package projectupgrade + +import ( + "strings" + + "github.com/shyim/go-version" + + "github.com/shopware/shopware-cli/internal/packagist" +) + +// ShopwarePackages are the first-party Shopware packages whose constraint is +// rewritten to the new target version in composer.json. This matches the list +// in shopware/web-installer ProjectComposerJsonUpdater. +var ShopwarePackages = []string{ + "shopware/core", + "shopware/administration", + "shopware/storefront", + "shopware/elasticsearch", +} + +// UpdateComposerJson rewrites the project composer.json so that composer can +// resolve dependencies for targetVersion. It mirrors the logic of +// `Shopware\WebInstaller\Services\ProjectComposerJsonUpdater` and additionally +// ensures shopware/deployment-helper is required so the post-update step can +// invoke vendor/bin/shopware-deployment-helper. +func UpdateComposerJson(composerJsonPath, targetVersion string) error { + composerJson, err := packagist.ReadComposerJson(composerJsonPath) + if err != nil { + return err + } + + if isPreRelease(targetVersion) { + composerJson.MinimumStability = "RC" + } else { + composerJson.MinimumStability = "" + } + + if composerJson.Require == nil { + composerJson.Require = packagist.ComposerPackageLink{} + } + + if _, ok := composerJson.Require["symfony/runtime"]; ok { + composerJson.Require["symfony/runtime"] = ">=5" + } + + for _, pkg := range ShopwarePackages { + if _, ok := composerJson.Require[pkg]; ok { + composerJson.Require[pkg] = targetVersion + } + } + + composerJson.EnsureRequire("shopware/deployment-helper", "*") + + return composerJson.Save() +} + +func isPreRelease(targetVersion string) bool { + v := strings.ToLower(targetVersion) + return strings.Contains(v, "rc") || strings.Contains(v, "beta") || strings.Contains(v, "alpha") +} + +// CurrentShopwareVersion returns the installed Shopware version reported by +// the composer.lock at projectDir. Returns an error when no Shopware package +// is recorded in the lock file. +func CurrentShopwareVersion(projectDir string) (*version.Version, error) { + lock, err := packagist.ReadComposerLock(projectDir + "/composer.lock") + if err != nil { + return nil, err + } + + for _, name := range []string{"shopware/core", "shopware/platform"} { + pkg := lock.GetPackage(name) + if pkg == nil { + continue + } + + return version.NewVersion(strings.TrimPrefix(pkg.Version, "v")) + } + + return nil, errNoShopwareInLock +} diff --git a/internal/projectupgrade/composer_resolve.go b/internal/projectupgrade/composer_resolve.go new file mode 100644 index 00000000..7b58483f --- /dev/null +++ b/internal/projectupgrade/composer_resolve.go @@ -0,0 +1,254 @@ +package projectupgrade + +import ( + "context" + "fmt" + "path/filepath" + "sort" + "strings" + + "github.com/shopware/shopware-cli/internal/executor" + "github.com/shopware/shopware-cli/internal/packagist" +) + +// composerPluginType is the composer "type" used by Shopware platform plugins. +const composerPluginType = "shopware-platform-plugin" + +// PluginAction describes how the resolver dealt with one plugin that blocked +// the upgrade. +type PluginAction struct { + // Name is the composer package name (e.g. "swag/paypal"). + Name string + // Removed is true when the plugin was dropped from composer.json because + // composer could not resolve it against the target version. + Removed bool + // Reason is a short human-readable explanation surfaced in the UI. + Reason string +} + +// ResolveResult summarises the actions the resolver took. +type ResolveResult struct { + Actions []PluginAction +} + +// Removed returns the actions that resulted in the plugin being dropped. +func (r *ResolveResult) Removed() []PluginAction { + out := make([]PluginAction, 0, len(r.Actions)) + for _, a := range r.Actions { + if a.Removed { + out = append(out, a) + } + } + return out +} + +// CompatReport is the outcome of a dry-run `composer require` against the +// target version. It is composer's own verdict, not a homegrown constraint +// check. +type CompatReport struct { + // OK is true when composer could resolve the requested upgrade. + OK bool + // Output is the captured composer output. On failure it contains the + // "Your requirements could not be resolved" block. + Output []string + // BlockingPlugins are the plugin names from the upgrade set that appear in + // composer's conflict output. Best-effort; may be empty when composer's + // message cannot be attributed to a specific plugin. + BlockingPlugins []string +} + +// requirePackages enumerates the composer require arguments for the upgrade: +// the first-party Shopware packages present in composer.json pinned to the +// target version, plus every required shopware-platform-plugin (passed without +// a constraint so composer picks the newest release compatible with the pinned +// core). +func requirePackages(composerJsonPath, targetVersion string) ([]string, []string, error) { + composerJson, err := packagist.ReadComposerJson(composerJsonPath) + if err != nil { + return nil, nil, err + } + + args := make([]string, 0, len(ShopwarePackages)) + for _, pkg := range ShopwarePackages { + if _, ok := composerJson.Require[pkg]; ok { + args = append(args, pkg+":"+targetVersion) + } + } + + plugins, err := requiredPlugins(filepath.Dir(composerJsonPath), composerJson) + if err != nil { + return nil, nil, err + } + + args = append(args, plugins...) + return args, plugins, nil +} + +// requiredPlugins returns the composer package names of every required +// shopware-platform-plugin, sorted for stable output. +func requiredPlugins(projectDir string, composerJson *packagist.ComposerJson) ([]string, error) { + installed, err := packagist.ReadInstalledJson(projectDir) + if err != nil { + return nil, err + } + + plugins := make([]string, 0) + for _, pkg := range installed.Packages { + if pkg.Type != composerPluginType { + continue + } + if _, ok := composerJson.Require[pkg.Name]; !ok { + continue + } + plugins = append(plugins, pkg.Name) + } + + sort.Strings(plugins) + return plugins, nil +} + +// composerRequire runs `composer require --no-install -W ` (optionally a +// dry run) through the executor and returns the combined output. The error is +// the process exit error, which composer returns non-zero on an unresolvable +// requirement. +func composerRequire(ctx context.Context, exec executor.Executor, dryRun bool, pkgs []string) ([]string, error) { + args := []string{ + "require", + "--no-interaction", + "--no-install", + "--no-scripts", + "--update-with-all-dependencies", + } + if dryRun { + args = append(args, "--dry-run") + } + args = append(args, pkgs...) + + out, err := exec.ComposerCommand(ctx, args...).CombinedOutput() + return splitLines(out), err +} + +// DryRunRequire asks composer whether the project can be upgraded to +// targetVersion without changing anything on disk. It runs +// `composer require --no-install --dry-run -W shopware/core: ` +// and reports composer's verdict. +func DryRunRequire(ctx context.Context, exec executor.Executor, composerJsonPath, targetVersion string) (CompatReport, error) { + pkgs, plugins, err := requirePackages(composerJsonPath, targetVersion) + if err != nil { + return CompatReport{}, err + } + + output, runErr := composerRequire(ctx, exec, true, pkgs) + if runErr == nil { + return CompatReport{OK: true, Output: output}, nil + } + + return CompatReport{ + OK: false, + Output: output, + BlockingPlugins: blockingPlugins(output, plugins), + }, nil +} + +// ApplyRequire performs the real upgrade resolution: it runs +// `composer require --no-install -W shopware/core: `, letting +// composer rewrite composer.json/composer.lock with the bumped constraints. +// When composer cannot resolve the set, the plugin(s) it names are removed from +// composer.json and the require is retried until it resolves or no plugin can +// be attributed to the failure. +func ApplyRequire(ctx context.Context, exec executor.Executor, composerJsonPath, targetVersion string) (*ResolveResult, error) { + pkgs, plugins, err := requirePackages(composerJsonPath, targetVersion) + if err != nil { + return nil, err + } + + result := &ResolveResult{} + dropped := make(map[string]struct{}) + + // Bounded by the number of plugins: each failed attempt drops at least one. + for attempt := 0; attempt <= len(plugins); attempt++ { + output, runErr := composerRequire(ctx, exec, false, pkgs) + if runErr == nil { + return result, nil + } + + blockers := blockingPlugins(output, plugins) + newlyDropped := false + for _, name := range blockers { + if _, ok := dropped[name]; ok { + continue + } + if err := removePluginFromComposer(composerJsonPath, name); err != nil { + return nil, err + } + dropped[name] = struct{}{} + result.Actions = append(result.Actions, PluginAction{ + Name: name, + Removed: true, + Reason: "composer could not resolve it for " + targetVersion, + }) + newlyDropped = true + } + + if !newlyDropped { + // Composer failed but the conflict is not attributable to a plugin + // we can drop (e.g. a platform requirement). Surface the output. + return result, fmt.Errorf("composer could not resolve the upgrade:\n%s", strings.Join(output, "\n")) + } + + pkgs = filterPackages(pkgs, dropped) + } + + return result, fmt.Errorf("composer could not resolve the upgrade after dropping all incompatible plugins") +} + +// removePluginFromComposer drops a plugin from the root composer.json require +// block so composer can resolve the remaining set. +func removePluginFromComposer(composerJsonPath, name string) error { + composerJson, err := packagist.ReadComposerJson(composerJsonPath) + if err != nil { + return err + } + if _, ok := composerJson.Require[name]; !ok { + return nil + } + delete(composerJson.Require, name) + return composerJson.Save() +} + +// filterPackages returns pkgs without the require arguments for any dropped +// plugin. Plugin args are bare names; core args carry a ":constraint" suffix +// and are never dropped. +func filterPackages(pkgs []string, dropped map[string]struct{}) []string { + out := make([]string, 0, len(pkgs)) + for _, p := range pkgs { + if _, ok := dropped[p]; ok { + continue + } + out = append(out, p) + } + return out +} + +// blockingPlugins returns the plugin names that appear anywhere in composer's +// output. Composer names the conflicting packages in its "could not be +// resolved" block; we only attribute failures to plugins we asked for so the +// resolver never drops first-party packages. +func blockingPlugins(output, plugins []string) []string { + joined := strings.Join(output, "\n") + blockers := make([]string, 0) + for _, name := range plugins { + if strings.Contains(joined, name) { + blockers = append(blockers, name) + } + } + return blockers +} + +func splitLines(out []byte) []string { + trimmed := strings.TrimRight(string(out), "\n") + if trimmed == "" { + return nil + } + return strings.Split(trimmed, "\n") +} diff --git a/internal/projectupgrade/composer_resolve_test.go b/internal/projectupgrade/composer_resolve_test.go new file mode 100644 index 00000000..6c3dec91 --- /dev/null +++ b/internal/projectupgrade/composer_resolve_test.go @@ -0,0 +1,84 @@ +package projectupgrade + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/shopware/shopware-cli/internal/packagist" +) + +func TestBlockingPluginsAttributesOnlyRequestedPlugins(t *testing.T) { + t.Parallel() + + output := []string{ + "Loading composer repositories with package information", + "Your requirements could not be resolved to an installable set of packages.", + " Problem 1", + " - swag/paypal 9.0.0 requires shopware/core ^6.7 -> found shopware/core[6.6.4.0]", + " - root composer.json requires swag/paypal ^9.0", + } + + // shopware/core appears in the output too, but it is not a plugin we asked + // for, so it must never be attributed as a blocker. + blockers := blockingPlugins(output, []string{"swag/paypal", "frosh/tools"}) + + assert.Equal(t, []string{"swag/paypal"}, blockers) +} + +func TestBlockingPluginsEmptyWhenNoPluginNamed(t *testing.T) { + t.Parallel() + + output := []string{"Your requirements could not be resolved", "ext-foo is missing"} + assert.Empty(t, blockingPlugins(output, []string{"swag/paypal"})) +} + +func TestFilterPackagesDropsOnlyDroppedNames(t *testing.T) { + t.Parallel() + + pkgs := []string{"shopware/core:6.6.4.0", "swag/paypal", "frosh/tools"} + dropped := map[string]struct{}{"swag/paypal": {}} + + assert.Equal(t, []string{"shopware/core:6.6.4.0", "frosh/tools"}, filterPackages(pkgs, dropped)) +} + +func TestRequirePackagesPinsCoreAndListsPlugins(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + composerJsonPath := filepath.Join(dir, "composer.json") + + writeJSON(t, composerJsonPath, map[string]any{ + "name": "shopware/production", + "require": map[string]any{ + "shopware/core": "6.5.8.0", + "shopware/storefront": "6.5.8.0", + "swag/paypal": "^8.11", + "frosh/tools": "^1.4", + "unrelated/library": "^1.0", + }, + }) + + writeInstalledJSON(t, dir, []packagist.InstalledPackage{ + {Name: "swag/paypal", Type: composerPluginType, InstallPath: "../swag/paypal"}, + {Name: "frosh/tools", Type: composerPluginType, InstallPath: "../frosh/tools"}, + {Name: "unrelated/library", Type: "library", InstallPath: "../unrelated/library"}, + }) + + args, plugins, err := requirePackages(composerJsonPath, "6.6.4.0") + require.NoError(t, err) + + // First-party packages present in require are pinned to the target. + assert.Contains(t, args, "shopware/core:6.6.4.0") + assert.Contains(t, args, "shopware/storefront:6.6.4.0") + // administration/elasticsearch are not required, so they are not pinned. + assert.NotContains(t, args, "shopware/administration:6.6.4.0") + + // Only shopware-platform-plugins from require are listed, without a constraint. + assert.Equal(t, []string{"frosh/tools", "swag/paypal"}, plugins) + assert.Contains(t, args, "swag/paypal") + assert.Contains(t, args, "frosh/tools") + assert.NotContains(t, args, "unrelated/library") +} diff --git a/internal/projectupgrade/composer_test.go b/internal/projectupgrade/composer_test.go new file mode 100644 index 00000000..b59a26b7 --- /dev/null +++ b/internal/projectupgrade/composer_test.go @@ -0,0 +1,136 @@ +package projectupgrade + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func writeJSON(t *testing.T, file string, content map[string]any) { + t.Helper() + + data, err := json.MarshalIndent(content, "", " ") + require.NoError(t, err) + require.NoError(t, os.WriteFile(file, data, 0o644)) +} + +func readJSON(t *testing.T, file string) map[string]any { + t.Helper() + + raw, err := os.ReadFile(file) + require.NoError(t, err) + + var out map[string]any + require.NoError(t, json.Unmarshal(raw, &out)) + return out +} + +func TestUpdateComposerJsonRewritesShopwarePackages(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + composerJsonPath := filepath.Join(dir, "composer.json") + + writeJSON(t, composerJsonPath, map[string]any{ + "name": "shopware/production", + "require": map[string]any{ + "shopware/core": "6.5.8.0", + "shopware/administration": "6.5.8.0", + "shopware/storefront": "6.5.8.0", + "unrelated/package": "^1.0", + }, + }) + + require.NoError(t, UpdateComposerJson(composerJsonPath, "6.6.4.0")) + + out := readJSON(t, composerJsonPath) + requireMap := out["require"].(map[string]any) + assert.Equal(t, "6.6.4.0", requireMap["shopware/core"]) + assert.Equal(t, "6.6.4.0", requireMap["shopware/administration"]) + assert.Equal(t, "6.6.4.0", requireMap["shopware/storefront"]) + assert.Equal(t, "^1.0", requireMap["unrelated/package"]) + assert.NotContains(t, requireMap, "shopware/elasticsearch", "should not add packages that were not already required") + assert.Equal(t, "*", requireMap["shopware/deployment-helper"], "deployment-helper is added so the upgrade can invoke it after composer update") +} + +func TestUpdateComposerJsonLeavesExistingDeploymentHelperConstraint(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + composerJsonPath := filepath.Join(dir, "composer.json") + + writeJSON(t, composerJsonPath, map[string]any{ + "name": "shopware/production", + "require": map[string]any{ + "shopware/core": "6.5.8.0", + "shopware/deployment-helper": "^1.0", + }, + }) + + require.NoError(t, UpdateComposerJson(composerJsonPath, "6.6.4.0")) + + requireMap := readJSON(t, composerJsonPath)["require"].(map[string]any) + assert.Equal(t, "^1.0", requireMap["shopware/deployment-helper"], "an existing constraint must not be overwritten") +} + +func TestUpdateComposerJsonSetsRCStability(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + composerJsonPath := filepath.Join(dir, "composer.json") + + writeJSON(t, composerJsonPath, map[string]any{ + "name": "shopware/production", + "require": map[string]any{ + "shopware/core": "6.5.8.0", + }, + }) + + require.NoError(t, UpdateComposerJson(composerJsonPath, "6.6.0.0-rc1")) + out := readJSON(t, composerJsonPath) + assert.Equal(t, "RC", out["minimum-stability"]) +} + +func TestUpdateComposerJsonClearsRCStabilityForStableTarget(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + composerJsonPath := filepath.Join(dir, "composer.json") + + writeJSON(t, composerJsonPath, map[string]any{ + "name": "shopware/production", + "minimum-stability": "RC", + "require": map[string]any{ + "shopware/core": "6.5.8.0", + }, + }) + + require.NoError(t, UpdateComposerJson(composerJsonPath, "6.6.4.0")) + out := readJSON(t, composerJsonPath) + _, hasStability := out["minimum-stability"] + assert.False(t, hasStability, "minimum-stability should be cleared for stable upgrades") +} + +func TestUpdateComposerJsonRewritesSymfonyRuntimeConstraint(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + composerJsonPath := filepath.Join(dir, "composer.json") + + writeJSON(t, composerJsonPath, map[string]any{ + "name": "shopware/production", + "require": map[string]any{ + "shopware/core": "6.5.8.0", + "symfony/runtime": "^5.4|^6.0", + }, + }) + + require.NoError(t, UpdateComposerJson(composerJsonPath, "6.6.4.0")) + out := readJSON(t, composerJsonPath) + requireMap := out["require"].(map[string]any) + assert.Equal(t, ">=5", requireMap["symfony/runtime"]) +} diff --git a/internal/projectupgrade/errors.go b/internal/projectupgrade/errors.go new file mode 100644 index 00000000..ea56d8ef --- /dev/null +++ b/internal/projectupgrade/errors.go @@ -0,0 +1,5 @@ +package projectupgrade + +import "errors" + +var errNoShopwareInLock = errors.New("no shopware/core or shopware/platform entry found in composer.lock") diff --git a/internal/projectupgrade/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/plugins.go b/internal/projectupgrade/plugins.go new file mode 100644 index 00000000..0e18da7f --- /dev/null +++ b/internal/projectupgrade/plugins.go @@ -0,0 +1,56 @@ +package projectupgrade + +import ( + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/shopware/shopware-cli/internal/packagist" +) + +// FindNonComposerPlugins returns directories under custom/plugins/ that are +// not tracked by composer (no entry in vendor/composer/installed.json). +// Returns an empty slice when no installed.json is present. +func FindNonComposerPlugins(projectRoot string) ([]string, error) { + customPlugins := filepath.Join(projectRoot, "custom", "plugins") + entries, err := os.ReadDir(customPlugins) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil, nil + } + return nil, fmt.Errorf("read %s: %w", customPlugins, err) + } + + composerTracked := make(map[string]struct{}) + // Best-effort: a missing or malformed installed.json simply means nothing + // is tracked, so every plugin directory is reported. + installed, _ := packagist.ReadInstalledJson(projectRoot) + if installed != nil { + for _, pkg := range installed.Packages { + if dir, ok := pkg.InstallDirName(projectRoot, customPlugins); ok { + composerTracked[dir] = struct{}{} + } + } + } + + orphans := make([]string, 0) + for _, entry := range entries { + if !entry.IsDir() { + continue + } + if strings.HasPrefix(entry.Name(), ".") { + continue + } + if _, tracked := composerTracked[entry.Name()]; tracked { + continue + } + orphans = append(orphans, entry.Name()) + } + + sort.Strings(orphans) + return orphans, nil +} diff --git a/internal/projectupgrade/plugins_test.go b/internal/projectupgrade/plugins_test.go new file mode 100644 index 00000000..f45d4139 --- /dev/null +++ b/internal/projectupgrade/plugins_test.go @@ -0,0 +1,71 @@ +package projectupgrade + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/shopware/shopware-cli/internal/packagist" +) + +func writeInstalledJSON(t *testing.T, projectDir string, packages []packagist.InstalledPackage) { + t.Helper() + + installedDir := filepath.Join(projectDir, "vendor", "composer") + require.NoError(t, os.MkdirAll(installedDir, 0o755)) + + data, err := json.MarshalIndent(packagist.InstalledJson{Packages: packages}, "", " ") + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(installedDir, "installed.json"), data, 0o644)) +} + +func TestFindNonComposerPluginsReportsUntrackedDirectories(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, "custom", "plugins", "TrackedPlugin"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(dir, "custom", "plugins", "UntrackedPlugin"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(dir, "custom", "plugins", "AnotherUntracked"), 0o755)) + + writeInstalledJSON(t, dir, []packagist.InstalledPackage{ + { + Name: "vendor/tracked", + Type: composerPluginType, + InstallPath: "../../custom/plugins/TrackedPlugin", + }, + }) + + orphans, err := FindNonComposerPlugins(dir) + require.NoError(t, err) + assert.Equal(t, []string{"AnotherUntracked", "UntrackedPlugin"}, orphans) +} + +func TestFindNonComposerPluginsNoCustomPluginsDirectory(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + orphans, err := FindNonComposerPlugins(dir) + require.NoError(t, err) + assert.Empty(t, orphans) +} + +func TestFindNonComposerPluginsAllTracked(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, "custom", "plugins", "A"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(dir, "custom", "plugins", "B"), 0o755)) + + writeInstalledJSON(t, dir, []packagist.InstalledPackage{ + {Name: "vendor/a", Type: composerPluginType, InstallPath: "../../custom/plugins/A"}, + {Name: "vendor/b", Type: composerPluginType, InstallPath: "../../custom/plugins/B"}, + }) + + orphans, err := FindNonComposerPlugins(dir) + require.NoError(t, err) + assert.Empty(t, orphans) +} diff --git a/internal/projectupgrade/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/releases.go b/internal/projectupgrade/releases.go new file mode 100644 index 00000000..c2c44f36 --- /dev/null +++ b/internal/projectupgrade/releases.go @@ -0,0 +1,70 @@ +package projectupgrade + +import ( + "sort" + + "github.com/shyim/go-version" +) + +// branch identifies a Shopware release branch by its major and minor segment +// (e.g. 6.6). Shopware ships feature releases on the minor segment, so the +// "next" branch is the current one with its minor incremented. +type branch struct { + major int + minor int +} + +func branchOf(v *version.Version) branch { + return branch{major: v.Major(), minor: v.Minor()} +} + +func (b branch) next() branch { + return branch{major: b.major, minor: b.minor + 1} +} + +// FilterUpdateVersions returns the upgrade target versions appropriate for +// currentVersion: the next branch's releases first (newest first), followed +// by the remaining patches of the current branch. This mirrors the version +// filtering applied by `ReleaseInfoProvider::fetchUpdateVersions` in +// shopware/web-installer. +// +// Pre-releases (RC/beta/alpha) and any version older than or equal to +// currentVersion are dropped. +func FilterUpdateVersions(currentVersion *version.Version, allVersions []string) []string { + parsed := make([]*version.Version, 0, len(allVersions)) + + for _, raw := range allVersions { + v, err := version.NewVersion(raw) + if err != nil { + continue + } + + if v.IsPrerelease() { + continue + } + + if !v.GreaterThan(currentVersion) { + continue + } + + parsed = append(parsed, v) + } + + sort.Slice(parsed, func(i, j int) bool { + return parsed[i].GreaterThan(parsed[j]) + }) + + byBranch := map[branch][]string{} + for _, v := range parsed { + b := branchOf(v) + byBranch[b] = append(byBranch[b], v.String()) + } + + currentBranch := branchOf(currentVersion) + + result := make([]string, 0) + result = append(result, byBranch[currentBranch.next()]...) + result = append(result, byBranch[currentBranch]...) + + return result +} diff --git a/internal/projectupgrade/releases_test.go b/internal/projectupgrade/releases_test.go new file mode 100644 index 00000000..80438ba5 --- /dev/null +++ b/internal/projectupgrade/releases_test.go @@ -0,0 +1,59 @@ +package projectupgrade + +import ( + "testing" + + "github.com/shyim/go-version" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFilterUpdateVersionsReturnsCurrentAndNextMajor(t *testing.T) { + t.Parallel() + + current, err := version.NewVersion("6.5.8.0") + require.NoError(t, err) + + all := []string{ + "6.4.20.0", // older, dropped + "6.5.8.0", // equal, dropped + "6.5.8.1", + "6.5.9.0", + "6.6.0.0", + "6.6.1.0", + "6.7.0.0", // two majors away, dropped + } + + result := FilterUpdateVersions(current, all) + + // next major (6.6) versions come first, descending, then current major (6.5) versions descending. + assert.Equal(t, []string{"6.6.1.0", "6.6.0.0", "6.5.9.0", "6.5.8.1"}, result) +} + +func TestFilterUpdateVersionsDropsReleaseCandidates(t *testing.T) { + t.Parallel() + + current, err := version.NewVersion("6.5.8.0") + require.NoError(t, err) + + all := []string{ + "6.5.9.0", + "6.6.0.0-rc1", + "6.6.0.0-RC2", + "6.6.0.0", + } + + result := FilterUpdateVersions(current, all) + assert.Equal(t, []string{"6.6.0.0", "6.5.9.0"}, result) +} + +func TestFilterUpdateVersionsReturnsEmptyWhenLatest(t *testing.T) { + t.Parallel() + + current, err := version.NewVersion("6.6.5.0") + require.NoError(t, err) + + all := []string{"6.5.0.0", "6.5.8.0", "6.6.5.0"} + result := FilterUpdateVersions(current, all) + assert.Empty(t, result) +} diff --git a/internal/projectupgrade/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 new file mode 100644 index 00000000..bfb940ae --- /dev/null +++ b/internal/projectupgrade/wizard.go @@ -0,0 +1,898 @@ +package projectupgrade + +import ( + "bufio" + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "charm.land/bubbles/v2/spinner" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/shyim/go-version" + + "github.com/shopware/shopware-cli/internal/executor" + "github.com/shopware/shopware-cli/internal/flexmigrator" + "github.com/shopware/shopware-cli/internal/packagist" + "github.com/shopware/shopware-cli/internal/tui" +) + +// streamBufferSize is the buffered channel size used when streaming +// subprocess output to the wizard. +const streamBufferSize = 50 + +// maxLogLines caps how many output lines we keep in the running phase so the +// view doesn't grow unbounded. +const maxLogLines = 18 + +// maxVisibleVersions caps how many upgrade targets the version select list +// shows at once so the card keeps a fixed height when many versions exist. +const maxVisibleVersions = 10 + +// 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 + ComposerJSONPath string + 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 + phasePrepare + phaseReview + phaseRunning + phaseDone +) + +type taskStatus int + +const ( + taskPending taskStatus = iota + taskRunning + taskDone + taskFailed + taskSkipped +) + +// task tracks one of the upgrade stages displayed in the running phase. +type task struct { + label string + status taskStatus + detail string +} + +// Stable indices into model.tasks. The flow is: rewrite composer.json, +// have composer pull the new vendor code, then let shopware-deployment-helper +// drive the install/update lifecycle (system:update:prepare, migrations, +// system:update:finish, theme compile, etc.) in one pass. +const ( + taskCleanup = iota + taskPlugins + taskComposerJSON + taskComposerUpdate + taskDeploymentHelper +) + +// wizardMsg variants advance the upgrade state machine. +type ( + preflightDoneMsg struct { + results []PreflightResult + } + compatLoadedMsg struct { + report CompatReport + queue []ExtensionRow + phpRequirement string + err error + } + reportWrittenMsg struct { + path string + err error + } + taskCompleteMsg struct { + task int + err error + detail string + pluginActions *ResolveResult + // output is the full captured subprocess output, set for streaming + // tasks so the complete log survives independent of log-line event + // ordering. Used to render the error tail on failure. + output []string + } + startNextTaskMsg struct{} + logLineMsg string + logDoneMsg struct{} +) + +// wizardModel is a small standalone bubbletea Program that walks the user +// through the Shopware upgrade in the same visual idiom as devtui's setup +// guide and install wizard. +type wizardModel struct { + opts WizardOptions + + phase phase + + 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 + tasks []task + currentTask int + logLines []string + fullLog []string + logChan chan string + finalErr error + finished bool + spinner spinner.Model + 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 + // 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 + // 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) { + 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)), + ) + + 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} + } + + return wizardModel{ + opts: opts, + phase: phaseWelcome, + confirmYes: true, + versionList: tui.NewSelectList( + "Select target version", + "Pick the Shopware version to upgrade to. Next-major releases are listed first.", + versionOptions, + maxVisibleVersions, + ), + spinner: s, + tasks: defaultTasks(), + markedRemove: map[string]bool{}, + } +} + +// ErrCancelled is returned when the user exits the wizard before the upgrade +// completes (e.g. via q / ctrl+c or selecting the cancel button). +var ErrCancelled = errors.New("upgrade cancelled by user") + +func defaultTasks() []task { + return []task{ + {label: "Clean up stale recipe files"}, + {label: "Resolve incompatible custom plugins"}, + {label: "Rewrite composer.json"}, + {label: "composer update --with-all-dependencies"}, + {label: "vendor/bin/shopware-deployment-helper run"}, + } +} + +func (m wizardModel) Init() tea.Cmd { + return m.spinner.Tick +} + +func (m wizardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + return m, nil + + case tea.KeyPressMsg: + return m.updateKey(msg) + + case spinner.TickMsg: + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + return m, cmd + + case 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.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: + return m.startTask() + + case taskCompleteMsg: + if msg.pluginActions != nil { + m.pluginActions = msg.pluginActions + } + if msg.output != nil { + m.fullLog = msg.output + } + if msg.task < len(m.tasks) { + if msg.err != nil { + m.tasks[msg.task].status = taskFailed + m.tasks[msg.task].detail = msg.err.Error() + } else { + m.tasks[msg.task].status = taskDone + if msg.detail != "" { + m.tasks[msg.task].detail = msg.detail + } + } + } + if msg.err != nil { + m.finalErr = msg.err + m.finished = true + m.phase = phaseDone + return m, nil + } + m.currentTask++ + if m.currentTask >= len(m.tasks) { + m.finished = true + m.phase = phaseDone + return m, nil + } + return m, func() tea.Msg { return startNextTaskMsg{} } + + case logLineMsg: + m.appendLog(string(msg)) + return m, m.readNextLog() + + case logDoneMsg: + m.logChan = nil + return m, nil + } + + return m, nil +} + +// 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" { + if m.cancelExecution != nil { + m.cancelExecution() + } + return m, tea.Quit + } + + switch m.phase { + case phaseWelcome: + return m.updateWelcome(key) + case phasePreflight: + return m.updatePreflight(key) + case phaseSelectVersion: + return m.updateSelectVersion(key) + case phasePrepare: + return m.updatePrepare(key) + case phaseReview: + return m.updateReview(key) + case phaseRunning: + return m, nil + case phaseDone: + switch key { + case "e": + return m, m.writeReport() + case "q", "enter", "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 + } + 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 + } + return m, nil +} + +func (m wizardModel) updateSelectVersion(key string) (tea.Model, tea.Cmd) { + if m.versionList.HandleKey(key) { + return m, nil + } + + switch key { + case "q", "esc": + return m, tea.Quit + case "enter": + selected, ok := m.versionList.Selected() + if !ok { + return m, nil + } + m.targetVersion = selected.Label + return m.startCompatCheck() + } + return m, nil +} + +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 "up", "k": + if m.extCursor > 0 { + m.extCursor-- + } + case "down", "j": + if m.extCursor < len(m.extQueue)-1 { + m.extCursor++ + } + case "enter": + 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": + m.confirmYes = true + case "right", "l": + m.confirmYes = false + case "tab": + m.confirmYes = !m.confirmYes + case "e": + return m, m.writeReport() + case "q", "esc": + return m, tea.Quit + case "enter": + if !m.confirmYes { + return m, tea.Quit + } + m.phase = phaseRunning + m.currentTask = 0 + return m, func() tea.Msg { return startNextTaskMsg{} } + } + return m, nil +} + +func (m *wizardModel) appendLog(line string) { + // fullLog keeps the complete subprocess output so it can be surfaced on + // failure (both in the done view and printed to the terminal after the + // alt-screen tears down). logLines is the capped window shown live. + m.fullLog = append(m.fullLog, line) + m.logLines = append(m.logLines, line) + if len(m.logLines) > maxLogLines { + m.logLines = m.logLines[len(m.logLines)-maxLogLines:] + } +} + +func (m wizardModel) readNextLog() tea.Cmd { + ch := m.logChan + if ch == nil { + return nil + } + return func() tea.Msg { + line, ok := <-ch + if !ok { + return logDoneMsg{} + } + return logLineMsg(line) + } +} + +// loadCompatibility asks composer (dry run) whether the project can be +// upgraded to the target version 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 + exec := m.opts.Executor + + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + report, err := DryRunRequire(ctx, exec, composerJsonPath, targetVersion) + if err != nil { + return compatLoadedMsg{err: err} + } + + 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} + } +} + +func (m wizardModel) startTask() (tea.Model, tea.Cmd) { + if m.currentTask >= len(m.tasks) { + m.finished = true + m.phase = phaseDone + return m, nil + } + + m.tasks[m.currentTask].status = taskRunning + + switch m.currentTask { + case taskCleanup: + return m, m.runCleanup() + case taskPlugins: + return m, m.runRemovePlugins() + case taskComposerJSON: + return m, m.runUpdateComposer() + case taskComposerUpdate: + return m.startComposerUpdate() + case taskDeploymentHelper: + return m.startDeploymentHelper() + } + + return m, nil +} + +func (m wizardModel) runCleanup() tea.Cmd { + projectRoot := m.opts.ProjectRoot + idx := taskCleanup + return func() tea.Msg { + if err := flexmigrator.CleanupByHash(projectRoot); err != nil { + return taskCompleteMsg{task: idx, err: err} + } + return taskCompleteMsg{task: idx} + } +} + +func (m wizardModel) runRemovePlugins() tea.Cmd { + composerJSONPath := m.opts.ComposerJSONPath + target := m.targetVersion + idx := taskPlugins + exec := m.opts.Executor + removals := m.plannedRemovals() + + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + 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 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 plugin(s)", removed) + } + return taskCompleteMsg{task: idx, detail: detail, pluginActions: result} + } +} + +func (m wizardModel) runUpdateComposer() tea.Cmd { + composerJSONPath := m.opts.ComposerJSONPath + target := m.targetVersion + idx := taskComposerJSON + return func() tea.Msg { + if err := UpdateComposerJson(composerJSONPath, target); err != nil { + return taskCompleteMsg{task: idx, err: err} + } + return taskCompleteMsg{task: idx, detail: "pinned to " + target} + } +} + +func (m wizardModel) startComposerUpdate() (tea.Model, tea.Cmd) { + ctx, cancel := context.WithCancel(context.Background()) + m.cancelExecution = cancel + + ch := make(chan string, streamBufferSize) + m.logChan = ch + m.logLines = nil + m.fullLog = nil + + args := []string{ + "update", + "--no-interaction", + "--no-scripts", + "--with-all-dependencies", + "-v", + } + p := m.opts.Executor.ComposerCommand(ctx, args...) + + idx := taskComposerUpdate + + doneCmd := func() tea.Msg { + output, err := streamCmdOutput(p.Cmd, ch, true) + return taskCompleteMsg{task: idx, err: err, output: output} + } + + return m, tea.Batch(m.readNextLog(), doneCmd) +} + +func (m wizardModel) startDeploymentHelper() (tea.Model, tea.Cmd) { + ctx, cancel := context.WithCancel(context.Background()) + m.cancelExecution = cancel + + ch := make(chan string, streamBufferSize) + m.logChan = ch + m.logLines = nil + m.fullLog = nil + + p := m.opts.Executor.PHPCommand(ctx, "vendor/bin/shopware-deployment-helper", "run") + + idx := taskDeploymentHelper + + doneCmd := func() tea.Msg { + output, err := streamCmdOutput(p.Cmd, ch, true) + return taskCompleteMsg{task: idx, err: err, output: output} + } + + return m, tea.Batch(m.readNextLog(), doneCmd) +} + +// streamCmdOutput starts cmd, fans stdout (or stderr) lines into ch, and +// closes ch when done. It also returns the complete captured output so the +// caller can surface it on failure regardless of how many lines the live +// view kept. The returned error is the process exit error, if any. +func streamCmdOutput(cmd *exec.Cmd, ch chan<- string, useStdout bool) ([]string, error) { + var pipe io.Reader + var err error + if useStdout { + pipe, err = cmd.StdoutPipe() + if err == nil { + cmd.Stderr = cmd.Stdout + } + } else { + pipe, err = cmd.StderrPipe() + if err == nil { + cmd.Stdout = cmd.Stderr + } + } + if err != nil { + close(ch) + return nil, err + } + + if err := cmd.Start(); err != nil { + close(ch) + return nil, err + } + + var captured []string + scanner := bufio.NewScanner(pipe) + scanner.Buffer(make([]byte, 64*1024), 1024*1024) + for scanner.Scan() { + line := scanner.Text() + captured = append(captured, line) + ch <- line + } + close(ch) + + if err := scanner.Err(); err != nil { + _ = cmd.Wait() + return captured, err + } + return captured, cmd.Wait() +} diff --git a/internal/projectupgrade/wizard_test.go b/internal/projectupgrade/wizard_test.go new file mode 100644 index 00000000..33b94c5f --- /dev/null +++ b/internal/projectupgrade/wizard_test.go @@ -0,0 +1,448 @@ +package projectupgrade + +import ( + "os" + "path/filepath" + "testing" + + tea "charm.land/bubbletea/v2" + "github.com/shyim/go-version" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/shopware/shopware-cli/internal/tui" +) + +func newTestModel(t *testing.T) wizardModel { + t.Helper() + return newTestModelWithVersions(t, []string{"6.6.4.0", "6.6.3.0", "6.5.9.0"}) +} + +func newTestModelWithVersions(t *testing.T, versions []string) wizardModel { + t.Helper() + + current, err := version.NewVersion("6.5.8.0") + require.NoError(t, err) + + opts := make([]tui.SelectOption, len(versions)) + for i, v := range versions { + opts[i] = tui.SelectOption{Label: v} + } + + m := wizardModel{ + opts: WizardOptions{ + ProjectRoot: "/tmp/example", + ComposerJSONPath: "/tmp/example/composer.json", + CurrentVersion: current, + UpdateVersions: versions, + }, + phase: phaseWelcome, + confirmYes: true, + versionList: tui.NewSelectList("Select target version", "", opts, maxVisibleVersions), + tasks: defaultTasks(), + markedRemove: map[string]bool{}, + } + return m +} + +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, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + wm := updated.(wizardModel) + 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) { + 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 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) { + t.Parallel() + + m := newTestModel(t) + m.phase = phaseSelectVersion + + updated, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyDown}) + wm := updated.(wizardModel) + assert.Equal(t, 1, wm.versionList.Cursor()) +} + +func TestWizardSelectVersionGoesToPrepare(t *testing.T) { + t.Parallel() + + m := newTestModel(t) + m.phase = phaseSelectVersion + + updated, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + wm := updated.(wizardModel) + assert.Equal(t, phasePrepare, wm.phase) + assert.True(t, wm.compatLoading) + assert.Equal(t, "6.6.4.0", wm.targetVersion) +} + +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 = phasePrepare + m.compatLoading = true + + 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"}, + {Name: "swag/ok", Current: "1.0.0", Target: "1.0.0", State: ExtensionOK}, + }, + }) + wm := updated.(wizardModel) + assert.False(t, wm.compatLoading) + 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 TestWizardPrepareReadyContinuesToReview(t *testing.T) { + t.Parallel() + + m := newTestModel(t) + 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.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) { + t.Parallel() + + // composer update must rewrite vendor before shopware-deployment-helper + // runs the install/update lifecycle that drives system:update:prepare, + // migrations, system:update:finish and theme compilation. + assert.Less(t, taskComposerJSON, taskComposerUpdate, "composer.json must be rewritten before composer update") + assert.Less(t, taskComposerUpdate, taskDeploymentHelper, "deployment-helper runs after composer update") + + tasks := defaultTasks() + require.Len(t, tasks, taskDeploymentHelper+1) + assert.Equal(t, "composer update --with-all-dependencies", tasks[taskComposerUpdate].label) + assert.Equal(t, "vendor/bin/shopware-deployment-helper run", tasks[taskDeploymentHelper].label) +} + +func TestWizardTaskCompleteErrorEndsRun(t *testing.T) { + t.Parallel() + + m := newTestModel(t) + m.phase = phaseRunning + m.currentTask = taskComposerUpdate + + updated, _ := m.Update(taskCompleteMsg{ + task: taskComposerUpdate, + err: assertErr("exit status 2"), + output: []string{"Loading composer repositories", "Your requirements could not be resolved"}, + }) + wm := updated.(wizardModel) + assert.Equal(t, phaseDone, wm.phase) + assert.True(t, wm.finished) + require.Error(t, wm.finalErr) + assert.Equal(t, taskFailed, wm.tasks[taskComposerUpdate].status) + + // The full subprocess output must be retained so the user can see what + // actually failed instead of just "exit status 2". + assert.Equal(t, []string{"Loading composer repositories", "Your requirements could not be resolved"}, wm.fullLog) + + out := wm.viewContent() + assert.Contains(t, out, "Your requirements could not be resolved", "failed step output should be shown on the done screen") +} + +func 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() + + m := newTestModel(t) + total := maxLogLines + 5 + for i := 0; i < total; i++ { + updated, _ := m.Update(logLineMsg("line")) + m = updated.(wizardModel) + } + assert.LessOrEqual(t, len(m.logLines), maxLogLines, "live view stays capped") + assert.Len(t, m.fullLog, total, "full log keeps every line for failure reporting") +} + +func TestLastLines(t *testing.T) { + t.Parallel() + + assert.Equal(t, []string{"c", "d"}, lastLines([]string{"a", "b", "c", "d"}, 2)) + assert.Equal(t, []string{"a", "b"}, lastLines([]string{"a", "b"}, 5), "fewer than n returns all") + assert.Nil(t, lastLines(nil, 3)) +} + +type testError string + +func (e testError) Error() string { return string(e) } + +func assertErr(s string) error { return testError(s) } + +func 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() + + phases := []phase{ + phaseWelcome, + phasePreflight, + phaseSelectVersion, + phasePrepare, + phaseReview, + phaseRunning, + phaseDone, + } + + for _, p := range phases { + t.Run(t.Name(), func(t *testing.T) { + t.Parallel() + m := newTestModel(t) + m.phase = p + m.targetVersion = "6.6.4.0" + out := m.viewContent() + assert.NotEmpty(t, out, "phase %d should render content", p) + + 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..17a333ec --- /dev/null +++ b/internal/projectupgrade/wizard_views.go @@ -0,0 +1,758 @@ +package projectupgrade + +import ( + "fmt" + "path/filepath" + "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 + 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. +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) 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(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(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(tui.TitleStyle.Render("Prepare upgrade")) + 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(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") + 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(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(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 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:] +}