From 89c8d027b45e2c04e7f9149cd283bab75df7a930 Mon Sep 17 00:00:00 2001 From: Varun Narravula Date: Thu, 18 Dec 2025 22:30:37 -0800 Subject: [PATCH 1/5] refactor: use functions to init cobra functions, flatten structures --- cmd/auth/auth.go | 20 +- cmd/auth/login/login.go | 268 ++++++----- cmd/auth/logout/logout.go | 178 +++---- cmd/auth/status/status.go | 104 +++-- cmd/down/client.go | 124 ++--- cmd/down/down.go | 25 +- cmd/logs/client.go | 84 ++-- cmd/logs/logs.go | 15 +- cmd/root.go | 20 +- cmd/select/account/account.go | 126 ++--- cmd/select/org/org.go | 150 +++--- cmd/select/select.go | 18 +- cmd/status/client.go | 73 +-- cmd/status/status.go | 33 +- cmd/up/client.go | 854 +++++++++++++++++----------------- cmd/up/up.go | 42 +- cmd/update/update.go | 48 +- cmd/version/version.go | 48 +- docs/pangolin_auth_login.md | 23 + docs/pangolin_auth_logout.md | 23 + 20 files changed, 1199 insertions(+), 1077 deletions(-) create mode 100644 docs/pangolin_auth_login.md create mode 100644 docs/pangolin_auth_logout.md diff --git a/cmd/auth/auth.go b/cmd/auth/auth.go index 39791c8..3cada6f 100644 --- a/cmd/auth/auth.go +++ b/cmd/auth/auth.go @@ -7,14 +7,16 @@ import ( "github.com/spf13/cobra" ) -var AuthCmd = &cobra.Command{ - Use: "auth", - Short: "Authentication commands", - Long: "Manage authentication and sessions", -} +func AuthCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "auth", + Short: "Authentication commands", + Long: "Manage authentication and sessions", + } + + cmd.AddCommand(login.LoginCmd()) + cmd.AddCommand(logout.LogoutCmd()) + cmd.AddCommand(status.StatusCmd()) -func init() { - AuthCmd.AddCommand(login.LoginCmd) - AuthCmd.AddCommand(logout.LogoutCmd) - AuthCmd.AddCommand(status.StatusCmd) + return cmd } diff --git a/cmd/auth/login/login.go b/cmd/auth/login/login.go index 4fe5a6c..e06cd1f 100644 --- a/cmd/auth/login/login.go +++ b/cmd/auth/login/login.go @@ -140,159 +140,179 @@ func loginWithWeb(hostname string) (string, error) { } } -var LoginCmd = &cobra.Command{ - Use: "login [hostname]", - Short: "Login to Pangolin", - Long: "Interactive login to select your hosting option and configure access.", - Args: cobra.MaximumNArgs(1), - Run: func(cmd *cobra.Command, args []string) { - apiClient := api.FromContext(cmd.Context()) - accountStore := config.AccountStoreFromContext(cmd.Context()) +type LoginCmdOpts struct { + Hostname string +} + +func LoginCmd() *cobra.Command { + opts := LoginCmdOpts{} + + cmd := &cobra.Command{ + Use: "login [hostname]", + Short: "Login to Pangolin", + Long: "Interactive login to select your hosting option and configure access.", + Args: func(cmd *cobra.Command, args []string) error { + if err := cobra.MaximumNArgs(1)(cmd, args); err != nil { + return err + } + + if len(args) > 0 { + opts.Hostname = args[0] + } + + return nil + }, + Run: func(cmd *cobra.Command, args []string) { + loginMain(cmd, &opts) + }, + } + + return cmd +} + +func loginMain(cmd *cobra.Command, opts *LoginCmdOpts) { + apiClient := api.FromContext(cmd.Context()) + accountStore := config.AccountStoreFromContext(cmd.Context()) + hostname := opts.Hostname + + // If hostname was provided, skip hosting option selection + if hostname == "" { var hostingOption HostingOption - var hostname string - // Check if hostname was provided as positional argument - if len(args) > 0 { - hostname = args[0] + // First question: select hosting option + form := huh.NewForm( + huh.NewGroup( + huh.NewSelect[HostingOption](). + Title("Select your hosting option"). + Options( + huh.NewOption("Pangolin Cloud (app.pangolin.net)", HostingOptionCloud), + huh.NewOption("Self-hosted or Dedicated instance", HostingOptionSelfHosted), + ). + Value(&hostingOption), + ), + ) + + if err := form.Run(); err != nil { + logger.Error("Error: %v", err) + return } - // If hostname was provided, skip hosting option selection - if hostname == "" { - // First question: select hosting option - form := huh.NewForm( + // If self-hosted, prompt for hostname + if hostingOption == HostingOptionSelfHosted { + hostnameForm := huh.NewForm( huh.NewGroup( - huh.NewSelect[HostingOption](). - Title("Select your hosting option"). - Options( - huh.NewOption("Pangolin Cloud (app.pangolin.net)", HostingOptionCloud), - huh.NewOption("Self-hosted or Dedicated instance", HostingOptionSelfHosted), - ). - Value(&hostingOption), + huh.NewInput(). + Title("Enter hostname URL"). + Placeholder("https://your-instance.example.com"). + Value(&hostname), ), ) - if err := form.Run(); err != nil { + if err := hostnameForm.Run(); err != nil { logger.Error("Error: %v", err) return } - - // If self-hosted, prompt for hostname - if hostingOption == HostingOptionSelfHosted { - hostnameForm := huh.NewForm( - huh.NewGroup( - huh.NewInput(). - Title("Enter hostname URL"). - Placeholder("https://your-instance.example.com"). - Value(&hostname), - ), - ) - - if err := hostnameForm.Run(); err != nil { - logger.Error("Error: %v", err) - return - } - } else { - // For cloud, set the default hostname - hostname = "app.pangolin.net" - } + } else { + // For cloud, set the default hostname + hostname = "app.pangolin.net" } + } - // Normalize hostname (preserve protocol, remove trailing slash) - hostname = strings.TrimSuffix(hostname, "/") + // Normalize hostname (preserve protocol, remove trailing slash) + hostname = strings.TrimSuffix(hostname, "/") - // If no protocol specified, default to https - if !strings.HasPrefix(hostname, "http://") && !strings.HasPrefix(hostname, "https://") { - hostname = "https://" + hostname - } + // If no protocol specified, default to https + if !strings.HasPrefix(hostname, "http://") && !strings.HasPrefix(hostname, "https://") { + hostname = "https://" + hostname + } - // Perform web login - sessionToken, err := loginWithWeb(hostname) - if err != nil { - logger.Error("%v", err) - return - } + // Perform web login + sessionToken, err := loginWithWeb(hostname) + if err != nil { + logger.Error("%v", err) + return + } - if sessionToken == "" { - logger.Error("Login appeared successful but no session token was received.") - return - } + if sessionToken == "" { + logger.Error("Login appeared successful but no session token was received.") + return + } - // Update the global API client (always initialized) - // Update base URL and token (hostname already includes protocol) - apiBaseURL := hostname + "/api/v1" - apiClient.SetBaseURL(apiBaseURL) - apiClient.SetToken(sessionToken) + // Update the global API client (always initialized) + // Update base URL and token (hostname already includes protocol) + apiBaseURL := hostname + "/api/v1" + apiClient.SetBaseURL(apiBaseURL) + apiClient.SetToken(sessionToken) - logger.Success("Device authorized") - fmt.Println() + logger.Success("Device authorized") + fmt.Println() - // Get user information - var user *api.User - user, err = apiClient.GetUser() - if err != nil { - logger.Error("Failed to get user information: %v", err) - return // FIXME: handle errors properly with exit codes! - } + // Get user information + var user *api.User + user, err = apiClient.GetUser() + if err != nil { + logger.Error("Failed to get user information: %v", err) + return // FIXME: handle errors properly with exit codes! + } - if _, exists := accountStore.Accounts[user.UserID]; exists { - logger.Warning("Already logged in as this user; no action needed") - return - } + if _, exists := accountStore.Accounts[user.UserID]; exists { + logger.Warning("Already logged in as this user; no action needed") + return + } - // Ensure OLM credentials exist and are valid - userID := user.UserID + // Ensure OLM credentials exist and are valid + userID := user.UserID - orgID, err := utils.SelectOrgForm(apiClient, userID) - if err != nil { - logger.Error("Failed to select organization: %v", err) - return - } + orgID, err := utils.SelectOrgForm(apiClient, userID) + if err != nil { + logger.Error("Failed to select organization: %v", err) + return + } - newOlmCreds, err := apiClient.CreateOlm(userID, utils.GetDeviceName()) - if err != nil { - logger.Error("Failed to obtain olm credentials: %v", err) - return - } + newOlmCreds, err := apiClient.CreateOlm(userID, utils.GetDeviceName()) + if err != nil { + logger.Error("Failed to obtain olm credentials: %v", err) + return + } - newAccount := config.Account{ - UserID: userID, - Host: hostname, - Email: user.Email, - SessionToken: sessionToken, - OrgID: orgID, - OlmCredentials: &config.OlmCredentials{ - ID: newOlmCreds.OlmID, - Secret: newOlmCreds.Secret, - }, - } + newAccount := config.Account{ + UserID: userID, + Host: hostname, + Email: user.Email, + SessionToken: sessionToken, + OrgID: orgID, + OlmCredentials: &config.OlmCredentials{ + ID: newOlmCreds.OlmID, + Secret: newOlmCreds.Secret, + }, + } - accountStore.Accounts[user.UserID] = newAccount - accountStore.ActiveUserID = userID + accountStore.Accounts[user.UserID] = newAccount + accountStore.ActiveUserID = userID - err = accountStore.Save() - if err != nil { - logger.Error("Failed to save account store: %s", err) - logger.Warning("You may not be able to login properly until this is saved.") - return - } + err = accountStore.Save() + if err != nil { + logger.Error("Failed to save account store: %s", err) + logger.Warning("You may not be able to login properly until this is saved.") + return + } - // List and select organization - if user != nil { - if _, err := utils.SelectOrgForm(apiClient, user.UserID); err != nil { - logger.Warning("%v", err) - } + // List and select organization + if user != nil { + if _, err := utils.SelectOrgForm(apiClient, user.UserID); err != nil { + logger.Warning("%v", err) } + } - // Print logged in message after all setup is complete - if user != nil { - displayName := user.Email - if displayName == "" && user.Username != nil && *user.Username != "" { - displayName = *user.Username - } - if displayName != "" { - logger.Success("Logged in as %s", displayName) - } + // Print logged in message after all setup is complete + if user != nil { + displayName := user.Email + if displayName == "" && user.Username != nil && *user.Username != "" { + displayName = *user.Username + } + if displayName != "" { + logger.Success("Logged in as %s", displayName) } - }, + } } diff --git a/cmd/auth/logout/logout.go b/cmd/auth/logout/logout.go index 76b4af2..ebea583 100644 --- a/cmd/auth/logout/logout.go +++ b/cmd/auth/logout/logout.go @@ -11,105 +11,113 @@ import ( "github.com/spf13/cobra" ) -var LogoutCmd = &cobra.Command{ - Use: "logout", - Short: "Logout from Pangolin", - Long: "Logout and clear your session", - Run: func(cmd *cobra.Command, args []string) { - apiClient := api.FromContext(cmd.Context()) - - // Check if client is running before logout - olmClient := olm.NewClient("") - if olmClient.IsRunning() { - // Check that the client was started by this CLI by verifying the version - status, err := olmClient.GetStatus() - if err != nil { - logger.Warning("Failed to get client status: %v", err) - // Continue with logout even if we can't check version - } else if status.Agent == olm.AgentName { - // Only prompt and stop if client was started by this CLI - // Prompt user to confirm they want to disconnect the client - var confirm bool - confirmForm := huh.NewForm( - huh.NewGroup( - huh.NewConfirm(). - Title("A client is currently running. Logging out will disconnect it."). - Description("Do you want to continue?"). - Value(&confirm), - ), - ) - - if err := confirmForm.Run(); err != nil { - logger.Error("Error: %v", err) - return - } +func LogoutCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "logout", + Short: "Logout from Pangolin", + Long: "Logout and clear your session", + Run: func(cmd *cobra.Command, args []string) { + logoutMain(cmd) + }, + } - if !confirm { - logger.Info("Logout cancelled") - return - } + return cmd +} + +func logoutMain(cmd *cobra.Command) { + apiClient := api.FromContext(cmd.Context()) - // Kill the client without showing TUI - _, err := olmClient.Exit() - if err != nil { - logger.Warning("Failed to send exit signal to client: %v", err) - } else { - // Wait for client to stop (poll until socket is gone) - maxWait := 10 * time.Second - pollInterval := 200 * time.Millisecond - elapsed := time.Duration(0) - for olmClient.IsRunning() && elapsed < maxWait { - time.Sleep(pollInterval) - elapsed += pollInterval - } - if olmClient.IsRunning() { - logger.Warning("Client did not stop within timeout") - } + // Check if client is running before logout + olmClient := olm.NewClient("") + if olmClient.IsRunning() { + // Check that the client was started by this CLI by verifying the version + status, err := olmClient.GetStatus() + if err != nil { + logger.Warning("Failed to get client status: %v", err) + // Continue with logout even if we can't check version + } else if status.Agent == olm.AgentName { + // Only prompt and stop if client was started by this CLI + // Prompt user to confirm they want to disconnect the client + var confirm bool + confirmForm := huh.NewForm( + huh.NewGroup( + huh.NewConfirm(). + Title("A client is currently running. Logging out will disconnect it."). + Description("Do you want to continue?"). + Value(&confirm), + ), + ) + + if err := confirmForm.Run(); err != nil { + logger.Error("Error: %v", err) + return + } + + if !confirm { + logger.Info("Logout cancelled") + return + } + + // Kill the client without showing TUI + _, err := olmClient.Exit() + if err != nil { + logger.Warning("Failed to send exit signal to client: %v", err) + } else { + // Wait for client to stop (poll until socket is gone) + maxWait := 10 * time.Second + pollInterval := 200 * time.Millisecond + elapsed := time.Duration(0) + for olmClient.IsRunning() && elapsed < maxWait { + time.Sleep(pollInterval) + elapsed += pollInterval + } + if olmClient.IsRunning() { + logger.Warning("Client did not stop within timeout") } } - // If version doesn't match, skip client shutdown and continue with logout } + // If version doesn't match, skip client shutdown and continue with logout + } - // Check if there's an active session in the key store - accountStore, err := config.LoadAccountStore() - if err != nil { - logger.Error("Failed to load account store: %s", err) - return - } + // Check if there's an active session in the key store + accountStore, err := config.LoadAccountStore() + if err != nil { + logger.Error("Failed to load account store: %s", err) + return + } - if accountStore.ActiveUserID == "" { - logger.Success("Already logged out!") - return - } + if accountStore.ActiveUserID == "" { + logger.Success("Already logged out!") + return + } - // Try to logout from server (client is always initialized) - if err := apiClient.Logout(); err != nil { - // Ignore logout errors - we'll still clear local data - logger.Debug("Failed to logout from server: %v", err) - } + // Try to logout from server (client is always initialized) + if err := apiClient.Logout(); err != nil { + // Ignore logout errors - we'll still clear local data + logger.Debug("Failed to logout from server: %v", err) + } - deletedAccount := accountStore.Accounts[accountStore.ActiveUserID] - delete(accountStore.Accounts, accountStore.ActiveUserID) + deletedAccount := accountStore.Accounts[accountStore.ActiveUserID] + delete(accountStore.Accounts, accountStore.ActiveUserID) - // If there are still other accounts, then we need to set the active key for it. - if nextUserID, ok := anyKey(accountStore.Accounts); ok { - accountStore.ActiveUserID = nextUserID + // If there are still other accounts, then we need to set the active key for it. + if nextUserID, ok := anyKey(accountStore.Accounts); ok { + accountStore.ActiveUserID = nextUserID - // TODO: perform automatic select of account when required - } else { - accountStore.ActiveUserID = "" - } + // TODO: perform automatic select of account when required + } else { + accountStore.ActiveUserID = "" + } - // Automatically set next active user ID to the first account found. + // Automatically set next active user ID to the first account found. - if err := accountStore.Save(); err != nil { - logger.Error("Failed to save account store: %v", err) - return - } + if err := accountStore.Save(); err != nil { + logger.Error("Failed to save account store: %v", err) + return + } - // Print logout message with account name - logger.Success("Logged out of Pangolin account %s", deletedAccount.Email) - }, + // Print logout message with account name + logger.Success("Logged out of Pangolin account %s", deletedAccount.Email) } func anyKey[K comparable, V any](m map[K]V) (K, bool) { diff --git a/cmd/auth/status/status.go b/cmd/auth/status/status.go index f5daac4..3623623 100644 --- a/cmd/auth/status/status.go +++ b/cmd/auth/status/status.go @@ -9,52 +9,60 @@ import ( "github.com/spf13/cobra" ) -var StatusCmd = &cobra.Command{ - Use: "status", - Short: "Check authentication status", - Long: "Check if you are logged in and view your account information", - Run: func(cmd *cobra.Command, args []string) { - apiClient := api.FromContext(cmd.Context()) - accountStore := config.AccountStoreFromContext(cmd.Context()) - - account, err := accountStore.ActiveAccount() - if err != nil { - logger.Info("Status: %s", err) - logger.Info("Run 'pangolin login' to authenticate") - return - } - - // User info exists in config, try to get user from API - user, err := apiClient.GetUser() - if err != nil { - // Unable to get user - consider logged out (previously logged in but now not) - logger.Info("Status: Logged out: %v", err) - logger.Info("Your session has expired or is invalid") - logger.Info("Run 'pangolin login' to authenticate again") - return - } - - // Successfully got user - logged in - logger.Success("Status: Logged in") - // Show hostname if available - logger.Info("@ %s", account.Host) - fmt.Println() - - // Display user information - displayName := user.Email - if user.Username != nil && *user.Username != "" { - displayName = *user.Username - } else if user.Name != nil && *user.Name != "" { - displayName = *user.Name - } - if displayName != "" { - logger.Info("User: %s", displayName) - } - if user.UserID != "" { - logger.Info("User ID: %s", user.UserID) - } - - // Display organization information - logger.Info("Org ID: %s", account.OrgID) - }, +func StatusCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "status", + Short: "Check authentication status", + Long: "Check if you are logged in and view your account information", + Run: func(cmd *cobra.Command, args []string) { + statusMain(cmd) + }, + } + + return cmd +} + +func statusMain(cmd *cobra.Command) { + apiClient := api.FromContext(cmd.Context()) + accountStore := config.AccountStoreFromContext(cmd.Context()) + + account, err := accountStore.ActiveAccount() + if err != nil { + logger.Info("Status: %s", err) + logger.Info("Run 'pangolin login' to authenticate") + return + } + + // User info exists in config, try to get user from API + user, err := apiClient.GetUser() + if err != nil { + // Unable to get user - consider logged out (previously logged in but now not) + logger.Info("Status: Logged out: %v", err) + logger.Info("Your session has expired or is invalid") + logger.Info("Run 'pangolin login' to authenticate again") + return + } + + // Successfully got user - logged in + logger.Success("Status: Logged in") + // Show hostname if available + logger.Info("@ %s", account.Host) + fmt.Println() + + // Display user information + displayName := user.Email + if user.Username != nil && *user.Username != "" { + displayName = *user.Username + } else if user.Name != nil && *user.Name != "" { + displayName = *user.Name + } + if displayName != "" { + logger.Info("User: %s", displayName) + } + if user.UserID != "" { + logger.Info("User ID: %s", user.UserID) + } + + // Display organization information + logger.Info("Org ID: %s", account.OrgID) } diff --git a/cmd/down/client.go b/cmd/down/client.go index 4cc8b04..956e8ce 100644 --- a/cmd/down/client.go +++ b/cmd/down/client.go @@ -10,72 +10,74 @@ import ( "github.com/spf13/cobra" ) -var ClientCmd = &cobra.Command{ - Use: "client", - Short: "Stop the client connection", - Long: "Stop the currently running client connection", - Run: func(cmd *cobra.Command, args []string) { - cfg := config.ConfigFromContext(cmd.Context()) +func ClientDownCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "client", + Short: "Stop the client connection", + Long: "Stop the currently running client connection", + Run: clientDownMain, + } - // Get socket path from config or use default - client := olm.NewClient("") + return cmd +} - // Check if client is running - if !client.IsRunning() { - logger.Info("No client is currently running") - return - } +func clientDownMain(cmd *cobra.Command, args []string) { + cfg := config.ConfigFromContext(cmd.Context()) - // Check that the client was started by this CLI by verifying the version - status, err := client.GetStatus() - if err != nil { - logger.Error("Failed to get client status: %v", err) - os.Exit(1) - } - if status.Agent != olm.AgentName { - logger.Error("Client was not started by Pangolin CLI (version: %s)", status.Version) - logger.Info("Only clients started by this CLI can be stopped using this command") - os.Exit(1) - } + // Get socket path from config or use default + client := olm.NewClient("") - // Send exit signal - exitResp, err := client.Exit() - if err != nil { - logger.Error("Error: %v", err) - os.Exit(1) - } + // Check if client is running + if !client.IsRunning() { + logger.Info("No client is currently running") + return + } - // Show log preview until process stops - completed, err := tui.NewLogPreview(tui.LogPreviewConfig{ - LogFile: cfg.LogFile, - Header: "Shutting down client...", - ExitCondition: func(client *olm.Client, status *olm.StatusResponse) (bool, bool) { - // Exit when process is no longer running (socket doesn't exist) - if !client.IsRunning() { - return true, true - } - return false, false - }, - StatusFormatter: func(isRunning bool, status *olm.StatusResponse) string { - if !isRunning { - return "Stopped" - } - return "Stopping..." - }, - }) - if err != nil { - logger.Error("Error: %v", err) - os.Exit(1) - } + // Check that the client was started by this CLI by verifying the version + status, err := client.GetStatus() + if err != nil { + logger.Error("Failed to get client status: %v", err) + os.Exit(1) + } + if status.Agent != olm.AgentName { + logger.Error("Client was not started by Pangolin CLI (version: %s)", status.Version) + logger.Info("Only clients started by this CLI can be stopped using this command") + os.Exit(1) + } - if completed { - logger.Success("Client shutdown completed") - } else { - logger.Info("Client shutdown initiated: %s", exitResp.Status) - } - }, -} + // Send exit signal + exitResp, err := client.Exit() + if err != nil { + logger.Error("Error: %v", err) + os.Exit(1) + } + + // Show log preview until process stops + completed, err := tui.NewLogPreview(tui.LogPreviewConfig{ + LogFile: cfg.LogFile, + Header: "Shutting down client...", + ExitCondition: func(client *olm.Client, status *olm.StatusResponse) (bool, bool) { + // Exit when process is no longer running (socket doesn't exist) + if !client.IsRunning() { + return true, true + } + return false, false + }, + StatusFormatter: func(isRunning bool, status *olm.StatusResponse) string { + if !isRunning { + return "Stopped" + } + return "Stopping..." + }, + }) + if err != nil { + logger.Error("Error: %v", err) + os.Exit(1) + } -func init() { - DownCmd.AddCommand(ClientCmd) + if completed { + logger.Success("Client shutdown completed") + } else { + logger.Info("Client shutdown initiated: %s", exitResp.Status) + } } diff --git a/cmd/down/down.go b/cmd/down/down.go index 56c413c..20f2fe3 100644 --- a/cmd/down/down.go +++ b/cmd/down/down.go @@ -4,14 +4,19 @@ import ( "github.com/spf13/cobra" ) -var DownCmd = &cobra.Command{ - Use: "down", - Short: "Stop a client", - Long: "Stop a client connection", - Run: func(cmd *cobra.Command, args []string) { - // Default to client subcommand if no subcommand is provided - // This makes "pangolin down" equivalent to "pangolin down client" - ClientCmd.SetContext(cmd.Context()) - ClientCmd.Run(ClientCmd, args) - }, +func DownCmd() *cobra.Command { + // If no subcommand is specified, run the `client` + // subcommand by default. + cmd := ClientDownCmd() + + cmd.Use = "down" + cmd.Short = "Stop a connection" + cmd.Long = `Bring down a connection. + +If ran with no subcommand, 'client' is passed. +` + + cmd.AddCommand(ClientDownCmd()) + + return cmd } diff --git a/cmd/logs/client.go b/cmd/logs/client.go index c48a9f4..6f14f5e 100644 --- a/cmd/logs/client.go +++ b/cmd/logs/client.go @@ -14,48 +14,56 @@ import ( "github.com/spf13/cobra" ) -var ( - flagFollow bool - flagLines int -) +type ClientLogsCmdOpts struct { + Follow bool + Lines int +} -var clientLogsCmd = &cobra.Command{ - Use: "client", - Short: "View client logs", - Long: "View client logs. Use -f to follow log output.", - Run: func(cmd *cobra.Command, args []string) { - cfg := config.ConfigFromContext(cmd.Context()) - - if flagFollow { - // Follow the log file - if err := watchLogFile(cfg.LogFile, flagLines); err != nil { - logger.Error("Error: %v", err) - os.Exit(1) - } - } else { - // Just print the current log file contents - if flagLines > 0 { - // Show last N lines - if err := printLastLines(cfg.LogFile, flagLines); err != nil { - logger.Error("Error: %v", err) - os.Exit(1) - } - } else { - // Show all lines - if err := printLogFile(cfg.LogFile); err != nil { - logger.Error("Error: %v", err) - os.Exit(1) - } - } - } - }, +func ClientLogsCmd() *cobra.Command { + opts := ClientLogsCmdOpts{} + + cmd := &cobra.Command{ + Use: "client", + Short: "View client logs", + Long: "View client logs. Use -f to follow log output.", + Run: func(cmd *cobra.Command, args []string) { + clientLogsMain(cmd, &opts) + }, + } + + cmd.Flags().BoolVarP(&opts.Follow, "follow", "f", false, "Follow log output (like tail -f)") + cmd.Flags().IntVarP(&opts.Lines, "lines", "n", 0, "Number of lines to show (0 = all lines, only used with -f to show lines before following)") + + return cmd } -func init() { - clientLogsCmd.Flags().BoolVarP(&flagFollow, "follow", "f", false, "Follow log output (like tail -f)") - clientLogsCmd.Flags().IntVarP(&flagLines, "lines", "n", 0, "Number of lines to show (0 = all lines, only used with -f to show lines before following)") +func clientLogsMain(cmd *cobra.Command, opts *ClientLogsCmdOpts) { + cfg := config.ConfigFromContext(cmd.Context()) + + if opts.Follow { + // Follow the log file + if err := watchLogFile(cfg.LogFile, opts.Lines); err != nil { + logger.Error("Error: %v", err) + os.Exit(1) + } + + return + } - LogsCmd.AddCommand(clientLogsCmd) + // Just print the current log file contents + if opts.Lines > 0 { + // Show last N lines + if err := printLastLines(cfg.LogFile, opts.Lines); err != nil { + logger.Error("Error: %v", err) + os.Exit(1) + } + } else { + // Show all lines + if err := printLogFile(cfg.LogFile); err != nil { + logger.Error("Error: %v", err) + os.Exit(1) + } + } } // printLogFile prints the contents of the log file diff --git a/cmd/logs/logs.go b/cmd/logs/logs.go index 34e26fb..b4dc29f 100644 --- a/cmd/logs/logs.go +++ b/cmd/logs/logs.go @@ -4,9 +4,14 @@ import ( "github.com/spf13/cobra" ) -var LogsCmd = &cobra.Command{ - Use: "logs", - Short: "View client logs", - Long: "View and follow client logs", -} +func LogsCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "logs", + Short: "View client logs", + Long: "View and follow client logs", + } + + cmd.AddCommand(ClientLogsCmd()) + return cmd +} diff --git a/cmd/root.go b/cmd/root.go index b24d6a6..22f61c6 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -39,16 +39,16 @@ func RootCommand(initResources bool) (*cobra.Command, error) { PersistentPreRunE: mainCommandPreRun, } - cmd.AddCommand(auth.AuthCmd) - cmd.AddCommand(selectcmd.SelectCmd) - cmd.AddCommand(up.UpCmd) - cmd.AddCommand(down.DownCmd) - cmd.AddCommand(logs.LogsCmd) - cmd.AddCommand(status.StatusCmd) - cmd.AddCommand(update.UpdateCmd) - cmd.AddCommand(version.VersionCmd) - cmd.AddCommand(login.LoginCmd) - cmd.AddCommand(logout.LogoutCmd) + cmd.AddCommand(auth.AuthCommand()) + cmd.AddCommand(selectcmd.SelectCmd()) + cmd.AddCommand(up.UpCmd()) + cmd.AddCommand(down.DownCmd()) + cmd.AddCommand(logs.LogsCmd()) + cmd.AddCommand(status.StatusCmd()) + cmd.AddCommand(update.UpdateCmd()) + cmd.AddCommand(version.VersionCmd()) + cmd.AddCommand(login.LoginCmd()) + cmd.AddCommand(logout.LogoutCmd()) if !initResources { return cmd, nil diff --git a/cmd/select/account/account.go b/cmd/select/account/account.go index b2c32f7..3d57ad0 100644 --- a/cmd/select/account/account.go +++ b/cmd/select/account/account.go @@ -10,82 +10,95 @@ import ( "github.com/spf13/cobra" ) -var ( - accountToSelect string - hostToSelect string -) +type AccountCmdOpts struct { + Account string + Host string +} -var AccountCmd = &cobra.Command{ - Use: "account", - Short: "Select an account", - Long: "List your logged-in accounts and select active one", - Run: func(cmd *cobra.Command, args []string) { - accountStore := config.AccountStoreFromContext(cmd.Context()) +func AccountCmd() *cobra.Command { + opts := AccountCmdOpts{} - if len(accountStore.Accounts) == 0 { - logger.Warning("Not logged in.") - return - } + cmd := &cobra.Command{ + Use: "account", + Short: "Select an account", + Long: "List your logged-in accounts and select active one", + Run: func(cmd *cobra.Command, args []string) { + accountMain(cmd, &opts) + }, + } - var selectedAccount *config.Account + cmd.Flags().StringVarP(&opts.Account, "account", "a", "", "Account to select") + cmd.Flags().StringVar(&opts.Host, "host", "", "Pangolin host where account is located") - // If flag is provided, find an account that matches the - // terms verbatim. - if accountToSelect != "" { - for _, account := range accountStore.Accounts { - if hostToSelect != "" && hostToSelect != account.Host { - continue - } + return cmd +} - if accountToSelect == account.Email { - selectedAccount = &account - break - } - } +func accountMain(cmd *cobra.Command, opts *AccountCmdOpts) { + accountStore := config.AccountStoreFromContext(cmd.Context()) - if selectedAccount == nil { - logger.Error("No accounts found that match the search terms") - return - } - } else { - // No flag provided, use GUI selection if necessary - selected, err := selectAccountForm(accountStore.Accounts) - if err != nil { - logger.Error("Failed to select account: %v", err) - return + if len(accountStore.Accounts) == 0 { + logger.Warning("Not logged in.") + return + } + + var selectedAccount *config.Account + + // If flag is provided, find an account that matches the + // terms verbatim. + if opts.Account != "" { + for _, account := range accountStore.Accounts { + if opts.Host != "" && opts.Host != account.Host { + continue } - selectedAccount = selected + if opts.Account == account.Email { + selectedAccount = &account + break + } } - accountStore.ActiveUserID = selectedAccount.UserID - if err := accountStore.Save(); err != nil { - logger.Error("Failed to save account to store: %v", err) + if selectedAccount == nil { + logger.Error("No accounts found that match the search terms") + return + } + } else { + // No flag provided, use GUI selection if necessary + selected, err := selectAccountForm(accountStore.Accounts, opts.Host) + if err != nil { + logger.Error("Failed to select account: %v", err) return } - // Check if olmClient is running and if we need to shut it down - olmClient := olm.NewClient("") - if olmClient.IsRunning() { - logger.Info("Shutting down running client") - _, err := olmClient.Exit() - if err != nil { - logger.Warning("Failed to shut down OLM client: %s; you may need to do so manually.", err) - } + selectedAccount = selected + } + + accountStore.ActiveUserID = selectedAccount.UserID + if err := accountStore.Save(); err != nil { + logger.Error("Failed to save account to store: %v", err) + return + } + + // Check if olmClient is running and if we need to shut it down + olmClient := olm.NewClient("") + if olmClient.IsRunning() { + logger.Info("Shutting down running client") + _, err := olmClient.Exit() + if err != nil { + logger.Warning("Failed to shut down OLM client: %s; you may need to do so manually.", err) } + } - selectedAccountStr := fmt.Sprintf("%s @ %s", selectedAccount.Email, selectedAccount.Host) - logger.Success("Successfully selected account: %s", selectedAccountStr) - }, + selectedAccountStr := fmt.Sprintf("%s @ %s", selectedAccount.Email, selectedAccount.Host) + logger.Success("Successfully selected account: %s", selectedAccountStr) } // selectAccountForm lists organizations for a user and prompts them to select one. // It returns the selected org ID and any error. // If the user has only one organization, it's automatically selected. -func selectAccountForm(accounts map[string]config.Account) (*config.Account, error) { +func selectAccountForm(accounts map[string]config.Account, hostFilter string) (*config.Account, error) { var filteredAccounts []*config.Account for _, account := range accounts { - if hostToSelect == "" || hostToSelect == account.Host { + if hostFilter == "" || hostFilter == account.Host { filteredAccounts = append(filteredAccounts, &account) } } @@ -131,8 +144,3 @@ func selectAccountForm(accounts map[string]config.Account) (*config.Account, err return selectedAccountOption.Account, nil } - -func init() { - AccountCmd.Flags().StringVarP(&accountToSelect, "account", "a", "", "Account to select") - AccountCmd.Flags().StringVar(&hostToSelect, "host", "", "Pangolin host where account is located") -} diff --git a/cmd/select/org/org.go b/cmd/select/org/org.go index 2cc0d1b..ad3bc6e 100644 --- a/cmd/select/org/org.go +++ b/cmd/select/org/org.go @@ -12,87 +12,101 @@ import ( "github.com/spf13/cobra" ) -var flagOrgID string - -var OrgCmd = &cobra.Command{ - Use: "org", - Short: "Select an organization", - Long: "List your organizations and select one to use", - Run: func(cmd *cobra.Command, args []string) { - apiClient := api.FromContext(cmd.Context()) - accountStore := config.AccountStoreFromContext(cmd.Context()) - cfg := config.ConfigFromContext(cmd.Context()) - - activeAccount, err := accountStore.ActiveAccount() - if err != nil { - logger.Error("%v", err) - return +type OrgCmdOpts struct { + OrgID string +} - } - userID := activeAccount.UserID +func OrgCmd() *cobra.Command { + opts := OrgCmdOpts{} - var selectedOrgID string + cmd := &cobra.Command{ + Use: "org", + Short: "Select an organization", + Long: "List your organizations and select one to use", + Run: func(cmd *cobra.Command, args []string) { + orgMain(cmd, &opts) + }, + } - // Check if --org-id flag is provided - if flagOrgID != "" { - // Validate that the org exists - orgsResp, err := apiClient.ListUserOrgs(userID) - if err != nil { - logger.Error("Failed to list organizations: %v", err) - return - } + cmd.Flags().StringVar(&opts.OrgID, "org", "", "Organization `ID` to select") - // Check if the provided orgId exists in the user's organizations - orgExists := false - for _, org := range orgsResp.Orgs { - if org.OrgID == flagOrgID { - orgExists = true - break - } - } + return cmd +} - if !orgExists { - logger.Error("Organization '%s' not found or you don't have access to it", flagOrgID) - return - } +func orgMain(cmd *cobra.Command, opts *OrgCmdOpts) { + apiClient := api.FromContext(cmd.Context()) + accountStore := config.AccountStoreFromContext(cmd.Context()) + cfg := config.ConfigFromContext(cmd.Context()) - // Org exists, use it - selectedOrgID = flagOrgID - } else { - // No flag provided, use GUI selection - selectedOrgID, err = utils.SelectOrgForm(apiClient, userID) - if err != nil { - logger.Error("%v", err) - return + activeAccount, err := accountStore.ActiveAccount() + if err != nil { + logger.Error("%v", err) + return + + } + userID := activeAccount.UserID + + var selectedOrgID string + + // Check if --org-id flag is provided + if opts.OrgID != "" { + // Validate that the org exists + orgsResp, err := apiClient.ListUserOrgs(userID) + if err != nil { + logger.Error("Failed to list organizations: %v", err) + return + } + + // Check if the provided orgId exists in the user's organizations + orgExists := false + for _, org := range orgsResp.Orgs { + if org.OrgID == opts.OrgID { + orgExists = true + break } } - activeAccount.OrgID = selectedOrgID - if err := accountStore.Save(); err != nil { - logger.Error("Failed to save account to store: %v", err) + if !orgExists { + logger.Error("Organization '%s' not found or you don't have access to it", opts.OrgID) return } - // Switch active client if running - utils.SwitchActiveClientOrg(selectedOrgID) - - // Check if olmClient is running and if we need to monitor a switch - olmClient := olm.NewClient("") - if olmClient.IsRunning() { - // Get current status - if it doesn't match the new org, monitor the switch - currentStatus, err := olmClient.GetStatus() - if err == nil && currentStatus != nil && currentStatus.OrgID != selectedOrgID { - // Switch was sent, monitor the switch process - monitorOrgSwitch(cfg.LogFile, selectedOrgID) - } else { - // Already on the correct org or no status available - logger.Success("Successfully selected organization: %s", selectedOrgID) - } + // Org exists, use it + selectedOrgID = opts.OrgID + } else { + // No flag provided, use GUI selection + selectedOrgID, err = utils.SelectOrgForm(apiClient, userID) + if err != nil { + logger.Error("%v", err) + return + } + } + + activeAccount.OrgID = selectedOrgID + if err := accountStore.Save(); err != nil { + logger.Error("Failed to save account to store: %v", err) + return + } + + // Switch active client if running + utils.SwitchActiveClientOrg(selectedOrgID) + + // Check if olmClient is running and if we need to monitor a switch + olmClient := olm.NewClient("") + if olmClient.IsRunning() { + // Get current status - if it doesn't match the new org, monitor the switch + currentStatus, err := olmClient.GetStatus() + if err == nil && currentStatus != nil && currentStatus.OrgID != selectedOrgID { + // Switch was sent, monitor the switch process + monitorOrgSwitch(cfg.LogFile, selectedOrgID) } else { - // Client not running, no switch needed + // Already on the correct org or no status available logger.Success("Successfully selected organization: %s", selectedOrgID) } - }, + } else { + // Client not running, no switch needed + logger.Success("Successfully selected organization: %s", selectedOrgID) + } } // monitorOrgSwitch monitors the organization switch process with log preview @@ -131,7 +145,3 @@ func monitorOrgSwitch(logFile string, orgID string) { logger.Warning("Failed to monitor organization switch: %v", err) } } - -func init() { - OrgCmd.Flags().StringVar(&flagOrgID, "org", "", "Organization ID to select") -} diff --git a/cmd/select/select.go b/cmd/select/select.go index 43b2957..f2a21d4 100644 --- a/cmd/select/select.go +++ b/cmd/select/select.go @@ -6,13 +6,15 @@ import ( "github.com/spf13/cobra" ) -var SelectCmd = &cobra.Command{ - Use: "select", - Short: "Select objects to work with", - Long: "Select objects to work with", -} +func SelectCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "select", + Short: "Select account information to use", + Long: "Select account information to use", + } + + cmd.AddCommand(account.AccountCmd()) + cmd.AddCommand(org.OrgCmd()) -func init() { - SelectCmd.AddCommand(account.AccountCmd) - SelectCmd.AddCommand(org.OrgCmd) + return cmd } diff --git a/cmd/status/client.go b/cmd/status/client.go index 7aa1f4e..84562bc 100644 --- a/cmd/status/client.go +++ b/cmd/status/client.go @@ -12,45 +12,50 @@ import ( "github.com/spf13/cobra" ) -var flagJSON bool - -var ClientCmd = &cobra.Command{ - Use: "client", - Short: "Show client status", - Long: "Display current client connection status and peer information", - Run: func(cmd *cobra.Command, args []string) { - // Get socket path from config or use default - client := olm.NewClient("") - - // Check if client is running - if !client.IsRunning() { - logger.Info("No client is currently running") - return - } +type ClientStatusCmdOpts = struct { + JSON bool +} - // Get status - status, err := client.GetStatus() - if err != nil { - logger.Error("Error: %v", err) - os.Exit(1) - } +func ClientStatusCmd() *cobra.Command { + opts := ClientStatusCmdOpts{} - // Print raw JSON if flag is set, otherwise print formatted table - if flagJSON { - printJSON(status) - } else { - printStatusTable(status) - } - }, -} + cmd := &cobra.Command{ + Use: "client", + Short: "Show client status", + Long: "Display current client connection status and peer information", + Run: func(cmd *cobra.Command, args []string) { + clientStatusMain(&opts) + }, + } -// addStatusClientFlags adds all status client flags to the given command -func addStatusClientFlags(cmd *cobra.Command) { - cmd.Flags().BoolVar(&flagJSON, "json", false, "Print raw JSON response") + cmd.Flags().BoolVar(&opts.JSON, "json", false, "Print raw JSON response") + + return cmd } -func init() { - addStatusClientFlags(ClientCmd) +func clientStatusMain(opts *ClientStatusCmdOpts) { + // Get socket path from config or use default + client := olm.NewClient("") + + // Check if client is running + if !client.IsRunning() { + logger.Info("No client is currently running") + return + } + + // Get status + status, err := client.GetStatus() + if err != nil { + logger.Error("Error: %v", err) + os.Exit(1) + } + + // Print raw JSON if flag is set, otherwise print formatted table + if opts.JSON { + printJSON(status) + } else { + printStatusTable(status) + } } // printJSON prints the status response as JSON diff --git a/cmd/status/status.go b/cmd/status/status.go index 666b50e..a50c55e 100644 --- a/cmd/status/status.go +++ b/cmd/status/status.go @@ -2,26 +2,21 @@ package status import ( "github.com/spf13/cobra" - "github.com/spf13/pflag" ) -var StatusCmd = &cobra.Command{ - Use: "status", - Short: "Status commands", - Long: "View status information.", - Run: func(cmd *cobra.Command, args []string) { - // Default to client subcommand if no subcommand is provided - // This makes "pangolin status" equivalent to "pangolin status client" - cmd.Flags().VisitAll(func(flag *pflag.Flag) { - if cmd.Flags().Changed(flag.Name) { - ClientCmd.Flags().Set(flag.Name, flag.Value.String()) - } - }) - ClientCmd.Run(ClientCmd, args) - }, -} +func StatusCmd() *cobra.Command { + // If no subcommand is specified, run the `client` + // subcommand by default. + cmd := ClientStatusCmd() + + cmd.Use = "status" + cmd.Short = "Status commands" + cmd.Long = `View status information. + +If ran with no subcommand, 'client' is passed. +` + + cmd.AddCommand(ClientStatusCmd()) -func init() { - addStatusClientFlags(StatusCmd) - StatusCmd.AddCommand(ClientCmd) + return cmd } diff --git a/cmd/up/client.go b/cmd/up/client.go index dbb03a0..f1da3f6 100644 --- a/cmd/up/client.go +++ b/cmd/up/client.go @@ -61,502 +61,500 @@ var ( flagUpstreamDNS []string ) -var ClientCmd = &cobra.Command{ - Use: "client", - Short: "Start a client connection", - Long: "Bring up a client tunneled connection", - Run: func(cmd *cobra.Command, args []string) { - apiClient := api.FromContext(cmd.Context()) - accountStore := config.AccountStoreFromContext(cmd.Context()) - cfg := config.ConfigFromContext(cmd.Context()) - - if runtime.GOOS == "windows" { - logger.Error("Windows is not supported") - os.Exit(1) - } +func ClientUpCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "client", + Short: "Start a client connection", + Long: "Bring up a client tunneled connection", + Run: clientUpMain, + } - // Check if a client is already running - olmClient := olm.NewClient("") - if olmClient.IsRunning() { - logger.Info("A client is already running") - os.Exit(1) - } + // Optional flags - if not provided, will use config or create new OLM + cmd.Flags().StringVar(&flagID, "id", "", "Client ID (optional, will use user info if not provided)") + cmd.Flags().StringVar(&flagSecret, "secret", "", "Client secret (optional, will use user info if not provided)") - var olmID, olmSecret string - var credentialsFromKeyring bool + // Optional flags + cmd.Flags().StringVar(&flagOrgID, "org", "", "Organization ID (optional, will use selected org if not provided)") + cmd.Flags().StringVar(&flagEndpoint, "endpoint", "", "Client endpoint (required if not logged in)") + cmd.Flags().IntVar(&flagMTU, "mtu", 0, fmt.Sprintf("MTU (default: %d)", defaultMTU)) + cmd.Flags().StringVar(&flagDNS, "netstack-dns", "", fmt.Sprintf("DNS server to use for Netstack (default: %s)", defaultDNS)) + cmd.Flags().StringVar(&flagInterfaceName, "interface-name", "", fmt.Sprintf("Interface name (default: %s)", defaultInterfaceName)) + cmd.Flags().StringVar(&flagLogLevel, "log-level", "", fmt.Sprintf("Log level (default: %s)", defaultLogLevel)) + cmd.Flags().StringVar(&flagHTTPAddr, "http-addr", "", "HTTP address for API server (default: disabled)") + cmd.Flags().StringVar(&flagPingInterval, "ping-interval", "", fmt.Sprintf("Ping interval (default: %s)", defaultPingInterval)) + cmd.Flags().StringVar(&flagPingTimeout, "ping-timeout", "", fmt.Sprintf("Ping timeout (default: %s)", defaultPingTimeout)) + cmd.Flags().BoolVar(&flagHolepunch, "holepunch", false, fmt.Sprintf("Enable holepunching (default: %v)", defaultHolepunch)) + cmd.Flags().StringVar(&flagTlsClientCert, "tls-client-cert", "", "TLS client certificate path") + cmd.Flags().BoolVar(&flagOverrideDNS, "override-dns", defaultOverrideDNS, fmt.Sprintf("Override system DNS for resolving internal resource alias (default: %v)", defaultOverrideDNS)) + cmd.Flags().BoolVar(&flagTunnelDNS, "tunnel-dns", defaultTunnelDNS, fmt.Sprintf("Use tunnel DNS for internal resource alias resolution (default: %v)", defaultTunnelDNS)) + cmd.Flags().StringSliceVar(&flagUpstreamDNS, "upstream-dns", nil, fmt.Sprintf("Comma separated list of DNS servers to use for external DNS resolution if overriding system DNS (default: %s)", defaultDNS)) + cmd.Flags().BoolVar(&flagAttached, "attach", false, "Run in attached mode (foreground, default is detached)") + cmd.Flags().BoolVar(&flagSilent, "silent", false, "Disable TUI and run silently (only applies to detached mode)") - if flagID != "" && flagSecret != "" { - // Use provided flags - no user session needed, continue even if not logged in - // Org cannot be set when passing id and secret directly - olmID = flagID - olmSecret = flagSecret - credentialsFromKeyring = false - } else if flagID != "" || flagSecret != "" { - // If only one flag is provided, require both - logger.Error("Both --id and --secret must be provided together") - os.Exit(1) - } else { - activeAccount, err := accountStore.ActiveAccount() - if err != nil { - logger.Error("Error: %v. Run `pangolin login` to login", err) - os.Exit(1) - } + return cmd +} - // Ensure OLM credentials exist and are valid - newCredsGenerated, err := utils.EnsureOlmCredentials(apiClient, activeAccount) - if err != nil { - logger.Error("Failed to ensure OLM credentials: %v", err) - os.Exit(1) - } +func clientUpMain(cmd *cobra.Command, args []string) { + apiClient := api.FromContext(cmd.Context()) + accountStore := config.AccountStoreFromContext(cmd.Context()) + cfg := config.ConfigFromContext(cmd.Context()) - if newCredsGenerated { - err := accountStore.Save() - if err != nil { - logger.Error("Failed to save accounts to store: %v", err) - os.Exit(1) - } - } + if runtime.GOOS == "windows" { + logger.Error("Windows is not supported") + os.Exit(1) + } + + // Check if a client is already running + olmClient := olm.NewClient("") + if olmClient.IsRunning() { + logger.Info("A client is already running") + os.Exit(1) + } - olmID = activeAccount.OlmCredentials.ID - olmSecret = activeAccount.OlmCredentials.Secret + var olmID, olmSecret string + var credentialsFromKeyring bool + + if flagID != "" && flagSecret != "" { + // Use provided flags - no user session needed, continue even if not logged in + // Org cannot be set when passing id and secret directly + olmID = flagID + olmSecret = flagSecret + credentialsFromKeyring = false + } else if flagID != "" || flagSecret != "" { + // If only one flag is provided, require both + logger.Error("Both --id and --secret must be provided together") + os.Exit(1) + } else { + activeAccount, err := accountStore.ActiveAccount() + if err != nil { + logger.Error("Error: %v. Run `pangolin login` to login", err) + os.Exit(1) + } + // Ensure OLM credentials exist and are valid + newCredsGenerated, err := utils.EnsureOlmCredentials(apiClient, activeAccount) + if err != nil { + logger.Error("Failed to ensure OLM credentials: %v", err) + os.Exit(1) + } + + if newCredsGenerated { + err := accountStore.Save() if err != nil { - logger.Error("Failed to get OLM credentials: %v", err) + logger.Error("Failed to save accounts to store: %v", err) os.Exit(1) } - credentialsFromKeyring = true } - orgID := flagOrgID + olmID = activeAccount.OlmCredentials.ID + olmSecret = activeAccount.OlmCredentials.Secret - // Get orgId from flag or viper (required for OLM config when using logged-in user) - if credentialsFromKeyring { - activeAccount, _ := accountStore.ActiveAccount() + if err != nil { + logger.Error("Failed to get OLM credentials: %v", err) + os.Exit(1) + } + credentialsFromKeyring = true + } - // When using credentials from keyring, orgID is required - if orgID == "" { - orgID = activeAccount.OrgID - } + orgID := flagOrgID - if orgID == "" { - logger.Error("Please select an organization first. Run `pangolin select org` to select an organization or pass --org [id] to the command") - os.Exit(1) - } + // Get orgId from flag or viper (required for OLM config when using logged-in user) + if credentialsFromKeyring { + activeAccount, _ := accountStore.ActiveAccount() - if err := utils.EnsureOrgAccess(apiClient, activeAccount); err != nil { - logger.Error("%v", err) - os.Exit(1) - } + // When using credentials from keyring, orgID is required + if orgID == "" { + orgID = activeAccount.OrgID } - // Handle log file setup - if detached mode, always use log file - var logFile string - if !flagAttached { - logFile = cfg.LogFile + if orgID == "" { + logger.Error("Please select an organization first. Run `pangolin select org` to select an organization or pass --org [id] to the command") + os.Exit(1) } - endpoint := flagEndpoint - if endpoint == "" { - activeAccount, _ := accountStore.ActiveAccount() - if activeAccount != nil { - endpoint = activeAccount.Host - } + if err := utils.EnsureOrgAccess(apiClient, activeAccount); err != nil { + logger.Error("%v", err) + os.Exit(1) } + } - // Handle detached mode - subprocess self without --attach flag - // Skip detached mode if already running as root (we're a subprocess spawned by sudo) - isRunningAsRoot := runtime.GOOS != "windows" && os.Geteuid() == 0 - if !flagAttached && !isRunningAsRoot { - executable, err := os.Executable() - if err != nil { - logger.Error("Error: failed to get executable path: %v", err) - os.Exit(1) - } + // Handle log file setup - if detached mode, always use log file + var logFile string + if !flagAttached { + logFile = cfg.LogFile + } - // Build command arguments, excluding --attach flag - cmdArgs := []string{"up", "client"} + endpoint := flagEndpoint + if endpoint == "" { + activeAccount, _ := accountStore.ActiveAccount() + if activeAccount != nil { + endpoint = activeAccount.Host + } + } - // Add org flag (required for subprocess, which runs as root and won't have user's config) - cmdArgs = append(cmdArgs, "--org", orgID) + // Handle detached mode - subprocess self without --attach flag + // Skip detached mode if already running as root (we're a subprocess spawned by sudo) + isRunningAsRoot := runtime.GOOS != "windows" && os.Geteuid() == 0 + if !flagAttached && !isRunningAsRoot { + executable, err := os.Executable() + if err != nil { + logger.Error("Error: failed to get executable path: %v", err) + os.Exit(1) + } - // Add all flags that were set (except --attach) - // OLM credentials are always included (from flags, config, or newly created) - cmdArgs = append(cmdArgs, "--id", olmID) - cmdArgs = append(cmdArgs, "--secret", olmSecret) + // Build command arguments, excluding --attach flag + cmdArgs := []string{"up", "client"} - // Always pass endpoint to subprocess (required, subprocess won't have user's config) - // Get endpoint from flag or hostname config (same logic as attached mode) - if endpoint == "" { - logger.Error("Endpoint is required. Please login with a host or provide --endpoint flag") - os.Exit(1) - } - cmdArgs = append(cmdArgs, "--endpoint", endpoint) + // Add org flag (required for subprocess, which runs as root and won't have user's config) + cmdArgs = append(cmdArgs, "--org", orgID) - // Optional flags - only include if they were explicitly set - if cmd.Flags().Changed("mtu") { - cmdArgs = append(cmdArgs, "--mtu", fmt.Sprintf("%d", flagMTU)) - } - if cmd.Flags().Changed("netstack-dns") { - cmdArgs = append(cmdArgs, "--netstack-dns", flagDNS) - } - if cmd.Flags().Changed("interface-name") { - cmdArgs = append(cmdArgs, "--interface-name", flagInterfaceName) - } - if cmd.Flags().Changed("log-level") { - cmdArgs = append(cmdArgs, "--log-level", flagLogLevel) - } - if cmd.Flags().Changed("http-addr") { - cmdArgs = append(cmdArgs, "--http-addr", flagHTTPAddr) - } - if cmd.Flags().Changed("ping-interval") { - cmdArgs = append(cmdArgs, "--ping-interval", flagPingInterval) - } - if cmd.Flags().Changed("ping-timeout") { - cmdArgs = append(cmdArgs, "--ping-timeout", flagPingTimeout) - } - if cmd.Flags().Changed("holepunch") { - if flagHolepunch { - cmdArgs = append(cmdArgs, "--holepunch") - } else { - cmdArgs = append(cmdArgs, "--holepunch=false") - } - } - if cmd.Flags().Changed("tls-client-cert") { - cmdArgs = append(cmdArgs, "--tls-client-cert", flagTlsClientCert) - } - if cmd.Flags().Changed("override-dns") { - if flagOverrideDNS { - cmdArgs = append(cmdArgs, "--override-dns") - } else { - cmdArgs = append(cmdArgs, "--override-dns=false") - } - } - if cmd.Flags().Changed("tunnel-dns") { - if flagOverrideDNS { - cmdArgs = append(cmdArgs, "--tunnel-dns") - } else { - cmdArgs = append(cmdArgs, "--tunnel-dns=false") - } - } - if cmd.Flags().Changed("upstream-dns") { - // Comma sep - cmdArgs = append(cmdArgs, "--upstream-dns", strings.Join(flagUpstreamDNS, ",")) - } + // Add all flags that were set (except --attach) + // OLM credentials are always included (from flags, config, or newly created) + cmdArgs = append(cmdArgs, "--id", olmID) + cmdArgs = append(cmdArgs, "--secret", olmSecret) - // Add positional args if any - cmdArgs = append(cmdArgs, args...) - - // Create command - subprocess should run with elevated permissions - var procCmd *exec.Cmd - if runtime.GOOS != "windows" { - // Use sudo with a shell wrapper to background the subprocess - // This allows sudo to exit immediately after starting the subprocess - // The subprocess needs root access for network interface creation - // Build shell command with proper quoting using printf %q - var shellArgs []string - shellArgs = append(shellArgs, executable) - shellArgs = append(shellArgs, cmdArgs...) - // Export environment variable to indicate credentials came from config - // This allows subprocess to distinguish between user-provided credentials and stored credentials - shellCmd := "" - if credentialsFromKeyring { - shellCmd = "export PANGOLIN_CREDENTIALS_FROM_KEYRING=1 && " - } - // Build command: nohup executable args >/dev/null 2>&1 & - shellCmd += "nohup" - for _, arg := range shellArgs { - shellCmd += " " + fmt.Sprintf("%q", arg) - } - shellCmd += " >/dev/null 2>&1 &" - procCmd = exec.Command("sudo", "sh", "-c", shellCmd) - // Connect stdin/stderr so sudo can prompt for password interactively - procCmd.Stdin = os.Stdin - procCmd.Stdout = nil - procCmd.Stderr = os.Stderr + // Always pass endpoint to subprocess (required, subprocess won't have user's config) + // Get endpoint from flag or hostname config (same logic as attached mode) + if endpoint == "" { + logger.Error("Endpoint is required. Please login with a host or provide --endpoint flag") + os.Exit(1) + } + cmdArgs = append(cmdArgs, "--endpoint", endpoint) + + // Optional flags - only include if they were explicitly set + if cmd.Flags().Changed("mtu") { + cmdArgs = append(cmdArgs, "--mtu", fmt.Sprintf("%d", flagMTU)) + } + if cmd.Flags().Changed("netstack-dns") { + cmdArgs = append(cmdArgs, "--netstack-dns", flagDNS) + } + if cmd.Flags().Changed("interface-name") { + cmdArgs = append(cmdArgs, "--interface-name", flagInterfaceName) + } + if cmd.Flags().Changed("log-level") { + cmdArgs = append(cmdArgs, "--log-level", flagLogLevel) + } + if cmd.Flags().Changed("http-addr") { + cmdArgs = append(cmdArgs, "--http-addr", flagHTTPAddr) + } + if cmd.Flags().Changed("ping-interval") { + cmdArgs = append(cmdArgs, "--ping-interval", flagPingInterval) + } + if cmd.Flags().Changed("ping-timeout") { + cmdArgs = append(cmdArgs, "--ping-timeout", flagPingTimeout) + } + if cmd.Flags().Changed("holepunch") { + if flagHolepunch { + cmdArgs = append(cmdArgs, "--holepunch") } else { - logger.Error("Windows is not supported for detached mode") - os.Exit(1) + cmdArgs = append(cmdArgs, "--holepunch=false") } - - // Start the process - if err := procCmd.Start(); err != nil { - logger.Error("Error: failed to start detached process: %v", err) - os.Exit(1) + } + if cmd.Flags().Changed("tls-client-cert") { + cmdArgs = append(cmdArgs, "--tls-client-cert", flagTlsClientCert) + } + if cmd.Flags().Changed("override-dns") { + if flagOverrideDNS { + cmdArgs = append(cmdArgs, "--override-dns") + } else { + cmdArgs = append(cmdArgs, "--override-dns=false") } - - // Wait for sudo to complete (password prompt + subprocess start) - // The shell wrapper backgrounds the subprocess, so sudo exits immediately - if err := procCmd.Wait(); err != nil { - logger.Error("Error: failed to start subprocess: %v", err) - os.Exit(1) + } + if cmd.Flags().Changed("tunnel-dns") { + if flagOverrideDNS { + cmdArgs = append(cmdArgs, "--tunnel-dns") + } else { + cmdArgs = append(cmdArgs, "--tunnel-dns=false") } + } + if cmd.Flags().Changed("upstream-dns") { + // Comma sep + cmdArgs = append(cmdArgs, "--upstream-dns", strings.Join(flagUpstreamDNS, ",")) + } - // In silent mode, skip TUI and just exit after starting the process - if flagSilent { - os.Exit(0) - } + // Add positional args if any + cmdArgs = append(cmdArgs, args...) - // Show live log preview and status - completed, err := tui.NewLogPreview(tui.LogPreviewConfig{ - LogFile: logFile, - Header: "Starting up client...", - ExitCondition: func(client *olm.Client, status *olm.StatusResponse) (bool, bool) { - // Exit when interface is registered - if status != nil && status.Registered { - return true, true - } - return false, false - }, - OnEarlyExit: func(client *olm.Client) { - // Kill the subprocess if user exits early - if client.IsRunning() { - client.Exit() - } - }, - StatusFormatter: func(isRunning bool, status *olm.StatusResponse) string { - if !isRunning || status == nil { - return "Starting" - } else if status.Registered { - return "Registered" - } - return "Starting" - }, - }) - if err != nil { - logger.Error("Error: %v", err) - os.Exit(1) - } + // Create command - subprocess should run with elevated permissions + var procCmd *exec.Cmd + if runtime.GOOS != "windows" { + // Use sudo with a shell wrapper to background the subprocess + // This allows sudo to exit immediately after starting the subprocess + // The subprocess needs root access for network interface creation + // Build shell command with proper quoting using printf %q + var shellArgs []string + shellArgs = append(shellArgs, executable) + shellArgs = append(shellArgs, cmdArgs...) + // Export environment variable to indicate credentials came from config + // This allows subprocess to distinguish between user-provided credentials and stored credentials + shellCmd := "" + if credentialsFromKeyring { + shellCmd = "export PANGOLIN_CREDENTIALS_FROM_KEYRING=1 && " + } + // Build command: nohup executable args >/dev/null 2>&1 & + shellCmd += "nohup" + for _, arg := range shellArgs { + shellCmd += " " + fmt.Sprintf("%q", arg) + } + shellCmd += " >/dev/null 2>&1 &" + procCmd = exec.Command("sudo", "sh", "-c", shellCmd) + // Connect stdin/stderr so sudo can prompt for password interactively + procCmd.Stdin = os.Stdin + procCmd.Stdout = nil + procCmd.Stderr = os.Stderr + } else { + logger.Error("Windows is not supported for detached mode") + os.Exit(1) + } - // Check if the process completed successfully or was killed - if !completed { - // User exited early - subprocess was killed - logger.Info("Client process killed") - } else { - // Completed successfully - logger.Success("Client interface created successfully") - } - os.Exit(0) + // Start the process + if err := procCmd.Start(); err != nil { + logger.Error("Error: failed to start detached process: %v", err) + os.Exit(1) } - // Helper function to get value with precedence: CLI flag > default - getString := func(flagValue, flagName, configKey, defaultValue string) string { - // Check if flag was explicitly set (CLI takes precedence) - if cmd.Flags().Changed(flagName) { - return flagValue - } - return defaultValue + // Wait for sudo to complete (password prompt + subprocess start) + // The shell wrapper backgrounds the subprocess, so sudo exits immediately + if err := procCmd.Wait(); err != nil { + logger.Error("Error: failed to start subprocess: %v", err) + os.Exit(1) } - getInt := func(flagValue int, flagName, configKey string, defaultValue int) int { - // Check if flag was explicitly set (CLI takes precedence) - if cmd.Flags().Changed(flagName) { - return flagValue - } - return defaultValue + // In silent mode, skip TUI and just exit after starting the process + if flagSilent { + os.Exit(0) } - getBool := func(flagValue bool, flagName, configKey string, defaultValue bool) bool { - // Check if flag was explicitly set (CLI takes precedence) - if cmd.Flags().Changed(flagName) { - return flagValue - } - return defaultValue + // Show live log preview and status + completed, err := tui.NewLogPreview(tui.LogPreviewConfig{ + LogFile: logFile, + Header: "Starting up client...", + ExitCondition: func(client *olm.Client, status *olm.StatusResponse) (bool, bool) { + // Exit when interface is registered + if status != nil && status.Registered { + return true, true + } + return false, false + }, + OnEarlyExit: func(client *olm.Client) { + // Kill the subprocess if user exits early + if client.IsRunning() { + client.Exit() + } + }, + StatusFormatter: func(isRunning bool, status *olm.StatusResponse) string { + if !isRunning || status == nil { + return "Starting" + } else if status.Registered { + return "Registered" + } + return "Starting" + }, + }) + if err != nil { + logger.Error("Error: %v", err) + os.Exit(1) } - getStringSlice := func(flagValue []string, flagName, configKey string, defaultValue []string) []string { - // Check if flag was explicitly set (CLI takes precedence) - if cmd.Flags().Changed(flagName) { - return flagValue - } - return defaultValue + // Check if the process completed successfully or was killed + if !completed { + // User exited early - subprocess was killed + logger.Info("Client process killed") + } else { + // Completed successfully + logger.Success("Client interface created successfully") } + os.Exit(0) + } - // Parse duration strings to time.Duration - parseDuration := func(durationStr string, defaultDuration time.Duration) time.Duration { - if durationStr == "" { - return defaultDuration - } - d, err := time.ParseDuration(durationStr) - if err != nil { - logger.Warning("Invalid duration format '%s', using default: %v", durationStr, defaultDuration) - return defaultDuration - } - return d + // Helper function to get value with precedence: CLI flag > default + getString := func(flagValue, flagName, configKey, defaultValue string) string { + // Check if flag was explicitly set (CLI takes precedence) + if cmd.Flags().Changed(flagName) { + return flagValue } + return defaultValue + } - if endpoint == "" { - logger.Error("Endpoint is required. Please provide --endpoint flag or set hostname in config") - os.Exit(1) + getInt := func(flagValue int, flagName, configKey string, defaultValue int) int { + // Check if flag was explicitly set (CLI takes precedence) + if cmd.Flags().Changed(flagName) { + return flagValue } + return defaultValue + } - mtu := getInt(flagMTU, "mtu", "mtu", defaultMTU) - dns := getString(flagDNS, "netstack-dns", "netstack-dns", defaultDNS) - interfaceName := getString(flagInterfaceName, "interface-name", "interface_name", defaultInterfaceName) - logLevel := getString(flagLogLevel, "log-level", "log_level", defaultLogLevel) - enableAPI := defaultEnableAPI - - // In detached mode, API cannot be disabled (required for status/control) - if !flagAttached && !enableAPI { - enableAPI = true - } - - httpAddr := getString(flagHTTPAddr, "http-addr", "http_addr", "") - socketPath := defaultSocketPath - pingInterval := getString(flagPingInterval, "ping-interval", "ping_interval", defaultPingInterval) - pingTimeout := getString(flagPingTimeout, "ping-timeout", "ping_timeout", defaultPingTimeout) - holepunch := getBool(flagHolepunch, "holepunch", "holepunch", defaultHolepunch) - tlsClientCert := getString(flagTlsClientCert, "tls-client-cert", "tls_client_cert", "") - version := versionpkg.Version - overrideDNS := getBool(flagOverrideDNS, "override-dns", "override_dns", defaultOverrideDNS) - tunnelDNS := getBool(flagTunnelDNS, "tunnel-dns", "tunnel_dns", defaultTunnelDNS) - upstreamDNS := getStringSlice(flagUpstreamDNS, "upstream-dns", "upstream_dns", []string{defaultDNS}) - - processedUpstreamDNS := make([]string, 0, len(upstreamDNS)) - for _, entry := range upstreamDNS { - entry = strings.TrimSpace(entry) - if entry == "" { - continue - } + getBool := func(flagValue bool, flagName, configKey string, defaultValue bool) bool { + // Check if flag was explicitly set (CLI takes precedence) + if cmd.Flags().Changed(flagName) { + return flagValue + } + return defaultValue + } - if !strings.Contains(entry, ":") { - entry = entry + ":53" - } - processedUpstreamDNS = append(processedUpstreamDNS, entry) + getStringSlice := func(flagValue []string, flagName, configKey string, defaultValue []string) []string { + // Check if flag was explicitly set (CLI takes precedence) + if cmd.Flags().Changed(flagName) { + return flagValue } + return defaultValue + } - // If no DNS servers were provided, use default - if len(processedUpstreamDNS) == 0 { - processedUpstreamDNS = []string{defaultDNS + ":53"} + // Parse duration strings to time.Duration + parseDuration := func(durationStr string, defaultDuration time.Duration) time.Duration { + if durationStr == "" { + return defaultDuration + } + d, err := time.ParseDuration(durationStr) + if err != nil { + logger.Warning("Invalid duration format '%s', using default: %v", durationStr, defaultDuration) + return defaultDuration } + return d + } - // Parse durations - defaultPingIntervalDuration, _ := time.ParseDuration(defaultPingInterval) - defaultPingTimeoutDuration, _ := time.ParseDuration(defaultPingTimeout) - pingIntervalDuration := parseDuration(pingInterval, defaultPingIntervalDuration) - pingTimeoutDuration := parseDuration(pingTimeout, defaultPingTimeoutDuration) + if endpoint == "" { + logger.Error("Endpoint is required. Please provide --endpoint flag or set hostname in config") + os.Exit(1) + } - // Setup log file if specified - if logFile != "" { - if err := setupLogFile(cfg.LogFile); err != nil { - logger.Error("Error: failed to setup log file: %v", err) - os.Exit(1) - } - } + mtu := getInt(flagMTU, "mtu", "mtu", defaultMTU) + dns := getString(flagDNS, "netstack-dns", "netstack-dns", defaultDNS) + interfaceName := getString(flagInterfaceName, "interface-name", "interface_name", defaultInterfaceName) + logLevel := getString(flagLogLevel, "log-level", "log_level", defaultLogLevel) + enableAPI := defaultEnableAPI - // Get UserToken from config if credentials came from config - // Check environment variable to distinguish between: - // - Parent process passing id/secret from config (should fetch userToken) - // - User directly passing id/secret (should NOT fetch userToken) - var userToken string - credentialsFromKeyringEnv := os.Getenv("PANGOLIN_CREDENTIALS_FROM_KEYRING") - if credentialsFromKeyringEnv == "1" || credentialsFromKeyring { - // Credentials came from config, fetch userToken from secrets - activeAccount, err := accountStore.ActiveAccount() - if err != nil { - logger.Error("Failed to get session token: %v", err) - return - } + // In detached mode, API cannot be disabled (required for status/control) + if !flagAttached && !enableAPI { + enableAPI = true + } - userToken = activeAccount.SessionToken - } - - // Create context for signal handling and cleanup - ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) - defer olmpkg.Close() - defer stop() - - // Create OLM GlobalConfig with hardcoded values from Swift - olmInitConfig := olmpkg.GlobalConfig{ - LogLevel: logLevel, - EnableAPI: enableAPI, - SocketPath: socketPath, - HTTPAddr: httpAddr, - Version: version, - Agent: defaultAgent, - OnTerminated: func() { - logger.Info("Client process terminated") - stop() - os.Exit(0) - }, - OnAuthError: func(statusCode int, message string) { - logger.Error("Authentication error: %d %s", statusCode, message) - stop() - os.Exit(1) - }, - OnExit: func() { - logger.Info("Client process exiting") - os.Exit(0) - }, + httpAddr := getString(flagHTTPAddr, "http-addr", "http_addr", "") + socketPath := defaultSocketPath + pingInterval := getString(flagPingInterval, "ping-interval", "ping_interval", defaultPingInterval) + pingTimeout := getString(flagPingTimeout, "ping-timeout", "ping_timeout", defaultPingTimeout) + holepunch := getBool(flagHolepunch, "holepunch", "holepunch", defaultHolepunch) + tlsClientCert := getString(flagTlsClientCert, "tls-client-cert", "tls_client_cert", "") + version := versionpkg.Version + overrideDNS := getBool(flagOverrideDNS, "override-dns", "override_dns", defaultOverrideDNS) + tunnelDNS := getBool(flagTunnelDNS, "tunnel-dns", "tunnel_dns", defaultTunnelDNS) + upstreamDNS := getStringSlice(flagUpstreamDNS, "upstream-dns", "upstream_dns", []string{defaultDNS}) + + processedUpstreamDNS := make([]string, 0, len(upstreamDNS)) + for _, entry := range upstreamDNS { + entry = strings.TrimSpace(entry) + if entry == "" { + continue } - olmConfig := olmpkg.TunnelConfig{ - Endpoint: endpoint, - ID: olmID, - Secret: olmSecret, - OrgID: orgID, - MTU: mtu, - DNS: dns, - InterfaceName: interfaceName, - Holepunch: holepunch, - TlsClientCert: tlsClientCert, - PingIntervalDuration: pingIntervalDuration, - PingTimeoutDuration: pingTimeoutDuration, - OverrideDNS: overrideDNS, - TunnelDNS: tunnelDNS, - UpstreamDNS: processedUpstreamDNS, + if !strings.Contains(entry, ":") { + entry = entry + ":53" } + processedUpstreamDNS = append(processedUpstreamDNS, entry) + } - // Add UserToken if we have it (from flag or config) - if userToken != "" { - olmConfig.UserToken = userToken - } + // If no DNS servers were provided, use default + if len(processedUpstreamDNS) == 0 { + processedUpstreamDNS = []string{defaultDNS + ":53"} + } - // Check if running with elevated permissions (required for network interface creation) - // This check is only for attached mode; in detached mode, the subprocess runs elevated - if runtime.GOOS != "windows" { - if os.Geteuid() != 0 { - logger.Error("This command requires elevated permissions for network interface creation.") - logger.Info("Please run with sudo or use detached mode (default) to run the subprocess elevated.") - os.Exit(1) - } + // Parse durations + defaultPingIntervalDuration, _ := time.ParseDuration(defaultPingInterval) + defaultPingTimeoutDuration, _ := time.ParseDuration(defaultPingTimeout) + pingIntervalDuration := parseDuration(pingInterval, defaultPingIntervalDuration) + pingTimeoutDuration := parseDuration(pingTimeout, defaultPingTimeoutDuration) + + // Setup log file if specified + if logFile != "" { + if err := setupLogFile(cfg.LogFile); err != nil { + logger.Error("Error: failed to setup log file: %v", err) + os.Exit(1) } + } - olmpkg.Init(ctx, olmInitConfig) - if enableAPI { - olmpkg.StartApi() + // Get UserToken from config if credentials came from config + // Check environment variable to distinguish between: + // - Parent process passing id/secret from config (should fetch userToken) + // - User directly passing id/secret (should NOT fetch userToken) + var userToken string + credentialsFromKeyringEnv := os.Getenv("PANGOLIN_CREDENTIALS_FROM_KEYRING") + if credentialsFromKeyringEnv == "1" || credentialsFromKeyring { + // Credentials came from config, fetch userToken from secrets + activeAccount, err := accountStore.ActiveAccount() + if err != nil { + logger.Error("Failed to get session token: %v", err) + return } - olmpkg.StartTunnel(olmConfig) - }, -} -// addClientFlags adds all client flags to the given command -func addClientFlags(cmd *cobra.Command) { - // Optional flags - if not provided, will use config or create new OLM - cmd.Flags().StringVar(&flagID, "id", "", "Client ID (optional, will use user info if not provided)") - cmd.Flags().StringVar(&flagSecret, "secret", "", "Client secret (optional, will use user info if not provided)") + userToken = activeAccount.SessionToken + } - // Optional flags - cmd.Flags().StringVar(&flagOrgID, "org", "", "Organization ID (optional, will use selected org if not provided)") - cmd.Flags().StringVar(&flagEndpoint, "endpoint", "", "Client endpoint (required if not logged in)") - cmd.Flags().IntVar(&flagMTU, "mtu", 0, fmt.Sprintf("MTU (default: %d)", defaultMTU)) - cmd.Flags().StringVar(&flagDNS, "netstack-dns", "", fmt.Sprintf("DNS server to use for Netstack (default: %s)", defaultDNS)) - cmd.Flags().StringVar(&flagInterfaceName, "interface-name", "", fmt.Sprintf("Interface name (default: %s)", defaultInterfaceName)) - cmd.Flags().StringVar(&flagLogLevel, "log-level", "", fmt.Sprintf("Log level (default: %s)", defaultLogLevel)) - cmd.Flags().StringVar(&flagHTTPAddr, "http-addr", "", "HTTP address for API server (default: disabled)") - cmd.Flags().StringVar(&flagPingInterval, "ping-interval", "", fmt.Sprintf("Ping interval (default: %s)", defaultPingInterval)) - cmd.Flags().StringVar(&flagPingTimeout, "ping-timeout", "", fmt.Sprintf("Ping timeout (default: %s)", defaultPingTimeout)) - cmd.Flags().BoolVar(&flagHolepunch, "holepunch", false, fmt.Sprintf("Enable holepunching (default: %v)", defaultHolepunch)) - cmd.Flags().StringVar(&flagTlsClientCert, "tls-client-cert", "", "TLS client certificate path") - cmd.Flags().BoolVar(&flagOverrideDNS, "override-dns", defaultOverrideDNS, fmt.Sprintf("Override system DNS for resolving internal resource alias (default: %v)", defaultOverrideDNS)) - cmd.Flags().BoolVar(&flagTunnelDNS, "tunnel-dns", defaultTunnelDNS, fmt.Sprintf("Use tunnel DNS for internal resource alias resolution (default: %v)", defaultTunnelDNS)) - cmd.Flags().StringSliceVar(&flagUpstreamDNS, "upstream-dns", nil, fmt.Sprintf("Comma separated list of DNS servers to use for external DNS resolution if overriding system DNS (default: %s)", defaultDNS)) - cmd.Flags().BoolVar(&flagAttached, "attach", false, "Run in attached mode (foreground, default is detached)") - cmd.Flags().BoolVar(&flagSilent, "silent", false, "Disable TUI and run silently (only applies to detached mode)") -} + // Create context for signal handling and cleanup + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer olmpkg.Close() + defer stop() + + // Create OLM GlobalConfig with hardcoded values from Swift + olmInitConfig := olmpkg.GlobalConfig{ + LogLevel: logLevel, + EnableAPI: enableAPI, + SocketPath: socketPath, + HTTPAddr: httpAddr, + Version: version, + Agent: defaultAgent, + OnTerminated: func() { + logger.Info("Client process terminated") + stop() + os.Exit(0) + }, + OnAuthError: func(statusCode int, message string) { + logger.Error("Authentication error: %d %s", statusCode, message) + stop() + os.Exit(1) + }, + OnExit: func() { + logger.Info("Client process exiting") + os.Exit(0) + }, + } + + olmConfig := olmpkg.TunnelConfig{ + Endpoint: endpoint, + ID: olmID, + Secret: olmSecret, + OrgID: orgID, + MTU: mtu, + DNS: dns, + InterfaceName: interfaceName, + Holepunch: holepunch, + TlsClientCert: tlsClientCert, + PingIntervalDuration: pingIntervalDuration, + PingTimeoutDuration: pingTimeoutDuration, + OverrideDNS: overrideDNS, + TunnelDNS: tunnelDNS, + UpstreamDNS: processedUpstreamDNS, + } + + // Add UserToken if we have it (from flag or config) + if userToken != "" { + olmConfig.UserToken = userToken + } + + // Check if running with elevated permissions (required for network interface creation) + // This check is only for attached mode; in detached mode, the subprocess runs elevated + if runtime.GOOS != "windows" { + if os.Geteuid() != 0 { + logger.Error("This command requires elevated permissions for network interface creation.") + logger.Info("Please run with sudo or use detached mode (default) to run the subprocess elevated.") + os.Exit(1) + } + } -func init() { - addClientFlags(ClientCmd) - UpCmd.AddCommand(ClientCmd) + olmpkg.Init(ctx, olmInitConfig) + if enableAPI { + olmpkg.StartApi() + } + olmpkg.StartTunnel(olmConfig) } // setupLogFile sets up file logging with rotation diff --git a/cmd/up/up.go b/cmd/up/up.go index 01b2d8e..b952a57 100644 --- a/cmd/up/up.go +++ b/cmd/up/up.go @@ -1,38 +1,22 @@ package up import ( - "strings" - "github.com/spf13/cobra" - "github.com/spf13/pflag" ) -var UpCmd = &cobra.Command{ - Use: "up", - Short: "Start a client", - Long: "Bring up a client connection", - Run: func(cmd *cobra.Command, args []string) { - // _ = api.FromContext(cmd.Context()) +func UpCmd() *cobra.Command { + // If no subcommand is specified, run the `client` + // subcommand by default. + cmd := ClientUpCmd() - // Default to client subcommand if no subcommand is provided - // This makes "pangolin up" equivalent to "pangolin up client" - cmd.Flags().VisitAll(func(flag *pflag.Flag) { - if cmd.Flags().Changed(flag.Name) { - // Ensure stringSlice flags are passed without the bracketed representation - if flag.Value.Type() == "stringSlice" { - if vals, err := cmd.Flags().GetStringSlice(flag.Name); err == nil { - ClientCmd.Flags().Set(flag.Name, strings.Join(vals, ",")) - return - } - } - ClientCmd.Flags().Set(flag.Name, flag.Value.String()) - } - }) - ClientCmd.SetContext(cmd.Context()) - ClientCmd.Run(ClientCmd, args) - }, -} + cmd.Use = "up" + cmd.Short = "Start a connection" + cmd.Long = `Bring up a connection. + +If ran with no subcommand, 'client' is passed. +` + + cmd.AddCommand(ClientUpCmd()) -func init() { - addClientFlags(UpCmd) + return cmd } diff --git a/cmd/update/update.go b/cmd/update/update.go index 44e2e1b..deef5d5 100644 --- a/cmd/update/update.go +++ b/cmd/update/update.go @@ -8,24 +8,32 @@ import ( "github.com/spf13/cobra" ) -var UpdateCmd = &cobra.Command{ - Use: "update", - Short: "Update Pangolin CLI to the latest version", - Long: "Update Pangolin CLI to the latest version by downloading and running the installation script", - Run: func(cmd *cobra.Command, args []string) { - logger.Info("Updating Pangolin CLI...") - - // Execute: curl -fsSL https://pangolin.net/get-cli.sh | bash - updateCmd := exec.Command("sh", "-c", "curl -fsSL https://static.pangolin.net/get-cli.sh | bash") - updateCmd.Stdin = os.Stdin - updateCmd.Stdout = os.Stdout - updateCmd.Stderr = os.Stderr - - if err := updateCmd.Run(); err != nil { - logger.Error("Failed to update Pangolin CLI: %v", err) - os.Exit(1) - } - - logger.Success("Pangolin CLI updated successfully!") - }, +func UpdateCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "update", + Short: "Update Pangolin CLI to the latest version", + Long: "Update Pangolin CLI to the latest version by downloading and running the installation script", + Run: func(cmd *cobra.Command, args []string) { + updateMain() + }, + } + + return cmd +} + +func updateMain() { + logger.Info("Updating Pangolin CLI...") + + // Execute: curl -fsSL https://pangolin.net/get-cli.sh | bash + updateCmd := exec.Command("sh", "-c", "curl -fsSL https://static.pangolin.net/get-cli.sh | bash") + updateCmd.Stdin = os.Stdin + updateCmd.Stdout = os.Stdout + updateCmd.Stderr = os.Stderr + + if err := updateCmd.Run(); err != nil { + logger.Error("Failed to update Pangolin CLI: %v", err) + os.Exit(1) + } + + logger.Success("Pangolin CLI updated successfully!") } diff --git a/cmd/version/version.go b/cmd/version/version.go index b12f087..2ab9a8c 100644 --- a/cmd/version/version.go +++ b/cmd/version/version.go @@ -8,27 +8,35 @@ import ( "github.com/spf13/cobra" ) -var VersionCmd = &cobra.Command{ - Use: "version", - Short: "Print the version number", - Long: "Print the version number and check for updates", - Run: func(cmd *cobra.Command, args []string) { - fmt.Println(versionpkg.Version) +func VersionCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "version", + Short: "Print the version number", + Long: "Print the version number and check for updates", + Run: func(cmd *cobra.Command, args []string) { + versionMain() + }, + } - // Check for updates - latest, err := versionpkg.CheckForUpdate() - if err != nil { - // Silently fail - don't show error to user for update check failures - return - } + return cmd +} + +func versionMain() { + fmt.Println(versionpkg.Version) + + // Check for updates + latest, err := versionpkg.CheckForUpdate() + if err != nil { + // Silently fail - don't show error to user for update check failures + return + } - if latest != nil { - logger.Warning("\nA new version is available: %s (current: %s)", latest.TagName, versionpkg.Version) - if latest.URL != "" { - logger.Info("Release: %s", latest.URL) - } - fmt.Println() - logger.Info("Run 'pangolin update' to update to the latest version") + if latest != nil { + logger.Warning("\nA new version is available: %s (current: %s)", latest.TagName, versionpkg.Version) + if latest.URL != "" { + logger.Info("Release: %s", latest.URL) } - }, + fmt.Println() + logger.Info("Run 'pangolin update' to update to the latest version") + } } diff --git a/docs/pangolin_auth_login.md b/docs/pangolin_auth_login.md new file mode 100644 index 0000000..2d24df8 --- /dev/null +++ b/docs/pangolin_auth_login.md @@ -0,0 +1,23 @@ +## pangolin auth login + +Login to Pangolin + +### Synopsis + +Interactive login to select your hosting option and configure access. + +``` +pangolin auth login [hostname] [flags] +``` + +### Options + +``` + -h, --help help for login +``` + +### SEE ALSO + +* [pangolin auth](pangolin_auth.md) - Authentication commands + +###### Auto generated by spf13/cobra on 18-Dec-2025 diff --git a/docs/pangolin_auth_logout.md b/docs/pangolin_auth_logout.md new file mode 100644 index 0000000..72b4579 --- /dev/null +++ b/docs/pangolin_auth_logout.md @@ -0,0 +1,23 @@ +## pangolin auth logout + +Logout from Pangolin + +### Synopsis + +Logout and clear your session + +``` +pangolin auth logout [flags] +``` + +### Options + +``` + -h, --help help for logout +``` + +### SEE ALSO + +* [pangolin auth](pangolin_auth.md) - Authentication commands + +###### Auto generated by spf13/cobra on 18-Dec-2025 From 58640ec01dde1bbd06868ad78218841507f9f12e Mon Sep 17 00:00:00 2001 From: Varun Narravula Date: Thu, 18 Dec 2025 22:35:54 -0800 Subject: [PATCH 2/5] refactor: move client subcommands into separate packages for consistency --- cmd/down/{ => client}/client.go | 2 +- cmd/down/down.go | 5 +++-- cmd/logs/{ => client}/client.go | 2 +- cmd/logs/logs.go | 3 ++- cmd/status/{ => client}/client.go | 2 +- cmd/status/status.go | 5 +++-- cmd/up/{ => client}/client.go | 2 +- cmd/up/up.go | 5 +++-- 8 files changed, 15 insertions(+), 11 deletions(-) rename cmd/down/{ => client}/client.go (99%) rename cmd/logs/{ => client}/client.go (99%) rename cmd/status/{ => client}/client.go (99%) rename cmd/up/{ => client}/client.go (99%) diff --git a/cmd/down/client.go b/cmd/down/client/client.go similarity index 99% rename from cmd/down/client.go rename to cmd/down/client/client.go index 956e8ce..e224998 100644 --- a/cmd/down/client.go +++ b/cmd/down/client/client.go @@ -1,4 +1,4 @@ -package down +package client import ( "os" diff --git a/cmd/down/down.go b/cmd/down/down.go index 20f2fe3..984557d 100644 --- a/cmd/down/down.go +++ b/cmd/down/down.go @@ -1,13 +1,14 @@ package down import ( + "github.com/fosrl/cli/cmd/down/client" "github.com/spf13/cobra" ) func DownCmd() *cobra.Command { // If no subcommand is specified, run the `client` // subcommand by default. - cmd := ClientDownCmd() + cmd := client.ClientDownCmd() cmd.Use = "down" cmd.Short = "Stop a connection" @@ -16,7 +17,7 @@ func DownCmd() *cobra.Command { If ran with no subcommand, 'client' is passed. ` - cmd.AddCommand(ClientDownCmd()) + cmd.AddCommand(client.ClientDownCmd()) return cmd } diff --git a/cmd/logs/client.go b/cmd/logs/client/client.go similarity index 99% rename from cmd/logs/client.go rename to cmd/logs/client/client.go index 6f14f5e..7b7835f 100644 --- a/cmd/logs/client.go +++ b/cmd/logs/client/client.go @@ -1,4 +1,4 @@ -package logs +package client import ( "bufio" diff --git a/cmd/logs/logs.go b/cmd/logs/logs.go index b4dc29f..5777188 100644 --- a/cmd/logs/logs.go +++ b/cmd/logs/logs.go @@ -1,6 +1,7 @@ package logs import ( + "github.com/fosrl/cli/cmd/logs/client" "github.com/spf13/cobra" ) @@ -11,7 +12,7 @@ func LogsCmd() *cobra.Command { Long: "View and follow client logs", } - cmd.AddCommand(ClientLogsCmd()) + cmd.AddCommand(client.ClientLogsCmd()) return cmd } diff --git a/cmd/status/client.go b/cmd/status/client/client.go similarity index 99% rename from cmd/status/client.go rename to cmd/status/client/client.go index 84562bc..7d5f97e 100644 --- a/cmd/status/client.go +++ b/cmd/status/client/client.go @@ -1,4 +1,4 @@ -package status +package client import ( "encoding/json" diff --git a/cmd/status/status.go b/cmd/status/status.go index a50c55e..12939e8 100644 --- a/cmd/status/status.go +++ b/cmd/status/status.go @@ -1,13 +1,14 @@ package status import ( + "github.com/fosrl/cli/cmd/status/client" "github.com/spf13/cobra" ) func StatusCmd() *cobra.Command { // If no subcommand is specified, run the `client` // subcommand by default. - cmd := ClientStatusCmd() + cmd := client.ClientStatusCmd() cmd.Use = "status" cmd.Short = "Status commands" @@ -16,7 +17,7 @@ func StatusCmd() *cobra.Command { If ran with no subcommand, 'client' is passed. ` - cmd.AddCommand(ClientStatusCmd()) + cmd.AddCommand(client.ClientStatusCmd()) return cmd } diff --git a/cmd/up/client.go b/cmd/up/client/client.go similarity index 99% rename from cmd/up/client.go rename to cmd/up/client/client.go index f1da3f6..60f1eb8 100644 --- a/cmd/up/client.go +++ b/cmd/up/client/client.go @@ -1,4 +1,4 @@ -package up +package client import ( "context" diff --git a/cmd/up/up.go b/cmd/up/up.go index b952a57..083bbd5 100644 --- a/cmd/up/up.go +++ b/cmd/up/up.go @@ -1,13 +1,14 @@ package up import ( + "github.com/fosrl/cli/cmd/up/client" "github.com/spf13/cobra" ) func UpCmd() *cobra.Command { // If no subcommand is specified, run the `client` // subcommand by default. - cmd := ClientUpCmd() + cmd := client.ClientUpCmd() cmd.Use = "up" cmd.Short = "Start a connection" @@ -16,7 +17,7 @@ func UpCmd() *cobra.Command { If ran with no subcommand, 'client' is passed. ` - cmd.AddCommand(ClientUpCmd()) + cmd.AddCommand(client.ClientUpCmd()) return cmd } From e66cb374b64fafbace8857144f7f0f0230a32a2b Mon Sep 17 00:00:00 2001 From: Varun Narravula Date: Fri, 19 Dec 2025 01:14:17 -0800 Subject: [PATCH 3/5] refactor(client-up): avoid globals, completely redo cobra flag logic Instead of using global variables for Cobra flag values, and separate defaults and clunky getter functions everywhere, set the defaults inside Cobra's flag set itself. This makes handling of values radically simpler. Additionally, this also adds more stringent flag validation before even running the program, and errors are handled more gracefully at the end with a single os.Exit() invocation. --- cmd/up/client/client.go | 401 +++++++++++++++++----------------------- 1 file changed, 173 insertions(+), 228 deletions(-) diff --git a/cmd/up/client/client.go b/cmd/up/client/client.go index 60f1eb8..9ee83f4 100644 --- a/cmd/up/client/client.go +++ b/cmd/up/client/client.go @@ -2,6 +2,7 @@ package client import ( "context" + "errors" "fmt" "log" "os" @@ -26,187 +27,199 @@ import ( ) const ( - defaultMTU = 1280 - defaultDNS = "8.8.8.8" - defaultInterfaceName = "pangolin" - defaultLogLevel = "info" - defaultEnableAPI = true - defaultSocketPath = "/var/run/olm.sock" - defaultPingInterval = "5s" - defaultPingTimeout = "5s" - defaultHolepunch = true - defaultAgent = "Pangolin CLI" - defaultOverrideDNS = true - defaultTunnelDNS = false + defaultDNSServer = "8.8.8.8" + defaultEnableAPI = true + defaultSocketPath = "/var/run/olm.sock" + defaultAgent = "Pangolin CLI" ) -var ( - flagID string - flagSecret string - flagEndpoint string - flagOrgID string - flagMTU int - flagDNS string - flagInterfaceName string - flagLogLevel string - flagHTTPAddr string - flagPingInterval string - flagPingTimeout string - flagHolepunch bool - flagTlsClientCert string - flagAttached bool - flagSilent bool - flagOverrideDNS bool - flagTunnelDNS bool - flagUpstreamDNS []string -) +type ClientUpCmdOpts struct { + ID string + Secret string + Endpoint string + OrgID string + MTU int + DNS string + InterfaceName string + LogLevel string + HTTPAddr string + PingInterval time.Duration + PingTimeout time.Duration + Holepunch bool + TlsClientCert string + Attached bool + Silent bool + OverrideDNS bool + TunnelDNS bool + UpstreamDNS []string +} func ClientUpCmd() *cobra.Command { + opts := ClientUpCmdOpts{} + cmd := &cobra.Command{ Use: "client", Short: "Start a client connection", Long: "Bring up a client tunneled connection", - Run: clientUpMain, + PreRunE: func(cmd *cobra.Command, args []string) error { + // `--id` and `--secret` must be specified together + if (opts.ID == "") != (opts.Secret == "") { + return errors.New("--id and --secret must be provided together") + } + + if opts.Attached && opts.Silent { + return errors.New("--silent and --attached options conflict") + } + + return nil + }, + Run: func(cmd *cobra.Command, args []string) { + if err := clientUpMain(cmd, &opts, args); err != nil { + os.Exit(1) + } + }, } // Optional flags - if not provided, will use config or create new OLM - cmd.Flags().StringVar(&flagID, "id", "", "Client ID (optional, will use user info if not provided)") - cmd.Flags().StringVar(&flagSecret, "secret", "", "Client secret (optional, will use user info if not provided)") + cmd.Flags().StringVar(&opts.ID, "id", "", "Client ID (optional, will use user info if not provided)") + cmd.Flags().StringVar(&opts.Secret, "secret", "", "Client secret (optional, will use user info if not provided)") // Optional flags - cmd.Flags().StringVar(&flagOrgID, "org", "", "Organization ID (optional, will use selected org if not provided)") - cmd.Flags().StringVar(&flagEndpoint, "endpoint", "", "Client endpoint (required if not logged in)") - cmd.Flags().IntVar(&flagMTU, "mtu", 0, fmt.Sprintf("MTU (default: %d)", defaultMTU)) - cmd.Flags().StringVar(&flagDNS, "netstack-dns", "", fmt.Sprintf("DNS server to use for Netstack (default: %s)", defaultDNS)) - cmd.Flags().StringVar(&flagInterfaceName, "interface-name", "", fmt.Sprintf("Interface name (default: %s)", defaultInterfaceName)) - cmd.Flags().StringVar(&flagLogLevel, "log-level", "", fmt.Sprintf("Log level (default: %s)", defaultLogLevel)) - cmd.Flags().StringVar(&flagHTTPAddr, "http-addr", "", "HTTP address for API server (default: disabled)") - cmd.Flags().StringVar(&flagPingInterval, "ping-interval", "", fmt.Sprintf("Ping interval (default: %s)", defaultPingInterval)) - cmd.Flags().StringVar(&flagPingTimeout, "ping-timeout", "", fmt.Sprintf("Ping timeout (default: %s)", defaultPingTimeout)) - cmd.Flags().BoolVar(&flagHolepunch, "holepunch", false, fmt.Sprintf("Enable holepunching (default: %v)", defaultHolepunch)) - cmd.Flags().StringVar(&flagTlsClientCert, "tls-client-cert", "", "TLS client certificate path") - cmd.Flags().BoolVar(&flagOverrideDNS, "override-dns", defaultOverrideDNS, fmt.Sprintf("Override system DNS for resolving internal resource alias (default: %v)", defaultOverrideDNS)) - cmd.Flags().BoolVar(&flagTunnelDNS, "tunnel-dns", defaultTunnelDNS, fmt.Sprintf("Use tunnel DNS for internal resource alias resolution (default: %v)", defaultTunnelDNS)) - cmd.Flags().StringSliceVar(&flagUpstreamDNS, "upstream-dns", nil, fmt.Sprintf("Comma separated list of DNS servers to use for external DNS resolution if overriding system DNS (default: %s)", defaultDNS)) - cmd.Flags().BoolVar(&flagAttached, "attach", false, "Run in attached mode (foreground, default is detached)") - cmd.Flags().BoolVar(&flagSilent, "silent", false, "Disable TUI and run silently (only applies to detached mode)") + cmd.Flags().StringVar(&opts.OrgID, "org", "", "Organization ID (default: selected organization if logged in)") + cmd.Flags().StringVar(&opts.Endpoint, "endpoint", "", "Client endpoint (required if not logged in)") + cmd.Flags().IntVar(&opts.MTU, "mtu", 1280, "Maximum transmission unit") + cmd.Flags().StringVar(&opts.DNS, "netstack-dns", defaultDNSServer, "DNS `server` to use for Netstack") + cmd.Flags().StringVar(&opts.InterfaceName, "interface-name", "pangolin", "Interface `name`") + cmd.Flags().StringVar(&opts.LogLevel, "log-level", "info", "Log level") + cmd.Flags().StringVar(&opts.HTTPAddr, "http-addr", "", "HTTP address for API server") + cmd.Flags().DurationVar(&opts.PingInterval, "ping-interval", 5*time.Second, "Ping `interval`") + cmd.Flags().DurationVar(&opts.PingTimeout, "ping-timeout", 5*time.Second, "Ping `timeout`") + cmd.Flags().BoolVar(&opts.Holepunch, "holepunch", true, "Enable holepunching") + cmd.Flags().StringVar(&opts.TlsClientCert, "tls-client-cert", "", "TLS client certificate `path`") + cmd.Flags().BoolVar(&opts.OverrideDNS, "override-dns", true, "Override system DNS for resolving internal resource alias") + cmd.Flags().BoolVar(&opts.TunnelDNS, "tunnel-dns", false, "Use tunnel DNS for internal resource alias resolution") + cmd.Flags().StringSliceVar(&opts.UpstreamDNS, "upstream-dns", []string{defaultDNSServer}, "List of DNS servers to use for external DNS resolution if overriding system DNS") + cmd.Flags().BoolVar(&opts.Attached, "attach", false, "Run in attached (foreground) mode, (default: detached (background) mode)") + cmd.Flags().BoolVar(&opts.Silent, "silent", false, "Disable TUI and run silently when detached") return cmd } -func clientUpMain(cmd *cobra.Command, args []string) { +func clientUpMain(cmd *cobra.Command, opts *ClientUpCmdOpts, extraArgs []string) error { apiClient := api.FromContext(cmd.Context()) accountStore := config.AccountStoreFromContext(cmd.Context()) cfg := config.ConfigFromContext(cmd.Context()) if runtime.GOOS == "windows" { - logger.Error("Windows is not supported") - os.Exit(1) + err := errors.New("this command is currently unsupported on Windows") + logger.Error("Error: %v", err) + return err } // Check if a client is already running olmClient := olm.NewClient("") if olmClient.IsRunning() { - logger.Info("A client is already running") - os.Exit(1) + err := errors.New("a client is already running") + logger.Error("Error: %v", err) + return err } - var olmID, olmSecret string - var credentialsFromKeyring bool - - if flagID != "" && flagSecret != "" { - // Use provided flags - no user session needed, continue even if not logged in - // Org cannot be set when passing id and secret directly - olmID = flagID - olmSecret = flagSecret - credentialsFromKeyring = false - } else if flagID != "" || flagSecret != "" { - // If only one flag is provided, require both - logger.Error("Both --id and --secret must be provided together") - os.Exit(1) - } else { + // Use provided flags whenever possible. + // No user session is needed when passing these directly, + // so continue even if not logged in. + olmID := opts.ID + olmSecret := opts.Secret + + credentialsFromKeyring := olmID == "" && olmSecret == "" + + if credentialsFromKeyring { activeAccount, err := accountStore.ActiveAccount() if err != nil { logger.Error("Error: %v. Run `pangolin login` to login", err) - os.Exit(1) + return err } // Ensure OLM credentials exist and are valid newCredsGenerated, err := utils.EnsureOlmCredentials(apiClient, activeAccount) if err != nil { logger.Error("Failed to ensure OLM credentials: %v", err) - os.Exit(1) + return err } if newCredsGenerated { err := accountStore.Save() if err != nil { logger.Error("Failed to save accounts to store: %v", err) - os.Exit(1) + return err } } olmID = activeAccount.OlmCredentials.ID olmSecret = activeAccount.OlmCredentials.Secret - - if err != nil { - logger.Error("Failed to get OLM credentials: %v", err) - os.Exit(1) - } - credentialsFromKeyring = true } - orgID := flagOrgID + orgID := opts.OrgID - // Get orgId from flag or viper (required for OLM config when using logged-in user) - if credentialsFromKeyring { + // If no organization ID is specified, then use the active user's + // selected organization if possible. + if orgID == "" && credentialsFromKeyring { activeAccount, _ := accountStore.ActiveAccount() - // When using credentials from keyring, orgID is required - if orgID == "" { - orgID = activeAccount.OrgID - } - - if orgID == "" { - logger.Error("Please select an organization first. Run `pangolin select org` to select an organization or pass --org [id] to the command") - os.Exit(1) + if activeAccount.OrgID == "" { + err := errors.New("organization not selected") + logger.Error("Error: %v", err) + logger.Info("Run `pangolin select org` to select an organization or pass --org [id] to the command") + return err } if err := utils.EnsureOrgAccess(apiClient, activeAccount); err != nil { logger.Error("%v", err) - os.Exit(1) + return err } + + orgID = activeAccount.OrgID } // Handle log file setup - if detached mode, always use log file var logFile string - if !flagAttached { + if !opts.Attached { logFile = cfg.LogFile } - endpoint := flagEndpoint - if endpoint == "" { + var endpoint string + + if opts.Endpoint == "" && credentialsFromKeyring { activeAccount, _ := accountStore.ActiveAccount() - if activeAccount != nil { - endpoint = activeAccount.Host - } + endpoint = activeAccount.Host + } else { + endpoint = opts.Endpoint + } + + if endpoint == "" { + err := errors.New("endpoint is required") + logger.Error("Error: %v", err) + logger.Info("Please login with a host or provide the --endpoint flag.") + return err } // Handle detached mode - subprocess self without --attach flag // Skip detached mode if already running as root (we're a subprocess spawned by sudo) isRunningAsRoot := runtime.GOOS != "windows" && os.Geteuid() == 0 - if !flagAttached && !isRunningAsRoot { + if !opts.Attached && !isRunningAsRoot { executable, err := os.Executable() if err != nil { logger.Error("Error: failed to get executable path: %v", err) - os.Exit(1) + return err } // Build command arguments, excluding --attach flag cmdArgs := []string{"up", "client"} - // Add org flag (required for subprocess, which runs as root and won't have user's config) - cmdArgs = append(cmdArgs, "--org", orgID) + // Add org flag if needed (required for subprocess, which runs as + // root and won't have user's config) + if orgID != "" { + cmdArgs = append(cmdArgs, "--org", orgID) + } // Add all flags that were set (except --attach) // OLM credentials are always included (from flags, config, or newly created) @@ -215,53 +228,49 @@ func clientUpMain(cmd *cobra.Command, args []string) { // Always pass endpoint to subprocess (required, subprocess won't have user's config) // Get endpoint from flag or hostname config (same logic as attached mode) - if endpoint == "" { - logger.Error("Endpoint is required. Please login with a host or provide --endpoint flag") - os.Exit(1) - } cmdArgs = append(cmdArgs, "--endpoint", endpoint) // Optional flags - only include if they were explicitly set if cmd.Flags().Changed("mtu") { - cmdArgs = append(cmdArgs, "--mtu", fmt.Sprintf("%d", flagMTU)) + cmdArgs = append(cmdArgs, "--mtu", fmt.Sprintf("%d", opts.MTU)) } if cmd.Flags().Changed("netstack-dns") { - cmdArgs = append(cmdArgs, "--netstack-dns", flagDNS) + cmdArgs = append(cmdArgs, "--netstack-dns", opts.DNS) } if cmd.Flags().Changed("interface-name") { - cmdArgs = append(cmdArgs, "--interface-name", flagInterfaceName) + cmdArgs = append(cmdArgs, "--interface-name", opts.InterfaceName) } if cmd.Flags().Changed("log-level") { - cmdArgs = append(cmdArgs, "--log-level", flagLogLevel) + cmdArgs = append(cmdArgs, "--log-level", opts.LogLevel) } if cmd.Flags().Changed("http-addr") { - cmdArgs = append(cmdArgs, "--http-addr", flagHTTPAddr) + cmdArgs = append(cmdArgs, "--http-addr", opts.HTTPAddr) } if cmd.Flags().Changed("ping-interval") { - cmdArgs = append(cmdArgs, "--ping-interval", flagPingInterval) + cmdArgs = append(cmdArgs, "--ping-interval", opts.PingInterval.String()) } if cmd.Flags().Changed("ping-timeout") { - cmdArgs = append(cmdArgs, "--ping-timeout", flagPingTimeout) + cmdArgs = append(cmdArgs, "--ping-timeout", opts.PingTimeout.String()) } if cmd.Flags().Changed("holepunch") { - if flagHolepunch { + if opts.Holepunch { cmdArgs = append(cmdArgs, "--holepunch") } else { cmdArgs = append(cmdArgs, "--holepunch=false") } } if cmd.Flags().Changed("tls-client-cert") { - cmdArgs = append(cmdArgs, "--tls-client-cert", flagTlsClientCert) + cmdArgs = append(cmdArgs, "--tls-client-cert", opts.TlsClientCert) } if cmd.Flags().Changed("override-dns") { - if flagOverrideDNS { + if opts.OverrideDNS { cmdArgs = append(cmdArgs, "--override-dns") } else { cmdArgs = append(cmdArgs, "--override-dns=false") } } if cmd.Flags().Changed("tunnel-dns") { - if flagOverrideDNS { + if opts.TunnelDNS { cmdArgs = append(cmdArgs, "--tunnel-dns") } else { cmdArgs = append(cmdArgs, "--tunnel-dns=false") @@ -269,11 +278,11 @@ func clientUpMain(cmd *cobra.Command, args []string) { } if cmd.Flags().Changed("upstream-dns") { // Comma sep - cmdArgs = append(cmdArgs, "--upstream-dns", strings.Join(flagUpstreamDNS, ",")) + cmdArgs = append(cmdArgs, "--upstream-dns", strings.Join(opts.UpstreamDNS, ",")) } // Add positional args if any - cmdArgs = append(cmdArgs, args...) + cmdArgs = append(cmdArgs, extraArgs...) // Create command - subprocess should run with elevated permissions var procCmd *exec.Cmd @@ -303,26 +312,27 @@ func clientUpMain(cmd *cobra.Command, args []string) { procCmd.Stdout = nil procCmd.Stderr = os.Stderr } else { - logger.Error("Windows is not supported for detached mode") - os.Exit(1) + err := errors.New("detached mode is not supported on Windows") + logger.Error("Error: %v", err) + return err } // Start the process if err := procCmd.Start(); err != nil { logger.Error("Error: failed to start detached process: %v", err) - os.Exit(1) + return err } // Wait for sudo to complete (password prompt + subprocess start) // The shell wrapper backgrounds the subprocess, so sudo exits immediately if err := procCmd.Wait(); err != nil { logger.Error("Error: failed to start subprocess: %v", err) - os.Exit(1) + return err } // In silent mode, skip TUI and just exit after starting the process - if flagSilent { - os.Exit(0) + if opts.Silent { + return nil } // Show live log preview and status @@ -339,7 +349,7 @@ func clientUpMain(cmd *cobra.Command, args []string) { OnEarlyExit: func(client *olm.Client) { // Kill the subprocess if user exits early if client.IsRunning() { - client.Exit() + _, _ = client.Exit() } }, StatusFormatter: func(isRunning bool, status *olm.StatusResponse) string { @@ -353,7 +363,7 @@ func clientUpMain(cmd *cobra.Command, args []string) { }) if err != nil { logger.Error("Error: %v", err) - os.Exit(1) + return err } // Check if the process completed successfully or was killed @@ -364,111 +374,43 @@ func clientUpMain(cmd *cobra.Command, args []string) { // Completed successfully logger.Success("Client interface created successfully") } - os.Exit(0) - } - - // Helper function to get value with precedence: CLI flag > default - getString := func(flagValue, flagName, configKey, defaultValue string) string { - // Check if flag was explicitly set (CLI takes precedence) - if cmd.Flags().Changed(flagName) { - return flagValue - } - return defaultValue - } - - getInt := func(flagValue int, flagName, configKey string, defaultValue int) int { - // Check if flag was explicitly set (CLI takes precedence) - if cmd.Flags().Changed(flagName) { - return flagValue - } - return defaultValue - } - - getBool := func(flagValue bool, flagName, configKey string, defaultValue bool) bool { - // Check if flag was explicitly set (CLI takes precedence) - if cmd.Flags().Changed(flagName) { - return flagValue - } - return defaultValue - } - - getStringSlice := func(flagValue []string, flagName, configKey string, defaultValue []string) []string { - // Check if flag was explicitly set (CLI takes precedence) - if cmd.Flags().Changed(flagName) { - return flagValue - } - return defaultValue - } - - // Parse duration strings to time.Duration - parseDuration := func(durationStr string, defaultDuration time.Duration) time.Duration { - if durationStr == "" { - return defaultDuration - } - d, err := time.ParseDuration(durationStr) - if err != nil { - logger.Warning("Invalid duration format '%s', using default: %v", durationStr, defaultDuration) - return defaultDuration - } - return d - } - - if endpoint == "" { - logger.Error("Endpoint is required. Please provide --endpoint flag or set hostname in config") - os.Exit(1) + return nil } - mtu := getInt(flagMTU, "mtu", "mtu", defaultMTU) - dns := getString(flagDNS, "netstack-dns", "netstack-dns", defaultDNS) - interfaceName := getString(flagInterfaceName, "interface-name", "interface_name", defaultInterfaceName) - logLevel := getString(flagLogLevel, "log-level", "log_level", defaultLogLevel) enableAPI := defaultEnableAPI // In detached mode, API cannot be disabled (required for status/control) - if !flagAttached && !enableAPI { + if !opts.Attached && !enableAPI { enableAPI = true } - httpAddr := getString(flagHTTPAddr, "http-addr", "http_addr", "") socketPath := defaultSocketPath - pingInterval := getString(flagPingInterval, "ping-interval", "ping_interval", defaultPingInterval) - pingTimeout := getString(flagPingTimeout, "ping-timeout", "ping_timeout", defaultPingTimeout) - holepunch := getBool(flagHolepunch, "holepunch", "holepunch", defaultHolepunch) - tlsClientCert := getString(flagTlsClientCert, "tls-client-cert", "tls_client_cert", "") - version := versionpkg.Version - overrideDNS := getBool(flagOverrideDNS, "override-dns", "override_dns", defaultOverrideDNS) - tunnelDNS := getBool(flagTunnelDNS, "tunnel-dns", "tunnel_dns", defaultTunnelDNS) - upstreamDNS := getStringSlice(flagUpstreamDNS, "upstream-dns", "upstream_dns", []string{defaultDNS}) - - processedUpstreamDNS := make([]string, 0, len(upstreamDNS)) - for _, entry := range upstreamDNS { - entry = strings.TrimSpace(entry) - if entry == "" { + + upstreamDNS := make([]string, 0, len(opts.UpstreamDNS)) + for _, server := range opts.UpstreamDNS { + server = strings.TrimSpace(server) + if server == "" { continue } - if !strings.Contains(entry, ":") { - entry = entry + ":53" + if !strings.Contains(server, ":") { + server = fmt.Sprintf("%s:53", server) } - processedUpstreamDNS = append(processedUpstreamDNS, entry) - } - // If no DNS servers were provided, use default - if len(processedUpstreamDNS) == 0 { - processedUpstreamDNS = []string{defaultDNS + ":53"} + upstreamDNS = append(upstreamDNS, server) } - // Parse durations - defaultPingIntervalDuration, _ := time.ParseDuration(defaultPingInterval) - defaultPingTimeoutDuration, _ := time.ParseDuration(defaultPingTimeout) - pingIntervalDuration := parseDuration(pingInterval, defaultPingIntervalDuration) - pingTimeoutDuration := parseDuration(pingTimeout, defaultPingTimeoutDuration) + // If no DNS servers were provided, force using + // the default server again. + if len(upstreamDNS) == 0 { + upstreamDNS = []string{fmt.Sprintf("%s:53", defaultDNSServer)} + } // Setup log file if specified if logFile != "" { if err := setupLogFile(cfg.LogFile); err != nil { logger.Error("Error: failed to setup log file: %v", err) - os.Exit(1) + return err } } @@ -483,7 +425,7 @@ func clientUpMain(cmd *cobra.Command, args []string) { activeAccount, err := accountStore.ActiveAccount() if err != nil { logger.Error("Failed to get session token: %v", err) - return + return err } userToken = activeAccount.SessionToken @@ -496,11 +438,11 @@ func clientUpMain(cmd *cobra.Command, args []string) { // Create OLM GlobalConfig with hardcoded values from Swift olmInitConfig := olmpkg.GlobalConfig{ - LogLevel: logLevel, + LogLevel: opts.LogLevel, EnableAPI: enableAPI, SocketPath: socketPath, - HTTPAddr: httpAddr, - Version: version, + HTTPAddr: opts.HTTPAddr, + Version: versionpkg.Version, Agent: defaultAgent, OnTerminated: func() { logger.Info("Client process terminated") @@ -523,16 +465,16 @@ func clientUpMain(cmd *cobra.Command, args []string) { ID: olmID, Secret: olmSecret, OrgID: orgID, - MTU: mtu, - DNS: dns, - InterfaceName: interfaceName, - Holepunch: holepunch, - TlsClientCert: tlsClientCert, - PingIntervalDuration: pingIntervalDuration, - PingTimeoutDuration: pingTimeoutDuration, - OverrideDNS: overrideDNS, - TunnelDNS: tunnelDNS, - UpstreamDNS: processedUpstreamDNS, + MTU: opts.MTU, + DNS: opts.DNS, + InterfaceName: opts.InterfaceName, + Holepunch: opts.Holepunch, + TlsClientCert: opts.TlsClientCert, + PingIntervalDuration: opts.PingInterval, + PingTimeoutDuration: opts.PingTimeout, + OverrideDNS: opts.OverrideDNS, + TunnelDNS: opts.TunnelDNS, + UpstreamDNS: upstreamDNS, } // Add UserToken if we have it (from flag or config) @@ -544,17 +486,20 @@ func clientUpMain(cmd *cobra.Command, args []string) { // This check is only for attached mode; in detached mode, the subprocess runs elevated if runtime.GOOS != "windows" { if os.Geteuid() != 0 { - logger.Error("This command requires elevated permissions for network interface creation.") + err := errors.New("elevated permissions are required for network interface creation") + logger.Error("Error: %v", err) logger.Info("Please run with sudo or use detached mode (default) to run the subprocess elevated.") - os.Exit(1) + return err } } olmpkg.Init(ctx, olmInitConfig) if enableAPI { - olmpkg.StartApi() + _ = olmpkg.StartApi() } olmpkg.StartTunnel(olmConfig) + + return nil } // setupLogFile sets up file logging with rotation @@ -632,7 +577,7 @@ func cleanupOldLogFiles(logDir string, daysToKeep int) { continue } if info.ModTime().Before(cutoff) { - os.Remove(filePath) + _ = os.Remove(filePath) } } } From 431fff10ce3efaafde87020535291401954a966c Mon Sep 17 00:00:00 2001 From: Varun Narravula Date: Fri, 19 Dec 2025 01:45:34 -0800 Subject: [PATCH 4/5] feat: normalize exit success/error codes, only call os.Exit() in Run() This normalizes error handling to always propagate up until the Run() function, where this will exit with the 1 status code if an error is detected. Before this commit, some unsuccessful attempts and error states resulted in an exit code of 0, which can throw off shell scripts. --- cmd/auth/login/login.go | 30 ++++++++++++++++++------------ cmd/auth/logout/logout.go | 30 +++++++++++++++++------------- cmd/auth/status/status.go | 17 +++++++++++------ cmd/down/client/client.go | 25 +++++++++++++++++-------- cmd/logs/client/client.go | 16 ++++++++++------ cmd/root.go | 5 +++-- cmd/select/account/account.go | 28 ++++++++++++++++++---------- cmd/select/org/org.go | 22 ++++++++++++++-------- cmd/status/client/client.go | 19 ++++++++++++------- cmd/update/update.go | 10 +++++++--- 10 files changed, 127 insertions(+), 75 deletions(-) diff --git a/cmd/auth/login/login.go b/cmd/auth/login/login.go index e06cd1f..463cc25 100644 --- a/cmd/auth/login/login.go +++ b/cmd/auth/login/login.go @@ -2,6 +2,7 @@ package login import ( "bufio" + "errors" "fmt" "os" "strings" @@ -163,14 +164,16 @@ func LoginCmd() *cobra.Command { return nil }, Run: func(cmd *cobra.Command, args []string) { - loginMain(cmd, &opts) + if err := loginMain(cmd, &opts); err != nil { + os.Exit(1) + } }, } return cmd } -func loginMain(cmd *cobra.Command, opts *LoginCmdOpts) { +func loginMain(cmd *cobra.Command, opts *LoginCmdOpts) error { apiClient := api.FromContext(cmd.Context()) accountStore := config.AccountStoreFromContext(cmd.Context()) @@ -195,7 +198,7 @@ func loginMain(cmd *cobra.Command, opts *LoginCmdOpts) { if err := form.Run(); err != nil { logger.Error("Error: %v", err) - return + return err } // If self-hosted, prompt for hostname @@ -211,7 +214,7 @@ func loginMain(cmd *cobra.Command, opts *LoginCmdOpts) { if err := hostnameForm.Run(); err != nil { logger.Error("Error: %v", err) - return + return err } } else { // For cloud, set the default hostname @@ -231,12 +234,13 @@ func loginMain(cmd *cobra.Command, opts *LoginCmdOpts) { sessionToken, err := loginWithWeb(hostname) if err != nil { logger.Error("%v", err) - return + return err } if sessionToken == "" { - logger.Error("Login appeared successful but no session token was received.") - return + err := errors.New("login appeared successful but no session token was received.") + logger.Error("Error: %v", err) + return err } // Update the global API client (always initialized) @@ -253,12 +257,12 @@ func loginMain(cmd *cobra.Command, opts *LoginCmdOpts) { user, err = apiClient.GetUser() if err != nil { logger.Error("Failed to get user information: %v", err) - return // FIXME: handle errors properly with exit codes! + return err } if _, exists := accountStore.Accounts[user.UserID]; exists { logger.Warning("Already logged in as this user; no action needed") - return + return nil } // Ensure OLM credentials exist and are valid @@ -267,13 +271,13 @@ func loginMain(cmd *cobra.Command, opts *LoginCmdOpts) { orgID, err := utils.SelectOrgForm(apiClient, userID) if err != nil { logger.Error("Failed to select organization: %v", err) - return + return err } newOlmCreds, err := apiClient.CreateOlm(userID, utils.GetDeviceName()) if err != nil { logger.Error("Failed to obtain olm credentials: %v", err) - return + return err } newAccount := config.Account{ @@ -295,7 +299,7 @@ func loginMain(cmd *cobra.Command, opts *LoginCmdOpts) { if err != nil { logger.Error("Failed to save account store: %s", err) logger.Warning("You may not be able to login properly until this is saved.") - return + return err } // List and select organization @@ -315,4 +319,6 @@ func loginMain(cmd *cobra.Command, opts *LoginCmdOpts) { logger.Success("Logged in as %s", displayName) } } + + return nil } diff --git a/cmd/auth/logout/logout.go b/cmd/auth/logout/logout.go index ebea583..937dbdc 100644 --- a/cmd/auth/logout/logout.go +++ b/cmd/auth/logout/logout.go @@ -1,6 +1,8 @@ package logout import ( + "errors" + "os" "time" "github.com/charmbracelet/huh" @@ -17,14 +19,16 @@ func LogoutCmd() *cobra.Command { Short: "Logout from Pangolin", Long: "Logout and clear your session", Run: func(cmd *cobra.Command, args []string) { - logoutMain(cmd) + if err := logoutMain(cmd); err != nil { + os.Exit(1) + } }, } return cmd } -func logoutMain(cmd *cobra.Command) { +func logoutMain(cmd *cobra.Command) error { apiClient := api.FromContext(cmd.Context()) // Check if client is running before logout @@ -50,12 +54,13 @@ func logoutMain(cmd *cobra.Command) { if err := confirmForm.Run(); err != nil { logger.Error("Error: %v", err) - return + return err } if !confirm { - logger.Info("Logout cancelled") - return + err := errors.New("logout cancelled") + logger.Info("%v", err) + return err } // Kill the client without showing TUI @@ -83,12 +88,12 @@ func logoutMain(cmd *cobra.Command) { accountStore, err := config.LoadAccountStore() if err != nil { logger.Error("Failed to load account store: %s", err) - return + return err } if accountStore.ActiveUserID == "" { logger.Success("Already logged out!") - return + return nil } // Try to logout from server (client is always initialized) @@ -100,24 +105,23 @@ func logoutMain(cmd *cobra.Command) { deletedAccount := accountStore.Accounts[accountStore.ActiveUserID] delete(accountStore.Accounts, accountStore.ActiveUserID) - // If there are still other accounts, then we need to set the active key for it. + // If there are still other accounts, then we need to set the active key again. + // Automatically set next active user ID to the first account found. if nextUserID, ok := anyKey(accountStore.Accounts); ok { accountStore.ActiveUserID = nextUserID - - // TODO: perform automatic select of account when required } else { accountStore.ActiveUserID = "" } - // Automatically set next active user ID to the first account found. - if err := accountStore.Save(); err != nil { logger.Error("Failed to save account store: %v", err) - return + return err } // Print logout message with account name logger.Success("Logged out of Pangolin account %s", deletedAccount.Email) + + return nil } func anyKey[K comparable, V any](m map[K]V) (K, bool) { diff --git a/cmd/auth/status/status.go b/cmd/auth/status/status.go index 3623623..84a131b 100644 --- a/cmd/auth/status/status.go +++ b/cmd/auth/status/status.go @@ -2,6 +2,7 @@ package status import ( "fmt" + "os" "github.com/fosrl/cli/internal/api" "github.com/fosrl/cli/internal/config" @@ -15,14 +16,16 @@ func StatusCmd() *cobra.Command { Short: "Check authentication status", Long: "Check if you are logged in and view your account information", Run: func(cmd *cobra.Command, args []string) { - statusMain(cmd) + if err := statusMain(cmd); err != nil { + os.Exit(1) + } }, } return cmd } -func statusMain(cmd *cobra.Command) { +func statusMain(cmd *cobra.Command) error { apiClient := api.FromContext(cmd.Context()) accountStore := config.AccountStoreFromContext(cmd.Context()) @@ -30,21 +33,21 @@ func statusMain(cmd *cobra.Command) { if err != nil { logger.Info("Status: %s", err) logger.Info("Run 'pangolin login' to authenticate") - return + return err } // User info exists in config, try to get user from API user, err := apiClient.GetUser() if err != nil { // Unable to get user - consider logged out (previously logged in but now not) - logger.Info("Status: Logged out: %v", err) + logger.Info("Status: logged out: %v", err) logger.Info("Your session has expired or is invalid") logger.Info("Run 'pangolin login' to authenticate again") - return + return err } // Successfully got user - logged in - logger.Success("Status: Logged in") + logger.Success("Status: logged in") // Show hostname if available logger.Info("@ %s", account.Host) fmt.Println() @@ -65,4 +68,6 @@ func statusMain(cmd *cobra.Command) { // Display organization information logger.Info("Org ID: %s", account.OrgID) + + return nil } diff --git a/cmd/down/client/client.go b/cmd/down/client/client.go index e224998..1851f76 100644 --- a/cmd/down/client/client.go +++ b/cmd/down/client/client.go @@ -1,6 +1,7 @@ package client import ( + "errors" "os" "github.com/fosrl/cli/internal/config" @@ -15,13 +16,17 @@ func ClientDownCmd() *cobra.Command { Use: "client", Short: "Stop the client connection", Long: "Stop the currently running client connection", - Run: clientDownMain, + Run: func(cmd *cobra.Command, args []string) { + if err := clientDownMain(cmd); err != nil { + os.Exit(1) + } + }, } return cmd } -func clientDownMain(cmd *cobra.Command, args []string) { +func clientDownMain(cmd *cobra.Command) error { cfg := config.ConfigFromContext(cmd.Context()) // Get socket path from config or use default @@ -29,27 +34,29 @@ func clientDownMain(cmd *cobra.Command, args []string) { // Check if client is running if !client.IsRunning() { - logger.Info("No client is currently running") - return + err := errors.New("no client is currently running") + logger.Info("Error: %v", err) + return err } // Check that the client was started by this CLI by verifying the version status, err := client.GetStatus() if err != nil { logger.Error("Failed to get client status: %v", err) - os.Exit(1) + return err } + if status.Agent != olm.AgentName { logger.Error("Client was not started by Pangolin CLI (version: %s)", status.Version) logger.Info("Only clients started by this CLI can be stopped using this command") - os.Exit(1) + return err } // Send exit signal exitResp, err := client.Exit() if err != nil { logger.Error("Error: %v", err) - os.Exit(1) + return err } // Show log preview until process stops @@ -72,7 +79,7 @@ func clientDownMain(cmd *cobra.Command, args []string) { }) if err != nil { logger.Error("Error: %v", err) - os.Exit(1) + return err } if completed { @@ -80,4 +87,6 @@ func clientDownMain(cmd *cobra.Command, args []string) { } else { logger.Info("Client shutdown initiated: %s", exitResp.Status) } + + return nil } diff --git a/cmd/logs/client/client.go b/cmd/logs/client/client.go index 7b7835f..db98a97 100644 --- a/cmd/logs/client/client.go +++ b/cmd/logs/client/client.go @@ -27,7 +27,9 @@ func ClientLogsCmd() *cobra.Command { Short: "View client logs", Long: "View client logs. Use -f to follow log output.", Run: func(cmd *cobra.Command, args []string) { - clientLogsMain(cmd, &opts) + if err := clientLogsMain(cmd, &opts); err != nil { + os.Exit(1) + } }, } @@ -37,17 +39,17 @@ func ClientLogsCmd() *cobra.Command { return cmd } -func clientLogsMain(cmd *cobra.Command, opts *ClientLogsCmdOpts) { +func clientLogsMain(cmd *cobra.Command, opts *ClientLogsCmdOpts) error { cfg := config.ConfigFromContext(cmd.Context()) if opts.Follow { // Follow the log file if err := watchLogFile(cfg.LogFile, opts.Lines); err != nil { logger.Error("Error: %v", err) - os.Exit(1) + return err } - return + return nil } // Just print the current log file contents @@ -55,15 +57,17 @@ func clientLogsMain(cmd *cobra.Command, opts *ClientLogsCmdOpts) { // Show last N lines if err := printLastLines(cfg.LogFile, opts.Lines); err != nil { logger.Error("Error: %v", err) - os.Exit(1) + return err } } else { // Show all lines if err := printLogFile(cfg.LogFile); err != nil { logger.Error("Error: %v", err) - os.Exit(1) + return err } } + + return nil } // printLogFile prints the contents of the log file diff --git a/cmd/root.go b/cmd/root.go index 22f61c6..31f534b 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -31,8 +31,9 @@ import ( // state when doing doc generation. func RootCommand(initResources bool) (*cobra.Command, error) { cmd := &cobra.Command{ - Use: "pangolin", - Short: "Pangolin CLI", + Use: "pangolin", + Short: "Pangolin CLI", + SilenceUsage: true, CompletionOptions: cobra.CompletionOptions{ HiddenDefaultCmd: true, }, diff --git a/cmd/select/account/account.go b/cmd/select/account/account.go index 3d57ad0..e4e2bb4 100644 --- a/cmd/select/account/account.go +++ b/cmd/select/account/account.go @@ -1,7 +1,9 @@ package account import ( + "errors" "fmt" + "os" "github.com/charmbracelet/huh" "github.com/fosrl/cli/internal/config" @@ -23,7 +25,9 @@ func AccountCmd() *cobra.Command { Short: "Select an account", Long: "List your logged-in accounts and select active one", Run: func(cmd *cobra.Command, args []string) { - accountMain(cmd, &opts) + if err := accountMain(cmd, &opts); err != nil { + os.Exit(1) + } }, } @@ -33,12 +37,13 @@ func AccountCmd() *cobra.Command { return cmd } -func accountMain(cmd *cobra.Command, opts *AccountCmdOpts) { +func accountMain(cmd *cobra.Command, opts *AccountCmdOpts) error { accountStore := config.AccountStoreFromContext(cmd.Context()) if len(accountStore.Accounts) == 0 { - logger.Warning("Not logged in.") - return + err := errors.New("not logged in") + logger.Error("Error: %v", err) + return err } var selectedAccount *config.Account @@ -58,15 +63,16 @@ func accountMain(cmd *cobra.Command, opts *AccountCmdOpts) { } if selectedAccount == nil { - logger.Error("No accounts found that match the search terms") - return + err := errors.New("no accounts found that match the search terms") + logger.Error("Error: %v", err) + return err } } else { // No flag provided, use GUI selection if necessary selected, err := selectAccountForm(accountStore.Accounts, opts.Host) if err != nil { - logger.Error("Failed to select account: %v", err) - return + logger.Error("Error: failed to select account: %v", err) + return err } selectedAccount = selected @@ -74,8 +80,8 @@ func accountMain(cmd *cobra.Command, opts *AccountCmdOpts) { accountStore.ActiveUserID = selectedAccount.UserID if err := accountStore.Save(); err != nil { - logger.Error("Failed to save account to store: %v", err) - return + logger.Error("Error: failed to save account to store: %v", err) + return err } // Check if olmClient is running and if we need to shut it down @@ -90,6 +96,8 @@ func accountMain(cmd *cobra.Command, opts *AccountCmdOpts) { selectedAccountStr := fmt.Sprintf("%s @ %s", selectedAccount.Email, selectedAccount.Host) logger.Success("Successfully selected account: %s", selectedAccountStr) + + return nil } // selectAccountForm lists organizations for a user and prompts them to select one. diff --git a/cmd/select/org/org.go b/cmd/select/org/org.go index ad3bc6e..f3943b4 100644 --- a/cmd/select/org/org.go +++ b/cmd/select/org/org.go @@ -2,6 +2,7 @@ package org import ( "fmt" + "os" "github.com/fosrl/cli/internal/api" "github.com/fosrl/cli/internal/config" @@ -24,7 +25,9 @@ func OrgCmd() *cobra.Command { Short: "Select an organization", Long: "List your organizations and select one to use", Run: func(cmd *cobra.Command, args []string) { - orgMain(cmd, &opts) + if err := orgMain(cmd, &opts); err != nil { + os.Exit(1) + } }, } @@ -33,7 +36,7 @@ func OrgCmd() *cobra.Command { return cmd } -func orgMain(cmd *cobra.Command, opts *OrgCmdOpts) { +func orgMain(cmd *cobra.Command, opts *OrgCmdOpts) error { apiClient := api.FromContext(cmd.Context()) accountStore := config.AccountStoreFromContext(cmd.Context()) cfg := config.ConfigFromContext(cmd.Context()) @@ -41,7 +44,7 @@ func orgMain(cmd *cobra.Command, opts *OrgCmdOpts) { activeAccount, err := accountStore.ActiveAccount() if err != nil { logger.Error("%v", err) - return + return err } userID := activeAccount.UserID @@ -54,7 +57,7 @@ func orgMain(cmd *cobra.Command, opts *OrgCmdOpts) { orgsResp, err := apiClient.ListUserOrgs(userID) if err != nil { logger.Error("Failed to list organizations: %v", err) - return + return err } // Check if the provided orgId exists in the user's organizations @@ -67,8 +70,9 @@ func orgMain(cmd *cobra.Command, opts *OrgCmdOpts) { } if !orgExists { - logger.Error("Organization '%s' not found or you don't have access to it", opts.OrgID) - return + err := fmt.Errorf("organization '%s' not found or you don't have access to it", opts.OrgID) + logger.Error("Error: %v", err) + return err } // Org exists, use it @@ -78,14 +82,14 @@ func orgMain(cmd *cobra.Command, opts *OrgCmdOpts) { selectedOrgID, err = utils.SelectOrgForm(apiClient, userID) if err != nil { logger.Error("%v", err) - return + return err } } activeAccount.OrgID = selectedOrgID if err := accountStore.Save(); err != nil { logger.Error("Failed to save account to store: %v", err) - return + return err } // Switch active client if running @@ -107,6 +111,8 @@ func orgMain(cmd *cobra.Command, opts *OrgCmdOpts) { // Client not running, no switch needed logger.Success("Successfully selected organization: %s", selectedOrgID) } + + return nil } // monitorOrgSwitch monitors the organization switch process with log preview diff --git a/cmd/status/client/client.go b/cmd/status/client/client.go index 7d5f97e..e6ddb93 100644 --- a/cmd/status/client/client.go +++ b/cmd/status/client/client.go @@ -24,7 +24,9 @@ func ClientStatusCmd() *cobra.Command { Short: "Show client status", Long: "Display current client connection status and peer information", Run: func(cmd *cobra.Command, args []string) { - clientStatusMain(&opts) + if err := clientStatusMain(&opts); err != nil { + os.Exit(1) + } }, } @@ -33,39 +35,42 @@ func ClientStatusCmd() *cobra.Command { return cmd } -func clientStatusMain(opts *ClientStatusCmdOpts) { +func clientStatusMain(opts *ClientStatusCmdOpts) error { // Get socket path from config or use default client := olm.NewClient("") // Check if client is running if !client.IsRunning() { logger.Info("No client is currently running") - return + return nil } // Get status status, err := client.GetStatus() if err != nil { logger.Error("Error: %v", err) - os.Exit(1) + return err } // Print raw JSON if flag is set, otherwise print formatted table if opts.JSON { - printJSON(status) + return printJSON(status) } else { printStatusTable(status) } + + return nil } // printJSON prints the status response as JSON -func printJSON(status *olm.StatusResponse) { +func printJSON(status *olm.StatusResponse) error { jsonData, err := json.MarshalIndent(status, "", " ") if err != nil { logger.Error("Error marshaling JSON: %v", err) - os.Exit(1) + return err } fmt.Println(string(jsonData)) + return nil } // printStatusTable prints the status information in a table format diff --git a/cmd/update/update.go b/cmd/update/update.go index deef5d5..44cfcc0 100644 --- a/cmd/update/update.go +++ b/cmd/update/update.go @@ -14,14 +14,16 @@ func UpdateCmd() *cobra.Command { Short: "Update Pangolin CLI to the latest version", Long: "Update Pangolin CLI to the latest version by downloading and running the installation script", Run: func(cmd *cobra.Command, args []string) { - updateMain() + if err := updateMain(); err != nil { + os.Exit(1) + } }, } return cmd } -func updateMain() { +func updateMain() error { logger.Info("Updating Pangolin CLI...") // Execute: curl -fsSL https://pangolin.net/get-cli.sh | bash @@ -32,8 +34,10 @@ func updateMain() { if err := updateCmd.Run(); err != nil { logger.Error("Failed to update Pangolin CLI: %v", err) - os.Exit(1) + return err } logger.Success("Pangolin CLI updated successfully!") + + return nil } From 470cdc2dc8c122f5bc7b4d116683605f2af4b593 Mon Sep 17 00:00:00 2001 From: Varun Narravula Date: Fri, 19 Dec 2025 12:10:33 -0800 Subject: [PATCH 5/5] docs: re-generate cobra markdown docs --- docs/pangolin.md | 8 +++---- docs/pangolin_auth.md | 2 +- docs/pangolin_auth_login.md | 2 +- docs/pangolin_auth_logout.md | 2 +- docs/pangolin_auth_status.md | 2 +- docs/pangolin_down.md | 9 +++++--- docs/pangolin_down_client.md | 4 ++-- docs/pangolin_login.md | 2 +- docs/pangolin_logout.md | 2 +- docs/pangolin_logs.md | 2 +- docs/pangolin_logs_client.md | 2 +- docs/pangolin_select.md | 6 +++--- docs/pangolin_select_account.md | 4 ++-- docs/pangolin_select_org.md | 8 +++---- docs/pangolin_status.md | 5 ++++- docs/pangolin_status_client.md | 2 +- docs/pangolin_up.md | 37 ++++++++++++++++++--------------- docs/pangolin_up_client.md | 32 ++++++++++++++-------------- docs/pangolin_update.md | 2 +- docs/pangolin_version.md | 2 +- 20 files changed, 72 insertions(+), 63 deletions(-) diff --git a/docs/pangolin.md b/docs/pangolin.md index 63ebdfe..7607133 100644 --- a/docs/pangolin.md +++ b/docs/pangolin.md @@ -11,14 +11,14 @@ Pangolin CLI ### SEE ALSO * [pangolin auth](pangolin_auth.md) - Authentication commands -* [pangolin down](pangolin_down.md) - Stop a client +* [pangolin down](pangolin_down.md) - Stop a connection * [pangolin login](pangolin_login.md) - Login to Pangolin * [pangolin logout](pangolin_logout.md) - Logout from Pangolin * [pangolin logs](pangolin_logs.md) - View client logs -* [pangolin select](pangolin_select.md) - Select objects to work with +* [pangolin select](pangolin_select.md) - Select account information to use * [pangolin status](pangolin_status.md) - Status commands -* [pangolin up](pangolin_up.md) - Start a client +* [pangolin up](pangolin_up.md) - Start a connection * [pangolin update](pangolin_update.md) - Update Pangolin CLI to the latest version * [pangolin version](pangolin_version.md) - Print the version number -###### Auto generated by spf13/cobra on 17-Dec-2025 +###### Auto generated by spf13/cobra on 19-Dec-2025 diff --git a/docs/pangolin_auth.md b/docs/pangolin_auth.md index a9e904c..26c7428 100644 --- a/docs/pangolin_auth.md +++ b/docs/pangolin_auth.md @@ -19,4 +19,4 @@ Manage authentication and sessions * [pangolin auth logout](pangolin_auth_logout.md) - Logout from Pangolin * [pangolin auth status](pangolin_auth_status.md) - Check authentication status -###### Auto generated by spf13/cobra on 17-Dec-2025 +###### Auto generated by spf13/cobra on 19-Dec-2025 diff --git a/docs/pangolin_auth_login.md b/docs/pangolin_auth_login.md index 2d24df8..b903b33 100644 --- a/docs/pangolin_auth_login.md +++ b/docs/pangolin_auth_login.md @@ -20,4 +20,4 @@ pangolin auth login [hostname] [flags] * [pangolin auth](pangolin_auth.md) - Authentication commands -###### Auto generated by spf13/cobra on 18-Dec-2025 +###### Auto generated by spf13/cobra on 19-Dec-2025 diff --git a/docs/pangolin_auth_logout.md b/docs/pangolin_auth_logout.md index 72b4579..78e149f 100644 --- a/docs/pangolin_auth_logout.md +++ b/docs/pangolin_auth_logout.md @@ -20,4 +20,4 @@ pangolin auth logout [flags] * [pangolin auth](pangolin_auth.md) - Authentication commands -###### Auto generated by spf13/cobra on 18-Dec-2025 +###### Auto generated by spf13/cobra on 19-Dec-2025 diff --git a/docs/pangolin_auth_status.md b/docs/pangolin_auth_status.md index 9c22139..293df0f 100644 --- a/docs/pangolin_auth_status.md +++ b/docs/pangolin_auth_status.md @@ -20,4 +20,4 @@ pangolin auth status [flags] * [pangolin auth](pangolin_auth.md) - Authentication commands -###### Auto generated by spf13/cobra on 17-Dec-2025 +###### Auto generated by spf13/cobra on 19-Dec-2025 diff --git a/docs/pangolin_down.md b/docs/pangolin_down.md index 2853c73..dc4ecfb 100644 --- a/docs/pangolin_down.md +++ b/docs/pangolin_down.md @@ -1,10 +1,13 @@ ## pangolin down -Stop a client +Stop a connection ### Synopsis -Stop a client connection +Bring down a connection. + +If ran with no subcommand, 'client' is passed. + ``` pangolin down [flags] @@ -21,4 +24,4 @@ pangolin down [flags] * [pangolin](pangolin.md) - Pangolin CLI * [pangolin down client](pangolin_down_client.md) - Stop the client connection -###### Auto generated by spf13/cobra on 17-Dec-2025 +###### Auto generated by spf13/cobra on 19-Dec-2025 diff --git a/docs/pangolin_down_client.md b/docs/pangolin_down_client.md index 96086d0..c23bc44 100644 --- a/docs/pangolin_down_client.md +++ b/docs/pangolin_down_client.md @@ -18,6 +18,6 @@ pangolin down client [flags] ### SEE ALSO -* [pangolin down](pangolin_down.md) - Stop a client +* [pangolin down](pangolin_down.md) - Stop a connection -###### Auto generated by spf13/cobra on 17-Dec-2025 +###### Auto generated by spf13/cobra on 19-Dec-2025 diff --git a/docs/pangolin_login.md b/docs/pangolin_login.md index 0c0fa5a..220a927 100644 --- a/docs/pangolin_login.md +++ b/docs/pangolin_login.md @@ -20,4 +20,4 @@ pangolin login [hostname] [flags] * [pangolin](pangolin.md) - Pangolin CLI -###### Auto generated by spf13/cobra on 17-Dec-2025 +###### Auto generated by spf13/cobra on 19-Dec-2025 diff --git a/docs/pangolin_logout.md b/docs/pangolin_logout.md index 2515a4f..055d23e 100644 --- a/docs/pangolin_logout.md +++ b/docs/pangolin_logout.md @@ -20,4 +20,4 @@ pangolin logout [flags] * [pangolin](pangolin.md) - Pangolin CLI -###### Auto generated by spf13/cobra on 17-Dec-2025 +###### Auto generated by spf13/cobra on 19-Dec-2025 diff --git a/docs/pangolin_logs.md b/docs/pangolin_logs.md index 43f2a65..a2a508d 100644 --- a/docs/pangolin_logs.md +++ b/docs/pangolin_logs.md @@ -17,4 +17,4 @@ View and follow client logs * [pangolin](pangolin.md) - Pangolin CLI * [pangolin logs client](pangolin_logs_client.md) - View client logs -###### Auto generated by spf13/cobra on 17-Dec-2025 +###### Auto generated by spf13/cobra on 19-Dec-2025 diff --git a/docs/pangolin_logs_client.md b/docs/pangolin_logs_client.md index 84053d0..1c4331c 100644 --- a/docs/pangolin_logs_client.md +++ b/docs/pangolin_logs_client.md @@ -22,4 +22,4 @@ pangolin logs client [flags] * [pangolin logs](pangolin_logs.md) - View client logs -###### Auto generated by spf13/cobra on 17-Dec-2025 +###### Auto generated by spf13/cobra on 19-Dec-2025 diff --git a/docs/pangolin_select.md b/docs/pangolin_select.md index b028043..37adc49 100644 --- a/docs/pangolin_select.md +++ b/docs/pangolin_select.md @@ -1,10 +1,10 @@ ## pangolin select -Select objects to work with +Select account information to use ### Synopsis -Select objects to work with +Select account information to use ### Options @@ -18,4 +18,4 @@ Select objects to work with * [pangolin select account](pangolin_select_account.md) - Select an account * [pangolin select org](pangolin_select_org.md) - Select an organization -###### Auto generated by spf13/cobra on 17-Dec-2025 +###### Auto generated by spf13/cobra on 19-Dec-2025 diff --git a/docs/pangolin_select_account.md b/docs/pangolin_select_account.md index 27e614f..874dfee 100644 --- a/docs/pangolin_select_account.md +++ b/docs/pangolin_select_account.md @@ -20,6 +20,6 @@ pangolin select account [flags] ### SEE ALSO -* [pangolin select](pangolin_select.md) - Select objects to work with +* [pangolin select](pangolin_select.md) - Select account information to use -###### Auto generated by spf13/cobra on 17-Dec-2025 +###### Auto generated by spf13/cobra on 19-Dec-2025 diff --git a/docs/pangolin_select_org.md b/docs/pangolin_select_org.md index e42ebb0..778d181 100644 --- a/docs/pangolin_select_org.md +++ b/docs/pangolin_select_org.md @@ -13,12 +13,12 @@ pangolin select org [flags] ### Options ``` - -h, --help help for org - --org string Organization ID to select + -h, --help help for org + --org ID Organization ID to select ``` ### SEE ALSO -* [pangolin select](pangolin_select.md) - Select objects to work with +* [pangolin select](pangolin_select.md) - Select account information to use -###### Auto generated by spf13/cobra on 17-Dec-2025 +###### Auto generated by spf13/cobra on 19-Dec-2025 diff --git a/docs/pangolin_status.md b/docs/pangolin_status.md index 4a04f7b..30f0c9f 100644 --- a/docs/pangolin_status.md +++ b/docs/pangolin_status.md @@ -6,6 +6,9 @@ Status commands View status information. +If ran with no subcommand, 'client' is passed. + + ``` pangolin status [flags] ``` @@ -22,4 +25,4 @@ pangolin status [flags] * [pangolin](pangolin.md) - Pangolin CLI * [pangolin status client](pangolin_status_client.md) - Show client status -###### Auto generated by spf13/cobra on 17-Dec-2025 +###### Auto generated by spf13/cobra on 19-Dec-2025 diff --git a/docs/pangolin_status_client.md b/docs/pangolin_status_client.md index 1de6698..9a20eeb 100644 --- a/docs/pangolin_status_client.md +++ b/docs/pangolin_status_client.md @@ -21,4 +21,4 @@ pangolin status client [flags] * [pangolin status](pangolin_status.md) - Status commands -###### Auto generated by spf13/cobra on 17-Dec-2025 +###### Auto generated by spf13/cobra on 19-Dec-2025 diff --git a/docs/pangolin_up.md b/docs/pangolin_up.md index 7fe9981..f9b4be9 100644 --- a/docs/pangolin_up.md +++ b/docs/pangolin_up.md @@ -1,10 +1,13 @@ ## pangolin up -Start a client +Start a connection ### Synopsis -Bring up a client connection +Bring up a connection. + +If ran with no subcommand, 'client' is passed. + ``` pangolin up [flags] @@ -13,24 +16,24 @@ pangolin up [flags] ### Options ``` - --attach Run in attached mode (foreground, default is detached) + --attach Run in attached (foreground) mode, (default: detached (background) mode) --endpoint string Client endpoint (required if not logged in) -h, --help help for up - --holepunch Enable holepunching (default: true) - --http-addr string HTTP address for API server (default: disabled) + --holepunch Enable holepunching (default true) + --http-addr string HTTP address for API server --id string Client ID (optional, will use user info if not provided) - --interface-name string Interface name (default: pangolin) - --log-level string Log level (default: info) - --mtu int MTU (default: 1280) - --netstack-dns string DNS server to use for Netstack (default: 8.8.8.8) - --org string Organization ID (optional, will use selected org if not provided) - --override-dns Override system DNS for resolving internal resource alias (default: true) (default true) - --ping-interval string Ping interval (default: 5s) - --ping-timeout string Ping timeout (default: 5s) + --interface-name name Interface name (default "pangolin") + --log-level string Log level (default "info") + --mtu int Maximum transmission unit (default 1280) + --netstack-dns server DNS server to use for Netstack (default "8.8.8.8") + --org string Organization ID (default: selected organization if logged in) + --override-dns Override system DNS for resolving internal resource alias (default true) + --ping-interval interval Ping interval (default 5s) + --ping-timeout timeout Ping timeout (default 5s) --secret string Client secret (optional, will use user info if not provided) - --silent Disable TUI and run silently (only applies to detached mode) - --tls-client-cert string TLS client certificate path - --upstream-dns strings Comma separated list of DNS servers to use for external DNS resolution if overriding system DNS (default: 8.8.8.8) + --silent Disable TUI and run silently when detached + --tls-client-cert path TLS client certificate path + --upstream-dns strings List of DNS servers to use for external DNS resolution if overriding system DNS (default [8.8.8.8]) ``` ### SEE ALSO @@ -38,4 +41,4 @@ pangolin up [flags] * [pangolin](pangolin.md) - Pangolin CLI * [pangolin up client](pangolin_up_client.md) - Start a client connection -###### Auto generated by spf13/cobra on 17-Dec-2025 +###### Auto generated by spf13/cobra on 19-Dec-2025 diff --git a/docs/pangolin_up_client.md b/docs/pangolin_up_client.md index 92dbe0a..df5f98a 100644 --- a/docs/pangolin_up_client.md +++ b/docs/pangolin_up_client.md @@ -13,28 +13,28 @@ pangolin up client [flags] ### Options ``` - --attach Run in attached mode (foreground, default is detached) + --attach Run in attached (foreground) mode, (default: detached (background) mode) --endpoint string Client endpoint (required if not logged in) -h, --help help for client - --holepunch Enable holepunching (default: true) - --http-addr string HTTP address for API server (default: disabled) + --holepunch Enable holepunching (default true) + --http-addr string HTTP address for API server --id string Client ID (optional, will use user info if not provided) - --interface-name string Interface name (default: pangolin) - --log-level string Log level (default: info) - --mtu int MTU (default: 1280) - --netstack-dns string DNS server to use for Netstack (default: 8.8.8.8) - --org string Organization ID (optional, will use selected org if not provided) - --override-dns Override system DNS for resolving internal resource alias (default: true) (default true) - --ping-interval string Ping interval (default: 5s) - --ping-timeout string Ping timeout (default: 5s) + --interface-name name Interface name (default "pangolin") + --log-level string Log level (default "info") + --mtu int Maximum transmission unit (default 1280) + --netstack-dns server DNS server to use for Netstack (default "8.8.8.8") + --org string Organization ID (default: selected organization if logged in) + --override-dns Override system DNS for resolving internal resource alias (default true) + --ping-interval interval Ping interval (default 5s) + --ping-timeout timeout Ping timeout (default 5s) --secret string Client secret (optional, will use user info if not provided) - --silent Disable TUI and run silently (only applies to detached mode) - --tls-client-cert string TLS client certificate path - --upstream-dns strings Comma separated list of DNS servers to use for external DNS resolution if overriding system DNS (default: 8.8.8.8) + --silent Disable TUI and run silently when detached + --tls-client-cert path TLS client certificate path + --upstream-dns strings List of DNS servers to use for external DNS resolution if overriding system DNS (default [8.8.8.8]) ``` ### SEE ALSO -* [pangolin up](pangolin_up.md) - Start a client +* [pangolin up](pangolin_up.md) - Start a connection -###### Auto generated by spf13/cobra on 17-Dec-2025 +###### Auto generated by spf13/cobra on 19-Dec-2025 diff --git a/docs/pangolin_update.md b/docs/pangolin_update.md index c9d6e3e..8b1a214 100644 --- a/docs/pangolin_update.md +++ b/docs/pangolin_update.md @@ -20,4 +20,4 @@ pangolin update [flags] * [pangolin](pangolin.md) - Pangolin CLI -###### Auto generated by spf13/cobra on 17-Dec-2025 +###### Auto generated by spf13/cobra on 19-Dec-2025 diff --git a/docs/pangolin_version.md b/docs/pangolin_version.md index 68c167b..dfb20e5 100644 --- a/docs/pangolin_version.md +++ b/docs/pangolin_version.md @@ -20,4 +20,4 @@ pangolin version [flags] * [pangolin](pangolin.md) - Pangolin CLI -###### Auto generated by spf13/cobra on 17-Dec-2025 +###### Auto generated by spf13/cobra on 19-Dec-2025