diff --git a/cmd/sandbox/sandbox.go b/cmd/sandbox/sandbox.go index fe8dd38a..c0eb1e9b 100644 --- a/cmd/sandbox/sandbox.go +++ b/cmd/sandbox/sandbox.go @@ -46,6 +46,7 @@ New to the Developer Program? Sign up at cmd.AddCommand(NewCreateCommand(clients)) cmd.AddCommand(NewDeleteCommand(clients)) cmd.AddCommand(NewListCommand(clients)) + cmd.AddCommand(NewSignupCommand(clients)) return cmd } diff --git a/cmd/sandbox/signup.go b/cmd/sandbox/signup.go new file mode 100644 index 00000000..c3312b43 --- /dev/null +++ b/cmd/sandbox/signup.go @@ -0,0 +1,337 @@ +// Copyright 2022-2026 Salesforce, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sandbox + +import ( + "context" + "fmt" + "strings" + + "github.com/slackapi/slack-cli/internal/api" + "github.com/slackapi/slack-cli/internal/experiment" + "github.com/slackapi/slack-cli/internal/iostreams" + "github.com/slackapi/slack-cli/internal/shared" + "github.com/slackapi/slack-cli/internal/shared/types" + "github.com/slackapi/slack-cli/internal/slackerror" + "github.com/slackapi/slack-cli/internal/style" + "github.com/spf13/cobra" +) + +type signupFlags struct { + firstName string + lastName string + postalCode string + countryOption string + newsletterSubscription bool + acceptTermsOfService bool +} + +var signupCmdFlags signupFlags + +func NewSignupCommand(clients *shared.ClientFactory) *cobra.Command { + cmd := &cobra.Command{ + Use: "signup [flags]", + Short: "Sign up for the Slack Developer Program", + Long: `Sign up for the Slack Developer Program! + +The developer program gives you access to sandbox environments for building +and testing Slack apps isolated from your production workspace. To get started, +log in to your production workspace via the 'login' command. + +Learn more at {{LinkText "https://api.slack.com/developer-program"}}`, + Example: style.ExampleCommandsf([]style.ExampleCommand{ + {Command: "sandbox signup", Meaning: "Sign up for the Developer Program"}, + {Command: "sandbox signup --first-name Anna --last-name Banana --postal-code 94105 --country US", Meaning: "Sign up with the provided details"}, + {Command: "sandbox signup --team T0123456 --accept-tos", Meaning: "Sign up with a specific team and accept the terms"}, + }), + Args: cobra.NoArgs, + Hidden: true, + PreRunE: func(cmd *cobra.Command, args []string) error { + if !clients.Config.WithExperimentOn(experiment.SandboxSignup) { + return slackerror.New(slackerror.ErrExperimentRequired). + WithRemediation("Enable the %s experiment with %s", + style.Highlight(string(experiment.SandboxSignup)), + style.CommandText("--experiment sandbox-signup"), + ) + } + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + return runSignupCommand(cmd, clients) + }, + } + + cmd.Flags().StringVar(&signupCmdFlags.firstName, "first-name", "", "Your first name") + cmd.Flags().StringVar(&signupCmdFlags.lastName, "last-name", "", "Your last name") + cmd.Flags().StringVar(&signupCmdFlags.postalCode, "postal-code", "", "Your postal code") + cmd.Flags().StringVar(&signupCmdFlags.countryOption, "country", "", "Your country (ISO 3166-1 alpha-2 format, e.g. US)") + cmd.Flags().BoolVar(&signupCmdFlags.newsletterSubscription, "newsletter", false, "Subscribe to the developer newsletter") + cmd.Flags().BoolVar(&signupCmdFlags.acceptTermsOfService, "accept-tos", false, "Accept the Privacy Policy, API Terms of Service, and Developer Program Agreement") + + return cmd +} + +func runSignupCommand(cmd *cobra.Command, clients *shared.ClientFactory) error { + ctx := cmd.Context() + + auth, err := selectAuthForSignup(ctx, clients) + if err != nil { + return err + } + + // Prompt user to accept terms unless --accept-tos or --force flag is used + if !signupCmdFlags.acceptTermsOfService && !clients.Config.ForceFlag { + if err := promptTermsAcceptance(ctx, clients); err != nil { + return err + } + } + + email := prefillUserInfoFromAuth(ctx, clients, auth, &signupCmdFlags) + + info, err := collectUserInfo(ctx, clients, &signupCmdFlags) + if err != nil { + return err + } + info.email = email + + req := api.SignupRequest{ + FirstName: info.firstName, + LastName: info.lastName, + PostalCode: info.postalCode, + CountryOption: info.countryOption, + NewsletterSubscription: signupCmdFlags.newsletterSubscription, + AcceptTermsOfService: true, // Terms were accepted via the --accept-tos flag, --force flag, or prompt + } + + printSignupDetails(ctx, clients, info, signupCmdFlags.newsletterSubscription) + + _, err = apiClientForAuth(clients, auth).SignupDeveloperProgramVerified(ctx, auth.Token, req) + if err != nil { + return err + } + + clients.IO.PrintInfo(ctx, false, "\n%s", style.Sectionf(style.TextSection{ + Emoji: "tada", + Text: "Welcome to the Slack Developer Program!", + })) + + clients.IO.PrintInfo(ctx, false, "\nRun %s to create a developer sandbox", style.Commandf("sandbox create", false)) + return nil +} + +// apiClientForAuth returns an API client targeting the host for the given auth. +// An explicit --apihost (or --slackdev) flag takes precedence over the auth's stored API host, +// since clients.API() is already configured with the resolved flag value. +func apiClientForAuth(clients *shared.ClientFactory, auth *types.SlackAuth) api.APIInterface { + apiClient := clients.API() + if clients.Config.APIHostFlag != "" || clients.Config.SlackDevFlag { + return apiClient + } + if auth != nil && auth.APIHost != nil { + apiClient.SetHost(*auth.APIHost) + } + return apiClient +} + +// selectAuthForSignup returns the auth to link to the Developer Program account. +// Uses the global --token or --team flag if present, otherwise prompts the user to select an account. +func selectAuthForSignup(ctx context.Context, clients *shared.ClientFactory) (*types.SlackAuth, error) { + if clients.Config.TokenFlag != "" { + auth, err := clients.Auth().AuthWithToken(ctx, clients.Config.TokenFlag) + if err != nil { + return nil, err + } + return &auth, nil + } + + allAuths, err := clients.Auth().Auths(ctx) + if err != nil { + return nil, err + } + if len(allAuths) == 0 { + return nil, slackerror.New(slackerror.ErrNotAuthed). + WithMessage("You must be logged in to sign up for the Developer Program"). + WithRemediation("Run %s to sign in to a workspace, then try again", style.Commandf("login", false)) + } + + teamFlag := clients.Config.Flags.Lookup("team") + labels := make([]string, 0, len(allAuths)) + options := make([]iostreams.PromptOption, 0, len(allAuths)) + for _, auth := range allAuths { + label := style.TeamSelectLabel(auth.TeamDomain, auth.TeamID) + if userInfo, err := apiClientForAuth(clients, &auth).UsersInfo(ctx, auth.Token, auth.UserID); err == nil && userInfo.Profile.Email != "" { + label = fmt.Sprintf("%s %s", userInfo.Profile.Email, style.Faint(fmt.Sprintf("(%s %s)", auth.TeamDomain, auth.TeamID))) + } + labels = append(labels, label) + options = append(options, iostreams.PromptOption{ + Label: label, + Flag: teamFlag, + Value: auth.TeamID, + }) + } + + selection, err := clients.IO.SelectPrompt(ctx, "Select an email for your developer program account", labels, iostreams.SelectPromptConfig{ + Required: true, + Flag: teamFlag, + Options: options, + Help: "The selected login wil be used to fetch your user details and create your account", + }) + if err != nil { + if slackerror.Is(err, slackerror.ErrProcessInterrupted) { + clients.IO.SetExitCode(iostreams.ExitCancel) + } + return nil, err + } + + if selection.Prompt { + return &allAuths[selection.Index], nil + } + for i, auth := range allAuths { + if selection.Option == auth.TeamID || selection.Option == auth.TeamDomain { + return &allAuths[i], nil + } + } + return nil, slackerror.New(slackerror.ErrCredentialsNotFound) +} + +// prefillUserInfoFromAuth fetches the user's Slack profile and fills in any unset name flags. +// Returns the email address of the profile, if available. +func prefillUserInfoFromAuth(ctx context.Context, clients *shared.ClientFactory, auth *types.SlackAuth, flags *signupFlags) string { + if auth.UserID == "" { + return "" + } + userInfo, err := apiClientForAuth(clients, auth).UsersInfo(ctx, auth.Token, auth.UserID) + if err != nil { + return "" + } + + // Prefer the profile name fields, falling back to splitting the real name + realFirst, realLast := splitName(userInfo.RealName) + if flags.firstName == "" { + flags.firstName = userInfo.Profile.FirstName + if flags.firstName == "" { + flags.firstName = realFirst + } + } + if flags.lastName == "" { + flags.lastName = userInfo.Profile.LastName + if flags.lastName == "" { + flags.lastName = realLast + } + } + + return userInfo.Profile.Email +} + +// splitName splits a full name into first and last name +func splitName(fullName string) (firstName, lastName string) { + parts := strings.SplitN(strings.TrimSpace(fullName), " ", 2) + firstName = parts[0] + if len(parts) == 2 { + lastName = strings.TrimSpace(parts[1]) + } + return firstName, lastName +} + +// userInfo holds the collected user information for signup +type userInfo struct { + firstName string + lastName string + email string + postalCode string + countryOption string +} + +// promptTermsAcceptance shows the terms and prompts the user to accept them +func promptTermsAcceptance(ctx context.Context, clients *shared.ClientFactory) error { + clients.IO.PrintInfo(ctx, false, "\n%s", style.Sectionf(style.TextSection{ + Emoji: "scroll", + Text: "Terms and Conditions", + Secondary: []string{ + "By signing up, you agree to the following:", + fmt.Sprintf("• Privacy Policy: %s", style.LinkText("https://slack.com/trust/privacy/privacy-policy")), + fmt.Sprintf("• API Terms of Service: %s", style.LinkText("https://slack.com/terms-of-service/api")), + fmt.Sprintf("• Developer Program Agreement: %s", style.LinkText("https://slack.com/terms-of-service/slack-developer-program")), + }, + })) + + acceptTerms, err := clients.IO.ConfirmPrompt(ctx, "Do you agree to these terms?", iostreams.ConfirmPromptConfig{Default: true}) + if err != nil { + if slackerror.Is(err, slackerror.ErrProcessInterrupted) { + clients.IO.SetExitCode(iostreams.ExitCancel) + } + return err + } + if !acceptTerms { + clients.IO.PrintInfo(ctx, false, "\n%s", style.Sectionf(style.TextSection{ + Emoji: "information_source", + Text: "Signup Cancelled", + Secondary: []string{ + "You must agree to the terms to join the Developer Program.", + }, + })) + clients.IO.SetExitCode(iostreams.ExitCancel) + return slackerror.New(slackerror.ErrProcessInterrupted) + } + return nil +} + +// collectUserInfo prompts for a first and last name when not already set. +// Postal code and country are optional for authenticated signups so are only read from flags. +func collectUserInfo(ctx context.Context, clients *shared.ClientFactory, flags *signupFlags) (*userInfo, error) { + var err error + info := &userInfo{ + firstName: flags.firstName, + lastName: flags.lastName, + postalCode: flags.postalCode, + countryOption: strings.ToUpper(strings.TrimSpace(flags.countryOption)), + } + + if info.firstName == "" { + info.firstName, err = clients.IO.InputPrompt(ctx, "Enter your first name", iostreams.InputPromptConfig{Required: true}) + if err != nil { + return nil, err + } + } + + if info.lastName == "" { + info.lastName, err = clients.IO.InputPrompt(ctx, "Enter your last name", iostreams.InputPromptConfig{Required: true}) + if err != nil { + return nil, err + } + } + + return info, nil +} + +// printSignupDetails shows the signup details +func printSignupDetails(ctx context.Context, clients *shared.ClientFactory, info *userInfo, newsletter bool) { + details := []string{fmt.Sprintf("Name: %s %s", info.firstName, info.lastName)} + if info.email != "" { + details = append(details, fmt.Sprintf("Email: %s", info.email)) + } + if info.countryOption != "" { + details = append(details, fmt.Sprintf("Country: %s", info.countryOption)) + } + if info.postalCode != "" { + details = append(details, fmt.Sprintf("Postal Code: %s", info.postalCode)) + } + details = append(details, fmt.Sprintf("Newsletter: %v", newsletter)) + clients.IO.PrintInfo(ctx, false, "\n%s", style.Sectionf(style.TextSection{ + Emoji: "clipboard", + Text: "Developer Program Signup", + Secondary: details, + })) +} diff --git a/cmd/sandbox/signup_test.go b/cmd/sandbox/signup_test.go new file mode 100644 index 00000000..6f361c58 --- /dev/null +++ b/cmd/sandbox/signup_test.go @@ -0,0 +1,227 @@ +// Copyright 2022-2026 Salesforce, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sandbox + +import ( + "context" + "errors" + "testing" + + "github.com/slackapi/slack-cli/internal/api" + "github.com/slackapi/slack-cli/internal/experiment" + "github.com/slackapi/slack-cli/internal/iostreams" + "github.com/slackapi/slack-cli/internal/shared" + "github.com/slackapi/slack-cli/internal/shared/types" + "github.com/slackapi/slack-cli/internal/slackerror" + "github.com/slackapi/slack-cli/internal/style" + "github.com/slackapi/slack-cli/test/testutil" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func TestSignupCommand(t *testing.T) { + testToken := "xoxb-test-token" + + testutil.TableTestCommand(t, testutil.CommandTests{ + "errors when the sandbox-signup experiment is off": { + CmdArgs: []string{"--token", testToken, "--accept-tos"}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + cm.AddDefaultMocks() + cf.Config.LoadExperiments(ctx, cf.IO.PrintDebug) + }, + ExpectedErrorStrings: []string{"experiment_required", "--experiment sandbox-signup"}, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.Auth.AssertNotCalled(t, "AuthWithToken", mock.Anything, mock.Anything) + cm.API.AssertNotCalled(t, "SignupDeveloperProgramVerified", mock.Anything, mock.Anything, mock.Anything) + }, + }, + "creates an account with details provided via flag": { + CmdArgs: []string{ + "--token", testToken, + "--first-name", "Ada", + "--last-name", "Lovelace", + "--postal-code", "94105", + "--country", "us", + "--newsletter", + "--accept-tos", + }, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + cm.Auth.On("AuthWithToken", mock.Anything, testToken).Return(types.SlackAuth{Token: testToken, UserID: "U123"}, nil) + cm.API.On("UsersInfo", mock.Anything, testToken, "U123").Return(&types.UserInfo{ + RealName: "Someone Else", + Profile: types.UserProfile{Email: "ada@example.com"}, + }, nil) + cm.API.On("SignupDeveloperProgramVerified", mock.Anything, testToken, mock.Anything).Return(&api.SignupVerifiedResponse{ + DeveloperID: "D123", + IsEligibleToProvisionSandboxes: true, + }, nil) + enableSandboxSignupExperiment(ctx, cm, cf) + }, + ExpectedStdoutOutputs: []string{ + "Name: Ada Lovelace", + "Email: ada@example.com", + "Country: US", + "Postal Code: 94105", + "Newsletter: true", + "Welcome to the Slack Developer Program!", + "sandbox create", + }, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.API.AssertCalled(t, "SignupDeveloperProgramVerified", mock.Anything, testToken, api.SignupRequest{ + FirstName: "Ada", + LastName: "Lovelace", + PostalCode: "94105", + CountryOption: "US", + NewsletterSubscription: true, + AcceptTermsOfService: true, + }) + cm.IO.AssertNotCalled(t, "ConfirmPrompt", mock.Anything, mock.Anything, mock.Anything) + cm.IO.AssertNotCalled(t, "InputPrompt", mock.Anything, mock.Anything, mock.Anything) + }, + }, + "prompts for auth and terms then prefills the name from the profile": { + CmdArgs: []string{}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + cm.Auth.On("Auths", mock.Anything).Return([]types.SlackAuth{ + {Token: "xoxp-one", TeamID: "T1", TeamDomain: "one", UserID: "U1"}, + {Token: "xoxp-two", TeamID: "T2", TeamDomain: "two", UserID: "U2"}, + }, nil) + cm.API.On("UsersInfo", mock.Anything, "xoxp-one", "U1").Return(&types.UserInfo{ + Profile: types.UserProfile{Email: "one@example.com"}, + }, nil) + cm.API.On("UsersInfo", mock.Anything, "xoxp-two", "U2").Return(&types.UserInfo{ + RealName: "Grace Brewster Hopper", + Profile: types.UserProfile{Email: "grace@example.com"}, + }, nil) + cm.IO.On("SelectPrompt", mock.Anything, "Select an email for your developer program account", []string{ + "one@example.com " + style.Secondary("(one T1)"), + "grace@example.com " + style.Secondary("(two T2)"), + }, mock.Anything). + Return(iostreams.SelectPromptResponse{Prompt: true, Index: 1}, nil) + cm.IO.On("ConfirmPrompt", mock.Anything, "Do you agree to these terms?", mock.Anything).Return(true, nil) + cm.API.On("SignupDeveloperProgramVerified", mock.Anything, "xoxp-two", mock.Anything).Return(&api.SignupVerifiedResponse{ + DeveloperID: "D456", + IsEligibleToProvisionSandboxes: true, + }, nil) + enableSandboxSignupExperiment(ctx, cm, cf) + }, + ExpectedStdoutOutputs: []string{ + "Terms and Conditions", + "Name: Grace Brewster Hopper", + "Email: grace@example.com", + "Newsletter: false", + "Welcome to the Slack Developer Program!", + }, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.API.AssertCalled(t, "SignupDeveloperProgramVerified", mock.Anything, "xoxp-two", api.SignupRequest{ + FirstName: "Grace", + LastName: "Brewster Hopper", + AcceptTermsOfService: true, + }) + cm.IO.AssertNotCalled(t, "InputPrompt", mock.Anything, mock.Anything, mock.Anything) + }, + }, + "prompts for name when we cannot fetch it from the user's profile": { + CmdArgs: []string{"--token", testToken, "--accept-tos"}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + cm.Auth.On("AuthWithToken", mock.Anything, testToken).Return(types.SlackAuth{Token: testToken, UserID: "U123"}, nil) + cm.API.On("UsersInfo", mock.Anything, testToken, "U123").Return(&types.UserInfo{}, nil) + cm.IO.On("InputPrompt", mock.Anything, "Enter your first name", mock.Anything).Return("Alan", nil) + cm.IO.On("InputPrompt", mock.Anything, "Enter your last name", mock.Anything).Return("Turing", nil) + cm.API.On("SignupDeveloperProgramVerified", mock.Anything, testToken, mock.Anything).Return(&api.SignupVerifiedResponse{ + IsEligibleToProvisionSandboxes: true, + }, nil) + enableSandboxSignupExperiment(ctx, cm, cf) + }, + ExpectedStdoutOutputs: []string{"Name: Alan Turing"}, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.API.AssertCalled(t, "SignupDeveloperProgramVerified", mock.Anything, testToken, api.SignupRequest{ + FirstName: "Alan", + LastName: "Turing", + AcceptTermsOfService: true, + }) + }, + }, + "errors when not logged in to any accounts": { + CmdArgs: []string{}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + cm.Auth.On("Auths", mock.Anything).Return([]types.SlackAuth{}, nil) + enableSandboxSignupExperiment(ctx, cm, cf) + }, + ExpectedErrorStrings: []string{"not_authed", "You must be logged in to sign up for the Developer Program"}, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.API.AssertNotCalled(t, "SignupDeveloperProgramVerified", mock.Anything, mock.Anything, mock.Anything) + }, + }, + "cancels when the terms are declined": { + CmdArgs: []string{"--token", testToken}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + cm.Auth.On("AuthWithToken", mock.Anything, testToken).Return(types.SlackAuth{Token: testToken, UserID: "U123"}, nil) + cm.IO.On("ConfirmPrompt", mock.Anything, "Do you agree to these terms?", mock.Anything).Return(false, nil) + enableSandboxSignupExperiment(ctx, cm, cf) + }, + ExpectedError: slackerror.New(slackerror.ErrProcessInterrupted), + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.API.AssertNotCalled(t, "SignupDeveloperProgramVerified", mock.Anything, mock.Anything, mock.Anything) + }, + }, + "returns an API error": { + CmdArgs: []string{"--token", testToken, "--first-name", "Ada", "--last-name", "Lovelace", "--accept-tos"}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + cm.Auth.On("AuthWithToken", mock.Anything, testToken).Return(types.SlackAuth{Token: testToken, UserID: "U123"}, nil) + cm.API.On("UsersInfo", mock.Anything, testToken, "U123").Return(&types.UserInfo{}, nil) + cm.API.On("SignupDeveloperProgramVerified", mock.Anything, testToken, mock.Anything).Return(nil, errors.New("developer_account_exists")) + enableSandboxSignupExperiment(ctx, cm, cf) + }, + ExpectedErrorStrings: []string{"developer_account_exists"}, + }, + }, func(cf *shared.ClientFactory) *cobra.Command { + return NewSignupCommand(cf) + }) +} + +func TestSignupCommandHidden(t *testing.T) { + clients := shared.NewClientFactory(shared.NewClientsMock().MockClientFactory()) + assert.True(t, NewSignupCommand(clients).Hidden) +} + +// enableSandboxSignupExperiment turns on the experiment that gates the signup command +func enableSandboxSignupExperiment(ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + cm.AddDefaultMocks() + cf.Config.ExperimentsFlag = []string{string(experiment.SandboxSignup)} + cf.Config.LoadExperiments(ctx, cf.IO.PrintDebug) +} + +func TestSplitName(t *testing.T) { + tests := map[string]struct { + input string + first string + last string + }{ + "empty": {input: "", first: "", last: ""}, + "single name": {input: "Cher", first: "Cher", last: ""}, + "two names": {input: "Ada Lovelace", first: "Ada", last: "Lovelace"}, + "many names": {input: "Grace Brewster Hopper", first: "Grace", last: "Brewster Hopper"}, + "extra spaces": {input: " Alan Turing ", first: "Alan", last: "Turing"}, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + first, last := splitName(tc.input) + assert.Equal(t, tc.first, first) + assert.Equal(t, tc.last, last) + }) + } +} diff --git a/docs/reference/experiments.md b/docs/reference/experiments.md index 19ff9f1c..370458bf 100644 --- a/docs/reference/experiments.md +++ b/docs/reference/experiments.md @@ -7,11 +7,13 @@ The Slack CLI has an experiment (`-e`) flag behind which we put features current The following is a list of currently available experiments. We'll remove experiments from this page if we decide they are no longer needed or once they are released, in which case we'll make an announcement about the feature's general availability in the [developer changelog](https://docs.slack.dev/changelog). - `lipgloss`: shows pretty styles. +- `sandbox-signup`: enables the `slack sandbox signup` command to join the Slack Developer Program with an existing login. ## Experiments changelog Below is a list of updates related to experiments. +- **November 2026**: Added the `sandbox-signup` experiment to sign up for the Slack Developer Program from the Slack CLI. - **September 2026**: Concluded the `manifest-sync` experiment with full support for two-way manifest sync now enabled by default in the Slack CLI. - **August 2026**: Concluded the `set-icon` experiment; The Slack CLI now offers full support for icon upload on all app types by default. - **July 2026**: Added the `manifest-sync` experiment to resolve changed app manifest values between a project and app settings. diff --git a/internal/api/api_mock.go b/internal/api/api_mock.go index 10ed3ab9..2cd9f498 100644 --- a/internal/api/api_mock.go +++ b/internal/api/api_mock.go @@ -230,6 +230,14 @@ func (m *APIMock) ListSandboxes(ctx context.Context, token string, status string return args.Get(0).([]types.Sandbox), args.Error(1) } +func (m *APIMock) SignupDeveloperProgramVerified(ctx context.Context, token string, req SignupRequest) (*SignupVerifiedResponse, error) { + args := m.Called(ctx, token, req) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*SignupVerifiedResponse), args.Error(1) +} + // DatastoresClient func (m *APIMock) AppsDatastorePut(ctx context.Context, token string, request types.AppDatastorePut) (types.AppDatastorePutResult, error) { diff --git a/internal/api/sandbox.go b/internal/api/sandbox.go index 5368c994..5fe0d697 100644 --- a/internal/api/sandbox.go +++ b/internal/api/sandbox.go @@ -26,16 +26,34 @@ import ( ) const ( - sandboxCreateMethod = "enterprise.signup.createDevOrg" - sandboxDeleteMethod = "developer.sandbox.delete" - sandboxListMethod = "developer.sandbox.list" + sandboxCreateMethod = "enterprise.signup.createDevOrg" + sandboxDeleteMethod = "developer.sandbox.delete" + sandboxListMethod = "developer.sandbox.list" + sandboxSignupVerifiedMethod = "developer.account.createVerified" ) +// SignupRequest contains the fields for Developer Program signup +type SignupRequest struct { + FirstName string + LastName string + PostalCode string + CountryOption string + NewsletterSubscription bool + AcceptTermsOfService bool +} + +// SignupVerifiedResponse contains the response from verified Developer Program signup +type SignupVerifiedResponse struct { + DeveloperID string `json:"developer_id"` + IsEligibleToProvisionSandboxes bool `json:"is_eligible_to_provision_sandboxes"` +} + // SandboxClient is the interface for sandbox-related API calls type SandboxClient interface { CreateSandbox(ctx context.Context, token, name, domain, password, locale, owningOrgID string, templateID int, eventCode string, archiveDate int64, isPartner, useEmailLogin bool) (teamID, sandboxURL string, err error) DeleteSandbox(ctx context.Context, token, sandboxID string) error ListSandboxes(ctx context.Context, token string, status string) ([]types.Sandbox, error) + SignupDeveloperProgramVerified(ctx context.Context, token string, req SignupRequest) (*SignupVerifiedResponse, error) } type createSandboxResponse struct { @@ -165,3 +183,48 @@ func (c *Client) ListSandboxes(ctx context.Context, token string, status string) return resp.Sandboxes, nil } + +type signupDeveloperProgramVerifiedResponse struct { + extendedBaseResponse + SignupVerifiedResponse +} + +// SignupDeveloperProgramVerified signs the authenticated user up for the Slack Developer Program. +// The email address of the developer account is derived from the token, so no email verification is needed. +func (c *Client) SignupDeveloperProgramVerified(ctx context.Context, token string, req SignupRequest) (*SignupVerifiedResponse, error) { + var span opentracing.Span + span, ctx = opentracing.StartSpanFromContext(ctx, "apiclient.SignupDeveloperProgramVerified") + defer span.Finish() + + values := url.Values{} + values.Add("token", token) + values.Add("first_name", req.FirstName) + values.Add("last_name", req.LastName) + if req.PostalCode != "" { + values.Add("postal_code", req.PostalCode) + } + if req.CountryOption != "" { + values.Add("country_option", req.CountryOption) + } + if req.NewsletterSubscription { + values.Add("newsletter_subscription", "true") + } + values.Add("accept_tos", strconv.FormatBool(req.AcceptTermsOfService)) + + b, err := c.postForm(ctx, sandboxSignupVerifiedMethod, values) + if err != nil { + return nil, errHTTPRequestFailed.WithRootCause(err) + } + + resp := signupDeveloperProgramVerifiedResponse{} + err = goutils.JSONUnmarshal(b, &resp) + if err != nil { + return nil, errHTTPResponseInvalid.WithRootCause(err).AddAPIMethod(sandboxSignupVerifiedMethod) + } + + if !resp.Ok { + return nil, slackerror.NewAPIError(resp.Error, resp.Description, resp.Errors, sandboxSignupVerifiedMethod) + } + + return &resp.SignupVerifiedResponse, nil +} diff --git a/internal/api/sandbox_test.go b/internal/api/sandbox_test.go index 65557c1d..bb3a106f 100644 --- a/internal/api/sandbox_test.go +++ b/internal/api/sandbox_test.go @@ -141,6 +141,33 @@ func TestClient_ListSandboxes_WithStatusFilter(t *testing.T) { require.Equal(t, "archived", sandboxes[0].Status) } +func TestClient_SignupDeveloperProgramVerified_SendsAcceptTOS(t *testing.T) { + ctx := slackcontext.MockContext(t.Context()) + c, teardown := NewFakeClient(t, FakeClientParams{ + ExpectedMethod: sandboxSignupVerifiedMethod, + ExpectedRequest: "accept_tos=true&country_option=US&first_name=Ada&last_name=Lovelace&token=token", + Response: `{"ok":true,"developer_id":"D123","is_eligible_to_provision_sandboxes":true}`, + }) + defer teardown() + resp, err := c.SignupDeveloperProgramVerified(ctx, "token", SignupRequest{ + FirstName: "Ada", + LastName: "Lovelace", + CountryOption: "US", + AcceptTermsOfService: true, + }) + require.NoError(t, err) + require.Equal(t, "D123", resp.DeveloperID) + require.True(t, resp.IsEligibleToProvisionSandboxes) +} + +func TestClient_SignupDeveloperProgramVerified_CommonErrors(t *testing.T) { + ctx := slackcontext.MockContext(t.Context()) + verifyCommonErrorCases(t, sandboxSignupVerifiedMethod, func(c *Client) error { + _, err := c.SignupDeveloperProgramVerified(ctx, "token", SignupRequest{AcceptTermsOfService: true}) + return err + }) +} + func TestClient_ListSandboxes_CommonErrors(t *testing.T) { ctx := slackcontext.MockContext(t.Context()) verifyCommonErrorCases(t, sandboxListMethod, func(c *Client) error { diff --git a/internal/experiment/experiment.go b/internal/experiment/experiment.go index f2093205..bfec385e 100644 --- a/internal/experiment/experiment.go +++ b/internal/experiment/experiment.go @@ -35,6 +35,9 @@ const ( // Placeholder experiment is a placeholder for testing and does nothing... or does it? Placeholder Experiment = "placeholder" + + // SandboxSignup experiment enables the sandbox signup command to join the Developer Program. + SandboxSignup Experiment = "sandbox-signup" ) // AllExperiments is a list of all available experiments that can be enabled @@ -42,6 +45,7 @@ const ( var AllExperiments = []Experiment{ Lipgloss, Placeholder, + SandboxSignup, } // EnabledExperiments is a list of experiments that are permanently enabled diff --git a/internal/shared/types/user.go b/internal/shared/types/user.go index 4f56b73b..c3913433 100644 --- a/internal/shared/types/user.go +++ b/internal/shared/types/user.go @@ -73,4 +73,7 @@ type UserInfo struct { type UserProfile struct { DisplayName string `json:"display_name"` Email string `json:"email,omitempty"` + FirstName string `json:"first_name,omitempty"` + LastName string `json:"last_name,omitempty"` + RealName string `json:"real_name,omitempty"` }