From 5c7034155607e12a361ad382ccc0ece85369eb73 Mon Sep 17 00:00:00 2001 From: aeneasr <3372410+aeneasr@users.noreply.github.com> Date: Wed, 22 May 2024 07:32:45 +0000 Subject: [PATCH 01/15] chore: update repository templates to https://github.com/ory/meta/commit/e838bee8d0a29d022b61472be9efccf096099130 --- .github/workflows/cve-scan.yaml | 4 ++-- CONTRIBUTING.md | 14 +++++++++----- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/.github/workflows/cve-scan.yaml b/.github/workflows/cve-scan.yaml index 20243850..455da42c 100644 --- a/.github/workflows/cve-scan.yaml +++ b/.github/workflows/cve-scan.yaml @@ -32,7 +32,7 @@ jobs: severity-cutoff: critical add-cpes-if-none: true - name: Inspect action SARIF report - run: cat ${{ steps.scan.outputs.sarif }} + run: cat ${{ steps.scan.outputs.sarif }} - name: Upload SARIF report uses: github/codeql-action/upload-sarif@v2 with: @@ -45,4 +45,4 @@ jobs: output-format: table fail-build: true severity-cutoff: critical - add-cpes-if-none: true \ No newline at end of file + add-cpes-if-none: true diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 111e88b4..17f40eba 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -143,10 +143,12 @@ checklist to contribute an example: not get mixed up. 1. Add a descriptive prefix to commits. This ensures a uniform commit history and helps structure the changelog. Please refer to this - [list of prefixes for CLI](https://github.com/ory/cli/blob/master/.github/semantic.yml) - for an overview. + [Convential Commits configuration](https://github.com/ory/cli/blob/master/.github/workflows/conventional_commits.yml) + for the list of accepted prefixes. You can read more about the Conventional + Commit specification + [at their site](https://www.conventionalcommits.org/en/v1.0.0/). 1. Create a `README.md` that explains how to use the example. (Use - [the README template](https://github.com/ory/examples/blob/master/_common/README)). + [the README template](https://github.com/ory/examples/blob/master/_common/README.md)). 1. Open a pull request and maintainers will review and merge your example. ## Contribute code @@ -171,8 +173,10 @@ request, go through this checklist: 1. Run `make format` 1. Add a descriptive prefix to commits. This ensures a uniform commit history and helps structure the changelog. Please refer to this - [list of prefixes for CLI](https://github.com/ory/cli/blob/master/.github/semantic.yml) - for an overview. + [Convential Commits configuration](https://github.com/ory/cli/blob/master/.github/workflows/conventional_commits.yml) + for the list of accepted prefixes. You can read more about the Conventional + Commit specification + [at their site](https://www.conventionalcommits.org/en/v1.0.0/). If a pull request is not ready to be reviewed yet [it should be marked as a "Draft"](https://docs.github.com/en/github/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/changing-the-stage-of-a-pull-request). From 164a94f6aba6811fb14f438145eba810c3325eca Mon Sep 17 00:00:00 2001 From: Patrik Date: Wed, 26 Jun 2024 17:01:53 +0200 Subject: [PATCH 02/15] feat: workspace-aware commands (#358) --- .docker/Dockerfile-build | 2 +- .github/workflows/ci.yaml | 2 +- .github/workflows/format.yml | 2 +- .github/workflows/licenses.yml | 2 +- .github/workflows/test-e2e.yml | 12 +- .github/workflows/test.yml | 11 +- .../accountexperience/accountexperience.go | 78 +- .../accountexperience_test.go | 29 +- cmd/cloudx/accountexperience/utils.go | 24 - cmd/cloudx/auth.go | 6 +- cmd/cloudx/auth_test.go | 58 +- cmd/cloudx/client/api_key.go | 88 ++ cmd/cloudx/client/auth.go | 288 ++++++ cmd/cloudx/client/client.go | 58 +- cmd/cloudx/client/command_helper.go | 273 +++++ cmd/cloudx/client/command_helper_test.go | 364 +++++++ cmd/cloudx/client/config.go | 163 +++ cmd/cloudx/client/event_stream.go | 66 ++ cmd/cloudx/client/flags.go | 26 + cmd/cloudx/client/form.go | 15 +- cmd/cloudx/client/handler.go | 961 ------------------ cmd/cloudx/client/handler_test.go | 364 ------- cmd/cloudx/client/iohelpers.go | 21 +- cmd/cloudx/client/iohelpers_test.go | 2 +- cmd/cloudx/client/organization.go | 74 ++ cmd/cloudx/client/print.go | 20 - cmd/cloudx/client/project.go | 281 +++++ cmd/cloudx/client/sdks.go | 80 +- cmd/cloudx/client/tokens.go | 4 +- cmd/cloudx/client/workspace.go | 69 ++ cmd/cloudx/create.go | 3 + cmd/cloudx/e2e/root.go | 4 +- cmd/cloudx/eventstreams/create.go | 27 +- cmd/cloudx/eventstreams/delete.go | 9 +- cmd/cloudx/eventstreams/flags.go | 32 + cmd/cloudx/eventstreams/list.go | 7 +- cmd/cloudx/eventstreams/update.go | 22 +- cmd/cloudx/identity/delete_test.go | 16 +- cmd/cloudx/identity/get_test.go | 12 +- cmd/cloudx/identity/import_test.go | 10 +- cmd/cloudx/identity/list_test.go | 14 +- cmd/cloudx/identity/main_test.go | 7 +- cmd/cloudx/list.go | 3 + cmd/cloudx/logout.go | 4 +- cmd/cloudx/logout_test.go | 4 +- cmd/cloudx/oauth2/client_test.go | 72 +- cmd/cloudx/oauth2/jwks.go | 1 + cmd/cloudx/oauth2/main_test.go | 7 +- .../organizations/create_organization.go | 17 +- .../organizations/delete_organization.go | 9 +- .../organizations/list_organizations.go | 7 +- .../organizations/organizations_test.go | 16 +- .../organizations/update_organization.go | 24 +- cmd/cloudx/project/create.go | 95 +- cmd/cloudx/project/create_test.go | 32 +- cmd/cloudx/project/get.go | 13 +- cmd/cloudx/project/get_identity_config.go | 22 +- cmd/cloudx/project/get_oauth2_config.go | 24 +- cmd/cloudx/project/get_permission_config.go | 22 +- cmd/cloudx/project/get_test.go | 6 +- cmd/cloudx/project/helper_test.go | 22 +- cmd/cloudx/project/list.go | 4 +- cmd/cloudx/project/list_test.go | 24 +- cmd/cloudx/project/main_test.go | 9 +- cmd/cloudx/project/output.go | 28 +- cmd/cloudx/project/patch.go | 17 +- cmd/cloudx/project/patch_identity_config.go | 12 +- .../project/patch_identity_config_test.go | 8 +- cmd/cloudx/project/patch_oauth2_config.go | 16 +- .../project/patch_oauth2_config_test.go | 8 +- cmd/cloudx/project/patch_permission_config.go | 16 +- .../project/patch_permission_config_test.go | 8 +- cmd/cloudx/project/patch_test.go | 2 +- cmd/cloudx/project/update.go | 14 +- cmd/cloudx/project/update_identity_config.go | 16 +- cmd/cloudx/project/update_namespace_config.go | 28 +- cmd/cloudx/project/update_oauth2_config.go | 18 +- .../project/update_permission_config.go | 16 +- cmd/cloudx/project/update_test.go | 15 +- cmd/cloudx/project/use.go | 27 +- cmd/cloudx/project/use_test.go | 14 +- cmd/cloudx/project/utils.go | 16 - cmd/cloudx/proxy/cmd_proxy.go | 305 ------ cmd/cloudx/proxy/cmd_proxy_test.go | 57 -- cmd/cloudx/proxy/cmd_tunnel.go | 178 ---- cmd/cloudx/proxy/helpers.go | 397 ++++++++ cmd/cloudx/proxy/proxy.go | 476 +++------ cmd/cloudx/proxy/tunnel.go | 145 +++ cmd/cloudx/relationtuples/permissions_test.go | 6 +- cmd/cloudx/relationtuples/relationtuples.go | 14 +- .../relationtuples/relationtuples_test.go | 35 +- cmd/cloudx/testhelpers/testhelpers.go | 180 ++-- cmd/cloudx/testhelpers/testmain.go | 39 +- cmd/cloudx/workspace/create.go | 59 ++ cmd/cloudx/workspace/list.go | 34 + cmd/cloudx/workspace/output.go | 56 + cmd/dev/swagger/sanitize_test.go | 4 +- cmd/root.go | 7 +- go.mod | 63 +- go.sum | 132 +-- 100 files changed, 3434 insertions(+), 3075 deletions(-) delete mode 100644 cmd/cloudx/accountexperience/utils.go create mode 100644 cmd/cloudx/client/api_key.go create mode 100644 cmd/cloudx/client/auth.go create mode 100644 cmd/cloudx/client/command_helper.go create mode 100644 cmd/cloudx/client/command_helper_test.go create mode 100644 cmd/cloudx/client/config.go create mode 100644 cmd/cloudx/client/event_stream.go create mode 100644 cmd/cloudx/client/flags.go delete mode 100644 cmd/cloudx/client/handler.go delete mode 100644 cmd/cloudx/client/handler_test.go create mode 100644 cmd/cloudx/client/organization.go create mode 100644 cmd/cloudx/client/project.go create mode 100644 cmd/cloudx/client/workspace.go create mode 100644 cmd/cloudx/eventstreams/flags.go delete mode 100644 cmd/cloudx/proxy/cmd_proxy.go delete mode 100644 cmd/cloudx/proxy/cmd_proxy_test.go delete mode 100644 cmd/cloudx/proxy/cmd_tunnel.go create mode 100644 cmd/cloudx/proxy/helpers.go create mode 100644 cmd/cloudx/proxy/tunnel.go create mode 100644 cmd/cloudx/workspace/create.go create mode 100644 cmd/cloudx/workspace/list.go create mode 100644 cmd/cloudx/workspace/output.go diff --git a/.docker/Dockerfile-build b/.docker/Dockerfile-build index bed42b5a..16b8dbc2 100644 --- a/.docker/Dockerfile-build +++ b/.docker/Dockerfile-build @@ -1,4 +1,4 @@ -FROM golang:1.21-alpine3.19 AS builder +FROM golang:1.22-alpine3.19 AS builder RUN apk -U --no-cache add build-base git gcc bash diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 18c0485c..b329c79e 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -20,7 +20,7 @@ jobs: - uses: ory/ci/checkout@master - uses: actions/setup-go@v2 with: - go-version: "1.21" + go-version: "1.22" - run: | make test env: diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml index 8203bcc1..59069ca6 100644 --- a/.github/workflows/format.yml +++ b/.github/workflows/format.yml @@ -11,7 +11,7 @@ jobs: - uses: actions/checkout@v3 - uses: actions/setup-go@v3 with: - go-version: 1.21 + go-version: 1.22 - run: make format - name: Indicate formatting issues run: git diff HEAD --exit-code --color diff --git a/.github/workflows/licenses.yml b/.github/workflows/licenses.yml index 8871ccb2..8a864860 100644 --- a/.github/workflows/licenses.yml +++ b/.github/workflows/licenses.yml @@ -14,7 +14,7 @@ jobs: - uses: actions/checkout@v2 - uses: actions/setup-go@v2 with: - go-version: "1.21" + go-version: "1.22" - uses: actions/setup-node@v2 with: node-version: "18" diff --git a/.github/workflows/test-e2e.yml b/.github/workflows/test-e2e.yml index a001ef1e..ea9b893e 100644 --- a/.github/workflows/test-e2e.yml +++ b/.github/workflows/test-e2e.yml @@ -14,16 +14,17 @@ jobs: - uses: actions/checkout@v2 - uses: actions/setup-go@v2 with: - go-version: "^1.21" + go-version: "1.22" - uses: actions/setup-node@v2 with: node-version: "16" - run: | go build -o ory . - ./ory tunnel http://localhost:4001 --project admiring-tu-swczqlujc0 --quiet & + ./ory tunnel http://localhost:4001 --quiet & env: ORY_API_KEY: ${{ secrets.ORY_PROJECT_API_KEY }} - - name: Install dependencies and run server + ORY_PROJECT_SLUG: admiring-tu-swczqlujc0 + - name: Install dependencies working-directory: cmd/cloudx/e2e run: | npm ci @@ -53,15 +54,16 @@ jobs: - uses: actions/checkout@v2 - uses: actions/setup-go@v2 with: - go-version: "^1.21" + go-version: "1.22" - uses: actions/setup-node@v2 with: node-version: "16" - run: | go build -o ory . - ./ory proxy https://ory-network-httpbin-ijakee5waq-ez.a.run.app/anything --rewrite-host --project admiring-tu-swczqlujc0 --quiet & + ./ory proxy https://ory-network-httpbin-ijakee5waq-ez.a.run.app/anything --rewrite-host --quiet & env: ORY_API_KEY: ${{ secrets.ORY_PROJECT_API_KEY }} + ORY_PROJECT_SLUG: admiring-tu-swczqlujc0 - name: Install Node working-directory: cmd/cloudx/e2e run: | diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index af4753e3..0ccb6ee2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -14,17 +14,18 @@ jobs: - uses: actions/checkout@v2 - uses: actions/setup-go@v2 with: - go-version: "^1.21" + go-version: "1.22" - uses: actions/setup-node@v2 with: node-version: "16" - run: npm ci - - run: go build -o ory . - run: | + go build -o ory . ./ory proxy https://ory-network-httpbin-ijakee5waq-ez.a.run.app --quiet --rewrite-host & npm run test env: - ORY_SDK_URL: https://affectionate-archimedes-s9mkjq77k0.projects.staging.oryapis.dev - ORY_CLOUD_CONSOLE_URL: https://console.staging.ory.dev - ORY_CLOUD_ORYAPIS_URL: https://staging.oryapis.dev + ORY_API_KEY: nokey + ORY_PROJECT_SLUG: affectionate-archimedes-s9mkjq77k0 + ORY_CONSOLE_URL: https://console.staging.ory.dev + ORY_ORYAPIS_URL: https://projects.staging.oryapis.dev ORY_RATE_LIMIT_HEADER: ${{ secrets.ORY_RATE_LIMIT_HEADER }} diff --git a/cmd/cloudx/accountexperience/accountexperience.go b/cmd/cloudx/accountexperience/accountexperience.go index 1ff80586..89e58112 100644 --- a/cmd/cloudx/accountexperience/accountexperience.go +++ b/cmd/cloudx/accountexperience/accountexperience.go @@ -5,65 +5,59 @@ package accountexperience import ( "fmt" - "os" - - "os/exec" + "path" "github.com/pkg/browser" + "github.com/pkg/errors" "github.com/spf13/cobra" - client "github.com/ory/cli/cmd/cloudx/client" - cloud "github.com/ory/client-go" + "github.com/ory/x/flagx" + "github.com/ory/x/stringsx" + + "github.com/ory/cli/cmd/cloudx/client" "github.com/ory/x/cmdx" ) func NewAccountExperienceOpenCmd() *cobra.Command { cmd := &cobra.Command{ - Use: "account-experience [project-id]", - Args: cobra.MaximumNArgs(1), + Use: "account-experience ", + Aliases: []string{"ax", "ui"}, + Args: func(cmd *cobra.Command, args []string) error { + if err := cobra.ExactArgs(1)(cmd, args); err != nil { + return err + } + switch f := stringsx.SwitchExact(args[0]); { + case f.AddCase("login", "registration", "recovery", "verification", "settings"): + return nil + default: + return errors.Wrap(f.ToUnknownCaseErr(), "unknown flow type") + } + }, Short: "Open Ory Account Experience Pages", - } - var pages = [5]string{"login", "registration", "recovery", "verification", "settings"} - for _, p := range pages { - cmd.AddCommand(NewAxCmd(p)) - } - - return cmd -} - -func NewAxCmd(cmd string) *cobra.Command { - return &cobra.Command{ - Use: cmd, - Short: "Open " + cmd + " page", RunE: func(cmd *cobra.Command, args []string) error { - h, err := client.NewCommandHelper(cmd) + h, err := client.NewCobraCommandHelper(cmd) if err != nil { return err } - id, err := getSelectedProjectId(h, args) - if err != nil { - return cmdx.PrintOpenAPIError(cmd, err) - } - project, err := h.GetProject(id) + + project, err := h.GetSelectedProject(cmd.Context()) if err != nil { return cmdx.PrintOpenAPIError(cmd, err) } - return AxWrapper(cmd, project) - - }} -} - -func AxWrapper(cmd *cobra.Command, p *cloud.Project) error { - url := fmt.Sprintf("https://%s.projects.oryapis.com/ui/%s", p.GetSlug(), cmd.CalledAs()) - err := browser.OpenURL(url) - if err != nil { - - // #nosec G204 - this is ok - if err := exec.Command("open", url); err != nil { - _, _ = fmt.Fprintf(os.Stderr, "Unable to automatically open the %s page in your browser. Please open it manually!", cmd.CalledAs()) - } + url := client.CloudAPIsURL(project.Slug) + url.Path = path.Join(url.Path, "ui", args[0]) + if flagx.MustGetBool(cmd, cmdx.FlagQuiet) { + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s\n", url) + return nil + } + if err := browser.OpenURL(url.String()); err != nil { + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "%s\n\nUnable to automatically open %s in your browser. Please open it manually!\n", err, url) + return cmdx.FailSilently(cmd) + } + return nil + }, } - - return nil + cmdx.RegisterNoiseFlags(cmd.Flags()) + return cmd } diff --git a/cmd/cloudx/accountexperience/accountexperience_test.go b/cmd/cloudx/accountexperience/accountexperience_test.go index 43bbc791..adad172a 100644 --- a/cmd/cloudx/accountexperience/accountexperience_test.go +++ b/cmd/cloudx/accountexperience/accountexperience_test.go @@ -6,28 +6,35 @@ package accountexperience_test import ( "testing" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/ory/cli/cmd/cloudx/testhelpers" - "github.com/ory/x/cmdx" -) - -var ( - defaultProject, defaultConfig, defaultEmail, defaultPassword string - defaultCmd *cmdx.CommandExecuter ) func TestMain(m *testing.M) { - defaultConfig, defaultEmail, defaultPassword, _, defaultProject, defaultCmd = testhelpers.CreateDefaultAssets() testhelpers.RunAgainstStaging(m) } func TestOpenAXPages(t *testing.T) { - t.Run("is able to open login page", func(t *testing.T) { - var pages = [5]string{"login", "registration", "recovery", "verification", "settings"} - for _, p := range pages { - _, stderr, err := defaultCmd.Exec(nil, "open", "account-experience", p, "--project", defaultProject) + cfg := testhelpers.NewConfigFile(t) + testhelpers.RegisterAccount(t, cfg) + project := testhelpers.CreateProject(t, cfg, nil) + cmd := testhelpers.CmdWithConfig(cfg) + + t.Run("is able to open all pages", func(t *testing.T) { + for _, flowType := range []string{"login", "registration", "recovery", "verification", "settings"} { + stdout, stderr, err := cmd.Exec(nil, "open", "account-experience", flowType, "--quiet") require.NoError(t, err, stderr) + assert.Contains(t, stdout, "https://"+project.Slug) + assert.Contains(t, stdout, flowType) } }) + + t.Run("errors on unknown flow type", func(t *testing.T) { + stdout, stderr, err := cmd.Exec(nil, "open", "account-experience", "unknown", "--quiet") + require.Error(t, err) + assert.Contains(t, stderr, "unknown flow type", stdout) + }) } diff --git a/cmd/cloudx/accountexperience/utils.go b/cmd/cloudx/accountexperience/utils.go deleted file mode 100644 index e311737a..00000000 --- a/cmd/cloudx/accountexperience/utils.go +++ /dev/null @@ -1,24 +0,0 @@ -// Copyright © 2023 Ory Corp -// SPDX-License-Identifier: Apache-2.0 - -package accountexperience - -import ( - "github.com/pkg/errors" - - "github.com/ory/cli/cmd/cloudx/client" -) - -var defaultProjectNotSetError = errors.New("no project was specified") - -func getSelectedProjectId(h *client.CommandHelper, args []string) (string, error) { - if len(args) == 0 { - if id := h.GetDefaultProjectID(); id == "" { - return "", defaultProjectNotSetError - } else { - return id, nil - } - } else { - return args[0], nil - } -} diff --git a/cmd/cloudx/auth.go b/cmd/cloudx/auth.go index b46e3481..9fc24954 100644 --- a/cmd/cloudx/auth.go +++ b/cmd/cloudx/auth.go @@ -15,14 +15,16 @@ func NewAuthCmd() *cobra.Command { Use: "auth", Short: "Create a new Ory Network account or sign in to an existing account.", RunE: func(cmd *cobra.Command, args []string) error { - h, err := client.NewCommandHelper(cmd) + h, err := client.NewCobraCommandHelper(cmd) if err != nil { return err } - ac, err := h.Authenticate() + + ac, err := h.GetAuthenticatedConfig(cmd.Context()) if err != nil { return err } + cmdx.PrintRow(cmd, ac) return nil }, diff --git a/cmd/cloudx/auth_test.go b/cmd/cloudx/auth_test.go index 8e576212..37b81395 100644 --- a/cmd/cloudx/auth_test.go +++ b/cmd/cloudx/auth_test.go @@ -6,33 +6,34 @@ package cloudx_test import ( "bytes" "context" + "io" "os" "testing" "time" - "github.com/ory/cli/cmd" - oldCloud "github.com/ory/client-go/114" - "github.com/pquerna/otp/totp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/ory/cli/cmd" + cloud "github.com/ory/client-go" + "github.com/ory/cli/cmd/cloudx/client" "github.com/ory/cli/cmd/cloudx/testhelpers" "github.com/ory/x/pointerx" ) func TestAuthenticator(t *testing.T) { - configDir := testhelpers.NewConfigDir(t) + configDir := testhelpers.NewConfigFile(t) t.Run("errors without config and --quiet flag", func(t *testing.T) { c := cmd.NewRootCmd() - c.SetArgs([]string{"auth", "--" + client.ConfigFlag, configDir, "--quiet"}) + c.SetArgs([]string{"auth", "--" + client.FlagConfig, configDir, "--quiet"}) require.Error(t, c.Execute()) }) password := testhelpers.FakePassword() - cmd := testhelpers.ConfigPasswordAwareCmd(configDir, password) + cmd := testhelpers.CmdWithConfigPassword(configDir, password) signIn := func(t *testing.T, email string) (string, string, error) { testhelpers.ClearConfig(t, configDir) @@ -49,13 +50,13 @@ func TestAuthenticator(t *testing.T) { name := testhelpers.FakeName() // Create the account - r := testhelpers.RegistrationBuffer(name, email, password) - stdout, stderr, err := cmd.Exec(&r, "auth") + r := testhelpers.RegistrationBuffer(name, email) + stdout, stderr, err := cmd.Exec(r, "auth") require.NoError(t, err) assert.Contains(t, stderr, "You are now signed in as: "+email, "Expected to be signed in but response was:\n\t%s\n\tstderr: %s", stdout, stderr) assert.Contains(t, stdout, email) - testhelpers.AssertConfig(t, configDir, email, name, false) + testhelpers.AssertConfig(t, configDir, email, name) testhelpers.ClearConfig(t, configDir) expectSignInSuccess := func(t *testing.T) { @@ -63,7 +64,7 @@ func TestAuthenticator(t *testing.T) { require.NoError(t, err) assert.Contains(t, stderr, "You are now signed in as: ", email, stdout) - testhelpers.AssertConfig(t, configDir, email, name, false) + testhelpers.AssertConfig(t, configDir, email, name) } t.Run("sign in with valid data", func(t *testing.T) { @@ -71,7 +72,7 @@ func TestAuthenticator(t *testing.T) { }) t.Run("forced to reauthenticate on session expiration", func(t *testing.T) { - cmd := testhelpers.ConfigAwareCmd(configDir) + cmd := testhelpers.CmdWithConfig(configDir) expectSignInSuccess(t) testhelpers.ChangeAccessToken(t, configDir) var r bytes.Buffer @@ -82,7 +83,7 @@ func TestAuthenticator(t *testing.T) { }) t.Run("user is able to reauthenticate on session expiration", func(t *testing.T) { - cmd := testhelpers.ConfigAwareCmd(configDir) + cmd := testhelpers.CmdWithConfig(configDir) expectSignInSuccess(t) testhelpers.ChangeAccessToken(t, configDir) var r bytes.Buffer @@ -94,7 +95,7 @@ func TestAuthenticator(t *testing.T) { }) t.Run("expired session with quiet flag returns error", func(t *testing.T) { - cmd := testhelpers.ConfigAwareCmd(configDir) + cmd := testhelpers.CmdWithConfig(configDir) expectSignInSuccess(t) testhelpers.ChangeAccessToken(t, configDir) _, stderr, err := cmd.Exec(nil, "list", "projects", "-q") @@ -107,10 +108,10 @@ func TestAuthenticator(t *testing.T) { expectSignInSuccess(t) ac := testhelpers.ReadConfig(t, configDir) - c, err := client.NewKratosClient() + c, err := client.NewOryProjectClient() require.NoError(t, err) - flow, _, err := c.FrontendApi.CreateNativeSettingsFlow(context.Background()).XSessionToken(ac.SessionToken).Execute() + flow, _, err := c.FrontendAPI.CreateNativeSettingsFlow(context.Background()).XSessionToken(ac.SessionToken).Execute() require.NoError(t, err) var secret string @@ -129,9 +130,9 @@ func TestAuthenticator(t *testing.T) { code, err := totp.GenerateCode(secret, time.Now()) require.NoError(t, err) - _, _, err = c.FrontendApi.UpdateSettingsFlow(context.Background()).XSessionToken(ac.SessionToken).Flow(flow.Id).UpdateSettingsFlowBody(oldCloud.UpdateSettingsFlowBody{ - UpdateSettingsFlowWithTotpMethod: &oldCloud.UpdateSettingsFlowWithTotpMethod{ - TotpCode: pointerx.String(code), + _, _, err = c.FrontendAPI.UpdateSettingsFlow(context.Background()).XSessionToken(ac.SessionToken).Flow(flow.Id).UpdateSettingsFlowBody(cloud.UpdateSettingsFlowBody{ + UpdateSettingsFlowWithTotpMethod: &cloud.UpdateSettingsFlowWithTotpMethod{ + TotpCode: pointerx.Ptr(code), Method: "totp", }, }).Execute() @@ -174,7 +175,7 @@ func TestAuthenticator(t *testing.T) { assert.Contains(t, stderr, "Please complete the second authentication challenge", stdout) assert.Contains(t, stderr, "You are now signed in as: ", email, stdout) - testhelpers.AssertConfig(t, configDir, email, name, false) + testhelpers.AssertConfig(t, configDir, email, name) }) }) }) @@ -182,26 +183,21 @@ func TestAuthenticator(t *testing.T) { t.Run("retry sign up on invalid data", func(t *testing.T) { testhelpers.ClearConfig(t, configDir) - r := testhelpers.RegistrationBuffer(testhelpers.FakeName(), "not-an-email", password) + r0 := testhelpers.RegistrationBuffer(testhelpers.FakeName(), "not-an-email") // Redo the flow email := testhelpers.FakeEmail() name := testhelpers.FakeName() - _, _ = r.WriteString(email + "\n") // Work email: FakeEmail() - _, _ = r.WriteString(password + "\n") // Password: FakePassword() - _, _ = r.WriteString(name + "\n") // Name: FakeName() - _, _ = r.WriteString("n\n") // Please inform me about platform and security updates: [y/n]: n - _, _ = r.WriteString("y\n") // I accept the Terms of Service https://www.ory.sh/ptos: [y/n]: y - _, _ = r.WriteString("Ory\n") // Company: Ory - _, _ = r.WriteString("12345\n") // Phone: 12345 - _, _ = r.WriteString("Dev\n") // Job title/role: Dev - - stdout, stderr, err := cmd.Exec(&r, "auth", "--"+client.ConfigFlag, configDir) + r1 := testhelpers.RegistrationBuffer(name, email) + // on retry, we need to skip "Do you want to sign in to an existing Ory Network account? [y/n]: " + _, _ = r1.ReadString('\n') + + stdout, stderr, err := cmd.Exec(io.MultiReader(r0, r1), "auth", "--"+client.FlagConfig, configDir) require.NoError(t, err) assert.Contains(t, stderr, "Your account creation attempt failed. Please try again!", stdout) // First try fails assert.Contains(t, stderr, "You are now signed in as: "+email, stdout) // Second try succeeds - testhelpers.AssertConfig(t, configDir, email, name, true) + testhelpers.AssertConfig(t, configDir, email, name) }) t.Run("sign in with invalid data", func(t *testing.T) { diff --git a/cmd/cloudx/client/api_key.go b/cmd/cloudx/client/api_key.go new file mode 100644 index 00000000..37a2918e --- /dev/null +++ b/cmd/cloudx/client/api_key.go @@ -0,0 +1,88 @@ +// Copyright © 2024 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package client + +import ( + "context" + "errors" + "fmt" + + cloud "github.com/ory/client-go" + "github.com/ory/x/cmdx" +) + +func (h *CommandHelper) CreateAPIKey(ctx context.Context, projectID, name string) (*cloud.ProjectApiKey, error) { + c, err := h.newCloudClient(ctx) + if err != nil { + return nil, err + } + + token, _, err := c.ProjectAPI.CreateProjectApiKey(ctx, projectID).CreateProjectApiKeyRequest(cloud.CreateProjectApiKeyRequest{Name: name}).Execute() + if err != nil { + return nil, err + } + + return token, nil +} + +func (h *CommandHelper) DeleteAPIKey(ctx context.Context, projectIdOrSlug, id string) error { + c, err := h.newCloudClient(ctx) + if err != nil { + return err + } + + if _, err := c.ProjectAPI.DeleteProjectApiKey(ctx, projectIdOrSlug, id).Execute(); err != nil { + return err + } + + return nil +} + +func (h *CommandHelper) TemporaryAPIKey(ctx context.Context, name string) (apiKey string, cleanup func() error, err error) { + if ak := GetProjectAPIKeyFromEnvironment(); len(ak) > 0 { + return ak, noop, nil + } + + // For all other projects, except the playground, we need to authenticate. + if err := h.Authenticate(ctx); errors.Is(err, ErrNoConfigQuiet) { + _, _ = fmt.Fprintf(h.VerboseErrWriter, "Because you are not authenticated, the Ory CLI can not configure your project automatically. You can still use the Ory Proxy / Ory Tunnel, but complex flows such as Social Sign In will not work. Remove the `--quiet` flag or run `ory auth login` to authenticate.") + return "", noop, nil + } else if errors.Is(err, ErrNotAuthenticated) { + ok, err := cmdx.AskScannerForConfirmation("To support complex flows such as Social Sign In, the Ory CLI can configure your project automatically. To do so, you need to be signed in. Do you want to sign in?", h.Stdin, h.VerboseErrWriter) + if err != nil { + return "", noop, err + } + + if !ok { + _, _ = fmt.Fprintf(h.VerboseErrWriter, "Because you are not authenticated, the Ory CLI can not configure your project automatically. You can still use the Ory Proxy / Ory Tunnel, but complex flows such as Social Sign In will not work.") + return "", noop, nil + } + + if err := h.Authenticate(ctx); err != nil { + return "", noop, err + } + } else if err != nil { + return "", noop, err + } + + projectID, err := h.ProjectID() + if err != nil { + return "", noop, err + } + ak, err := h.CreateAPIKey(ctx, projectID, name) + if err != nil { + _, _ = fmt.Fprintf(h.VerboseErrWriter, "Unable to create API key. Do you have the required permissions to use the Ory CLI with project %q? Continuing without API key.", projectID) + return "", noop, nil + } + + if !ak.HasValue() { + return "", noop, nil + } + + return *ak.Value, func() error { + return h.DeleteAPIKey(ctx, projectID, ak.Id) + }, nil +} + +func noop() error { return nil } diff --git a/cmd/cloudx/client/auth.go b/cmd/cloudx/client/auth.go new file mode 100644 index 00000000..18dfea6d --- /dev/null +++ b/cmd/cloudx/client/auth.go @@ -0,0 +1,288 @@ +// Copyright © 2024 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package client + +import ( + "bytes" + "context" + "encoding/json" + stderrors "errors" + "fmt" + + "github.com/gofrs/uuid" + "github.com/tidwall/gjson" + + cloud "github.com/ory/client-go" + "github.com/ory/x/cmdx" + "github.com/ory/x/stringsx" +) + +func (h *CommandHelper) checkSession(ctx context.Context) error { + c, err := h.getConfig() + if err != nil { + return err + } + if c.isAuthenticated { + return nil + } + if c.SessionToken == "" { + return ErrNotAuthenticated + } + + client, err := NewOryProjectClient() + if err != nil { + return err + } + + sess, _, err := client.FrontendAPI.ToSession(ctx).XSessionToken(c.SessionToken).Execute() + if err != nil || sess == nil { + return stderrors.Join(err, ErrReauthenticate) + } + c.isAuthenticated = true + + return nil +} + +func (h *CommandHelper) GetAuthenticatedConfig(ctx context.Context) (*Config, error) { + if err := h.checkSession(ctx); err == nil { + return h.getConfig() + } else if stderrors.Is(err, ErrReauthenticate) { + if h.isQuiet { + return nil, ErrNoConfigQuiet + } + _, _ = fmt.Fprintf(h.VerboseErrWriter, "Your session has expired or has otherwise become invalid. Please re-authenticate to continue.\n") + } else if stderrors.Is(err, ErrNoConfig) || stderrors.Is(err, ErrNotAuthenticated) { + if h.isQuiet { + return nil, ErrNoConfigQuiet + } + } + if err := h.ClearConfig(); err != nil { + return nil, err + } + + if err := h.Authenticate(ctx); err != nil { + return nil, err + } + + return h.getConfig() +} + +func (h *CommandHelper) signup(ctx context.Context, c *cloud.APIClient) error { + flow, _, err := c.FrontendAPI.CreateNativeRegistrationFlow(ctx).Execute() + if err != nil { + return err + } + +doRegistration: + var form cloud.UpdateRegistrationFlowWithPasswordMethod + if err := renderForm(h.Stdin, h.pwReader, h.VerboseErrWriter, flow.Ui, "password", &form); err != nil { + return err + } + + signup, _, err := c.FrontendAPI. + UpdateRegistrationFlow(ctx). + Flow(flow.Id). + UpdateRegistrationFlowBody(cloud.UpdateRegistrationFlowBody{UpdateRegistrationFlowWithPasswordMethod: &form}). + Execute() + if err != nil { + if e, ok := err.(*cloud.GenericOpenAPIError); ok { + switch m := e.Model().(type) { + case *cloud.RegistrationFlow: + flow = m + case cloud.RegistrationFlow: + flow = &m + } + _, _ = fmt.Fprintf(h.VerboseErrWriter, "\nYour account creation attempt failed. Please try again!\n\n") + goto doRegistration + } + + return err + } + + sessionToken := *signup.SessionToken + sess, _, err := c.FrontendAPI.ToSession(ctx).XSessionToken(sessionToken).Execute() + if err != nil { + return err + } + + config := new(Config) + if err := config.fromSession(sess, sessionToken); err != nil { + return err + } + return h.UpdateConfig(config) +} + +func (h *CommandHelper) signin(ctx context.Context, c *cloud.APIClient, sessionToken string) error { + req := c.FrontendAPI.CreateNativeLoginFlow(ctx) + if len(sessionToken) > 0 { + req = req.XSessionToken(sessionToken).Aal("aal2") + } + flow, _, err := req.Execute() + if err != nil { + return err + } + +doLogin: + var form interface{} = &cloud.UpdateLoginFlowWithPasswordMethod{} + method := "password" + if len(sessionToken) > 0 { + var foundTOTP, foundLookup bool + for _, n := range flow.Ui.Nodes { + foundTOTP = foundTOTP || n.Group == "totp" + foundLookup = foundLookup || n.Group == "lookup_secret" + } + if !foundLookup && !foundTOTP { + return stderrors.New("only TOTP and lookup secrets are supported for two-step verification in the CLI") + } + + method = "lookup_secret" + if foundTOTP { + form = &cloud.UpdateLoginFlowWithTotpMethod{} + method = "totp" + } + } + + if err := renderForm(h.Stdin, h.pwReader, h.VerboseErrWriter, flow.Ui, method, form); err != nil { + return err + } + + var body cloud.UpdateLoginFlowBody + switch e := form.(type) { + case *cloud.UpdateLoginFlowWithTotpMethod: + body.UpdateLoginFlowWithTotpMethod = e + case *cloud.UpdateLoginFlowWithPasswordMethod: + body.UpdateLoginFlowWithPasswordMethod = e + default: + panic("unexpected type") + } + + login, _, err := c.FrontendAPI.UpdateLoginFlow(ctx).XSessionToken(sessionToken). + Flow(flow.Id).UpdateLoginFlowBody(body).Execute() + if err != nil { + if e, ok := err.(*cloud.GenericOpenAPIError); ok { + switch m := e.Model().(type) { + case *cloud.LoginFlow: + flow = m + case cloud.LoginFlow: + flow = &m + } + _, _ = fmt.Fprintf(h.VerboseErrWriter, "\nYour sign in attempt failed. Please try again!\n\n") + goto doLogin + } + + return err + } + + sessionToken = stringsx.Coalesce(*login.SessionToken, sessionToken) + sess, _, err := c.FrontendAPI.ToSession(ctx).XSessionToken(sessionToken).Execute() + if err != nil { + if e, ok := err.(interface{ Body() []byte }); ok { + switch gjson.GetBytes(e.Body(), "error.id").String() { + case "session_aal2_required": + return h.signin(ctx, c, sessionToken) + } + } + return err + } + config := new(Config) + if err := config.fromSession(sess, sessionToken); err != nil { + return err + } + return h.UpdateConfig(config) + +} + +func getField(i interface{}, path string) (*gjson.Result, error) { + var b bytes.Buffer + if err := json.NewEncoder(&b).Encode(i); err != nil { + return nil, err + } + result := gjson.GetBytes(b.Bytes(), path) + return &result, nil +} + +func (c *Config) fromSession(session *cloud.Session, token string) error { + email, err := getField(session.Identity.Traits, "email") + if err != nil { + return err + } + name, err := getField(session.Identity.Traits, "name") + if err != nil { + return err + } + + c.Version = ConfigVersion + c.SessionToken = token + c.IdentityTraits = Identity{ + Email: email.String(), + Name: name.String(), + ID: uuid.FromStringOrNil(session.Identity.Id), + } + return nil +} + +func (h *CommandHelper) Authenticate(ctx context.Context) error { + if h.isQuiet { + return stderrors.New("can not sign in or sign up when flag --quiet is set") + } + + config, err := h.getConfig() + if stderrors.Is(err, ErrNoConfig) { + config = new(Config) + } else if err != nil { + return err + } + + if len(config.SessionToken) > 0 { + if !h.noConfirm { + ok, err := cmdx.AskScannerForConfirmation(fmt.Sprintf("You are signed in as %q already. Do you wish to authenticate with another account?", config.IdentityTraits.Email), h.Stdin, h.VerboseErrWriter) + if err != nil { + return err + } else if !ok { + return nil + } + _, _ = fmt.Fprintf(h.VerboseErrWriter, "Ok, signing you out!\n") + } + + if err := h.ClearConfig(); err != nil { + return err + } + } + + c, err := NewOryProjectClient() + if err != nil { + return err + } + + signIn, err := cmdx.AskScannerForConfirmation("Do you want to sign in to an existing Ory Network account?", h.Stdin, h.VerboseErrWriter) + if err != nil { + return err + } + + if signIn { + if err := h.signin(ctx, c, ""); err != nil { + return err + } + } else { + _, _ = fmt.Fprintln(h.VerboseErrWriter, "Great to have you! Let's create a new Ory Network account.") + if err := h.signup(ctx, c); err != nil { + return err + } + } + + config, err = h.getConfig() + if err != nil { + return err + } + if len(config.SessionToken) == 0 { + return fmt.Errorf("unable to authenticate") + } + + _, _ = fmt.Fprintf(h.VerboseErrWriter, "You are now signed in as: %s\n", config.IdentityTraits.Email) + return nil +} + +func (h *CommandHelper) ClearConfig() error { + return h.UpdateConfig(new(Config)) +} diff --git a/cmd/cloudx/client/client.go b/cmd/cloudx/client/client.go index 662e4297..055a9d06 100644 --- a/cmd/cloudx/client/client.go +++ b/cmd/cloudx/client/client.go @@ -15,77 +15,48 @@ import ( "github.com/hashicorp/go-retryablehttp" "github.com/spf13/cobra" - flag "github.com/spf13/pflag" hydra "github.com/ory/hydra-client-go/v2" hydracli "github.com/ory/hydra/v2/cmd/cliclient" kratoscli "github.com/ory/kratos/cmd/cliclient" "github.com/ory/x/cmdx" - "github.com/ory/x/flagx" ) -const projectFlag = "project" - -func RegisterProjectFlag(f *flag.FlagSet) { - f.String(projectFlag, "", "The project to use, either project ID or a (partial) slug.") -} - -// ProjectOrDefault returns the slug or ID the user set with the `--project` flag, or the default project, or prints a warning and returns an error -// if none was set. -func ProjectOrDefault(cmd *cobra.Command, h *CommandHelper) (string, error) { - if flag := flagx.MustGetString(cmd, projectFlag); flag == "" { - if id := h.GetDefaultProjectID(); id != "" { - return id, nil - } else { - _, _ = fmt.Fprintf(os.Stderr, "No project selected! Please use the flag --%s to specify one.\n", projectFlag) - return "", cmdx.FailSilently(cmd) - } - } else { - return flag, nil - } -} - -func Client(cmd *cobra.Command) (*retryablehttp.Client, *AuthContext, *cloud.Project, error) { - sc, err := NewCommandHelper(cmd) +func Client(cmd *cobra.Command) (*retryablehttp.Client, *Config, *cloud.Project, error) { + ctx := cmd.Context() + sc, err := NewCobraCommandHelper(cmd) if err != nil { _, _ = fmt.Fprintf(os.Stderr, "Failed to initialize HTTP Client: %s\n", err) return nil, nil, nil, cmdx.FailSilently(cmd) } - ac, err := sc.EnsureContext() + ac, err := sc.GetAuthenticatedConfig(ctx) if err != nil { return nil, nil, nil, err } - projectOrSlug, err := ProjectOrDefault(cmd, sc) - if err != nil { - return nil, nil, nil, cmdx.FailSilently(cmd) - } - - p, err := sc.GetProject(projectOrSlug) + project, err := sc.GetSelectedProject(ctx) if err != nil { return nil, nil, nil, err } c := retryablehttp.NewClient() c.Logger = nil - return c, ac, p, nil + return c, ac, project, nil } func ContextWithClient(ctx context.Context) context.Context { ctx = context.WithValue(ctx, hydracli.OAuth2URLOverrideContextKey, func(cmd *cobra.Command) *url.URL { - _, _, p, err := Client(cmd) + h, err := NewCobraCommandHelper(cmd) if err != nil { return nil } - - apiURL, err := url.ParseRequestURI(makeCloudAPIsURL(p.Slug + ".projects")) + project, err := h.GetSelectedProject(cmd.Context()) if err != nil { return nil } - // We use the cloud console API because it works with ory cloud session tokens. - return apiURL + return CloudAPIsURL(project.Slug) }) ctx = context.WithValue(ctx, hydracli.ClientContextKey, func(cmd *cobra.Command) (*hydra.APIClient, *url.URL, error) { @@ -100,13 +71,10 @@ func ContextWithClient(ctx context.Context) context.Context { Timeout: time.Second * 30, } - consoleURL, err := url.ParseRequestURI(makeCloudConsoleURL(p.Slug + ".projects")) - if err != nil { - return nil, nil, err - } + consoleProjectURL := cloudConsoleURL(p.Slug + ".projects") // We use the cloud console API because it works with ory cloud session tokens. - conf.Servers = hydra.ServerConfigurations{{URL: consoleURL.String()}} - return hydra.NewAPIClient(conf), consoleURL, nil + conf.Servers = hydra.ServerConfigurations{{URL: consoleProjectURL.String()}} + return hydra.NewAPIClient(conf), consoleProjectURL, nil }) ctx = context.WithValue(ctx, kratoscli.ClientContextKey, func(cmd *cobra.Command) (*kratoscli.ClientContext, error) { @@ -117,7 +85,7 @@ func ContextWithClient(ctx context.Context) context.Context { // We use the cloud console API because it works with ory cloud session tokens. return &kratoscli.ClientContext{ - Endpoint: makeCloudConsoleURL(p.Slug + ".projects"), + Endpoint: cloudConsoleURL(p.Slug + ".projects").String(), HTTPClient: &http.Client{ Transport: &bearerTokenTransporter{ RoundTripper: c.StandardClient().Transport, diff --git a/cmd/cloudx/client/command_helper.go b/cmd/cloudx/client/command_helper.go new file mode 100644 index 00000000..250e4584 --- /dev/null +++ b/cmd/cloudx/client/command_helper.go @@ -0,0 +1,273 @@ +// Copyright © 2023 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package client + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "os/user" + "strings" + + "github.com/ory/x/pointerx" + + "github.com/gofrs/uuid" + "github.com/pkg/errors" + "github.com/spf13/cobra" + "github.com/tidwall/gjson" + "golang.org/x/term" + + cloud "github.com/ory/client-go" + "github.com/ory/x/cmdx" + "github.com/ory/x/flagx" + "github.com/ory/x/jsonx" +) + +const ( + WorkspaceKey = "ORY_WORKSPACE" + ProjectKey = "ORY_PROJECT" +) + +var ErrProjectNotSet = fmt.Errorf("no project was specified") + +type ( + CommandHelper struct { + config *Config + projectID *string + workspaceID *string + configLocation string + noConfirm bool + isQuiet bool + VerboseErrWriter io.Writer + Stdin *bufio.Reader + pwReader passwordReader + } + helperOptionsContextKey struct{} + CommandHelperOption func(*CommandHelper) +) + +func ContextWithOptions(ctx context.Context, opts ...CommandHelperOption) context.Context { + baseOpts, _ := ctx.Value(helperOptionsContextKey{}).([]CommandHelperOption) + return context.WithValue(ctx, helperOptionsContextKey{}, append(baseOpts[:], opts...)) +} + +func WithConfigLocation(location string) CommandHelperOption { + return func(h *CommandHelper) { + h.configLocation = location + } +} + +func WithNoConfirm(noConfirm bool) CommandHelperOption { + return func(h *CommandHelper) { + h.noConfirm = noConfirm + } +} + +func WithQuiet(isQuiet bool) CommandHelperOption { + return func(h *CommandHelper) { + h.isQuiet = isQuiet + } +} + +func WithVerboseErrWriter(w io.Writer) CommandHelperOption { + return func(h *CommandHelper) { + h.VerboseErrWriter = w + } +} + +func WithStdin(r io.Reader) CommandHelperOption { + return func(h *CommandHelper) { + h.Stdin = bufio.NewReader(r) + } +} + +func WithPasswordReader(p passwordReader) CommandHelperOption { + return func(h *CommandHelper) { + h.pwReader = p + } +} + +func WithProjectOverride(project string) CommandHelperOption { + return func(h *CommandHelper) { + h.projectID = &project + } +} + +func WithWorkspaceOverride(workspace string) CommandHelperOption { + return func(h *CommandHelper) { + h.workspaceID = pointerx.Ptr(workspace) + } +} + +// NewCobraCommandHelper creates a new CommandHelper instance which handles cobra CLI commands. +func NewCobraCommandHelper(cmd *cobra.Command, opts ...CommandHelperOption) (*CommandHelper, error) { + stdErr := cmd.ErrOrStderr() + quiet := flagx.MustGetBool(cmd, cmdx.FlagQuiet) + if quiet { + stdErr = io.Discard + } + defaultOpts := []CommandHelperOption{ + WithVerboseErrWriter(stdErr), + WithStdin(cmd.InOrStdin()), + WithQuiet(quiet), + WithNoConfirm(flagx.MustGetBool(cmd, FlagYes)), + } + // we explicitly ignore the error here, because the command might not support the project flag (most do) + if project, _ := cmd.Flags().GetString(FlagProject); project != "" { + defaultOpts = append(defaultOpts, WithProjectOverride(project)) + } + // we explicitly ignore the error here, because the command might not support the workspace flag (most do) + if workspace, _ := cmd.Flags().GetString(FlagWorkspace); workspace != "" { + defaultOpts = append(defaultOpts, WithWorkspaceOverride(workspace)) + } + if config := flagx.MustGetString(cmd, FlagConfig); config != "" { + defaultOpts = append(defaultOpts, WithConfigLocation(config)) + } + return NewCommandHelper(cmd.Context(), append(defaultOpts, opts...)...) +} + +func NewCommandHelper(ctx context.Context, opts ...CommandHelperOption) (*CommandHelper, error) { + location, err := getConfigPath() + if err != nil { + return nil, err + } + + h := &CommandHelper{ + configLocation: location, + noConfirm: false, + VerboseErrWriter: io.Discard, + Stdin: bufio.NewReader(os.Stdin), + pwReader: func() ([]byte, error) { + return term.ReadPassword(int(os.Stdin.Fd())) + }, + } + if ctxOpts, ok := ctx.Value(helperOptionsContextKey{}).([]CommandHelperOption); ok { + opts = append(opts, ctxOpts...) + } + for _, o := range opts { + o(h) + } + config, err := h.getConfig() + if errors.Is(err, ErrNoConfig) { + // this might happen initially, we don't want to error in this case + config = &Config{} + } else if err != nil { + return nil, err + } + + { + // determine current workspace from all possible sources + workspace := "" + if h.workspaceID != nil { + workspace = *h.workspaceID + } else if ws, ok := os.LookupEnv(WorkspaceKey); ok { + workspace = ws + } else { + if config.SelectedWorkspace != uuid.Nil { + workspace = config.SelectedWorkspace.String() + } + } + workspace = strings.TrimSpace(workspace) + + if id, err := uuid.FromString(workspace); err == nil { + h.workspaceID = pointerx.Ptr(id.String()) + } else if workspace != "" { + ws, err := h.findWorkspace(ctx, workspace) + if err != nil { + return nil, err + } + if ws != nil { + h.workspaceID = pointerx.Ptr(ws.Id) + } + } + } + { + // determine current project from all possible sources + project := "" + if h.projectID != nil { + project = *h.projectID + } else if pj, ok := os.LookupEnv(ProjectKey); ok { + project = pj + } else if config.SelectedProject != uuid.Nil { + project = config.SelectedProject.String() + } + project = strings.TrimSpace(project) + + if id, err := uuid.FromString(project); err == nil { + h.projectID = pointerx.Ptr(id.String()) + } else if project != "" { + pj, err := h.findProject(ctx, project, h.workspaceID) + if err != nil { + return nil, err + } + if pj != nil { + h.projectID = pointerx.Ptr(pj.Id) + } + } + } + + return h, nil +} + +func (h *CommandHelper) ProjectID() (string, error) { + if h.projectID == nil { + return "", ErrProjectNotSet + } + return *h.projectID, nil +} + +func (h *CommandHelper) WorkspaceID() *string { + return h.workspaceID +} + +func (h *CommandHelper) UserName(ctx context.Context) string { + config, err := h.GetAuthenticatedConfig(ctx) + if err == nil && config.IdentityTraits.Name != "" { + return config.IdentityTraits.Name + } + u, err := user.Current() + if err != nil { + return "unknown" + } + if u.Name != "" { + return u.Name + } + return u.Username +} + +func handleError(message string, res *http.Response, err error) error { + if e := new(cloud.GenericOpenAPIError); errors.As(err, &e) { + return errors.Wrapf(err, "%s: %s", message, e.Body()) + } + + if res == nil { + return errors.Wrapf(err, "%s", message) + } + + body, _ := io.ReadAll(res.Body) + return errors.Wrapf(err, "%s: %s", message, body) +} + +func toPatch(op string, values []string) (patches []cloud.JsonPatch, err error) { + for _, v := range values { + path, value, found := strings.Cut(v, "=") + if !found { + return nil, errors.Errorf("patches must be in format of `/some/config/key=some-value` but got: %s", v) + } else if !gjson.Valid(value) { + return nil, errors.Errorf("value for %s must be valid JSON but got: %s", path, value) + } + + config, err := jsonx.EmbedSources(json.RawMessage(value), jsonx.WithIgnoreKeys("$id", "$schema"), jsonx.WithOnlySchemes("file")) + if err != nil { + return nil, errors.WithStack(err) + } + + patches = append(patches, cloud.JsonPatch{Op: op, Path: path, Value: config}) + } + return patches, nil +} diff --git a/cmd/cloudx/client/command_helper_test.go b/cmd/cloudx/client/command_helper_test.go new file mode 100644 index 00000000..2bd644e6 --- /dev/null +++ b/cmd/cloudx/client/command_helper_test.go @@ -0,0 +1,364 @@ +// Copyright © 2023 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package client_test + +import ( + "bytes" + "context" + _ "embed" + "encoding/json" + "io" + "testing" + + "github.com/containerd/continuity/fs" + + "github.com/ory/x/assertx" + "github.com/ory/x/snapshotx" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/ory/cli/cmd/cloudx/client" + "github.com/ory/cli/cmd/cloudx/testhelpers" + cloud "github.com/ory/client-go" +) + +//go:embed fixtures/update_project/config.json +var updatedProjectConfig json.RawMessage + +func TestMain(m *testing.M) { + testhelpers.RunAgainstStaging(m) +} + +func TestCommandHelper(t *testing.T) { + ctx := context.Background() + configPath := testhelpers.NewConfigFile(t) + email, password, name := testhelpers.RegisterAccount(t, configPath) + project := testhelpers.CreateProject(t, configPath, nil) + + defaultOpts := func() []client.CommandHelperOption { + return []client.CommandHelperOption{ + client.WithNoConfirm(true), + client.WithQuiet(true), + client.WithVerboseErrWriter(io.Discard), + } + } + + loggedIn, err := client.NewCommandHelper( + ctx, + append(defaultOpts(), client.WithConfigLocation(configPath))..., + ) + require.NoError(t, err) + assertValidProject := func(t *testing.T, actual *cloud.Project) { + assert.NotZero(t, actual.Slug) + assert.NotZero(t, actual.Services.Identity.Config) + assert.NotZero(t, actual.Services.Permission.Config) + } + + t.Run("func=SelectProject", func(t *testing.T) { + t.Parallel() + configDir := testhelpers.NewConfigFile(t) + testhelpers.RegisterAccount(t, configDir) + firstProject := testhelpers.CreateProject(t, configDir, nil) + secondProject := testhelpers.CreateProject(t, configDir, nil) + testhelpers.SetDefaultProject(t, configDir, secondProject.Id) + + t.Run("can change the selected project", func(t *testing.T) { + h, err := client.NewCommandHelper(ctx, append(defaultOpts(), client.WithConfigLocation(configDir))...) + require.NoError(t, err) + + current, err := h.ProjectID() + require.NoError(t, err) + require.Equal(t, current, secondProject.Id) + + require.NoError(t, h.SelectProject(firstProject.Id)) + + selected, err := h.ProjectID() + require.NoError(t, err) + assert.Equal(t, selected, firstProject.Id) + }) + }) + + t.Run("func=ListProjects", func(t *testing.T) { + t.Parallel() + configFile := testhelpers.NewConfigFile(t) + testhelpers.RegisterAccount(t, configFile) + + h, err := client.NewCommandHelper(ctx, append(defaultOpts(), client.WithConfigLocation(configFile))...) + require.NoError(t, err) + + t.Run("empty list", func(t *testing.T) { + projects, err := h.ListProjects(ctx, nil) + + require.NoError(t, err) + require.Empty(t, projects) + }) + + t.Run("list of projects", func(t *testing.T) { + p0, p1 := testhelpers.CreateProject(t, configFile, nil), testhelpers.CreateProject(t, configFile, nil) + + projects, err := h.ListProjects(ctx, nil) + + require.NoError(t, err) + require.Len(t, projects, 2) + assert.ElementsMatch(t, []string{p0.Id, p1.Id}, []string{projects[0].Id, projects[1].Id}) + }) + + t.Run("list of workspace projects", func(t *testing.T) { + workspace := testhelpers.CreateWorkspace(t, configFile) + p0, p1 := testhelpers.CreateProject(t, configFile, &workspace), testhelpers.CreateProject(t, configFile, &workspace) + + projects, err := h.ListProjects(ctx, &workspace) + + require.NoError(t, err) + require.Len(t, projects, 2) + assert.ElementsMatch(t, []string{p0.Id, p1.Id}, []string{projects[0].Id, projects[1].Id}) + }) + }) + + t.Run("func=CreateProject", func(t *testing.T) { + t.Parallel() + configPath := testhelpers.NewConfigFile(t) + testhelpers.RegisterAccount(t, configPath) + + h, err := client.NewCommandHelper(ctx, append(defaultOpts(), client.WithConfigLocation(configPath))...) + require.NoError(t, err) + + t.Run("creates project and sets default project", func(t *testing.T) { + newName := "new project name" + + project, err := h.CreateProject(ctx, newName, "dev", nil, true) + require.NoError(t, err) + assert.Equal(t, project.Name, newName) + + defaultID, err := h.ProjectID() + require.NoError(t, err) + assert.Equal(t, project.Id, defaultID) + }) + + t.Run("creates two projects with different names", func(t *testing.T) { + name1 := "new project name1" + name2 := "new project name2" + + project1, err := h.CreateProject(ctx, name1, "dev", nil, true) + require.NoError(t, err) + + project2, err := h.CreateProject(ctx, name2, "dev", nil, false) + require.NoError(t, err) + + assert.NotEqual(t, project1.Id, project2.Id) + assert.NotEqual(t, project1.Name, project2.Name) + assert.NotEqual(t, project1.Slug, project2.Slug) + + selectedID, err := h.ProjectID() + require.NoError(t, err) + assert.Equal(t, project1.Id, selectedID) + }) + }) + + t.Run("func=Authenticate", func(t *testing.T) { + t.Parallel() + configPath := testhelpers.NewConfigFile(t) + email2, password2, name2 := testhelpers.FakeAccount() + + t.Run("create new account", func(t *testing.T) { + h, err := client.NewCommandHelper(ctx, + client.WithConfigLocation(configPath), + client.WithStdin(testhelpers.RegistrationBuffer(name2, email2)), + client.WithPasswordReader(func() ([]byte, error) { return []byte(password2), nil }), + ) + require.NoError(t, err) + + require.NoError(t, h.Authenticate(ctx)) + + config, err := h.GetAuthenticatedConfig(ctx) + require.NoError(t, err) + require.NotNil(t, config) + assert.Equal(t, email2, config.IdentityTraits.Email) + assert.Equal(t, name2, config.IdentityTraits.Name) + }) + + t.Run("log into existing account", func(t *testing.T) { + var r bytes.Buffer + _, _ = r.WriteString("y\n") // Do you want to sign in to an existing Ory Network account? [y/n]: y + _, _ = r.WriteString(email2 + "\n") // Email: FakeEmail() + h, err := client.NewCommandHelper(ctx, + client.WithConfigLocation(testhelpers.NewConfigFile(t)), + client.WithStdin(&r), + client.WithPasswordReader(func() ([]byte, error) { return []byte(password2), nil }), + ) + require.NoError(t, err) + + require.NoError(t, h.Authenticate(ctx)) + + config, err := h.GetAuthenticatedConfig(ctx) + require.NoError(t, err) + require.NotNil(t, config) + assert.Equal(t, email2, config.IdentityTraits.Email) + assert.Equal(t, name2, config.IdentityTraits.Name) + }) + + t.Run("retry login after wrong password", func(t *testing.T) { + var r bytes.Buffer + _, _ = r.WriteString("y\n") // Do you want to sign in to an existing Ory Network account? [y/n]: y + _, _ = r.WriteString(email2 + "\n") // Email: FakeEmail() + _, _ = r.WriteString(email2 + "\n") // Email: FakeEmail() [RETRY] + + retry := false + pwReader := func() ([]byte, error) { + if retry { + return []byte(password2), nil + } + retry = true + return []byte("wrong"), nil + } + + h, err := client.NewCommandHelper(ctx, + client.WithConfigLocation(testhelpers.NewConfigFile(t)), + client.WithStdin(&r), + client.WithPasswordReader(pwReader), + ) + require.NoError(t, err) + + require.NoError(t, h.Authenticate(ctx)) + + config, err := h.GetAuthenticatedConfig(ctx) + require.NoError(t, err) + require.NotNil(t, config) + assert.Equal(t, email2, config.IdentityTraits.Email) + assert.Equal(t, name2, config.IdentityTraits.Name) + }) + + t.Run("switch logged in account", func(t *testing.T) { + newConfigPath := testhelpers.NewConfigFile(t) + require.NoError(t, fs.CopyFile(newConfigPath, configPath)) + + var r bytes.Buffer + _, _ = r.WriteString("y\n") // You are signed in as \"%s\" already. Do you wish to authenticate with another account?: y + _, _ = r.WriteString("y\n") // Do you want to sign in to an existing Ory Network account? [y/n]: y + _, _ = r.WriteString(email + "\n") // Email: FakeEmail() + + h, err := client.NewCommandHelper(ctx, + client.WithConfigLocation(newConfigPath), + client.WithStdin(&r), + client.WithPasswordReader(func() ([]byte, error) { return []byte(password), nil }), + ) + require.NoError(t, err) + + require.NoError(t, h.Authenticate(ctx)) + + config, err := h.GetAuthenticatedConfig(ctx) + require.NoError(t, err) + require.NotNil(t, config) + assert.Equal(t, config.IdentityTraits.Email, email) + assert.Equal(t, config.IdentityTraits.Name, name) + }) + }) + + t.Run("func=CreateAPIKey and DeleteApiKey", func(t *testing.T) { + t.Run("is able to get project", func(t *testing.T) { + name := "a test key" + token, err := loggedIn.CreateAPIKey(ctx, project.Id, name) + require.NoError(t, err) + assert.Equal(t, name, token.Name) + assert.NotEmpty(t, name, token.Value) + + require.NoError(t, loggedIn.DeleteAPIKey(ctx, project.Id, token.Id)) + }) + }) + + t.Run("func=GetProject", func(t *testing.T) { + t.Run("is able to get project", func(t *testing.T) { + p, err := loggedIn.GetProject(ctx, project.Id, nil) + require.NoError(t, err) + assert.Equal(t, project.Id, p.Id) + assertValidProject(t, p) + + actual, err := loggedIn.GetProject(ctx, p.Slug[0:4], nil) + require.NoError(t, err) + assert.Equal(t, p, actual) + }) + + t.Run("is able to get workspace project", func(t *testing.T) { + workspace := testhelpers.CreateWorkspace(t, configPath) + project := testhelpers.CreateProject(t, configPath, &workspace) + p, err := loggedIn.GetProject(ctx, project.Id, &workspace) + require.NoError(t, err) + assert.Equal(t, project, p) + assertValidProject(t, p) + + actual, err := loggedIn.GetProject(ctx, p.Slug[0:4], &workspace) + require.NoError(t, err) + assert.Equal(t, project, actual) + }) + + t.Run("is not able to get project if not authenticated and quiet flag", func(t *testing.T) { + h, err := client.NewCommandHelper(ctx, append( + defaultOpts(), + client.WithConfigLocation(testhelpers.NewConfigFile(t)), + client.WithQuiet(true), + )...) + require.NoError(t, err) + _, err = h.GetProject(ctx, project.Id, nil) + assert.ErrorIs(t, err, client.ErrNoConfigQuiet) + }) + }) + + t.Run("func=UpdateProject", func(t *testing.T) { + t.Run("is able to update a project", func(t *testing.T) { + t.Skip("TODO") + + res, err := loggedIn.UpdateProject(ctx, project.Id, "", []json.RawMessage{updatedProjectConfig}) + require.NoErrorf(t, err, "%+v", err) + + assertx.EqualAsJSONExcept(t, updatedProjectConfig, res.Project, []string{ + "id", + "revision_id", + "state", + "slug", + "services.identity.config.serve", + "services.identity.config.cookies", + "services.identity.config.identity.default_schema_id", + "services.identity.config.identity.schemas", + "services.identity.config.session.cookie", + "services.identity.config.selfservice.allowed_return_urls.0", + "services.oauth2.config.urls.self", + "services.oauth2.config.serve.public.tls", + "services.oauth2.config.serve.tls", + "services.oauth2.config.serve.admin.tls", + "services.oauth2.config.serve.cookies.domain", + "services.oauth2.config.serve.cookies.names", + "services.oauth2.config.oauth2.session.encrypt_at_rest", + "services.oauth2.config.oauth2.expose_internal_errors", + "services.oauth2.config.oauth2.hashers", + "services.oauth2.config.hsm", + "services.oauth2.config.clients", + "services.oauth2.config.oauth2.session", + }) + + snapshotx.SnapshotT(t, res, snapshotx.ExceptPaths( + "project.id", + "project.revision_id", + "project.slug", + "project.services.identity.config.serve.public.base_url", + "project.services.identity.config.serve.admin.base_url", + "project.services.identity.config.session.cookie.domain", + "project.services.identity.config.session.cookie.name", + "project.services.identity.config.cookies.domain", + "project.services.identity.config.selfservice.allowed_return_urls.0", + "project.services.oauth2.config.urls.self", + "project.services.oauth2.config.serve.cookies.domain", + "project.services.oauth2.config.serve.cookies.names", + "project.services.identity.config.identity.schemas.1.url", // bucket changes locally vs staging + )) + }) + + t.Run("is able to update a projects name", func(t *testing.T) { + name := testhelpers.FakeName() + res, err := loggedIn.UpdateProject(ctx, project.Id, name, []json.RawMessage{updatedProjectConfig}) + require.NoError(t, err) + assert.Equal(t, name, res.Project.Name) + }) + }) +} diff --git a/cmd/cloudx/client/config.go b/cmd/cloudx/client/config.go new file mode 100644 index 00000000..34cc591b --- /dev/null +++ b/cmd/cloudx/client/config.go @@ -0,0 +1,163 @@ +// Copyright © 2024 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package client + +import ( + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + + "github.com/gofrs/uuid" + "github.com/spf13/pflag" + + "github.com/ory/x/cmdx" + + "github.com/ory/x/stringsx" +) + +var ( + ErrNoConfig = errors.New("no ory configuration file present") + ErrNoConfigQuiet = errors.New("please run `ory auth` to initialize your configuration or remove the `--quiet` flag") + ErrNotAuthenticated = errors.New("you are not authenticated, please run `ory auth` to authenticate") + ErrReauthenticate = errors.New("your session or key has expired or has otherwise become invalid, re-authenticate to continue") +) + +const ( + ConfigFileName = ".ory-cloud.json" + FlagConfig = "config" + ConfigPathKey = "ORY_CONFIG_PATH" + ConfigVersion = "v0alpha0" +) + +func RegisterConfigFlag(f *pflag.FlagSet) { + f.StringP(FlagConfig, FlagConfig[:1], "", "Path to the Ory Network configuration file.") +} + +func getConfigPath() (string, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("unable to guess your home directory: %w", err) + } + + return stringsx.Coalesce( + os.Getenv(ConfigPathKey), + filepath.Join(homeDir, ConfigFileName), + ), nil +} + +func (h *CommandHelper) UpdateConfig(c *Config) error { + c.Version = ConfigVersion + h.config = c + + f, err := os.OpenFile(h.configLocation, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600) + if err != nil { + return fmt.Errorf("unable to open file %q for writing: %w", h.configLocation, err) + } + defer f.Close() + + if err := json.NewEncoder(f).Encode(c); err != nil { + return fmt.Errorf("unable to write configuration file %q: %w", h.configLocation, err) + } + + return nil +} + +func (h *CommandHelper) getConfig() (*Config, error) { + if h.config == nil { + c, err := readConfig(h.configLocation) + if err != nil { + return nil, err + } + h.config = c + } + return h.config, nil +} + +func readConfig(location string) (*Config, error) { + f, err := os.Open(location) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil, ErrNoConfig + } + return nil, fmt.Errorf("unable to open ory config file %q: %w", location, err) + } + defer f.Close() + + var c Config + if err := json.NewDecoder(f).Decode(&c); err != nil { + return nil, fmt.Errorf("unable to JSON decode the ory config file %q: %w", location, err) + } + + return &c, nil +} + +func (h *CommandHelper) SelectProject(id string) error { + conf, err := h.getConfig() + if err != nil { + return err + } + + uid, err := uuid.FromString(id) + if err != nil { + return err + } + + if conf.SelectedProject == uid { + // nothing to do + return nil + } + + conf.SelectedProject = uid + h.projectID = &id + return h.UpdateConfig(conf) +} + +type Config struct { + Version string `json:"version"` + SessionToken string `json:"session_token"` + SelectedProject uuid.UUID `json:"selected_project"` + SelectedWorkspace uuid.UUID `json:"selected_workspace"` + IdentityTraits Identity `json:"session_identity_traits"` + + // isAuthenticated is a flag that we set once the session was checked and is valid. + // Because this is not stored to the config file, it means that every command execution does at most one session check. + isAuthenticated bool +} + +func (c *Config) ID() string { + return c.IdentityTraits.ID.String() +} + +func (*Config) Header() []string { + return []string{"ID", "EMAIL", "SELECTED PROJECT", "SELECTED WORKSPACE"} +} + +func (c *Config) Columns() []string { + project, workspace := cmdx.None, cmdx.None + if c.SelectedProject != uuid.Nil { + project = c.SelectedProject.String() + } + if c.SelectedWorkspace != uuid.Nil { + workspace = c.SelectedWorkspace.String() + } + return []string{ + c.ID(), + c.IdentityTraits.Email, + project, + workspace, + } +} + +func (c *Config) Interface() any { + return c +} + +type Identity struct { + ID uuid.UUID + Email string `json:"email"` + Name string `json:"name"` +} diff --git a/cmd/cloudx/client/event_stream.go b/cmd/cloudx/client/event_stream.go new file mode 100644 index 00000000..1041d5be --- /dev/null +++ b/cmd/cloudx/client/event_stream.go @@ -0,0 +1,66 @@ +// Copyright © 2024 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package client + +import ( + "context" + + cloud "github.com/ory/client-go" +) + +func (h *CommandHelper) CreateEventStream(ctx context.Context, projectID string, body cloud.CreateEventStreamBody) (*cloud.EventStream, error) { + c, err := h.newCloudClient(ctx) + if err != nil { + return nil, err + } + + stream, res, err := c.EventsAPI.CreateEventStream(ctx, projectID).CreateEventStreamBody(body).Execute() + if err != nil { + return nil, handleError("unable to create event stream", res, err) + } + + return stream, nil +} + +func (h *CommandHelper) UpdateEventStream(ctx context.Context, projectID, streamID string, body cloud.SetEventStreamBody) (*cloud.EventStream, error) { + c, err := h.newCloudClient(ctx) + if err != nil { + return nil, err + } + + stream, res, err := c.EventsAPI.SetEventStream(ctx, projectID, streamID).SetEventStreamBody(body).Execute() + if err != nil { + return nil, handleError("unable to update event stream", res, err) + } + + return stream, nil +} + +func (h *CommandHelper) DeleteEventStream(ctx context.Context, projectID, streamID string) error { + c, err := h.newCloudClient(ctx) + if err != nil { + return err + } + + res, err := c.EventsAPI.DeleteEventStream(ctx, projectID, streamID).Execute() + if err != nil { + return handleError("unable to delete event stream", res, err) + } + + return nil +} + +func (h *CommandHelper) ListEventStreams(ctx context.Context, projectID string) (*cloud.ListEventStreams, error) { + c, err := h.newCloudClient(ctx) + if err != nil { + return nil, err + } + + streams, res, err := c.EventsAPI.ListEventStreams(ctx, projectID).Execute() + if err != nil { + return nil, handleError("unable to list event streams", res, err) + } + + return streams, nil +} diff --git a/cmd/cloudx/client/flags.go b/cmd/cloudx/client/flags.go new file mode 100644 index 00000000..61439c56 --- /dev/null +++ b/cmd/cloudx/client/flags.go @@ -0,0 +1,26 @@ +// Copyright © 2024 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package client + +import ( + "github.com/spf13/pflag" +) + +const ( + FlagWorkspace = "workspace" + FlagProject = "project" + FlagYes = "yes" +) + +func RegisterWorkspaceFlag(f *pflag.FlagSet) { + f.String(FlagWorkspace, "", "The workspace to use, either workspace ID or a (partial) name.") +} + +func RegisterProjectFlag(f *pflag.FlagSet) { + f.String(FlagProject, "", "The project to use, either project ID or a (partial) slug.") +} + +func RegisterYesFlag(f *pflag.FlagSet) { + f.BoolP(FlagYes, FlagYes[:1], false, "Confirm all dialogs with yes.") +} diff --git a/cmd/cloudx/client/form.go b/cmd/cloudx/client/form.go index 05c17068..3e697eb5 100644 --- a/cmd/cloudx/client/form.go +++ b/cmd/cloudx/client/form.go @@ -12,7 +12,7 @@ import ( "strings" "time" - oldCloud "github.com/ory/client-go/114" + cloud "github.com/ory/client-go" "github.com/pkg/errors" "github.com/tidwall/sjson" @@ -20,7 +20,7 @@ import ( "github.com/ory/x/cmdx" ) -func getLabel(attrs *oldCloud.UiNodeInputAttributes, node *oldCloud.UiNode) string { +func getLabel(attrs *cloud.UiNodeInputAttributes, node *cloud.UiNode) string { if attrs.Name == "identifier" { return fmt.Sprintf("%s: ", "Email") } else if node.Meta.Label != nil { @@ -33,7 +33,7 @@ func getLabel(attrs *oldCloud.UiNodeInputAttributes, node *oldCloud.UiNode) stri type passwordReader = func() ([]byte, error) -func renderForm(stdin *bufio.Reader, pwReader passwordReader, stderr io.Writer, ui oldCloud.UiContainer, method string, out interface{}) (err error) { +func renderForm(stdin *bufio.Reader, pwReader passwordReader, stderr io.Writer, ui cloud.UiContainer, method string, out interface{}) (err error) { for _, message := range ui.Messages { _, _ = fmt.Fprintf(stderr, "%s\n", message.Text) } @@ -54,12 +54,6 @@ func renderForm(stdin *bufio.Reader, pwReader passwordReader, stderr io.Writer, switch node.Type { case "input": attrs := node.Attributes.UiNodeInputAttributes - switch attrs.Type { - case "button": - continue - case "submit": - continue - } if attrs.Name == "traits.consent.tos" { for { @@ -79,11 +73,12 @@ func renderForm(stdin *bufio.Reader, pwReader passwordReader, stderr io.Writer, } if strings.Contains(attrs.Name, "traits.details") { + // TODO ask for details continue } switch attrs.Type { - case "hidden": + case "button", "submit", "hidden": continue case "checkbox": result, err := cmdx.AskScannerForConfirmation(getLabel(attrs, &node), stdin, stderr) diff --git a/cmd/cloudx/client/handler.go b/cmd/cloudx/client/handler.go deleted file mode 100644 index 9c144de6..00000000 --- a/cmd/cloudx/client/handler.go +++ /dev/null @@ -1,961 +0,0 @@ -// Copyright © 2023 Ory Corp -// SPDX-License-Identifier: Apache-2.0 - -package client - -import ( - "bufio" - "bytes" - "context" - "encoding/json" - stderrs "errors" - "fmt" - "io" - "io/fs" - "net/http" - "net/url" - "os" - "path/filepath" - "strings" - - "github.com/gofrs/uuid/v3" - "github.com/imdario/mergo" - "github.com/pkg/errors" - "github.com/spf13/cobra" - "github.com/spf13/pflag" - "github.com/tidwall/gjson" - "golang.org/x/term" - - cloud "github.com/ory/client-go" - oldCloud "github.com/ory/client-go/114" - "github.com/ory/x/cmdx" - "github.com/ory/x/flagx" - "github.com/ory/x/jsonx" - "github.com/ory/x/stringsx" -) - -const ( - fileName = ".ory-cloud.json" - ConfigFlag = "config" - osEnvVar = "ORY_CLOUD_CONFIG_PATH" - Version = "v0alpha0" - yesFlag = "yes" -) - -func RegisterConfigFlag(f *pflag.FlagSet) { - f.StringP(ConfigFlag, ConfigFlag[:1], "", "Path to the Ory Network configuration file.") -} - -func RegisterYesFlag(f *pflag.FlagSet) { - f.BoolP(yesFlag, yesFlag[:1], false, "Confirm all dialogs with yes.") -} - -type AuthContext struct { - Version string `json:"version"` - SessionToken string `json:"session_token"` - SelectedProject uuid.UUID `json:"selected_project"` - IdentityTraits AuthIdentity `json:"session_identity_traits"` -} - -func (i *AuthContext) ID() string { - return i.IdentityTraits.ID.String() -} - -func (*AuthContext) Header() []string { - return []string{"ID", "EMAIL", "SELECTED_PROJECT"} -} - -func (i *AuthContext) Columns() []string { - return []string{ - i.ID(), - i.IdentityTraits.Email, - i.SelectedProject.String(), - } -} - -func (i *AuthContext) Interface() interface{} { - return i -} - -type AuthIdentity struct { - ID uuid.UUID - Email string `json:"email"` -} - -type AuthProject struct { - ID uuid.UUID `json:"id"` - Slug string `json:"slug"` -} - -var ErrNoConfig = stderrs.New("no ory configuration file present") -var ErrNoConfigQuiet = stderrs.New("please run `ory auth` to initialize your configuration or remove the `--quiet` flag") - -func getConfigPath(cmd *cobra.Command) (string, error) { - path, err := os.UserHomeDir() - if err != nil { - return "", errors.Wrapf(err, "unable to guess your home directory") - } - - return stringsx.Coalesce( - os.Getenv(osEnvVar), - flagx.MustGetString(cmd, ConfigFlag), - filepath.Join(path, fileName), - ), nil -} - -type CommandHelper struct { - Ctx context.Context - VerboseWriter io.Writer - VerboseErrWriter io.Writer - ConfigLocation string - NoConfirm bool - IsQuiet bool - APIDomain *url.URL - Stdin *bufio.Reader - PwReader passwordReader -} - -type PasswordReader struct{} - -// NewCommandHelper creates a new CommandHelper instance which handles cobra CLI commands. -func NewCommandHelper(cmd *cobra.Command) (*CommandHelper, error) { - location, err := getConfigPath(cmd) - if err != nil { - return nil, err - } - - var out = cmd.OutOrStdout() - if flagx.MustGetBool(cmd, cmdx.FlagQuiet) { - out = io.Discard - } - - var outErr = cmd.ErrOrStderr() - if flagx.MustGetBool(cmd, cmdx.FlagQuiet) { - outErr = io.Discard - } - - pwReader := func() ([]byte, error) { - return term.ReadPassword(int(os.Stdin.Fd())) - } - if p, ok := cmd.Context().Value(PasswordReader{}).(passwordReader); ok { - pwReader = p - } - - return &CommandHelper{ - ConfigLocation: location, - NoConfirm: flagx.MustGetBool(cmd, yesFlag), - IsQuiet: flagx.MustGetBool(cmd, cmdx.FlagQuiet), - VerboseWriter: out, - VerboseErrWriter: outErr, - Stdin: bufio.NewReader(cmd.InOrStdin()), - Ctx: cmd.Context(), - PwReader: pwReader, - }, nil -} - -func (h *CommandHelper) GetDefaultProjectID() string { - conf, err := h.readConfig() - if err != nil { - return "" - } - - if conf.SelectedProject != uuid.Nil { - return conf.SelectedProject.String() - } - - return "" -} - -func (h *CommandHelper) SetDefaultProject(id string) error { - conf, err := h.readConfig() - if err != nil { - return err - } - - uid, err := uuid.FromString(id) - if err != nil { - return err - } - - conf.SelectedProject = uid - return h.WriteConfig(conf) -} - -func (h *CommandHelper) WriteConfig(c *AuthContext) error { - c.Version = Version - file, err := os.OpenFile(h.ConfigLocation, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600) - if err != nil { - return errors.Wrapf(err, "unable to open file for writing at location: %s", file.Name()) - } - defer file.Close() - - if err := json.NewEncoder(file).Encode(c); err != nil { - return errors.Wrapf(err, "unable to write configuration to file: %s", h.ConfigLocation) - } - - return nil -} - -func (h *CommandHelper) readConfig() (*AuthContext, error) { - contents, err := os.ReadFile(h.ConfigLocation) - if err != nil { - if errors.Is(err, fs.ErrNotExist) { - return new(AuthContext), ErrNoConfig - } - return nil, errors.Wrapf(err, "unable to open ory config file location: %s", h.ConfigLocation) - } - - var c AuthContext - if err := json.Unmarshal(contents, &c); err != nil { - return nil, errors.Wrapf(err, "unable to JSON decode the ory config file: %s", h.ConfigLocation) - } - - return &c, nil -} - -func (h *CommandHelper) HasValidContext() (*AuthContext, bool, error) { - c, err := h.readConfig() - if err != nil { - if errors.Is(err, ErrNoConfig) { - if h.IsQuiet { - return nil, false, errors.WithStack(ErrNoConfigQuiet) - } - // No context - return nil, false, nil - } - - return nil, false, err - } - - if len(c.SessionToken) > 0 { - client, err := NewKratosClient() - if err != nil { - return nil, false, err - } - - sess, _, err := client.FrontendApi.ToSession(h.Ctx).XSessionToken(c.SessionToken).Execute() - if err != nil { - if h.IsQuiet { - return nil, false, errors.WithStack(ErrNoConfigQuiet) - } - return nil, false, nil - } else if sess == nil { - if h.IsQuiet { - return nil, false, errors.WithStack(ErrNoConfigQuiet) - } - return nil, false, nil - } - return c, true, nil - } - - return nil, false, nil -} - -func (h *CommandHelper) EnsureContext() (*AuthContext, error) { - c, valid, err := h.HasValidContext() - if err != nil { - return nil, err - } else if valid { - return c, nil - } - - // No valid session, but also quiet mode -> failure! - if h.IsQuiet { - return nil, errors.WithStack(ErrNoConfigQuiet) - } - - // Not valid, but we have a session -> tell the user we need to re-authenticate - _, _ = fmt.Fprintf(h.VerboseErrWriter, "Your session has expired or has otherwise become invalid. Please re-authenticate to continue.\n") - - if err := h.SignOut(); err != nil { - return nil, err - } - - c, err = h.Authenticate() - if err != nil { - return nil, err - } - - return c, nil -} - -func (h *CommandHelper) getField(i interface{}, path string) (*gjson.Result, error) { - var b bytes.Buffer - if err := json.NewEncoder(&b).Encode(i); err != nil { - return nil, err - } - result := gjson.GetBytes(b.Bytes(), path) - return &result, nil -} - -func (h *CommandHelper) signup(c *oldCloud.APIClient) (*AuthContext, error) { - flow, _, err := c.FrontendApi.CreateNativeRegistrationFlow(h.Ctx).Execute() - if err != nil { - return nil, err - } - - var isRetry bool -retryRegistration: - if isRetry { - _, _ = fmt.Fprintf(h.VerboseErrWriter, "\nYour account creation attempt failed. Please try again!\n\n") - } - isRetry = true - - var form oldCloud.UpdateRegistrationFlowWithPasswordMethod - if err := renderForm(h.Stdin, h.PwReader, h.VerboseErrWriter, flow.Ui, "password", &form); err != nil { - return nil, err - } - - signup, _, err := c.FrontendApi. - UpdateRegistrationFlow(h.Ctx). - Flow(flow.Id). - UpdateRegistrationFlowBody(oldCloud.UpdateRegistrationFlowBody{UpdateRegistrationFlowWithPasswordMethod: &form}). - Execute() - if err != nil { - if e, ok := err.(*oldCloud.GenericOpenAPIError); ok { - switch m := e.Model().(type) { - case *oldCloud.RegistrationFlow: - flow = m - goto retryRegistration - case oldCloud.RegistrationFlow: - flow = &m - goto retryRegistration - } - } - - return nil, errors.WithStack(err) - } - - sessionToken := *signup.SessionToken - sess, _, err := c.FrontendApi.ToSession(h.Ctx).XSessionToken(sessionToken).Execute() - if err != nil { - return nil, err - } - - return h.sessionToContext(sess, sessionToken) -} - -func (h *CommandHelper) signin(c *oldCloud.APIClient, sessionToken string) (*AuthContext, error) { - req := c.FrontendApi.CreateNativeLoginFlow(h.Ctx) - if len(sessionToken) > 0 { - req = req.XSessionToken(sessionToken).Aal("aal2") - } - - flow, _, err := req.Execute() - if err != nil { - return nil, err - } - - var isRetry bool -retryLogin: - if isRetry { - _, _ = fmt.Fprintf(h.VerboseErrWriter, "\nYour sign in attempt failed. Please try again!\n\n") - } - isRetry = true - - var form interface{} = &oldCloud.UpdateLoginFlowWithPasswordMethod{} - method := "password" - if len(sessionToken) > 0 { - var foundTOTP bool - var foundLookup bool - for _, n := range flow.Ui.Nodes { - if n.Group == "totp" { - foundTOTP = true - } else if n.Group == "lookup_secret" { - foundLookup = true - } - } - if !foundLookup && !foundTOTP { - return nil, errors.New("only TOTP and lookup secrets are supported for two-step verification in the CLI") - } - - method = "lookup_secret" - if foundTOTP { - form = &oldCloud.UpdateLoginFlowWithTotpMethod{} - method = "totp" - } - } - - if err := renderForm(h.Stdin, h.PwReader, h.VerboseErrWriter, flow.Ui, method, form); err != nil { - return nil, err - } - - var body oldCloud.UpdateLoginFlowBody - switch e := form.(type) { - case *oldCloud.UpdateLoginFlowWithTotpMethod: - body.UpdateLoginFlowWithTotpMethod = e - case *oldCloud.UpdateLoginFlowWithPasswordMethod: - body.UpdateLoginFlowWithPasswordMethod = e - default: - panic("unexpected type") - } - - login, _, err := c.FrontendApi.UpdateLoginFlow(h.Ctx).XSessionToken(sessionToken). - Flow(flow.Id).UpdateLoginFlowBody(body).Execute() - if err != nil { - if e, ok := err.(*oldCloud.GenericOpenAPIError); ok { - switch m := e.Model().(type) { - case *oldCloud.LoginFlow: - flow = m - goto retryLogin - case oldCloud.LoginFlow: - flow = &m - goto retryLogin - } - } - - return nil, errors.WithStack(err) - } - - sessionToken = stringsx.Coalesce(*login.SessionToken, sessionToken) - sess, _, err := c.FrontendApi.ToSession(h.Ctx).XSessionToken(sessionToken).Execute() - if err == nil { - return h.sessionToContext(sess, sessionToken) - } - - if e, ok := err.(interface{ Body() []byte }); ok { - switch gjson.GetBytes(e.Body(), "error.id").String() { - case "session_aal2_required": - return h.signin(c, sessionToken) - } - } - return nil, err -} - -func (h *CommandHelper) sessionToContext(session *oldCloud.Session, token string) (*AuthContext, error) { - email, err := h.getField(session.Identity.Traits, "email") - if err != nil { - return nil, err - } - - return &AuthContext{ - Version: Version, - SessionToken: token, - IdentityTraits: AuthIdentity{ - Email: email.String(), - ID: uuid.FromStringOrNil(session.Identity.Id), - }, - }, nil -} - -func (h *CommandHelper) Authenticate() (*AuthContext, error) { - if h.IsQuiet { - return nil, errors.New("can not sign in or sign up when flag --quiet is set") - } - - ac, err := h.readConfig() - if err != nil { - if !errors.Is(err, ErrNoConfig) { - return nil, err - } - } - - if len(ac.SessionToken) > 0 { - if !h.NoConfirm { - ok, err := cmdx.AskScannerForConfirmation(fmt.Sprintf("You are signed in as \"%s\" already. Do you wish to authenticate with another account?", ac.IdentityTraits.Email), h.Stdin, h.VerboseErrWriter) - if err != nil { - return nil, err - } else if !ok { - return ac, nil - } - _, _ = fmt.Fprintf(h.VerboseErrWriter, "Ok, signing you out!\n") - } - - if err := h.SignOut(); err != nil { - return nil, err - } - } - - c, err := NewKratosClient() - if err != nil { - return nil, err - } - - signIn, err := cmdx.AskScannerForConfirmation("Do you want to sign in to an existing Ory Network account?", h.Stdin, h.VerboseErrWriter) - if err != nil { - return nil, err - } - - if signIn { - ac, err = h.signin(c, "") - if err != nil { - return nil, err - } - } else { - _, _ = fmt.Fprintln(h.VerboseErrWriter, "Great to have you! Let's create a new Ory Network account. Select the Enter key to start the account creation wizard.") - - ac, err = h.signup(c) - if err != nil { - return nil, err - } - } - - if err := h.WriteConfig(ac); err != nil { - return nil, err - } - - _, _ = fmt.Fprintf(h.VerboseErrWriter, "You are now signed in as: %s\n", ac.IdentityTraits.Email) - - if len(ac.SessionToken) == 0 { - return nil, errors.Errorf("unable to authenticate") - } - - return ac, nil -} - -func (h *CommandHelper) SignOut() error { - return h.WriteConfig(new(AuthContext)) -} - -func (h *CommandHelper) ListProjects() ([]cloud.ProjectMetadata, error) { - ac, err := h.EnsureContext() - if err != nil { - return nil, err - } - - c, err := newCloudClient(ac.SessionToken) - if err != nil { - return nil, err - } - - projects, res, err := c.ProjectAPI.ListProjects(h.Ctx).Execute() - if err != nil { - return nil, handleError("unable to list projects", res, err) - } - - return projects, nil -} - -func (h *CommandHelper) CreateEventStream(projectID string, body cloud.CreateEventStreamBody) (*cloud.EventStream, error) { - ac, err := h.EnsureContext() - if err != nil { - return nil, err - } - - c, err := newCloudClient(ac.SessionToken) - if err != nil { - return nil, err - } - - stream, res, err := c.EventsAPI.CreateEventStream(h.Ctx, projectID).CreateEventStreamBody(body).Execute() - if err != nil { - return nil, handleError("unable to create event stream", res, err) - } - - return stream, nil -} - -func (h *CommandHelper) UpdateEventStream(projectID, streamID string, body cloud.SetEventStreamBody) (*cloud.EventStream, error) { - ac, err := h.EnsureContext() - if err != nil { - return nil, err - } - - c, err := newCloudClient(ac.SessionToken) - if err != nil { - return nil, err - } - - stream, res, err := c.EventsAPI.SetEventStream(h.Ctx, projectID, streamID).SetEventStreamBody(body).Execute() - if err != nil { - return nil, handleError("unable to update event stream", res, err) - } - - return stream, nil -} - -func (h *CommandHelper) DeleteEventStream(projectID, streamID string) error { - ac, err := h.EnsureContext() - if err != nil { - return err - } - - c, err := newCloudClient(ac.SessionToken) - if err != nil { - return err - } - - res, err := c.EventsAPI.DeleteEventStream(h.Ctx, projectID, streamID).Execute() - if err != nil { - return handleError("unable to delete event stream", res, err) - } - - return nil -} - -func (h *CommandHelper) ListEventStreams(projectID string) (*cloud.ListEventStreams, error) { - ac, err := h.EnsureContext() - if err != nil { - return nil, err - } - - c, err := newCloudClient(ac.SessionToken) - if err != nil { - return nil, err - } - - streams, res, err := c.EventsAPI.ListEventStreams(h.Ctx, projectID).Execute() - if err != nil { - return nil, handleError("unable to list event streams", res, err) - } - - return streams, nil -} - -func (h *CommandHelper) ListOrganizations(projectID string) (*cloud.ListOrganizationsResponse, error) { - ac, err := h.EnsureContext() - if err != nil { - return nil, err - } - - c, err := newCloudClient(ac.SessionToken) - if err != nil { - return nil, err - } - - organizations, res, err := c.ProjectAPI.ListOrganizations(h.Ctx, projectID).Execute() - if err != nil { - return nil, handleError("unable to list organizations", res, err) - } - - return organizations, nil -} - -func (h *CommandHelper) CreateOrganization(projectID string, body cloud.OrganizationBody) (*cloud.Organization, error) { - ac, err := h.EnsureContext() - if err != nil { - return nil, err - } - - c, err := newCloudClient(ac.SessionToken) - if err != nil { - return nil, err - } - - organization, res, err := c.ProjectAPI. - CreateOrganization(h.Ctx, projectID). - OrganizationBody(body). - Execute() - if err != nil { - return nil, handleError("unable to create organization", res, err) - } - - return organization, nil -} - -func (h *CommandHelper) UpdateOrganization(projectID, orgID string, body cloud.OrganizationBody) (*cloud.Organization, error) { - ac, err := h.EnsureContext() - if err != nil { - return nil, err - } - - c, err := newCloudClient(ac.SessionToken) - if err != nil { - return nil, err - } - - organization, res, err := c.ProjectAPI. - UpdateOrganization(h.Ctx, projectID, orgID). - OrganizationBody(body). - Execute() - if err != nil { - return nil, handleError("unable to update organization", res, err) - } - - return organization, nil -} - -func (h *CommandHelper) DeleteOrganization(projectID, orgID string) error { - ac, err := h.EnsureContext() - if err != nil { - return err - } - - c, err := newCloudClient(ac.SessionToken) - if err != nil { - return err - } - - res, err := c.ProjectAPI. - DeleteOrganization(h.Ctx, projectID, orgID). - Execute() - if err != nil { - return handleError("unable to create organization", res, err) - } - - return nil -} - -func (h *CommandHelper) GetProject(projectOrSlug string) (*cloud.Project, error) { - if projectOrSlug == "" { - return nil, errors.Errorf("No project selected! Please see the help message on how to set one.") - } - - ac, err := h.EnsureContext() - if err != nil { - return nil, err - } - - c, err := newCloudClient(ac.SessionToken) - if err != nil { - return nil, err - } - - id := uuid.FromStringOrNil(projectOrSlug) - if id == uuid.Nil { - pjs, err := h.ListProjects() - if err != nil { - return nil, err - } - - availableSlugs := make([]string, len(pjs)) - for i, pm := range pjs { - availableSlugs[i] = pm.GetSlug() - if strings.HasPrefix(pm.GetSlug(), projectOrSlug) { - if id != uuid.Nil { - return nil, errors.Errorf("The slug prefix %q is not unique, please use more characters. Found slugs:\n%s", projectOrSlug, strings.Join(availableSlugs, "\n")) - } - id = uuid.FromStringOrNil(pm.GetId()) - } - } - if id == uuid.Nil { - return nil, errors.Errorf("no project found with slug %s, only slugs known are: %v", projectOrSlug, availableSlugs) - } - } - - project, res, err := c.ProjectAPI.GetProject(h.Ctx, id.String()).Execute() - if err != nil { - return nil, handleError("unable to get project", res, err) - } - - return project, nil -} - -func (h *CommandHelper) CreateProject(name string, setDefault bool) (*cloud.Project, error) { - ac, err := h.EnsureContext() - if err != nil { - return nil, err - } - - c, err := newCloudClient(ac.SessionToken) - if err != nil { - return nil, err - } - - project, res, err := c.ProjectAPI.CreateProject(h.Ctx).CreateProjectBody(*cloud.NewCreateProjectBody(strings.TrimSpace(name))).Execute() - if err != nil { - return nil, handleError("unable to list projects", res, err) - } - - if def := h.GetDefaultProjectID(); setDefault || def == "" { - _ = h.SetDefaultProject(project.Id) - } - - return project, nil -} - -func handleError(message string, res *http.Response, err error) error { - if e, ok := err.(*cloud.GenericOpenAPIError); ok { - return errors.Wrapf(err, "%s: %s", message, e.Body()) - } - - if res == nil { - return errors.Wrapf(err, "%s", message) - } - - body, _ := io.ReadAll(res.Body) - return errors.Wrapf(err, "%s: %s", message, body) -} - -func toPatch(op string, values []string) (patches []cloud.JsonPatch, err error) { - for _, v := range values { - parts := strings.SplitN(v, "=", 2) - if len(parts) != 2 { - return nil, errors.Errorf("patches must be in format of `/some/config/key=some-value` but got: %s", v) - } else if !gjson.Valid(parts[1]) { - return nil, errors.Errorf("value for %s must be valid JSON but got: %s", parts[0], parts[1]) - } - - config, err := jsonx.EmbedSources(json.RawMessage(parts[1]), jsonx.WithIgnoreKeys("$id", "$schema"), jsonx.WithOnlySchemes("file")) - if err != nil { - return nil, errors.WithStack(err) - } - - var value interface{} - if err := json.Unmarshal(config, &value); err != nil { - return nil, errors.WithStack(err) - } - - patches = append(patches, cloud.JsonPatch{Op: op, Path: parts[0], Value: value}) - } - return patches, nil -} - -func (h *CommandHelper) PatchProject(id string, raw []json.RawMessage, add, replace, del []string) (*cloud.SuccessfulProjectUpdate, error) { - ac, err := h.EnsureContext() - if err != nil { - return nil, err - } - - c, err := newCloudClient(ac.SessionToken) - if err != nil { - return nil, err - } - - var patches []cloud.JsonPatch - for _, r := range raw { - config, err := jsonx.EmbedSources(r, jsonx.WithIgnoreKeys("$id", "$schema"), jsonx.WithOnlySchemes("file")) - if err != nil { - return nil, errors.WithStack(err) - } - - var p []cloud.JsonPatch - if err := json.NewDecoder(bytes.NewReader(config)).Decode(&p); err != nil { - return nil, errors.WithStack(err) - } - patches = append(patches, p...) - } - - if v, err := toPatch("add", add); err != nil { - return nil, err - } else { - //revive:disable indent-error-flow - patches = append(patches, v...) - } - - if v, err := toPatch("replace", replace); err != nil { - return nil, err - } else { - //revive:disable indent-error-flow - patches = append(patches, v...) - } - - for _, del := range del { - patches = append(patches, cloud.JsonPatch{Op: "remove", Path: del}) - } - - res, _, err := c.ProjectAPI.PatchProject(h.Ctx, id).JsonPatch(patches).Execute() - if err != nil { - return nil, err - } - - return res, nil -} - -func (h *CommandHelper) UpdateProject(id string, name string, configs []json.RawMessage) (*cloud.SuccessfulProjectUpdate, error) { - ac, err := h.EnsureContext() - if err != nil { - return nil, err - } - - c, err := newCloudClient(ac.SessionToken) - if err != nil { - return nil, err - } - - for k := range configs { - config, err := jsonx.EmbedSources( - configs[k], - jsonx.WithIgnoreKeys( - "$id", - "$schema", - ), - jsonx.WithOnlySchemes( - "file", - ), - ) - if err != nil { - return nil, err - } - configs[k] = config - } - - interim := make(map[string]interface{}) - for _, config := range configs { - var decoded map[string]interface{} - if err := json.Unmarshal(config, &decoded); err != nil { - return nil, errors.WithStack(err) - } - - if err := mergo.Merge(&interim, decoded, mergo.WithAppendSlice, mergo.WithOverride); err != nil { - return nil, errors.WithStack(err) - } - } - - if _, found := interim["cors_admin"]; !found { - interim["cors_admin"] = map[string]interface{}{} - } - if _, found := interim["cors_public"]; !found { - interim["cors_public"] = map[string]interface{}{} - } - if _, found := interim["name"]; !found { - interim["name"] = "" - } - - var payload cloud.SetProject - var b bytes.Buffer - if err := json.NewEncoder(&b).Encode(interim); err != nil { - return nil, errors.WithStack(err) - } - if err := json.NewDecoder(&b).Decode(&payload); err != nil { - return nil, errors.WithStack(err) - } - - if payload.Services.Identity == nil && payload.Services.Permission == nil && payload.Services.Oauth2 == nil { - return nil, errors.Errorf("at least one of the keys `services.identity.config` and `services.permission.config` and `services.oauth2.config` is required and can not be empty") - } - - if name != "" { - payload.Name = name - } else if payload.Name == "" { - res, _, err := c.ProjectAPI.GetProject(h.Ctx, id).Execute() - if err != nil { - return nil, errors.WithStack(err) - } - payload.Name = res.Name - } - - res, _, err := c.ProjectAPI.SetProject(h.Ctx, id).SetProject(payload).Execute() - if err != nil { - return nil, err - } - - return res, nil -} - -func (h *CommandHelper) CreateAPIKey(projectIdOrSlug, name string) (*cloud.ProjectApiKey, error) { - ac, err := h.EnsureContext() - if err != nil { - return nil, err - } - - c, err := newCloudClient(ac.SessionToken) - if err != nil { - return nil, err - } - - token, _, err := c.ProjectAPI.CreateProjectApiKey(h.Ctx, projectIdOrSlug).CreateProjectApiKeyRequest(cloud.CreateProjectApiKeyRequest{Name: name}).Execute() - if err != nil { - return nil, err - } - - return token, nil -} - -func (h *CommandHelper) DeleteAPIKey(projectIdOrSlug, id string) error { - ac, err := h.EnsureContext() - if err != nil { - return err - } - - c, err := newCloudClient(ac.SessionToken) - if err != nil { - return err - } - - if _, err := c.ProjectAPI.DeleteProjectApiKey(h.Ctx, projectIdOrSlug, id).Execute(); err != nil { - return err - } - - return nil -} diff --git a/cmd/cloudx/client/handler_test.go b/cmd/cloudx/client/handler_test.go deleted file mode 100644 index f896f857..00000000 --- a/cmd/cloudx/client/handler_test.go +++ /dev/null @@ -1,364 +0,0 @@ -// Copyright © 2023 Ory Corp -// SPDX-License-Identifier: Apache-2.0 - -package client_test - -import ( - "bufio" - "bytes" - "context" - _ "embed" - "encoding/json" - "io" - "testing" - - "github.com/ory/x/assertx" - "github.com/ory/x/snapshotx" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/ory/cli/cmd/cloudx/client" - "github.com/ory/cli/cmd/cloudx/testhelpers" - cloud "github.com/ory/client-go" -) - -//go:embed fixtures/update_project/config.json -var config json.RawMessage - -func TestMain(m *testing.M) { - testhelpers.RunAgainstStaging(m) -} - -func TestCommandHelper(t *testing.T) { - configDir := testhelpers.NewConfigDir(t) - email, password := testhelpers.RegisterAccount(t, configDir) - project := testhelpers.CreateProject(t, configDir) - - loggedIn := &client.CommandHelper{ - ConfigLocation: configDir, - NoConfirm: true, - IsQuiet: true, - VerboseWriter: io.Discard, - VerboseErrWriter: io.Discard, - Ctx: context.Background(), - } - assertValidProject := func(t *testing.T, actual *cloud.Project) { - assert.Equal(t, project, actual.Id) - assert.NotZero(t, actual.Slug) - assert.NotZero(t, actual.Services.Identity.Config) - assert.NotZero(t, actual.Services.Permission.Config) - } - reauth := func() *client.CommandHelper { - notYetLoggedIn := *loggedIn - notYetLoggedIn.ConfigLocation = testhelpers.NewConfigDir(t) - notYetLoggedIn.IsQuiet = false - notYetLoggedIn.PwReader = func() ([]byte, error) { - return []byte(password), nil - } - notYetLoggedIn.Stdin = bufio.NewReader(bytes.NewBufferString( - "y\n" + // Do you want to sign in to an existing Ory Network account? [y/n]: y - email + "\n")) // Email fakeEmail() - return ¬YetLoggedIn - } - - t.Run("func=SetDefaultProject", func(t *testing.T) { - t.Parallel() - configDir := testhelpers.NewConfigDir(t) - testhelpers.RegisterAccount(t, configDir) - otherId := testhelpers.CreateProject(t, configDir) - defaultId := testhelpers.CreateProject(t, configDir) - testhelpers.SetDefaultProject(t, configDir, defaultId) - - cmdBase := client.CommandHelper{ - ConfigLocation: configDir, - } - - t.Run("can change the selected project", func(t *testing.T) { - cmd := cmdBase - current := cmd.GetDefaultProjectID() - assert.Equal(t, current, defaultId) - - err := cmd.SetDefaultProject(otherId) - assert.NoError(t, err) - - selected := cmd.GetDefaultProjectID() - assert.Equal(t, selected, otherId) - }) - }) - - t.Run("func=ListProjects", func(t *testing.T) { - t.Parallel() - configDir := testhelpers.NewConfigDir(t) - testhelpers.RegisterAccount(t, configDir) - - cmdBase := client.CommandHelper{ - ConfigLocation: configDir, - } - - t.Run("With no projects returns empty list", func(t *testing.T) { - cmd := cmdBase - - projects, err := cmd.ListProjects() - - require.NoError(t, err) - require.Empty(t, projects) - }) - - t.Run("With some projects returns list of projects", func(t *testing.T) { - cmd := cmdBase - project_name1 := "new_project_name1" - project_name2 := "new_project_name2" - - project1, err := cmd.CreateProject(project_name1, false) - require.NoError(t, err) - project2, err := cmd.CreateProject(project_name2, false) - require.NoError(t, err) - - projects, err := cmd.ListProjects() - - require.NoError(t, err) - assert.Len(t, projects, 2) - assert.ElementsMatch(t, []string{projects[0].Id, projects[1].Id}, []string{project1.Id, project2.Id}) - }) - }) - - t.Run("func=CreateProject", func(t *testing.T) { - t.Parallel() - configDir := testhelpers.NewConfigDir(t) - testhelpers.RegisterAccount(t, configDir) - - cmdBase := client.CommandHelper{ - ConfigLocation: configDir, - } - - t.Run("creates project and sets default project", func(t *testing.T) { - cmd := cmdBase - project_name := "new_project_name" - - project, err := cmd.CreateProject(project_name, true) - require.NoError(t, err) - assert.Equal(t, project.Name, project_name) - - defaultId := cmd.GetDefaultProjectID() - assert.Equal(t, project.Id, defaultId) - }) - - t.Run("creates two projects with different names", func(t *testing.T) { - cmd := cmdBase - project_name1 := "new_project_name1" - project_name2 := "new_project_name2" - - project1, err := cmd.CreateProject(project_name1, true) - require.NoError(t, err) - - project2, err := cmd.CreateProject(project_name2, false) - require.NoError(t, err) - - assert.NotEqual(t, project1.Id, project2.Id) - assert.NotEqual(t, project1.Name, project2.Name) - assert.NotEqual(t, project1.Slug, project2.Slug) - - defaultId := cmd.GetDefaultProjectID() - assert.Equal(t, project1.Id, defaultId) - }) - }) - - t.Run("func=Authenticate", func(t *testing.T) { - t.Parallel() - cmdBase := client.CommandHelper{ - ConfigLocation: testhelpers.NewConfigDir(t), - NoConfirm: true, - IsQuiet: false, - VerboseWriter: io.Discard, - VerboseErrWriter: io.Discard, - } - - t.Run("create new account", func(t *testing.T) { - cmd := cmdBase - - name := testhelpers.FakeName() - email := testhelpers.FakeEmail() - password := testhelpers.FakePassword() - - r := testhelpers.RegistrationBuffer(name, email, password) - cmd.Stdin = bufio.NewReader(&r) - cmd.PwReader = func() ([]byte, error) { return []byte(password), nil } - authCtx, err := cmd.Authenticate() - - require.NoError(t, err) - require.NotNil(t, authCtx) - require.Equal(t, authCtx.IdentityTraits.Email, email) - }) - - t.Run("log into existing account", func(t *testing.T) { - cmd := cmdBase - - var r bytes.Buffer - _, _ = r.WriteString("y\n") // Do you want to sign in to an existing Ory Network account? [y/n]: y - _, _ = r.WriteString(email + "\n") // Email: FakeEmail() - cmd.Stdin = bufio.NewReader(&r) - - cmd.PwReader = func() ([]byte, error) { return []byte(password), nil } - - auth_ctx, err := cmd.Authenticate() - - require.NoError(t, err) - require.NotNil(t, auth_ctx) - require.Equal(t, auth_ctx.IdentityTraits.Email, email) - }) - - t.Run("retry login after wrong password", func(t *testing.T) { - cmd := cmdBase - - var r bytes.Buffer - _, _ = r.WriteString("y\n") // Do you want to sign in to an existing Ory Network account? [y/n]: y - _, _ = r.WriteString(email + "\n") // Email: FakeEmail() - _, _ = r.WriteString(email + "\n") // Email: FakeEmail() [RETRY] - cmd.Stdin = bufio.NewReader(&r) - - var retry = false - cmd.PwReader = func() ([]byte, error) { - if retry { - return []byte(password), nil - } - retry = true - return []byte("wrong"), nil - } - - auth_ctx, err := cmd.Authenticate() - - require.NoError(t, err) - require.NotNil(t, auth_ctx) - require.Equal(t, auth_ctx.IdentityTraits.Email, email) - }) - - t.Run("switch logged in account", func(t *testing.T) { - cmd := *loggedIn - - cmd.NoConfirm = false - cmd.IsQuiet = false - - var r bytes.Buffer - _, _ = r.WriteString("y\n") // You are signed in as \"%s\" already. Do you wish to authenticate with another account?: y - _, _ = r.WriteString("y\n") // Do you want to sign in to an existing Ory Network account? [y/n]: y - _, _ = r.WriteString(email + "\n") // Email: FakeEmail() - cmd.Stdin = bufio.NewReader(&r) - - cmd.PwReader = func() ([]byte, error) { return []byte(password), nil } - - auth_ctx, err := cmd.Authenticate() - - require.NoError(t, err) - require.NotNil(t, auth_ctx) - require.Equal(t, auth_ctx.IdentityTraits.Email, email) - }) - }) - - t.Run("func=CreateAPIKey and DeleteApiKey", func(t *testing.T) { - t.Run("is able to get project", func(t *testing.T) { - name := "a test key" - token, err := loggedIn.CreateAPIKey(project, name) - require.NoError(t, err) - assert.Equal(t, name, token.Name) - assert.NotEmpty(t, name, token.Value) - - require.NoError(t, loggedIn.DeleteAPIKey(project, token.Id)) - }) - }) - - t.Run("func=GetProject", func(t *testing.T) { - t.Run("is able to get project", func(t *testing.T) { - p, err := loggedIn.GetProject(project) - require.NoError(t, err) - assertValidProject(t, p) - - actual, err := loggedIn.GetProject(p.Slug[0:4]) - require.NoError(t, err) - assert.Equal(t, p, actual) - }) - - t.Run("is not able to list projects if not authenticated and quiet flag", func(t *testing.T) { - notLoggedIn := *loggedIn - notLoggedIn.ConfigLocation = testhelpers.NewConfigDir(t) - _, err := notLoggedIn.GetProject(project) - assert.ErrorIs(t, err, client.ErrNoConfigQuiet) - }) - - t.Run("is able to get project after authenticating", func(t *testing.T) { - notYetLoggedIn := reauth() - p, err := notYetLoggedIn.GetProject(project) - require.NoError(t, err) - assertValidProject(t, p) - }) - }) - - t.Run("func=UpdateProject", func(t *testing.T) { - t.Run("is able to update a project", func(t *testing.T) { - t.Skip("TODO") - - res, err := loggedIn.UpdateProject(project, "", []json.RawMessage{config}) - require.NoErrorf(t, err, "%+v", err) - - assertx.EqualAsJSONExcept(t, config, res.Project, []string{ - "id", - "revision_id", - "state", - "slug", - "services.identity.config.serve", - "services.identity.config.cookies", - "services.identity.config.identity.default_schema_id", - "services.identity.config.identity.schemas", - "services.identity.config.session.cookie", - "services.identity.config.selfservice.allowed_return_urls.0", - "services.oauth2.config.urls.self", - "services.oauth2.config.serve.public.tls", - "services.oauth2.config.serve.tls", - "services.oauth2.config.serve.admin.tls", - "services.oauth2.config.serve.cookies.domain", - "services.oauth2.config.serve.cookies.names", - "services.oauth2.config.oauth2.session.encrypt_at_rest", - "services.oauth2.config.oauth2.expose_internal_errors", - "services.oauth2.config.oauth2.hashers", - "services.oauth2.config.hsm", - "services.oauth2.config.clients", - "services.oauth2.config.oauth2.session", - }) - - snapshotx.SnapshotT(t, res, snapshotx.ExceptPaths( - "project.id", - "project.revision_id", - "project.slug", - "project.services.identity.config.serve.public.base_url", - "project.services.identity.config.serve.admin.base_url", - "project.services.identity.config.session.cookie.domain", - "project.services.identity.config.session.cookie.name", - "project.services.identity.config.cookies.domain", - "project.services.identity.config.selfservice.allowed_return_urls.0", - "project.services.oauth2.config.urls.self", - "project.services.oauth2.config.serve.cookies.domain", - "project.services.oauth2.config.serve.cookies.names", - "project.services.identity.config.identity.schemas.1.url", // bucket changes locally vs staging - )) - }) - - t.Run("is able to update a projects name", func(t *testing.T) { - name := testhelpers.FakeName() - res, err := loggedIn.UpdateProject(project, name, []json.RawMessage{config}) - require.NoError(t, err) - assert.Equal(t, name, res.Project.Name) - }) - - t.Run("is able to update a project after authenticating", func(t *testing.T) { - notYetLoggedIn := reauth() - res, err := notYetLoggedIn.UpdateProject(project, "", []json.RawMessage{config}) - require.NoError(t, err) - assertValidProject(t, &res.Project) - - for _, w := range res.Warnings { - t.Logf("Warning: %s", *w.Message) - } - assert.Len(t, res.Warnings, 1) - }) - }) -} diff --git a/cmd/cloudx/client/iohelpers.go b/cmd/cloudx/client/iohelpers.go index 3ea0cc8b..2e5112ce 100644 --- a/cmd/cloudx/client/iohelpers.go +++ b/cmd/cloudx/client/iohelpers.go @@ -6,44 +6,45 @@ package client import ( "bytes" "encoding/json" + "fmt" "path/filepath" "github.com/ghodss/yaml" - "github.com/pkg/errors" "github.com/ory/x/osx" "github.com/ory/x/stringsx" ) -func ReadConfigFiles(files []string) ([]json.RawMessage, error) { - var configs []json.RawMessage +// ReadAndParseFiles reads and parses JSON/YAML files from the given sources. +func ReadAndParseFiles(files []string) ([]json.RawMessage, error) { + var fileContents []json.RawMessage for _, source := range files { - config, err := readConfigFile(source) + config, err := readAndParseFile(source) if err != nil { return nil, err } - configs = append(configs, config) + fileContents = append(fileContents, config) } - return configs, nil + return fileContents, nil } -func readConfigFile(source string) (json.RawMessage, error) { +func readAndParseFile(source string) (json.RawMessage, error) { contents, err := osx.ReadFileFromAllSources(source, osx.WithEnabledBase64Loader(), osx.WithEnabledHTTPLoader(), osx.WithEnabledFileLoader()) if err != nil { - return nil, errors.Wrapf(err, "failed to read file: %s", source) + return nil, fmt.Errorf("failed to read file %q: %w", source, err) } switch f := stringsx.SwitchExact(filepath.Ext(source)); { case f.AddCase(".yaml"), f.AddCase(".yml"): var config json.RawMessage if err := yaml.Unmarshal(contents, &config); err != nil { - return nil, errors.Wrapf(err, "failed to parse YAML file: %s", source) + return nil, fmt.Errorf("failed to parse YAML file %q: %w", source, err) } return config, nil case f.AddCase(".json"): var config json.RawMessage if err := json.NewDecoder(bytes.NewReader(contents)).Decode(&config); err != nil { - return nil, errors.Wrapf(err, "failed to parse file `%s` from JSON", source) + return nil, fmt.Errorf("failed to parse JSON file %q: %w", source, err) } return config, nil default: diff --git a/cmd/cloudx/client/iohelpers_test.go b/cmd/cloudx/client/iohelpers_test.go index 711f8429..a568c1dd 100644 --- a/cmd/cloudx/client/iohelpers_test.go +++ b/cmd/cloudx/client/iohelpers_test.go @@ -13,7 +13,7 @@ import ( ) func TestReadConfigFiles(t *testing.T) { - configs, err := ReadConfigFiles([]string{ + configs, err := ReadAndParseFiles([]string{ "fixtures/iohelpers/a.yaml", "fixtures/iohelpers/b.yml", "fixtures/iohelpers/c.json", diff --git a/cmd/cloudx/client/organization.go b/cmd/cloudx/client/organization.go new file mode 100644 index 00000000..1ed1536f --- /dev/null +++ b/cmd/cloudx/client/organization.go @@ -0,0 +1,74 @@ +// Copyright © 2024 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package client + +import ( + "context" + + cloud "github.com/ory/client-go" +) + +func (h *CommandHelper) ListOrganizations(ctx context.Context, projectID string) (*cloud.ListOrganizationsResponse, error) { + c, err := h.newCloudClient(ctx) + if err != nil { + return nil, err + } + + organizations, res, err := c.ProjectAPI.ListOrganizations(ctx, projectID).Execute() + if err != nil { + return nil, handleError("unable to list organizations", res, err) + } + + return organizations, nil +} + +func (h *CommandHelper) CreateOrganization(ctx context.Context, projectID string, body cloud.OrganizationBody) (*cloud.Organization, error) { + c, err := h.newCloudClient(ctx) + if err != nil { + return nil, err + } + + organization, res, err := c.ProjectAPI. + CreateOrganization(ctx, projectID). + OrganizationBody(body). + Execute() + if err != nil { + return nil, handleError("unable to create organization", res, err) + } + + return organization, nil +} + +func (h *CommandHelper) UpdateOrganization(ctx context.Context, projectID, orgID string, body cloud.OrganizationBody) (*cloud.Organization, error) { + c, err := h.newCloudClient(ctx) + if err != nil { + return nil, err + } + + organization, res, err := c.ProjectAPI. + UpdateOrganization(ctx, projectID, orgID). + OrganizationBody(body). + Execute() + if err != nil { + return nil, handleError("unable to update organization", res, err) + } + + return organization, nil +} + +func (h *CommandHelper) DeleteOrganization(ctx context.Context, projectID, orgID string) error { + c, err := h.newCloudClient(ctx) + if err != nil { + return err + } + + res, err := c.ProjectAPI. + DeleteOrganization(ctx, projectID, orgID). + Execute() + if err != nil { + return handleError("unable to delete organization", res, err) + } + + return nil +} diff --git a/cmd/cloudx/client/print.go b/cmd/cloudx/client/print.go index 548590d3..c9636239 100644 --- a/cmd/cloudx/client/print.go +++ b/cmd/cloudx/client/print.go @@ -2,23 +2,3 @@ // SPDX-License-Identifier: Apache-2.0 package client - -import ( - "fmt" - - "github.com/ory/client-go" -) - -func (h *CommandHelper) PrintUpdateProjectWarnings(p *client.SuccessfulProjectUpdate) error { - if len(p.Warnings) > 0 { - _, _ = fmt.Fprintln(h.VerboseErrWriter) - _, _ = fmt.Fprintln(h.VerboseErrWriter, "Warnings were found.") - for _, warning := range p.Warnings { - _, _ = fmt.Fprintf(h.VerboseErrWriter, "- %s\n", *warning.Message) - } - _, _ = fmt.Fprintln(h.VerboseErrWriter, "It is safe to ignore these warnings unless your intention was to set these keys.") - } - - _, _ = fmt.Fprintf(h.VerboseErrWriter, "\nProject updated successfully!\n") - return nil -} diff --git a/cmd/cloudx/client/project.go b/cmd/cloudx/client/project.go new file mode 100644 index 00000000..64230d79 --- /dev/null +++ b/cmd/cloudx/client/project.go @@ -0,0 +1,281 @@ +// Copyright © 2024 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package client + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + + "github.com/gofrs/uuid" + "github.com/imdario/mergo" + "github.com/pkg/errors" + + "github.com/ory/client-go" + "github.com/ory/x/jsonx" +) + +func (h *CommandHelper) ListProjects(ctx context.Context, workspace *string) ([]client.ProjectMetadata, error) { + c, err := h.newCloudClient(ctx) + if err != nil { + return nil, err + } + + var projects []client.ProjectMetadata + if workspace != nil { + list, res, err := c.WorkspaceAPI.ListWorkspaceProjects(ctx, *workspace).Execute() + if err != nil { + return nil, handleError("unable to list workspace projects"+*workspace, res, err) + } + projects = list.Projects + } else { + var res *http.Response + projects, res, err = c.ProjectAPI.ListProjects(ctx).Execute() + if err != nil { + return nil, handleError("unable to list projects", res, err) + } + } + + return projects, nil +} + +func (h *CommandHelper) GetSelectedProject(ctx context.Context) (*client.Project, error) { + id, err := h.ProjectID() + if err != nil { + return nil, err + } + + c, err := h.newCloudClient(ctx) + if err != nil { + return nil, err + } + + project, res, err := c.ProjectAPI.GetProject(ctx, id).Execute() + if err != nil { + return nil, handleError("unable to get project", res, err) + } + + return project, nil +} + +func (h *CommandHelper) GetProject(ctx context.Context, idOrSlug string, workspace *string) (*client.Project, error) { + if idOrSlug == "" { + return nil, errors.Errorf("No project selected! Please see the help message on how to set one.") + } + + id, err := uuid.FromString(idOrSlug) + if err != nil { + projectMeta, err := h.findProject(ctx, idOrSlug, workspace) + if err != nil { + return nil, err + } + id = uuid.FromStringOrNil(projectMeta.GetId()) + } + + c, err := h.newCloudClient(ctx) + if err != nil { + return nil, err + } + + project, res, err := c.ProjectAPI.GetProject(ctx, id.String()).Execute() + if err != nil { + return nil, handleError("unable to get project", res, err) + } + + return project, nil +} + +func (h *CommandHelper) findProject(ctx context.Context, semiIdentifier string, workspace *string) (project *client.ProjectMetadata, _ error) { + pjs, err := h.ListProjects(ctx, workspace) + if err != nil { + return nil, err + } + + candidateSlugs := make([]string, 0, len(pjs)) + candidateIDs := make([]string, 0, len(pjs)) + allSlugs := make([]string, 0, len(pjs)) + allIDs := make([]string, 0, len(pjs)) + for _, pj := range pjs { + allSlugs = append(allSlugs, pj.Slug) + allIDs = append(allIDs, pj.Id) + if strings.HasPrefix(pj.Slug, semiIdentifier) { + candidateSlugs = append(candidateSlugs, pj.Slug) + project = &pj + } + if strings.HasPrefix(pj.Id, semiIdentifier) { + candidateIDs = append(candidateIDs, pj.Id) + project = &pj + } + } + if len(candidateSlugs)+len(candidateIDs) > 1 { + return nil, errors.Errorf("The slug or ID prefix %q is not unique, please use more characters.\nMatching slugs: %v\nMatching IDs: %v", semiIdentifier, candidateSlugs, candidateIDs) + } + if project == nil { + return nil, errors.Errorf("No project found with slug or ID %s.\nAll known slugs: %v\nAll known IDs: %v", semiIdentifier, allSlugs, allIDs) + } + return project, nil +} + +func (h *CommandHelper) CreateProject(ctx context.Context, name, environment string, workspace *string, setDefault bool) (*client.Project, error) { + c, err := h.newCloudClient(ctx) + if err != nil { + return nil, err + } + + project, res, err := c.ProjectAPI.CreateProject(ctx).CreateProjectBody(client.CreateProjectBody{ + Name: strings.TrimSpace(name), + Environment: environment, + WorkspaceId: workspace, + }).Execute() + if err != nil { + return nil, handleError("unable to list projects", res, err) + } + + if setDefault || h.projectID == nil { + if err := h.SelectProject(project.Id); err != nil { + return nil, fmt.Errorf("project created successfully, but could not select it: %w", err) + } + } + + return project, nil +} + +func (h *CommandHelper) PatchProject(ctx context.Context, id string, raw []json.RawMessage, add, replace, del []string) (*client.SuccessfulProjectUpdate, error) { + c, err := h.newCloudClient(ctx) + if err != nil { + return nil, err + } + + var patches []client.JsonPatch + for _, r := range raw { + config, err := jsonx.EmbedSources(r, jsonx.WithIgnoreKeys("$id", "$schema"), jsonx.WithOnlySchemes("file")) + if err != nil { + return nil, errors.WithStack(err) + } + + var p []client.JsonPatch + if err := json.NewDecoder(bytes.NewReader(config)).Decode(&p); err != nil { + return nil, errors.WithStack(err) + } + patches = append(patches, p...) + } + + if v, err := toPatch("add", add); err != nil { + return nil, err + } else { + //revive:disable indent-error-flow + patches = append(patches, v...) + } + + if v, err := toPatch("replace", replace); err != nil { + return nil, err + } else { + //revive:disable indent-error-flow + patches = append(patches, v...) + } + + for _, del := range del { + patches = append(patches, client.JsonPatch{Op: "remove", Path: del}) + } + + res, _, err := c.ProjectAPI.PatchProject(ctx, id).JsonPatch(patches).Execute() + if err != nil { + return nil, err + } + + return res, nil +} + +func (h *CommandHelper) UpdateProject(ctx context.Context, id string, name string, configs []json.RawMessage) (*client.SuccessfulProjectUpdate, error) { + c, err := h.newCloudClient(ctx) + if err != nil { + return nil, err + } + + for k := range configs { + config, err := jsonx.EmbedSources( + configs[k], + jsonx.WithIgnoreKeys( + "$id", + "$schema", + ), + jsonx.WithOnlySchemes( + "file", + ), + ) + if err != nil { + return nil, err + } + configs[k] = config + } + + interim := make(map[string]interface{}) + for _, config := range configs { + var decoded map[string]interface{} + if err := json.Unmarshal(config, &decoded); err != nil { + return nil, errors.WithStack(err) + } + + if err := mergo.Merge(&interim, decoded, mergo.WithAppendSlice, mergo.WithOverride); err != nil { + return nil, errors.WithStack(err) + } + } + + if _, found := interim["cors_admin"]; !found { + interim["cors_admin"] = map[string]interface{}{} + } + if _, found := interim["cors_public"]; !found { + interim["cors_public"] = map[string]interface{}{} + } + if _, found := interim["name"]; !found { + interim["name"] = "" + } + + var payload client.SetProject + var b bytes.Buffer + if err := json.NewEncoder(&b).Encode(interim); err != nil { + return nil, errors.WithStack(err) + } + if err := json.NewDecoder(&b).Decode(&payload); err != nil { + return nil, errors.WithStack(err) + } + + if payload.Services.Identity == nil && payload.Services.Permission == nil && payload.Services.Oauth2 == nil { + return nil, errors.Errorf("at least one of the keys `services.identity.config` and `services.permission.config` and `services.oauth2.config` is required and can not be empty") + } + + if name != "" { + payload.Name = name + } else if payload.Name == "" { + res, _, err := c.ProjectAPI.GetProject(ctx, id).Execute() + if err != nil { + return nil, errors.WithStack(err) + } + payload.Name = res.Name + } + + res, _, err := c.ProjectAPI.SetProject(ctx, id).SetProject(payload).Execute() + if err != nil { + return nil, errors.WithStack(err) + } + + return res, nil +} + +func (h *CommandHelper) PrintUpdateProjectWarnings(p *client.SuccessfulProjectUpdate) error { + if len(p.Warnings) > 0 { + _, _ = fmt.Fprintln(h.VerboseErrWriter) + _, _ = fmt.Fprintln(h.VerboseErrWriter, "Warnings were found.") + for _, warning := range p.Warnings { + _, _ = fmt.Fprintf(h.VerboseErrWriter, "- %s\n", *warning.Message) + } + _, _ = fmt.Fprintln(h.VerboseErrWriter, "It is safe to ignore these warnings unless your intention was to set these keys.") + } + + _, _ = fmt.Fprintf(h.VerboseErrWriter, "\nProject updated successfully!\n") + return nil +} diff --git a/cmd/cloudx/client/sdks.go b/cmd/cloudx/client/sdks.go index c785eb4e..87731639 100644 --- a/cmd/cloudx/client/sdks.go +++ b/cmd/cloudx/client/sdks.go @@ -4,75 +4,75 @@ package client import ( + "context" "net/http" "net/url" "os" "time" cloud "github.com/ory/client-go" - oldCloud "github.com/ory/client-go/114" "github.com/ory/x/stringsx" ) -var RateLimitHeader = os.Getenv("ORY_RATE_LIMIT_HEADER") +const ( + RateLimitHeaderKey = "ORY_RATE_LIMIT_HEADER" + ConsoleURLKey = "ORY_CONSOLE_URL" + OryAPIsURLKey = "ORY_ORYAPIS_URL" +) + +var rateLimitHeader = os.Getenv(RateLimitHeaderKey) -func CloudConsoleURL(prefix string) *url.URL { - u, err := url.ParseRequestURI(stringsx.Coalesce(os.Getenv("ORY_CLOUD_CONSOLE_URL"), "https://console.ory.sh")) +func cloudConsoleURL(prefix string) *url.URL { + // we load the URL from the env here instead of init() because the tests might want to change this + consoleURL, err := url.ParseRequestURI(stringsx.Coalesce(os.Getenv(ConsoleURLKey), "https://console.ory.sh")) if err != nil { - u = &url.URL{Scheme: "https", Host: "console.ory.sh"} + consoleURL = &url.URL{Scheme: "https", Host: "console.ory.sh"} } - u.Host = prefix + "." + u.Host - if u.Port() == "" { - u.Host = u.Host + ":443" + consoleURL.Host = prefix + "." + consoleURL.Host + if consoleURL.Port() == "" { + consoleURL.Host = consoleURL.Host + ":443" } - return u -} - -func makeCloudConsoleURL(prefix string) string { - u := CloudConsoleURL(prefix) - - return u.Scheme + "://" + u.Host + return consoleURL } -func CloudAPIsURL(prefix string) *url.URL { - u, err := url.ParseRequestURI(stringsx.Coalesce(os.Getenv("ORY_CLOUD_ORYAPIS_URL"), "https://oryapis.com")) +func CloudAPIsURL(slug string) *url.URL { + // we load the URL from the env here instead of init() because the tests might want to change this + oryAPIsURL, err := url.ParseRequestURI(stringsx.Coalesce(os.Getenv(OryAPIsURLKey), "https://projects.oryapis.com")) if err != nil { - u = &url.URL{Scheme: "https", Host: "oryapis.com"} + oryAPIsURL = &url.URL{Scheme: "https", Host: "projects.oryapis.com"} } - u.Host = prefix + "." + u.Host - if u.Port() == "" { - u.Host = u.Host + ":443" + oryAPIsURL.Host = slug + "." + oryAPIsURL.Host + if oryAPIsURL.Port() == "" { + oryAPIsURL.Host = oryAPIsURL.Host + ":443" } - return u -} - -func makeCloudAPIsURL(prefix string) string { - u := CloudAPIsURL(prefix) - - return u.Scheme + "://" + u.Host + return oryAPIsURL } -func NewKratosClient() (*oldCloud.APIClient, error) { - conf := oldCloud.NewConfiguration() - conf.Servers = oldCloud.ServerConfigurations{{URL: makeCloudConsoleURL("project")}} +func NewOryProjectClient() (*cloud.APIClient, error) { + conf := cloud.NewConfiguration() + conf.Servers = cloud.ServerConfigurations{{URL: cloudConsoleURL("project").String()}} conf.HTTPClient = &http.Client{Timeout: time.Second * 30} - if RateLimitHeader != "" { - conf.AddDefaultHeader("Ory-RateLimit-Action", RateLimitHeader) + if rateLimitHeader != "" { + conf.AddDefaultHeader("Ory-RateLimit-Action", rateLimitHeader) } - return oldCloud.NewAPIClient(conf), nil + return cloud.NewAPIClient(conf), nil } -func newCloudClient(token string) (*cloud.APIClient, error) { - u := makeCloudConsoleURL("api") +func (h *CommandHelper) newCloudClient(ctx context.Context) (*cloud.APIClient, error) { + config, err := h.GetAuthenticatedConfig(ctx) + if err != nil { + return nil, err + } conf := cloud.NewConfiguration() - conf.Servers = cloud.ServerConfigurations{{URL: u}} - conf.HTTPClient = newBearerTokenClient(token) - if RateLimitHeader != "" { - conf.AddDefaultHeader("Ory-RateLimit-Action", RateLimitHeader) + conf.OperationServers = nil + conf.Servers = cloud.ServerConfigurations{{URL: cloudConsoleURL("api").String()}} + conf.HTTPClient = newBearerTokenClient(config.SessionToken) + if rateLimitHeader != "" { + conf.AddDefaultHeader("Ory-RateLimit-Action", rateLimitHeader) } return cloud.NewAPIClient(conf), nil diff --git a/cmd/cloudx/client/tokens.go b/cmd/cloudx/client/tokens.go index fecbf6de..8af73252 100644 --- a/cmd/cloudx/client/tokens.go +++ b/cmd/cloudx/client/tokens.go @@ -5,11 +5,9 @@ package client import ( "os" - - "github.com/ory/x/stringsx" ) // GetProjectAPIKeyFromEnvironment returns the project API key from the environment variable. func GetProjectAPIKeyFromEnvironment() string { - return stringsx.Coalesce(os.Getenv("ORY_API_KEY"), os.Getenv("ORY_PERSONAL_ACCESS_TOKEN"), os.Getenv("ORY_PAT")) + return os.Getenv("ORY_API_KEY") } diff --git a/cmd/cloudx/client/workspace.go b/cmd/cloudx/client/workspace.go new file mode 100644 index 00000000..2eadb67f --- /dev/null +++ b/cmd/cloudx/client/workspace.go @@ -0,0 +1,69 @@ +// Copyright © 2024 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package client + +import ( + "context" + "strings" + + "github.com/pkg/errors" + + cloud "github.com/ory/client-go" +) + +func (h *CommandHelper) ListWorkspaces(ctx context.Context) ([]cloud.Workspace, error) { + c, err := h.newCloudClient(ctx) + if err != nil { + return nil, err + } + list, _, err := c.WorkspaceAPI.ListWorkspaces(ctx).Execute() + if err != nil { + return nil, err + } + return list.Workspaces, nil +} + +func (h *CommandHelper) findWorkspace(ctx context.Context, semiIdentifier string) (workspace *cloud.Workspace, _ error) { + wss, err := h.ListWorkspaces(ctx) + if err != nil { + return nil, err + } + + candidateNames := make([]string, 0, len(wss)) + candidateIDs := make([]string, 0, len(wss)) + allNames := make([]string, 0, len(wss)) + allIDs := make([]string, 0, len(wss)) + for _, ws := range wss { + allNames = append(allNames, ws.Name) + allIDs = append(allIDs, ws.Id) + if strings.HasPrefix(ws.Name, semiIdentifier) { + candidateNames = append(candidateNames, ws.Name) + workspace = &ws + } + if strings.HasPrefix(ws.Id, semiIdentifier) { + candidateIDs = append(candidateIDs, ws.Id) + workspace = &ws + } + } + if len(candidateNames)+len(candidateIDs) > 1 { + return nil, errors.Errorf("Found more than one workspace matching the identifier %q.\nMatching names: %v\nMatching IDs: %v", semiIdentifier, candidateNames, candidateIDs) + } + if workspace == nil { + return nil, errors.Errorf("No workspace found with the identifier %q.\nAll known names: %v\nAll known IDs: %v", semiIdentifier, allNames, allIDs) + } + return workspace, nil +} + +func (h *CommandHelper) CreateWorkspace(ctx context.Context, name string) (*cloud.Workspace, error) { + c, err := h.newCloudClient(ctx) + if err != nil { + return nil, err + } + + workspace, _, err := c.WorkspaceAPI.CreateWorkspace(ctx).CreateWorkspaceBody(cloud.CreateWorkspaceBody{Name: name}).Execute() + if err != nil { + return nil, err + } + return workspace, nil +} diff --git a/cmd/cloudx/create.go b/cmd/cloudx/create.go index 716e09db..aaeb51fc 100644 --- a/cmd/cloudx/create.go +++ b/cmd/cloudx/create.go @@ -6,6 +6,8 @@ package cloudx import ( "github.com/spf13/cobra" + "github.com/ory/cli/cmd/cloudx/workspace" + "github.com/ory/cli/cmd/cloudx/eventstreams" "github.com/ory/cli/cmd/cloudx/oauth2" "github.com/ory/cli/cmd/cloudx/organizations" @@ -28,6 +30,7 @@ func NewCreateCmd() *cobra.Command { oauth2.NewCreateJWK(), organizations.NewCreateOrganizationCmd(), eventstreams.NewCreateEventStreamCmd(), + workspace.NewCreateCmd(), ) client.RegisterConfigFlag(cmd.PersistentFlags()) diff --git a/cmd/cloudx/e2e/root.go b/cmd/cloudx/e2e/root.go index e12ab401..5de4ae00 100644 --- a/cmd/cloudx/e2e/root.go +++ b/cmd/cloudx/e2e/root.go @@ -22,8 +22,8 @@ func NewRootCmd() *cobra.Command { } c.AddCommand( - proxy.NewProxyCommand("", ""), - proxy.NewTunnelCommand("", ""), + proxy.NewProxyCommand(), + proxy.NewTunnelCommand(), ) return c diff --git a/cmd/cloudx/eventstreams/create.go b/cmd/cloudx/eventstreams/create.go index 3289af96..22176849 100644 --- a/cmd/cloudx/eventstreams/create.go +++ b/cmd/cloudx/eventstreams/create.go @@ -11,29 +11,32 @@ import ( "github.com/ory/cli/cmd/cloudx/client" cloud "github.com/ory/client-go" "github.com/ory/x/cmdx" - "github.com/ory/x/flagx" ) func NewCreateEventStreamCmd() *cobra.Command { + c := streamConfig{} + cmd := &cobra.Command{ Use: "event-stream [--project=PROJECT_ID] --type=sns --aws-iam-role-arn=arn:aws:iam::123456789012:role/MyRole --aws-sns-topic-arn=arn:aws:sns:us-east-1:123456789012:MyTopic", Short: "Create a new event stream", + Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { - h, err := client.NewCommandHelper(cmd) + ctx := cmd.Context() + + h, err := client.NewCobraCommandHelper(cmd) if err != nil { return err } - projectID, err := client.ProjectOrDefault(cmd, h) + projectID, err := h.ProjectID() if err != nil { - return cmdx.PrintOpenAPIError(cmd, err) + return err } - stream, err := h.CreateEventStream(projectID, cloud.CreateEventStreamBody{ - Type: flagx.MustGetString(cmd, "type"), - RoleArn: flagx.MustGetString(cmd, "aws-iam-role-arn"), - TopicArn: flagx.MustGetString(cmd, "aws-sns-topic-arn"), - }) + if err := c.Validate(); err != nil { + return err + } + stream, err := h.CreateEventStream(ctx, projectID, cloud.CreateEventStreamBody(c)) if err != nil { return cmdx.PrintOpenAPIError(cmd, err) } @@ -45,10 +48,10 @@ func NewCreateEventStreamCmd() *cobra.Command { } client.RegisterProjectFlag(cmd.Flags()) + client.RegisterWorkspaceFlag(cmd.Flags()) cmdx.RegisterFormatFlags(cmd.Flags()) - cmd.Flags().String("type", "", `The type of the event stream destination. Only "sns" is supported at the moment.`) - cmd.Flags().String("aws-iam-role-arn", "", "The ARN of the AWS IAM role to assume when publishing messages to the SNS topic.") - cmd.Flags().String("aws-sns-topic-arn", "", "The ARN of the AWS SNS topic.") + + registerStreamConfigFlags(cmd.Flags(), &c) return cmd } diff --git a/cmd/cloudx/eventstreams/delete.go b/cmd/cloudx/eventstreams/delete.go index e029c78a..4a66cbfd 100644 --- a/cmd/cloudx/eventstreams/delete.go +++ b/cmd/cloudx/eventstreams/delete.go @@ -14,22 +14,22 @@ import ( func NewDeleteEventStream() *cobra.Command { cmd := &cobra.Command{ - Use: "event-stream id [--project=PROJECT_ID]", + Use: "event-stream [--project=PROJECT_ID]", Args: cobra.ExactArgs(1), Short: "Delete the event stream with the given ID", RunE: func(cmd *cobra.Command, args []string) error { - h, err := client.NewCommandHelper(cmd) + h, err := client.NewCobraCommandHelper(cmd) if err != nil { return err } - projectID, err := client.ProjectOrDefault(cmd, h) + projectID, err := h.ProjectID() if err != nil { return cmdx.PrintOpenAPIError(cmd, err) } streamID := args[0] - err = h.DeleteEventStream(projectID, streamID) + err = h.DeleteEventStream(cmd.Context(), projectID, streamID) if err != nil { return cmdx.PrintOpenAPIError(cmd, err) } @@ -40,5 +40,6 @@ func NewDeleteEventStream() *cobra.Command { } client.RegisterProjectFlag(cmd.Flags()) + client.RegisterWorkspaceFlag(cmd.Flags()) return cmd } diff --git a/cmd/cloudx/eventstreams/flags.go b/cmd/cloudx/eventstreams/flags.go new file mode 100644 index 00000000..f074bc6c --- /dev/null +++ b/cmd/cloudx/eventstreams/flags.go @@ -0,0 +1,32 @@ +// Copyright © 2024 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package eventstreams + +import ( + "fmt" + + "github.com/spf13/pflag" + + "github.com/ory/client-go" +) + +type streamConfig client.CreateEventStreamBody + +func (c *streamConfig) Validate() error { + switch "" { + case c.Type: + return fmt.Errorf("flag --type must be set") + case c.RoleArn: + return fmt.Errorf("flag --aws-iam-role-arn must be set") + case c.TopicArn: + return fmt.Errorf("flag --aws-sns-topic-arn must be set") + } + return nil +} + +func registerStreamConfigFlags(f *pflag.FlagSet, c *streamConfig) { + f.StringVar(&c.Type, "type", "", `The type of the event stream destination. Only "sns" is supported at the moment.`) + f.StringVar(&c.RoleArn, "aws-iam-role-arn", "", "The ARN of the AWS IAM role to assume when publishing messages to the SNS topic.") + f.StringVar(&c.TopicArn, "aws-sns-topic-arn", "", "The ARN of the AWS SNS topic.") +} diff --git a/cmd/cloudx/eventstreams/list.go b/cmd/cloudx/eventstreams/list.go index 7fa6ba6f..2c18751f 100644 --- a/cmd/cloudx/eventstreams/list.go +++ b/cmd/cloudx/eventstreams/list.go @@ -16,17 +16,17 @@ func NewListEventStreamsCmd() *cobra.Command { Args: cobra.NoArgs, Short: "List your event streams", RunE: func(cmd *cobra.Command, args []string) error { - h, err := client.NewCommandHelper(cmd) + h, err := client.NewCobraCommandHelper(cmd) if err != nil { return err } - id, err := client.ProjectOrDefault(cmd, h) + projectID, err := h.ProjectID() if err != nil { return cmdx.PrintOpenAPIError(cmd, err) } - streams, err := h.ListEventStreams(id) + streams, err := h.ListEventStreams(cmd.Context(), projectID) if err != nil { return cmdx.PrintOpenAPIError(cmd, err) } @@ -37,6 +37,7 @@ func NewListEventStreamsCmd() *cobra.Command { } client.RegisterProjectFlag(cmd.Flags()) + client.RegisterWorkspaceFlag(cmd.Flags()) cmdx.RegisterFormatFlags(cmd.Flags()) return cmd } diff --git a/cmd/cloudx/eventstreams/update.go b/cmd/cloudx/eventstreams/update.go index 133918aa..cada4ae7 100644 --- a/cmd/cloudx/eventstreams/update.go +++ b/cmd/cloudx/eventstreams/update.go @@ -12,31 +12,32 @@ import ( cloud "github.com/ory/client-go" "github.com/ory/x/cmdx" - "github.com/ory/x/flagx" ) func NewUpdateEventStreamCmd() *cobra.Command { + c := streamConfig{} + cmd := &cobra.Command{ Use: "event-stream id [--project=PROJECT_ID] [--type=sns] [--aws-iam-role-arn=arn:aws:iam::123456789012:role/MyRole] [--aws-sns-topic-arn=arn:aws:sns:us-east-1:123456789012:MyTopic]", Args: cobra.ExactArgs(1), Short: "Update the event stream with the given ID", RunE: func(cmd *cobra.Command, args []string) error { - h, err := client.NewCommandHelper(cmd) + ctx := cmd.Context() + h, err := client.NewCobraCommandHelper(cmd) if err != nil { return err } - projectID, err := client.ProjectOrDefault(cmd, h) + projectID, err := h.ProjectID() if err != nil { - return cmdx.PrintOpenAPIError(cmd, err) + return err } streamID := args[0] - stream, err := h.UpdateEventStream(projectID, streamID, cloud.SetEventStreamBody{ - Type: flagx.MustGetString(cmd, "type"), - RoleArn: flagx.MustGetString(cmd, "aws-iam-role-arn"), - TopicArn: flagx.MustGetString(cmd, "aws-sns-topic-arn"), - }) + if err := c.Validate(); err != nil { + return err + } + stream, err := h.UpdateEventStream(ctx, projectID, streamID, cloud.SetEventStreamBody(c)) if err != nil { return cmdx.PrintOpenAPIError(cmd, err) } @@ -47,9 +48,6 @@ func NewUpdateEventStreamCmd() *cobra.Command { }, } - cmd.Flags().String("type", "", `The type of the event stream destination. Only "sns" is supported at the moment.`) - cmd.Flags().String("aws-iam-role-arn", "", "The ARN of the AWS IAM role to assume when publishing messages to the SNS topic.") - cmd.Flags().String("aws-sns-topic-arn", "", "The ARN of the AWS SNS topic.") client.RegisterProjectFlag(cmd.Flags()) cmdx.RegisterFormatFlags(cmd.Flags()) diff --git a/cmd/cloudx/identity/delete_test.go b/cmd/cloudx/identity/delete_test.go index cc3fb55b..0a4b4d27 100644 --- a/cmd/cloudx/identity/delete_test.go +++ b/cmd/cloudx/identity/delete_test.go @@ -16,16 +16,16 @@ import ( func TestDeleteIdentity(t *testing.T) { t.Run("is not able to delete identities if not authenticated and quiet flag", func(t *testing.T) { - userID := testhelpers.ImportIdentity(t, defaultCmd, defaultProject, nil) - configDir := testhelpers.NewConfigDir(t) - cmd := testhelpers.ConfigAwareCmd(configDir) - _, _, err := cmd.Exec(nil, "delete", "identity", "--quiet", "--project", defaultProject, userID) + userID := testhelpers.ImportIdentity(t, defaultCmd, defaultProject.Id, nil) + + cmd := testhelpers.CmdWithConfig(testhelpers.NewConfigFile(t)) + _, _, err := cmd.Exec(nil, "delete", "identity", "--quiet", "--project", defaultProject.Id, userID) require.ErrorIs(t, err, client.ErrNoConfigQuiet) }) t.Run("is able to delete identities", func(t *testing.T) { - userID := testhelpers.ImportIdentity(t, defaultCmd, defaultProject, nil) - stdout, stderr, err := defaultCmd.Exec(nil, "delete", "identity", "--format", "json", "--project", defaultProject, userID) + userID := testhelpers.ImportIdentity(t, defaultCmd, defaultProject.Id, nil) + stdout, stderr, err := defaultCmd.Exec(nil, "delete", "identity", "--format", "json", "--project", defaultProject.Id, userID) require.NoError(t, err, stderr) out := gjson.Parse(stdout) assert.True(t, gjson.Valid(stdout)) @@ -33,9 +33,9 @@ func TestDeleteIdentity(t *testing.T) { }) t.Run("is able to delete identities after authenticating", func(t *testing.T) { - userID := testhelpers.ImportIdentity(t, defaultCmd, defaultProject, nil) + userID := testhelpers.ImportIdentity(t, defaultCmd, defaultProject.Id, nil) cmd, r := testhelpers.WithReAuth(t, defaultEmail, defaultPassword) - stdout, stderr, err := cmd.Exec(r, "delete", "identity", "--format", "json", "--project", defaultProject, userID) + stdout, stderr, err := cmd.Exec(r, "delete", "identity", "--format", "json", "--project", defaultProject.Id, userID) require.NoError(t, err, stderr) assert.True(t, gjson.Valid(stdout)) out := gjson.Parse(stdout) diff --git a/cmd/cloudx/identity/get_test.go b/cmd/cloudx/identity/get_test.go index 6c7cf49f..3ed9d970 100644 --- a/cmd/cloudx/identity/get_test.go +++ b/cmd/cloudx/identity/get_test.go @@ -15,17 +15,17 @@ import ( ) func TestGetIdentity(t *testing.T) { - userID := testhelpers.ImportIdentity(t, defaultCmd, defaultProject, nil) + userID := testhelpers.ImportIdentity(t, defaultCmd, defaultProject.Id, nil) t.Run("is not able to get identity if not authenticated and quiet flag", func(t *testing.T) { - configDir := testhelpers.NewConfigDir(t) - cmd := testhelpers.ConfigAwareCmd(configDir) - _, _, err := cmd.Exec(nil, "get", "identity", "--quiet", "--project", defaultProject, userID) + configDir := testhelpers.NewConfigFile(t) + cmd := testhelpers.CmdWithConfig(configDir) + _, _, err := cmd.Exec(nil, "get", "identity", "--quiet", "--project", defaultProject.Id, userID) require.ErrorIs(t, err, client.ErrNoConfigQuiet) }) t.Run("is able to get identity", func(t *testing.T) { - stdout, stderr, err := defaultCmd.Exec(nil, "get", "identity", "--format", "json", "--project", defaultProject, userID) + stdout, stderr, err := defaultCmd.Exec(nil, "get", "identity", "--format", "json", "--project", defaultProject.Id, userID) require.NoError(t, err, stderr) out := gjson.Parse(stdout) assert.True(t, gjson.Valid(stdout)) @@ -35,7 +35,7 @@ func TestGetIdentity(t *testing.T) { t.Run("is able to get identity after authenticating", func(t *testing.T) { cmd, r := testhelpers.WithReAuth(t, defaultEmail, defaultPassword) - stdout, stderr, err := cmd.Exec(r, "get", "identity", "--format", "json", "--project", defaultProject, userID) + stdout, stderr, err := cmd.Exec(r, "get", "identity", "--format", "json", "--project", defaultProject.Id, userID) require.NoError(t, err, stderr) assert.True(t, gjson.Valid(stdout)) out := gjson.Parse(stdout) diff --git a/cmd/cloudx/identity/import_test.go b/cmd/cloudx/identity/import_test.go index a032e552..6effbbe3 100644 --- a/cmd/cloudx/identity/import_test.go +++ b/cmd/cloudx/identity/import_test.go @@ -15,18 +15,18 @@ import ( func TestImportIdentity(t *testing.T) { t.Run("is not able to import identities if not authenticated and quiet flag", func(t *testing.T) { - configDir := testhelpers.NewConfigDir(t) - cmd := testhelpers.ConfigAwareCmd(configDir) - _, _, err := cmd.Exec(nil, "import", "identities", "--quiet", "--project", defaultProject) + configDir := testhelpers.NewConfigFile(t) + cmd := testhelpers.CmdWithConfig(configDir) + _, _, err := cmd.Exec(nil, "import", "identities", "--quiet", "--project", defaultProject.Id) require.ErrorIs(t, err, client.ErrNoConfigQuiet) }) t.Run("is able to import identities", func(t *testing.T) { - testhelpers.ImportIdentity(t, defaultCmd, defaultProject, nil) + testhelpers.ImportIdentity(t, defaultCmd, defaultProject.Id, nil) }) t.Run("is able to import identities after authenticating", func(t *testing.T) { cmd, r := testhelpers.WithReAuth(t, defaultEmail, defaultPassword) - testhelpers.ImportIdentity(t, cmd, defaultProject, r) + testhelpers.ImportIdentity(t, cmd, defaultProject.Id, r) }) } diff --git a/cmd/cloudx/identity/list_test.go b/cmd/cloudx/identity/list_test.go index 453bca99..fd03f603 100644 --- a/cmd/cloudx/identity/list_test.go +++ b/cmd/cloudx/identity/list_test.go @@ -16,20 +16,20 @@ import ( ) func TestListIdentities(t *testing.T) { - project := testhelpers.CreateProject(t, defaultConfig) + project := testhelpers.CreateProject(t, defaultConfig, nil) - userID := testhelpers.ImportIdentity(t, defaultCmd, project, nil) + userID := testhelpers.ImportIdentity(t, defaultCmd, project.Id, nil) t.Run("is not able to list identities if not authenticated and quiet flag", func(t *testing.T) { - configDir := testhelpers.NewConfigDir(t) - cmd := testhelpers.ConfigAwareCmd(configDir) - _, _, err := cmd.Exec(nil, "list", "identities", "--quiet", "--project", project, "--consistency", "strong") + configDir := testhelpers.NewConfigFile(t) + cmd := testhelpers.CmdWithConfig(configDir) + _, _, err := cmd.Exec(nil, "list", "identities", "--quiet", "--project", project.Id, "--consistency", "strong") require.ErrorIs(t, err, client.ErrNoConfigQuiet) }) for _, proc := range []string{"list", "ls"} { t.Run(fmt.Sprintf("is able to %s identities", proc), func(t *testing.T) { - stdout, stderr, err := defaultCmd.Exec(nil, proc, "identities", "--format", "json", "--project", project, "--consistency", "strong") + stdout, stderr, err := defaultCmd.Exec(nil, proc, "identities", "--format", "json", "--project", project.Id, "--consistency", "strong") require.NoError(t, err, stderr) out := gjson.Parse(stdout) assert.True(t, gjson.Valid(stdout)) @@ -40,7 +40,7 @@ func TestListIdentities(t *testing.T) { t.Run("is able to list identities after authenticating", func(t *testing.T) { cmd, r := testhelpers.WithReAuth(t, defaultEmail, defaultPassword) - stdout, stderr, err := cmd.Exec(r, "ls", "identities", "--format", "json", "--project", project, "--consistency", "strong") + stdout, stderr, err := cmd.Exec(r, "ls", "identities", "--format", "json", "--project", project.Id, "--consistency", "strong") require.NoError(t, err, stderr) assert.True(t, gjson.Valid(stdout)) out := gjson.Parse(stdout) diff --git a/cmd/cloudx/identity/main_test.go b/cmd/cloudx/identity/main_test.go index d8f1ef3f..2063c97e 100644 --- a/cmd/cloudx/identity/main_test.go +++ b/cmd/cloudx/identity/main_test.go @@ -6,13 +6,16 @@ package identity_test import ( "testing" + cloud "github.com/ory/client-go" + "github.com/ory/cli/cmd/cloudx/testhelpers" "github.com/ory/x/cmdx" ) var ( - defaultProject, defaultConfig, defaultEmail, defaultPassword string - defaultCmd *cmdx.CommandExecuter + defaultConfig, defaultEmail, defaultPassword string + defaultProject *cloud.Project + defaultCmd *cmdx.CommandExecuter ) func TestMain(m *testing.M) { diff --git a/cmd/cloudx/list.go b/cmd/cloudx/list.go index d9329a5c..c76917fb 100644 --- a/cmd/cloudx/list.go +++ b/cmd/cloudx/list.go @@ -6,6 +6,8 @@ package cloudx import ( "github.com/spf13/cobra" + "github.com/ory/cli/cmd/cloudx/workspace" + "github.com/ory/cli/cmd/cloudx/eventstreams" "github.com/ory/cli/cmd/cloudx/identity" "github.com/ory/cli/cmd/cloudx/oauth2" @@ -31,6 +33,7 @@ func NewListCmd() *cobra.Command { oauth2.NewListOAuth2Clients(), relationtuples.NewListCmd(), eventstreams.NewListEventStreamsCmd(), + workspace.NewListCmd(), ) client.RegisterConfigFlag(cmd.PersistentFlags()) diff --git a/cmd/cloudx/logout.go b/cmd/cloudx/logout.go index 1fe2a9c5..142a3a1a 100644 --- a/cmd/cloudx/logout.go +++ b/cmd/cloudx/logout.go @@ -16,11 +16,11 @@ func NewLogoutCmd() *cobra.Command { Use: "logout", Short: "Signs you out of your account on this computer.", RunE: func(cmd *cobra.Command, args []string) error { - h, err := client.NewCommandHelper(cmd) + h, err := client.NewCobraCommandHelper(cmd) if err != nil { return err } - if err := h.SignOut(); err != nil { + if err := h.ClearConfig(); err != nil { return err } fmt.Println("You signed out successfully.") diff --git a/cmd/cloudx/logout_test.go b/cmd/cloudx/logout_test.go index 2cde7258..1b3cfce9 100644 --- a/cmd/cloudx/logout_test.go +++ b/cmd/cloudx/logout_test.go @@ -13,10 +13,10 @@ import ( ) func TestAuthLogout(t *testing.T) { - configDir := testhelpers.NewConfigDir(t) + configDir := testhelpers.NewConfigFile(t) testhelpers.RegisterAccount(t, configDir) - exec := testhelpers.ConfigAwareCmd(configDir) + exec := testhelpers.CmdWithConfig(configDir) _, _, err := exec.Exec(nil, "auth", "logout") require.NoError(t, err) diff --git a/cmd/cloudx/oauth2/client_test.go b/cmd/cloudx/oauth2/client_test.go index 0ca6a7e6..790b5104 100644 --- a/cmd/cloudx/oauth2/client_test.go +++ b/cmd/cloudx/oauth2/client_test.go @@ -19,14 +19,14 @@ import ( func TestCreateClient(t *testing.T) { t.Run("is not able to create client if not authenticated and quiet flag", func(t *testing.T) { - configDir := testhelpers.NewConfigDir(t) - cmd := testhelpers.ConfigAwareCmd(configDir) - _, _, err := cmd.Exec(nil, "create", "client", "--quiet", "--project", defaultProject) + configDir := testhelpers.NewConfigFile(t) + cmd := testhelpers.CmdWithConfig(configDir) + _, _, err := cmd.Exec(nil, "create", "client", "--quiet", "--project", defaultProject.Id) require.ErrorIs(t, err, client.ErrNoConfigQuiet) }) t.Run("is able to create client", func(t *testing.T) { - stdout, stderr, err := defaultCmd.Exec(nil, "create", "client", "--format", "json", "--project", defaultProject) + stdout, stderr, err := defaultCmd.Exec(nil, "create", "client", "--format", "json", "--project", defaultProject.Id) require.NoError(t, err, stderr) out := gjson.Parse(stdout) assert.True(t, gjson.Valid(stdout)) @@ -37,16 +37,16 @@ func TestCreateClient(t *testing.T) { func TestDeleteClient(t *testing.T) { t.Run("is not able to delete oauth2 client if not authenticated and quiet flag", func(t *testing.T) { - userID := testhelpers.CreateClient(t, defaultCmd, defaultProject).Get("client_id").String() - configDir := testhelpers.NewConfigDir(t) - cmd := testhelpers.ConfigAwareCmd(configDir) - _, _, err := cmd.Exec(nil, "delete", "oauth2-client", "--quiet", "--project", defaultProject, userID) + userID := testhelpers.CreateClient(t, defaultCmd, defaultProject.Id).Get("client_id").String() + configDir := testhelpers.NewConfigFile(t) + cmd := testhelpers.CmdWithConfig(configDir) + _, _, err := cmd.Exec(nil, "delete", "oauth2-client", "--quiet", "--project", defaultProject.Id, userID) require.ErrorIs(t, err, client.ErrNoConfigQuiet) }) t.Run("is able to delete oauth2 client", func(t *testing.T) { - userID := testhelpers.CreateClient(t, defaultCmd, defaultProject).Get("client_id").String() - stdout, stderr, err := defaultCmd.Exec(nil, "delete", "oauth2-client", "--format", "json", "--project", defaultProject, userID) + userID := testhelpers.CreateClient(t, defaultCmd, defaultProject.Id).Get("client_id").String() + stdout, stderr, err := defaultCmd.Exec(nil, "delete", "oauth2-client", "--format", "json", "--project", defaultProject.Id, userID) require.NoError(t, err, stderr) out := gjson.Parse(stdout) assert.True(t, gjson.Valid(stdout)) @@ -54,9 +54,9 @@ func TestDeleteClient(t *testing.T) { }) t.Run("is able to delete oauth2 client after authenticating", func(t *testing.T) { - userID := testhelpers.CreateClient(t, defaultCmd, defaultProject).Get("client_id").String() + userID := testhelpers.CreateClient(t, defaultCmd, defaultProject.Id).Get("client_id").String() cmd, r := testhelpers.WithReAuth(t, defaultEmail, defaultPassword) - stdout, stderr, err := cmd.Exec(r, "delete", "oauth2-client", "--format", "json", "--project", defaultProject, userID) + stdout, stderr, err := cmd.Exec(r, "delete", "oauth2-client", "--format", "json", "--project", defaultProject.Id, userID) require.NoError(t, err, stderr) assert.True(t, gjson.Valid(stdout)) out := gjson.Parse(stdout) @@ -65,17 +65,17 @@ func TestDeleteClient(t *testing.T) { } func TestGetClient(t *testing.T) { - userID := testhelpers.CreateClient(t, defaultCmd, defaultProject).Get("client_id").String() + userID := testhelpers.CreateClient(t, defaultCmd, defaultProject.Id).Get("client_id").String() t.Run("is not able to get oauth2 if not authenticated and quiet flag", func(t *testing.T) { - configDir := testhelpers.NewConfigDir(t) - cmd := testhelpers.ConfigAwareCmd(configDir) - _, _, err := cmd.Exec(nil, "get", "oauth2-client", "--quiet", "--project", defaultProject, userID) + configDir := testhelpers.NewConfigFile(t) + cmd := testhelpers.CmdWithConfig(configDir) + _, _, err := cmd.Exec(nil, "get", "oauth2-client", "--quiet", "--project", defaultProject.Id, userID) require.ErrorIs(t, err, client.ErrNoConfigQuiet) }) t.Run("is able to get oauth2", func(t *testing.T) { - stdout, stderr, err := defaultCmd.Exec(nil, "get", "oauth2-client", "--format", "json", "--project", defaultProject, userID) + stdout, stderr, err := defaultCmd.Exec(nil, "get", "oauth2-client", "--format", "json", "--project", defaultProject.Id, userID) require.NoError(t, err, stderr) out := gjson.Parse(stdout) assert.True(t, gjson.Valid(stdout)) @@ -85,7 +85,7 @@ func TestGetClient(t *testing.T) { t.Run("is able to get oauth2 after authenticating", func(t *testing.T) { cmd, r := testhelpers.WithReAuth(t, defaultEmail, defaultPassword) - stdout, stderr, err := cmd.Exec(r, "get", "oauth2-client", "--format", "json", "--project", defaultProject, userID) + stdout, stderr, err := cmd.Exec(r, "get", "oauth2-client", "--format", "json", "--project", defaultProject.Id, userID) require.NoError(t, err, stderr) assert.True(t, gjson.Valid(stdout)) out := gjson.Parse(stdout) @@ -96,15 +96,15 @@ func TestGetClient(t *testing.T) { func TestImportClient(t *testing.T) { t.Run("is not able to import oauth2-client if not authenticated and quiet flag", func(t *testing.T) { - configDir := testhelpers.NewConfigDir(t) - cmd := testhelpers.ConfigAwareCmd(configDir) - _, _, err := cmd.Exec(nil, "import", "oauth2-client", "--quiet", "--project", defaultProject) + configDir := testhelpers.NewConfigFile(t) + cmd := testhelpers.CmdWithConfig(configDir) + _, _, err := cmd.Exec(nil, "import", "oauth2-client", "--quiet", "--project", defaultProject.Id) require.ErrorIs(t, err, client.ErrNoConfigQuiet) }) t.Run("is able to import oauth2-client", func(t *testing.T) { name := uuid.Must(uuid.NewV4()).String() - stdout, stderr, err := defaultCmd.Exec(nil, "import", "oauth2-client", "--format", "json", "--project", defaultProject, testhelpers.MakeRandomClient(t, name)) + stdout, stderr, err := defaultCmd.Exec(nil, "import", "oauth2-client", "--format", "json", "--project", defaultProject.Id, testhelpers.MakeRandomClient(t, name)) require.NoError(t, err, stderr) out := gjson.Parse(stdout) assert.True(t, gjson.Valid(stdout)) @@ -114,7 +114,7 @@ func TestImportClient(t *testing.T) { t.Run("is able to import oauth2-client after authenticating", func(t *testing.T) { cmd, r := testhelpers.WithReAuth(t, defaultEmail, defaultPassword) name := uuid.Must(uuid.NewV4()).String() - stdout, stderr, err := cmd.Exec(r, "import", "oauth2-client", "--format", "json", "--project", defaultProject, testhelpers.MakeRandomClient(t, name)) + stdout, stderr, err := cmd.Exec(r, "import", "oauth2-client", "--format", "json", "--project", defaultProject.Id, testhelpers.MakeRandomClient(t, name)) require.NoError(t, err, stderr) out := gjson.Parse(stdout) assert.True(t, gjson.Valid(stdout)) @@ -123,20 +123,20 @@ func TestImportClient(t *testing.T) { } func TestListClients(t *testing.T) { - project := testhelpers.CreateProject(t, defaultConfig) + project := testhelpers.CreateProject(t, defaultConfig, nil) - userID := testhelpers.CreateClient(t, defaultCmd, project).Get("client_id").String() + userID := testhelpers.CreateClient(t, defaultCmd, project.Id).Get("client_id").String() t.Run("is not able to list oauth2 clients if not authenticated and quiet flag", func(t *testing.T) { - configDir := testhelpers.NewConfigDir(t) - cmd := testhelpers.ConfigAwareCmd(configDir) - _, _, err := cmd.Exec(nil, "list", "oauth2-clients", "--quiet", "--project", project) + configDir := testhelpers.NewConfigFile(t) + cmd := testhelpers.CmdWithConfig(configDir) + _, _, err := cmd.Exec(nil, "list", "oauth2-clients", "--quiet", "--project", project.Id) require.ErrorIs(t, err, client.ErrNoConfigQuiet) }) for _, proc := range []string{"list", "ls"} { t.Run(fmt.Sprintf("is able to %s oauth2 clients", proc), func(t *testing.T) { - stdout, stderr, err := defaultCmd.Exec(nil, proc, "oauth2-clients", "--format", "json", "--project", project) + stdout, stderr, err := defaultCmd.Exec(nil, proc, "oauth2-clients", "--format", "json", "--project", project.Id) require.NoError(t, err, stderr) out := gjson.Parse(stdout).Get("items") assert.True(t, gjson.Valid(stdout)) @@ -147,7 +147,7 @@ func TestListClients(t *testing.T) { t.Run("is able to list oauth2 clients after authenticating", func(t *testing.T) { cmd, r := testhelpers.WithReAuth(t, defaultEmail, defaultPassword) - stdout, stderr, err := cmd.Exec(r, "ls", "oauth2-clients", "--format", "json", "--project", project) + stdout, stderr, err := cmd.Exec(r, "ls", "oauth2-clients", "--format", "json", "--project", project.Id) require.NoError(t, err, stderr) assert.True(t, gjson.Valid(stdout)) out := gjson.Parse(stdout).Get("items") @@ -157,17 +157,17 @@ func TestListClients(t *testing.T) { } func TestUpdateOAuth2(t *testing.T) { - userID := testhelpers.CreateClient(t, defaultCmd, defaultProject).Get("client_id").String() + userID := testhelpers.CreateClient(t, defaultCmd, defaultProject.Id).Get("client_id").String() t.Run("is not able to update oauth2 if not authenticated and quiet flag", func(t *testing.T) { - configDir := testhelpers.NewConfigDir(t) - cmd := testhelpers.ConfigAwareCmd(configDir) - _, _, err := cmd.Exec(nil, "update", "oauth2-client", "--quiet", "--project", defaultProject, userID) + configDir := testhelpers.NewConfigFile(t) + cmd := testhelpers.CmdWithConfig(configDir) + _, _, err := cmd.Exec(nil, "update", "oauth2-client", "--quiet", "--project", defaultProject.Id, userID) require.ErrorIs(t, err, client.ErrNoConfigQuiet) }) t.Run("is able to update oauth2", func(t *testing.T) { - stdout, stderr, err := defaultCmd.Exec(nil, "update", "oauth2-client", "--format", "json", "--project", defaultProject, userID) + stdout, stderr, err := defaultCmd.Exec(nil, "update", "oauth2-client", "--format", "json", "--project", defaultProject.Id, userID) require.NoError(t, err, stderr) out := gjson.Parse(stdout) assert.True(t, gjson.Valid(stdout)) @@ -177,7 +177,7 @@ func TestUpdateOAuth2(t *testing.T) { t.Run("is able to update oauth2 after authenticating", func(t *testing.T) { cmd, r := testhelpers.WithReAuth(t, defaultEmail, defaultPassword) - stdout, stderr, err := cmd.Exec(r, "update", "oauth2-client", "--format", "json", "--project", defaultProject, userID) + stdout, stderr, err := cmd.Exec(r, "update", "oauth2-client", "--format", "json", "--project", defaultProject.Id, userID) require.NoError(t, err, stderr) assert.True(t, gjson.Valid(stdout)) out := gjson.Parse(stdout) diff --git a/cmd/cloudx/oauth2/jwks.go b/cmd/cloudx/oauth2/jwks.go index e802cbf5..b21fdf12 100644 --- a/cmd/cloudx/oauth2/jwks.go +++ b/cmd/cloudx/oauth2/jwks.go @@ -16,6 +16,7 @@ import ( func wrapHydraCmd(newCmd func() *cobra.Command) *cobra.Command { c := newCmd() client.RegisterProjectFlag(c.Flags()) + client.RegisterWorkspaceFlag(c.Flags()) cmdx.RegisterFormatFlags(c.Flags()) cliclient.RegisterClientFlags(c.Flags()) return c diff --git a/cmd/cloudx/oauth2/main_test.go b/cmd/cloudx/oauth2/main_test.go index 0c9029ac..4161a5a9 100644 --- a/cmd/cloudx/oauth2/main_test.go +++ b/cmd/cloudx/oauth2/main_test.go @@ -6,13 +6,16 @@ package oauth2_test import ( "testing" + cloud "github.com/ory/client-go" + "github.com/ory/cli/cmd/cloudx/testhelpers" "github.com/ory/x/cmdx" ) var ( - defaultProject, defaultConfig, defaultEmail, defaultPassword string - defaultCmd *cmdx.CommandExecuter + defaultConfig, defaultEmail, defaultPassword string + defaultProject *cloud.Project + defaultCmd *cmdx.CommandExecuter ) func TestMain(m *testing.M) { diff --git a/cmd/cloudx/organizations/create_organization.go b/cmd/cloudx/organizations/create_organization.go index 9d19adeb..52b931af 100644 --- a/cmd/cloudx/organizations/create_organization.go +++ b/cmd/cloudx/organizations/create_organization.go @@ -12,28 +12,28 @@ import ( cloud "github.com/ory/client-go" "github.com/ory/x/cmdx" - "github.com/ory/x/flagx" ) func NewCreateOrganizationCmd() *cobra.Command { + var domains []string + cmd := &cobra.Command{ - Use: "organization label [--project=PROJECT_ID] [--domains=a.example.com,b.example.com]", + Use: "organization