diff --git a/cmd/deploy.go b/cmd/deploy.go index d82d7ea..dd57dae 100644 --- a/cmd/deploy.go +++ b/cmd/deploy.go @@ -147,10 +147,7 @@ func runDeployGithub(cmd *cobra.Command, args []string) error { if strings.TrimSpace(apiKey) == "" { return fmt.Errorf("KERNEL_API_KEY is required for github deploy") } - baseURL := os.Getenv("KERNEL_BASE_URL") - if strings.TrimSpace(baseURL) == "" { - baseURL = "https://api.onkernel.com" - } + baseURL := util.GetBaseURL() var body bytes.Buffer mw := multipart.NewWriter(&body) @@ -561,22 +558,22 @@ func followDeployment(ctx context.Context, client kernel.Client, deploymentID st if err == nil { fmt.Println(string(bs)) } - // Check for terminal states - if data.Event == "deployment_state" { - deploymentState := data.AsDeploymentState() - status := deploymentState.Deployment.Status - if status == string(kernel.DeploymentGetResponseStatusFailed) || - status == string(kernel.DeploymentGetResponseStatusStopped) { - return fmt.Errorf("deployment %s: %s", status, deploymentState.Deployment.StatusReason) + // Check for terminal states + if data.Event == "deployment_state" { + deploymentState := data.AsDeploymentState() + status := deploymentState.Deployment.Status + if status == string(kernel.DeploymentGetResponseStatusFailed) || + status == string(kernel.DeploymentGetResponseStatusStopped) { + return fmt.Errorf("deployment %s: %s", status, deploymentState.Deployment.StatusReason) + } + if status == string(kernel.DeploymentGetResponseStatusRunning) { + return nil + } } - if status == string(kernel.DeploymentGetResponseStatusRunning) { - return nil + if data.Event == "error" { + errorEv := data.AsErrorEvent() + return fmt.Errorf("%s: %s", errorEv.Error.Code, errorEv.Error.Message) } - } - if data.Event == "error" { - errorEv := data.AsErrorEvent() - return fmt.Errorf("%s: %s", errorEv.Error.Code, errorEv.Error.Message) - } continue } diff --git a/cmd/root.go b/cmd/root.go index 5542dcb..8179d8c 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -90,7 +90,7 @@ func isAuthExempt(cmd *cobra.Command) bool { // Check if the top-level command is in the exempt list switch topLevel.Name() { - case "login", "logout", "help", "completion", "create", "mcp", "upgrade": + case "login", "logout", "help", "completion", "create", "mcp", "upgrade", "status": return true case "auth": // Only exempt the auth command itself (status display), not its subcommands @@ -147,6 +147,7 @@ func init() { rootCmd.AddCommand(createCmd) rootCmd.AddCommand(mcp.MCPCmd) rootCmd.AddCommand(upgradeCmd) + rootCmd.AddCommand(statusCmd) rootCmd.PersistentPostRunE = func(cmd *cobra.Command, args []string) error { // running synchronously so we never slow the command diff --git a/cmd/status.go b/cmd/status.go new file mode 100644 index 0000000..4d1ca70 --- /dev/null +++ b/cmd/status.go @@ -0,0 +1,116 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "net/http" + "os" + "time" + + "github.com/kernel/cli/pkg/util" + "github.com/pterm/pterm" + "github.com/spf13/cobra" +) + +type statusComponent struct { + Name string `json:"name"` + Status string `json:"status"` +} + +type statusGroup struct { + Name string `json:"name"` + Status string `json:"status"` + Components []statusComponent `json:"components"` +} + +type statusResponse struct { + Status string `json:"status"` + Groups []statusGroup `json:"groups"` +} + +var statusCmd = &cobra.Command{ + Use: "status", + Short: "Check the operational status of Kernel services", + RunE: runStatus, +} + +func init() { + statusCmd.Flags().StringP("output", "o", "", "Output format (json)") +} + +func runStatus(cmd *cobra.Command, args []string) error { + output, _ := cmd.Flags().GetString("output") + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Get(util.GetBaseURL() + "/status") + if err != nil { + pterm.Error.Println("Could not reach Kernel API. Check https://status.kernel.sh for updates.") + return nil + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + pterm.Error.Println("Could not reach Kernel API. Check https://status.kernel.sh for updates.") + return nil + } + + var status statusResponse + if err := json.NewDecoder(resp.Body).Decode(&status); err != nil { + return fmt.Errorf("invalid response: %w", err) + } + + if output == "json" { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(status) + } + + printStatus(status) + return nil +} + +// Colors match the dashboard's api-status-indicator.tsx +var statusDisplay = map[string]struct { + label string + rgb pterm.RGB +}{ + "operational": {label: "Operational", rgb: pterm.NewRGB(31, 163, 130)}, + "degraded_performance": {label: "Degraded Performance", rgb: pterm.NewRGB(245, 158, 11)}, + "partial_outage": {label: "Partial Outage", rgb: pterm.NewRGB(242, 85, 51)}, + "full_outage": {label: "Major Outage", rgb: pterm.NewRGB(239, 68, 68)}, + "maintenance": {label: "Maintenance", rgb: pterm.NewRGB(36, 99, 235)}, + "unknown": {label: "Unknown", rgb: pterm.NewRGB(128, 128, 128)}, +} + +func getStatusDisplay(status string) (string, pterm.RGB) { + if d, ok := statusDisplay[status]; ok { + return d.label, d.rgb + } + return "Unknown", pterm.NewRGB(128, 128, 128) +} + +func coloredDot(rgb pterm.RGB) string { + return rgb.Sprint("●") +} + +func printStatus(resp statusResponse) { + label, rgb := getStatusDisplay(resp.Status) + header := fmt.Sprintf("Kernel Status: %s", rgb.Sprint(label)) + pterm.Println() + pterm.Println(" " + header) + + for _, group := range resp.Groups { + pterm.Println() + if len(group.Components) == 0 { + groupLabel, groupColor := getStatusDisplay(group.Status) + pterm.Printf(" %s %s %s\n", coloredDot(groupColor), pterm.Bold.Sprint(group.Name), groupLabel) + } else { + pterm.Println(" " + pterm.Bold.Sprint(group.Name)) + for _, comp := range group.Components { + compLabel, compColor := getStatusDisplay(comp.Status) + pterm.Printf(" %s %-20s %s\n", coloredDot(compColor), comp.Name, compLabel) + } + } + } + pterm.Println() +} diff --git a/pkg/util/client.go b/pkg/util/client.go index 20faf5f..c831cc6 100644 --- a/pkg/util/client.go +++ b/pkg/util/client.go @@ -7,6 +7,7 @@ import ( "io" "net/http" "os" + "strings" "sync/atomic" "github.com/kernel/cli/pkg/update" @@ -84,6 +85,15 @@ func showUpgradeMessage() { } } +// GetBaseURL returns the Kernel API base URL, falling back to production. +// KERNEL_BASE_URL is never set in .env; it exists solely for internal dev/staging overrides. +func GetBaseURL() string { + if u := os.Getenv("KERNEL_BASE_URL"); strings.TrimSpace(u) != "" { + return u + } + return "https://api.onkernel.com" +} + // IsNotFound returns true if the error is a Kernel API error with HTTP 404. func IsNotFound(err error) bool { if err == nil {