diff --git a/cmd/cloudx/client/command_helper.go b/cmd/cloudx/client/command_helper.go index a36d0264..1f43d1df 100644 --- a/cmd/cloudx/client/command_helper.go +++ b/cmd/cloudx/client/command_helper.go @@ -34,7 +34,10 @@ const ( ProjectAPIKey = "ORY_PROJECT_API_KEY" ) -var ErrProjectNotSet = fmt.Errorf("no project was specified") +var ( + ErrProjectNotSet = fmt.Errorf("no project was specified") + ErrWorkspaceNotSet = fmt.Errorf("no workspace was specified") +) type ( CommandHelper struct { diff --git a/cmd/cloudx/use.go b/cmd/cloudx/use.go index ceadccd5..c1518631 100644 --- a/cmd/cloudx/use.go +++ b/cmd/cloudx/use.go @@ -8,6 +8,7 @@ import ( "github.com/ory/cli/cmd/cloudx/client" "github.com/ory/cli/cmd/cloudx/project" + "github.com/ory/cli/cmd/cloudx/workspace" "github.com/ory/x/cmdx" ) @@ -19,6 +20,7 @@ func NewUseCmd() *cobra.Command { cmd.AddCommand( project.NewUseProjectCmd(), + workspace.NewUseWorkspaceCmd(), ) client.RegisterConfigFlag(cmd.PersistentFlags()) diff --git a/cmd/cloudx/workspace/output.go b/cmd/cloudx/workspace/output.go index d7a33322..4c349967 100644 --- a/cmd/cloudx/workspace/output.go +++ b/cmd/cloudx/workspace/output.go @@ -54,3 +54,23 @@ func (o outputWorkspaces) Interface() interface{} { func (o outputWorkspaces) Len() int { return len(o) } + +// selectedWorkspace is the output of `ory use workspace`, mirroring the shape +// `ory use project` prints. +type selectedWorkspace struct { + ID string `json:"id"` +} + +var _ cmdx.TableRow = (*selectedWorkspace)(nil) + +func (*selectedWorkspace) Header() []string { + return []string{"ID"} +} + +func (i *selectedWorkspace) Columns() []string { + return []string{i.ID} +} + +func (i *selectedWorkspace) Interface() interface{} { + return i +} diff --git a/cmd/cloudx/workspace/use.go b/cmd/cloudx/workspace/use.go new file mode 100644 index 00000000..f54ae4ae --- /dev/null +++ b/cmd/cloudx/workspace/use.go @@ -0,0 +1,62 @@ +// Copyright © 2026 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package workspace + +import ( + "github.com/spf13/cobra" + + "github.com/ory/cli/cmd/cloudx/client" + "github.com/ory/x/cmdx" +) + +func NewUseWorkspaceCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "workspace [id]", + Aliases: []string{"workspaces", "ws"}, + Args: cobra.MaximumNArgs(1), + Short: "Set the workspace as the default. When no id is provided, prints the currently used default workspace.", + Example: `$ ory use workspace ecaaa3cb-0730-4ee8-a6df-9553cdfeef89 + +ID ecaaa3cb-0730-4ee8-a6df-9553cdfeef89 + +$ ory use workspace ecaaa3cb-0730-4ee8-a6df-9553cdfeef89 --format json + +{ + "id": "ecaaa3cb-0730-4ee8-a6df-9553cdfeef89" +}`, + RunE: func(cmd *cobra.Command, args []string) error { + opts := make([]client.CommandHelperOption, 0, 1) + if len(args) == 1 { + opts = append(opts, client.WithWorkspaceOverride(args[0])) + } + h, err := client.NewCobraCommandHelper(cmd, opts...) + if err != nil { + return err + } + + // The helper resolves a (partial) name or ID to the workspace ID, + // and falls back to the environment or the one already stored in + // the config when no argument is given. + id := h.WorkspaceID() + if id == nil { + return client.ErrWorkspaceNotSet + } + + // Only persist when the user asked to change the default. Without + // an argument this command just reports the current one, which must + // not overwrite the stored default with e.g. an ORY_WORKSPACE value. + if len(args) == 1 { + if err := h.SelectWorkspace(*id); err != nil { + return cmdx.PrintOpenAPIError(cmd, err) + } + } + + cmdx.PrintRow(cmd, &selectedWorkspace{ID: *id}) + return nil + }, + } + + cmdx.RegisterFormatFlags(cmd.Flags()) + return cmd +} diff --git a/cmd/cloudx/workspace/use_test.go b/cmd/cloudx/workspace/use_test.go new file mode 100644 index 00000000..9798c139 --- /dev/null +++ b/cmd/cloudx/workspace/use_test.go @@ -0,0 +1,113 @@ +// Copyright © 2026 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package workspace_test + +import ( + "context" + "encoding/json" + "os" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/ory/cli/cmd/cloudx/client" + "github.com/ory/cli/cmd/cloudx/testhelpers" +) + +// TestUseWorkspace covers https://github.com/ory/cli/issues/406. The command is +// exercised against a config file only, so it needs no Ory Network access: +// selecting an already-known workspace ID never hits the API. +func TestUseWorkspace(t *testing.T) { + t.Parallel() + + const ( + initial = "11111111-1111-1111-1111-111111111111" + other = "33333333-3333-3333-3333-333333333333" + project = "22222222-2222-2222-2222-222222222222" + accessToken = "the-access-token" + ) + + newContext := func(t *testing.T, workspace string) (context.Context, string) { + t.Helper() + + conf := map[string]any{ + "version": client.ConfigVersion, + "selected_project": project, + "access_token": map[string]any{"access_token": accessToken, "token_type": "bearer"}, + } + if workspace != "" { + conf["selected_workspace"] = workspace + } + raw, err := json.Marshal(conf) + require.NoError(t, err) + + location := testhelpers.NewConfigFile(t) + require.NoError(t, os.WriteFile(location, raw, 0600)) + + return client.ContextWithOptions(context.Background(), client.WithConfigLocation(location)), location + } + + t.Run("case=prints the default workspace when no id is given", func(t *testing.T) { + t.Parallel() + + ctx, _ := newContext(t, initial) + + stdout, _, err := testhelpers.Cmd(ctx).Exec(nil, "use", "workspace", "--quiet") + require.NoError(t, err) + assert.Equal(t, initial, strings.TrimSpace(stdout)) + }) + + t.Run("case=prints the default workspace as JSON", func(t *testing.T) { + t.Parallel() + + ctx, _ := newContext(t, initial) + + stdout, _, err := testhelpers.Cmd(ctx).Exec(nil, "use", "workspace", "--format", "json") + require.NoError(t, err) + assert.JSONEq(t, `{"id":"`+initial+`"}`, stdout) + }) + + t.Run("case=does not persist an overridden workspace when no id is given", func(t *testing.T) { + t.Parallel() + + ctx, location := newContext(t, initial) + // Simulates ORY_WORKSPACE (or a workspace API key) pointing somewhere + // else: printing the default must not rewrite it. + ctx = client.ContextWithOptions(ctx, client.WithWorkspaceOverride(other)) + + stdout, _, err := testhelpers.Cmd(ctx).Exec(nil, "use", "workspace", "--quiet") + require.NoError(t, err) + assert.Equal(t, other, strings.TrimSpace(stdout)) + + conf := testhelpers.ReadConfig(t, location) + assert.Equal(t, initial, conf.SelectedWorkspace.String()) + }) + + t.Run("case=sets the default workspace and persists it", func(t *testing.T) { + t.Parallel() + + ctx, location := newContext(t, initial) + + stdout, _, err := testhelpers.Cmd(ctx).Exec(nil, "use", "workspace", other, "--quiet") + require.NoError(t, err) + assert.Equal(t, other, strings.TrimSpace(stdout)) + + conf := testhelpers.ReadConfig(t, location) + assert.Equal(t, other, conf.SelectedWorkspace.String()) + assert.Equal(t, project, conf.SelectedProject.String(), "selecting a workspace must not clear the project") + require.NotNil(t, conf.AccessToken, "selecting a workspace must not log the user out") + assert.Equal(t, accessToken, conf.AccessToken.AccessToken) + }) + + t.Run("case=errors when no workspace is set", func(t *testing.T) { + t.Parallel() + + ctx, _ := newContext(t, "") + + _, _, err := testhelpers.Cmd(ctx).Exec(nil, "use", "workspace", "--quiet") + assert.ErrorIs(t, err, client.ErrWorkspaceNotSet) + }) +}