diff --git a/cmd/auth/login/login.go b/cmd/auth/login/login.go index 33689a5..4fe5a6c 100644 --- a/cmd/auth/login/login.go +++ b/cmd/auth/login/login.go @@ -4,17 +4,16 @@ import ( "bufio" "fmt" "os" - "path/filepath" "strings" "time" "github.com/charmbracelet/huh" "github.com/fosrl/cli/internal/api" - "github.com/fosrl/cli/internal/secrets" + "github.com/fosrl/cli/internal/config" + "github.com/fosrl/cli/internal/logger" "github.com/fosrl/cli/internal/utils" "github.com/pkg/browser" "github.com/spf13/cobra" - "github.com/spf13/viper" ) type HostingOption string @@ -72,8 +71,8 @@ func loginWithWeb(hostname string) (string, error) { loginURL := fmt.Sprintf("%s?code=%s", baseLoginURL, code) // Display code and instructions (similar to GH CLI format) - utils.Info("First copy your one-time code: %s", code) - utils.Info("Press Enter to open %s in your browser...", baseLoginURL) + logger.Info("First copy your one-time code: %s", code) + logger.Info("Press Enter to open %s in your browser...", baseLoginURL) // Wait for Enter in a goroutine (non-blocking) and open browser when pressed go func() { @@ -83,8 +82,8 @@ func loginWithWeb(hostname string) (string, error) { // User pressed Enter, open browser if err := browser.OpenURL(loginURL); err != nil { // Don't fail if browser can't be opened, just warn - utils.Warning("Failed to open browser automatically") - utils.Info("Please manually visit: %s", baseLoginURL) + logger.Warning("Failed to open browser automatically") + logger.Info("Please manually visit: %s", baseLoginURL) } } }() @@ -97,26 +96,26 @@ func loginWithWeb(hostname string) (string, error) { var token string for { - //print - utils.Debug("Polling for device web auth verification...") + // print + logger.Debug("Polling for device web auth verification...") // Check if code has expired if time.Now().After(expiresAt) { - utils.Error("Device web auth code has expired") + logger.Error("Device web auth code has expired") return "", fmt.Errorf("code expired. Please try again") } // Check if we've exceeded max polling duration if time.Since(startTime) > maxPollDuration { - utils.Error("Polling timed out after %v", maxPollDuration) + logger.Error("Polling timed out after %v", maxPollDuration) return "", fmt.Errorf("polling timeout. Please try again") } // Poll for verification status pollResp, message, err := api.PollDeviceWebAuth(loginClient, code) // print debug info - utils.Debug("Polling response: %+v, message: %s, err: %v", pollResp, message, err) + logger.Debug("Polling response: %+v, message: %s, err: %v", pollResp, message, err) if err != nil { - utils.Error("Error polling device web auth: %v", err) + logger.Error("Error polling device web auth: %v", err) return "", fmt.Errorf("failed to poll device web auth: %w", err) } @@ -124,7 +123,7 @@ func loginWithWeb(hostname string) (string, error) { if pollResp.Verified { token = pollResp.Token if token == "" { - utils.Error("Verification succeeded but no token received") + logger.Error("Verification succeeded but no token received") return "", fmt.Errorf("verification succeeded but no token received") } return token, nil @@ -132,7 +131,7 @@ func loginWithWeb(hostname string) (string, error) { // Check for expired or not found messages if message == "Code expired" || message == "Code not found" { - utils.Error("Device web auth code has expired or not found") + logger.Error("Device web auth code has expired or not found") return "", fmt.Errorf("code expired or not found. Please try again") } @@ -147,17 +146,8 @@ var LoginCmd = &cobra.Command{ Long: "Interactive login to select your hosting option and configure access.", Args: cobra.MaximumNArgs(1), Run: func(cmd *cobra.Command, args []string) { - // Check if user is already logged in - if err := utils.EnsureLoggedIn(); err == nil { - // User is logged in, show error with account info - email := viper.GetString("email") - var accountInfo string - if email != "" { - accountInfo = fmt.Sprintf(" (%s)", email) - } - utils.Error("You are already logged in%s. Please logout first using 'pangolin logout'", accountInfo) - return - } + apiClient := api.FromContext(cmd.Context()) + accountStore := config.AccountStoreFromContext(cmd.Context()) var hostingOption HostingOption var hostname string @@ -183,7 +173,7 @@ var LoginCmd = &cobra.Command{ ) if err := form.Run(); err != nil { - utils.Error("Error: %v", err) + logger.Error("Error: %v", err) return } @@ -199,7 +189,7 @@ var LoginCmd = &cobra.Command{ ) if err := hostnameForm.Run(); err != nil { - utils.Error("Error: %v", err) + logger.Error("Error: %v", err) return } } else { @@ -216,79 +206,81 @@ var LoginCmd = &cobra.Command{ hostname = "https://" + hostname } - // Store hostname in viper config (with protocol) - viper.Set("hostname", hostname) - - // Ensure config type is set and file path is correct - if viper.ConfigFileUsed() == "" { - // Config file doesn't exist yet, set the full path - // Get .pangolin directory and ensure it exists - pangolinDir, err := utils.GetPangolinDir() - if err == nil { - viper.SetConfigFile(filepath.Join(pangolinDir, "pangolin.json")) - viper.SetConfigType("json") - } - } - - if err := viper.WriteConfig(); err != nil { - // If config file doesn't exist, create it - if err := viper.SafeWriteConfig(); err != nil { - utils.Warning("Failed to save hostname to config: %v", err) - } - } - // Perform web login sessionToken, err := loginWithWeb(hostname) - if err != nil { - utils.Error("%v", err) + logger.Error("%v", err) return } if sessionToken == "" { - utils.Error("Login appeared successful but no session token was received.") - return - } - - // Save session token to config - if err := secrets.SaveSessionToken(sessionToken); err != nil { - utils.Error("Failed to save session token: %v", err) + 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" - api.GlobalClient.SetBaseURL(apiBaseURL) - api.GlobalClient.SetToken(sessionToken) + apiClient.SetBaseURL(apiBaseURL) + apiClient.SetToken(sessionToken) - utils.Success("Device authorized") + logger.Success("Device authorized") fmt.Println() // Get user information var user *api.User - user, err = api.GlobalClient.GetUser() + user, err = apiClient.GetUser() if err != nil { - utils.Warning("Failed to get user information: %v", err) - } else { - // Store userId and email in viper config - viper.Set("userId", user.UserID) - viper.Set("email", user.Email) - if err := viper.WriteConfig(); err != nil { - utils.Warning("Failed to save user information to config: %v", err) - } + logger.Error("Failed to get user information: %v", err) + return // FIXME: handle errors properly with exit codes! + } - // Ensure OLM credentials exist and are valid - userID := user.UserID - if err := utils.EnsureOlmCredentials(userID); err != nil { - utils.Warning("Failed to ensure OLM credentials: %v", err) - } + 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 + + 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 + } + + 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 + + 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.SelectOrg(user.UserID); err != nil { - utils.Warning("%v", err) + if _, err := utils.SelectOrgForm(apiClient, user.UserID); err != nil { + logger.Warning("%v", err) } } @@ -299,7 +291,7 @@ var LoginCmd = &cobra.Command{ displayName = *user.Username } if displayName != "" { - utils.Success("Logged in as %s", displayName) + logger.Success("Logged in as %s", displayName) } } }, diff --git a/cmd/auth/logout/logout.go b/cmd/auth/logout/logout.go index 40f9fcd..76b4af2 100644 --- a/cmd/auth/logout/logout.go +++ b/cmd/auth/logout/logout.go @@ -5,11 +5,10 @@ import ( "github.com/charmbracelet/huh" "github.com/fosrl/cli/internal/api" + "github.com/fosrl/cli/internal/config" + "github.com/fosrl/cli/internal/logger" "github.com/fosrl/cli/internal/olm" - "github.com/fosrl/cli/internal/secrets" - "github.com/fosrl/cli/internal/utils" "github.com/spf13/cobra" - "github.com/spf13/viper" ) var LogoutCmd = &cobra.Command{ @@ -17,13 +16,15 @@ var LogoutCmd = &cobra.Command{ 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 { - utils.Warning("Failed to get client status: %v", err) + 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 @@ -39,19 +40,19 @@ var LogoutCmd = &cobra.Command{ ) if err := confirmForm.Run(); err != nil { - utils.Error("Error: %v", err) + logger.Error("Error: %v", err) return } if !confirm { - utils.Info("Logout cancelled") + logger.Info("Logout cancelled") return } // Kill the client without showing TUI _, err := olmClient.Exit() if err != nil { - utils.Warning("Failed to send exit signal to client: %v", err) + 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 @@ -62,7 +63,7 @@ var LogoutCmd = &cobra.Command{ elapsed += pollInterval } if olmClient.IsRunning() { - utils.Warning("Client did not stop within timeout") + logger.Warning("Client did not stop within timeout") } } } @@ -70,60 +71,51 @@ var LogoutCmd = &cobra.Command{ } // Check if there's an active session in the key store - _, err := secrets.GetSessionToken() + accountStore, err := config.LoadAccountStore() if err != nil { - // No session found - user is already logged out - utils.Success("Already logged out!") + logger.Error("Failed to load account store: %s", err) return } - // Get user info before clearing config - accountName := viper.GetString("email") - if accountName == "" { - // Try to get username from API as fallback - if user, err := api.GlobalClient.GetUser(); err == nil { - if user.Username != nil && *user.Username != "" { - accountName = *user.Username - } else if user.Email != "" { - accountName = user.Email - } - } + if accountStore.ActiveUserID == "" { + logger.Success("Already logged out!") + return } // Try to logout from server (client is always initialized) - if err := api.GlobalClient.Logout(); err != nil { + if err := apiClient.Logout(); err != nil { // Ignore logout errors - we'll still clear local data - utils.Debug("Failed to logout from server: %v", err) + logger.Debug("Failed to logout from server: %v", err) } - // Clear session token from config - if err := secrets.DeleteSessionToken(); err != nil { - // Ignore error if token doesn't exist (already logged out) - utils.Error("Failed to delete session token: %v", err) - return - } + deletedAccount := accountStore.Accounts[accountStore.ActiveUserID] + delete(accountStore.Accounts, accountStore.ActiveUserID) - // Clear user-specific config values - viper.Set("userId", "") - viper.Set("email", "") - viper.Set("orgId", "") + // 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 err := viper.WriteConfig(); err != nil { - utils.Error("Failed to clear config: %v", err) - return + // TODO: perform automatic select of account when required + } else { + accountStore.ActiveUserID = "" } - // Re-initialize the global client without a token - if err := api.InitGlobalClient(); err != nil { - // This should never happen, but log it - utils.Warning("Failed to re-initialize API client: %v", err) + // 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 } // Print logout message with account name - if accountName != "" { - utils.Success("Logged out of Pangolin account %s", accountName) - } else { - utils.Success("Logged out of Pangolin account") - } + logger.Success("Logged out of Pangolin account %s", deletedAccount.Email) }, } + +func anyKey[K comparable, V any](m map[K]V) (K, bool) { + var zero K + for k := range m { + return k, true + } + return zero, false +} diff --git a/cmd/auth/status/status.go b/cmd/auth/status/status.go index 28a10df..f5daac4 100644 --- a/cmd/auth/status/status.go +++ b/cmd/auth/status/status.go @@ -4,9 +4,9 @@ import ( "fmt" "github.com/fosrl/cli/internal/api" - "github.com/fosrl/cli/internal/utils" + "github.com/fosrl/cli/internal/config" + "github.com/fosrl/cli/internal/logger" "github.com/spf13/cobra" - "github.com/spf13/viper" ) var StatusCmd = &cobra.Command{ @@ -14,34 +14,30 @@ var 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) { - // Check if user info exists in config - userID := viper.GetString("userId") - email := viper.GetString("email") + apiClient := api.FromContext(cmd.Context()) + accountStore := config.AccountStoreFromContext(cmd.Context()) - // If no user info in config, user is not logged in (never logged in) - if userID == "" && email == "" { - utils.Info("Status: Not logged in") - utils.Info("Run 'pangolin login' to authenticate") + 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 := api.GlobalClient.GetUser() + user, err := apiClient.GetUser() if err != nil { // Unable to get user - consider logged out (previously logged in but now not) - utils.Info("Status: Logged out") - utils.Info("Your session has expired or is invalid") - utils.Info("Run 'pangolin login' to authenticate again") + 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 - utils.Success("Status: Logged in") + logger.Success("Status: Logged in") // Show hostname if available - hostname := viper.GetString("hostname") - if hostname != "" { - utils.Info("@ %s", hostname) - } + logger.Info("@ %s", account.Host) fmt.Println() // Display user information @@ -51,19 +47,14 @@ var StatusCmd = &cobra.Command{ } else if user.Name != nil && *user.Name != "" { displayName = *user.Name } - if displayName != "" { - utils.Info("User: %s", displayName) + logger.Info("User: %s", displayName) } if user.UserID != "" { - utils.Info("User ID: %s", user.UserID) + logger.Info("User ID: %s", user.UserID) } - fmt.Println() // Display organization information - orgID := viper.GetString("orgId") - if orgID != "" { - utils.Info("Org ID: %s", orgID) - } + logger.Info("Org ID: %s", account.OrgID) }, } diff --git a/cmd/down/client.go b/cmd/down/client.go index d811f6a..4cc8b04 100644 --- a/cmd/down/client.go +++ b/cmd/down/client.go @@ -3,9 +3,10 @@ package down import ( "os" + "github.com/fosrl/cli/internal/config" + "github.com/fosrl/cli/internal/logger" "github.com/fosrl/cli/internal/olm" "github.com/fosrl/cli/internal/tui" - "github.com/fosrl/cli/internal/utils" "github.com/spf13/cobra" ) @@ -14,40 +15,39 @@ var ClientCmd = &cobra.Command{ Short: "Stop the client connection", Long: "Stop the currently running client connection", Run: func(cmd *cobra.Command, args []string) { + cfg := config.ConfigFromContext(cmd.Context()) + // Get socket path from config or use default client := olm.NewClient("") // Check if client is running if !client.IsRunning() { - utils.Info("No client is currently running") + logger.Info("No client is currently running") return } - // Get log file path (same as up client) - logFile := utils.GetDefaultLogPath() - // Check that the client was started by this CLI by verifying the version status, err := client.GetStatus() if err != nil { - utils.Error("Failed to get client status: %v", err) + logger.Error("Failed to get client status: %v", err) os.Exit(1) } if status.Agent != olm.AgentName { - utils.Error("Client was not started by Pangolin CLI (version: %s)", status.Version) - utils.Info("Only clients started by this CLI can be stopped using this command") + 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) } // Send exit signal exitResp, err := client.Exit() if err != nil { - utils.Error("Error: %v", err) + logger.Error("Error: %v", err) os.Exit(1) } // Show log preview until process stops completed, err := tui.NewLogPreview(tui.LogPreviewConfig{ - LogFile: logFile, + 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) @@ -64,14 +64,14 @@ var ClientCmd = &cobra.Command{ }, }) if err != nil { - utils.Error("Error: %v", err) + logger.Error("Error: %v", err) os.Exit(1) } if completed { - utils.Success("Client shutdown completed") + logger.Success("Client shutdown completed") } else { - utils.Info("Client shutdown initiated: %s", exitResp.Status) + logger.Info("Client shutdown initiated: %s", exitResp.Status) } }, } diff --git a/cmd/down/down.go b/cmd/down/down.go index 2122cd3..56c413c 100644 --- a/cmd/down/down.go +++ b/cmd/down/down.go @@ -11,6 +11,7 @@ var DownCmd = &cobra.Command{ 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) }, } diff --git a/cmd/logs/client.go b/cmd/logs/client.go index 752314f..c48a9f4 100644 --- a/cmd/logs/client.go +++ b/cmd/logs/client.go @@ -9,7 +9,8 @@ import ( "syscall" "time" - "github.com/fosrl/cli/internal/utils" + "github.com/fosrl/cli/internal/config" + "github.com/fosrl/cli/internal/logger" "github.com/spf13/cobra" ) @@ -23,26 +24,26 @@ var clientLogsCmd = &cobra.Command{ Short: "View client logs", Long: "View client logs. Use -f to follow log output.", Run: func(cmd *cobra.Command, args []string) { - logPath := utils.GetDefaultLogPath() + cfg := config.ConfigFromContext(cmd.Context()) if flagFollow { // Follow the log file - if err := watchLogFile(logPath, flagLines); err != nil { - utils.Error("Error: %v", err) + 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(logPath, flagLines); err != nil { - utils.Error("Error: %v", err) + if err := printLastLines(cfg.LogFile, flagLines); err != nil { + logger.Error("Error: %v", err) os.Exit(1) } } else { // Show all lines - if err := printLogFile(logPath); err != nil { - utils.Error("Error: %v", err) + if err := printLogFile(cfg.LogFile); err != nil { + logger.Error("Error: %v", err) os.Exit(1) } } diff --git a/cmd/root.go b/cmd/root.go index c4c17de..b24d6a6 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -1,9 +1,10 @@ package cmd import ( + "context" "fmt" "os" - "strings" + "path/filepath" "github.com/fosrl/cli/cmd/auth" "github.com/fosrl/cli/cmd/auth/login" @@ -16,102 +17,139 @@ import ( "github.com/fosrl/cli/cmd/update" "github.com/fosrl/cli/cmd/version" "github.com/fosrl/cli/internal/api" - "github.com/fosrl/cli/internal/utils" + "github.com/fosrl/cli/internal/config" + "github.com/fosrl/cli/internal/logger" versionpkg "github.com/fosrl/cli/internal/version" "github.com/spf13/cobra" - "github.com/spf13/viper" ) -var cfgFile string - -var rootCmd = &cobra.Command{ - Use: "pangolin", - Short: "Pangolin CLI", - PersistentPreRun: func(cmd *cobra.Command, args []string) { - // Skip update check for version and update commands - // Check both the command name and if it's one of these specific commands - cmdName := cmd.Name() - if cmdName == "version" || cmdName == "update" { - return - } - // Also check if this is the version or update command object - if cmd == version.VersionCmd || cmd == update.UpdateCmd { - return - } +// Initialize a root Cobra command. +// +// Set initResources to false when generating documentation to avoid +// parsing configuration files and instantiating the API client, among +// other such external resources. This is to avoid depending on external +// state when doing doc generation. +func RootCommand(initResources bool) (*cobra.Command, error) { + cmd := &cobra.Command{ + Use: "pangolin", + Short: "Pangolin CLI", + CompletionOptions: cobra.CompletionOptions{ + HiddenDefaultCmd: true, + }, + PersistentPreRunE: mainCommandPreRun, + } - // Check for updates asynchronously - versionpkg.CheckForUpdateAsync(func(release *versionpkg.GitHubRelease) { - utils.Warning("A new version is available: %s (current: %s)", release.TagName, versionpkg.Version) - utils.Info("Run 'pangolin update' to update to the latest version") - fmt.Println() - }) - }, -} + 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) -// Execute is called by main.go -func Execute() { - if err := rootCmd.Execute(); err != nil { - os.Exit(1) + if !initResources { + return cmd, nil + } + + cfg, err := config.LoadConfig() + if err != nil { + return nil, err + } + + if err := cfg.Validate(); err != nil { + return nil, err + } + + accountStore, err := config.LoadAccountStore() + if err != nil { + return nil, err + } + + var apiBaseURL string + var sessionToken string + + if activeAccount, _ := accountStore.ActiveAccount(); activeAccount != nil { + apiBaseURL = activeAccount.Host + sessionToken = activeAccount.SessionToken + } else { + apiBaseURL = "" + sessionToken = "" } -} -// GetRootCmd returns the root command for documentation generation -func GetRootCmd() *cobra.Command { - return rootCmd + client, err := api.InitClient(apiBaseURL, sessionToken) + if err != nil { + return nil, err + } + + ctx := context.Background() + ctx = api.WithAPIClient(ctx, client) + ctx = config.WithAccountStore(ctx, accountStore) + ctx = config.WithConfig(ctx, cfg) + + cmd.SetContext(ctx) + + return cmd, nil } -func init() { - cobra.OnInitialize(initConfig) - - // Register verb commands - rootCmd.AddCommand(auth.AuthCmd) - rootCmd.AddCommand(selectcmd.SelectCmd) - rootCmd.AddCommand(up.UpCmd) - rootCmd.AddCommand(down.DownCmd) - rootCmd.AddCommand(logs.LogsCmd) - rootCmd.AddCommand(status.StatusCmd) - rootCmd.AddCommand(update.UpdateCmd) - rootCmd.AddCommand(version.VersionCmd) - - // Add login and logout as top-level aliases - rootCmd.AddCommand(login.LoginCmd) - rootCmd.AddCommand(logout.LogoutCmd) - - // Hide the completion command - rootCmd.CompletionOptions.HiddenDefaultCmd = true +func mainCommandPreRun(cmd *cobra.Command, args []string) error { + cfg := config.ConfigFromContext(cmd.Context()) + + // Skip init/update check for version and update commands + // Check both the command name and if it's one of these specific commands + cmdName := cmd.Name() + if cmdName == "version" || cmdName == "update" { + return nil + } + + ensureRuntimeDirs(cfg) + + // Check for updates asynchronously + if !cfg.DisableUpdateCheck { + versionpkg.CheckForUpdateAsync(func(release *versionpkg.GitHubRelease) { + logger.Warning("A new version is available: %s (current: %s)", release.TagName, versionpkg.Version) + logger.Info("Run 'pangolin update' to update to the latest version") + fmt.Println() + }) + } + + return nil } -func initConfig() { - if cfgFile != "" { - viper.SetConfigFile(cfgFile) +// Make sure all required directories exist once +// before executing any subcommands. +func ensureRuntimeDirs(cfg *config.Config) { + configDir, err := config.GetPangolinConfigDir() + if err != nil { + logger.Warning("failed to create pangolin configuration directory: %v", err) } else { - // Get .pangolin directory and ensure it exists - pangolinDir, err := utils.GetPangolinDir() + err = os.MkdirAll(configDir, 0o755) if err != nil { - // Fallback to $HOME/.pangolin if we can't determine original user - viper.AddConfigPath("$HOME/.pangolin") - } else { - viper.AddConfigPath(pangolinDir) + logger.Warning("failed to create %s: %v", configDir, err) } - viper.SetConfigName("pangolin") - viper.SetConfigType("json") } - viper.AutomaticEnv() // read env variables - // Initialize logger (must be done before any logging) - utils.InitLogger() + if cfg.LogFile != "" { + logPathDirname := filepath.Dir(cfg.LogFile) - if err := viper.ReadInConfig(); err != nil { - // Only warn if it's not a "file not found" error (which is expected for new users) - if !strings.Contains(err.Error(), "Not Found") { - utils.Warning("Failed to read config file: %v", err) + err = os.MkdirAll(logPathDirname, 0o755) + if err != nil { + logger.Warning("failed to create %s: %v", logPathDirname, err) } } +} + +// Execute is called by main.go +func Execute() { + cmd, err := RootCommand(true) + if err != nil { + logger.Error("%v", err) + os.Exit(1) + } - // Initialize API client (always succeeds - may be unauthenticated) - if err := api.InitGlobalClient(); err != nil { - // This should never happen, but log it just in case - utils.Error("Failed to initialize API client: %v", err) + if err := cmd.Execute(); err != nil { os.Exit(1) } } diff --git a/cmd/select/account/account.go b/cmd/select/account/account.go new file mode 100644 index 0000000..b2c32f7 --- /dev/null +++ b/cmd/select/account/account.go @@ -0,0 +1,138 @@ +package account + +import ( + "fmt" + + "github.com/charmbracelet/huh" + "github.com/fosrl/cli/internal/config" + "github.com/fosrl/cli/internal/logger" + "github.com/fosrl/cli/internal/olm" + "github.com/spf13/cobra" +) + +var ( + accountToSelect string + hostToSelect 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()) + + 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 accountToSelect != "" { + for _, account := range accountStore.Accounts { + if hostToSelect != "" && hostToSelect != account.Host { + continue + } + + if accountToSelect == account.Email { + selectedAccount = &account + break + } + } + + 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 + } + + 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) + }, +} + +// 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) { + var filteredAccounts []*config.Account + for _, account := range accounts { + if hostToSelect == "" || hostToSelect == account.Host { + filteredAccounts = append(filteredAccounts, &account) + } + } + + if len(filteredAccounts) == 0 { + return nil, fmt.Errorf("no accounts found that match the query") + } + + if len(filteredAccounts) == 1 { + // Auto-select the first account + for _, account := range filteredAccounts { + return account, nil + } + } + + type accountOption struct { + Account *config.Account + Label string + } + + var orgOptions []huh.Option[accountOption] + for _, account := range filteredAccounts { + label := fmt.Sprintf("%s @ %s", account.Email, account.Host) + orgOptions = append(orgOptions, huh.NewOption(label, accountOption{ + Account: account, + Label: label, + })) + } + + var selectedAccountOption accountOption + orgSelectForm := huh.NewForm( + huh.NewGroup( + huh.NewSelect[accountOption](). + Title("Select an account"). + Options(orgOptions...). + Value(&selectedAccountOption), + ), + ) + + if err := orgSelectForm.Run(); err != nil { + return nil, fmt.Errorf("error running account selection form: %w", 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.go b/cmd/select/org/org.go similarity index 60% rename from cmd/select/org.go rename to cmd/select/org/org.go index 79b9923..2cc0d1b 100644 --- a/cmd/select/org.go +++ b/cmd/select/org/org.go @@ -1,41 +1,44 @@ -package selectcmd +package org import ( "fmt" "github.com/fosrl/cli/internal/api" + "github.com/fosrl/cli/internal/config" + "github.com/fosrl/cli/internal/logger" "github.com/fosrl/cli/internal/olm" "github.com/fosrl/cli/internal/tui" "github.com/fosrl/cli/internal/utils" "github.com/spf13/cobra" - "github.com/spf13/viper" ) var flagOrgID string -var orgCmd = &cobra.Command{ +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) { - // Check if user is logged in - if err := utils.EnsureLoggedIn(); err != nil { - utils.Error("%v", err) + 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 - } - // Get userId from config - userID := viper.GetString("userId") + } + userID := activeAccount.UserID - var orgID string - var err error + var selectedOrgID string // Check if --org-id flag is provided if flagOrgID != "" { // Validate that the org exists - orgsResp, err := api.GlobalClient.ListUserOrgs(userID) + orgsResp, err := apiClient.ListUserOrgs(userID) if err != nil { - utils.Error("Failed to list organizations: %v", err) + logger.Error("Failed to list organizations: %v", err) return } @@ -49,55 +52,51 @@ var orgCmd = &cobra.Command{ } if !orgExists { - utils.Error("Organization '%s' not found or you don't have access to it", flagOrgID) + logger.Error("Organization '%s' not found or you don't have access to it", flagOrgID) return } // Org exists, use it - orgID = flagOrgID - - // Save to config - viper.Set("orgId", orgID) - if err := viper.WriteConfig(); err != nil { - utils.Error("Failed to save organization to config: %v", err) - return - } + selectedOrgID = flagOrgID } else { // No flag provided, use GUI selection - orgID, err = utils.SelectOrg(userID) + selectedOrgID, err = utils.SelectOrgForm(apiClient, userID) if err != nil { - utils.Error("%v", err) + 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(orgID) + utils.SwitchActiveClientOrg(selectedOrgID) - // Check if client is running and if we need to monitor a switch - client := olm.NewClient("") - if client.IsRunning() { + // 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 := client.GetStatus() - if err == nil && currentStatus != nil && currentStatus.OrgID != orgID { + currentStatus, err := olmClient.GetStatus() + if err == nil && currentStatus != nil && currentStatus.OrgID != selectedOrgID { // Switch was sent, monitor the switch process - monitorOrgSwitch(orgID) + monitorOrgSwitch(cfg.LogFile, selectedOrgID) } else { // Already on the correct org or no status available - utils.Success("Successfully selected organization: %s", orgID) + logger.Success("Successfully selected organization: %s", selectedOrgID) } } else { // Client not running, no switch needed - utils.Success("Successfully selected organization: %s", orgID) + logger.Success("Successfully selected organization: %s", selectedOrgID) } }, } // monitorOrgSwitch monitors the organization switch process with log preview -func monitorOrgSwitch(orgID string) { - // Get log file path - logFile := utils.GetDefaultLogPath() - +func monitorOrgSwitch(logFile string, orgID string) { // Show live log preview and status during switch completed, err := tui.NewLogPreview(tui.LogPreviewConfig{ LogFile: logFile, @@ -127,13 +126,12 @@ func monitorOrgSwitch(orgID string) { // Clear the TUI lines after completion if completed { - utils.Success("Successfully switched organization to: %s", orgID) + logger.Success("Successfully switched organization to: %s", orgID) } else if err != nil { - utils.Warning("Failed to monitor organization switch: %v", err) + logger.Warning("Failed to monitor organization switch: %v", err) } } func init() { - orgCmd.Flags().StringVar(&flagOrgID, "org", "", "Organization ID to select") - SelectCmd.AddCommand(orgCmd) + OrgCmd.Flags().StringVar(&flagOrgID, "org", "", "Organization ID to select") } diff --git a/cmd/select/select.go b/cmd/select/select.go index fdedba6..43b2957 100644 --- a/cmd/select/select.go +++ b/cmd/select/select.go @@ -1,11 +1,18 @@ package selectcmd import ( + "github.com/fosrl/cli/cmd/select/account" + "github.com/fosrl/cli/cmd/select/org" "github.com/spf13/cobra" ) var SelectCmd = &cobra.Command{ Use: "select", - Short: "Select organization", - Long: "Select an organization to work with", + Short: "Select objects to work with", + Long: "Select objects to work with", +} + +func init() { + SelectCmd.AddCommand(account.AccountCmd) + SelectCmd.AddCommand(org.OrgCmd) } diff --git a/cmd/status/client.go b/cmd/status/client.go index edf0cdf..7aa1f4e 100644 --- a/cmd/status/client.go +++ b/cmd/status/client.go @@ -6,14 +6,13 @@ import ( "os" "time" + "github.com/fosrl/cli/internal/logger" "github.com/fosrl/cli/internal/olm" "github.com/fosrl/cli/internal/utils" "github.com/spf13/cobra" ) -var ( - flagJSON bool -) +var flagJSON bool var ClientCmd = &cobra.Command{ Use: "client", @@ -25,14 +24,14 @@ var ClientCmd = &cobra.Command{ // Check if client is running if !client.IsRunning() { - utils.Info("No client is currently running") + logger.Info("No client is currently running") return } // Get status status, err := client.GetStatus() if err != nil { - utils.Error("Error: %v", err) + logger.Error("Error: %v", err) os.Exit(1) } @@ -58,7 +57,7 @@ func init() { func printJSON(status *olm.StatusResponse) { jsonData, err := json.MarshalIndent(status, "", " ") if err != nil { - utils.Error("Error marshaling JSON: %v", err) + logger.Error("Error marshaling JSON: %v", err) os.Exit(1) } fmt.Println(string(jsonData)) diff --git a/cmd/up/client.go b/cmd/up/client.go index 7bb63c7..d2b0cb8 100644 --- a/cmd/up/client.go +++ b/cmd/up/client.go @@ -13,15 +13,16 @@ import ( "syscall" "time" + "github.com/fosrl/cli/internal/api" + "github.com/fosrl/cli/internal/config" + "github.com/fosrl/cli/internal/logger" "github.com/fosrl/cli/internal/olm" - "github.com/fosrl/cli/internal/secrets" "github.com/fosrl/cli/internal/tui" "github.com/fosrl/cli/internal/utils" versionpkg "github.com/fosrl/cli/internal/version" - "github.com/fosrl/newt/logger" + newtLogger "github.com/fosrl/newt/logger" olmpkg "github.com/fosrl/olm/olm" "github.com/spf13/cobra" - "github.com/spf13/viper" ) const ( @@ -63,22 +64,24 @@ var ClientCmd = &cobra.Command{ 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" { - utils.Error("Windows is not supported") + logger.Error("Windows is not supported") os.Exit(1) } // Check if a client is already running olmClient := olm.NewClient("") if olmClient.IsRunning() { - utils.Info("A client is already running") + logger.Info("A client is already running") os.Exit(1) } var olmID, olmSecret string var credentialsFromKeyring bool - var userID string if flagID != "" && flagSecret != "" { // Use provided flags - no user session needed, continue even if not logged in @@ -88,60 +91,58 @@ var ClientCmd = &cobra.Command{ credentialsFromKeyring = false } else if flagID != "" || flagSecret != "" { // If only one flag is provided, require both - utils.Error("Both --id and --secret must be provided together") + logger.Error("Both --id and --secret must be provided together") os.Exit(1) } else { - // No flags provided - assume user is logged in and use credentials from config - // Ensure user is logged in (this also verifies user exists via API) - if err := utils.EnsureLoggedIn(); err != nil { - utils.Error("%v", err) + activeAccount, err := accountStore.ActiveAccount() + if err != nil { + logger.Error("Error: %v. Run `pangolin login` to login", err) os.Exit(1) } - // Get userId from viper (required for OLM credentials lookup) - userID = viper.GetString("userId") - if userID == "" { - utils.Error("Please log in first. Run `pangolin login` to login") + // 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) } - // Ensure OLM credentials exist and are valid - var err error - if err = utils.EnsureOlmCredentials(userID); err != nil { - utils.Error("Failed to ensure OLM credentials: %v", err) - os.Exit(1) + if newCredsGenerated { + err := accountStore.Save() + if err != nil { + logger.Error("Failed to save accounts to store: %v", err) + os.Exit(1) + } } - // Get OLM credentials from config (they should exist after EnsureOlmCredentials) - olmID, olmSecret, err = secrets.GetOlmCredentials(userID) + olmID = activeAccount.OlmCredentials.ID + olmSecret = activeAccount.OlmCredentials.Secret + if err != nil { - utils.Error("Failed to get OLM credentials: %v", err) + logger.Error("Failed to get OLM credentials: %v", err) os.Exit(1) } credentialsFromKeyring = true } + orgID := flagOrgID + // Get orgId from flag or viper (required for OLM config when using logged-in user) - var orgID string if credentialsFromKeyring { + activeAccount, _ := accountStore.ActiveAccount() + // When using credentials from keyring, orgID is required - orgID = flagOrgID if orgID == "" { - orgID = viper.GetString("orgId") + orgID = activeAccount.OrgID } + if orgID == "" { - utils.Error("Please select an organization first. Run `pangolin select org` to select an organization or pass --org [id] to the command") + 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) } - } else { - // When using id/secret directly, orgID is optional (may come from credentials) - orgID = flagOrgID - } - // Ensure org access (only when using logged-in user, not when credentials come from flags) - if credentialsFromKeyring && userID != "" { - if err := utils.EnsureOrgAccess(orgID, userID); err != nil { - utils.Error("%v", err) + if err := utils.EnsureOrgAccess(apiClient, activeAccount); err != nil { + logger.Error("%v", err) os.Exit(1) } } @@ -149,7 +150,15 @@ var ClientCmd = &cobra.Command{ // Handle log file setup - if detached mode, always use log file var logFile string if !flagAttached { - logFile = utils.GetDefaultLogPath() + logFile = cfg.LogFile + } + + endpoint := flagEndpoint + if endpoint == "" { + activeAccount, _ := accountStore.ActiveAccount() + if activeAccount != nil { + endpoint = activeAccount.Host + } } // Handle detached mode - subprocess self without --attach flag @@ -158,7 +167,7 @@ var ClientCmd = &cobra.Command{ if !flagAttached && !isRunningAsRoot { executable, err := os.Executable() if err != nil { - utils.Error("Error: failed to get executable path: %v", err) + logger.Error("Error: failed to get executable path: %v", err) os.Exit(1) } @@ -166,15 +175,7 @@ var ClientCmd = &cobra.Command{ cmdArgs := []string{"up", "client"} // Add org flag (required for subprocess, which runs as root and won't have user's config) - // Use flag value if provided, otherwise use the resolved orgID - // Only add org flag if credentials came from keyring (not when id/secret are provided directly) - if credentialsFromKeyring { - if flagOrgID != "" { - cmdArgs = append(cmdArgs, "--org", flagOrgID) - } else { - cmdArgs = append(cmdArgs, "--org", 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) @@ -183,15 +184,8 @@ var ClientCmd = &cobra.Command{ // 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) - endpoint := flagEndpoint if endpoint == "" { - // Check if hostname is actually set in config (not just using default) - if hostname := viper.GetString("hostname"); hostname != "" { - endpoint = hostname - } - } - if endpoint == "" { - utils.Error("Endpoint is required. Please login with a host or provide --endpoint flag") + logger.Error("Endpoint is required. Please login with a host or provide --endpoint flag") os.Exit(1) } cmdArgs = append(cmdArgs, "--endpoint", endpoint) @@ -271,20 +265,20 @@ var ClientCmd = &cobra.Command{ procCmd.Stdout = nil procCmd.Stderr = os.Stderr } else { - utils.Error("Windows is not supported for detached mode") + logger.Error("Windows is not supported for detached mode") os.Exit(1) } // Start the process if err := procCmd.Start(); err != nil { - utils.Error("Error: failed to start detached process: %v", err) + logger.Error("Error: failed to start detached process: %v", err) os.Exit(1) } // 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 { - utils.Error("Error: failed to start subprocess: %v", err) + logger.Error("Error: failed to start subprocess: %v", err) os.Exit(1) } @@ -320,17 +314,17 @@ var ClientCmd = &cobra.Command{ }, }) if err != nil { - utils.Error("Error: %v", err) + logger.Error("Error: %v", err) os.Exit(1) } // Check if the process completed successfully or was killed if !completed { // User exited early - subprocess was killed - utils.Info("Client process killed") + logger.Info("Client process killed") } else { // Completed successfully - utils.Success("Client interface created successfully") + logger.Success("Client interface created successfully") } os.Exit(0) } @@ -375,22 +369,14 @@ var ClientCmd = &cobra.Command{ } d, err := time.ParseDuration(durationStr) if err != nil { - utils.Warning("Invalid duration format '%s', using default: %v", durationStr, defaultDuration) + logger.Warning("Invalid duration format '%s', using default: %v", durationStr, defaultDuration) return defaultDuration } return d } - // Get endpoint from flag or config - required - endpoint := flagEndpoint if endpoint == "" { - // Check if hostname is actually set in config (not just using default) - if hostname := viper.GetString("hostname"); hostname != "" { - endpoint = hostname - } - } - if endpoint == "" { - utils.Error("Endpoint is required. Please provide --endpoint flag or set hostname in config") + logger.Error("Endpoint is required. Please provide --endpoint flag or set hostname in config") os.Exit(1) } @@ -441,8 +427,8 @@ var ClientCmd = &cobra.Command{ // Setup log file if specified if logFile != "" { - if err := setupLogFile(logFile); err != nil { - utils.Error("Error: failed to setup log file: %v", err) + if err := setupLogFile(cfg.LogFile); err != nil { + logger.Error("Error: failed to setup log file: %v", err) os.Exit(1) } } @@ -455,12 +441,13 @@ var ClientCmd = &cobra.Command{ credentialsFromKeyringEnv := os.Getenv("PANGOLIN_CREDENTIALS_FROM_KEYRING") if credentialsFromKeyringEnv == "1" || credentialsFromKeyring { // Credentials came from config, fetch userToken from secrets - token, err := secrets.GetSessionToken() + activeAccount, err := accountStore.ActiveAccount() if err != nil { - utils.Warning("Failed to get session token: %v", err) - } else { - userToken = token + logger.Error("Failed to get session token: %v", err) + return } + + userToken = activeAccount.SessionToken } // Create context for signal handling and cleanup @@ -477,17 +464,17 @@ var ClientCmd = &cobra.Command{ Version: version, Agent: defaultAgent, OnTerminated: func() { - utils.Info("Client process terminated") + logger.Info("Client process terminated") stop() os.Exit(0) }, OnAuthError: func(statusCode int, message string) { - utils.Error("Authentication error: %d %s", statusCode, message) + logger.Error("Authentication error: %d %s", statusCode, message) stop() os.Exit(1) }, OnExit: func() { - utils.Info("Client process exiting") + logger.Info("Client process exiting") os.Exit(0) }, } @@ -517,8 +504,8 @@ var ClientCmd = &cobra.Command{ // This check is only for attached mode; in detached mode, the subprocess runs elevated if runtime.GOOS != "windows" { if os.Geteuid() != 0 { - utils.Error("This command requires elevated permissions for network interface creation.") - utils.Info("Please run with sudo or use detached mode (default) to run the subprocess elevated.") + 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) } } @@ -562,28 +549,23 @@ func init() { // setupLogFile sets up file logging with rotation func setupLogFile(logPath string) error { - // Create log directory if it doesn't exist logDir := filepath.Dir(logPath) - err := os.MkdirAll(logDir, 0755) - if err != nil { - return fmt.Errorf("failed to create log directory: %v", err) - } // Rotate log file if needed - err = rotateLogFile(logDir, logPath) + err := rotateLogFile(logDir, logPath) if err != nil { // Log warning but continue log.Printf("Warning: failed to rotate log file: %v", err) } // Open log file for appending - file, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666) + file, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o666) if err != nil { return fmt.Errorf("failed to open log file: %v", err) } // Set the logger output - logger.GetLogger().SetOutput(file) + newtLogger.GetLogger().SetOutput(file) // log.Printf("Logging to file: %s", logPath) return nil diff --git a/cmd/up/up.go b/cmd/up/up.go index 335267c..01b2d8e 100644 --- a/cmd/up/up.go +++ b/cmd/up/up.go @@ -12,6 +12,8 @@ var UpCmd = &cobra.Command{ Short: "Start a client", Long: "Bring up a client connection", Run: func(cmd *cobra.Command, args []string) { + // _ = api.FromContext(cmd.Context()) + // 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) { @@ -26,6 +28,7 @@ var UpCmd = &cobra.Command{ ClientCmd.Flags().Set(flag.Name, flag.Value.String()) } }) + ClientCmd.SetContext(cmd.Context()) ClientCmd.Run(ClientCmd, args) }, } diff --git a/cmd/update/update.go b/cmd/update/update.go index 9d679e3..44e2e1b 100644 --- a/cmd/update/update.go +++ b/cmd/update/update.go @@ -4,7 +4,7 @@ import ( "os" "os/exec" - "github.com/fosrl/cli/internal/utils" + "github.com/fosrl/cli/internal/logger" "github.com/spf13/cobra" ) @@ -13,20 +13,19 @@ var 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) { - utils.Info("Updating Pangolin CLI...") - + 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 { - utils.Error("Failed to update Pangolin CLI: %v", err) + logger.Error("Failed to update Pangolin CLI: %v", err) os.Exit(1) } - - utils.Success("Pangolin CLI updated successfully!") + + logger.Success("Pangolin CLI updated successfully!") }, } - diff --git a/cmd/version/version.go b/cmd/version/version.go index 5d3077d..b12f087 100644 --- a/cmd/version/version.go +++ b/cmd/version/version.go @@ -3,7 +3,7 @@ package version import ( "fmt" - "github.com/fosrl/cli/internal/utils" + "github.com/fosrl/cli/internal/logger" versionpkg "github.com/fosrl/cli/internal/version" "github.com/spf13/cobra" ) @@ -23,12 +23,12 @@ var VersionCmd = &cobra.Command{ } if latest != nil { - utils.Warning("\nA new version is available: %s (current: %s)", latest.TagName, versionpkg.Version) + logger.Warning("\nA new version is available: %s (current: %s)", latest.TagName, versionpkg.Version) if latest.URL != "" { - utils.Info("Release: %s", latest.URL) + logger.Info("Release: %s", latest.URL) } fmt.Println() - utils.Info("Run 'pangolin update' to update to the latest version") + logger.Info("Run 'pangolin update' to update to the latest version") } }, } diff --git a/docs/pangolin.md b/docs/pangolin.md index 7e0e4ac..63ebdfe 100644 --- a/docs/pangolin.md +++ b/docs/pangolin.md @@ -15,10 +15,10 @@ Pangolin CLI * [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 organization +* [pangolin select](pangolin_select.md) - Select objects to work with * [pangolin status](pangolin_status.md) - Status commands * [pangolin up](pangolin_up.md) - Start a client * [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 16-Dec-2025 +###### Auto generated by spf13/cobra on 17-Dec-2025 diff --git a/docs/pangolin_auth.md b/docs/pangolin_auth.md index 3be414e..a9e904c 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 16-Dec-2025 +###### Auto generated by spf13/cobra on 17-Dec-2025 diff --git a/docs/pangolin_auth_status.md b/docs/pangolin_auth_status.md index ab81211..9c22139 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 16-Dec-2025 +###### Auto generated by spf13/cobra on 17-Dec-2025 diff --git a/docs/pangolin_down.md b/docs/pangolin_down.md index d290907..2853c73 100644 --- a/docs/pangolin_down.md +++ b/docs/pangolin_down.md @@ -21,4 +21,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 16-Dec-2025 +###### Auto generated by spf13/cobra on 17-Dec-2025 diff --git a/docs/pangolin_down_client.md b/docs/pangolin_down_client.md index c988dff..96086d0 100644 --- a/docs/pangolin_down_client.md +++ b/docs/pangolin_down_client.md @@ -20,4 +20,4 @@ pangolin down client [flags] * [pangolin down](pangolin_down.md) - Stop a client -###### Auto generated by spf13/cobra on 16-Dec-2025 +###### Auto generated by spf13/cobra on 17-Dec-2025 diff --git a/docs/pangolin_login.md b/docs/pangolin_login.md index 0706969..0c0fa5a 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 16-Dec-2025 +###### Auto generated by spf13/cobra on 17-Dec-2025 diff --git a/docs/pangolin_logout.md b/docs/pangolin_logout.md index 2586ad8..2515a4f 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 16-Dec-2025 +###### Auto generated by spf13/cobra on 17-Dec-2025 diff --git a/docs/pangolin_logs.md b/docs/pangolin_logs.md index f5df019..43f2a65 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 16-Dec-2025 +###### Auto generated by spf13/cobra on 17-Dec-2025 diff --git a/docs/pangolin_logs_client.md b/docs/pangolin_logs_client.md index 7f480d0..84053d0 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 16-Dec-2025 +###### Auto generated by spf13/cobra on 17-Dec-2025 diff --git a/docs/pangolin_select.md b/docs/pangolin_select.md index dbf06a7..b028043 100644 --- a/docs/pangolin_select.md +++ b/docs/pangolin_select.md @@ -1,10 +1,10 @@ ## pangolin select -Select organization +Select objects to work with ### Synopsis -Select an organization to work with +Select objects to work with ### Options @@ -15,6 +15,7 @@ Select an organization to work with ### SEE ALSO * [pangolin](pangolin.md) - Pangolin CLI +* [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 16-Dec-2025 +###### Auto generated by spf13/cobra on 17-Dec-2025 diff --git a/docs/pangolin_select_account.md b/docs/pangolin_select_account.md new file mode 100644 index 0000000..27e614f --- /dev/null +++ b/docs/pangolin_select_account.md @@ -0,0 +1,25 @@ +## pangolin select account + +Select an account + +### Synopsis + +List your logged-in accounts and select active one + +``` +pangolin select account [flags] +``` + +### Options + +``` + -a, --account string Account to select + -h, --help help for account + --host string Pangolin host where account is located +``` + +### SEE ALSO + +* [pangolin select](pangolin_select.md) - Select objects to work with + +###### Auto generated by spf13/cobra on 17-Dec-2025 diff --git a/docs/pangolin_select_org.md b/docs/pangolin_select_org.md index ac87a41..e42ebb0 100644 --- a/docs/pangolin_select_org.md +++ b/docs/pangolin_select_org.md @@ -19,6 +19,6 @@ pangolin select org [flags] ### SEE ALSO -* [pangolin select](pangolin_select.md) - Select organization +* [pangolin select](pangolin_select.md) - Select objects to work with -###### Auto generated by spf13/cobra on 16-Dec-2025 +###### Auto generated by spf13/cobra on 17-Dec-2025 diff --git a/docs/pangolin_status.md b/docs/pangolin_status.md index c153759..4a04f7b 100644 --- a/docs/pangolin_status.md +++ b/docs/pangolin_status.md @@ -22,4 +22,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 16-Dec-2025 +###### Auto generated by spf13/cobra on 17-Dec-2025 diff --git a/docs/pangolin_status_client.md b/docs/pangolin_status_client.md index deb8b27..1de6698 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 16-Dec-2025 +###### Auto generated by spf13/cobra on 17-Dec-2025 diff --git a/docs/pangolin_up.md b/docs/pangolin_up.md index e6caf74..7fe9981 100644 --- a/docs/pangolin_up.md +++ b/docs/pangolin_up.md @@ -38,4 +38,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 16-Dec-2025 +###### Auto generated by spf13/cobra on 17-Dec-2025 diff --git a/docs/pangolin_up_client.md b/docs/pangolin_up_client.md index 98d31d7..92dbe0a 100644 --- a/docs/pangolin_up_client.md +++ b/docs/pangolin_up_client.md @@ -37,4 +37,4 @@ pangolin up client [flags] * [pangolin up](pangolin_up.md) - Start a client -###### Auto generated by spf13/cobra on 16-Dec-2025 +###### Auto generated by spf13/cobra on 17-Dec-2025 diff --git a/docs/pangolin_update.md b/docs/pangolin_update.md index 22d8951..c9d6e3e 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 16-Dec-2025 +###### Auto generated by spf13/cobra on 17-Dec-2025 diff --git a/docs/pangolin_version.md b/docs/pangolin_version.md index 8e7d55f..68c167b 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 16-Dec-2025 +###### Auto generated by spf13/cobra on 17-Dec-2025 diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..580e0af --- /dev/null +++ b/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1765772535, + "narHash": "sha256-aq+dQoaPONOSjtFIBnAXseDm9TUhIbe215TPmkfMYww=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "09b8fda8959d761445f12b55f380d90375a1d6bb", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..5837cdd --- /dev/null +++ b/flake.nix @@ -0,0 +1,37 @@ +{ + description = "pangolin-cli - a VPN client for pangolin"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; + }; + + outputs = {nixpkgs, ...}: let + supportedSystems = [ + "x86_64-linux" + "aarch64-linux" + "x86_64-darwin" + "aarch64-darwin" + ]; + forAllSystems = nixpkgs.lib.genAttrs supportedSystems; + pkgsFor = system: nixpkgs.legacyPackages.${system}; + in { + devShells = forAllSystems ( + system: let + pkgs = pkgsFor system; + + inherit + (pkgs) + go + golangci-lint + ; + in { + default = pkgs.mkShell { + buildInputs = [ + go + golangci-lint + ]; + }; + } + ); + }; +} diff --git a/internal/api/context.go b/internal/api/context.go new file mode 100644 index 0000000..109783f --- /dev/null +++ b/internal/api/context.go @@ -0,0 +1,19 @@ +package api + +import "context" + +type apiClientCtxKeyType string + +const apiClientCtxKey apiClientCtxKeyType = "apiClient" + +func WithAPIClient(ctx context.Context, client *Client) context.Context { + return context.WithValue(ctx, apiClientCtxKey, client) +} + +func FromContext(ctx context.Context) *Client { + logger, ok := ctx.Value(apiClientCtxKey).(*Client) + if !ok { + panic("apiClient not present in context") + } + return logger +} diff --git a/internal/api/global.go b/internal/api/global.go index 0b76149..5ad7758 100644 --- a/internal/api/global.go +++ b/internal/api/global.go @@ -3,25 +3,12 @@ package api import ( "fmt" "strings" - - "github.com/fosrl/cli/internal/secrets" - "github.com/spf13/viper" ) -var GlobalClient *Client - -// InitGlobalClient initializes the global API client with stored credentials. -// The client will be created without authentication if no token is found. -func InitGlobalClient() error { - // Get hostname from viper config - hostname := viper.GetString("hostname") - if hostname == "" { - hostname = "app.pangolin.net" - } - - // Get session token from config (ignore errors - just use empty token if not found) - token, _ := secrets.GetSessionToken() - +// InitClient initializes a new API client with stored credentials and +// a URL. The client will be created without authentication if no token +// is found. +func InitClient(hostname string, token string) (*Client, error) { // Build base URL (hostname should already include protocol from login) baseURL := hostname if !strings.HasPrefix(baseURL, "http://") && !strings.HasPrefix(baseURL, "https://") { @@ -39,9 +26,8 @@ func InitGlobalClient() error { CSRFToken: "x-csrf-protection", }) if err != nil { - return fmt.Errorf("failed to create API client: %w", err) + return nil, fmt.Errorf("failed to create API client: %w", err) } - GlobalClient = client - return nil + return client, nil } diff --git a/internal/config/accounts.go b/internal/config/accounts.go new file mode 100644 index 0000000..b31676c --- /dev/null +++ b/internal/config/accounts.go @@ -0,0 +1,97 @@ +package config + +import ( + "errors" + "os" + "path/filepath" + + "github.com/spf13/viper" +) + +type AccountStore struct { + // All operations must happen to the configuration file, + // so they must operate on separate Viper instances. + v *viper.Viper + + ActiveUserID string `mapstructure:"activeUserId" json:"activeUserId"` + Accounts map[string]Account `mapstructure:"accounts" json:"accounts"` +} + +type Account struct { + UserID string `mapstructure:"userId" json:"userId"` + Host string `mapstructure:"host" json:"host"` + Email string `mapstructure:"email" json:"email"` + SessionToken string `mapstructure:"sessionToken" json:"sessionToken"` + OrgID string `mapstructure:"orgId" json:"orgId,omitempty"` + OlmCredentials *OlmCredentials `mapstructure:"olmCredentials" json:"olmCredentials,omitempty"` +} + +type OlmCredentials struct { + ID string `mapstructure:"id" json:"id"` + Secret string `mapstructure:"secret" json:"secret"` +} + +func newAccountViper() (*viper.Viper, error) { + v := viper.New() + + dir, err := GetPangolinConfigDir() + if err != nil { + return nil, err + } + + accountsFile := filepath.Join(dir, "accounts.json") + v.SetConfigFile(accountsFile) + v.SetConfigType("json") + + return v, nil +} + +func LoadAccountStore() (*AccountStore, error) { + v, err := newAccountViper() + if err != nil { + return nil, err + } + + store := AccountStore{ + v: v, + ActiveUserID: "", + Accounts: map[string]Account{}, + } + + if err := v.ReadInConfig(); err != nil { + if errors.Is(err, os.ErrNotExist) { + return &store, nil + } + return nil, err + } + + if err := v.Unmarshal(&store); err != nil { + return nil, err + } + + return &store, nil +} + +func (s *AccountStore) ActiveAccount() (*Account, error) { + if s.ActiveUserID == "" { + return nil, errors.New("not logged in") + } + + activeAccount, exists := s.Accounts[s.ActiveUserID] + if !exists { + return nil, errors.New("active account missing") + } + + return &activeAccount, nil +} + +func (s *AccountStore) Save() error { + // HACK: If there's a better way to write the config all at once + // without having to specify each toplevel struct key, that + // would be preferable. + // However, this is fine for now. + s.v.Set("activeUserId", s.ActiveUserID) + s.v.Set("accounts", s.Accounts) + + return s.v.WriteConfig() +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..0ab8385 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,136 @@ +package config + +import ( + "errors" + "fmt" + "os" + "os/user" + "path/filepath" + "strings" + + "github.com/fosrl/cli/internal/logger" + "github.com/spf13/viper" +) + +type Config struct { + // All operations must happen to the configuration file, + // so they must operate on separate Viper instances. + v *viper.Viper + + LogLevel logger.LogLevel `mapstructure:"log_level" json:"log_level"` + LogFile string `mapstructure:"log_file" json:"log_file"` + DisableUpdateCheck bool `mapstructure:"disable_update_check" json:"disable_update_check"` +} + +func newConfigViper() (*viper.Viper, error) { + v := viper.New() + + dir, err := GetPangolinConfigDir() + if err != nil { + return nil, err + } + + // Bind to environment variables of the same name + v.SetEnvPrefix("PANGOLIN_CLI") + v.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) + v.AutomaticEnv() + + configFile := filepath.Join(dir, "config.json") + v.SetConfigFile(configFile) + v.SetConfigType("json") + + defaultLogPath := defaultLogPath() + + // Defaults + v.SetDefault("log_level", "info") + v.SetDefault("log_file", defaultLogPath) + v.SetDefault("disable_update_check", false) + + return v, nil +} + +func LoadConfig() (*Config, error) { + v, err := newConfigViper() + if err != nil { + return nil, err + } + + cfg := Config{v: v} + + if err := v.ReadInConfig(); err != nil { + if errors.Is(err, os.ErrNotExist) { + if err := v.Unmarshal(&cfg); err != nil { + return nil, err + } + + return &cfg, nil + } + + return nil, err + } + + if err := v.Unmarshal(&cfg); err != nil { + return nil, err + } + + return &cfg, nil +} + +func (c *Config) Validate() error { + switch c.LogLevel { + case logger.LogLevelDebug, logger.LogLevelInfo: + return nil + default: + return fmt.Errorf("invalid log level: %v", c.LogLevel) + } +} + +func (c *Config) Save() error { + c.v.Set("log_level", c.LogLevel) + c.v.Set("log_file", c.LogFile) + c.v.Set("disable_update_check", c.DisableUpdateCheck) + + return c.v.WriteConfig() +} + +// GetPangolinConfigDir returns the path to the .pangolin directory and ensures it exists +func GetPangolinConfigDir() (string, error) { + homeDir, err := userHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get home directory: %w", err) + } + + pangolinDir := filepath.Join(homeDir, ".config", "pangolin") + + return pangolinDir, nil +} + +// userHomeDir returns the home directory of the original user +// (the user who invoked the command, not the effective user when running with sudo). +// This ensures that config files work both with and without sudo. +func userHomeDir() (string, error) { + // Check if we're running under sudo - SUDO_USER contains the original user + sudoUser := os.Getenv("SUDO_USER") + if sudoUser != "" { + // We're running with sudo, get the original user's home directory + u, err := user.Lookup(sudoUser) + if err != nil { + return "", fmt.Errorf("failed to lookup original user %s: %w", sudoUser, err) + } + return u.HomeDir, nil + } + + // Not running with sudo, use current user's home directory + return os.UserHomeDir() +} + +// defaultLogPath returns the default log file path for client logs +func defaultLogPath() string { + pangolinDir, err := GetPangolinConfigDir() + if err != nil { + return "/tmp/olm.log" + } + + logsDir := filepath.Join(pangolinDir, "logs") + return filepath.Join(logsDir, "client.log") +} diff --git a/internal/config/context.go b/internal/config/context.go new file mode 100644 index 0000000..0b4befb --- /dev/null +++ b/internal/config/context.go @@ -0,0 +1,35 @@ +package config + +import "context" + +type accountStoreCtxKeyType string + +const accountStoreCtxKey accountStoreCtxKeyType = "accountStore" + +type configCtxKeyType string + +const configCtxKey configCtxKeyType = "configuration" + +func WithAccountStore(ctx context.Context, store *AccountStore) context.Context { + return context.WithValue(ctx, accountStoreCtxKey, store) +} + +func AccountStoreFromContext(ctx context.Context) *AccountStore { + logger, ok := ctx.Value(accountStoreCtxKey).(*AccountStore) + if !ok { + panic("accountStore not present in context") + } + return logger +} + +func WithConfig(ctx context.Context, store *Config) context.Context { + return context.WithValue(ctx, configCtxKey, store) +} + +func ConfigFromContext(ctx context.Context) *Config { + logger, ok := ctx.Value(configCtxKey).(*Config) + if !ok { + panic("configuration not present in context") + } + return logger +} diff --git a/internal/utils/logger.go b/internal/logger/logger.go similarity index 55% rename from internal/utils/logger.go rename to internal/logger/logger.go index 8640f3f..44744f1 100644 --- a/internal/utils/logger.go +++ b/internal/logger/logger.go @@ -1,15 +1,34 @@ -package utils +package logger import ( "fmt" "os" - "path/filepath" "strings" "github.com/charmbracelet/lipgloss" - "github.com/spf13/viper" ) +// Color represents a lipgloss color ID +type Color string + +const ( + // Standard colors + ColorInfo Color = "6" // Cyan (ANSI 36) + ColorDebug Color = "248" // Light gray (ANSI 90) + ColorSuccess Color = "46" // Bright green (ANSI 32) + ColorWarning Color = "220" // Yellow/Orange (ANSI 33) + ColorError Color = "1" // Red (ANSI 31) + + // Gray scale + ColorDarkGray Color = "240" // Dark gray + ColorLightGray Color = "248" // Light gray +) + +// String returns the color ID as a string +func (c Color) String() string { + return string(c) +} + // LogLevel represents the logging level type LogLevel string @@ -20,18 +39,18 @@ const ( var ( // Icons - iconInfo = "ℹ" - iconDebug = "⚙" - iconSuccess = "✓" - iconWarning = "⚠" - iconError = "✗" + // iconInfo = "ℹ" + iconDebug = "⚙" + // iconSuccess = "✓" + // iconWarning = "⚠" + // iconError = "✗" // Color styles using lipgloss - colorInfoStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorInfo)) - colorDebugStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorDebug)) - colorSuccessStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorSuccess)) - colorWarningStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorWarning)) - colorErrorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorError)) + // colorInfoStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorInfo)) + colorDebugStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorDebug)) + // colorSuccessStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorSuccess)) + // colorWarningStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorWarning)) + // colorErrorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorError)) ) // Logger is a simple logger wrapper around fmt.Printf @@ -42,20 +61,7 @@ type Logger struct { var globalLogger *Logger // InitLogger initializes the global logger with log level from viper config -func InitLogger() { - levelStr := viper.GetString("log_level") - if levelStr == "" { - levelStr = string(LogLevelInfo) - } - - levelStr = strings.ToLower(strings.TrimSpace(levelStr)) - level := LogLevel(levelStr) - - // Validate log level, default to info if invalid - if level != LogLevelDebug && level != LogLevelInfo { - level = LogLevelInfo - } - +func InitLogger(level LogLevel) { globalLogger = &Logger{ level: level, } @@ -64,13 +70,13 @@ func InitLogger() { // GetLogger returns the global logger instance func GetLogger() *Logger { if globalLogger == nil { - InitLogger() + InitLogger(LogLevelInfo) } return globalLogger } // Info logs an info message -func (l *Logger) Info(format string, args ...interface{}) { +func (l *Logger) Info(format string, args ...any) { message := fmt.Sprintf(format, args...) fmt.Printf("%s", message) if !strings.HasSuffix(message, "\n") { @@ -79,7 +85,7 @@ func (l *Logger) Info(format string, args ...interface{}) { } // Debug logs a debug message (only if log level is debug) -func (l *Logger) Debug(format string, args ...interface{}) { +func (l *Logger) Debug(format string, args ...any) { if l.level != LogLevelDebug { return } @@ -92,7 +98,7 @@ func (l *Logger) Debug(format string, args ...interface{}) { } // Success logs a success message -func (l *Logger) Success(format string, args ...interface{}) { +func (l *Logger) Success(format string, args ...any) { message := fmt.Sprintf(format, args...) // icon := colorSuccessStyle.Render(iconSuccess) fmt.Printf("%s", message) @@ -102,7 +108,7 @@ func (l *Logger) Success(format string, args ...interface{}) { } // Error logs an error message -func (l *Logger) Error(format string, args ...interface{}) { +func (l *Logger) Error(format string, args ...any) { message := fmt.Sprintf(format, args...) // icon := colorErrorStyle.Render(iconError) fmt.Fprintf(os.Stderr, "%s", message) @@ -112,7 +118,7 @@ func (l *Logger) Error(format string, args ...interface{}) { } // Warning logs a warning message -func (l *Logger) Warning(format string, args ...interface{}) { +func (l *Logger) Warning(format string, args ...any) { message := fmt.Sprintf(format, args...) // icon := colorWarningStyle.Render(iconWarning) fmt.Printf("%s", message) @@ -124,38 +130,26 @@ func (l *Logger) Warning(format string, args ...interface{}) { // Package-level convenience functions that use the global logger // Info logs an info message using the global logger -func Info(format string, args ...interface{}) { +func Info(format string, args ...any) { GetLogger().Info(format, args...) } // Debug logs a debug message using the global logger -func Debug(format string, args ...interface{}) { +func Debug(format string, args ...any) { GetLogger().Debug(format, args...) } // Success logs a success message using the global logger -func Success(format string, args ...interface{}) { +func Success(format string, args ...any) { GetLogger().Success(format, args...) } // Error logs an error message using the global logger -func Error(format string, args ...interface{}) { +func Error(format string, args ...any) { GetLogger().Error(format, args...) } // Warning logs a warning message using the global logger -func Warning(format string, args ...interface{}) { +func Warning(format string, args ...any) { GetLogger().Warning(format, args...) } - -// GetDefaultLogPath returns the default log file path for client logs -func GetDefaultLogPath() string { - pangolinDir, err := GetPangolinDir() - if err != nil { - return "/tmp/olm.log" - } - // Ensure logs subdirectory exists - logsDir := filepath.Join(pangolinDir, "logs") - os.MkdirAll(logsDir, 0755) - return filepath.Join(logsDir, "client.log") -} diff --git a/internal/secrets/secrets.go b/internal/secrets/secrets.go deleted file mode 100644 index 3c7f9f1..0000000 --- a/internal/secrets/secrets.go +++ /dev/null @@ -1,61 +0,0 @@ -package secrets - -import ( - "fmt" - - "github.com/spf13/viper" -) - -// SaveSessionToken saves the session token to the config file -func SaveSessionToken(token string) error { - viper.Set("sessionToken", token) - return viper.WriteConfig() -} - -// GetSessionToken retrieves the session token from the config file -func GetSessionToken() (string, error) { - token := viper.GetString("sessionToken") - if token == "" { - return "", fmt.Errorf("session token not found") - } - return token, nil -} - -// DeleteSessionToken deletes the session token from the config file -func DeleteSessionToken() error { - viper.Set("sessionToken", "") - return viper.WriteConfig() -} - -// SaveOlmCredentials saves OLM credentials to the config file -func SaveOlmCredentials(userID, olmID, secret string) error { - if userID == "" { - return fmt.Errorf("userId is required to save OLM credentials") - } - viper.Set(fmt.Sprintf("olmCredentials.%s.id", userID), olmID) - viper.Set(fmt.Sprintf("olmCredentials.%s.secret", userID), secret) - return viper.WriteConfig() -} - -// GetOlmCredentials retrieves OLM credentials from the config file -func GetOlmCredentials(userID string) (string, string, error) { - if userID == "" { - return "", "", fmt.Errorf("userId is required to get OLM credentials") - } - olmID := viper.GetString(fmt.Sprintf("olmCredentials.%s.id", userID)) - secret := viper.GetString(fmt.Sprintf("olmCredentials.%s.secret", userID)) - if olmID == "" || secret == "" { - return "", "", fmt.Errorf("OLM credentials not found for user %s", userID) - } - return olmID, secret, nil -} - -// DeleteOlmCredentials deletes OLM credentials from the config file -func DeleteOlmCredentials(userID string) error { - if userID == "" { - return fmt.Errorf("userId is required to delete OLM credentials") - } - viper.Set(fmt.Sprintf("olmCredentials.%s.id", userID), "") - viper.Set(fmt.Sprintf("olmCredentials.%s.secret", userID), "") - return viper.WriteConfig() -} diff --git a/internal/tui/logpreview.go b/internal/tui/logpreview.go index 9a09e76..2272e56 100644 --- a/internal/tui/logpreview.go +++ b/internal/tui/logpreview.go @@ -9,8 +9,8 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/fosrl/cli/internal/logger" "github.com/fosrl/cli/internal/olm" - "github.com/fosrl/cli/internal/utils" ) // ExitCondition is a function that determines if the preview should exit @@ -147,7 +147,7 @@ func (m *logPreviewModel) View() string { // Styles logStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color(utils.ColorLightGray)) + Foreground(lipgloss.Color(logger.ColorLightGray)) // Header sb.WriteString(m.config.Header) @@ -174,9 +174,11 @@ func (m *logPreviewModel) View() string { } // Messages for bubbletea -type logUpdateMsg struct{} -type statusUpdateMsg struct{} -type initCompleteMsg struct{} +type ( + logUpdateMsg struct{} + statusUpdateMsg struct{} + initCompleteMsg struct{} +) // tickLogUpdate sends a log update tick func tickLogUpdate() tea.Cmd { diff --git a/internal/utils/auth.go b/internal/utils/auth.go index ee2c800..99f9b4f 100644 --- a/internal/utils/auth.go +++ b/internal/utils/auth.go @@ -1,60 +1,15 @@ package utils import ( - "errors" "fmt" "os" - "os/user" "github.com/fosrl/cli/internal/api" - "github.com/fosrl/cli/internal/secrets" - "github.com/spf13/viper" + "github.com/fosrl/cli/internal/config" ) -// GetOriginalUserHomeDir returns the home directory of the original user -// (the user who invoked the command, not the effective user when running with sudo). -// This ensures that config files work both with and without sudo. -func GetOriginalUserHomeDir() (string, error) { - // Check if we're running under sudo - SUDO_USER contains the original user - sudoUser := os.Getenv("SUDO_USER") - if sudoUser != "" { - // We're running with sudo, get the original user's home directory - u, err := user.Lookup(sudoUser) - if err != nil { - return "", fmt.Errorf("failed to lookup original user %s: %w", sudoUser, err) - } - return u.HomeDir, nil - } - - // Not running with sudo, use current user's home directory - return os.UserHomeDir() -} - -// Returns an error if the user is not logged in, nil otherwise. -func EnsureLoggedIn() error { - // Check for userId in config - userID := viper.GetString("userId") - if userID == "" { - return errors.New("Please log in first. Run `pangolin login` to login") - } - - // Check for session token in config - _, err := secrets.GetSessionToken() - if err != nil { - return fmt.Errorf("Please log in first. Run `pangolin login` to login") - } - - // Get user via API to ensure the user exists - _, err = api.GlobalClient.GetUser() - if err != nil { - return fmt.Errorf("failed to get user information: %w", err) - } - - return nil -} - -// getDeviceName returns a human-readable device name -func getDeviceName() string { +// GetDeviceName returns a human-readable device name +func GetDeviceName() string { hostname, err := os.Hostname() if err != nil { return "Unknown Device" @@ -65,74 +20,53 @@ func getDeviceName() string { // EnsureOlmCredentials ensures that OLM credentials exist and are valid. // It checks if OLM credentials exist locally, verifies them on the server, // and creates new ones if they don't exist or are invalid. -func EnsureOlmCredentials(userID string) error { - if userID == "" { - return errors.New("userId is required") - } - - // Check if OLM credentials already exist locally - olmID, _, err := secrets.GetOlmCredentials(userID) - if err == nil && olmID != "" { - // Verify OLM exists on server by getting the OLM directly - olm, err := api.GlobalClient.GetUserOlm(userID, olmID) - if err == nil && olm != nil { - // Verify the olmID matches - if olm.OlmID == olmID { - return nil - } else { - Error("OLM ID mismatch - olm olmID: %s, stored olmID: %s", olm.OlmID, olmID) - // Clear invalid credentials - secrets.DeleteOlmCredentials(userID) - } - } else { - // If getting OLM fails, the OLM might not exist - _, ok := err.(*api.ErrorResponse) - if !ok { - return fmt.Errorf("failed to get OLM: %w", err) - } +// +// If new ones are created, a "true" is returned to indicate we need to +// save the new credentials to disk. +func EnsureOlmCredentials(client *api.Client, account *config.Account) (bool, error) { + userID := account.UserID + + if account.OlmCredentials != nil { + serverCreds, err := client.GetUserOlm(userID, account.OlmCredentials.ID) + if err == nil && serverCreds != nil { + return false, nil + } - // Clear invalid credentials so we can try to create new ones - secrets.DeleteOlmCredentials(userID) + // If getting OLM fails, the OLM might not exist. + // This requires regeneration; in case of any errors + // that are not API-related, these are likely not + // related to the credentials and should be bubbled up. + if _, ok := err.(*api.ErrorResponse); !ok { + return false, fmt.Errorf("failed to get OLM: %w", err) } + + // Clear invalid credentials so we can try to create new ones + account.OlmCredentials = nil } - // If credentials don't exist or were cleared, create new ones - _, _, err = secrets.GetOlmCredentials(userID) + newOlm, err := client.CreateOlm(userID, GetDeviceName()) if err != nil { - // Get friendly device name - deviceName := getDeviceName() - - olmResponse, err := api.GlobalClient.CreateOlm(userID, deviceName) - if err != nil { - return fmt.Errorf("failed to create OLM: %w", err) - } + return false, fmt.Errorf("failed to create OLM: %w", err) + } - // Save OLM credentials - if err := secrets.SaveOlmCredentials(userID, olmResponse.OlmID, olmResponse.Secret); err != nil { - return fmt.Errorf("failed to save OLM credentials: %w", err) - } + account.OlmCredentials = &config.OlmCredentials{ + ID: newOlm.OlmID, + Secret: newOlm.Secret, } - return nil + return true, nil } // EnsureOrgAccess ensures that the user has access to the organization -func EnsureOrgAccess(orgID, userID string) error { - if orgID == "" { - return errors.New("orgId is required") - } - if userID == "" { - return errors.New("userId is required") - } - +func EnsureOrgAccess(client *api.Client, account *config.Account) error { // Get org via API to ensure it exists - _, err := api.GlobalClient.GetOrg(orgID) + _, err := client.GetOrg(account.OrgID) if err != nil { return err } // Check org user access and policies - accessResponse, err := api.GlobalClient.CheckOrgUserAccess(orgID, userID) + accessResponse, err := client.CheckOrgUserAccess(account.OrgID, account.UserID) if err != nil { return err } @@ -140,8 +74,7 @@ func EnsureOrgAccess(orgID, userID string) error { // Check if user is allowed access if !accessResponse.Allowed { // Get hostname base URL for constructing the web URL - hostname := GetHostnameBaseURL() - url := fmt.Sprintf("%s/%s", hostname, orgID) + url := fmt.Sprintf("%s/%s", FormatHostnameBaseURL(account.Host), account.OrgID) return fmt.Errorf("Organization policy is preventing you from connecting. Please visit %s to complete required steps", url) } diff --git a/internal/utils/colors.go b/internal/utils/colors.go deleted file mode 100644 index d246ee8..0000000 --- a/internal/utils/colors.go +++ /dev/null @@ -1,23 +0,0 @@ -package utils - -// Color represents a lipgloss color ID -type Color string - -const ( - // Standard colors - ColorInfo Color = "6" // Cyan (ANSI 36) - ColorDebug Color = "248" // Light gray (ANSI 90) - ColorSuccess Color = "46" // Bright green (ANSI 32) - ColorWarning Color = "220" // Yellow/Orange (ANSI 33) - ColorError Color = "1" // Red (ANSI 31) - - // Gray scale - ColorDarkGray Color = "240" // Dark gray - ColorLightGray Color = "248" // Light gray -) - -// String returns the color ID as a string -func (c Color) String() string { - return string(c) -} - diff --git a/internal/utils/config.go b/internal/utils/config.go index 53e7287..35ab988 100644 --- a/internal/utils/config.go +++ b/internal/utils/config.go @@ -1,45 +1,17 @@ package utils import ( - "fmt" - "os" - "path/filepath" "strings" - - "github.com/spf13/viper" ) const defaultHostname = "app.pangolin.net" -// GetPangolinDir returns the path to the .pangolin directory and ensures it exists -func GetPangolinDir() (string, error) { - homeDir, err := GetOriginalUserHomeDir() - if err != nil { - return "", fmt.Errorf("failed to get home directory: %w", err) - } - - pangolinDir := filepath.Join(homeDir, ".pangolin") - if err := os.MkdirAll(pangolinDir, 0755); err != nil { - return "", fmt.Errorf("failed to create .pangolin directory: %w", err) - } - - return pangolinDir, nil -} - -// GetHostname returns the hostname from config with default fallback. -// It returns the hostname with protocol if present, or defaults to "app.pangolin.net". -func GetHostname() string { - hostname := viper.GetString("hostname") +// FormatHostnameBaseURL returns the hostname formatted as a base URL (with protocol, without /api/v1). +// This is useful for constructing URLs to the web interface. +func FormatHostnameBaseURL(hostname string) string { if hostname == "" { - return defaultHostname + hostname = defaultHostname } - return hostname -} - -// GetHostnameBaseURL returns the hostname formatted as a base URL (with protocol, without /api/v1). -// This is useful for constructing URLs to the web interface. -func GetHostnameBaseURL() string { - hostname := GetHostname() // Ensure hostname has protocol if !strings.HasPrefix(hostname, "http://") && !strings.HasPrefix(hostname, "https://") { diff --git a/internal/utils/org.go b/internal/utils/org.go index 8c9cfe1..c144b4c 100644 --- a/internal/utils/org.go +++ b/internal/utils/org.go @@ -5,15 +5,15 @@ import ( "github.com/charmbracelet/huh" "github.com/fosrl/cli/internal/api" + "github.com/fosrl/cli/internal/logger" "github.com/fosrl/cli/internal/olm" - "github.com/spf13/viper" ) -// SelectOrg lists organizations for a user and prompts them to select one. +// SelectOrgForm 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 SelectOrg(userID string) (string, error) { - orgsResp, err := api.GlobalClient.ListUserOrgs(userID) +func SelectOrgForm(client *api.Client, userID string) (string, error) { + orgsResp, err := client.ListUserOrgs(userID) if err != nil { return "", fmt.Errorf("failed to list organizations: %w", err) } @@ -25,11 +25,6 @@ func SelectOrg(userID string) (string, error) { if len(orgsResp.Orgs) == 1 { // Auto-select if only one org selectedOrg := orgsResp.Orgs[0] - viper.Set("orgId", selectedOrg.OrgID) - if err := viper.WriteConfig(); err != nil { - return "", fmt.Errorf("failed to save organization to config: %w", err) - } - SwitchActiveClientOrg(selectedOrg.OrgID) return selectedOrg.OrgID, nil } @@ -62,13 +57,6 @@ func SelectOrg(userID string) (string, error) { return "", fmt.Errorf("error selecting organization: %w", err) } - viper.Set("orgId", selectedOrgOption.OrgID) - if err := viper.WriteConfig(); err != nil { - return "", fmt.Errorf("failed to save organization to config: %w", err) - } - - SwitchActiveClientOrg(selectedOrgOption.OrgID) - return selectedOrgOption.OrgID, nil } @@ -84,7 +72,7 @@ func SwitchActiveClientOrg(orgID string) bool { // Get current status to check current orgId currentStatus, err := client.GetStatus() if err != nil { - Warning("Failed to get current status: %v", err) + logger.Warning("Failed to get current status: %v", err) return false } @@ -96,8 +84,8 @@ func SwitchActiveClientOrg(orgID string) bool { // Client is running, try to switch org _, err = client.SwitchOrg(orgID) if err != nil { - Warning("Failed to switch organization in active client: %v", err) - Warning("The organization has been saved to config, but the active client may still be using the previous organization.") + logger.Warning("Failed to switch organization in active client: %v", err) + logger.Warning("The organization has been saved to config, but the active client may still be using the previous organization.") return false } diff --git a/internal/version/cache.go b/internal/version/cache.go index be3715c..60306b3 100644 --- a/internal/version/cache.go +++ b/internal/version/cache.go @@ -7,7 +7,7 @@ import ( "path/filepath" "time" - "github.com/fosrl/cli/internal/utils" + "github.com/fosrl/cli/internal/config" ) const ( @@ -26,9 +26,9 @@ type UpdateCheckCache struct { // getCacheFilePath returns the path to the update check cache file func getCacheFilePath() (string, error) { - pangolinDir, err := utils.GetPangolinDir() + pangolinDir, err := config.GetPangolinConfigDir() if err != nil { - return "", fmt.Errorf("failed to get .pangolin directory: %w", err) + return "", fmt.Errorf("failed to get pangolin config directory: %w", err) } return filepath.Join(pangolinDir, UpdateCheckCacheFile), nil } @@ -69,7 +69,7 @@ func writeCache(cache *UpdateCheckCache) error { return fmt.Errorf("failed to marshal cache: %w", err) } - if err := os.WriteFile(cachePath, data, 0644); err != nil { + if err := os.WriteFile(cachePath, data, 0o644); err != nil { return fmt.Errorf("failed to write cache: %w", err) } diff --git a/internal/version/check.go b/internal/version/check.go index e8cf250..fd568b9 100644 --- a/internal/version/check.go +++ b/internal/version/check.go @@ -9,7 +9,6 @@ import ( "time" "github.com/Masterminds/semver/v3" - "github.com/spf13/viper" ) const ( @@ -121,11 +120,6 @@ func CheckForUpdate() (*GitHubRelease, error) { // It waits a short time for the check to complete so the message is shown even for fast commands. // It respects the cache interval and only checks once per day. func CheckForUpdateAsync(showMessage func(*GitHubRelease)) { - // Check if update checking is disabled in config - if viper.GetBool("disable_update_check") { - return - } - // First, check if we have cached info that shows an update if cachedRelease, ok := getCachedUpdateInfo(); ok { showMessage(cachedRelease) diff --git a/tools/gendocs/main.go b/tools/gendocs/main.go index 65f031f..b837dfe 100644 --- a/tools/gendocs/main.go +++ b/tools/gendocs/main.go @@ -25,19 +25,19 @@ url: %s func main() { var ( - outputDir = flag.String("dir", "./docs", "Output directory for generated documentation") + outputDir = flag.String("dir", "./docs", "Output directory for generated documentation") withFrontMatter = flag.Bool("frontmatter", false, "Add Hugo front matter to generated files") - baseURL = flag.String("baseurl", "/commands", "Base URL for command links (used with front matter)") + baseURL = flag.String("baseurl", "/commands", "Base URL for command links (used with front matter)") ) flag.Parse() // Create output directory if it doesn't exist - if err := os.MkdirAll(*outputDir, 0755); err != nil { + if err := os.MkdirAll(*outputDir, 0o755); err != nil { log.Fatalf("Failed to create output directory: %v", err) } // Get the root command - rootCmd := cmd.GetRootCmd() + rootCmd, _ := cmd.RootCommand(false) var err error if *withFrontMatter { @@ -67,7 +67,7 @@ func main() { } log.Printf("Successfully generated markdown documentation in %s", *outputDir) - + // List generated files files, err := filepath.Glob(filepath.Join(*outputDir, "*.md")) if err == nil { @@ -77,4 +77,3 @@ func main() { } } } -