Skip to content

Commit b49177e

Browse files
authored
feat: project type (Gateway/Outpost/Console), gateway gating, whoami/list/use updates (#237)
* feat: project type (Gateway/Outpost/Console), gateway gating, whoami/list/use updates - Add project_type to config (gateway, outpost, console) with fallback from project_mode - whoami: show Project type line - project list: show type column; --output json with id, org, project, type, current; --type filter - project use: use normalized list, match by id or org/project display - gateway: PersistentPreRunE requires Gateway project; block on Outpost/Console with clear error - MCP: tool_projects returns type; tool_login persists type; outbound excluded - Acceptance: project list tests require HOOKDECK_CLI_TESTING_CLI_KEY, auth via login --api-key Made-with: Cursor * test(acceptance): add project list filter-by-type and filter-by-org tests - TestProjectListFilterByType: project list --type gateway --output json, assert all items type gateway - TestProjectListFilterByOrgProject: project list <org_substring> --output json, assert org contains substring Both require HOOKDECK_CLI_TESTING_CLI_KEY (skip with clear message when unset). Made-with: Cursor * test(acceptance): add project list invalid --type and two-arg filter tests - TestProjectListInvalidType: project list --type invalid returns error (no CLI key) - TestProjectListFilterByOrgAndProject: project list <org> <project> --output json filters by both substrings Made-with: Cursor * feat: treat outbound project mode as Gateway (same as inbound) - ModeToProjectType(outbound) now returns ProjectTypeGateway; outbound projects appear in list as Gateway - IsGatewayProject(outbound) true so gateway commands work when profile has outbound mode - Unit tests: ModeToProjectType/IsGatewayProject outbound, NormalizeProjects includes outbound as Gateway, requireGatewayProject passes for outbound mode Made-with: Cursor
1 parent 5115961 commit b49177e

25 files changed

Lines changed: 986 additions & 215 deletions

REFERENCE.md

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,8 @@ hookdeck whoami
103103
```bash
104104
$ hookdeck whoami
105105
```
106+
107+
Output includes the current project type (Gateway, Outpost, or Console).
106108
<!-- GENERATE_END -->
107109
## Projects
108110

@@ -112,21 +114,30 @@ $ hookdeck whoami
112114

113115
### hookdeck project list
114116

115-
List and filter projects by organization and project name substrings
117+
List and filter projects by organization and project name substrings. Output shows project type (Gateway, Outpost, Console). Outbound projects are excluded from the list.
116118

117119
**Usage:**
118120

119121
```bash
120-
hookdeck project list [<organization_substring>] [<project_substring>]
122+
hookdeck project list [<organization_substring>] [<project_substring>] [flags]
121123
```
122124

125+
**Flags:**
126+
127+
| Flag | Type | Description |
128+
|------|------|-------------|
129+
| `--output` | `string` | Output format: `json` for machine-readable list (id, org, project, type, current) |
130+
| `--type` | `string` | Filter by project type: `gateway`, `outpost`, or `console` |
131+
123132
**Examples:**
124133

125134
```bash
126135
$ hookdeck project list
127-
[Acme] Ecommerce Production (current)
128-
[Acme] Ecommerce Staging
129-
[Acme] Ecommerce Development
136+
Acme / Ecommerce Production (current) | Gateway
137+
Acme / Ecommerce Staging | Gateway
138+
139+
$ hookdeck project list --output json
140+
$ hookdeck project list --type gateway
130141
```
131142
### hookdeck project use
132143

@@ -208,6 +219,7 @@ Commands for managing Event Gateway sources, destinations, connections,
208219
transformations, events, requests, metrics, and MCP server.
209220
210221
The gateway command group provides full access to all Event Gateway resources.
222+
**Gateway commands require the current project to be a Gateway project** (inbound or console). If your project type is Outpost or you have no project selected, run `hookdeck project use` to switch to a Gateway project first.
211223
212224
**Usage:**
213225

pkg/cmd/gateway.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,52 @@
11
package cmd
22

33
import (
4+
"fmt"
5+
46
"github.com/spf13/cobra"
57

8+
"github.com/hookdeck/hookdeck-cli/pkg/config"
69
"github.com/hookdeck/hookdeck-cli/pkg/validators"
710
)
811

912
type gatewayCmd struct {
1013
cmd *cobra.Command
1114
}
1215

16+
// requireGatewayProject ensures the current project is a Gateway project (inbound or console).
17+
// It runs API key validation, resolves project type from config or API, and returns an error if not Gateway.
18+
// cfg is optional; when nil, the global Config is used (for production).
19+
func requireGatewayProject(cfg *config.Config) error {
20+
if cfg == nil {
21+
cfg = &Config
22+
}
23+
if err := cfg.Profile.ValidateAPIKey(); err != nil {
24+
return err
25+
}
26+
if cfg.Profile.ProjectId == "" {
27+
return fmt.Errorf("no project selected. Run 'hookdeck project use' to select a project")
28+
}
29+
projectType := cfg.Profile.ProjectType
30+
if projectType == "" && cfg.Profile.ProjectMode != "" {
31+
projectType = config.ModeToProjectType(cfg.Profile.ProjectMode)
32+
}
33+
if projectType == "" {
34+
// Resolve from API
35+
response, err := cfg.GetAPIClient().ValidateAPIKey()
36+
if err != nil {
37+
return err
38+
}
39+
projectType = config.ModeToProjectType(response.ProjectMode)
40+
cfg.Profile.ProjectType = projectType
41+
cfg.Profile.ProjectMode = response.ProjectMode
42+
_ = cfg.Profile.SaveProfile()
43+
}
44+
if !config.IsGatewayProject(projectType) {
45+
return fmt.Errorf("this command requires a Gateway project; current project type is %s. Use 'hookdeck project use' to switch to a Gateway project", projectType)
46+
}
47+
return nil
48+
}
49+
1350
func newGatewayCmd() *gatewayCmd {
1451
g := &gatewayCmd{}
1552

@@ -32,6 +69,9 @@ The gateway command group provides full access to all Event Gateway resources.`,
3269
3370
# Start the MCP server for AI agent access
3471
hookdeck gateway mcp`,
72+
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
73+
return requireGatewayProject(nil)
74+
},
3575
}
3676

3777
// Register resource subcommands (same factory as root backward-compat registration)

pkg/cmd/gateway_test.go

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
package cmd
2+
3+
import (
4+
"strings"
5+
"testing"
6+
7+
"github.com/hookdeck/hookdeck-cli/pkg/config"
8+
"github.com/stretchr/testify/assert"
9+
"github.com/stretchr/testify/require"
10+
)
11+
12+
func TestRequireGatewayProject(t *testing.T) {
13+
t.Run("no API key", func(t *testing.T) {
14+
cfg := &config.Config{}
15+
cfg.Profile.ProjectId = "proj_1"
16+
cfg.Profile.ProjectType = config.ProjectTypeGateway
17+
err := requireGatewayProject(cfg)
18+
require.Error(t, err)
19+
assert.Contains(t, err.Error(), "authenticated")
20+
})
21+
22+
t.Run("no project selected", func(t *testing.T) {
23+
cfg := &config.Config{}
24+
cfg.Profile.APIKey = "sk_xxx"
25+
cfg.Profile.ProjectId = ""
26+
err := requireGatewayProject(cfg)
27+
require.Error(t, err)
28+
assert.Contains(t, err.Error(), "no project selected")
29+
})
30+
31+
t.Run("Gateway type passes", func(t *testing.T) {
32+
cfg := &config.Config{}
33+
cfg.Profile.APIKey = "sk_xxx"
34+
cfg.Profile.ProjectId = "proj_1"
35+
cfg.Profile.ProjectType = config.ProjectTypeGateway
36+
err := requireGatewayProject(cfg)
37+
assert.NoError(t, err)
38+
})
39+
40+
t.Run("Console type passes", func(t *testing.T) {
41+
cfg := &config.Config{}
42+
cfg.Profile.APIKey = "sk_xxx"
43+
cfg.Profile.ProjectId = "proj_1"
44+
cfg.Profile.ProjectType = config.ProjectTypeConsole
45+
err := requireGatewayProject(cfg)
46+
assert.NoError(t, err)
47+
})
48+
49+
t.Run("inbound mode passes when type empty", func(t *testing.T) {
50+
cfg := &config.Config{}
51+
cfg.Profile.APIKey = "sk_xxx"
52+
cfg.Profile.ProjectId = "proj_1"
53+
cfg.Profile.ProjectMode = "inbound"
54+
err := requireGatewayProject(cfg)
55+
assert.NoError(t, err)
56+
})
57+
58+
t.Run("outbound mode passes when type empty (same as inbound)", func(t *testing.T) {
59+
cfg := &config.Config{}
60+
cfg.Profile.APIKey = "sk_xxx"
61+
cfg.Profile.ProjectId = "proj_1"
62+
cfg.Profile.ProjectMode = "outbound"
63+
err := requireGatewayProject(cfg)
64+
assert.NoError(t, err)
65+
})
66+
67+
t.Run("Outpost type fails", func(t *testing.T) {
68+
cfg := &config.Config{}
69+
cfg.Profile.APIKey = "sk_xxx"
70+
cfg.Profile.ProjectId = "proj_1"
71+
cfg.Profile.ProjectType = config.ProjectTypeOutpost
72+
err := requireGatewayProject(cfg)
73+
require.Error(t, err)
74+
assert.Contains(t, err.Error(), "requires a Gateway project")
75+
assert.Contains(t, err.Error(), "Outpost")
76+
assert.Contains(t, err.Error(), "hookdeck project use")
77+
})
78+
79+
t.Run("unknown type fails", func(t *testing.T) {
80+
cfg := &config.Config{}
81+
cfg.Profile.APIKey = "sk_xxx"
82+
cfg.Profile.ProjectId = "proj_1"
83+
cfg.Profile.ProjectMode = "outpost"
84+
err := requireGatewayProject(cfg)
85+
require.Error(t, err)
86+
assert.True(t, strings.Contains(err.Error(), "requires a Gateway project") || strings.Contains(err.Error(), "Outpost"))
87+
})
88+
}

pkg/cmd/project_list.go

Lines changed: 68 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,24 @@
11
package cmd
22

33
import (
4+
"encoding/json"
45
"fmt"
56
"os"
6-
"strings"
77

88
"github.com/spf13/cobra"
99

1010
"github.com/hookdeck/hookdeck-cli/pkg/ansi"
11-
"github.com/hookdeck/hookdeck-cli/pkg/hookdeck"
11+
"github.com/hookdeck/hookdeck-cli/pkg/config"
1212
"github.com/hookdeck/hookdeck-cli/pkg/project"
1313
"github.com/hookdeck/hookdeck-cli/pkg/validators"
1414
)
1515

16+
var validProjectTypes = []string{"gateway", "outpost", "console"}
17+
1618
type projectListCmd struct {
17-
cmd *cobra.Command
19+
cmd *cobra.Command
20+
output string
21+
typeFilter string
1822
}
1923

2024
func newProjectListCmd() *projectListCmd {
@@ -26,11 +30,15 @@ func newProjectListCmd() *projectListCmd {
2630
Short: "List and filter projects by organization and project name substrings",
2731
RunE: lc.runProjectListCmd,
2832
Example: `$ hookdeck project list
29-
[Acme] Ecommerce Production (current)
30-
[Acme] Ecommerce Staging
31-
[Acme] Ecommerce Development`,
33+
Acme / Ecommerce Production (current) | Gateway
34+
Acme / Ecommerce Staging | Gateway
35+
$ hookdeck project list --output json
36+
$ hookdeck project list --type gateway`,
3237
}
3338

39+
lc.cmd.Flags().StringVar(&lc.output, "output", "", "Output format: json")
40+
lc.cmd.Flags().StringVar(&lc.typeFilter, "type", "", "Filter by project type: gateway, outpost, console")
41+
3442
return lc
3543
}
3644

@@ -39,58 +47,77 @@ func (lc *projectListCmd) runProjectListCmd(cmd *cobra.Command, args []string) e
3947
return err
4048
}
4149

50+
if lc.typeFilter != "" {
51+
ok := false
52+
for _, v := range validProjectTypes {
53+
if lc.typeFilter == v {
54+
ok = true
55+
break
56+
}
57+
}
58+
if !ok {
59+
return fmt.Errorf("invalid --type value: %q (must be one of: gateway, outpost, console)", lc.typeFilter)
60+
}
61+
}
62+
4263
projects, err := project.ListProjects(&Config)
4364
if err != nil {
4465
return err
4566
}
4667

47-
var filteredProjects []hookdeck.Project
68+
items := project.NormalizeProjects(projects, Config.Profile.ProjectId)
69+
items = project.FilterByType(items, lc.typeFilter)
4870

4971
switch len(args) {
50-
case 0:
51-
filteredProjects = projects
5272
case 1:
53-
argOrgNameInput := args[0]
54-
argOrgNameLower := strings.ToLower(argOrgNameInput)
55-
56-
for _, p := range projects {
57-
org, _, errParser := project.ParseProjectName(p.Name)
58-
if errParser != nil {
59-
continue
60-
}
61-
if strings.Contains(strings.ToLower(org), argOrgNameLower) {
62-
filteredProjects = append(filteredProjects, p)
63-
}
64-
}
73+
items = project.FilterByOrgProject(items, args[0], "")
6574
case 2:
66-
argOrgNameInput := args[0]
67-
argProjNameInput := args[1]
68-
argOrgNameLower := strings.ToLower(argOrgNameInput)
69-
argProjNameLower := strings.ToLower(argProjNameInput)
70-
71-
for _, p := range projects {
72-
org, proj, errParser := project.ParseProjectName(p.Name)
73-
if errParser != nil {
74-
continue
75-
}
76-
if strings.Contains(strings.ToLower(org), argOrgNameLower) && strings.Contains(strings.ToLower(proj), argProjNameLower) {
77-
filteredProjects = append(filteredProjects, p)
78-
}
79-
}
75+
items = project.FilterByOrgProject(items, args[0], args[1])
8076
}
8177

82-
if len(filteredProjects) == 0 {
78+
if len(items) == 0 {
79+
if lc.output == "json" {
80+
fmt.Println("[]")
81+
return nil
82+
}
8383
fmt.Println("No projects found.")
8484
return nil
8585
}
8686

87-
color := ansi.Color(os.Stdout)
87+
if lc.output == "json" {
88+
type jsonItem struct {
89+
Id string `json:"id"`
90+
Org string `json:"org"`
91+
Project string `json:"project"`
92+
Type string `json:"type"`
93+
Current bool `json:"current"`
94+
}
95+
out := make([]jsonItem, len(items))
96+
for i, it := range items {
97+
out[i] = jsonItem{
98+
Id: it.Id,
99+
Org: it.Org,
100+
Project: it.Project,
101+
Type: config.ProjectTypeToJSON(it.Type),
102+
Current: it.Current,
103+
}
104+
}
105+
enc := json.NewEncoder(os.Stdout)
106+
enc.SetIndent("", " ")
107+
return enc.Encode(out)
108+
}
88109

89-
for _, project := range filteredProjects {
90-
if project.Id == Config.Profile.ProjectId {
91-
fmt.Printf("%s (current)\n", color.Green(project.Name))
110+
color := ansi.Color(os.Stdout)
111+
for _, it := range items {
112+
if it.Current {
113+
// highlight (current) in green
114+
namePart := it.Project
115+
if it.Org != "" {
116+
namePart = it.Org + " / " + it.Project
117+
}
118+
fmt.Printf("%s%s | %s\n", namePart, color.Green(" (current)"), it.Type)
92119
} else {
93-
fmt.Printf("%s\n", project.Name)
120+
fmt.Println(it.DisplayLine())
94121
}
95122
}
96123

0 commit comments

Comments
 (0)