From 06786402a43aa69f5df74b70087d7f52928c1cb4 Mon Sep 17 00:00:00 2001 From: Emmanuel Jacquier Date: Thu, 29 Jan 2026 19:06:24 -0500 Subject: [PATCH 01/19] Refactored credentials.go for deploy access status and added deploy access display to cre whoami command --- cmd/whoami/whoami.go | 15 ++++++++ internal/credentials/credentials.go | 55 ++++++++++++++++++++--------- 2 files changed, 53 insertions(+), 17 deletions(-) diff --git a/cmd/whoami/whoami.go b/cmd/whoami/whoami.go index 7fa0c879..3ba3be5c 100644 --- a/cmd/whoami/whoami.go +++ b/cmd/whoami/whoami.go @@ -88,6 +88,12 @@ func (h *Handler) Execute(ctx context.Context) error { return fmt.Errorf("graphql request failed: %w", err) } + // Get deployment access status + deployAccess, err := h.credentials.GetDeploymentAccessStatus() + if err != nil { + h.log.Debug().Err(err).Msg("failed to get deployment access status") + } + ui.Line() ui.Title("Account Details") @@ -101,6 +107,15 @@ func (h *Handler) Execute(ctx context.Context) error { details) } + // Add deployment access status + if deployAccess != nil { + if deployAccess.HasAccess { + details = fmt.Sprintf("%s\nDeploy Access: Enabled", details) + } else { + details = fmt.Sprintf("%s\nDeploy Access: Not enabled", details) + } + } + ui.Box(details) ui.Line() diff --git a/internal/credentials/credentials.go b/internal/credentials/credentials.go index 6b53867b..9b7339b2 100644 --- a/internal/credentials/credentials.go +++ b/internal/credentials/credentials.go @@ -35,8 +35,17 @@ const ( AuthTypeBearer = "bearer" ConfigDir = ".cre" ConfigFile = "cre.yaml" + + // DeploymentAccessStatusFullAccess indicates the organization has full deployment access + DeploymentAccessStatusFullAccess = "FULL_ACCESS" ) +// DeploymentAccess holds information about an organization's deployment access status +type DeploymentAccess struct { + HasAccess bool // Whether the organization has deployment access + Status string // The raw status value (e.g., "FULL_ACCESS", "PENDING", etc.) +} + // UngatedOrgRequiredMsg is the error message shown when an organization does not have ungated access. var UngatedOrgRequiredMsg = "\n✖ Workflow deployment is currently in early access. We're onboarding organizations gradually.\n\nWant to deploy?\n→ Request access here: https://cre.chain.link/request-access\n" @@ -96,36 +105,38 @@ func SaveCredentials(tokenSet *CreLoginTokenSet) error { return nil } -// CheckIsUngatedOrganization verifies that the organization associated with the credentials -// has FULL_ACCESS status (is not gated). This check is required for certain operations like -// workflow key linking. -func (c *Credentials) CheckIsUngatedOrganization() error { - // API keys can only be generated on ungated organizations, so they always pass +// GetDeploymentAccessStatus returns the deployment access status for the organization. +// This can be used to check and display whether the user has deployment access. +func (c *Credentials) GetDeploymentAccessStatus() (*DeploymentAccess, error) { + // API keys can only be generated on ungated organizations, so they always have access if c.AuthType == AuthTypeApiKey { - return nil + return &DeploymentAccess{ + HasAccess: true, + Status: DeploymentAccessStatusFullAccess, + }, nil } // For JWT bearer tokens, we need to parse the token and check the organization_status claim if c.Tokens == nil || c.Tokens.AccessToken == "" { - return fmt.Errorf("no access token available") + return nil, fmt.Errorf("no access token available") } // Parse the JWT to extract claims parts := strings.Split(c.Tokens.AccessToken, ".") if len(parts) < 2 { - return fmt.Errorf("invalid JWT token format") + return nil, fmt.Errorf("invalid JWT token format") } // Decode the payload (second part of the JWT) payload, err := base64.RawURLEncoding.DecodeString(parts[1]) if err != nil { - return fmt.Errorf("failed to decode JWT payload: %w", err) + return nil, fmt.Errorf("failed to decode JWT payload: %w", err) } // Parse claims into a map var claims map[string]interface{} if err := json.Unmarshal(payload, &claims); err != nil { - return fmt.Errorf("failed to unmarshal JWT claims: %w", err) + return nil, fmt.Errorf("failed to unmarshal JWT claims: %w", err) } // Log all claims for debugging @@ -146,17 +157,27 @@ func (c *Credentials) CheckIsUngatedOrganization() error { c.log.Debug().Str("claim_key", orgStatusKey).Str("organization_status", orgStatus).Msg("checking organization status claim") - if orgStatus == "" { - // If the claim is missing or empty, the organization is considered gated - return errors.New(UngatedOrgRequiredMsg) + hasAccess := orgStatus == DeploymentAccessStatusFullAccess + c.log.Debug().Str("organization_status", orgStatus).Bool("has_access", hasAccess).Msg("deployment access status retrieved") + + return &DeploymentAccess{ + HasAccess: hasAccess, + Status: orgStatus, + }, nil +} + +// CheckIsUngatedOrganization verifies that the organization associated with the credentials +// has FULL_ACCESS status (is not gated). This check is required for certain operations like +// workflow key linking. +func (c *Credentials) CheckIsUngatedOrganization() error { + access, err := c.GetDeploymentAccessStatus() + if err != nil { + return err } - // Check if the organization has full access - if orgStatus != "FULL_ACCESS" { - c.log.Debug().Str("organization_status", orgStatus).Msg("organization does not have FULL_ACCESS - organization is gated") + if !access.HasAccess { return errors.New(UngatedOrgRequiredMsg) } - c.log.Debug().Str("organization_status", orgStatus).Msg("organization has FULL_ACCESS - organization is ungated") return nil } From 9981c7bce5e182030f937675c2fd360317928644 Mon Sep 17 00:00:00 2001 From: Emmanuel Jacquier Date: Thu, 29 Jan 2026 19:09:36 -0500 Subject: [PATCH 02/19] Updated gated message with the command to request access --- internal/credentials/credentials.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/credentials/credentials.go b/internal/credentials/credentials.go index 9b7339b2..1dd6dc59 100644 --- a/internal/credentials/credentials.go +++ b/internal/credentials/credentials.go @@ -47,7 +47,7 @@ type DeploymentAccess struct { } // UngatedOrgRequiredMsg is the error message shown when an organization does not have ungated access. -var UngatedOrgRequiredMsg = "\n✖ Workflow deployment is currently in early access. We're onboarding organizations gradually.\n\nWant to deploy?\n→ Request access here: https://cre.chain.link/request-access\n" +var UngatedOrgRequiredMsg = "\n✖ Workflow deployment is currently in early access. We're onboarding organizations gradually.\n\nWant to deploy?\n→ Run 'cre account access' to request access\n" func New(logger *zerolog.Logger) (*Credentials, error) { cfg := &Credentials{ From 42a5c4872a5c3196a0686495be50967c9d05ab8c Mon Sep 17 00:00:00 2001 From: Emmanuel Jacquier Date: Thu, 29 Jan 2026 19:21:02 -0500 Subject: [PATCH 03/19] added new account access command --- cmd/account/access/access.go | 72 ++++++++++++++++++++++++++++++++++++ cmd/account/account.go | 2 + 2 files changed, 74 insertions(+) create mode 100644 cmd/account/access/access.go diff --git a/cmd/account/access/access.go b/cmd/account/access/access.go new file mode 100644 index 00000000..733a1cbd --- /dev/null +++ b/cmd/account/access/access.go @@ -0,0 +1,72 @@ +package access + +import ( + "context" + "fmt" + + "github.com/rs/zerolog" + "github.com/spf13/cobra" + + "github.com/smartcontractkit/cre-cli/internal/credentials" + "github.com/smartcontractkit/cre-cli/internal/runtime" +) + +func New(runtimeCtx *runtime.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: "access", + Short: "Check or request deployment access", + Long: "Check your deployment access status or request access to deploy workflows.", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + h := NewHandler(runtimeCtx) + return h.Execute(cmd.Context()) + }, + } + + return cmd +} + +type Handler struct { + log *zerolog.Logger + credentials *credentials.Credentials +} + +func NewHandler(ctx *runtime.Context) *Handler { + return &Handler{ + log: ctx.Logger, + credentials: ctx.Credentials, + } +} + +func (h *Handler) Execute(ctx context.Context) error { + // Get deployment access status + deployAccess, err := h.credentials.GetDeploymentAccessStatus() + if err != nil { + return fmt.Errorf("failed to check deployment access: %w", err) + } + + if deployAccess.HasAccess { + fmt.Println("") + fmt.Println("You have deployment access enabled for your organization.") + fmt.Println("") + fmt.Println("You're all set to deploy workflows. Get started with:") + fmt.Println("") + fmt.Println(" cre workflow deploy") + fmt.Println("") + fmt.Println("For more information, run 'cre workflow deploy --help'") + fmt.Println("") + return nil + } + + // User doesn't have access - submit request to Zendesk + // TODO: Implement Zendesk request submission + fmt.Println("") + fmt.Println("Deployment access is not enabled for your organization.") + fmt.Println("") + fmt.Println("Submitting access request...") + fmt.Println("") + + // TODO: Call Zendesk API here + + return nil +} diff --git a/cmd/account/account.go b/cmd/account/account.go index d69ec3a9..27deaee5 100644 --- a/cmd/account/account.go +++ b/cmd/account/account.go @@ -3,6 +3,7 @@ package account import ( "github.com/spf13/cobra" + "github.com/smartcontractkit/cre-cli/cmd/account/access" "github.com/smartcontractkit/cre-cli/cmd/account/link_key" "github.com/smartcontractkit/cre-cli/cmd/account/list_key" "github.com/smartcontractkit/cre-cli/cmd/account/unlink_key" @@ -16,6 +17,7 @@ func New(runtimeContext *runtime.Context) *cobra.Command { Long: "Manage your linked public key addresses for workflow operations.", } + accountCmd.AddCommand(access.New(runtimeContext)) accountCmd.AddCommand(link_key.New(runtimeContext)) accountCmd.AddCommand(unlink_key.New(runtimeContext)) accountCmd.AddCommand(list_key.New(runtimeContext)) From ff49d9a37e7292a1f616a4dd99cccfebfefc62c1 Mon Sep 17 00:00:00 2001 From: Emmanuel Jacquier Date: Thu, 29 Jan 2026 19:23:22 -0500 Subject: [PATCH 04/19] added account access command to settings exclusion --- cmd/root.go | 1 + 1 file changed, 1 insertion(+) diff --git a/cmd/root.go b/cmd/root.go index c6bb594c..adc1f4b9 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -364,6 +364,7 @@ func isLoadSettings(cmd *cobra.Command) bool { "cre login": {}, "cre logout": {}, "cre whoami": {}, + "cre account access": {}, "cre account list-key": {}, "cre init": {}, "cre generate-bindings": {}, From 6ca89a8dfd592d9f02e81c8a2b93dae842644ac7 Mon Sep 17 00:00:00 2001 From: Emmanuel Jacquier Date: Thu, 29 Jan 2026 19:28:22 -0500 Subject: [PATCH 05/19] access command logic to submit form to zendesk --- cmd/account/access/access.go | 154 +++++++++++++++++++++++++++++++++-- 1 file changed, 147 insertions(+), 7 deletions(-) diff --git a/cmd/account/access/access.go b/cmd/account/access/access.go index 733a1cbd..6fc1061c 100644 --- a/cmd/account/access/access.go +++ b/cmd/account/access/access.go @@ -1,16 +1,36 @@ package access import ( + "bytes" "context" + "encoding/base64" + "encoding/json" "fmt" + "net/http" + "os" + "github.com/machinebox/graphql" "github.com/rs/zerolog" "github.com/spf13/cobra" + "github.com/smartcontractkit/cre-cli/internal/client/graphqlclient" "github.com/smartcontractkit/cre-cli/internal/credentials" + "github.com/smartcontractkit/cre-cli/internal/environments" "github.com/smartcontractkit/cre-cli/internal/runtime" ) +const ( + // Environment variables for Zendesk credentials + EnvVarZendeskUsername = "CRE_ZENDESK_USERNAME" + EnvVarZendeskPassword = "CRE_ZENDESK_PASSWORD" + + // Zendesk configuration + zendeskAPIURL = "https://chainlinklabs.zendesk.com/api/v2/tickets.json" + zendeskBrandID = "41986419936660" + zendeskRequestTypeField = "41987045113748" + zendeskRequestTypeValue = "cre_customer_deploy_access_request" +) + func New(runtimeCtx *runtime.Context) *cobra.Command { cmd := &cobra.Command{ Use: "access", @@ -27,17 +47,25 @@ func New(runtimeCtx *runtime.Context) *cobra.Command { } type Handler struct { - log *zerolog.Logger - credentials *credentials.Credentials + log *zerolog.Logger + credentials *credentials.Credentials + environmentSet *environments.EnvironmentSet } func NewHandler(ctx *runtime.Context) *Handler { return &Handler{ - log: ctx.Logger, - credentials: ctx.Credentials, + log: ctx.Logger, + credentials: ctx.Credentials, + environmentSet: ctx.EnvironmentSet, } } +type userInfo struct { + Email string + Name string + OrganizationID string +} + func (h *Handler) Execute(ctx context.Context) error { // Get deployment access status deployAccess, err := h.credentials.GetDeploymentAccessStatus() @@ -59,14 +87,126 @@ func (h *Handler) Execute(ctx context.Context) error { } // User doesn't have access - submit request to Zendesk - // TODO: Implement Zendesk request submission fmt.Println("") - fmt.Println("Deployment access is not enabled for your organization.") + fmt.Println("Deployment access is not yet enabled for your organization.") fmt.Println("") + + // Fetch user info for the request + user, err := h.fetchUserInfo(ctx) + if err != nil { + return fmt.Errorf("failed to fetch user info: %w", err) + } + fmt.Println("Submitting access request...") + + if err := h.submitAccessRequest(user); err != nil { + return fmt.Errorf("failed to submit access request: %w", err) + } + + fmt.Println("") + fmt.Println("Access request submitted successfully!") + fmt.Println("") + fmt.Println("Our team will review your request and get back to you shortly.") + fmt.Println("You'll receive a confirmation email at: " + user.Email) fmt.Println("") - // TODO: Call Zendesk API here + return nil +} + +func (h *Handler) fetchUserInfo(ctx context.Context) (*userInfo, error) { + query := ` + query GetAccountDetails { + getAccountDetails { + emailAddress + firstName + lastName + } + getOrganization { + organizationId + } + }` + + client := graphqlclient.New(h.credentials, h.environmentSet, h.log) + req := graphql.NewRequest(query) + + var resp struct { + GetAccountDetails struct { + EmailAddress string `json:"emailAddress"` + FirstName string `json:"firstName"` + LastName string `json:"lastName"` + } `json:"getAccountDetails"` + GetOrganization struct { + OrganizationID string `json:"organizationId"` + } `json:"getOrganization"` + } + + if err := client.Execute(ctx, req, &resp); err != nil { + return nil, fmt.Errorf("graphql request failed: %w", err) + } + + name := resp.GetAccountDetails.FirstName + if resp.GetAccountDetails.LastName != "" { + name += " " + resp.GetAccountDetails.LastName + } + + return &userInfo{ + Email: resp.GetAccountDetails.EmailAddress, + Name: name, + OrganizationID: resp.GetOrganization.OrganizationID, + }, nil +} + +func (h *Handler) submitAccessRequest(user *userInfo) error { + username := os.Getenv(EnvVarZendeskUsername) + password := os.Getenv(EnvVarZendeskPassword) + + if username == "" || password == "" { + return fmt.Errorf("zendesk credentials not configured (set %s and %s environment variables)", EnvVarZendeskUsername, EnvVarZendeskPassword) + } + + ticket := map[string]interface{}{ + "ticket": map[string]interface{}{ + "subject": "CRE Deployment Access Request", + "comment": map[string]interface{}{ + "body": fmt.Sprintf("Deployment access request submitted via CRE CLI.\n\nOrganization ID: %s", user.OrganizationID), + }, + "brand_id": zendeskBrandID, + "custom_fields": []map[string]interface{}{ + { + "id": zendeskRequestTypeField, + "value": zendeskRequestTypeValue, + }, + }, + "requester": map[string]interface{}{ + "name": user.Name, + "email": user.Email, + }, + }, + } + + body, err := json.Marshal(ticket) + if err != nil { + return fmt.Errorf("failed to marshal request: %w", err) + } + + req, err := http.NewRequest(http.MethodPost, zendeskAPIURL, bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + credentials := base64.StdEncoding.EncodeToString([]byte(username + ":" + password)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Basic "+credentials) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("zendesk API returned status %d", resp.StatusCode) + } return nil } From 0882d7399227d9a83d0036a8d431d2f8fb4a1f57 Mon Sep 17 00:00:00 2001 From: Emmanuel Jacquier Date: Thu, 29 Jan 2026 19:44:03 -0500 Subject: [PATCH 06/19] added prompt to request access when running cre account access cmd --- cmd/account/access/access.go | 33 ++++++++++++++++++++++----------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/cmd/account/access/access.go b/cmd/account/access/access.go index 6fc1061c..3345752f 100644 --- a/cmd/account/access/access.go +++ b/cmd/account/access/access.go @@ -6,6 +6,7 @@ import ( "encoding/base64" "encoding/json" "fmt" + "io" "net/http" "os" @@ -16,6 +17,7 @@ import ( "github.com/smartcontractkit/cre-cli/internal/client/graphqlclient" "github.com/smartcontractkit/cre-cli/internal/credentials" "github.com/smartcontractkit/cre-cli/internal/environments" + "github.com/smartcontractkit/cre-cli/internal/prompt" "github.com/smartcontractkit/cre-cli/internal/runtime" ) @@ -38,7 +40,7 @@ func New(runtimeCtx *runtime.Context) *cobra.Command { Long: "Check your deployment access status or request access to deploy workflows.", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { - h := NewHandler(runtimeCtx) + h := NewHandler(runtimeCtx, cmd.InOrStdin()) return h.Execute(cmd.Context()) }, } @@ -50,13 +52,15 @@ type Handler struct { log *zerolog.Logger credentials *credentials.Credentials environmentSet *environments.EnvironmentSet + stdin io.Reader } -func NewHandler(ctx *runtime.Context) *Handler { +func NewHandler(ctx *runtime.Context, stdin io.Reader) *Handler { return &Handler{ log: ctx.Logger, credentials: ctx.Credentials, environmentSet: ctx.EnvironmentSet, + stdin: stdin, } } @@ -86,17 +90,30 @@ func (h *Handler) Execute(ctx context.Context) error { return nil } - // User doesn't have access - submit request to Zendesk + // User doesn't have access - prompt to submit request fmt.Println("") fmt.Println("Deployment access is not yet enabled for your organization.") fmt.Println("") + // Ask user if they want to request access + shouldRequest, err := prompt.YesNoPrompt(h.stdin, "Request deployment access?") + if err != nil { + return fmt.Errorf("failed to get user confirmation: %w", err) + } + + if !shouldRequest { + fmt.Println("") + fmt.Println("Access request canceled.") + return nil + } + // Fetch user info for the request user, err := h.fetchUserInfo(ctx) if err != nil { return fmt.Errorf("failed to fetch user info: %w", err) } + fmt.Println("") fmt.Println("Submitting access request...") if err := h.submitAccessRequest(user); err != nil { @@ -118,8 +135,6 @@ func (h *Handler) fetchUserInfo(ctx context.Context) (*userInfo, error) { query GetAccountDetails { getAccountDetails { emailAddress - firstName - lastName } getOrganization { organizationId @@ -132,8 +147,6 @@ func (h *Handler) fetchUserInfo(ctx context.Context) (*userInfo, error) { var resp struct { GetAccountDetails struct { EmailAddress string `json:"emailAddress"` - FirstName string `json:"firstName"` - LastName string `json:"lastName"` } `json:"getAccountDetails"` GetOrganization struct { OrganizationID string `json:"organizationId"` @@ -144,10 +157,8 @@ func (h *Handler) fetchUserInfo(ctx context.Context) (*userInfo, error) { return nil, fmt.Errorf("graphql request failed: %w", err) } - name := resp.GetAccountDetails.FirstName - if resp.GetAccountDetails.LastName != "" { - name += " " + resp.GetAccountDetails.LastName - } + // Use email as name since firstName/lastName are not available in the schema + name := resp.GetAccountDetails.EmailAddress return &userInfo{ Email: resp.GetAccountDetails.EmailAddress, From 1a5eb0ad10583a9b0a4e7e5acfb60609e9e2d587 Mon Sep 17 00:00:00 2001 From: Emmanuel Jacquier Date: Thu, 29 Jan 2026 21:13:51 -0500 Subject: [PATCH 07/19] Refactor access request logic into shared package and add deploy access check to workflow deploy command --- cmd/account/access/access.go | 166 +--------------------- cmd/root.go | 2 +- cmd/workflow/deploy/deploy.go | 17 ++- internal/accessrequest/accessrequest.go | 179 ++++++++++++++++++++++++ 4 files changed, 200 insertions(+), 164 deletions(-) create mode 100644 internal/accessrequest/accessrequest.go diff --git a/cmd/account/access/access.go b/cmd/account/access/access.go index 3345752f..e74e3fe5 100644 --- a/cmd/account/access/access.go +++ b/cmd/account/access/access.go @@ -1,38 +1,18 @@ package access import ( - "bytes" "context" - "encoding/base64" - "encoding/json" "fmt" - "io" - "net/http" - "os" - "github.com/machinebox/graphql" "github.com/rs/zerolog" "github.com/spf13/cobra" - "github.com/smartcontractkit/cre-cli/internal/client/graphqlclient" + "github.com/smartcontractkit/cre-cli/internal/accessrequest" "github.com/smartcontractkit/cre-cli/internal/credentials" "github.com/smartcontractkit/cre-cli/internal/environments" - "github.com/smartcontractkit/cre-cli/internal/prompt" "github.com/smartcontractkit/cre-cli/internal/runtime" ) -const ( - // Environment variables for Zendesk credentials - EnvVarZendeskUsername = "CRE_ZENDESK_USERNAME" - EnvVarZendeskPassword = "CRE_ZENDESK_PASSWORD" - - // Zendesk configuration - zendeskAPIURL = "https://chainlinklabs.zendesk.com/api/v2/tickets.json" - zendeskBrandID = "41986419936660" - zendeskRequestTypeField = "41987045113748" - zendeskRequestTypeValue = "cre_customer_deploy_access_request" -) - func New(runtimeCtx *runtime.Context) *cobra.Command { cmd := &cobra.Command{ Use: "access", @@ -52,26 +32,19 @@ type Handler struct { log *zerolog.Logger credentials *credentials.Credentials environmentSet *environments.EnvironmentSet - stdin io.Reader + requester *accessrequest.Requester } -func NewHandler(ctx *runtime.Context, stdin io.Reader) *Handler { +func NewHandler(ctx *runtime.Context, stdin interface{ Read([]byte) (int, error) }) *Handler { return &Handler{ log: ctx.Logger, credentials: ctx.Credentials, environmentSet: ctx.EnvironmentSet, - stdin: stdin, + requester: accessrequest.NewRequester(ctx.Credentials, ctx.EnvironmentSet, ctx.Logger, stdin), } } -type userInfo struct { - Email string - Name string - OrganizationID string -} - func (h *Handler) Execute(ctx context.Context) error { - // Get deployment access status deployAccess, err := h.credentials.GetDeploymentAccessStatus() if err != nil { return fmt.Errorf("failed to check deployment access: %w", err) @@ -90,134 +63,5 @@ func (h *Handler) Execute(ctx context.Context) error { return nil } - // User doesn't have access - prompt to submit request - fmt.Println("") - fmt.Println("Deployment access is not yet enabled for your organization.") - fmt.Println("") - - // Ask user if they want to request access - shouldRequest, err := prompt.YesNoPrompt(h.stdin, "Request deployment access?") - if err != nil { - return fmt.Errorf("failed to get user confirmation: %w", err) - } - - if !shouldRequest { - fmt.Println("") - fmt.Println("Access request canceled.") - return nil - } - - // Fetch user info for the request - user, err := h.fetchUserInfo(ctx) - if err != nil { - return fmt.Errorf("failed to fetch user info: %w", err) - } - - fmt.Println("") - fmt.Println("Submitting access request...") - - if err := h.submitAccessRequest(user); err != nil { - return fmt.Errorf("failed to submit access request: %w", err) - } - - fmt.Println("") - fmt.Println("Access request submitted successfully!") - fmt.Println("") - fmt.Println("Our team will review your request and get back to you shortly.") - fmt.Println("You'll receive a confirmation email at: " + user.Email) - fmt.Println("") - - return nil -} - -func (h *Handler) fetchUserInfo(ctx context.Context) (*userInfo, error) { - query := ` - query GetAccountDetails { - getAccountDetails { - emailAddress - } - getOrganization { - organizationId - } - }` - - client := graphqlclient.New(h.credentials, h.environmentSet, h.log) - req := graphql.NewRequest(query) - - var resp struct { - GetAccountDetails struct { - EmailAddress string `json:"emailAddress"` - } `json:"getAccountDetails"` - GetOrganization struct { - OrganizationID string `json:"organizationId"` - } `json:"getOrganization"` - } - - if err := client.Execute(ctx, req, &resp); err != nil { - return nil, fmt.Errorf("graphql request failed: %w", err) - } - - // Use email as name since firstName/lastName are not available in the schema - name := resp.GetAccountDetails.EmailAddress - - return &userInfo{ - Email: resp.GetAccountDetails.EmailAddress, - Name: name, - OrganizationID: resp.GetOrganization.OrganizationID, - }, nil -} - -func (h *Handler) submitAccessRequest(user *userInfo) error { - username := os.Getenv(EnvVarZendeskUsername) - password := os.Getenv(EnvVarZendeskPassword) - - if username == "" || password == "" { - return fmt.Errorf("zendesk credentials not configured (set %s and %s environment variables)", EnvVarZendeskUsername, EnvVarZendeskPassword) - } - - ticket := map[string]interface{}{ - "ticket": map[string]interface{}{ - "subject": "CRE Deployment Access Request", - "comment": map[string]interface{}{ - "body": fmt.Sprintf("Deployment access request submitted via CRE CLI.\n\nOrganization ID: %s", user.OrganizationID), - }, - "brand_id": zendeskBrandID, - "custom_fields": []map[string]interface{}{ - { - "id": zendeskRequestTypeField, - "value": zendeskRequestTypeValue, - }, - }, - "requester": map[string]interface{}{ - "name": user.Name, - "email": user.Email, - }, - }, - } - - body, err := json.Marshal(ticket) - if err != nil { - return fmt.Errorf("failed to marshal request: %w", err) - } - - req, err := http.NewRequest(http.MethodPost, zendeskAPIURL, bytes.NewReader(body)) - if err != nil { - return fmt.Errorf("failed to create request: %w", err) - } - - credentials := base64.StdEncoding.EncodeToString([]byte(username + ":" + password)) - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", "Basic "+credentials) - - resp, err := http.DefaultClient.Do(req) - if err != nil { - return fmt.Errorf("failed to send request: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("zendesk API returned status %d", resp.StatusCode) - } - - return nil + return h.requester.PromptAndSubmitRequest(ctx) } diff --git a/cmd/root.go b/cmd/root.go index adc1f4b9..845d8b44 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -189,7 +189,7 @@ func newRootCommand() *cobra.Command { // Check if organization is ungated for commands that require it cmdPath := cmd.CommandPath() - if cmdPath == "cre account link-key" || cmdPath == "cre workflow deploy" { + if cmdPath == "cre account link-key" { if err := runtimeContext.Credentials.CheckIsUngatedOrganization(); err != nil { if showSpinner { spinner.Stop() diff --git a/cmd/workflow/deploy/deploy.go b/cmd/workflow/deploy/deploy.go index 59c303d8..31393e65 100644 --- a/cmd/workflow/deploy/deploy.go +++ b/cmd/workflow/deploy/deploy.go @@ -1,6 +1,7 @@ package deploy import ( + "context" "errors" "fmt" "io" @@ -13,6 +14,7 @@ import ( "github.com/spf13/viper" "github.com/smartcontractkit/cre-cli/cmd/client" + "github.com/smartcontractkit/cre-cli/internal/accessrequest" "github.com/smartcontractkit/cre-cli/internal/constants" "github.com/smartcontractkit/cre-cli/internal/credentials" "github.com/smartcontractkit/cre-cli/internal/environments" @@ -62,6 +64,7 @@ type handler struct { workflowArtifact *workflowArtifact wrc *client.WorkflowRegistryV2Client runtimeContext *runtime.Context + accessRequester *accessrequest.Requester validated bool @@ -94,7 +97,7 @@ func New(runtimeContext *runtime.Context) *cobra.Command { if err := h.ValidateInputs(); err != nil { return err } - return h.Execute() + return h.Execute(cmd.Context()) }, } @@ -118,6 +121,7 @@ func newHandler(ctx *runtime.Context, stdin io.Reader) *handler { workflowArtifact: &workflowArtifact{}, wrc: nil, runtimeContext: ctx, + accessRequester: accessrequest.NewRequester(ctx.Credentials, ctx.EnvironmentSet, ctx.Logger, stdin), validated: false, wg: sync.WaitGroup{}, wrcErr: nil, @@ -177,7 +181,16 @@ func (h *handler) ValidateInputs() error { return nil } -func (h *handler) Execute() error { +func (h *handler) Execute(ctx context.Context) error { + deployAccess, err := h.credentials.GetDeploymentAccessStatus() + if err != nil { + return fmt.Errorf("failed to check deployment access: %w", err) + } + + if !deployAccess.HasAccess { + return h.accessRequester.PromptAndSubmitRequest(ctx) + } + h.displayWorkflowDetails() if err := h.Compile(); err != nil { diff --git a/internal/accessrequest/accessrequest.go b/internal/accessrequest/accessrequest.go new file mode 100644 index 00000000..b8ff2576 --- /dev/null +++ b/internal/accessrequest/accessrequest.go @@ -0,0 +1,179 @@ +package accessrequest + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + + "github.com/machinebox/graphql" + "github.com/rs/zerolog" + + "github.com/smartcontractkit/cre-cli/internal/client/graphqlclient" + "github.com/smartcontractkit/cre-cli/internal/credentials" + "github.com/smartcontractkit/cre-cli/internal/environments" + "github.com/smartcontractkit/cre-cli/internal/prompt" +) + +const ( + EnvVarZendeskUsername = "CRE_ZENDESK_USERNAME" + EnvVarZendeskPassword = "CRE_ZENDESK_PASSWORD" + + zendeskAPIURL = "https://chainlinklabs.zendesk.com/api/v2/tickets.json" + zendeskBrandID = "41986419936660" + zendeskRequestTypeField = "41987045113748" + zendeskRequestTypeValue = "cre_customer_deploy_access_request" +) + +type UserInfo struct { + Email string + Name string + OrganizationID string +} + +type Requester struct { + credentials *credentials.Credentials + environmentSet *environments.EnvironmentSet + log *zerolog.Logger + stdin io.Reader +} + +func NewRequester(creds *credentials.Credentials, envSet *environments.EnvironmentSet, log *zerolog.Logger, stdin io.Reader) *Requester { + return &Requester{ + credentials: creds, + environmentSet: envSet, + log: log, + stdin: stdin, + } +} + +func (r *Requester) PromptAndSubmitRequest(ctx context.Context) error { + fmt.Println("") + fmt.Println("Deployment access is not yet enabled for your organization.") + fmt.Println("") + + shouldRequest, err := prompt.YesNoPrompt(r.stdin, "Request deployment access?") + if err != nil { + return fmt.Errorf("failed to get user confirmation: %w", err) + } + + if !shouldRequest { + fmt.Println("") + fmt.Println("Access request canceled.") + return nil + } + + user, err := r.FetchUserInfo(ctx) + if err != nil { + return fmt.Errorf("failed to fetch user info: %w", err) + } + + fmt.Println("") + fmt.Println("Submitting access request...") + + if err := r.SubmitAccessRequest(user); err != nil { + return fmt.Errorf("failed to submit access request: %w", err) + } + + fmt.Println("") + fmt.Println("Access request submitted successfully!") + fmt.Println("") + fmt.Println("Our team will review your request and get back to you shortly.") + fmt.Println("You'll receive a confirmation email at: " + user.Email) + fmt.Println("") + + return nil +} + +func (r *Requester) FetchUserInfo(ctx context.Context) (*UserInfo, error) { + query := ` + query GetAccountDetails { + getAccountDetails { + emailAddress + } + getOrganization { + organizationId + } + }` + + client := graphqlclient.New(r.credentials, r.environmentSet, r.log) + req := graphql.NewRequest(query) + + var resp struct { + GetAccountDetails struct { + EmailAddress string `json:"emailAddress"` + } `json:"getAccountDetails"` + GetOrganization struct { + OrganizationID string `json:"organizationId"` + } `json:"getOrganization"` + } + + if err := client.Execute(ctx, req, &resp); err != nil { + return nil, fmt.Errorf("graphql request failed: %w", err) + } + + return &UserInfo{ + Email: resp.GetAccountDetails.EmailAddress, + Name: resp.GetAccountDetails.EmailAddress, + OrganizationID: resp.GetOrganization.OrganizationID, + }, nil +} + +func (r *Requester) SubmitAccessRequest(user *UserInfo) error { + username := os.Getenv(EnvVarZendeskUsername) + password := os.Getenv(EnvVarZendeskPassword) + + if username == "" || password == "" { + return fmt.Errorf("zendesk credentials not configured (set %s and %s environment variables)", EnvVarZendeskUsername, EnvVarZendeskPassword) + } + + ticket := map[string]interface{}{ + "ticket": map[string]interface{}{ + "subject": "CRE Deployment Access Request", + "comment": map[string]interface{}{ + "body": fmt.Sprintf("Deployment access request submitted via CRE CLI.\n\nOrganization ID: %s", user.OrganizationID), + }, + "brand_id": zendeskBrandID, + "custom_fields": []map[string]interface{}{ + { + "id": zendeskRequestTypeField, + "value": zendeskRequestTypeValue, + }, + }, + "requester": map[string]interface{}{ + "name": user.Name, + "email": user.Email, + }, + }, + } + + body, err := json.Marshal(ticket) + if err != nil { + return fmt.Errorf("failed to marshal request: %w", err) + } + + req, err := http.NewRequest(http.MethodPost, zendeskAPIURL, bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + creds := base64.StdEncoding.EncodeToString([]byte(username + ":" + password)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Basic "+creds) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("zendesk API returned status %d", resp.StatusCode) + } + + return nil +} From 6222ef62b04e707eb4a416d63b1b86be45384ed1 Mon Sep 17 00:00:00 2001 From: Emmanuel Jacquier Date: Thu, 29 Jan 2026 21:24:11 -0500 Subject: [PATCH 08/19] Fix background goroutine error appearing during deploy access prompt --- cmd/workflow/deploy/deploy.go | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/cmd/workflow/deploy/deploy.go b/cmd/workflow/deploy/deploy.go index 31393e65..ca5846ed 100644 --- a/cmd/workflow/deploy/deploy.go +++ b/cmd/workflow/deploy/deploy.go @@ -126,6 +126,11 @@ func newHandler(ctx *runtime.Context, stdin io.Reader) *handler { wg: sync.WaitGroup{}, wrcErr: nil, } + + return &h +} + +func (h *handler) initWorkflowRegistryClient() { h.wg.Add(1) go func() { defer h.wg.Done() @@ -136,8 +141,6 @@ func newHandler(ctx *runtime.Context, stdin io.Reader) *handler { } h.wrc = wrc }() - - return &h } func (h *handler) ResolveInputs(v *viper.Viper) (Inputs, error) { @@ -191,6 +194,8 @@ func (h *handler) Execute(ctx context.Context) error { return h.accessRequester.PromptAndSubmitRequest(ctx) } + h.initWorkflowRegistryClient() + h.displayWorkflowDetails() if err := h.Compile(); err != nil { From 4af0fa90a3db6c510e48ec90f514d4b12b9189d4 Mon Sep 17 00:00:00 2001 From: Emmanuel Jacquier Date: Thu, 29 Jan 2026 21:29:44 -0500 Subject: [PATCH 09/19] Update account command description to mention deploy access --- cmd/account/account.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/account/account.go b/cmd/account/account.go index 27deaee5..bc96644c 100644 --- a/cmd/account/account.go +++ b/cmd/account/account.go @@ -13,8 +13,8 @@ import ( func New(runtimeContext *runtime.Context) *cobra.Command { accountCmd := &cobra.Command{ Use: "account", - Short: "Manages account", - Long: "Manage your linked public key addresses for workflow operations.", + Short: "Manage account and request deploy access", + Long: "Manage your linked public key addresses for workflow operations and request deployment access.", } accountCmd.AddCommand(access.New(runtimeContext)) From 6d74f5653c270050b29452c36a3de3411af9f17a Mon Sep 17 00:00:00 2001 From: Emmanuel Jacquier Date: Thu, 29 Jan 2026 21:42:49 -0500 Subject: [PATCH 10/19] Show deploy access hint after successful workflow simulation --- cmd/workflow/simulate/simulate.go | 34 ++++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/cmd/workflow/simulate/simulate.go b/cmd/workflow/simulate/simulate.go index 373a82bf..75c519bf 100644 --- a/cmd/workflow/simulate/simulate.go +++ b/cmd/workflow/simulate/simulate.go @@ -41,6 +41,7 @@ import ( cmdcommon "github.com/smartcontractkit/cre-cli/cmd/common" "github.com/smartcontractkit/cre-cli/internal/constants" + "github.com/smartcontractkit/cre-cli/internal/credentials" "github.com/smartcontractkit/cre-cli/internal/runtime" "github.com/smartcontractkit/cre-cli/internal/settings" "github.com/smartcontractkit/cre-cli/internal/ui" @@ -102,6 +103,7 @@ func New(runtimeContext *runtime.Context) *cobra.Command { type handler struct { log *zerolog.Logger runtimeContext *runtime.Context + credentials *credentials.Credentials validated bool } @@ -109,6 +111,7 @@ func newHandler(ctx *runtime.Context) *handler { return &handler{ log: ctx.Logger, runtimeContext: ctx, + credentials: ctx.Credentials, validated: false, } } @@ -340,7 +343,36 @@ func (h *handler) Execute(inputs Inputs) error { // if logger instance is set to DEBUG, that means verbosity flag is set by the user verbosity := h.log.GetLevel() == zerolog.DebugLevel - return run(ctx, wasmFileBinary, config, secrets, inputs, verbosity) + err = run(ctx, wasmFileBinary, config, secrets, inputs, verbosity) + if err != nil { + return err + } + + h.showDeployAccessHint() + + return nil +} + +func (h *handler) showDeployAccessHint() { + if h.credentials == nil { + return + } + + deployAccess, err := h.credentials.GetDeploymentAccessStatus() + if err != nil { + return + } + + if !deployAccess.HasAccess { + fmt.Println("") + fmt.Println("─────────────────────────────────────────────────────────────") + fmt.Println("") + fmt.Println(" Simulation complete! Ready to deploy your workflow?") + fmt.Println("") + fmt.Println(" Run 'cre account access' to request deployment access.") + fmt.Println("") + fmt.Println("─────────────────────────────────────────────────────────────") + } } // run instantiates the engine, starts it and blocks until the context is canceled. From 5a24a1c41d13f07893fc87fee1ecb02492c6f6ef Mon Sep 17 00:00:00 2001 From: Emmanuel Jacquier Date: Thu, 29 Jan 2026 21:55:32 -0500 Subject: [PATCH 11/19] Add deploy access hint to global help template for gated users --- cmd/root.go | 18 ++++++++++++++++++ cmd/template/help_template.tpl | 6 ++++++ 2 files changed, 24 insertions(+) diff --git a/cmd/root.go b/cmd/root.go index 845d8b44..a3be57b2 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -26,6 +26,7 @@ import ( "github.com/smartcontractkit/cre-cli/cmd/workflow" "github.com/smartcontractkit/cre-cli/internal/constants" "github.com/smartcontractkit/cre-cli/internal/context" + "github.com/smartcontractkit/cre-cli/internal/credentials" "github.com/smartcontractkit/cre-cli/internal/logger" "github.com/smartcontractkit/cre-cli/internal/runtime" "github.com/smartcontractkit/cre-cli/internal/settings" @@ -254,6 +255,7 @@ func newRootCommand() *cobra.Command { return false }) +<<<<<<< HEAD // Lipgloss-styled template functions for help (using Chainlink brand colors) cobra.AddTemplateFunc("styleTitle", func(s string) string { return ui.TitleStyle.Render(s) @@ -275,6 +277,22 @@ func newRootCommand() *cobra.Command { }) cobra.AddTemplateFunc("styleURL", func(s string) string { return ui.URLStyle.Render(s) // Chainlink Blue, underlined +======= + cobra.AddTemplateFunc("needsDeployAccess", func() bool { + creds := runtimeContext.Credentials + if creds == nil { + var err error + creds, err = credentials.New(rootLogger) + if err != nil { + return false + } + } + deployAccess, err := creds.GetDeploymentAccessStatus() + if err != nil { + return false + } + return !deployAccess.HasAccess +>>>>>>> eea3004 (Add deploy access hint to global help template for gated users) }) rootCmd.SetHelpTemplate(helpTemplate) diff --git a/cmd/template/help_template.tpl b/cmd/template/help_template.tpl index f91585f6..f2d04869 100644 --- a/cmd/template/help_template.tpl +++ b/cmd/template/help_template.tpl @@ -91,6 +91,12 @@ to login into your cre account, then: {{styleCode "$ cre init"}} to create your first cre project. +{{- if needsDeployAccess}} + +🔑 Ready to deploy? Run: + $ cre account access + to request deployment access. +{{- end}} {{styleSection "Need more help?"}} Visit {{styleURL "https://docs.chain.link/cre"}} From 80834e9d592d7b03666cf5131be7c18d65419e54 Mon Sep 17 00:00:00 2001 From: Emmanuel Jacquier Date: Fri, 30 Jan 2026 10:47:40 -0500 Subject: [PATCH 12/19] Added prompt to describe use cases when request access request --- internal/accessrequest/accessrequest.go | 34 +++++++++++++++++++++---- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/internal/accessrequest/accessrequest.go b/internal/accessrequest/accessrequest.go index b8ff2576..594e10f8 100644 --- a/internal/accessrequest/accessrequest.go +++ b/internal/accessrequest/accessrequest.go @@ -1,6 +1,7 @@ package accessrequest import ( + "bufio" "bytes" "context" "encoding/base64" @@ -9,6 +10,7 @@ import ( "io" "net/http" "os" + "strings" "github.com/machinebox/graphql" "github.com/rs/zerolog" @@ -67,6 +69,21 @@ func (r *Requester) PromptAndSubmitRequest(ctx context.Context) error { return nil } + fmt.Println("") + fmt.Println("Briefly describe your use case (what are you building with CRE?):") + fmt.Print("> ") + + reader := bufio.NewReader(r.stdin) + useCase, err := reader.ReadString('\n') + if err != nil { + return fmt.Errorf("failed to read use case: %w", err) + } + useCase = strings.TrimSpace(useCase) + + if useCase == "" { + return fmt.Errorf("use case description is required") + } + user, err := r.FetchUserInfo(ctx) if err != nil { return fmt.Errorf("failed to fetch user info: %w", err) @@ -75,7 +92,7 @@ func (r *Requester) PromptAndSubmitRequest(ctx context.Context) error { fmt.Println("") fmt.Println("Submitting access request...") - if err := r.SubmitAccessRequest(user); err != nil { + if err := r.SubmitAccessRequest(user, useCase); err != nil { return fmt.Errorf("failed to submit access request: %w", err) } @@ -123,7 +140,7 @@ func (r *Requester) FetchUserInfo(ctx context.Context) (*UserInfo, error) { }, nil } -func (r *Requester) SubmitAccessRequest(user *UserInfo) error { +func (r *Requester) SubmitAccessRequest(user *UserInfo, useCase string) error { username := os.Getenv(EnvVarZendeskUsername) password := os.Getenv(EnvVarZendeskPassword) @@ -131,11 +148,18 @@ func (r *Requester) SubmitAccessRequest(user *UserInfo) error { return fmt.Errorf("zendesk credentials not configured (set %s and %s environment variables)", EnvVarZendeskUsername, EnvVarZendeskPassword) } + body := fmt.Sprintf(`Deployment access request submitted via CRE CLI. + +Organization ID: %s + +Use Case: +%s`, user.OrganizationID, useCase) + ticket := map[string]interface{}{ "ticket": map[string]interface{}{ "subject": "CRE Deployment Access Request", "comment": map[string]interface{}{ - "body": fmt.Sprintf("Deployment access request submitted via CRE CLI.\n\nOrganization ID: %s", user.OrganizationID), + "body": body, }, "brand_id": zendeskBrandID, "custom_fields": []map[string]interface{}{ @@ -151,12 +175,12 @@ func (r *Requester) SubmitAccessRequest(user *UserInfo) error { }, } - body, err := json.Marshal(ticket) + jsonBody, err := json.Marshal(ticket) if err != nil { return fmt.Errorf("failed to marshal request: %w", err) } - req, err := http.NewRequest(http.MethodPost, zendeskAPIURL, bytes.NewReader(body)) + req, err := http.NewRequest(http.MethodPost, zendeskAPIURL, bytes.NewReader(jsonBody)) if err != nil { return fmt.Errorf("failed to create request: %w", err) } From ef2d10c9c6ed75315239018e343ca85fc426bc22 Mon Sep 17 00:00:00 2001 From: Emmanuel Jacquier Date: Fri, 30 Jan 2026 11:05:13 -0500 Subject: [PATCH 13/19] update code so that request is sent to a proxy that will take care of submitting the request to zendesk --- cmd/account/access/access.go | 22 ++-- cmd/workflow/deploy/deploy.go | 4 +- internal/accessrequest/accessrequest.go | 142 +++++++----------------- 3 files changed, 50 insertions(+), 118 deletions(-) diff --git a/cmd/account/access/access.go b/cmd/account/access/access.go index e74e3fe5..b37e9801 100644 --- a/cmd/account/access/access.go +++ b/cmd/account/access/access.go @@ -1,7 +1,6 @@ package access import ( - "context" "fmt" "github.com/rs/zerolog" @@ -9,7 +8,6 @@ import ( "github.com/smartcontractkit/cre-cli/internal/accessrequest" "github.com/smartcontractkit/cre-cli/internal/credentials" - "github.com/smartcontractkit/cre-cli/internal/environments" "github.com/smartcontractkit/cre-cli/internal/runtime" ) @@ -21,7 +19,7 @@ func New(runtimeCtx *runtime.Context) *cobra.Command { Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { h := NewHandler(runtimeCtx, cmd.InOrStdin()) - return h.Execute(cmd.Context()) + return h.Execute() }, } @@ -29,22 +27,20 @@ func New(runtimeCtx *runtime.Context) *cobra.Command { } type Handler struct { - log *zerolog.Logger - credentials *credentials.Credentials - environmentSet *environments.EnvironmentSet - requester *accessrequest.Requester + log *zerolog.Logger + credentials *credentials.Credentials + requester *accessrequest.Requester } func NewHandler(ctx *runtime.Context, stdin interface{ Read([]byte) (int, error) }) *Handler { return &Handler{ - log: ctx.Logger, - credentials: ctx.Credentials, - environmentSet: ctx.EnvironmentSet, - requester: accessrequest.NewRequester(ctx.Credentials, ctx.EnvironmentSet, ctx.Logger, stdin), + log: ctx.Logger, + credentials: ctx.Credentials, + requester: accessrequest.NewRequester(ctx.Credentials, ctx.Logger, stdin), } } -func (h *Handler) Execute(ctx context.Context) error { +func (h *Handler) Execute() error { deployAccess, err := h.credentials.GetDeploymentAccessStatus() if err != nil { return fmt.Errorf("failed to check deployment access: %w", err) @@ -63,5 +59,5 @@ func (h *Handler) Execute(ctx context.Context) error { return nil } - return h.requester.PromptAndSubmitRequest(ctx) + return h.requester.PromptAndSubmitRequest() } diff --git a/cmd/workflow/deploy/deploy.go b/cmd/workflow/deploy/deploy.go index ca5846ed..a1dc9a56 100644 --- a/cmd/workflow/deploy/deploy.go +++ b/cmd/workflow/deploy/deploy.go @@ -121,7 +121,7 @@ func newHandler(ctx *runtime.Context, stdin io.Reader) *handler { workflowArtifact: &workflowArtifact{}, wrc: nil, runtimeContext: ctx, - accessRequester: accessrequest.NewRequester(ctx.Credentials, ctx.EnvironmentSet, ctx.Logger, stdin), + accessRequester: accessrequest.NewRequester(ctx.Credentials, ctx.Logger, stdin), validated: false, wg: sync.WaitGroup{}, wrcErr: nil, @@ -191,7 +191,7 @@ func (h *handler) Execute(ctx context.Context) error { } if !deployAccess.HasAccess { - return h.accessRequester.PromptAndSubmitRequest(ctx) + return h.accessRequester.PromptAndSubmitRequest() } h.initWorkflowRegistryClient() diff --git a/internal/accessrequest/accessrequest.go b/internal/accessrequest/accessrequest.go index 594e10f8..5c87dc2c 100644 --- a/internal/accessrequest/accessrequest.go +++ b/internal/accessrequest/accessrequest.go @@ -3,8 +3,6 @@ package accessrequest import ( "bufio" "bytes" - "context" - "encoding/base64" "encoding/json" "fmt" "io" @@ -12,48 +10,35 @@ import ( "os" "strings" - "github.com/machinebox/graphql" "github.com/rs/zerolog" - "github.com/smartcontractkit/cre-cli/internal/client/graphqlclient" "github.com/smartcontractkit/cre-cli/internal/credentials" - "github.com/smartcontractkit/cre-cli/internal/environments" "github.com/smartcontractkit/cre-cli/internal/prompt" ) const ( - EnvVarZendeskUsername = "CRE_ZENDESK_USERNAME" - EnvVarZendeskPassword = "CRE_ZENDESK_PASSWORD" - - zendeskAPIURL = "https://chainlinklabs.zendesk.com/api/v2/tickets.json" - zendeskBrandID = "41986419936660" - zendeskRequestTypeField = "41987045113748" - zendeskRequestTypeValue = "cre_customer_deploy_access_request" + EnvVarAccessRequestURL = "CRE_ACCESS_REQUEST_URL" ) -type UserInfo struct { - Email string - Name string - OrganizationID string +type AccessRequest struct { + UseCase string `json:"useCase"` } type Requester struct { - credentials *credentials.Credentials - environmentSet *environments.EnvironmentSet - log *zerolog.Logger - stdin io.Reader + credentials *credentials.Credentials + log *zerolog.Logger + stdin io.Reader } -func NewRequester(creds *credentials.Credentials, envSet *environments.EnvironmentSet, log *zerolog.Logger, stdin io.Reader) *Requester { +func NewRequester(creds *credentials.Credentials, log *zerolog.Logger, stdin io.Reader) *Requester { return &Requester{ - credentials: creds, - environmentSet: envSet, - log: log, - stdin: stdin, + credentials: creds, + log: log, + stdin: stdin, } } -func (r *Requester) PromptAndSubmitRequest(ctx context.Context) error { +func (r *Requester) PromptAndSubmitRequest() error { fmt.Println("") fmt.Println("Deployment access is not yet enabled for your organization.") fmt.Println("") @@ -84,15 +69,10 @@ func (r *Requester) PromptAndSubmitRequest(ctx context.Context) error { return fmt.Errorf("use case description is required") } - user, err := r.FetchUserInfo(ctx) - if err != nil { - return fmt.Errorf("failed to fetch user info: %w", err) - } - fmt.Println("") fmt.Println("Submitting access request...") - if err := r.SubmitAccessRequest(user, useCase); err != nil { + if err := r.SubmitAccessRequest(useCase); err != nil { return fmt.Errorf("failed to submit access request: %w", err) } @@ -100,94 +80,50 @@ func (r *Requester) PromptAndSubmitRequest(ctx context.Context) error { fmt.Println("Access request submitted successfully!") fmt.Println("") fmt.Println("Our team will review your request and get back to you shortly.") - fmt.Println("You'll receive a confirmation email at: " + user.Email) fmt.Println("") return nil } -func (r *Requester) FetchUserInfo(ctx context.Context) (*UserInfo, error) { - query := ` - query GetAccountDetails { - getAccountDetails { - emailAddress - } - getOrganization { - organizationId - } - }` - - client := graphqlclient.New(r.credentials, r.environmentSet, r.log) - req := graphql.NewRequest(query) - - var resp struct { - GetAccountDetails struct { - EmailAddress string `json:"emailAddress"` - } `json:"getAccountDetails"` - GetOrganization struct { - OrganizationID string `json:"organizationId"` - } `json:"getOrganization"` - } - - if err := client.Execute(ctx, req, &resp); err != nil { - return nil, fmt.Errorf("graphql request failed: %w", err) - } - - return &UserInfo{ - Email: resp.GetAccountDetails.EmailAddress, - Name: resp.GetAccountDetails.EmailAddress, - OrganizationID: resp.GetOrganization.OrganizationID, - }, nil -} - -func (r *Requester) SubmitAccessRequest(user *UserInfo, useCase string) error { - username := os.Getenv(EnvVarZendeskUsername) - password := os.Getenv(EnvVarZendeskPassword) - - if username == "" || password == "" { - return fmt.Errorf("zendesk credentials not configured (set %s and %s environment variables)", EnvVarZendeskUsername, EnvVarZendeskPassword) +func (r *Requester) SubmitAccessRequest(useCase string) error { + apiURL := os.Getenv(EnvVarAccessRequestURL) + if apiURL == "" { + return fmt.Errorf("access request API URL not configured (set %s environment variable)", EnvVarAccessRequestURL) } - body := fmt.Sprintf(`Deployment access request submitted via CRE CLI. - -Organization ID: %s - -Use Case: -%s`, user.OrganizationID, useCase) - - ticket := map[string]interface{}{ - "ticket": map[string]interface{}{ - "subject": "CRE Deployment Access Request", - "comment": map[string]interface{}{ - "body": body, - }, - "brand_id": zendeskBrandID, - "custom_fields": []map[string]interface{}{ - { - "id": zendeskRequestTypeField, - "value": zendeskRequestTypeValue, - }, - }, - "requester": map[string]interface{}{ - "name": user.Name, - "email": user.Email, - }, - }, + reqBody := AccessRequest{ + UseCase: useCase, } - jsonBody, err := json.Marshal(ticket) + jsonBody, err := json.MarshalIndent(reqBody, "", " ") if err != nil { return fmt.Errorf("failed to marshal request: %w", err) } - req, err := http.NewRequest(http.MethodPost, zendeskAPIURL, bytes.NewReader(jsonBody)) + if r.credentials.Tokens == nil || r.credentials.Tokens.AccessToken == "" { + return fmt.Errorf("no access token available - please run 'cre login' first") + } + token := r.credentials.Tokens.AccessToken + + fmt.Println("") + fmt.Println("Request Details:") + fmt.Println("----------------") + fmt.Printf("URL: %s\n", apiURL) + fmt.Printf("Method: POST\n") + fmt.Println("Headers:") + fmt.Println(" Content-Type: application/json") + fmt.Printf(" Authorization: Bearer %s\n", token) + fmt.Println("Body:") + fmt.Println(string(jsonBody)) + fmt.Println("----------------") + + req, err := http.NewRequest(http.MethodPost, apiURL, bytes.NewReader(jsonBody)) if err != nil { return fmt.Errorf("failed to create request: %w", err) } - creds := base64.StdEncoding.EncodeToString([]byte(username + ":" + password)) req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", "Basic "+creds) + req.Header.Set("Authorization", "Bearer "+token) resp, err := http.DefaultClient.Do(req) if err != nil { @@ -196,7 +132,7 @@ Use Case: defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("zendesk API returned status %d", resp.StatusCode) + return fmt.Errorf("access request API returned status %d", resp.StatusCode) } return nil From 1a26d580284eb9280e90970f34954f7da636e0e7 Mon Sep 17 00:00:00 2001 From: Emmanuel Jacquier Date: Mon, 2 Feb 2026 09:40:05 -0500 Subject: [PATCH 14/19] updated deploy request changes to be compatible with new charm lib refactor --- cmd/account/access/access.go | 25 ++++--- cmd/root.go | 4 +- cmd/workflow/deploy/compile_test.go | 3 +- cmd/workflow/deploy/deploy.go | 2 +- internal/accessrequest/accessrequest.go | 97 +++++++++++++------------ 5 files changed, 68 insertions(+), 63 deletions(-) diff --git a/cmd/account/access/access.go b/cmd/account/access/access.go index b37e9801..258c296f 100644 --- a/cmd/account/access/access.go +++ b/cmd/account/access/access.go @@ -9,6 +9,7 @@ import ( "github.com/smartcontractkit/cre-cli/internal/accessrequest" "github.com/smartcontractkit/cre-cli/internal/credentials" "github.com/smartcontractkit/cre-cli/internal/runtime" + "github.com/smartcontractkit/cre-cli/internal/ui" ) func New(runtimeCtx *runtime.Context) *cobra.Command { @@ -18,7 +19,7 @@ func New(runtimeCtx *runtime.Context) *cobra.Command { Long: "Check your deployment access status or request access to deploy workflows.", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { - h := NewHandler(runtimeCtx, cmd.InOrStdin()) + h := NewHandler(runtimeCtx) return h.Execute() }, } @@ -32,11 +33,11 @@ type Handler struct { requester *accessrequest.Requester } -func NewHandler(ctx *runtime.Context, stdin interface{ Read([]byte) (int, error) }) *Handler { +func NewHandler(ctx *runtime.Context) *Handler { return &Handler{ log: ctx.Logger, credentials: ctx.Credentials, - requester: accessrequest.NewRequester(ctx.Credentials, ctx.Logger, stdin), + requester: accessrequest.NewRequester(ctx.Credentials, ctx.Logger), } } @@ -47,15 +48,15 @@ func (h *Handler) Execute() error { } if deployAccess.HasAccess { - fmt.Println("") - fmt.Println("You have deployment access enabled for your organization.") - fmt.Println("") - fmt.Println("You're all set to deploy workflows. Get started with:") - fmt.Println("") - fmt.Println(" cre workflow deploy") - fmt.Println("") - fmt.Println("For more information, run 'cre workflow deploy --help'") - fmt.Println("") + ui.Line() + ui.Success("You have deployment access enabled for your organization.") + ui.Line() + ui.Print("You're all set to deploy workflows. Get started with:") + ui.Line() + ui.Command(" cre workflow deploy") + ui.Line() + ui.Dim("For more information, run 'cre workflow deploy --help'") + ui.Line() return nil } diff --git a/cmd/root.go b/cmd/root.go index a3be57b2..024873ac 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -255,7 +255,6 @@ func newRootCommand() *cobra.Command { return false }) -<<<<<<< HEAD // Lipgloss-styled template functions for help (using Chainlink brand colors) cobra.AddTemplateFunc("styleTitle", func(s string) string { return ui.TitleStyle.Render(s) @@ -277,7 +276,7 @@ func newRootCommand() *cobra.Command { }) cobra.AddTemplateFunc("styleURL", func(s string) string { return ui.URLStyle.Render(s) // Chainlink Blue, underlined -======= + }) cobra.AddTemplateFunc("needsDeployAccess", func() bool { creds := runtimeContext.Credentials if creds == nil { @@ -292,7 +291,6 @@ func newRootCommand() *cobra.Command { return false } return !deployAccess.HasAccess ->>>>>>> eea3004 (Add deploy access hint to global help template for gated users) }) rootCmd.SetHelpTemplate(helpTemplate) diff --git a/cmd/workflow/deploy/compile_test.go b/cmd/workflow/deploy/compile_test.go index d0ebadd8..74bde2f5 100644 --- a/cmd/workflow/deploy/compile_test.go +++ b/cmd/workflow/deploy/compile_test.go @@ -1,6 +1,7 @@ package deploy import ( + "context" "encoding/base64" "errors" "io" @@ -212,7 +213,7 @@ func TestCompileCmd(t *testing.T) { err := handler.ValidateInputs() require.NoError(t, err) - err = handler.Execute() + err = handler.Execute(context.Background()) w.Close() os.Stdout = oldStdout diff --git a/cmd/workflow/deploy/deploy.go b/cmd/workflow/deploy/deploy.go index a1dc9a56..5da4776e 100644 --- a/cmd/workflow/deploy/deploy.go +++ b/cmd/workflow/deploy/deploy.go @@ -121,7 +121,7 @@ func newHandler(ctx *runtime.Context, stdin io.Reader) *handler { workflowArtifact: &workflowArtifact{}, wrc: nil, runtimeContext: ctx, - accessRequester: accessrequest.NewRequester(ctx.Credentials, ctx.Logger, stdin), + accessRequester: accessrequest.NewRequester(ctx.Credentials, ctx.Logger), validated: false, wg: sync.WaitGroup{}, wrcErr: nil, diff --git a/internal/accessrequest/accessrequest.go b/internal/accessrequest/accessrequest.go index 5c87dc2c..dc2dba6a 100644 --- a/internal/accessrequest/accessrequest.go +++ b/internal/accessrequest/accessrequest.go @@ -1,19 +1,17 @@ package accessrequest import ( - "bufio" "bytes" "encoding/json" "fmt" - "io" "net/http" "os" - "strings" + "github.com/charmbracelet/huh" "github.com/rs/zerolog" "github.com/smartcontractkit/cre-cli/internal/credentials" - "github.com/smartcontractkit/cre-cli/internal/prompt" + "github.com/smartcontractkit/cre-cli/internal/ui" ) const ( @@ -27,60 +25,74 @@ type AccessRequest struct { type Requester struct { credentials *credentials.Credentials log *zerolog.Logger - stdin io.Reader } -func NewRequester(creds *credentials.Credentials, log *zerolog.Logger, stdin io.Reader) *Requester { +func NewRequester(creds *credentials.Credentials, log *zerolog.Logger) *Requester { return &Requester{ credentials: creds, log: log, - stdin: stdin, } } func (r *Requester) PromptAndSubmitRequest() error { - fmt.Println("") - fmt.Println("Deployment access is not yet enabled for your organization.") - fmt.Println("") - - shouldRequest, err := prompt.YesNoPrompt(r.stdin, "Request deployment access?") - if err != nil { + ui.Line() + ui.Warning("Deployment access is not yet enabled for your organization.") + ui.Line() + + var shouldRequest bool + confirmForm := huh.NewForm( + huh.NewGroup( + huh.NewConfirm(). + Title("Request deployment access?"). + Value(&shouldRequest), + ), + ).WithTheme(ui.ChainlinkTheme()) + + if err := confirmForm.Run(); err != nil { return fmt.Errorf("failed to get user confirmation: %w", err) } if !shouldRequest { - fmt.Println("") - fmt.Println("Access request canceled.") + ui.Line() + ui.Dim("Access request canceled.") return nil } - fmt.Println("") - fmt.Println("Briefly describe your use case (what are you building with CRE?):") - fmt.Print("> ") - - reader := bufio.NewReader(r.stdin) - useCase, err := reader.ReadString('\n') - if err != nil { + var useCase string + inputForm := huh.NewForm( + huh.NewGroup( + huh.NewText(). + Title("Briefly describe your use case"). + Description("What are you building with CRE?"). + Value(&useCase). + Validate(func(s string) error { + if s == "" { + return fmt.Errorf("use case description is required") + } + return nil + }), + ), + ).WithTheme(ui.ChainlinkTheme()) + + if err := inputForm.Run(); err != nil { return fmt.Errorf("failed to read use case: %w", err) } - useCase = strings.TrimSpace(useCase) - - if useCase == "" { - return fmt.Errorf("use case description is required") - } - fmt.Println("") - fmt.Println("Submitting access request...") + ui.Line() + spinner := ui.NewSpinner() + spinner.Start("Submitting access request...") if err := r.SubmitAccessRequest(useCase); err != nil { + spinner.Stop() return fmt.Errorf("failed to submit access request: %w", err) } - fmt.Println("") - fmt.Println("Access request submitted successfully!") - fmt.Println("") - fmt.Println("Our team will review your request and get back to you shortly.") - fmt.Println("") + spinner.Stop() + ui.Line() + ui.Success("Access request submitted successfully!") + ui.Line() + ui.Dim("Our team will review your request and get back to you shortly.") + ui.Line() return nil } @@ -95,7 +107,7 @@ func (r *Requester) SubmitAccessRequest(useCase string) error { UseCase: useCase, } - jsonBody, err := json.MarshalIndent(reqBody, "", " ") + jsonBody, err := json.Marshal(reqBody) if err != nil { return fmt.Errorf("failed to marshal request: %w", err) } @@ -105,17 +117,10 @@ func (r *Requester) SubmitAccessRequest(useCase string) error { } token := r.credentials.Tokens.AccessToken - fmt.Println("") - fmt.Println("Request Details:") - fmt.Println("----------------") - fmt.Printf("URL: %s\n", apiURL) - fmt.Printf("Method: POST\n") - fmt.Println("Headers:") - fmt.Println(" Content-Type: application/json") - fmt.Printf(" Authorization: Bearer %s\n", token) - fmt.Println("Body:") - fmt.Println(string(jsonBody)) - fmt.Println("----------------") + r.log.Debug(). + Str("url", apiURL). + Str("method", "POST"). + Msg("submitting access request") req, err := http.NewRequest(http.MethodPost, apiURL, bytes.NewReader(jsonBody)) if err != nil { From 4153aef39644e3c41e8f7d01e0a240d50b2d521f Mon Sep 17 00:00:00 2001 From: Emmanuel Jacquier Date: Mon, 2 Feb 2026 09:50:33 -0500 Subject: [PATCH 15/19] updated simulator deploy message to use box layout --- cmd/workflow/simulate/simulate.go | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/cmd/workflow/simulate/simulate.go b/cmd/workflow/simulate/simulate.go index 75c519bf..307ac1c1 100644 --- a/cmd/workflow/simulate/simulate.go +++ b/cmd/workflow/simulate/simulate.go @@ -364,14 +364,10 @@ func (h *handler) showDeployAccessHint() { } if !deployAccess.HasAccess { - fmt.Println("") - fmt.Println("─────────────────────────────────────────────────────────────") - fmt.Println("") - fmt.Println(" Simulation complete! Ready to deploy your workflow?") - fmt.Println("") - fmt.Println(" Run 'cre account access' to request deployment access.") - fmt.Println("") - fmt.Println("─────────────────────────────────────────────────────────────") + ui.Line() + message := ui.RenderSuccess("Simulation complete!") + " Ready to deploy your workflow?\n\n" + + "Run " + ui.RenderCommand("cre account access") + " to request deployment access." + ui.Box(message) } } From 1b261c9c022aab3619098f87d9ebafc4801ec65e Mon Sep 17 00:00:00 2001 From: Emmanuel Jacquier Date: Mon, 2 Feb 2026 15:43:47 -0500 Subject: [PATCH 16/19] Yes is now selected by default for cre deploy access request prompt --- internal/accessrequest/accessrequest.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/accessrequest/accessrequest.go b/internal/accessrequest/accessrequest.go index dc2dba6a..e5d63a5b 100644 --- a/internal/accessrequest/accessrequest.go +++ b/internal/accessrequest/accessrequest.go @@ -39,7 +39,7 @@ func (r *Requester) PromptAndSubmitRequest() error { ui.Warning("Deployment access is not yet enabled for your organization.") ui.Line() - var shouldRequest bool + shouldRequest := true confirmForm := huh.NewForm( huh.NewGroup( huh.NewConfirm(). From 16a2c0c0723d4c4e83536a6224d6b30f4ecea5ad Mon Sep 17 00:00:00 2001 From: Emmanuel Jacquier Date: Wed, 4 Feb 2026 16:50:13 -0500 Subject: [PATCH 17/19] Temp mock access request behavior before API implementation --- internal/accessrequest/accessrequest.go | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/internal/accessrequest/accessrequest.go b/internal/accessrequest/accessrequest.go index e5d63a5b..c77d8cd9 100644 --- a/internal/accessrequest/accessrequest.go +++ b/internal/accessrequest/accessrequest.go @@ -6,6 +6,7 @@ import ( "fmt" "net/http" "os" + "time" "github.com/charmbracelet/huh" "github.com/rs/zerolog" @@ -91,7 +92,7 @@ func (r *Requester) PromptAndSubmitRequest() error { ui.Line() ui.Success("Access request submitted successfully!") ui.Line() - ui.Dim("Our team will review your request and get back to you shortly.") + ui.Print("Our team will review your request and get back to you via email shortly.") ui.Line() return nil @@ -99,8 +100,13 @@ func (r *Requester) PromptAndSubmitRequest() error { func (r *Requester) SubmitAccessRequest(useCase string) error { apiURL := os.Getenv(EnvVarAccessRequestURL) + + // If API URL is not configured, simulate the request submission + // This allows testing the flow before the API is available if apiURL == "" { - return fmt.Errorf("access request API URL not configured (set %s environment variable)", EnvVarAccessRequestURL) + r.log.Debug().Msg("API URL not configured, simulating access request submission") + time.Sleep(2 * time.Second) + return nil } reqBody := AccessRequest{ From 96a477f6c11b0e54eb54b20461790fb3dc7341bd Mon Sep 17 00:00:00 2001 From: Emmanuel Jacquier Date: Thu, 5 Feb 2026 22:04:30 -0500 Subject: [PATCH 18/19] replaced temp REST HTTP client with GraphQL client --- cmd/account/access/access.go | 9 ++- cmd/workflow/deploy/deploy.go | 4 +- internal/accessrequest/accessrequest.go | 98 ++++++++++--------------- 3 files changed, 46 insertions(+), 65 deletions(-) diff --git a/cmd/account/access/access.go b/cmd/account/access/access.go index 258c296f..32baab97 100644 --- a/cmd/account/access/access.go +++ b/cmd/account/access/access.go @@ -1,6 +1,7 @@ package access import ( + "context" "fmt" "github.com/rs/zerolog" @@ -20,7 +21,7 @@ func New(runtimeCtx *runtime.Context) *cobra.Command { Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { h := NewHandler(runtimeCtx) - return h.Execute() + return h.Execute(cmd.Context()) }, } @@ -37,11 +38,11 @@ func NewHandler(ctx *runtime.Context) *Handler { return &Handler{ log: ctx.Logger, credentials: ctx.Credentials, - requester: accessrequest.NewRequester(ctx.Credentials, ctx.Logger), + requester: accessrequest.NewRequester(ctx.Credentials, ctx.EnvironmentSet, ctx.Logger), } } -func (h *Handler) Execute() error { +func (h *Handler) Execute(ctx context.Context) error { deployAccess, err := h.credentials.GetDeploymentAccessStatus() if err != nil { return fmt.Errorf("failed to check deployment access: %w", err) @@ -60,5 +61,5 @@ func (h *Handler) Execute() error { return nil } - return h.requester.PromptAndSubmitRequest() + return h.requester.PromptAndSubmitRequest(ctx) } diff --git a/cmd/workflow/deploy/deploy.go b/cmd/workflow/deploy/deploy.go index 5da4776e..781bbbdb 100644 --- a/cmd/workflow/deploy/deploy.go +++ b/cmd/workflow/deploy/deploy.go @@ -121,7 +121,7 @@ func newHandler(ctx *runtime.Context, stdin io.Reader) *handler { workflowArtifact: &workflowArtifact{}, wrc: nil, runtimeContext: ctx, - accessRequester: accessrequest.NewRequester(ctx.Credentials, ctx.Logger), + accessRequester: accessrequest.NewRequester(ctx.Credentials, ctx.EnvironmentSet, ctx.Logger), validated: false, wg: sync.WaitGroup{}, wrcErr: nil, @@ -191,7 +191,7 @@ func (h *handler) Execute(ctx context.Context) error { } if !deployAccess.HasAccess { - return h.accessRequester.PromptAndSubmitRequest() + return h.accessRequester.PromptAndSubmitRequest(ctx) } h.initWorkflowRegistryClient() diff --git a/internal/accessrequest/accessrequest.go b/internal/accessrequest/accessrequest.go index c77d8cd9..267bd512 100644 --- a/internal/accessrequest/accessrequest.go +++ b/internal/accessrequest/accessrequest.go @@ -1,41 +1,42 @@ package accessrequest import ( - "bytes" - "encoding/json" + "context" "fmt" - "net/http" - "os" - "time" "github.com/charmbracelet/huh" + "github.com/machinebox/graphql" "github.com/rs/zerolog" + "github.com/smartcontractkit/cre-cli/internal/client/graphqlclient" "github.com/smartcontractkit/cre-cli/internal/credentials" + "github.com/smartcontractkit/cre-cli/internal/environments" "github.com/smartcontractkit/cre-cli/internal/ui" ) -const ( - EnvVarAccessRequestURL = "CRE_ACCESS_REQUEST_URL" -) - -type AccessRequest struct { - UseCase string `json:"useCase"` -} +const requestDeploymentAccessMutation = ` +mutation RequestDeploymentAccess($input: RequestDeploymentAccessInput!) { + requestDeploymentAccess(input: $input) { + success + message + } +}` type Requester struct { - credentials *credentials.Credentials - log *zerolog.Logger + credentials *credentials.Credentials + environmentSet *environments.EnvironmentSet + log *zerolog.Logger } -func NewRequester(creds *credentials.Credentials, log *zerolog.Logger) *Requester { +func NewRequester(creds *credentials.Credentials, environmentSet *environments.EnvironmentSet, log *zerolog.Logger) *Requester { return &Requester{ - credentials: creds, - log: log, + credentials: creds, + environmentSet: environmentSet, + log: log, } } -func (r *Requester) PromptAndSubmitRequest() error { +func (r *Requester) PromptAndSubmitRequest(ctx context.Context) error { ui.Line() ui.Warning("Deployment access is not yet enabled for your organization.") ui.Line() @@ -83,7 +84,7 @@ func (r *Requester) PromptAndSubmitRequest() error { spinner := ui.NewSpinner() spinner.Start("Submitting access request...") - if err := r.SubmitAccessRequest(useCase); err != nil { + if err := r.SubmitAccessRequest(ctx, useCase); err != nil { spinner.Stop() return fmt.Errorf("failed to submit access request: %w", err) } @@ -98,52 +99,31 @@ func (r *Requester) PromptAndSubmitRequest() error { return nil } -func (r *Requester) SubmitAccessRequest(useCase string) error { - apiURL := os.Getenv(EnvVarAccessRequestURL) +func (r *Requester) SubmitAccessRequest(ctx context.Context, useCase string) error { + client := graphqlclient.New(r.credentials, r.environmentSet, r.log) - // If API URL is not configured, simulate the request submission - // This allows testing the flow before the API is available - if apiURL == "" { - r.log.Debug().Msg("API URL not configured, simulating access request submission") - time.Sleep(2 * time.Second) - return nil - } - - reqBody := AccessRequest{ - UseCase: useCase, - } + req := graphql.NewRequest(requestDeploymentAccessMutation) + req.Var("input", map[string]any{ + "description": useCase, + }) - jsonBody, err := json.Marshal(reqBody) - if err != nil { - return fmt.Errorf("failed to marshal request: %w", err) + var resp struct { + RequestDeploymentAccess struct { + Success bool `json:"success"` + Message *string `json:"message"` + } `json:"requestDeploymentAccess"` } - if r.credentials.Tokens == nil || r.credentials.Tokens.AccessToken == "" { - return fmt.Errorf("no access token available - please run 'cre login' first") - } - token := r.credentials.Tokens.AccessToken - - r.log.Debug(). - Str("url", apiURL). - Str("method", "POST"). - Msg("submitting access request") - - req, err := http.NewRequest(http.MethodPost, apiURL, bytes.NewReader(jsonBody)) - if err != nil { - return fmt.Errorf("failed to create request: %w", err) - } - - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", "Bearer "+token) - - resp, err := http.DefaultClient.Do(req) - if err != nil { - return fmt.Errorf("failed to send request: %w", err) + if err := client.Execute(ctx, req, &resp); err != nil { + return fmt.Errorf("graphql request failed: %w", err) } - defer resp.Body.Close() - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("access request API returned status %d", resp.StatusCode) + if !resp.RequestDeploymentAccess.Success { + msg := "access request was not successful" + if resp.RequestDeploymentAccess.Message != nil { + msg = *resp.RequestDeploymentAccess.Message + } + return fmt.Errorf("request failed: %s", msg) } return nil From 83f222583c47a5b694617a9dd6cbe25a7d14ef5c Mon Sep 17 00:00:00 2001 From: Emmanuel Jacquier Date: Thu, 5 Feb 2026 22:14:21 -0500 Subject: [PATCH 19/19] added tests for access cmd --- cmd/account/access/access_test.go | 85 +++++++++++ internal/accessrequest/accessrequest_test.go | 144 +++++++++++++++++++ 2 files changed, 229 insertions(+) create mode 100644 cmd/account/access/access_test.go create mode 100644 internal/accessrequest/accessrequest_test.go diff --git a/cmd/account/access/access_test.go b/cmd/account/access/access_test.go new file mode 100644 index 00000000..ebca9079 --- /dev/null +++ b/cmd/account/access/access_test.go @@ -0,0 +1,85 @@ +package access_test + +import ( + "context" + "io" + "os" + "strings" + "testing" + + "github.com/rs/zerolog" + + "github.com/smartcontractkit/cre-cli/cmd/account/access" + "github.com/smartcontractkit/cre-cli/internal/credentials" + "github.com/smartcontractkit/cre-cli/internal/environments" + "github.com/smartcontractkit/cre-cli/internal/runtime" +) + +func TestHandlerExecute_HasAccess(t *testing.T) { + // API key auth type always returns HasAccess: true + creds := &credentials.Credentials{ + AuthType: "api-key", + APIKey: "test-key", + } + logger := zerolog.New(io.Discard) + envSet := &environments.EnvironmentSet{} + + rtCtx := &runtime.Context{ + Credentials: creds, + Logger: &logger, + EnvironmentSet: envSet, + } + + // Capture stdout + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + h := access.NewHandler(rtCtx) + err := h.Execute(context.Background()) + + w.Close() + os.Stdout = oldStdout + var output strings.Builder + _, _ = io.Copy(&output, r) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + out := output.String() + expectedSnippets := []string{ + "deployment access enabled", + "cre workflow deploy", + } + for _, snippet := range expectedSnippets { + if !strings.Contains(out, snippet) { + t.Errorf("output missing %q; full output:\n%s", snippet, out) + } + } +} + +func TestHandlerExecute_NoTokens(t *testing.T) { + // Bearer auth with no tokens should return an error from GetDeploymentAccessStatus + creds := &credentials.Credentials{ + AuthType: "bearer", + } + logger := zerolog.New(io.Discard) + envSet := &environments.EnvironmentSet{} + + rtCtx := &runtime.Context{ + Credentials: creds, + Logger: &logger, + EnvironmentSet: envSet, + } + + h := access.NewHandler(rtCtx) + err := h.Execute(context.Background()) + + if err == nil { + t.Fatal("expected error for missing tokens, got nil") + } + if !strings.Contains(err.Error(), "failed to check deployment access") { + t.Errorf("expected 'failed to check deployment access' error, got: %v", err) + } +} diff --git a/internal/accessrequest/accessrequest_test.go b/internal/accessrequest/accessrequest_test.go new file mode 100644 index 00000000..8bfdecb1 --- /dev/null +++ b/internal/accessrequest/accessrequest_test.go @@ -0,0 +1,144 @@ +package accessrequest_test + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/rs/zerolog" + + "github.com/smartcontractkit/cre-cli/internal/accessrequest" + "github.com/smartcontractkit/cre-cli/internal/credentials" + "github.com/smartcontractkit/cre-cli/internal/environments" +) + +func TestSubmitAccessRequest(t *testing.T) { + tests := []struct { + name string + useCase string + graphqlHandler http.HandlerFunc + wantErr bool + wantErrMsg string + }{ + { + name: "successful request", + useCase: "Building a cross-chain DeFi protocol", + graphqlHandler: func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + bodyStr := string(body) + + if !strings.Contains(bodyStr, "requestDeploymentAccess") { + t.Errorf("expected mutation requestDeploymentAccess in body, got: %s", bodyStr) + } + if !strings.Contains(bodyStr, "Building a cross-chain DeFi protocol") { + t.Errorf("expected use case description in body, got: %s", bodyStr) + } + + resp := map[string]interface{}{ + "data": map[string]interface{}{ + "requestDeploymentAccess": map[string]interface{}{ + "success": true, + "message": nil, + }, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + }, + wantErr: false, + }, + { + name: "request denied with message", + useCase: "some use case", + graphqlHandler: func(w http.ResponseWriter, r *http.Request) { + resp := map[string]interface{}{ + "data": map[string]interface{}{ + "requestDeploymentAccess": map[string]interface{}{ + "success": false, + "message": "organization is not eligible", + }, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + }, + wantErr: true, + wantErrMsg: "organization is not eligible", + }, + { + name: "request denied without message", + useCase: "some use case", + graphqlHandler: func(w http.ResponseWriter, r *http.Request) { + resp := map[string]interface{}{ + "data": map[string]interface{}{ + "requestDeploymentAccess": map[string]interface{}{ + "success": false, + "message": nil, + }, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + }, + wantErr: true, + wantErrMsg: "access request was not successful", + }, + { + name: "graphql server error", + useCase: "some use case", + graphqlHandler: func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "internal server error", http.StatusInternalServerError) + }, + wantErr: true, + wantErrMsg: "graphql request failed", + }, + { + name: "graphql returns errors", + useCase: "some use case", + graphqlHandler: func(w http.ResponseWriter, r *http.Request) { + resp := map[string]interface{}{ + "errors": []map[string]interface{}{ + {"message": "not authenticated"}, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + }, + wantErr: true, + wantErrMsg: "graphql request failed", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + ts := httptest.NewServer(tc.graphqlHandler) + defer ts.Close() + + envSet := &environments.EnvironmentSet{ + GraphQLURL: ts.URL, + } + creds := &credentials.Credentials{} + logger := zerolog.New(io.Discard) + + requester := accessrequest.NewRequester(creds, envSet, &logger) + err := requester.SubmitAccessRequest(context.Background(), tc.useCase) + + if tc.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), tc.wantErrMsg) { + t.Errorf("expected error containing %q, got: %v", tc.wantErrMsg, err) + } + } else { + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + } + }) + } +}