Skip to content

Commit 2af4157

Browse files
JAORMXclaude
andcommitted
Add thv upgrade check and list --check-upgrades
CLI users had no way to see whether their registry-sourced MCP servers have newer versions available. Surface the upgrade checker on the command line. Add a thv upgrade command group with a check [name] subcommand: with a name it prints a verbose report (candidate image, new env vars, and permission/transport/network posture drift); with no name it prints a table for all workloads. Add an opt-in --check-upgrades flag to thv list that appends an upgrade column. Both reuse the pkg/workloads/upgrade checker and only format results; the default list path is unchanged and performs no registry lookup, so it stays offline-friendly. Bulk output is sorted by name to match thv list. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 0224c57 commit 2af4157

8 files changed

Lines changed: 545 additions & 16 deletions

File tree

cmd/thv/app/commands.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ func NewRootCmd(enableUpdates bool) *cobra.Command {
7676
rootCmd.AddCommand(skillCmd)
7777
rootCmd.AddCommand(statusCmd)
7878
rootCmd.AddCommand(tuiCmd)
79+
rootCmd.AddCommand(upgradeCmd)
7980

8081
// Silence printing the usage on error
8182
rootCmd.SilenceUsage = true

cmd/thv/app/list.go

Lines changed: 98 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
package app
55

66
import (
7+
"context"
78
"encoding/json"
89
"fmt"
910
"log/slog"
@@ -13,7 +14,9 @@ import (
1314
"github.com/spf13/cobra"
1415

1516
"github.com/stacklok/toolhive/pkg/core"
17+
"github.com/stacklok/toolhive/pkg/runner"
1618
"github.com/stacklok/toolhive/pkg/workloads"
19+
"github.com/stacklok/toolhive/pkg/workloads/upgrade"
1720
)
1821

1922
var listCmd = &cobra.Command{
@@ -41,17 +44,20 @@ Examples:
4144
}
4245

4346
var (
44-
listAll bool
45-
listFormat string
46-
listLabelFilter []string
47-
listGroupFilter string
47+
listAll bool
48+
listFormat string
49+
listLabelFilter []string
50+
listGroupFilter string
51+
listCheckUpgrades bool
4852
)
4953

5054
func init() {
5155
AddAllFlag(listCmd, &listAll, true, "Show all workloads (default shows just running)")
5256
AddFormatFlag(listCmd, &listFormat, FormatJSON, FormatText, "mcpservers")
5357
listCmd.Flags().StringArrayVarP(&listLabelFilter, "label", "l", []string{}, "Filter workloads by labels (format: key=value)")
5458
AddGroupFlag(listCmd, &listGroupFilter, false)
59+
listCmd.Flags().BoolVar(&listCheckUpgrades, "check-upgrades", false,
60+
"Check each workload for available upgrades against its source registry (performs a registry lookup)")
5561

5662
listCmd.PreRunE = chainPreRunE(
5763
validateGroupFlag(),
@@ -81,10 +87,20 @@ func listCmdFunc(cmd *cobra.Command, _ []string) error {
8187
}
8288
}
8389

90+
// Optionally compute upgrade status for each workload. This is the only path
91+
// that performs a registry lookup; the default list stays offline-friendly.
92+
var upgrades map[string]*upgrade.CheckResult
93+
if listCheckUpgrades {
94+
upgrades, err = checkUpgradesForWorkloads(ctx, workloadList)
95+
if err != nil {
96+
return err
97+
}
98+
}
99+
84100
// Output based on format
85101
switch listFormat {
86102
case FormatJSON:
87-
return printJSONOutput(workloadList)
103+
return printJSONOutput(workloadList, upgrades)
88104
case "mcpservers":
89105
return printMCPServersOutput(workloadList)
90106
default:
@@ -97,13 +113,49 @@ func listCmdFunc(cmd *cobra.Command, _ []string) error {
97113
}
98114
return nil
99115
}
100-
printTextOutput(workloadList)
116+
printTextOutput(workloadList, upgrades)
101117
return nil
102118
}
103119
}
104120

105-
// printJSONOutput prints workload information in JSON format
106-
func printJSONOutput(workloadList []core.Workload) error {
121+
// checkUpgradesForWorkloads builds a single Checker, loads each workload's saved
122+
// RunConfig, and returns the upgrade result keyed by workload name. Workloads
123+
// whose config cannot be loaded are omitted from the map. The comparison logic
124+
// lives entirely in pkg/workloads/upgrade; this only collects inputs.
125+
func checkUpgradesForWorkloads(ctx context.Context, workloadList []core.Workload) (map[string]*upgrade.CheckResult, error) {
126+
checker, err := newUpgradeChecker()
127+
if err != nil {
128+
return nil, err
129+
}
130+
131+
configs := make([]*runner.RunConfig, 0, len(workloadList))
132+
for _, wl := range workloadList {
133+
cfg, err := runner.LoadState(ctx, wl.Name)
134+
if err != nil {
135+
slog.Debug("skipping upgrade check for workload with unloadable config", "workload", wl.Name, "error", err)
136+
continue
137+
}
138+
configs = append(configs, cfg)
139+
}
140+
141+
results := checker.CheckAll(ctx, configs)
142+
byName := make(map[string]*upgrade.CheckResult, len(results))
143+
for _, r := range results {
144+
byName[r.WorkloadName] = r
145+
}
146+
return byName, nil
147+
}
148+
149+
// workloadWithUpgrade augments a workload with its optional upgrade-check
150+
// result for JSON output when --check-upgrades is set.
151+
type workloadWithUpgrade struct {
152+
core.Workload
153+
Upgrade *upgrade.CheckResult `json:"upgrade,omitempty"`
154+
}
155+
156+
// printJSONOutput prints workload information in JSON format. When upgrades is
157+
// non-nil, each workload is augmented with its upgrade-check result.
158+
func printJSONOutput(workloadList []core.Workload, upgrades map[string]*upgrade.CheckResult) error {
107159
// Ensure we have a non-nil slice to avoid null in JSON output
108160
if workloadList == nil {
109161
workloadList = []core.Workload{}
@@ -112,13 +164,26 @@ func printJSONOutput(workloadList []core.Workload) error {
112164
// Sort workloads alphabetically by name for deterministic output
113165
core.SortWorkloadsByName(workloadList)
114166

115-
// Marshal to JSON
116-
jsonData, err := json.MarshalIndent(workloadList, "", " ")
167+
// Without upgrade data, marshal the workloads directly to preserve the
168+
// existing output shape.
169+
if upgrades == nil {
170+
jsonData, err := json.MarshalIndent(workloadList, "", " ")
171+
if err != nil {
172+
return fmt.Errorf("failed to marshal JSON: %w", err)
173+
}
174+
fmt.Println(string(jsonData))
175+
return nil
176+
}
177+
178+
augmented := make([]workloadWithUpgrade, 0, len(workloadList))
179+
for _, wl := range workloadList {
180+
augmented = append(augmented, workloadWithUpgrade{Workload: wl, Upgrade: upgrades[wl.Name]})
181+
}
182+
183+
jsonData, err := json.MarshalIndent(augmented, "", " ")
117184
if err != nil {
118185
return fmt.Errorf("failed to marshal JSON: %w", err)
119186
}
120-
121-
// Print JSON directly to stdout
122187
fmt.Println(string(jsonData))
123188
return nil
124189
}
@@ -150,14 +215,19 @@ func printMCPServersOutput(workloadList []core.Workload) error {
150215
return nil
151216
}
152217

153-
// printTextOutput prints workload information in text format
154-
func printTextOutput(workloadList []core.Workload) {
218+
// printTextOutput prints workload information in text format. When upgrades is
219+
// non-nil, an additional UPGRADE column reports each workload's upgrade status.
220+
func printTextOutput(workloadList []core.Workload, upgrades map[string]*upgrade.CheckResult) {
155221
// Sort workloads alphabetically by name for deterministic output
156222
core.SortWorkloadsByName(workloadList)
157223

158224
// Create a tabwriter for pretty output
159225
w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
160-
if _, err := fmt.Fprintln(w, "NAME\tPACKAGE\tSTATUS\tURL\tPORT\tGROUP\tCREATED"); err != nil {
226+
header := "NAME\tPACKAGE\tSTATUS\tURL\tPORT\tGROUP\tCREATED"
227+
if upgrades != nil {
228+
header += "\tUPGRADE"
229+
}
230+
if _, err := fmt.Fprintln(w, header); err != nil {
161231
slog.Warn(fmt.Sprintf("Failed to write output header: %v", err))
162232
return
163233
}
@@ -168,7 +238,7 @@ func printTextOutput(workloadList []core.Workload) {
168238
status := workloadStatusIndicator(c.Status)
169239

170240
// Print workload information
171-
if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%d\t%s\t%s\n",
241+
if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%d\t%s\t%s",
172242
c.Name,
173243
c.Package,
174244
status,
@@ -179,6 +249,18 @@ func printTextOutput(workloadList []core.Workload) {
179249
); err != nil {
180250
slog.Debug(fmt.Sprintf("Failed to write workload information: %v", err))
181251
}
252+
if upgrades != nil {
253+
upgradeStatus := "-"
254+
if r, ok := upgrades[c.Name]; ok {
255+
upgradeStatus = string(r.Status)
256+
}
257+
if _, err := fmt.Fprintf(w, "\t%s", upgradeStatus); err != nil {
258+
slog.Debug(fmt.Sprintf("Failed to write upgrade status: %v", err))
259+
}
260+
}
261+
if _, err := fmt.Fprintln(w); err != nil {
262+
slog.Debug(fmt.Sprintf("Failed to write newline: %v", err))
263+
}
182264
}
183265

184266
// Flush the tabwriter

0 commit comments

Comments
 (0)