diff --git a/apps/cli-go/internal/orgs/create/create.go b/apps/cli-go/internal/orgs/create/create.go index 1d88630c18..46046bdfdf 100644 --- a/apps/cli-go/internal/orgs/create/create.go +++ b/apps/cli-go/internal/orgs/create/create.go @@ -3,16 +3,34 @@ package create import ( "context" "fmt" + "net/http" "os" + "strings" "github.com/go-errors/errors" + "github.com/spf13/afero" "github.com/supabase/cli/internal/orgs/list" "github.com/supabase/cli/internal/utils" "github.com/supabase/cli/pkg/api" + "github.com/supabase/cli/pkg/fetcher" ) +type onboardingSurveyRequest struct { + Slug string `json:"slug"` + HeardFrom string `json:"heard_from,omitempty"` + Building string `json:"building,omitempty"` +} + +var newConsole = utils.NewConsole +var submitSurvey = submitOnboardingSurvey + func Run(ctx context.Context, name string) error { - resp, err := utils.GetSupabase().V1CreateAnOrganizationWithResponse(ctx, api.V1CreateAnOrganizationJSONRequestBody{Name: name}) + if utils.OutputFormat.Value == utils.OutputPretty { + fmt.Fprintln(os.Stderr, "Creating organization...") + } + resp, err := utils.GetSupabase().V1CreateAnOrganizationWithResponse(ctx, api.V1CreateAnOrganizationJSONRequestBody{ + Name: name, + }) if err != nil { return errors.Errorf("failed to create organization: %w", err) } else if resp.JSON201 == nil { @@ -22,7 +40,69 @@ func Run(ctx context.Context, name string) error { fmt.Println("Created organization:", resp.JSON201.Id) if utils.OutputFormat.Value == utils.OutputPretty { table := list.ToMarkdown([]api.OrganizationResponseV1{*resp.JSON201}) - return utils.RenderTable(table) + if err := utils.RenderTable(table); err != nil { + return err + } + survey, err := buildOnboardingSurveyRequest(ctx, organizationSlug(*resp.JSON201)) + if err != nil { + return err + } + if err := submitSurvey(ctx, survey); err != nil { + fmt.Fprintln(os.Stderr, "WARN: failed to submit organization survey:", err) + } + return nil } return utils.EncodeOutput(utils.OutputFormat.Value, os.Stdout, *resp.JSON201) } + +func buildOnboardingSurveyRequest(ctx context.Context, slug string) (onboardingSurveyRequest, error) { + body := onboardingSurveyRequest{Slug: slug} + console := newConsole() + if !console.IsTTY { + return body, nil + } + + fmt.Fprintln(os.Stderr, "Answer two optional questions to help us improve Supabase. Press Enter to skip.") + fmt.Fprintln(os.Stderr) + heardFrom, err := console.PromptText(ctx, "1/2: Where did you hear about us? ") + if err != nil { + return body, err + } + body.HeardFrom = strings.TrimSpace(heardFrom) + + building, err := console.PromptText(ctx, "2/2: What are you building? ") + if err != nil { + return body, err + } + body.Building = strings.TrimSpace(building) + + return body, nil +} + +func organizationSlug(org api.OrganizationResponseV1) string { + if org.Slug != "" { + return org.Slug + } + return org.Id +} + +func submitOnboardingSurvey(ctx context.Context, body onboardingSurveyRequest) error { + if body.HeardFrom == "" && body.Building == "" { + return nil + } + token, err := utils.LoadAccessTokenFS(afero.NewOsFs()) + if err != nil { + return err + } + client := fetcher.NewFetcher( + utils.GetSupabaseAPIHost(), + fetcher.WithBearerToken(token), + fetcher.WithUserAgent("SupabaseCLI/"+utils.Version), + fetcher.WithExpectedStatus(http.StatusNoContent), + ) + resp, err := client.Send(ctx, http.MethodPost, "/platform/organizations/onboarding-survey", body) + if err != nil { + return err + } + return resp.Body.Close() +} diff --git a/apps/cli-go/internal/orgs/create/create_test.go b/apps/cli-go/internal/orgs/create/create_test.go index f491bdc314..98c1dd5694 100644 --- a/apps/cli-go/internal/orgs/create/create_test.go +++ b/apps/cli-go/internal/orgs/create/create_test.go @@ -9,6 +9,7 @@ import ( "github.com/h2non/gock" "github.com/stretchr/testify/assert" "github.com/supabase/cli/internal/testing/apitest" + "github.com/supabase/cli/internal/testing/fstest" "github.com/supabase/cli/internal/utils" "github.com/supabase/cli/pkg/api" ) @@ -16,6 +17,12 @@ import ( func TestOrganizationCreateCommand(t *testing.T) { orgName := "Test Organization" + t.Cleanup(func() { + newConsole = utils.NewConsole + submitSurvey = submitOnboardingSurvey + utils.OutputFormat.Value = utils.OutputPretty + }) + t.Run("create an organization", func(t *testing.T) { // Setup valid access token token := apitest.RandomAccessToken(t) @@ -24,6 +31,78 @@ func TestOrganizationCreateCommand(t *testing.T) { defer gock.OffAll() gock.New(utils.DefaultApiHost). Post("/v1/organizations"). + MatchType("json"). + JSON(api.V1CreateAnOrganizationJSONRequestBody{Name: orgName}). + Reply(http.StatusCreated). + JSON(api.OrganizationResponseV1{ + Id: "combined-fuchsia-lion", + Name: orgName, + }) + // Run test + assert.NoError(t, Run(context.Background(), orgName)) + // Validate api + assert.Empty(t, apitest.ListUnmatchedRequests()) + }) + + t.Run("sends optional survey fields from interactive prompts", func(t *testing.T) { + // Setup valid access token + token := apitest.RandomAccessToken(t) + t.Setenv("SUPABASE_ACCESS_TOKEN", string(token)) + t.Cleanup(fstest.MockStdin(t, "GitHub\nAI coding assistant\n")) + newConsole = func() *utils.Console { + console := utils.NewConsole() + console.IsTTY = true + return console + } + t.Cleanup(func() { + newConsole = utils.NewConsole + }) + // Flush pending mocks after test execution + defer gock.OffAll() + gock.New(utils.DefaultApiHost). + Post("/v1/organizations"). + MatchType("json"). + JSON(api.V1CreateAnOrganizationJSONRequestBody{Name: orgName}). + Reply(http.StatusCreated). + JSON(api.OrganizationResponseV1{ + Id: "combined-fuchsia-lion", + Name: orgName, + Slug: "combined-fuchsia-lion", + }) + gock.New(utils.DefaultApiHost). + Post("/platform/organizations/onboarding-survey"). + MatchType("json"). + JSON(onboardingSurveyRequest{ + Slug: "combined-fuchsia-lion", + HeardFrom: "GitHub", + Building: "AI coding assistant", + }). + Reply(http.StatusNoContent) + // Run test + assert.NoError(t, Run(context.Background(), orgName)) + // Validate api + assert.Empty(t, apitest.ListUnmatchedRequests()) + }) + + t.Run("omits blank survey prompt answers", func(t *testing.T) { + // Setup valid access token + token := apitest.RandomAccessToken(t) + t.Setenv("SUPABASE_ACCESS_TOKEN", string(token)) + t.Cleanup(fstest.MockStdin(t, "\n\n")) + newConsole = func() *utils.Console { + console := utils.NewConsole() + console.IsTTY = true + return console + } + t.Cleanup(func() { + newConsole = utils.NewConsole + }) + // Flush pending mocks after test execution + defer gock.OffAll() + gock.New(utils.DefaultApiHost). + Post("/v1/organizations"). + MatchType("json"). + JSON(api.V1CreateAnOrganizationJSONRequestBody{Name: orgName}). Reply(http.StatusCreated). JSON(api.OrganizationResponseV1{ Id: "combined-fuchsia-lion", @@ -35,6 +114,81 @@ func TestOrganizationCreateCommand(t *testing.T) { assert.Empty(t, apitest.ListUnmatchedRequests()) }) + t.Run("skips survey prompts for structured output", func(t *testing.T) { + // Setup valid access token + token := apitest.RandomAccessToken(t) + t.Setenv("SUPABASE_ACCESS_TOKEN", string(token)) + utils.OutputFormat.Value = utils.OutputJson + t.Cleanup(func() { + utils.OutputFormat.Value = utils.OutputPretty + }) + t.Cleanup(fstest.MockStdin(t, "GitHub\nAI coding assistant\n")) + newConsole = func() *utils.Console { + console := utils.NewConsole() + console.IsTTY = true + return console + } + t.Cleanup(func() { + newConsole = utils.NewConsole + }) + // Flush pending mocks after test execution + defer gock.OffAll() + gock.New(utils.DefaultApiHost). + Post("/v1/organizations"). + MatchType("json"). + JSON(api.V1CreateAnOrganizationJSONRequestBody{Name: orgName}). + Reply(http.StatusCreated). + JSON(api.OrganizationResponseV1{ + Id: "combined-fuchsia-lion", + Name: orgName, + }) + // Run test + assert.NoError(t, Run(context.Background(), orgName)) + // Validate api + assert.Empty(t, apitest.ListUnmatchedRequests()) + }) + + t.Run("does not fail organization creation when survey submit fails", func(t *testing.T) { + // Setup valid access token + token := apitest.RandomAccessToken(t) + t.Setenv("SUPABASE_ACCESS_TOKEN", string(token)) + t.Cleanup(fstest.MockStdin(t, "GitHub\nAI coding assistant\n")) + newConsole = func() *utils.Console { + console := utils.NewConsole() + console.IsTTY = true + return console + } + t.Cleanup(func() { + newConsole = utils.NewConsole + }) + // Flush pending mocks after test execution + defer gock.OffAll() + gock.New(utils.DefaultApiHost). + Post("/v1/organizations"). + MatchType("json"). + JSON(api.V1CreateAnOrganizationJSONRequestBody{Name: orgName}). + Reply(http.StatusCreated). + JSON(api.OrganizationResponseV1{ + Id: "combined-fuchsia-lion", + Name: orgName, + Slug: "combined-fuchsia-lion", + }) + gock.New(utils.DefaultApiHost). + Post("/platform/organizations/onboarding-survey"). + MatchType("json"). + JSON(onboardingSurveyRequest{ + Slug: "combined-fuchsia-lion", + HeardFrom: "GitHub", + Building: "AI coding assistant", + }). + Reply(http.StatusServiceUnavailable). + JSON(map[string]string{"message": "unavailable"}) + // Run test + assert.NoError(t, Run(context.Background(), orgName)) + // Validate api + assert.Empty(t, apitest.ListUnmatchedRequests()) + }) + t.Run("throws error on network error", func(t *testing.T) { // Setup valid access token token := apitest.RandomAccessToken(t) @@ -43,6 +197,8 @@ func TestOrganizationCreateCommand(t *testing.T) { defer gock.OffAll() gock.New(utils.DefaultApiHost). Post("/v1/organizations"). + MatchType("json"). + JSON(api.V1CreateAnOrganizationJSONRequestBody{Name: orgName}). ReplyError(errors.New("network error")) // Run test assert.Error(t, Run(context.Background(), orgName)) @@ -58,6 +214,8 @@ func TestOrganizationCreateCommand(t *testing.T) { defer gock.OffAll() gock.New(utils.DefaultApiHost). Post("/v1/organizations"). + MatchType("json"). + JSON(api.V1CreateAnOrganizationJSONRequestBody{Name: orgName}). Reply(http.StatusServiceUnavailable). JSON(map[string]string{"message": "unavailable"}) // Run test