diff --git a/cmd/cluster/list.go b/cmd/cluster/list.go index 12f6f067..88eb7163 100644 --- a/cmd/cluster/list.go +++ b/cmd/cluster/list.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "os" "github.com/flamingo-stack/openframe-cli/internal/cluster/discovery" "github.com/flamingo-stack/openframe-cli/internal/cluster/models" @@ -203,12 +204,21 @@ func clustersToJSON(clusters []models.ClusterInfo) []clusterJSON { return out } +// writeStructuredOutput writes raw machine-readable output (JSON/YAML) +// directly to stdout. Structured output must remain unadorned for piping and +// is intentionally exempt from the pterm/ui presentation layer; this helper +// is the single, explicit funnel point for that exemption so future changes +// to the sink (e.g. respecting a --silent flag) only need to happen here. +func writeStructuredOutput(b []byte) { + fmt.Fprint(os.Stdout, string(b)) +} + func printClustersJSON(clusters []models.ClusterInfo) error { b, err := json.MarshalIndent(clustersToJSON(clusters), "", " ") if err != nil { return fmt.Errorf("encoding JSON: %w", err) } - fmt.Println(string(b)) + writeStructuredOutput(append(b, '\n')) return nil } @@ -219,6 +229,6 @@ func printClustersYAML(clusters []models.ClusterInfo) error { if err != nil { return fmt.Errorf("encoding YAML: %w", err) } - fmt.Print(string(b)) // yaml.Marshal already terminates with a newline + writeStructuredOutput(b) // yaml.Marshal already terminates with a newline return nil } diff --git a/internal/cluster/prerequisites/helm/helm.go b/internal/cluster/prerequisites/helm/helm.go index f670f460..4bccc205 100644 --- a/internal/cluster/prerequisites/helm/helm.go +++ b/internal/cluster/prerequisites/helm/helm.go @@ -8,6 +8,8 @@ import ( "runtime" "time" + "github.com/pterm/pterm" + "github.com/flamingo-stack/openframe-cli/internal/platform" "github.com/flamingo-stack/openframe-cli/internal/shared/download" "github.com/flamingo-stack/openframe-cli/internal/shared/wsllauncher" @@ -71,7 +73,7 @@ func (h *HelmInstaller) installMacOS() error { return fmt.Errorf("automatic helm installation on macOS requires Homebrew. Please install brew first: https://brew.sh") } - fmt.Println("Installing helm via Homebrew...") + pterm.Info.Println("Installing helm via Homebrew...") cmd := exec.Command("brew", "install", "helm") cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr @@ -99,12 +101,12 @@ func (h *HelmInstaller) installVerified() error { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) defer cancel() - fmt.Printf("Downloading verified helm %s...\n", download.Helm.Version) + pterm.Info.Printfln("Downloading verified helm %s...", download.Helm.Version) path, err := (download.Downloader{}).InstallPinnedTool(ctx, download.Helm, binDir) if err != nil { return fmt.Errorf("verified helm install failed: %w", err) } download.PrependToPath(binDir) - fmt.Printf("Installed verified helm %s to %s\n", download.Helm.Version, path) + pterm.Success.Printfln("Installed verified helm %s to %s", download.Helm.Version, path) return nil } diff --git a/internal/cluster/providers/k3d/manager.go b/internal/cluster/providers/k3d/manager.go index aae32f40..8b600fa1 100644 --- a/internal/cluster/providers/k3d/manager.go +++ b/internal/cluster/providers/k3d/manager.go @@ -12,6 +12,7 @@ import ( "github.com/flamingo-stack/openframe-cli/internal/cluster/models" "github.com/flamingo-stack/openframe-cli/internal/shared/executor" + "github.com/pterm/pterm" "k8s.io/client-go/rest" ) @@ -233,7 +234,7 @@ func (m *K3dManager) forceCleanupDockerContainers(ctx context.Context, clusterNa id = strings.TrimSpace(id) if id != "" { if _, rerr := m.executor.Execute(ctx, "docker", "rm", "-f", id); rerr != nil && m.verbose { - fmt.Printf("Warning: failed to remove container %s: %v\n", id, rerr) + pterm.Warning.Printf("failed to remove container %s: %v\n", id, rerr) } } } @@ -241,7 +242,7 @@ func (m *K3dManager) forceCleanupDockerContainers(ctx context.Context, clusterNa // Also remove the network if _, nerr := m.executor.Execute(ctx, "docker", "network", "rm", fmt.Sprintf("k3d-%s", clusterName)); nerr != nil && m.verbose { - fmt.Printf("Warning: failed to remove k3d network for %s: %v\n", clusterName, nerr) + pterm.Warning.Printf("failed to remove k3d network for %s: %v\n", clusterName, nerr) } return nil @@ -574,3 +575,4 @@ func (m *K3dManager) inotifyLimitsSufficient(ctx context.Context, wantWatches, w } return true } + diff --git a/internal/cluster/service.go b/internal/cluster/service.go index db826c28..7f7b5ff4 100644 --- a/internal/cluster/service.go +++ b/internal/cluster/service.go @@ -48,7 +48,13 @@ func isTerminalEnvironment() bool { // NewClusterService creates a new cluster service with default configuration func NewClusterService(exec executor.CommandExecutor) *ClusterService { - manager, _ := provider.New(models.ClusterTypeK3d, exec) // k3d never fails to construct + manager, err := provider.New(models.ClusterTypeK3d, exec) + if err != nil { + // k3d is expected to never fail to construct; if this invariant is + // ever violated, fail loudly here rather than leaving manager nil and + // panicking later inside every method that dereferences it. + panic(fmt.Sprintf("cluster: failed to construct k3d provider: %v", err)) + } return &ClusterService{ manager: manager, executor: exec, @@ -58,7 +64,13 @@ func NewClusterService(exec executor.CommandExecutor) *ClusterService { // NewClusterServiceSuppressed creates a cluster service with UI suppression func NewClusterServiceSuppressed(exec executor.CommandExecutor) *ClusterService { - manager, _ := provider.New(models.ClusterTypeK3d, exec) // k3d never fails to construct + manager, err := provider.New(models.ClusterTypeK3d, exec) + if err != nil { + // k3d is expected to never fail to construct; if this invariant is + // ever violated, fail loudly here rather than leaving manager nil and + // panicking later inside every method that dereferences it. + panic(fmt.Sprintf("cluster: failed to construct k3d provider: %v", err)) + } return &ClusterService{ manager: manager, executor: exec, @@ -241,9 +253,14 @@ func (s *ClusterService) cloudProviders() []provider.Provider { } // ListClusters merges the local k3d clusters with the cloud clusters recorded -// in the workspace registry. +// in the workspace registry. If any backend fails to list, the failure is +// warned to stderr and a wrapped error is returned alongside whatever +// clusters were successfully gathered, so machine consumers (e.g. `-o json`) +// can detect a degraded/partial result instead of silently receiving an +// incomplete list. func (s *ClusterService) ListClusters() ([]models.ClusterInfo, error) { ctx := context.Background() + var errs []error // k3d enumeration shells out to `k3d cluster list`, which needs a running // Docker daemon. Treat its failure as best-effort (like the cloud loop // below): a stopped Docker must not hide the cloud clusters. The warning @@ -252,17 +269,27 @@ func (s *ClusterService) ListClusters() ([]models.ClusterInfo, error) { if err != nil { pterm.Warning.WithWriter(os.Stderr).Printf("local (k3d) clusters could not be listed (is Docker running?): %v\n", err) clusters = nil + errs = append(errs, fmt.Errorf("local (k3d) clusters could not be listed: %w", err)) } for _, cloud := range s.cloudProviders() { cloudClusters, err := cloud.ListAllClusters(ctx) if err != nil { // A broken cloud registry (local file damage) must not hide the - // local clusters or the other provider's results. - pterm.Debug.Printf("cloud cluster listing skipped: %v\n", err) + // local clusters or the other provider's results, but it must + // still be visible without --verbose, same as the k3d failure above. + pterm.Warning.WithWriter(os.Stderr).Printf("cloud cluster listing skipped: %v\n", err) + errs = append(errs, fmt.Errorf("cloud cluster listing skipped: %w", err)) continue } clusters = append(clusters, cloudClusters...) } + if len(errs) > 0 { + combined := make([]string, len(errs)) + for i, e := range errs { + combined[i] = e.Error() + } + return clusters, fmt.Errorf("partial cluster listing: %s", strings.Join(combined, "; ")) + } return clusters, nil }