From 97caea112f7b22e9e184199717fc49d3164c6d4d Mon Sep 17 00:00:00 2001 From: Tobias Gesellchen Date: Mon, 10 Aug 2026 23:55:13 +0200 Subject: [PATCH] feat(cli): add on-demand update-check command to soundtouch-cli and soundtouch-backup Answers #591's open question 2: CLI-only users get no update notice from soundtouch-service's periodic background check. Both binaries gain a soundtouch-cli/soundtouch-backup update-check command that does a single, on-demand GitHub Releases check via the existing pkg/service/updatecheck package. Running the command is itself the opt-in, so unlike the service there's no config flag or persisted state. pkg/service/updatecheck.Checker was already designed decoupled from handlers.Server/main.go specifically so other binaries could import it directly; this is that follow-through. --- cmd/soundtouch-backup/cmd_updatecheck.go | 56 +++++++++++++++++++ cmd/soundtouch-backup/cmd_updatecheck_test.go | 42 ++++++++++++++ cmd/soundtouch-backup/main.go | 1 + cmd/soundtouch-cli/cmd_updatecheck.go | 56 +++++++++++++++++++ cmd/soundtouch-cli/cmd_updatecheck_test.go | 42 ++++++++++++++ cmd/soundtouch-cli/main.go | 3 + 6 files changed, 200 insertions(+) create mode 100644 cmd/soundtouch-backup/cmd_updatecheck.go create mode 100644 cmd/soundtouch-backup/cmd_updatecheck_test.go create mode 100644 cmd/soundtouch-cli/cmd_updatecheck.go create mode 100644 cmd/soundtouch-cli/cmd_updatecheck_test.go diff --git a/cmd/soundtouch-backup/cmd_updatecheck.go b/cmd/soundtouch-backup/cmd_updatecheck.go new file mode 100644 index 0000000..e640e49 --- /dev/null +++ b/cmd/soundtouch-backup/cmd_updatecheck.go @@ -0,0 +1,56 @@ +package main + +import ( + "fmt" + + "github.com/gesellix/bose-soundtouch/pkg/service/updatecheck" + "github.com/urfave/cli/v2" +) + +// updateCheckRepo is the GitHub repo checked for newer releases, matching +// soundtouch-service's periodic background check (#591, +// _/i591/design-update-check.md). +const updateCheckRepo = "gesellix/Bose-SoundTouch" + +// updateCheckCommand assembles the on-demand `soundtouch-backup +// update-check` command, the CLI-side answer to that design doc's open +// question 2 (CLI-only users get no update notice from the service's +// background checker). Unlike the service's opt-in periodic check, running +// this command *is* the opt-in: no config flag, no persisted state, just +// one GitHub API request each time it's invoked. +func updateCheckCommand() *cli.Command { + return &cli.Command{ + Name: "update-check", + Usage: "Check GitHub for a newer soundtouch-backup release", + Action: runUpdateCheck, + } +} + +func runUpdateCheck(c *cli.Context) error { + checker := updatecheck.NewChecker(nil, updateCheckRepo, version) + + result, err := checker.CheckNow(c.Context) + if err != nil { + return fmt.Errorf("update check failed: %w", err) + } + + printUpdateCheckResult(result) + + return nil +} + +func printUpdateCheckResult(result updatecheck.Result) { + if result.LatestVersion == "" { + fmt.Printf("Running %s, not a released version, skipping comparison.\n", result.CurrentVersion) + return + } + + if result.Available { + fmt.Printf("A newer version is available: %s (you're on %s)\n", result.LatestVersion, result.CurrentVersion) + fmt.Println(result.ReleaseURL) + + return + } + + fmt.Printf("You're on the latest version (%s).\n", result.CurrentVersion) +} diff --git a/cmd/soundtouch-backup/cmd_updatecheck_test.go b/cmd/soundtouch-backup/cmd_updatecheck_test.go new file mode 100644 index 0000000..df21705 --- /dev/null +++ b/cmd/soundtouch-backup/cmd_updatecheck_test.go @@ -0,0 +1,42 @@ +package main + +import ( + "testing" + + "github.com/gesellix/bose-soundtouch/pkg/service/updatecheck" +) + +// TestUpdateCheckCommand_Registered checks the command is wired up with the +// expected name and an Action, without making any real GitHub API calls. +func TestUpdateCheckCommand_Registered(t *testing.T) { + cmd := updateCheckCommand() + + if cmd.Name != "update-check" { + t.Errorf("command name = %q; want %q", cmd.Name, "update-check") + } + + if cmd.Action == nil { + t.Error("expected an Action to be set") + } +} + +// TestPrintUpdateCheckResult_DoesNotPanic exercises all three result shapes +// (unparseable current version, update available, up to date) purely for +// the "does not panic" guarantee; updatecheck.Checker's own tests already +// cover the comparison logic itself. +func TestPrintUpdateCheckResult_DoesNotPanic(t *testing.T) { + cases := []struct { + name string + result updatecheck.Result + }{ + {"unparseable current version", updatecheck.Result{CurrentVersion: "dev"}}, + {"update available", updatecheck.Result{CurrentVersion: "v1.0.0", LatestVersion: "v1.1.0", Available: true, ReleaseURL: "https://example.invalid"}}, + {"up to date", updatecheck.Result{CurrentVersion: "v1.1.0", LatestVersion: "v1.1.0", Available: false}}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + printUpdateCheckResult(tc.result) + }) + } +} diff --git a/cmd/soundtouch-backup/main.go b/cmd/soundtouch-backup/main.go index 6043b4c..f4d9f0a 100644 --- a/cmd/soundtouch-backup/main.go +++ b/cmd/soundtouch-backup/main.go @@ -33,6 +33,7 @@ func main() { allCommand(), cloudCommand(), localCommand(), + updateCheckCommand(), }, } if err := app.Run(os.Args); err != nil { diff --git a/cmd/soundtouch-cli/cmd_updatecheck.go b/cmd/soundtouch-cli/cmd_updatecheck.go new file mode 100644 index 0000000..cd1f200 --- /dev/null +++ b/cmd/soundtouch-cli/cmd_updatecheck.go @@ -0,0 +1,56 @@ +package main + +import ( + "fmt" + + "github.com/gesellix/bose-soundtouch/pkg/service/updatecheck" + "github.com/urfave/cli/v2" +) + +// updateCheckRepo is the GitHub repo checked for newer releases, matching +// soundtouch-service's periodic background check (#591, +// _/i591/design-update-check.md). +const updateCheckRepo = "gesellix/Bose-SoundTouch" + +// updateCheckCommand assembles the on-demand `soundtouch-cli update-check` +// command, the CLI-side answer to that design doc's open question 2 +// (CLI-only users get no update notice from the service's background +// checker). Unlike the service's opt-in periodic check, running this +// command *is* the opt-in: no config flag, no persisted state, just one +// GitHub API request each time it's invoked. +func updateCheckCommand() *cli.Command { + return &cli.Command{ + Name: "update-check", + Usage: "Check GitHub for a newer soundtouch-cli release", + Action: runUpdateCheck, + } +} + +func runUpdateCheck(c *cli.Context) error { + checker := updatecheck.NewChecker(nil, updateCheckRepo, version) + + result, err := checker.CheckNow(c.Context) + if err != nil { + return fmt.Errorf("update check failed: %w", err) + } + + printUpdateCheckResult(result) + + return nil +} + +func printUpdateCheckResult(result updatecheck.Result) { + if result.LatestVersion == "" { + fmt.Printf("Running %s, not a released version, skipping comparison.\n", result.CurrentVersion) + return + } + + if result.Available { + fmt.Printf("A newer version is available: %s (you're on %s)\n", result.LatestVersion, result.CurrentVersion) + fmt.Println(result.ReleaseURL) + + return + } + + fmt.Printf("You're on the latest version (%s).\n", result.CurrentVersion) +} diff --git a/cmd/soundtouch-cli/cmd_updatecheck_test.go b/cmd/soundtouch-cli/cmd_updatecheck_test.go new file mode 100644 index 0000000..df21705 --- /dev/null +++ b/cmd/soundtouch-cli/cmd_updatecheck_test.go @@ -0,0 +1,42 @@ +package main + +import ( + "testing" + + "github.com/gesellix/bose-soundtouch/pkg/service/updatecheck" +) + +// TestUpdateCheckCommand_Registered checks the command is wired up with the +// expected name and an Action, without making any real GitHub API calls. +func TestUpdateCheckCommand_Registered(t *testing.T) { + cmd := updateCheckCommand() + + if cmd.Name != "update-check" { + t.Errorf("command name = %q; want %q", cmd.Name, "update-check") + } + + if cmd.Action == nil { + t.Error("expected an Action to be set") + } +} + +// TestPrintUpdateCheckResult_DoesNotPanic exercises all three result shapes +// (unparseable current version, update available, up to date) purely for +// the "does not panic" guarantee; updatecheck.Checker's own tests already +// cover the comparison logic itself. +func TestPrintUpdateCheckResult_DoesNotPanic(t *testing.T) { + cases := []struct { + name string + result updatecheck.Result + }{ + {"unparseable current version", updatecheck.Result{CurrentVersion: "dev"}}, + {"update available", updatecheck.Result{CurrentVersion: "v1.0.0", LatestVersion: "v1.1.0", Available: true, ReleaseURL: "https://example.invalid"}}, + {"up to date", updatecheck.Result{CurrentVersion: "v1.1.0", LatestVersion: "v1.1.0", Available: false}}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + printUpdateCheckResult(tc.result) + }) + } +} diff --git a/cmd/soundtouch-cli/main.go b/cmd/soundtouch-cli/main.go index c77bb9f..62c3f65 100644 --- a/cmd/soundtouch-cli/main.go +++ b/cmd/soundtouch-cli/main.go @@ -2339,6 +2339,9 @@ func main() { // Defined in cmd_library.go. app.Commands = append(app.Commands, libraryCommand()) + // On-demand GitHub release check (#591). Defined in cmd_updatecheck.go. + app.Commands = append(app.Commands, updateCheckCommand()) + // Sort commands alphabetically (including subcommands and flags recursively) sortCommands(app.Commands)