Skip to content
Open
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
22 changes: 17 additions & 5 deletions REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,8 @@ hookdeck whoami
```bash
$ hookdeck whoami
```

Output includes the current project type (Gateway, Outpost, or Console).
<!-- GENERATE_END -->
## Projects

Expand All @@ -112,21 +114,30 @@ $ hookdeck whoami

### hookdeck project list

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

**Usage:**

```bash
hookdeck project list [<organization_substring>] [<project_substring>]
hookdeck project list [<organization_substring>] [<project_substring>] [flags]
```

**Flags:**

| Flag | Type | Description |
|------|------|-------------|
| `--output` | `string` | Output format: `json` for machine-readable list (id, org, project, type, current) |
| `--type` | `string` | Filter by project type: `gateway`, `outpost`, or `console` |

**Examples:**

```bash
$ hookdeck project list
[Acme] Ecommerce Production (current)
[Acme] Ecommerce Staging
[Acme] Ecommerce Development
Acme / Ecommerce Production (current) | Gateway
Acme / Ecommerce Staging | Gateway

$ hookdeck project list --output json
$ hookdeck project list --type gateway
```
### hookdeck project use

Expand Down Expand Up @@ -208,6 +219,7 @@ Commands for managing Event Gateway sources, destinations, connections,
transformations, events, requests, metrics, and MCP server.

The gateway command group provides full access to all Event Gateway resources.
**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.

**Usage:**

Expand Down
40 changes: 40 additions & 0 deletions pkg/cmd/gateway.go
Original file line number Diff line number Diff line change
@@ -1,15 +1,52 @@
package cmd

import (
"fmt"

"github.com/spf13/cobra"

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

type gatewayCmd struct {
cmd *cobra.Command
}

// requireGatewayProject ensures the current project is a Gateway project (inbound or console).
// It runs API key validation, resolves project type from config or API, and returns an error if not Gateway.
// cfg is optional; when nil, the global Config is used (for production).
func requireGatewayProject(cfg *config.Config) error {
if cfg == nil {
cfg = &Config
}
if err := cfg.Profile.ValidateAPIKey(); err != nil {
return err
}
if cfg.Profile.ProjectId == "" {
return fmt.Errorf("no project selected. Run 'hookdeck project use' to select a project")
}
projectType := cfg.Profile.ProjectType
if projectType == "" && cfg.Profile.ProjectMode != "" {
projectType = config.ModeToProjectType(cfg.Profile.ProjectMode)
}
if projectType == "" {
// Resolve from API
response, err := cfg.GetAPIClient().ValidateAPIKey()
if err != nil {
return err
}
projectType = config.ModeToProjectType(response.ProjectMode)
cfg.Profile.ProjectType = projectType
cfg.Profile.ProjectMode = response.ProjectMode
_ = cfg.Profile.SaveProfile()
}
if !config.IsGatewayProject(projectType) {
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)
}
return nil
}

func newGatewayCmd() *gatewayCmd {
g := &gatewayCmd{}

Expand All @@ -32,6 +69,9 @@ The gateway command group provides full access to all Event Gateway resources.`,

# Start the MCP server for AI agent access
hookdeck gateway mcp`,
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
return requireGatewayProject(nil)
},
}

// Register resource subcommands (same factory as root backward-compat registration)
Expand Down
79 changes: 79 additions & 0 deletions pkg/cmd/gateway_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package cmd

import (
"strings"
"testing"

"github.com/hookdeck/hookdeck-cli/pkg/config"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestRequireGatewayProject(t *testing.T) {
t.Run("no API key", func(t *testing.T) {
cfg := &config.Config{}
cfg.Profile.ProjectId = "proj_1"
cfg.Profile.ProjectType = config.ProjectTypeGateway
err := requireGatewayProject(cfg)
require.Error(t, err)
assert.Contains(t, err.Error(), "authenticated")
})

t.Run("no project selected", func(t *testing.T) {
cfg := &config.Config{}
cfg.Profile.APIKey = "sk_xxx"
cfg.Profile.ProjectId = ""
err := requireGatewayProject(cfg)
require.Error(t, err)
assert.Contains(t, err.Error(), "no project selected")
})

t.Run("Gateway type passes", func(t *testing.T) {
cfg := &config.Config{}
cfg.Profile.APIKey = "sk_xxx"
cfg.Profile.ProjectId = "proj_1"
cfg.Profile.ProjectType = config.ProjectTypeGateway
err := requireGatewayProject(cfg)
assert.NoError(t, err)
})

t.Run("Console type passes", func(t *testing.T) {
cfg := &config.Config{}
cfg.Profile.APIKey = "sk_xxx"
cfg.Profile.ProjectId = "proj_1"
cfg.Profile.ProjectType = config.ProjectTypeConsole
err := requireGatewayProject(cfg)
assert.NoError(t, err)
})

t.Run("inbound mode passes when type empty", func(t *testing.T) {
cfg := &config.Config{}
cfg.Profile.APIKey = "sk_xxx"
cfg.Profile.ProjectId = "proj_1"
cfg.Profile.ProjectMode = "inbound"
err := requireGatewayProject(cfg)
assert.NoError(t, err)
})

t.Run("Outpost type fails", func(t *testing.T) {
cfg := &config.Config{}
cfg.Profile.APIKey = "sk_xxx"
cfg.Profile.ProjectId = "proj_1"
cfg.Profile.ProjectType = config.ProjectTypeOutpost
err := requireGatewayProject(cfg)
require.Error(t, err)
assert.Contains(t, err.Error(), "requires a Gateway project")
assert.Contains(t, err.Error(), "Outpost")
assert.Contains(t, err.Error(), "hookdeck project use")
})

t.Run("unknown type fails", func(t *testing.T) {
cfg := &config.Config{}
cfg.Profile.APIKey = "sk_xxx"
cfg.Profile.ProjectId = "proj_1"
cfg.Profile.ProjectMode = "outpost"
err := requireGatewayProject(cfg)
require.Error(t, err)
assert.True(t, strings.Contains(err.Error(), "requires a Gateway project") || strings.Contains(err.Error(), "Outpost"))
})
}
109 changes: 68 additions & 41 deletions pkg/cmd/project_list.go
Original file line number Diff line number Diff line change
@@ -1,20 +1,24 @@
package cmd

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/config"
"github.com/hookdeck/hookdeck-cli/pkg/project"
"github.com/hookdeck/hookdeck-cli/pkg/validators"
)

var validProjectTypes = []string{"gateway", "outpost", "console"}

type projectListCmd struct {
cmd *cobra.Command
cmd *cobra.Command
output string
typeFilter string
}

func newProjectListCmd() *projectListCmd {
Expand All @@ -26,11 +30,15 @@ func newProjectListCmd() *projectListCmd {
Short: "List and filter projects by organization and project name substrings",
RunE: lc.runProjectListCmd,
Example: `$ hookdeck project list
[Acme] Ecommerce Production (current)
[Acme] Ecommerce Staging
[Acme] Ecommerce Development`,
Acme / Ecommerce Production (current) | Gateway
Acme / Ecommerce Staging | Gateway
$ hookdeck project list --output json
$ hookdeck project list --type gateway`,
}

lc.cmd.Flags().StringVar(&lc.output, "output", "", "Output format: json")
lc.cmd.Flags().StringVar(&lc.typeFilter, "type", "", "Filter by project type: gateway, outpost, console")

return lc
}

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

if lc.typeFilter != "" {
ok := false
for _, v := range validProjectTypes {
if lc.typeFilter == v {
ok = true
break
}
}
if !ok {
return fmt.Errorf("invalid --type value: %q (must be one of: gateway, outpost, console)", lc.typeFilter)
}
}

projects, err := project.ListProjects(&Config)
if err != nil {
return err
}

var filteredProjects []hookdeck.Project
items := project.NormalizeProjects(projects, Config.Profile.ProjectId)
items = project.FilterByType(items, lc.typeFilter)

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)
}
}
items = project.FilterByOrgProject(items, args[0], "")
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)
}
}
items = project.FilterByOrgProject(items, args[0], args[1])
}

if len(filteredProjects) == 0 {
if len(items) == 0 {
if lc.output == "json" {
fmt.Println("[]")
return nil
}
fmt.Println("No projects found.")
return nil
}

color := ansi.Color(os.Stdout)
if lc.output == "json" {
type jsonItem struct {
Id string `json:"id"`
Org string `json:"org"`
Project string `json:"project"`
Type string `json:"type"`
Current bool `json:"current"`
}
out := make([]jsonItem, len(items))
for i, it := range items {
out[i] = jsonItem{
Id: it.Id,
Org: it.Org,
Project: it.Project,
Type: config.ProjectTypeToJSON(it.Type),
Current: it.Current,
}
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(out)
}

for _, project := range filteredProjects {
if project.Id == Config.Profile.ProjectId {
fmt.Printf("%s (current)\n", color.Green(project.Name))
color := ansi.Color(os.Stdout)
for _, it := range items {
if it.Current {
// highlight (current) in green
namePart := it.Project
if it.Org != "" {
namePart = it.Org + " / " + it.Project
}
fmt.Printf("%s%s | %s\n", namePart, color.Green(" (current)"), it.Type)
} else {
fmt.Printf("%s\n", project.Name)
fmt.Println(it.DisplayLine())
}
}

Expand Down
Loading