diff --git a/charm/input.go b/charm/input.go new file mode 100644 index 0000000..e17b302 --- /dev/null +++ b/charm/input.go @@ -0,0 +1,91 @@ +package charm + +import ( + "fmt" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +type Input struct { + text string + placeholder string + initialValue string + hidden bool + required bool +} + +var ( + SuccessStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#00FF00")) + + ErrorStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FF0000")). // Red color + Bold(true) + + inputStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("205")) + titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("63")) +) + +// Function to create and run the bubbletea model +func GetSensitiveInput(placeholder string, defaultValue string) (string, error) { + initialModel := model{ + textInput: textinput.New(), + } + initialModel.textInput.Placeholder = placeholder + initialModel.textInput.SetValue(defaultValue) + initialModel.textInput.EchoMode = textinput.EchoPassword // Hides the input + initialModel.textInput.Focus() + + p := tea.NewProgram(initialModel) + finalModel, err := p.Run() + if err != nil { + return "", err + } + + return finalModel.(model).textInput.Value(), nil +} + +// Bubbletea model +type model struct { + textInput textinput.Model + err error +} + +func (m model) Init() tea.Cmd { + return textinput.Blink +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.Type { + case tea.KeyEnter: + if m.textInput.Value() == "" { + m.err = fmt.Errorf("input is required") + return m, nil + } + return m, tea.Quit + case tea.KeyCtrlC: + return m, tea.Quit + } + } + var cmd tea.Cmd + m.textInput, cmd = m.textInput.Update(msg) + return m, cmd +} + +func (m model) View() string { + var errMsg string + if m.err != nil { + errMsg = ErrorStyle.Render(m.err.Error()) + "\n\n" + } + + return fmt.Sprintf( + "%s\n\n%s\n\n%s", + titleStyle.Render("Enter your token:"), + inputStyle.Render(m.textInput.View()), + errMsg, + ) +} diff --git a/cmd/main.go b/cmd/main.go new file mode 100644 index 0000000..2e1f6fa --- /dev/null +++ b/cmd/main.go @@ -0,0 +1,10 @@ +package main + +import ( + "github.com/qernal/cli-qernal/commands" +) + +func main() { + commands.RootCmd.Execute() + +} diff --git a/commands/auth/auth.go b/commands/auth/auth.go new file mode 100644 index 0000000..31f7d06 --- /dev/null +++ b/commands/auth/auth.go @@ -0,0 +1,25 @@ +package auth + +import ( + "errors" + + "github.com/spf13/cobra" +) + +var AuthCmd = &cobra.Command{ + Use: "auth", + Short: "Manage your auth tokens", + RunE: func(cmd *cobra.Command, args []string) error { + err := cmd.Help() + if err != nil { + return err + } + return errors.New("a valid subcommand is required") + }, +} + +func init() { + + AuthCmd.AddCommand(loginCmd) + +} diff --git a/commands/auth/init.go b/commands/auth/init.go new file mode 100644 index 0000000..180de04 --- /dev/null +++ b/commands/auth/init.go @@ -0,0 +1,100 @@ +package auth + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/qernal/cli-qernal/charm" + "github.com/spf13/cobra" + "github.com/spf13/viper" + "gopkg.in/yaml.v2" +) + +type config struct { + Token string `yaml:"token"` +} + +var ( + loginCmd = &cobra.Command{ + Use: "login", + Short: "Log in to your Qernal account", + Long: `log in to your Qernal account by searching for the QERNAL_TOKEN environment variable first. + + the order in which values are searched for: + +1. **QERNAL_TOKEN environment variable:** If set, this is used as the token. +2. **$HOME/.qernal/config.yaml file:** If the environment variable is not found, the CLI checks for the token in this file. +3. **User input:** If neither of the above is found, the user is prompted to enter their Qernal token.`, + RunE: func(cmd *cobra.Command, args []string) error { + token, err := getQernalToken() + if err != nil { + return err + } + + return saveConfig(token) + }, + } + + cfgPath = filepath.Join(os.Getenv("HOME"), ".qernal", "config.yaml") +) + +func getQernalToken() (string, error) { + // 1. Check environment variable + if token := os.Getenv("QERNAL_TOKEN"); token != "" { + fmt.Println(charm.SuccessStyle.Render("configuring CLI using environment variable ✅")) + + return token, nil + } + + // 2. Check config file + if token, err := readConfig(cfgPath); err == nil { + fmt.Println(charm.SuccessStyle.Render(fmt.Sprintf("Using token from %s.", cfgPath))) + return token, nil + } else if os.IsNotExist(err) { + // File doesn't exist, continue to prompt user + token, err := charm.GetSensitiveInput("clientid@clientsecret", ".....") + if err != nil { + fmt.Println(charm.ErrorStyle.Render(fmt.Sprintf("error retrieving input %s", err.Error()))) + return "", err + } + return token, nil + + } + token, err := charm.GetSensitiveInput("Enter your token", ".....") + if err != nil { + fmt.Println(charm.ErrorStyle.Render(fmt.Sprintf("error retrieving input %s", err.Error()))) + return "", err + } + return token, nil +} + +func readConfig(cfgPath string) (string, error) { + viper.SetConfigFile(cfgPath) + + // Read the config file + if err := viper.ReadInConfig(); err != nil { + return "", fmt.Errorf("error reading config file, %s", err) + } + + // Unmarshal the config into a struct + var cfg config + if err := viper.Unmarshal(&cfg); err != nil { + return "", fmt.Errorf("unable to decode into struct, %v", err) + } + + return cfg.Token, nil +} + +func saveConfig(token string) error { + cfg := &config{Token: token} + data, err := yaml.Marshal(cfg) + if err != nil { + return err + } + + if err := os.MkdirAll(filepath.Dir(cfgPath), 0755); err != nil { + return err + } + return os.WriteFile(cfgPath, data, 0644) +} diff --git a/commands/root.go b/commands/root.go new file mode 100644 index 0000000..59e0a7a --- /dev/null +++ b/commands/root.go @@ -0,0 +1,31 @@ +package commands + +import ( + "os" + + "github.com/qernal/cli-qernal/commands/auth" + "github.com/spf13/cobra" +) + +var RootCmd = &cobra.Command{ + Use: "qernal", + Short: "CLI for interacting with qernal.com", + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + return cmd.Help() + } + return nil + }, +} + +func Execute() { + err := RootCmd.Execute() + if err != nil { + os.Exit(1) + } +} + +func init() { + RootCmd.AddCommand(auth.AuthCmd) + +} diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..403d2b1 --- /dev/null +++ b/config/config.go @@ -0,0 +1,57 @@ +package config + +import ( + "fmt" + "os" + + "github.com/mitchellh/go-homedir" + "github.com/spf13/viper" +) + +var ( + + // represents the current config + Current Config +) + +type ErrNoTokenFound struct{} + +func (m *ErrNoTokenFound) Error() string { + return "no token found" +} + +type Config struct { + Token string `yaml:"token"` +} + +func LoadConfig() { + + token, found := os.LookupEnv("QERNAL_TOKEN") + + if found && token != "" { + Current.Token = token + return + } + + // lookup token in config + home, err := homedir.Dir() + if err != nil { + fmt.Println(err) + os.Exit(1) + } + viper.SetConfigFile(fmt.Sprintf("%s/%s/%s", home, ".qernal", "config.yaml")) + + err = viper.ReadInConfig() + if err != nil { + fmt.Print(fmt.Errorf("unable to read qernal config", err).Error()) + } + + token = viper.Get("token").(string) + if Current.Token != "" { + Current.Token = token + return + } + + // TODO: prompt user for token + fmt.Println("no token found") +}