From cec92b61601bb39d31d2a9ae89c0b60c063418f1 Mon Sep 17 00:00:00 2001 From: Arne Luenser Date: Wed, 29 Jul 2026 14:29:44 +0200 Subject: [PATCH 1/2] feat: add `ory use workspace` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The documentation advertises `ory use workspace ` for setting the default workspace, and `ory auth` prints a SELECTED WORKSPACE field, but the subcommand was never registered — only `ory use project` existed. The workspace had to be passed via --workspace on every invocation. The config already carried a selected_workspace field and CommandHelper.SelectWorkspace already persisted it, so this is command wiring: the new subcommand mirrors `ory use project`, printing the current default when called without an argument and otherwise resolving the given ID or (partial) name and storing it. Closes #406 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01JYzGVwAKQ4ormxRHZg1eDu --- cmd/cloudx/client/command_helper.go | 5 +- cmd/cloudx/use.go | 2 + cmd/cloudx/workspace/output.go | 24 +++++++++ cmd/cloudx/workspace/use.go | 57 ++++++++++++++++++++ cmd/cloudx/workspace/use_test.go | 83 +++++++++++++++++++++++++++++ 5 files changed, 170 insertions(+), 1 deletion(-) create mode 100644 cmd/cloudx/workspace/use.go create mode 100644 cmd/cloudx/workspace/use_test.go 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..b7cdc979 100644 --- a/cmd/cloudx/workspace/output.go +++ b/cmd/cloudx/workspace/output.go @@ -54,3 +54,27 @@ 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 (i selectedWorkspace) String() string { + return i.ID +} + +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..f8b6c21b --- /dev/null +++ b/cmd/cloudx/workspace/use.go @@ -0,0 +1,57 @@ +// 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{"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 one already stored in the config when no + // argument is given. + id := h.WorkspaceID() + if id == nil { + return client.ErrWorkspaceNotSet + } + + 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..3583a2e5 --- /dev/null +++ b/cmd/cloudx/workspace/use_test.go @@ -0,0 +1,83 @@ +// 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" + ) + + newContext := func(t *testing.T, workspace string) (context.Context, string) { + t.Helper() + + conf := map[string]any{ + "version": client.ConfigVersion, + "selected_project": project, + } + 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=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") + }) + + 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) + }) +} From 126917f95f300f707f8260ec3087e938f69e01ab Mon Sep 17 00:00:00 2001 From: Arne Luenser Date: Wed, 29 Jul 2026 15:12:22 +0200 Subject: [PATCH 2/2] fix: do not persist the workspace when `ory use workspace` only prints it `ory use workspace` without an argument is documented as printing the current default, but it called SelectWorkspace unconditionally. With ORY_WORKSPACE (or a workspace API key) set, the resolved value was written to the config file, silently replacing the stored default. Persist only when an id argument is actually given. Also accept the `workspaces` alias, matching `get`/`list`/`create workspace`, and drop the unused selectedWorkspace.String method (cmdx.PrintRow only uses Header/Columns/Interface). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01JYzGVwAKQ4ormxRHZg1eDu --- cmd/cloudx/workspace/output.go | 4 ---- cmd/cloudx/workspace/use.go | 15 ++++++++----- cmd/cloudx/workspace/use_test.go | 36 +++++++++++++++++++++++++++++--- 3 files changed, 43 insertions(+), 12 deletions(-) diff --git a/cmd/cloudx/workspace/output.go b/cmd/cloudx/workspace/output.go index b7cdc979..4c349967 100644 --- a/cmd/cloudx/workspace/output.go +++ b/cmd/cloudx/workspace/output.go @@ -63,10 +63,6 @@ type selectedWorkspace struct { var _ cmdx.TableRow = (*selectedWorkspace)(nil) -func (i selectedWorkspace) String() string { - return i.ID -} - func (*selectedWorkspace) Header() []string { return []string{"ID"} } diff --git a/cmd/cloudx/workspace/use.go b/cmd/cloudx/workspace/use.go index f8b6c21b..f54ae4ae 100644 --- a/cmd/cloudx/workspace/use.go +++ b/cmd/cloudx/workspace/use.go @@ -13,7 +13,7 @@ import ( func NewUseWorkspaceCmd() *cobra.Command { cmd := &cobra.Command{ Use: "workspace [id]", - Aliases: []string{"ws"}, + 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 @@ -36,15 +36,20 @@ $ ory use workspace ecaaa3cb-0730-4ee8-a6df-9553cdfeef89 --format json } // The helper resolves a (partial) name or ID to the workspace ID, - // and falls back to the one already stored in the config when no - // argument is given. + // 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 } - if err := h.SelectWorkspace(*id); err != nil { - return cmdx.PrintOpenAPIError(cmd, err) + // 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}) diff --git a/cmd/cloudx/workspace/use_test.go b/cmd/cloudx/workspace/use_test.go index 3583a2e5..9798c139 100644 --- a/cmd/cloudx/workspace/use_test.go +++ b/cmd/cloudx/workspace/use_test.go @@ -24,9 +24,10 @@ 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" + 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) { @@ -35,6 +36,7 @@ func TestUseWorkspace(t *testing.T) { 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 @@ -58,6 +60,32 @@ func TestUseWorkspace(t *testing.T) { 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() @@ -70,6 +98,8 @@ func TestUseWorkspace(t *testing.T) { 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) {