diff --git a/README.md b/README.md index 19fd2d0a..9320a371 100644 --- a/README.md +++ b/README.md @@ -1212,7 +1212,7 @@ The Hookdeck CLI configuration file is stored in TOML format and typically inclu ```toml api_key = "api_key_xxxxxxxxxxxxxxxxxxxx" project_id = "tm_xxxxxxxxxxxxxxx" -project_product = "event_gateway" | "outpost" | "console" +project_type = "event_gateway" | "outpost" | "console" ``` ### Local Configuration @@ -1243,12 +1243,12 @@ profile = "dev" [dev] api_key = "api_key_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" project_id = "tm_5JxTelcYxOJy" - project_product = "event_gateway" + project_type = "event_gateway" [prod] api_key = "api_key_yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy" project_id = "tm_U9Zod13qtsHp" - project_product = "event_gateway" + project_type = "event_gateway" ``` This allows you to run commands against different projects. For example, to listen to the `webhooks` source in the `dev` profile, run: diff --git a/REFERENCE.md b/REFERENCE.md index 086322f3..e5841841 100644 --- a/REFERENCE.md +++ b/REFERENCE.md @@ -1936,6 +1936,8 @@ Query Event Gateway metrics (events, requests, attempts, queue depth, pending ev **Common flags (all metrics subcommands):** `--start`, `--end` (required), `--granularity` (e.g. 1h, 5m, 1d), `--measures`, `--dimensions`, `--source-id`, `--destination-id`, `--connection-id`, `--status`, `--output` (json). +`--delivery-group` filters by delivery group on `metrics events` and `metrics attempts` only. The requests and transformations endpoints do not accept it, so the flag is not offered there, and it cannot be combined with `--measures pending` or per-issue metrics. + ## Utilities diff --git a/pkg/cmd/gateway.go b/pkg/cmd/gateway.go index 580a7fa8..ca4684b7 100644 --- a/pkg/cmd/gateway.go +++ b/pkg/cmd/gateway.go @@ -46,13 +46,7 @@ func requireGatewayProject(cfg *config.Config) error { 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.ProjectProduct != "" { - projectType = config.ProductToProjectType(cfg.Profile.ProjectProduct) - } - if projectType == "" && cfg.Profile.ProjectMode != "" { - projectType = config.ModeToProjectType(cfg.Profile.ProjectMode) - } + projectType := cfg.Profile.ResolveProjectType() if projectType == "" { // Resolve team/project/mode/type from API (authoritative for the key). Do not clear // guest_url here — gateway PreRun may run for users who still have a guest upgrade link. @@ -65,7 +59,13 @@ func requireGatewayProject(cfg *config.Config) error { _ = 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) + // Show the label, not the wire value: "Outpost" is what the user saw in + // the project picker and in `project list`. + shown := config.TypeLabel(projectType) + if shown == "" { + shown = 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", shown) } return nil } diff --git a/pkg/cmd/gateway_test.go b/pkg/cmd/gateway_test.go index 8eddfef0..dfd85af0 100644 --- a/pkg/cmd/gateway_test.go +++ b/pkg/cmd/gateway_test.go @@ -19,7 +19,7 @@ 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 + cfg.Profile.ProjectType = config.ProjectTypeEventGateway err := requireGatewayProject(cfg) require.Error(t, err) assert.Contains(t, err.Error(), "authenticated") @@ -38,7 +38,7 @@ func TestRequireGatewayProject(t *testing.T) { cfg := &config.Config{} cfg.Profile.APIKey = "sk_xxx" cfg.Profile.ProjectId = "proj_1" - cfg.Profile.ProjectType = config.ProjectTypeGateway + cfg.Profile.ProjectType = config.ProjectTypeEventGateway err := requireGatewayProject(cfg) assert.NoError(t, err) }) @@ -105,7 +105,7 @@ func TestRequireGatewayProject_resolveFromValidate(t *testing.T) { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(hookdeck.ValidateAPIKeyResponse{ ProjectID: "team_from_validate", - ProjectProduct: "event_gateway", + ProjectType: "event_gateway", }) })) t.Cleanup(server.Close) @@ -128,7 +128,7 @@ guest_url = "https://guest.example/keep-me" err = requireGatewayProject(cfg) require.NoError(t, err) require.Equal(t, "team_from_validate", cfg.Profile.ProjectId) - require.Equal(t, config.ProjectTypeGateway, cfg.Profile.ProjectType) + require.Equal(t, config.ProjectTypeEventGateway, cfg.Profile.ProjectType) require.Equal(t, "inbound", cfg.Profile.ProjectMode) require.Equal(t, "https://guest.example/keep-me", cfg.Profile.GuestURL, "gateway validate path must not clear guest_url") } diff --git a/pkg/cmd/listen_cli_key_test.go b/pkg/cmd/listen_cli_key_test.go index 51696505..ea7396c8 100644 --- a/pkg/cmd/listen_cli_key_test.go +++ b/pkg/cmd/listen_cli_key_test.go @@ -26,7 +26,7 @@ func validateStub(t *testing.T, projectID, projectName string) *httptest.Server w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(hookdeck.ValidateAPIKeyResponse{ ProjectID: projectID, - ProjectProduct: "console", + ProjectType: "console", ProjectName: projectName, }) })) diff --git a/pkg/cmd/login.go b/pkg/cmd/login.go index dc7a2080..e866a015 100644 --- a/pkg/cmd/login.go +++ b/pkg/cmd/login.go @@ -91,11 +91,13 @@ func (lc *loginCmd) runLoginCmd(cmd *cobra.Command, args []string) error { // saveLocalConfig writes the current profile credentials to .hookdeck/config.toml // and prints a security warning if the file is newly created. func saveLocalConfig() error { - projectProduct := Config.Profile.ProjectProduct - if projectProduct == "" { - projectProduct = Config.Profile.ProjectMode + // Fall back to the raw mode when the type is unrecognized, so a value this + // CLI does not understand is carried through rather than dropped. + projectType := Config.Profile.ResolveProjectType() + if projectType == "" { + projectType = Config.Profile.ProjectMode } - isNewConfig, err := Config.UseProjectLocal(Config.Profile.ProjectId, projectProduct) + isNewConfig, err := Config.UseProjectLocal(Config.Profile.ProjectId, projectType) if err != nil { return err } diff --git a/pkg/cmd/metrics.go b/pkg/cmd/metrics.go index 93ca5bab..c0ad5bfa 100644 --- a/pkg/cmd/metrics.go +++ b/pkg/cmd/metrics.go @@ -68,14 +68,25 @@ type metricsCommonFlags struct { output string } +// metricsFlagOpts omits flags the target endpoint would reject. A flag the API +// refuses is worse than a missing one: the filter is silently accepted by cobra +// and comes back as an opaque 422 from the server. +type metricsFlagOpts struct { + // skipIssueID omits --issue-id for subcommands that take the id as an + // argument instead (e.g. events-by-issue ). + skipIssueID bool + // skipDeliveryGroup omits --delivery-group. Only the events, attempts and + // queue-depth filter schemas accept delivery_group; requests and + // transformations do not, and their filters are additionalProperties:false. + skipDeliveryGroup bool +} + // addMetricsCommonFlags adds common metrics flags to cmd and binds them to f. -// For subcommands that take a required resource id as an argument (e.g. events-by-issue ), -// pass skipIssueID true so --issue-id is not added as a flag. func addMetricsCommonFlags(cmd *cobra.Command, f *metricsCommonFlags) { - addMetricsCommonFlagsEx(cmd, f, false) + addMetricsCommonFlagsEx(cmd, f, metricsFlagOpts{}) } -func addMetricsCommonFlagsEx(cmd *cobra.Command, f *metricsCommonFlags, skipIssueID bool) { +func addMetricsCommonFlagsEx(cmd *cobra.Command, f *metricsCommonFlags, opts metricsFlagOpts) { cmd.Flags().StringVar(&f.start, "start", "", "Start of time range (ISO 8601 date-time, required)") cmd.Flags().StringVar(&f.end, "end", "", "End of time range (ISO 8601 date-time, required)") cmd.Flags().StringVar(&f.granularity, "granularity", "", granularityHelp) @@ -83,10 +94,12 @@ func addMetricsCommonFlagsEx(cmd *cobra.Command, f *metricsCommonFlags, skipIssu cmd.Flags().StringVar(&f.dimensions, "dimensions", "", "Comma-separated dimensions to group by (e.g. connection_id, source_id, destination_id, delivery_group, status)") cmd.Flags().StringVar(&f.sourceID, "source-id", "", "Filter by source ID") cmd.Flags().StringVar(&f.destinationID, "destination-id", "", "Filter by destination ID") - cmd.Flags().StringVar(&f.deliveryGroup, "delivery-group", "", "Filter by delivery group") + if !opts.skipDeliveryGroup { + cmd.Flags().StringVar(&f.deliveryGroup, "delivery-group", "", "Filter by delivery group") + } cmd.Flags().StringVar(&f.connectionID, "connection-id", "", "Filter by connection ID") cmd.Flags().StringVar(&f.status, "status", "", "Filter by status (e.g. SUCCESSFUL, FAILED)") - if !skipIssueID { + if !opts.skipIssueID { cmd.Flags().StringVar(&f.issueID, "issue-id", "", "Filter by issue ID (required for per-issue metrics, e.g. when using --dimensions issue_id)") } cmd.Flags().StringVar(&f.output, "output", "", "Output format (json)") diff --git a/pkg/cmd/metrics_delivery_group_test.go b/pkg/cmd/metrics_delivery_group_test.go new file mode 100644 index 00000000..52e02741 --- /dev/null +++ b/pkg/cmd/metrics_delivery_group_test.go @@ -0,0 +1,74 @@ +package cmd + +import ( + "context" + "testing" + + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestDeliveryGroupFlagOnlyWhereTheAPIAcceptsIt covers the filter schemas the API +// actually declares: delivery_group exists on events, attempts and queue-depth, +// and not on requests or transformations. Those filters are additionalProperties: +// false, so offering the flag where it is not accepted turns a typo-level mistake +// into an opaque 422 from the server. +func TestDeliveryGroupFlagOnlyWhereTheAPIAcceptsIt(t *testing.T) { + tests := []struct { + name string + cmd *cobra.Command + expected bool + }{ + {"events", newMetricsEventsCmd().cmd, true}, + {"attempts", newMetricsAttemptsCmd().cmd, true}, + {"requests", newMetricsRequestsCmd().cmd, false}, + {"transformations", newMetricsTransformationsCmd().cmd, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + flag := tt.cmd.Flags().Lookup("delivery-group") + if tt.expected { + assert.NotNil(t, flag, "%s accepts delivery_group and should offer the flag", tt.name) + } else { + assert.Nil(t, flag, "%s rejects unknown filters; the flag must not be offered", tt.name) + } + }) + } +} + +// TestEventMetricsRejectDeliveryGroupOnUnsupportedRoutes covers the two routes +// `metrics events` can take where the target endpoint has no delivery_group in +// its filter schema. The client must say so rather than let the API answer 422. +func TestEventMetricsRejectDeliveryGroupOnUnsupportedRoutes(t *testing.T) { + t.Run("pending timeseries", func(t *testing.T) { + _, err := queryEventMetricsConsolidated(context.Background(), nil, hookdeck.MetricsQueryParams{ + Measures: []string{"pending"}, + Granularity: "1h", + DeliveryGroup: "dg_1", + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "--delivery-group") + }) + + t.Run("per-issue", func(t *testing.T) { + _, err := queryEventMetricsConsolidated(context.Background(), nil, hookdeck.MetricsQueryParams{ + Dimensions: []string{"issue_id"}, + IssueID: "iss_1", + DeliveryGroup: "dg_1", + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "--delivery-group") + }) + + t.Run("per-issue still reports the missing issue id first", func(t *testing.T) { + _, err := queryEventMetricsConsolidated(context.Background(), nil, hookdeck.MetricsQueryParams{ + Dimensions: []string{"issue_id"}, + DeliveryGroup: "dg_1", + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "--issue-id") + }) +} diff --git a/pkg/cmd/metrics_events.go b/pkg/cmd/metrics_events.go index df0430a1..5c29de63 100644 --- a/pkg/cmd/metrics_events.go +++ b/pkg/cmd/metrics_events.go @@ -75,6 +75,9 @@ func queryEventMetricsConsolidated(ctx context.Context, client *hookdeck.Client, // 2. If measures include "pending" with granularity → QueryEventsPendingTimeseries // API expects measures[]=count; "pending" is only used for routing. if hasMeasure(params, map[string]bool{"pending": true}) && params.Granularity != "" { + if params.DeliveryGroup != "" { + return nil, errors.New("--delivery-group cannot be used with --measures pending; the pending timeseries endpoint filters on destination only") + } pendingParams := params pendingParams.Measures = []string{"count"} return client.QueryEventsPendingTimeseries(ctx, pendingParams) @@ -85,6 +88,9 @@ func queryEventMetricsConsolidated(ctx context.Context, client *hookdeck.Client, if params.IssueID == "" { return nil, errors.New("per-issue metrics require --issue-id (required when using --dimensions issue_id)") } + if params.DeliveryGroup != "" { + return nil, errors.New("--delivery-group cannot be used with per-issue metrics; the events-by-issue endpoint does not filter on delivery group") + } return client.QueryEventsByIssue(ctx, params) } // 4. Default → QueryEventMetrics diff --git a/pkg/cmd/metrics_requests.go b/pkg/cmd/metrics_requests.go index 084dbf11..5d515924 100644 --- a/pkg/cmd/metrics_requests.go +++ b/pkg/cmd/metrics_requests.go @@ -10,7 +10,7 @@ import ( const metricsRequestsMeasures = "count, accepted_count, rejected_count, discarded_count, avg_events_per_request, avg_ignored_per_request" type metricsRequestsCmd struct { - cmd *cobra.Command + cmd *cobra.Command flags metricsCommonFlags } @@ -23,7 +23,8 @@ func newMetricsRequestsCmd() *metricsRequestsCmd { Long: LongBeta(`Query metrics for requests (acceptance, rejection, etc.). Measures: ` + metricsRequestsMeasures + `.`), RunE: c.runE, } - addMetricsCommonFlags(c.cmd, &c.flags) + // The requests filter schema has no delivery_group, and rejects unknown filters. + addMetricsCommonFlagsEx(c.cmd, &c.flags, metricsFlagOpts{skipDeliveryGroup: true}) return c } diff --git a/pkg/cmd/metrics_transformations.go b/pkg/cmd/metrics_transformations.go index a47b6e8d..3a7a6b5b 100644 --- a/pkg/cmd/metrics_transformations.go +++ b/pkg/cmd/metrics_transformations.go @@ -10,7 +10,7 @@ import ( const metricsTransformationsMeasures = "count, successful_count, failed_count, error_rate, error_count, warn_count, info_count, debug_count" type metricsTransformationsCmd struct { - cmd *cobra.Command + cmd *cobra.Command flags metricsCommonFlags } @@ -23,7 +23,8 @@ func newMetricsTransformationsCmd() *metricsTransformationsCmd { Long: LongBeta(`Query metrics for transformations. Measures: ` + metricsTransformationsMeasures + `.`), RunE: c.runE, } - addMetricsCommonFlags(c.cmd, &c.flags) + // The transformations filter schema has no delivery_group, and rejects unknown filters. + addMetricsCommonFlagsEx(c.cmd, &c.flags, metricsFlagOpts{skipDeliveryGroup: true}) return c } diff --git a/pkg/cmd/project_list.go b/pkg/cmd/project_list.go index db620ee2..2621e8fd 100644 --- a/pkg/cmd/project_list.go +++ b/pkg/cmd/project_list.go @@ -16,19 +16,19 @@ import ( var validProjectTypes = []string{"gateway", "outpost", "console"} type projectListCmd struct { - cmd *cobra.Command - output string - typeFilter string + cmd *cobra.Command + output string + typeFilter string } func newProjectListCmd() *projectListCmd { lc := &projectListCmd{} lc.cmd = &cobra.Command{ - Use: "list [] []", - Args: validators.MaximumNArgs(2), - Short: "List and filter projects by organization and project name substrings", - RunE: lc.runProjectListCmd, + Use: "list [] []", + Args: validators.MaximumNArgs(2), + Short: "List and filter projects by organization and project name substrings", + RunE: lc.runProjectListCmd, Example: `$ hookdeck project list Acme / Ecommerce Production (current) | Gateway Acme / Ecommerce Staging | Gateway @@ -119,7 +119,7 @@ func (lc *projectListCmd) runProjectListCmd(cmd *cobra.Command, args []string) e if it.Org != "" { namePart = it.Org + " / " + it.Project } - fmt.Printf("%s%s | %s\n", namePart, color.Green(" (current)"), it.Type) + fmt.Printf("%s%s | %s\n", namePart, color.Green(" (current)"), config.TypeLabel(it.Type)) } else { fmt.Println(it.DisplayLine()) } diff --git a/pkg/cmd/project_use.go b/pkg/cmd/project_use.go index 01249b78..b65212da 100644 --- a/pkg/cmd/project_use.go +++ b/pkg/cmd/project_use.go @@ -10,7 +10,6 @@ import ( "github.com/hookdeck/hookdeck-cli/pkg/ansi" "github.com/spf13/cobra" - "github.com/hookdeck/hookdeck-cli/pkg/config" "github.com/hookdeck/hookdeck-cli/pkg/project" "github.com/hookdeck/hookdeck-cli/pkg/validators" ) @@ -24,10 +23,10 @@ func newProjectUseCmd() *projectUseCmd { lc := &projectUseCmd{} lc.cmd = &cobra.Command{ - Use: "use [ []]", - Args: validators.MaximumNArgs(2), - Short: "Set the active project for future commands", - RunE: lc.runProjectUseCmd, + Use: "use [ []]", + Args: validators.MaximumNArgs(2), + Short: "Set the active project for future commands", + RunE: lc.runProjectUseCmd, Example: `$ hookdeck project use Use the arrow keys to navigate: ↓ ↑ → ← ? Select Project: @@ -119,13 +118,13 @@ func (lc *projectUseCmd) runProjectUseCmd(cmd *cobra.Command, args []string) err } } - // Use project by id and public API product derived from the display type. - product := config.ProjectTypeToProduct(selected.Type) + // selected.Type is already the API project type. + projectType := selected.Type var configPath string var isNewConfig bool if lc.local { - isNewConfig, err = Config.UseProjectLocal(selected.Id, product) + isNewConfig, err = Config.UseProjectLocal(selected.Id, projectType) if err != nil { return err } @@ -143,13 +142,13 @@ func (lc *projectUseCmd) runProjectUseCmd(cmd *cobra.Command, args []string) err localConfigExists, _ := Config.FileExists(localConfigPath) if localConfigExists { - isNewConfig, err = Config.UseProjectLocal(selected.Id, product) + isNewConfig, err = Config.UseProjectLocal(selected.Id, projectType) if err != nil { return err } configPath = localConfigPath } else { - err = Config.UseProject(selected.Id, product) + err = Config.UseProject(selected.Id, projectType) if err != nil { return err } diff --git a/pkg/cmd/whoami.go b/pkg/cmd/whoami.go index 2545f69c..bde5ae16 100644 --- a/pkg/cmd/whoami.go +++ b/pkg/cmd/whoami.go @@ -44,7 +44,7 @@ func (lc *whoamiCmd) runWhoamiCmd(cmd *cobra.Command, args []string) error { return err } - projectName, orgName, projectProduct, note := resolveActiveProject(response, Config.Profile.ProjectId, func() ([]hookdeck.Project, error) { + projectName, orgName, apiProjectType, note := resolveActiveProject(response, Config.Profile.ProjectId, func() ([]hookdeck.Project, error) { return Config.GetAPIClient().ListProjects() }) @@ -68,42 +68,37 @@ func (lc *whoamiCmd) runWhoamiCmd(cmd *cobra.Command, args []string) error { fmt.Printf("%s\n", note) } - projectType := Config.Profile.ProjectType - if projectType == "" && Config.Profile.ProjectProduct != "" { - projectType = config.ProductToProjectType(Config.Profile.ProjectProduct) + projectType := Config.Profile.ResolveProjectType() + if projectType == "" { + projectType = config.NormalizeProjectType(apiProjectType) } - if projectType == "" && Config.Profile.ProjectMode != "" { - projectType = config.ModeToProjectType(Config.Profile.ProjectMode) - } - if projectType == "" && projectProduct != "" { - projectType = config.ProductToProjectType(projectProduct) - } - if projectType != "" { - fmt.Printf("Project type: %s\n", projectType) + if label := config.TypeLabel(projectType); label != "" { + fmt.Printf("Project type: %s\n", label) } return nil } // resolveActiveProject returns the project name, organization name, and project -// product to display. /cli-auth/validate resolves the project from the API key's +// type to display. /cli-auth/validate resolves the project from the API key's // bound team and ignores the profile's active project_id, so when the two // differ the active project is looked up via listProjects. A non-empty note is // returned when the active project could not be resolved and the key-bound // values are shown instead. -func resolveActiveProject(response *hookdeck.ValidateAPIKeyResponse, activeProjectID string, listProjects func() ([]hookdeck.Project, error)) (projectName, orgName, projectProduct, note string) { +func resolveActiveProject(response *hookdeck.ValidateAPIKeyResponse, activeProjectID string, listProjects func() ([]hookdeck.Project, error)) (projectName, orgName, apiProjectType, note string) { projectName = response.ProjectName orgName = response.OrganizationName - projectProduct = response.ProjectProduct + // Newest field first: team_type, then the short-lived team_product, then team_mode. + apiProjectType = firstKnownProjectType(response.ProjectType, response.ProjectProduct, response.ProjectMode) if activeProjectID == "" || activeProjectID == response.ProjectID { - return projectName, orgName, projectProduct, "" + return projectName, orgName, apiProjectType, "" } projects, err := listProjects() if err != nil { note = fmt.Sprintf("Warning: could not look up the active project (%s); showing the project associated with your API key.", activeProjectID) - return projectName, orgName, projectProduct, note + return projectName, orgName, apiProjectType, note } for _, p := range projects { @@ -115,9 +110,22 @@ func resolveActiveProject(response *hookdeck.ValidateAPIKeyResponse, activeProje org = "" proj = p.Name } - return proj, org, p.Product, "" + return proj, org, p.Type, "" } note = fmt.Sprintf("Warning: the active project (%s) was not found; showing the project associated with your API key. Run 'hookdeck project use' to select a project.", activeProjectID) - return projectName, orgName, projectProduct, note + return projectName, orgName, apiProjectType, note +} + +// firstKnownProjectType returns the first value that resolves to a known API +// project type. The auth endpoints renamed this field twice, so a response can +// carry any one of team_type, team_product or team_mode depending on how far the +// API has been rolled out. +func firstKnownProjectType(values ...string) string { + for _, v := range values { + if t := config.NormalizeProjectType(v); t != "" { + return t + } + } + return "" } diff --git a/pkg/cmd/whoami_test.go b/pkg/cmd/whoami_test.go index 8880aa25..900dd5fa 100644 --- a/pkg/cmd/whoami_test.go +++ b/pkg/cmd/whoami_test.go @@ -11,14 +11,14 @@ func TestResolveActiveProject(t *testing.T) { validateResponse := &hookdeck.ValidateAPIKeyResponse{ ProjectID: "tm_bound", ProjectName: "Bound Project", - ProjectProduct: "event_gateway", + ProjectType: "event_gateway", OrganizationName: "Org A", } projects := []hookdeck.Project{ - {Id: "tm_bound", Name: "[Org A] Bound Project", Product: "event_gateway"}, - {Id: "tm_active", Name: "[Org B] Active Project", Product: "event_gateway"}, - {Id: "tm_unparsable", Name: "No Org Format", Product: "event_gateway"}, + {Id: "tm_bound", Name: "[Org A] Bound Project", Type: "event_gateway"}, + {Id: "tm_active", Name: "[Org B] Active Project", Type: "event_gateway"}, + {Id: "tm_unparsable", Name: "No Org Format", Type: "event_gateway"}, } t.Run("no active project id uses validate response", func(t *testing.T) { diff --git a/pkg/config/clear_active_profile_credentials_test.go b/pkg/config/clear_active_profile_credentials_test.go index 0741cf13..99fe9a9e 100644 --- a/pkg/config/clear_active_profile_credentials_test.go +++ b/pkg/config/clear_active_profile_credentials_test.go @@ -11,11 +11,11 @@ func TestClearActiveProfileCredentials_MemoryOnly(t *testing.T) { c := &Config{} c.Profile.APIKey = "sk_test_123456789012" c.Profile.ProjectId = "proj_1" - c.Profile.ProjectProduct = ProjectProductEventGateway + c.Profile.ProjectType = ProjectTypeEventGateway c.Profile.ProjectMode = "inbound" - c.Profile.ProjectType = ProjectTypeGateway + c.Profile.ProjectType = ProjectTypeEventGateway require.NoError(t, c.ClearActiveProfileCredentials()) assert.Empty(t, c.Profile.APIKey) assert.Empty(t, c.Profile.ProjectId) - assert.Empty(t, c.Profile.ProjectProduct) + assert.Empty(t, c.Profile.ProjectType) } diff --git a/pkg/config/config.go b/pkg/config/config.go index 9c405b55..57b0e49e 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -181,16 +181,16 @@ func (c *Config) InitConfig() { log.SetFormatter(logFormatter) } -// UseProject selects the active project. projectProduct is the public API -// product; legacy mode values are still accepted for config compatibility. -func (c *Config) UseProject(projectId string, projectProduct string) error { - c.setProjectIdentity(projectId, projectProduct) +// UseProject selects the active project. projectType is the API project type; +// display labels and legacy mode values are still accepted for compatibility. +func (c *Config) UseProject(projectId string, projectType string) error { + c.setProjectIdentity(projectId, projectType) return c.Profile.SaveProfile() } // UseProjectLocal selects the active project to be used in local config // Returns true if a new file was created, false if existing file was updated -func (c *Config) UseProjectLocal(projectId string, projectProduct string) (bool, error) { +func (c *Config) UseProjectLocal(projectId string, projectType string) (bool, error) { // Get current working directory workingDir, err := os.Getwd() if err != nil { @@ -213,7 +213,7 @@ func (c *Config) UseProjectLocal(projectId string, projectProduct string) (bool, } // Update in-memory state - c.setProjectIdentity(projectId, projectProduct) + c.setProjectIdentity(projectId, projectType) // Write to local config file using shared helper if err := c.writeProjectConfig(localConfigPath, !fileExists); err != nil { @@ -223,16 +223,17 @@ func (c *Config) UseProjectLocal(projectId string, projectProduct string) (bool, return !fileExists, nil } -func (c *Config) setProjectIdentity(projectID, productOrLegacyMode string) { +func (c *Config) setProjectIdentity(projectID, typeOrLegacyMode string) { c.Profile.ProjectId = projectID - c.Profile.ProjectProduct = productOrLegacyMode - c.Profile.ProjectType = ProductToProjectType(productOrLegacyMode) - c.Profile.ProjectMode = ProductToLegacyMode(productOrLegacyMode) - if c.Profile.ProjectType == "" { - c.Profile.ProjectProduct = ModeToProduct(productOrLegacyMode) - c.Profile.ProjectMode = productOrLegacyMode - c.Profile.ProjectType = ModeToProjectType(productOrLegacyMode) + projectType := NormalizeProjectType(typeOrLegacyMode) + c.Profile.ProjectType = projectType + if projectType != "" { + c.Profile.ProjectMode = TypeToLegacyMode(projectType) + return } + // Unknown value: keep it as the legacy mode rather than discarding it, so a + // future CLI that understands it can still read the config. + c.Profile.ProjectMode = typeOrLegacyMode } // writeProjectConfig writes the current profile's project configuration to the specified config file @@ -272,16 +273,8 @@ 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_product"), c.Profile.ProjectProduct) v.Set(c.Profile.getConfigField("project_mode"), c.Profile.ProjectMode) - projectType := c.Profile.ProjectType - if projectType == "" && c.Profile.ProjectProduct != "" { - projectType = ProductToProjectType(c.Profile.ProjectProduct) - } - if projectType == "" && c.Profile.ProjectMode != "" { - projectType = ModeToProjectType(c.Profile.ProjectMode) - } - v.Set(c.Profile.getConfigField("project_type"), projectType) + v.Set(c.Profile.getConfigField("project_type"), c.Profile.ResolveProjectType()) if c.Profile.GuestURL != "" { v.Set(c.Profile.getConfigField("guest_url"), c.Profile.GuestURL) } @@ -395,21 +388,13 @@ func (c *Config) constructConfig() { c.Profile.ProjectId = stringCoalesce(c.Profile.ProjectId, c.viper.GetString(c.Profile.getConfigField("project_id")), c.viper.GetString("project_id"), c.viper.GetString(c.Profile.getConfigField("workspace_id")), c.viper.GetString(c.Profile.getConfigField("team_id")), c.viper.GetString("workspace_id"), "") - c.Profile.ProjectProduct = stringCoalesce(c.Profile.ProjectProduct, c.viper.GetString(c.Profile.getConfigField("project_product")), c.viper.GetString("project_product"), "") - 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, then derive from the public product, then legacy mode. + // ProjectType: prefer project_type, then derive from the legacy mode. + // Configs written before this release stored a display label here, so the + // value is normalized rather than trusted. 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.ProjectProduct != "" { - c.Profile.ProjectType = ProductToProjectType(c.Profile.ProjectProduct) - } - if c.Profile.ProjectType == "" && c.Profile.ProjectMode != "" { - c.Profile.ProjectType = ModeToProjectType(c.Profile.ProjectMode) - } - if c.Profile.ProjectProduct == "" && c.Profile.ProjectMode != "" { - c.Profile.ProjectProduct = ModeToProduct(c.Profile.ProjectMode) - } + c.Profile.ProjectType = c.Profile.ResolveProjectType() c.Profile.GuestURL = stringCoalesce(c.Profile.GuestURL, c.viper.GetString(c.Profile.getConfigField("guest_url")), c.viper.GetString("guest_url"), "") @@ -456,7 +441,6 @@ func (c *Config) ClearActiveProfileCredentials() error { func zeroProfileCredentialFields(p *Profile) { p.APIKey = "" p.ProjectId = "" - p.ProjectProduct = "" p.ProjectMode = "" p.ProjectType = "" p.GuestURL = "" diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 22851d2b..228f14cc 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -245,7 +245,7 @@ func TestInitConfig(t *testing.T) { } c.InitConfig() - assert.Equal(t, "Gateway", c.Profile.ProjectType) + assert.Equal(t, "event_gateway", c.Profile.ProjectType) assert.Equal(t, "", c.Profile.ProjectMode) }) @@ -259,7 +259,12 @@ func TestInitConfig(t *testing.T) { c.InitConfig() assert.Equal(t, "inbound", c.Profile.ProjectMode) - assert.Equal(t, "Gateway", c.Profile.ProjectType) + assert.Equal(t, "event_gateway", c.Profile.ProjectType) + // The upgrade path: a config written before 2026-09-01 has no + // project_type, so it has to be derived from the legacy mode. + // Without this, an upgraded user has an empty product until they + // log in again, and IsGatewayProject("") fails every gateway command. + assert.Equal(t, "event_gateway", c.Profile.ProjectType) }) t.Run("project_type and project_mode - prefer project_type", func(t *testing.T) { @@ -271,7 +276,7 @@ func TestInitConfig(t *testing.T) { } c.InitConfig() - assert.Equal(t, "Outpost", c.Profile.ProjectType) + assert.Equal(t, "outpost", c.Profile.ProjectType) assert.Equal(t, "inbound", c.Profile.ProjectMode) }) } @@ -312,7 +317,26 @@ func TestWriteConfig(t *testing.T) { 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'`) + assert.Contains(t, string(contentBytes), `project_type = 'event_gateway'`) + // A legacy mode in, the API project type written back out. + assert.Contains(t, string(contentBytes), `project_type = 'event_gateway'`) + assert.Contains(t, string(contentBytes), `project_mode = 'inbound'`) + }) + + t.Run("use project with a product", func(t *testing.T) { + t.Parallel() + + c := Config{LogLevel: "info"} + c.ConfigFileFlag = setupTempConfig(t, "./testdata/default-profile.toml") + c.InitConfig() + + err := c.UseProject("new_team_id", "outpost") + + assert.NoError(t, err) + contentBytes, _ := ioutil.ReadFile(c.viper.ConfigFileUsed()) + assert.Contains(t, string(contentBytes), `project_type = 'outpost'`) + assert.Contains(t, string(contentBytes), `project_type = 'outpost'`) + assert.Contains(t, string(contentBytes), `project_mode = 'outpost'`) }) t.Run("use profile", func(t *testing.T) { diff --git a/pkg/config/load_config_file_test.go b/pkg/config/load_config_file_test.go index 25a6f184..4f5e6e46 100644 --- a/pkg/config/load_config_file_test.go +++ b/pkg/config/load_config_file_test.go @@ -27,5 +27,5 @@ project_mode = "inbound" require.Equal(t, "sk_test_123456789012", c.Profile.APIKey) require.Equal(t, "proj_a", c.Profile.ProjectId) require.Equal(t, "inbound", c.Profile.ProjectMode) - require.Equal(t, ProjectTypeGateway, c.Profile.ProjectType) + require.Equal(t, ProjectTypeEventGateway, c.Profile.ProjectType) } diff --git a/pkg/config/profile.go b/pkg/config/profile.go index 517f5ddb..d0609495 100644 --- a/pkg/config/profile.go +++ b/pkg/config/profile.go @@ -8,7 +8,6 @@ type Profile struct { Name string // profile name APIKey string ProjectId string - ProjectProduct string ProjectMode string ProjectType string // display type: Gateway, Outpost, Console GuestURL string // URL to create permanent account for guest users @@ -21,19 +20,22 @@ func (p *Profile) getConfigField(field string) string { return p.Name + "." + field } +// ResolveProjectType returns the API project type for this profile: the stored +// type if there is one, otherwise derived from the legacy mode. Values written +// by older CLIs held a display label, so everything goes through +// NormalizeProjectType rather than being trusted as-is. +func (p *Profile) ResolveProjectType() string { + if t := NormalizeProjectType(p.ProjectType); t != "" { + return t + } + return ModeToType(p.ProjectMode) +} + 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_product"), p.ProjectProduct) p.Config.viper.Set(p.getConfigField("project_mode"), p.ProjectMode) - projectType := p.ProjectType - if projectType == "" && p.ProjectProduct != "" { - projectType = ProductToProjectType(p.ProjectProduct) - } - if projectType == "" && p.ProjectMode != "" { - projectType = ModeToProjectType(p.ProjectMode) - } - p.Config.viper.Set(p.getConfigField("project_type"), projectType) + p.Config.viper.Set(p.getConfigField("project_type"), p.ResolveProjectType()) p.Config.viper.Set(p.getConfigField("guest_url"), p.GuestURL) if err := p.removeLegacyConfigKeys(); err != nil { diff --git a/pkg/config/profile_credentials.go b/pkg/config/profile_credentials.go index 8f8914fd..94cb3120 100644 --- a/pkg/config/profile_credentials.go +++ b/pkg/config/profile_credentials.go @@ -2,6 +2,21 @@ package config import "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" +// resolveType returns the project type to store. It prefers the current +// team_type field, then team_product (served briefly before the rename), then +// the pre-2026-09-01 team_mode. Without the fallbacks a response missing the +// newest field leaves the profile blank: an empty type makes IsGatewayProject +// false and fails every gateway command. +func resolveType(projectType, legacyProduct, legacyMode string) string { + if t := NormalizeProjectType(projectType); t != "" { + return t + } + if t := NormalizeProjectType(legacyProduct); t != "" { + return t + } + return ModeToType(legacyMode) +} + // ApplyValidateAPIKeyResponse updates project fields from GET /cli-auth/validate. // When clearGuestURL is true, GuestURL is cleared (e.g. hookdeck login re-verify). // When false, GuestURL is left unchanged (e.g. gateway PreRun resolving type only). @@ -10,9 +25,9 @@ func (p *Profile) ApplyValidateAPIKeyResponse(resp *hookdeck.ValidateAPIKeyRespo return } p.ProjectId = resp.ProjectID - p.ProjectProduct = resp.ProjectProduct - p.ProjectMode = ProductToLegacyMode(resp.ProjectProduct) - p.ProjectType = ProductToProjectType(resp.ProjectProduct) + projectType := resolveType(resp.ProjectType, resp.ProjectProduct, resp.ProjectMode) + p.ProjectType = projectType + p.ProjectMode = TypeToLegacyMode(projectType) if clearGuestURL { p.GuestURL = "" } @@ -26,9 +41,9 @@ func (p *Profile) ApplyPollAPIKeyResponse(resp *hookdeck.PollAPIKeyResponse, gue } p.APIKey = resp.APIKey p.ProjectId = resp.ProjectID - p.ProjectProduct = resp.ProjectProduct - p.ProjectMode = ProductToLegacyMode(resp.ProjectProduct) - p.ProjectType = ProductToProjectType(resp.ProjectProduct) + projectType := resolveType(resp.ProjectType, resp.ProjectProduct, resp.ProjectMode) + p.ProjectType = projectType + p.ProjectMode = TypeToLegacyMode(projectType) p.GuestURL = guestURL } @@ -36,8 +51,8 @@ func (p *Profile) ApplyPollAPIKeyResponse(resp *hookdeck.PollAPIKeyResponse, gue func (p *Profile) ApplyCIClient(ci hookdeck.CIClient) { p.APIKey = ci.APIKey p.ProjectId = ci.ProjectID - p.ProjectProduct = ci.ProjectProduct - p.ProjectMode = ProductToLegacyMode(ci.ProjectProduct) - p.ProjectType = ProductToProjectType(ci.ProjectProduct) + projectType := resolveType(ci.ProjectType, ci.ProjectProduct, ci.ProjectMode) + p.ProjectType = projectType + p.ProjectMode = TypeToLegacyMode(projectType) p.GuestURL = "" } diff --git a/pkg/config/profile_credentials_test.go b/pkg/config/profile_credentials_test.go index 6e6a9a48..ef3315bd 100644 --- a/pkg/config/profile_credentials_test.go +++ b/pkg/config/profile_credentials_test.go @@ -22,12 +22,12 @@ func TestProfile_ApplyValidateAPIKeyResponse(t *testing.T) { p := &Profile{GuestURL: "https://guest"} p.ApplyValidateAPIKeyResponse(&hookdeck.ValidateAPIKeyResponse{ ProjectID: "team_1", - ProjectProduct: "event_gateway", + ProjectType: "event_gateway", }, true) require.Equal(t, "team_1", p.ProjectId) - require.Equal(t, "event_gateway", p.ProjectProduct) + require.Equal(t, "event_gateway", p.ProjectType) require.Equal(t, "inbound", p.ProjectMode) - require.Equal(t, ProjectTypeGateway, p.ProjectType) + require.Equal(t, ProjectTypeEventGateway, p.ProjectType) require.Empty(t, p.GuestURL) }) @@ -35,7 +35,7 @@ func TestProfile_ApplyValidateAPIKeyResponse(t *testing.T) { p := &Profile{GuestURL: "https://guest.example/x"} p.ApplyValidateAPIKeyResponse(&hookdeck.ValidateAPIKeyResponse{ ProjectID: "team_2", - ProjectProduct: "console", + ProjectType: "console", }, false) require.Equal(t, "team_2", p.ProjectId) require.Equal(t, ProjectTypeConsole, p.ProjectType) @@ -43,6 +43,62 @@ func TestProfile_ApplyValidateAPIKeyResponse(t *testing.T) { }) } +// TestProfile_LegacyModeFallback covers a response that predates team_type, +// or one where the field is absent for any other reason. Without the fallback +// the profile is blanked: ProjectType becomes "", IsGatewayProject("") is false, +// and every `hookdeck gateway ...` command fails with an empty project type. +func TestProfile_LegacyModeFallback(t *testing.T) { + t.Run("validate response falls back to team_mode", func(t *testing.T) { + p := &Profile{} + p.ApplyValidateAPIKeyResponse(&hookdeck.ValidateAPIKeyResponse{ + ProjectID: "team_legacy", + ProjectMode: "outbound", + }, false) + require.Equal(t, "event_gateway", p.ProjectType) + require.Equal(t, ProjectTypeEventGateway, p.ProjectType) + }) + + t.Run("poll response falls back to team_mode", func(t *testing.T) { + p := &Profile{} + p.ApplyPollAPIKeyResponse(&hookdeck.PollAPIKeyResponse{ + APIKey: "key", + ProjectID: "team_legacy", + ProjectMode: "console", + }, "") + require.Equal(t, "console", p.ProjectType) + require.Equal(t, ProjectTypeConsole, p.ProjectType) + }) + + t.Run("ci client falls back to team_mode", func(t *testing.T) { + p := &Profile{} + p.ApplyCIClient(hookdeck.CIClient{ + APIKey: "key", + ProjectID: "team_legacy", + ProjectMode: "outpost", + }) + require.Equal(t, "outpost", p.ProjectType) + require.Equal(t, ProjectTypeOutpost, p.ProjectType) + }) + + t.Run("product wins when both are present", func(t *testing.T) { + p := &Profile{} + p.ApplyValidateAPIKeyResponse(&hookdeck.ValidateAPIKeyResponse{ + ProjectID: "team_both", + ProjectType: "outpost", + ProjectMode: "inbound", + }, false) + require.Equal(t, "outpost", p.ProjectType) + require.Equal(t, ProjectTypeOutpost, p.ProjectType) + }) + + t.Run("both absent leaves the type empty", func(t *testing.T) { + p := &Profile{} + p.ApplyValidateAPIKeyResponse(&hookdeck.ValidateAPIKeyResponse{ProjectID: "team_none"}, false) + require.Empty(t, p.ProjectType) + require.Empty(t, p.ProjectType) + }) +} + func TestProfile_ApplyPollAPIKeyResponse(t *testing.T) { t.Run("nil response is no-op", func(t *testing.T) { p := &Profile{APIKey: "k", ProjectId: "p"} @@ -56,11 +112,11 @@ func TestProfile_ApplyPollAPIKeyResponse(t *testing.T) { p.ApplyPollAPIKeyResponse(&hookdeck.PollAPIKeyResponse{ APIKey: "key_from_poll", ProjectID: "team_p", - ProjectProduct: "event_gateway", + ProjectType: "event_gateway", }, "https://guest") require.Equal(t, "key_from_poll", p.APIKey) require.Equal(t, "team_p", p.ProjectId) - require.Equal(t, ProjectTypeGateway, p.ProjectType) + require.Equal(t, ProjectTypeEventGateway, p.ProjectType) require.Equal(t, "https://guest", p.GuestURL) }) @@ -69,7 +125,7 @@ func TestProfile_ApplyPollAPIKeyResponse(t *testing.T) { p.ApplyPollAPIKeyResponse(&hookdeck.PollAPIKeyResponse{ APIKey: "k123456789012", ProjectID: "t", - ProjectProduct: "event_gateway", + ProjectType: "event_gateway", }, "") require.Empty(t, p.GuestURL) }) @@ -80,11 +136,11 @@ func TestProfile_ApplyCIClient(t *testing.T) { p.ApplyCIClient(hookdeck.CIClient{ APIKey: "ci_key_123456", ProjectID: "team_ci", - ProjectProduct: "event_gateway", + ProjectType: "event_gateway", }) require.Equal(t, "ci_key_123456", p.APIKey) require.Equal(t, "team_ci", p.ProjectId) - require.Equal(t, ProjectTypeGateway, p.ProjectType) + require.Equal(t, ProjectTypeEventGateway, p.ProjectType) require.Empty(t, p.GuestURL) } @@ -122,5 +178,5 @@ team_mode = "inbound" assert.NotContains(t, tomlText, "team_id") assert.NotContains(t, tomlText, "team_mode") assert.Contains(t, tomlText, "project_id") - assert.Contains(t, tomlText, `project_product = 'event_gateway'`) + assert.Contains(t, tomlText, `project_type = 'event_gateway'`) } diff --git a/pkg/config/project_type.go b/pkg/config/project_type.go index 271f65c3..d29f6a1a 100644 --- a/pkg/config/project_type.go +++ b/pkg/config/project_type.go @@ -2,99 +2,78 @@ package config import "strings" -// Project type display values (user-facing and config). +// Project types as the API names them: `type` on GET /projects, and `team_type` +// on the CLI auth endpoints. These are the values stored in config and passed +// around internally, so the CLI speaks the same vocabulary as the API it calls. const ( - ProjectTypeGateway = "Gateway" - ProjectTypeOutpost = "Outpost" - ProjectTypeConsole = "Console" + ProjectTypeEventGateway = "event_gateway" + ProjectTypeOutpost = "outpost" + ProjectTypeConsole = "console" +) - ProjectProductEventGateway = "event_gateway" - ProjectProductOutpost = "outpost" - ProjectProductConsole = "console" +// Labels shown to the user. Presentation only: derived at print time, never +// stored, so there is one source of truth for what a project is. +const ( + ProjectLabelGateway = "Gateway" + ProjectLabelOutpost = "Outpost" + ProjectLabelConsole = "Console" ) -// OutboundMode is the API mode for outbound projects; treated as Gateway (same as inbound). +// OutboundMode is the legacy internal mode for outbound projects. The API folds +// inbound and outbound into event_gateway. const OutboundMode = "outbound" -// ModeToProjectType maps API mode to display project type. -// Inbound and outbound both map to Gateway. Returns empty string only for unknown modes. -func ModeToProjectType(mode string) string { - switch strings.ToLower(mode) { - case "inbound": - return ProjectTypeGateway - case OutboundMode: - return ProjectTypeGateway // same as inbound for gateway purposes - case "console": - return ProjectTypeConsole - case "outpost": - return ProjectTypeOutpost - default: - return "" - } -} - -// ProductToProjectType maps the public API product to the CLI display type. -func ProductToProjectType(product string) string { - switch strings.ToLower(product) { - case ProjectProductEventGateway: - return ProjectTypeGateway - case ProjectProductConsole: - return ProjectTypeConsole - case ProjectProductOutpost: - return ProjectTypeOutpost - default: - return "" - } -} - -// ProjectTypeToProduct maps the CLI display type to the public API product. -func ProjectTypeToProduct(projectType string) string { - switch projectType { - case ProjectTypeGateway: - return ProjectProductEventGateway +// TypeLabel returns the label shown to the user for an API project type. +func TypeLabel(projectType string) string { + switch strings.ToLower(projectType) { + case ProjectTypeEventGateway: + return ProjectLabelGateway case ProjectTypeConsole: - return ProjectProductConsole + return ProjectLabelConsole case ProjectTypeOutpost: - return ProjectProductOutpost + return ProjectLabelOutpost default: return "" } } -// ProductToLegacyMode returns a representative legacy mode for local config -// compatibility. The public API intentionally combines inbound and outbound -// projects under the event_gateway product. -func ProductToLegacyMode(product string) string { - switch strings.ToLower(product) { - case ProjectProductEventGateway: - return "inbound" - case ProjectProductConsole: - return "console" - case ProjectProductOutpost: - return "outpost" +// LabelToType maps a display label back to the API type. Needed for config files +// written before project_type held the API value, and for anything that only has +// the label a user was shown. +func LabelToType(label string) string { + switch strings.ToLower(label) { + case strings.ToLower(ProjectLabelGateway): + return ProjectTypeEventGateway + case strings.ToLower(ProjectLabelConsole): + return ProjectTypeConsole + case strings.ToLower(ProjectLabelOutpost): + return ProjectTypeOutpost default: return "" } } -// ModeToProduct maps a legacy internal API mode to the public product. -func ModeToProduct(mode string) string { +// ModeToType maps a legacy internal mode to the API project type. +func ModeToType(mode string) string { switch strings.ToLower(mode) { case "inbound", OutboundMode: - return ProjectProductEventGateway + return ProjectTypeEventGateway case "console": - return ProjectProductConsole + return ProjectTypeConsole case "outpost": - return ProjectProductOutpost + return ProjectTypeOutpost 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: +// TypeToLegacyMode returns a representative legacy mode for a project type, kept +// so older CLIs reading the same config still resolve a project. The API folds +// inbound and outbound into event_gateway, so a round trip through the type +// normalizes outbound to inbound. +func TypeToLegacyMode(projectType string) string { + switch strings.ToLower(projectType) { + case ProjectTypeEventGateway: return "inbound" case ProjectTypeConsole: return "console" @@ -105,20 +84,42 @@ func ProjectTypeToMode(projectType string) string { } } -// IsGatewayProject returns true if the given type, product, or legacy mode represents a Gateway project. -func IsGatewayProject(typeProductOrMode string) bool { - switch typeProductOrMode { - case ProjectTypeGateway, ProjectTypeConsole, ProjectProductEventGateway, "inbound", "outbound", "console": +// NormalizeProjectType accepts an API type, a display label, or a legacy mode and +// returns the API type. Every value read from disk or handed in by a caller goes +// through here, so the three vocabularies converge in one place rather than at +// each call site. +func NormalizeProjectType(value string) string { + lowered := strings.ToLower(strings.TrimSpace(value)) + if lowered == "" { + return "" + } + if TypeLabel(lowered) != "" { + return lowered + } + if t := LabelToType(lowered); t != "" { + return t + } + return ModeToType(lowered) +} + +// IsGatewayProject reports whether the value denotes a project the gateway +// commands can act on. Console projects count: they are Event Gateway projects +// with a different entry point. +func IsGatewayProject(value string) bool { + switch NormalizeProjectType(value) { + case ProjectTypeEventGateway, ProjectTypeConsole: return true default: return false } } -// ProjectTypeToJSON returns the lowercase type for JSON output (gateway, outpost, console). +// ProjectTypeToJSON returns the value used in `--output json` and accepted by the +// `--type` filter. Deliberately not the API type: `gateway` is what the CLI has +// always emitted, and changing it would break anyone parsing that output. func ProjectTypeToJSON(projectType string) string { - switch projectType { - case ProjectTypeGateway: + switch NormalizeProjectType(projectType) { + case ProjectTypeEventGateway: return "gateway" case ProjectTypeOutpost: return "outpost" @@ -128,3 +129,15 @@ func ProjectTypeToJSON(projectType string) string { return strings.ToLower(projectType) } } + +// IsConsoleProject reports whether the first recognized value identifies a +// Console project. Values are given newest-field-first, matching the order the +// CLI reads team_type, team_product and team_mode from an auth response. +func IsConsoleProject(values ...string) bool { + for _, v := range values { + if t := NormalizeProjectType(v); t != "" { + return t == ProjectTypeConsole + } + } + return false +} diff --git a/pkg/config/project_type_test.go b/pkg/config/project_type_test.go index bdf1d9a2..9846c048 100644 --- a/pkg/config/project_type_test.go +++ b/pkg/config/project_type_test.go @@ -6,68 +6,143 @@ import ( "github.com/stretchr/testify/assert" ) -func TestModeToProjectType(t *testing.T) { +func TestTypeLabel(t *testing.T) { + tests := []struct { + projectType string + expected string + }{ + {ProjectTypeEventGateway, ProjectLabelGateway}, + {ProjectTypeConsole, ProjectLabelConsole}, + {ProjectTypeOutpost, ProjectLabelOutpost}, + {"EVENT_GATEWAY", ProjectLabelGateway}, + {"unknown", ""}, + {"", ""}, + } + for _, tt := range tests { + t.Run(tt.projectType, func(t *testing.T) { + assert.Equal(t, tt.expected, TypeLabel(tt.projectType)) + }) + } +} + +func TestLabelToType(t *testing.T) { + tests := []struct { + label string + expected string + }{ + {ProjectLabelGateway, ProjectTypeEventGateway}, + {ProjectLabelConsole, ProjectTypeConsole}, + {ProjectLabelOutpost, ProjectTypeOutpost}, + {"gateway", ProjectTypeEventGateway}, + {"Unknown", ""}, + {"", ""}, + } + for _, tt := range tests { + t.Run(tt.label, func(t *testing.T) { + assert.Equal(t, tt.expected, LabelToType(tt.label)) + }) + } +} + +func TestModeToType(t *testing.T) { tests := []struct { mode string expected string }{ - {"inbound", ProjectTypeGateway}, - {"INBOUND", ProjectTypeGateway}, + {"inbound", ProjectTypeEventGateway}, + {OutboundMode, ProjectTypeEventGateway}, {"console", ProjectTypeConsole}, - {"Console", ProjectTypeConsole}, {"outpost", ProjectTypeOutpost}, - {"outbound", ProjectTypeGateway}, // same as inbound - {"Outbound", ProjectTypeGateway}, + {"Inbound", ProjectTypeEventGateway}, {"unknown", ""}, {"", ""}, } for _, tt := range tests { t.Run(tt.mode, func(t *testing.T) { - got := ModeToProjectType(tt.mode) - assert.Equal(t, tt.expected, got) + assert.Equal(t, tt.expected, ModeToType(tt.mode)) }) } } -func TestProjectTypeToMode(t *testing.T) { +func TestTypeToLegacyMode(t *testing.T) { tests := []struct { projectType string expected string }{ - {ProjectTypeGateway, "inbound"}, + // event_gateway covers both inbound and outbound; "inbound" is the + // representative value written back to config. + {ProjectTypeEventGateway, "inbound"}, {ProjectTypeConsole, "console"}, {ProjectTypeOutpost, "outpost"}, + {"OUTPOST", "outpost"}, + {"unknown", ""}, {"", ""}, - {"Unknown", ""}, } for _, tt := range tests { t.Run(tt.projectType, func(t *testing.T) { - got := ProjectTypeToMode(tt.projectType) - assert.Equal(t, tt.expected, got) + assert.Equal(t, tt.expected, TypeToLegacyMode(tt.projectType)) + }) + } +} + +// TestNormalizeProjectType is the important one: it is the single door every +// value from disk or from a caller goes through, and it has to accept all three +// vocabularies the CLI has used - the API type, the display label written to +// project_type by older CLIs, and the legacy mode. +func TestNormalizeProjectType(t *testing.T) { + tests := []struct { + name string + value string + expected string + }{ + {"api type", ProjectTypeEventGateway, ProjectTypeEventGateway}, + {"api type outpost", ProjectTypeOutpost, ProjectTypeOutpost}, + {"display label from an older config", ProjectLabelGateway, ProjectTypeEventGateway}, + {"display label console", ProjectLabelConsole, ProjectTypeConsole}, + {"legacy mode inbound", "inbound", ProjectTypeEventGateway}, + {"legacy mode outbound", OutboundMode, ProjectTypeEventGateway}, + {"legacy mode outpost", "outpost", ProjectTypeOutpost}, + {"mixed case", "Event_Gateway", ProjectTypeEventGateway}, + {"surrounding space", " outpost ", ProjectTypeOutpost}, + {"unknown", "something_else", ""}, + {"empty", "", ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, NormalizeProjectType(tt.value)) }) } } -func TestProductMappings(t *testing.T) { - assert.Equal(t, ProjectTypeGateway, ProductToProjectType("event_gateway")) - assert.Equal(t, ProjectTypeConsole, ProductToProjectType("console")) - assert.Equal(t, ProjectTypeOutpost, ProductToProjectType("outpost")) - assert.Equal(t, "", ProductToProjectType("unknown")) +// TestTypeRoundTrip pins the deliberate lossiness: the API folds inbound and +// outbound into one type, so a round trip normalizes outbound to inbound. The +// type survives; the mode does not. +func TestTypeRoundTrip(t *testing.T) { + for _, projectType := range []string{ProjectTypeEventGateway, ProjectTypeConsole, ProjectTypeOutpost} { + t.Run(projectType, func(t *testing.T) { + assert.Equal(t, projectType, LabelToType(TypeLabel(projectType))) + }) + } - assert.Equal(t, "event_gateway", ProjectTypeToProduct(ProjectTypeGateway)) - assert.Equal(t, "inbound", ProductToLegacyMode("event_gateway")) - assert.Equal(t, "event_gateway", ModeToProduct("outbound")) + assert.Equal(t, "inbound", TypeToLegacyMode(ModeToType(OutboundMode)), + "outbound is expected to normalize to inbound through the type mapping") + assert.Equal(t, "inbound", TypeToLegacyMode(ModeToType("inbound"))) } func TestIsGatewayProject(t *testing.T) { - // Gateway = inbound, outbound, or console (type or mode) - trueCases := []string{ProjectTypeGateway, ProjectProductEventGateway, "inbound", "outbound", "console", ProjectTypeConsole} + // Console projects are Event Gateway projects with a different entry point. + trueCases := []string{ + ProjectTypeEventGateway, ProjectTypeConsole, + ProjectLabelGateway, ProjectLabelConsole, + "inbound", "outbound", "EVENT_GATEWAY", + } for _, v := range trueCases { t.Run("true_"+v, func(t *testing.T) { assert.True(t, IsGatewayProject(v)) }) } - falseCases := []string{ProjectTypeOutpost, ""} + + falseCases := []string{ProjectTypeOutpost, ProjectLabelOutpost, "", "unknown"} for _, v := range falseCases { t.Run("false_"+v, func(t *testing.T) { assert.False(t, IsGatewayProject(v)) @@ -75,20 +150,33 @@ func TestIsGatewayProject(t *testing.T) { } } +func TestIsConsoleProject(t *testing.T) { + assert.True(t, IsConsoleProject(ProjectTypeConsole, "", "")) + assert.True(t, IsConsoleProject("", ProjectTypeConsole, ""), "falls through to the legacy product field") + assert.True(t, IsConsoleProject("", "", "console"), "falls through to the legacy mode field") + assert.False(t, IsConsoleProject(ProjectTypeEventGateway, ProjectTypeConsole, ""), + "the first recognized value wins, so a newer field is not overridden by an older one") + assert.False(t, IsConsoleProject("", "", "")) +} + +// TestProjectTypeToJSON guards the user-facing values. `gateway` is what the CLI +// has always emitted in --output json and accepted in --type; the API type is +// deliberately not used here. func TestProjectTypeToJSON(t *testing.T) { tests := []struct { - projectType string - expected string + value string + expected string }{ - {ProjectTypeGateway, "gateway"}, + {ProjectTypeEventGateway, "gateway"}, {ProjectTypeOutpost, "outpost"}, {ProjectTypeConsole, "console"}, + {ProjectLabelGateway, "gateway"}, + {"inbound", "gateway"}, {"", ""}, } for _, tt := range tests { - t.Run(tt.projectType, func(t *testing.T) { - got := ProjectTypeToJSON(tt.projectType) - assert.Equal(t, tt.expected, got) + t.Run(tt.value, func(t *testing.T) { + assert.Equal(t, tt.expected, ProjectTypeToJSON(tt.value)) }) } } diff --git a/pkg/gateway/mcp/project_display_test.go b/pkg/gateway/mcp/project_display_test.go index 78ca3290..95616afd 100644 --- a/pkg/gateway/mcp/project_display_test.go +++ b/pkg/gateway/mcp/project_display_test.go @@ -18,7 +18,7 @@ func TestFillProjectDisplayNameIfNeeded_SetsNameFromAPI(t *testing.T) { return } _ = json.NewEncoder(w).Encode([]map[string]any{ - {"id": "proj_x", "name": "[Acme] production", "product": "console"}, + {"id": "proj_x", "name": "[Acme] production", "type": "console"}, }) })) t.Cleanup(srv.Close) diff --git a/pkg/gateway/mcp/server_test.go b/pkg/gateway/mcp/server_test.go index 665ea44e..8f7c079c 100644 --- a/pkg/gateway/mcp/server_test.go +++ b/pkg/gateway/mcp/server_test.go @@ -108,7 +108,7 @@ func mockAPI(t *testing.T, handlers map[string]http.HandlerFunc) *httptest.Serve "organization_id": "org_test", "team_id": "proj_test123", "team_name_no_org": "Production", - "team_product": "console", + "team_type": "console", }) } } @@ -790,10 +790,10 @@ func TestEventsList_MetadataFilters(t *testing.T) { }) result := callTool(t, session, "hookdeck_events", map[string]any{ - "action": "list", - "id": "evt_1,evt_2", - "attempts": "3", - "cli_id": "cli_abc", + "action": "list", + "id": "evt_1,evt_2", + "attempts": "3", + "cli_id": "cli_abc", "delivery_group": "cus_123", }) assert.False(t, result.IsError) @@ -1110,8 +1110,8 @@ func TestProjectsList_Success(t *testing.T) { session := mockAPIWithClient(t, map[string]http.HandlerFunc{ "/2026-09-01/projects": func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode([]map[string]any{ - {"id": "proj_test123", "name": "Production", "product": "console"}, - {"id": "proj_other", "name": "Staging", "product": "console"}, + {"id": "proj_test123", "name": "Production", "type": "console"}, + {"id": "proj_other", "name": "Staging", "type": "console"}, }) }, }) @@ -1149,8 +1149,8 @@ func TestProjectsUse_Success(t *testing.T) { session := mockAPIWithClient(t, map[string]http.HandlerFunc{ "/2026-09-01/projects": func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode([]map[string]any{ - {"id": "proj_test123", "name": "Production", "product": "console"}, - {"id": "proj_new", "name": "Staging", "product": "console"}, + {"id": "proj_test123", "name": "Production", "type": "console"}, + {"id": "proj_new", "name": "Staging", "type": "console"}, }) }, }) @@ -1176,7 +1176,7 @@ func TestProjectsUse_ProjectNotFound(t *testing.T) { session := mockAPIWithClient(t, map[string]http.HandlerFunc{ "/2026-09-01/projects": func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode([]map[string]any{ - {"id": "proj_test123", "name": "Production", "product": "console"}, + {"id": "proj_test123", "name": "Production", "type": "console"}, }) }, }) @@ -1227,10 +1227,10 @@ func TestMetricsEvents_DefaultRoute(t *testing.T) { }) result := callTool(t, session, "hookdeck_metrics", map[string]any{ - "action": "events", - "start": "2025-01-01T00:00:00Z", - "end": "2025-01-02T00:00:00Z", - "measures": []any{"count"}, + "action": "events", + "start": "2025-01-01T00:00:00Z", + "end": "2025-01-02T00:00:00Z", + "measures": []any{"count"}, "delivery_group": "cus_123", }) assert.False(t, result.IsError) @@ -1362,7 +1362,7 @@ func TestLoginTool_AlreadyAuthenticated(t *testing.T) { "organization_id": "org_1", "team_id": "tm_1", "team_name_no_org": "Proj", - "team_product": "event_gateway", + "team_type": "event_gateway", }) }, }) @@ -1382,7 +1382,7 @@ func TestLoginTool_CIScopedKeyStartsLogin(t *testing.T) { "organization_id": "org_1", "team_id": "tm_ci", "team_name_no_org": "CI Project", - "team_product": "event_gateway", + "team_type": "event_gateway", }) }, "/2026-09-01/cli-auth": func(w http.ResponseWriter, r *http.Request) { @@ -1568,7 +1568,7 @@ func TestLoginTool_PollSurvivesAcrossToolCalls(t *testing.T) { "key": "sk_test_survive12345", "team_id": "proj_survive", "team_name": "Survive Project", - "team_product": "console", + "team_type": "console", "user_name": "test-user", "organization_name": "test-org", }) diff --git a/pkg/gateway/mcp/telemetry_test.go b/pkg/gateway/mcp/telemetry_test.go index 8c5d5d68..b1660e7e 100644 --- a/pkg/gateway/mcp/telemetry_test.go +++ b/pkg/gateway/mcp/telemetry_test.go @@ -328,7 +328,7 @@ func TestMCPToolCall_MultipleAPICallsSameInvocation(t *testing.T) { "GET /2026-09-01/projects": capture.handler(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode([]map[string]any{ - {"id": "proj_abc", "name": "My Project", "product": "console"}, + {"id": "proj_abc", "name": "My Project", "type": "console"}, }) }), }) diff --git a/pkg/hookdeck/auth.go b/pkg/hookdeck/auth.go index abefc803..216b87f3 100644 --- a/pkg/hookdeck/auth.go +++ b/pkg/hookdeck/auth.go @@ -26,8 +26,15 @@ type ValidateAPIKeyResponse struct { OrganizationID string `json:"organization_id"` ProjectID string `json:"team_id"` ProjectName string `json:"team_name_no_org"` - ProjectProduct string `json:"team_product"` - ClientID string `json:"client_id"` + ProjectType string `json:"team_type"` + // ProjectProduct and ProjectMode are earlier names for the same field: + // team_product was served briefly before the rename to team_type, and + // team_mode before that. Both are still read so a response from an API + // that has not been updated yet resolves a project type instead of + // blanking it. Drop ProjectProduct once 2026-09-01 is deployed. + ProjectProduct string `json:"team_product"` + ProjectMode string `json:"team_mode"` + ClientID string `json:"client_id"` } // PollAPIKeyResponse returns the data of the polling client login @@ -40,9 +47,16 @@ type PollAPIKeyResponse struct { OrganizationID string `json:"organization_id"` ProjectID string `json:"team_id"` ProjectName string `json:"team_name"` - ProjectProduct string `json:"team_product"` - APIKey string `json:"key"` - ClientID string `json:"client_id"` + ProjectType string `json:"team_type"` + // ProjectProduct and ProjectMode are earlier names for the same field: + // team_product was served briefly before the rename to team_type, and + // team_mode before that. Both are still read so a response from an API + // that has not been updated yet resolves a project type instead of + // blanking it. Drop ProjectProduct once 2026-09-01 is deployed. + ProjectProduct string `json:"team_product"` + ProjectMode string `json:"team_mode"` + APIKey string `json:"key"` + ClientID string `json:"client_id"` } // UpdateClientInput represents the input for updating a CLI client diff --git a/pkg/hookdeck/auth_test.go b/pkg/hookdeck/auth_test.go index 5d2c9fa4..981f2a3c 100644 --- a/pkg/hookdeck/auth_test.go +++ b/pkg/hookdeck/auth_test.go @@ -30,7 +30,7 @@ func TestValidateAPIKey_omitsTeamAndProjectHeadersWhenConfigHasProjectID(t *test OrganizationID: "o1", ProjectID: "t1", ProjectName: "p", - ProjectProduct: "event_gateway", + ProjectType: "event_gateway", }) })) t.Cleanup(server.Close) @@ -49,5 +49,5 @@ func TestValidateAPIKey_omitsTeamAndProjectHeadersWhenConfigHasProjectID(t *test require.False(t, sawTeamHeader, "validate must not send X-Team-ID") require.False(t, sawProjectHeader, "validate must not send X-Project-ID") require.Equal(t, "t1", resp.ProjectID) - require.Equal(t, "event_gateway", resp.ProjectProduct) + require.Equal(t, "event_gateway", resp.ProjectType) } diff --git a/pkg/hookdeck/ci.go b/pkg/hookdeck/ci.go index d8805aae..562c6a74 100644 --- a/pkg/hookdeck/ci.go +++ b/pkg/hookdeck/ci.go @@ -15,9 +15,16 @@ type CIClient struct { OrganizationID string `json:"organization_id"` ProjectID string `json:"team_id"` ProjectName string `json:"team_name"` - ProjectProduct string `json:"team_product"` - APIKey string `json:"key"` - ClientID string `json:"client_id"` + ProjectType string `json:"team_type"` + // ProjectProduct and ProjectMode are earlier names for the same field: + // team_product was served briefly before the rename to team_type, and + // team_mode before that. Both are still read so a response from an API + // that has not been updated yet resolves a project type instead of + // blanking it. Drop ProjectProduct once 2026-09-01 is deployed. + ProjectProduct string `json:"team_product"` + ProjectMode string `json:"team_mode"` + APIKey string `json:"key"` + ClientID string `json:"client_id"` } type CreateCIClientInput struct { diff --git a/pkg/hookdeck/events.go b/pkg/hookdeck/events.go index 08131da9..fe058ba4 100644 --- a/pkg/hookdeck/events.go +++ b/pkg/hookdeck/events.go @@ -15,7 +15,7 @@ type Event struct { WebhookID string `json:"webhook_id"` SourceID string `json:"source_id"` DestinationID string `json:"destination_id"` - DeliveryGroup *string `json:"delivery_group"` + DeliveryGroup *string `json:"delivery_group,omitempty"` RequestID string `json:"request_id"` Attempts int `json:"attempts"` ResponseStatus *int `json:"response_status,omitempty"` diff --git a/pkg/hookdeck/projects.go b/pkg/hookdeck/projects.go index ea4e9488..cab8aedf 100644 --- a/pkg/hookdeck/projects.go +++ b/pkg/hookdeck/projects.go @@ -2,12 +2,13 @@ package hookdeck import ( "context" + "fmt" ) type Project struct { - Id string `json:"id"` - Name string `json:"name"` - Product string `json:"product"` + Id string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` } func (c *Client) ListProjects() ([]Project, error) { @@ -19,7 +20,11 @@ func (c *Client) ListProjects() ([]Project, error) { return []Project{}, err } projects := []Project{} - postprocessJsonResponse(res, &projects) + // A shape mismatch here used to return an empty list and a nil error, so a + // renamed field or a wrapped envelope read as "you have no projects". + if _, err := postprocessJsonResponse(res, &projects); err != nil { + return []Project{}, fmt.Errorf("failed to parse project list response: %w", err) + } return projects, nil } diff --git a/pkg/hookdeck/projects_test.go b/pkg/hookdeck/projects_test.go index 1b88f97e..ea58beaf 100644 --- a/pkg/hookdeck/projects_test.go +++ b/pkg/hookdeck/projects_test.go @@ -1,43 +1,43 @@ -package hookdeck - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "net/url" - "testing" - - "github.com/stretchr/testify/require" -) - -func TestListProjects_omitsTeamAndProjectHeadersWhenConfigHasProjectID(t *testing.T) { - var sawTeamHeader bool - var sawProjectHeader bool - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - sawTeamHeader = r.Header.Get("X-Team-ID") != "" - sawProjectHeader = r.Header.Get("X-Project-ID") != "" - if r.URL.Path != APIPathPrefix+"/projects" { - http.NotFound(w, r) - return - } - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode([]Project{{Id: "tm_1", Name: "[Org] Proj", Product: "event_gateway"}}) - })) - t.Cleanup(server.Close) - - baseURL, err := url.Parse(server.URL) - require.NoError(t, err) - - client := &Client{ - BaseURL: baseURL, - APIKey: "test_key", - ProjectID: "stale_team_should_not_be_sent", - } - - projects, err := client.ListProjects() - require.NoError(t, err) - require.False(t, sawTeamHeader, "list projects must not send X-Team-ID") - require.False(t, sawProjectHeader, "list projects must not send X-Project-ID") - require.Len(t, projects, 1) -} +package hookdeck + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestListProjects_omitsTeamAndProjectHeadersWhenConfigHasProjectID(t *testing.T) { + var sawTeamHeader bool + var sawProjectHeader bool + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sawTeamHeader = r.Header.Get("X-Team-ID") != "" + sawProjectHeader = r.Header.Get("X-Project-ID") != "" + if r.URL.Path != APIPathPrefix+"/projects" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode([]Project{{Id: "tm_1", Name: "[Org] Proj", Type: "event_gateway"}}) + })) + t.Cleanup(server.Close) + + baseURL, err := url.Parse(server.URL) + require.NoError(t, err) + + client := &Client{ + BaseURL: baseURL, + APIKey: "test_key", + ProjectID: "stale_team_should_not_be_sent", + } + + projects, err := client.ListProjects() + require.NoError(t, err) + require.False(t, sawTeamHeader, "list projects must not send X-Team-ID") + require.False(t, sawProjectHeader, "list projects must not send X-Project-ID") + require.Len(t, projects, 1) +} diff --git a/pkg/login/claimed_cli_key.go b/pkg/login/claimed_cli_key.go index c82ef8fa..42c4ce28 100644 --- a/pkg/login/claimed_cli_key.go +++ b/pkg/login/claimed_cli_key.go @@ -1,54 +1,54 @@ -package login - -import ( - "fmt" - "os" - "strings" - - "github.com/hookdeck/hookdeck-cli/pkg/ansi" - configpkg "github.com/hookdeck/hookdeck-cli/pkg/config" - "github.com/hookdeck/hookdeck-cli/pkg/validators" -) - -// ConfigureFromClaimedCliKey validates a product-issued CLI key (dashboard onboarding, Console -// destination, etc.) and saves the profile. Unlike Login(), this path does not start browser -// device auth or guest sandbox claim—even when the existing profile is a guest Console session. -func ConfigureFromClaimedCliKey(config *configpkg.Config, cli_key string) error { - cli_key = strings.TrimSpace(cli_key) - if cli_key == "" { - return fmt.Errorf("--cli-key is required") - } - if err := validators.APIKey(cli_key); err != nil { - return err - } - - config.Profile.APIKey = cli_key - - spinner := ansi.StartNewSpinner("Verifying credentials...", os.Stdout) - response, err := config.GetAPIClient().ValidateAPIKey() - if err != nil { - ansi.StopSpinner(spinner, "", os.Stdout) - return err - } - - message := SuccessMessage( - response.UserName, - response.UserEmail, - response.OrganizationName, - response.ProjectName, - response.ProjectProduct == configpkg.ProjectProductConsole, - ) - ansi.StopSpinner(spinner, message, os.Stdout) - - config.Profile.ApplyValidateAPIKeyResponse(response, true) - - if err := config.Profile.SaveProfile(); err != nil { - return err - } - if err := config.Profile.UseProfile(); err != nil { - return err - } - config.RefreshCachedAPIClient() - - return nil -} +package login + +import ( + "fmt" + "os" + "strings" + + "github.com/hookdeck/hookdeck-cli/pkg/ansi" + configpkg "github.com/hookdeck/hookdeck-cli/pkg/config" + "github.com/hookdeck/hookdeck-cli/pkg/validators" +) + +// ConfigureFromClaimedCliKey validates a product-issued CLI key (dashboard onboarding, Console +// destination, etc.) and saves the profile. Unlike Login(), this path does not start browser +// device auth or guest sandbox claim—even when the existing profile is a guest Console session. +func ConfigureFromClaimedCliKey(config *configpkg.Config, cli_key string) error { + cli_key = strings.TrimSpace(cli_key) + if cli_key == "" { + return fmt.Errorf("--cli-key is required") + } + if err := validators.APIKey(cli_key); err != nil { + return err + } + + config.Profile.APIKey = cli_key + + spinner := ansi.StartNewSpinner("Verifying credentials...", os.Stdout) + response, err := config.GetAPIClient().ValidateAPIKey() + if err != nil { + ansi.StopSpinner(spinner, "", os.Stdout) + return err + } + + message := SuccessMessage( + response.UserName, + response.UserEmail, + response.OrganizationName, + response.ProjectName, + configpkg.IsConsoleProject(response.ProjectType, response.ProjectProduct, response.ProjectMode), + ) + ansi.StopSpinner(spinner, message, os.Stdout) + + config.Profile.ApplyValidateAPIKeyResponse(response, true) + + if err := config.Profile.SaveProfile(); err != nil { + return err + } + if err := config.Profile.UseProfile(); err != nil { + return err + } + config.RefreshCachedAPIClient() + + return nil +} diff --git a/pkg/login/claimed_cli_key_test.go b/pkg/login/claimed_cli_key_test.go index 70182e8c..d4f7425b 100644 --- a/pkg/login/claimed_cli_key_test.go +++ b/pkg/login/claimed_cli_key_test.go @@ -32,7 +32,7 @@ func TestConfigureFromClaimedCliKey_guestProfileReplacesCredentials(t *testing.T "organization_id": "org_1", "team_id": "tm_gateway", "team_name_no_org": "Production", - "team_product": "event_gateway", + "team_type": "event_gateway", "client_id": "cl_onboard", }) require.NoError(t, err) diff --git a/pkg/login/client_login.go b/pkg/login/client_login.go index a6bf4ccf..179a08fc 100644 --- a/pkg/login/client_login.go +++ b/pkg/login/client_login.go @@ -51,7 +51,7 @@ func Login(config *configpkg.Config, input io.Reader) error { config.Profile.APIKey = "" } else if response.UserID != "" { if config.Profile.GuestURL == "" || !response.UserIsGuest { - message := SuccessMessage(response.UserName, response.UserEmail, response.OrganizationName, response.ProjectName, response.ProjectProduct == configpkg.ProjectProductConsole) + message := SuccessMessage(response.UserName, response.UserEmail, response.OrganizationName, response.ProjectName, configpkg.IsConsoleProject(response.ProjectType, response.ProjectProduct, response.ProjectMode)) ansi.StopSpinner(s, message, os.Stdout) config.Profile.ApplyValidateAPIKeyResponse(response, true) @@ -139,7 +139,7 @@ func waitForLoginSession(config *configpkg.Config, input io.Reader, session *hoo config.RefreshCachedAPIClient() - message := SuccessMessage(response.UserName, response.UserEmail, response.OrganizationName, response.ProjectName, response.ProjectProduct == configpkg.ProjectProductConsole) + message := SuccessMessage(response.UserName, response.UserEmail, response.OrganizationName, response.ProjectName, configpkg.IsConsoleProject(response.ProjectType, response.ProjectProduct, response.ProjectMode)) ansi.StopSpinner(s, message, os.Stdout) return nil @@ -279,7 +279,7 @@ func waitForGuestUpgrade(config *configpkg.Config, input io.Reader) error { config.RefreshCachedAPIClient() - message := SuccessMessage(response.UserName, response.UserEmail, response.OrganizationName, response.ProjectName, response.ProjectProduct == configpkg.ProjectProductConsole) + message := SuccessMessage(response.UserName, response.UserEmail, response.OrganizationName, response.ProjectName, configpkg.IsConsoleProject(response.ProjectType, response.ProjectProduct, response.ProjectMode)) ansi.StopSpinner(s, message, os.Stdout) return nil diff --git a/pkg/login/client_login_test.go b/pkg/login/client_login_test.go index e01e50e6..06b093d4 100644 --- a/pkg/login/client_login_test.go +++ b/pkg/login/client_login_test.go @@ -83,7 +83,7 @@ func TestLogin_unauthorizedValidateStartsBrowserFlow(t *testing.T) { "claimed": true, "key": "hk_test_newkey_abcdefghij", "team_id": "tm_1", - "team_product": "event_gateway", + "team_type": "event_gateway", "team_name": "Proj", "user_name": "U", "user_email": "u@example.com", @@ -152,7 +152,7 @@ func TestLogin_guestProfileWithValidKeyStartsGuestUpgrade(t *testing.T) { "organization_id": "org_1", "team_id": "tm_console", "team_name_no_org": "Sandbox", - "team_product": "console", + "team_type": "console", "client_id": "cl_guest", } enc, err := json.Marshal(resp) @@ -217,7 +217,7 @@ func TestLogin_ciKeyHeadlessFailsFast(t *testing.T) { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(hookdeck.ValidateAPIKeyResponse{ ProjectID: "tm_ci", - ProjectProduct: "event_gateway", + ProjectType: "event_gateway", OrganizationName: "Org", OrganizationID: "org_1", ProjectName: "CI", @@ -276,7 +276,7 @@ func TestLogin_ciKeyStartsBrowserFlow(t *testing.T) { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(hookdeck.ValidateAPIKeyResponse{ ProjectID: "tm_ci", - ProjectProduct: "event_gateway", + ProjectType: "event_gateway", OrganizationName: "Org", OrganizationID: "org_1", ProjectName: "CI", @@ -297,7 +297,7 @@ func TestLogin_ciKeyStartsBrowserFlow(t *testing.T) { "key": "hk_test_userkey_abcdefghij", "user_id": "usr_1", "team_id": "tm_1", - "team_product": "event_gateway", + "team_type": "event_gateway", "team_name": "Proj", "user_name": "U", "user_email": "u@example.com", diff --git a/pkg/login/interactive_login.go b/pkg/login/interactive_login.go index 19c1adf5..99ebab7c 100644 --- a/pkg/login/interactive_login.go +++ b/pkg/login/interactive_login.go @@ -66,7 +66,7 @@ func InteractiveLogin(config *configpkg.Config) error { return err } - message := SuccessMessage(response.UserName, response.UserEmail, response.OrganizationName, response.ProjectName, response.ProjectProduct == configpkg.ProjectProductConsole) + message := SuccessMessage(response.UserName, response.UserEmail, response.OrganizationName, response.ProjectName, configpkg.IsConsoleProject(response.ProjectType, response.ProjectProduct, response.ProjectMode)) ansi.StopSpinner(s, message, os.Stdout) diff --git a/pkg/project/credentials_test.go b/pkg/project/credentials_test.go index 951be7f2..ff63575d 100644 --- a/pkg/project/credentials_test.go +++ b/pkg/project/credentials_test.go @@ -20,7 +20,7 @@ func TestEnsureUserAssociatedCredentials_rejectsProjectScopedKey(t *testing.T) { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(hookdeck.ValidateAPIKeyResponse{ ProjectID: "tm_ci", - ProjectProduct: "event_gateway", + ProjectType: "event_gateway", OrganizationName: "Org", OrganizationID: "org_1", ProjectName: "CI Project", diff --git a/pkg/project/normalize.go b/pkg/project/normalize.go index e2b0698e..a3f23ff9 100644 --- a/pkg/project/normalize.go +++ b/pkg/project/normalize.go @@ -12,18 +12,18 @@ type ProjectListItem struct { Id string Org string Project string - Type string // display type: Gateway, Outpost, Console + Type string // API project type: event_gateway, console, outpost Current bool } -// NormalizeProjects converts API projects into a normalized list: parses name once and sets type from product. -// Only projects with no known product are excluded. currentID is the profile's current project id for the Current flag. +// NormalizeProjects converts API projects into a normalized list: parses name once and keeps the API type. +// Only projects with an unrecognized type are excluded. 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.ProductToProjectType(p.Product) + projectType := config.NormalizeProjectType(p.Type) if projectType == "" { - // unknown product: exclude from list + // unrecognized type: exclude from list continue } org, proj, err := ParseProjectName(p.Name) @@ -43,7 +43,7 @@ func NormalizeProjects(projects []hookdeck.Project, currentID string) []ProjectL return out } -// FilterByType returns items whose Type (display) matches the given type filter (lowercase: gateway, outpost, console). +// FilterByType returns items whose type matches the given filter (lowercase: gateway, outpost, console). func FilterByType(items []ProjectListItem, typeFilter string) []ProjectListItem { if typeFilter == "" { return items @@ -66,7 +66,7 @@ func (it *ProjectListItem) DisplayLine() string { if it.Current { namePart += " (current)" } - return namePart + " | " + it.Type + return namePart + " | " + config.TypeLabel(it.Type) } // FilterByOrgProject filters items by org and/or project name substrings (case-insensitive). diff --git a/pkg/project/normalize_test.go b/pkg/project/normalize_test.go index 131722ec..3cc5fcec 100644 --- a/pkg/project/normalize_test.go +++ b/pkg/project/normalize_test.go @@ -10,11 +10,11 @@ import ( func TestNormalizeProjects(t *testing.T) { projects := []hookdeck.Project{ - {Id: "p1", Name: "[Acme] Prod", Product: "event_gateway"}, - {Id: "p2", Name: "[Acme] Staging", Product: "console"}, - {Id: "p3", Name: "[Org2] Outpost", Product: "outpost"}, - {Id: "p4", Name: "[Org] Gateway", Product: "event_gateway"}, - {Id: "p5", Name: "No brackets", Product: "event_gateway"}, + {Id: "p1", Name: "[Acme] Prod", Type: "event_gateway"}, + {Id: "p2", Name: "[Acme] Staging", Type: "console"}, + {Id: "p3", Name: "[Org2] Outpost", Type: "outpost"}, + {Id: "p4", Name: "[Org] Gateway", Type: "event_gateway"}, + {Id: "p5", Name: "No brackets", Type: "event_gateway"}, } items := NormalizeProjects(projects, "p2") // event_gateway, console, and outpost are all known products. @@ -24,21 +24,21 @@ func TestNormalizeProjects(t *testing.T) { 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.Equal(t, "event_gateway", items[0].Type) assert.False(t, items[0].Current) - // p2: current, console product -> Console type + // p2: current, console type assert.True(t, items[1].Current) - assert.Equal(t, "Console", items[1].Type) + assert.Equal(t, "console", items[1].Type) // p3: Outpost - assert.Equal(t, "Outpost", items[2].Type) + assert.Equal(t, "outpost", items[2].Type) - // p4: event_gateway product -> Gateway + // p4: event_gateway assert.Equal(t, "p4", items[3].Id) assert.Equal(t, "Org", items[3].Org) assert.Equal(t, "Gateway", items[3].Project) - assert.Equal(t, "Gateway", items[3].Type) + assert.Equal(t, "event_gateway", items[3].Type) // p5: unparseable name -> org "", project "No brackets" assert.Equal(t, "", items[4].Org) @@ -50,35 +50,35 @@ func TestNormalizeProjects_EmptyList(t *testing.T) { assert.Empty(t, items) } -func TestNormalizeProjects_EventGatewayProductMapsToGateway(t *testing.T) { +func TestNormalizeProjects_KeepsAPIType(t *testing.T) { projects := []hookdeck.Project{ - {Id: "p1", Name: "[A] P", Product: "event_gateway"}, + {Id: "p1", Name: "[A] P", Type: "event_gateway"}, } items := NormalizeProjects(projects, "p1") require.Len(t, items, 1) assert.Equal(t, "p1", items[0].Id) - assert.Equal(t, "Gateway", items[0].Type) + assert.Equal(t, "event_gateway", items[0].Type) assert.True(t, items[0].Current) } func TestFilterByType(t *testing.T) { items := []ProjectListItem{ - {Type: "Gateway"}, - {Type: "Outpost"}, - {Type: "Gateway"}, - {Type: "Console"}, + {Type: "event_gateway"}, + {Type: "outpost"}, + {Type: "event_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) + assert.Equal(t, "event_gateway", got[0].Type) + assert.Equal(t, "event_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) + assert.Equal(t, "console", got[0].Type) } func TestFilterByOrgProject(t *testing.T) { @@ -97,7 +97,7 @@ func TestFilterByOrgProject(t *testing.T) { } func TestProjectListItem_DisplayLine(t *testing.T) { - it := ProjectListItem{Org: "Acme", Project: "Prod", Type: "Gateway", Current: false} + it := ProjectListItem{Org: "Acme", Project: "Prod", Type: "event_gateway", Current: false} assert.Equal(t, "Acme / Prod | Gateway", it.DisplayLine()) it.Current = true diff --git a/test/acceptance/guest_login_acceptance_test.go b/test/acceptance/guest_login_acceptance_test.go index 68f4652c..ee8ae4e0 100644 --- a/test/acceptance/guest_login_acceptance_test.go +++ b/test/acceptance/guest_login_acceptance_test.go @@ -81,7 +81,7 @@ func newGuestLoginMock(t *testing.T, assertBody func(map[string]interface{}), br "claimed": true, "key": "hk_test_guest_claimed", "team_id": "tm_guest", - "team_product": "console", + "team_type": "console", "team_name": "Guest Sandbox", "user_name": "Guest", "user_email": "guest@example.com", @@ -143,7 +143,7 @@ func TestGuestLoginDefaultClaimGuestAcceptance(t *testing.T) { "organization_id": "org_guest", "team_id": "tm_guest", "team_name_no_org": "Guest Sandbox", - "team_product": "console", + "team_type": "console", "client_id": "cl_guest", }) require.NoError(t, encErr) diff --git a/test/acceptance/login_auth_acceptance_test.go b/test/acceptance/login_auth_acceptance_test.go index 90432853..41f4f48c 100644 --- a/test/acceptance/login_auth_acceptance_test.go +++ b/test/acceptance/login_auth_acceptance_test.go @@ -16,6 +16,7 @@ import ( "time" "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -59,7 +60,7 @@ api_key = "hk_test_stale_accept01" "claimed": true, "key": "hk_test_newkey_accept01", "team_id": "tm_accept", - "team_product": "event_gateway", + "team_type": "event_gateway", "team_name": "AcceptProj", "user_name": "Accept", "user_email": "accept@example.com", @@ -99,6 +100,17 @@ api_key = "hk_test_stale_accept01" require.NoError(t, err, "stdout=%q stderr=%q", stdout.String(), stderr.String()) require.Contains(t, stdout.String(), "no longer valid", "user should see stale-key message") require.Equal(t, 1, pollHits, "mock should see exactly one poll after cli-auth") + + // End-to-end check that the 2026-09-01 project type survives the whole round trip: + // API response -> profile -> config file. Everything else about the rename is + // covered by unit tests against mocks that this repo also writes, so this is + // the only place the persisted field is verified against a real CLI run. + written, readErr := os.ReadFile(configPath) + require.NoError(t, readErr) + assert.Contains(t, string(written), "project_type = 'event_gateway'") + assert.Contains(t, string(written), "project_mode = 'inbound'", "the legacy mode is still written for older CLIs") + assert.NotContains(t, string(written), "project_product", "the short-lived product key must not be written") + assert.Contains(t, string(written), "project_id = 'tm_accept'") } // TestCIFailsFastWithInvalidAPIKeyAcceptance verifies hookdeck ci does not enter the