From 12e37e92a84d432f11c6c505cc87d9d82bce75c6 Mon Sep 17 00:00:00 2001 From: DireMunchkin <1665676+DireMunchkin@users.noreply.github.com> Date: Fri, 19 Jun 2026 17:22:10 +0200 Subject: [PATCH 1/4] feature: available-updates CLI op --- cmd/runtipi/main.go | 10 +++++ internal/commands/app.go | 95 ++++++++++++++++++++++++++++++++++++++++ internal/types/args.go | 23 +++++----- 3 files changed, 117 insertions(+), 11 deletions(-) diff --git a/cmd/runtipi/main.go b/cmd/runtipi/main.go index 73eb9e6..5746e03 100755 --- a/cmd/runtipi/main.go +++ b/cmd/runtipi/main.go @@ -290,6 +290,15 @@ func main() { }, } + appAvailableUpdatesCmd := &cobra.Command{ + Use: "available-updates", + Short: "List apps with available updates", + Run: func(cmd *cobra.Command, args []string) { + appArgs.Command = types.AppCommandAvailableUpdates + commands.RunApp(appArgs) + }, + } + appCmd.AddCommand(appStartCmd) appCmd.AddCommand(appStopCmd) appCmd.AddCommand(appUninstallCmd) @@ -301,6 +310,7 @@ func main() { appCmd.AddCommand(appDeleteBackupCmd) appCmd.AddCommand(appStartAllCmd) appCmd.AddCommand(appStopAllCmd) + appCmd.AddCommand(appAvailableUpdatesCmd) // AppStore command and subcommands appStoreCmd := &cobra.Command{ diff --git a/internal/commands/app.go b/internal/commands/app.go index d8bf527..ae3e1fc 100644 --- a/internal/commands/app.go +++ b/internal/commands/app.go @@ -177,6 +177,101 @@ func RunApp(args types.AppArgs) { errorMessage := fmt.Sprintf("Failed to update app %s. See logs/error.log for more details.", args.ID) handleAPIResponse(spin, resp, err, "App updated successfully!", errorMessage) + case types.AppCommandAvailableUpdates: + spin := components.NewSpinner("Checking for available updates...") + installedURL := utils.GetAPIBaseURL("apps/installed") + resp, err := utils.APIRequest(installedURL, "GET", "") + if err != nil { + spin.Fail("Failed to check for available updates.") + fmt.Printf("Error: %v\n", err) + spin.Finish() + return + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + body, _ := io.ReadAll(resp.Body) + fmt.Printf("Error code: %d\n", resp.StatusCode) + fmt.Printf("Response: %s\n", string(body)) + spin.Fail("Failed to check for available updates.") + spin.Finish() + return + } + + body, readErr := io.ReadAll(resp.Body) + if readErr != nil { + spin.Fail("Failed to read API response.") + fmt.Printf("Error: %v\n", readErr) + spin.Finish() + return + } + + var response map[string]any + if jsonErr := json.Unmarshal(body, &response); jsonErr != nil { + spin.Fail("Failed to parse API response.") + fmt.Printf("Error: %v\n", jsonErr) + spin.Finish() + return + } + + installedApps, ok := response["installed"].([]any) + if !ok { + spin.Fail("Unexpected response format: 'installed' field is missing or invalid.") + spin.Finish() + return + } + + type appUpdate struct { + URN string + CurrentVersion string + LatestVersion string + } + + var updates []appUpdate + for _, app := range installedApps { + appMap, ok := app.(map[string]any) + if !ok { + continue + } + + info, ok := appMap["info"].(map[string]any) + if !ok { + continue + } + + urn, _ := info["urn"].(string) + currentVersion, _ := info["version"].(string) + latestVersion, _ := info["latestVersion"].(string) + + if urn == "" || currentVersion == "" || latestVersion == "" { + continue + } + + if currentVersion != latestVersion { + updates = append(updates, appUpdate{ + URN: urn, + CurrentVersion: currentVersion, + LatestVersion: latestVersion, + }) + } + } + + if len(updates) == 0 { + spin.Succeed("All apps are up to date.") + spin.Finish() + return + } + + spin.Succeed(fmt.Sprintf("Found %d app(s) with available updates.", len(updates))) + spin.Finish() + + table := tablewriter.NewWriter(os.Stdout) + table.Header([]string{"App", "Current Version", "Latest Version"}) + for _, u := range updates { + table.Append([]string{u.URN, u.CurrentVersion, u.LatestVersion}) + } + table.Render() + case types.AppCommandStartAll: spin := components.NewSpinner("Starting all apps...") url := fmt.Sprintf("%s/start-all", lifecycleURL) diff --git a/internal/types/args.go b/internal/types/args.go index 62dec64..486563b 100644 --- a/internal/types/args.go +++ b/internal/types/args.go @@ -21,17 +21,18 @@ type AppArgs struct { type AppCommand string const ( - AppCommandStart AppCommand = "start" - AppCommandStop AppCommand = "stop" - AppCommandUninstall AppCommand = "uninstall" - AppCommandReset AppCommand = "reset" - AppCommandUpdate AppCommand = "update" - AppCommandBackup AppCommand = "backup" - AppCommandRestore AppCommand = "restore" - AppCommandListBackups AppCommand = "list-backups" - AppCommandDeleteBackup AppCommand = "delete-backup" - AppCommandStartAll AppCommand = "start-all" - AppCommandStopAll AppCommand = "stop-all" + AppCommandStart AppCommand = "start" + AppCommandStop AppCommand = "stop" + AppCommandUninstall AppCommand = "uninstall" + AppCommandReset AppCommand = "reset" + AppCommandUpdate AppCommand = "update" + AppCommandBackup AppCommand = "backup" + AppCommandRestore AppCommand = "restore" + AppCommandListBackups AppCommand = "list-backups" + AppCommandDeleteBackup AppCommand = "delete-backup" + AppCommandStartAll AppCommand = "start-all" + AppCommandStopAll AppCommand = "stop-all" + AppCommandAvailableUpdates AppCommand = "available-updates" ) type AppStoreArgs struct { From 7ef94dc747470aa4462011dd44ebd0f6d950a41b Mon Sep 17 00:00:00 2001 From: DireMunchkin <1665676+DireMunchkin@users.noreply.github.com> Date: Fri, 19 Jun 2026 18:20:05 +0200 Subject: [PATCH 2/4] Refactor available-updates --- internal/commands/app.go | 176 ++++++++++++++++++--------------------- 1 file changed, 83 insertions(+), 93 deletions(-) diff --git a/internal/commands/app.go b/internal/commands/app.go index ae3e1fc..c141655 100644 --- a/internal/commands/app.go +++ b/internal/commands/app.go @@ -20,6 +20,26 @@ type AppResponseBody struct { RequestID string `json:"requestId"` } +type InstalledAppsResponse struct { + Installed []InstalledApp `json:"installed"` +} + +type InstalledApp struct { + Info AppInfo `json:"info"` +} + +type AppInfo struct { + URN string `json:"urn"` + Version string `json:"version"` + LatestVersion string `json:"latestVersion"` +} + +type appUpdate struct { + URN string + CurrentVersion string + LatestVersion string +} + func handleAPIResponse(spin *components.Spinner, resp *http.Response, err error, successMessage, errorMessage string) { if err != nil { spin.Fail(errorMessage) @@ -89,6 +109,68 @@ func handleSimpleAPIResponse(spin *components.Spinner, resp *http.Response, err spin.Finish() } +func handleAvailableUpdates() { + spin := components.NewSpinner("Checking for available updates...") + installedURL := utils.GetAPIBaseURL("apps/installed") + resp, err := utils.APIRequest(installedURL, "GET", "") + + if err != nil { + spin.Fail("Failed to check for available updates.") + fmt.Printf("Error: %v\n", err) + spin.Finish() + return + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + body, _ := io.ReadAll(resp.Body) + fmt.Printf("Error code: %d\n", resp.StatusCode) + fmt.Printf("Response: %s\n", string(body)) + spin.Fail("Failed to check for available updates.") + spin.Finish() + return + } + + var response InstalledAppsResponse + if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { + spin.Fail("Failed to parse API response.") + fmt.Printf("Error: %v\n", err) + spin.Finish() + return + } + + var updates []appUpdate + for _, app := range response.Installed { + info := app.Info + if info.URN == "" || info.Version == "" || info.LatestVersion == "" { + continue + } + if info.Version != info.LatestVersion { + updates = append(updates, appUpdate{ + URN: info.URN, + CurrentVersion: info.Version, + LatestVersion: info.LatestVersion, + }) + } + } + + if len(updates) == 0 { + spin.Succeed("All apps are up to date.") + spin.Finish() + return + } + + spin.Succeed(fmt.Sprintf("Found %d app(s) with available updates.", len(updates))) + spin.Finish() + + table := tablewriter.NewWriter(os.Stdout) + table.Header([]string{"App", "Current Version", "Latest Version"}) + for _, u := range updates { + table.Append([]string{u.URN, u.CurrentVersion, u.LatestVersion}) + } + table.Render() +} + func RunApp(args types.AppArgs) { lifecycleURL := utils.GetAPIBaseURL("app-lifecycle") backupsURL := utils.GetAPIBaseURL("backups") @@ -178,99 +260,7 @@ func RunApp(args types.AppArgs) { handleAPIResponse(spin, resp, err, "App updated successfully!", errorMessage) case types.AppCommandAvailableUpdates: - spin := components.NewSpinner("Checking for available updates...") - installedURL := utils.GetAPIBaseURL("apps/installed") - resp, err := utils.APIRequest(installedURL, "GET", "") - if err != nil { - spin.Fail("Failed to check for available updates.") - fmt.Printf("Error: %v\n", err) - spin.Finish() - return - } - defer resp.Body.Close() - - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - body, _ := io.ReadAll(resp.Body) - fmt.Printf("Error code: %d\n", resp.StatusCode) - fmt.Printf("Response: %s\n", string(body)) - spin.Fail("Failed to check for available updates.") - spin.Finish() - return - } - - body, readErr := io.ReadAll(resp.Body) - if readErr != nil { - spin.Fail("Failed to read API response.") - fmt.Printf("Error: %v\n", readErr) - spin.Finish() - return - } - - var response map[string]any - if jsonErr := json.Unmarshal(body, &response); jsonErr != nil { - spin.Fail("Failed to parse API response.") - fmt.Printf("Error: %v\n", jsonErr) - spin.Finish() - return - } - - installedApps, ok := response["installed"].([]any) - if !ok { - spin.Fail("Unexpected response format: 'installed' field is missing or invalid.") - spin.Finish() - return - } - - type appUpdate struct { - URN string - CurrentVersion string - LatestVersion string - } - - var updates []appUpdate - for _, app := range installedApps { - appMap, ok := app.(map[string]any) - if !ok { - continue - } - - info, ok := appMap["info"].(map[string]any) - if !ok { - continue - } - - urn, _ := info["urn"].(string) - currentVersion, _ := info["version"].(string) - latestVersion, _ := info["latestVersion"].(string) - - if urn == "" || currentVersion == "" || latestVersion == "" { - continue - } - - if currentVersion != latestVersion { - updates = append(updates, appUpdate{ - URN: urn, - CurrentVersion: currentVersion, - LatestVersion: latestVersion, - }) - } - } - - if len(updates) == 0 { - spin.Succeed("All apps are up to date.") - spin.Finish() - return - } - - spin.Succeed(fmt.Sprintf("Found %d app(s) with available updates.", len(updates))) - spin.Finish() - - table := tablewriter.NewWriter(os.Stdout) - table.Header([]string{"App", "Current Version", "Latest Version"}) - for _, u := range updates { - table.Append([]string{u.URN, u.CurrentVersion, u.LatestVersion}) - } - table.Render() + handleAvailableUpdates() case types.AppCommandStartAll: spin := components.NewSpinner("Starting all apps...") From 8adcd190fc57e0a068c3923184b8cedf9a38f7ac Mon Sep 17 00:00:00 2001 From: DireMunchkin Date: Tue, 23 Jun 2026 17:55:12 +0200 Subject: [PATCH 3/4] fix the API format --- internal/commands/app.go | 43 +++++++++++++++++++++++++++++----------- 1 file changed, 31 insertions(+), 12 deletions(-) diff --git a/internal/commands/app.go b/internal/commands/app.go index c141655..02c535f 100644 --- a/internal/commands/app.go +++ b/internal/commands/app.go @@ -25,19 +25,31 @@ type InstalledAppsResponse struct { } type InstalledApp struct { - Info AppInfo `json:"info"` + Info AppInfo `json:"info"` + App AppDetails `json:"app"` + Metadata AppMetadata `json:"metadata"` } type AppInfo struct { - URN string `json:"urn"` - Version string `json:"version"` - LatestVersion string `json:"latestVersion"` + URN string `json:"urn"` + Version string `json:"version"` +} + +type AppDetails struct { + Version int `json:"version"` +} + +type AppMetadata struct { + LatestVersion int `json:"latestVersion"` + LatestDockerVersion string `json:"latestDockerVersion"` } type appUpdate struct { URN string CurrentVersion string LatestVersion string + TipiVersion int + LatestTipi int } func handleAPIResponse(spin *components.Spinner, resp *http.Response, err error, successMessage, errorMessage string) { @@ -141,15 +153,16 @@ func handleAvailableUpdates() { var updates []appUpdate for _, app := range response.Installed { - info := app.Info - if info.URN == "" || info.Version == "" || info.LatestVersion == "" { + if app.Info.URN == "" { continue } - if info.Version != info.LatestVersion { + if app.App.Version < app.Metadata.LatestVersion { updates = append(updates, appUpdate{ - URN: info.URN, - CurrentVersion: info.Version, - LatestVersion: info.LatestVersion, + URN: app.Info.URN, + CurrentVersion: app.Info.Version, + LatestVersion: app.Metadata.LatestDockerVersion, + TipiVersion: app.App.Version, + LatestTipi: app.Metadata.LatestVersion, }) } } @@ -164,9 +177,15 @@ func handleAvailableUpdates() { spin.Finish() table := tablewriter.NewWriter(os.Stdout) - table.Header([]string{"App", "Current Version", "Latest Version"}) + table.Header([]string{"App", "Docker Version", "Latest Docker", "Tipi Version", "Latest Tipi"}) for _, u := range updates { - table.Append([]string{u.URN, u.CurrentVersion, u.LatestVersion}) + table.Append([]string{ + u.URN, + u.CurrentVersion, + u.LatestVersion, + fmt.Sprintf("%d", u.TipiVersion), + fmt.Sprintf("%d", u.LatestTipi), + }) } table.Render() } From ff85fb062d50f4492e59c0cdd448d5d5f261448e Mon Sep 17 00:00:00 2001 From: DireMunchkin Date: Mon, 13 Jul 2026 18:24:19 +0200 Subject: [PATCH 4/4] Stricter error handling, added unit test --- .github/workflows/ci.yml | 3 ++ cmd/runtipi/main.go | 1 + internal/commands/app.go | 77 +++++++++++++++++++++++++++----------- internal/types/args.go | 24 ++++++------ tests/commands/app_test.go | 72 +++++++++++++++++++++++++++++++++++ 5 files changed, 144 insertions(+), 33 deletions(-) create mode 100644 tests/commands/app_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c7fd704..1150f15 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,3 +42,6 @@ jobs: - name: Build Go binary run: go build ./cmd/runtipi/main.go + + - name: Run Go tests + run: go test ./... diff --git a/cmd/runtipi/main.go b/cmd/runtipi/main.go index 5746e03..3903c29 100755 --- a/cmd/runtipi/main.go +++ b/cmd/runtipi/main.go @@ -293,6 +293,7 @@ func main() { appAvailableUpdatesCmd := &cobra.Command{ Use: "available-updates", Short: "List apps with available updates", + Args: cobra.NoArgs, Run: func(cmd *cobra.Command, args []string) { appArgs.Command = types.AppCommandAvailableUpdates commands.RunApp(appArgs) diff --git a/internal/commands/app.go b/internal/commands/app.go index 02c535f..f9e83a0 100644 --- a/internal/commands/app.go +++ b/internal/commands/app.go @@ -8,6 +8,7 @@ import ( "io" "net/http" "os" + "strings" "time" "github.com/olekukonko/tablewriter" @@ -21,7 +22,9 @@ type AppResponseBody struct { } type InstalledAppsResponse struct { - Installed []InstalledApp `json:"installed"` + // A pointer lets us distinguish an empty installed-app list from a + // missing or null field in the API response. + Installed *[]InstalledApp `json:"installed"` } type InstalledApp struct { @@ -31,20 +34,21 @@ type InstalledApp struct { } type AppInfo struct { - URN string `json:"urn"` - Version string `json:"version"` + URN *string `json:"urn"` + Version *string `json:"version"` } type AppDetails struct { - Version int `json:"version"` + Version *int `json:"version"` } type AppMetadata struct { - LatestVersion int `json:"latestVersion"` - LatestDockerVersion string `json:"latestDockerVersion"` + LatestVersion *int `json:"latestVersion"` + LatestDockerVersion *string `json:"latestDockerVersion"` } -type appUpdate struct { +// AppUpdate describes an app with a newer Tipi version available. +type AppUpdate struct { URN string CurrentVersion string LatestVersion string @@ -52,6 +56,45 @@ type appUpdate struct { LatestTipi int } +// FindAvailableUpdates validates the installed-app response and returns apps +// whose Tipi version is behind the latest available version. +func FindAvailableUpdates(response InstalledAppsResponse) ([]AppUpdate, error) { + if response.Installed == nil { + return nil, fmt.Errorf("unexpected response format: 'installed' field is missing or null") + } + + updates := make([]AppUpdate, 0) + for index, app := range *response.Installed { + if app.Info.URN == nil || strings.TrimSpace(*app.Info.URN) == "" { + return nil, fmt.Errorf("unexpected response format: installed[%d].info.urn is missing or empty", index) + } + if app.Info.Version == nil || strings.TrimSpace(*app.Info.Version) == "" { + return nil, fmt.Errorf("unexpected response format: installed[%d].info.version is missing or empty", index) + } + if app.App.Version == nil { + return nil, fmt.Errorf("unexpected response format: installed[%d].app.version is missing", index) + } + if app.Metadata.LatestVersion == nil { + return nil, fmt.Errorf("unexpected response format: installed[%d].metadata.latestVersion is missing", index) + } + if app.Metadata.LatestDockerVersion == nil || strings.TrimSpace(*app.Metadata.LatestDockerVersion) == "" { + return nil, fmt.Errorf("unexpected response format: installed[%d].metadata.latestDockerVersion is missing or empty", index) + } + + if *app.App.Version < *app.Metadata.LatestVersion { + updates = append(updates, AppUpdate{ + URN: *app.Info.URN, + CurrentVersion: *app.Info.Version, + LatestVersion: *app.Metadata.LatestDockerVersion, + TipiVersion: *app.App.Version, + LatestTipi: *app.Metadata.LatestVersion, + }) + } + } + + return updates, nil +} + func handleAPIResponse(spin *components.Spinner, resp *http.Response, err error, successMessage, errorMessage string) { if err != nil { spin.Fail(errorMessage) @@ -151,20 +194,12 @@ func handleAvailableUpdates() { return } - var updates []appUpdate - for _, app := range response.Installed { - if app.Info.URN == "" { - continue - } - if app.App.Version < app.Metadata.LatestVersion { - updates = append(updates, appUpdate{ - URN: app.Info.URN, - CurrentVersion: app.Info.Version, - LatestVersion: app.Metadata.LatestDockerVersion, - TipiVersion: app.App.Version, - LatestTipi: app.Metadata.LatestVersion, - }) - } + updates, err := FindAvailableUpdates(response) + if err != nil { + spin.Fail("Failed to validate API response.") + fmt.Printf("Error: %v\n", err) + spin.Finish() + return } if len(updates) == 0 { diff --git a/internal/types/args.go b/internal/types/args.go index 486563b..de49364 100644 --- a/internal/types/args.go +++ b/internal/types/args.go @@ -21,18 +21,18 @@ type AppArgs struct { type AppCommand string const ( - AppCommandStart AppCommand = "start" - AppCommandStop AppCommand = "stop" - AppCommandUninstall AppCommand = "uninstall" - AppCommandReset AppCommand = "reset" - AppCommandUpdate AppCommand = "update" - AppCommandBackup AppCommand = "backup" - AppCommandRestore AppCommand = "restore" - AppCommandListBackups AppCommand = "list-backups" - AppCommandDeleteBackup AppCommand = "delete-backup" - AppCommandStartAll AppCommand = "start-all" - AppCommandStopAll AppCommand = "stop-all" - AppCommandAvailableUpdates AppCommand = "available-updates" + AppCommandStart AppCommand = "start" + AppCommandStop AppCommand = "stop" + AppCommandUninstall AppCommand = "uninstall" + AppCommandReset AppCommand = "reset" + AppCommandUpdate AppCommand = "update" + AppCommandBackup AppCommand = "backup" + AppCommandRestore AppCommand = "restore" + AppCommandListBackups AppCommand = "list-backups" + AppCommandDeleteBackup AppCommand = "delete-backup" + AppCommandStartAll AppCommand = "start-all" + AppCommandStopAll AppCommand = "stop-all" + AppCommandAvailableUpdates AppCommand = "available-updates" ) type AppStoreArgs struct { diff --git a/tests/commands/app_test.go b/tests/commands/app_test.go new file mode 100644 index 0000000..e85b6a8 --- /dev/null +++ b/tests/commands/app_test.go @@ -0,0 +1,72 @@ +package commands_test + +import ( + "encoding/json" + "testing" + + "github.com/runtipi/cli/internal/commands" +) + +func TestFindAvailableUpdates(t *testing.T) { + tests := []struct { + name string + payload string + wantUpdates int + wantError bool + wantTipi int + wantLatestTipi int + }{ + { + name: "finds an update", + payload: `{"installed":[{"info":{"urn":"bitcoin","version":"1.0.0"},"app":{"version":1},"metadata":{"latestVersion":2,"latestDockerVersion":"2.0.0"}}]}`, + wantUpdates: 1, + wantTipi: 1, + wantLatestTipi: 2, + }, + { + name: "does not report an up to date app", + payload: `{"installed":[{"info":{"urn":"bitcoin","version":"2.0.0"},"app":{"version":2},"metadata":{"latestVersion":2,"latestDockerVersion":"2.0.0"}}]}`, + wantUpdates: 0, + }, + { + name: "rejects a missing installed field", + payload: `{}`, + wantError: true, + }, + { + name: "rejects a null installed field", + payload: `{"installed":null}`, + wantError: true, + }, + { + name: "rejects an incomplete app", + payload: `{"installed":[{"info":{"urn":"bitcoin"},"app":{"version":1},"metadata":{"latestVersion":2,"latestDockerVersion":"2.0.0"}}]}`, + wantError: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var response commands.InstalledAppsResponse + if err := json.Unmarshal([]byte(test.payload), &response); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + + updates, err := commands.FindAvailableUpdates(response) + if (err != nil) != test.wantError { + t.Fatalf("FindAvailableUpdates() error = %v, want error: %v", err, test.wantError) + } + if err != nil { + return + } + if len(updates) != test.wantUpdates { + t.Fatalf("FindAvailableUpdates() returned %d updates, want %d", len(updates), test.wantUpdates) + } + if len(updates) == 1 { + if updates[0].TipiVersion != test.wantTipi || updates[0].LatestTipi != test.wantLatestTipi { + t.Fatalf("FindAvailableUpdates() returned Tipi versions %d -> %d, want %d -> %d", updates[0].TipiVersion, updates[0].LatestTipi, test.wantTipi, test.wantLatestTipi) + } + } + }) + } +}