From 0c2cf56a915534ea4abc2aa74613111cc634bd5f Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Wed, 21 Feb 2024 17:44:22 -0600 Subject: [PATCH 01/10] feat: add coder organizations switch to change org context --- cli/organization.go | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/cli/organization.go b/cli/organization.go index 455c0e9988e6d..cb997fa39008d 100644 --- a/cli/organization.go +++ b/cli/organization.go @@ -1,7 +1,9 @@ package cli import ( + "errors" "fmt" + "os" "strings" "github.com/coder/coder/v2/cli/clibase" @@ -28,6 +30,45 @@ func (r *RootCmd) organizations() *clibase.Cmd { return cmd } +func (r *RootCmd) switchOrganization() *clibase.Cmd { + var ( + client = new(codersdk.Client) + ) + + cmd := &clibase.Cmd{ + Use: "switch ", + Short: "Switch the organization used by the cli. Pass an empty string to reset to the default organization.", + Long: "Switch the organization used by the cli. Pass an empty string to reset to the default organization.\n" + formatExamples( + example{ + Description: "Remove the current organization and defer to the default.", + Command: "coder organizations switch ''", + }, + example{ + Description: "Switch to a custom organization.", + Command: "coder organizations switch my-org", + }, + ), + Middleware: clibase.Chain( + r.InitClient(client), + ), + Options: clibase.OptionSet{}, + Handler: func(inv *clibase.Invocation) error { + conf := r.createConfig() + // If the user passes an empty string, we want to remove the organization + if inv.Args[0] == "" { + err := conf.Organization().Delete() + if err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("failed to unset organization: %w", err) + } + } + + return nil + }, + } + + return cmd +} + func (r *RootCmd) currentOrganization() *clibase.Cmd { var ( stringFormat func(orgs []codersdk.Organization) (string, error) From c65ec3595de065dd762fada43010822239e276d3 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Thu, 22 Feb 2024 08:23:35 -0600 Subject: [PATCH 02/10] Switch error message --- cli/organization.go | 35 ++++++++++++++++++++++++++++++++++- cli/root.go | 8 ++++++-- 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/cli/organization.go b/cli/organization.go index cb997fa39008d..6e3780f84823b 100644 --- a/cli/organization.go +++ b/cli/organization.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "os" + "slices" "strings" "github.com/coder/coder/v2/cli/clibase" @@ -23,6 +24,7 @@ func (r *RootCmd) organizations() *clibase.Cmd { }, Children: []*clibase.Cmd{ r.currentOrganization(), + r.switchOrganization(), }, } @@ -53,13 +55,44 @@ func (r *RootCmd) switchOrganization() *clibase.Cmd { ), Options: clibase.OptionSet{}, Handler: func(inv *clibase.Invocation) error { + orgArg := inv.Args[0] conf := r.createConfig() + orgs, err := client.OrganizationsByUser(inv.Context(), codersdk.Me) + if err != nil { + return fmt.Errorf("failed to get organizations: %w", err) + } + // If the user passes an empty string, we want to remove the organization - if inv.Args[0] == "" { + if orgArg == "" { err := conf.Organization().Delete() if err != nil && !errors.Is(err, os.ErrNotExist) { return fmt.Errorf("failed to unset organization: %w", err) } + } else { + index := slices.IndexFunc(orgs, func(org codersdk.Organization) bool { + return org.Name == orgArg || org.ID.String() == orgArg + }) + if index < 0 { + names := make([]string, 0, len(orgs)) + for _, org := range orgs { + names = append(names, org.Name) + } + + // Using this error for better error message formatting + err := &codersdk.Error{ + Response: codersdk.Response{ + Message: fmt.Sprintf("Organization %q not found.", orgArg), + Detail: "Ensure the organization argument is correct and you are a member of it.", + }, + Helper: fmt.Sprintf("Valid organizations you can switch to: %q", strings.Join(names, ", ")), + } + return err + } + + err := conf.Organization().Write(orgs[index].ID.String()) + if err != nil { + return fmt.Errorf("failed to write organization to config file: %w", err) + } } return nil diff --git a/cli/root.go b/cli/root.go index 4d78575c75c1e..f8368cc4ae398 100644 --- a/cli/root.go +++ b/cli/root.go @@ -1192,8 +1192,12 @@ func formatRunCommandError(err *clibase.RunCommandError, opts *formatOpts) strin func formatCoderSDKError(from string, err *codersdk.Error, opts *formatOpts) string { var str strings.Builder if opts.Verbose { - _, _ = str.WriteString(pretty.Sprint(headLineStyle(), fmt.Sprintf("API request error to \"%s:%s\". Status code %d", err.Method(), err.URL(), err.StatusCode()))) - _, _ = str.WriteString("\n") + // If all these fields are empty, then do not print this information. + // This can occur if the error is being used outside the api. + if !(err.Method() == "" && err.URL() == "" && err.StatusCode() == 0) { + _, _ = str.WriteString(pretty.Sprint(headLineStyle(), fmt.Sprintf("API request error to \"%s:%s\". Status code %d", err.Method(), err.URL(), err.StatusCode()))) + _, _ = str.WriteString("\n") + } } // Always include this trace. Users can ignore this. if from != "" { From ec35cf56af067fb5a77c9226d8db7c7bbd592440 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Thu, 22 Feb 2024 08:56:30 -0600 Subject: [PATCH 03/10] include user prompt if no org specified --- cli/organization.go | 117 +++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 109 insertions(+), 8 deletions(-) diff --git a/cli/organization.go b/cli/organization.go index 6e3780f84823b..66f5615b9328e 100644 --- a/cli/organization.go +++ b/cli/organization.go @@ -9,7 +9,9 @@ import ( "github.com/coder/coder/v2/cli/clibase" "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/cli/config" "github.com/coder/coder/v2/codersdk" + "github.com/coder/pretty" ) func (r *RootCmd) organizations() *clibase.Cmd { @@ -52,49 +54,80 @@ func (r *RootCmd) switchOrganization() *clibase.Cmd { ), Middleware: clibase.Chain( r.InitClient(client), + clibase.RequireRangeArgs(0, 1), ), Options: clibase.OptionSet{}, Handler: func(inv *clibase.Invocation) error { - orgArg := inv.Args[0] conf := r.createConfig() orgs, err := client.OrganizationsByUser(inv.Context(), codersdk.Me) if err != nil { return fmt.Errorf("failed to get organizations: %w", err) } + // Keep the list of orgs sorted + slices.SortFunc(orgs, func(a, b codersdk.Organization) int { + return strings.Compare(a.Name, b.Name) + }) + + var orgArg string + if len(inv.Args) == 0 { + // Pull orgArg from a prompt selector, rather than command line + // args. + orgArg, err = promptUserSelectOrg(inv, conf, orgs) + if err != nil { + return err + } + } else { + orgArg = inv.Args[0] + } // If the user passes an empty string, we want to remove the organization + // from the config file. This will defer to default behavior. if orgArg == "" { err := conf.Organization().Delete() if err != nil && !errors.Is(err, os.ErrNotExist) { return fmt.Errorf("failed to unset organization: %w", err) } + _, _ = fmt.Fprintf(inv.Stdout, "Organization unset\n") } else { + // Find the selected org in our list. index := slices.IndexFunc(orgs, func(org codersdk.Organization) bool { return org.Name == orgArg || org.ID.String() == orgArg }) if index < 0 { - names := make([]string, 0, len(orgs)) - for _, org := range orgs { - names = append(names, org.Name) - } - // Using this error for better error message formatting err := &codersdk.Error{ Response: codersdk.Response{ - Message: fmt.Sprintf("Organization %q not found.", orgArg), + Message: fmt.Sprintf("Organization %q not found. Is the name correct, and are you a member of it?", orgArg), Detail: "Ensure the organization argument is correct and you are a member of it.", }, - Helper: fmt.Sprintf("Valid organizations you can switch to: %q", strings.Join(names, ", ")), + Helper: fmt.Sprintf("Valid organizations you can switch to: %q", strings.Join(orgNames(orgs), ", ")), } return err } + // Always write the uuid to the config file. Names can change. err := conf.Organization().Write(orgs[index].ID.String()) if err != nil { return fmt.Errorf("failed to write organization to config file: %w", err) } } + // Verify it worked. + current, err := CurrentOrganization(r, inv, client) + if err != nil { + // An SDK error could be a permission error. So offer the advice to unset the org + // and reset the context. + var sdkError *codersdk.Error + if errors.As(err, &sdkError) { + if sdkError.Helper == "" && sdkError.StatusCode() != 500 { + sdkError.Helper = fmt.Sprintf("If this error persists, try unsetting your org with 'coder organizations switch \"\"'") + } + return sdkError + } + return fmt.Errorf("failed to get current organization: %w", err) + } + + _, _ = fmt.Fprintf(inv.Stdout, "Current organization context set to %s (%s)\n", current.Name, current.ID.String()) return nil }, } @@ -102,6 +135,74 @@ func (r *RootCmd) switchOrganization() *clibase.Cmd { return cmd } +// promptUserSelectOrg will prompt the user to select an organization from a list +// of their organizations. +func promptUserSelectOrg(inv *clibase.Invocation, conf config.Root, orgs []codersdk.Organization) (string, error) { + // Default choice + var defaultOrg string + // Comes from config file + if conf.Organization().Exists() { + defaultOrg, _ = conf.Organization().Read() + } + + // No config? Comes from default org in the list + if defaultOrg == "" { + defIndex := slices.IndexFunc(orgs, func(org codersdk.Organization) bool { + return org.IsDefault + }) + if defIndex >= 0 { + defaultOrg = orgs[defIndex].Name + } + } + + // Defer to first org + if defaultOrg == "" && len(orgs) > 0 { + defaultOrg = orgs[0].Name + } + + // Ensure the `defaultOrg` value is an org name, not a uuid. + // If it is a uuid, change it to the org name. + index := slices.IndexFunc(orgs, func(org codersdk.Organization) bool { + return org.ID.String() == defaultOrg || org.Name == defaultOrg + }) + if index >= 0 { + defaultOrg = orgs[index].Name + } + + const deselectOption = "--Deselect--" + if defaultOrg == "" { + defaultOrg = deselectOption + } + + // Pull value from a prompt + _, _ = fmt.Fprintln(inv.Stdout, pretty.Sprint(cliui.DefaultStyles.Wrap, "Select an organization below to switch the current cli context to:")) + value, err := cliui.Select(inv, cliui.SelectOptions{ + Options: append([]string{deselectOption}, orgNames(orgs)...), + Default: defaultOrg, + Size: 10, + HideSearch: false, + }) + if err != nil { + return "", err + } + // Deselect is an alias for "" + if value == deselectOption { + value = "" + } + + return value, nil +} + +// orgNames is a helper function to turn a list of organizations into a list of +// their names as strings. +func orgNames(orgs []codersdk.Organization) []string { + names := make([]string, 0, len(orgs)) + for _, org := range orgs { + names = append(names, org.Name) + } + return names +} + func (r *RootCmd) currentOrganization() *clibase.Cmd { var ( stringFormat func(orgs []codersdk.Organization) (string, error) From 255bbe5afe778f84e3d66ffa0973a359f26ecda6 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Thu, 22 Feb 2024 11:40:49 -0600 Subject: [PATCH 04/10] linting --- cli/organization.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/organization.go b/cli/organization.go index 66f5615b9328e..5af88d0858330 100644 --- a/cli/organization.go +++ b/cli/organization.go @@ -120,7 +120,7 @@ func (r *RootCmd) switchOrganization() *clibase.Cmd { var sdkError *codersdk.Error if errors.As(err, &sdkError) { if sdkError.Helper == "" && sdkError.StatusCode() != 500 { - sdkError.Helper = fmt.Sprintf("If this error persists, try unsetting your org with 'coder organizations switch \"\"'") + sdkError.Helper = `If this error persists, try unsetting your org with 'coder organizations switch ""'` } return sdkError } From 6244193da58c0a1965b2f1b0bab04cdb48c3f370 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Thu, 22 Feb 2024 11:43:05 -0600 Subject: [PATCH 05/10] Add unit test for switch cli --- cli/organization_test.go | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/cli/organization_test.go b/cli/organization_test.go index 658498883ece8..61af79b65d805 100644 --- a/cli/organization_test.go +++ b/cli/organization_test.go @@ -43,3 +43,37 @@ func TestCurrentOrganization(t *testing.T) { pty.ExpectMatch(first.OrganizationID.String()) }) } + +func TestOrganizationSwitch(t *testing.T) { + t.Parallel() + + t.Run("Switch", func(t *testing.T) { + t.Parallel() + ownerClient := coderdtest.New(t, nil) + first := coderdtest.CreateFirstUser(t, ownerClient) + // Owner is required to make orgs + client, _ := coderdtest.CreateAnotherUser(t, ownerClient, first.OrganizationID, rbac.RoleOwner()) + + ctx := testutil.Context(t, testutil.WaitMedium) + orgs := []string{"foo", "bar"} + for _, orgName := range orgs { + _, err := client.CreateOrganization(ctx, codersdk.CreateOrganizationRequest{ + Name: orgName, + }) + require.NoError(t, err) + } + + exp, err := client.OrganizationByName(ctx, codersdk.Me, "foo") + require.NoError(t, err) + + inv, root := clitest.New(t, "organizations", "switch", "foo") + clitest.SetupConfig(t, client, root) + pty := ptytest.New(t).Attach(inv) + errC := make(chan error) + go func() { + errC <- inv.Run() + }() + require.NoError(t, <-errC) + pty.ExpectMatch(exp.ID.String()) + }) +} From 81d6a1ed034353510513c9693b28fcad7ebe930b Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Thu, 22 Feb 2024 13:23:55 -0600 Subject: [PATCH 06/10] fmt --- cli/organization.go | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/cli/organization.go b/cli/organization.go index 5af88d0858330..aad8d4df77ccf 100644 --- a/cli/organization.go +++ b/cli/organization.go @@ -35,9 +35,7 @@ func (r *RootCmd) organizations() *clibase.Cmd { } func (r *RootCmd) switchOrganization() *clibase.Cmd { - var ( - client = new(codersdk.Client) - ) + client := new(codersdk.Client) cmd := &clibase.Cmd{ Use: "switch ", From b07765cf6dec17fdc7931af8ff42af4db868404f Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Thu, 22 Feb 2024 13:37:29 -0600 Subject: [PATCH 07/10] rename var --- cli/organization.go | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/cli/organization.go b/cli/organization.go index aad8d4df77ccf..24b57babf6a4b 100644 --- a/cli/organization.go +++ b/cli/organization.go @@ -66,21 +66,21 @@ func (r *RootCmd) switchOrganization() *clibase.Cmd { return strings.Compare(a.Name, b.Name) }) - var orgArg string + var switchToOrg string if len(inv.Args) == 0 { - // Pull orgArg from a prompt selector, rather than command line + // Pull switchToOrg from a prompt selector, rather than command line // args. - orgArg, err = promptUserSelectOrg(inv, conf, orgs) + switchToOrg, err = promptUserSelectOrg(inv, conf, orgs) if err != nil { return err } } else { - orgArg = inv.Args[0] + switchToOrg = inv.Args[0] } // If the user passes an empty string, we want to remove the organization // from the config file. This will defer to default behavior. - if orgArg == "" { + if switchToOrg == "" { err := conf.Organization().Delete() if err != nil && !errors.Is(err, os.ErrNotExist) { return fmt.Errorf("failed to unset organization: %w", err) @@ -89,13 +89,13 @@ func (r *RootCmd) switchOrganization() *clibase.Cmd { } else { // Find the selected org in our list. index := slices.IndexFunc(orgs, func(org codersdk.Organization) bool { - return org.Name == orgArg || org.ID.String() == orgArg + return org.Name == switchToOrg || org.ID.String() == switchToOrg }) if index < 0 { // Using this error for better error message formatting err := &codersdk.Error{ Response: codersdk.Response{ - Message: fmt.Sprintf("Organization %q not found. Is the name correct, and are you a member of it?", orgArg), + Message: fmt.Sprintf("Organization %q not found. Is the name correct, and are you a member of it?", switchToOrg), Detail: "Ensure the organization argument is correct and you are a member of it.", }, Helper: fmt.Sprintf("Valid organizations you can switch to: %q", strings.Join(orgNames(orgs), ", ")), From 48947ecfd86a6778abe382383dd7920d4f98cac4 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Fri, 23 Feb 2024 10:39:04 -0600 Subject: [PATCH 08/10] remove quotes from org list --- cli/organization.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/organization.go b/cli/organization.go index 24b57babf6a4b..481d1d6e70211 100644 --- a/cli/organization.go +++ b/cli/organization.go @@ -98,7 +98,7 @@ func (r *RootCmd) switchOrganization() *clibase.Cmd { Message: fmt.Sprintf("Organization %q not found. Is the name correct, and are you a member of it?", switchToOrg), Detail: "Ensure the organization argument is correct and you are a member of it.", }, - Helper: fmt.Sprintf("Valid organizations you can switch to: %q", strings.Join(orgNames(orgs), ", ")), + Helper: fmt.Sprintf("Valid organizations you can switch to: %s", strings.Join(orgNames(orgs), ", ")), } return err } From 887bcaee0137f8224cc77aadf04ba663b80b0bb5 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Fri, 23 Feb 2024 10:43:36 -0600 Subject: [PATCH 09/10] switch -> set --- cli/organization.go | 18 ++++++++++-------- cli/organization_test.go | 2 +- cli/root.go | 2 +- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/cli/organization.go b/cli/organization.go index 481d1d6e70211..f014cc40ee02a 100644 --- a/cli/organization.go +++ b/cli/organization.go @@ -38,16 +38,16 @@ func (r *RootCmd) switchOrganization() *clibase.Cmd { client := new(codersdk.Client) cmd := &clibase.Cmd{ - Use: "switch ", - Short: "Switch the organization used by the cli. Pass an empty string to reset to the default organization.", - Long: "Switch the organization used by the cli. Pass an empty string to reset to the default organization.\n" + formatExamples( + Use: "set ", + Short: "set the organization used by the CLI. Pass an empty string to reset to the default organization.", + Long: "set the organization used by the CLI. Pass an empty string to reset to the default organization.\n" + formatExamples( example{ Description: "Remove the current organization and defer to the default.", - Command: "coder organizations switch ''", + Command: "coder organizations set ''", }, example{ Description: "Switch to a custom organization.", - Command: "coder organizations switch my-org", + Command: "coder organizations set my-org", }, ), Middleware: clibase.Chain( @@ -118,7 +118,7 @@ func (r *RootCmd) switchOrganization() *clibase.Cmd { var sdkError *codersdk.Error if errors.As(err, &sdkError) { if sdkError.Helper == "" && sdkError.StatusCode() != 500 { - sdkError.Helper = `If this error persists, try unsetting your org with 'coder organizations switch ""'` + sdkError.Helper = `If this error persists, try unsetting your org with 'coder organizations set ""'` } return sdkError } @@ -167,13 +167,15 @@ func promptUserSelectOrg(inv *clibase.Invocation, conf config.Root, orgs []coder defaultOrg = orgs[index].Name } - const deselectOption = "--Deselect--" + // deselectOption is the option to delete the organization config file and defer + // to default behavior. + const deselectOption = "[Default]" if defaultOrg == "" { defaultOrg = deselectOption } // Pull value from a prompt - _, _ = fmt.Fprintln(inv.Stdout, pretty.Sprint(cliui.DefaultStyles.Wrap, "Select an organization below to switch the current cli context to:")) + _, _ = fmt.Fprintln(inv.Stdout, pretty.Sprint(cliui.DefaultStyles.Wrap, "Select an organization below to set the current CLI context to:")) value, err := cliui.Select(inv, cliui.SelectOptions{ Options: append([]string{deselectOption}, orgNames(orgs)...), Default: defaultOrg, diff --git a/cli/organization_test.go b/cli/organization_test.go index 61af79b65d805..b09efefe4122f 100644 --- a/cli/organization_test.go +++ b/cli/organization_test.go @@ -66,7 +66,7 @@ func TestOrganizationSwitch(t *testing.T) { exp, err := client.OrganizationByName(ctx, codersdk.Me, "foo") require.NoError(t, err) - inv, root := clitest.New(t, "organizations", "switch", "foo") + inv, root := clitest.New(t, "organizations", "set", "foo") clitest.SetupConfig(t, client, root) pty := ptytest.New(t).Attach(inv) errC := make(chan error) diff --git a/cli/root.go b/cli/root.go index f8368cc4ae398..e3690d85ae447 100644 --- a/cli/root.go +++ b/cli/root.go @@ -733,7 +733,7 @@ func CurrentOrganization(r *RootCmd, inv *clibase.Invocation, client *codersdk.C return org.IsDefault }) if index < 0 { - return codersdk.Organization{}, xerrors.Errorf("unable to determine current organization. Use 'coder organizations switch ' to select an organization to use") + return codersdk.Organization{}, xerrors.Errorf("unable to determine current organization. Use 'coder set ' to select an organization to use") } return orgs[index], nil From b34cdfed242183912b960a4a0dfaa8d49b4cf963 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Mon, 26 Feb 2024 08:44:44 -0600 Subject: [PATCH 10/10] fix compile issue --- cli/organization_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/organization_test.go b/cli/organization_test.go index b09efefe4122f..bb8801c25af5b 100644 --- a/cli/organization_test.go +++ b/cli/organization_test.go @@ -63,7 +63,7 @@ func TestOrganizationSwitch(t *testing.T) { require.NoError(t, err) } - exp, err := client.OrganizationByName(ctx, codersdk.Me, "foo") + exp, err := client.OrganizationByName(ctx, "foo") require.NoError(t, err) inv, root := clitest.New(t, "organizations", "set", "foo")