diff --git a/cmd/root.go b/cmd/root.go index 19b8b09..ccb114f 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -1,15 +1,12 @@ package cmd import ( - "encoding/json" "fmt" "io" "log/slog" "os" - "os/exec" "path/filepath" - tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/spf13/cobra" "github.com/spf13/viper" @@ -29,22 +26,6 @@ const ( ` ) -type Vault struct { - ID string `json:"id"` - Name string `json:"name"` -} - -type VaultSelectionModel struct { - vaults []Vault - cursor int - choice int - resetView bool -} - -func (m VaultSelectionModel) Init() tea.Cmd { - return nil -} - var ( // cliVersion is the version of the software. // Typically a branch or tag name. @@ -74,6 +55,8 @@ var textStyle = lipgloss.NewStyle().Render var textStyleGrayBold = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#666666")).Render var textStyleBold = lipgloss.NewStyle().Bold(true).Render var headerStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#9FC131")).Render +var checkMark = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).SetString("✓") +var errorkMark = lipgloss.NewStyle().Foreground(lipgloss.Color("212")).SetString("X") // rootCmd represents the base command when called without any subcommands var rootCmd = &cobra.Command{ @@ -116,103 +99,11 @@ func init() { } } -func (m *VaultSelectionModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.KeyMsg: - switch msg.String() { - case "up": - if m.cursor > 0 { - m.cursor-- - } - case "down": - if m.cursor < len(m.vaults)-1 { - m.cursor++ - } - case "enter": - m.choice = m.cursor - m.resetView = true - return m, tea.Quit - } - } - return m, nil -} - -func (m VaultSelectionModel) View() string { - if m.resetView { - return "\033[H\033[2J" - } - - s := "Select the 1password vault that should be used with buchhalter cli:\n\n" - for i, vault := range m.vaults { - if m.cursor == i { - s += "(•) " - } else { - s += "( ) " - } - s += vault.Name + "\n" - } - return s -} - -// TODO Refactor and move to vault.1password.go -func getVaults() ([]Vault, error) { - cmd := exec.Command("op", "vault", "list", "--format", "json") - output, err := cmd.Output() - if err != nil { - return nil, err - } - - var vaults []Vault - err = json.Unmarshal(output, &vaults) - if err != nil { - return nil, err - } - - return vaults, nil -} - -func selectVault(vaults []Vault) (Vault, error) { - model := VaultSelectionModel{vaults: vaults} - p := tea.NewProgram(&model) - if _, err := p.Run(); err != nil { - return Vault{}, err - } - return model.vaults[model.choice], nil -} - func initConfig() { homeDir, _ := os.UserHomeDir() + buchhalterDir := filepath.Join(homeDir, "buchhalter") buchhalterConfigDir := filepath.Join(homeDir, ".buchhalter") configFile := filepath.Join(buchhalterConfigDir, ".buchhalter.yaml") - buchhalterDir := filepath.Join(homeDir, "buchhalter") - - // TODO We check the existing part of the configuration file here and below -> refctor to only once - if _, err := os.Stat(configFile); os.IsNotExist(err) { - err := utils.CreateDirectoryIfNotExists(buchhalterConfigDir) - if err != nil { - fmt.Println("Error creating config directory:", err) - os.Exit(1) - } - - vaults, err := getVaults() - if err != nil { - fmt.Println("Error listing vaults. Make sure 1password cli is installed and you are logged in with eval $(op signin)", err) - os.Exit(1) - } - - selectedVault, err := selectVault(vaults) - if err != nil { - fmt.Println("Error selecting vault:", err) - os.Exit(1) - } - - viper.Set("credential_provider_vault", selectedVault.Name) - err = viper.WriteConfigAs(configFile) - if err != nil { - fmt.Println("Error creating config file:", err) - os.Exit(1) - } - } // Set default values for viper config // Documented settings @@ -220,6 +111,7 @@ func initConfig() { viper.SetDefault("credential_provider_item_tag", "buchhalter-ai") viper.SetDefault("buchhalter_directory", buchhalterDir) viper.SetDefault("buchhalter_config_directory", buchhalterConfigDir) + viper.SetDefault("buchhalter_config_file", configFile) viper.SetDefault("buchhalter_max_download_files_per_receipt", 2) viper.SetDefault("buchhalter_api_host", "https://app.buchhalter.ai/") viper.SetDefault("buchhalter_always_send_metrics", false) diff --git a/cmd/vault-select.go b/cmd/vault-select.go new file mode 100644 index 0000000..87314d1 --- /dev/null +++ b/cmd/vault-select.go @@ -0,0 +1,243 @@ +package cmd + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/spf13/cobra" + "github.com/spf13/viper" + + "buchhalter/lib/vault" +) + +// selectCmd represents the `vault select` command +var selectCmd = &cobra.Command{ + Use: "select", + Short: "Select the 1Password vault that should be used with buchhalter-cli", + Long: `Your secrets can be organized via vaults inside 1Password. buchhalter-cli is respecting these vaults to only retrieve items from a single vault. To know which vault should be used, the vault need to be selected.longer description that spans multiple lines and likely contains examples. For example: + +(•) Private +( ) ACME Corp +( ) Reddit Side Project + +The chosen Vault name will be stores inside a local configuration for later use.`, + Run: RunVaultSelectCommand, +} + +func init() { + vaultCmd.AddCommand(selectCmd) +} + +func RunVaultSelectCommand(cmd *cobra.Command, args []string) { + // Init logging + buchhalterDirectory := viper.GetString("buchhalter_directory") + developmentMode := viper.GetBool("dev") + logSetting, err := cmd.Flags().GetBool("log") + if err != nil { + exitMessage := fmt.Sprintf("Error reading log flag: %s", err) + exitWithLogo(exitMessage) + } + logger, err := initializeLogger(logSetting, developmentMode, buchhalterDirectory) + if err != nil { + exitMessage := fmt.Sprintf("Error on initializing logging: %s", err) + exitWithLogo(exitMessage) + } + logger.Info("Booting up", "development_mode", developmentMode) + defer logger.Info("Shutting down") + + // Init UI + spinnerModel := spinner.New() + spinnerModel.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("63")) + viewModel := ViewModelVaultSelect{ + showSelection: false, + selectionChoice: viper.GetString("credential_provider_vault"), + actionsCompleted: []string{}, + actionInProgress: "Initializing connection to Password Vault", + spinner: spinnerModel, + } + + // Run the program + p := tea.NewProgram(&viewModel) + if _, err := p.Run(); err != nil { + logger.Error("Error running program", "error", err) + exitMessage := fmt.Sprintf("Error running program: %s", err) + exitWithLogo(exitMessage) + } +} + +type ViewModelVaultSelect struct { + actionsCompleted []string + actionInProgress string + actionError string + spinner spinner.Model + + // Vault selection + showSelection bool + selectionCursor int + selectionChoice string + selectionChoices []vault.Vault +} + +type vaultSelectErrorMsg struct { + err string +} + +type vaultSelectInitSuccessMsg struct { + vaults []vault.Vault +} + +func vaultSelectInitCmd() tea.Msg { + // Init vault provider + vaultConfigBinary := viper.GetString("credential_provider_cli_command") + vaultProvider, err := vault.GetProvider(vault.PROVIDER_1PASSWORD, vaultConfigBinary, "", "") + if err != nil { + return vaultSelectErrorMsg{err: vaultProvider.GetHumanReadableErrorMessage(err)} + } + + // Get vaults + vaults, err := vaultProvider.GetVaults() + if err != nil { + return vaultSelectErrorMsg{err: vaultProvider.GetHumanReadableErrorMessage(err)} + } + return vaultSelectInitSuccessMsg{ + vaults: vaults, + } +} + +type writeConfigFileMsg struct { + vaultName string + err error +} + +func (m ViewModelVaultSelect) Init() tea.Cmd { + return tea.Batch(vaultSelectInitCmd, m.spinner.Tick) +} + +func (m ViewModelVaultSelect) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "q": + return m, tea.Quit + + case "enter": + // We only allow enter if the vault selection is shown + if !m.showSelection { + return m, nil + } + + // Deactivate selection + m.showSelection = false + m.actionInProgress = "" + m.actionsCompleted = append(m.actionsCompleted, "Selected the 1Password vault that should be used with buchhalter-cli") + + // Send the choice on the channel and exit. + m.selectionChoice = m.selectionChoices[m.selectionCursor].ID + selectedVaultName := m.selectionChoices[m.selectionCursor].Name + + return m, func() tea.Msg { + viper.Set("credential_provider_vault", m.selectionChoice) + configFile := viper.GetString("buchhalter_config_file") + err := viper.WriteConfigAs(configFile) + if err != nil { + return writeConfigFileMsg{ + vaultName: selectedVaultName, + err: err, + } + } + return writeConfigFileMsg{vaultName: selectedVaultName} + } + + case "down", "j": + // We only allow enter if the vault selection is shown + if !m.showSelection { + return m, nil + } + + m.selectionCursor++ + if m.selectionCursor >= len(m.selectionChoices) { + m.selectionCursor = 0 + } + + case "up", "k": + // We only allow enter if the vault selection is shown + if !m.showSelection { + return m, nil + } + + m.selectionCursor-- + if m.selectionCursor < 0 { + m.selectionCursor = len(m.selectionChoices) - 1 + } + } + case spinner.TickMsg: + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + return m, cmd + + case vaultSelectErrorMsg: + m.actionError = msg.err + return m, tea.Quit + + case vaultSelectInitSuccessMsg: + m.selectionChoices = msg.vaults + m.actionInProgress = "" + m.actionsCompleted = append(m.actionsCompleted, "Initializing connection to Password Vault") + + // Show Vault selection + m.actionInProgress = "Select the 1Password vault that should be used with buchhalter-cli" + m.showSelection = true + + case writeConfigFileMsg: + if msg.err != nil { + m.actionError = fmt.Sprintf("Error creating config file: %s", msg.err) + return m, tea.Quit + } + + m.actionsCompleted = append(m.actionsCompleted, fmt.Sprintf("Configured 1Password vault '%s' as new default", msg.vaultName)) + return m, tea.Quit + } + + return m, nil +} + +func (m ViewModelVaultSelect) View() string { + s := strings.Builder{} + s.WriteString(headerStyle(LogoText) + "\n\n") + + for _, actionCompleted := range m.actionsCompleted { + s.WriteString(checkMark.Render() + " " + textStyleBold(actionCompleted) + "\n") + } + + if len(m.actionInProgress) > 0 { + s.WriteString(m.spinner.View() + " " + textStyleBold(m.actionInProgress) + "\n") + } + + if len(m.actionError) > 0 { + s.WriteString(errorkMark.Render() + " " + textStyleBold(m.actionError) + "\n") + } + + if m.showSelection { + for i := 0; i < len(m.selectionChoices); i++ { + currentConfigValue := "" + if m.selectionChoices[i].Name == m.selectionChoice || m.selectionChoices[i].ID == m.selectionChoice { + currentConfigValue = textStyleBold(" (currently configured)") + } + if m.selectionCursor == i { + s.WriteString("(•) ") + } else { + s.WriteString("( ) ") + } + s.WriteString(m.selectionChoices[i].Name) + s.WriteString(currentConfigValue) + s.WriteString("\n") + } + } + + s.WriteString("\n(press q to quit)\n") + + return s.String() +} diff --git a/cmd/vault.go b/cmd/vault.go new file mode 100644 index 0000000..14f9b61 --- /dev/null +++ b/cmd/vault.go @@ -0,0 +1,21 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +// vaultCmd represents the vault command +var vaultCmd = &cobra.Command{ + Use: "vault", + Short: "Sub-Commands to manage the password vault", + Long: `Sub-Commands to manage the password vault.`, + Run: func(cmd *cobra.Command, args []string) { + fmt.Println("Nothing to see here. Try `buchhalter help vault`.") + }, +} + +func init() { + rootCmd.AddCommand(vaultCmd) +} diff --git a/lib/vault/1password.go b/lib/vault/1password.go index 2c345c5..8ebf0f4 100644 --- a/lib/vault/1password.go +++ b/lib/vault/1password.go @@ -63,7 +63,7 @@ func (p *Provider1Password) initializeVaultversion() error { func (p *Provider1Password) LoadVaultItems() (Items, error) { // Build item list command // #nosec G204 - cmdArgs := p.buildVaultCommandArguments([]string{"item", "list"}, true) + cmdArgs := p.buildVaultCommandArguments([]string{"item", "list"}, true, true) itemListResponse, err := exec.Command(p.binary, cmdArgs...).Output() if err != nil { return nil, ProviderConnectionError{ @@ -99,7 +99,7 @@ func (p *Provider1Password) LoadVaultItems() (Items, error) { } func (p Provider1Password) GetCredentialsByItemId(itemId string) (*Credentials, error) { - cmdArgs := p.buildVaultCommandArguments([]string{"item", "get", itemId}, false) + cmdArgs := p.buildVaultCommandArguments([]string{"item", "get", itemId}, true, false) // #nosec G204 itemGetResponse, err := exec.Command(p.binary, cmdArgs...).Output() @@ -131,9 +131,9 @@ func (p Provider1Password) GetCredentialsByItemId(itemId string) (*Credentials, return credentials, nil } -func (p Provider1Password) buildVaultCommandArguments(baseCmd []string, includeTag bool) []string { +func (p Provider1Password) buildVaultCommandArguments(baseCmd []string, limitVault, includeTag bool) []string { cmdArgs := baseCmd - if len(p.base) > 0 { + if limitVault && len(p.base) > 0 { cmdArgs = append(cmdArgs, "--vault", p.base) } if includeTag && len(p.tag) > 0 { @@ -144,6 +144,30 @@ func (p Provider1Password) buildVaultCommandArguments(baseCmd []string, includeT return cmdArgs } +func (p *Provider1Password) GetVaults() ([]Vault, error) { + cmdArgs := p.buildVaultCommandArguments([]string{"vault", "list"}, false, false) + vaultListResponse, err := exec.Command(p.binary, cmdArgs...).Output() + if err != nil { + return nil, ProviderConnectionError{ + Code: ProviderConnectionErrorCode, + Cmd: fmt.Sprintf("%s %s", p.binary, strings.Join(cmdArgs, " ")), + Err: err, + } + } + + var vaultList []Vault + err = json.Unmarshal(vaultListResponse, &vaultList) + if err != nil { + return nil, ProviderResponseParsingError{ + Code: ProviderResponseParsingErrorCode, + Cmd: fmt.Sprintf("%s %s", p.binary, strings.Join(cmdArgs, " ")), + Err: err, + } + } + + return vaultList, nil +} + func (p *Provider1Password) GetHumanReadableErrorMessage(err error) string { message := "" diff --git a/lib/vault/types.go b/lib/vault/types.go index 3c7de15..a66bf42 100644 --- a/lib/vault/types.go +++ b/lib/vault/types.go @@ -7,15 +7,17 @@ import ( type Items []Item +type Vault struct { + ID string `json:"id"` + Name string `json:"name"` +} + type Item struct { - ID string `json:"id"` - Title string `json:"title"` - Tags []string `json:"tags"` - Version int `json:"version"` - Vault struct { - ID string `json:"id"` - Name string `json:"name"` - } `json:"vault"` + ID string `json:"id"` + Title string `json:"title"` + Tags []string `json:"tags"` + Version int `json:"version"` + Vault Vault `json:"vault"` Category string `json:"category"` LastEditedBy string `json:"last_edited_by"` CreatedAt time.Time `json:"created_at"`