diff --git a/REFERENCE.md b/REFERENCE.md index af0f213..046f73f 100644 --- a/REFERENCE.md +++ b/REFERENCE.md @@ -103,6 +103,8 @@ hookdeck whoami ```bash $ hookdeck whoami ``` + +Output includes the current project type (Gateway, Outpost, or Console). ## Projects @@ -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 [] [] +hookdeck project list [] [] [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 @@ -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:** diff --git a/pkg/cmd/gateway.go b/pkg/cmd/gateway.go index cacc0af..d1b1cbe 100644 --- a/pkg/cmd/gateway.go +++ b/pkg/cmd/gateway.go @@ -1,8 +1,11 @@ package cmd import ( + "fmt" + "github.com/spf13/cobra" + "github.com/hookdeck/hookdeck-cli/pkg/config" "github.com/hookdeck/hookdeck-cli/pkg/validators" ) @@ -10,6 +13,40 @@ 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{} @@ -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) diff --git a/pkg/cmd/gateway_test.go b/pkg/cmd/gateway_test.go new file mode 100644 index 0000000..fbc72d3 --- /dev/null +++ b/pkg/cmd/gateway_test.go @@ -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")) + }) +} diff --git a/pkg/cmd/project_list.go b/pkg/cmd/project_list.go index 3390266..dcc56f9 100644 --- a/pkg/cmd/project_list.go +++ b/pkg/cmd/project_list.go @@ -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 { @@ -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 } @@ -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()) } } diff --git a/pkg/cmd/project_use.go b/pkg/cmd/project_use.go index a5be539..7fc79ae 100644 --- a/pkg/cmd/project_use.go +++ b/pkg/cmd/project_use.go @@ -10,7 +10,7 @@ import ( "github.com/hookdeck/hookdeck-cli/pkg/ansi" "github.com/spf13/cobra" - "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" ) @@ -31,14 +31,11 @@ func newProjectUseCmd() *projectUseCmd { Example: `$ hookdeck project use Use the arrow keys to navigate: ↓ ↑ → ← ? Select Project: - ▸ [Acme] Ecommerce Production - [Acme] Ecommerce Staging - [Acme] Ecommerce Development - -Selecting project [Acme] Ecommerce Staging + ▸ Acme / Ecommerce Production (current) | Gateway + Acme / Ecommerce Staging | Gateway $ hookdeck project use --local -Pinning project [Acme] Ecommerce Staging to current directory`, +Pinning project to current directory`, } lc.cmd.Flags().BoolVar(&lc.local, "local", false, "Save project to current directory (.hookdeck/config.toml)") @@ -47,7 +44,6 @@ Pinning project [Acme] Ecommerce Staging to current directory`, } func (lc *projectUseCmd) runProjectUseCmd(cmd *cobra.Command, args []string) error { - // Validate flag compatibility if lc.local && Config.ConfigFileFlag != "" { return fmt.Errorf("Error: --local and --hookdeck-config flags cannot be used together\n --local creates config at: .hookdeck/config.toml\n --hookdeck-config uses custom path: %s", Config.ConfigFileFlag) } @@ -60,201 +56,114 @@ func (lc *projectUseCmd) runProjectUseCmd(cmd *cobra.Command, args []string) err if err != nil { return err } - if len(projects) == 0 { + + items := project.NormalizeProjects(projects, Config.Profile.ProjectId) + if len(items) == 0 { return fmt.Errorf("no projects found. Please create a project first using 'hookdeck project create'") } - var selectedProject hookdeck.Project - projectFound := false - + // Filter by exact org and/or project when args provided switch len(args) { - case 0: // Interactive: select from all projects - var currentProjectName string - projectDisplayNames := make([]string, len(projects)) - for i, p := range projects { - projectDisplayNames[i] = p.Name - if p.Id == Config.Profile.ProjectId { - currentProjectName = p.Name - } + case 1: + items = filterItemsByExactOrg(items, args[0]) + if len(items) == 0 { + return fmt.Errorf("no projects found for organization '%s'", args[0]) } - - prompt := &survey.Select{ - Message: "Select Project", - Options: projectDisplayNames, + case 2: + items = filterItemsByExactOrgProject(items, args[0], args[1]) + if len(items) == 0 { + return fmt.Errorf("project '%s' in organization '%s' not found", args[1], args[0]) } - - if currentProjectName != "" { - prompt.Default = currentProjectName - } - - answers := struct { - SelectedFullName string `survey:"selected_full_name"` - }{} - qs := []*survey.Question{ - { - Name: "selected_full_name", - Prompt: prompt, - Validate: survey.Required, - }, - } - - if err := survey.Ask(qs, &answers); err != nil { - return err + if len(items) > 1 { + return fmt.Errorf("multiple projects named '%s' found in organization '%s'. Projects must have unique names to be used with the `project use ` command", args[1], args[0]) } + } - for _, p := range projects { - if answers.SelectedFullName == p.Name { - selectedProject = p - projectFound = true - break + var selected *project.ProjectListItem + if len(args) == 2 || len(items) == 1 { + selected = &items[0] + } else { + options := make([]string, len(items)) + var defaultOpt string + for i, it := range items { + options[i] = it.DisplayLine() + if it.Current { + defaultOpt = options[i] } } - if !projectFound { // Should not happen if survey selection is from projectDisplayNames - return fmt.Errorf("internal error: selected project '%s' not found in project list", answers.SelectedFullName) - } - case 1: // Organization name provided, select project from this org - argOrgNameInput := args[0] - argOrgNameLower := strings.ToLower(argOrgNameInput) - var orgProjects []hookdeck.Project - var orgProjectDisplayNames []string - - for _, p := range projects { - org, _, errParser := project.ParseProjectName(p.Name) - if errParser != nil { - continue // Skip projects with names that don't match the expected format - } - if strings.ToLower(org) == argOrgNameLower { - orgProjects = append(orgProjects, p) - orgProjectDisplayNames = append(orgProjectDisplayNames, p.Name) - } + message := "Select Project" + if len(args) == 1 { + message = fmt.Sprintf("Select project for organization '%s'", args[0]) } - - if len(orgProjects) == 0 { - return fmt.Errorf("no projects found for organization '%s'", argOrgNameInput) + prompt := &survey.Select{ + Message: message, + Options: options, + Default: defaultOpt, } - - if len(orgProjects) == 1 { - selectedProject = orgProjects[0] - projectFound = true - } else { // More than one project in the org, prompt user - answers := struct { - SelectedFullName string `survey:"selected_full_name"` - }{} - qs := []*survey.Question{ - { - Name: "selected_full_name", - Prompt: &survey.Select{ - Message: fmt.Sprintf("Select project for organization '%s'", argOrgNameInput), - Options: orgProjectDisplayNames, - }, - Validate: survey.Required, - }, - } - if err := survey.Ask(qs, &answers); err != nil { - return err - } - for _, p := range orgProjects { // Search within the filtered orgProjects - if answers.SelectedFullName == p.Name { - selectedProject = p - projectFound = true - break - } - } - if !projectFound { // Should not happen - return fmt.Errorf("internal error: selected project '%s' not found in organization list", answers.SelectedFullName) - } + var selectedOption string + if err := survey.AskOne(prompt, &selectedOption); err != nil { + return err } - case 2: // Organization and Project name provided - argOrgNameInput := args[0] - argProjNameInput := args[1] - argOrgNameLower := strings.ToLower(argOrgNameInput) - argProjNameLower := strings.ToLower(argProjNameInput) - var matchingProjects []hookdeck.Project - - for _, p := range projects { - org, proj, errParser := project.ParseProjectName(p.Name) - if errParser != nil { - continue // Skip projects with names that don't match the expected format - } - if strings.ToLower(org) == argOrgNameLower && strings.ToLower(proj) == argProjNameLower { - matchingProjects = append(matchingProjects, p) + for i := range options { + if options[i] == selectedOption { + selected = &items[i] + break } } - - if len(matchingProjects) > 1 { - return fmt.Errorf("multiple projects named '%s' found in organization '%s'. Projects must have unique names to be used with the `project use ` command", argProjNameInput, argOrgNameInput) + if selected == nil { + return fmt.Errorf("internal error: selected project not found in list") } - - if len(matchingProjects) == 1 { - selectedProject = matchingProjects[0] - projectFound = true - } - - if !projectFound { - return fmt.Errorf("project '%s' in organization '%s' not found", argProjNameInput, argOrgNameInput) - } - } - - if !projectFound { - // This case should ideally be unreachable if all paths correctly set projectFound or error out. - // It acts as a safeguard. - return fmt.Errorf("a project could not be determined based on the provided arguments") } - // Determine which config to update + // Use project by id and mode derived from type + mode := config.ProjectTypeToMode(selected.Type) var configPath string var isNewConfig bool if lc.local { - // User explicitly requested local config - isNewConfig, err = Config.UseProjectLocal(selectedProject.Id, selectedProject.Mode) + isNewConfig, err = Config.UseProjectLocal(selected.Id, mode) if err != nil { return err } - workingDir, wdErr := os.Getwd() if wdErr != nil { return wdErr } configPath = filepath.Join(workingDir, ".hookdeck/config.toml") } else { - // Smart default: check if local config exists workingDir, wdErr := os.Getwd() if wdErr != nil { return wdErr } - localConfigPath := filepath.Join(workingDir, ".hookdeck/config.toml") localConfigExists, _ := Config.FileExists(localConfigPath) if localConfigExists { - // Local config exists, update it - isNewConfig, err = Config.UseProjectLocal(selectedProject.Id, selectedProject.Mode) + isNewConfig, err = Config.UseProjectLocal(selected.Id, mode) if err != nil { return err } configPath = localConfigPath } else { - // No local config, use global (existing behavior) - err = Config.UseProject(selectedProject.Id, selectedProject.Mode) + err = Config.UseProject(selected.Id, mode) if err != nil { return err } - - // Get global config path from Config configPath = Config.GetConfigFile() isNewConfig = false } } + displayName := selected.Project + if selected.Org != "" { + displayName = selected.Org + " / " + selected.Project + } color := ansi.Color(os.Stdout) - fmt.Printf("Successfully set active project to: %s\n", color.Green(selectedProject.Name)) + fmt.Printf("Successfully set active project to: %s\n", color.Green(displayName)) - // Show which config was updated if strings.Contains(configPath, ".hookdeck/config.toml") { if isNewConfig && lc.local { fmt.Printf("Created: %s\n", configPath) - // Show security warning for new local configs fmt.Printf("\n%s\n", color.Yellow("Security:")) fmt.Printf(" Local config files contain credentials and should NOT be committed to source control.\n") fmt.Printf(" Add .hookdeck/ to your .gitignore file.\n") @@ -267,3 +176,26 @@ func (lc *projectUseCmd) runProjectUseCmd(cmd *cobra.Command, args []string) err return nil } + +func filterItemsByExactOrg(items []project.ProjectListItem, org string) []project.ProjectListItem { + orgLower := strings.ToLower(org) + var out []project.ProjectListItem + for _, it := range items { + if strings.ToLower(it.Org) == orgLower { + out = append(out, it) + } + } + return out +} + +func filterItemsByExactOrgProject(items []project.ProjectListItem, org, proj string) []project.ProjectListItem { + orgLower := strings.ToLower(org) + projLower := strings.ToLower(proj) + var out []project.ProjectListItem + for _, it := range items { + if strings.ToLower(it.Org) == orgLower && strings.ToLower(it.Project) == projLower { + out = append(out, it) + } + } + return out +} diff --git a/pkg/cmd/whoami.go b/pkg/cmd/whoami.go index 9027bb2..d76fe98 100644 --- a/pkg/cmd/whoami.go +++ b/pkg/cmd/whoami.go @@ -5,6 +5,7 @@ import ( "os" "github.com/hookdeck/hookdeck-cli/pkg/ansi" + "github.com/hookdeck/hookdeck-cli/pkg/config" "github.com/hookdeck/hookdeck-cli/pkg/validators" "github.com/spf13/cobra" ) @@ -49,5 +50,16 @@ func (lc *whoamiCmd) runWhoamiCmd(cmd *cobra.Command, args []string) error { color.Bold(response.OrganizationName), ) + projectType := Config.Profile.ProjectType + if projectType == "" && Config.Profile.ProjectMode != "" { + projectType = config.ModeToProjectType(Config.Profile.ProjectMode) + } + if projectType == "" && response.ProjectMode != "" { + projectType = config.ModeToProjectType(response.ProjectMode) + } + if projectType != "" { + fmt.Printf("Project type: %s\n", projectType) + } + return nil } diff --git a/pkg/config/config.go b/pkg/config/config.go index 09542e5..6f267de 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -164,6 +164,7 @@ func (c *Config) InitConfig() { func (c *Config) UseProject(projectId string, projectMode string) error { c.Profile.ProjectId = projectId c.Profile.ProjectMode = projectMode + c.Profile.ProjectType = ModeToProjectType(projectMode) return c.Profile.SaveProfile() } @@ -194,6 +195,7 @@ func (c *Config) UseProjectLocal(projectId string, projectMode string) (bool, er // Update in-memory state c.Profile.ProjectId = projectId c.Profile.ProjectMode = projectMode + c.Profile.ProjectType = ModeToProjectType(projectMode) // Write to local config file using shared helper if err := c.writeProjectConfig(localConfigPath, !fileExists); err != nil { @@ -236,6 +238,11 @@ func (c *Config) setProfileFieldsInViper(v *viper.Viper) { v.Set("profile", c.Profile.Name) v.Set(c.Profile.getConfigField("project_id"), c.Profile.ProjectId) v.Set(c.Profile.getConfigField("project_mode"), c.Profile.ProjectMode) + projectType := c.Profile.ProjectType + if projectType == "" && c.Profile.ProjectMode != "" { + projectType = ModeToProjectType(c.Profile.ProjectMode) + } + v.Set(c.Profile.getConfigField("project_type"), projectType) if c.Profile.GuestURL != "" { v.Set(c.Profile.getConfigField("guest_url"), c.Profile.GuestURL) } @@ -331,6 +338,12 @@ func (c *Config) constructConfig() { c.Profile.ProjectMode = stringCoalesce(c.Profile.ProjectMode, c.viper.GetString(c.Profile.getConfigField("project_mode")), c.viper.GetString("project_mode"), c.viper.GetString(c.Profile.getConfigField("workspace_mode")), c.viper.GetString(c.Profile.getConfigField("team_mode")), c.viper.GetString("workspace_mode"), "") + // ProjectType: prefer project_type from config; else derive from project_mode + c.Profile.ProjectType = stringCoalesce(c.Profile.ProjectType, c.viper.GetString(c.Profile.getConfigField("project_type")), c.viper.GetString("project_type"), "") + if c.Profile.ProjectType == "" && c.Profile.ProjectMode != "" { + c.Profile.ProjectType = ModeToProjectType(c.Profile.ProjectMode) + } + c.Profile.GuestURL = stringCoalesce(c.Profile.GuestURL, c.viper.GetString(c.Profile.getConfigField("guest_url")), c.viper.GetString("guest_url"), "") // Telemetry opt-out: check config file for telemetry_disabled = true diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 2cba613..22851d2 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -235,6 +235,45 @@ func TestInitConfig(t *testing.T) { assert.Equal(t, "test_project_id", c.Profile.ProjectId) assert.Equal(t, "test_project_mode", c.Profile.ProjectMode) }) + + t.Run("project_type only", func(t *testing.T) { + t.Parallel() + + c := Config{ + LogLevel: "info", + ConfigFileFlag: "./testdata/project-type-only.toml", + } + c.InitConfig() + + assert.Equal(t, "Gateway", c.Profile.ProjectType) + assert.Equal(t, "", c.Profile.ProjectMode) + }) + + t.Run("project_mode only - derive project_type", func(t *testing.T) { + t.Parallel() + + c := Config{ + LogLevel: "info", + ConfigFileFlag: "./testdata/project-mode-inbound.toml", + } + c.InitConfig() + + assert.Equal(t, "inbound", c.Profile.ProjectMode) + assert.Equal(t, "Gateway", c.Profile.ProjectType) + }) + + t.Run("project_type and project_mode - prefer project_type", func(t *testing.T) { + t.Parallel() + + c := Config{ + LogLevel: "info", + ConfigFileFlag: "./testdata/project-type-and-mode.toml", + } + c.InitConfig() + + assert.Equal(t, "Outpost", c.Profile.ProjectType) + assert.Equal(t, "inbound", c.Profile.ProjectMode) + }) } func TestWriteConfig(t *testing.T) { @@ -267,12 +306,13 @@ func TestWriteConfig(t *testing.T) { c.InitConfig() // Act - err := c.UseProject("new_team_id", "new_team_mode") + err := c.UseProject("new_team_id", "inbound") // Assert assert.NoError(t, err) contentBytes, _ := ioutil.ReadFile(c.viper.ConfigFileUsed()) assert.Contains(t, string(contentBytes), `project_id = 'new_team_id'`) + assert.Contains(t, string(contentBytes), `project_type = 'Gateway'`) }) t.Run("use profile", func(t *testing.T) { diff --git a/pkg/config/profile.go b/pkg/config/profile.go index 77c9142..2608f73 100644 --- a/pkg/config/profile.go +++ b/pkg/config/profile.go @@ -9,6 +9,7 @@ type Profile struct { APIKey string ProjectId string ProjectMode string + ProjectType string // display type: Gateway, Outpost, Console GuestURL string // URL to create permanent account for guest users Config *Config @@ -23,6 +24,11 @@ func (p *Profile) SaveProfile() error { p.Config.viper.Set(p.getConfigField("api_key"), p.APIKey) p.Config.viper.Set(p.getConfigField("project_id"), p.ProjectId) p.Config.viper.Set(p.getConfigField("project_mode"), p.ProjectMode) + projectType := p.ProjectType + if projectType == "" && p.ProjectMode != "" { + projectType = ModeToProjectType(p.ProjectMode) + } + p.Config.viper.Set(p.getConfigField("project_type"), projectType) p.Config.viper.Set(p.getConfigField("guest_url"), p.GuestURL) return p.Config.writeConfig() } diff --git a/pkg/config/project_type.go b/pkg/config/project_type.go new file mode 100644 index 0000000..3004291 --- /dev/null +++ b/pkg/config/project_type.go @@ -0,0 +1,68 @@ +package config + +import "strings" + +// Project type display values (user-facing and config). +const ( + ProjectTypeGateway = "Gateway" + ProjectTypeOutpost = "Outpost" + ProjectTypeConsole = "Console" +) + +// OutboundMode is the API mode for outbound projects; they are excluded from project list. +const OutboundMode = "outbound" + +// ModeToProjectType maps API mode to display project type. +// Returns empty string for outbound so callers can exclude those projects from the list. +func ModeToProjectType(mode string) string { + switch strings.ToLower(mode) { + case "inbound": + return ProjectTypeGateway + case "console": + return ProjectTypeConsole + case "outpost": + return ProjectTypeOutpost + case OutboundMode: + return "" // excluded from list + default: + return "" + } +} + +// ProjectTypeToMode maps display type to API mode (for backward compat when only type is set). +func ProjectTypeToMode(projectType string) string { + switch projectType { + case ProjectTypeGateway: + return "inbound" + case ProjectTypeConsole: + return "console" + case ProjectTypeOutpost: + return "outpost" + default: + return "" + } +} + +// IsGatewayProject returns true if the given type or mode represents a Gateway project (inbound or console). +func IsGatewayProject(typeOrMode string) bool { + switch typeOrMode { + case ProjectTypeGateway, ProjectTypeConsole, "inbound", "console": + return true + default: + return false + } +} + +// ProjectTypeToJSON returns the lowercase type for JSON output (gateway, outpost, console). +func ProjectTypeToJSON(projectType string) string { + switch projectType { + case ProjectTypeGateway: + return "gateway" + case ProjectTypeOutpost: + return "outpost" + case ProjectTypeConsole: + return "console" + default: + return strings.ToLower(projectType) + } +} diff --git a/pkg/config/project_type_test.go b/pkg/config/project_type_test.go new file mode 100644 index 0000000..83a469c --- /dev/null +++ b/pkg/config/project_type_test.go @@ -0,0 +1,83 @@ +package config + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestModeToProjectType(t *testing.T) { + tests := []struct { + mode string + expected string + }{ + {"inbound", ProjectTypeGateway}, + {"INBOUND", ProjectTypeGateway}, + {"console", ProjectTypeConsole}, + {"Console", ProjectTypeConsole}, + {"outpost", ProjectTypeOutpost}, + {"outbound", ""}, + {"Outbound", ""}, + {"unknown", ""}, + {"", ""}, + } + for _, tt := range tests { + t.Run(tt.mode, func(t *testing.T) { + got := ModeToProjectType(tt.mode) + assert.Equal(t, tt.expected, got) + }) + } +} + +func TestProjectTypeToMode(t *testing.T) { + tests := []struct { + projectType string + expected string + }{ + {ProjectTypeGateway, "inbound"}, + {ProjectTypeConsole, "console"}, + {ProjectTypeOutpost, "outpost"}, + {"", ""}, + {"Unknown", ""}, + } + for _, tt := range tests { + t.Run(tt.projectType, func(t *testing.T) { + got := ProjectTypeToMode(tt.projectType) + assert.Equal(t, tt.expected, got) + }) + } +} + +func TestIsGatewayProject(t *testing.T) { + // Gateway = inbound or console (type or mode) + trueCases := []string{ProjectTypeGateway, "inbound", "console", ProjectTypeConsole} + for _, v := range trueCases { + t.Run("true_"+v, func(t *testing.T) { + assert.True(t, IsGatewayProject(v)) + }) + } + falseCases := []string{"outbound", ProjectTypeOutpost, ""} + for _, v := range falseCases { + t.Run("false_"+v, func(t *testing.T) { + assert.False(t, IsGatewayProject(v)) + }) + } +} + +func TestProjectTypeToJSON(t *testing.T) { + tests := []struct { + projectType string + expected string + }{ + {ProjectTypeGateway, "gateway"}, + {ProjectTypeOutpost, "outpost"}, + {ProjectTypeConsole, "console"}, + {"", ""}, + } + for _, tt := range tests { + t.Run(tt.projectType, func(t *testing.T) { + got := ProjectTypeToJSON(tt.projectType) + assert.Equal(t, tt.expected, got) + }) + } +} diff --git a/pkg/config/testdata/project-mode-inbound.toml b/pkg/config/testdata/project-mode-inbound.toml new file mode 100644 index 0000000..87eb1ae --- /dev/null +++ b/pkg/config/testdata/project-mode-inbound.toml @@ -0,0 +1,6 @@ +profile = "default" + +[default] + api_key = "test_api_key" + project_id = "test_project_id" + project_mode = "inbound" diff --git a/pkg/config/testdata/project-type-and-mode.toml b/pkg/config/testdata/project-type-and-mode.toml new file mode 100644 index 0000000..6696ff1 --- /dev/null +++ b/pkg/config/testdata/project-type-and-mode.toml @@ -0,0 +1,7 @@ +profile = "default" + +[default] + api_key = "test_api_key" + project_id = "test_project_id" + project_type = "Outpost" + project_mode = "inbound" diff --git a/pkg/config/testdata/project-type-only.toml b/pkg/config/testdata/project-type-only.toml new file mode 100644 index 0000000..a39728e --- /dev/null +++ b/pkg/config/testdata/project-type-only.toml @@ -0,0 +1,6 @@ +profile = "default" + +[default] + api_key = "test_api_key" + project_id = "test_project_id" + project_type = "Gateway" diff --git a/pkg/gateway/mcp/tool_help.go b/pkg/gateway/mcp/tool_help.go index 989f323..b27f5e9 100644 --- a/pkg/gateway/mcp/tool_help.go +++ b/pkg/gateway/mcp/tool_help.go @@ -56,7 +56,7 @@ var toolHelp = map[string]string{ "hookdeck_projects": `hookdeck_projects — List or switch the active project Actions: - list — List all projects. Returns id, name, mode, and which is current. + list — List all projects. Returns id, org, project, type (gateway/outpost/console), and which is current. Outbound projects are excluded. use — Switch the active project for this session (in-memory only). Parameters: diff --git a/pkg/gateway/mcp/tool_login.go b/pkg/gateway/mcp/tool_login.go index bec6d81..0650ffc 100644 --- a/pkg/gateway/mcp/tool_login.go +++ b/pkg/gateway/mcp/tool_login.go @@ -109,6 +109,7 @@ func handleLogin(client *hookdeck.Client, cfg *config.Config, mcpServer *mcpsdk. cfg.Profile.APIKey = response.APIKey cfg.Profile.ProjectId = response.ProjectID cfg.Profile.ProjectMode = response.ProjectMode + cfg.Profile.ProjectType = config.ModeToProjectType(response.ProjectMode) cfg.Profile.GuestURL = "" if err := cfg.Profile.SaveProfile(); err != nil { diff --git a/pkg/gateway/mcp/tool_projects.go b/pkg/gateway/mcp/tool_projects.go index d6cbf66..1f1be0f 100644 --- a/pkg/gateway/mcp/tool_projects.go +++ b/pkg/gateway/mcp/tool_projects.go @@ -6,7 +6,9 @@ import ( mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/hookdeck/hookdeck-cli/pkg/config" "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" + "github.com/hookdeck/hookdeck-cli/pkg/project" ) func handleProjects(client *hookdeck.Client) mcpsdk.ToolHandler { @@ -34,8 +36,9 @@ func handleProjects(client *hookdeck.Client) mcpsdk.ToolHandler { type projectEntry struct { ID string `json:"id"` - Name string `json:"name"` - Mode string `json:"mode"` + Org string `json:"org"` + Project string `json:"project"` + Type string `json:"type"` // lowercase: gateway, outpost, console Current bool `json:"current"` } @@ -45,13 +48,16 @@ func projectsList(client *hookdeck.Client) (*mcpsdk.CallToolResult, error) { return ErrorResult(TranslateAPIError(err)), nil } - entries := make([]projectEntry, len(projects)) - for i, p := range projects { + items := project.NormalizeProjects(projects, client.ProjectID) + + entries := make([]projectEntry, len(items)) + for i, it := range items { entries[i] = projectEntry{ - ID: p.Id, - Name: p.Name, - Mode: p.Mode, - Current: p.Id == client.ProjectID, + ID: it.Id, + Org: it.Org, + Project: it.Project, + Type: config.ProjectTypeToJSON(it.Type), + Current: it.Current, } } return JSONResult(entries) @@ -63,28 +69,33 @@ func projectsUse(client *hookdeck.Client, in input) (*mcpsdk.CallToolResult, err return ErrorResult("project_id is required for the use action"), nil } - // Validate project exists projects, err := client.ListProjects() if err != nil { return ErrorResult(TranslateAPIError(err)), nil } - var name string - for _, p := range projects { - if p.Id == id { - name = p.Name + items := project.NormalizeProjects(projects, client.ProjectID) + var found *project.ProjectListItem + for i := range items { + if items[i].Id == id { + found = &items[i] break } } - if name == "" { + if found == nil { return ErrorResult(fmt.Sprintf("project %q not found", id)), nil } client.ProjectID = id + displayName := found.Project + if found.Org != "" { + displayName = found.Org + " / " + found.Project + } return JSONResult(map[string]string{ "project_id": id, - "project_name": name, + "project_name": displayName, + "type": config.ProjectTypeToJSON(found.Type), "status": "ok", }) } diff --git a/pkg/login/client_login.go b/pkg/login/client_login.go index 4254bb1..d79b5fe 100644 --- a/pkg/login/client_login.go +++ b/pkg/login/client_login.go @@ -11,7 +11,7 @@ import ( "github.com/briandowns/spinner" "github.com/hookdeck/hookdeck-cli/pkg/ansi" - "github.com/hookdeck/hookdeck-cli/pkg/config" + configpkg "github.com/hookdeck/hookdeck-cli/pkg/config" "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" "github.com/hookdeck/hookdeck-cli/pkg/open" "github.com/hookdeck/hookdeck-cli/pkg/validators" @@ -21,7 +21,7 @@ var openBrowser = open.Browser var canOpenBrowser = open.CanOpenBrowser // Login function is used to obtain credentials via hookdeck dashboard. -func Login(config *config.Config, input io.Reader) error { +func Login(config *configpkg.Config, input io.Reader) error { var s *spinner.Spinner if config.Profile.APIKey != "" { @@ -95,6 +95,7 @@ func Login(config *config.Config, input io.Reader) error { config.Profile.APIKey = response.APIKey config.Profile.ProjectId = response.ProjectID config.Profile.ProjectMode = response.ProjectMode + config.Profile.ProjectType = configpkg.ModeToProjectType(response.ProjectMode) config.Profile.GuestURL = "" // Clear guest URL when logging in with permanent account if err = config.Profile.SaveProfile(); err != nil { @@ -110,7 +111,7 @@ func Login(config *config.Config, input io.Reader) error { return nil } -func GuestLogin(config *config.Config) (string, error) { +func GuestLogin(config *configpkg.Config) (string, error) { parsedBaseURL, err := url.Parse(config.APIBaseURL) if err != nil { return "", err @@ -140,6 +141,7 @@ func GuestLogin(config *config.Config) (string, error) { config.Profile.APIKey = response.APIKey config.Profile.ProjectId = response.ProjectID config.Profile.ProjectMode = response.ProjectMode + config.Profile.ProjectType = configpkg.ModeToProjectType(response.ProjectMode) config.Profile.GuestURL = session.GuestURL if err = config.Profile.SaveProfile(); err != nil { @@ -152,7 +154,7 @@ func GuestLogin(config *config.Config) (string, error) { return session.GuestURL, nil } -func CILogin(config *config.Config, apiKey string, name string) error { +func CILogin(config *configpkg.Config, apiKey string, name string) error { parsedBaseURL, err := url.Parse(config.APIBaseURL) if err != nil { return err @@ -182,6 +184,7 @@ func CILogin(config *config.Config, apiKey string, name string) error { config.Profile.APIKey = response.APIKey config.Profile.ProjectId = response.ProjectID config.Profile.ProjectMode = response.ProjectMode + config.Profile.ProjectType = configpkg.ModeToProjectType(response.ProjectMode) if err = config.Profile.SaveProfile(); err != nil { return err diff --git a/pkg/login/interactive_login.go b/pkg/login/interactive_login.go index 919ecde..1cd49de 100644 --- a/pkg/login/interactive_login.go +++ b/pkg/login/interactive_login.go @@ -14,13 +14,13 @@ import ( "golang.org/x/term" "github.com/hookdeck/hookdeck-cli/pkg/ansi" - "github.com/hookdeck/hookdeck-cli/pkg/config" + configpkg "github.com/hookdeck/hookdeck-cli/pkg/config" "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" "github.com/hookdeck/hookdeck-cli/pkg/validators" ) // InteractiveLogin lets the user set configuration on the command line -func InteractiveLogin(config *config.Config) error { +func InteractiveLogin(config *configpkg.Config) error { apiKey, err := getConfigureAPIKey(os.Stdin) if err != nil { return err @@ -58,6 +58,7 @@ func InteractiveLogin(config *config.Config) error { config.Profile.APIKey = response.APIKey config.Profile.ProjectMode = response.ProjectMode config.Profile.ProjectId = response.ProjectID + config.Profile.ProjectType = configpkg.ModeToProjectType(response.ProjectMode) config.Profile.GuestURL = "" // Clear guest URL when logging in with permanent account if err = config.Profile.SaveProfile(); err != nil { diff --git a/pkg/project/normalize.go b/pkg/project/normalize.go new file mode 100644 index 0000000..205b8fa --- /dev/null +++ b/pkg/project/normalize.go @@ -0,0 +1,88 @@ +package project + +import ( + "strings" + + "github.com/hookdeck/hookdeck-cli/pkg/config" + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" +) + +// ProjectListItem is a normalized project entry for list output, JSON, and project use selector. +type ProjectListItem struct { + Id string + Org string + Project string + Type string // display type: Gateway, Outpost, Console + Current bool +} + +// NormalizeProjects converts API projects into a normalized list: parses name once, excludes outbound, +// sets type from mode. currentID is the profile's current project id for the Current flag. +func NormalizeProjects(projects []hookdeck.Project, currentID string) []ProjectListItem { + var out []ProjectListItem + for _, p := range projects { + projectType := config.ModeToProjectType(p.Mode) + if projectType == "" { + // outbound or unknown: exclude from list + continue + } + org, proj, err := ParseProjectName(p.Name) + if err != nil { + // fallback: use full name as project, empty org + org = "" + proj = p.Name + } + out = append(out, ProjectListItem{ + Id: p.Id, + Org: org, + Project: proj, + Type: projectType, + Current: p.Id == currentID, + }) + } + return out +} + +// FilterByType returns items whose Type (display) matches the given type filter (lowercase: gateway, outpost, console). +func FilterByType(items []ProjectListItem, typeFilter string) []ProjectListItem { + if typeFilter == "" { + return items + } + var out []ProjectListItem + for _, it := range items { + if config.ProjectTypeToJSON(it.Type) == typeFilter { + out = append(out, it) + } + } + return out +} + +// DisplayLine returns the human-readable line for an item: "Org / Project (current?) | Type". +func (it *ProjectListItem) DisplayLine() string { + namePart := it.Project + if it.Org != "" { + namePart = it.Org + " / " + it.Project + } + if it.Current { + namePart += " (current)" + } + return namePart + " | " + it.Type +} + +// FilterByOrgProject filters items by org and/or project name substrings (case-insensitive). +// If orgSubstr is non-empty, item must match. If projectSubstr is non-empty, item must match. +func FilterByOrgProject(items []ProjectListItem, orgSubstr, projectSubstr string) []ProjectListItem { + orgLower := strings.ToLower(orgSubstr) + projLower := strings.ToLower(projectSubstr) + var out []ProjectListItem + for _, it := range items { + if orgSubstr != "" && !strings.Contains(strings.ToLower(it.Org), orgLower) { + continue + } + if projectSubstr != "" && !strings.Contains(strings.ToLower(it.Project), projLower) { + continue + } + out = append(out, it) + } + return out +} diff --git a/pkg/project/normalize_test.go b/pkg/project/normalize_test.go new file mode 100644 index 0000000..60a4079 --- /dev/null +++ b/pkg/project/normalize_test.go @@ -0,0 +1,100 @@ +package project + +import ( + "testing" + + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNormalizeProjects(t *testing.T) { + projects := []hookdeck.Project{ + {Id: "p1", Name: "[Acme] Prod", Mode: "inbound"}, + {Id: "p2", Name: "[Acme] Staging", Mode: "console"}, + {Id: "p3", Name: "[Org2] Outpost", Mode: "outpost"}, + {Id: "p4", Name: "[Org] Outbound", Mode: "outbound"}, + {Id: "p5", Name: "No brackets", Mode: "inbound"}, + } + items := NormalizeProjects(projects, "p2") + // outbound excluded, so 4 items (p4 excluded) + require.Len(t, items, 4) + + // p1: Gateway, Acme, Prod + assert.Equal(t, "p1", items[0].Id) + assert.Equal(t, "Acme", items[0].Org) + assert.Equal(t, "Prod", items[0].Project) + assert.Equal(t, "Gateway", items[0].Type) + assert.False(t, items[0].Current) + + // p2: current, console mode -> Console type + assert.True(t, items[1].Current) + assert.Equal(t, "Console", items[1].Type) + + // p3: Outpost + assert.Equal(t, "Outpost", items[2].Type) + + // p5: unparseable name -> org "", project "No brackets" + assert.Equal(t, "", items[3].Org) + assert.Equal(t, "No brackets", items[3].Project) +} + +func TestNormalizeProjects_EmptyList(t *testing.T) { + items := NormalizeProjects(nil, "") + assert.Empty(t, items) +} + +func TestNormalizeProjects_AllOutbound(t *testing.T) { + projects := []hookdeck.Project{ + {Id: "p1", Name: "[A] P", Mode: "outbound"}, + } + items := NormalizeProjects(projects, "p1") + assert.Empty(t, items) +} + +func TestFilterByType(t *testing.T) { + items := []ProjectListItem{ + {Type: "Gateway"}, + {Type: "Outpost"}, + {Type: "Gateway"}, + {Type: "Console"}, + } + got := FilterByType(items, "gateway") + require.Len(t, got, 2) + assert.Equal(t, "Gateway", got[0].Type) + assert.Equal(t, "Gateway", got[1].Type) + + got = FilterByType(items, "") + require.Len(t, got, 4) + + got = FilterByType(items, "console") + require.Len(t, got, 1) + assert.Equal(t, "Console", got[0].Type) +} + +func TestFilterByOrgProject(t *testing.T) { + items := []ProjectListItem{ + {Org: "Acme", Project: "Prod"}, + {Org: "Acme", Project: "Staging"}, + {Org: "Other", Project: "Prod"}, + } + got := FilterByOrgProject(items, "acme", "") + require.Len(t, got, 2) + got = FilterByOrgProject(items, "", "prod") + require.Len(t, got, 2) + got = FilterByOrgProject(items, "acme", "stag") + require.Len(t, got, 1) + assert.Equal(t, "Staging", got[0].Project) +} + +func TestProjectListItem_DisplayLine(t *testing.T) { + it := ProjectListItem{Org: "Acme", Project: "Prod", Type: "Gateway", Current: false} + assert.Equal(t, "Acme / Prod | Gateway", it.DisplayLine()) + + it.Current = true + assert.Equal(t, "Acme / Prod (current) | Gateway", it.DisplayLine()) + + it.Org = "" + it.Project = "Solo" + assert.Equal(t, "Solo (current) | Gateway", it.DisplayLine()) +} diff --git a/test/acceptance/README.md b/test/acceptance/README.md index 78b9a21..95280d1 100644 --- a/test/acceptance/README.md +++ b/test/acceptance/README.md @@ -27,6 +27,8 @@ For local testing, create a `.env` file in this directory: ```bash # test/acceptance/.env HOOKDECK_CLI_TESTING_API_KEY=your_api_key_here +# Optional: CLI key (from interactive login) required for project list tests only +# HOOKDECK_CLI_TESTING_CLI_KEY=your_cli_key_here ``` The `.env` file is automatically loaded when tests run. **This file is git-ignored and should never be committed.** @@ -64,6 +66,8 @@ ACCEPTANCE_SLICE=2 go test -tags="attempt metrics issue transformation" ./test/a ``` For slice 1 set `HOOKDECK_CLI_TESTING_API_KEY_2`; for slice 2 set `HOOKDECK_CLI_TESTING_API_KEY_3` (or set `HOOKDECK_CLI_TESTING_API_KEY` to that key). +**Project list tests** (`TestProjectListShowsType`, `TestProjectListJSONOutput`) require a **CLI key**, not an API or CI key: only keys created via interactive login can list or switch projects. Set `HOOKDECK_CLI_TESTING_CLI_KEY` in your `.env` (or environment) to run these tests; if unset, they are skipped with a clear message. + ### Run in parallel locally (three keys) From the **repository root**, run the script that runs all three slices in parallel (same as CI): ```bash diff --git a/test/acceptance/basic_test.go b/test/acceptance/basic_test.go index d8d9514..5fdf421 100644 --- a/test/acceptance/basic_test.go +++ b/test/acceptance/basic_test.go @@ -55,6 +55,16 @@ func TestCLIBasics(t *testing.T) { t.Logf("Whoami output: %s", strings.TrimSpace(stdout)) }) + t.Run("WhoamiShowsProjectType", func(t *testing.T) { + cli := NewCLIRunner(t) + stdout := cli.RunExpectSuccess("whoami") + // Output should include project type (Gateway, Outpost, or Console) + assert.Contains(t, stdout, "Project type:", "whoami should show project type line") + assert.True(t, + strings.Contains(stdout, "Gateway") || strings.Contains(stdout, "Outpost") || strings.Contains(stdout, "Console"), + "whoami should show one of Gateway, Outpost, Console") + }) + t.Run("WhoamiAfterAuth", func(t *testing.T) { cli := NewCLIRunner(t) diff --git a/test/acceptance/helpers.go b/test/acceptance/helpers.go index cdd06fc..dd0afd1 100644 --- a/test/acceptance/helpers.go +++ b/test/acceptance/helpers.go @@ -102,6 +102,29 @@ func getAcceptanceAPIKey(t *testing.T) string { return os.Getenv("HOOKDECK_CLI_TESTING_API_KEY") } +// NewCLIRunnerWithKey creates a new CLI runner authenticated with the given CLI key via +// hookdeck login --api-key. Used only for project list/use tests (HOOKDECK_CLI_TESTING_CLI_KEY); +// API and CI keys cannot list or switch projects, so those tests require a CLI key and login auth. +func NewCLIRunnerWithKey(t *testing.T, apiKey string) *CLIRunner { + t.Helper() + require.NotEmpty(t, apiKey, "api key must be non-empty for NewCLIRunnerWithKey") + + projectRoot, err := filepath.Abs("../..") + require.NoError(t, err, "Failed to get project root path") + + runner := &CLIRunner{ + t: t, + apiKey: apiKey, + projectRoot: projectRoot, + configPath: getAcceptanceConfigPath(), + } + + stdout, stderr, err := runner.Run("login", "--api-key", apiKey) + require.NoError(t, err, "Failed to authenticate CLI (login --api-key): stdout=%s, stderr=%s", stdout, stderr) + + return runner +} + // getAcceptanceConfigPath returns a per-slice config path when ACCEPTANCE_SLICE is set, // so parallel runs do not overwrite the same config file. Empty when not in sliced mode. func getAcceptanceConfigPath() string { diff --git a/test/acceptance/project_use_test.go b/test/acceptance/project_use_test.go index 9eadee1..ecd1a5e 100644 --- a/test/acceptance/project_use_test.go +++ b/test/acceptance/project_use_test.go @@ -3,8 +3,10 @@ package acceptance import ( + "encoding/json" "os" "path/filepath" + "strings" "testing" "github.com/BurntSushi/toml" @@ -150,3 +152,181 @@ api_key = "test_key_456" t.Log("Successfully verified local config helper functions work correctly") } + +// TestProjectListShowsType asserts that project list output includes project type (Gateway, Outpost, or Console). +// Requires HOOKDECK_CLI_TESTING_CLI_KEY (only CLI keys can list projects; API/CI keys cannot). +func TestProjectListShowsType(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + cliKey := os.Getenv("HOOKDECK_CLI_TESTING_CLI_KEY") + if cliKey == "" { + t.Skip("Skipping project list test: HOOKDECK_CLI_TESTING_CLI_KEY must be set (CLI key required for listing projects; API and CI keys cannot list or switch projects)") + } + cli := NewCLIRunnerWithKey(t, cliKey) + stdout := cli.RunExpectSuccess("project", "list") + // Default output format: "Org / Project (current?) | Type" + assert.Contains(t, stdout, "|", "project list should show type separator") + assert.True(t, + strings.Contains(stdout, "Gateway") || strings.Contains(stdout, "Outpost") || strings.Contains(stdout, "Console"), + "project list should show at least one project type (Gateway, Outpost, or Console)") +} + +// TestProjectListJSONOutput asserts that project list --output json returns valid JSON with id, org, project, type, current. +// Requires HOOKDECK_CLI_TESTING_CLI_KEY (only CLI keys can list projects; API/CI keys cannot). +func TestProjectListJSONOutput(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + cliKey := os.Getenv("HOOKDECK_CLI_TESTING_CLI_KEY") + if cliKey == "" { + t.Skip("Skipping project list test: HOOKDECK_CLI_TESTING_CLI_KEY must be set (CLI key required for listing projects; API and CI keys cannot list or switch projects)") + } + cli := NewCLIRunnerWithKey(t, cliKey) + stdout := cli.RunExpectSuccess("project", "list", "--output", "json") + var list []struct { + Id string `json:"id"` + Org string `json:"org"` + Project string `json:"project"` + Type string `json:"type"` + Current bool `json:"current"` + } + err := json.Unmarshal([]byte(stdout), &list) + require.NoError(t, err, "project list --output json should return valid JSON array") + for i, item := range list { + assert.NotEmpty(t, item.Id, "item %d should have id", i) + assert.NotEmpty(t, item.Type, "item %d should have type", i) + assert.True(t, item.Type == "gateway" || item.Type == "outpost" || item.Type == "console", + "item %d type should be gateway, outpost, or console", i) + } +} + +// TestProjectListInvalidType asserts that project list --type returns an error. +// Does not require CLI key (validation runs before listing). +func TestProjectListInvalidType(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + cli := NewCLIRunner(t) + stdout, stderr, err := cli.Run("project", "list", "--type", "invalid") + require.Error(t, err) + combined := stdout + stderr + assert.Contains(t, combined, "invalid", "error should mention invalid type") + assert.Contains(t, combined, "gateway", "error should list valid types") +} + +// TestProjectListFilterByType asserts that project list --type returns only projects of that type. +// Requires HOOKDECK_CLI_TESTING_CLI_KEY. +func TestProjectListFilterByType(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + cliKey := os.Getenv("HOOKDECK_CLI_TESTING_CLI_KEY") + if cliKey == "" { + t.Skip("Skipping project list test: HOOKDECK_CLI_TESTING_CLI_KEY must be set (CLI key required for listing projects; API and CI keys cannot list or switch projects)") + } + cli := NewCLIRunnerWithKey(t, cliKey) + stdout := cli.RunExpectSuccess("project", "list", "--type", "gateway", "--output", "json") + var list []struct { + Id string `json:"id"` + Org string `json:"org"` + Project string `json:"project"` + Type string `json:"type"` + Current bool `json:"current"` + } + err := json.Unmarshal([]byte(stdout), &list) + require.NoError(t, err, "project list --type gateway --output json should return valid JSON array") + for i, item := range list { + assert.Equal(t, "gateway", item.Type, "item %d should have type gateway when filtering by --type gateway", i) + } +} + +// TestProjectListFilterByOrgProject asserts that project list with org/project substrings filters results. +// Requires HOOKDECK_CLI_TESTING_CLI_KEY. Runs list with a substring and checks output shape and that items match. +func TestProjectListFilterByOrgProject(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + cliKey := os.Getenv("HOOKDECK_CLI_TESTING_CLI_KEY") + if cliKey == "" { + t.Skip("Skipping project list test: HOOKDECK_CLI_TESTING_CLI_KEY must be set (CLI key required for listing projects; API and CI keys cannot list or switch projects)") + } + cli := NewCLIRunnerWithKey(t, cliKey) + // Get full list first to derive a substring that matches at least one project + full := cli.RunExpectSuccess("project", "list", "--output", "json") + var fullList []struct { + Org string `json:"org"` + Project string `json:"project"` + } + require.NoError(t, json.Unmarshal([]byte(full), &fullList), "full list should be valid JSON") + if len(fullList) == 0 { + t.Skip("No projects to filter; skipping filter test") + } + // Use first character of first org as substring so we get a non-empty filtered result + firstOrg := strings.TrimSpace(fullList[0].Org) + if firstOrg == "" { + t.Skip("First project has no org; skipping filter test") + } + substring := string([]rune(firstOrg)[0]) + stdout := cli.RunExpectSuccess("project", "list", substring, "--output", "json") + var filtered []struct { + Id string `json:"id"` + Org string `json:"org"` + Project string `json:"project"` + Type string `json:"type"` + Current bool `json:"current"` + } + err := json.Unmarshal([]byte(stdout), &filtered) + require.NoError(t, err, "project list with org substring should return valid JSON array") + for i, item := range filtered { + assert.Contains(t, strings.ToLower(item.Org), strings.ToLower(substring), + "item %d org should contain substring %q", i, substring) + assert.NotEmpty(t, item.Type, "item %d should have type", i) + } +} + +// TestProjectListFilterByOrgAndProject asserts that project list with two args (org and project substrings) filters correctly. +// Requires HOOKDECK_CLI_TESTING_CLI_KEY. +func TestProjectListFilterByOrgAndProject(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + cliKey := os.Getenv("HOOKDECK_CLI_TESTING_CLI_KEY") + if cliKey == "" { + t.Skip("Skipping project list test: HOOKDECK_CLI_TESTING_CLI_KEY must be set (CLI key required for listing projects; API and CI keys cannot list or switch projects)") + } + cli := NewCLIRunnerWithKey(t, cliKey) + full := cli.RunExpectSuccess("project", "list", "--output", "json") + var fullList []struct { + Org string `json:"org"` + Project string `json:"project"` + } + require.NoError(t, json.Unmarshal([]byte(full), &fullList), "full list should be valid JSON") + if len(fullList) == 0 { + t.Skip("No projects to filter; skipping filter test") + } + first := fullList[0] + orgSub := strings.TrimSpace(first.Org) + projSub := strings.TrimSpace(first.Project) + if orgSub == "" || projSub == "" { + t.Skip("First project missing org or project name; skipping filter test") + } + // Use first character of each so filter matches at least one project + orgChar := string([]rune(orgSub)[0]) + projChar := string([]rune(projSub)[0]) + stdout := cli.RunExpectSuccess("project", "list", orgChar, projChar, "--output", "json") + var filtered []struct { + Org string `json:"org"` + Project string `json:"project"` + Type string `json:"type"` + } + err := json.Unmarshal([]byte(stdout), &filtered) + require.NoError(t, err, "project list with org and project substrings should return valid JSON array") + for i, item := range filtered { + assert.Contains(t, strings.ToLower(item.Org), strings.ToLower(orgChar), + "item %d org should contain org substring", i) + assert.Contains(t, strings.ToLower(item.Project), strings.ToLower(projChar), + "item %d project should contain project substring", i) + assert.NotEmpty(t, item.Type, "item %d should have type", i) + } +}