From 64d33337339dd666140a38a00fd4abd0d57c29b5 Mon Sep 17 00:00:00 2001
From: Soner Sayakci
Date: Wed, 29 Jul 2026 11:14:17 +0200
Subject: [PATCH 01/11] fix: cr review
---
cmd/project/project_autofix_composer.go | 2 +-
cmd/project/project_upgrade.go | 20 ++++-----
internal/shop/pluginmigrate/migrator.go | 19 ++++-----
internal/shop/upgrade/compat.go | 14 +++++--
internal/shop/upgrade/composerjson.go | 21 +++++++---
internal/shop/upgrade/composerjson_test.go | 1 +
internal/shop/upgrade/readiness.go | 2 +-
internal/shop/upgrade/run.go | 47 +++++-----------------
internal/shop/upgrade/versions.go | 11 ++---
internal/tui/app/app.go | 25 ++++++++++--
internal/tui/app/layout.go | 4 +-
internal/tui/credentialstep.go | 5 +++
internal/tui/dev/migration_wizard.go | 4 +-
internal/tui/filter_multi_select.go | 5 ++-
internal/tui/filterlist.go | 9 ++++-
internal/tui/modal.go | 11 +++--
internal/tui/pluginmigrate/messages.go | 5 +++
internal/tui/steplist.go | 5 ++-
internal/tui/stream.go | 21 ++++++----
internal/tui/task.go | 43 ++++++++++++++++----
internal/tui/task_test.go | 36 ++++++++++++-----
internal/tui/textfit.go | 7 +++-
internal/tui/twocolumn.go | 10 ++---
internal/tui/upgrade/chrome.go | 5 +++
internal/tui/upgrade/panel_check.go | 12 ++++--
internal/tui/upgrade/panel_done.go | 3 +-
internal/tui/upgrade/panel_prepare.go | 22 ++++++++--
internal/tui/upgrade/panel_review.go | 6 ++-
internal/tui/upgrade/panel_run.go | 2 +
29 files changed, 249 insertions(+), 128 deletions(-)
diff --git a/cmd/project/project_autofix_composer.go b/cmd/project/project_autofix_composer.go
index f946d2a7..55cd0832 100644
--- a/cmd/project/project_autofix_composer.go
+++ b/cmd/project/project_autofix_composer.go
@@ -32,7 +32,7 @@ var projectAutofixComposerCmd = &cobra.Command{
return pluginmigrate.NewPluginMigrator(projectRoot, exec).RunHeadless(cmd.Context(), pluginmigrate.HeadlessOptions{
Token: os.Getenv("SHOPWARE_PACKAGIST_TOKEN"),
DryRun: dryRun,
- Out: os.Stdout,
+ Out: cmd.OutOrStdout(),
})
}
diff --git a/cmd/project/project_upgrade.go b/cmd/project/project_upgrade.go
index 07863764..a8f4b9e6 100644
--- a/cmd/project/project_upgrade.go
+++ b/cmd/project/project_upgrade.go
@@ -21,16 +21,6 @@ var projectUpgradeCmd = &cobra.Command{
return err
}
- cfg, err := shop.ReadConfig(cmd.Context(), projectConfigPath, true)
- if err != nil {
- return err
- }
-
- envCfg, err := cfg.ResolveEnvironment(environmentName)
- if err != nil {
- return err
- }
-
exec, err := resolveExecutor(cmd, projectRoot)
if err != nil {
return err
@@ -49,6 +39,16 @@ var projectUpgradeCmd = &cobra.Command{
})
}
+ cfg, err := shop.ReadConfig(cmd.Context(), projectConfigPath, true)
+ if err != nil {
+ return err
+ }
+
+ envCfg, err := cfg.ResolveEnvironment(environmentName)
+ if err != nil {
+ return err
+ }
+
envName := environmentName
if envName == "" {
envName = envCfg.Type
diff --git a/internal/shop/pluginmigrate/migrator.go b/internal/shop/pluginmigrate/migrator.go
index 3e50742e..cee4e221 100644
--- a/internal/shop/pluginmigrate/migrator.go
+++ b/internal/shop/pluginmigrate/migrator.go
@@ -148,9 +148,11 @@ func (m *PluginMigrator) projectRepositories() *repository.Set {
}
auth, err := composer.ReadAuth(filepath.Join(m.projectRoot, "auth.json"))
- if err == nil {
- _ = auth.MergeEnv()
+ if err != nil {
+ // An unreadable auth.json must not drop COMPOSER_AUTH credentials.
+ auth = &composer.Auth{}
}
+ _ = auth.MergeEnv()
return repository.FromComposer(composerJSON, auth, true)
}
@@ -180,7 +182,7 @@ func (m *PluginMigrator) Scan(ctx context.Context) []ScannedExtension {
if resolved, err := filepath.EvalSymlinks(root); err == nil {
root = resolved
}
- customDir := filepath.Join(root, "custom") + string(filepath.Separator)
+ customDir := filepath.Join(root, "custom")
var result []ScannedExtension
for _, ext := range found {
@@ -191,7 +193,8 @@ func (m *PluginMigrator) Scan(ctx context.Context) []ScannedExtension {
if resolved, err := filepath.EvalSymlinks(extPath); err == nil {
extPath = resolved
}
- if !strings.HasPrefix(extPath, customDir) {
+ rel, err := filepath.Rel(customDir, extPath)
+ if err != nil || rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
continue
}
@@ -201,11 +204,9 @@ func (m *PluginMigrator) Scan(ctx context.Context) []ScannedExtension {
}
scanned := ScannedExtension{
- Name: name,
- Path: extPath,
- }
- if rel, err := filepath.Rel(root, extPath); err == nil {
- scanned.RelPath = filepath.ToSlash(rel)
+ Name: name,
+ Path: extPath,
+ RelPath: filepath.ToSlash(filepath.Join("custom", rel)),
}
if composerName, err := ext.GetComposerName(); err == nil {
scanned.ComposerName = composerName
diff --git a/internal/shop/upgrade/compat.go b/internal/shop/upgrade/compat.go
index f8b1d1ba..627a7310 100644
--- a/internal/shop/upgrade/compat.go
+++ b/internal/shop/upgrade/compat.go
@@ -51,6 +51,10 @@ func (u *ProjectUpgrader) CheckExtensions(ctx context.Context, current, target *
// loadStoreStatus queries the Shopware Store update check. Store metadata is
// advisory, so failures degrade to an empty map instead of erroring.
func (u *ProjectUpgrader) loadStoreStatus(ctx context.Context, current, target *version.Version, extensions []InstalledExtension) map[string]account_api.UpdateCheckExtensionCompatibility {
+ if current == nil || target == nil {
+ return nil
+ }
+
toCheck := make([]account_api.UpdateCheckExtension, 0, len(extensions))
for _, ext := range extensions {
if !ext.ComposerManaged {
@@ -90,10 +94,12 @@ func (u *ProjectUpgrader) projectRepositories(ctx context.Context) *repository.S
}
auth, err := composer.ReadAuth(filepath.Join(u.projectRoot, "auth.json"))
- if err == nil {
- if mergeErr := auth.MergeEnv(); mergeErr != nil {
- logging.FromContext(ctx).Debugf("merge COMPOSER_AUTH: %v", mergeErr)
- }
+ if err != nil {
+ // An unreadable auth.json must not drop COMPOSER_AUTH credentials.
+ auth = &composer.Auth{}
+ }
+ if mergeErr := auth.MergeEnv(); mergeErr != nil {
+ logging.FromContext(ctx).Debugf("merge COMPOSER_AUTH: %v", mergeErr)
}
return u.repositories(composerJSON, auth)
diff --git a/internal/shop/upgrade/composerjson.go b/internal/shop/upgrade/composerjson.go
index 34e7eb9e..96a27c36 100644
--- a/internal/shop/upgrade/composerjson.go
+++ b/internal/shop/upgrade/composerjson.go
@@ -60,7 +60,7 @@ func applyTargetConstraints(c *composer.Json, target string, extensionPackages [
c.Require[pkg] = constraint
}
- if !c.HasPackage(deploymentHelperPackage) {
+ if !c.HasPackage(deploymentHelperPackage) && !c.HasPackageDev(deploymentHelperPackage) {
c.AddPackage(deploymentHelperPackage, "*")
changes = append(changes, deploymentHelperPackage+": added")
}
@@ -70,10 +70,10 @@ func applyTargetConstraints(c *composer.Json, target string, extensionPackages [
// extensionPackages lists the Composer-managed Shopware extensions
// (plugins, apps, bundles) recorded in composer.lock.
-func extensionPackages(projectRoot string) []string {
+func extensionPackages(projectRoot string) ([]string, error) {
lock, err := composer.ReadLock(filepath.Join(projectRoot, "composer.lock"))
if err != nil {
- return nil
+ return nil, fmt.Errorf("read composer.lock: %w", err)
}
var packages []string
@@ -83,7 +83,7 @@ func extensionPackages(projectRoot string) []string {
packages = append(packages, pkg.Name)
}
}
- return packages
+ return packages, nil
}
// RewriteComposerJSON applies the target constraints to the project's real
@@ -96,7 +96,12 @@ func (u *ProjectUpgrader) RewriteComposerJSON(target string, resolved map[string
return nil, err
}
- changes := applyTargetConstraints(c, target, extensionPackages(u.projectRoot), resolved)
+ extensions, err := extensionPackages(u.projectRoot)
+ if err != nil {
+ return nil, err
+ }
+
+ changes := applyTargetConstraints(c, target, extensions, resolved)
if err := c.Save(); err != nil {
return nil, err
}
@@ -113,7 +118,11 @@ func (u *ProjectUpgrader) renderUpgradeManifest(target string) ([]byte, error) {
return nil, err
}
- applyTargetConstraints(c, target, extensionPackages(u.projectRoot), nil)
+ extensions, err := extensionPackages(u.projectRoot)
+ if err != nil {
+ return nil, err
+ }
+ applyTargetConstraints(c, target, extensions, nil)
out, err := json.MarshalIndent(c, "", " ")
if err != nil {
diff --git a/internal/shop/upgrade/composerjson_test.go b/internal/shop/upgrade/composerjson_test.go
index 2394f476..8083c213 100644
--- a/internal/shop/upgrade/composerjson_test.go
+++ b/internal/shop/upgrade/composerjson_test.go
@@ -59,6 +59,7 @@ func TestRenderUpgradeManifestLeavesProjectUntouched(t *testing.T) {
"require": {"shopware/core": "6.6.10.3", "shopware/deployment-helper": "*"}
}`
writeFile(t, filepath.Join(dir, "composer.json"), original)
+ writeFile(t, filepath.Join(dir, "composer.lock"), `{"packages": [], "packages-dev": []}`)
manifest, err := newTestUpgrader(t, dir).renderUpgradeManifest("6.7.11.0")
require.NoError(t, err)
diff --git a/internal/shop/upgrade/readiness.go b/internal/shop/upgrade/readiness.go
index fe7f3ea7..e8298321 100644
--- a/internal/shop/upgrade/readiness.go
+++ b/internal/shop/upgrade/readiness.go
@@ -124,7 +124,7 @@ func checkDeploymentHelper(projectRoot string) ReadinessCheck {
return check
}
- if _, ok := composerJSON.Require[deploymentHelperPackage]; !ok {
+ if !composerJSON.HasPackage(deploymentHelperPackage) && !composerJSON.HasPackageDev(deploymentHelperPackage) {
check.State = StateWarn
check.Value = "no"
check.Detail = "shopware/deployment-helper is not required; the wizard adds it during the upgrade."
diff --git a/internal/shop/upgrade/run.go b/internal/shop/upgrade/run.go
index 429417aa..5b6d79b6 100644
--- a/internal/shop/upgrade/run.go
+++ b/internal/shop/upgrade/run.go
@@ -6,10 +6,10 @@ import (
"fmt"
"os"
"path/filepath"
- "sync"
"time"
"github.com/shopware/shopware-cli/internal/executor"
+ "github.com/shopware/shopware-cli/internal/tui"
)
// StepID identifies one step of the upgrade execution (panel 5's checklist).
@@ -103,7 +103,13 @@ func (u *ProjectUpgrader) Run(ctx context.Context, opts RunOptions) <-chan StepE
} else if ev.State != StateRunning {
_, _ = fmt.Fprintf(logFile, "== %s: %s\n", ev.Step.Label(), stateName(ev.State, ev.Err))
}
- events <- ev
+ // Never block on a consumer that stopped reading (e.g. the TUI
+ // exited mid-run) — rollback and the failure report must still
+ // complete, and the goroutine must not leak.
+ select {
+ case events <- ev:
+ case <-ctx.Done():
+ }
}
if err := u.backup(); err != nil {
@@ -203,7 +209,7 @@ func (u *ProjectUpgrader) runSteps(ctx context.Context, opts *RunOptions, emit f
// while Stop signals the actual process inside the container — otherwise
// Composer keeps running there and races the rollback that follows.
func streamProcess(ctx context.Context, p *executor.Process, line func(string)) error {
- w := &lineWriter{emit: line}
+ w := tui.NewLineWriter(line)
err := p.RunWithOutput(w)
w.Flush()
@@ -321,38 +327,3 @@ func stateName(s CheckState, err error) string {
}
return "unknown"
}
-
-// lineWriter converts a byte stream into per-line emit calls.
-type lineWriter struct {
- mu sync.Mutex
- emit func(string)
- buf []byte
-}
-
-func (w *lineWriter) Write(p []byte) (int, error) {
- w.mu.Lock()
- defer w.mu.Unlock()
-
- w.buf = append(w.buf, p...)
- for {
- idx := bytes.IndexByte(w.buf, '\n')
- if idx < 0 {
- break
- }
- line := string(bytes.TrimRight(w.buf[:idx], "\r"))
- w.buf = w.buf[idx+1:]
- w.emit(line)
- }
- return len(p), nil
-}
-
-// Flush emits any trailing output that did not end in a newline.
-func (w *lineWriter) Flush() {
- w.mu.Lock()
- defer w.mu.Unlock()
-
- if len(w.buf) > 0 {
- w.emit(string(w.buf))
- w.buf = nil
- }
-}
diff --git a/internal/shop/upgrade/versions.go b/internal/shop/upgrade/versions.go
index bf0d7f08..1c4d309d 100644
--- a/internal/shop/upgrade/versions.go
+++ b/internal/shop/upgrade/versions.go
@@ -175,15 +175,12 @@ func supportLeft(until, now time.Time) string {
return ""
}
+ // Count months from the original date each iteration: chaining AddDate
+ // drifts at month ends (Jan 31 + 1 month normalizes to Mar 3, anchoring
+ // every following step to day 3 and undercounting).
months := 0
- cursor := now
- for {
- next := cursor.AddDate(0, 1, 0)
- if next.After(until) {
- break
- }
+ for !now.AddDate(0, months+1, 0).After(until) {
months++
- cursor = next
}
years := months / 12
diff --git a/internal/tui/app/app.go b/internal/tui/app/app.go
index 0454ed00..f637281e 100644
--- a/internal/tui/app/app.go
+++ b/internal/tui/app/app.go
@@ -149,8 +149,16 @@ func (a *App) TopOverlay() Overlay { return a.overlays.Top() }
// Content returns the current content.
func (a *App) Content() Content { return a.content }
-// SetContent replaces the main content without initializing it.
-func (a *App) SetContent(c Content) { a.content = c }
+// SetContent replaces the main content without initializing it. When the
+// terminal size is already known the new content is sized immediately —
+// otherwise it would render unsized until the next resize event.
+func (a *App) SetContent(c Content) {
+ a.content = c
+ if c != nil && a.width > 0 && a.height > 0 {
+ ctx := a.Context()
+ PropagateSize(c, ctx.Width, ctx.MainHeight)
+ }
+}
// RegisterCommand adds a command and optional key binding. Registering with a
// built-in ID (e.g. CmdQuit) replaces the built-in behavior.
@@ -173,6 +181,13 @@ func (a *App) RunCommand(id string) tea.Cmd {
// Context builds the current frame context.
func (a *App) Context() Context {
header, footer := a.chrome()
+ return a.contextFor(header, footer)
+}
+
+// contextFor builds the frame context from already-rendered chrome, so
+// callers that need the chrome strings themselves (View) evaluate the header
+// and footer functions only once per frame.
+func (a *App) contextFor(header, footer string) Context {
r := ComputeRegion(max(a.width, 1), max(a.height, 1), header, footer)
return Context{
Width: a.width,
@@ -271,14 +286,16 @@ func (a *App) View() tea.View {
return tea.NewView("")
}
- ctx := a.Context()
+ // Render the chrome once per frame; Context() would evaluate the header
+ // and footer functions a second time.
w, h := max(a.width, 1), max(a.height, 1)
+ header, footer := a.chrome()
+ ctx := a.contextFor(header, footer)
var body string
if a.overlays.Open() && a.fullOverlay {
body = a.overlays.View(w, h)
} else {
- header, footer := a.chrome()
main := ""
switch {
case a.overlays.Open():
diff --git a/internal/tui/app/layout.go b/internal/tui/app/layout.go
index 4d5956ef..39060266 100644
--- a/internal/tui/app/layout.go
+++ b/internal/tui/app/layout.go
@@ -58,7 +58,9 @@ func Frame(width, height int, header, main, footer string) string {
if r.Header > 0 {
parts = append(parts, fitHeight(header, r.Header, r.Width))
}
- parts = append(parts, fitHeight(main, r.Main, r.Width))
+ if r.Main > 0 {
+ parts = append(parts, fitHeight(main, r.Main, r.Width))
+ }
if r.Footer > 0 {
parts = append(parts, fitHeight(footer, r.Footer, r.Width))
}
diff --git a/internal/tui/credentialstep.go b/internal/tui/credentialstep.go
index 7a1237cf..adb06283 100644
--- a/internal/tui/credentialstep.go
+++ b/internal/tui/credentialstep.go
@@ -172,6 +172,11 @@ func (c *CredentialStep) HandleKey(msg tea.KeyPressMsg) (cmd tea.Cmd, submitted
return c.Focus(c.focus + 1), false
case KeyShiftTab, KeyUp:
return c.Focus(c.focus - 1), false
+ case "space", " ":
+ if c.focus == CredFocusShowPassword {
+ c.ToggleShowPassword()
+ return nil, false
+ }
}
return c.updateInput(msg), false
}
diff --git a/internal/tui/dev/migration_wizard.go b/internal/tui/dev/migration_wizard.go
index 77a46924..90218234 100644
--- a/internal/tui/dev/migration_wizard.go
+++ b/internal/tui/dev/migration_wizard.go
@@ -139,7 +139,9 @@ func (sg *migrationWizard) updateWelcome(msg tea.KeyPressMsg) (migrationWizard,
if sg.confirmYes {
sg.startedAt = time.Now()
sg.step = migrationStepAdminUser
- return *sg, sg.Focus(tui.CredFocusUsername)
+ // Focus must run before *sg is copied into the return value.
+ cmd := sg.Focus(tui.CredFocusUsername)
+ return *sg, cmd
}
return *sg, tea.Quit
}
diff --git a/internal/tui/filter_multi_select.go b/internal/tui/filter_multi_select.go
index 119652f2..baf33ce6 100644
--- a/internal/tui/filter_multi_select.go
+++ b/internal/tui/filter_multi_select.go
@@ -229,8 +229,11 @@ func (m *filterMultiSelectModel) render() string {
}
label := check + item.Label
if item.Detail != "" {
+ // Render label and detail as separate styled segments: nesting a
+ // styled detail inside the width-padded row style would reset the
+ // selection background before the trailing padding.
gap := max(innerWidth-lipgloss.Width(label)-lipgloss.Width(item.Detail), 1)
- b.WriteString(rowStyle.Render(label + strings.Repeat(" ", gap) + dStyle.Render(item.Detail)))
+ b.WriteString(rowStyle.UnsetWidth().Render(label+strings.Repeat(" ", gap)) + dStyle.Render(item.Detail))
} else {
b.WriteString(rowStyle.Render(label))
}
diff --git a/internal/tui/filterlist.go b/internal/tui/filterlist.go
index 2629e43d..b47c2d5b 100644
--- a/internal/tui/filterlist.go
+++ b/internal/tui/filterlist.go
@@ -151,8 +151,11 @@ func (l FilterList) View(width int) string {
rowStyle, dStyle = selectedStyle, selectedDetailStyle
}
if item.Detail != "" {
+ // Render label and detail as separate styled segments: nesting a
+ // styled detail inside the width-padded row style would reset the
+ // selection background before the trailing padding.
gap := max(width-lipgloss.Width(item.Label)-lipgloss.Width(item.Detail), 1)
- b.WriteString(rowStyle.Render(item.Label + strings.Repeat(" ", gap) + dStyle.Render(item.Detail)))
+ b.WriteString(rowStyle.UnsetWidth().Render(item.Label+strings.Repeat(" ", gap)) + dStyle.Render(item.Detail))
} else {
b.WriteString(rowStyle.Render(item.Label))
}
@@ -173,7 +176,9 @@ func (l FilterList) View(width int) string {
func (l *FilterList) applyFilter() {
query := strings.ToLower(l.filter.Value())
- l.filtered = l.filtered[:0]
+ // A fresh slice, not filtered[:0]: FilterList is copied by value, so
+ // reusing the backing array would corrupt other copies of the list.
+ l.filtered = make([]int, 0, len(l.opts.Items))
for i, item := range l.opts.Items {
if query == "" ||
strings.Contains(strings.ToLower(item.Label), query) ||
diff --git a/internal/tui/modal.go b/internal/tui/modal.go
index 57cf3423..4b8105b9 100644
--- a/internal/tui/modal.go
+++ b/internal/tui/modal.go
@@ -28,9 +28,14 @@ func NewModal(opts ModalOptions) Modal {
// Width returns the modal box width.
func (m Modal) Width() int {
- width := m.opts.AreaWidth - 4
- if m.opts.MaxWidth > 0 && width > m.opts.MaxWidth {
- width = m.opts.MaxWidth
+ // Without a known area (e.g. before the first resize) fall back to
+ // MaxWidth instead of collapsing to the 1-column minimum.
+ width := m.opts.MaxWidth
+ if m.opts.AreaWidth > 0 {
+ areaCap := m.opts.AreaWidth - 4
+ if width <= 0 || areaCap < width {
+ width = areaCap
+ }
}
if width < 1 {
width = 1
diff --git a/internal/tui/pluginmigrate/messages.go b/internal/tui/pluginmigrate/messages.go
index 9f612e08..bb05681a 100644
--- a/internal/tui/pluginmigrate/messages.go
+++ b/internal/tui/pluginmigrate/messages.go
@@ -43,6 +43,11 @@ func fetchAvailabilityCmd(m *migrate.PluginMigrator, token string, extensions []
// readRunEventCmd pulls the next runner event; re-issue it after each event.
func readRunEventCmd(events <-chan migrate.StepEvent) tea.Cmd {
return func() tea.Msg {
+ // Receiving from a nil channel blocks forever and would leak the
+ // Bubble Tea command goroutine.
+ if events == nil {
+ return runClosedMsg{}
+ }
ev, ok := <-events
if !ok {
return runClosedMsg{}
diff --git a/internal/tui/steplist.go b/internal/tui/steplist.go
index 9e907312..fc20a22f 100644
--- a/internal/tui/steplist.go
+++ b/internal/tui/steplist.go
@@ -2,6 +2,8 @@ package tui
import (
"strings"
+
+ "charm.land/lipgloss/v2"
)
// StepState is the display state of one StepItem.
@@ -55,7 +57,8 @@ func (l StepList) renderStep(step StepItem) string {
case StepStatePending:
indicator = DimStyle.Render("·")
case StepStateActive:
- indicator = ""
+ // Default when the caller provides no spinner frame as Indicator.
+ indicator = lipgloss.NewStyle().Foreground(BrandColor).Render("◐")
}
}
return fixedIndicator(indicator) + step.Label + "\n"
diff --git a/internal/tui/stream.go b/internal/tui/stream.go
index 4e976a62..a8470d6c 100644
--- a/internal/tui/stream.go
+++ b/internal/tui/stream.go
@@ -72,17 +72,23 @@ func NewLineWriter(emit func(string)) *LineWriter {
// Write implements io.Writer.
func (w *LineWriter) Write(p []byte) (int, error) {
- w.mu.Lock()
- defer w.mu.Unlock()
+ var lines []string
+ w.mu.Lock()
w.buf = append(w.buf, p...)
for {
idx := bytes.IndexByte(w.buf, '\n')
if idx < 0 {
break
}
- line := string(bytes.TrimRight(w.buf[:idx], "\r"))
+ lines = append(lines, string(bytes.TrimRight(w.buf[:idx], "\r")))
w.buf = w.buf[idx+1:]
+ }
+ w.mu.Unlock()
+
+ // Emit outside the lock: a blocking emit (channel backpressure) must not
+ // hold up concurrent writers or Flush.
+ for _, line := range lines {
w.emit(line)
}
return len(p), nil
@@ -91,11 +97,12 @@ func (w *LineWriter) Write(p []byte) (int, error) {
// Flush emits any trailing output that did not end in a newline.
func (w *LineWriter) Flush() {
w.mu.Lock()
- defer w.mu.Unlock()
+ rest := string(w.buf)
+ w.buf = nil
+ w.mu.Unlock()
- if len(w.buf) > 0 {
- w.emit(string(w.buf))
- w.buf = nil
+ if rest != "" {
+ w.emit(rest)
}
}
diff --git a/internal/tui/task.go b/internal/tui/task.go
index 6e67ff77..0ed87069 100644
--- a/internal/tui/task.go
+++ b/internal/tui/task.go
@@ -13,11 +13,17 @@ const taskLogKeep = 400
// TaskLineMsg carries one line of a running task's output.
type TaskLineMsg struct{ Line string }
-// TaskDoneMsg is delivered when a task's command has finished.
+// TaskDoneMsg is delivered when a task's command has finished and its output
+// has been fully drained.
type TaskDoneMsg struct{ Err error }
-// taskStreamClosedMsg signals that the output channel drained; the final
-// TaskDoneMsg arrives from the runner itself.
+// taskExitMsg carries the command's exit result. The public TaskDoneMsg is
+// only emitted once the output stream has also drained — the exit result
+// usually arrives while buffered lines are still queued in the channel, and
+// finishing early would truncate the visible log.
+type taskExitMsg struct{ err error }
+
+// taskStreamClosedMsg signals that the output channel drained.
type taskStreamClosedMsg struct{}
// Task runs one command in the background and accumulates its streamed
@@ -30,6 +36,8 @@ type Task struct {
spinner spinner.Model
lines []string
ch <-chan string
+ exited bool
+ drained bool
done bool
err error
}
@@ -44,6 +52,8 @@ func NewTask(title string) Task {
// the spinner ticking; completion arrives as a TaskDoneMsg.
func (t *Task) Start(factory func() (*exec.Cmd, error)) tea.Cmd {
t.lines = nil
+ t.exited = false
+ t.drained = false
t.done = false
t.err = nil
@@ -54,9 +64,9 @@ func (t *Task) Start(factory func() (*exec.Cmd, error)) tea.Cmd {
cmd, err := factory()
if err != nil {
close(ch)
- return TaskDoneMsg{Err: err}
+ return taskExitMsg{err: err}
}
- return TaskDoneMsg{Err: StreamCmdOutput(cmd, ch, true)}
+ return taskExitMsg{err: StreamCmdOutput(cmd, ch, true)}
}
// The spinner tick (kept last) keeps the title animated so long-running
@@ -64,10 +74,21 @@ func (t *Task) Start(factory func() (*exec.Cmd, error)) tea.Cmd {
return tea.Batch(t.readLine(), run, t.spinner.Tick)
}
-func (t *Task) readLine() tea.Cmd {
+func (t Task) readLine() tea.Cmd {
return ReadLineCmd(t.ch, func(line string) tea.Msg { return TaskLineMsg{Line: line} }, taskStreamClosedMsg{})
}
+// finish emits TaskDoneMsg once both the exit result arrived and the output
+// stream drained, whichever came last.
+func (t *Task) finish() tea.Cmd {
+ if !t.exited || !t.drained || t.done {
+ return nil
+ }
+ t.done = true
+ err := t.err
+ return func() tea.Msg { return TaskDoneMsg{Err: err} }
+}
+
// Update handles the task's stream, completion, and spinner messages.
func (t Task) Update(msg tea.Msg) (Task, tea.Cmd) {
switch msg := msg.(type) {
@@ -76,9 +97,17 @@ func (t Task) Update(msg tea.Msg) (Task, tea.Cmd) {
return t, t.readLine()
case taskStreamClosedMsg:
- return t, nil
+ t.drained = true
+ return t, t.finish()
+
+ case taskExitMsg:
+ t.exited = true
+ t.err = msg.err
+ return t, t.finish()
case TaskDoneMsg:
+ // Normally self-emitted by finish (a no-op then); also accepted from
+ // the embedding model to force the final state.
t.done = true
t.err = msg.Err
return t, nil
diff --git a/internal/tui/task_test.go b/internal/tui/task_test.go
index 857582fb..18b98c3e 100644
--- a/internal/tui/task_test.go
+++ b/internal/tui/task_test.go
@@ -57,8 +57,8 @@ func TestTaskStart_FactoryErrorEmitsDoneMsg(t *testing.T) {
select {
case msg := <-results:
switch v := msg.(type) {
- case TaskDoneMsg:
- got["done"] = v
+ case taskExitMsg:
+ got["exit"] = v
case taskStreamClosedMsg:
got["stream-closed"] = v
}
@@ -67,11 +67,23 @@ func TestTaskStart_FactoryErrorEmitsDoneMsg(t *testing.T) {
}
}
- done, ok := got["done"].(TaskDoneMsg)
- require.True(t, ok, "expected TaskDoneMsg from one of the batched cmds")
- assert.Same(t, wantErr, done.Err)
+ exit, ok := got["exit"].(taskExitMsg)
+ require.True(t, ok, "expected taskExitMsg from one of the batched cmds")
+ assert.Same(t, wantErr, exit.err)
_, hasClosed := got["stream-closed"]
- assert.True(t, hasClosed, "expected the stream-closed msg from the reader cmd")
+ require.True(t, hasClosed, "expected the stream-closed msg from the reader cmd")
+
+ // TaskDoneMsg is only emitted once both the exit result arrived and the
+ // stream drained — regardless of the order the two messages land in.
+ task, cmd = task.Update(got["exit"])
+ assert.Nil(t, cmd, "no TaskDoneMsg before the stream drained")
+ assert.False(t, task.Done())
+ task, cmd = task.Update(got["stream-closed"])
+ require.NotNil(t, cmd)
+ done, ok := cmd().(TaskDoneMsg)
+ require.True(t, ok, "expected the emitted TaskDoneMsg")
+ assert.Same(t, wantErr, done.Err)
+ assert.True(t, task.Done())
// The spinner tick is the last batched cmd so long-running commands with
// no early output never look frozen.
@@ -99,15 +111,20 @@ func TestTask_StreamsLinesAndCompletes(t *testing.T) {
deadline := time.Now().Add(5 * time.Second)
for read != nil && time.Now().Before(deadline) {
msg := read()
+ var cmd tea.Cmd
+ task, cmd = task.Update(msg)
if _, closed := msg.(taskStreamClosedMsg); closed {
break
}
- task, read = task.Update(msg)
+ read = cmd
}
select {
case msg := <-runnerDone:
- task, _ = task.Update(msg)
+ var cmd tea.Cmd
+ task, cmd = task.Update(msg)
+ require.NotNil(t, cmd, "exit + drained stream must emit TaskDoneMsg")
+ assert.IsType(t, TaskDoneMsg{}, cmd())
case <-time.After(5 * time.Second):
t.Fatal("timed out waiting for the runner")
}
@@ -129,7 +146,8 @@ func TestTask_StatusTitleAndSpinnerStopWhenDone(t *testing.T) {
task := NewTask("Building...")
assert.NotEqual(t, "Building...", task.StatusTitle(), "running tasks carry the spinner prefix")
- task, _ = task.Update(TaskDoneMsg{})
+ task, _ = task.Update(taskExitMsg{})
+ task, _ = task.Update(taskStreamClosedMsg{})
assert.Equal(t, "Building...", task.StatusTitle())
_, cmd := task.Update(spinner.TickMsg{})
diff --git a/internal/tui/textfit.go b/internal/tui/textfit.go
index eca75208..19baa5ec 100644
--- a/internal/tui/textfit.go
+++ b/internal/tui/textfit.go
@@ -22,7 +22,10 @@ func Truncate(s string, width int) string {
if lipgloss.Width(s) <= width {
return s
}
- if width <= 1 {
+ if width <= 0 {
+ return ""
+ }
+ if width == 1 {
return "…"
}
return ansi.Truncate(s, width, "…")
@@ -60,7 +63,7 @@ func JoinColumns(left, right string, gap int) string {
if i < len(rightLines) {
r = rightLines[i]
}
- b.WriteString(l + strings.Repeat(" ", width-lipgloss.Width(l)+gap) + r)
+ b.WriteString(l + strings.Repeat(" ", max(width-lipgloss.Width(l)+gap, 0)) + r)
if i < rows-1 {
b.WriteString("\n")
}
diff --git a/internal/tui/twocolumn.go b/internal/tui/twocolumn.go
index 539b1d84..f92ef780 100644
--- a/internal/tui/twocolumn.go
+++ b/internal/tui/twocolumn.go
@@ -30,12 +30,10 @@ func NewTwoColumn(opts TwoColumnOptions) TwoColumn {
// Render implements the component contract.
func (c TwoColumn) Render() string {
- rightWidth := c.opts.Width - c.opts.LeftWidth - 3
- if rightWidth < 0 {
- rightWidth = 0
- }
+ leftWidth := max(c.opts.LeftWidth, 0)
+ rightWidth := max(c.opts.Width-leftWidth-3, 0)
- leftLines := splitToWidth(c.opts.Left, c.opts.LeftWidth)
+ leftLines := splitToWidth(c.opts.Left, leftWidth)
rightLines := splitToWidth(c.opts.Right, rightWidth)
rows := max(len(leftLines), len(rightLines))
@@ -50,7 +48,7 @@ func (c TwoColumn) Render() string {
if i < len(rightLines) {
r = rightLines[i]
}
- b.WriteString(padToWidth(l, c.opts.LeftWidth))
+ b.WriteString(padToWidth(l, leftWidth))
b.WriteString(" " + divider + " ")
b.WriteString(padToWidth(r, rightWidth))
if i < rows-1 {
diff --git a/internal/tui/upgrade/chrome.go b/internal/tui/upgrade/chrome.go
index 453ff98e..5d8784eb 100644
--- a/internal/tui/upgrade/chrome.go
+++ b/internal/tui/upgrade/chrome.go
@@ -35,6 +35,11 @@ func (m *Model) footerView(ctx app.Context) string {
// footerHint returns the active panel's shortcut bar.
func (m *Model) footerHint(width int) string {
fit := width - 20 // room for the exit badge
+ if fit <= 0 {
+ // ShortcutBarFit treats a non-positive max as unconstrained; a
+ // terminal this narrow gets no hint instead of an overflowing one.
+ return ""
+ }
switch m.panel {
case panelIntro:
return tui.ShortcutBarFit(fit,
diff --git a/internal/tui/upgrade/panel_check.go b/internal/tui/upgrade/panel_check.go
index 9f58d387..58e96e40 100644
--- a/internal/tui/upgrade/panel_check.go
+++ b/internal/tui/upgrade/panel_check.go
@@ -46,11 +46,11 @@ type versionRow struct {
func (s checkState) versionRows() []versionRow {
var rows []versionRow
if s.catalog != nil {
- if s.catalog.Recommended >= 0 {
+ if s.catalog.Recommended >= 0 && s.catalog.Recommended < len(s.catalog.Options) {
opt := &s.catalog.Options[s.catalog.Recommended]
rows = append(rows, versionRow{option: opt, label: opt.Version.String(), hint: "recommended"})
}
- if s.catalog.LatestPatch >= 0 && s.catalog.LatestPatch != s.catalog.Recommended {
+ if s.catalog.LatestPatch >= 0 && s.catalog.LatestPatch < len(s.catalog.Options) && s.catalog.LatestPatch != s.catalog.Recommended {
opt := &s.catalog.Options[s.catalog.LatestPatch]
rows = append(rows, versionRow{option: opt, label: opt.Version.String(), hint: opt.Tag})
}
@@ -69,13 +69,16 @@ func (m *Model) updateCheck(msg tea.Msg) (app.Content, tea.Cmd) {
case catalogLoadedMsg:
m.check.catalog = msg.catalog
m.check.catalogErr = msg.err
- if msg.err == nil && msg.catalog != nil && msg.catalog.Recommended >= 0 {
+ if msg.err == nil && msg.catalog != nil && msg.catalog.Recommended >= 0 && msg.catalog.Recommended < len(msg.catalog.Options) {
m.check.chosen = &msg.catalog.Options[msg.catalog.Recommended]
}
return m, nil
case picker.ResultMsg:
if _, ok := msg.Key.(versionPickerKey); ok && !msg.Cancelled {
+ if m.check.catalog == nil || msg.Index < 0 || msg.Index >= len(m.check.catalog.Options) {
+ return m, nil
+ }
option := m.check.catalog.Options[msg.Index]
m.check.chosen = &option
// The picker's confirm is the selection: continue directly instead
@@ -106,6 +109,9 @@ func (m *Model) updateCheckKeys(msg tea.KeyPressMsg) (app.Content, tea.Cmd) {
case "q", "esc":
return m, tea.Quit
case "enter":
+ if m.check.cursor < 0 || m.check.cursor >= len(rows) {
+ return m, nil
+ }
row := rows[m.check.cursor]
if row.option == nil {
return m.openVersionPicker()
diff --git a/internal/tui/upgrade/panel_done.go b/internal/tui/upgrade/panel_done.go
index becc4589..97023b14 100644
--- a/internal/tui/upgrade/panel_done.go
+++ b/internal/tui/upgrade/panel_done.go
@@ -4,6 +4,7 @@ import (
"context"
"os"
"path/filepath"
+ "strconv"
"strings"
"time"
@@ -166,7 +167,7 @@ func (m *Model) viewDoneRight() string {
}
}
for i, step := range steps {
- b.WriteString(tui.DimStyle.Render(" "+string(rune('1'+i))+". ") + tui.LabelStyle.Render(step) + "\n")
+ b.WriteString(tui.DimStyle.Render(" "+strconv.Itoa(i+1)+". ") + tui.LabelStyle.Render(step) + "\n")
}
b.WriteString("\n")
diff --git a/internal/tui/upgrade/panel_prepare.go b/internal/tui/upgrade/panel_prepare.go
index bc010392..bc6ff459 100644
--- a/internal/tui/upgrade/panel_prepare.go
+++ b/internal/tui/upgrade/panel_prepare.go
@@ -88,6 +88,13 @@ func (s prepareState) loading() bool {
return s.envRunning == nil || s.packagist == nil || (s.resolve == nil && s.resolveErr == nil) || !s.compatDone || !s.phpDone
}
+// resolveFailed reports whether the Composer dry run ended without a usable
+// resolution — either the solver found conflicts or the command itself failed
+// to run.
+func (s prepareState) resolveFailed() bool {
+ return s.resolveErr != nil || (s.resolve != nil && !s.resolve.OK)
+}
+
// applyResolved overwrites the metadata-derived target versions with the
// exact releases the composer dry run picked, once both checks finished.
func (s *prepareState) applyResolved() {
@@ -220,7 +227,7 @@ func (m *Model) maybeWriteFailureReport() tea.Cmd {
if m.prepare.reportRequested || m.prepare.loading() || !m.prepare.phpDone {
return nil
}
- if m.prepare.resolve == nil || m.prepare.resolve.OK {
+ if !m.prepare.resolveFailed() {
return nil
}
m.prepare.reportRequested = true
@@ -350,7 +357,7 @@ func (m *Model) viewPrepareLeft() string {
// queue would be — it names the exact packages and constraints that
// clash. Any flagged extension (blocking, deprecated, manual review)
// keeps the queue instead: those findings are only visible here.
- if m.prepare.resolve != nil && !m.prepare.resolve.OK && m.prepare.flagged() == 0 {
+ if m.prepare.resolveFailed() && m.prepare.flagged() == 0 {
b.WriteString(m.viewResolveFailure())
return b.String()
}
@@ -410,7 +417,16 @@ func (m *Model) viewResolveFailure() string {
visible = 3
}
- lines := strings.Split(strings.TrimRight(m.prepare.resolve.Report, "\n"), "\n")
+ // The dry run either produced solver output or failed to run at all — in
+ // the latter case the error itself is the report.
+ report := ""
+ if m.prepare.resolve != nil {
+ report = m.prepare.resolve.Report
+ } else if m.prepare.resolveErr != nil {
+ report = m.prepare.resolveErr.Error()
+ }
+
+ lines := strings.Split(strings.TrimRight(report, "\n"), "\n")
if len(lines) > visible {
b.WriteString(tui.DimStyle.Render(fmt.Sprintf("… %d earlier output lines omitted", len(lines)-visible)))
b.WriteString("\n")
diff --git a/internal/tui/upgrade/panel_review.go b/internal/tui/upgrade/panel_review.go
index 7441f6ad..4c7520b5 100644
--- a/internal/tui/upgrade/panel_review.go
+++ b/internal/tui/upgrade/panel_review.go
@@ -130,7 +130,11 @@ func (m *Model) viewReview() (title, status, body string) {
}
left.WriteString(fmt.Sprintf(" %-28s", fmt.Sprintf("%d compatible extensions", okCount)) + okStyle.Render("ok") + "\n")
left.WriteString(fmt.Sprintf(" %-28s", fmt.Sprintf("%d extensions to review", reviewCount)) + okStyle.Render("reports ready") + "\n")
- left.WriteString(fmt.Sprintf(" %-28s", fmt.Sprintf("%d blocking extensions", blockedCount)) + okStyle.Render("ready") + "\n")
+ blockedStatus := okStyle.Render("none")
+ if blockedCount > 0 {
+ blockedStatus = warnStyle.Render("review advised")
+ }
+ left.WriteString(fmt.Sprintf(" %-28s", fmt.Sprintf("%d blocking extensions", blockedCount)) + blockedStatus + "\n")
left.WriteString("\n")
left.WriteString(tui.BoldStyle.Render("Why these files change"))
diff --git a/internal/tui/upgrade/panel_run.go b/internal/tui/upgrade/panel_run.go
index e0c3a65a..6df9b8ec 100644
--- a/internal/tui/upgrade/panel_run.go
+++ b/internal/tui/upgrade/panel_run.go
@@ -82,6 +82,8 @@ func (m *Model) updateRun(msg tea.Msg) (app.Content, tea.Cmd) {
return m, readRunEventCmd(m.run.events)
case runClosedMsg:
+ // The runner is finished; release the context created in beginRun.
+ m.run.cancel()
return m.beginDone()
case tea.KeyPressMsg:
From 81ad670db9a38a51c40bde05cd60361f8a95389c Mon Sep 17 00:00:00 2001
From: Soner Sayakci
Date: Wed, 29 Jul 2026 11:26:59 +0200
Subject: [PATCH 02/11] refactor: remove redundant project details message from
overview report
---
internal/tui/dev/tab_overview.go | 4 ----
1 file changed, 4 deletions(-)
diff --git a/internal/tui/dev/tab_overview.go b/internal/tui/dev/tab_overview.go
index 973f76b4..1effe7db 100644
--- a/internal/tui/dev/tab_overview.go
+++ b/internal/tui/dev/tab_overview.go
@@ -374,8 +374,6 @@ func (m OverviewModel) renderProjectReport(width int) string {
divider := tui.SectionDivider(width)
var s strings.Builder
- s.WriteString(helpStyle.Render("Project details and readonly setup report."))
- s.WriteString("\n\n")
s.WriteString(m.renderShopSection())
s.WriteString(divider)
s.WriteString(m.renderAccess())
@@ -405,8 +403,6 @@ func (m OverviewModel) renderStacked(width int) string {
divider := tui.SectionDivider(width)
var s strings.Builder
- s.WriteString(helpStyle.Render("Project details and readonly setup report."))
- s.WriteString("\n\n")
s.WriteString(m.renderShopSection())
s.WriteString(divider)
s.WriteString(m.renderAccess())
From 8e356b6872cb0e22f71a2d6dec09f4935d43ddb4 Mon Sep 17 00:00:00 2001
From: Soner Sayakci
Date: Wed, 29 Jul 2026 11:40:55 +0200
Subject: [PATCH 03/11] chore: enable perfsprint linter and apply its fixes
fmt.Errorf without verbs becomes errors.New, fmt.Sprintf becomes plain
concatenation or strconv. Two intentional swallowed errors in the PHP
lint walk (reported as validation warnings instead) are annotated for
nilerr, which resurfaced alongside the sweep.
Co-Authored-By: Claude Fable 5
Claude-Session: https://claude.ai/code/session_01HR5veromwFWWyasWLqv4QJ
---
.golangci.yml | 1 +
cmd/extension/extension_admin_watch.go | 6 +--
cmd/extension/extension_fix.go | 3 +-
cmd/extension/extension_package.go | 6 +--
cmd/project/ci.go | 5 ++-
cmd/project/platform.go | 20 ++++-----
cmd/project/project_admin_api.go | 5 ++-
cmd/project/project_autofix_flex.go | 5 ++-
cmd/project/project_clear_cache.go | 3 +-
cmd/project/project_config_init.go | 6 +--
cmd/project/project_create_form.go | 3 +-
cmd/project/project_create_install.go | 7 ++--
cmd/project/project_create_scaffold.go | 10 ++---
cmd/project/project_create_validate.go | 9 +++--
cmd/project/project_dump.go | 5 ++-
cmd/project/project_extension_activate.go | 4 +-
cmd/project/project_extension_deactivate.go | 4 +-
cmd/project/project_extension_delete.go | 4 +-
cmd/project/project_extension_install.go | 4 +-
cmd/project/project_extension_uninstall.go | 4 +-
cmd/project/project_extension_update.go | 4 +-
cmd/project/project_extension_upload.go | 4 +-
cmd/project/project_fix.go | 3 +-
cmd/project/project_generate_jwt.go | 3 +-
cmd/project/project_image_proxy.go | 6 +--
cmd/project/project_logs.go | 3 +-
cmd/project/project_storefront_watch.go | 5 ++-
cmd/project/project_upgrade_check.go | 5 ++-
cmd/project/project_worker.go | 4 +-
internal/account-api/client.go | 3 +-
internal/account-api/login.go | 7 ++--
internal/account-api/oauth2.go | 9 +++--
internal/account-api/producer.go | 7 ++--
internal/account-api/producer_extension.go | 7 +++-
internal/admin-api/extension.go | 2 +-
internal/esbuild/esbuild.go | 3 +-
internal/esbuild/sass_plugin.go | 3 +-
internal/esbuild/watch.go | 2 +-
internal/executor/executor.go | 3 +-
internal/executor/factory.go | 3 +-
internal/executor/local.go | 2 +-
internal/extension/app.go | 9 +++--
internal/extension/asset_cache.go | 3 +-
internal/extension/asset_config.go | 3 +-
internal/extension/asset_platform.go | 6 +--
internal/extension/bundle.go | 7 ++--
internal/extension/changelog.go | 5 ++-
internal/extension/cleanup_ci.go | 2 +-
internal/extension/config.go | 9 +++--
internal/extension/platform.go | 22 +++++-----
internal/extension/project.go | 7 ++--
internal/extension/root.go | 14 +++----
internal/extension/storefront_watch.go | 4 +-
internal/extension/validator.go | 6 +--
internal/extension/zip.go | 2 +-
internal/git/git.go | 6 +--
internal/mjml/compiler.go | 2 +-
internal/mysqldump/mysql.go | 4 +-
internal/shop/client.go | 3 +-
internal/shop/config.go | 3 +-
internal/shop/console.go | 3 +-
internal/shop/pluginmigrate/headless.go | 3 +-
internal/shop/project_composer_json.go | 3 +-
internal/shop/project_scaffold.go | 4 +-
internal/shop/upgrade/headless.go | 7 ++--
internal/symfony/config_packages.go | 3 +-
internal/symfony/config_packages_write.go | 3 +-
internal/symfony/convert.go | 45 +++++++++++----------
internal/symfony/routes_convert.go | 23 ++++++-----
internal/system/cache_disk.go | 5 ++-
internal/system/cache_github_actions.go | 6 ++-
internal/system/node.go | 2 +-
internal/system/php.go | 2 +-
internal/system/setup.go | 7 ++--
internal/tui/dev/model_view.go | 4 +-
internal/tui/dev/tab_overview.go | 3 +-
internal/tui/shortcuts.go | 6 ++-
internal/validation/reporter.go | 3 +-
internal/verifier/admin_twig.go | 2 +-
internal/verifier/composer.go | 2 +-
internal/verifier/embed.go | 5 ++-
internal/verifier/eslint.go | 6 +--
internal/verifier/phpstan.go | 5 +--
internal/verifier/project.go | 3 +-
internal/verifier/stylelint.go | 6 +--
internal/xmlpath/xmlpath.go | 6 +--
86 files changed, 268 insertions(+), 218 deletions(-)
diff --git a/.golangci.yml b/.golangci.yml
index 937f339e..2430b0c9 100644
--- a/.golangci.yml
+++ b/.golangci.yml
@@ -39,6 +39,7 @@ linters:
- gocheckcompilerdirectives
- godox
- nilnil
+ - perfsprint
exclusions:
rules:
- path: cmd\/*
diff --git a/cmd/extension/extension_admin_watch.go b/cmd/extension/extension_admin_watch.go
index ae5f597f..52926901 100644
--- a/cmd/extension/extension_admin_watch.go
+++ b/cmd/extension/extension_admin_watch.go
@@ -77,7 +77,7 @@ var extensionAdminWatchCmd = &cobra.Command{
cfgs := extension.BuildAssetConfigFromExtensions(cmd.Context(), sources, extension.AssetBuildConfig{}).FilterByAdmin()
if len(cfgs) == 0 {
- return fmt.Errorf("found nothing to compile")
+ return errors.New("found nothing to compile")
}
if _, err := extension.InstallNodeModulesOfConfigs(cmd.Context(), cfgs, extension.AssetBuildConfig{}); err != nil {
@@ -125,7 +125,7 @@ var extensionAdminWatchCmd = &cobra.Command{
listenSplit := strings.Split(adminWatchListen, ":")
if len(listenSplit) != 2 {
- return fmt.Errorf("listen should contain a colon")
+ return errors.New("listen should contain a colon")
}
if len(adminWatchURL) == 0 {
@@ -184,7 +184,7 @@ var extensionAdminWatchCmd = &cobra.Command{
// Modify admin url index page to load anything from our watcher
if req.URL.Path == targetShopUrl.Path+"/admin" {
- resp, err := http.Get(fmt.Sprintf("%s/admin", targetShopUrl.Scheme+schemeHostSeparator+targetShopUrl.Host))
+ resp, err := http.Get(targetShopUrl.Scheme + schemeHostSeparator + targetShopUrl.Host + "/admin")
if err != nil {
logging.FromContext(cmd.Context()).Errorf("proxy failed %v", err)
w.WriteHeader(http.StatusInternalServerError)
diff --git a/cmd/extension/extension_fix.go b/cmd/extension/extension_fix.go
index 07ceda0c..03bc81c4 100644
--- a/cmd/extension/extension_fix.go
+++ b/cmd/extension/extension_fix.go
@@ -1,6 +1,7 @@
package extension
import (
+ "errors"
"fmt"
"os"
"path/filepath"
@@ -25,7 +26,7 @@ var extensionFixCmd = &cobra.Command{
if !allowNonGit {
if stat, err := os.Stat(filepath.Join(args[0], ".git")); err != nil || !stat.IsDir() {
- return fmt.Errorf("provided folder is not a git repository. Use --allow-non-git flag to run anyway")
+ return errors.New("provided folder is not a git repository. Use --allow-non-git flag to run anyway")
}
}
diff --git a/cmd/extension/extension_package.go b/cmd/extension/extension_package.go
index 6fc56a2c..d69650eb 100644
--- a/cmd/extension/extension_package.go
+++ b/cmd/extension/extension_package.go
@@ -191,7 +191,7 @@ var extensionPackageCmd = &cobra.Command{
if len(fileName) == 0 {
fileName = fmt.Sprintf("%s-%s.zip", name, tag)
if len(tag) == 0 {
- fileName = fmt.Sprintf("%s.zip", name)
+ fileName = name + ".zip"
}
}
@@ -247,8 +247,8 @@ func getStringOnStringError(val string, _ error) string {
func executeHooks(ctx context.Context, ext extension.Extension, hooks []string, extDir string) error {
env := []string{
- fmt.Sprintf("EXTENSION_DIR=%s", extDir),
- fmt.Sprintf("ORIGINAL_EXTENSION_DIR=%s", ext.GetPath()),
+ "EXTENSION_DIR=" + extDir,
+ "ORIGINAL_EXTENSION_DIR=" + ext.GetPath(),
}
for _, hook := range hooks {
diff --git a/cmd/project/ci.go b/cmd/project/ci.go
index 80a16a66..7131fc56 100644
--- a/cmd/project/ci.go
+++ b/cmd/project/ci.go
@@ -3,6 +3,7 @@ package project
import (
"context"
"encoding/json"
+ "errors"
"fmt"
"os"
"os/exec"
@@ -414,7 +415,7 @@ func projectCISafetyCheck(ctx context.Context, root string, force bool, getenv f
}
if dirty {
- return fmt.Errorf("project ci removes source files and creates build stubs; refusing to run outside CI with a dirty git working tree. Commit, stash, or clean local changes, or pass --force if you intentionally want to run it")
+ return errors.New("project ci removes source files and creates build stubs; refusing to run outside CI with a dirty git working tree. Commit, stash, or clean local changes, or pass --force if you intentionally want to run it")
}
logging.FromContext(ctx).Warnf("Running project ci outside a CI environment; this command removes source files and should usually only be used in CI")
@@ -495,7 +496,7 @@ func executeCIHooks(ctx context.Context, sectionName string, hooks []string, roo
hookCmd.Stdout = os.Stdout
hookCmd.Stderr = os.Stderr
hookCmd.Dir = root
- hookCmd.Env = append(os.Environ(), fmt.Sprintf("PROJECT_ROOT=%s", root))
+ hookCmd.Env = append(os.Environ(), "PROJECT_ROOT="+root)
if err := hookCmd.Run(); err != nil {
return fmt.Errorf("hook failed (%s): %w", hook, err)
diff --git a/cmd/project/platform.go b/cmd/project/platform.go
index 6db2fe15..acec4116 100644
--- a/cmd/project/platform.go
+++ b/cmd/project/platform.go
@@ -2,7 +2,7 @@ package project
import (
"context"
- "fmt"
+ "errors"
"os"
"path/filepath"
"slices"
@@ -35,8 +35,8 @@ func findClosestShopwareProject() (string, error) {
for {
files := []string{
- fmt.Sprintf("%s/composer.json", currentDir),
- fmt.Sprintf("%s/composer.lock", currentDir),
+ currentDir + "/composer.json",
+ currentDir + "/composer.lock",
}
for _, file := range files {
@@ -48,7 +48,7 @@ func findClosestShopwareProject() (string, error) {
contentString := string(content)
if strings.Contains(contentString, "shopware/core") {
- if _, err := os.Stat(fmt.Sprintf("%s/bin/console", currentDir)); err == nil {
+ if _, err := os.Stat(currentDir + "/bin/console"); err == nil {
return currentDir, nil
}
}
@@ -62,7 +62,7 @@ func findClosestShopwareProject() (string, error) {
}
}
- return "", fmt.Errorf("cannot find Shopware project in current directory")
+ return "", errors.New("cannot find Shopware project in current directory")
}
func filterAndWritePluginJson(cmd *cobra.Command, projectRoot string, shopCfg *shop.Config, cmdExecutor executor.Executor) error {
@@ -102,7 +102,7 @@ func filterAndGetSources(cmd *cobra.Command, projectRoot string, shopCfg *shop.C
}
if onlyExtensions != "" && skipExtensions != "" {
- return nil, fmt.Errorf("only-extensions and skip-extensions cannot be used together")
+ return nil, errors.New("only-extensions and skip-extensions cannot be used together")
}
logger := logging.FromContext(cmd.Context())
@@ -178,11 +178,11 @@ func validateExtensionSelection(ctx context.Context, onlyExtensions string, sele
}
if onlyExtensions != "" {
- return fmt.Errorf("only one of --only-extensions and --select-extensions can be used")
+ return errors.New("only one of --only-extensions and --select-extensions can be used")
}
if !system.IsInteractionEnabled(ctx) {
- return fmt.Errorf("--select-extensions requires an interactive terminal; use --only-extensions with a comma-separated list instead")
+ return errors.New("--select-extensions requires an interactive terminal; use --only-extensions with a comma-separated list instead")
}
return nil
@@ -202,7 +202,7 @@ func selectExtensionsInteractively(cmd *cobra.Command, sources []asset.Source) (
}
if len(items) == 0 {
- return "", fmt.Errorf("no extensions available to select")
+ return "", errors.New("no extensions available to select")
}
selected, err := tui.FilterMultiSelect(cmd.Context(),
@@ -214,7 +214,7 @@ func selectExtensionsInteractively(cmd *cobra.Command, sources []asset.Source) (
}
if len(selected) == 0 {
- return "", fmt.Errorf("no extensions selected")
+ return "", errors.New("no extensions selected")
}
return strings.Join(selected, ","), nil
diff --git a/cmd/project/project_admin_api.go b/cmd/project/project_admin_api.go
index 92f833a1..b8faaf62 100644
--- a/cmd/project/project_admin_api.go
+++ b/cmd/project/project_admin_api.go
@@ -1,6 +1,7 @@
package project
import (
+ "errors"
"fmt"
"net/url"
"path"
@@ -26,7 +27,7 @@ var projectAdminApiCmd = &cobra.Command{
}
if cfg.AdminApi == nil {
- return fmt.Errorf("admin api is not activated in the config")
+ return errors.New("admin api is not activated in the config")
}
client, err := shop.NewShopClient(cobraCmd.Context(), cfg)
@@ -47,7 +48,7 @@ var projectAdminApiCmd = &cobra.Command{
}
if len(args) < 2 {
- return fmt.Errorf("command needs 2 arguments")
+ return errors.New("command needs 2 arguments")
}
shopURL, err := url.Parse(cfg.URL)
diff --git a/cmd/project/project_autofix_flex.go b/cmd/project/project_autofix_flex.go
index b6b9d04e..1f09f78f 100644
--- a/cmd/project/project_autofix_flex.go
+++ b/cmd/project/project_autofix_flex.go
@@ -1,6 +1,7 @@
package project
import (
+ "errors"
"fmt"
"os"
"path"
@@ -35,11 +36,11 @@ var projectAutofixFlexCmd = &cobra.Command{
}
if !confirmed {
- return fmt.Errorf("autofix cancelled")
+ return errors.New("autofix cancelled")
}
if _, err := os.Stat(path.Join(project, "symfony.lock")); err == nil {
- return fmt.Errorf("symfony.lock already exists, is that project already migrated to Symfony Flex?")
+ return errors.New("symfony.lock already exists, is that project already migrated to Symfony Flex?")
}
if err := flexmigrator.MigrateComposerJson(project); err != nil {
diff --git a/cmd/project/project_clear_cache.go b/cmd/project/project_clear_cache.go
index b92adec3..213a33b6 100644
--- a/cmd/project/project_clear_cache.go
+++ b/cmd/project/project_clear_cache.go
@@ -1,7 +1,6 @@
package project
import (
- "fmt"
"os"
"github.com/spf13/cobra"
@@ -30,7 +29,7 @@ var projectClearCacheCmd = &cobra.Command{
return err
}
- return os.RemoveAll(fmt.Sprintf("%s/var/cache", projectRoot))
+ return os.RemoveAll(projectRoot + "/var/cache")
}
logging.FromContext(cmd.Context()).Infof("Clearing cache using admin-api")
diff --git a/cmd/project/project_config_init.go b/cmd/project/project_config_init.go
index 745b1d16..7e57733a 100644
--- a/cmd/project/project_config_init.go
+++ b/cmd/project/project_config_init.go
@@ -1,7 +1,7 @@
package project
import (
- "fmt"
+ "errors"
"charm.land/huh/v2"
"github.com/spf13/cobra"
@@ -17,7 +17,7 @@ var projectConfigInitCmd = &cobra.Command{
Short: "Creates a new project config in current dir",
RunE: func(cmd *cobra.Command, _ []string) error {
if !system.IsInteractionEnabled(cmd.Context()) {
- return fmt.Errorf("this command requires interaction, but interaction is disabled")
+ return errors.New("this command requires interaction, but interaction is disabled")
}
config := &shop.Config{
@@ -117,7 +117,7 @@ func init() {
func emptyValidator(s string) error {
if len(s) == 0 {
- return fmt.Errorf("this cannot be empty")
+ return errors.New("this cannot be empty")
}
return nil
diff --git a/cmd/project/project_create_form.go b/cmd/project/project_create_form.go
index 34d91058..415ea79f 100644
--- a/cmd/project/project_create_form.go
+++ b/cmd/project/project_create_form.go
@@ -1,6 +1,7 @@
package project
import (
+ "errors"
"fmt"
"os"
"slices"
@@ -282,7 +283,7 @@ func runCreateForm(cmd *cobra.Command, opts *createOptions, filteredVersions []*
}
if selectConfirm == "cancel" {
- return fmt.Errorf("project creation cancelled")
+ return errors.New("project creation cancelled")
}
}
}
diff --git a/cmd/project/project_create_install.go b/cmd/project/project_create_install.go
index 3000b749..86f83703 100644
--- a/cmd/project/project_create_install.go
+++ b/cmd/project/project_create_install.go
@@ -3,6 +3,7 @@ package project
import (
"bytes"
"context"
+ "errors"
"fmt"
"io"
"os"
@@ -140,7 +141,7 @@ func handleSecurityBlockedInstall(ctx context.Context, opts *createOptions, chos
}
if continueAnyway == tui.No {
- return fmt.Errorf("project creation cancelled")
+ return errors.New("project creation cancelled")
}
opts.noAudit = true
@@ -165,7 +166,7 @@ func runComposerInstall(ctx context.Context, projectFolder string, useDocker boo
dockerArgs := []string{"run",
"--rm",
"--pull=always",
- "-v", fmt.Sprintf("%s:/app", absProjectFolder),
+ "-v", absProjectFolder + ":/app",
"-w", "/app"}
dockerArgs = append(dockerArgs, system.DockerRunUserArgs(absProjectFolder)...)
@@ -175,7 +176,7 @@ func runComposerInstall(ctx context.Context, projectFolder string, useDocker boo
if err == nil {
composerDir := filepath.Join(homeDir, ".composer")
_ = os.MkdirAll(composerDir, 0o755)
- dockerArgs = append(dockerArgs, "-v", fmt.Sprintf("%s:/tmp/composer/", composerDir))
+ dockerArgs = append(dockerArgs, "-v", composerDir+":/tmp/composer/")
}
}
diff --git a/cmd/project/project_create_scaffold.go b/cmd/project/project_create_scaffold.go
index ff2d8505..83d39d52 100644
--- a/cmd/project/project_create_scaffold.go
+++ b/cmd/project/project_create_scaffold.go
@@ -2,7 +2,7 @@ package project
import (
"context"
- "fmt"
+ "strconv"
"github.com/shopware/shopware-cli/internal/shop"
"github.com/shopware/shopware-cli/internal/system"
@@ -14,10 +14,10 @@ func scaffoldProject(ctx context.Context, opts *createOptions, chosenVersion str
tracking.TagVersion: opts.selectedVersion,
tracking.TagDeployment: opts.selectedDeployment,
tracking.TagCI: opts.selectedCI,
- tracking.TagDocker: fmt.Sprintf("%v", opts.useDocker),
- tracking.TagWithElasticsearch: fmt.Sprintf("%v", opts.withElasticsearch),
- tracking.TagWithAMQP: fmt.Sprintf("%v", opts.withAMQP),
- tracking.TagInteractive: fmt.Sprintf("%v", opts.interactive),
+ tracking.TagDocker: strconv.FormatBool(opts.useDocker),
+ tracking.TagWithElasticsearch: strconv.FormatBool(opts.withElasticsearch),
+ tracking.TagWithAMQP: strconv.FormatBool(opts.withAMQP),
+ tracking.TagInteractive: strconv.FormatBool(opts.interactive),
})
scaffold := newShopwareProjectScaffold(opts, chosenVersion)
diff --git a/cmd/project/project_create_validate.go b/cmd/project/project_create_validate.go
index 0c311e8e..cd165ee4 100644
--- a/cmd/project/project_create_validate.go
+++ b/cmd/project/project_create_validate.go
@@ -2,6 +2,7 @@ package project
import (
"context"
+ "errors"
"fmt"
"os"
"strings"
@@ -90,7 +91,7 @@ func checkSecurityAdvisories(ctx context.Context, opts *createOptions, chosenVer
}
if continueAnyway == tui.No {
- return fmt.Errorf("project creation cancelled")
+ return errors.New("project creation cancelled")
}
opts.noAudit = true
@@ -113,14 +114,14 @@ func checkIncompatibilities(ctx context.Context, opts *createOptions) error {
if err := huh.NewForm(huh.NewGroup(
tui.NewYesNo().
Title(incompatibility.Title).
- Description(fmt.Sprintf("%s. Do you want to continue anyway?", incompatibility.Description)).
+ Description(incompatibility.Description + ". Do you want to continue anyway?").
Value(&continueAnyway),
)).Run(); err != nil {
return err
}
if continueAnyway == tui.No {
- return fmt.Errorf("project creation cancelled")
+ return errors.New("project creation cancelled")
}
} else {
logging.FromContext(ctx).Warnf("%s. %s", incompatibility.Title, incompatibility.Description)
@@ -133,7 +134,7 @@ func checkIncompatibilities(ctx context.Context, opts *createOptions) error {
func renderSecurityAdvisories(chosenVersion string, advisories []repository.SecurityAdvisory) string {
var b strings.Builder
- b.WriteString(tui.RedText.Bold(true).Render(fmt.Sprintf("Security Advisories for Shopware %s", chosenVersion)))
+ b.WriteString(tui.RedText.Bold(true).Render("Security Advisories for Shopware " + chosenVersion))
b.WriteString("\n\n")
warn := tui.YellowText.Render("⚠")
diff --git a/cmd/project/project_dump.go b/cmd/project/project_dump.go
index 4dbb0c9d..ae654c6f 100644
--- a/cmd/project/project_dump.go
+++ b/cmd/project/project_dump.go
@@ -4,6 +4,7 @@ import (
"compress/gzip"
"context"
"database/sql"
+ "errors"
"fmt"
"io"
"net"
@@ -186,11 +187,11 @@ func assembleConnectionURI(cmd *cobra.Command) (*mysql.Config, error) {
if cmd.Flags().Changed("password") {
if password == passwordFlagPrompt {
if !system.IsInteractionEnabled(cmd.Context()) {
- return nil, fmt.Errorf("cannot prompt for password: interaction disabled")
+ return nil, errors.New("cannot prompt for password: interaction disabled")
}
if !term.IsTerminal(os.Stdin.Fd()) {
- return nil, fmt.Errorf("cannot prompt for password: stdin is not a terminal")
+ return nil, errors.New("cannot prompt for password: stdin is not a terminal")
}
fmt.Fprint(cmd.ErrOrStderr(), "Enter MySQL password: ") //nolint:errcheck // prompt output is best-effort, ReadPassword surfaces real terminal errors
diff --git a/cmd/project/project_extension_activate.go b/cmd/project/project_extension_activate.go
index d012801f..1596894e 100644
--- a/cmd/project/project_extension_activate.go
+++ b/cmd/project/project_extension_activate.go
@@ -1,7 +1,7 @@
package project
import (
- "fmt"
+ "errors"
"github.com/spf13/cobra"
@@ -66,7 +66,7 @@ var projectExtensionActivateCmd = &cobra.Command{
}
if failed {
- return fmt.Errorf("activation failed")
+ return errors.New("activation failed")
}
return nil
diff --git a/cmd/project/project_extension_deactivate.go b/cmd/project/project_extension_deactivate.go
index 1c5287e8..d5defdd1 100644
--- a/cmd/project/project_extension_deactivate.go
+++ b/cmd/project/project_extension_deactivate.go
@@ -1,7 +1,7 @@
package project
import (
- "fmt"
+ "errors"
"github.com/spf13/cobra"
@@ -58,7 +58,7 @@ var projectExtensionDeactivateCmd = &cobra.Command{
}
if failed {
- return fmt.Errorf("deactivation failed")
+ return errors.New("deactivation failed")
}
return nil
diff --git a/cmd/project/project_extension_delete.go b/cmd/project/project_extension_delete.go
index bd868e5e..a678def9 100644
--- a/cmd/project/project_extension_delete.go
+++ b/cmd/project/project_extension_delete.go
@@ -1,7 +1,7 @@
package project
import (
- "fmt"
+ "errors"
"github.com/spf13/cobra"
@@ -74,7 +74,7 @@ var projectExtensionDeleteCmd = &cobra.Command{
}
if failed {
- return fmt.Errorf("remove failed")
+ return errors.New("remove failed")
}
return nil
diff --git a/cmd/project/project_extension_install.go b/cmd/project/project_extension_install.go
index 2e8f2a7a..cd74c375 100644
--- a/cmd/project/project_extension_install.go
+++ b/cmd/project/project_extension_install.go
@@ -1,7 +1,7 @@
package project
import (
- "fmt"
+ "errors"
"github.com/spf13/cobra"
@@ -70,7 +70,7 @@ var projectExtensionInstallCmd = &cobra.Command{
}
if failed {
- return fmt.Errorf("install failed")
+ return errors.New("install failed")
}
return nil
diff --git a/cmd/project/project_extension_uninstall.go b/cmd/project/project_extension_uninstall.go
index 88ea2bbc..0b3e0bbe 100644
--- a/cmd/project/project_extension_uninstall.go
+++ b/cmd/project/project_extension_uninstall.go
@@ -1,7 +1,7 @@
package project
import (
- "fmt"
+ "errors"
"github.com/spf13/cobra"
@@ -68,7 +68,7 @@ var projectExtensionUninstallCmd = &cobra.Command{
}
if failed {
- return fmt.Errorf("uninstall failed")
+ return errors.New("uninstall failed")
}
return nil
diff --git a/cmd/project/project_extension_update.go b/cmd/project/project_extension_update.go
index 964acda1..b9238357 100644
--- a/cmd/project/project_extension_update.go
+++ b/cmd/project/project_extension_update.go
@@ -1,7 +1,7 @@
package project
import (
- "fmt"
+ "errors"
"github.com/spf13/cobra"
@@ -85,7 +85,7 @@ var projectExtensionUpdateCmd = &cobra.Command{
}
if failed {
- return fmt.Errorf("update failed")
+ return errors.New("update failed")
}
return nil
diff --git a/cmd/project/project_extension_upload.go b/cmd/project/project_extension_upload.go
index 669217ac..250f1e2b 100644
--- a/cmd/project/project_extension_upload.go
+++ b/cmd/project/project_extension_upload.go
@@ -236,7 +236,7 @@ var projectExtensionUploadCmd = &cobra.Command{
func increaseExtensionVersion(ctx context.Context, ext extension.Extension) error {
if ext.GetType() == "app" {
- manifestPath := fmt.Sprintf("%s/manifest.xml", ext.GetPath())
+ manifestPath := ext.GetPath() + "/manifest.xml"
file, err := os.Open(manifestPath)
if err != nil {
return fmt.Errorf("cannot read manifest file: %w", err)
@@ -304,7 +304,7 @@ func increaseExtensionVersion(ctx context.Context, ext extension.Extension) erro
return nil
}
- composerJsonPath := fmt.Sprintf("%s/composer.json", ext.GetPath())
+ composerJsonPath := ext.GetPath() + "/composer.json"
composerJsonContent, err := os.ReadFile(composerJsonPath)
if err != nil {
diff --git a/cmd/project/project_fix.go b/cmd/project/project_fix.go
index 90004640..3cf0dd75 100644
--- a/cmd/project/project_fix.go
+++ b/cmd/project/project_fix.go
@@ -1,6 +1,7 @@
package project
import (
+ "errors"
"fmt"
"os"
"path/filepath"
@@ -22,7 +23,7 @@ var projectFixCmd = &cobra.Command{
gitPath := filepath.Join(args[0], ".git")
if !allowNonGit {
if stat, err := os.Stat(gitPath); err != nil || !stat.IsDir() {
- return fmt.Errorf("provided folder is not a git repository. Use --allow-non-git flag to run anyway")
+ return errors.New("provided folder is not a git repository. Use --allow-non-git flag to run anyway")
}
}
diff --git a/cmd/project/project_generate_jwt.go b/cmd/project/project_generate_jwt.go
index 7972b278..526eac93 100644
--- a/cmd/project/project_generate_jwt.go
+++ b/cmd/project/project_generate_jwt.go
@@ -7,6 +7,7 @@ import (
"crypto/x509/pkix"
"encoding/base64"
"encoding/pem"
+ "errors"
"fmt"
"math/big"
"os"
@@ -36,7 +37,7 @@ var projectNewJWTCmd = &cobra.Command{
}
if len(args) == 0 {
- return fmt.Errorf("project root path is required, please pass a path to the project root")
+ return errors.New("project root path is required, please pass a path to the project root")
}
projectRoot := args[0]
diff --git a/cmd/project/project_image_proxy.go b/cmd/project/project_image_proxy.go
index 4487a421..cad28e6d 100644
--- a/cmd/project/project_image_proxy.go
+++ b/cmd/project/project_image_proxy.go
@@ -95,7 +95,7 @@ If a file is not found locally, it proxies the request to the upstream server.`,
}
if upstreamURL == "" {
- return fmt.Errorf("upstream URL must be provided either via --url flag or in .shopware-project.yml")
+ return errors.New("upstream URL must be provided either via --url flag or in .shopware-project.yml")
}
// Parse upstream URL
@@ -217,7 +217,7 @@ If a file is not found locally, it proxies the request to the upstream server.`,
})
// Prepare server address
- addr := fmt.Sprintf(":%s", imageProxyPort)
+ addr := ":" + imageProxyPort
// Setup config file management if not skipped
var cleanup func()
@@ -232,7 +232,7 @@ If a file is not found locally, it proxies the request to the upstream server.`,
}
// Determine the URL to use in Shopware config
- configURL := fmt.Sprintf("http://localhost:%s", imageProxyPort)
+ configURL := "http://localhost:" + imageProxyPort
if imageProxyExternalURL != "" {
configURL = strings.TrimSuffix(imageProxyExternalURL, "/")
}
diff --git a/cmd/project/project_logs.go b/cmd/project/project_logs.go
index 3d2624c4..5bde8351 100644
--- a/cmd/project/project_logs.go
+++ b/cmd/project/project_logs.go
@@ -7,6 +7,7 @@ import (
"os/exec"
"path/filepath"
"slices"
+ "strconv"
"strings"
"time"
@@ -166,7 +167,7 @@ func printLastLines(path string, n int) error {
}
func tailFollow(cmd *cobra.Command, path string, n int) error {
- tailCmd := exec.CommandContext(cmd.Context(), "tail", "-n", fmt.Sprintf("%d", n), "-f", path)
+ tailCmd := exec.CommandContext(cmd.Context(), "tail", "-n", strconv.Itoa(n), "-f", path)
tailCmd.Stdout = cmd.OutOrStdout()
tailCmd.Stderr = cmd.ErrOrStderr()
diff --git a/cmd/project/project_storefront_watch.go b/cmd/project/project_storefront_watch.go
index 304f0990..9a0ae38b 100644
--- a/cmd/project/project_storefront_watch.go
+++ b/cmd/project/project_storefront_watch.go
@@ -2,6 +2,7 @@ package project
import (
"context"
+ "errors"
"fmt"
"os"
"strings"
@@ -98,7 +99,7 @@ func resolveStorefrontWatcherOptions(ctx context.Context, cmdExecutor executor.E
}
if len(channels) == 0 {
- return extension.StorefrontWatcherOptions{}, fmt.Errorf("no storefront sales channels found")
+ return extension.StorefrontWatcherOptions{}, errors.New("no storefront sales channels found")
}
var picked *adminSdk.SalesChannel
@@ -137,7 +138,7 @@ func resolveStorefrontWatcherOptions(ctx context.Context, cmdExecutor executor.E
}
}
if picked == nil {
- return extension.StorefrontWatcherOptions{}, fmt.Errorf("no sales channel selected")
+ return extension.StorefrontWatcherOptions{}, errors.New("no sales channel selected")
}
}
diff --git a/cmd/project/project_upgrade_check.go b/cmd/project/project_upgrade_check.go
index 5216d740..8cd37215 100644
--- a/cmd/project/project_upgrade_check.go
+++ b/cmd/project/project_upgrade_check.go
@@ -2,6 +2,7 @@ package project
import (
"context"
+ "errors"
"fmt"
"path"
"strconv"
@@ -114,7 +115,7 @@ var projectUpgradeCheckCmd = &cobra.Command{
}
if selectedVersion == "" {
- return fmt.Errorf("no version selected")
+ return errors.New("no version selected")
}
extensionNames := make([]account_api.UpdateCheckExtension, 0)
@@ -193,7 +194,7 @@ func getLocalExtensions() (*version.Version, map[string]string, error) {
corePackage := composerLock.GetPackage("shopware/core")
if corePackage == nil {
- return nil, nil, fmt.Errorf("shopware/core package not found in composer.lock")
+ return nil, nil, errors.New("shopware/core package not found in composer.lock")
}
currentVersion, err := version.NewVersion(strings.TrimPrefix(corePackage.Version, "v"))
diff --git a/cmd/project/project_worker.go b/cmd/project/project_worker.go
index db9becd4..c4e3f8d8 100644
--- a/cmd/project/project_worker.go
+++ b/cmd/project/project_worker.go
@@ -63,8 +63,8 @@ var projectWorkerCmd = &cobra.Command{
consumeArgs := []string{
"messenger:consume",
- fmt.Sprintf("--memory-limit=%s", memoryLimit),
- fmt.Sprintf("--time-limit=%s", timeLimit),
+ "--memory-limit=" + memoryLimit,
+ "--time-limit=" + timeLimit,
"--failure-limit=5",
}
diff --git a/internal/account-api/client.go b/internal/account-api/client.go
index 416c3747..d8cc41e2 100644
--- a/internal/account-api/client.go
+++ b/internal/account-api/client.go
@@ -3,6 +3,7 @@ package account_api
import (
"context"
"encoding/json"
+ "errors"
"fmt"
"io"
"net/http"
@@ -128,7 +129,7 @@ func createApiFromTokenCache(ctx context.Context) (*Client, error) {
logging.FromContext(ctx).Debugf("Using token cache from %s", tokenFilePath)
if !client.isTokenValid() {
- return nil, fmt.Errorf("token is expired")
+ return nil, errors.New("token is expired")
}
return client, nil
diff --git a/internal/account-api/login.go b/internal/account-api/login.go
index 16f5832f..6cc27f8b 100644
--- a/internal/account-api/login.go
+++ b/internal/account-api/login.go
@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"encoding/json"
+ "errors"
"fmt"
"io"
"net/http"
@@ -28,7 +29,7 @@ func NewApi(ctx context.Context) (*Client, error) {
if clientID != "" || clientSecret != "" {
if clientID == "" || clientSecret == "" {
- return nil, fmt.Errorf("both SHOPWARE_CLI_ACCOUNT_CLIENT_ID and SHOPWARE_CLI_ACCOUNT_CLIENT_SECRET must be set")
+ return nil, errors.New("both SHOPWARE_CLI_ACCOUNT_CLIENT_ID and SHOPWARE_CLI_ACCOUNT_CLIENT_SECRET must be set")
}
return loginWithClientCredentials(ctx, clientID, clientSecret)
}
@@ -61,7 +62,7 @@ func loginWithClientCredentials(ctx context.Context, clientID, clientSecret stri
conf := &clientcredentials.Config{
ClientID: clientID,
ClientSecret: clientSecret,
- TokenURL: fmt.Sprintf("%s/oauth2/token", getOIDCEndpoint()),
+ TokenURL: getOIDCEndpoint() + "/oauth2/token",
Scopes: []string{ClientCredentialsScopes},
AuthStyle: oauth2.AuthStyleInParams,
}
@@ -119,7 +120,7 @@ func loginWithCredentials(ctx context.Context, email, password string) (*Client,
return nil, fmt.Errorf("login failed: %s", apiErr.Detail)
}
- return nil, fmt.Errorf("login failed. Check your credentials")
+ return nil, errors.New("login failed. Check your credentials")
}
var tokenResp legacyToken
diff --git a/internal/account-api/oauth2.go b/internal/account-api/oauth2.go
index 7766023f..4d964759 100644
--- a/internal/account-api/oauth2.go
+++ b/internal/account-api/oauth2.go
@@ -5,6 +5,7 @@ import (
"context"
"crypto/rand"
"encoding/hex"
+ "errors"
"fmt"
"net"
"net/http"
@@ -28,8 +29,8 @@ func InteractiveLogin(ctx context.Context) (*oauth2.Token, error) {
client := &oauth2.Config{
ClientID: getOIDCClientID(),
Endpoint: oauth2.Endpoint{
- AuthURL: fmt.Sprintf("%s/oauth2/auth", getOIDCEndpoint()),
- TokenURL: fmt.Sprintf("%s/oauth2/token", getOIDCEndpoint()),
+ AuthURL: getOIDCEndpoint() + "/oauth2/auth",
+ TokenURL: getOIDCEndpoint() + "/oauth2/token",
AuthStyle: oauth2.AuthStyleInParams,
},
}
@@ -74,7 +75,7 @@ func InteractiveLogin(ctx context.Context) (*oauth2.Token, error) {
}
code := r.Form.Get("code")
if code == "" {
- result <- callbackResult{err: fmt.Errorf("missing code")}
+ result <- callbackResult{err: errors.New("missing code")}
return
}
t, err := client.Exchange(
@@ -103,7 +104,7 @@ func InteractiveLogin(ctx context.Context) (*oauth2.Token, error) {
)
fmt.Println(tui.BoldText.Render(" Press Enter to open the login page in your browser..."))
- fmt.Println(tui.DimText.Render(fmt.Sprintf(" URL: %s", u)))
+ fmt.Println(tui.DimText.Render(" URL: " + u))
fmt.Println()
enterPressed := make(chan struct{})
diff --git a/internal/account-api/producer.go b/internal/account-api/producer.go
index 42e7cf03..51f83567 100644
--- a/internal/account-api/producer.go
+++ b/internal/account-api/producer.go
@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"encoding/json"
+ "errors"
"fmt"
"net/http"
"net/url"
@@ -19,7 +20,7 @@ type ProducerEndpoint struct {
}
func (c *Client) Producer(ctx context.Context) (*ProducerEndpoint, error) {
- r, err := c.NewAuthenticatedRequest(ctx, http.MethodGet, fmt.Sprintf("%s/integrations/shopwarecli/producers", getApiUrl()), nil)
+ r, err := c.NewAuthenticatedRequest(ctx, http.MethodGet, getApiUrl()+"/integrations/shopwarecli/producers", nil)
if err != nil {
return nil, err
}
@@ -35,7 +36,7 @@ func (c *Client) Producer(ctx context.Context) (*ProducerEndpoint, error) {
}
if len(producers) == 0 {
- return nil, fmt.Errorf("producer.profile: no producer found for current user")
+ return nil, errors.New("producer.profile: no producer found for current user")
}
return &ProducerEndpoint{producers: producers, c: c}, nil
@@ -518,7 +519,7 @@ type ExtensionGeneralInformation struct {
}
func (e ProducerEndpoint) GetExtensionGeneralInfo(ctx context.Context) (*ExtensionGeneralInformation, error) {
- r, err := e.c.NewAuthenticatedRequest(ctx, http.MethodGet, fmt.Sprintf("%s/pluginstatics/all", getApiUrl()), nil)
+ r, err := e.c.NewAuthenticatedRequest(ctx, http.MethodGet, getApiUrl()+"/pluginstatics/all", nil)
if err != nil {
return nil, fmt.Errorf("GetExtensionGeneralInfo: %v", err)
}
diff --git a/internal/account-api/producer_extension.go b/internal/account-api/producer_extension.go
index 5f53d631..a496a96c 100644
--- a/internal/account-api/producer_extension.go
+++ b/internal/account-api/producer_extension.go
@@ -14,6 +14,7 @@ import (
"net/http"
"os"
"path/filepath"
+ "strings"
"github.com/microcosm-cc/bluemonday"
"github.com/shyim/go-version"
@@ -421,14 +422,16 @@ func (review BinaryReviewResult) GetSummary() string {
p := bluemonday.NewPolicy()
+ var messageSb424 strings.Builder
for _, result := range review.SubCheckResults {
if result.Passed && !result.HasWarnings {
continue
}
- message += fmt.Sprintf("=== %s ===\n", result.SubCheck)
- message += fmt.Sprintf("%s\n\n", p.Sanitize(result.Message))
+ fmt.Fprintf(&messageSb424, "=== %s ===\n", result.SubCheck)
+ messageSb424.WriteString(p.Sanitize(result.Message) + "\n\n")
}
+ message += messageSb424.String()
return message
}
diff --git a/internal/admin-api/extension.go b/internal/admin-api/extension.go
index 032823ce..d61a2848 100644
--- a/internal/admin-api/extension.go
+++ b/internal/admin-api/extension.go
@@ -63,7 +63,7 @@ func (e ExtensionManagerService) UpdateExtension(ctx ApiContext, extType, name s
}
func (e ExtensionManagerService) DownloadExtension(ctx ApiContext, name string) (*http.Response, error) {
- return e.lifecycleUpdate("DownloadExtension", ctx, fmt.Sprintf("/api/_action/extension/download/%s", name), http.MethodPost)
+ return e.lifecycleUpdate("DownloadExtension", ctx, "/api/_action/extension/download/"+name, http.MethodPost)
}
func (e ExtensionManagerService) ActivateExtension(ctx ApiContext, extType, name string) (*http.Response, error) {
diff --git a/internal/esbuild/esbuild.go b/internal/esbuild/esbuild.go
index 43a1b7b9..51e67aba 100644
--- a/internal/esbuild/esbuild.go
+++ b/internal/esbuild/esbuild.go
@@ -3,6 +3,7 @@ package esbuild
import (
"context"
_ "embed"
+ "errors"
"fmt"
"os"
"path"
@@ -149,7 +150,7 @@ func CompileExtensionAsset(ctx context.Context, options AssetCompileOptions) (*A
result := api.Build(*bundlerOptions)
if len(result.Errors) > 0 {
- return nil, fmt.Errorf("initial compile failed")
+ return nil, errors.New("initial compile failed")
}
if err := cleanupOutputFolder(options); err != nil {
diff --git a/internal/esbuild/sass_plugin.go b/internal/esbuild/sass_plugin.go
index c1505c47..2ed9be9f 100644
--- a/internal/esbuild/sass_plugin.go
+++ b/internal/esbuild/sass_plugin.go
@@ -2,7 +2,6 @@ package esbuild
import (
"context"
- "fmt"
"os"
"path/filepath"
@@ -39,7 +38,7 @@ func newScssPlugin(ctx context.Context) api.Plugin {
execute, err := start.Execute(godartsass.Args{
Source: string(content),
- URL: fmt.Sprintf("file://%s", args.Path),
+ URL: "file://" + args.Path,
EnableSourceMap: true,
IncludePaths: []string{
filepath.Dir(args.Path),
diff --git a/internal/esbuild/watch.go b/internal/esbuild/watch.go
index 532ad18c..da26ddb3 100644
--- a/internal/esbuild/watch.go
+++ b/internal/esbuild/watch.go
@@ -66,7 +66,7 @@ func findEntrypoints(result api.BuildResult) (Entrypoints, error) {
}, nil
}
- return Entrypoints{}, fmt.Errorf("esbuild emitted no JavaScript entrypoint")
+ return Entrypoints{}, errors.New("esbuild emitted no JavaScript entrypoint")
}
func servePath(outputPath string) (string, error) {
diff --git a/internal/executor/executor.go b/internal/executor/executor.go
index 179256c9..b9bbb80f 100644
--- a/internal/executor/executor.go
+++ b/internal/executor/executor.go
@@ -3,7 +3,6 @@ package executor
import (
"context"
"errors"
- "fmt"
"os"
"os/exec"
"path/filepath"
@@ -40,7 +39,7 @@ type Executor interface {
func adminAPIClient(ctx context.Context, cfg *shop.Config, envCfg *shop.EnvironmentConfig) (*adminSdk.Client, error) {
if cfg == nil {
- return nil, fmt.Errorf("admin api requires a shop configuration")
+ return nil, errors.New("admin api requires a shop configuration")
}
effective := *cfg
diff --git a/internal/executor/factory.go b/internal/executor/factory.go
index 3ba74e27..5836841c 100644
--- a/internal/executor/factory.go
+++ b/internal/executor/factory.go
@@ -1,6 +1,7 @@
package executor
import (
+ "errors"
"fmt"
"os"
"os/exec"
@@ -21,7 +22,7 @@ func New(projectRoot string, cfg *shop.EnvironmentConfig, shopCfg *shop.Config)
case TypeSymfonyCLI:
path := pathToSymfonyCLI()
if path == "" {
- return nil, fmt.Errorf("symfony CLI not found in PATH")
+ return nil, errors.New("symfony CLI not found in PATH")
}
return &SymfonyCLIExecutor{BinaryPath: path, projectRoot: projectRoot, shopCfg: shopCfg, envCfg: cfg}, nil
case TypeDocker:
diff --git a/internal/executor/local.go b/internal/executor/local.go
index 95024455..8449a431 100644
--- a/internal/executor/local.go
+++ b/internal/executor/local.go
@@ -88,7 +88,7 @@ func applyLocalEnv(projectRoot string, env map[string]string, cmd *exec.Cmd) {
cmd.Env = os.Environ()
if projectRoot != "" {
- cmd.Env = append(cmd.Env, fmt.Sprintf("PROJECT_ROOT=%s", projectRoot))
+ cmd.Env = append(cmd.Env, "PROJECT_ROOT="+projectRoot)
}
for k, v := range env {
diff --git a/internal/extension/app.go b/internal/extension/app.go
index d1de89d4..74dd904d 100644
--- a/internal/extension/app.go
+++ b/internal/extension/app.go
@@ -3,6 +3,7 @@ package extension
import (
"context"
"encoding/xml"
+ "errors"
"fmt"
"os"
"path/filepath"
@@ -37,11 +38,11 @@ func (a App) GetResourcesDirs() []string {
}
func (a App) GetComposerName() (string, error) {
- return "", fmt.Errorf("app does not have a composer name")
+ return "", errors.New("app does not have a composer name")
}
func newApp(ctx context.Context, path string) (*App, error) {
- appFileName := fmt.Sprintf("%s/manifest.xml", path)
+ appFileName := path + "/manifest.xml"
if _, err := os.Stat(appFileName); err != nil {
return nil, err
@@ -145,7 +146,7 @@ func (a App) GetMetaData() *ExtensionMetadata {
}
func (a App) UpdateMetaData(metadata *ExtensionMetadata) error {
- manifestFile := fmt.Sprintf("%s/manifest.xml", a.path)
+ manifestFile := a.path + "/manifest.xml"
manifestBytes, err := os.ReadFile(manifestFile)
if err != nil {
@@ -159,7 +160,7 @@ func (a App) UpdateMetaData(metadata *ExtensionMetadata) error {
meta := manifest.Root().Find("meta")
if meta == nil {
- return fmt.Errorf("could not update manifest.xml: meta element not found")
+ return errors.New("could not update manifest.xml: meta element not found")
}
updateTranslatableXMLElement(meta, "label", metadata.Label)
diff --git a/internal/extension/asset_cache.go b/internal/extension/asset_cache.go
index b523020b..7831e50c 100644
--- a/internal/extension/asset_cache.go
+++ b/internal/extension/asset_cache.go
@@ -6,6 +6,7 @@ import (
"fmt"
"path"
"slices"
+ "strconv"
"github.com/cespare/xxhash/v2"
"golang.org/x/sync/errgroup"
@@ -15,7 +16,7 @@ import (
)
func hashCacheKeySuffix(p string) string {
- return fmt.Sprintf("%x", xxhash.Sum64String(p))
+ return strconv.FormatUint(xxhash.Sum64String(p), 16)
}
func restoreAssetCaches(ctx context.Context, sources ExtensionAssetConfig, assetCfg AssetBuildConfig) error {
diff --git a/internal/extension/asset_config.go b/internal/extension/asset_config.go
index 1dc4883c..0463b944 100644
--- a/internal/extension/asset_config.go
+++ b/internal/extension/asset_config.go
@@ -9,6 +9,7 @@ import (
"path/filepath"
"slices"
"sort"
+ "strconv"
"strings"
"sync"
@@ -318,7 +319,7 @@ func (e *ExtensionAssetConfigEntry) GetContentHash() (string, error) {
}
}
- e.sumOfFiles = fmt.Sprintf("%x", hasher.Sum64())
+ e.sumOfFiles = strconv.FormatUint(hasher.Sum64(), 16)
return e.sumOfFiles, nil
}
diff --git a/internal/extension/asset_platform.go b/internal/extension/asset_platform.go
index 01326a56..675da9b6 100644
--- a/internal/extension/asset_platform.go
+++ b/internal/extension/asset_platform.go
@@ -347,7 +347,7 @@ func BuildAssetsForExtensions(ctx context.Context, sources []asset.Source, asset
}
func prepareShopwareForAsset(shopwareRoot string, cfgs ExtensionAssetConfig, assetConfig AssetBuildConfig) error {
- varFolder := fmt.Sprintf("%s/var", shopwareRoot)
+ varFolder := shopwareRoot + "/var"
if _, err := os.Stat(varFolder); os.IsNotExist(err) {
err := os.Mkdir(varFolder, 0o755)
if err != nil {
@@ -376,11 +376,11 @@ func prepareShopwareForAsset(shopwareRoot string, cfgs ExtensionAssetConfig, ass
return fmt.Errorf("prepareShopwareForAsset: %w", err)
}
- if err = os.WriteFile(fmt.Sprintf("%s/var/plugins.json", shopwareRoot), pluginJson, os.ModePerm); err != nil {
+ if err = os.WriteFile(shopwareRoot+"/var/plugins.json", pluginJson, os.ModePerm); err != nil {
return fmt.Errorf("prepareShopwareForAsset: %w", err)
}
- err = os.WriteFile(fmt.Sprintf("%s/var/features.json", shopwareRoot), []byte("{}"), 0o644)
+ err = os.WriteFile(shopwareRoot+"/var/features.json", []byte("{}"), 0o644)
if err != nil {
return fmt.Errorf("prepareShopwareForAsset: %w", err)
}
diff --git a/internal/extension/bundle.go b/internal/extension/bundle.go
index 35a1b14c..58fd6c70 100644
--- a/internal/extension/bundle.go
+++ b/internal/extension/bundle.go
@@ -3,6 +3,7 @@ package extension
import (
"context"
"encoding/json"
+ "errors"
"fmt"
"os"
"path/filepath"
@@ -19,7 +20,7 @@ type ShopwareBundle struct {
}
func newShopwareBundle(ctx context.Context, path string) (*ShopwareBundle, error) {
- composerJsonFile := fmt.Sprintf("%s/composer.json", path)
+ composerJsonFile := path + "/composer.json"
if _, err := os.Stat(composerJsonFile); err != nil {
return nil, err
}
@@ -36,11 +37,11 @@ func newShopwareBundle(ctx context.Context, path string) (*ShopwareBundle, error
}
if composerJson.Type != "shopware-bundle" {
- return nil, fmt.Errorf("newShopwareBundle: composer.json type is not shopware-bundle")
+ return nil, errors.New("newShopwareBundle: composer.json type is not shopware-bundle")
}
if composerJson.Extra.BundleName == "" {
- return nil, fmt.Errorf("composer.json does not contain shopware-bundle-name in extra")
+ return nil, errors.New("composer.json does not contain shopware-bundle-name in extra")
}
cfg, err := readExtensionConfig(ctx, path)
diff --git a/internal/extension/changelog.go b/internal/extension/changelog.go
index 1186e0aa..8dfadaa7 100644
--- a/internal/extension/changelog.go
+++ b/internal/extension/changelog.go
@@ -1,6 +1,7 @@
package extension
import (
+ "errors"
"fmt"
"os"
"path/filepath"
@@ -10,7 +11,7 @@ import (
)
func parseMarkdownChangelogInPath(path string) (map[string]map[string]string, error) {
- files, err := filepath.Glob(fmt.Sprintf("%s/CHANGELOG*.md", path))
+ files, err := filepath.Glob(path + "/CHANGELOG*.md")
if err != nil {
return nil, err
}
@@ -89,7 +90,7 @@ func parseExtensionMarkdownChangelog(ext Extension) (*ExtensionChangelog, error)
changelogEnVersion, ok := changelogEn[v.String()]
if !ok {
- return nil, fmt.Errorf("english changelog is missing")
+ return nil, errors.New("english changelog is missing")
}
changelogDe, ok := changelogs["de-DE"]
diff --git a/internal/extension/cleanup_ci.go b/internal/extension/cleanup_ci.go
index a2394bde..5e0f24a8 100644
--- a/internal/extension/cleanup_ci.go
+++ b/internal/extension/cleanup_ci.go
@@ -185,7 +185,7 @@ func CleanupJavaScriptSourceMaps(folder string) error {
return fmt.Errorf("could not open file %s: %w", expectedJsFile, readErr)
}
- expectedSourceMapComment := fmt.Sprintf("//# sourceMappingURL=%s", filepath.Base(path))
+ expectedSourceMapComment := "//# sourceMappingURL=" + filepath.Base(path)
overwrittenContent := strings.ReplaceAll(string(content), expectedSourceMapComment, "")
diff --git a/internal/extension/config.go b/internal/extension/config.go
index 0ef37311..703be587 100644
--- a/internal/extension/config.go
+++ b/internal/extension/config.go
@@ -2,6 +2,7 @@ package extension
import (
"context"
+ "errors"
"fmt"
"log"
"os"
@@ -286,19 +287,19 @@ func validateExtensionConfig(config *Config) error {
}
if config.Store.Tags.English != nil && len(*config.Store.Tags.English) > 5 {
- return fmt.Errorf("store.info.tags.en can contain maximal 5 items")
+ return errors.New("store.info.tags.en can contain maximal 5 items")
}
if config.Store.Tags.German != nil && len(*config.Store.Tags.German) > 5 {
- return fmt.Errorf("store.info.tags.de can contain maximal 5 items")
+ return errors.New("store.info.tags.de can contain maximal 5 items")
}
if config.Store.Videos.English != nil && len(*config.Store.Videos.English) > 2 {
- return fmt.Errorf("store.info.videos.en can contain maximal 2 items")
+ return errors.New("store.info.videos.en can contain maximal 2 items")
}
if config.Store.Videos.German != nil && len(*config.Store.Videos.German) > 2 {
- return fmt.Errorf("store.info.videos.de can contain maximal 2 items")
+ return errors.New("store.info.videos.de can contain maximal 2 items")
}
for i, cache := range config.Build.Zip.Assets.AdditionalCaches {
diff --git a/internal/extension/platform.go b/internal/extension/platform.go
index 7ada9e8b..f43972d4 100644
--- a/internal/extension/platform.go
+++ b/internal/extension/platform.go
@@ -56,7 +56,7 @@ func (p PlatformPlugin) GetResourcesDirs() []string {
}
func newPlatformPlugin(ctx context.Context, path string) (*PlatformPlugin, error) {
- composerJsonFile := fmt.Sprintf("%s/composer.json", path)
+ composerJsonFile := path + "/composer.json"
if _, err := os.Stat(composerJsonFile); err != nil {
return nil, err
}
@@ -120,7 +120,7 @@ type platformComposerJsonExtra struct {
func (p PlatformPlugin) GetName() (string, error) {
if p.Composer.Extra.ShopwarePluginClass == "" {
- return "", fmt.Errorf("extension name is empty")
+ return "", errors.New("extension name is empty")
}
parts := strings.Split(p.Composer.Extra.ShopwarePluginClass, "\\")
@@ -175,7 +175,7 @@ func (p PlatformPlugin) GetMetaData() *ExtensionMetadata {
}
func (p PlatformPlugin) UpdateMetaData(metadata *ExtensionMetadata) error {
- composerJsonFile := fmt.Sprintf("%s/composer.json", p.path)
+ composerJsonFile := p.path + "/composer.json"
composerJson, err := os.ReadFile(composerJsonFile)
if err != nil {
@@ -408,7 +408,7 @@ func validatePHPFiles(c context.Context, ext Extension, check validation.Check)
check.AddResult(validation.CheckResult{
Path: "composer.json",
Identifier: "php.linter",
- Message: fmt.Sprintf("Could not parse shopware version constraint: %s", err.Error()),
+ Message: "Could not parse shopware version constraint: " + err.Error(),
Severity: validation.SeverityError,
})
return
@@ -428,7 +428,7 @@ func validatePHPFiles(c context.Context, ext Extension, check validation.Check)
check.AddResult(validation.CheckResult{
Path: "composer.json",
Identifier: "php.linter",
- Message: fmt.Sprintf("Could not find min php version for plugin: %s", err.Error()),
+ Message: "Could not find min php version for plugin: " + err.Error(),
Severity: validation.SeverityWarning,
})
return
@@ -445,7 +445,7 @@ func validatePHPFiles(c context.Context, ext Extension, check validation.Check)
check.AddResult(validation.CheckResult{
Path: "composer.json",
Identifier: "php.linter",
- Message: fmt.Sprintf("Could not parse php version: %s", err.Error()),
+ Message: "Could not parse php version: " + err.Error(),
Severity: validation.SeverityWarning,
})
return
@@ -471,10 +471,11 @@ func validatePHPFiles(c context.Context, ext Extension, check validation.Check)
check.AddResult(validation.CheckResult{
Path: relPath,
Identifier: "php.linter",
- Message: fmt.Sprintf("Could not read php file: %s", err.Error()),
+ Message: "Could not read php file: " + err.Error(),
Severity: validation.SeverityWarning,
})
- return nil
+ // The unreadable file is reported as a warning; the walk goes on.
+ return nil //nolint:nilerr
}
diags, err := phplint.Lint(relPath, content, phplint.Options{PHPVersion: ver})
@@ -482,10 +483,11 @@ func validatePHPFiles(c context.Context, ext Extension, check validation.Check)
check.AddResult(validation.CheckResult{
Path: relPath,
Identifier: "php.linter",
- Message: fmt.Sprintf("Could not lint php file: %s", err.Error()),
+ Message: "Could not lint php file: " + err.Error(),
Severity: validation.SeverityWarning,
})
- return nil
+ // The unlintable file is reported as a warning; the walk goes on.
+ return nil //nolint:nilerr
}
for _, diag := range diags {
diff --git a/internal/extension/project.go b/internal/extension/project.go
index c003ae35..1398a9d7 100644
--- a/internal/extension/project.go
+++ b/internal/extension/project.go
@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"encoding/json"
+ "errors"
"fmt"
"os"
"os/exec"
@@ -40,7 +41,7 @@ func GetShopwareProjectConstraint(project string) (*version.Constraints, error)
return v, nil
}
- return nil, fmt.Errorf("missing shopware/core requirement in composer.json")
+ return nil, errors.New("missing shopware/core requirement in composer.json")
}
c, err := version.NewConstraint(constraint)
@@ -80,13 +81,13 @@ func getProjectConstraintFromKernel(project string) (*version.Constraints, error
kernel, err := os.ReadFile(kernelPath)
if err != nil {
- return nil, fmt.Errorf("could not determine shopware version")
+ return nil, errors.New("could not determine shopware version")
}
matches := kernelFallbackRegExp.FindSubmatch(kernel)
if len(matches) < 2 {
- return nil, fmt.Errorf("could not determine shopware version")
+ return nil, errors.New("could not determine shopware version")
}
v, err := version.NewConstraint(fmt.Sprintf("~%s.0", string(matches[1])))
diff --git a/internal/extension/root.go b/internal/extension/root.go
index c14e9893..d2fcac3d 100644
--- a/internal/extension/root.go
+++ b/internal/extension/root.go
@@ -26,16 +26,16 @@ const (
)
func GetExtensionByFolder(ctx context.Context, path string) (Extension, error) {
- if _, err := os.Stat(fmt.Sprintf("%s/plugin.xml", path)); err == nil {
- return nil, fmt.Errorf("shopware 5 is not supported. Please use https://github.com/FriendsOfShopware/FroshPluginUploader instead")
+ if _, err := os.Stat(path + "/plugin.xml"); err == nil {
+ return nil, errors.New("shopware 5 is not supported. Please use https://github.com/FriendsOfShopware/FroshPluginUploader instead")
}
- if _, err := os.Stat(fmt.Sprintf("%s/manifest.xml", path)); err == nil {
+ if _, err := os.Stat(path + "/manifest.xml"); err == nil {
return newApp(ctx, path)
}
- if _, err := os.Stat(fmt.Sprintf("%s/composer.json", path)); err != nil {
- return nil, fmt.Errorf("unknown extension type")
+ if _, err := os.Stat(path + "/composer.json"); err != nil {
+ return nil, errors.New("unknown extension type")
}
var ext Extension
@@ -76,7 +76,7 @@ func GetExtensionByZip(ctx context.Context, filePath string) (Extension, error)
fileName := file.File[0].Name
if strings.Contains(fileName, "..") {
- return nil, fmt.Errorf("invalid zip file")
+ return nil, errors.New("invalid zip file")
}
extName := strings.Split(fileName, "/")[0]
@@ -134,7 +134,7 @@ type Extension interface {
func getShopwareVersionConstraintFromComposer(composerRequire map[string]string) (*version.Constraints, error) {
shopwareConstraintString, ok := composerRequire["shopware/core"]
if !ok {
- return nil, fmt.Errorf("require.shopware/core is required")
+ return nil, errors.New("require.shopware/core is required")
}
shopwareConstraint, err := version.NewConstraint(shopwareConstraintString)
diff --git a/internal/extension/storefront_watch.go b/internal/extension/storefront_watch.go
index 590a822f..5e2aa1f1 100644
--- a/internal/extension/storefront_watch.go
+++ b/internal/extension/storefront_watch.go
@@ -2,7 +2,7 @@ package extension
import (
"context"
- "fmt"
+ "errors"
"io"
"os"
"strings"
@@ -73,7 +73,7 @@ func storefrontThemeDumpArgs(ctx context.Context, opts StorefrontWatcherOptions)
args = append(args, opts.DomainURL)
}
} else if !system.IsInteractionEnabled(ctx) {
- return nil, fmt.Errorf("theme selection requires interaction; pass --sales-channel when using --no-interaction")
+ return nil, errors.New("theme selection requires interaction; pass --sales-channel when using --no-interaction")
}
return args, nil
diff --git a/internal/extension/validator.go b/internal/extension/validator.go
index a0fb8afc..425c0fd0 100644
--- a/internal/extension/validator.go
+++ b/internal/extension/validator.go
@@ -238,7 +238,7 @@ func runDefaultValidate(ext Extension, check validation.Check) {
check.AddResult(validation.CheckResult{
Path: rootFile,
Identifier: "metadata.license",
- Message: fmt.Sprintf("Could not read the license of the extension: %s", err.Error()),
+ Message: "Could not read the license of the extension: " + err.Error(),
Severity: validation.SeverityError,
})
} else if strings.TrimSpace(strings.ToLower(license)) != "proprietary" {
@@ -247,7 +247,7 @@ func runDefaultValidate(ext Extension, check validation.Check) {
check.AddResult(validation.CheckResult{
Path: rootFile,
Identifier: "metadata.license",
- Message: fmt.Sprintf("Could not load the SPDX license list: %s", err.Error()),
+ Message: "Could not load the SPDX license list: " + err.Error(),
Severity: validation.SeverityWarning,
})
} else {
@@ -256,7 +256,7 @@ func runDefaultValidate(ext Extension, check validation.Check) {
check.AddResult(validation.CheckResult{
Path: rootFile,
Identifier: "metadata.license",
- Message: fmt.Sprintf("Could not validate the license: %s", err.Error()),
+ Message: "Could not validate the license: " + err.Error(),
Severity: validation.SeverityError,
})
} else if !valid {
diff --git a/internal/extension/zip.go b/internal/extension/zip.go
index 1984463c..adeed174 100644
--- a/internal/extension/zip.go
+++ b/internal/extension/zip.go
@@ -148,7 +148,7 @@ func addComposerReplacements(composer map[string]interface{}, minVersion string)
}
for _, component := range components {
- packageName := fmt.Sprintf("shopware/%s", component)
+ packageName := "shopware/" + component
if _, ok := require.(map[string]interface{})[packageName]; ok {
composerFile, err := composerInfo.Open(fmt.Sprintf("%s/%s.json", minVersion, component))
diff --git a/internal/git/git.go b/internal/git/git.go
index 798e004e..c2ae2100 100644
--- a/internal/git/git.go
+++ b/internal/git/git.go
@@ -174,17 +174,17 @@ func GetPublicVCSURL(ctx context.Context, repo string) (string, error) {
case strings.HasPrefix(origin, "https://github.com/"):
origin = strings.TrimSuffix(origin, ".git")
- return fmt.Sprintf("%s/commit", origin), nil
+ return origin + "/commit", nil
case strings.HasPrefix(origin, "git@github.com:"):
origin = origin[15:]
origin = strings.TrimSuffix(origin, ".git")
return fmt.Sprintf("https://github.com/%s/commit", origin), nil
case os.Getenv("CI_PROJECT_URL") != "":
- return fmt.Sprintf("%s/-/commit", os.Getenv("CI_PROJECT_URL")), nil
+ return os.Getenv("CI_PROJECT_URL") + "/-/commit", nil
}
- return "", fmt.Errorf("unsupported vcs provider")
+ return "", errors.New("unsupported vcs provider")
}
func unshallowRepository(ctx context.Context, repo string) error {
diff --git a/internal/mjml/compiler.go b/internal/mjml/compiler.go
index 4062af03..f9a659b3 100644
--- a/internal/mjml/compiler.go
+++ b/internal/mjml/compiler.go
@@ -61,7 +61,7 @@ func Compile(ctx context.Context, mjmlPath string, opts CompileOptions) (string,
if err != nil {
return "", fmt.Errorf("failed to encode mjml include paths: %w", err)
}
- args = append(args, fmt.Sprintf("--config.includePath=%s", string(encoded)))
+ args = append(args, "--config.includePath="+string(encoded))
}
cmd := exec.CommandContext(ctx, "npx", args...)
diff --git a/internal/mysqldump/mysql.go b/internal/mysqldump/mysql.go
index 22e9bd1b..f0197754 100644
--- a/internal/mysqldump/mysql.go
+++ b/internal/mysqldump/mysql.go
@@ -589,9 +589,11 @@ func (d *Dumper) getProperEscapedValue(col *sql.RawBytes, table, columnName stri
func (d *Dumper) generateInsertStatement(cols []string, table string) string {
s := fmt.Sprintf("INSERT INTO `%s` (", table)
+ var sSb592 strings.Builder
for _, col := range cols {
- s += fmt.Sprintf("%s, ", col)
+ sSb592.WriteString(col + ", ")
}
+ s += sSb592.String()
return s[:len(s)-2] + ") VALUES"
}
diff --git a/internal/shop/client.go b/internal/shop/client.go
index ece29dcc..b81f2ffe 100644
--- a/internal/shop/client.go
+++ b/internal/shop/client.go
@@ -3,6 +3,7 @@ package shop
import (
"context"
"crypto/tls"
+ "errors"
"fmt"
"net/http"
"os"
@@ -24,7 +25,7 @@ func newShopCredentials(config *Config) (adminSdk.OAuthCredentials, error) {
}
if config.AdminApi == nil {
- return nil, fmt.Errorf("admin-api is not enabled in config")
+ return nil, errors.New("admin-api is not enabled in config")
}
if config.AdminApi.Username != "" {
diff --git a/internal/shop/config.go b/internal/shop/config.go
index 10dfb666..739b63b6 100644
--- a/internal/shop/config.go
+++ b/internal/shop/config.go
@@ -2,6 +2,7 @@ package shop
import (
"context"
+ "errors"
"fmt"
"os"
"path"
@@ -471,7 +472,7 @@ func (h *ConfigDeploymentHook) UnmarshalYAML(value *yaml.Node) error {
return nil
}
- return fmt.Errorf("invalid hook: expected a script string or a list of steps")
+ return errors.New("invalid hook: expected a script string or a list of steps")
}
func (ConfigDeploymentHook) JSONSchema() *jsonschema.Schema {
diff --git a/internal/shop/console.go b/internal/shop/console.go
index d26464a5..8f973b8d 100644
--- a/internal/shop/console.go
+++ b/internal/shop/console.go
@@ -3,7 +3,6 @@ package shop
import (
"context"
"encoding/json"
- "fmt"
"os"
"os/exec"
"path"
@@ -27,7 +26,7 @@ func (c ConsoleResponse) GetCommandOptions(name string) []string {
if !command.Hidden && command.Name == name {
options := make([]string, 0)
for optionName := range command.Definition.Options {
- options = append(options, fmt.Sprintf("--%s", optionName))
+ options = append(options, "--"+optionName)
}
return options
diff --git a/internal/shop/pluginmigrate/headless.go b/internal/shop/pluginmigrate/headless.go
index 86771021..c3a0d952 100644
--- a/internal/shop/pluginmigrate/headless.go
+++ b/internal/shop/pluginmigrate/headless.go
@@ -2,6 +2,7 @@ package pluginmigrate
import (
"context"
+ "errors"
"fmt"
"io"
@@ -46,7 +47,7 @@ func (m *PluginMigrator) RunHeadless(ctx context.Context, opts HeadlessOptions)
printPlan(out, plan)
if !plan.Actionable() {
- return fmt.Errorf("none of the extensions can be migrated automatically; add a composer.json with a package name to each")
+ return errors.New("none of the extensions can be migrated automatically; add a composer.json with a package name to each")
}
if opts.DryRun {
diff --git a/internal/shop/project_composer_json.go b/internal/shop/project_composer_json.go
index 720d099b..f0ab15be 100644
--- a/internal/shop/project_composer_json.go
+++ b/internal/shop/project_composer_json.go
@@ -3,6 +3,7 @@ package shop
import (
"context"
"encoding/json"
+ "errors"
"fmt"
"io"
"net/http"
@@ -248,7 +249,7 @@ func getLatestFallbackVersion(ctx context.Context, branch string) (string, error
matches := kernelFallbackRegExp.FindSubmatch(content)
if len(matches) < 2 {
- return "", fmt.Errorf("could not determine shopware version")
+ return "", errors.New("could not determine shopware version")
}
return string(matches[1]), nil
diff --git a/internal/shop/project_scaffold.go b/internal/shop/project_scaffold.go
index df7a02fd..51446021 100644
--- a/internal/shop/project_scaffold.go
+++ b/internal/shop/project_scaffold.go
@@ -4,7 +4,7 @@ import (
"bytes"
"context"
_ "embed"
- "fmt"
+ "errors"
"os"
"path/filepath"
"strings"
@@ -68,7 +68,7 @@ func (s ShopwareProjectScaffold) Validate() error {
return err
}
if s.Version == "" {
- return fmt.Errorf("project version must not be empty")
+ return errors.New("project version must not be empty")
}
if err := ValidateDeploymentMethod(s.DeploymentMethod); err != nil {
return err
diff --git a/internal/shop/upgrade/headless.go b/internal/shop/upgrade/headless.go
index ca2facb3..3f40a972 100644
--- a/internal/shop/upgrade/headless.go
+++ b/internal/shop/upgrade/headless.go
@@ -2,6 +2,7 @@ package upgrade
import (
"context"
+ "errors"
"fmt"
"io"
"path/filepath"
@@ -57,7 +58,7 @@ func (u *ProjectUpgrader) RunHeadless(ctx context.Context, opts HeadlessOptions)
}
}
if readiness.Blocked() {
- return fmt.Errorf("the project is not ready to upgrade; fix the failing checks above")
+ return errors.New("the project is not ready to upgrade; fix the failing checks above")
}
catalog, err := u.LoadCatalog(ctx, readiness.CurrentVersion)
@@ -148,12 +149,12 @@ func selectTarget(catalog *Catalog, target string) (*VersionOption, error) {
return nil, fmt.Errorf("--target is required in non-interactive mode; available versions:\n%s", availableTargets(catalog))
case TargetRecommended:
if catalog.Recommended < 0 || catalog.Recommended >= len(catalog.Options) {
- return nil, fmt.Errorf("no recommended version available")
+ return nil, errors.New("no recommended version available")
}
return &catalog.Options[catalog.Recommended], nil
case TargetLatestPatch:
if catalog.LatestPatch < 0 || catalog.LatestPatch >= len(catalog.Options) {
- return nil, fmt.Errorf("no newer patch release of the current minor available")
+ return nil, errors.New("no newer patch release of the current minor available")
}
return &catalog.Options[catalog.LatestPatch], nil
}
diff --git a/internal/symfony/config_packages.go b/internal/symfony/config_packages.go
index 8c8c6d22..d56a9298 100644
--- a/internal/symfony/config_packages.go
+++ b/internal/symfony/config_packages.go
@@ -1,6 +1,7 @@
package symfony
import (
+ "errors"
"fmt"
"os"
"path/filepath"
@@ -236,7 +237,7 @@ func (pc *ProjectConfig) GetConfigValue(environment, path string) (any, bool, er
func getConfigValue(cfg map[string]any, path string) (any, bool, error) {
segments := splitPath(path)
if len(segments) == 0 {
- return nil, false, fmt.Errorf("empty config path")
+ return nil, false, errors.New("empty config path")
}
var current any = cfg
diff --git a/internal/symfony/config_packages_write.go b/internal/symfony/config_packages_write.go
index ae7bf1c9..04e4cb41 100644
--- a/internal/symfony/config_packages_write.go
+++ b/internal/symfony/config_packages_write.go
@@ -2,6 +2,7 @@ package symfony
import (
"bytes"
+ "errors"
"fmt"
"os"
"path/filepath"
@@ -30,7 +31,7 @@ import (
func (pc *ProjectConfig) SetConfigValue(environment string, path string, value any) error {
segments := splitPath(path)
if len(segments) == 0 {
- return fmt.Errorf("empty config path")
+ return errors.New("empty config path")
}
target := pc.resolveWriteTarget(environment, segments)
diff --git a/internal/symfony/convert.go b/internal/symfony/convert.go
index bba71cd0..1c986db4 100644
--- a/internal/symfony/convert.go
+++ b/internal/symfony/convert.go
@@ -2,6 +2,7 @@ package symfony
import (
"bytes"
+ "errors"
"fmt"
"strings"
@@ -29,7 +30,7 @@ func ConvertContainerToYAML(container *Container) ([]byte, error) {
for _, when := range container.When {
if when.Env == "" {
- return nil, fmt.Errorf(" requires an env attribute")
+ return nil, errors.New(" requires an env attribute")
}
if err := unknownElementError("when", when.Unknown); err != nil {
@@ -113,7 +114,7 @@ func importsToNode(imports *xmlImports) (*yaml.Node, error) {
}
if imp.Resource == "" {
- return nil, fmt.Errorf(" requires a resource attribute")
+ return nil, errors.New(" requires a resource attribute")
}
entry := newMapping()
@@ -143,7 +144,7 @@ func parametersToNode(parameters *xmlParameters) (*yaml.Node, error) {
for _, parameter := range parameters.Parameters {
if parameter.Key == "" {
- return nil, fmt.Errorf(" requires a key attribute")
+ return nil, errors.New(" requires a key attribute")
}
value, err := parameterValueToNode(parameter)
@@ -214,7 +215,7 @@ func parameterCollectionToNode(children []xmlParameter) (*yaml.Node, error) {
for _, child := range children {
if child.Key == "" {
- return nil, fmt.Errorf("collection mixes keyed and unkeyed elements")
+ return nil, errors.New("collection mixes keyed and unkeyed elements")
}
value, err := parameterValueToNode(child)
@@ -232,7 +233,7 @@ func servicesToNode(services *xmlServices) (*yaml.Node, error) {
mapping := newMapping()
if len(services.Defaults) > 1 {
- return nil, fmt.Errorf("multiple elements are not supported")
+ return nil, errors.New("multiple elements are not supported")
}
if len(services.Defaults) == 1 {
@@ -255,7 +256,7 @@ func servicesToNode(services *xmlServices) (*yaml.Node, error) {
}
if item.Service.ID == "" {
- return nil, fmt.Errorf(" requires an id attribute")
+ return nil, errors.New(" requires an id attribute")
}
if item.Service.Class != "" {
@@ -364,7 +365,7 @@ func defaultsToNode(defaults xmlDefaults) (*yaml.Node, error) {
func serviceToNode(service *xmlService) (string, *yaml.Node, error) {
if service.ID == "" {
- return "", nil, fmt.Errorf(" requires an id attribute")
+ return "", nil, errors.New(" requires an id attribute")
}
if err := rejectPrototypeFields(service, fmt.Sprintf("service %q", service.ID)); err != nil {
@@ -400,7 +401,7 @@ func aliasToNode(service *xmlService) (*yaml.Node, error) {
}
if hasDefinitionConfig(service) {
- return nil, fmt.Errorf("aliases only support the public attribute and ")
+ return nil, errors.New("aliases only support the public attribute and ")
}
if service.Public == "" && service.Deprecated == nil {
@@ -455,7 +456,7 @@ func hasDefinitionConfig(service *xmlService) bool {
func prototypeToNode(prototype *xmlService) (string, *yaml.Node, error) {
if prototype.Namespace == "" {
- return "", nil, fmt.Errorf(" requires a namespace attribute")
+ return "", nil, errors.New(" requires a namespace attribute")
}
if prototype.Resource == "" {
@@ -570,7 +571,7 @@ func appendServiceAttributes(mapping *yaml.Node, service *xmlService, omitClass
func appendDecoration(mapping *yaml.Node, service *xmlService) error {
if service.Decorates == "" {
if service.DecorationInnerName != "" || service.DecorationPriority != "" || service.DecorationOnInvalid != "" {
- return fmt.Errorf("decoration attributes require the decorates attribute")
+ return errors.New("decoration attributes require the decorates attribute")
}
return nil
@@ -806,11 +807,11 @@ func argumentValueToNode(argument xmlArgument) (*yaml.Node, error) {
}
if len(argument.InlineServices) > 0 && argument.Type != "service" {
- return nil, fmt.Errorf("inline elements require type=\"service\"")
+ return nil, errors.New("inline elements require type=\"service\"")
}
if len(argument.Excludes) > 0 && argument.Type != "tagged" && argument.Type != "tagged_iterator" && argument.Type != "tagged_locator" {
- return nil, fmt.Errorf(" elements are only supported on tagged iterator arguments")
+ return nil, errors.New(" elements are only supported on tagged iterator arguments")
}
switch argument.Type {
@@ -824,7 +825,7 @@ func argumentValueToNode(argument xmlArgument) (*yaml.Node, error) {
return argumentsToNode(argument.Children, false)
case "service":
if len(argument.InlineServices) > 1 {
- return nil, fmt.Errorf("only one inline is supported per argument")
+ return nil, errors.New("only one inline is supported per argument")
}
if len(argument.InlineServices) == 1 {
@@ -832,7 +833,7 @@ func argumentValueToNode(argument xmlArgument) (*yaml.Node, error) {
}
if argument.ID == "" {
- return nil, fmt.Errorf("argument type=\"service\" requires an id attribute")
+ return nil, errors.New("argument type=\"service\" requires an id attribute")
}
return referenceNode(argument.ID, argument.OnInvalid)
@@ -934,11 +935,11 @@ func taggedIteratorToNode(argument xmlArgument) (*yaml.Node, error) {
func inlineServiceToNode(service xmlService) (*yaml.Node, error) {
if service.ID != "" || service.Alias != "" {
- return nil, fmt.Errorf("inline services do not support id or alias attributes")
+ return nil, errors.New("inline services do not support id or alias attributes")
}
if service.Class == "" {
- return nil, fmt.Errorf("inline services require a class attribute")
+ return nil, errors.New("inline services require a class attribute")
}
if err := rejectPrototypeFields(&service, "inline service"); err != nil {
@@ -966,7 +967,7 @@ func referenceNode(id string, onInvalid string) (*yaml.Node, error) {
// "@?" means ignore, which removes method calls and collection
// entries instead of passing null for them, so the null strategy
// cannot be expressed in YAML.
- return nil, fmt.Errorf(`on-invalid="null" is not supported by the YAML format, change it to on-invalid="ignore" first if dropping the dependency is acceptable`)
+ return nil, errors.New(`on-invalid="null" is not supported by the YAML format, change it to on-invalid="ignore" first if dropping the dependency is acceptable`)
case "ignore_uninitialized":
prefix = "@!"
default:
@@ -981,7 +982,7 @@ func propertiesToNode(properties []xmlProperty) (*yaml.Node, error) {
for _, property := range properties {
if property.Name == "" {
- return nil, fmt.Errorf(" requires a name attribute")
+ return nil, errors.New(" requires a name attribute")
}
node, err := argumentValueToNode(property.xmlArgument)
@@ -1000,7 +1001,7 @@ func bindsToNode(binds []xmlArgument) (*yaml.Node, error) {
for _, bind := range binds {
if bind.Key == "" {
- return nil, fmt.Errorf(" requires a key attribute")
+ return nil, errors.New(" requires a key attribute")
}
key := bind.Key
@@ -1030,7 +1031,7 @@ func callsToNode(calls []xmlCall) (*yaml.Node, error) {
}
if call.Method == "" {
- return nil, fmt.Errorf(" requires a method attribute")
+ return nil, errors.New(" requires a method attribute")
}
entry := newSequence()
@@ -1080,7 +1081,7 @@ func tagToNode(tag xmlTag) (*yaml.Node, error) {
}
if name == "" {
- return nil, fmt.Errorf(" requires a name")
+ return nil, errors.New(" requires a name")
}
if len(tag.OtherAttrs) == 0 && len(tag.Attributes) == 0 {
@@ -1109,7 +1110,7 @@ func tagToNode(tag xmlTag) (*yaml.Node, error) {
func tagAttributeToNode(attribute xmlTagAttribute) (*yaml.Node, error) {
if attribute.Name == "" {
- return nil, fmt.Errorf("tag requires a name attribute")
+ return nil, errors.New("tag requires a name attribute")
}
if len(attribute.Children) == 0 {
diff --git a/internal/symfony/routes_convert.go b/internal/symfony/routes_convert.go
index d06fe91b..d6cae242 100644
--- a/internal/symfony/routes_convert.go
+++ b/internal/symfony/routes_convert.go
@@ -2,6 +2,7 @@ package symfony
import (
"bytes"
+ "errors"
"fmt"
"path/filepath"
"regexp"
@@ -74,11 +75,11 @@ func appendRouteItems(target *yaml.Node, items []xmlRouteItem, usedKeys map[stri
mapPut(target, routeImportKey(item.Import.Resource, usedKeys), node)
case "when":
if !allowWhen {
- return fmt.Errorf(" elements cannot be nested")
+ return errors.New(" elements cannot be nested")
}
if item.When.Env == "" {
- return fmt.Errorf(" requires an env attribute")
+ return errors.New(" requires an env attribute")
}
key := "when@" + item.When.Env
@@ -145,7 +146,7 @@ func routeImportKey(resource string, usedKeys map[string]bool) string {
func routeToNode(route *xmlRoute) (string, *yaml.Node, error) {
if route.ID == "" {
- return "", nil, fmt.Errorf(" requires an id attribute")
+ return "", nil, errors.New(" requires an id attribute")
}
node, err := buildRouteNode(route)
@@ -169,7 +170,7 @@ func buildRouteNode(route *xmlRoute) (*yaml.Node, error) {
switch {
case route.Path != "" && len(route.Paths) > 0:
- return nil, fmt.Errorf("must not have both a path attribute and child elements")
+ return nil, errors.New("must not have both a path attribute and child elements")
case route.Path != "":
mapPut(mapping, "path", newString(route.Path))
case len(route.Paths) > 0:
@@ -180,13 +181,13 @@ func buildRouteNode(route *xmlRoute) (*yaml.Node, error) {
mapPut(mapping, "path", node)
default:
- return nil, fmt.Errorf("requires a path attribute or child elements")
+ return nil, errors.New("requires a path attribute or child elements")
}
if route.Controller != "" {
for _, def := range route.Defaults {
if def.Key == "_controller" {
- return nil, fmt.Errorf("must not specify both the controller attribute and the _controller default")
+ return nil, errors.New("must not specify both the controller attribute and the _controller default")
}
}
@@ -226,7 +227,7 @@ func routeImportToNode(imp *xmlRouteImport) (*yaml.Node, error) {
}
if imp.Resource == "" {
- return nil, fmt.Errorf(" requires a resource attribute")
+ return nil, errors.New(" requires a resource attribute")
}
mapping := newMapping()
@@ -314,7 +315,7 @@ type sharedRouteConfig struct {
func appendSharedRouteConfig(mapping *yaml.Node, config sharedRouteConfig) error {
switch {
case config.host != "" && len(config.hosts) > 0:
- return fmt.Errorf("must not have both a host attribute and child elements")
+ return errors.New("must not have both a host attribute and child elements")
case config.host != "":
mapPut(mapping, "host", newString(config.host))
case len(config.hosts) > 0:
@@ -433,7 +434,7 @@ func routeDefaultsToNode(defaults []xmlRouteDefault) (*yaml.Node, error) {
for _, def := range defaults {
if def.Key == "" {
- return nil, fmt.Errorf(" requires a key attribute")
+ return nil, errors.New(" requires a key attribute")
}
node, err := routeDefaultToNode(def)
@@ -453,7 +454,7 @@ func routeDefaultToNode(def xmlRouteDefault) (*yaml.Node, error) {
}
if len(def.Typed) > 1 {
- return nil, fmt.Errorf("only one typed value element is allowed")
+ return nil, errors.New("only one typed value element is allowed")
}
if len(def.Typed) == 1 {
@@ -506,7 +507,7 @@ func typedValueToNode(value xmlTypedValue) (*yaml.Node, error) {
mapping := newMapping()
for _, child := range value.Children {
if child.Key == "" {
- return nil, fmt.Errorf("
", "\n",
+ "
", "\n",
+ "
", "\n",
+ "
", "\n",
+ )
+ s = replacer.Replace(s)
+
+ var b strings.Builder
+ inTag := false
+ for _, r := range s {
+ switch {
+ case r == '<':
+ inTag = true
+ case r == '>':
+ inTag = false
+ case !inTag:
+ b.WriteRune(r)
+ }
+ }
+
+ lines := strings.Split(html.UnescapeString(b.String()), "\n")
+ cleaned := make([]string, 0, len(lines))
+ for _, line := range lines {
+ if line = strings.TrimSpace(line); line != "" {
+ cleaned = append(cleaned, line)
+ }
+ }
+ return strings.Join(cleaned, "\n")
+}
diff --git a/internal/shop/upgrade/changelog_test.go b/internal/shop/upgrade/changelog_test.go
new file mode 100644
index 00000000..6f178ff6
--- /dev/null
+++ b/internal/shop/upgrade/changelog_test.go
@@ -0,0 +1,114 @@
+package upgrade
+
+import (
+ "context"
+ "errors"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ account_api "github.com/shopware/shopware-cli/internal/account-api"
+)
+
+func updateResult(name, installed, available string) ExtensionResult {
+ return ExtensionResult{
+ Extension: InstalledExtension{Name: name, Version: installed, ComposerManaged: true},
+ Status: ExtOK,
+ Available: available,
+ }
+}
+
+func storeChangelog(v, date, text string) account_api.StoreChangelog {
+ return account_api.StoreChangelog{Version: v, Text: text, CreationDate: account_api.StoreDate{Date: date}}
+}
+
+func TestLoadExtensionChangelogs(t *testing.T) {
+ dir := setupProject(t)
+ u := newTestUpgrader(t, dir)
+
+ var gotNames []string
+ var gotVersion string
+ u.storePlugins = func(_ context.Context, locale, shopwareVersion string, names []string) ([]account_api.StorePlugin, error) {
+ assert.Equal(t, "en_GB", locale)
+ gotVersion = shopwareVersion
+ gotNames = names
+ return []account_api.StorePlugin{{
+ Name: "SwagDemo",
+ Changelogs: []account_api.StoreChangelog{
+ storeChangelog("2.5.0", "2026-06-01 00:00:00.000000", "too new"),
+ storeChangelog("2.1.0", "2026-03-01 00:00:00.000000", "Adds things
"),
+ storeChangelog("2.0.0", "2026-01-15 00:00:00.000000", "Major rework"),
+ storeChangelog("1.0.0", "2025-01-01 00:00:00.000000", "already installed"),
+ },
+ }}, nil
+ }
+
+ results := []ExtensionResult{
+ updateResult("SwagDemo", "1.0.0", "2.1.0"),
+ updateResult("NoUpdate", "3.0.0", "3.0.0"),
+ {Extension: InstalledExtension{Name: "NoRelease", Version: "1.0.0"}, Status: ExtBlocked},
+ }
+
+ changelogs := u.LoadExtensionChangelogs(t.Context(), "6.7.11.0", results)
+
+ assert.Equal(t, []string{"SwagDemo"}, gotNames, "only extensions with an actual update are queried")
+ assert.Equal(t, "6.7.11.0", gotVersion)
+
+ require.Len(t, changelogs, 1)
+ cl := changelogs[0]
+ assert.Equal(t, "SwagDemo", cl.Extension)
+ assert.Equal(t, "1.0.0", cl.From)
+ assert.Equal(t, "2.1.0", cl.To)
+
+ require.Len(t, cl.Entries, 2, "entries outside (installed, available] are dropped")
+ assert.Equal(t, "2.1.0", cl.Entries[0].Version, "newest first")
+ assert.Equal(t, "2026-03-01", cl.Entries[0].Date)
+ assert.Equal(t, "Adds things\n- one\n- two & three", cl.Entries[0].Text, "HTML is flattened to text")
+ assert.Equal(t, "2.0.0", cl.Entries[1].Version)
+}
+
+func TestLoadExtensionChangelogsNoUpdatesSkipsStore(t *testing.T) {
+ dir := setupProject(t)
+ u := newTestUpgrader(t, dir)
+ u.storePlugins = func(context.Context, string, string, []string) ([]account_api.StorePlugin, error) {
+ t.Fatal("the store must not be queried without updates")
+ return nil, context.Canceled
+ }
+
+ assert.Nil(t, u.LoadExtensionChangelogs(t.Context(), "6.7.11.0", []ExtensionResult{
+ updateResult("NoUpdate", "3.0.0", "3.0.0"),
+ }))
+}
+
+func TestLoadExtensionChangelogsStoreErrorIsAdvisory(t *testing.T) {
+ dir := setupProject(t)
+ u := newTestUpgrader(t, dir)
+ u.storePlugins = func(context.Context, string, string, []string) ([]account_api.StorePlugin, error) {
+ return nil, errors.New("store down")
+ }
+
+ assert.Nil(t, u.LoadExtensionChangelogs(t.Context(), "6.7.11.0", []ExtensionResult{
+ updateResult("SwagDemo", "1.0.0", "2.0.0"),
+ }))
+}
+
+func TestReportIncludesChangelogs(t *testing.T) {
+ report := renderReport(ReportData{
+ ProjectName: "acme-shop",
+ Current: "6.6.10.3",
+ Target: "6.7.11.0",
+ Changelogs: []ExtensionChangelog{{
+ Extension: "SwagDemo",
+ From: "1.0.0",
+ To: "2.0.0",
+ Entries: []ChangelogEntry{
+ {Version: "2.0.0", Date: "2026-01-15", Text: "Major rework"},
+ },
+ }},
+ })
+ assert.Contains(t, report, "## Extension update changelogs")
+ assert.Contains(t, report, "### SwagDemo (1.0.0 → 2.0.0)")
+ assert.Contains(t, report, "**2.0.0 — 2026-01-15**")
+ assert.Contains(t, report, "Major rework")
+}
diff --git a/internal/shop/upgrade/headless.go b/internal/shop/upgrade/headless.go
index 3f40a972..5525d010 100644
--- a/internal/shop/upgrade/headless.go
+++ b/internal/shop/upgrade/headless.go
@@ -232,6 +232,7 @@ func (u *ProjectUpgrader) headlessReportData(ctx context.Context, readiness Read
PHPInstalled: u.InstalledPHPVersion(ctx),
ComposerReport: composerReport,
ResolvedChanges: resolve.Changes,
+ Changelogs: u.LoadExtensionChangelogs(ctx, target.Version.String(), results),
}
}
diff --git a/internal/shop/upgrade/headless_test.go b/internal/shop/upgrade/headless_test.go
index 6a94117d..2a7366a0 100644
--- a/internal/shop/upgrade/headless_test.go
+++ b/internal/shop/upgrade/headless_test.go
@@ -52,6 +52,9 @@ func headlessUpgrader(t *testing.T, dir, composerScript string) *ProjectUpgrader
u.extensionUpdates = func(context.Context, string, string, []account_api.UpdateCheckExtension) ([]account_api.UpdateCheckExtensionCompatibility, error) {
return nil, nil
}
+ u.storePlugins = func(context.Context, string, string, []string) ([]account_api.StorePlugin, error) {
+ return nil, nil
+ }
return u
}
diff --git a/internal/shop/upgrade/report.go b/internal/shop/upgrade/report.go
index fc0a1b3f..42ae0153 100644
--- a/internal/shop/upgrade/report.go
+++ b/internal/shop/upgrade/report.go
@@ -37,6 +37,9 @@ type ReportData struct {
ComposerReport string
// ResolvedChanges are the lock-file operations the dry run predicted.
ResolvedChanges []PackageChange
+ // Changelogs are the Store release notes of the extension updates the
+ // upgrade applies.
+ Changelogs []ExtensionChangelog
// Failed marks a report written for an upgrade that was rolled back;
// Error carries the failing step's message.
Failed bool
@@ -109,6 +112,8 @@ func renderReport(data ReportData) string {
return s == ExtOK
})
+ writeChangelogs(&b, data.Changelogs)
+
if len(data.ResolvedChanges) > 0 {
b.WriteString("## Resolved package changes\n\n")
b.WriteString("| Package | From | To | Operation |\n|---|---|---|---|\n")
@@ -165,6 +170,28 @@ func writeExtensionGroup(b *strings.Builder, title string, extensions []Extensio
b.WriteString("\n")
}
+func writeChangelogs(b *strings.Builder, changelogs []ExtensionChangelog) {
+ if len(changelogs) == 0 {
+ return
+ }
+
+ b.WriteString("## Extension update changelogs\n\n")
+ for _, cl := range changelogs {
+ fmt.Fprintf(b, "### %s (%s → %s)\n\n", cl.Extension, cl.From, cl.To)
+ for _, entry := range cl.Entries {
+ heading := entry.Version
+ if entry.Date != "" {
+ heading += " — " + entry.Date
+ }
+ fmt.Fprintf(b, "**%s**\n\n", heading)
+ if entry.Text != "" {
+ b.WriteString(entry.Text)
+ b.WriteString("\n\n")
+ }
+ }
+ }
+}
+
func stateMarker(s CheckState) string {
switch s {
case StateOK:
diff --git a/internal/shop/upgrade/upgrader.go b/internal/shop/upgrade/upgrader.go
index a01ffae0..c511b3dc 100644
--- a/internal/shop/upgrade/upgrader.go
+++ b/internal/shop/upgrade/upgrader.go
@@ -29,6 +29,7 @@ type ProjectUpgrader struct {
shopwareVersions func(ctx context.Context) ([]string, error)
extensionUpdates func(ctx context.Context, current, future string, extensions []account_api.UpdateCheckExtension) ([]account_api.UpdateCheckExtensionCompatibility, error)
+ storePlugins func(ctx context.Context, locale, shopwareVersion string, names []string) ([]account_api.StorePlugin, error)
repositories func(*composer.Json, *composer.Auth) *repository.Set
endOfLifeURL string
packagistPingURL string
@@ -44,6 +45,7 @@ func NewProjectUpgrader(projectRoot string, exec executor.Executor) *ProjectUpgr
shopwareVersions: extension.GetShopwareVersions,
extensionUpdates: account_api.GetFutureExtensionUpdates,
+ storePlugins: account_api.GetStorePluginsByName,
repositories: func(c *composer.Json, auth *composer.Auth) *repository.Set {
return repository.FromComposer(c, auth, true)
},
diff --git a/internal/tui/upgrade/messages.go b/internal/tui/upgrade/messages.go
index 740f0ae3..aabf2b8d 100644
--- a/internal/tui/upgrade/messages.go
+++ b/internal/tui/upgrade/messages.go
@@ -3,6 +3,7 @@ package upgrade
import (
"context"
"errors"
+ "slices"
tea "charm.land/bubbletea/v2"
"github.com/shyim/go-version"
@@ -48,6 +49,13 @@ type compatDoneMsg struct {
results []backend.ExtensionResult
}
+// changelogsMsg carries the Store release notes of the planned extension
+// updates for the report.
+type changelogsMsg struct {
+ gen int
+ changelogs []backend.ExtensionChangelog
+}
+
type phpInfoMsg struct {
gen int
requirement string
@@ -114,6 +122,14 @@ func compatCmd(u *backend.ProjectUpgrader, current, target *version.Version, ext
}
}
+func changelogsCmd(u *backend.ProjectUpgrader, target string, results []backend.ExtensionResult, gen int) tea.Cmd {
+ // The command goroutine must not share the slice the panel keeps updating.
+ results = slices.Clone(results)
+ return func() tea.Msg {
+ return changelogsMsg{gen: gen, changelogs: u.LoadExtensionChangelogs(context.Background(), target, results)}
+ }
+}
+
func phpInfoCmd(u *backend.ProjectUpgrader, target *version.Version, gen int) tea.Cmd {
return func() tea.Msg {
ctx := context.Background()
diff --git a/internal/tui/upgrade/model_test.go b/internal/tui/upgrade/model_test.go
index abe9479e..e611000a 100644
--- a/internal/tui/upgrade/model_test.go
+++ b/internal/tui/upgrade/model_test.go
@@ -382,13 +382,13 @@ func TestPrepareResolveFailureShowsConflictInline(t *testing.T) {
w.Send(envStatusMsg{gen: gen, running: true})
w.Send(packagistMsg{gen: gen, reachable: true})
w.Send(compatDoneMsg{gen: gen, results: []backend.ExtensionResult{okResult()}})
- cmd := w.Send(resolveDoneMsg{gen: gen, result: backend.ResolveResult{
+ w.Send(resolveDoneMsg{gen: gen, result: backend.ResolveResult{
OK: false,
Report: "Loading composer repositories\nProblem 1\n - shopware/core 6.7.11.0 conflicts with swag/demo 2.0.0",
}})
- assert.Nil(t, cmd, "the report waits for the remaining preparation results")
+ assert.False(t, w.m.prepare.reportRequested, "the report waits for the remaining preparation results")
- cmd = w.Send(phpInfoMsg{gen: gen, requirement: ">=8.2", installed: "8.3.1"})
+ cmd := w.Send(phpInfoMsg{gen: gen, requirement: ">=8.2", installed: "8.3.1"})
require.NotNil(t, cmd, "once every result arrived, the failure report is written")
// The solver's conflict summary replaces the extension queue.
@@ -634,6 +634,26 @@ func TestReviewPanel(t *testing.T) {
assert.Empty(t, data.ComposerReport, "no composer report when resolution succeeded")
}
+func TestPrepareLoadsChangelogsIntoReport(t *testing.T) {
+ w := wizardAtPrepare(t, []backend.ExtensionResult{okResult()}, true)
+ assert.True(t, w.m.prepare.changelogsRequested,
+ "changelog fetch starts once compatibility and resolution finished")
+
+ changelogs := []backend.ExtensionChangelog{{
+ Extension: "SwagDemo", From: "2.0.0", To: "2.1.0",
+ Entries: []backend.ChangelogEntry{{Version: "2.1.0", Date: "2026-03-01", Text: "Fixes"}},
+ }}
+ w.Send(changelogsMsg{gen: w.m.prepareGen, changelogs: changelogs})
+
+ assert.Equal(t, changelogs, w.m.reportData().Changelogs)
+}
+
+func TestPrepareDropsStaleChangelogs(t *testing.T) {
+ w := wizardAtPrepare(t, []backend.ExtensionResult{okResult()}, true)
+ w.Send(changelogsMsg{gen: w.m.prepareGen - 1, changelogs: []backend.ExtensionChangelog{{Extension: "Old"}}})
+ assert.Empty(t, w.m.reportData().Changelogs)
+}
+
func TestReviewBackReturnsToPrepare(t *testing.T) {
w := wizardAtPrepare(t, []backend.ExtensionResult{okResult()}, true)
w.Send(key('c'))
diff --git a/internal/tui/upgrade/panel_prepare.go b/internal/tui/upgrade/panel_prepare.go
index 8ad0fca3..028b7b3d 100644
--- a/internal/tui/upgrade/panel_prepare.go
+++ b/internal/tui/upgrade/panel_prepare.go
@@ -31,6 +31,11 @@ type prepareState struct {
phpDone bool
phpReq string
phpInstalled string
+ // changelogs are the Store release notes of the planned extension
+ // updates, fetched once compatibility and resolution finished; they are
+ // advisory and only enrich the exported report.
+ changelogs []backend.ExtensionChangelog
+ changelogsRequested bool
// reportRequested marks that the failure report write was kicked off;
// reportPath or reportErr carry its outcome.
reportRequested bool
@@ -95,6 +100,22 @@ func (s prepareState) resolveFailed() bool {
return s.resolveErr != nil || (s.resolve != nil && !s.resolve.OK)
}
+// maybeLoadChangelogs fetches the Store changelogs of the planned extension
+// updates once — after both the compatibility check and the Composer
+// resolution finished, so the update targets are final.
+func (m *Model) maybeLoadChangelogs() tea.Cmd {
+ resolutionDone := m.prepare.resolve != nil || m.prepare.resolveErr != nil
+ if m.prepare.changelogsRequested || !m.prepare.compatDone || !resolutionDone {
+ return nil
+ }
+ target := m.check.target()
+ if target == nil {
+ return nil
+ }
+ m.prepare.changelogsRequested = true
+ return changelogsCmd(m.upgrader, target.Version.String(), m.prepare.results, m.prepare.gen)
+}
+
// deploymentHelperMissing reports whether the upgrade will add
// shopware/deployment-helper to composer.json, per the readiness check.
func (m *Model) deploymentHelperMissing() bool {
@@ -156,7 +177,7 @@ func (m *Model) updatePrepare(msg tea.Msg) (app.Content, tea.Cmd) {
m.prepare.resolve = &result
}
m.prepare.applyResolved()
- cmds := []tea.Cmd{m.maybeWriteFailureReport()}
+ cmds := []tea.Cmd{m.maybeWriteFailureReport(), m.maybeLoadChangelogs()}
// Composer >= 2.9 refuses to load packages affected by security
// advisories, which would leave this check blocked with no way
// forward — offer to continue with audit blocking disabled.
@@ -189,7 +210,7 @@ func (m *Model) updatePrepare(msg tea.Msg) (app.Content, tea.Cmd) {
m.prepare.cursor = 0
m.prepare.scroll = 0
m.prepare.applyResolved()
- return m, m.maybeWriteFailureReport()
+ return m, tea.Batch(m.maybeWriteFailureReport(), m.maybeLoadChangelogs())
case phpInfoMsg:
if msg.gen != m.prepare.gen {
@@ -200,6 +221,13 @@ func (m *Model) updatePrepare(msg tea.Msg) (app.Content, tea.Cmd) {
m.prepare.phpInstalled = msg.installed
return m, m.maybeWriteFailureReport()
+ case changelogsMsg:
+ if msg.gen != m.prepare.gen {
+ return m, nil
+ }
+ m.prepare.changelogs = msg.changelogs
+ return m, nil
+
case tea.KeyPressMsg:
return m.updatePrepareKeys(msg)
}
diff --git a/internal/tui/upgrade/panel_review.go b/internal/tui/upgrade/panel_review.go
index 4c7520b5..42846df5 100644
--- a/internal/tui/upgrade/panel_review.go
+++ b/internal/tui/upgrade/panel_review.go
@@ -57,6 +57,7 @@ func (m *Model) reportData() backend.ReportData {
PHPInstalled: m.prepare.phpInstalled,
ComposerReport: composerReport,
ResolvedChanges: resolvedChanges,
+ Changelogs: m.prepare.changelogs,
}
}
From 7905b2b2bd416c76ae691a34034a90637981813e Mon Sep 17 00:00:00 2001
From: Soner Sayakci
Date: Wed, 29 Jul 2026 16:04:52 +0200
Subject: [PATCH 09/11] Refactor string concatenation for improved readability
and performance across multiple files
- Updated string concatenation in `mysql.go`, `setup.go`, `tab_overview.go`, `tab_overview_health.go`, `filter_multi_select.go`, `filterlist.go`, `panels.go`, `progress_spinner.go`, `scrollbar.go`, `selectlist.go`, `shortcuts.go`, `textfit.go`, `twocolumn.go`, `overlay_extension_detail.go`, `panel_check.go`, `panel_done.go`, `panel_intro.go`, `panel_prepare.go`, `panel_review.go`, and `panel_run.go` to use separate WriteString calls for better clarity.
- Replaced instances of `strings.Repeat` and other calculations with `max` and `min` functions for cleaner logic.
- Enhanced readability by breaking down complex lines into simpler, more manageable parts.
---
internal/account-api/producer_extension.go | 3 +-
internal/envfile/envfile.go | 4 +-
internal/html/format.go | 82 +++++++++++++------
internal/mysqldump/mysql.go | 5 +-
internal/system/setup.go | 62 ++++++++++----
internal/tui/dev/tab_overview.go | 12 ++-
internal/tui/dev/tab_overview_health.go | 12 ++-
internal/tui/filter_multi_select.go | 8 +-
internal/tui/filterlist.go | 3 +-
internal/tui/pluginmigrate/panels.go | 46 +++++++----
internal/tui/progress_spinner.go | 19 ++++-
internal/tui/scrollbar.go | 3 +-
internal/tui/selectlist.go | 15 ++--
internal/tui/shortcuts.go | 3 +-
internal/tui/textfit.go | 9 +-
internal/tui/twocolumn.go | 4 +-
.../tui/upgrade/overlay_extension_detail.go | 13 ++-
internal/tui/upgrade/panel_check.go | 20 +++--
internal/tui/upgrade/panel_done.go | 32 ++++++--
internal/tui/upgrade/panel_intro.go | 27 ++++--
internal/tui/upgrade/panel_prepare.go | 23 +++---
internal/tui/upgrade/panel_review.go | 28 +++++--
internal/tui/upgrade/panel_run.go | 7 +-
23 files changed, 297 insertions(+), 143 deletions(-)
diff --git a/internal/account-api/producer_extension.go b/internal/account-api/producer_extension.go
index a496a96c..fb858ffe 100644
--- a/internal/account-api/producer_extension.go
+++ b/internal/account-api/producer_extension.go
@@ -429,7 +429,8 @@ func (review BinaryReviewResult) GetSummary() string {
}
fmt.Fprintf(&messageSb424, "=== %s ===\n", result.SubCheck)
- messageSb424.WriteString(p.Sanitize(result.Message) + "\n\n")
+ messageSb424.WriteString(p.Sanitize(result.Message))
+ messageSb424.WriteString("\n\n")
}
message += messageSb424.String()
diff --git a/internal/envfile/envfile.go b/internal/envfile/envfile.go
index 683f25cf..cba72bd0 100644
--- a/internal/envfile/envfile.go
+++ b/internal/envfile/envfile.go
@@ -198,7 +198,9 @@ func replaceEnvLine(content []byte, key, value string) ([]byte, bool) {
trimmed := strings.TrimLeft(line, " \t")
if !replaced && strings.HasPrefix(trimmed, key+"=") {
- out.WriteString(key + "=" + value)
+ out.WriteString(key)
+ out.WriteString("=")
+ out.WriteString(value)
replaced = true
continue
}
diff --git a/internal/html/format.go b/internal/html/format.go
index 164223f2..c728f05d 100644
--- a/internal/html/format.go
+++ b/internal/html/format.go
@@ -19,7 +19,7 @@ func (a *Attribute) Dump(indent int) string {
var builder strings.Builder
indentStr := indentConfig.GetIndent()
- for i := 0; i < indent; i++ {
+ for range indent {
builder.WriteString(indentStr)
}
@@ -188,7 +188,8 @@ func dumpVerbatim(builder *strings.Builder, nodes NodeList) {
for _, node := range nodes {
switch n := node.(type) {
case *ElementNode:
- builder.WriteString("<" + n.Tag)
+ builder.WriteString("<")
+ builder.WriteString(n.Tag)
for _, attr := range n.Attributes {
builder.WriteString(" ")
builder.WriteString(attr.Dump(0))
@@ -200,15 +201,21 @@ func dumpVerbatim(builder *strings.Builder, nodes NodeList) {
builder.WriteString(">")
dumpVerbatim(builder, n.Children)
if !n.Unclosed {
- builder.WriteString("" + n.Tag + ">")
+ builder.WriteString("")
+ builder.WriteString(n.Tag)
+ builder.WriteString(">")
}
case *TwigIfNode:
for i, br := range n.Branches {
builder.WriteString(openStmt(br.Trim.Left))
if i == 0 {
- builder.WriteString(" if " + br.Condition + " ")
+ builder.WriteString(" if ")
+ builder.WriteString(br.Condition)
+ builder.WriteString(" ")
} else {
- builder.WriteString(" elseif " + br.Condition + " ")
+ builder.WriteString(" elseif ")
+ builder.WriteString(br.Condition)
+ builder.WriteString(" ")
}
builder.WriteString(closeStmt(br.Trim.Right))
dumpVerbatim(builder, br.Body)
@@ -224,7 +231,9 @@ func dumpVerbatim(builder *strings.Builder, nodes NodeList) {
builder.WriteString(closeStmt(n.EndTrim.Right))
case *TwigBlockNode:
builder.WriteString(openStmt(n.OpenTrim.Left))
- builder.WriteString(" block " + n.Name + " ")
+ builder.WriteString(" block ")
+ builder.WriteString(n.Name)
+ builder.WriteString(" ")
builder.WriteString(closeStmt(n.OpenTrim.Right))
dumpVerbatim(builder, n.Children)
builder.WriteString(openStmt(n.CloseTrim.Left))
@@ -232,9 +241,11 @@ func dumpVerbatim(builder *strings.Builder, nodes NodeList) {
builder.WriteString(closeStmt(n.CloseTrim.Right))
case *TwigGenericBlockNode:
builder.WriteString(openStmt(n.OpenTrim.Left))
- builder.WriteString(" " + n.Name)
+ builder.WriteString(" ")
+ builder.WriteString(n.Name)
if n.Args != "" {
- builder.WriteString(" " + n.Args)
+ builder.WriteString(" ")
+ builder.WriteString(n.Args)
}
builder.WriteString(" ")
builder.WriteString(closeStmt(n.OpenTrim.Right))
@@ -246,7 +257,9 @@ func dumpVerbatim(builder *strings.Builder, nodes NodeList) {
dumpVerbatim(builder, n.Else)
}
builder.WriteString(openStmt(n.CloseTrim.Left))
- builder.WriteString(" " + n.EndTag + " ")
+ builder.WriteString(" ")
+ builder.WriteString(n.EndTag)
+ builder.WriteString(" ")
builder.WriteString(closeStmt(n.CloseTrim.Right))
default:
builder.WriteString(node.Dump(0))
@@ -272,11 +285,13 @@ func (r *RawNode) Dump(indent int) string {
func (c *CommentNode) Dump(indent int) string {
var builder strings.Builder
indentStr := indentConfig.GetIndent()
- for i := 0; i < indent; i++ {
+ for range indent {
builder.WriteString(indentStr)
}
- builder.WriteString("")
+ builder.WriteString("")
return builder.String()
}
@@ -293,11 +308,12 @@ func (e *ElementNode) Dump(indent int) string {
indentStr := indentConfig.GetIndent()
// Add initial indentation
- for i := 0; i < indent; i++ {
+ for range indent {
builder.WriteString(indentStr)
}
- builder.WriteString("<" + e.Tag)
+ builder.WriteString("<")
+ builder.WriteString(e.Tag)
attributesDidNewLine := false
@@ -330,7 +346,7 @@ func (e *ElementNode) Dump(indent int) string {
}
if attributesDidNewLine {
- for i := 0; i < indent; i++ {
+ for range indent {
builder.WriteString(indentStr)
}
}
@@ -351,7 +367,9 @@ func (e *ElementNode) Dump(indent int) string {
if whitespacePreservingTags[strings.ToLower(e.Tag)] {
dumpVerbatim(&builder, e.Children)
if !e.Unclosed {
- builder.WriteString("" + e.Tag + ">")
+ builder.WriteString("")
+ builder.WriteString(e.Tag)
+ builder.WriteString(">")
}
return builder.String()
}
@@ -374,20 +392,22 @@ func (e *ElementNode) Dump(indent int) string {
for j := 0; j < indent+1; j++ {
builder.WriteString(indentStr)
}
- builder.WriteString(child.Dump(indent+1) + "\n")
+ builder.WriteString(child.Dump(indent + 1))
+ builder.WriteString("\n")
} else if raw, ok := child.(*RawNode); ok {
trimmed := strings.TrimSpace(raw.Text)
if trimmed != "" {
for j := 0; j < indent+1; j++ {
builder.WriteString(indentStr)
}
- builder.WriteString(trimmed + "\n")
+ builder.WriteString(trimmed)
+ builder.WriteString("\n")
}
} else {
builder.WriteString(child.Dump(indent + 1))
}
}
- for i := 0; i < indent; i++ {
+ for range indent {
builder.WriteString(indentStr)
}
} else {
@@ -481,20 +501,22 @@ func (e *ElementNode) Dump(indent int) string {
for j := 0; j < indent+1; j++ {
builder.WriteString(indentStr)
}
- builder.WriteString(child.Dump(indent+1) + "\n")
+ builder.WriteString(child.Dump(indent + 1))
+ builder.WriteString("\n")
} else if raw, ok := child.(*RawNode); ok {
trimmed := strings.TrimSpace(raw.Text)
if trimmed != "" {
for j := 0; j < indent+1; j++ {
builder.WriteString(indentStr)
}
- builder.WriteString(trimmed + "\n")
+ builder.WriteString(trimmed)
+ builder.WriteString("\n")
}
} else {
builder.WriteString(child.Dump(indent + 1))
}
}
- for i := 0; i < indent; i++ {
+ for range indent {
builder.WriteString(indentStr)
}
} else {
@@ -569,7 +591,7 @@ func (e *ElementNode) Dump(indent int) string {
}
}
builder.WriteString("\n")
- for i := 0; i < indent; i++ {
+ for range indent {
builder.WriteString(indentStr)
}
}
@@ -577,7 +599,9 @@ func (e *ElementNode) Dump(indent int) string {
}
if !e.Unclosed {
- builder.WriteString("" + e.Tag + ">")
+ builder.WriteString("")
+ builder.WriteString(e.Tag)
+ builder.WriteString(">")
}
return builder.String()
}
@@ -585,11 +609,13 @@ func (e *ElementNode) Dump(indent int) string {
func (t *TwigBlockNode) Dump(indent int) string {
var builder strings.Builder
indentStr := indentConfig.GetIndent()
- for i := 0; i < indent; i++ {
+ for range indent {
builder.WriteString(indentStr)
}
builder.WriteString(openStmt(t.OpenTrim.Left))
- builder.WriteString(" block " + t.Name + " ")
+ builder.WriteString(" block ")
+ builder.WriteString(t.Name)
+ builder.WriteString(" ")
builder.WriteString(closeStmt(t.OpenTrim.Right))
// Inline content: all children are text or short expressions (no nested
@@ -703,10 +729,12 @@ func (t *TwigIfNode) Dump(indent int) string {
writeIndent(indent)
builder.WriteString(openStmt(br.Trim.Left))
if i == 0 {
- builder.WriteString(" if " + br.Condition + " ")
+ builder.WriteString(" if ")
} else {
- builder.WriteString(" elseif " + br.Condition + " ")
+ builder.WriteString(" elseif ")
}
+ builder.WriteString(br.Condition)
+ builder.WriteString(" ")
builder.WriteString(closeStmt(br.Trim.Right))
writeIfBranchBody(&builder, br.Body, indent, indentStr)
}
diff --git a/internal/mysqldump/mysql.go b/internal/mysqldump/mysql.go
index f0197754..c5acf442 100644
--- a/internal/mysqldump/mysql.go
+++ b/internal/mysqldump/mysql.go
@@ -526,7 +526,7 @@ func (d *Dumper) dumpTableData(ctx context.Context, w io.Writer, table string) e
}
values := make([]*sql.RawBytes, len(columns))
- scanArgs := make([]interface{}, len(values))
+ scanArgs := make([]any, len(values))
for i := range values {
scanArgs[i] = &values[i]
}
@@ -591,7 +591,8 @@ func (d *Dumper) generateInsertStatement(cols []string, table string) string {
s := fmt.Sprintf("INSERT INTO `%s` (", table)
var sSb592 strings.Builder
for _, col := range cols {
- sSb592.WriteString(col + ", ")
+ sSb592.WriteString(col)
+ sSb592.WriteString(", ")
}
s += sSb592.String()
diff --git a/internal/system/setup.go b/internal/system/setup.go
index ffdf4df0..32f76cd9 100644
--- a/internal/system/setup.go
+++ b/internal/system/setup.go
@@ -121,8 +121,8 @@ func ValidateProjectDependencies(ctx context.Context, useDocker bool, phpConstra
// found.
func phpDependencyConstraint(missing []MissingDependency) (string, bool) {
for _, m := range missing {
- if strings.HasPrefix(m.Name, "PHP ") {
- return strings.TrimPrefix(m.Name, "PHP "), true
+ if after, ok := strings.CutPrefix(m.Name, "PHP "); ok {
+ return after, true
}
}
return "", false
@@ -180,23 +180,39 @@ func RenderMissingDependencies(useDocker bool, missing []MissingDependency, acti
case insideContainer:
b.WriteString(tui.BoldText.Render(fmt.Sprintf("To %s from inside this container, install:", action)))
b.WriteString("\n\n")
- b.WriteString(" " + arrow + " " + tui.BoldText.Render("PHP 8.2+ and Composer") + "\n")
- b.WriteString(" PHP: " + tui.BlueText.Render("https://www.php.net/downloads.php") + "\n")
- b.WriteString(" Composer: " + tui.BlueText.Render("https://getcomposer.org/") + "\n")
+ b.WriteString(" ")
+ b.WriteString(arrow)
+ b.WriteString(" ")
+ b.WriteString(tui.BoldText.Render("PHP 8.2+ and Composer"))
+ b.WriteString("\n")
+ b.WriteString(" PHP: ")
+ b.WriteString(tui.BlueText.Render("https://www.php.net/downloads.php"))
+ b.WriteString("\n")
+ b.WriteString(" Composer: ")
+ b.WriteString(tui.BlueText.Render("https://getcomposer.org/"))
+ b.WriteString("\n")
default:
phpConstraint, hasPHP := phpDependencyConstraint(missing)
composerMissing := composerDependency(missing)
b.WriteString(tui.BoldText.Render(fmt.Sprintf("To %s, either:", action)))
b.WriteString("\n\n")
- b.WriteString(" " + arrow + " " + tui.RecommendedText.Render("Docker") + " " + tui.DimText.Render("(recommended)") + ": ")
+ b.WriteString(" ")
+ b.WriteString(arrow)
+ b.WriteString(" ")
+ b.WriteString(tui.RecommendedText.Render("Docker"))
+ b.WriteString(" ")
+ b.WriteString(tui.DimText.Render("(recommended)"))
+ b.WriteString(": ")
if !useDocker && dockerHint != "" {
b.WriteString(dockerHint)
} else {
b.WriteString(tui.DimText.Render("re-run with " + tui.BoldText.Render("--docker")))
}
b.WriteString("\n")
- b.WriteString(" " + tui.BlueText.Render("https://docs.docker.com/get-docker/") + "\n")
+ b.WriteString(" ")
+ b.WriteString(tui.BlueText.Render("https://docs.docker.com/get-docker/"))
+ b.WriteString("\n")
b.WriteString("\n")
if hasPHP {
@@ -206,16 +222,34 @@ func RenderMissingDependencies(useDocker bool, missing []MissingDependency, acti
} else {
phpText = fmt.Sprintf("Install a PHP version matching %s, or point PHP_BINARY at one", phpConstraint)
}
- b.WriteString(" " + arrow + " " + tui.BoldText.Render(phpText) + "\n")
- b.WriteString(" " + tui.DimText.Render("(e.g. "+phpBinaryExample(phpConstraint)+")") + "\n")
- b.WriteString(" PHP: " + tui.BlueText.Render("https://www.php.net/downloads.php") + "\n")
+ b.WriteString(" ")
+ b.WriteString(arrow)
+ b.WriteString(" ")
+ b.WriteString(tui.BoldText.Render(phpText))
+ b.WriteString("\n")
+ b.WriteString(" ")
+ b.WriteString(tui.DimText.Render("(e.g. " + phpBinaryExample(phpConstraint) + ")"))
+ b.WriteString("\n")
+ b.WriteString(" PHP: ")
+ b.WriteString(tui.BlueText.Render("https://www.php.net/downloads.php"))
+ b.WriteString("\n")
if composerMissing {
- b.WriteString(" Composer: " + tui.BlueText.Render("https://getcomposer.org/") + "\n")
+ b.WriteString(" Composer: ")
+ b.WriteString(tui.BlueText.Render("https://getcomposer.org/"))
+ b.WriteString("\n")
}
} else {
- b.WriteString(" " + arrow + " " + tui.BoldText.Render("PHP 8.2+ and Composer") + "\n")
- b.WriteString(" PHP: " + tui.BlueText.Render("https://www.php.net/downloads.php") + "\n")
- b.WriteString(" Composer: " + tui.BlueText.Render("https://getcomposer.org/") + "\n")
+ b.WriteString(" ")
+ b.WriteString(arrow)
+ b.WriteString(" ")
+ b.WriteString(tui.BoldText.Render("PHP 8.2+ and Composer"))
+ b.WriteString("\n")
+ b.WriteString(" PHP: ")
+ b.WriteString(tui.BlueText.Render("https://www.php.net/downloads.php"))
+ b.WriteString("\n")
+ b.WriteString(" Composer: ")
+ b.WriteString(tui.BlueText.Render("https://getcomposer.org/"))
+ b.WriteString("\n")
}
}
diff --git a/internal/tui/dev/tab_overview.go b/internal/tui/dev/tab_overview.go
index 4ad138cd..45be9d14 100644
--- a/internal/tui/dev/tab_overview.go
+++ b/internal/tui/dev/tab_overview.go
@@ -514,12 +514,18 @@ func (m OverviewModel) renderAccess() string {
switch {
case m.loading:
- s.WriteString(" " + helpStyle.Render("Scanning for further local services...") + "\n")
+ s.WriteString(" ")
+ s.WriteString(helpStyle.Render("Scanning for further local services..."))
+ s.WriteString("\n")
case m.err != nil:
- s.WriteString(" " + errorStyle.Render(m.err.Error()) + "\n")
+ s.WriteString(" ")
+ s.WriteString(errorStyle.Render(m.err.Error()))
+ s.WriteString("\n")
}
if m.username == "" && m.password == "" {
- s.WriteString(" " + helpStyle.Render("Admin credentials will appear here once Shopware is installed.") + "\n")
+ s.WriteString(" ")
+ s.WriteString(helpStyle.Render("Admin credentials will appear here once Shopware is installed."))
+ s.WriteString("\n")
}
return s.String()
diff --git a/internal/tui/dev/tab_overview_health.go b/internal/tui/dev/tab_overview_health.go
index c83c6445..8a339591 100644
--- a/internal/tui/dev/tab_overview_health.go
+++ b/internal/tui/dev/tab_overview_health.go
@@ -296,10 +296,14 @@ func (m OverviewModel) renderSetupHealth() string {
switch {
case m.healthLoading:
- s.WriteString(" " + tui.StatusBadge("checking", tui.BrandColor) + "\n")
+ s.WriteString(" ")
+ s.WriteString(tui.StatusBadge("checking", tui.BrandColor))
+ s.WriteString("\n")
return s.String()
case len(m.health) == 0:
- s.WriteString(" " + helpStyle.Render("No setup checks available.") + "\n")
+ s.WriteString(" ")
+ s.WriteString(helpStyle.Render("No setup checks available."))
+ s.WriteString("\n")
return s.String()
}
@@ -322,7 +326,9 @@ func (m OverviewModel) renderSetupHealth() string {
for _, check := range m.health {
if check.Group != group {
group = check.Group
- s.WriteString("\n " + tui.TitleStyle.Render(group) + "\n")
+ s.WriteString("\n ")
+ s.WriteString(tui.TitleStyle.Render(group))
+ s.WriteString("\n")
}
dot := check.Level.dot()
name := check.Name
diff --git a/internal/tui/filter_multi_select.go b/internal/tui/filter_multi_select.go
index baf33ce6..1a8b8bea 100644
--- a/internal/tui/filter_multi_select.go
+++ b/internal/tui/filter_multi_select.go
@@ -212,10 +212,7 @@ func (m *filterMultiSelectModel) render() string {
Foreground(MutedColor).
Background(SelectedBgColor)
- end := m.scroll + m.pageSize
- if end > len(m.filtered) {
- end = len(m.filtered)
- }
+ end := min(m.scroll+m.pageSize, len(m.filtered))
for i := m.scroll; i < end; i++ {
idx := m.filtered[i]
item := m.items[idx]
@@ -233,7 +230,8 @@ func (m *filterMultiSelectModel) render() string {
// styled detail inside the width-padded row style would reset the
// selection background before the trailing padding.
gap := max(innerWidth-lipgloss.Width(label)-lipgloss.Width(item.Detail), 1)
- b.WriteString(rowStyle.UnsetWidth().Render(label+strings.Repeat(" ", gap)) + dStyle.Render(item.Detail))
+ b.WriteString(rowStyle.UnsetWidth().Render(label + strings.Repeat(" ", gap)))
+ b.WriteString(dStyle.Render(item.Detail))
} else {
b.WriteString(rowStyle.Render(label))
}
diff --git a/internal/tui/filterlist.go b/internal/tui/filterlist.go
index b47c2d5b..ee7422bf 100644
--- a/internal/tui/filterlist.go
+++ b/internal/tui/filterlist.go
@@ -155,7 +155,8 @@ func (l FilterList) View(width int) string {
// styled detail inside the width-padded row style would reset the
// selection background before the trailing padding.
gap := max(width-lipgloss.Width(item.Label)-lipgloss.Width(item.Detail), 1)
- b.WriteString(rowStyle.UnsetWidth().Render(item.Label+strings.Repeat(" ", gap)) + dStyle.Render(item.Detail))
+ b.WriteString(rowStyle.UnsetWidth().Render(item.Label + strings.Repeat(" ", gap)))
+ b.WriteString(dStyle.Render(item.Detail))
} else {
b.WriteString(rowStyle.Render(item.Label))
}
diff --git a/internal/tui/pluginmigrate/panels.go b/internal/tui/pluginmigrate/panels.go
index bed7398d..816aa811 100644
--- a/internal/tui/pluginmigrate/panels.go
+++ b/internal/tui/pluginmigrate/panels.go
@@ -62,7 +62,11 @@ func (m *Model) viewWelcome() string {
if version == "" {
version = "unknown version"
}
- b.WriteString(" • " + tui.LabelStyle.Render(ext.Name) + " " + tui.DimStyle.Render(version) + "\n")
+ b.WriteString(" • ")
+ b.WriteString(tui.LabelStyle.Render(ext.Name))
+ b.WriteString(" ")
+ b.WriteString(tui.DimStyle.Render(version))
+ b.WriteString("\n")
}
b.WriteString("\n")
b.WriteString(tui.DimStyle.Render("Store plugins get required from packages.shopware.com; local plugins"))
@@ -165,9 +169,9 @@ func (m *Model) viewToken() (title, status, body string) {
b.WriteString("\n")
b.WriteString(tui.LabelStyle.Render("packages.shopware.com and keep receiving updates through Composer."))
b.WriteString("\n\n")
- b.WriteString(tui.DimStyle.Render("Get yours at ") +
- tui.StyledLink("https://account.shopware.com", "account.shopware.com", tui.LinkStyle) +
- tui.DimStyle.Render(" → Merchant area → Shops → Packagist."))
+ b.WriteString(tui.DimStyle.Render("Get yours at "))
+ b.WriteString(tui.StyledLink("https://account.shopware.com", "account.shopware.com", tui.LinkStyle))
+ b.WriteString(tui.DimStyle.Render(" → Merchant area → Shops → Packagist."))
b.WriteString("\n\n")
b.WriteString(m.tokenInput.View())
b.WriteString("\n\n")
@@ -243,16 +247,20 @@ func (m *Model) viewReview() (title, status, body string) {
b.WriteString(tui.BoldStyle.Render("Configuration changes"))
b.WriteString("\n")
if m.plan.AddStoreRepository {
- b.WriteString(tui.DimStyle.Render(" • add Composer repository "+migrate.StoreRepositoryURL) + "\n")
+ b.WriteString(tui.DimStyle.Render(" • add Composer repository " + migrate.StoreRepositoryURL))
+ b.WriteString("\n")
}
for _, path := range m.plan.PathRepositories() {
- b.WriteString(tui.DimStyle.Render(" • add path repository "+path) + "\n")
+ b.WriteString(tui.DimStyle.Render(" • add path repository " + path))
+ b.WriteString("\n")
}
if m.token != "" {
- b.WriteString(tui.DimStyle.Render(" • store the Packagist token in auth.json") + "\n")
+ b.WriteString(tui.DimStyle.Render(" • store the Packagist token in auth.json"))
+ b.WriteString("\n")
}
if len(m.plan.RemoveDirs()) > 0 {
- b.WriteString(tui.DimStyle.Render(fmt.Sprintf(" • remove %d migrated directories after the require succeeded", len(m.plan.RemoveDirs()))) + "\n")
+ b.WriteString(tui.DimStyle.Render(fmt.Sprintf(" • remove %d migrated directories after the require succeeded", len(m.plan.RemoveDirs()))))
+ b.WriteString("\n")
}
b.WriteString("\n")
@@ -345,10 +353,7 @@ func (m *Model) viewRun() (title, status, body string) {
b.WriteString(tui.NewStepList(tui.StepListOptions{Steps: items}).Render())
b.WriteString("\n")
- visible := m.frameHeight() - len(migrate.RunSteps) - 8
- if visible < 3 {
- visible = 3
- }
+ visible := max(m.frameHeight()-len(migrate.RunSteps)-8, 3)
for _, line := range tui.TailLines(m.run.log, visible) {
b.WriteString(tui.DimStyle.Render(tui.Truncate(line, m.bodyWidth())))
b.WriteString("\n")
@@ -376,17 +381,24 @@ func (m *Model) viewDone() string {
b.WriteString(tui.BoldStyle.Render("All extensions are now managed through Composer."))
b.WriteString("\n\n")
if n := m.plan.Count(migrate.ActionStoreRequire); n > 0 {
- b.WriteString(okStyle.Render("✓") + tui.LabelStyle.Render(fmt.Sprintf(" %d plugins now come from the Shopware Store", n)) + "\n")
+ b.WriteString(okStyle.Render("✓"))
+ b.WriteString(tui.LabelStyle.Render(fmt.Sprintf(" %d plugins now come from the Shopware Store", n)))
+ b.WriteString("\n")
}
if n := m.plan.Count(migrate.ActionPathRepository); n > 0 {
- b.WriteString(okStyle.Render("✓") + tui.LabelStyle.Render(fmt.Sprintf(" %d local plugins are path repositories", n)) + "\n")
+ b.WriteString(okStyle.Render("✓"))
+ b.WriteString(tui.LabelStyle.Render(fmt.Sprintf(" %d local plugins are path repositories", n)))
+ b.WriteString("\n")
}
b.WriteString("\n")
b.WriteString(tui.BoldStyle.Render("Next steps"))
b.WriteString("\n")
- b.WriteString(tui.DimStyle.Render(" 1. Verify the shop still works") + "\n")
- b.WriteString(tui.DimStyle.Render(" 2. Commit composer.json, composer.lock, and auth.json") + "\n")
- b.WriteString(tui.DimStyle.Render(" 3. `project upgrade` can now resolve every extension") + "\n")
+ b.WriteString(tui.DimStyle.Render(" 1. Verify the shop still works"))
+ b.WriteString("\n")
+ b.WriteString(tui.DimStyle.Render(" 2. Commit composer.json, composer.lock, and auth.json"))
+ b.WriteString("\n")
+ b.WriteString(tui.DimStyle.Render(" 3. `project upgrade` can now resolve every extension"))
+ b.WriteString("\n")
b.WriteString("\n")
b.WriteString(tui.NewButtonRow(tui.ButtonRowOptions{Labels: []string{"Close"}, Active: 0}).Render())
return tui.RenderPhaseCardCowsay("All plugins are Composer-managed now!", b.String())
diff --git a/internal/tui/progress_spinner.go b/internal/tui/progress_spinner.go
index 4ce1abed..92510248 100644
--- a/internal/tui/progress_spinner.go
+++ b/internal/tui/progress_spinner.go
@@ -175,15 +175,22 @@ func (m *installProgressModel) View() tea.View {
if m.done {
if m.err != nil {
- b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#FF4D4D")).Bold(true).Render("✗") + " " + titleStyle.Render(m.title) + "\n\n")
+ b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#FF4D4D")).Bold(true).Render("✗"))
+ b.WriteString(" ")
+ b.WriteString(titleStyle.Render(m.title))
+ b.WriteString("\n\n")
lines := m.logWriter.GetLastLines(12)
logStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FF4D4D")).PaddingLeft(2)
for _, line := range lines {
- b.WriteString(logStyle.Render(line) + "\n")
+ b.WriteString(logStyle.Render(line))
+ b.WriteString("\n")
}
} else {
- b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#04B575")).Bold(true).Render("✔") + " " + titleStyle.Render(m.title) + "\n")
+ b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#04B575")).Bold(true).Render("✔"))
+ b.WriteString(" ")
+ b.WriteString(titleStyle.Render(m.title))
+ b.WriteString("\n")
}
return tea.NewView(b.String())
}
@@ -195,7 +202,11 @@ func (m *installProgressModel) View() tea.View {
hint = lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")).Render(" (Ctrl+L to see live log)")
}
- b.WriteString(spinnerStr + " " + titleStyle.Render(m.title) + hint + "\n")
+ b.WriteString(spinnerStr)
+ b.WriteString(" ")
+ b.WriteString(titleStyle.Render(m.title))
+ b.WriteString(hint)
+ b.WriteString("\n")
if m.showLogs {
b.WriteString("\n")
diff --git a/internal/tui/scrollbar.go b/internal/tui/scrollbar.go
index 4153a10a..2e630e7d 100644
--- a/internal/tui/scrollbar.go
+++ b/internal/tui/scrollbar.go
@@ -54,6 +54,7 @@ func (s Scrollbar) Render() string {
b.WriteString(dim.Render("┆"))
}
}
- b.WriteString("\n" + dim.Render("↓"))
+ b.WriteString("\n")
+ b.WriteString(dim.Render("↓"))
return b.String()
}
diff --git a/internal/tui/selectlist.go b/internal/tui/selectlist.go
index ff1141df..42113a88 100644
--- a/internal/tui/selectlist.go
+++ b/internal/tui/selectlist.go
@@ -44,13 +44,7 @@ func RenderSelectListWindowed(title, description string, options []SelectOption,
start, end := 0, len(options)
if windowed {
// Keep the cursor inside the [start, end) window.
- start = cursor - maxVisible/2
- if start < 0 {
- start = 0
- }
- if start > len(options)-maxVisible {
- start = len(options) - maxVisible
- }
+ start = min(max(cursor-maxVisible/2, 0), len(options)-maxVisible)
end = start + maxVisible
}
@@ -61,9 +55,12 @@ func RenderSelectListWindowed(title, description string, options []SelectOption,
detail = " " + DimStyle.Render("("+opt.Detail+")")
}
if i == cursor {
- s.WriteString(selectorStyle.Render("● ") + selectedStyle.Render(opt.Label) + detail)
+ s.WriteString(selectorStyle.Render("● "))
+ s.WriteString(selectedStyle.Render(opt.Label))
+ s.WriteString(detail)
} else {
- s.WriteString(" " + FormatLabel(opt.Label, opt.Detail))
+ s.WriteString(" ")
+ s.WriteString(FormatLabel(opt.Label, opt.Detail))
}
s.WriteString("\n")
}
diff --git a/internal/tui/shortcuts.go b/internal/tui/shortcuts.go
index 9d5792f8..218eb7df 100644
--- a/internal/tui/shortcuts.go
+++ b/internal/tui/shortcuts.go
@@ -56,7 +56,8 @@ func (s Shortcuts) bar(separator string) string {
result := s.badge(s.opts.Items[0])
var resultSb55 strings.Builder
for _, item := range s.opts.Items[1:] {
- resultSb55.WriteString(sep + s.badge(item))
+ resultSb55.WriteString(sep)
+ resultSb55.WriteString(s.badge(item))
}
result += resultSb55.String()
return result
diff --git a/internal/tui/textfit.go b/internal/tui/textfit.go
index 19baa5ec..51d0b110 100644
--- a/internal/tui/textfit.go
+++ b/internal/tui/textfit.go
@@ -9,10 +9,7 @@ import (
// SpreadRow places left and right on one row, padding the middle with spaces.
func SpreadRow(width int, left, right string) string {
- fill := width - lipgloss.Width(left) - lipgloss.Width(right)
- if fill < 1 {
- fill = 1
- }
+ fill := max(width-lipgloss.Width(left)-lipgloss.Width(right), 1)
return left + strings.Repeat(" ", fill) + right
}
@@ -63,7 +60,9 @@ func JoinColumns(left, right string, gap int) string {
if i < len(rightLines) {
r = rightLines[i]
}
- b.WriteString(l + strings.Repeat(" ", max(width-lipgloss.Width(l)+gap, 0)) + r)
+ b.WriteString(l)
+ b.WriteString(strings.Repeat(" ", max(width-lipgloss.Width(l)+gap, 0)))
+ b.WriteString(r)
if i < rows-1 {
b.WriteString("\n")
}
diff --git a/internal/tui/twocolumn.go b/internal/tui/twocolumn.go
index f92ef780..ddc126df 100644
--- a/internal/tui/twocolumn.go
+++ b/internal/tui/twocolumn.go
@@ -49,7 +49,9 @@ func (c TwoColumn) Render() string {
r = rightLines[i]
}
b.WriteString(padToWidth(l, leftWidth))
- b.WriteString(" " + divider + " ")
+ b.WriteString(" ")
+ b.WriteString(divider)
+ b.WriteString(" ")
b.WriteString(padToWidth(r, rightWidth))
if i < rows-1 {
b.WriteString("\n")
diff --git a/internal/tui/upgrade/overlay_extension_detail.go b/internal/tui/upgrade/overlay_extension_detail.go
index 301f308d..d2543e4a 100644
--- a/internal/tui/upgrade/overlay_extension_detail.go
+++ b/internal/tui/upgrade/overlay_extension_detail.go
@@ -120,19 +120,22 @@ func (d *extensionDetail) viewLeft() string {
if r.Extension.Package != "" {
b.WriteString(tui.BoldStyle.Render("Package"))
b.WriteString("\n")
- b.WriteString(" " + tui.LabelStyle.Render(r.Extension.Package))
+ b.WriteString(" ")
+ b.WriteString(tui.LabelStyle.Render(r.Extension.Package))
b.WriteString("\n\n")
}
if r.Extension.Path != "" && !r.Extension.ComposerManaged {
b.WriteString(tui.BoldStyle.Render("Path"))
b.WriteString("\n")
- b.WriteString(" " + tui.DimStyle.Render(r.Extension.Path))
+ b.WriteString(" ")
+ b.WriteString(tui.DimStyle.Render(r.Extension.Path))
b.WriteString("\n\n")
}
if r.StoreLabel != "" {
b.WriteString(tui.BoldStyle.Render("Store"))
b.WriteString("\n")
- b.WriteString(" " + tui.DimStyle.Render(r.StoreLabel))
+ b.WriteString(" ")
+ b.WriteString(tui.DimStyle.Render(r.StoreLabel))
b.WriteString("\n")
}
@@ -146,7 +149,9 @@ func (d *extensionDetail) viewRight() string {
b.WriteString("\n\n")
bullet := func(s string) {
- b.WriteString(tui.DimStyle.Render("• ") + tui.LabelStyle.Render(s) + "\n\n")
+ b.WriteString(tui.DimStyle.Render("• "))
+ b.WriteString(tui.LabelStyle.Render(s))
+ b.WriteString("\n\n")
}
switch r.Status {
diff --git a/internal/tui/upgrade/panel_check.go b/internal/tui/upgrade/panel_check.go
index 58e96e40..50e6d0f6 100644
--- a/internal/tui/upgrade/panel_check.go
+++ b/internal/tui/upgrade/panel_check.go
@@ -179,10 +179,7 @@ func (m *Model) viewCheckLeft() string {
if check.Detail != "" && check.State != backend.StateOK {
// Wrap the detail to the column instead of letting the frame
// truncate it; details may span multiple lines.
- detailWidth := m.bodyWidth()*11/20 - 3
- if detailWidth < 20 {
- detailWidth = 20
- }
+ detailWidth := max(m.bodyWidth()*11/20-3, 20)
wrapped := lipgloss.NewStyle().Width(detailWidth).Render(check.Detail)
for line := range strings.SplitSeq(wrapped, "\n") {
b.WriteString(tui.DimStyle.Render(" " + line))
@@ -245,16 +242,25 @@ func (m *Model) viewCheckRight() string {
}
if row.option == nil {
- b.WriteString(cursor + marker + " " + tui.LabelStyle.Render(row.label))
+ b.WriteString(cursor)
+ b.WriteString(marker)
+ b.WriteString(" ")
+ b.WriteString(tui.LabelStyle.Render(row.label))
if m.check.chosen != nil && !m.isQuickChoice(m.check.chosen) {
- b.WriteString(" " + tui.BoldStyle.Render(m.check.chosen.Version.String()))
+ b.WriteString(" ")
+ b.WriteString(tui.BoldStyle.Render(m.check.chosen.Version.String()))
}
b.WriteString("\n")
continue
}
link := tui.StyledLink(row.option.ReleaseNotesURL, row.label, tui.LinkStyle)
- b.WriteString(cursor + marker + " " + link + " " + tui.LabelStyle.Render(row.hint))
+ b.WriteString(cursor)
+ b.WriteString(marker)
+ b.WriteString(" ")
+ b.WriteString(link)
+ b.WriteString(" ")
+ b.WriteString(tui.LabelStyle.Render(row.hint))
b.WriteString("\n")
if detail := supportDetail(*row.option); detail != "" {
b.WriteString(tui.DimStyle.Render(" " + detail))
diff --git a/internal/tui/upgrade/panel_done.go b/internal/tui/upgrade/panel_done.go
index 97023b14..28c0141f 100644
--- a/internal/tui/upgrade/panel_done.go
+++ b/internal/tui/upgrade/panel_done.go
@@ -98,10 +98,13 @@ func (m *Model) viewDoneLeft() string {
if m.check.readiness.CurrentVersion != nil {
current = m.check.readiness.CurrentVersion.String()
}
- b.WriteString(stateDot(backend.StateOK) + " " + tui.LabelStyle.Render("Shopware packages updated"))
+ b.WriteString(stateDot(backend.StateOK))
+ b.WriteString(" ")
+ b.WriteString(tui.LabelStyle.Render("Shopware packages updated"))
b.WriteString("\n")
if t := m.check.target(); t != nil {
- b.WriteString(tui.DimStyle.Render(" "+current+" -> "+t.Version.String()) + "\n")
+ b.WriteString(tui.DimStyle.Render(" " + current + " -> " + t.Version.String()))
+ b.WriteString("\n")
}
for _, line := range []string{
"composer.json updated",
@@ -109,12 +112,19 @@ func (m *Model) viewDoneLeft() string {
"Deployment Helper completed",
"Composer-managed extensions checked",
} {
- b.WriteString(stateDot(backend.StateOK) + " " + tui.LabelStyle.Render(line) + "\n")
+ b.WriteString(stateDot(backend.StateOK))
+ b.WriteString(" ")
+ b.WriteString(tui.LabelStyle.Render(line))
+ b.WriteString("\n")
}
} else {
- b.WriteString(stateDot(backend.StateFail) + " " + tui.LabelStyle.Render("The upgrade did not complete"))
+ b.WriteString(stateDot(backend.StateFail))
+ b.WriteString(" ")
+ b.WriteString(tui.LabelStyle.Render("The upgrade did not complete"))
b.WriteString("\n")
- b.WriteString(stateDot(backend.StateOK) + " " + tui.LabelStyle.Render("composer.json and composer.lock were restored"))
+ b.WriteString(stateDot(backend.StateOK))
+ b.WriteString(" ")
+ b.WriteString(tui.LabelStyle.Render("composer.json and composer.lock were restored"))
b.WriteString("\n")
if m.done.err != nil {
b.WriteString("\n")
@@ -131,11 +141,15 @@ func (m *Model) viewDoneLeft() string {
// what actually exists.
reportPath := m.upgrader.ReportPath()
if _, err := os.Stat(reportPath); err == nil {
- b.WriteString(tui.DimStyle.Render(" • ") + tui.StyledLink("file://"+reportPath, relativePath(m.opts.ProjectRoot, reportPath), tui.LinkStyle) + "\n")
+ b.WriteString(tui.DimStyle.Render(" • "))
+ b.WriteString(tui.StyledLink("file://"+reportPath, relativePath(m.opts.ProjectRoot, reportPath), tui.LinkStyle))
+ b.WriteString("\n")
}
logPath := m.upgrader.LogPath()
if _, err := os.Stat(logPath); err == nil {
- b.WriteString(tui.DimStyle.Render(" • ") + tui.StyledLink("file://"+logPath, relativePath(m.opts.ProjectRoot, logPath), tui.LinkStyle) + "\n")
+ b.WriteString(tui.DimStyle.Render(" • "))
+ b.WriteString(tui.StyledLink("file://"+logPath, relativePath(m.opts.ProjectRoot, logPath), tui.LinkStyle))
+ b.WriteString("\n")
}
return b.String()
@@ -167,7 +181,9 @@ func (m *Model) viewDoneRight() string {
}
}
for i, step := range steps {
- b.WriteString(tui.DimStyle.Render(" "+strconv.Itoa(i+1)+". ") + tui.LabelStyle.Render(step) + "\n")
+ b.WriteString(tui.DimStyle.Render(" " + strconv.Itoa(i+1) + ". "))
+ b.WriteString(tui.LabelStyle.Render(step))
+ b.WriteString("\n")
}
b.WriteString("\n")
diff --git a/internal/tui/upgrade/panel_intro.go b/internal/tui/upgrade/panel_intro.go
index f70cad75..b67282c9 100644
--- a/internal/tui/upgrade/panel_intro.go
+++ b/internal/tui/upgrade/panel_intro.go
@@ -51,7 +51,8 @@ func (m *Model) viewIntro() (title, status, body string) {
title = "Upgrade Shopware to a newer version"
var left strings.Builder
- left.WriteString(tui.LabelStyle.Render("This wizard will guide you through a ") + tui.BoldStyle.Render("local"))
+ left.WriteString(tui.LabelStyle.Render("This wizard will guide you through a "))
+ left.WriteString(tui.BoldStyle.Render("local"))
left.WriteString("\n")
left.WriteString(tui.LabelStyle.Render("Shopware upgrade:"))
left.WriteString("\n\n")
@@ -63,27 +64,39 @@ func (m *Model) viewIntro() (title, status, body string) {
"Run Deployment Helper",
}
for i, step := range steps {
- left.WriteString(tui.DimStyle.Render(" "+string(rune('1'+i))+". ") + tui.LabelStyle.Render(step) + "\n")
+ left.WriteString(tui.DimStyle.Render(" " + string(rune('1'+i)) + ". "))
+ left.WriteString(tui.LabelStyle.Render(step))
+ left.WriteString("\n")
}
left.WriteString("\n\n")
left.WriteString(tui.BoldStyle.Render("Before files change"))
left.WriteString("\n")
left.WriteString(tui.DimStyle.Render("You will review the upgrade plan before the"))
left.WriteString("\n")
- left.WriteString(tui.DimStyle.Render("wizard applies it ") + tui.BoldStyle.Render("locally") + tui.DimStyle.Render("."))
+ left.WriteString(tui.DimStyle.Render("wizard applies it "))
+ left.WriteString(tui.BoldStyle.Render("locally"))
+ left.WriteString(tui.DimStyle.Render("."))
left.WriteString("\n\n")
left.WriteString(tui.DimStyle.Render("We do not check custom project extensions."))
var right strings.Builder
right.WriteString(tui.LabelStyle.Render("After the wizard finishes:"))
right.WriteString("\n\n")
- right.WriteString(tui.DimStyle.Render(" • ") + tui.LabelStyle.Render("test the shop ") + tui.BoldStyle.Render("locally") + "\n")
- right.WriteString(tui.DimStyle.Render(" • ") + tui.LabelStyle.Render("commit the changed files") + "\n")
- right.WriteString(tui.DimStyle.Render(" • ") + tui.LabelStyle.Render("deploy through your normal process") + "\n")
+ right.WriteString(tui.DimStyle.Render(" • "))
+ right.WriteString(tui.LabelStyle.Render("test the shop "))
+ right.WriteString(tui.BoldStyle.Render("locally"))
+ right.WriteString("\n")
+ right.WriteString(tui.DimStyle.Render(" • "))
+ right.WriteString(tui.LabelStyle.Render("commit the changed files"))
+ right.WriteString("\n")
+ right.WriteString(tui.DimStyle.Render(" • "))
+ right.WriteString(tui.LabelStyle.Render("deploy through your normal process"))
+ right.WriteString("\n")
right.WriteString("\n\n")
right.WriteString(userActionStyle.Render("User action"))
right.WriteString("\n\n")
- right.WriteString(tui.BoldStyle.Render("Begin upgrade") + tui.LabelStyle.Render(" starts the guided checks"))
+ right.WriteString(tui.BoldStyle.Render("Begin upgrade"))
+ right.WriteString(tui.LabelStyle.Render(" starts the guided checks"))
right.WriteString("\n")
right.WriteString(tui.LabelStyle.Render("and version selection."))
right.WriteString("\n\n")
diff --git a/internal/tui/upgrade/panel_prepare.go b/internal/tui/upgrade/panel_prepare.go
index 028b7b3d..9059940e 100644
--- a/internal/tui/upgrade/panel_prepare.go
+++ b/internal/tui/upgrade/panel_prepare.go
@@ -330,10 +330,7 @@ func (m *Model) clampPrepareScroll() {
visible := m.queueHeight()
// The cursor position past the last row focuses the Continue button and
// does not scroll the queue.
- row := min(m.prepare.cursor, len(m.prepare.results)-1)
- if row < 0 {
- row = 0
- }
+ row := max(min(m.prepare.cursor, len(m.prepare.results)-1), 0)
if row < m.prepare.scroll {
m.prepare.scroll = row
}
@@ -416,7 +413,8 @@ func (m *Model) viewPrepareLeft() string {
}
nameW, versionW := 26, 20
- b.WriteString(" " + tui.BoldStyle.Render(tui.PadRight("Name", nameW)+tui.PadRight("Current -> target", versionW)+"Result"))
+ b.WriteString(" ")
+ b.WriteString(tui.BoldStyle.Render(tui.PadRight("Name", nameW) + tui.PadRight("Current -> target", versionW) + "Result"))
b.WriteString("\n")
visible := m.queueHeight()
@@ -451,10 +449,7 @@ func (m *Model) viewResolveFailure() string {
// table header, plus queueHeight rows): heading + omission notice + tail
// + blank + report line. Budget the tail so the report link is never
// cropped off the frame — long failures need it the most.
- visible := m.queueHeight() - 2
- if visible < 3 {
- visible = 3
- }
+ visible := max(m.queueHeight()-2, 3)
// The dry run either produced solver output or failed to run at all — in
// the latter case the error itself is the report.
@@ -478,8 +473,8 @@ func (m *Model) viewResolveFailure() string {
b.WriteString("\n")
switch {
case m.prepare.reportPath != "":
- b.WriteString(tui.DimStyle.Render("Full output: ") +
- tui.StyledLink("file://"+m.prepare.reportPath, relativePath(m.opts.ProjectRoot, m.prepare.reportPath), tui.LinkStyle))
+ b.WriteString(tui.DimStyle.Render("Full output: "))
+ b.WriteString(tui.StyledLink("file://"+m.prepare.reportPath, relativePath(m.opts.ProjectRoot, m.prepare.reportPath), tui.LinkStyle))
case m.prepare.reportErr != nil:
b.WriteString(failStyle.Render(tui.Truncate("Could not write the report: "+m.prepare.reportErr.Error(), width)))
}
@@ -563,7 +558,8 @@ func (m *Model) viewPrepareRight() string {
if m.deploymentHelperMissing() {
b.WriteString(tui.BoldStyle.Render("Deployment Helper workflow"))
b.WriteString("\n\n")
- b.WriteString(tui.DimStyle.Render(" • ") + tui.LabelStyle.Render("add shopware/deployment-helper"))
+ b.WriteString(tui.DimStyle.Render(" • "))
+ b.WriteString(tui.LabelStyle.Render("add shopware/deployment-helper"))
b.WriteString("\n")
b.WriteString(tui.LabelStyle.Render(" to composer.json"))
b.WriteString("\n\n\n")
@@ -590,7 +586,8 @@ func (m *Model) viewPrepareRight() string {
active = 0
}
}
- b.WriteString(cursor + m.buttonRow([]string{"Continue"}, active))
+ b.WriteString(cursor)
+ b.WriteString(m.buttonRow([]string{"Continue"}, active))
return b.String()
}
diff --git a/internal/tui/upgrade/panel_review.go b/internal/tui/upgrade/panel_review.go
index 42846df5..713fdfb0 100644
--- a/internal/tui/upgrade/panel_review.go
+++ b/internal/tui/upgrade/panel_review.go
@@ -112,7 +112,9 @@ func (m *Model) viewReview() (title, status, body string) {
left.WriteString(tui.BoldStyle.Render("Planned project changes"))
left.WriteString("\n\n")
for _, change := range m.plannedChanges() {
- left.WriteString(tui.DimStyle.Render(" • ") + tui.LabelStyle.Render(change) + "\n")
+ left.WriteString(tui.DimStyle.Render(" • "))
+ left.WriteString(tui.LabelStyle.Render(change))
+ left.WriteString("\n")
}
left.WriteString("\n")
@@ -129,13 +131,19 @@ func (m *Model) viewReview() (title, status, body string) {
reviewCount++
}
}
- left.WriteString(fmt.Sprintf(" %-28s", fmt.Sprintf("%d compatible extensions", okCount)) + okStyle.Render("ok") + "\n")
- left.WriteString(fmt.Sprintf(" %-28s", fmt.Sprintf("%d extensions to review", reviewCount)) + okStyle.Render("reports ready") + "\n")
+ fmt.Fprintf(&left, " %-28s", fmt.Sprintf("%d compatible extensions", okCount))
+ left.WriteString(okStyle.Render("ok"))
+ left.WriteString("\n")
+ fmt.Fprintf(&left, " %-28s", fmt.Sprintf("%d extensions to review", reviewCount))
+ left.WriteString(okStyle.Render("reports ready"))
+ left.WriteString("\n")
blockedStatus := okStyle.Render("none")
if blockedCount > 0 {
blockedStatus = warnStyle.Render("review advised")
}
- left.WriteString(fmt.Sprintf(" %-28s", fmt.Sprintf("%d blocking extensions", blockedCount)) + blockedStatus + "\n")
+ fmt.Fprintf(&left, " %-28s", fmt.Sprintf("%d blocking extensions", blockedCount))
+ left.WriteString(blockedStatus)
+ left.WriteString("\n")
left.WriteString("\n")
left.WriteString(tui.BoldStyle.Render("Why these files change"))
@@ -158,12 +166,18 @@ func (m *Model) viewReview() (title, status, body string) {
var right strings.Builder
right.WriteString(tui.BoldStyle.Render("Deployment Helper workflow"))
right.WriteString("\n\n")
- right.WriteString(tui.DimStyle.Render(" • ") + tui.LabelStyle.Render("run Shopware update lifecycle") + "\n")
- right.WriteString(tui.DimStyle.Render(" • ") + tui.LabelStyle.Render("update extension state where supported") + "\n")
+ right.WriteString(tui.DimStyle.Render(" • "))
+ right.WriteString(tui.LabelStyle.Render("run Shopware update lifecycle"))
+ right.WriteString("\n")
+ right.WriteString(tui.DimStyle.Render(" • "))
+ right.WriteString(tui.LabelStyle.Render("update extension state where supported"))
+ right.WriteString("\n")
right.WriteString("\n\n")
right.WriteString(userActionStyle.Render("User action"))
right.WriteString("\n")
- right.WriteString(tui.LabelStyle.Render("Press ") + tui.BoldStyle.Render("Start upgrade") + tui.LabelStyle.Render(" to apply the plan"))
+ right.WriteString(tui.LabelStyle.Render("Press "))
+ right.WriteString(tui.BoldStyle.Render("Start upgrade"))
+ right.WriteString(tui.LabelStyle.Render(" to apply the plan"))
right.WriteString("\n")
right.WriteString(tui.LabelStyle.Render("locally, then run Composer and"))
right.WriteString("\n")
diff --git a/internal/tui/upgrade/panel_run.go b/internal/tui/upgrade/panel_run.go
index 6df9b8ec..dab1eb9c 100644
--- a/internal/tui/upgrade/panel_run.go
+++ b/internal/tui/upgrade/panel_run.go
@@ -126,14 +126,17 @@ func (m *Model) viewRunProgress() string {
if !seen {
state = backend.StatePending
}
- b.WriteString(stateDot(state) + " " + tui.LabelStyle.Render(id.Label()))
+ b.WriteString(stateDot(state))
+ b.WriteString(" ")
+ b.WriteString(tui.LabelStyle.Render(id.Label()))
b.WriteString("\n")
if err := m.run.stepErrs[id]; err != nil {
style := failStyle
if state == backend.StateWarn {
style = warnStyle
}
- b.WriteString(" " + style.Render(tui.Truncate(err.Error(), 60)))
+ b.WriteString(" ")
+ b.WriteString(style.Render(tui.Truncate(err.Error(), 60)))
b.WriteString("\n")
}
}
From d60e8f242acededfc795851ca42a026d483fb590 Mon Sep 17 00:00:00 2001
From: Soner Sayakci
Date: Thu, 30 Jul 2026 07:54:48 +0200
Subject: [PATCH 10/11] feat: add headless migrator tests and enhance wizard
review functionality
---
.../shop/pluginmigrate/pluginmigrate_test.go | 104 ++++++++++++++++++
internal/tui/app/app_test.go | 46 ++++++++
internal/tui/pluginmigrate/model_test.go | 67 +++++++++++
3 files changed, 217 insertions(+)
diff --git a/internal/shop/pluginmigrate/pluginmigrate_test.go b/internal/shop/pluginmigrate/pluginmigrate_test.go
index b8219d87..b2a7167c 100644
--- a/internal/shop/pluginmigrate/pluginmigrate_test.go
+++ b/internal/shop/pluginmigrate/pluginmigrate_test.go
@@ -11,6 +11,7 @@ import (
"path/filepath"
"testing"
+ "github.com/charmbracelet/x/ansi"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -399,3 +400,106 @@ func TestConsumeRunEventsReturnsFailureThatAlsoHasOutput(t *testing.T) {
assert.ErrorIs(t, err, runErr)
assert.Contains(t, out.String(), "rollback detail")
}
+
+// headlessMigrator returns a migrator with all network seams stubbed.
+func headlessMigrator(dir string, exec executor.Executor, store map[string]struct{}, storeErr error) *PluginMigrator {
+ m := NewPluginMigrator(dir, exec)
+ m.storePackageNames = func(context.Context, string) (map[string]struct{}, error) {
+ return store, storeErr
+ }
+ m.publishedVersions = func(context.Context, []string) map[string][]string {
+ return nil
+ }
+ return m
+}
+
+func TestRunHeadlessNothingToDo(t *testing.T) {
+ dir := setupProject(t)
+ require.NoError(t, os.RemoveAll(filepath.Join(dir, "custom")))
+ m := headlessMigrator(dir, trueExecutor(), nil, nil)
+
+ var out bytes.Buffer
+ require.NoError(t, m.RunHeadless(t.Context(), HeadlessOptions{Out: &out}))
+ assert.Contains(t, ansi.Strip(out.String()), "already managed through Composer")
+}
+
+func TestRunHeadlessInvalidToken(t *testing.T) {
+ dir := setupProject(t)
+ m := headlessMigrator(dir, trueExecutor(), nil, errors.New("401 unauthorized"))
+
+ var out bytes.Buffer
+ err := m.RunHeadless(t.Context(), HeadlessOptions{Token: "bad", Out: &out})
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "SHOPWARE_PACKAGIST_TOKEN")
+}
+
+func TestRunHeadlessDryRun(t *testing.T) {
+ dir := setupProject(t)
+ before, err := os.ReadFile(filepath.Join(dir, "composer.json"))
+ require.NoError(t, err)
+ m := headlessMigrator(dir, trueExecutor(), storeSet(), nil)
+
+ var out bytes.Buffer
+ require.NoError(t, m.RunHeadless(t.Context(), HeadlessOptions{Token: "tok", DryRun: true, Out: &out}))
+
+ content := ansi.Strip(out.String())
+ assert.Contains(t, content, "StorePlugin")
+ assert.Contains(t, content, "require from Shopware Store")
+ assert.Contains(t, content, "LocalPlugin")
+ assert.Contains(t, content, "manage via path repository")
+ assert.Contains(t, content, "Dry run — nothing was modified.")
+
+ after, err := os.ReadFile(filepath.Join(dir, "composer.json"))
+ require.NoError(t, err)
+ assert.Equal(t, string(before), string(after))
+}
+
+func TestRunHeadlessWithoutTokenExecutes(t *testing.T) {
+ dir := setupProject(t)
+ m := headlessMigrator(dir, trueExecutor(), nil, nil)
+
+ var out bytes.Buffer
+ require.NoError(t, m.RunHeadless(t.Context(), HeadlessOptions{Out: &out}))
+
+ content := ansi.Strip(out.String())
+ assert.Contains(t, content, "No SHOPWARE_PACKAGIST_TOKEN set")
+ assert.Contains(t, content, "All extensions are now managed through Composer.")
+
+ composerJSON, err := os.ReadFile(filepath.Join(dir, "composer.json"))
+ require.NoError(t, err)
+ assert.Contains(t, string(composerJSON), "custom/plugins/LocalPlugin")
+ assert.Contains(t, string(composerJSON), "custom/plugins/StorePlugin")
+}
+
+func TestRunHeadlessFailingRequireReportsRestore(t *testing.T) {
+ dir := setupProject(t)
+ exec := trueExecutor()
+ exec.composer = func(ctx context.Context, _ ...string) *executor.Process {
+ return shellProcess(ctx, "echo boom >&2; exit 2")
+ }
+ m := headlessMigrator(dir, exec, nil, nil)
+
+ var out bytes.Buffer
+ err := m.RunHeadless(t.Context(), HeadlessOptions{Out: &out})
+ require.Error(t, err)
+ assert.Contains(t, ansi.Strip(out.String()), "were restored")
+}
+
+func TestRunHeadlessNothingActionable(t *testing.T) {
+ dir := t.TempDir()
+ writeFile(t, filepath.Join(dir, "composer.json"), `{"require": {"shopware/core": "6.6.10.3"}}`)
+ // An extension without a composer package name cannot be migrated.
+ writeFile(t, filepath.Join(dir, "custom", "plugins", "Broken", "composer.json"), `{
+ "type": "shopware-platform-plugin",
+ "version": "1.0.0",
+ "require": {"shopware/core": "~6.6.0"},
+ "extra": {"shopware-plugin-class": "Broken\\Broken", "label": {"en-GB": "Broken"}},
+ "autoload": {"psr-4": {"Broken\\": "src/"}}
+ }`)
+ m := headlessMigrator(dir, trueExecutor(), nil, nil)
+
+ var out bytes.Buffer
+ err := m.RunHeadless(t.Context(), HeadlessOptions{Out: &out})
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "none of the extensions can be migrated automatically")
+}
diff --git a/internal/tui/app/app_test.go b/internal/tui/app/app_test.go
index 78c2443a..185d4b72 100644
--- a/internal/tui/app/app_test.go
+++ b/internal/tui/app/app_test.go
@@ -251,3 +251,49 @@ func TestNilCommandRegistryRegisterIsSafe(t *testing.T) {
registry.Register(Command{ID: "ignored"})
})
}
+
+func TestSetContentSizesImmediately(t *testing.T) {
+ a := New(Options{})
+ a.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
+
+ leaf := &sizeLeaf{}
+ a.SetContent(leaf)
+
+ assert.Equal(t, 80, leaf.w, "content set after the first resize is sized without waiting for the next one")
+ assert.Equal(t, 24, leaf.h)
+ assert.Same(t, leaf, a.Content())
+}
+
+func TestSetContentBeforeSizeIsDeferred(t *testing.T) {
+ a := New(Options{})
+ leaf := &sizeLeaf{}
+ a.SetContent(leaf)
+ assert.Zero(t, leaf.w, "no size known yet")
+
+ a.Update(tea.WindowSizeMsg{Width: 60, Height: 20})
+ assert.Equal(t, 60, leaf.w, "the resize sizes the deferred content")
+}
+
+func TestOverlayStackAccessors(t *testing.T) {
+ a := New(Options{})
+ assert.Nil(t, a.TopOverlay())
+
+ o := &testOverlay{}
+ cmd := a.PushOverlay(o)
+ if cmd != nil {
+ cmd()
+ }
+ assert.Same(t, Overlay(o), a.TopOverlay())
+ assert.True(t, a.OverlayOpen())
+
+ a.PopOverlay()
+ assert.Nil(t, a.TopOverlay())
+ assert.False(t, a.OverlayOpen())
+}
+
+func TestStatusRoundTrip(t *testing.T) {
+ a := New(Options{})
+ assert.Empty(t, a.Status())
+ a.SetStatus("busy")
+ assert.Equal(t, "busy", a.Status())
+}
diff --git a/internal/tui/pluginmigrate/model_test.go b/internal/tui/pluginmigrate/model_test.go
index 49912eca..547851e8 100644
--- a/internal/tui/pluginmigrate/model_test.go
+++ b/internal/tui/pluginmigrate/model_test.go
@@ -240,3 +240,70 @@ func TestDonePanelFailure(t *testing.T) {
require.NotNil(t, cmd)
assert.IsType(t, tea.QuitMsg{}, cmd())
}
+
+// wizardAtReview drives the wizard to the review panel with a path-repo plan.
+func wizardAtReview(t *testing.T) *wizard {
+ t.Helper()
+ w := wizardAtToken(t)
+ w.Send(specialKey(tea.KeyDown))
+ w.Send(specialKey(tea.KeyEnter))
+ w.Send(availabilityMsg{token: ""})
+ require.Equal(t, panelReview, w.m.panel)
+ return w
+}
+
+func TestReviewCancelQuits(t *testing.T) {
+ w := wizardAtReview(t)
+
+ // Right focuses Cancel; enter on it quits without touching anything.
+ w.Send(specialKey(tea.KeyRight))
+ cmd := w.Send(specialKey(tea.KeyEnter))
+ require.NotNil(t, cmd)
+ assert.IsType(t, tea.QuitMsg{}, cmd())
+}
+
+func TestReviewEscReturnsToToken(t *testing.T) {
+ w := wizardAtReview(t)
+ w.Send(specialKey(tea.KeyEscape))
+ assert.Equal(t, panelToken, w.m.panel)
+}
+
+func TestReviewApplyRunsMigrationToDone(t *testing.T) {
+ w := wizardAtReview(t)
+
+ // Apply starts the real runner. The wizard's project root does not
+ // exist, so the runner fails at the backup step and rolls back — the
+ // wizard must still land on the failure done panel via the event stream.
+ cmd := w.Send(specialKey(tea.KeyEnter))
+ require.Equal(t, panelRun, w.m.panel)
+ require.NotNil(t, w.m.run.events)
+
+ for cmd != nil && w.m.panel == panelRun {
+ cmd = w.Send(cmd())
+ }
+
+ require.Equal(t, panelDone, w.m.panel)
+ assert.False(t, w.m.done.succeeded)
+ assert.Contains(t, w.view(t), "The migration did not complete.")
+}
+
+func TestReadRunEventCmdGuards(t *testing.T) {
+ assert.Equal(t, runClosedMsg{}, readRunEventCmd(nil)(), "nil channel must not block")
+
+ ch := make(chan migrate.StepEvent, 1)
+ ch <- migrate.StepEvent{Step: migrate.StepComposerRequire, State: migrate.StateRunning}
+ close(ch)
+ assert.Equal(t, runEventMsg(migrate.StepEvent{Step: migrate.StepComposerRequire, State: migrate.StateRunning}), readRunEventCmd(ch)())
+ assert.Equal(t, runClosedMsg{}, readRunEventCmd(ch)(), "closed channel ends the stream")
+}
+
+func TestDonePanelSuccessQuits(t *testing.T) {
+ w := newTestWizard(t)
+ w.m.panel = panelDone
+ w.m.done = doneState{succeeded: true}
+
+ assert.Contains(t, w.view(t), "All plugins are Composer-managed now!")
+ cmd := w.Send(specialKey(tea.KeyEnter))
+ require.NotNil(t, cmd)
+ assert.IsType(t, tea.QuitMsg{}, cmd())
+}
From 5422b20a3f5596478f82f72a3c5cb1a683bb3c6e Mon Sep 17 00:00:00 2001
From: Soner Sayakci
Date: Fri, 31 Jul 2026 11:10:16 +0200
Subject: [PATCH 11/11] fix: address store link and task completion review
findings
normalizeStoreLink matched ":80" as a prefix of custom ports like
":8080", corrupting the URL when trimming; it now parses the URL and
only rewrites store.shopware.com links on the default ports.
Task.finish mutated the local Task through a pointer receiver inside
`return t, t.finish()`, where the copy of t as a return operand races
the mutation (unspecified evaluation order). finish is now a value
receiver returning the updated Task.
Co-Authored-By: Claude Fable 5
Claude-Session: https://claude.ai/code/session_01HR5veromwFWWyasWLqv4QJ
---
internal/account-api/store.go | 22 +++++++++++-----------
internal/account-api/store_test.go | 3 +++
internal/tui/task.go | 16 +++++++++-------
3 files changed, 23 insertions(+), 18 deletions(-)
diff --git a/internal/account-api/store.go b/internal/account-api/store.go
index 3c3a9804..f871d30b 100644
--- a/internal/account-api/store.go
+++ b/internal/account-api/store.go
@@ -7,7 +7,6 @@ import (
"io"
"net/http"
"net/url"
- "strings"
"github.com/shopware/shopware-cli/logging"
)
@@ -169,16 +168,17 @@ func (r *rawStorePlugin) toStorePlugin() StorePlugin {
// normalizeStoreLink rewrites the explicit-port store URLs the API returns
// (e.g. http://store.shopware.com:80/... or https://store.shopware.com:443/...)
-// to a clean https URL.
+// to a clean https URL. Links to other hosts or with custom ports pass
+// through untouched.
func normalizeStoreLink(link string) string {
- for _, rep := range []struct{ from, to string }{
- {"http://store.shopware.com:80", "https://store.shopware.com"},
- {"https://store.shopware.com:443", "https://store.shopware.com"},
- {"http://store.shopware.com", "https://store.shopware.com"},
- } {
- if strings.HasPrefix(link, rep.from) {
- return rep.to + strings.TrimPrefix(link, rep.from)
- }
+ u, err := url.Parse(link)
+ if err != nil || u.Hostname() != "store.shopware.com" {
+ return link
+ }
+ if port := u.Port(); port != "" && port != "80" && port != "443" {
+ return link
}
- return link
+ u.Scheme = "https"
+ u.Host = u.Hostname()
+ return u.String()
}
diff --git a/internal/account-api/store_test.go b/internal/account-api/store_test.go
index 754e4b18..5ed99b5f 100644
--- a/internal/account-api/store_test.go
+++ b/internal/account-api/store_test.go
@@ -75,5 +75,8 @@ func TestNormalizeStoreLink(t *testing.T) {
assert.Equal(t, "https://store.shopware.com/a", normalizeStoreLink("http://store.shopware.com:80/a"))
assert.Equal(t, "https://store.shopware.com/a", normalizeStoreLink("https://store.shopware.com:443/a"))
assert.Equal(t, "https://store.shopware.com/a", normalizeStoreLink("http://store.shopware.com/a"))
+ assert.Equal(t, "https://store.shopware.com", normalizeStoreLink("http://store.shopware.com:80"))
assert.Equal(t, "https://example.com/a", normalizeStoreLink("https://example.com/a"))
+ assert.Equal(t, "http://store.shopware.com:8080/a", normalizeStoreLink("http://store.shopware.com:8080/a"),
+ "custom ports are not default-port URLs and must pass through untouched")
}
diff --git a/internal/tui/task.go b/internal/tui/task.go
index 0ed87069..290f7e17 100644
--- a/internal/tui/task.go
+++ b/internal/tui/task.go
@@ -78,15 +78,17 @@ func (t Task) readLine() tea.Cmd {
return ReadLineCmd(t.ch, func(line string) tea.Msg { return TaskLineMsg{Line: line} }, taskStreamClosedMsg{})
}
-// finish emits TaskDoneMsg once both the exit result arrived and the output
-// stream drained, whichever came last.
-func (t *Task) finish() tea.Cmd {
+// finish marks the task done and emits TaskDoneMsg once both the exit result
+// arrived and the output stream drained, whichever came last. It returns the
+// updated task: mutating through a pointer inside a return statement would
+// copy the value before the mutation (unspecified evaluation order).
+func (t Task) finish() (Task, tea.Cmd) {
if !t.exited || !t.drained || t.done {
- return nil
+ return t, nil
}
t.done = true
err := t.err
- return func() tea.Msg { return TaskDoneMsg{Err: err} }
+ return t, func() tea.Msg { return TaskDoneMsg{Err: err} }
}
// Update handles the task's stream, completion, and spinner messages.
@@ -98,12 +100,12 @@ func (t Task) Update(msg tea.Msg) (Task, tea.Cmd) {
case taskStreamClosedMsg:
t.drained = true
- return t, t.finish()
+ return t.finish()
case taskExitMsg:
t.exited = true
t.err = msg.err
- return t, t.finish()
+ return t.finish()
case TaskDoneMsg:
// Normally self-emitted by finish (a no-op then); also accepted from