diff --git a/.github/workflows/acceptance-test.yml b/.github/workflows/acceptance-test.yml new file mode 100644 index 0000000..3da9f1e --- /dev/null +++ b/.github/workflows/acceptance-test.yml @@ -0,0 +1,26 @@ +name: Acceptance Tests + +on: + pull_request: + branches: + - main + +jobs: + test: + runs-on: ubuntu-latest + env: + HOOKDECK_CLI_TESTING_API_KEY: ${{ secrets.HOOKDECK_CLI_TESTING_API_KEY }} + steps: + - name: Check out code + uses: actions/checkout@v3 + + - name: Set up Go + uses: actions/setup-go@v3 + with: + go-version: '1.18' + + - name: Make script executable + run: chmod +x scripts/acceptance-test.sh + + - name: Run acceptance tests + run: ./scripts/acceptance-test.sh \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a8813d6..e772bb1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -100,11 +100,29 @@ jobs: steps: - uses: actions/checkout@v4 with: - # Checkout on main so that the later commit works - ref: main # With permission to push to a protected branch token: ${{ secrets.READ_WRITE_PAT }} - + fetch-depth: 0 # Required to find branches for a tag + + - name: Determine release branch + id: get_branch + run: | + # Find the branch that contains the tag. + # Prefers 'main', then 'master', then the first branch found. + BRANCHES=$(git branch -r --contains ${{ github.ref_name }} | sed 's/ *origin\///' | grep -v HEAD) + if echo "$BRANCHES" | grep -q -w "main"; then + RELEASE_BRANCH="main" + elif echo "$BRANCHES" | grep -q -w "master"; then + RELEASE_BRANCH="master" + else + RELEASE_BRANCH=$(echo "$BRANCHES" | head -n 1) + fi + echo "RELEASE_BRANCH=${RELEASE_BRANCH}" >> $GITHUB_OUTPUT + echo "Determined release branch for commit: ${RELEASE_BRANCH}" + + - name: Checkout release branch + run: git checkout ${{ steps.get_branch.outputs.RELEASE_BRANCH }} + - uses: actions/setup-node@v4 with: node-version: "20.x" @@ -133,6 +151,18 @@ jobs: add: 'package.json' - run: npm ci - - run: npm publish + + - name: Determine npm tag for pre-releases + id: npm_tag + run: | + TAG_VERSION="${{ steps.tag-version.outputs.TAG_VERSION }}" + NPM_TAG="latest" + if [[ "$TAG_VERSION" == *-* ]]; then + NPM_TAG=$(echo "$TAG_VERSION" | cut -d'-' -f2 | cut -d'.' -f1) + fi + echo "tag=${NPM_TAG}" >> $GITHUB_OUTPUT + echo "npm tag: ${NPM_TAG}" + + - run: npm publish --tag ${{ steps.npm_tag.outputs.tag }} env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/README.md b/README.md index 69805be..9061ba6 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ docker run --rm -it -v $HOME/.config/hookdeck:/root/.config/hookdeck hookdeck/ho Installing the CLI provides access to the `hookdeck` command. -```sh-session +```sh hookdeck [command] # Run `--help` for detailed information about CLI commands @@ -92,19 +92,24 @@ hookdeck [command] help ### Login -Login with your Hookdeck account. +Login with your Hookdeck account. This will typically open a browser window for authentication. -```sh-session +```sh hookdeck login ``` +If you are in an environment without a browser (e.g., a TTY-only terminal), you can use the `--interactive` (or `-i`) flag to log in by pasting your API key: +```sh +hookdeck login --interactive +``` + > Login is optional, if you do not login a temporary guest account will be created for you when you run other commands. ### Listen Start a session to forward your events to an HTTP server. -```sh-session +```sh hookdeck listen [--path?] ``` @@ -120,7 +125,7 @@ Contrary to ngrok, **Hookdeck does not allow to append a path to your event URL* The second param, `source-alias` is used to select a specific source to listen on. By default, the CLI will start listening on all eligible connections for that source. -```sh-session +```sh $ hookdeck listen 3000 shopify 👉 Inspect and replay events: https://dashboard.hookdeck.com/cli/events @@ -141,7 +146,7 @@ Orders Service forwarding to /webhooks/shopify/orders `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 +```sh $ hookdeck listen 3000 '*' 👉 Inspect and replay events: https://dashboard.hookdeck.com/cli/events @@ -164,7 +169,7 @@ twilio -> cli-twilio forwarding to /webhooks/twilio The 3rd param, `connection-query` can be used to filter the list of connections the CLI will listen to. The connection query can either be the `connection` `alias` or the `path` -```sh-session +```sh $ hookdeck listen 3000 shopify orders 👉 Inspect and replay events: https://dashboard.hookdeck.com/cli/events @@ -184,7 +189,7 @@ Orders Service forwarding to /webhooks/shopify/orders The `--path` flag sets the path to which events are forwarded. -```sh-session +```sh $ hookdeck listen 3000 shopify orders --path /events/shopify/orders 👉 Inspect and replay events: https://dashboard.hookdeck.com/cli/events @@ -208,26 +213,28 @@ Event logs for your CLI can be found at [https://dashboard.hookdeck.com/cli/even Logout of your Hookdeck account and clear your stored credentials. -```sh-session +```sh hookdeck logout ``` ### Skip SSL validation -If you are developing on an SSL destination, and are using a self-signed certificate, you can skip the SSL validation by using the flag `--insecure`. -You have to specify the full URL with the protocol when using this flag. +When forwarding events to an HTTPS URL as the first argument to `hookdeck listen` (e.g., `https://localhost:1234/webhook`), you might encounter SSL validation errors if the destination is using a self-signed certificate. + +For local development scenarios, you can instruct the `listen` command to bypass this SSL certificate validation by using its `--insecure` flag. You must provide the full HTTPS URL. -**This is dangerous, and should only be used in development scenarios, and for desitnations that you trust.** +**This is dangerous and should only be used in trusted local development environments for destinations you control.** -```sh-session -hookdeck --insecure listen https:/// +Example of skipping SSL validation for an HTTPS destination: +```sh +hookdeck listen --insecure https:/// ``` ### Version Print your CLI version and whether or not a new version is available. -```sh-session +```sh hookdeck version ``` @@ -235,7 +242,7 @@ hookdeck version Configure auto-completion for Hookdeck CLI. It is run on install when using Homebrew or Scoop. You can optionally run this command when using the binaries directly or without a package manager. -```sh-session +```sh hookdeck completion ``` @@ -243,7 +250,7 @@ hookdeck completion If you want to use Hookdeck in CI for tests or any other purposes, you can use your HOOKDECK_API_KEY to authenticate and start forwarding events. -```sh-session +```sh $ hookdeck ci --api-key $HOOKDECK_API_KEY Done! The Hookdeck CLI is configured in project MyProject @@ -264,51 +271,186 @@ Inventory Service forwarding to /webhooks/shopify/inventory ### Manage active project -If you are a part of multiple project, you can switch between them using our project management commands. +If you are a part of multiple projects, you can switch between them using our project management commands. -```sh-session +To list your projects, you can use the `hookdeck project list` command. It can take optional organization and project name substrings to filter the list. The matching is partial and case-insensitive. + +```sh +# List all projects $ hookdeck project list -My Project (current) -Another Project -Yet Another One +My Org / My Project (current) +My Org / Another Project +Another Org / Yet Another One + +# List projects with "Org" in the organization name and "Proj" in the project name +$ hookdeck project list Org Proj +My Org / My Project (current) +My Org / Another Project +``` + +To select or change the active project, use the `hookdeck project use` command. When arguments are provided, it uses exact, case-insensitive matching for the organization and project names. + +```console +hookdeck project use [ []] +``` + +**Behavior:** + +- **`hookdeck project use`** (no arguments): + An interactive prompt will guide you through selecting your organization and then the project within that organization. + ```sh + $ hookdeck project use + Use the arrow keys to navigate: ↓ ↑ → ← + ? Select Organization: + My Org + ▸ Another Org + ... + ? Select Project (Another Org): + Project X + ▸ Project Y + Selecting project Project Y + Successfully set active project to: [Another Org] Project Y + ``` + +- **`hookdeck project use `** (one argument): + Filters projects by the specified ``. + - If multiple projects exist under that organization, you'll be prompted to choose one. + - If only one project exists, it will be selected automatically. + ```sh + $ hookdeck project use "My Org" + # (If multiple projects, prompts to select. If one, auto-selects) + Successfully set active project to: [My Org] Default Project + ``` + +- **`hookdeck project use `** (two arguments): + Directly selects the project `` under the organization ``. + ```sh + $ hookdeck project use "My Corp" "API Staging" + Successfully set active project to: [My Corp] API Staging + ``` + +Upon successful selection, you will generally see a confirmation message like: +`Successfully set active project to: [] ` + +## Configuration files + +The Hookdeck CLI uses configuration files to store the your keys, project settings, profiles, and other configurations. + +### Configuration file name and locations + +The CLI will look for the configuration file in the following order: + + 1. The `--config` flag, which allows you to specify a custom configuration file name and path per command. + 2. The local directory `.hookdeck/config.toml`. + 3. The default global configuration file location. + +### Default configuration Location + +The default configuration location varies by operating system: + +- **macOS/Linux**: `~/.config/hookdeck/config.toml` +- **Windows**: `%USERPROFILE%\.config\hookdeck\config.toml` -$ hookdeck project use -Use the arrow keys to navigate: ↓ ↑ → ← -? Select Project: - My Project - Another Project - ▸ Yet Another One +The CLI follows the [XDG Base Directory Specification](https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html) on Unix-like systems, respecting the `XDG_CONFIG_HOME` environment variable if set. -Selecting project Yet Another One +### Configuration File Format -$ hookdeck whoami -Using profile default -Logged in as Me in project Yet Another One +The Hookdeck CLI configuration file is stored in TOML format and typically includes: + +```toml +api_key = "api_key_xxxxxxxxxxxxxxxxxxxx" +project_id = "tm_xxxxxxxxxxxxxxx" +project_mode = "inbound" | "console" +``` + +### Local Configuration + +The Hookdeck CLI also supports local configuration files. If you run the CLI commands in a directory that contains a `.hookdeck/config.toml` file, the CLI will use that file for configuration instead of the global one. + +### Using Profiles + +The `config.toml` file supports profiles which give you the ability to save different CLI configuration within the same configuration file. + +You can create new profiles by either running `hookdeck login` or `hookdeck use` with the `-p` flag and a profile name. For example: + +```sh +hookdeck login -p dev ``` -You can also pin an active project in the current working directory with the `--local` flag. +If you know the name of your Hookdeck organization and the project you want to use with a profile you can use the following: -```sh-session -$ hookdeck project use --local -Use the arrow keys to navigate: ↓ ↑ → ← -? Select Project: - My Project - Another Project - ▸ Yet Another One +```sh +hookdeck project use org_name proj_name -p prod +``` + +This will results in the following config file that has two profiles: + +```toml +profile = "dev" + +[dev] + api_key = "api_key_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + project_id = "tm_5JxTelcYxOJy" + project_mode = "inbound" -Selecting project Yet Another One +[prod] + api_key = "api_key_yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy" + project_id = "tm_U9Zod13qtsHp" + project_mode = "inbound" +``` + +This allows you to run commands against different projects. For example, to listen to the `webhooks` source in the `dev` profile, run: + +```sh +hookdeck listen 3030 webhooks -p dev ``` -This will create a local config file in your current directory at `myproject/.hookdeck/config.toml`. Depending on your team's Hookdeck usage and project setup, you may or may not want to commit this configuration file to version control. +To listen to the `webhooks` source in the `prod` profile, run: + +```sh +hookdeck listen 3030 webhooks -p prod +``` + +## Global Flags + +The following flags can be used with any command: + +* `--api-key`: Your API key to use for the command. +* `--color`: Turn on/off color output (on, off, auto). +* `--config`: Path to a specific configuration file. +* `--device-name`: A unique name for your device. +* `--insecure`: Allow invalid TLS certificates. +* `--log-level`: Set the logging level (debug, info, warn, error). +* `--profile` or `-p`: Use a specific configuration profile. + +There are also some hidden flags that are mainly used for development and debugging: + +* `--api-base`: Sets the API base URL. +* `--dashboard-base`: Sets the web dashboard base URL. +* `--console-base`: Sets the web console base URL. +* `--ws-base`: Sets the Websocket base URL. + ## Developing +Running from source: + +```sh +go run main.go +``` + Build from source by running: ```sh go build ``` +Then run the locally generated `hookdeck-cli` binary: + +```sh +./hookdeck-cli +``` + ### Testing against a local API When testing against a non-production Hookdeck API, you can use the diff --git a/package.json b/package.json index 85c4153..691a312 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hookdeck-cli", - "version": "1.0.0-beta.1", + "version": "1.0.0-beta.3", "description": "Hookdeck CLI", "repository": { "type": "git", 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_list.go b/pkg/cmd/project_list.go index 19d3f0c..c58b7ca 100644 --- a/pkg/cmd/project_list.go +++ b/pkg/cmd/project_list.go @@ -3,12 +3,14 @@ package cmd import ( "fmt" "os" + "strings" "github.com/spf13/cobra" "github.com/hookdeck/hookdeck-cli/pkg/ansi" - "github.com/hookdeck/hookdeck-cli/pkg/validators" + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" "github.com/hookdeck/hookdeck-cli/pkg/project" + "github.com/hookdeck/hookdeck-cli/pkg/validators" ) type projectListCmd struct { @@ -19,9 +21,9 @@ func newProjectListCmd() *projectListCmd { lc := &projectListCmd{} lc.cmd = &cobra.Command{ - Use: "list", - Args: validators.NoArgs, - Short: "List your projects", + Use: "list [] []", + Args: validators.MaximumNArgs(2), + Short: "List and filter projects by organization and project name substrings", RunE: lc.runProjectListCmd, } @@ -38,10 +40,50 @@ func (lc *projectListCmd) runProjectListCmd(cmd *cobra.Command, args []string) e return err } + var filteredProjects []hookdeck.Project + + switch len(args) { + case 0: + filteredProjects = projects + case 1: + argOrgNameInput := args[0] + argOrgNameLower := strings.ToLower(argOrgNameInput) + + for _, p := range projects { + org, _, errParser := project.ParseProjectName(p.Name) + if errParser != nil { + continue + } + if strings.Contains(strings.ToLower(org), argOrgNameLower) { + filteredProjects = append(filteredProjects, p) + } + } + case 2: + argOrgNameInput := args[0] + argProjNameInput := args[1] + argOrgNameLower := strings.ToLower(argOrgNameInput) + argProjNameLower := strings.ToLower(argProjNameInput) + + for _, p := range projects { + org, proj, errParser := project.ParseProjectName(p.Name) + if errParser != nil { + continue + } + if strings.Contains(strings.ToLower(org), argOrgNameLower) && strings.Contains(strings.ToLower(proj), argProjNameLower) { + filteredProjects = append(filteredProjects, p) + } + } + } + + if len(filteredProjects) == 0 { + fmt.Println("No projects found.") + return nil + } + color := ansi.Color(os.Stdout) - for _, project := range projects { - if project.Id == Config.Profile.TeamID { + for _, project := range filteredProjects { + if project.Id == Config.Profile.ProjectId { fmt.Printf("%s (current)\n", color.Green(project.Name)) } else { fmt.Printf("%s\n", project.Name) diff --git a/pkg/cmd/project_use.go b/pkg/cmd/project_use.go index d96dd20..d510dda 100644 --- a/pkg/cmd/project_use.go +++ b/pkg/cmd/project_use.go @@ -1,29 +1,33 @@ package cmd import ( + "fmt" + "os" + "strings" + "github.com/AlecAivazis/survey/v2" + "github.com/hookdeck/hookdeck-cli/pkg/ansi" "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 { lc := &projectUseCmd{} lc.cmd = &cobra.Command{ - Use: "use", - Args: validators.MaximumNArgs(1), - Short: "Select your active project for future commands", + Use: "use [ []]", + Args: validators.MaximumNArgs(2), + Short: "Set the active project for future commands", RunE: lc.runProjectUseCmd, } - lc.cmd.Flags().BoolVar(&lc.local, "local", false, "Pin active project to the current directory") return lc } @@ -37,42 +41,148 @@ func (lc *projectUseCmd) runProjectUseCmd(cmd *cobra.Command, args []string) err if err != nil { return err } + if len(projects) == 0 { + return fmt.Errorf("no projects found. Please create a project first using 'hookdeck project create'") + } - var currentProjectName string - projectNames := make([]string, len(projects)) - for index, project := range projects { - projectNames[index] = project.Name - if project.Id == Config.Profile.TeamID { - currentProjectName = project.Name + var selectedProject hookdeck.Project + projectFound := false + + switch len(args) { + case 0: // Interactive: select from all projects + var currentProjectName string + projectDisplayNames := make([]string, len(projects)) + for i, p := range projects { + projectDisplayNames[i] = p.Name + if p.Id == Config.Profile.ProjectId { + currentProjectName = p.Name + } } - } - var qs = []*survey.Question{ - { - Name: "project_name", - Prompt: &survey.Select{ - Message: "Select Project", - Options: projectNames, - Default: currentProjectName, + answers := struct { + SelectedFullName string `survey:"selected_full_name"` + }{} + qs := []*survey.Question{ + { + Name: "selected_full_name", + Prompt: &survey.Select{ + Message: "Select Project", + Options: projectDisplayNames, + Default: currentProjectName, + }, + Validate: survey.Required, }, - Validate: survey.Required, - }, - } + } - answers := struct { - ProjectName string `survey:"project_name"` - }{} + if err := survey.Ask(qs, &answers); err != nil { + return err + } - if err = survey.Ask(qs, &answers); err != nil { - return err - } + for _, p := range projects { + if answers.SelectedFullName == p.Name { + selectedProject = p + projectFound = true + break + } + } + if !projectFound { // Should not happen if survey selection is from projectDisplayNames + return fmt.Errorf("internal error: selected project '%s' not found in project list", answers.SelectedFullName) + } + case 1: // Organization name provided, select project from this org + argOrgNameInput := args[0] + argOrgNameLower := strings.ToLower(argOrgNameInput) + var orgProjects []hookdeck.Project + var orgProjectDisplayNames []string + + for _, p := range projects { + org, _, errParser := project.ParseProjectName(p.Name) + if errParser != nil { + continue // Skip projects with names that don't match the expected format + } + if strings.ToLower(org) == argOrgNameLower { + orgProjects = append(orgProjects, p) + orgProjectDisplayNames = append(orgProjectDisplayNames, p.Name) + } + } - var project hookdeck.Project - for _, tempProject := range projects { - if answers.ProjectName == tempProject.Name { - project = tempProject + if len(orgProjects) == 0 { + return fmt.Errorf("no projects found for organization '%s'", argOrgNameInput) } + + if len(orgProjects) == 1 { + selectedProject = orgProjects[0] + projectFound = true + } else { // More than one project in the org, prompt user + answers := struct { + SelectedFullName string `survey:"selected_full_name"` + }{} + qs := []*survey.Question{ + { + Name: "selected_full_name", + Prompt: &survey.Select{ + Message: fmt.Sprintf("Select project for organization '%s'", argOrgNameInput), + Options: orgProjectDisplayNames, + }, + Validate: survey.Required, + }, + } + if err := survey.Ask(qs, &answers); err != nil { + return err + } + for _, p := range orgProjects { // Search within the filtered orgProjects + if answers.SelectedFullName == p.Name { + selectedProject = p + projectFound = true + break + } + } + if !projectFound { // Should not happen + return fmt.Errorf("internal error: selected project '%s' not found in organization list", answers.SelectedFullName) + } + } + case 2: // Organization and Project name provided + argOrgNameInput := args[0] + argProjNameInput := args[1] + argOrgNameLower := strings.ToLower(argOrgNameInput) + argProjNameLower := strings.ToLower(argProjNameInput) + var matchingProjects []hookdeck.Project + + for _, p := range projects { + org, proj, errParser := project.ParseProjectName(p.Name) + if errParser != nil { + continue // Skip projects with names that don't match the expected format + } + if strings.ToLower(org) == argOrgNameLower && strings.ToLower(proj) == argProjNameLower { + matchingProjects = append(matchingProjects, p) + } + } + + if len(matchingProjects) > 1 { + return fmt.Errorf("multiple projects named '%s' found in organization '%s'. Projects must have unique names to be used with the `project use ` command", argProjNameInput, argOrgNameInput) + } + + if len(matchingProjects) == 1 { + selectedProject = matchingProjects[0] + projectFound = true + } + + if !projectFound { + return fmt.Errorf("project '%s' in organization '%s' not found", argProjNameInput, argOrgNameInput) + } + } + + if !projectFound { + // This case should ideally be unreachable if all paths correctly set projectFound or error out. + // It acts as a safeguard. + return fmt.Errorf("a project could not be determined based on the provided arguments") + } + + err = Config.UseProject(selectedProject.Id, selectedProject.Mode) + if err != nil { + return err } - return Config.UseProject(lc.local, project.Id, project.Mode) + color := ansi.Color(os.Stdout) + fmt.Printf("Successfully set active project to: %s\n", color.Green(selectedProject.Name)) + return nil } diff --git a/pkg/cmd/root.go b/pkg/cmd/root.go index 4fe851d..2883d74 100644 --- a/pkg/cmd/root.go +++ b/pkg/cmd/root.go @@ -97,7 +97,7 @@ func init() { 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") diff --git a/pkg/cmd/whoami.go b/pkg/cmd/whoami.go index 64678a4..d563f9f 100644 --- a/pkg/cmd/whoami.go +++ b/pkg/cmd/whoami.go @@ -36,7 +36,7 @@ func (lc *whoamiCmd) runWhoamiCmd(cmd *cobra.Command, args []string) error { fmt.Printf("\nUsing profile %s (use -p flag to use a different config profile)\n\n", color.Bold(Config.Profile.Name)) - response, err := login.ValidateKey(Config.APIBaseURL, Config.Profile.APIKey, Config.Profile.TeamID) + response, err := login.ValidateKey(Config.APIBaseURL, Config.Profile.APIKey, Config.Profile.ProjectId) if err != nil { return err } @@ -45,7 +45,7 @@ func (lc *whoamiCmd) runWhoamiCmd(cmd *cobra.Command, args []string) error { "Logged in as %s (%s) on project %s in organization %s\n", color.Bold(response.UserName), color.Bold(response.UserEmail), - color.Bold(response.TeamName), + color.Bold(response.ProjectName), color.Bold(response.OrganizationName), ) diff --git a/pkg/config/config.go b/pkg/config/config.go index bc89042..106c142 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" @@ -44,38 +37,20 @@ type Config struct { Insecure bool // Config - GlobalConfigFile string - GlobalConfig *viper.Viper - LocalConfigFile string - LocalConfig *viper.Viper -} - -// 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 { - 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") + ConfigFileFlag string // flag -- should NOT use this directly + configFile string // resolved path of config file + viper *viper.Viper - return filepath.Join(configPath, "hookdeck") + // Internal + fs ConfigFS } // 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 @@ -97,51 +72,61 @@ 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() + + configPath, isGlobalConfig := c.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) + } } - // Read local config - workspaceFolder, err := os.Getwd() - if err != nil { - log.Fatal(err) + // Check if config file exists, create if not + var exists bool + var checkErr error + exists, checkErr = c.fs.fileExists(c.configFile) + if checkErr != nil { + log.Fatalf("Error checking existence of config file %s: %v", c.configFile, checkErr) } - 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) + + if !exists { + log.WithFields(log.Fields{"prefix": "config.Config.InitConfig", "path": c.configFile}).Debug("Configuration file not found. Creating a new one.") + createErr := c.fs.makePath(c.configFile) + if createErr != nil { + log.Fatalf("Error creating directory for config file %s: %v", c.configFile, createErr) + } + + file, createErr := os.Create(c.configFile) + if createErr != nil { + log.Fatalf("Error creating new config file %s: %v", c.configFile, createErr) + } + file.Close() // Immediately close the newly created file + + if isGlobalConfig { + permErr := os.Chmod(c.configFile, os.FileMode(0600)) + if permErr != nil { + log.Fatalf("Error setting permissions for new config file %s: %v", c.configFile, permErr) + } } } - c.LocalConfig.SetConfigType("toml") - c.LocalConfig.SetConfigFile(localConfigFile) - c.LocalConfigFile = localConfigFile - if err := c.LocalConfig.ReadInConfig(); err == nil { - log.WithFields(log.Fields{ - "prefix": "config.Config.InitConfig", - "path": c.LocalConfig.ConfigFileUsed(), - }).Debug("Using local profiles file") + + // Read config file + log.WithFields(log.Fields{ + "prefix": "config.Config.InitConfig", + "path": c.viper.ConfigFileUsed(), + }).Debug("Reading config file") + if readErr := c.viper.ReadInConfig(); readErr != nil { + log.Fatalf("Error reading config file %s: %v", c.viper.ConfigFileUsed(), readErr) + } else { + log.WithFields(log.Fields{"prefix": "config.Config.InitConfig", "path": c.viper.ConfigFileUsed()}).Debug("Successfully read config file") } // Construct the config struct @@ -171,48 +156,17 @@ 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.LocalConfigFile) - - switch runtime.GOOS { - case "darwin", "linux": - editor := os.Getenv("EDITOR") - if editor == "" { - editor = "vi" - } - - cmd := exec.Command(editor, c.LocalConfigFile) - // 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.LocalConfigFile).Run() - default: - err = fmt.Errorf("unsupported platform") - } - - return err -} - // UseProject selects the active project to be used -func (c *Config) UseProject(local bool, teamId string, teamMode string) error { - c.Profile.TeamID = teamId - c.Profile.TeamMode = teamMode - return c.Profile.SaveProfile(local) +func (c *Config) UseProject(projectId string, projectMode string) error { + c.Profile.ProjectId = projectId + c.Profile.ProjectMode = projectMode + 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 +176,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,127 +196,82 @@ 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 := c.fs.makePath(c.viper.ConfigFileUsed()); err != nil { return err } log.WithFields(log.Fields{ - "prefix": "config.Config.WriteGlobalConfig", - "path": c.GlobalConfig.ConfigFileUsed(), - }).Debug("Writing global config") + "prefix": "config.Config.writeConfig", + "path": c.viper.ConfigFileUsed(), + }).Debug("Writing config") - return c.GlobalConfig.WriteConfig() -} - -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 = 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: + // ```` + // [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 = stringCoalesce(c.Profile.APIKey, c.viper.GetString(c.Profile.getConfigField("api_key")), c.viper.GetString("api_key"), "") + + c.Profile.ProjectId = stringCoalesce(c.Profile.ProjectId, c.viper.GetString(c.Profile.getConfigField("project_id")), c.viper.GetString("project_id"), c.viper.GetString(c.Profile.getConfigField("workspace_id")), c.viper.GetString(c.Profile.getConfigField("team_id")), c.viper.GetString("workspace_id"), "") + + c.Profile.ProjectMode = stringCoalesce(c.Profile.ProjectMode, c.viper.GetString(c.Profile.getConfigField("project_mode")), c.viper.GetString("project_mode"), 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] -} - -// 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) +// 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 (c *Config) getConfigPath(path string) (string, bool) { + workspaceFolder, err := os.Getwd() if err != nil { - return nil, err + log.Fatal(err) } - 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 + if path != "" { + if filepath.IsAbs(path) { + return path, false } + return filepath.Join(workspaceFolder, path), false } - 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{} { - 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 + localConfigPath := filepath.Join(workspaceFolder, ".hookdeck/config.toml") + localConfigExists, err := c.fs.fileExists(localConfigPath) + if err != nil { + log.Fatal(err) + } + if localConfigExists { + return localConfigPath, false } - return m + globalConfigFolder := getSystemConfigFolder(os.Getenv("XDG_CONFIG_HOME")) + return filepath.Join(globalConfigFolder, "config.toml"), true } diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 274adc7..844d397 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -1,9 +1,14 @@ package config import ( + "io" + "io/ioutil" + "os" + "path/filepath" "testing" "github.com/spf13/viper" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -18,3 +23,422 @@ 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} + customPathInput := "" + expectedPath := filepath.Join(getSystemConfigFolder(os.Getenv("XDG_CONFIG_HOME")), "config.toml") + + path, isGlobalConfig := c.getConfigPath(customPathInput) + assert.True(t, isGlobalConfig) + assert.Equal(t, expectedPath, path) + }) + + 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} + customPathInput := "" + expectedPath := filepath.Join(getSystemConfigFolder(os.Getenv("XDG_CONFIG_HOME")), "config.toml") + + path, isGlobalConfig := c.getConfigPath(customPathInput) + assert.True(t, isGlobalConfig) + assert.Equal(t, expectedPath, path) + }) + + t.Run("with local and custom config - should return custom config path", func(t *testing.T) { + t.Parallel() + + fs := &globalAndLocalConfigFS{} + c := Config{fs: fs} + customPathInput := "/absolute/custom/config.toml" + expectedPath := customPathInput + + path, isGlobalConfig := c.getConfigPath(customPathInput) + assert.False(t, isGlobalConfig) + assert.Equal(t, expectedPath, path) + }) + + t.Run("with local only - should return local config path", func(t *testing.T) { + t.Parallel() + + fs := &globalAndLocalConfigFS{} + c := Config{fs: fs} + customPathInput := "" + pwd, _ := os.Getwd() + expectedPath := filepath.Join(pwd, "./.hookdeck/config.toml") + + path, isGlobalConfig := c.getConfigPath(customPathInput) + assert.False(t, isGlobalConfig) + assert.Equal(t, expectedPath, path) + }) + + t.Run("with absolute custom config - should return custom config path", func(t *testing.T) { + t.Parallel() + + fs := &noConfigFS{} + c := Config{fs: fs} + customPathInput := "/absolute/custom/config.toml" + expectedPath := customPathInput + + path, isGlobalConfig := c.getConfigPath(customPathInput) + assert.False(t, isGlobalConfig) + assert.Equal(t, expectedPath, path) + }) + + t.Run("with relative custom config - should return custom config path", func(t *testing.T) { + t.Parallel() + + fs := &noConfigFS{} + c := Config{fs: fs} + customPathInput := "absolute/custom/config.toml" + pwd, _ := os.Getwd() + expectedPath := filepath.Join(pwd, customPathInput) + + path, isGlobalConfig := c.getConfigPath(customPathInput) + assert.False(t, isGlobalConfig) + assert.Equal(t, expectedPath, path) + }) +} + +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.ProjectId) + assert.Equal(t, "", c.Profile.ProjectMode) + }) + + 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_project_id", c.Profile.ProjectId) + assert.Equal(t, "test_project_mode", c.Profile.ProjectMode) + }) + + 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_project_id", c.Profile.ProjectId) + assert.Equal(t, "account_2_test_project_mode", c.Profile.ProjectMode) + }) + + 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_project_id", c.Profile.ProjectId) + assert.Equal(t, "account_3_test_project_mode", c.Profile.ProjectMode) + }) + + 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_project_id", c.Profile.ProjectId) + assert.Equal(t, "local_project_mode", c.Profile.ProjectMode) + }) + + t.Run("backwards compatible", func(t *testing.T) { + t.Parallel() + + c := Config{ + LogLevel: "info", + ConfigFileFlag: "./testdata/local-full-workspace.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.ProjectId) + assert.Equal(t, "local_workspace_mode", c.Profile.ProjectMode) + }) + + // 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.ProjectId) + assert.Equal(t, "", c.Profile.ProjectMode) + }) + + 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_project_id", c.Profile.ProjectId) + assert.Equal(t, "test_project_mode", c.Profile.ProjectMode) + }) +} + +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.ProjectMode = "new_team_mode" + err := c.Profile.SaveProfile() + + // Assert + assert.NoError(t, err) + contentBytes, _ := ioutil.ReadFile(c.viper.ConfigFileUsed()) + assert.Contains(t, string(contentBytes), `project_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), `project_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 +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 := getSystemConfigFolder(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 +} 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 +} 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 d44782d..487a34b 100644 --- a/pkg/config/profile.go +++ b/pkg/config/profile.go @@ -1,43 +1,33 @@ 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 - APIKey string - TeamID string - TeamMode string + Name string // profile name + APIKey string + ProjectId string + ProjectMode string 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(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("project_id"), p.ProjectId) + p.Config.viper.Set(p.getConfigField("project_mode"), p.ProjectMode) + 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 +39,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/config/sdkclient.go b/pkg/config/sdkclient.go index d529bcd..f972824 100644 --- a/pkg/config/sdkclient.go +++ b/pkg/config/sdkclient.go @@ -15,7 +15,7 @@ func (c *Config) GetClient() *hookdeckclient.Client { client = hookdeck.CreateSDKClient(hookdeck.SDKClientInit{ APIBaseURL: c.APIBaseURL, APIKey: c.Profile.APIKey, - TeamID: c.Profile.TeamID, + TeamID: c.Profile.ProjectId, }) }) 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..2f6a9b7 --- /dev/null +++ b/pkg/config/testdata/default-profile.toml @@ -0,0 +1,6 @@ +profile = "default" + +[default] + api_key = "test_api_key" + project_id = "test_project_id" + project_mode = "test_project_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-workspace.toml b/pkg/config/testdata/local-full-workspace.toml new file mode 100644 index 0000000..8f29e42 --- /dev/null +++ b/pkg/config/testdata/local-full-workspace.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-full.toml b/pkg/config/testdata/local-full.toml new file mode 100644 index 0000000..43c67f8 --- /dev/null +++ b/pkg/config/testdata/local-full.toml @@ -0,0 +1,3 @@ +api_key = "local_api_key" +project_id = "local_project_id" +project_mode = "local_project_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..c76f18c --- /dev/null +++ b/pkg/config/testdata/multiple-profiles.toml @@ -0,0 +1,21 @@ +profile = "account_2" + +[default] + api_key = "test_api_key" + project_id = "test_project_id" + project_mode = "test_project_mode" + +[account_1] + api_key = "account_1_test_api_key" + project_id = "account_1_test_project_id" + project_mode = "account_1_test_project_mode" + +[account_2] + api_key = "account_2_test_api_key" + project_id = "account_2_test_project_id" + project_mode = "account_2_test_project_mode" + +[account_3] + api_key = "account_3_test_api_key" + project_id = "account_3_test_project_id" + project_mode = "account_3_test_project_mode" diff --git a/pkg/hookdeck/ci.go b/pkg/hookdeck/ci.go index ad99782..04770da 100644 --- a/pkg/hookdeck/ci.go +++ b/pkg/hookdeck/ci.go @@ -13,9 +13,9 @@ type CIClient struct { UserName string `json:"user_name"` OrganizationName string `json:"organization_name"` OrganizationID string `json:"organization_id"` - TeamID string `json:"team_id"` - TeamName string `json:"team_name"` - TeamMode string `json:"team_mode"` + ProjectID string `json:"team_id"` + ProjectName string `json:"team_name"` + ProjectMode string `json:"team_mode"` APIKey string `json:"key"` ClientID string `json:"client_id"` } diff --git a/pkg/hookdeck/client.go b/pkg/hookdeck/client.go index 679966e..1f169eb 100644 --- a/pkg/hookdeck/client.go +++ b/pkg/hookdeck/client.go @@ -5,7 +5,7 @@ import ( "context" "encoding/json" "fmt" - "io/ioutil" + "io" "net" "net/http" "net/url" @@ -13,6 +13,7 @@ import ( "time" "github.com/hookdeck/hookdeck-cli/pkg/useragent" + log "github.com/sirupsen/logrus" ) // DefaultAPIBaseURL is the default base URL for API requests @@ -40,7 +41,7 @@ type Client struct { // empty, the `Authorization` header will be omitted. APIKey string - TeamID string + ProjectID string // When this is enabled, request and response headers will be printed to // stdout. @@ -65,8 +66,9 @@ func (c *Client) PerformRequest(ctx context.Context, req *http.Request) (*http.R req.Header.Set("User-Agent", useragent.GetEncodedUserAgent()) req.Header.Set("X-Hookdeck-Client-User-Agent", useragent.GetEncodedHookdeckUserAgent()) - if c.TeamID != "" { - req.Header.Set("X-Team-ID", c.TeamID) + if c.ProjectID != "" { + req.Header.Set("X-Team-ID", c.ProjectID) + req.Header.Set("X-Project-ID", c.ProjectID) } if !telemetryOptedOut(os.Getenv("HOOKDECK_CLI_TELEMETRY_OPTOUT")) { @@ -86,18 +88,68 @@ func (c *Client) PerformRequest(ctx context.Context, req *http.Request) (*http.R if ctx != nil { req = req.WithContext(ctx) - } + logFields := log.Fields{ + "prefix": "client.Client.PerformRequest", + "method": req.Method, + "url": req.URL.String(), + "headers": req.Header, + } + if req.Body != nil { + bodyBytes, err := io.ReadAll(req.Body) + if err != nil { + // Log the error and potentially return or handle it + log.WithFields(logFields).WithError(err).Error("Failed to read request body") + // Depending on desired behavior, you might want to return an error here + // or proceed without the body in logFields. + // For now, just log and continue. + } else { + req.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) + logFields["body"] = string(bodyBytes) + } + } + log.WithFields(logFields).Debug("Performing request") + } resp, err := c.httpClient.Do(req) if err != nil { + log.WithFields(log.Fields{ + "prefix": "client.Client.PerformRequest 1", + "method": req.Method, + "url": req.URL.String(), + "error": err.Error(), + "status": resp.StatusCode, + }).Error("Failed to perform request") return nil, err } err = checkAndPrintError(resp) if err != nil { + log.WithFields(log.Fields{ + "prefix": "client.Client.PerformRequest 2", + "method": req.Method, + "url": req.URL.String(), + "error": err.Error(), + "status": resp.StatusCode, + }).Error("Unexpected response") return nil, err } + if ctx != nil { + logFields := log.Fields{ + "prefix": "client.Client.PerformRequest", + "statusCode": resp.StatusCode, + "headers": resp.Header, + } + + bodyBytes, err := io.ReadAll(resp.Body) + if err == nil { + resp.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) + logFields["body"] = string(bodyBytes) + } + + log.WithFields(logFields).Debug("Received response") + } + return resp, nil } @@ -149,8 +201,10 @@ func (c *Client) Put(ctx context.Context, path string, data []byte, configure fu func checkAndPrintError(res *http.Response) error { if res.StatusCode != http.StatusOK { - defer res.Body.Close() - body, err := ioutil.ReadAll(res.Body) + if res.Body != nil { + defer res.Body.Close() + } + body, err := io.ReadAll(res.Body) if err != nil { return err } @@ -158,7 +212,7 @@ func checkAndPrintError(res *http.Response) error { err = json.Unmarshal(body, &response) if err != nil { // Not a valid JSON response, just use body - return fmt.Errorf("unexpected http status code: %d %s", res.StatusCode, body) + return fmt.Errorf("unexpected http status code: %d, raw response body: %s", res.StatusCode, body) } if response.Message != "" { return fmt.Errorf("error: %s", response.Message) @@ -170,7 +224,7 @@ func checkAndPrintError(res *http.Response) error { func postprocessJsonResponse(res *http.Response, target interface{}) (interface{}, error) { defer res.Body.Close() - body, err := ioutil.ReadAll(res.Body) + body, err := io.ReadAll(res.Body) if err != nil { return nil, err } diff --git a/pkg/listen/listen.go b/pkg/listen/listen.go index 20d808f..2480a88 100644 --- a/pkg/listen/listen.go +++ b/pkg/listen/listen.go @@ -129,8 +129,8 @@ Specify a single destination to update the path. For example, pass a connection p := proxy.New(&proxy.Config{ DeviceName: config.DeviceName, Key: config.Profile.APIKey, - TeamID: config.Profile.TeamID, - TeamMode: config.Profile.TeamMode, + ProjectID: config.Profile.ProjectId, + ProjectMode: config.Profile.ProjectMode, APIBaseURL: config.APIBaseURL, DashboardBaseURL: config.DashboardBaseURL, ConsoleBaseURL: config.ConsoleBaseURL, diff --git a/pkg/listen/printer.go b/pkg/listen/printer.go index 7708624..265c0b9 100644 --- a/pkg/listen/printer.go +++ b/pkg/listen/printer.go @@ -25,10 +25,10 @@ func printDashboardInformation(config *config.Config, guestURL string) { fmt.Println() } else { var url = config.DashboardBaseURL - if config.Profile.TeamID != "" { - url += "?team_id=" + config.Profile.TeamID + if config.Profile.ProjectId != "" { + url += "?team_id=" + config.Profile.ProjectId } - if config.Profile.TeamMode == "console" { + if config.Profile.ProjectMode == "console" { url = config.ConsoleBaseURL } fmt.Println("👉 Inspect and replay events: " + url) diff --git a/pkg/login/client_login.go b/pkg/login/client_login.go index 00a2c5a..53766de 100644 --- a/pkg/login/client_login.go +++ b/pkg/login/client_login.go @@ -43,15 +43,15 @@ func Login(config *config.Config, input io.Reader) error { }).Debug("Logging in with API key") s = ansi.StartNewSpinner("Verifying credentials...", os.Stdout) - response, err := ValidateKey(config.APIBaseURL, config.Profile.APIKey, config.Profile.TeamID) + response, err := ValidateKey(config.APIBaseURL, config.Profile.APIKey, config.Profile.ProjectId) if err != nil { return err } - message := SuccessMessage(response.UserName, response.UserEmail, response.OrganizationName, response.TeamName, response.TeamMode == "console") + message := SuccessMessage(response.UserName, response.UserEmail, response.OrganizationName, response.ProjectName, response.ProjectMode == "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 { @@ -96,17 +96,17 @@ func Login(config *config.Config, input io.Reader) error { } config.Profile.APIKey = response.APIKey - config.Profile.TeamID = response.TeamID - config.Profile.TeamMode = response.TeamMode + config.Profile.ProjectId = response.ProjectID + config.Profile.ProjectMode = response.ProjectMode - if err = config.Profile.SaveProfile(false); err != nil { + if err = config.Profile.SaveProfile(); err != nil { return err } if err = config.Profile.UseProfile(); err != nil { return err } - message := SuccessMessage(response.UserName, response.UserEmail, response.OrganizationName, response.TeamName, response.TeamMode == "console") + message := SuccessMessage(response.UserName, response.UserEmail, response.OrganizationName, response.ProjectName, response.ProjectMode == "console") ansi.StopSpinner(s, message, os.Stdout) return nil @@ -142,10 +142,10 @@ func GuestLogin(config *config.Config) (string, error) { } config.Profile.APIKey = response.APIKey - config.Profile.TeamID = response.TeamID - config.Profile.TeamMode = response.TeamMode + config.Profile.ProjectId = response.ProjectID + config.Profile.ProjectMode = response.ProjectMode - if err = config.Profile.SaveProfile(false); err != nil { + if err = config.Profile.SaveProfile(); err != nil { return "", err } if err = config.Profile.UseProfile(); err != nil { @@ -182,10 +182,10 @@ func CILogin(config *config.Config, apiKey string, name string) error { } config.Profile.APIKey = response.APIKey - config.Profile.TeamID = response.TeamID - config.Profile.TeamMode = response.TeamMode + config.Profile.ProjectId = response.ProjectID + config.Profile.ProjectMode = response.ProjectMode - if err = config.Profile.SaveProfile(false); err != nil { + if err = config.Profile.SaveProfile(); err != nil { return err } if err = config.Profile.UseProfile(); err != nil { @@ -196,7 +196,7 @@ func CILogin(config *config.Config, apiKey string, name string) error { log.Println(fmt.Sprintf( "The Hookdeck CLI is configured on project %s in organization %s\n", - color.Bold(response.TeamName), + color.Bold(response.ProjectName), color.Bold(response.OrganizationName), )) diff --git a/pkg/login/interactive_login.go b/pkg/login/interactive_login.go index b193517..d5a53b5 100644 --- a/pkg/login/interactive_login.go +++ b/pkg/login/interactive_login.go @@ -63,10 +63,10 @@ func InteractiveLogin(config *config.Config) error { } config.Profile.APIKey = response.APIKey - config.Profile.TeamMode = response.TeamMode - config.Profile.TeamID = response.TeamID + config.Profile.ProjectMode = response.ProjectMode + config.Profile.ProjectId = response.ProjectID - if err = config.Profile.SaveProfile(false); err != nil { + if err = config.Profile.SaveProfile(); err != nil { ansi.StopSpinner(s, "", os.Stdout) return err } @@ -75,7 +75,7 @@ func InteractiveLogin(config *config.Config) error { return err } - message := SuccessMessage(response.UserName, response.UserEmail, response.OrganizationName, response.TeamName, response.TeamMode == "console") + message := SuccessMessage(response.UserName, response.UserEmail, response.OrganizationName, response.ProjectName, response.ProjectMode == "console") ansi.StopSpinner(s, message, os.Stdout) diff --git a/pkg/login/poll.go b/pkg/login/poll.go index 7f6ee87..e5a66b8 100644 --- a/pkg/login/poll.go +++ b/pkg/login/poll.go @@ -22,9 +22,9 @@ type PollAPIKeyResponse struct { UserEmail string `json:"user_email"` OrganizationName string `json:"organization_name"` OrganizationID string `json:"organization_id"` - TeamID string `json:"team_id"` - TeamName string `json:"team_name"` - TeamMode string `json:"team_mode"` + ProjectID string `json:"team_id"` + ProjectName string `json:"team_name"` + ProjectMode string `json:"team_mode"` APIKey string `json:"key"` ClientID string `json:"client_id"` } diff --git a/pkg/login/validate.go b/pkg/login/validate.go index 784d673..963124c 100644 --- a/pkg/login/validate.go +++ b/pkg/login/validate.go @@ -16,22 +16,22 @@ type ValidateAPIKeyResponse struct { UserEmail string `json:"user_email"` OrganizationName string `json:"organization_name"` OrganizationID string `json:"organization_id"` - TeamID string `json:"team_id"` - TeamName string `json:"team_name_no_org"` - TeamMode string `json:"team_mode"` + ProjectID string `json:"team_id"` + ProjectName string `json:"team_name_no_org"` + ProjectMode string `json:"team_mode"` ClientID string `json:"client_id"` } -func ValidateKey(baseURL string, key string, teamId string) (*ValidateAPIKeyResponse, error) { +func ValidateKey(baseURL string, key string, projectId string) (*ValidateAPIKeyResponse, error) { parsedBaseURL, err := url.Parse(baseURL) if err != nil { return nil, err } client := &hookdeck.Client{ - BaseURL: parsedBaseURL, - APIKey: key, - TeamID: teamId, + BaseURL: parsedBaseURL, + APIKey: key, + ProjectID: projectId, } res, err := client.Get(context.Background(), "/cli-auth/validate", "", nil) diff --git a/pkg/project/parse.go b/pkg/project/parse.go new file mode 100644 index 0000000..26d2fbd --- /dev/null +++ b/pkg/project/parse.go @@ -0,0 +1,26 @@ +package project + +import ( + "fmt" + "regexp" + "strings" +) + +// ParseProjectName extracts the organization and project name from a string +// formatted as "[organization_name] project_name". +// (The API returns project names in this format as it recognizes the request coming from the CLI.) +// It returns the organization name, project name, or an error if parsing fails. +func ParseProjectName(fullName string) (orgName string, projName string, err error) { + re := regexp.MustCompile(`^\[(.*?)\]\s*(.*)$`) + matches := re.FindStringSubmatch(fullName) + + if len(matches) == 3 { + org := strings.TrimSpace(matches[1]) + proj := strings.TrimSpace(matches[2]) + if org == "" || proj == "" { + return "", "", fmt.Errorf("invalid project name format: organization or project name is empty in '%s'", fullName) + } + return org, proj, nil + } + return "", "", fmt.Errorf("could not parse project name into '[organization] project' format: '%s'", fullName) +} diff --git a/pkg/proxy/proxy.go b/pkg/proxy/proxy.go index b07a1fd..afff791 100644 --- a/pkg/proxy/proxy.go +++ b/pkg/proxy/proxy.go @@ -37,8 +37,8 @@ type Config struct { DeviceName string // Key is the API key used to authenticate with Hookdeck Key string - TeamID string - TeamMode string + ProjectID string + ProjectMode string URL *url.URL APIBaseURL string DashboardBaseURL string @@ -125,7 +125,7 @@ func (p *Proxy) Run(parentCtx context.Context) error { p.cfg.WSBaseURL, session.Id, p.cfg.Key, - p.cfg.TeamID, + p.cfg.ProjectID, &websocket.Config{ Log: p.cfg.Log, NoWSS: p.cfg.NoWSS, @@ -207,9 +207,9 @@ func (p *Proxy) createSession(ctx context.Context) (hookdeck.Session, error) { } client := &hookdeck.Client{ - BaseURL: parsedBaseURL, - APIKey: p.cfg.Key, - TeamID: p.cfg.TeamID, + BaseURL: parsedBaseURL, + APIKey: p.cfg.Key, + ProjectID: p.cfg.ProjectID, } var connectionIDs []string @@ -318,7 +318,7 @@ func (p *Proxy) processEndpointResponse(webhookEvent *websocket.Attempt, resp *h localTime := time.Now().Format(timeLayout) color := ansi.Color(os.Stdout) var url = p.cfg.DashboardBaseURL + "/cli/events/" + webhookEvent.Body.EventID - if p.cfg.TeamMode == "console" { + if p.cfg.ProjectMode == "console" { url = p.cfg.ConsoleBaseURL + "/?event_id=" + webhookEvent.Body.EventID } outputStr := fmt.Sprintf("%s [%d] %s %s | %s", diff --git a/scripts/acceptance-test.sh b/scripts/acceptance-test.sh new file mode 100755 index 0000000..378b36f --- /dev/null +++ b/scripts/acceptance-test.sh @@ -0,0 +1,74 @@ +#!/bin/bash + +# Basic Acceptance Test for Hookdeck CLI +# -------------------------------------- +# This script tests the following: +# - Basic CLI functionality (build, version, help) +# - Authentication with API key +# - CI mode operation +# - Listen command initialization +# +# Limitations in CI mode: +# - Cannot test interactive workflows +# - Source/destination creation and management not directly tested +# - Connection creation not directly tested +# - It seems that the CI mode is restricted to a single org and project +# Therefore, switching between projects or orgs is not tested + +set -e + +if [ -z "$HOOKDECK_CLI_TESTING_API_KEY" ]; then + echo "Error: HOOKDECK_CLI_TESTING_API_KEY environment variable is not set." + exit 1 +fi + +# Add a function to echo commands before executing them +echo_and_run() { + echo "Running command: $@" + "$@" +} + +echo "Running tests..." +echo_and_run go test ./... + +echo "Building CLI..." +echo_and_run go build . + +echo "Authenticating with API key..." +# Define CLI command variable (can be overridden from outside) +CLI_CMD=${CLI_CMD:-"./hookdeck-cli"} + +echo "Checking CLI version..." +echo_and_run $CLI_CMD version + +echo "Displaying CLI help..." +echo_and_run $CLI_CMD help + +# Use the variable instead of hardcoded path +$CLI_CMD ci --api-key $HOOKDECK_CLI_TESTING_API_KEY + +echo "Verifying authentication..." +echo_and_run $CLI_CMD whoami + +echo "Testing listen command..." +echo_and_run $CLI_CMD listen 8080 "test-$(date +%Y%m%d%H%M%S)" & +PID=$! + +# Wait for the listen command to initialize +echo "Waiting for 5 seconds to allow listen command to initialize..." +sleep 5 + +# Check if the process is still running +if ! kill -0 $PID 2>/dev/null; then + echo "Error: The listen command failed to start properly" + exit 1 +fi + +echo "Listen command successfully started with PID $PID" + +kill $PID + +echo "Calling logout..." +$CLI_CMD logout + +echo "All tests passed!" \ No newline at end of file