diff --git a/cli/organization.go b/cli/organization.go index 9395b21b00..528f9f0d34 100644 --- a/cli/organization.go +++ b/cli/organization.go @@ -24,6 +24,7 @@ func (r *RootCmd) organizations() *serpent.Command { Children: []*serpent.Command{ r.showOrganization(orgContext), r.createOrganization(), + r.deleteOrganization(orgContext), r.organizationMembers(orgContext), r.organizationRoles(orgContext), r.organizationSettings(orgContext), diff --git a/cli/organization_test.go b/cli/organization_test.go index 2347ca6e79..83f49b9cb7 100644 --- a/cli/organization_test.go +++ b/cli/organization_test.go @@ -2,9 +2,11 @@ package cli_test import ( "encoding/json" + "fmt" "net/http" "net/http/httptest" "net/url" + "sync/atomic" "testing" "time" @@ -12,8 +14,10 @@ import ( "github.com/stretchr/testify/require" "github.com/coder/coder/v2/cli/clitest" + "github.com/coder/coder/v2/cli/cliui" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/pty/ptytest" + "github.com/coder/pretty" ) func TestCurrentOrganization(t *testing.T) { @@ -54,6 +58,124 @@ func TestCurrentOrganization(t *testing.T) { }) } +func TestOrganizationDelete(t *testing.T) { + t.Parallel() + + t.Run("Yes", func(t *testing.T) { + t.Parallel() + + orgID := uuid.New() + var deleteCalled atomic.Bool + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/v2/organizations/my-org": + _ = json.NewEncoder(w).Encode(codersdk.Organization{ + MinimalOrganization: codersdk.MinimalOrganization{ + ID: orgID, + Name: "my-org", + }, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + }) + case r.Method == http.MethodDelete && r.URL.Path == fmt.Sprintf("/api/v2/organizations/%s", orgID.String()): + deleteCalled.Store(true) + w.WriteHeader(http.StatusOK) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + client := codersdk.New(must(url.Parse(server.URL))) + inv, root := clitest.New(t, "organizations", "delete", "my-org", "--yes") + clitest.SetupConfig(t, client, root) + + require.NoError(t, inv.Run()) + require.True(t, deleteCalled.Load(), "expected delete request") + }) + + t.Run("Prompted", func(t *testing.T) { + t.Parallel() + + orgID := uuid.New() + var deleteCalled atomic.Bool + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/v2/organizations/my-org": + _ = json.NewEncoder(w).Encode(codersdk.Organization{ + MinimalOrganization: codersdk.MinimalOrganization{ + ID: orgID, + Name: "my-org", + }, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + }) + case r.Method == http.MethodDelete && r.URL.Path == fmt.Sprintf("/api/v2/organizations/%s", orgID.String()): + deleteCalled.Store(true) + w.WriteHeader(http.StatusOK) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + client := codersdk.New(must(url.Parse(server.URL))) + inv, root := clitest.New(t, "organizations", "delete", "my-org") + clitest.SetupConfig(t, client, root) + pty := ptytest.New(t).Attach(inv) + + execDone := make(chan error) + go func() { + execDone <- inv.Run() + }() + + pty.ExpectMatch(fmt.Sprintf("Delete organization %s?", pretty.Sprint(cliui.DefaultStyles.Code, "my-org"))) + pty.WriteLine("yes") + + require.NoError(t, <-execDone) + require.True(t, deleteCalled.Load(), "expected delete request") + }) + + t.Run("Default", func(t *testing.T) { + t.Parallel() + + orgID := uuid.New() + var deleteCalled atomic.Bool + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/v2/organizations/default": + _ = json.NewEncoder(w).Encode(codersdk.Organization{ + MinimalOrganization: codersdk.MinimalOrganization{ + ID: orgID, + Name: "default", + }, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + IsDefault: true, + }) + case r.Method == http.MethodDelete: + deleteCalled.Store(true) + w.WriteHeader(http.StatusOK) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + client := codersdk.New(must(url.Parse(server.URL))) + inv, root := clitest.New(t, "organizations", "delete", "default", "--yes") + clitest.SetupConfig(t, client, root) + + err := inv.Run() + require.Error(t, err) + require.ErrorContains(t, err, "default organization") + require.False(t, deleteCalled.Load(), "expected no delete request") + }) +} + func must[V any](v V, err error) V { if err != nil { panic(err) diff --git a/cli/organizationdelete.go b/cli/organizationdelete.go new file mode 100644 index 0000000000..a5f989fc51 --- /dev/null +++ b/cli/organizationdelete.go @@ -0,0 +1,65 @@ +package cli + +import ( + "fmt" + "time" + + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/pretty" + "github.com/coder/serpent" +) + +func (r *RootCmd) deleteOrganization(_ *OrganizationContext) *serpent.Command { + cmd := &serpent.Command{ + Use: "delete ", + Short: "Delete an organization", + Middleware: serpent.Chain( + serpent.RequireNArgs(1), + ), + Options: serpent.OptionSet{ + cliui.SkipPromptOption(), + }, + Handler: func(inv *serpent.Invocation) error { + client, err := r.InitClient(inv) + if err != nil { + return err + } + + orgArg := inv.Args[0] + organization, err := client.OrganizationByName(inv.Context(), orgArg) + if err != nil { + return err + } + + if organization.IsDefault { + return xerrors.Errorf("cannot delete the default organization %q", organization.Name) + } + + _, err = cliui.Prompt(inv, cliui.PromptOptions{ + Text: fmt.Sprintf("Delete organization %s?", pretty.Sprint(cliui.DefaultStyles.Code, organization.Name)), + IsConfirm: true, + Default: cliui.ConfirmNo, + }) + if err != nil { + return err + } + + err = client.DeleteOrganization(inv.Context(), organization.ID.String()) + if err != nil { + return xerrors.Errorf("delete organization %q: %w", organization.Name, err) + } + + _, _ = fmt.Fprintf( + inv.Stdout, + "Deleted organization %s at %s\n", + pretty.Sprint(cliui.DefaultStyles.Keyword, organization.Name), + cliui.Timestamp(time.Now()), + ) + return nil + }, + } + + return cmd +} diff --git a/cli/testdata/coder_organizations_--help.golden b/cli/testdata/coder_organizations_--help.golden index 5b06825e39..417ba5edb9 100644 --- a/cli/testdata/coder_organizations_--help.golden +++ b/cli/testdata/coder_organizations_--help.golden @@ -9,6 +9,7 @@ USAGE: SUBCOMMANDS: create Create a new organization. + delete Delete an organization members Manage organization members roles Manage organization roles. settings Manage organization settings. diff --git a/cli/testdata/coder_organizations_delete_--help.golden b/cli/testdata/coder_organizations_delete_--help.golden new file mode 100644 index 0000000000..f8982a1d39 --- /dev/null +++ b/cli/testdata/coder_organizations_delete_--help.golden @@ -0,0 +1,15 @@ +coder v0.0.0-devel + +USAGE: + coder organizations delete [flags] + + Delete an organization + + Aliases: rm + +OPTIONS: + -y, --yes bool + Bypass confirmation prompts. + +——— +Run `coder --help` for a list of global options. diff --git a/docs/manifest.json b/docs/manifest.json index 8c755accc9..44d322620f 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -1627,6 +1627,11 @@ "description": "Create a new organization.", "path": "reference/cli/organizations_create.md" }, + { + "title": "organizations delete", + "description": "Delete an organization", + "path": "reference/cli/organizations_delete.md" + }, { "title": "organizations members", "description": "Manage organization members", diff --git a/docs/reference/cli/organizations.md b/docs/reference/cli/organizations.md index c2d4497173..0d4cc7f6a2 100644 --- a/docs/reference/cli/organizations.md +++ b/docs/reference/cli/organizations.md @@ -21,6 +21,7 @@ coder organizations [flags] [subcommand] |------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------| | [show](./organizations_show.md) | Show the organization. Using "selected" will show the selected organization from the "--org" flag. Using "me" will show all organizations you are a member of. | | [create](./organizations_create.md) | Create a new organization. | +| [delete](./organizations_delete.md) | Delete an organization | | [members](./organizations_members.md) | Manage organization members | | [roles](./organizations_roles.md) | Manage organization roles. | | [settings](./organizations_settings.md) | Manage organization settings. | diff --git a/docs/reference/cli/organizations_delete.md b/docs/reference/cli/organizations_delete.md new file mode 100644 index 0000000000..da8a1c717d --- /dev/null +++ b/docs/reference/cli/organizations_delete.md @@ -0,0 +1,24 @@ + +# organizations delete + +Delete an organization + +Aliases: + +* rm + +## Usage + +```console +coder organizations delete [flags] +``` + +## Options + +### -y, --yes + +| | | +|------|-------------------| +| Type | bool | + +Bypass confirmation prompts.