Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
162 changes: 128 additions & 34 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -471,7 +471,7 @@ hookdeck connection unpause # Unpause a connection

If you are a part of multiple projects, you can switch between them using our project management commands.

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.
#### List projects

```sh
# List all projects
Expand All @@ -480,58 +480,149 @@ 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
# Filter by organization and 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.
#### Select active project

```console
hookdeck project use [<organization_name> [<project_name>]]
hookdeck project use [<organization_name> [<project_name>]] [--local]

Flags:
--local Save project to current directory (.hookdeck/config.toml)
```

**Behavior:**
**Project Selection Modes:**

- **`hookdeck project use`** (no arguments):
An interactive prompt will guide you through selecting your organization and then the project within that organization.
- **No arguments**: Interactive prompt to select organization and project
- **One argument**: Filter by organization name (prompts if multiple projects)
- **Two arguments**: Directly select organization and project

```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
```
```sh
$ hookdeck project use my-org my-project
Successfully set active project to: my-org / my-project
```

- **`hookdeck project use <organization_name>`** (one argument):
Filters projects by the specified `<organization_name>`.
#### Configuration scope: Global vs Local

- If multiple projects exist under that organization, you'll be prompted to choose one.
- If only one project exists, it will be selected automatically.
By default, `project use` saves your selection to the **global configuration** (`~/.config/hookdeck/config.toml`). You can pin a specific project to the **current directory** using the `--local` flag.

```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
**Configuration file precedence (only ONE is used):**

The CLI uses exactly one configuration file based on this precedence:

1. **Custom config** (via `--config` flag) - highest priority
2. **Local config** - `${PWD}/.hookdeck/config.toml` (if exists)
3. **Global config** - `~/.config/hookdeck/config.toml` (default)

Unlike Git, Hookdeck **does not merge** multiple config files - only the highest precedence config is used.

**Examples:**

```sh
# No local config exists → saves to global
$ hookdeck project use my-org my-project
Successfully set active project to: my-org / my-project
Saved to: ~/.config/hookdeck/config.toml

# Local config exists → automatically updates local
$ cd ~/repo-with-local-config # has .hookdeck/config.toml
$ hookdeck project use another-org another-project
Successfully set active project to: another-org / another-project
Updated: .hookdeck/config.toml

# Create new local config
$ cd ~/my-new-repo # no .hookdeck/ directory
$ hookdeck project use my-org my-project --local
Successfully set active project to: my-org / my-project
Created: .hookdeck/config.toml
⚠️ Security: Add .hookdeck/ to .gitignore (contains credentials)

# Update existing local config with confirmation
$ hookdeck project use another-org another-project --local
Local configuration already exists at: .hookdeck/config.toml
? Overwrite with new project configuration? (y/N) y
Successfully set active project to: another-org / another-project
Updated: .hookdeck/config.toml
```

**Smart default behavior:**

When you run `project use` without `--local`:
- **If `.hookdeck/config.toml` exists**: Updates the local config
- **Otherwise**: Updates the global config

This ensures your directory-specific configuration is preserved when it exists.

**Flag validation:**

```sh
# ✅ Valid
hookdeck project use my-org my-project
hookdeck project use my-org my-project --local

# ❌ Invalid (cannot combine --config with --local)
hookdeck --config custom.toml project use my-org my-project --local
Error: --local and --config flags cannot be used together
--local creates config at: .hookdeck/config.toml
--config uses custom path: custom.toml
```

#### Benefits of local project pinning

- **Per-repository configuration**: Each repository can use a different Hookdeck project
- **Team collaboration**: Commit `.hookdeck/config.toml` to private repos (see security note)
- **No context switching**: Automatically uses the right project when you `cd` into a directory
- **CI/CD friendly**: Works seamlessly in automated environments

#### Security: Config files and source control

⚠️ **IMPORTANT**: Configuration files contain your Hookdeck credentials and should be treated as sensitive.

**Credential Types:**

- **CLI Key**: Created when you run `hookdeck login` (interactive authentication)
- **CI Key**: Created in the Hookdeck dashboard for use in CI/CD pipelines
- Both are stored as `api_key` in config files

**Recommended practices:**

- **Private repositories**: You MAY commit `.hookdeck/config.toml` if your repository is guaranteed to remain private and all collaborators should have access to the credentials.

- **Public repositories**: You MUST add `.hookdeck/` to your `.gitignore`:
```gitignore
# Hookdeck CLI configuration (contains credentials)
.hookdeck/
```

- **`hookdeck project use <organization_name> <project_name>`** (two arguments):
Directly selects the project `<project_name>` under the organization `<organization_name>`.
- **CI/CD environments**: Use the `HOOKDECK_API_KEY` environment variable:
```sh
$ hookdeck project use "My Corp" "API Staging"
Successfully set active project to: [My Corp] API Staging
# The ci command automatically reads HOOKDECK_API_KEY
export HOOKDECK_API_KEY="your-ci-key"
hookdeck ci
hookdeck listen 3000
```

Upon successful selection, you will generally see a confirmation message like:
`Successfully set active project to: [<organization_name>] <project_name>`
**Checking which config is active:**

```sh
$ hookdeck whoami
Logged in as: user@example.com
Active project: my-org / my-project
Config file: /Users/username/my-repo/.hookdeck/config.toml (local)
```

**Removing local configuration:**

To stop using local configuration and switch back to global:

```sh
$ rm -rf .hookdeck/
# Now CLI uses global config
```

### Manage connections

Expand Down Expand Up @@ -1020,6 +1111,9 @@ npm install hookdeck-cli@beta -g
# Homebrew
brew install hookdeck/hookdeck/hookdeck-beta

# To force the symlink update and overwrite all conflicting files:
# brew link --overwrite hookdeck-beta

# Scoop
scoop install hookdeck-beta

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "hookdeck-cli",
"version": "1.2.0",
"version": "1.3.0-beta.1",
"description": "Hookdeck CLI",
"repository": {
"type": "git",
Expand Down
88 changes: 81 additions & 7 deletions pkg/cmd/connection_get.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@ import (
"encoding/json"
"fmt"
"os"
"strings"

"github.com/spf13/cobra"

"github.com/hookdeck/hookdeck-cli/pkg/ansi"
"github.com/hookdeck/hookdeck-cli/pkg/hookdeck"
"github.com/hookdeck/hookdeck-cli/pkg/validators"
)

Expand All @@ -22,14 +24,19 @@ func newConnectionGetCmd() *connectionGetCmd {
cc := &connectionGetCmd{}

cc.cmd = &cobra.Command{
Use: "get <connection-id>",
Use: "get <connection-id-or-name>",
Args: validators.ExactArgs(1),
Short: "Get connection details",
Long: `Get detailed information about a specific connection.

You can specify either a connection ID or name.

Examples:
# Get connection details
hookdeck connection get conn_abc123`,
# Get connection by ID
hookdeck connection get conn_abc123

# Get connection by name
hookdeck connection get my-connection`,
RunE: cc.runConnectionGetCmd,
}

Expand All @@ -43,14 +50,20 @@ func (cc *connectionGetCmd) runConnectionGetCmd(cmd *cobra.Command, args []strin
return err
}

connectionID := args[0]
client := Config.GetAPIClient()
connectionIDOrName := args[0]
apiClient := Config.GetAPIClient()
ctx := context.Background()

// Resolve connection ID from name or ID
connectionID, err := resolveConnectionID(ctx, apiClient, connectionIDOrName)
if err != nil {
return err
}

// Get connection by ID
conn, err := client.GetConnection(ctx, connectionID)
conn, err := apiClient.GetConnection(ctx, connectionID)
if err != nil {
return fmt.Errorf("failed to get connection: %w", err)
return formatConnectionError(err, connectionIDOrName)
}

if cc.output == "json" {
Expand Down Expand Up @@ -88,6 +101,7 @@ func (cc *connectionGetCmd) runConnectionGetCmd(cmd *cobra.Command, args []strin
fmt.Printf("Source:\n")
fmt.Printf(" Name: %s\n", conn.Source.Name)
fmt.Printf(" ID: %s\n", conn.Source.ID)
fmt.Printf(" Type: %s\n", conn.Source.Type)
fmt.Printf(" URL: %s\n", conn.Source.URL)
fmt.Printf("\n")
}
Expand All @@ -97,6 +111,7 @@ func (cc *connectionGetCmd) runConnectionGetCmd(cmd *cobra.Command, args []strin
fmt.Printf("Destination:\n")
fmt.Printf(" Name: %s\n", conn.Destination.Name)
fmt.Printf(" ID: %s\n", conn.Destination.ID)
fmt.Printf(" Type: %s\n", conn.Destination.Type)

if cliPath := conn.Destination.GetCLIPath(); cliPath != nil {
fmt.Printf(" CLI Path: %s\n", *cliPath)
Expand Down Expand Up @@ -139,3 +154,62 @@ func (cc *connectionGetCmd) runConnectionGetCmd(cmd *cobra.Command, args []strin

return nil
}

// resolveConnectionID accepts both connection names and IDs
// Try as ID first (if it starts with conn_ or web_), then lookup by name
func resolveConnectionID(ctx context.Context, client *hookdeck.Client, nameOrID string) (string, error) {
// If it looks like a connection ID, try it directly
if strings.HasPrefix(nameOrID, "conn_") || strings.HasPrefix(nameOrID, "web_") {
// Try to get it to verify it exists
_, err := client.GetConnection(ctx, nameOrID)
if err == nil {
return nameOrID, nil
}
// If we get a 404, fall through to name lookup
// For other errors, format and return the error
errMsg := strings.ToLower(err.Error())
if !strings.Contains(errMsg, "404") && !strings.Contains(errMsg, "not found") {
return "", err
}
// 404 on ID lookup - fall through to try name lookup
}

// Try to find by name
params := map[string]string{
"name": nameOrID,
}

result, err := client.ListConnections(ctx, params)
if err != nil {
return "", fmt.Errorf("failed to lookup connection by name '%s': %w", nameOrID, err)
}

if result.Pagination.Limit == 0 || len(result.Models) == 0 {
return "", fmt.Errorf("connection not found: '%s'\n\nPlease check the connection name or ID and try again", nameOrID)
}

if len(result.Models) > 1 {
return "", fmt.Errorf("multiple connections found with name '%s', please use the connection ID instead", nameOrID)
}

return result.Models[0].ID, nil
}

// formatConnectionError provides user-friendly error messages for connection get failures
func formatConnectionError(err error, identifier string) error {
errMsg := err.Error()

// Check for 404/not found errors (case-insensitive)
errMsgLower := strings.ToLower(errMsg)
if strings.Contains(errMsgLower, "404") || strings.Contains(errMsgLower, "not found") {
return fmt.Errorf("connection not found: '%s'\n\nPlease check the connection name or ID and try again", identifier)
}

// Check for network/timeout errors
if strings.Contains(errMsg, "timeout") || strings.Contains(errMsg, "connection refused") {
return fmt.Errorf("failed to connect to Hookdeck API: %w\n\nPlease check your network connection and try again", err)
}

// Default to the original error with some context
return fmt.Errorf("failed to get connection '%s': %w", identifier, err)
}
8 changes: 6 additions & 2 deletions pkg/cmd/connection_list.go
Original file line number Diff line number Diff line change
Expand Up @@ -142,23 +142,27 @@ func (cc *connectionListCmd) runConnectionListCmd(cmd *cobra.Command, args []str

sourceName := "unknown"
sourceID := "unknown"
sourceType := "unknown"
if conn.Source != nil {
sourceName = conn.Source.Name
sourceID = conn.Source.ID
sourceType = conn.Source.Type
}

destinationName := "unknown"
destinationID := "unknown"
destinationType := "unknown"
if conn.Destination != nil {
destinationName = conn.Destination.Name
destinationID = conn.Destination.ID
destinationType = conn.Destination.Type
}

// Show connection name in color
fmt.Printf("%s\n", color.Green(connectionName))
fmt.Printf(" ID: %s\n", conn.ID)
fmt.Printf(" Source: %s (%s)\n", sourceName, sourceID)
fmt.Printf(" Destination: %s (%s)\n", destinationName, destinationID)
fmt.Printf(" Source: %s (%s) [%s]\n", sourceName, sourceID, sourceType)
fmt.Printf(" Destination: %s (%s) [%s]\n", destinationName, destinationID, destinationType)

if conn.DisabledAt != nil {
fmt.Printf(" Status: %s\n", color.Red("disabled"))
Expand Down
Loading