44package app
55
66import (
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
1922var listCmd = & cobra.Command {
@@ -41,17 +44,20 @@ Examples:
4144}
4245
4346var (
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
5054func 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\t PACKAGE\t STATUS\t URL\t PORT\t GROUP\t CREATED" ); err != nil {
226+ header := "NAME\t PACKAGE\t STATUS\t URL\t PORT\t GROUP\t CREATED"
227+ if upgrades != nil {
228+ header += "\t UPGRADE"
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