Skip to content

Commit 70ccefc

Browse files
authored
feat: set organization context in coder organizations (#12265)
* feat: add coder organizations set to change org context `coder organizations set <org>`
1 parent 748cf4b commit 70ccefc

File tree

3 files changed

+216
-3
lines changed

3 files changed

+216
-3
lines changed

cli/organization.go

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
11
package cli
22

33
import (
4+
"errors"
45
"fmt"
6+
"os"
7+
"slices"
58
"strings"
69

710
"github.com/coder/coder/v2/cli/clibase"
811
"github.com/coder/coder/v2/cli/cliui"
12+
"github.com/coder/coder/v2/cli/config"
913
"github.com/coder/coder/v2/codersdk"
14+
"github.com/coder/pretty"
1015
)
1116

1217
func (r *RootCmd) organizations() *clibase.Cmd {
@@ -21,13 +26,183 @@ func (r *RootCmd) organizations() *clibase.Cmd {
2126
},
2227
Children: []*clibase.Cmd{
2328
r.currentOrganization(),
29+
r.switchOrganization(),
2430
},
2531
}
2632

2733
cmd.Options = clibase.OptionSet{}
2834
return cmd
2935
}
3036

37+
func (r *RootCmd) switchOrganization() *clibase.Cmd {
38+
client := new(codersdk.Client)
39+
40+
cmd := &clibase.Cmd{
41+
Use: "set <organization name | ID>",
42+
Short: "set the organization used by the CLI. Pass an empty string to reset to the default organization.",
43+
Long: "set the organization used by the CLI. Pass an empty string to reset to the default organization.\n" + formatExamples(
44+
example{
45+
Description: "Remove the current organization and defer to the default.",
46+
Command: "coder organizations set ''",
47+
},
48+
example{
49+
Description: "Switch to a custom organization.",
50+
Command: "coder organizations set my-org",
51+
},
52+
),
53+
Middleware: clibase.Chain(
54+
r.InitClient(client),
55+
clibase.RequireRangeArgs(0, 1),
56+
),
57+
Options: clibase.OptionSet{},
58+
Handler: func(inv *clibase.Invocation) error {
59+
conf := r.createConfig()
60+
orgs, err := client.OrganizationsByUser(inv.Context(), codersdk.Me)
61+
if err != nil {
62+
return fmt.Errorf("failed to get organizations: %w", err)
63+
}
64+
// Keep the list of orgs sorted
65+
slices.SortFunc(orgs, func(a, b codersdk.Organization) int {
66+
return strings.Compare(a.Name, b.Name)
67+
})
68+
69+
var switchToOrg string
70+
if len(inv.Args) == 0 {
71+
// Pull switchToOrg from a prompt selector, rather than command line
72+
// args.
73+
switchToOrg, err = promptUserSelectOrg(inv, conf, orgs)
74+
if err != nil {
75+
return err
76+
}
77+
} else {
78+
switchToOrg = inv.Args[0]
79+
}
80+
81+
// If the user passes an empty string, we want to remove the organization
82+
// from the config file. This will defer to default behavior.
83+
if switchToOrg == "" {
84+
err := conf.Organization().Delete()
85+
if err != nil && !errors.Is(err, os.ErrNotExist) {
86+
return fmt.Errorf("failed to unset organization: %w", err)
87+
}
88+
_, _ = fmt.Fprintf(inv.Stdout, "Organization unset\n")
89+
} else {
90+
// Find the selected org in our list.
91+
index := slices.IndexFunc(orgs, func(org codersdk.Organization) bool {
92+
return org.Name == switchToOrg || org.ID.String() == switchToOrg
93+
})
94+
if index < 0 {
95+
// Using this error for better error message formatting
96+
err := &codersdk.Error{
97+
Response: codersdk.Response{
98+
Message: fmt.Sprintf("Organization %q not found. Is the name correct, and are you a member of it?", switchToOrg),
99+
Detail: "Ensure the organization argument is correct and you are a member of it.",
100+
},
101+
Helper: fmt.Sprintf("Valid organizations you can switch to: %s", strings.Join(orgNames(orgs), ", ")),
102+
}
103+
return err
104+
}
105+
106+
// Always write the uuid to the config file. Names can change.
107+
err := conf.Organization().Write(orgs[index].ID.String())
108+
if err != nil {
109+
return fmt.Errorf("failed to write organization to config file: %w", err)
110+
}
111+
}
112+
113+
// Verify it worked.
114+
current, err := CurrentOrganization(r, inv, client)
115+
if err != nil {
116+
// An SDK error could be a permission error. So offer the advice to unset the org
117+
// and reset the context.
118+
var sdkError *codersdk.Error
119+
if errors.As(err, &sdkError) {
120+
if sdkError.Helper == "" && sdkError.StatusCode() != 500 {
121+
sdkError.Helper = `If this error persists, try unsetting your org with 'coder organizations set ""'`
122+
}
123+
return sdkError
124+
}
125+
return fmt.Errorf("failed to get current organization: %w", err)
126+
}
127+
128+
_, _ = fmt.Fprintf(inv.Stdout, "Current organization context set to %s (%s)\n", current.Name, current.ID.String())
129+
return nil
130+
},
131+
}
132+
133+
return cmd
134+
}
135+
136+
// promptUserSelectOrg will prompt the user to select an organization from a list
137+
// of their organizations.
138+
func promptUserSelectOrg(inv *clibase.Invocation, conf config.Root, orgs []codersdk.Organization) (string, error) {
139+
// Default choice
140+
var defaultOrg string
141+
// Comes from config file
142+
if conf.Organization().Exists() {
143+
defaultOrg, _ = conf.Organization().Read()
144+
}
145+
146+
// No config? Comes from default org in the list
147+
if defaultOrg == "" {
148+
defIndex := slices.IndexFunc(orgs, func(org codersdk.Organization) bool {
149+
return org.IsDefault
150+
})
151+
if defIndex >= 0 {
152+
defaultOrg = orgs[defIndex].Name
153+
}
154+
}
155+
156+
// Defer to first org
157+
if defaultOrg == "" && len(orgs) > 0 {
158+
defaultOrg = orgs[0].Name
159+
}
160+
161+
// Ensure the `defaultOrg` value is an org name, not a uuid.
162+
// If it is a uuid, change it to the org name.
163+
index := slices.IndexFunc(orgs, func(org codersdk.Organization) bool {
164+
return org.ID.String() == defaultOrg || org.Name == defaultOrg
165+
})
166+
if index >= 0 {
167+
defaultOrg = orgs[index].Name
168+
}
169+
170+
// deselectOption is the option to delete the organization config file and defer
171+
// to default behavior.
172+
const deselectOption = "[Default]"
173+
if defaultOrg == "" {
174+
defaultOrg = deselectOption
175+
}
176+
177+
// Pull value from a prompt
178+
_, _ = fmt.Fprintln(inv.Stdout, pretty.Sprint(cliui.DefaultStyles.Wrap, "Select an organization below to set the current CLI context to:"))
179+
value, err := cliui.Select(inv, cliui.SelectOptions{
180+
Options: append([]string{deselectOption}, orgNames(orgs)...),
181+
Default: defaultOrg,
182+
Size: 10,
183+
HideSearch: false,
184+
})
185+
if err != nil {
186+
return "", err
187+
}
188+
// Deselect is an alias for ""
189+
if value == deselectOption {
190+
value = ""
191+
}
192+
193+
return value, nil
194+
}
195+
196+
// orgNames is a helper function to turn a list of organizations into a list of
197+
// their names as strings.
198+
func orgNames(orgs []codersdk.Organization) []string {
199+
names := make([]string, 0, len(orgs))
200+
for _, org := range orgs {
201+
names = append(names, org.Name)
202+
}
203+
return names
204+
}
205+
31206
func (r *RootCmd) currentOrganization() *clibase.Cmd {
32207
var (
33208
stringFormat func(orgs []codersdk.Organization) (string, error)

cli/organization_test.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,3 +74,37 @@ func TestCurrentOrganization(t *testing.T) {
7474
pty.ExpectMatch(orgs["bar"].ID.String())
7575
})
7676
}
77+
78+
func TestOrganizationSwitch(t *testing.T) {
79+
t.Parallel()
80+
81+
t.Run("Switch", func(t *testing.T) {
82+
t.Parallel()
83+
ownerClient := coderdtest.New(t, nil)
84+
first := coderdtest.CreateFirstUser(t, ownerClient)
85+
// Owner is required to make orgs
86+
client, _ := coderdtest.CreateAnotherUser(t, ownerClient, first.OrganizationID, rbac.RoleOwner())
87+
88+
ctx := testutil.Context(t, testutil.WaitMedium)
89+
orgs := []string{"foo", "bar"}
90+
for _, orgName := range orgs {
91+
_, err := client.CreateOrganization(ctx, codersdk.CreateOrganizationRequest{
92+
Name: orgName,
93+
})
94+
require.NoError(t, err)
95+
}
96+
97+
exp, err := client.OrganizationByName(ctx, "foo")
98+
require.NoError(t, err)
99+
100+
inv, root := clitest.New(t, "organizations", "set", "foo")
101+
clitest.SetupConfig(t, client, root)
102+
pty := ptytest.New(t).Attach(inv)
103+
errC := make(chan error)
104+
go func() {
105+
errC <- inv.Run()
106+
}()
107+
require.NoError(t, <-errC)
108+
pty.ExpectMatch(exp.ID.String())
109+
})
110+
}

cli/root.go

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -743,7 +743,7 @@ func CurrentOrganization(r *RootCmd, inv *clibase.Invocation, client *codersdk.C
743743
return org.IsDefault
744744
})
745745
if index < 0 {
746-
return codersdk.Organization{}, xerrors.Errorf("unable to determine current organization. Use 'coder organizations switch <org>' to select an organization to use")
746+
return codersdk.Organization{}, xerrors.Errorf("unable to determine current organization. Use 'coder set <org>' to select an organization to use")
747747
}
748748

749749
return orgs[index], nil
@@ -1202,8 +1202,12 @@ func formatRunCommandError(err *clibase.RunCommandError, opts *formatOpts) strin
12021202
func formatCoderSDKError(from string, err *codersdk.Error, opts *formatOpts) string {
12031203
var str strings.Builder
12041204
if opts.Verbose {
1205-
_, _ = str.WriteString(pretty.Sprint(headLineStyle(), fmt.Sprintf("API request error to \"%s:%s\". Status code %d", err.Method(), err.URL(), err.StatusCode())))
1206-
_, _ = str.WriteString("\n")
1205+
// If all these fields are empty, then do not print this information.
1206+
// This can occur if the error is being used outside the api.
1207+
if !(err.Method() == "" && err.URL() == "" && err.StatusCode() == 0) {
1208+
_, _ = str.WriteString(pretty.Sprint(headLineStyle(), fmt.Sprintf("API request error to \"%s:%s\". Status code %d", err.Method(), err.URL(), err.StatusCode())))
1209+
_, _ = str.WriteString("\n")
1210+
}
12071211
}
12081212
// Always include this trace. Users can ignore this.
12091213
if from != "" {

0 commit comments

Comments
 (0)