diff --git a/client/cmd/down.go b/client/cmd/down.go index 17c152d22..d8bf08d23 100644 --- a/client/cmd/down.go +++ b/client/cmd/down.go @@ -10,6 +10,7 @@ import ( "github.com/spf13/cobra" "github.com/netbirdio/netbird/client/proto" + nbstatus "github.com/netbirdio/netbird/client/status" ) var downCmd = &cobra.Command{ @@ -44,7 +45,29 @@ var downCmd = &cobra.Command{ return err } - cmd.Println("Disconnected") + out := &nbstatus.DownOutput{Status: "Disconnected"} + switch { + case jsonFlag: + s, err := out.JSON() + if err != nil { + return err + } + cmd.Println(s) + case yamlFlag: + s, err := out.YAML() + if err != nil { + return err + } + cmd.Print(s) + default: + cmd.Println(out.Status) + } return nil }, } + +func init() { + downCmd.PersistentFlags().BoolVarP(&jsonFlag, "json", "j", false, "display command result in json format") + downCmd.PersistentFlags().BoolVarP(&yamlFlag, "yaml", "y", false, "display command result in yaml format") + downCmd.MarkFlagsMutuallyExclusive("json", "yaml") +} diff --git a/client/cmd/login.go b/client/cmd/login.go index bd37e30f1..e7d75c27c 100644 --- a/client/cmd/login.go +++ b/client/cmd/login.go @@ -18,6 +18,7 @@ import ( "github.com/netbirdio/netbird/client/internal/auth" "github.com/netbirdio/netbird/client/internal/profilemanager" "github.com/netbirdio/netbird/client/proto" + nbstatus "github.com/netbirdio/netbird/client/status" "github.com/netbirdio/netbird/client/system" "github.com/netbirdio/netbird/util" ) @@ -337,6 +338,11 @@ func foregroundGetTokenInfo(ctx context.Context, cmd *cobra.Command, config *pro } func openURL(cmd *cobra.Command, verificationURIComplete, userCode string, noBrowser, showQR bool) { + if jsonFlag || yamlFlag { + emitSSOEvent(cmd, verificationURIComplete, userCode) + return + } + var codeMsg string if userCode != "" && !strings.Contains(verificationURIComplete, userCode) { codeMsg = fmt.Sprintf("and enter the code %s to authenticate.", userCode) @@ -366,6 +372,33 @@ func openURL(cmd *cobra.Command, verificationURIComplete, userCode string, noBro } } +// emitSSOEvent writes the verification URL/code as a structured event for +// callers using --json or --yaml. The browser is intentionally not opened in +// this mode since automation contexts (CI, scripts) typically run headless. +func emitSSOEvent(cmd *cobra.Command, verificationURIComplete, userCode string) { + event := &nbstatus.SSOEvent{ + Event: "sso_required", + VerificationURIComplete: verificationURIComplete, + UserCode: userCode, + } + if jsonFlag { + s, err := event.JSON() + if err != nil { + log.Errorf("marshal sso event: %v", err) + return + } + cmd.Println(s) + return + } + s, err := event.YAML() + if err != nil { + log.Errorf("marshal sso event: %v", err) + return + } + cmd.Print(s) + cmd.Println("---") +} + // isUnixRunningDesktop checks if a Linux OS is running desktop environment func isUnixRunningDesktop() bool { if runtime.GOOS != "linux" && runtime.GOOS != "freebsd" { diff --git a/client/cmd/up.go b/client/cmd/up.go index cabd0aacf..fbffd43b7 100644 --- a/client/cmd/up.go +++ b/client/cmd/up.go @@ -22,6 +22,7 @@ import ( "github.com/netbirdio/netbird/client/internal/peer" "github.com/netbirdio/netbird/client/internal/profilemanager" "github.com/netbirdio/netbird/client/proto" + nbstatus "github.com/netbirdio/netbird/client/status" "github.com/netbirdio/netbird/client/system" "github.com/netbirdio/netbird/shared/management/domain" "github.com/netbirdio/netbird/util" @@ -88,6 +89,9 @@ func init() { upCmd.PersistentFlags().StringVar(&profileName, profileNameFlag, "", profileNameDesc) upCmd.PersistentFlags().StringVarP(&configPath, "config", "c", "", "(DEPRECATED) NetBird config file location. ") + upCmd.PersistentFlags().BoolVarP(&jsonFlag, "json", "j", false, "display command result in json format") + upCmd.PersistentFlags().BoolVarP(&yamlFlag, "yaml", "y", false, "display command result in yaml format") + upCmd.MarkFlagsMutuallyExclusive("json", "yaml") } func upFunc(cmd *cobra.Command, args []string) error { @@ -96,6 +100,10 @@ func upFunc(cmd *cobra.Command, args []string) error { cmd.SetOut(cmd.OutOrStdout()) + if (jsonFlag || yamlFlag) && foregroundMode { + return fmt.Errorf("--json/--yaml is not supported with --foreground-mode; use daemon mode") + } + err := util.InitLog(logLevel, util.LogConsole) if err != nil { return fmt.Errorf("failed initializing log %v", err) @@ -245,8 +253,10 @@ func runInDaemonMode(ctx context.Context, cmd *cobra.Command, pm *profilemanager if status.Status == string(internal.StatusConnected) { if !profileSwitched { - cmd.Println("Already connected") - return nil + return emitUpOutput(cmd, &nbstatus.UpOutput{ + Status: "already_connected", + ProfileName: activeProf.Name, + }, "Already connected") } if _, err := client.Down(ctx, &proto.DownRequest{}); err != nil { @@ -273,7 +283,31 @@ func runInDaemonMode(ctx context.Context, cmd *cobra.Command, pm *profilemanager if err := doDaemonUp(ctx, cmd, client, pm, activeProf, customDNSAddressConverted, username.Username); err != nil { return fmt.Errorf("daemon up failed: %v", err) } - cmd.Println("Connected") + return emitUpOutput(cmd, &nbstatus.UpOutput{ + Status: "connected", + ProfileName: activeProf.Name, + }, "Connected") +} + +// emitUpOutput writes the result of an up command in the format requested by +// the user (json, yaml, or human-readable text fallback). +func emitUpOutput(cmd *cobra.Command, out *nbstatus.UpOutput, textFallback string) error { + switch { + case jsonFlag: + s, err := out.JSON() + if err != nil { + return err + } + cmd.Println(s) + case yamlFlag: + s, err := out.YAML() + if err != nil { + return err + } + cmd.Print(s) + default: + cmd.Println(textFallback) + } return nil } diff --git a/client/status/status.go b/client/status/status.go index 11ed06c2d..1b54c9bfa 100644 --- a/client/status/status.go +++ b/client/status/status.go @@ -384,6 +384,80 @@ func (o *OutputOverview) YAML() (string, error) { return string(yamlBytes), nil } +// DownOutput is the structured result of a `netbird down` command. +type DownOutput struct { + Status string `json:"status" yaml:"status"` +} + +// UpOutput is the final structured result of a `netbird up` command. +type UpOutput struct { + Status string `json:"status" yaml:"status"` // "connected" | "already_connected" + ProfileName string `json:"profileName,omitempty" yaml:"profileName,omitempty"` +} + +// JSON returns the UpOutput as a JSON string. +func (o *UpOutput) JSON() (string, error) { + jsonBytes, err := json.Marshal(o) + if err != nil { + return "", fmt.Errorf("json marshal failed") + } + return string(jsonBytes), err +} + +// YAML returns the UpOutput as a YAML string. +func (o *UpOutput) YAML() (string, error) { + yamlBytes, err := yaml.Marshal(o) + if err != nil { + return "", fmt.Errorf("yaml marshal failed") + } + return string(yamlBytes), nil +} + +// SSOEvent is emitted before the final UpOutput when interactive SSO is required. +// It carries the verification URL and user code so callers can surface them +// (e.g. to a Slack channel) without parsing free-form text. +type SSOEvent struct { + Event string `json:"event" yaml:"event"` // "sso_required" + VerificationURIComplete string `json:"verificationUriComplete" yaml:"verificationUriComplete"` + UserCode string `json:"userCode,omitempty" yaml:"userCode,omitempty"` +} + +// JSON returns the SSOEvent as a JSON string. +func (e *SSOEvent) JSON() (string, error) { + jsonBytes, err := json.Marshal(e) + if err != nil { + return "", fmt.Errorf("json marshal failed") + } + return string(jsonBytes), err +} + +// YAML returns the SSOEvent as a YAML string. +func (e *SSOEvent) YAML() (string, error) { + yamlBytes, err := yaml.Marshal(e) + if err != nil { + return "", fmt.Errorf("yaml marshal failed") + } + return string(yamlBytes), nil +} + +// JSON returns the DownOutput as a JSON string. +func (o *DownOutput) JSON() (string, error) { + jsonBytes, err := json.Marshal(o) + if err != nil { + return "", fmt.Errorf("json marshal failed") + } + return string(jsonBytes), err +} + +// YAML returns the DownOutput as a YAML string. +func (o *DownOutput) YAML() (string, error) { + yamlBytes, err := yaml.Marshal(o) + if err != nil { + return "", fmt.Errorf("yaml marshal failed") + } + return string(yamlBytes), nil +} + // GeneralSummary returns a general summary of the status overview. func (o *OutputOverview) GeneralSummary(showURL bool, showRelays bool, showNameServers bool, showSSHSessions bool) string { var managementConnString string