Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cmd/thv/app/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ func NewRootCmd(enableUpdates bool) *cobra.Command {
rootCmd.AddCommand(skillCmd)
rootCmd.AddCommand(statusCmd)
rootCmd.AddCommand(tuiCmd)
rootCmd.AddCommand(upgradeCmd)

// Silence printing the usage on error
rootCmd.SilenceUsage = true
Expand Down
128 changes: 112 additions & 16 deletions cmd/thv/app/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
package app

import (
"context"
"encoding/json"
"fmt"
"log/slog"
Expand All @@ -13,7 +14,9 @@ import (
"github.com/spf13/cobra"

"github.com/stacklok/toolhive/pkg/core"
"github.com/stacklok/toolhive/pkg/runner"
"github.com/stacklok/toolhive/pkg/workloads"
"github.com/stacklok/toolhive/pkg/workloads/upgrade"
)

var listCmd = &cobra.Command{
Expand Down Expand Up @@ -41,24 +44,41 @@ Examples:
}

var (
listAll bool
listFormat string
listLabelFilter []string
listGroupFilter string
listAll bool
listFormat string
listLabelFilter []string
listGroupFilter string
listCheckUpgrades bool
)

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

listCmd.PreRunE = chainPreRunE(
validateGroupFlag(),
ValidateFormat(&listFormat, FormatJSON, FormatText, "mcpservers"),
validateCheckUpgradesFormat(),
)
}

// validateCheckUpgradesFormat rejects --check-upgrades with --format mcpservers.
// The mcpservers format emits client configuration and has no upgrade column, so
// the flag combination would perform a registry lookup per workload and then
// discard the result. Fail loudly rather than do hidden, wasted work.
func validateCheckUpgradesFormat() func(*cobra.Command, []string) error {
return func(_ *cobra.Command, _ []string) error {
if listCheckUpgrades && listFormat == "mcpservers" {
return fmt.Errorf("--check-upgrades is not supported with --format mcpservers; use --format text or json")
}
return nil
}
}

func listCmdFunc(cmd *cobra.Command, _ []string) error {
ctx := cmd.Context()

Expand All @@ -81,10 +101,20 @@ func listCmdFunc(cmd *cobra.Command, _ []string) error {
}
}

// Optionally compute upgrade status for each workload. This is the only path
// that performs a registry lookup; the default list stays offline-friendly.
var upgrades map[string]*upgrade.CheckResult
if listCheckUpgrades {
upgrades, err = checkUpgradesForWorkloads(ctx, workloadList)
Comment thread
JAORMX marked this conversation as resolved.
if err != nil {
return err
}
}

// Output based on format
switch listFormat {
case FormatJSON:
return printJSONOutput(workloadList)
return printJSONOutput(workloadList, upgrades)
case "mcpservers":
return printMCPServersOutput(workloadList)
default:
Expand All @@ -97,13 +127,49 @@ func listCmdFunc(cmd *cobra.Command, _ []string) error {
}
return nil
}
printTextOutput(workloadList)
printTextOutput(workloadList, upgrades)
return nil
}
}

// printJSONOutput prints workload information in JSON format
func printJSONOutput(workloadList []core.Workload) error {
// checkUpgradesForWorkloads builds a single Checker, loads each workload's saved
// RunConfig, and returns the upgrade result keyed by workload name. Workloads
// whose config cannot be loaded are omitted from the map. The comparison logic
// lives entirely in pkg/workloads/upgrade; this only collects inputs.
func checkUpgradesForWorkloads(ctx context.Context, workloadList []core.Workload) (map[string]*upgrade.CheckResult, error) {
checker, err := newUpgradeChecker()
if err != nil {
return nil, err
}

configs := make([]*runner.RunConfig, 0, len(workloadList))
for _, wl := range workloadList {
cfg, err := runner.LoadState(ctx, wl.Name)
if err != nil {
slog.Debug("skipping upgrade check for workload with unloadable config", "workload", wl.Name, "error", err)
continue
}
configs = append(configs, cfg)
}

results := checker.CheckAll(ctx, configs)
byName := make(map[string]*upgrade.CheckResult, len(results))
for _, r := range results {
byName[r.WorkloadName] = r
}
return byName, nil
}

// workloadWithUpgrade augments a workload with its optional upgrade-check
// result for JSON output when --check-upgrades is set.
type workloadWithUpgrade struct {
core.Workload
Upgrade *upgrade.CheckResult `json:"upgrade,omitempty"`
}

// printJSONOutput prints workload information in JSON format. When upgrades is
// non-nil, each workload is augmented with its upgrade-check result.
func printJSONOutput(workloadList []core.Workload, upgrades map[string]*upgrade.CheckResult) error {
// Ensure we have a non-nil slice to avoid null in JSON output
if workloadList == nil {
workloadList = []core.Workload{}
Expand All @@ -112,13 +178,26 @@ func printJSONOutput(workloadList []core.Workload) error {
// Sort workloads alphabetically by name for deterministic output
core.SortWorkloadsByName(workloadList)

// Marshal to JSON
jsonData, err := json.MarshalIndent(workloadList, "", " ")
// Without upgrade data, marshal the workloads directly to preserve the
// existing output shape.
if upgrades == nil {
jsonData, err := json.MarshalIndent(workloadList, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal JSON: %w", err)
}
fmt.Println(string(jsonData))
return nil
}

augmented := make([]workloadWithUpgrade, 0, len(workloadList))
for _, wl := range workloadList {
augmented = append(augmented, workloadWithUpgrade{Workload: wl, Upgrade: upgrades[wl.Name]})
}

jsonData, err := json.MarshalIndent(augmented, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal JSON: %w", err)
}

// Print JSON directly to stdout
fmt.Println(string(jsonData))
return nil
}
Expand Down Expand Up @@ -150,14 +229,19 @@ func printMCPServersOutput(workloadList []core.Workload) error {
return nil
}

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

// Create a tabwriter for pretty output
w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
if _, err := fmt.Fprintln(w, "NAME\tPACKAGE\tSTATUS\tURL\tPORT\tGROUP\tCREATED"); err != nil {
header := "NAME\tPACKAGE\tSTATUS\tURL\tPORT\tGROUP\tCREATED"
if upgrades != nil {
header += "\tUPGRADE"
}
if _, err := fmt.Fprintln(w, header); err != nil {
slog.Warn(fmt.Sprintf("Failed to write output header: %v", err))
return
}
Expand All @@ -168,7 +252,7 @@ func printTextOutput(workloadList []core.Workload) {
status := workloadStatusIndicator(c.Status)

// Print workload information
if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%d\t%s\t%s\n",
if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%d\t%s\t%s",
c.Name,
c.Package,
status,
Expand All @@ -179,6 +263,18 @@ func printTextOutput(workloadList []core.Workload) {
); err != nil {
slog.Debug(fmt.Sprintf("Failed to write workload information: %v", err))
}
if upgrades != nil {
upgradeStatus := "-"
if r, ok := upgrades[c.Name]; ok {
upgradeStatus = string(r.Status)
}
if _, err := fmt.Fprintf(w, "\t%s", upgradeStatus); err != nil {
slog.Debug(fmt.Sprintf("Failed to write upgrade status: %v", err))
}
}
if _, err := fmt.Fprintln(w); err != nil {
slog.Debug(fmt.Sprintf("Failed to write newline: %v", err))
}
}

// Flush the tabwriter
Expand Down
Loading
Loading