Repository: JanDeDobbeleer/oh-my-posh
Pull request #6321 (2025-03-30 14:55:27)

fix(cli): correctly handle exit codes
### Prerequisites

- [x] I have read and understood the [contributing guide][CONTRIBUTING.md].
- [x] The commit message follows the [conventional commits][cc] guidelines.
- [x] Tests for the changes have been added (for bug fixes / features).
- [x] Docs have been added/updated (for bug fixes / features).

<!---

Tips:

If you're not comfortable with working with Git,
we're working a guide (https://ohmyposh.dev/docs/contributing/git) to help you out.
Oh My Posh advises GitKraken (https://www.gitkraken.com/invite/nQmDPR9D) as your preferred cross platform Git GUI power tool.

-->

[CONTRIBUTING.md]: https://github.com/JanDeDobbeleer/oh-my-posh/blob/main/CONTRIBUTING.md
[cc]: https://www.conventionalcommits.org/en/v1.0.0/#summary



Patch:
diff --git a/src/cache/file.go b/src/cache/file.go
index 0087863a..fe4eee54 100644
--- a/src/cache/file.go
+++ b/src/cache/file.go
@@ -51,6 +51,7 @@ func (fc *File) Init(cacheFilePath string, persist bool) {
 
 func (fc *File) Close() {
 	if !fc.persist || !fc.dirty {
+		log.Debug("not persisting cache")
 		return
 	}
 
diff --git a/src/cli/cache.go b/src/cli/cache.go
index 40de0e1d..e1883e69 100644
--- a/src/cli/cache.go
+++ b/src/cli/cache.go
@@ -2,7 +2,6 @@ package cli
 
 import (
 	"fmt"
-	"os"
 	"path/filepath"
 
 	"github.com/jandedobbeleer/oh-my-posh/src/cache"
@@ -48,7 +47,7 @@ You can do the following:
 			}
 		case "edit":
 			cacheFilePath := filepath.Join(cache.Path(), cache.FileName)
-			os.Exit(editFileWithEditor(cacheFilePath))
+			exitcode = editFileWithEditor(cacheFilePath)
 		}
 	},
 }
diff --git a/src/cli/config.go b/src/cli/config.go
index 27a2500d..3b8e03d6 100644
--- a/src/cli/config.go
+++ b/src/cli/config.go
@@ -2,7 +2,6 @@ package cli
 
 import (
 	"fmt"
-	"os"
 	"time"
 
 	"github.com/jandedobbeleer/oh-my-posh/src/config"
@@ -32,7 +31,7 @@ You can export, migrate or edit the config (via the editor specified in the envi
 		switch args[0] {
 		case "edit":
 			path := config.Path((configFlag))
-			os.Exit(editFileWithEditor(path))
+			exitcode = editFileWithEditor(path)
 		case "get":
 			// only here for backwards compatibility
 			fmt.Print(time.Now().UnixNano() / 1000000)
diff --git a/src/cli/config_export.go b/src/cli/config_export.go
index 359ea1f3..8c454c9c 100644
--- a/src/cli/config_export.go
+++ b/src/cli/config_export.go
@@ -1,8 +1,8 @@
 package cli
 
 import (
+	"errors"
 	"fmt"
-	"os"
 	"path/filepath"
 	"strings"
 
@@ -37,13 +37,14 @@ Exports the current config to "~/new_config.omp.json" (in JSON format).`,
 		if len(output) == 0 && len(format) == 0 {
 			// usage error
 			fmt.Println("neither output path nor export format is specified")
-			os.Exit(2)
+			exitcode = 2
+			return
 		}
 
 		configFile := config.Path(configFlag)
 		cfg := config.Load(configFile, shell.GENERIC, false)
 
-		validateExportFormat := func() {
+		validateExportFormat := func() error {
 			format = strings.ToLower(format)
 			switch format {
 			case "json", "jsonc":
@@ -56,12 +57,17 @@ Exports the current config to "~/new_config.omp.json" (in JSON format).`,
 				formats := []string{"json", "jsonc", "toml", "tml", "yaml", "yml"}
 				// usage error
 				fmt.Printf("export format must be one of these: %s\n", strings.Join(formats, ", "))
-				os.Exit(2)
+				exitcode = 2
+				return errors.New("invalide export format")
 			}
+
+			return nil
 		}
 
 		if len(format) != 0 {
-			validateExportFormat()
+			if err := validateExportFormat(); err != nil {
+				return
+			}
 		}
 
 		if len(output) == 0 {
@@ -73,7 +79,9 @@ Exports the current config to "~/new_config.omp.json" (in JSON format).`,
 
 		if len(format) == 0 {
 			format = strings.TrimPrefix(filepath.Ext(output), ".")
-			validateExportFormat()
+			if err := validateExportFormat(); err != nil {
+				return
+			}
 		}
 
 		cfg.Write(format)
diff --git a/src/cli/font.go b/src/cli/font.go
index b3e44b51..7c17951e 100644
--- a/src/cli/font.go
+++ b/src/cli/font.go
@@ -5,6 +5,7 @@ import (
 	"strings"
 
 	"github.com/jandedobbeleer/oh-my-posh/src/font"
+	"github.com/jandedobbeleer/oh-my-posh/src/log"
 	"github.com/jandedobbeleer/oh-my-posh/src/runtime"
 	"github.com/jandedobbeleer/oh-my-posh/src/terminal"
 
@@ -52,7 +53,10 @@ This command is used to install fonts and configure the font in your terminal.
 					zipFolder += "/"
 				}
 
-				font.Run(fontName, env.Cache(), env.Root(), zipFolder)
+				if err := font.Run(fontName, env.Cache(), env.Root(), zipFolder); err != nil {
+					log.Error(err)
+					exitcode = 70
+				}
 
 				return
 			case "configure":
diff --git a/src/cli/root.go b/src/cli/root.go
index 9b617682..00119e35 100644
--- a/src/cli/root.go
+++ b/src/cli/root.go
@@ -17,6 +17,7 @@ var (
 	shellName    string
 	printVersion bool
 	trace        bool
+	exitcode     int
 
 	// for internal use only
 	silent bool
@@ -56,6 +57,12 @@ on getting started, have a look at the docs at https://ohmyposh.dev`,
 		log.Plain()
 	},
 	PersistentPostRun: func(cmd *cobra.Command, args []string) {
+		defer func() {
+			if exitcode != 0 {
+				os.Exit(exitcode)
+			}
+		}()
+
 		if !trace {
 			return
 		}
diff --git a/src/cli/upgrade.go b/src/cli/upgrade.go
index ede7f5a3..73c28c96 100644
--- a/src/cli/upgrade.go
+++ b/src/cli/upgrade.go
@@ -17,7 +17,9 @@ import (
 	"github.com/spf13/cobra"
 )
 
-var force bool
+var (
+	force bool
+)
 
 // noticeCmd represents the get command
 var upgradeCmd = &cobra.Command{
@@ -48,9 +50,24 @@ var upgradeCmd = &cobra.Command{
 		sh := os.Getenv("POSH_SHELL")
 
 		env := &runtime.Terminal{}
-		env.Init(&runtime.Flags{Debug: debug})
+		env.Init(&runtime.Flags{
+			Debug:     debug,
+			SaveCache: true,
+		})
+
+		terminal.Init(sh)
+		fmt.Print(terminal.StartProgress())
+
+		configFile := config.Path(configFlag)
+		cfg := config.Load(configFile, sh, false)
+		cfg.Upgrade.Cache = env.Cache()
 
 		defer func() {
+			fmt.Print(terminal.StopProgress())
+
+			// always reset the cache key so we respect the interval no matter what the outcome
+			env.Cache().Set(upgrade.CACHEKEY, "", cfg.Upgrade.Interval)
+
 			env.Close()
 
 			if !debug {
@@ -67,22 +84,13 @@ var upgradeCmd = &cobra.Command{
 			fmt.Println(builder.String())
 		}()
 
-		terminal.Init(sh)
-		fmt.Print(terminal.StartProgress())
-
-		configFile := config.Path(configFlag)
-		cfg := config.Load(configFile, sh, false)
-		cfg.Upgrade.Cache = env.Cache()
-
-		// always reset the cache key so we respect the interval no matter what the outcome
-		defer env.Cache().Set(upgrade.CACHEKEY, "", cfg.Upgrade.Interval)
-
 		latest, err := cfg.Upgrade.Latest()
 		if err != nil {
 			log.Debug("failed to get latest version")
 			log.Error(err)
-			fmt.Printf("\n❌ %s\n\n%s", err, terminal.StopProgress())
-			os.Exit(1)
+			fmt.Printf("\n❌ %s\n\n", err)
+
+			exitcode = 1
 			return
 		}
 
@@ -90,37 +98,34 @@ var upgradeCmd = &cobra.Command{
 
 		if force {
 			log.Debug("forced upgrade")
-			executeUpgrade(cfg.Upgrade)
+			exitcode = executeUpgrade(cfg.Upgrade)
 			return
 		}
 
 		if upgrade.IsMajorUpgrade(build.Version, latest) {
 			log.Debug("major upgrade available")
-			message := terminal.StopProgress()
-			message += fmt.Sprintf("\n🚨 major upgrade available: v%s -> v%s, use oh-my-posh upgrade --force to upgrade\n\n", build.Version, latest)
+			message := fmt.Sprintf("\n🚨 major upgrade available: v%s -> v%s, use oh-my-posh upgrade --force to upgrade\n\n", build.Version, latest)
 			fmt.Print(message)
 			return
 		}
 
 		if build.Version != latest {
-			executeUpgrade(cfg.Upgrade)
+			exitcode = executeUpgrade(cfg.Upgrade)
 			return
 		}
-
-		fmt.Print(terminal.StopProgress())
 	},
 }
 
-func executeUpgrade(cfg *upgrade.Config) {
+func executeUpgrade(cfg *upgrade.Config) int {
 	err := upgrade.Run(cfg)
-	fmt.Print(terminal.StopProgress())
 	if err == nil {
-		return
+		return 0
 	}
 
 	log.Debug("failed to upgrade")
 	log.Error(err)
-	os.Exit(1)
+
+	return 1
 }
 
 func init() {
diff --git a/src/config/backup.go b/src/config/backup.go
index a7a4e2af..894adf7d 100644
--- a/src/config/backup.go
+++ b/src/config/backup.go
@@ -81,7 +81,6 @@ func (cfg *Config) Write(format string) {
 	content := cfg.Export(format)
 	if len(content) == 0 {
 		// we are unable to perform the export
-		os.Exit(65)
 		return
 	}
 
diff --git a/src/font/cli.go b/src/font/cli.go
index 24c9bde9..d62bc42b 100644
--- a/src/font/cli.go
+++ b/src/font/cli.go
@@ -354,7 +354,7 @@ func SetCache(c cache_.Cache) {
 	cache = c
 }
 
-func Run(font string, ch cache_.Cache, root bool, zipFolder string) {
+func Run(font string, ch cache_.Cache, root bool, zipFolder string) error {
 	main := &main{
 		system: root,
 		Asset: Asset{
@@ -367,8 +367,6 @@ func Run(font string, ch cache_.Cache, root bool, zipFolder string) {
 	SetCache(ch)
 
 	program = tea.NewProgram(main)
-	if _, err := program.Run(); err != nil {
-		print("Error running program: %v", err)
-		os.Exit(70)
-	}
+	_, err := program.Run()
+	return err
 }
diff --git a/src/segments/spotify_linux.go b/src/segments/spotify_linux.go
new file mode 100644
index 00000000..c778aa2b
--- /dev/null
+++ b/src/segments/spotify_linux.go
@@ -0,0 +1,86 @@
+//go:build linux && !darwin && !windows
+
+package segments
+
+import (
+	"strings"
+
+	"github.com/jandedobbeleer/oh-my-posh/src/shell"
+)
+
+func (s *Spotify) Enabled() bool {
+	// Check if we're in WSL and handle that separately
+	if s.env.IsWsl() {
+		return s.enabledWsl()
+	}
+
+	// Standard Linux implementation
+	running := s.runLinuxScriptCommand(" string:PlaybackStatus | awk -F '\"' '/string/ {print tolower($2)}'")
+
+	if strings.HasPrefix(running, "Error") {
+		return false
+	}
+
+	if strings.Contains(running, "Error.ServiceUnknown") || strings.HasSuffix(running, "-") {
+		s.Status = stopped
+		return false
+	}
+
+	if running == stopped {
+		s.Status = stopped
+		return false
+	}
+
+	s.Status = running
+	s.Artist = s.runLinuxScriptCommand(" string:Metadata | awk -F '\"' 'BEGIN {RS=\"entry\"}; /'xesam:artist'/ {a=$4} END {print a}'")
+	s.Track = s.runLinuxScriptCommand(" string:Metadata | awk -F '\"' 'BEGIN {RS=\"entry\"}; /'xesam:title'/ {t=$4} END {print t}'")
+	s.resolveIcon()
+
+	return true
+}
+
+func (s *Spotify) runLinuxScriptCommand(command string) string {
+	dbusCMD := "dbus-send --print-reply --dest=org.mpris.MediaPlayer2.spotify /org/mpris/MediaPlayer2 org.freedesktop.DBus.Properties.Get string:org.mpris.MediaPlayer2.Player"
+	val := s.env.RunShellCommand(shell.BASH, dbusCMD+command)
+	return val
+}
+
+// Implementation for WSL - moved from spotify_wsl.go
+func (s *Spotify) enabledWsl() bool {
+	tlist, err := s.env.RunCommand("tasklist.exe", "/V", "/FI", "Imagename eq Spotify.exe", "/FO", "CSV", "/NH")
+	if err != nil || strings.HasPrefix(tlist, "INFO") {
+		return false
+	}
+
+	records := strings.Split(tlist, "\n")
+	if len(records) == 0 {
+		return false
+	}
+
+	for _, record := range records {
+		record = strings.TrimSpace(record)
+		fields := strings.Split(record, ",")
+		if len(fields) == 0 {
+			continue
+		}
+
+		// last elemant is the title
+		title := fields[len(fields)-1]
+		// trim leading and trailing quotes from the field
+		title = strings.TrimPrefix(title, `"`)
+		title = strings.TrimSuffix(title, `"`)
+		if !strings.Contains(title, " - ") {
+			continue
+		}
+
+		infos := strings.Split(title, " - ")
+		s.Artist = infos[0]
+		s.Track = strings.Join(infos[1:], " - ")
+		s.Status = playing
+		s.resolveIcon()
+
+		return true
+	}
+
+	return false
+}
diff --git a/src/segments/spotify_noop.go b/src/segments/spotify_noop.go
new file mode 100644
index 00000000..c15a950e
--- /dev/null
+++ b/src/segments/spotify_noop.go
@@ -0,0 +1,7 @@
+//go:build !linux && !darwin && !windows
+
+package segments
+
+func (s *Spotify) Enabled() bool {
+	return false
+}
diff --git a/src/segments/spotify_wsl.go b/src/segments/spotify_wsl.go
deleted file mode 100644
index ab754754..00000000
--- a/src/segments/spotify_wsl.go
+++ /dev/null
@@ -1,49 +0,0 @@
-//go:build !darwin && !windows
-
-package segments
-
-import (
-	"strings"
-)
-
-func (s *Spotify) Enabled() bool {
-	if !s.env.IsWsl() {
-		return false
-	}
-
-	tlist, err := s.env.RunCommand("tasklist.exe", "/V", "/FI", "Imagename eq Spotify.exe", "/FO", "CSV", "/NH")
-	if err != nil || strings.HasPrefix(tlist, "INFO") {
-		return false
-	}
-
-	records := strings.Split(tlist, "\n")
-	if len(records) == 0 {
-		return false
-	}
-
-	for _, record := range records {
-		record = strings.TrimSpace(record)
-		fields := strings.Split(record, ",")
-		if len(fields) == 0 {
-			continue
-		}
-
-		// last elemant is the title
-		title := fields[len(fields)-1]
-		// trim leading and trailing quotes from the field
-		title = strings.TrimPrefix(title, `"`)
-		title = strings.TrimSuffix(title, `"`)
-		if !strings.Contains(title, " - ") {
-			continue
-		}
-
-		infos := strings.Split(title, " - ")
-		s.Artist = infos[0]
-		s.Track = strings.Join(infos[1:], " - ")
-		s.Status = playing
-		s.resolveIcon()
-		return true
-	}
-
-	return false
-}
diff --git a/src/shell/scripts/omp.ps1 b/src/shell/scripts/omp.ps1
index 4274c772..93d697f6 100644
--- a/src/shell/scripts/omp.ps1
+++ b/src/shell/scripts/omp.ps1
@@ -227,8 +227,9 @@ New-Module -Name "oh-my-posh-core" -ScriptBlock {
         }
         catch { $null }
 
-        # check if the last command caused the last error
-        if ($null -ne $invocationInfo -and $lastHistory.CommandLine -eq $invocationInfo.Line) {
+        # Check if the error occurred in the current command scope
+        if ($null -ne $invocationInfo -and
+            $invocationInfo.HistoryId -eq $lastHistory.Id) {
             $script:ErrorCode = 1
             return
         }
diff --git a/website/docs/segments/music/spotify.mdx b/website/docs/segments/music/spotify.mdx
index a265672b..e3c3d5ea 100644
--- a/website/docs/segments/music/spotify.mdx
+++ b/website/docs/segments/music/spotify.mdx
@@ -6,12 +6,12 @@ sidebar_label: Spotify
 
 ## What
 
-Show the currently playing song in the [Spotify][spotify] macOS/Windows clients.
+Show the currently playing song in the [Spotify][spotify] client.
 
 :::caution
 Be aware this can make the prompt a tad bit slower as it needs to get a response from the Spotify player.
 
-On _macOS_, all states are supported (playing/paused/stopped).
+On _macOS & Linux_, all states are supported (playing/paused/stopped).
 
 On _Windows/WSL_, **only the playing state is supported** (no information when paused/stopped). It supports
 fetching information from the native Spotify application and Edge PWA.


Test patch:
diff --git a/src/segments/spotify_linux_test.go b/src/segments/spotify_linux_test.go
new file mode 100644
index 00000000..d8b59bcd
--- /dev/null
+++ b/src/segments/spotify_linux_test.go
@@ -0,0 +1,49 @@
+//go:build linux && !darwin && !windows
+
+package segments
+
+import (
+	"errors"
+	"testing"
+
+	"github.com/jandedobbeleer/oh-my-posh/src/properties"
+	"github.com/jandedobbeleer/oh-my-posh/src/runtime/mock"
+	"github.com/jandedobbeleer/oh-my-posh/src/shell"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestSpotifyLinux(t *testing.T) {
+	cases := []struct {
+		Error    error
+		Expected string
+		Status   string
+		Artist   string
+		Track    string
+		Running  bool
+	}{
+		{Running: false, Expected: ""},
+		{Running: false, Expected: "", Error: errors.New("oops")},
+		{Running: true, Expected: "\uF8E3 Candlemass - Spellbreaker", Status: "paused", Artist: "Candlemass", Track: "Spellbreaker"},
+		{Running: true, Expected: "\uE602 Candlemass - Spellbreaker", Status: "playing", Artist: "Candlemass", Track: "Spellbreaker"},
+	}
+	for _, tc := range cases {
+		env := new(mock.Environment)
+		env.On("IsWsl").Return(false)
+
+		dbusCMD := "dbus-send --print-reply --dest=org.mpris.MediaPlayer2.spotify /org/mpris/MediaPlayer2 org.freedesktop.DBus.Properties.Get string:org.mpris.MediaPlayer2.Player"
+		env.On("RunShellCommand", shell.BASH, dbusCMD+" string:PlaybackStatus | awk -F '\"' '/string/ {print tolower($2)}'").Return(tc.Status, nil)
+		env.On("RunShellCommand", shell.BASH, dbusCMD+" string:Metadata | awk -F '\"' 'BEGIN {RS=\"entry\"}; /'xesam:artist'/ {a=$4} END {print a}'").Return(tc.Artist, nil)
+		env.On("RunShellCommand", shell.BASH, dbusCMD+" string:Metadata | awk -F '\"' 'BEGIN {RS=\"entry\"}; /'xesam:title'/ {t=$4} END {print t}'").Return(tc.Track, nil)
+
+		s := &Spotify{}
+		s.Init(properties.Map{}, env)
+
+		enable := s.Enabled()
+		assert.True(t, enable)
+		if tc.Running {
+			assert.True(t, s.Enabled())
+			assert.Equal(t, tc.Expected, renderTemplate(env, s.Template(), s))
+		}
+	}
+}