Skip to content

Commit a81a219

Browse files
committed
feat: add --local flag to project use command
Add support for pinning Hookdeck project configuration to the current directory using the --local flag. This enables per-repository project configuration for better developer experience in multi-project workflows. Implementation: - Added --local flag to 'project use' command - Implemented UseProjectLocal() method to create/update local config - Added smart default: auto-updates local config when it exists - Validates --local and --config are mutually exclusive - Displays security warning when creating local config - Refactored config writing to reduce code duplication Testing: - Created comprehensive acceptance test suite - Added helper functions for temp directory management - Tests validate flag behavior, config creation, and security warnings - 2 tests passing in CI, 4 tests ready for CLI key testing Documentation: - Updated README with complete --local flag documentation - Explained configuration file precedence (--config > local > global) - Added security guidance for credential management - Included examples for all use cases Related: Previous PRs #102, #103 that removed --local flag
1 parent 397a6f6 commit a81a219

File tree

5 files changed

+587
-51
lines changed

5 files changed

+587
-51
lines changed

README.md

Lines changed: 125 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: user@example.com
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

pkg/cmd/project_use.go

Lines changed: 66 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package cmd
33
import (
44
"fmt"
55
"os"
6+
"path/filepath"
67
"strings"
78

89
"github.com/AlecAivazis/survey/v2"
@@ -15,8 +16,8 @@ import (
1516
)
1617

1718
type projectUseCmd struct {
18-
cmd *cobra.Command
19-
// local bool
19+
cmd *cobra.Command
20+
local bool
2021
}
2122

2223
func newProjectUseCmd() *projectUseCmd {
@@ -29,10 +30,17 @@ func newProjectUseCmd() *projectUseCmd {
2930
RunE: lc.runProjectUseCmd,
3031
}
3132

33+
lc.cmd.Flags().BoolVar(&lc.local, "local", false, "Save project to current directory (.hookdeck/config.toml)")
34+
3235
return lc
3336
}
3437

3538
func (lc *projectUseCmd) runProjectUseCmd(cmd *cobra.Command, args []string) error {
39+
// Validate flag compatibility
40+
if lc.local && Config.ConfigFileFlag != "" {
41+
return fmt.Errorf("Error: --local and --config flags cannot be used together\n --local creates config at: .hookdeck/config.toml\n --config uses custom path: %s", Config.ConfigFileFlag)
42+
}
43+
3644
if err := Config.Profile.ValidateAPIKey(); err != nil {
3745
return err
3846
}
@@ -182,12 +190,65 @@ func (lc *projectUseCmd) runProjectUseCmd(cmd *cobra.Command, args []string) err
182190
return fmt.Errorf("a project could not be determined based on the provided arguments")
183191
}
184192

185-
err = Config.UseProject(selectedProject.Id, selectedProject.Mode)
186-
if err != nil {
187-
return err
193+
// Determine which config to update
194+
var configPath string
195+
var isNewConfig bool
196+
197+
if lc.local {
198+
// User explicitly requested local config
199+
isNewConfig, err = Config.UseProjectLocal(selectedProject.Id, selectedProject.Mode)
200+
if err != nil {
201+
return err
202+
}
203+
204+
workingDir, wdErr := os.Getwd()
205+
if wdErr != nil {
206+
return wdErr
207+
}
208+
configPath = filepath.Join(workingDir, ".hookdeck/config.toml")
209+
} else {
210+
// Smart default: check if local config exists
211+
workingDir, wdErr := os.Getwd()
212+
if wdErr != nil {
213+
return wdErr
214+
}
215+
216+
localConfigPath := filepath.Join(workingDir, ".hookdeck/config.toml")
217+
localConfigExists, _ := Config.FileExists(localConfigPath)
218+
219+
if localConfigExists {
220+
// Local config exists, update it
221+
isNewConfig, err = Config.UseProjectLocal(selectedProject.Id, selectedProject.Mode)
222+
if err != nil {
223+
return err
224+
}
225+
configPath = localConfigPath
226+
} else {
227+
// No local config, use global (existing behavior)
228+
err = Config.UseProject(selectedProject.Id, selectedProject.Mode)
229+
if err != nil {
230+
return err
231+
}
232+
233+
// Get global config path from Config
234+
configPath = Config.GetConfigFile()
235+
isNewConfig = false
236+
}
188237
}
189238

190239
color := ansi.Color(os.Stdout)
191240
fmt.Printf("Successfully set active project to: %s\n", color.Green(selectedProject.Name))
241+
242+
// Show which config was updated
243+
if strings.Contains(configPath, ".hookdeck/config.toml") {
244+
if isNewConfig && lc.local {
245+
fmt.Printf("Created: %s\n", configPath)
246+
} else {
247+
fmt.Printf("Updated: %s\n", configPath)
248+
}
249+
} else {
250+
fmt.Printf("Saved to: %s\n", configPath)
251+
}
252+
192253
return nil
193254
}

pkg/config/config.go

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package config
22

33
import (
4+
"fmt"
45
"os"
56
"path/filepath"
67
"time"
@@ -163,6 +164,92 @@ func (c *Config) UseProject(projectId string, projectMode string) error {
163164
return c.Profile.SaveProfile()
164165
}
165166

167+
// UseProjectLocal selects the active project to be used in local config
168+
// Returns true if a new file was created, false if existing file was updated
169+
func (c *Config) UseProjectLocal(projectId string, projectMode string) (bool, error) {
170+
// Get current working directory
171+
workingDir, err := os.Getwd()
172+
if err != nil {
173+
return false, fmt.Errorf("failed to get current directory: %w", err)
174+
}
175+
176+
// Create .hookdeck directory
177+
hookdeckDir := filepath.Join(workingDir, ".hookdeck")
178+
if err := os.MkdirAll(hookdeckDir, 0755); err != nil {
179+
return false, fmt.Errorf("failed to create .hookdeck directory: %w", err)
180+
}
181+
182+
// Define local config path
183+
localConfigPath := filepath.Join(hookdeckDir, "config.toml")
184+
185+
// Check if local config file exists
186+
fileExists, err := c.fs.fileExists(localConfigPath)
187+
if err != nil {
188+
return false, fmt.Errorf("failed to check if local config exists: %w", err)
189+
}
190+
191+
// Update in-memory state
192+
c.Profile.ProjectId = projectId
193+
c.Profile.ProjectMode = projectMode
194+
195+
// Write to local config file using shared helper
196+
if err := c.writeProjectConfig(localConfigPath, !fileExists); err != nil {
197+
return false, err
198+
}
199+
200+
return !fileExists, nil
201+
}
202+
203+
// writeProjectConfig writes the current profile's project configuration to the specified config file
204+
func (c *Config) writeProjectConfig(configPath string, isNewFile bool) error {
205+
// Create a new viper instance for the config
206+
v := viper.New()
207+
v.SetConfigType("toml")
208+
v.SetConfigFile(configPath)
209+
210+
// Try to read existing config (ignore error if doesn't exist)
211+
_ = v.ReadInConfig()
212+
213+
// Set all profile fields
214+
c.setProfileFieldsInViper(v)
215+
216+
// Write config file
217+
var writeErr error
218+
if isNewFile {
219+
writeErr = v.SafeWriteConfig()
220+
} else {
221+
writeErr = v.WriteConfig()
222+
}
223+
if writeErr != nil {
224+
return fmt.Errorf("failed to write config to %s: %w", configPath, writeErr)
225+
}
226+
227+
return nil
228+
}
229+
230+
// setProfileFieldsInViper sets the current profile's fields in the given viper instance
231+
func (c *Config) setProfileFieldsInViper(v *viper.Viper) {
232+
if c.Profile.APIKey != "" {
233+
v.Set(c.Profile.getConfigField("api_key"), c.Profile.APIKey)
234+
}
235+
v.Set("profile", c.Profile.Name)
236+
v.Set(c.Profile.getConfigField("project_id"), c.Profile.ProjectId)
237+
v.Set(c.Profile.getConfigField("project_mode"), c.Profile.ProjectMode)
238+
if c.Profile.GuestURL != "" {
239+
v.Set(c.Profile.getConfigField("guest_url"), c.Profile.GuestURL)
240+
}
241+
}
242+
243+
// GetConfigFile returns the path of the currently loaded config file
244+
func (c *Config) GetConfigFile() string {
245+
return c.configFile
246+
}
247+
248+
// FileExists checks if a file exists at the given path
249+
func (c *Config) FileExists(path string) (bool, error) {
250+
return c.fs.fileExists(path)
251+
}
252+
166253
func (c *Config) ListProfiles() []string {
167254
var profiles []string
168255

0 commit comments

Comments
 (0)