From 5de450dd9077b5d30a2821d6228e625d19de44f3 Mon Sep 17 00:00:00 2001 From: Phil Leggetter Date: Wed, 2 Oct 2024 21:57:55 +0100 Subject: [PATCH 01/10] chore: add multi-source example --- README.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/README.md b/README.md index 5bb24b6..f231ba0 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,29 @@ Inventory Service forwarding to /webhooks/shopify/inventory Orders Service forwarding to /webhooks/shopify/orders +⣾ Getting ready... + +``` + +#### Listen to multiple sources + +`source-alia` can be a comma-separated list of source names (for example, `stripe,shopify,twilio`) or `'*'` (with quotes) to listen to all sources. + +```sh-session +$ hookdeck listen 3000 '*' + +👉 Inspect and replay events: https://dashboard.hookdeck.com/cli/events + +Sources +🔌 stripe URL: https://events.hookdeck.com/e/src_DAjaFWyyZXsFdZrTOKpuHn01 +🔌 shopify URL: https://events.hookdeck.com/e/src_DAjaFWyyZXsFdZrTOKpuHn02 +🔌 twilio URL: https://events.hookdeck.com/e/src_DAjaFWyyZXsFdZrTOKpuHn03 + +Connections +stripe -> cli-stripe forwarding to /webhooks/stripe +shopify -> cli-shopify forwarding to /webhooks/shopify +twilio -> cli-twilio forwarding to /webhooks/twilio + ⣾ Getting ready... ``` From 45a762275995540ffe5cd6be30829957116aa27c Mon Sep 17 00:00:00 2001 From: Phil Leggetter Date: Wed, 2 Oct 2024 22:02:26 +0100 Subject: [PATCH 02/10] chore: fix typo --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index f231ba0..69805be 100644 --- a/README.md +++ b/README.md @@ -139,7 +139,7 @@ Orders Service forwarding to /webhooks/shopify/orders #### Listen to multiple sources -`source-alia` can be a comma-separated list of source names (for example, `stripe,shopify,twilio`) or `'*'` (with quotes) to listen to all sources. +`source-alias` can be a comma-separated list of source names (for example, `stripe,shopify,twilio`) or `'*'` (with quotes) to listen to all sources. ```sh-session $ hookdeck listen 3000 '*' From 34d9c8b344324f0114a61b37d331376ca804d42b Mon Sep 17 00:00:00 2001 From: Alex Luong Date: Tue, 3 Sep 2024 00:11:00 +0700 Subject: [PATCH 03/10] feat: Use either global or local config file but not both --- pkg/cmd/listen.go | 1 - pkg/cmd/project_use.go | 13 ++- pkg/cmd/root.go | 2 +- pkg/cmd/whoami.go | 2 + pkg/config/config.go | 186 +++++++++++++++++++-------------- pkg/config/profile.go | 34 ++---- pkg/login/client_login.go | 8 +- pkg/login/interactive_login.go | 2 +- 8 files changed, 134 insertions(+), 114 deletions(-) diff --git a/pkg/cmd/listen.go b/pkg/cmd/listen.go index e09a924..8e8b095 100644 --- a/pkg/cmd/listen.go +++ b/pkg/cmd/listen.go @@ -38,7 +38,6 @@ func normalizeCliPathFlag(f *pflag.FlagSet, name string) pflag.NormalizedName { switch name { case "cli-path": name = "path" - break } return pflag.NormalizedName(name) } diff --git a/pkg/cmd/project_use.go b/pkg/cmd/project_use.go index d96dd20..ef822ce 100644 --- a/pkg/cmd/project_use.go +++ b/pkg/cmd/project_use.go @@ -5,13 +5,13 @@ import ( "github.com/spf13/cobra" "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" - "github.com/hookdeck/hookdeck-cli/pkg/validators" "github.com/hookdeck/hookdeck-cli/pkg/project" + "github.com/hookdeck/hookdeck-cli/pkg/validators" ) type projectUseCmd struct { - cmd *cobra.Command - local bool + cmd *cobra.Command + // local bool } func newProjectUseCmd() *projectUseCmd { @@ -23,7 +23,10 @@ func newProjectUseCmd() *projectUseCmd { Short: "Select your active project for future commands", RunE: lc.runProjectUseCmd, } - lc.cmd.Flags().BoolVar(&lc.local, "local", false, "Pin active project to the current directory") + + // With the change in config management (either local or global, not both), this flag is no longer needed + // TODO: consider remove / deprecate + // lc.cmd.Flags().BoolVar(&lc.local, "local", false, "Pin active project to the current directory") return lc } @@ -74,5 +77,5 @@ func (lc *projectUseCmd) runProjectUseCmd(cmd *cobra.Command, args []string) err } } - return Config.UseProject(lc.local, project.Id, project.Mode) + return Config.UseProject(project.Id, project.Mode) } diff --git a/pkg/cmd/root.go b/pkg/cmd/root.go index 83db790..4f84ee2 100644 --- a/pkg/cmd/root.go +++ b/pkg/cmd/root.go @@ -91,7 +91,7 @@ func init() { rootCmd.PersistentFlags().StringVar(&Config.Profile.APIKey, "cli-key", "", "(deprecated) Your API key to use for the command") rootCmd.PersistentFlags().StringVar(&Config.Profile.APIKey, "api-key", "", "Your API key to use for the command") rootCmd.PersistentFlags().StringVar(&Config.Color, "color", "", "turn on/off color output (on, off, auto)") - rootCmd.PersistentFlags().StringVar(&Config.LocalConfigFile, "config", "", "config file (default is $HOME/.config/hookdeck/config.toml)") + rootCmd.PersistentFlags().StringVar(&Config.ConfigFileFlag, "config", "", "config file (default is $HOME/.config/hookdeck/config.toml)") rootCmd.PersistentFlags().StringVar(&Config.DeviceName, "device-name", "", "device name") rootCmd.PersistentFlags().StringVar(&Config.LogLevel, "log-level", "info", "log level (debug, info, warn, error)") rootCmd.PersistentFlags().BoolVar(&Config.Insecure, "insecure", false, "Allow invalid TLS certificates") diff --git a/pkg/cmd/whoami.go b/pkg/cmd/whoami.go index 64678a4..6501347 100644 --- a/pkg/cmd/whoami.go +++ b/pkg/cmd/whoami.go @@ -28,6 +28,8 @@ func newWhoamiCmd() *whoamiCmd { } func (lc *whoamiCmd) runWhoamiCmd(cmd *cobra.Command, args []string) error { + fmt.Println(Config.Profile) + if err := Config.Profile.ValidateAPIKey(); err != nil { return err } diff --git a/pkg/config/config.go b/pkg/config/config.go index bc89042..c67e6d3 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -44,16 +44,15 @@ type Config struct { Insecure bool // Config - GlobalConfigFile string - GlobalConfig *viper.Viper - LocalConfigFile string - LocalConfig *viper.Viper + ConfigFileFlag string // flag -- should NOT use this directly + configFile string // resolved path of config file + viper *viper.Viper } -// GetConfigFolder retrieves the folder where the profiles file is stored +// getConfigFolder retrieves the folder where the profiles file is stored // It searches for the xdg environment path first and will secondarily // place it in the home directory -func (c *Config) GetConfigFolder(xdgPath string) string { +func getConfigFolder(xdgPath string) string { configPath := xdgPath if configPath == "" { @@ -97,51 +96,29 @@ func (c *Config) InitConfig() { TimestampFormat: time.RFC1123, } - c.GlobalConfig = viper.New() - c.LocalConfig = viper.New() - - // Read global config - globalConfigFolder := c.GetConfigFolder(os.Getenv("XDG_CONFIG_HOME")) - c.GlobalConfigFile = filepath.Join(globalConfigFolder, "config.toml") - c.GlobalConfig.SetConfigType("toml") - c.GlobalConfig.SetConfigFile(c.GlobalConfigFile) - c.GlobalConfig.SetConfigPermissions(os.FileMode(0600)) - // Try to change permissions manually, because we used to create files - // with default permissions (0644) - err := os.Chmod(c.GlobalConfigFile, os.FileMode(0600)) - if err != nil && !os.IsNotExist(err) { - log.Fatalf("%s", err) - } - if err := c.GlobalConfig.ReadInConfig(); err == nil { - log.WithFields(log.Fields{ - "prefix": "config.Config.InitConfig", - "path": c.GlobalConfig.ConfigFileUsed(), - }).Debug("Using global profiles file") - } + c.viper = viper.New() - // Read local config - workspaceFolder, err := os.Getwd() - if err != nil { - log.Fatal(err) - } - localConfigFile := "" - if c.LocalConfigFile == "" { - localConfigFile = filepath.Join(workspaceFolder, ".hookdeck/config.toml") - } else { - if filepath.IsAbs(c.LocalConfigFile) { - localConfigFile = c.LocalConfigFile - } else { - localConfigFile = filepath.Join(workspaceFolder, c.LocalConfigFile) + configPath, isGlobalConfig := getConfigPath(c.ConfigFileFlag) + c.configFile = configPath + c.viper.SetConfigType("toml") + c.viper.SetConfigFile(c.configFile) + + if isGlobalConfig { + // Try to change permissions manually, because we used to create files + // with default permissions (0644) + c.viper.SetConfigPermissions(os.FileMode(0600)) + err := os.Chmod(c.configFile, os.FileMode(0600)) + if err != nil && !os.IsNotExist(err) { + log.Fatalf("%s", err) } } - c.LocalConfig.SetConfigType("toml") - c.LocalConfig.SetConfigFile(localConfigFile) - c.LocalConfigFile = localConfigFile - if err := c.LocalConfig.ReadInConfig(); err == nil { + + // Read config file + if err := c.viper.ReadInConfig(); err == nil { log.WithFields(log.Fields{ "prefix": "config.Config.InitConfig", - "path": c.LocalConfig.ConfigFileUsed(), - }).Debug("Using local profiles file") + "path": c.viper.ConfigFileUsed(), + }).Debug("Reading config file") } // Construct the config struct @@ -175,7 +152,7 @@ func (c *Config) InitConfig() { func (c *Config) EditConfig() error { var err error - fmt.Println("Opening config file:", c.LocalConfigFile) + fmt.Println("Opening config file:", c.configFile) switch runtime.GOOS { case "darwin", "linux": @@ -184,7 +161,7 @@ func (c *Config) EditConfig() error { editor = "vi" } - cmd := exec.Command(editor, c.LocalConfigFile) + cmd := exec.Command(editor, c.configFile) // Some editors detect whether they have control of stdin/out and will // fail if they do not. cmd.Stdin = os.Stdin @@ -194,7 +171,7 @@ func (c *Config) EditConfig() error { case "windows": // As far as I can tell, Windows doesn't have an easily accesible or // comparable option to $EDITOR, so default to notepad for now - err = exec.Command("notepad", c.LocalConfigFile).Run() + err = exec.Command("notepad", c.configFile).Run() default: err = fmt.Errorf("unsupported platform") } @@ -203,16 +180,16 @@ func (c *Config) EditConfig() error { } // UseProject selects the active project to be used -func (c *Config) UseProject(local bool, teamId string, teamMode string) error { +func (c *Config) UseProject(teamId string, teamMode string) error { c.Profile.TeamID = teamId c.Profile.TeamMode = teamMode - return c.Profile.SaveProfile(local) + return c.Profile.SaveProfile() } func (c *Config) ListProfiles() []string { var profiles []string - for field, value := range c.GlobalConfig.AllSettings() { + for field, value := range c.viper.AllSettings() { if isProfile(value) { profiles = append(profiles, field) } @@ -222,8 +199,9 @@ func (c *Config) ListProfiles() []string { } // RemoveAllProfiles removes all the profiles from the config file. +// TODO: consider adding log to clarify which config file is being used func (c *Config) RemoveAllProfiles() error { - runtimeViper := c.GlobalConfig + runtimeViper := c.viper var err error for field, value := range runtimeViper.AllSettings() { @@ -241,43 +219,50 @@ func (c *Config) RemoveAllProfiles() error { } runtimeViper.SetConfigType("toml") - runtimeViper.SetConfigFile(c.GlobalConfig.ConfigFileUsed()) - c.GlobalConfig = runtimeViper - return c.WriteGlobalConfig() + runtimeViper.SetConfigFile(c.viper.ConfigFileUsed()) + c.viper = runtimeViper + return c.WriteConfig() } -func (c *Config) WriteGlobalConfig() error { - if err := makePath(c.GlobalConfig.ConfigFileUsed()); err != nil { +func (c *Config) WriteConfig() error { + if err := makePath(c.viper.ConfigFileUsed()); err != nil { return err } log.WithFields(log.Fields{ - "prefix": "config.Config.WriteGlobalConfig", - "path": c.GlobalConfig.ConfigFileUsed(), - }).Debug("Writing global config") - - return c.GlobalConfig.WriteConfig() -} + "prefix": "config.Config.WriteConfig", + "path": c.viper.WriteConfig(), + }).Debug("Writing config") -func (c *Config) WriteLocalConfig() error { - if err := makePath(c.LocalConfig.ConfigFileUsed()); err != nil { - return err - } - return c.LocalConfig.WriteConfig() + return c.viper.WriteConfig() } // Construct the config struct from flags > local config > global config func (c *Config) constructConfig() { - c.Color = getStringConfig([]string{c.Color, c.LocalConfig.GetString("color"), c.GlobalConfig.GetString(("color")), "auto"}) - c.LogLevel = getStringConfig([]string{c.LogLevel, c.LocalConfig.GetString("log"), c.GlobalConfig.GetString(("log")), "info"}) - c.APIBaseURL = getStringConfig([]string{c.APIBaseURL, c.LocalConfig.GetString("api_base"), c.GlobalConfig.GetString(("api_base")), hookdeck.DefaultAPIBaseURL}) - c.DashboardBaseURL = getStringConfig([]string{c.DashboardBaseURL, c.LocalConfig.GetString("dashboard_base"), c.GlobalConfig.GetString(("dashboard_base")), hookdeck.DefaultDashboardBaseURL}) - c.ConsoleBaseURL = getStringConfig([]string{c.ConsoleBaseURL, c.LocalConfig.GetString("console_base"), c.GlobalConfig.GetString(("console_base")), hookdeck.DefaultConsoleBaseURL}) - c.WSBaseURL = getStringConfig([]string{c.WSBaseURL, c.LocalConfig.GetString("ws_base"), c.GlobalConfig.GetString(("ws_base")), hookdeck.DefaultWebsocektURL}) - c.Profile.Name = getStringConfig([]string{c.Profile.Name, c.LocalConfig.GetString("profile"), c.GlobalConfig.GetString(("profile")), hookdeck.DefaultProfileName}) - c.Profile.APIKey = getStringConfig([]string{c.Profile.APIKey, c.LocalConfig.GetString("api_key"), c.GlobalConfig.GetString((c.Profile.GetConfigField("api_key"))), ""}) - c.Profile.TeamID = getStringConfig([]string{c.Profile.TeamID, c.LocalConfig.GetString("workspace_id"), c.LocalConfig.GetString("team_id"), c.GlobalConfig.GetString((c.Profile.GetConfigField("workspace_id"))), c.GlobalConfig.GetString((c.Profile.GetConfigField("team_id"))), ""}) - c.Profile.TeamMode = getStringConfig([]string{c.Profile.TeamMode, c.LocalConfig.GetString("workspace_mode"), c.LocalConfig.GetString("team_mode"), c.GlobalConfig.GetString((c.Profile.GetConfigField("workspace_mode"))), c.GlobalConfig.GetString((c.Profile.GetConfigField("team_mode"))), ""}) + c.Color = getStringConfig([]string{c.Color, c.viper.GetString(("color")), "auto"}) + c.LogLevel = getStringConfig([]string{c.LogLevel, c.viper.GetString(("log")), "info"}) + c.APIBaseURL = getStringConfig([]string{c.APIBaseURL, c.viper.GetString(("api_base")), hookdeck.DefaultAPIBaseURL}) + c.DashboardBaseURL = getStringConfig([]string{c.DashboardBaseURL, c.viper.GetString(("dashboard_base")), hookdeck.DefaultDashboardBaseURL}) + c.ConsoleBaseURL = getStringConfig([]string{c.ConsoleBaseURL, c.viper.GetString(("console_base")), hookdeck.DefaultConsoleBaseURL}) + c.WSBaseURL = getStringConfig([]string{c.WSBaseURL, c.viper.GetString(("ws_base")), hookdeck.DefaultWebsocektURL}) + c.Profile.Name = getStringConfig([]string{c.Profile.Name, c.viper.GetString(("profile")), hookdeck.DefaultProfileName}) + // Needs to support both profile-based config + // and top-level config for backward compat. For example: + // ```` + // [default] + // api_key = "key" + // ```` + // vs + // ```` + // api_key = "key" + // ``` + // Also support a few deprecated terminology + // "workspace" > "team" + // TODO: use "project" instead of "workspace" + // TODO: use "cli_key" instead of "api_key" + c.Profile.APIKey = getStringConfig([]string{c.Profile.APIKey, c.viper.GetString(c.Profile.GetConfigField("api_key")), c.viper.GetString("api_key"), ""}) + c.Profile.APIKey = getStringConfig([]string{c.Profile.APIKey, c.viper.GetString(c.Profile.GetConfigField("workspace_id")), c.viper.GetString(c.Profile.GetConfigField("team_id")), c.viper.GetString("workspace_id"), ""}) + c.Profile.APIKey = getStringConfig([]string{c.Profile.APIKey, c.viper.GetString(c.Profile.GetConfigField("workspace_mode")), c.viper.GetString(c.Profile.GetConfigField("team_mode")), c.viper.GetString("workspace_mode"), ""}) } func getStringConfig(values []string) string { @@ -290,6 +275,38 @@ func getStringConfig(values []string) string { return values[len(values)-1] } +// getConfigPath returns the path for the config file. +// Precedence: +// - path (if path is provided) +// - `${PWD}/.hookdeck/config.toml` +// - `${HOME}/.config/hookdeck/config.toml` +// Returns the path string and a boolean indicating whether it's the global default path. +func getConfigPath(path string) (string, bool) { + workspaceFolder, err := os.Getwd() + if err != nil { + log.Fatal(err) + } + + if path != "" { + if filepath.IsAbs(path) { + return path, false + } + return filepath.Join(workspaceFolder, path), false + } + + localConfigPath := filepath.Join(workspaceFolder, ".hookdeck/config.toml") + localConfigExists, err := fileExists(localConfigPath) + if err != nil { + log.Fatal(err) + } + if localConfigExists { + return localConfigPath, false + } + + globalConfigFolder := getConfigFolder(os.Getenv("XDG_CONFIG_HOME")) + return filepath.Join(globalConfigFolder, "config.toml"), true +} + // isProfile identifies whether a value in the config pertains to a profile. func isProfile(value interface{}) bool { // TODO: ianjabour - ideally find a better way to identify projects in config @@ -365,3 +382,14 @@ func deepSearch(m map[string]interface{}, path []string) map[string]interface{} return m } + +func fileExists(path string) (bool, error) { + _, err := os.Stat(path) + if err == nil { + return true, nil + } + if os.IsNotExist(err) { + return false, nil + } + return false, err +} diff --git a/pkg/config/profile.go b/pkg/config/profile.go index d44782d..b66bb50 100644 --- a/pkg/config/profile.go +++ b/pkg/config/profile.go @@ -16,28 +16,16 @@ func (p *Profile) GetConfigField(field string) string { return p.Name + "." + field } -func (p *Profile) SaveProfile(local bool) error { - // in local, we're d setting mode because it should always be inbound - // as a user can't have both inbound & console teams (i think) - // and we don't need to expose it to the end user - if local { - p.Config.GlobalConfig.Set(p.GetConfigField("api_key"), p.APIKey) - if err := p.Config.WriteGlobalConfig(); err != nil { - return err - } - p.Config.LocalConfig.Set("workspace_id", p.TeamID) - return p.Config.WriteLocalConfig() - } else { - p.Config.GlobalConfig.Set(p.GetConfigField("api_key"), p.APIKey) - p.Config.GlobalConfig.Set(p.GetConfigField("workspace_id"), p.TeamID) - p.Config.GlobalConfig.Set(p.GetConfigField("workspace_mode"), p.TeamMode) - return p.Config.WriteGlobalConfig() - } +func (p *Profile) SaveProfile() error { + p.Config.viper.Set(p.GetConfigField("api_key"), p.APIKey) + p.Config.viper.Set(p.GetConfigField("workspace_id"), p.TeamID) + p.Config.viper.Set(p.GetConfigField("workspace_mode"), p.TeamMode) + return p.Config.WriteConfig() } func (p *Profile) RemoveProfile() error { var err error - runtimeViper := p.Config.GlobalConfig + runtimeViper := p.Config.viper runtimeViper, err = removeKey(runtimeViper, "profile") if err != nil { @@ -49,14 +37,14 @@ func (p *Profile) RemoveProfile() error { } runtimeViper.SetConfigType("toml") - runtimeViper.SetConfigFile(p.Config.GlobalConfig.ConfigFileUsed()) - p.Config.GlobalConfig = runtimeViper - return p.Config.WriteGlobalConfig() + runtimeViper.SetConfigFile(p.Config.viper.ConfigFileUsed()) + p.Config.viper = runtimeViper + return p.Config.WriteConfig() } func (p *Profile) UseProfile() error { - p.Config.GlobalConfig.Set("profile", p.Name) - return p.Config.WriteGlobalConfig() + p.Config.viper.Set("profile", p.Name) + return p.Config.WriteConfig() } func (p *Profile) ValidateAPIKey() error { diff --git a/pkg/login/client_login.go b/pkg/login/client_login.go index 00a2c5a..3013028 100644 --- a/pkg/login/client_login.go +++ b/pkg/login/client_login.go @@ -51,7 +51,7 @@ func Login(config *config.Config, input io.Reader) error { message := SuccessMessage(response.UserName, response.UserEmail, response.OrganizationName, response.TeamName, response.TeamMode == "console") ansi.StopSpinner(s, message, os.Stdout) - if err = config.Profile.SaveProfile(false); err != nil { + if err = config.Profile.SaveProfile(); err != nil { return err } if err = config.Profile.UseProfile(); err != nil { @@ -99,7 +99,7 @@ func Login(config *config.Config, input io.Reader) error { config.Profile.TeamID = response.TeamID config.Profile.TeamMode = response.TeamMode - if err = config.Profile.SaveProfile(false); err != nil { + if err = config.Profile.SaveProfile(); err != nil { return err } if err = config.Profile.UseProfile(); err != nil { @@ -145,7 +145,7 @@ func GuestLogin(config *config.Config) (string, error) { config.Profile.TeamID = response.TeamID config.Profile.TeamMode = response.TeamMode - if err = config.Profile.SaveProfile(false); err != nil { + if err = config.Profile.SaveProfile(); err != nil { return "", err } if err = config.Profile.UseProfile(); err != nil { @@ -185,7 +185,7 @@ func CILogin(config *config.Config, apiKey string, name string) error { config.Profile.TeamID = response.TeamID config.Profile.TeamMode = response.TeamMode - if err = config.Profile.SaveProfile(false); err != nil { + if err = config.Profile.SaveProfile(); err != nil { return err } if err = config.Profile.UseProfile(); err != nil { diff --git a/pkg/login/interactive_login.go b/pkg/login/interactive_login.go index b193517..35f9a3a 100644 --- a/pkg/login/interactive_login.go +++ b/pkg/login/interactive_login.go @@ -66,7 +66,7 @@ func InteractiveLogin(config *config.Config) error { config.Profile.TeamMode = response.TeamMode config.Profile.TeamID = response.TeamID - if err = config.Profile.SaveProfile(false); err != nil { + if err = config.Profile.SaveProfile(); err != nil { ansi.StopSpinner(s, "", os.Stdout) return err } From b7dc5dfeb3578f2b2ae3cd39b2a0ebd5fbe9890a Mon Sep 17 00:00:00 2001 From: Alex Luong Date: Tue, 3 Sep 2024 01:15:41 +0700 Subject: [PATCH 04/10] chore: Remove debug log --- pkg/cmd/whoami.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/pkg/cmd/whoami.go b/pkg/cmd/whoami.go index 6501347..64678a4 100644 --- a/pkg/cmd/whoami.go +++ b/pkg/cmd/whoami.go @@ -28,8 +28,6 @@ func newWhoamiCmd() *whoamiCmd { } func (lc *whoamiCmd) runWhoamiCmd(cmd *cobra.Command, args []string) error { - fmt.Println(Config.Profile) - if err := Config.Profile.ValidateAPIKey(); err != nil { return err } From 9496ab4f26e07df144074c39a8ea97e7b9490850 Mon Sep 17 00:00:00 2001 From: Alex Luong Date: Tue, 3 Sep 2024 01:17:43 +0700 Subject: [PATCH 05/10] refactor: fs interface --- pkg/config/config.go | 39 +++++++++++---------------------------- pkg/config/fs.go | 43 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 28 deletions(-) create mode 100644 pkg/config/fs.go diff --git a/pkg/config/config.go b/pkg/config/config.go index c67e6d3..708b87d 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -47,6 +47,9 @@ type Config struct { ConfigFileFlag string // flag -- should NOT use this directly configFile string // resolved path of config file viper *viper.Viper + + // Internal + fs ConfigFS } // getConfigFolder retrieves the folder where the profiles file is stored @@ -75,6 +78,10 @@ func getConfigFolder(xdgPath string) string { // InitConfig reads in profiles file and ENV variables if set. func (c *Config) InitConfig() { + if c.fs == nil { + c.fs = newConfigFS() + } + c.Profile.Config = c // Set log level @@ -98,7 +105,7 @@ func (c *Config) InitConfig() { c.viper = viper.New() - configPath, isGlobalConfig := getConfigPath(c.ConfigFileFlag) + configPath, isGlobalConfig := c.getConfigPath(c.ConfigFileFlag) c.configFile = configPath c.viper.SetConfigType("toml") c.viper.SetConfigFile(c.configFile) @@ -225,7 +232,7 @@ func (c *Config) RemoveAllProfiles() error { } func (c *Config) WriteConfig() error { - if err := makePath(c.viper.ConfigFileUsed()); err != nil { + if err := c.fs.makePath(c.viper.ConfigFileUsed()); err != nil { return err } @@ -281,7 +288,7 @@ func getStringConfig(values []string) string { // - `${PWD}/.hookdeck/config.toml` // - `${HOME}/.config/hookdeck/config.toml` // Returns the path string and a boolean indicating whether it's the global default path. -func getConfigPath(path string) (string, bool) { +func (c *Config) getConfigPath(path string) (string, bool) { workspaceFolder, err := os.Getwd() if err != nil { log.Fatal(err) @@ -295,7 +302,7 @@ func getConfigPath(path string) (string, bool) { } localConfigPath := filepath.Join(workspaceFolder, ".hookdeck/config.toml") - localConfigExists, err := fileExists(localConfigPath) + localConfigExists, err := c.fs.fileExists(localConfigPath) if err != nil { log.Fatal(err) } @@ -340,19 +347,6 @@ func removeKey(v *viper.Viper, key string) (*viper.Viper, error) { return nv, nil } -func makePath(path string) error { - dir := filepath.Dir(path) - - if _, err := os.Stat(dir); os.IsNotExist(err) { - err = os.MkdirAll(dir, os.ModePerm) - if err != nil { - return err - } - } - - return nil -} - // taken from https://github.com/spf13/viper/blob/master/util.go#L199, // we need this to delete configs, remove when viper supprts unset natively func deepSearch(m map[string]interface{}, path []string) map[string]interface{} { @@ -382,14 +376,3 @@ func deepSearch(m map[string]interface{}, path []string) map[string]interface{} return m } - -func fileExists(path string) (bool, error) { - _, err := os.Stat(path) - if err == nil { - return true, nil - } - if os.IsNotExist(err) { - return false, nil - } - return false, err -} diff --git a/pkg/config/fs.go b/pkg/config/fs.go new file mode 100644 index 0000000..34d4cc5 --- /dev/null +++ b/pkg/config/fs.go @@ -0,0 +1,43 @@ +package config + +import ( + "os" + "path/filepath" +) + +type ConfigFS interface { + fileExists(path string) (bool, error) + makePath(path string) error +} + +type configFS struct{} + +var _ ConfigFS = &configFS{} + +func newConfigFS() *configFS { + return &configFS{} +} + +func (fs *configFS) fileExists(path string) (bool, error) { + _, err := os.Stat(path) + if err == nil { + return true, nil + } + if os.IsNotExist(err) { + return false, nil + } + return false, err +} + +func (fs *configFS) makePath(path string) error { + dir := filepath.Dir(path) + + if _, err := os.Stat(dir); os.IsNotExist(err) { + err = os.MkdirAll(dir, os.ModePerm) + if err != nil { + return err + } + } + + return nil +} From 8defd13e3d942816e01ff2fc76c83f8a0b8dfcbf Mon Sep 17 00:00:00 2001 From: Alex Luong Date: Tue, 3 Sep 2024 01:19:07 +0700 Subject: [PATCH 06/10] test: getConfigPath --- pkg/config/config_test.go | 158 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 158 insertions(+) diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 274adc7..00f84f8 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -1,9 +1,12 @@ package config import ( + "os" + "path/filepath" "testing" "github.com/spf13/viper" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -18,3 +21,158 @@ func TestRemoveKey(t *testing.T) { require.EqualValues(t, []string{"stay"}, nv.AllKeys()) require.ElementsMatch(t, []string{"stay", "remove"}, v.AllKeys()) } + +func TestGetConfigPath(t *testing.T) { + t.Parallel() + + t.Run("with no config - should return global config path", func(t *testing.T) { + t.Parallel() + + fs := &globalNoLocalConfigFS{} + c := Config{fs: fs} + customPath := "" + + path, isGlobalConfig := c.getConfigPath(customPath) + assert.True(t, isGlobalConfig) + assert.Equal(t, path, filepath.Join(getConfigFolder(os.Getenv("XDG_CONFIG_HOME")), "config.toml")) + }) + + t.Run("with no local or custom config - should return global config path", func(t *testing.T) { + t.Parallel() + + fs := &noConfigFS{} + c := Config{fs: fs} + customPath := "" + + path, isGlobalConfig := c.getConfigPath(customPath) + assert.True(t, isGlobalConfig) + assert.Equal(t, path, filepath.Join(getConfigFolder(os.Getenv("XDG_CONFIG_HOME")), "config.toml")) + }) + + t.Run("with local and custom config - should return custom config path", func(t *testing.T) { + t.Parallel() + + fs := &globalAndLocalConfigFS{} + c := Config{fs: fs} + customPath := "/absolute/custom/config.toml" + + path, isGlobalConfig := c.getConfigPath(customPath) + assert.False(t, isGlobalConfig) + assert.Equal(t, path, customPath) + }) + + t.Run("with local only - should return local config path", func(t *testing.T) { + t.Parallel() + + fs := &globalAndLocalConfigFS{} + c := Config{fs: fs} + customPath := "" + + path, isGlobalConfig := c.getConfigPath(customPath) + assert.False(t, isGlobalConfig) + pwd, _ := os.Getwd() + assert.Equal(t, path, filepath.Join(pwd, "./.hookdeck/config.toml")) + }) + + t.Run("with absolute custom config - should return custom config path", func(t *testing.T) { + t.Parallel() + + fs := &noConfigFS{} + c := Config{fs: fs} + customPath := "/absolute/custom/config.toml" + + path, isGlobalConfig := c.getConfigPath(customPath) + assert.False(t, isGlobalConfig) + assert.Equal(t, path, customPath) + }) + + t.Run("with relative custom config - should return custom config path", func(t *testing.T) { + t.Parallel() + + fs := &noConfigFS{} + c := Config{fs: fs} + customPath := "absolute/custom/config.toml" + + path, isGlobalConfig := c.getConfigPath(customPath) + assert.False(t, isGlobalConfig) + pwd, _ := os.Getwd() + assert.Equal(t, path, filepath.Join(pwd, customPath)) + }) +} + +// ===== Mock FS ===== + +// Mock fs where there's no config file, whether global or local +type noConfigFS struct{} + +var _ ConfigFS = &noConfigFS{} + +func (fs *noConfigFS) makePath(path string) error { + return nil +} +func (fs *noConfigFS) fileExists(path string) (bool, error) { + return false, nil +} + +// Mock fs where there's global and local config file +type globalAndLocalConfigFS struct{} + +var _ ConfigFS = &globalAndLocalConfigFS{} + +func (fs *globalAndLocalConfigFS) makePath(path string) error { + return nil +} +func (fs *globalAndLocalConfigFS) fileExists(path string) (bool, error) { + return true, nil +} + +// Mock fs where there's global but no local config file +type globalNoLocalConfigFS struct{} + +var _ ConfigFS = &globalNoLocalConfigFS{} + +func (fs *globalNoLocalConfigFS) makePath(path string) error { + return nil +} +func (fs *globalNoLocalConfigFS) fileExists(path string) (bool, error) { + globalConfigFolder := getConfigFolder(os.Getenv("XDG_CONFIG_HOME")) + globalPath := filepath.Join(globalConfigFolder, "config.toml") + if path == globalPath { + return true, nil + } + return false, nil +} + +// Mock fs where there's no global and yes local config file +type noGlobalYesLocalConfigFS struct{} + +var _ ConfigFS = &noGlobalYesLocalConfigFS{} + +func (fs *noGlobalYesLocalConfigFS) makePath(path string) error { + return nil +} +func (fs *noGlobalYesLocalConfigFS) fileExists(path string) (bool, error) { + workspaceFolder, _ := os.Getwd() + localPath := filepath.Join(workspaceFolder, ".hookdeck/config.toml") + if path == localPath { + return true, nil + } + return false, nil +} + +// Mock fs where there's only custom local config at ${PWD}/customconfig.toml +type onlyCustomConfigFS struct{} + +var _ ConfigFS = &onlyCustomConfigFS{} + +func (fs *onlyCustomConfigFS) makePath(path string) error { + return nil +} +func (fs *onlyCustomConfigFS) fileExists(path string) (bool, error) { + workspaceFolder, _ := os.Getwd() + customConfigPath := filepath.Join(workspaceFolder, "customconfig.toml") + if path == customConfigPath { + return true, nil + } + return false, nil +} From 827435b39136b7d0b91f57bf696969a62fe16666 Mon Sep 17 00:00:00 2001 From: Alex Luong Date: Tue, 3 Sep 2024 01:26:55 +0700 Subject: [PATCH 07/10] test: improve test semantic using expected path variable --- pkg/config/config_test.go | 46 ++++++++++++++++++++++----------------- 1 file changed, 26 insertions(+), 20 deletions(-) diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 00f84f8..eee49d3 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -30,11 +30,12 @@ func TestGetConfigPath(t *testing.T) { fs := &globalNoLocalConfigFS{} c := Config{fs: fs} - customPath := "" + customPathInput := "" + expectedPath := filepath.Join(getConfigFolder(os.Getenv("XDG_CONFIG_HOME")), "config.toml") - path, isGlobalConfig := c.getConfigPath(customPath) + path, isGlobalConfig := c.getConfigPath(customPathInput) assert.True(t, isGlobalConfig) - assert.Equal(t, path, filepath.Join(getConfigFolder(os.Getenv("XDG_CONFIG_HOME")), "config.toml")) + assert.Equal(t, expectedPath, path) }) t.Run("with no local or custom config - should return global config path", func(t *testing.T) { @@ -42,11 +43,12 @@ func TestGetConfigPath(t *testing.T) { fs := &noConfigFS{} c := Config{fs: fs} - customPath := "" + customPathInput := "" + expectedPath := filepath.Join(getConfigFolder(os.Getenv("XDG_CONFIG_HOME")), "config.toml") - path, isGlobalConfig := c.getConfigPath(customPath) + path, isGlobalConfig := c.getConfigPath(customPathInput) assert.True(t, isGlobalConfig) - assert.Equal(t, path, filepath.Join(getConfigFolder(os.Getenv("XDG_CONFIG_HOME")), "config.toml")) + assert.Equal(t, expectedPath, path) }) t.Run("with local and custom config - should return custom config path", func(t *testing.T) { @@ -54,11 +56,12 @@ func TestGetConfigPath(t *testing.T) { fs := &globalAndLocalConfigFS{} c := Config{fs: fs} - customPath := "/absolute/custom/config.toml" + customPathInput := "/absolute/custom/config.toml" + expectedPath := customPathInput - path, isGlobalConfig := c.getConfigPath(customPath) + path, isGlobalConfig := c.getConfigPath(customPathInput) assert.False(t, isGlobalConfig) - assert.Equal(t, path, customPath) + assert.Equal(t, expectedPath, path) }) t.Run("with local only - should return local config path", func(t *testing.T) { @@ -66,12 +69,13 @@ func TestGetConfigPath(t *testing.T) { fs := &globalAndLocalConfigFS{} c := Config{fs: fs} - customPath := "" + customPathInput := "" + pwd, _ := os.Getwd() + expectedPath := filepath.Join(pwd, "./.hookdeck/config.toml") - path, isGlobalConfig := c.getConfigPath(customPath) + path, isGlobalConfig := c.getConfigPath(customPathInput) assert.False(t, isGlobalConfig) - pwd, _ := os.Getwd() - assert.Equal(t, path, filepath.Join(pwd, "./.hookdeck/config.toml")) + assert.Equal(t, expectedPath, path) }) t.Run("with absolute custom config - should return custom config path", func(t *testing.T) { @@ -79,11 +83,12 @@ func TestGetConfigPath(t *testing.T) { fs := &noConfigFS{} c := Config{fs: fs} - customPath := "/absolute/custom/config.toml" + customPathInput := "/absolute/custom/config.toml" + expectedPath := customPathInput - path, isGlobalConfig := c.getConfigPath(customPath) + path, isGlobalConfig := c.getConfigPath(customPathInput) assert.False(t, isGlobalConfig) - assert.Equal(t, path, customPath) + assert.Equal(t, expectedPath, path) }) t.Run("with relative custom config - should return custom config path", func(t *testing.T) { @@ -91,12 +96,13 @@ func TestGetConfigPath(t *testing.T) { fs := &noConfigFS{} c := Config{fs: fs} - customPath := "absolute/custom/config.toml" + customPathInput := "absolute/custom/config.toml" + pwd, _ := os.Getwd() + expectedPath := filepath.Join(pwd, customPathInput) - path, isGlobalConfig := c.getConfigPath(customPath) + path, isGlobalConfig := c.getConfigPath(customPathInput) assert.False(t, isGlobalConfig) - pwd, _ := os.Getwd() - assert.Equal(t, path, filepath.Join(pwd, customPath)) + assert.Equal(t, expectedPath, path) }) } From 23767be2fa80d528b565f87f2e436136cfea9865 Mon Sep 17 00:00:00 2001 From: Alex Luong Date: Tue, 3 Sep 2024 03:18:51 +0700 Subject: [PATCH 08/10] test: Parse config with different mock config scenarios --- pkg/config/config.go | 4 +- pkg/config/config_test.go | 114 ++++++++++++++++++ pkg/config/testdata/README.md | 8 ++ pkg/config/testdata/default-profile.toml | 6 + pkg/config/testdata/empty.toml | 0 pkg/config/testdata/local-full.toml | 3 + pkg/config/testdata/local-workspace-only.toml | 1 + pkg/config/testdata/multiple-profiles.toml | 21 ++++ 8 files changed, 155 insertions(+), 2 deletions(-) create mode 100644 pkg/config/testdata/README.md create mode 100644 pkg/config/testdata/default-profile.toml create mode 100644 pkg/config/testdata/empty.toml create mode 100644 pkg/config/testdata/local-full.toml create mode 100644 pkg/config/testdata/local-workspace-only.toml create mode 100644 pkg/config/testdata/multiple-profiles.toml diff --git a/pkg/config/config.go b/pkg/config/config.go index 708b87d..7745ea3 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -268,8 +268,8 @@ func (c *Config) constructConfig() { // TODO: use "project" instead of "workspace" // TODO: use "cli_key" instead of "api_key" c.Profile.APIKey = getStringConfig([]string{c.Profile.APIKey, c.viper.GetString(c.Profile.GetConfigField("api_key")), c.viper.GetString("api_key"), ""}) - c.Profile.APIKey = getStringConfig([]string{c.Profile.APIKey, c.viper.GetString(c.Profile.GetConfigField("workspace_id")), c.viper.GetString(c.Profile.GetConfigField("team_id")), c.viper.GetString("workspace_id"), ""}) - c.Profile.APIKey = getStringConfig([]string{c.Profile.APIKey, c.viper.GetString(c.Profile.GetConfigField("workspace_mode")), c.viper.GetString(c.Profile.GetConfigField("team_mode")), c.viper.GetString("workspace_mode"), ""}) + c.Profile.TeamID = getStringConfig([]string{c.Profile.TeamID, c.viper.GetString(c.Profile.GetConfigField("workspace_id")), c.viper.GetString(c.Profile.GetConfigField("team_id")), c.viper.GetString("workspace_id"), ""}) + c.Profile.TeamMode = getStringConfig([]string{c.Profile.TeamMode, c.viper.GetString(c.Profile.GetConfigField("workspace_mode")), c.viper.GetString(c.Profile.GetConfigField("team_mode")), c.viper.GetString("workspace_mode"), ""}) } func getStringConfig(values []string) string { diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index eee49d3..3d8eeec 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -106,6 +106,120 @@ func TestGetConfigPath(t *testing.T) { }) } +func TestInitConfig(t *testing.T) { + t.Parallel() + + t.Run("empty config", func(t *testing.T) { + t.Parallel() + + c := Config{ + LogLevel: "info", + ConfigFileFlag: "./testdata/empty.toml", + } + c.InitConfig() + + assert.Equal(t, "default", c.Profile.Name) + assert.Equal(t, "", c.Profile.APIKey) + assert.Equal(t, "", c.Profile.TeamID) + assert.Equal(t, "", c.Profile.TeamMode) + }) + + t.Run("default profile", func(t *testing.T) { + t.Parallel() + + c := Config{ + LogLevel: "info", + ConfigFileFlag: "./testdata/default-profile.toml", + } + c.InitConfig() + + assert.Equal(t, "default", c.Profile.Name) + assert.Equal(t, "test_api_key", c.Profile.APIKey) + assert.Equal(t, "test_workspace_id", c.Profile.TeamID) + assert.Equal(t, "test_workspace_mode", c.Profile.TeamMode) + }) + + t.Run("multiple profile", func(t *testing.T) { + t.Parallel() + + c := Config{ + LogLevel: "info", + ConfigFileFlag: "./testdata/multiple-profiles.toml", + } + c.InitConfig() + + assert.Equal(t, "account_2", c.Profile.Name) + assert.Equal(t, "account_2_test_api_key", c.Profile.APIKey) + assert.Equal(t, "account_2_test_workspace_id", c.Profile.TeamID) + assert.Equal(t, "account_2_test_workspace_mode", c.Profile.TeamMode) + }) + + t.Run("custom profile", func(t *testing.T) { + t.Parallel() + + c := Config{ + LogLevel: "info", + ConfigFileFlag: "./testdata/multiple-profiles.toml", + } + c.Profile.Name = "account_3" + c.InitConfig() + + assert.Equal(t, "account_3", c.Profile.Name) + assert.Equal(t, "account_3_test_api_key", c.Profile.APIKey) + assert.Equal(t, "account_3_test_workspace_id", c.Profile.TeamID) + assert.Equal(t, "account_3_test_workspace_mode", c.Profile.TeamMode) + }) + + t.Run("local full", func(t *testing.T) { + t.Parallel() + + c := Config{ + LogLevel: "info", + ConfigFileFlag: "./testdata/local-full.toml", + } + c.InitConfig() + + assert.Equal(t, "default", c.Profile.Name) + assert.Equal(t, "local_api_key", c.Profile.APIKey) + assert.Equal(t, "local_workspace_id", c.Profile.TeamID) + assert.Equal(t, "local_workspace_mode", c.Profile.TeamMode) + }) + + // TODO: Consider this case. This is a breaking change. + // BREAKINGCHANGE + t.Run("local workspace only", func(t *testing.T) { + t.Parallel() + + c := Config{ + LogLevel: "info", + ConfigFileFlag: "./testdata/local-workspace-only.toml", + } + c.InitConfig() + + assert.Equal(t, "default", c.Profile.Name) + assert.Equal(t, "", c.Profile.APIKey) + assert.Equal(t, "local_workspace_id", c.Profile.TeamID) + assert.Equal(t, "", c.Profile.TeamMode) + }) + + t.Run("api key override", func(t *testing.T) { + t.Parallel() + + c := Config{ + LogLevel: "info", + ConfigFileFlag: "./testdata/default-profile.toml", + } + apiKey := "overridden_api_key" + c.Profile.APIKey = apiKey + c.InitConfig() + + assert.Equal(t, "default", c.Profile.Name) + assert.Equal(t, apiKey, c.Profile.APIKey) + assert.Equal(t, "test_workspace_id", c.Profile.TeamID) + assert.Equal(t, "test_workspace_mode", c.Profile.TeamMode) + }) +} + // ===== Mock FS ===== // Mock fs where there's no config file, whether global or local diff --git a/pkg/config/testdata/README.md b/pkg/config/testdata/README.md new file mode 100644 index 0000000..f294be1 --- /dev/null +++ b/pkg/config/testdata/README.md @@ -0,0 +1,8 @@ +# Config testdata + +Some explanation of different config testdata scenarios: + +- default-profile.toml: This config has a singular profile named "default". +- empty.toml: This config is completely empty. +- local-full.toml: This config is for local config `${PWD}/.hookdeck/config.toml` where the user has a full profile. +- local-workspace-only.toml: This config is for local config `${PWD}/.hookdeck/config.toml` where the user only has a `workspace_id` config. This happens when user runs `$ hookdeck project use --local` to scope the usage of the project within their local scope. diff --git a/pkg/config/testdata/default-profile.toml b/pkg/config/testdata/default-profile.toml new file mode 100644 index 0000000..4e0154c --- /dev/null +++ b/pkg/config/testdata/default-profile.toml @@ -0,0 +1,6 @@ +profile = "default" + +[default] + api_key = "test_api_key" + workspace_id = "test_workspace_id" + workspace_mode = "test_workspace_mode" diff --git a/pkg/config/testdata/empty.toml b/pkg/config/testdata/empty.toml new file mode 100644 index 0000000..e69de29 diff --git a/pkg/config/testdata/local-full.toml b/pkg/config/testdata/local-full.toml new file mode 100644 index 0000000..8f29e42 --- /dev/null +++ b/pkg/config/testdata/local-full.toml @@ -0,0 +1,3 @@ +api_key = "local_api_key" +workspace_id = "local_workspace_id" +workspace_mode = "local_workspace_mode" diff --git a/pkg/config/testdata/local-workspace-only.toml b/pkg/config/testdata/local-workspace-only.toml new file mode 100644 index 0000000..1d53a11 --- /dev/null +++ b/pkg/config/testdata/local-workspace-only.toml @@ -0,0 +1 @@ +workspace_id = "local_workspace_id" diff --git a/pkg/config/testdata/multiple-profiles.toml b/pkg/config/testdata/multiple-profiles.toml new file mode 100644 index 0000000..652bf21 --- /dev/null +++ b/pkg/config/testdata/multiple-profiles.toml @@ -0,0 +1,21 @@ +profile = "account_2" + +[default] + api_key = "test_api_key" + workspace_id = "test_workspace_id" + workspace_mode = "test_workspace_mode" + +[account_1] + api_key = "account_1_test_api_key" + workspace_id = "account_1_test_workspace_id" + workspace_mode = "account_1_test_workspace_mode" + +[account_2] + api_key = "account_2_test_api_key" + workspace_id = "account_2_test_workspace_id" + workspace_mode = "account_2_test_workspace_mode" + +[account_3] + api_key = "account_3_test_api_key" + workspace_id = "account_3_test_workspace_id" + workspace_mode = "account_3_test_workspace_mode" From 5006f6cf27ba4072d47b8c4ef2e5a4c34dc86112 Mon Sep 17 00:00:00 2001 From: Alex Luong Date: Tue, 3 Sep 2024 03:35:31 +0700 Subject: [PATCH 09/10] refactor: Clean config files & reduce package's public api surface --- pkg/config/config.go | 161 +++----------------------------------- pkg/config/config_test.go | 6 +- pkg/config/helpers.go | 112 ++++++++++++++++++++++++++ pkg/config/profile.go | 16 ++-- 4 files changed, 136 insertions(+), 159 deletions(-) create mode 100644 pkg/config/helpers.go diff --git a/pkg/config/config.go b/pkg/config/config.go index 7745ea3..43c3d92 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -1,17 +1,10 @@ package config import ( - "bytes" - "fmt" "os" - "os/exec" "path/filepath" - "runtime" - "strings" "time" - "github.com/BurntSushi/toml" - "github.com/mitchellh/go-homedir" log "github.com/sirupsen/logrus" "github.com/spf13/viper" prefixed "github.com/x-cray/logrus-prefixed-formatter" @@ -52,30 +45,6 @@ type Config struct { fs ConfigFS } -// getConfigFolder retrieves the folder where the profiles file is stored -// It searches for the xdg environment path first and will secondarily -// place it in the home directory -func getConfigFolder(xdgPath string) string { - configPath := xdgPath - - if configPath == "" { - home, err := homedir.Dir() - if err != nil { - fmt.Println(err) - os.Exit(1) - } - - configPath = filepath.Join(home, ".config") - } - - log.WithFields(log.Fields{ - "prefix": "config.Config.GetProfilesFolder", - "path": configPath, - }).Debug("Using profiles folder") - - return filepath.Join(configPath, "hookdeck") -} - // InitConfig reads in profiles file and ENV variables if set. func (c *Config) InitConfig() { if c.fs == nil { @@ -155,37 +124,6 @@ func (c *Config) InitConfig() { log.SetFormatter(logFormatter) } -// EditConfig opens the configuration file in the default editor. -func (c *Config) EditConfig() error { - var err error - - fmt.Println("Opening config file:", c.configFile) - - switch runtime.GOOS { - case "darwin", "linux": - editor := os.Getenv("EDITOR") - if editor == "" { - editor = "vi" - } - - cmd := exec.Command(editor, c.configFile) - // Some editors detect whether they have control of stdin/out and will - // fail if they do not. - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - - return cmd.Run() - case "windows": - // As far as I can tell, Windows doesn't have an easily accesible or - // comparable option to $EDITOR, so default to notepad for now - err = exec.Command("notepad", c.configFile).Run() - default: - err = fmt.Errorf("unsupported platform") - } - - return err -} - // UseProject selects the active project to be used func (c *Config) UseProject(teamId string, teamMode string) error { c.Profile.TeamID = teamId @@ -228,10 +166,10 @@ func (c *Config) RemoveAllProfiles() error { runtimeViper.SetConfigType("toml") runtimeViper.SetConfigFile(c.viper.ConfigFileUsed()) c.viper = runtimeViper - return c.WriteConfig() + return c.writeConfig() } -func (c *Config) WriteConfig() error { +func (c *Config) writeConfig() error { if err := c.fs.makePath(c.viper.ConfigFileUsed()); err != nil { return err } @@ -246,13 +184,13 @@ func (c *Config) WriteConfig() error { // Construct the config struct from flags > local config > global config func (c *Config) constructConfig() { - c.Color = getStringConfig([]string{c.Color, c.viper.GetString(("color")), "auto"}) - c.LogLevel = getStringConfig([]string{c.LogLevel, c.viper.GetString(("log")), "info"}) - c.APIBaseURL = getStringConfig([]string{c.APIBaseURL, c.viper.GetString(("api_base")), hookdeck.DefaultAPIBaseURL}) - c.DashboardBaseURL = getStringConfig([]string{c.DashboardBaseURL, c.viper.GetString(("dashboard_base")), hookdeck.DefaultDashboardBaseURL}) - c.ConsoleBaseURL = getStringConfig([]string{c.ConsoleBaseURL, c.viper.GetString(("console_base")), hookdeck.DefaultConsoleBaseURL}) - c.WSBaseURL = getStringConfig([]string{c.WSBaseURL, c.viper.GetString(("ws_base")), hookdeck.DefaultWebsocektURL}) - c.Profile.Name = getStringConfig([]string{c.Profile.Name, c.viper.GetString(("profile")), hookdeck.DefaultProfileName}) + c.Color = stringCoalesce(c.Color, c.viper.GetString(("color")), "auto") + c.LogLevel = stringCoalesce(c.LogLevel, c.viper.GetString(("log")), "info") + c.APIBaseURL = stringCoalesce(c.APIBaseURL, c.viper.GetString(("api_base")), hookdeck.DefaultAPIBaseURL) + c.DashboardBaseURL = stringCoalesce(c.DashboardBaseURL, c.viper.GetString(("dashboard_base")), hookdeck.DefaultDashboardBaseURL) + c.ConsoleBaseURL = stringCoalesce(c.ConsoleBaseURL, c.viper.GetString(("console_base")), hookdeck.DefaultConsoleBaseURL) + c.WSBaseURL = stringCoalesce(c.WSBaseURL, c.viper.GetString(("ws_base")), hookdeck.DefaultWebsocektURL) + c.Profile.Name = stringCoalesce(c.Profile.Name, c.viper.GetString(("profile")), hookdeck.DefaultProfileName) // Needs to support both profile-based config // and top-level config for backward compat. For example: // ```` @@ -267,19 +205,9 @@ func (c *Config) constructConfig() { // "workspace" > "team" // TODO: use "project" instead of "workspace" // TODO: use "cli_key" instead of "api_key" - c.Profile.APIKey = getStringConfig([]string{c.Profile.APIKey, c.viper.GetString(c.Profile.GetConfigField("api_key")), c.viper.GetString("api_key"), ""}) - c.Profile.TeamID = getStringConfig([]string{c.Profile.TeamID, c.viper.GetString(c.Profile.GetConfigField("workspace_id")), c.viper.GetString(c.Profile.GetConfigField("team_id")), c.viper.GetString("workspace_id"), ""}) - c.Profile.TeamMode = getStringConfig([]string{c.Profile.TeamMode, c.viper.GetString(c.Profile.GetConfigField("workspace_mode")), c.viper.GetString(c.Profile.GetConfigField("team_mode")), c.viper.GetString("workspace_mode"), ""}) -} - -func getStringConfig(values []string) string { - for _, str := range values { - if str != "" { - return str - } - } - - return values[len(values)-1] + c.Profile.APIKey = stringCoalesce(c.Profile.APIKey, c.viper.GetString(c.Profile.getConfigField("api_key")), c.viper.GetString("api_key"), "") + c.Profile.TeamID = stringCoalesce(c.Profile.TeamID, c.viper.GetString(c.Profile.getConfigField("workspace_id")), c.viper.GetString(c.Profile.getConfigField("team_id")), c.viper.GetString("workspace_id"), "") + c.Profile.TeamMode = stringCoalesce(c.Profile.TeamMode, c.viper.GetString(c.Profile.getConfigField("workspace_mode")), c.viper.GetString(c.Profile.getConfigField("team_mode")), c.viper.GetString("workspace_mode"), "") } // getConfigPath returns the path for the config file. @@ -310,69 +238,6 @@ func (c *Config) getConfigPath(path string) (string, bool) { return localConfigPath, false } - globalConfigFolder := getConfigFolder(os.Getenv("XDG_CONFIG_HOME")) + globalConfigFolder := getSystemConfigFolder(os.Getenv("XDG_CONFIG_HOME")) return filepath.Join(globalConfigFolder, "config.toml"), true } - -// isProfile identifies whether a value in the config pertains to a profile. -func isProfile(value interface{}) bool { - // TODO: ianjabour - ideally find a better way to identify projects in config - _, ok := value.(map[string]interface{}) - return ok -} - -// Temporary workaround until https://github.com/spf13/viper/pull/519 can remove a key from viper -func removeKey(v *viper.Viper, key string) (*viper.Viper, error) { - configMap := v.AllSettings() - path := strings.Split(key, ".") - lastKey := strings.ToLower(path[len(path)-1]) - deepestMap := deepSearch(configMap, path[0:len(path)-1]) - delete(deepestMap, lastKey) - - buf := new(bytes.Buffer) - - encodeErr := toml.NewEncoder(buf).Encode(configMap) - if encodeErr != nil { - return nil, encodeErr - } - - nv := viper.New() - nv.SetConfigType("toml") // hint to viper that we've encoded the data as toml - - err := nv.ReadConfig(buf) - if err != nil { - return nil, err - } - - return nv, nil -} - -// taken from https://github.com/spf13/viper/blob/master/util.go#L199, -// we need this to delete configs, remove when viper supprts unset natively -func deepSearch(m map[string]interface{}, path []string) map[string]interface{} { - for _, k := range path { - m2, ok := m[k] - if !ok { - // intermediate key does not exist - // => create it and continue from there - m3 := make(map[string]interface{}) - m[k] = m3 - m = m3 - - continue - } - - m3, ok := m2.(map[string]interface{}) - if !ok { - // intermediate key is a value - // => replace with a new map - m3 = make(map[string]interface{}) - m[k] = m3 - } - - // continue search from here - m = m3 - } - - return m -} diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 3d8eeec..f0fd752 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -31,7 +31,7 @@ func TestGetConfigPath(t *testing.T) { fs := &globalNoLocalConfigFS{} c := Config{fs: fs} customPathInput := "" - expectedPath := filepath.Join(getConfigFolder(os.Getenv("XDG_CONFIG_HOME")), "config.toml") + expectedPath := filepath.Join(getSystemConfigFolder(os.Getenv("XDG_CONFIG_HOME")), "config.toml") path, isGlobalConfig := c.getConfigPath(customPathInput) assert.True(t, isGlobalConfig) @@ -44,7 +44,7 @@ func TestGetConfigPath(t *testing.T) { fs := &noConfigFS{} c := Config{fs: fs} customPathInput := "" - expectedPath := filepath.Join(getConfigFolder(os.Getenv("XDG_CONFIG_HOME")), "config.toml") + expectedPath := filepath.Join(getSystemConfigFolder(os.Getenv("XDG_CONFIG_HOME")), "config.toml") path, isGlobalConfig := c.getConfigPath(customPathInput) assert.True(t, isGlobalConfig) @@ -255,7 +255,7 @@ func (fs *globalNoLocalConfigFS) makePath(path string) error { return nil } func (fs *globalNoLocalConfigFS) fileExists(path string) (bool, error) { - globalConfigFolder := getConfigFolder(os.Getenv("XDG_CONFIG_HOME")) + globalConfigFolder := getSystemConfigFolder(os.Getenv("XDG_CONFIG_HOME")) globalPath := filepath.Join(globalConfigFolder, "config.toml") if path == globalPath { return true, nil diff --git a/pkg/config/helpers.go b/pkg/config/helpers.go new file mode 100644 index 0000000..8153a5e --- /dev/null +++ b/pkg/config/helpers.go @@ -0,0 +1,112 @@ +package config + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/BurntSushi/toml" + "github.com/mitchellh/go-homedir" + log "github.com/sirupsen/logrus" + "github.com/spf13/viper" +) + +// getSystemConfigFolder retrieves the folder where the profiles file is stored +// It searches for the xdg environment path first and will secondarily +// place it in the home directory +func getSystemConfigFolder(xdgPath string) string { + configPath := xdgPath + + if configPath == "" { + home, err := homedir.Dir() + if err != nil { + fmt.Println(err) + os.Exit(1) + } + + configPath = filepath.Join(home, ".config") + } + + log.WithFields(log.Fields{ + "prefix": "config.Config.GetProfilesFolder", + "path": configPath, + }).Debug("Using profiles folder") + + return filepath.Join(configPath, "hookdeck") +} + +// isProfile identifies whether a value in the config pertains to a profile. +func isProfile(value interface{}) bool { + // TODO: ianjabour - ideally find a better way to identify projects in config + _, ok := value.(map[string]interface{}) + return ok +} + +// Temporary workaround until https://github.com/spf13/viper/pull/519 can remove a key from viper +func removeKey(v *viper.Viper, key string) (*viper.Viper, error) { + configMap := v.AllSettings() + path := strings.Split(key, ".") + lastKey := strings.ToLower(path[len(path)-1]) + deepestMap := deepSearch(configMap, path[0:len(path)-1]) + delete(deepestMap, lastKey) + + buf := new(bytes.Buffer) + + encodeErr := toml.NewEncoder(buf).Encode(configMap) + if encodeErr != nil { + return nil, encodeErr + } + + nv := viper.New() + nv.SetConfigType("toml") // hint to viper that we've encoded the data as toml + + err := nv.ReadConfig(buf) + if err != nil { + return nil, err + } + + return nv, nil +} + +// taken from https://github.com/spf13/viper/blob/master/util.go#L199, +// we need this to delete configs, remove when viper supprts unset natively +func deepSearch(m map[string]interface{}, path []string) map[string]interface{} { + for _, k := range path { + m2, ok := m[k] + if !ok { + // intermediate key does not exist + // => create it and continue from there + m3 := make(map[string]interface{}) + m[k] = m3 + m = m3 + + continue + } + + m3, ok := m2.(map[string]interface{}) + if !ok { + // intermediate key is a value + // => replace with a new map + m3 = make(map[string]interface{}) + m[k] = m3 + } + + // continue search from here + m = m3 + } + + return m +} + +// stringCoalesce returns the first non-empty string in the list of strings. +func stringCoalesce(values ...string) string { + for _, str := range values { + if str != "" { + return str + } + } + + return values[len(values)-1] +} diff --git a/pkg/config/profile.go b/pkg/config/profile.go index b66bb50..bef1914 100644 --- a/pkg/config/profile.go +++ b/pkg/config/profile.go @@ -11,16 +11,16 @@ type Profile struct { Config *Config } -// GetConfigField returns the configuration field for the specific profile -func (p *Profile) GetConfigField(field string) string { +// getConfigField returns the configuration field for the specific profile +func (p *Profile) getConfigField(field string) string { return p.Name + "." + field } func (p *Profile) SaveProfile() error { - p.Config.viper.Set(p.GetConfigField("api_key"), p.APIKey) - p.Config.viper.Set(p.GetConfigField("workspace_id"), p.TeamID) - p.Config.viper.Set(p.GetConfigField("workspace_mode"), p.TeamMode) - return p.Config.WriteConfig() + p.Config.viper.Set(p.getConfigField("api_key"), p.APIKey) + p.Config.viper.Set(p.getConfigField("workspace_id"), p.TeamID) + p.Config.viper.Set(p.getConfigField("workspace_mode"), p.TeamMode) + return p.Config.writeConfig() } func (p *Profile) RemoveProfile() error { @@ -39,12 +39,12 @@ func (p *Profile) RemoveProfile() error { runtimeViper.SetConfigType("toml") runtimeViper.SetConfigFile(p.Config.viper.ConfigFileUsed()) p.Config.viper = runtimeViper - return p.Config.WriteConfig() + return p.Config.writeConfig() } func (p *Profile) UseProfile() error { p.Config.viper.Set("profile", p.Name) - return p.Config.WriteConfig() + return p.Config.writeConfig() } func (p *Profile) ValidateAPIKey() error { From d5e33d0a02d09540b3d7f655478cf6ddce9678e1 Mon Sep 17 00:00:00 2001 From: Alex Luong Date: Tue, 3 Sep 2024 07:29:30 +0700 Subject: [PATCH 10/10] test: Methods related to config updates --- pkg/config/config.go | 15 +++-- pkg/config/config_test.go | 131 ++++++++++++++++++++++++++++++++++++++ pkg/config/profile.go | 4 +- 3 files changed, 142 insertions(+), 8 deletions(-) diff --git a/pkg/config/config.go b/pkg/config/config.go index 43c3d92..d9215fc 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -90,11 +90,12 @@ func (c *Config) InitConfig() { } // Read config file - if err := c.viper.ReadInConfig(); err == nil { - log.WithFields(log.Fields{ - "prefix": "config.Config.InitConfig", - "path": c.viper.ConfigFileUsed(), - }).Debug("Reading config file") + log.WithFields(log.Fields{ + "prefix": "config.Config.InitConfig", + "path": c.viper.ConfigFileUsed(), + }).Debug("Reading config file") + if err := c.viper.ReadInConfig(); err != nil { + log.Fatal(err) } // Construct the config struct @@ -175,8 +176,8 @@ func (c *Config) writeConfig() error { } log.WithFields(log.Fields{ - "prefix": "config.Config.WriteConfig", - "path": c.viper.WriteConfig(), + "prefix": "config.Config.writeConfig", + "path": c.viper.ConfigFileUsed(), }).Debug("Writing config") return c.viper.WriteConfig() diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index f0fd752..97db3f3 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -1,6 +1,8 @@ package config import ( + "io" + "io/ioutil" "os" "path/filepath" "testing" @@ -220,6 +222,135 @@ func TestInitConfig(t *testing.T) { }) } +func TestWriteConfig(t *testing.T) { + t.Parallel() + + t.Run("save profile", func(t *testing.T) { + t.Parallel() + + // Arrange + c := Config{LogLevel: "info"} + c.ConfigFileFlag = setupTempConfig(t, "./testdata/default-profile.toml") + c.InitConfig() + + // Act + c.Profile.TeamMode = "new_team_mode" + err := c.Profile.SaveProfile() + + // Assert + assert.NoError(t, err) + contentBytes, _ := ioutil.ReadFile(c.viper.ConfigFileUsed()) + assert.Contains(t, string(contentBytes), `workspace_mode = "new_team_mode"`) + }) + + t.Run("use project", func(t *testing.T) { + t.Parallel() + + // Arrange + c := Config{LogLevel: "info"} + c.ConfigFileFlag = setupTempConfig(t, "./testdata/default-profile.toml") + c.InitConfig() + + // Act + err := c.UseProject("new_team_id", "new_team_mode") + + // Assert + assert.NoError(t, err) + contentBytes, _ := ioutil.ReadFile(c.viper.ConfigFileUsed()) + assert.Contains(t, string(contentBytes), `workspace_id = "new_team_id"`) + }) + + t.Run("use profile", func(t *testing.T) { + t.Parallel() + + // Arrange + c := Config{LogLevel: "info"} + c.ConfigFileFlag = setupTempConfig(t, "./testdata/multiple-profiles.toml") + c.InitConfig() + + // Act + c.Profile.Name = "account_3" + err := c.Profile.UseProfile() + + // Assert + assert.NoError(t, err) + contentBytes, _ := ioutil.ReadFile(c.viper.ConfigFileUsed()) + assert.Contains(t, string(contentBytes), `profile = "account_3"`) + }) + + t.Run("remove profile", func(t *testing.T) { + t.Parallel() + + // Arrange + c := Config{LogLevel: "info"} + c.ConfigFileFlag = setupTempConfig(t, "./testdata/multiple-profiles.toml") + c.InitConfig() + + // Act + err := c.Profile.RemoveProfile() + + // Assert + assert.NoError(t, err) + contentBytes, _ := ioutil.ReadFile(c.viper.ConfigFileUsed()) + assert.NotContains(t, string(contentBytes), "account_2", `default profile "account_2" should be cleared`) + assert.NotContains(t, string(contentBytes), `profile =`, `profile key should be cleared`) + }) + + t.Run("remove profile multiple times", func(t *testing.T) { + t.Parallel() + + // Arrange + c := Config{LogLevel: "info"} + c.ConfigFileFlag = setupTempConfig(t, "./testdata/multiple-profiles.toml") + c.InitConfig() + + // Act + err := c.Profile.RemoveProfile() + + // Assert + assert.NoError(t, err) + contentBytes, _ := ioutil.ReadFile(c.viper.ConfigFileUsed()) + assert.NotContains(t, string(contentBytes), "account_2", `default profile "account_2" should be cleared`) + assert.NotContains(t, string(contentBytes), `profile =`, `profile key should be cleared`) + + // Remove profile again + + c2 := Config{LogLevel: "info"} + c2.ConfigFileFlag = c.ConfigFileFlag + c2.InitConfig() + err = c2.Profile.RemoveProfile() + + contentBytes, _ = ioutil.ReadFile(c2.viper.ConfigFileUsed()) + assert.NoError(t, err) + assert.NotContains(t, string(contentBytes), "[default]", `default profile "default" should be cleared`) + assert.NotContains(t, string(contentBytes), `api_key = "test_api_key"`, `default profile "default" should be cleared`) + + // Now even though there are some profiles (account_1, account_3), when reading config + // we won't register any profile. + // TODO: Consider this case. It's not great UX. This may be an edge case only power users run into + // given it requires users to be using multiple profiles. + + c3 := Config{LogLevel: "info"} + c3.ConfigFileFlag = c.ConfigFileFlag + c3.InitConfig() + assert.Equal(t, "default", c3.Profile.Name, `profile should be "default"`) + assert.Equal(t, "", c3.Profile.APIKey, "api key should be empty even though there are other profiles") + }) +} + +// ===== Test helpers ===== + +func setupTempConfig(t *testing.T, sourceConfigPath string) string { + dir := t.TempDir() + configPath := filepath.Join(dir, "config.toml") + srcFile, _ := os.Open(sourceConfigPath) + defer srcFile.Close() + destFile, _ := os.Create(configPath) + defer destFile.Close() + io.Copy(destFile, srcFile) + return configPath +} + // ===== Mock FS ===== // Mock fs where there's no config file, whether global or local diff --git a/pkg/config/profile.go b/pkg/config/profile.go index bef1914..745ab3a 100644 --- a/pkg/config/profile.go +++ b/pkg/config/profile.go @@ -1,6 +1,8 @@ package config -import "github.com/hookdeck/hookdeck-cli/pkg/validators" +import ( + "github.com/hookdeck/hookdeck-cli/pkg/validators" +) type Profile struct { Name string // profile name