Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions packages/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const (
operationCallLogin2V3 = "CallLogin2V3"
operationCallLoginV3 = "CallLoginV3"
operationCallGetAllOrganizations = "CallGetAllOrganizations"
operationCallGetAllOrganizationsWithSubOrgs = "CallGetAllOrganizationsWithSubOrgs"
operationCallSelectOrganization = "CallSelectOrganization"
operationCallGetAllWorkSpacesUserBelongsTo = "CallGetAllWorkSpacesUserBelongsTo"
operationCallGetProjectById = "CallGetProjectById"
Expand Down Expand Up @@ -63,6 +64,7 @@ const (
)

var ErrNotFound = errors.New("resource not found")
var ErrEndpointNotSupported = errors.New("endpoint not supported by this Infisical instance")

func CallGetEncryptedWorkspaceKey(httpClient *resty.Client, request GetEncryptedWorkspaceKeyRequest) (GetEncryptedWorkspaceKeyResponse, error) {
endpoint := fmt.Sprintf("%v/v2/workspace/%v/encrypted-key", config.INFISICAL_URL, request.WorkspaceId)
Expand Down Expand Up @@ -217,6 +219,31 @@ 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)
}

// 404 means this Infisical instance doesn't support the endpoint yet (older self-hosted).
// Check before the generic IsError() so we can return the sentinel instead of an API error.
if response.StatusCode() == http.StatusNotFound {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could be also when a resource not found as well right? I think Route not found has an explicit message

return GetOrganizationsWithSubOrgsResponse{}, ErrEndpointNotSupported
}

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.
Expand Down
27 changes: 23 additions & 4 deletions packages/api/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
90 changes: 66 additions & 24 deletions packages/cmd/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
}
Expand All @@ -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)
}
Expand All @@ -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.
subOrgMap := 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 {
subOrgMap[o.ID] = o.SubOrganizations
}
}

labels := util.BuildOrgRootLabels(orgs, subOrgMap)

prompt1 := promptui.Select{
Label: label,
Items: labels,
Size: 7,
}
index, _, err := prompt1.Run()
if err != nil {
return "", err
}

selectedOrg := orgs[index]
subs := subOrgMap[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 scope 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,
Expand Down
21 changes: 2 additions & 19 deletions packages/cmd/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -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})
Expand Down
44 changes: 34 additions & 10 deletions packages/util/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -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; subOrgMap is keyed by org ID.
func BuildOrgRootLabels(orgs []api.Organization, subOrgMap map[string][]api.SubOrganization) []string {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: With go static type - we already know it's a map - i don't think we need to say it again as subOrgMap

labels := make([]string, len(orgs))
for i, org := range orgs {
n := len(subOrgMap[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) {
Expand Down
13 changes: 9 additions & 4 deletions test/login_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 of your Infisical projects would you like to connect this project to?") && step < 1 {
step += 1;
} else if strings.Contains(terminalOut, "Which scope within") && step < 1 {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the meaning of scope?

step += 1
stepChan <- step
} else if strings.Contains(terminalOut, "Which of your Infisical projects would you like to connect this project to?") && step < 2 {
step += 1
stepChan <- step
}
}
Expand All @@ -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"))
}
}
}
Expand Down
Loading