From ff3e957bc24ce9d498d6053a72b97d198a3a6265 Mon Sep 17 00:00:00 2001 From: Pratik Date: Fri, 24 Apr 2026 15:08:03 +0530 Subject: [PATCH] feat: add login/logout/status commands --- .golangci.yml | 4 +- Makefile | 1 + cmd/login.go | 185 +++++++++++++++++++++++++++++++++++++ cmd/logout.go | 51 ++++++++++ cmd/pre.go | 4 +- cmd/status.go | 58 ++++++++++++ main.go | 46 +-------- pkg/analytics/analytics.go | 34 +++---- pkg/config/config.go | 7 +- pkg/http/http.go | 6 +- pkg/installer/installer.go | 2 + 11 files changed, 330 insertions(+), 68 deletions(-) create mode 100644 cmd/login.go create mode 100644 cmd/logout.go create mode 100644 cmd/status.go diff --git a/.golangci.yml b/.golangci.yml index d0cd449..6f2beb5 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -29,7 +29,7 @@ linters: rules: - text: "instead of using struct literal" linters: - - revive + - revive - text: "should have a package comment" linters: - revive @@ -38,7 +38,7 @@ linters: - revive - text: "time-naming" linters: - - revive + - revive - text: "error strings should not be capitalized or end with punctuation or a newline" linters: - revive diff --git a/Makefile b/Makefile index ed8501a..4b26e8a 100644 --- a/Makefile +++ b/Makefile @@ -19,6 +19,7 @@ checks: getdeps: @mkdir -p ${GOPATH}/bin @echo "Installing golangci-lint $(GOLANGCI_LINT_VERSION)" +# Will need to make it more error prone in future! @curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(GOPATH)/bin $(GOLANGCI_LINT_VERSION) crosscompile: diff --git a/cmd/login.go b/cmd/login.go new file mode 100644 index 0000000..2007562 --- /dev/null +++ b/cmd/login.go @@ -0,0 +1,185 @@ +// Copyright (c) 2024 Parseable, Inc +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package cmd + +import ( + "bufio" + "fmt" + "os" + "os/exec" + "pb/pkg/config" + "runtime" + "strings" + + "github.com/spf13/cobra" +) + +const defaultCloudURL = "https://staging.parseable.com:8000" + +var ( + loginToken string + loginURL string + loginUsername string + loginPassword string + loginProfileName string +) + +func init() { + LoginCmd.Flags().StringVar(&loginToken, "token", "", "Auth token for cloud login") + LoginCmd.Flags().StringVar(&loginURL, "url", "", "Server URL for self-hosted Parseable") + LoginCmd.Flags().StringVar(&loginUsername, "username", "", "Username for self-hosted login") + LoginCmd.Flags().StringVar(&loginPassword, "password", "", "Password for self-hosted login") + LoginCmd.Flags().StringVar(&loginProfileName, "profile", "default", "Profile name to save as") +} + +var LoginCmd = &cobra.Command{ + Use: "login", + Short: "Login to Parseable", + Long: `Login to Parseable cloud or a self-hosted instance. + +Cloud login (opens browser): + pb login + +Cloud login with token: + pb login --token + +Self-hosted login: + pb login --url http://localhost:8000 --username admin --password admin`, + RunE: func(_ *cobra.Command, _ []string) error { + // --- Self-hosted path --- + if loginURL != "" { + return selfHostedLogin() + } + + // --- Cloud path --- + return cloudLogin() + }, +} + +func selfHostedLogin() error { + username := loginUsername + password := loginPassword + + if username == "" { + fmt.Print("Username: ") + reader := bufio.NewReader(os.Stdin) + line, err := reader.ReadString('\n') + if err != nil { + return fmt.Errorf("failed to read username: %w", err) + } + username = strings.TrimSpace(line) + } + + if password == "" { + fmt.Print("Password: ") + reader := bufio.NewReader(os.Stdin) + line, err := reader.ReadString('\n') + if err != nil { + return fmt.Errorf("failed to read password: %w", err) + } + password = strings.TrimSpace(line) + } + + if username == "" || password == "" { + return fmt.Errorf("username and password are required for self-hosted login") + } + + profile := config.Profile{ + URL: loginURL, + Username: username, + Password: password, + } + if err := writeProfile(profile, loginProfileName); err != nil { + return fmt.Errorf("failed to save profile: %w", err) + } + + fmt.Printf("✓ Logged in. Profile '%s' saved.\n", loginProfileName) + fmt.Printf(" URL: %s\n", loginURL) + return nil +} + +func cloudLogin() error { + token := loginToken + + if token == "" { + loginPageURL := defaultCloudURL + "/login" + fmt.Printf("Opening login page: %s\n\n", loginPageURL) + + if err := openBrowser(loginPageURL); err != nil { + fmt.Println("Could not open browser automatically. Please visit the URL above and copy your token.") + } else { + fmt.Println("Browser opened. After logging in, copy your token from the dashboard.") + } + + fmt.Print("\nPaste your token here: ") + reader := bufio.NewReader(os.Stdin) + line, err := reader.ReadString('\n') + if err != nil { + return fmt.Errorf("failed to read token: %w", err) + } + token = strings.TrimSpace(line) + if token == "" { + return fmt.Errorf("no token provided, login canceled") + } + } + + profile := config.Profile{ + URL: defaultCloudURL, + Token: token, + } + if err := writeProfile(profile, loginProfileName); err != nil { + return fmt.Errorf("failed to save profile: %w", err) + } + + fmt.Printf("✓ Logged in. Profile '%s' saved.\n", loginProfileName) + fmt.Printf(" URL: %s\n", defaultCloudURL) + return nil +} + +func writeProfile(profile config.Profile, profileName string) error { + fileConfig, err := config.ReadConfigFromFile() + if err != nil { + newConfig := config.Config{ + Profiles: map[string]config.Profile{profileName: profile}, + DefaultProfile: profileName, + } + return config.WriteConfigToFile(&newConfig) + } + + if fileConfig.Profiles == nil { + fileConfig.Profiles = make(map[string]config.Profile) + } + fileConfig.Profiles[profileName] = profile + if fileConfig.DefaultProfile == "" { + fileConfig.DefaultProfile = profileName + } + return config.WriteConfigToFile(fileConfig) +} + +func openBrowser(url string) error { + var cmd *exec.Cmd + switch runtime.GOOS { + case "darwin": + cmd = exec.Command("open", url) + case "linux": + cmd = exec.Command("xdg-open", url) + case "windows": + cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url) + default: + return fmt.Errorf("unsupported platform: %s", runtime.GOOS) + } + return cmd.Start() +} diff --git a/cmd/logout.go b/cmd/logout.go new file mode 100644 index 0000000..fcfc162 --- /dev/null +++ b/cmd/logout.go @@ -0,0 +1,51 @@ +// Copyright (c) 2024 Parseable, Inc +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package cmd + +import ( + "fmt" + "pb/pkg/config" + + "github.com/spf13/cobra" +) + +var LogoutCmd = &cobra.Command{ + Use: "logout", + Short: "Logout from the current Parseable profile", + Long: "Removes the active profile (URL and credentials) from config.", + Example: " pb logout", + RunE: func(_ *cobra.Command, _ []string) error { + fileConfig, err := config.ReadConfigFromFile() + if err != nil { + return fmt.Errorf("no config found — nothing to logout from") + } + + profileName := fileConfig.DefaultProfile + if _, exists := fileConfig.Profiles[profileName]; !exists { + return fmt.Errorf("no active profile found") + } + + delete(fileConfig.Profiles, profileName) + fileConfig.DefaultProfile = "" + + if err := config.WriteConfigToFile(fileConfig); err != nil { + return fmt.Errorf("failed to update config: %w", err) + } + + fmt.Printf("Logged out and removed profile '%s'\n", profileName) + return nil + }, +} diff --git a/cmd/pre.go b/cmd/pre.go index d6bac82..b6da793 100644 --- a/cmd/pre.go +++ b/cmd/pre.go @@ -35,13 +35,13 @@ func PreRunDefaultProfile(_ *cobra.Command, _ []string) error { func PreRun() error { conf, err := config.ReadConfigFromFile() if os.IsNotExist(err) { - return errors.New("no config found to run this command. add a profile using pb profile command") + return errors.New("no profile configured. run: pb login") } else if err != nil { return err } if conf.Profiles == nil || conf.DefaultProfile == "" { - return errors.New("no profile is configured to run this command. please create one using profile command") + return errors.New("no profile configured. run: pb login") } DefaultProfile = conf.Profiles[conf.DefaultProfile] diff --git a/cmd/status.go b/cmd/status.go new file mode 100644 index 0000000..f7d3532 --- /dev/null +++ b/cmd/status.go @@ -0,0 +1,58 @@ +// Copyright (c) 2024 Parseable, Inc +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package cmd + +import ( + "fmt" + "pb/pkg/analytics" + "pb/pkg/config" + internalHTTP "pb/pkg/http" + + "github.com/spf13/cobra" +) + +var StatusCmd = &cobra.Command{ + Use: "status", + Short: "Check connection status for the active profile", + Example: " pb status", + RunE: func(_ *cobra.Command, _ []string) error { + fileConfig, err := config.ReadConfigFromFile() + if err != nil { + return fmt.Errorf("no profile configured. run: pb login") + } + + profileName := fileConfig.DefaultProfile + profile, exists := fileConfig.Profiles[profileName] + if !exists || profileName == "" { + return fmt.Errorf("no active profile. run: pb login") + } + + fmt.Printf("Profile : %s\n", profileName) + fmt.Printf("URL : %s\n", profile.URL) + + client := internalHTTP.DefaultClient(&profile) + about, err := analytics.FetchAbout(&client) + if err != nil { + fmt.Printf("Status : ✗ Not connected\n") + fmt.Printf("Error : %s\n", err.Error()) + return nil + } + + fmt.Printf("Status : ✓ Connected\n") + fmt.Printf("Version : %s\n", about.Version) + return nil + }, +} diff --git a/main.go b/main.go index 26ecd0b..e8875b7 100644 --- a/main.go +++ b/main.go @@ -24,7 +24,6 @@ import ( pb "pb/cmd" "pb/pkg/analytics" - "pb/pkg/config" "github.com/spf13/cobra" ) @@ -42,14 +41,6 @@ var ( versionFlagShort = "v" ) -func defaultInitialProfile() config.Profile { - return config.Profile{ - URL: "https://demo.parseable.com", - Username: "admin", - Password: "admin", - } -} - // Root command var cli = &cobra.Command{ Use: "pb", @@ -300,6 +291,9 @@ func main() { cli.AddCommand(cluster) cli.AddCommand(pb.AutocompleteCmd) + cli.AddCommand(pb.LoginCmd) + cli.AddCommand(pb.LogoutCmd) + cli.AddCommand(pb.StatusCmd) // Set as command pb.VersionCmd.Run = func(_ *cobra.Command, _ []string) { @@ -312,40 +306,6 @@ func main() { cli.CompletionOptions.HiddenDefaultCmd = true - // create a default profile if file does not exist - if previousConfig, err := config.ReadConfigFromFile(); os.IsNotExist(err) { - conf := config.Config{ - Profiles: map[string]config.Profile{"demo": defaultInitialProfile()}, - DefaultProfile: "demo", - } - err = config.WriteConfigToFile(&conf) - if err != nil { - fmt.Printf("failed to write to file %v\n", err) - os.Exit(1) - } - } else { - // Only update the "demo" profile without overwriting other profiles - demoProfile, exists := previousConfig.Profiles["demo"] - if exists { - // Update fields in the demo profile only - demoProfile.URL = "http://demo.parseable.com" - demoProfile.Username = "admin" - demoProfile.Password = "admin" - previousConfig.Profiles["demo"] = demoProfile - } else { - // Add the "demo" profile if it doesn't exist - previousConfig.Profiles["demo"] = defaultInitialProfile() - previousConfig.DefaultProfile = "demo" // Optional: set as default if needed - } - - // Write the updated configuration back to file - err = config.WriteConfigToFile(previousConfig) - if err != nil { - fmt.Printf("failed to write to existing file %v\n", err) - os.Exit(1) - } - } - err := cli.Execute() if err != nil { os.Exit(1) diff --git a/pkg/analytics/analytics.go b/pkg/analytics/analytics.go index 6c84712..34c5d53 100644 --- a/pkg/analytics/analytics.go +++ b/pkg/analytics/analytics.go @@ -53,23 +53,23 @@ type Event struct { // About struct type About struct { - Version string `json:"version"` - UIVersion string `json:"uiVersion"` - Commit string `json:"commit"` - DeploymentID string `json:"deploymentId"` - UpdateAvailable bool `json:"updateAvailable"` - LatestVersion string `json:"latestVersion"` - LLMActive bool `json:"llmActive"` - LLMProvider string `json:"llmProvider"` - OIDCActive bool `json:"oidcActive"` - License string `json:"license"` - Mode string `json:"mode"` - Staging string `json:"staging"` - HotTier string `json:"hotTier"` - GRPCPort int `json:"grpcPort"` - Store Store `json:"store"` - Analytics Analytics `json:"analytics"` - QueryEngine string `json:"queryEngine"` + Version string `json:"version"` + UIVersion string `json:"uiVersion"` + Commit string `json:"commit"` + DeploymentID string `json:"deploymentId"` + UpdateAvailable bool `json:"updateAvailable"` + LatestVersion string `json:"latestVersion"` + LLMActive bool `json:"llmActive"` + LLMProvider string `json:"llmProvider"` + OIDCActive bool `json:"oidcActive"` + License json.RawMessage `json:"license"` + Mode string `json:"mode"` + Staging string `json:"staging"` + HotTier string `json:"hotTier"` + GRPCPort int `json:"grpcPort"` + Store Store `json:"store"` + Analytics Analytics `json:"analytics"` + QueryEngine string `json:"queryEngine"` } // Store struct diff --git a/pkg/config/config.go b/pkg/config/config.go index c57a9cc..cf39cb5 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -49,9 +49,10 @@ type Config struct { // Profile is the struct that holds the profile configuration type Profile struct { - URL string `json:"url"` - Username string `json:"username"` - Password string `json:"password,omitempty"` + URL string `toml:"url" json:"url"` + Username string `toml:"username,omitempty" json:"username,omitempty"` + Password string `toml:"password,omitempty" json:"password,omitempty"` + Token string `toml:"token,omitempty" json:"token,omitempty"` } func (p *Profile) GrpcAddr(port string) string { diff --git a/pkg/http/http.go b/pkg/http/http.go index 340d1b1..ee4e6ca 100644 --- a/pkg/http/http.go +++ b/pkg/http/http.go @@ -48,7 +48,11 @@ func (client *HTTPClient) NewRequest(method string, path string, body io.Reader) if err != nil { return } - req.SetBasicAuth(client.Profile.Username, client.Profile.Password) + if client.Profile.Token != "" { + req.Header.Set("Authorization", "Bearer "+client.Profile.Token) + } else { + req.SetBasicAuth(client.Profile.Username, client.Profile.Password) + } req.Header.Add("Content-Type", "application/json") return } diff --git a/pkg/installer/installer.go b/pkg/installer/installer.go index 3d68aa4..b9a98ca 100644 --- a/pkg/installer/installer.go +++ b/pkg/installer/installer.go @@ -281,6 +281,8 @@ func applyParseableSecret(ps *ParseableInfo, store ObjectStore, objectStoreConfi secretManifest = getParseableSecretBlob(ps, objectStoreConfig) case GcsStore: secretManifest = getParseableSecretGcs(ps, objectStoreConfig) + default: + return fmt.Errorf("unsupported object store type: %s", store) } // apply the Kubernetes Secret