Skip to content

Commit b9d3312

Browse files
authored
Merge pull request #174 from hookdeck/feat/local-flag
Project Use --local Flag & Connection Management Improvements
2 parents 397a6f6 + 9089183 commit b9d3312

14 files changed

+1461
-69
lines changed

README.md

Lines changed: 128 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -471,7 +471,7 @@ hookdeck connection unpause # Unpause a connection
471471

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

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

476476
```sh
477477
# List all projects
@@ -480,58 +480,149 @@ My Org / My Project (current)
480480
My Org / Another Project
481481
Another Org / Yet Another One
482482

483-
# List projects with "Org" in the organization name and "Proj" in the project name
483+
# Filter by organization and project name
484484
$ hookdeck project list Org Proj
485485
My Org / My Project (current)
486486
My Org / Another Project
487487
```
488488

489-
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.
489+
#### Select active project
490490

491491
```console
492-
hookdeck project use [<organization_name> [<project_name>]]
492+
hookdeck project use [<organization_name> [<project_name>]] [--local]
493+
494+
Flags:
495+
--local Save project to current directory (.hookdeck/config.toml)
493496
```
494497

495-
**Behavior:**
498+
**Project Selection Modes:**
496499

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

500-
```sh
501-
$ hookdeck project use
502-
Use the arrow keys to navigate: ↓ ↑ → ←
503-
? Select Organization:
504-
My Org
505-
▸ Another Org
506-
...
507-
? Select Project (Another Org):
508-
Project X
509-
▸ Project Y
510-
Selecting project Project Y
511-
Successfully set active project to: [Another Org] Project Y
512-
```
504+
```sh
505+
$ hookdeck project use my-org my-project
506+
Successfully set active project to: my-org / my-project
507+
```
513508

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

517-
- If multiple projects exist under that organization, you'll be prompted to choose one.
518-
- If only one project exists, it will be selected automatically.
511+
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.
519512

520-
```sh
521-
$ hookdeck project use "My Org"
522-
# (If multiple projects, prompts to select. If one, auto-selects)
523-
Successfully set active project to: [My Org] Default Project
513+
**Configuration file precedence (only ONE is used):**
514+
515+
The CLI uses exactly one configuration file based on this precedence:
516+
517+
1. **Custom config** (via `--config` flag) - highest priority
518+
2. **Local config** - `${PWD}/.hookdeck/config.toml` (if exists)
519+
3. **Global config** - `~/.config/hookdeck/config.toml` (default)
520+
521+
Unlike Git, Hookdeck **does not merge** multiple config files - only the highest precedence config is used.
522+
523+
**Examples:**
524+
525+
```sh
526+
# No local config exists → saves to global
527+
$ hookdeck project use my-org my-project
528+
Successfully set active project to: my-org / my-project
529+
Saved to: ~/.config/hookdeck/config.toml
530+
531+
# Local config exists → automatically updates local
532+
$ cd ~/repo-with-local-config # has .hookdeck/config.toml
533+
$ hookdeck project use another-org another-project
534+
Successfully set active project to: another-org / another-project
535+
Updated: .hookdeck/config.toml
536+
537+
# Create new local config
538+
$ cd ~/my-new-repo # no .hookdeck/ directory
539+
$ hookdeck project use my-org my-project --local
540+
Successfully set active project to: my-org / my-project
541+
Created: .hookdeck/config.toml
542+
⚠️ Security: Add .hookdeck/ to .gitignore (contains credentials)
543+
544+
# Update existing local config with confirmation
545+
$ hookdeck project use another-org another-project --local
546+
Local configuration already exists at: .hookdeck/config.toml
547+
? Overwrite with new project configuration? (y/N) y
548+
Successfully set active project to: another-org / another-project
549+
Updated: .hookdeck/config.toml
550+
```
551+
552+
**Smart default behavior:**
553+
554+
When you run `project use` without `--local`:
555+
- **If `.hookdeck/config.toml` exists**: Updates the local config
556+
- **Otherwise**: Updates the global config
557+
558+
This ensures your directory-specific configuration is preserved when it exists.
559+
560+
**Flag validation:**
561+
562+
```sh
563+
# ✅ Valid
564+
hookdeck project use my-org my-project
565+
hookdeck project use my-org my-project --local
566+
567+
# ❌ Invalid (cannot combine --config with --local)
568+
hookdeck --config custom.toml project use my-org my-project --local
569+
Error: --local and --config flags cannot be used together
570+
--local creates config at: .hookdeck/config.toml
571+
--config uses custom path: custom.toml
572+
```
573+
574+
#### Benefits of local project pinning
575+
576+
- **Per-repository configuration**: Each repository can use a different Hookdeck project
577+
- **Team collaboration**: Commit `.hookdeck/config.toml` to private repos (see security note)
578+
- **No context switching**: Automatically uses the right project when you `cd` into a directory
579+
- **CI/CD friendly**: Works seamlessly in automated environments
580+
581+
#### Security: Config files and source control
582+
583+
⚠️ **IMPORTANT**: Configuration files contain your Hookdeck credentials and should be treated as sensitive.
584+
585+
**Credential Types:**
586+
587+
- **CLI Key**: Created when you run `hookdeck login` (interactive authentication)
588+
- **CI Key**: Created in the Hookdeck dashboard for use in CI/CD pipelines
589+
- Both are stored as `api_key` in config files
590+
591+
**Recommended practices:**
592+
593+
- **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.
594+
595+
- **Public repositories**: You MUST add `.hookdeck/` to your `.gitignore`:
596+
```gitignore
597+
# Hookdeck CLI configuration (contains credentials)
598+
.hookdeck/
524599
```
525600

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

533-
Upon successful selection, you will generally see a confirmation message like:
534-
`Successfully set active project to: [<organization_name>] <project_name>`
609+
**Checking which config is active:**
610+
611+
```sh
612+
$ hookdeck whoami
613+
Logged in as: [email protected]
614+
Active project: my-org / my-project
615+
Config file: /Users/username/my-repo/.hookdeck/config.toml (local)
616+
```
617+
618+
**Removing local configuration:**
619+
620+
To stop using local configuration and switch back to global:
621+
622+
```sh
623+
$ rm -rf .hookdeck/
624+
# Now CLI uses global config
625+
```
535626

536627
### Manage connections
537628

@@ -1020,6 +1111,9 @@ npm install hookdeck-cli@beta -g
10201111
# Homebrew
10211112
brew install hookdeck/hookdeck/hookdeck-beta
10221113

1114+
# To force the symlink update and overwrite all conflicting files:
1115+
# brew link --overwrite hookdeck-beta
1116+
10231117
# Scoop
10241118
scoop install hookdeck-beta
10251119

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "hookdeck-cli",
3-
"version": "1.2.0",
3+
"version": "1.3.0-beta.1",
44
"description": "Hookdeck CLI",
55
"repository": {
66
"type": "git",

pkg/cmd/connection_get.go

Lines changed: 81 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,12 @@ import (
55
"encoding/json"
66
"fmt"
77
"os"
8+
"strings"
89

910
"github.com/spf13/cobra"
1011

1112
"github.com/hookdeck/hookdeck-cli/pkg/ansi"
13+
"github.com/hookdeck/hookdeck-cli/pkg/hookdeck"
1214
"github.com/hookdeck/hookdeck-cli/pkg/validators"
1315
)
1416

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

2426
cc.cmd = &cobra.Command{
25-
Use: "get <connection-id>",
27+
Use: "get <connection-id-or-name>",
2628
Args: validators.ExactArgs(1),
2729
Short: "Get connection details",
2830
Long: `Get detailed information about a specific connection.
2931
32+
You can specify either a connection ID or name.
33+
3034
Examples:
31-
# Get connection details
32-
hookdeck connection get conn_abc123`,
35+
# Get connection by ID
36+
hookdeck connection get conn_abc123
37+
38+
# Get connection by name
39+
hookdeck connection get my-connection`,
3340
RunE: cc.runConnectionGetCmd,
3441
}
3542

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

46-
connectionID := args[0]
47-
client := Config.GetAPIClient()
53+
connectionIDOrName := args[0]
54+
apiClient := Config.GetAPIClient()
4855
ctx := context.Background()
4956

57+
// Resolve connection ID from name or ID
58+
connectionID, err := resolveConnectionID(ctx, apiClient, connectionIDOrName)
59+
if err != nil {
60+
return err
61+
}
62+
5063
// Get connection by ID
51-
conn, err := client.GetConnection(ctx, connectionID)
64+
conn, err := apiClient.GetConnection(ctx, connectionID)
5265
if err != nil {
53-
return fmt.Errorf("failed to get connection: %w", err)
66+
return formatConnectionError(err, connectionIDOrName)
5467
}
5568

5669
if cc.output == "json" {
@@ -88,6 +101,7 @@ func (cc *connectionGetCmd) runConnectionGetCmd(cmd *cobra.Command, args []strin
88101
fmt.Printf("Source:\n")
89102
fmt.Printf(" Name: %s\n", conn.Source.Name)
90103
fmt.Printf(" ID: %s\n", conn.Source.ID)
104+
fmt.Printf(" Type: %s\n", conn.Source.Type)
91105
fmt.Printf(" URL: %s\n", conn.Source.URL)
92106
fmt.Printf("\n")
93107
}
@@ -97,6 +111,7 @@ func (cc *connectionGetCmd) runConnectionGetCmd(cmd *cobra.Command, args []strin
97111
fmt.Printf("Destination:\n")
98112
fmt.Printf(" Name: %s\n", conn.Destination.Name)
99113
fmt.Printf(" ID: %s\n", conn.Destination.ID)
114+
fmt.Printf(" Type: %s\n", conn.Destination.Type)
100115

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

140155
return nil
141156
}
157+
158+
// resolveConnectionID accepts both connection names and IDs
159+
// Try as ID first (if it starts with conn_ or web_), then lookup by name
160+
func resolveConnectionID(ctx context.Context, client *hookdeck.Client, nameOrID string) (string, error) {
161+
// If it looks like a connection ID, try it directly
162+
if strings.HasPrefix(nameOrID, "conn_") || strings.HasPrefix(nameOrID, "web_") {
163+
// Try to get it to verify it exists
164+
_, err := client.GetConnection(ctx, nameOrID)
165+
if err == nil {
166+
return nameOrID, nil
167+
}
168+
// If we get a 404, fall through to name lookup
169+
// For other errors, format and return the error
170+
errMsg := strings.ToLower(err.Error())
171+
if !strings.Contains(errMsg, "404") && !strings.Contains(errMsg, "not found") {
172+
return "", err
173+
}
174+
// 404 on ID lookup - fall through to try name lookup
175+
}
176+
177+
// Try to find by name
178+
params := map[string]string{
179+
"name": nameOrID,
180+
}
181+
182+
result, err := client.ListConnections(ctx, params)
183+
if err != nil {
184+
return "", fmt.Errorf("failed to lookup connection by name '%s': %w", nameOrID, err)
185+
}
186+
187+
if result.Pagination.Limit == 0 || len(result.Models) == 0 {
188+
return "", fmt.Errorf("connection not found: '%s'\n\nPlease check the connection name or ID and try again", nameOrID)
189+
}
190+
191+
if len(result.Models) > 1 {
192+
return "", fmt.Errorf("multiple connections found with name '%s', please use the connection ID instead", nameOrID)
193+
}
194+
195+
return result.Models[0].ID, nil
196+
}
197+
198+
// formatConnectionError provides user-friendly error messages for connection get failures
199+
func formatConnectionError(err error, identifier string) error {
200+
errMsg := err.Error()
201+
202+
// Check for 404/not found errors (case-insensitive)
203+
errMsgLower := strings.ToLower(errMsg)
204+
if strings.Contains(errMsgLower, "404") || strings.Contains(errMsgLower, "not found") {
205+
return fmt.Errorf("connection not found: '%s'\n\nPlease check the connection name or ID and try again", identifier)
206+
}
207+
208+
// Check for network/timeout errors
209+
if strings.Contains(errMsg, "timeout") || strings.Contains(errMsg, "connection refused") {
210+
return fmt.Errorf("failed to connect to Hookdeck API: %w\n\nPlease check your network connection and try again", err)
211+
}
212+
213+
// Default to the original error with some context
214+
return fmt.Errorf("failed to get connection '%s': %w", identifier, err)
215+
}

pkg/cmd/connection_list.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -142,23 +142,27 @@ func (cc *connectionListCmd) runConnectionListCmd(cmd *cobra.Command, args []str
142142

143143
sourceName := "unknown"
144144
sourceID := "unknown"
145+
sourceType := "unknown"
145146
if conn.Source != nil {
146147
sourceName = conn.Source.Name
147148
sourceID = conn.Source.ID
149+
sourceType = conn.Source.Type
148150
}
149151

150152
destinationName := "unknown"
151153
destinationID := "unknown"
154+
destinationType := "unknown"
152155
if conn.Destination != nil {
153156
destinationName = conn.Destination.Name
154157
destinationID = conn.Destination.ID
158+
destinationType = conn.Destination.Type
155159
}
156160

157161
// Show connection name in color
158162
fmt.Printf("%s\n", color.Green(connectionName))
159163
fmt.Printf(" ID: %s\n", conn.ID)
160-
fmt.Printf(" Source: %s (%s)\n", sourceName, sourceID)
161-
fmt.Printf(" Destination: %s (%s)\n", destinationName, destinationID)
164+
fmt.Printf(" Source: %s (%s) [%s]\n", sourceName, sourceID, sourceType)
165+
fmt.Printf(" Destination: %s (%s) [%s]\n", destinationName, destinationID, destinationType)
162166

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

0 commit comments

Comments
 (0)