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(" entries require a key attribute") + return nil, errors.New(" entries require a key attribute") } node, err := typedValueToNode(child) diff --git a/internal/system/cache_disk.go b/internal/system/cache_disk.go index b286b1c9..41c1d0b4 100644 --- a/internal/system/cache_disk.go +++ b/internal/system/cache_disk.go @@ -3,6 +3,7 @@ package system import ( "context" "crypto/sha256" + "encoding/hex" "fmt" "io" "os" @@ -171,7 +172,7 @@ func (c *DiskCache) Close() error { func (c *DiskCache) getFilePath(key string) string { // Hash the key to create a safe filename hash := sha256.Sum256([]byte(key)) - filename := fmt.Sprintf("%x", hash) + filename := hex.EncodeToString(hash[:]) // Use the first two characters as subdirectory for better distribution subdir := filename[:2] @@ -183,7 +184,7 @@ func (c *DiskCache) getFilePath(key string) string { func (c *DiskCache) getFolderPath(key string) string { // Hash the key to create a safe folder name hash := sha256.Sum256([]byte(key)) - foldername := fmt.Sprintf("%x", hash) + foldername := hex.EncodeToString(hash[:]) // Use the first two characters as subdirectory for better distribution subdir := foldername[:2] diff --git a/internal/system/cache_github_actions.go b/internal/system/cache_github_actions.go index 7aeb90c5..17504dbe 100644 --- a/internal/system/cache_github_actions.go +++ b/internal/system/cache_github_actions.go @@ -6,6 +6,8 @@ import ( "compress/gzip" "context" "crypto/sha256" + "encoding/hex" + "errors" "fmt" "io" "os" @@ -32,7 +34,7 @@ func NewGitHubActionsCache(prefix string) (*GitHubActionsCache, error) { } if client == nil { - return nil, fmt.Errorf("GitHub Actions cache client is not available") + return nil, errors.New("GitHub Actions cache client is not available") } return &GitHubActionsCache{ @@ -380,7 +382,7 @@ func (c *GitHubActionsCache) Close() error { func (c *GitHubActionsCache) getCacheKey(key string) string { // GitHub Actions cache keys have restrictions, so we hash the key hash := sha256.Sum256([]byte(key)) - hashStr := fmt.Sprintf("%x", hash) + hashStr := hex.EncodeToString(hash[:]) // Combine prefix with hash, ensuring valid characters cacheKey := fmt.Sprintf("%s-%s", c.prefix, hashStr) diff --git a/internal/system/node.go b/internal/system/node.go index 2e358ede..e90dbd39 100644 --- a/internal/system/node.go +++ b/internal/system/node.go @@ -45,7 +45,7 @@ func IsNodeVersionAtLeast(ctx context.Context, requiredVersion string) (bool, er return false, fmt.Errorf("failed to parse installed Node.js version: %w", err) } - constraint, err := version.NewConstraint(fmt.Sprintf(">= %s", requiredVersion)) + constraint, err := version.NewConstraint(">= " + requiredVersion) if err != nil { return false, fmt.Errorf("failed to parse required Node.js version constraint: %w", err) } diff --git a/internal/system/php.go b/internal/system/php.go index 34f38c1d..4173fb5d 100644 --- a/internal/system/php.go +++ b/internal/system/php.go @@ -83,7 +83,7 @@ func IsPHPVersionAtLeast(ctx context.Context, requiredVersion string) (bool, err return false, fmt.Errorf("failed to parse installed PHP version: %w", err) } - constraint, err := version.NewConstraint(fmt.Sprintf(">= %s", requiredVersion)) + constraint, err := version.NewConstraint(">= " + requiredVersion) if err != nil { return false, fmt.Errorf("failed to parse required PHP version constraint: %w", err) } diff --git a/internal/system/setup.go b/internal/system/setup.go index e2882b8d..ffdf4df0 100644 --- a/internal/system/setup.go +++ b/internal/system/setup.go @@ -2,6 +2,7 @@ package system import ( "context" + "errors" "fmt" "os" "os/exec" @@ -81,14 +82,14 @@ func CheckProjectDependencies(ctx context.Context, useDocker bool, phpConstraint missing = append(missing, MissingDependency{Name: "PHP 8.2+", Reason: "not installed"}) case !phpOk: installed, _ := GetInstalledPHPVersion(ctx) - missing = append(missing, MissingDependency{Name: "PHP 8.2+", Reason: fmt.Sprintf("found PHP %s", strings.TrimSpace(installed))}) + missing = append(missing, MissingDependency{Name: "PHP 8.2+", Reason: "found PHP " + strings.TrimSpace(installed)}) default: if phpConstraint != nil { installed, _ := GetInstalledPHPVersion(ctx) if installed != "" && !phpConstraint.Check(installed) { missing = append(missing, MissingDependency{ Name: fmt.Sprintf("PHP %s", phpConstraint), - Reason: fmt.Sprintf("found PHP %s", strings.TrimSpace(installed)), + Reason: "found PHP " + strings.TrimSpace(installed), }) } } @@ -112,7 +113,7 @@ func ValidateProjectDependencies(ctx context.Context, useDocker bool, phpConstra } fmt.Fprintln(os.Stderr, RenderMissingDependencies(useDocker, missing, action, dockerHint)) - return fmt.Errorf("missing required dependencies") + return errors.New("missing required dependencies") } // phpDependencyConstraint returns the constraint text from a PHP-related diff --git a/internal/tui/dev/model_view.go b/internal/tui/dev/model_view.go index d5554543..a9ec6b7f 100644 --- a/internal/tui/dev/model_view.go +++ b/internal/tui/dev/model_view.go @@ -164,13 +164,13 @@ func (m Model) renderPhase(ctx app.Context) string { if m.dockerShowLogs { return m.renderDockerLogs("Starting Docker containers...", ctx.Width, ctx.MainHeight) } - cardContent := fmt.Sprintf("%s Starting Docker containers...", m.dockerSpinner.View()) + cardContent := m.dockerSpinner.View() + " Starting Docker containers..." content.WriteString(tui.RenderPhaseCard(cardContent)) case phaseStopping: if m.dockerShowLogs { return m.renderDockerLogs("Stopping Docker containers...", ctx.Width, ctx.MainHeight) } - cardContent := fmt.Sprintf("%s Stopping Docker containers...", m.dockerSpinner.View()) + cardContent := m.dockerSpinner.View() + " Stopping Docker containers..." content.WriteString(tui.RenderPhaseCard(cardContent)) case phaseInstallPrompt: var card strings.Builder diff --git a/internal/tui/dev/tab_overview.go b/internal/tui/dev/tab_overview.go index 1effe7db..3617daa9 100644 --- a/internal/tui/dev/tab_overview.go +++ b/internal/tui/dev/tab_overview.go @@ -4,6 +4,7 @@ import ( "bufio" "context" "encoding/json" + "errors" "fmt" "image/color" "io" @@ -646,7 +647,7 @@ func startWatcher(name string, prepare func(ctx context.Context, out io.Writer) stopCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second) _ = process.Stop(stopCtx) cancel() - running <- fmt.Errorf("watcher stopped") + running <- errors.New("watcher stopped") return } diff --git a/internal/tui/shortcuts.go b/internal/tui/shortcuts.go index 451c2e6d..9d5792f8 100644 --- a/internal/tui/shortcuts.go +++ b/internal/tui/shortcuts.go @@ -1,6 +1,8 @@ package tui import ( + "strings" + "charm.land/lipgloss/v2" "github.com/charmbracelet/x/ansi" ) @@ -52,9 +54,11 @@ func (s Shortcuts) bar(separator string) string { sep := lipgloss.NewStyle().Foreground(BorderColor).Render(separator) result := s.badge(s.opts.Items[0]) + var resultSb55 strings.Builder for _, item := range s.opts.Items[1:] { - result += sep + s.badge(item) + resultSb55.WriteString(sep + s.badge(item)) } + result += resultSb55.String() return result } diff --git a/internal/validation/reporter.go b/internal/validation/reporter.go index b9f3f14a..d664316a 100644 --- a/internal/validation/reporter.go +++ b/internal/validation/reporter.go @@ -6,6 +6,7 @@ import ( "encoding/hex" "encoding/json" "encoding/xml" + "errors" "fmt" "os" "sort" @@ -53,7 +54,7 @@ func DoCheckReport(result Check, reportingFormat string) error { } if result.HasErrors() { - return fmt.Errorf("found errors") + return errors.New("found errors") } return nil diff --git a/internal/verifier/admin_twig.go b/internal/verifier/admin_twig.go index 1b5525ef..88ed19ad 100644 --- a/internal/verifier/admin_twig.go +++ b/internal/verifier/admin_twig.go @@ -72,7 +72,7 @@ func (a AdminTwigLinter) Check(ctx context.Context, check *Check, config ToolCon Path: relPath, Line: 0, Severity: message.Severity, - Identifier: fmt.Sprintf("admintwiglinter/%s", message.Identifier), + Identifier: "admintwiglinter/" + message.Identifier, }) } } diff --git a/internal/verifier/composer.go b/internal/verifier/composer.go index 6e77580a..b62ac2f9 100644 --- a/internal/verifier/composer.go +++ b/internal/verifier/composer.go @@ -30,7 +30,7 @@ func installComposerDeps(ctx context.Context, rootDir string, checkAgainst strin if len(suggets) > 0 { additionalParams := []string{"require", "--prefer-dist", "--no-interaction", "--no-progress", "--no-plugins", "--no-scripts", "--ignore-platform-reqs"} for _, suggest := range suggets { - additionalParams = append(additionalParams, fmt.Sprintf("%s:*", suggest)) + additionalParams = append(additionalParams, suggest+":*") } composerInstall := exec.CommandContext(ctx, "composer", additionalParams...) diff --git a/internal/verifier/embed.go b/internal/verifier/embed.go index cc08324a..d832551a 100644 --- a/internal/verifier/embed.go +++ b/internal/verifier/embed.go @@ -3,6 +3,7 @@ package verifier import ( "context" "embed" + "errors" "fmt" "log" "os" @@ -36,13 +37,13 @@ func SetupTools(ctx context.Context, currentVersion string) error { if ok, err := system.IsPHPVersionAtLeast(ctx, "8.2.0"); err != nil { return fmt.Errorf("failed to check installed PHP version: %w", err) } else if !ok { - return fmt.Errorf("php version must be at least 8.2.0 to use this. Update your PHP version or use the shopware-cli docker image") + return errors.New("php version must be at least 8.2.0 to use this. Update your PHP version or use the shopware-cli docker image") } if ok, err := system.IsNodeVersionAtLeast(ctx, "20.0.0"); err != nil { return fmt.Errorf("failed to check installed Node.js version: %w", err) } else if !ok { - return fmt.Errorf("node.js version must be at least 20.0.0 to use this. Update your Node.js version or use the shopware-cli docker image") + return errors.New("node.js version must be at least 20.0.0 to use this. Update your Node.js version or use the shopware-cli docker image") } logging.FromContext(ctx).Debugf("Using tool directory: %s", toolsDir) diff --git a/internal/verifier/eslint.go b/internal/verifier/eslint.go index 083b37e2..cf5f4e36 100644 --- a/internal/verifier/eslint.go +++ b/internal/verifier/eslint.go @@ -53,7 +53,7 @@ func (e Eslint) Check(ctx context.Context, check *Check, config ToolConfig) erro var gr errgroup.Group - env := append(os.Environ(), fmt.Sprintf("SHOPWARE_VERSION=%s", config.MinShopwareVersion)) + env := append(os.Environ(), "SHOPWARE_VERSION="+config.MinShopwareVersion) for _, p := range paths { p := p @@ -97,7 +97,7 @@ func (e Eslint) Check(ctx context.Context, check *Check, config ToolConfig) erro Line: message.Line, Message: message.Message, Severity: severity, - Identifier: fmt.Sprintf("eslint/%s", message.RuleID), + Identifier: "eslint/" + message.RuleID, }) } } @@ -112,7 +112,7 @@ func (e Eslint) Check(ctx context.Context, check *Check, config ToolConfig) erro func (e Eslint) Fix(ctx context.Context, config ToolConfig) error { paths := append([]string{}, config.StorefrontDirectories...) paths = append(paths, config.AdminDirectories...) - env := append(os.Environ(), fmt.Sprintf("SHOPWARE_VERSION=%s", config.MinShopwareVersion)) + env := append(os.Environ(), "SHOPWARE_VERSION="+config.MinShopwareVersion) var gr errgroup.Group diff --git a/internal/verifier/phpstan.go b/internal/verifier/phpstan.go index cf5caea2..277007b1 100644 --- a/internal/verifier/phpstan.go +++ b/internal/verifier/phpstan.go @@ -5,7 +5,6 @@ import ( "context" _ "embed" "encoding/json" - "fmt" "os" "os/exec" "path" @@ -79,7 +78,7 @@ func (p PhpStan) Check(ctx context.Context, check *Check, config ToolConfig) err } phpstan := exec.CommandContext(ctx, "php", phpstanArguments...) - phpstan.Env = append(os.Environ(), fmt.Sprintf("PHP_DIR=%s", path.Join(config.ToolDirectory, "php"))) + phpstan.Env = append(os.Environ(), "PHP_DIR="+path.Join(config.ToolDirectory, "php")) phpstan.Dir = config.RootDir var stderr bytes.Buffer @@ -135,7 +134,7 @@ func (p PhpStan) Check(ctx context.Context, check *Check, config ToolConfig) err Line: message.Line, Message: message.Message, Severity: validation.SeverityError, - Identifier: fmt.Sprintf("phpstan/%s", message.Identifier), + Identifier: "phpstan/" + message.Identifier, Tip: message.Tip, }) } diff --git a/internal/verifier/project.go b/internal/verifier/project.go index 56ec6250..63408116 100644 --- a/internal/verifier/project.go +++ b/internal/verifier/project.go @@ -3,6 +3,7 @@ package verifier import ( "context" "encoding/json" + "errors" "fmt" "os" "path" @@ -72,7 +73,7 @@ func getShopwareConstraint(root string) (*version.Constraints, error) { } if composerJsonData.Require.Shopware == "" { - return nil, fmt.Errorf("shopware/core is not required") + return nil, errors.New("shopware/core is not required") } cst, err := version.NewConstraint(composerJsonData.Require.Shopware) diff --git a/internal/verifier/stylelint.go b/internal/verifier/stylelint.go index ca44f23f..cc66c7fe 100644 --- a/internal/verifier/stylelint.go +++ b/internal/verifier/stylelint.go @@ -63,7 +63,7 @@ func (s StyleLint) Check(ctx context.Context, check *Check, config ToolConfig) e "--ignore-pattern", "dist/**", "--ignore-pattern", ".tmp/**", "--ignore-pattern", "vendor/**", - fmt.Sprintf("%s/**/*.scss", p), + p+"/**/*.scss", ) stylelint.Dir = p @@ -84,7 +84,7 @@ func (s StyleLint) Check(ctx context.Context, check *Check, config ToolConfig) e Line: msg.Line, Message: msg.Text, Severity: msg.Severity, - Identifier: fmt.Sprintf("stylelint/%s", msg.Rule), + Identifier: "stylelint/" + msg.Rule, }) } @@ -94,7 +94,7 @@ func (s StyleLint) Check(ctx context.Context, check *Check, config ToolConfig) e Line: msg.Line, Message: msg.Text, Severity: msg.Severity, - Identifier: fmt.Sprintf("stylelint/%s", msg.Rule), + Identifier: "stylelint/" + msg.Rule, }) } } diff --git a/internal/xmlpath/xmlpath.go b/internal/xmlpath/xmlpath.go index e410ce48..2721050e 100644 --- a/internal/xmlpath/xmlpath.go +++ b/internal/xmlpath/xmlpath.go @@ -3,7 +3,7 @@ package xmlpath import ( "bytes" "encoding/xml" - "fmt" + "errors" "io" "strings" ) @@ -48,7 +48,7 @@ func Parse(data []byte) (*Document, error) { switch t := tok.(type) { case xml.StartElement: if doc.root != nil { - return nil, fmt.Errorf("multiple root elements found") + return nil, errors.New("multiple root elements found") } element, err := parseElement(decoder, t, namespaces) if err != nil { @@ -64,7 +64,7 @@ func Parse(data []byte) (*Document, error) { } if doc.root == nil { - return nil, fmt.Errorf("root element not found") + return nil, errors.New("root element not found") } return doc, nil From a9a075e8851b9d43c173fa3cb600b6e9bc2fcd9f Mon Sep 17 00:00:00 2001 From: Soner Sayakci Date: Wed, 29 Jul 2026 11:42:35 +0200 Subject: [PATCH 04/11] feat: add admin dev server port detection and update overview model --- internal/extension/admin_watch.go | 24 +++++++++++++++ internal/extension/admin_watch_test.go | 42 ++++++++++++++++++++++++++ internal/tui/dev/tab_overview.go | 4 ++- 3 files changed, 69 insertions(+), 1 deletion(-) create mode 100644 internal/extension/admin_watch_test.go diff --git a/internal/extension/admin_watch.go b/internal/extension/admin_watch.go index cde66403..a867c03f 100644 --- a/internal/extension/admin_watch.go +++ b/internal/extension/admin_watch.go @@ -11,6 +11,30 @@ import ( "github.com/shopware/shopware-cli/internal/npm" ) +// Default ports of the administration dev server, chosen by the platform's +// build tooling. +const ( + AdminVitePort = 5173 + AdminWebpackPort = 8080 +) + +// AdminDevServerPort returns the port of the dev server that +// PrepareAdminWatcher's `npm run dev` starts. The port is chosen by the +// platform's own build setup, so it is detected from the Administration app +// rather than inferred from a version: a Vite config (Shopware 6.7+) serves on +// 5173, a webpack config (older versions) on 8080. +func AdminDevServerPort(projectRoot string) int { + adminApp := PlatformPath(projectRoot, "Administration", "Resources/app/administration") + if matches, _ := filepath.Glob(filepath.Join(adminApp, "vite.config.*")); len(matches) > 0 { + return AdminVitePort + } + if _, err := os.Stat(filepath.Join(adminApp, "webpack.config.js")); err == nil { + return AdminWebpackPort + } + // Neither found (e.g. platform not installed yet): assume current tooling. + return AdminVitePort +} + // PrepareAdminWatcher runs the admin watcher preparation steps and returns the // dev server process. When out is non-nil, the output of every preparation step // (feature:dump, npm install, schema generation) is streamed to it so the steps diff --git a/internal/extension/admin_watch_test.go b/internal/extension/admin_watch_test.go new file mode 100644 index 00000000..692e7bbc --- /dev/null +++ b/internal/extension/admin_watch_test.go @@ -0,0 +1,42 @@ +package extension + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func writeAdminAppFile(t *testing.T, projectRoot, name string) { + t.Helper() + adminApp := filepath.Join(projectRoot, "vendor", "shopware", "administration", "Resources", "app", "administration") + require.NoError(t, os.MkdirAll(adminApp, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(adminApp, name), []byte("{}"), 0o644)) +} + +func TestAdminDevServerPort(t *testing.T) { + t.Run("vite config means 5173", func(t *testing.T) { + dir := t.TempDir() + writeAdminAppFile(t, dir, "vite.config.mts") + assert.Equal(t, AdminVitePort, AdminDevServerPort(dir)) + }) + + t.Run("webpack config means 8080", func(t *testing.T) { + dir := t.TempDir() + writeAdminAppFile(t, dir, "webpack.config.js") + assert.Equal(t, AdminWebpackPort, AdminDevServerPort(dir)) + }) + + t.Run("vite wins when both exist", func(t *testing.T) { + dir := t.TempDir() + writeAdminAppFile(t, dir, "vite.config.mts") + writeAdminAppFile(t, dir, "webpack.config.js") + assert.Equal(t, AdminVitePort, AdminDevServerPort(dir)) + }) + + t.Run("missing platform assumes current tooling", func(t *testing.T) { + assert.Equal(t, AdminVitePort, AdminDevServerPort(t.TempDir())) + }) +} diff --git a/internal/tui/dev/tab_overview.go b/internal/tui/dev/tab_overview.go index 3617daa9..4ad138cd 100644 --- a/internal/tui/dev/tab_overview.go +++ b/internal/tui/dev/tab_overview.go @@ -55,6 +55,7 @@ type OverviewModel struct { sfWatchRunning bool sfWatchStarting bool shopwareVersion string + adminWatchURL string securityEnd time.Time health []healthCheck healthLoading bool @@ -229,6 +230,7 @@ func NewOverviewModel(envType, shopURL, username, password, projectRoot string, envType: envType, shopURL: shopURL, adminURL: deriveAdminURL(shopURL), + adminWatchURL: fmt.Sprintf("http://127.0.0.1:%d", extension.AdminDevServerPort(projectRoot)), username: username, password: password, projectRoot: projectRoot, @@ -527,7 +529,7 @@ func (m OverviewModel) renderWatchers() string { var s strings.Builder s.WriteString(tui.TitleStyle.Render("Watchers")) s.WriteString("\n") - s.WriteString(m.renderWatcherStatus("Admin", m.adminWatchRunning, m.adminWatchStarting, "http://127.0.0.1:5173", m.cursor == 0)) + s.WriteString(m.renderWatcherStatus("Admin", m.adminWatchRunning, m.adminWatchStarting, m.adminWatchURL, m.cursor == 0)) s.WriteString(m.renderWatcherStatus("Storefront", m.sfWatchRunning, m.sfWatchStarting, "http://127.0.0.1:9998", m.cursor == 1)) return s.String() } From e1814f8929e7386898fa7334b4c87706d17fdc47 Mon Sep 17 00:00:00 2001 From: Soner Sayakci Date: Wed, 29 Jul 2026 13:26:35 +0200 Subject: [PATCH 05/11] feat: enhance AdminDevServerPort logic to support feature flag for Vite in Shopware 6.6 --- internal/extension/admin_watch.go | 38 ++++++++++++++++----- internal/extension/admin_watch_test.go | 46 ++++++++++++++++++++------ 2 files changed, 64 insertions(+), 20 deletions(-) diff --git a/internal/extension/admin_watch.go b/internal/extension/admin_watch.go index a867c03f..f3b95e55 100644 --- a/internal/extension/admin_watch.go +++ b/internal/extension/admin_watch.go @@ -2,6 +2,7 @@ package extension import ( "context" + "encoding/json" "fmt" "io" "os" @@ -19,20 +20,39 @@ const ( ) // AdminDevServerPort returns the port of the dev server that -// PrepareAdminWatcher's `npm run dev` starts. The port is chosen by the -// platform's own build setup, so it is detected from the Administration app -// rather than inferred from a version: a Vite config (Shopware 6.7+) serves on -// 5173, a webpack config (older versions) on 8080. +// PrepareAdminWatcher's `npm run dev` starts, mirroring how the platform's +// own dev script picks its tooling: Shopware 6.7+ ships Vite only (5173), +// while 6.6 ships both and decides at runtime via the ADMIN_VITE feature flag +// in var/config_js_features.json, defaulting to webpack-dev-server (8080). func AdminDevServerPort(projectRoot string) int { adminApp := PlatformPath(projectRoot, "Administration", "Resources/app/administration") - if matches, _ := filepath.Glob(filepath.Join(adminApp, "vite.config.*")); len(matches) > 0 { + if _, err := os.Stat(filepath.Join(adminApp, "webpack.config.js")); err != nil { + // Without a webpack config (6.7+, or platform not installed yet) + // `npm run dev` can only start Vite. return AdminVitePort } - if _, err := os.Stat(filepath.Join(adminApp, "webpack.config.js")); err == nil { - return AdminWebpackPort + if adminViteFeatureEnabled(projectRoot) { + return AdminVitePort + } + return AdminWebpackPort +} + +// adminViteFeatureEnabled reads the ADMIN_VITE flag the way 6.6's dev script +// does (`jq -r '.ADMIN_VITE' var/config_js_features.json`): only an explicit +// true switches to Vite — a missing file or key means webpack. +func adminViteFeatureEnabled(projectRoot string) bool { + content, err := os.ReadFile(filepath.Join(projectRoot, "var", "config_js_features.json")) + if err != nil { + return false + } + + var flags map[string]any + if err := json.Unmarshal(content, &flags); err != nil { + return false } - // Neither found (e.g. platform not installed yet): assume current tooling. - return AdminVitePort + + enabled, _ := flags["ADMIN_VITE"].(bool) + return enabled } // PrepareAdminWatcher runs the admin watcher preparation steps and returns the diff --git a/internal/extension/admin_watch_test.go b/internal/extension/admin_watch_test.go index 692e7bbc..2e554191 100644 --- a/internal/extension/admin_watch_test.go +++ b/internal/extension/admin_watch_test.go @@ -9,33 +9,57 @@ import ( "github.com/stretchr/testify/require" ) -func writeAdminAppFile(t *testing.T, projectRoot, name string) { +func writeAdminWatchFile(t *testing.T, projectRoot string, relPath, content string) { t.Helper() - adminApp := filepath.Join(projectRoot, "vendor", "shopware", "administration", "Resources", "app", "administration") - require.NoError(t, os.MkdirAll(adminApp, 0o755)) - require.NoError(t, os.WriteFile(filepath.Join(adminApp, name), []byte("{}"), 0o644)) + path := filepath.Join(projectRoot, relPath) + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(t, os.WriteFile(path, []byte(content), 0o644)) +} + +// writeAdminWebpackConfig marks the project as one whose Administration app +// still ships the webpack toolchain (Shopware 6.6). +func writeAdminWebpackConfig(t *testing.T, projectRoot string) { + t.Helper() + writeAdminWatchFile(t, projectRoot, "vendor/shopware/administration/Resources/app/administration/webpack.config.js", "module.exports = {}") } func TestAdminDevServerPort(t *testing.T) { - t.Run("vite config means 5173", func(t *testing.T) { + t.Run("6.7 without webpack config serves vite", func(t *testing.T) { dir := t.TempDir() - writeAdminAppFile(t, dir, "vite.config.mts") + writeAdminWatchFile(t, dir, "vendor/shopware/administration/Resources/app/administration/vite.config.mts", "export default {}") assert.Equal(t, AdminVitePort, AdminDevServerPort(dir)) }) - t.Run("webpack config means 8080", func(t *testing.T) { + t.Run("6.6 defaults to webpack even with a vite config present", func(t *testing.T) { + // 6.6 ships both configs; the dev script picks by feature flag and + // falls back to webpack when var/config_js_features.json is missing. dir := t.TempDir() - writeAdminAppFile(t, dir, "webpack.config.js") + writeAdminWebpackConfig(t, dir) + writeAdminWatchFile(t, dir, "vendor/shopware/administration/Resources/app/administration/vite.config.mts", "export default {}") assert.Equal(t, AdminWebpackPort, AdminDevServerPort(dir)) }) - t.Run("vite wins when both exist", func(t *testing.T) { + t.Run("6.6 with ADMIN_VITE disabled serves webpack", func(t *testing.T) { dir := t.TempDir() - writeAdminAppFile(t, dir, "vite.config.mts") - writeAdminAppFile(t, dir, "webpack.config.js") + writeAdminWebpackConfig(t, dir) + writeAdminWatchFile(t, dir, "var/config_js_features.json", `{"ADMIN_VITE": false, "admin.vite": false}`) + assert.Equal(t, AdminWebpackPort, AdminDevServerPort(dir)) + }) + + t.Run("6.6 with ADMIN_VITE enabled serves vite", func(t *testing.T) { + dir := t.TempDir() + writeAdminWebpackConfig(t, dir) + writeAdminWatchFile(t, dir, "var/config_js_features.json", `{"ADMIN_VITE": true}`) assert.Equal(t, AdminVitePort, AdminDevServerPort(dir)) }) + t.Run("broken feature dump falls back to webpack", func(t *testing.T) { + dir := t.TempDir() + writeAdminWebpackConfig(t, dir) + writeAdminWatchFile(t, dir, "var/config_js_features.json", "not-json") + assert.Equal(t, AdminWebpackPort, AdminDevServerPort(dir)) + }) + t.Run("missing platform assumes current tooling", func(t *testing.T) { assert.Equal(t, AdminVitePort, AdminDevServerPort(t.TempDir())) }) From 270c0115248c86dff26c9a52199b6327ea6b92ee Mon Sep 17 00:00:00 2001 From: Soner Sayakci Date: Wed, 29 Jul 2026 14:25:12 +0200 Subject: [PATCH 06/11] feat: add deployment helper hint logic to upgrade panel --- internal/tui/upgrade/model_test.go | 11 +++++++++++ internal/tui/upgrade/panel_prepare.go | 25 +++++++++++++++++++------ 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/internal/tui/upgrade/model_test.go b/internal/tui/upgrade/model_test.go index 02f0348c..abe9479e 100644 --- a/internal/tui/upgrade/model_test.go +++ b/internal/tui/upgrade/model_test.go @@ -315,6 +315,17 @@ func TestPreparePanelReady(t *testing.T) { assert.Equal(t, panelReview, w.m.panel, "continue is allowed when ready") } +func TestPreparePanelDeploymentHelperHintOnlyWhenMissing(t *testing.T) { + w := wizardAtPrepare(t, []backend.ExtensionResult{okResult()}, true) + assert.NotContains(t, w.view(t), "add shopware/deployment-helper", + "no hint when the helper is already required") + + w.m.check.readiness.Checks = append(w.m.check.readiness.Checks, backend.ReadinessCheck{ + ID: "deployment-helper", Label: "Deployment Helper workflow ready", Value: "no", State: backend.StateWarn, + }) + assert.Contains(t, w.view(t), "add shopware/deployment-helper") +} + func TestPreparePanelEnterOnContinueButton(t *testing.T) { w := wizardAtPrepare(t, []backend.ExtensionResult{okResult()}, true) diff --git a/internal/tui/upgrade/panel_prepare.go b/internal/tui/upgrade/panel_prepare.go index bc6ff459..8ad0fca3 100644 --- a/internal/tui/upgrade/panel_prepare.go +++ b/internal/tui/upgrade/panel_prepare.go @@ -95,6 +95,17 @@ func (s prepareState) resolveFailed() bool { return s.resolveErr != nil || (s.resolve != nil && !s.resolve.OK) } +// deploymentHelperMissing reports whether the upgrade will add +// shopware/deployment-helper to composer.json, per the readiness check. +func (m *Model) deploymentHelperMissing() bool { + for _, check := range m.check.readiness.Checks { + if check.ID == "deployment-helper" { + return check.State != backend.StateOK + } + } + return false +} + // applyResolved overwrites the metadata-derived target versions with the // exact releases the composer dry run picked, once both checks finished. func (s *prepareState) applyResolved() { @@ -521,12 +532,14 @@ func (m *Model) renderSystemChecks() string { func (m *Model) viewPrepareRight() string { var b strings.Builder - 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("\n") - b.WriteString(tui.LabelStyle.Render(" if missing")) - b.WriteString("\n\n\n") + 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("\n") + b.WriteString(tui.LabelStyle.Render(" to composer.json")) + b.WriteString("\n\n\n") + } b.WriteString(userActionStyle.Render("User action")) b.WriteString("\n") b.WriteString(tui.LabelStyle.Render("Open an extension detail popup to")) From 7be9da518cc6dec7bf6ef783d131fa5829d25440 Mon Sep 17 00:00:00 2001 From: Soner Sayakci Date: Wed, 29 Jul 2026 14:48:10 +0200 Subject: [PATCH 07/11] feat: add store plugin fetching functionality and tests --- internal/account-api/oidc.go | 3 + internal/account-api/store.go | 184 +++++++++++++++++++++++++++++ internal/account-api/store_test.go | 79 +++++++++++++ internal/shop/upgrade/run.go | 10 +- 4 files changed, 274 insertions(+), 2 deletions(-) create mode 100644 internal/account-api/store.go create mode 100644 internal/account-api/store_test.go diff --git a/internal/account-api/oidc.go b/internal/account-api/oidc.go index c8f6b830..29334ec4 100644 --- a/internal/account-api/oidc.go +++ b/internal/account-api/oidc.go @@ -29,6 +29,9 @@ func getOIDCClientID() string { } func getApiUrl() string { + if v := os.Getenv("SHOPWARE_CLI_API_ENDPOINT"); v != "" { + return v + } if isStaging() { return "https://next-api.shopware.com" } diff --git a/internal/account-api/store.go b/internal/account-api/store.go new file mode 100644 index 00000000..3c3a9804 --- /dev/null +++ b/internal/account-api/store.go @@ -0,0 +1,184 @@ +package account_api + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "github.com/shopware/shopware-cli/logging" +) + +// StorePlugin is a plugin entry returned by the store pluginsByName endpoint, +// flattened to the useful fields. Localized fields (label, description, short +// description, installation manual, changelog text) reflect the locale passed +// to GetStorePluginsByName. +type StorePlugin struct { + ID int + Name string + Label string + Description string + ShortDescription string + InstallationManual string + Version string + RatingAverage float64 + StoreLink string + IconPath string + ReleaseDate string + ProducerName string + ProducerWebsite string + Pictures []StorePicture + Changelogs []StoreChangelog +} + +// StorePicture is a listing image (screenshot) of a store plugin. +type StorePicture struct { + URL string + Preview bool + Priority int +} + +// StoreChangelog is a single changelog entry for a store plugin. +type StoreChangelog struct { + Version string + Text string + CreationDate StoreDate +} + +// StoreDate is the date wrapper the store API uses for timestamp fields. +type StoreDate struct { + Date string `json:"date"` +} + +// rawStorePlugin mirrors the raw pluginsByName response shape. +type rawStorePlugin struct { + ID int `json:"id"` + Name string `json:"name"` + Label string `json:"label"` + Description string `json:"description"` + InstallationManual string `json:"installationManual"` + Version string `json:"version"` + RatingAverage float64 `json:"ratingAverage"` + Link string `json:"link"` + IconPath string `json:"iconPath"` + ReleaseDate StoreDate `json:"releaseDate"` + Producer struct { + Name string `json:"name"` + Website string `json:"website"` + } `json:"producer"` + Infos []struct { + ShortDescription string `json:"shortDescription"` + } `json:"infos"` + Pictures []struct { + RemoteLink string `json:"remoteLink"` + Preview bool `json:"preview"` + Priority int `json:"priority"` + } `json:"pictures"` + Changelog []struct { + Version string `json:"version"` + Text string `json:"text"` + CreationDate StoreDate `json:"creationDate"` + } `json:"changelog"` +} + +// GetStorePluginsByName fetches store metadata (versions, rating, changelogs, +// pictures, localized descriptions, store link, ...) for the given technical +// names, scoped to a Shopware version. The locale must be in the store's +// underscore form (e.g. "en_GB", "de_DE"); hyphenated locales are silently +// ignored by the API. No authentication is required. +func GetStorePluginsByName(ctx context.Context, locale, shopwareVersion string, technicalNames []string) ([]StorePlugin, error) { + q := url.Values{} + q.Set("locale", locale) + q.Set("shopwareVersion", shopwareVersion) + for _, name := range technicalNames { + q.Add("technicalNames[]", name) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, getApiUrl()+"/pluginStore/pluginsByName?"+q.Encode(), nil) + if err != nil { + return nil, err + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("fetch store plugins: %w", err) + } + defer func() { + if err := resp.Body.Close(); err != nil { + logging.FromContext(ctx).Errorf("Cannot close response body: %v", err) + } + }() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("API returned non-OK status: %d\n%s", resp.StatusCode, string(body)) + } + + var raw []rawStorePlugin + if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil { + return nil, fmt.Errorf("parse store plugins response: %w", err) + } + + plugins := make([]StorePlugin, 0, len(raw)) + for i := range raw { + plugins = append(plugins, raw[i].toStorePlugin()) + } + return plugins, nil +} + +func (r *rawStorePlugin) toStorePlugin() StorePlugin { + sp := StorePlugin{ + ID: r.ID, + Name: r.Name, + Label: r.Label, + Description: r.Description, + InstallationManual: r.InstallationManual, + Version: r.Version, + RatingAverage: r.RatingAverage, + StoreLink: normalizeStoreLink(r.Link), + IconPath: r.IconPath, + ReleaseDate: r.ReleaseDate.Date, + ProducerName: r.Producer.Name, + ProducerWebsite: r.Producer.Website, + } + if len(r.Infos) > 0 { + sp.ShortDescription = r.Infos[0].ShortDescription + } + for _, p := range r.Pictures { + if p.RemoteLink == "" { + continue + } + sp.Pictures = append(sp.Pictures, StorePicture{ + URL: p.RemoteLink, + Preview: p.Preview, + Priority: p.Priority, + }) + } + for _, cl := range r.Changelog { + sp.Changelogs = append(sp.Changelogs, StoreChangelog{ + Version: cl.Version, + Text: cl.Text, + CreationDate: cl.CreationDate, + }) + } + return sp +} + +// 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. +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) + } + } + return link +} diff --git a/internal/account-api/store_test.go b/internal/account-api/store_test.go new file mode 100644 index 00000000..754e4b18 --- /dev/null +++ b/internal/account-api/store_test.go @@ -0,0 +1,79 @@ +package account_api + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetStorePluginsByName(t *testing.T) { + var gotQuery map[string][]string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/pluginStore/pluginsByName", r.URL.Path) + gotQuery = r.URL.Query() + _, _ = w.Write([]byte(`[{ + "id": 42, + "name": "SwagDemo", + "label": "Demo Plugin", + "description": "

Long description

", + "version": "2.1.0", + "ratingAverage": 4.5, + "link": "http://store.shopware.com:80/swag-demo.html", + "iconPath": "https://store.shopware.com/icon.png", + "releaseDate": {"date": "2026-01-15 00:00:00.000000"}, + "producer": {"name": "shopware AG", "website": "https://shopware.com"}, + "infos": [{"shortDescription": "Short text"}], + "pictures": [ + {"remoteLink": "https://img/1.png", "preview": true, "priority": 1}, + {"remoteLink": "", "preview": false, "priority": 2} + ], + "changelog": [{"version": "2.1.0", "text": "Fixes", "creationDate": {"date": "2026-01-15"}}] + }]`)) + })) + defer srv.Close() + t.Setenv("SHOPWARE_CLI_API_ENDPOINT", srv.URL) + + plugins, err := GetStorePluginsByName(t.Context(), "en_GB", "6.6.10.3", []string{"SwagDemo", "SwagOther"}) + require.NoError(t, err) + + assert.Equal(t, []string{"en_GB"}, gotQuery["locale"]) + assert.Equal(t, []string{"6.6.10.3"}, gotQuery["shopwareVersion"]) + assert.Equal(t, []string{"SwagDemo", "SwagOther"}, gotQuery["technicalNames[]"]) + + require.Len(t, plugins, 1) + p := plugins[0] + assert.Equal(t, 42, p.ID) + assert.Equal(t, "Demo Plugin", p.Label) + assert.Equal(t, "2.1.0", p.Version) + assert.InDelta(t, 4.5, p.RatingAverage, 0.001) + assert.Equal(t, "https://store.shopware.com/swag-demo.html", p.StoreLink, "explicit-port links are normalized") + assert.Equal(t, "Short text", p.ShortDescription) + assert.Equal(t, "shopware AG", p.ProducerName) + assert.Equal(t, "2026-01-15 00:00:00.000000", p.ReleaseDate) + require.Len(t, p.Pictures, 1, "pictures without a remote link are dropped") + assert.Equal(t, "https://img/1.png", p.Pictures[0].URL) + require.Len(t, p.Changelogs, 1) + assert.Equal(t, "Fixes", p.Changelogs[0].Text) +} + +func TestGetStorePluginsByNameErrorStatus(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "boom", http.StatusBadGateway) + })) + defer srv.Close() + t.Setenv("SHOPWARE_CLI_API_ENDPOINT", srv.URL) + + _, err := GetStorePluginsByName(t.Context(), "en_GB", "6.6.10.3", []string{"SwagDemo"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "502") +} + +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://example.com/a", normalizeStoreLink("https://example.com/a")) +} diff --git a/internal/shop/upgrade/run.go b/internal/shop/upgrade/run.go index 5b6d79b6..c4c49d9f 100644 --- a/internal/shop/upgrade/run.go +++ b/internal/shop/upgrade/run.go @@ -105,10 +105,16 @@ func (u *ProjectUpgrader) Run(ctx context.Context, opts RunOptions) <-chan StepE } // 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. + // complete, and the goroutine must not leak. The non-blocking + // attempt comes first: with a cancelled context a bare two-way + // select would randomly drop events that still fit the buffer. select { case events <- ev: - case <-ctx.Done(): + default: + select { + case events <- ev: + case <-ctx.Done(): + } } } From 34e3baf9bf9669de01f0407e744f39539fa18508 Mon Sep 17 00:00:00 2001 From: Soner Sayakci Date: Wed, 29 Jul 2026 15:22:28 +0200 Subject: [PATCH 08/11] feat: implement extension changelog loading and reporting functionality --- internal/shop/upgrade/changelog.go | 152 ++++++++++++++++++++++++ internal/shop/upgrade/changelog_test.go | 114 ++++++++++++++++++ internal/shop/upgrade/headless.go | 1 + internal/shop/upgrade/headless_test.go | 3 + internal/shop/upgrade/report.go | 27 +++++ internal/shop/upgrade/upgrader.go | 2 + internal/tui/upgrade/messages.go | 16 +++ internal/tui/upgrade/model_test.go | 26 +++- internal/tui/upgrade/panel_prepare.go | 32 ++++- internal/tui/upgrade/panel_review.go | 1 + 10 files changed, 369 insertions(+), 5 deletions(-) create mode 100644 internal/shop/upgrade/changelog.go create mode 100644 internal/shop/upgrade/changelog_test.go diff --git a/internal/shop/upgrade/changelog.go b/internal/shop/upgrade/changelog.go new file mode 100644 index 00000000..88d15375 --- /dev/null +++ b/internal/shop/upgrade/changelog.go @@ -0,0 +1,152 @@ +package upgrade + +import ( + "context" + "html" + "sort" + "strings" + + "github.com/shyim/go-version" + + "github.com/shopware/shopware-cli/logging" +) + +// ExtensionChangelog collects the Store changelog entries an extension update +// applies — everything after the installed release up to and including the +// release the upgrade moves to. +type ExtensionChangelog struct { + // Extension is the technical name, e.g. SwagPayPal. + Extension string + // From is the installed version, To the update's target version. + From string + To string + // Entries are ordered newest first. + Entries []ChangelogEntry +} + +// ChangelogEntry is one release note of an extension update. +type ChangelogEntry struct { + Version string + // Date is the release date (yyyy-mm-dd), "" when the Store has none. + Date string + // Text is the release note as plain text (the Store serves HTML). + Text string +} + +// LoadExtensionChangelogs fetches the Store changelogs for every extension +// the upgrade updates (installed -> available release) and keeps the entries +// inside that version window. Store metadata is advisory, so failures degrade +// to nil instead of erroring. +func (u *ProjectUpgrader) LoadExtensionChangelogs(ctx context.Context, target string, results []ExtensionResult) []ExtensionChangelog { + type window struct{ from, to *version.Version } + updates := make(map[string]window) + names := make([]string, 0, len(results)) + for _, res := range results { + if res.Available == "" || res.Available == res.Extension.Version { + continue + } + from, fromErr := version.NewVersion(res.Extension.Version) + to, toErr := version.NewVersion(res.Available) + if fromErr != nil || toErr != nil || !to.GreaterThan(from) { + continue + } + updates[res.Extension.Name] = window{from: from, to: to} + names = append(names, res.Extension.Name) + } + if len(names) == 0 { + return nil + } + + plugins, err := u.storePlugins(ctx, "en_GB", target, names) + if err != nil { + logging.FromContext(ctx).Debugf("store changelog lookup failed: %v", err) + return nil + } + + var changelogs []ExtensionChangelog + for _, plugin := range plugins { + win, ok := updates[plugin.Name] + if !ok { + continue + } + + cl := ExtensionChangelog{ + Extension: plugin.Name, + From: win.from.String(), + To: win.to.String(), + } + for _, entry := range plugin.Changelogs { + v, err := version.NewVersion(entry.Version) + if err != nil || !v.GreaterThan(win.from) || v.GreaterThan(win.to) { + continue + } + cl.Entries = append(cl.Entries, ChangelogEntry{ + Version: entry.Version, + Date: changelogDate(entry.CreationDate.Date), + Text: htmlToText(entry.Text), + }) + } + if len(cl.Entries) == 0 { + continue + } + + sort.SliceStable(cl.Entries, func(i, j int) bool { + vi, ei := version.NewVersion(cl.Entries[i].Version) + vj, ej := version.NewVersion(cl.Entries[j].Version) + if ei != nil || ej != nil { + return cl.Entries[i].Version > cl.Entries[j].Version + } + return vi.GreaterThan(vj) + }) + changelogs = append(changelogs, cl) + } + + sort.Slice(changelogs, func(i, j int) bool { return changelogs[i].Extension < changelogs[j].Extension }) + return changelogs +} + +// changelogDate reduces the Store's timestamp ("2026-01-15 00:00:00.000000") +// to its date part. +func changelogDate(date string) string { + if len(date) >= 10 { + return date[:10] + } + return date +} + +// htmlToText flattens the Store's HTML release notes to plain text: list +// items become bullet lines, block-level closings become line breaks, all +// remaining tags are dropped, and entities are unescaped. +func htmlToText(s string) string { + replacer := strings.NewReplacer( + "
  • ", "- ", + "
  • ", "\n", + "

    ", "\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

    • one
    • two & three
    "), + 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("") + 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("") + 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("") + 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