diff --git a/packages/api/api.go b/packages/api/api.go index d2b454ea..a6820870 100644 --- a/packages/api/api.go +++ b/packages/api/api.go @@ -23,6 +23,7 @@ const ( operationCallLogin2V3 = "CallLogin2V3" operationCallLoginV3 = "CallLoginV3" operationCallGetAllOrganizations = "CallGetAllOrganizations" + operationCallGetAllOrganizationsWithSubOrgs = "CallGetAllOrganizationsWithSubOrgs" operationCallSelectOrganization = "CallSelectOrganization" operationCallGetAllWorkSpacesUserBelongsTo = "CallGetAllWorkSpacesUserBelongsTo" operationCallGetProjectById = "CallGetProjectById" @@ -217,6 +218,29 @@ func CallLogin2V2(httpClient *resty.Client, request GetLoginTwoV2Request) (GetLo return loginTwoV2Response, nil } +func CallGetAllOrganizationsWithSubOrgs(httpClient *resty.Client) (GetOrganizationsWithSubOrgsResponse, error) { + var resp GetOrganizationsWithSubOrgsResponse + response, err := httpClient. + R(). + SetResult(&resp). + SetHeader("User-Agent", USER_AGENT). + Get(fmt.Sprintf("%v/v1/organization/accessible-with-sub-orgs", config.INFISICAL_URL)) + + if err != nil { + return GetOrganizationsWithSubOrgsResponse{}, NewGenericRequestError(operationCallGetAllOrganizationsWithSubOrgs, err) + } + + if response.StatusCode() == http.StatusNotFound { + return GetOrganizationsWithSubOrgsResponse{}, ErrNotFound + } + + if response.IsError() { + return GetOrganizationsWithSubOrgsResponse{}, NewAPIErrorWithResponse(operationCallGetAllOrganizationsWithSubOrgs, response, nil) + } + + return resp, nil +} + func CallGetAllOrganizations(httpClient *resty.Client) (GetOrganizationsResponse, error) { var orgResponse GetOrganizationsResponse response, err := httpClient. diff --git a/packages/api/model.go b/packages/api/model.go index 26124c03..881305f6 100644 --- a/packages/api/model.go +++ b/packages/api/model.go @@ -151,11 +151,30 @@ type GetCertificateProfileResponse struct { CertificateProfile CertificateProfile `json:"certificateProfile"` } +type Organization struct { + ID string `json:"id"` + Name string `json:"name"` +} + type GetOrganizationsResponse struct { - Organizations []struct { - ID string `json:"id"` - Name string `json:"name"` - } `json:"organizations"` + Organizations []Organization `json:"organizations"` +} + +type SubOrganization struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` +} + +type OrganizationWithSubOrgs struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + SubOrganizations []SubOrganization `json:"subOrganizations"` +} + +type GetOrganizationsWithSubOrgsResponse struct { + Organizations []OrganizationWithSubOrgs `json:"organizations"` } type SelectOrganizationResponse struct { diff --git a/packages/cmd/init.go b/packages/cmd/init.go index 6cb0203f..b1bc74b6 100644 --- a/packages/cmd/init.go +++ b/packages/cmd/init.go @@ -8,8 +8,10 @@ import ( "fmt" "github.com/Infisical/infisical-merge/packages/api" + "github.com/Infisical/infisical-merge/packages/config" "github.com/Infisical/infisical-merge/packages/models" "github.com/Infisical/infisical-merge/packages/util" + "github.com/go-resty/resty/v2" "github.com/manifoldco/promptui" "github.com/posthog/posthog-go" "github.com/rs/zerolog/log" @@ -55,29 +57,12 @@ var initCmd = &cobra.Command{ } httpClient.SetAuthToken(userCreds.UserCredentials.JTWToken) - organizationResponse, err := api.CallGetAllOrganizations(httpClient) + selectedOrgID, err := pickOrganization(httpClient, "Which Infisical organization would you like to select a project from?", userCreds.UserCredentials.Email) if err != nil { - util.HandleError(err, "Unable to pull organizations that belong to you") - } - - organizations := organizationResponse.Organizations - - organizationNames := util.GetOrganizationsNameList(organizationResponse) - - prompt := promptui.Select{ - Label: "Which Infisical organization would you like to select a project from?", - Items: organizationNames, - Size: 7, - } - - index, _, err := prompt.Run() - if err != nil { - util.HandleError(err) + util.HandleError(err, "Unable to select organization") } - selectedOrganization := organizations[index] - - tokenResponse, err := api.CallSelectOrganization(httpClient, api.SelectOrganizationRequest{OrganizationId: selectedOrganization.ID}) + tokenResponse, err := api.CallSelectOrganization(httpClient, api.SelectOrganizationRequest{OrganizationId: selectedOrgID}) if tokenResponse.MfaEnabled { i := 1 for i < 6 { @@ -113,7 +98,7 @@ var initCmd = &cobra.Command{ i++ } else { httpClient.SetAuthToken(verifyMFAresponse.Token) - tokenResponse, err = api.CallSelectOrganization(httpClient, api.SelectOrganizationRequest{OrganizationId: selectedOrganization.ID}) + tokenResponse, err = api.CallSelectOrganization(httpClient, api.SelectOrganizationRequest{OrganizationId: selectedOrgID}) break } } @@ -137,15 +122,15 @@ var initCmd = &cobra.Command{ util.HandleError(err, "Unable to pull projects that belong to you") } - filteredWorkspaces, workspaceNames := util.GetWorkspacesInOrganization(workspaceResponse, selectedOrganization.ID) + filteredWorkspaces, workspaceNames := util.GetWorkspacesInOrganization(workspaceResponse, selectedOrgID) - prompt = promptui.Select{ + prompt := promptui.Select{ Label: "Which of your Infisical projects would you like to connect this project to?", Items: workspaceNames, Size: 7, } - index, _, err = prompt.Run() + index, _, err := prompt.Run() if err != nil { util.HandleError(err) } @@ -164,6 +149,63 @@ func init() { RootCmd.AddCommand(initCmd) } +// pickOrganization prompts the user to select an organization (and optionally a sub-org). +// GET /v1/organization is always used as the source of truth for the org list. +// GET /v1/organization/accessible-with-sub-orgs is used only to enrich entries with sub-org +// counts and the second-level picker — if it fails or omits an org, that org still appears. +func pickOrganization(httpClient *resty.Client, label string, username string) (string, error) { + orgResp, err := api.CallGetAllOrganizations(httpClient) + if err != nil { + return "", err + } + orgs := orgResp.Organizations + if len(orgs) == 0 { + util.PrintErrorMessageAndExit(fmt.Sprintf("You don't have any organization created in Infisical. You must first create a organization at %s", config.INFISICAL_URL)) + } + + // Best-effort: enrich with sub-org data. Ignore any error — the flat list is enough. + subOrgsByOrgID := map[string][]api.SubOrganization{} + if subOrgsResp, err := api.CallGetAllOrganizationsWithSubOrgs(httpClient); err != nil { + log.Debug().Err(err).Str("username", username).Msg("Failed to fetch sub-org data; falling back to flat org list") + } else { + for _, o := range subOrgsResp.Organizations { + subOrgsByOrgID[o.ID] = o.SubOrganizations + } + } + + labels := util.BuildOrgRootLabels(orgs, subOrgsByOrgID) + + prompt1 := promptui.Select{ + Label: label, + Items: labels, + Size: 7, + } + index, _, err := prompt1.Run() + if err != nil { + return "", err + } + + selectedOrg := orgs[index] + subs := subOrgsByOrgID[selectedOrg.ID] + + if len(subs) == 0 { + return selectedOrg.ID, nil + } + + // Second prompt: root org itself or one of its sub-orgs + subItems, subLabels := util.BuildSubOrgPickerItems(selectedOrg.ID, selectedOrg.Name, subs) + prompt2 := promptui.Select{ + Label: fmt.Sprintf("Which organization or sub-organization within %s?", selectedOrg.Name), + Items: subLabels, + Size: 7, + } + subIndex, _, err := prompt2.Run() + if err != nil { + return "", err + } + return subItems[subIndex].ID, nil +} + func writeWorkspaceFile(selectedWorkspace models.Workspace) error { workspaceFileToSave := models.WorkspaceConfigFile{ WorkspaceId: selectedWorkspace.ID, diff --git a/packages/cmd/login.go b/packages/cmd/login.go index dea36b46..0b88f086 100644 --- a/packages/cmd/login.go +++ b/packages/cmd/login.go @@ -713,27 +713,10 @@ func GetJwtTokenWithOrganizationId(oldJwtToken string, email string, organizatio selectedOrganizationId := organizationId if selectedOrganizationId == "" { - organizationResponse, err := api.CallGetAllOrganizations(httpClient) - + selectedOrganizationId, err = pickOrganization(httpClient, "Which Infisical organization would you like to log into?", email) if err != nil { - util.HandleError(err, "Unable to pull organizations that belong to you") - } - - organizations := organizationResponse.Organizations - - organizationNames := util.GetOrganizationsNameList(organizationResponse) - - prompt := promptui.Select{ - Label: "Which Infisical organization would you like to log into?", - Items: organizationNames, + util.HandleError(err, "Unable to select organization") } - - index, _, err := prompt.Run() - if err != nil { - util.HandleError(err) - } - - selectedOrganizationId = organizations[index].ID } selectedOrgRes, err := api.CallSelectOrganization(httpClient, api.SelectOrganizationRequest{OrganizationId: selectedOrganizationId}) diff --git a/packages/util/init.go b/packages/util/init.go index 4aecb2ab..da6c5e49 100644 --- a/packages/util/init.go +++ b/packages/util/init.go @@ -8,20 +8,44 @@ import ( "github.com/Infisical/infisical-merge/packages/models" ) -func GetOrganizationsNameList(organizationResponse api.GetOrganizationsResponse) []string { - organizations := organizationResponse.Organizations +// OrgPickerItem holds the org ID to pass to CallSelectOrganization. +type OrgPickerItem struct { + ID string +} - if len(organizations) == 0 { - message := fmt.Sprintf("You don't have any organization created in Infisical. You must first create a organization at %s", config.INFISICAL_URL) - PrintErrorMessageAndExit(message) +// BuildOrgRootLabels returns first-prompt labels: org name with sub-org count when present. +// orgs is the flat list from GET /v1/organization; subOrgsByOrgID is keyed by org ID. +func BuildOrgRootLabels(orgs []api.Organization, subOrgsByOrgID map[string][]api.SubOrganization) []string { + labels := make([]string, len(orgs)) + for i, org := range orgs { + n := len(subOrgsByOrgID[org.ID]) + switch n { + case 0: + labels[i] = org.Name + case 1: + labels[i] = fmt.Sprintf("%s (1 sub-org)", org.Name) + default: + labels[i] = fmt.Sprintf("%s (%d sub-orgs)", org.Name, n) + } } + return labels +} - var organizationNames []string - for _, workspace := range organizations { - organizationNames = append(organizationNames, workspace.Name) - } +// BuildSubOrgPickerItems returns items + labels for the second prompt. +// The first item is always the root org itself, followed by each sub-org. +func BuildSubOrgPickerItems(rootID, rootName string, subs []api.SubOrganization) ([]OrgPickerItem, []string) { + items := make([]OrgPickerItem, 0, 1+len(subs)) + labels := make([]string, 0, 1+len(subs)) + + rootLabel := fmt.Sprintf("%s (organization)", rootName) + items = append(items, OrgPickerItem{ID: rootID}) + labels = append(labels, rootLabel) - return organizationNames + for _, sub := range subs { + items = append(items, OrgPickerItem{ID: sub.ID}) + labels = append(labels, sub.Name) + } + return items, labels } func GetWorkspacesInOrganization(workspaceResponse api.GetWorkSpacesResponse, orgId string) ([]models.Workspace, []string) { diff --git a/test/login_test.go b/test/login_test.go index 71273a3e..4f60a446 100644 --- a/test/login_test.go +++ b/test/login_test.go @@ -15,8 +15,8 @@ func UserInitCmd() { ptmx, err := pty.Start(c) if err != nil { log.Fatalf("error running CLI command: %v", err) - } - defer func() { _ = ptmx.Close() }() + } + defer func() { _ = ptmx.Close() }() stepChan := make(chan int, 10) @@ -27,11 +27,14 @@ func UserInitCmd() { n, err := ptmx.Read(buf) if n > 0 { terminalOut := string(buf) - if strings.Contains(terminalOut, "Which Infisical organization would you like to select a project from?") && step < 0 { + if strings.Contains(terminalOut, "Which Infisical organization would you like to select a project from?") && step < 0 { + step += 1 + stepChan <- step + } else if strings.Contains(terminalOut, "Which organization or sub-organization within") && step < 1 { step += 1 stepChan <- step - } else if strings.Contains(terminalOut, "Which of your Infisical projects would you like to connect this project to?") && step < 1 { - step += 1; + } else if strings.Contains(terminalOut, "Which of your Infisical projects would you like to connect this project to?") && step < 2 { + step += 1 stepChan <- step } } @@ -44,10 +47,12 @@ func UserInitCmd() { for i := range stepChan { switch i { - case 0: + case 0: ptmx.Write([]byte("\n")) case 1: ptmx.Write([]byte("\n")) + case 2: + ptmx.Write([]byte("\n")) } } } @@ -65,8 +70,8 @@ func UserLoginCmd() { ptmx, err := pty.Start(c) if err != nil { log.Fatalf("error running CLI command: %v", err) - } - defer func() { _ = ptmx.Close() }() + } + defer func() { _ = ptmx.Close() }() stepChan := make(chan int, 10) @@ -78,19 +83,19 @@ func UserLoginCmd() { if n > 0 { terminalOut := string(buf) if strings.Contains(terminalOut, "Infisical Cloud") && step < 0 { - step += 1; + step += 1 stepChan <- step } else if strings.Contains(terminalOut, "Email") && step < 1 { - step += 1; + step += 1 stepChan <- step } else if strings.Contains(terminalOut, "Password") && step < 2 { - step += 1; + step += 1 stepChan <- step } else if strings.Contains(terminalOut, "Infisical organization") && step < 3 { - step += 1; + step += 1 stepChan <- step } else if strings.Contains(terminalOut, "Enter passphrase") && step < 4 { - step += 1; + step += 1 stepChan <- step } }