From e51d974c52bdcdc74e3dc95fb965bf718d444b41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20Tron=C3=AD=C4=8Dek?= Date: Mon, 6 Nov 2023 15:32:44 +0100 Subject: [PATCH] Local App v2 :) (#18971) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Local App v2 :) * bind variables correctly * Play around with units * Port more commands over * Separate commands (1 per file) * `gitpod workspace delete` * Extract login * Show help text when run without a command * Fix login * `gitpod logout` * Simple logging * Remove unused import * Make host use consistent * Fix GetToken * Split distribution * πŸ€·β€β™‚οΈ * Fix paths 🀷🀦 * Change URL of binaries * Fix proxy binary handling Co-authored-by: Pudong * Improve logging * Change workspace list to be up-to-spec * `gitpod organizations list` * Simplify table code * `gitpod workspace get` * Created at * `gitpod organization get ` * Hide open for now * `workspace start --ssh` * `ws start --open` * server: OAuth client * Use OAuth app * logs * `gitpod workspace create` * Rename to follow singular noun semantics * Fix nil pointers in list and go cmds * `--field` for `gitpod organizations list` * `gitpod ws list --field` * Simplify some of the ws code * Unify WS data structure * Allow opening browser-based WSs * `gitpod workspace open` * Constants package to get rid of circular dependency issues * No config file by default * Guidance when missing in path * Fix local companion maybe πŸ€·β€β™‚οΈ * Create wait for start by default * Align scopes * KeychainName constant * Provide token via flag instead * Host in scope error lookup message * πŸ€·β€β™‚οΈ * Name for consistency * Editors in go client of papi * `gitpod workspace list-classes` * `gitpod config` * Infer orgs if applicable * Remove redundant error log * Retry mechanism for streaming * More useful error message for unauthed * README update * Allow `function:getTeam` * return org inference errors properly * Replace config with context * Fix config file path * Wrap up pretty printer * Name changes * Remove unused vars * πŸ‡ΊπŸ‡Έ * Update README * Fix login * [local-app] Add whoami command * [local-app] Add context management * Refactor common package * Harmonise output and formatting * Add error resolution support * Improve resolution printing * Add apology for system exceptions * Add class resolutions * Apologise more * Add unknown field resolution * Add better login context name * Make it build * `gitpod workspace list-editors` * Fix multiple ws IDs for `ws get` * Simplify open code * Update local-app README with usage instructions * Help for editor options * Remove unused config code * Call workspace ID field ID instead of workspace * Improve long format output * Fix whoami output * Streamline workspace listing * Introduce fancy intro * Improve set-context feedback * Remove common package * Add first unit test * Harmonise field order * Consistency across get commands * Consistency among list command aliases * Fix column name in whoami * Fix nil refs for empty hosts * Make prettyprint writer typesafe * Add resolutions for no token or no host found * Fix typo * Fix CI build * Properly record org ID on login * Print orgs in wide format * Added "workspace up" functionality back in but hidden * Make "Git" casing consistent https://english.stackexchange.com/questions/611711/tech-related-should-i-capitalize-the-word-git-in-this-context-or-not * Introduce workspace up intermediary * Fix proxied binary name --------- Co-authored-by: Pudong Co-authored-by: Christian Weichel (Chris) --- components/ide-proxy/Dockerfile | 9 +- components/local-app/BUILD.js | 63 ++++ components/local-app/BUILD.yaml | 132 +------- components/local-app/README.md | 40 ++- .../local-app/cmd/config-delete-context.go | 58 ++++ .../local-app/cmd/config-get-contexts.go | 49 +++ .../local-app/cmd/config-set-context.go | 85 +++++ .../local-app/cmd/config-use-context.go | 37 +++ components/local-app/cmd/config.go | 18 ++ components/local-app/cmd/login.go | 133 ++++++++ components/local-app/cmd/organization-get.go | 68 ++++ components/local-app/cmd/organization-list.go | 59 ++++ components/local-app/cmd/organizations.go | 19 ++ components/local-app/cmd/root.go | 181 +++++++++++ components/local-app/cmd/root_test.go | 128 ++++++++ components/local-app/cmd/whoami.go | 68 ++++ components/local-app/cmd/workspace-create.go | 152 +++++++++ components/local-app/cmd/workspace-delete.go | 42 +++ components/local-app/cmd/workspace-get.go | 56 ++++ .../local-app/cmd/workspace-list-classes.go | 63 ++++ .../local-app/cmd/workspace-list-editors.go | 73 +++++ components/local-app/cmd/workspace-list.go | 112 +++++++ .../local-app/cmd/workspace-list_test.go | 73 +++++ components/local-app/cmd/workspace-open.go | 36 +++ components/local-app/cmd/workspace-ssh.go | 36 +++ components/local-app/cmd/workspace-start.go | 87 ++++++ components/local-app/cmd/workspace-stop.go | 99 ++++++ components/local-app/cmd/workspace-up.go | 294 ++++++++++++++++++ components/local-app/cmd/workspace.go | 19 ++ components/local-app/go.mod | 89 ++++-- components/local-app/go.sum | 254 ++++++++++++--- components/local-app/leeway.Dockerfile | 15 +- components/local-app/main/gitpod-cli/main.go | 13 + .../{ => main/gitpod-local-companion}/main.go | 15 +- components/local-app/pkg/auth/auth.go | 94 +++++- components/local-app/pkg/auth/auth_test.go | 2 +- components/local-app/pkg/config/context.go | 145 +++++++++ .../local-app/pkg/constants/constants.go | 10 + components/local-app/pkg/helper/workspace.go | 224 +++++++++++++ .../local-app/pkg/prettyprint/errors.go | 88 ++++++ .../local-app/pkg/prettyprint/prettyprint.go | 198 ++++++++++++ .../pkg/prettyprint/prettyprint_test.go | 118 +++++++ components/proxy/conf/Caddyfile | 6 +- components/public-api/go/client/client.go | 20 +- .../public-api/go/client/client_test.go | 4 +- components/server/src/oauth-server/db.ts | 36 +++ 46 files changed, 3354 insertions(+), 266 deletions(-) create mode 100644 components/local-app/BUILD.js create mode 100644 components/local-app/cmd/config-delete-context.go create mode 100644 components/local-app/cmd/config-get-contexts.go create mode 100644 components/local-app/cmd/config-set-context.go create mode 100644 components/local-app/cmd/config-use-context.go create mode 100644 components/local-app/cmd/config.go create mode 100644 components/local-app/cmd/login.go create mode 100644 components/local-app/cmd/organization-get.go create mode 100644 components/local-app/cmd/organization-list.go create mode 100644 components/local-app/cmd/organizations.go create mode 100644 components/local-app/cmd/root.go create mode 100644 components/local-app/cmd/root_test.go create mode 100644 components/local-app/cmd/whoami.go create mode 100644 components/local-app/cmd/workspace-create.go create mode 100644 components/local-app/cmd/workspace-delete.go create mode 100644 components/local-app/cmd/workspace-get.go create mode 100644 components/local-app/cmd/workspace-list-classes.go create mode 100644 components/local-app/cmd/workspace-list-editors.go create mode 100644 components/local-app/cmd/workspace-list.go create mode 100644 components/local-app/cmd/workspace-list_test.go create mode 100644 components/local-app/cmd/workspace-open.go create mode 100644 components/local-app/cmd/workspace-ssh.go create mode 100644 components/local-app/cmd/workspace-start.go create mode 100644 components/local-app/cmd/workspace-stop.go create mode 100644 components/local-app/cmd/workspace-up.go create mode 100644 components/local-app/cmd/workspace.go create mode 100644 components/local-app/main/gitpod-cli/main.go rename components/local-app/{ => main/gitpod-local-companion}/main.go (96%) create mode 100644 components/local-app/pkg/config/context.go create mode 100644 components/local-app/pkg/constants/constants.go create mode 100644 components/local-app/pkg/helper/workspace.go create mode 100644 components/local-app/pkg/prettyprint/errors.go create mode 100644 components/local-app/pkg/prettyprint/prettyprint.go create mode 100644 components/local-app/pkg/prettyprint/prettyprint_test.go diff --git a/components/ide-proxy/Dockerfile b/components/ide-proxy/Dockerfile index 2c339271653c5a..ab005306416726 100644 --- a/components/ide-proxy/Dockerfile +++ b/components/ide-proxy/Dockerfile @@ -6,13 +6,8 @@ FROM cgr.dev/chainguard/wolfi-base:latest@sha256:a8c9c2888304e62c133af76f520c9c9 RUN apk add brotli gzip -COPY components-local-app--app/components-local-app--app-linux-amd64/local-app /bin/gitpod-local-companion-linux-amd64 -COPY components-local-app--app/components-local-app--app-darwin-amd64/local-app /bin/gitpod-local-companion-darwin-amd64 -COPY components-local-app--app/components-local-app--app-windows-amd64/local-app.exe /bin/gitpod-local-companion-windows-amd64.exe -COPY components-local-app--app/components-local-app--app-linux-arm64/local-app /bin/gitpod-local-companion-linux-arm64 -COPY components-local-app--app/components-local-app--app-darwin-arm64/local-app /bin/gitpod-local-companion-darwin-arm64 -COPY components-local-app--app/components-local-app--app-windows-386/local-app.exe /bin/gitpod-local-companion-windows-arm64.exe -COPY components-local-app--app/components-local-app--app-windows-386/local-app.exe /bin/gitpod-local-companion-windows-386.exe +# Gitpod CLI and Local App +COPY components-local-app--app/bin/* /bin/ RUN for FILE in `ls /bin/gitpod-local-companion*`;do \ gzip -v -f -9 -k "$FILE"; \ diff --git a/components/local-app/BUILD.js b/components/local-app/BUILD.js new file mode 100644 index 00000000000000..cec69427ea23cf --- /dev/null +++ b/components/local-app/BUILD.js @@ -0,0 +1,63 @@ +const generatePackage = function (goos, goarch, binaryName, mainFile) { + let name = binaryName + "-" + goos + "-" + goarch; + let dontTest = !(goos === "linux" && goarch === "amd64"); + if (goos === "windows") { + binaryName += ".exe"; + } + let pkg = { + name, + type: "go", + srcs: ["go.mod", "go.sum", "**/*.go"], + deps: [ + "components/supervisor-api/go:lib", + "components/gitpod-protocol/go:lib", + "components/local-app-api/go:lib", + "components/public-api/go:lib", + ], + env: ["GOOS=" + goos, "GOARCH=" + goarch, "CGO_ENABLED=0"], + config: { + packaging: "app", + dontTest: dontTest, + buildCommand: [ + "go", + "build", + "-trimpath", + "-ldflags", + "-buildid= -w -s -X 'github.com/gitpod-io/local-app/pkg/common.Version=commit-${__git_commit}'", + "-o", + binaryName, + mainFile, + ], + }, + binaryName, + }; + return pkg; +}; + +const packages = []; +for (binaryName of ["gitpod-local-companion", "gitpod-cli"]) { + for (goos of ["linux", "darwin", "windows"]) { + for (goarch of ["amd64", "arm64"]) { + packages.push(generatePackage(goos, goarch, binaryName, "main/" + binaryName + "/main.go")); + } + } +} + +let appCmds = packages.map((p) => { + let binName = p.name; + if (p.name.includes("windows")) { + binName += ".exe"; + } + return ["cp", "components-local-app--" + p.name + "/" + p.binaryName, "bin/" + binName]; +}); +appCmds.unshift(["mkdir", "bin"]); +appCmds.push(["sh", "-c", "rm -rf components-*"]); + +packages.push({ + name: "app", + type: "generic", + deps: packages.map((d) => ":" + d.name), + config: { + commands: appCmds, + }, +}); diff --git a/components/local-app/BUILD.yaml b/components/local-app/BUILD.yaml index 826ffee22599ea..4665c0cc20aadb 100644 --- a/components/local-app/BUILD.yaml +++ b/components/local-app/BUILD.yaml @@ -1,135 +1,5 @@ packages: - - name: app - type: generic - config: - commands: [["echo"]] - deps: - - :app-linux-amd64 - - :app-linux-arm64 - - :app-darwin-amd64 - - :app-darwin-arm64 - - :app-windows-386 - - :app-windows-amd64 - - :app-windows-arm64 - - name: app-linux-amd64 - type: go - srcs: - - go.mod - - go.sum - - "**/*.go" - deps: - - components/supervisor-api/go:lib - - components/gitpod-protocol/go:lib - - components/local-app-api/go:lib - env: - - CGO_ENABLED=0 - - GOOS=linux - - GOARCH=amd64 - config: - packaging: app - buildCommand: ["go", "build", "-trimpath", "-ldflags", "-buildid= -w -s -X 'github.com/gitpod-io/local-app.Version=commit-${__git_commit}'"] - - name: app-linux-arm64 - type: go - srcs: - - go.mod - - go.sum - - "**/*.go" - deps: - - components/supervisor-api/go:lib - - components/gitpod-protocol/go:lib - - components/local-app-api/go:lib - env: - - CGO_ENABLED=0 - - GOOS=linux - - GOARCH=arm64 - config: - packaging: app - buildCommand: ["go", "build", "-trimpath", "-ldflags", "-buildid= -w -s -X 'github.com/gitpod-io/local-app.Version=commit-${__git_commit}'"] - - name: app-darwin-amd64 - type: go - srcs: - - go.mod - - go.sum - - "**/*.go" - deps: - - components/supervisor-api/go:lib - - components/gitpod-protocol/go:lib - - components/local-app-api/go:lib - env: - - CGO_ENABLED=0 - - GOOS=darwin - - GOARCH=amd64 - config: - packaging: app - buildCommand: ["go", "build", "-trimpath", "-ldflags", "-buildid= -w -s -X 'github.com/gitpod-io/local-app.Version=commit-${__git_commit}'"] - - name: app-darwin-arm64 - type: go - srcs: - - go.mod - - go.sum - - "**/*.go" - deps: - - components/supervisor-api/go:lib - - components/gitpod-protocol/go:lib - - components/local-app-api/go:lib - env: - - CGO_ENABLED=0 - - GOOS=darwin - - GOARCH=arm64 - config: - packaging: app - buildCommand: ["go", "build", "-trimpath", "-ldflags", "-buildid= -w -s -X 'github.com/gitpod-io/local-app.Version=commit-${__git_commit}'"] - - name: app-windows-amd64 - type: go - srcs: - - go.mod - - go.sum - - "**/*.go" - deps: - - components/supervisor-api/go:lib - - components/gitpod-protocol/go:lib - - components/local-app-api/go:lib - env: - - CGO_ENABLED=0 - - GOOS=windows - - GOARCH=amd64 - config: - packaging: app - buildCommand: ["go", "build", "-trimpath", "-ldflags", "-buildid= -w -s -X 'github.com/gitpod-io/local-app.Version=commit-${__git_commit}'"] - - name: app-windows-386 - type: go - srcs: - - go.mod - - go.sum - - "**/*.go" - deps: - - components/supervisor-api/go:lib - - components/gitpod-protocol/go:lib - - components/local-app-api/go:lib - env: - - CGO_ENABLED=0 - - GOOS=windows - - GOARCH=386 - config: - packaging: app - buildCommand: ["go", "build", "-trimpath", "-ldflags", "-buildid= -w -s -X 'github.com/gitpod-io/local-app.Version=commit-${__git_commit}'"] - - name: app-windows-arm64 - type: go - srcs: - - go.mod - - go.sum - - "**/*.go" - deps: - - components/supervisor-api/go:lib - - components/gitpod-protocol/go:lib - - components/local-app-api/go:lib - env: - - CGO_ENABLED=0 - - GOOS=windows - - GOARCH=arm64 - config: - packaging: app - buildCommand: ["go", "build", "-trimpath", "-ldflags", "-buildid= -w -s -X 'github.com/gitpod-io/local-app.Version=commit-${__git_commit}'"] + # remaining packages are added by the BUILD.js generator - name: docker type: docker deps: diff --git a/components/local-app/README.md b/components/local-app/README.md index 5ad942f40be8d4..fd754b3a3e81f8 100644 --- a/components/local-app/README.md +++ b/components/local-app/README.md @@ -1,18 +1,50 @@ -# local-app + # local-app + +## gitpod-cli + +All of the accessible commands can be listed with `gitpod --help` . + +### Installing + +1. Download the CLI for your platform and make it executable: + +```bash +wget -O gitpod https://gitpod.io/static/bin/gitpod-cli-darwin-arm64 +chmod u+x gitpod +``` + +2. Optionally, make it available globally. On macOS: + +```bash +sudo mv gitpod /usr/local/bin/ +``` + +### Usage + +Start by logging in with `gitpod login`, which will also create a default context in the configuration file (`~/.gitpod/config.yaml`). + +### Development + +To develop the CLI with Gitpod, you can run it just like locally, but in Gitpod workspaces, a browser and a keyring are not available. To log in despite these limitations, provide a PAT via the `GITPOD_TOKEN` environment variable, or use the `--token` flag with the login command. + +## local-app **Beware**: this is very much work in progress and will likely break things. -## How to install +### How to install + ``` docker run --rm -it -v /tmp/dest:/out eu.gcr.io/gitpod-core-dev/build/local-app: ``` -## How to run +### How to run + ``` ./local-app ``` -## How to run in Gitpod against a dev-staging environment +### How to run in Gitpod against a dev-staging environment + ``` cd components/local-app BROWSER= GITPOD_HOST= go run main.go --mock-keyring run diff --git a/components/local-app/cmd/config-delete-context.go b/components/local-app/cmd/config-delete-context.go new file mode 100644 index 00000000000000..e30a524a339706 --- /dev/null +++ b/components/local-app/cmd/config-delete-context.go @@ -0,0 +1,58 @@ +// Copyright (c) 2023 Gitpod GmbH. All rights reserved. +// Licensed under the GNU Affero General Public License (AGPL). +// See License.AGPL.txt in the project root for license information. + +package cmd + +import ( + "log/slog" + + "github.com/gitpod-io/local-app/pkg/auth" + "github.com/gitpod-io/local-app/pkg/config" + "github.com/spf13/cobra" +) + +var configDeleteCmd = &cobra.Command{ + Use: "delete-context ", + Short: "Deletes a context", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) (err error) { + cmd.SilenceUsage = true + + targetContext := args[0] + cfg := config.FromContext(cmd.Context()) + + var update bool + defer func() { + if err == nil && update { + slog.Debug("saving config", "filename", cfg.Filename) + err = config.SaveConfig(cfg.Filename, cfg) + } + }() + + if cfg.ActiveContext == targetContext { + slog.Info("deleting active context - use `gitpod config use-context` to set a new active context") + cfg.ActiveContext = "" + update = true + } + + gpctx := cfg.Contexts[targetContext] + if gpctx == nil { + return nil + } + delete(cfg.Contexts, targetContext) + update = true + + err = auth.DeleteToken(gpctx.Host.String()) + if err != nil { + slog.Warn("did not delete token from keyring", "err", err) + err = nil + } + + return nil + }, +} + +func init() { + configCmd.AddCommand(configDeleteCmd) +} diff --git a/components/local-app/cmd/config-get-contexts.go b/components/local-app/cmd/config-get-contexts.go new file mode 100644 index 00000000000000..c7befab02ed851 --- /dev/null +++ b/components/local-app/cmd/config-get-contexts.go @@ -0,0 +1,49 @@ +// Copyright (c) 2023 Gitpod GmbH. All rights reserved. +// Licensed under the GNU Affero General Public License (AGPL). +// See License.AGPL.txt in the project root for license information. + +package cmd + +import ( + "github.com/gitpod-io/local-app/pkg/config" + "github.com/gitpod-io/local-app/pkg/prettyprint" + "github.com/spf13/cobra" +) + +var configGetContextsCmd = &cobra.Command{ + Use: "get-contexts", + Short: "Lists the available contexts", + RunE: func(cmd *cobra.Command, args []string) error { + cmd.SilenceUsage = true + + cfg := config.FromContext(cmd.Context()) + + res := make([]tabularContext, 0, len(cfg.Contexts)) + for name, ctx := range cfg.Contexts { + res = append(res, tabularContext{ + Active: name == cfg.ActiveContext, + Name: name, + Host: ctx.Host.String(), + Organization: ctx.OrganizationID, + }) + } + + return WriteTabular(res, configGetContextsOpts.Format, prettyprint.WriterFormatWide) + }, +} + +type tabularContext struct { + Active bool + Name string + Host string + Organization string +} + +var configGetContextsOpts struct { + Format formatOpts +} + +func init() { + configCmd.AddCommand(configGetContextsCmd) + addFormatFlags(configGetContextsCmd, &configGetContextsOpts.Format) +} diff --git a/components/local-app/cmd/config-set-context.go b/components/local-app/cmd/config-set-context.go new file mode 100644 index 00000000000000..7555f211341655 --- /dev/null +++ b/components/local-app/cmd/config-set-context.go @@ -0,0 +1,85 @@ +// Copyright (c) 2023 Gitpod GmbH. All rights reserved. +// Licensed under the GNU Affero General Public License (AGPL). +// See License.AGPL.txt in the project root for license information. + +package cmd + +import ( + "fmt" + "net/url" + + "github.com/gitpod-io/local-app/pkg/config" + "github.com/gitpod-io/local-app/pkg/prettyprint" + "github.com/spf13/cobra" +) + +var configSetContext = &cobra.Command{ + Use: "set-context ", + Short: "Set a context entry in the gitpod CLI config", + Aliases: []string{"add-context"}, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cmd.SilenceUsage = true + + cfg := config.FromContext(cmd.Context()) + var targetContext string + if configSetContextOpts.Current { + if len(args) > 0 { + return prettyprint.AddResolution(fmt.Errorf("cannot use --current and specify a context name"), + "modify current context with `{gitpod} config set-context --current`", + "modify/create a different context with `{gitpod} config set-context `", + ) + } + targetContext = cfg.ActiveContext + } else { + if len(args) == 0 { + return prettyprint.AddResolution(fmt.Errorf("must specify a context name or use --current"), + "modify current context with `{gitpod} config set-context --current`", + "modify/create a different context with `{gitpod} config set-context `", + ) + } + targetContext = args[0] + } + + gpctx := cfg.Contexts[targetContext] + if gpctx == nil { + gpctx = &config.ConnectionContext{} + cfg.Contexts[targetContext] = gpctx + } + if cmd.Flags().Changed("host") { + host, err := url.Parse(configSetContextOpts.Host) + if err != nil { + return fmt.Errorf("invalid host: %w", err) + } + gpctx.Host = &config.YamlURL{URL: host} + } + if cmd.Flags().Changed("organization-id") { + gpctx.OrganizationID = configSetContextOpts.OrganizationID + } + if cmd.Flags().Changed("token") { + gpctx.Token = configSetContextOpts.Token + } + + err := config.SaveConfig(cfg.Filename, cfg) + if err != nil { + return err + } + return nil + }, +} + +var configSetContextOpts struct { + Current bool + Host string + OrganizationID string + Token string +} + +func init() { + configCmd.AddCommand(configSetContext) + + configSetContext.Flags().BoolVar(&configSetContextOpts.Current, "current", false, "modify the current context") + configSetContext.Flags().StringVar(&configSetContextOpts.Host, "host", "", "the host to use for the context") + configSetContext.Flags().StringVar(&configSetContextOpts.OrganizationID, "organization-id", "", "the organization ID to use for the context") + configSetContext.Flags().StringVar(&configSetContextOpts.Token, "token", "", "the token to use for the context") +} diff --git a/components/local-app/cmd/config-use-context.go b/components/local-app/cmd/config-use-context.go new file mode 100644 index 00000000000000..cb1c1b516ca5f9 --- /dev/null +++ b/components/local-app/cmd/config-use-context.go @@ -0,0 +1,37 @@ +// Copyright (c) 2023 Gitpod GmbH. All rights reserved. +// Licensed under the GNU Affero General Public License (AGPL). +// See License.AGPL.txt in the project root for license information. + +package cmd + +import ( + "fmt" + + "github.com/gitpod-io/local-app/pkg/config" + "github.com/spf13/cobra" +) + +var configUseContextCmd = &cobra.Command{ + Use: "use-context ", + Short: "Sets the active context", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cmd.SilenceUsage = true + + targetContext := args[0] + cfg := config.FromContext(cmd.Context()) + if _, ok := cfg.Contexts[targetContext]; !ok { + return fmt.Errorf("unknown context: %s", targetContext) + } + cfg.ActiveContext = targetContext + err := config.SaveConfig(cfg.Filename, cfg) + if err != nil { + return err + } + return nil + }, +} + +func init() { + configCmd.AddCommand(configUseContextCmd) +} diff --git a/components/local-app/cmd/config.go b/components/local-app/cmd/config.go new file mode 100644 index 00000000000000..0f2210a51fba19 --- /dev/null +++ b/components/local-app/cmd/config.go @@ -0,0 +1,18 @@ +// Copyright (c) 2023 Gitpod GmbH. All rights reserved. +// Licensed under the GNU Affero General Public License (AGPL). +// See License.AGPL.txt in the project root for license information. + +package cmd + +import ( + "github.com/spf13/cobra" +) + +var configCmd = &cobra.Command{ + Use: "config", + Short: "Interact with the CLI's configuration", +} + +func init() { + rootCmd.AddCommand(configCmd) +} diff --git a/components/local-app/cmd/login.go b/components/local-app/cmd/login.go new file mode 100644 index 00000000000000..5ee43740e822b3 --- /dev/null +++ b/components/local-app/cmd/login.go @@ -0,0 +1,133 @@ +// Copyright (c) 2023 Gitpod GmbH. All rights reserved. +// Licensed under the GNU Affero General Public License (AGPL). +// See License.AGPL.txt in the project root for license information. + +package cmd + +import ( + "context" + "fmt" + "log/slog" + "net/url" + "os" + "time" + + "github.com/bufbuild/connect-go" + v1 "github.com/gitpod-io/gitpod/components/public-api/go/experimental/v1" + "github.com/gitpod-io/local-app/pkg/auth" + "github.com/gitpod-io/local-app/pkg/config" + "github.com/gitpod-io/local-app/pkg/prettyprint" + "github.com/spf13/cobra" +) + +var loginOpts struct { + Token string + Host string + ContextName string + OrganizationID string +} + +// loginCmd represents the login command +var loginCmd = &cobra.Command{ + Use: "login", + Short: "Logs the user in to the CLI", + Long: `Logs the user in and stores the token in the system keychain.`, + Args: cobra.ExactArgs(0), + RunE: func(cmd *cobra.Command, args []string) error { + cmd.SilenceUsage = true + + host, err := url.Parse(loginOpts.Host) + if err != nil { + return fmt.Errorf("cannot parse host %s: %w", loginOpts.Host, err) + } + + token := loginOpts.Token + if token == "" { + token = os.Getenv("GITPOD_TOKEN") + } + if token == "" { + var err error + token, err = auth.Login(context.Background(), auth.LoginOpts{ + GitpodURL: loginOpts.Host, + AuthTimeout: 5 * time.Minute, + // Request CLI scopes (extended compared to the local companion app) + ExtendScopes: true, + }) + if err != nil { + return err + } + } + + cfg := config.FromContext(cmd.Context()) + gpctx := &config.ConnectionContext{ + Host: &config.YamlURL{URL: host}, + OrganizationID: loginOpts.OrganizationID, + } + + err = auth.SetToken(loginOpts.Host, token) + if err != nil { + slog.Warn("could not write token to keyring, storing in config file instead", "err", err) + gpctx.Token = token + } + + contextName := loginOpts.ContextName + if _, exists := cfg.Contexts[contextName]; exists && !cmd.Flags().Changed("context-name") { + contextName = host.Hostname() + } + cfg.Contexts[contextName] = gpctx + cfg.ActiveContext = contextName + + if loginOpts.OrganizationID == "" { + clnt, err := getGitpodClient(config.ToContext(context.Background(), cfg)) + if err != nil { + return fmt.Errorf("cannot connect to Gitpod with this context: %w", err) + } + orgsList, err := clnt.Teams.ListTeams(cmd.Context(), connect.NewRequest(&v1.ListTeamsRequest{})) + if err != nil { + resolutions := []string{ + "pass an organization ID using --organization-id", + } + if loginOpts.Token != "" { + resolutions = append(resolutions, + "make sure the token has the right scopes", + "use a different token", + "login without passing a token but using the browser instead", + ) + } + return prettyprint.AddResolution(fmt.Errorf("cannot list organizations: %w", err), resolutions...) + } + + var orgID string + switch len(orgsList.Msg.GetTeams()) { + case 0: + return fmt.Errorf("no organizations found. Please pass an organization ID using --organization-id") + case 1: + orgID = orgsList.Msg.GetTeams()[0].Id + default: + orgID = orgsList.Msg.GetTeams()[0].Id + slog.Info("found more than one organization and choose the first one", "org", orgID) + } + cfg.Contexts[contextName].OrganizationID = orgID + } + + err = config.SaveConfig(cfg.Filename, cfg) + if err != nil { + return err + } + + return nil + }, +} + +func init() { + rootCmd.AddCommand(loginCmd) + + host := "https://gitpod.io" + if v := os.Getenv("GITPOD_HOST"); v != "" { + host = v + } + loginCmd.Flags().StringVar(&loginOpts.Host, "host", host, "The Gitpod instance to log in to (defaults to $GITPOD_HOST)") + loginCmd.Flags().StringVar(&loginOpts.Token, "token", "", "The token to use for authentication (defaults to $GITPOD_TOKEN)") + loginCmd.Flags().StringVarP(&loginOpts.ContextName, "context-name", "n", "default", "The name of the context to create") + loginCmd.Flags().StringVar(&loginOpts.OrganizationID, "org", "", "The organization ID to use for the context") +} diff --git a/components/local-app/cmd/organization-get.go b/components/local-app/cmd/organization-get.go new file mode 100644 index 00000000000000..190c0f021e6314 --- /dev/null +++ b/components/local-app/cmd/organization-get.go @@ -0,0 +1,68 @@ +// Copyright (c) 2023 Gitpod GmbH. All rights reserved. +// Licensed under the GNU Affero General Public License (AGPL). +// See License.AGPL.txt in the project root for license information. + +package cmd + +import ( + "context" + "time" + + "github.com/bufbuild/connect-go" + v1 "github.com/gitpod-io/gitpod/components/public-api/go/experimental/v1" + "github.com/gitpod-io/local-app/pkg/config" + "github.com/gitpod-io/local-app/pkg/prettyprint" + "github.com/spf13/cobra" +) + +// organizationGetCmd gets a single organization +var organizationGetCmd = &cobra.Command{ + Use: "get [organization-id]", + Short: "Retrieves metadata about a given organization", + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) < 1 { + cfg := config.FromContext(cmd.Context()) + gpctx, err := cfg.GetActiveContext() + if err != nil { + return err + } + args = append(args, gpctx.OrganizationID) + } + + var organizations []tabularTeam + for _, orgId := range args { + if len(orgId) == 0 { + return cmd.Help() + } + + ctx, cancel := context.WithTimeout(cmd.Context(), 5*time.Second) + defer cancel() + + gitpod, err := getGitpodClient(ctx) + if err != nil { + return err + } + + orgs, err := gitpod.Teams.GetTeam(ctx, connect.NewRequest(&v1.GetTeamRequest{TeamId: orgId})) + if err != nil { + return err + } + + organizations = append(organizations, tabularTeam{ + ID: orgs.Msg.GetTeam().Id, + Name: orgs.Msg.GetTeam().Name, + }) + } + return WriteTabular(organizations, organizationGetOpts.Format, prettyprint.WriterFormatNarrow) + }, +} + +var organizationGetOpts struct { + Format formatOpts +} + +func init() { + organizationCmd.AddCommand(organizationGetCmd) + addFormatFlags(organizationGetCmd, &organizationGetOpts.Format) +} diff --git a/components/local-app/cmd/organization-list.go b/components/local-app/cmd/organization-list.go new file mode 100644 index 00000000000000..d04028e02c8066 --- /dev/null +++ b/components/local-app/cmd/organization-list.go @@ -0,0 +1,59 @@ +// Copyright (c) 2023 Gitpod GmbH. All rights reserved. +// Licensed under the GNU Affero General Public License (AGPL). +// See License.AGPL.txt in the project root for license information. + +package cmd + +import ( + "context" + "time" + + "github.com/bufbuild/connect-go" + v1 "github.com/gitpod-io/gitpod/components/public-api/go/experimental/v1" + "github.com/gitpod-io/local-app/pkg/prettyprint" + "github.com/spf13/cobra" +) + +// organizationListCmd lists all available organizations +var organizationListCmd = &cobra.Command{ + Use: "list", + Short: "Lists organizations", + Aliases: []string{"ls"}, + RunE: func(cmd *cobra.Command, args []string) error { + ctx, cancel := context.WithTimeout(cmd.Context(), 5*time.Second) + defer cancel() + + gitpod, err := getGitpodClient(ctx) + if err != nil { + return err + } + + orgs, err := gitpod.Teams.ListTeams(ctx, connect.NewRequest(&v1.ListTeamsRequest{})) + if err != nil { + return err + } + + res := make([]tabularTeam, 0, len(orgs.Msg.GetTeams())) + for _, org := range orgs.Msg.GetTeams() { + res = append(res, tabularTeam{ + ID: org.Id, + Name: org.Name, + }) + } + return WriteTabular(res, organizationListOpts.Format, prettyprint.WriterFormatWide) + }, +} + +type tabularTeam struct { + ID string `print:"id"` + Name string `print:"name"` +} + +var organizationListOpts struct { + Format formatOpts +} + +func init() { + organizationCmd.AddCommand(organizationListCmd) + addFormatFlags(organizationListCmd, &organizationListOpts.Format) +} diff --git a/components/local-app/cmd/organizations.go b/components/local-app/cmd/organizations.go new file mode 100644 index 00000000000000..1f41d06dfd5f88 --- /dev/null +++ b/components/local-app/cmd/organizations.go @@ -0,0 +1,19 @@ +// Copyright (c) 2023 Gitpod GmbH. All rights reserved. +// Licensed under the GNU Affero General Public License (AGPL). +// See License.AGPL.txt in the project root for license information. + +package cmd + +import ( + "github.com/spf13/cobra" +) + +var organizationCmd = &cobra.Command{ + Use: "organization", + Short: "Interact with organizations", + Aliases: []string{"organizations", "org", "orgs"}, +} + +func init() { + rootCmd.AddCommand(organizationCmd) +} diff --git a/components/local-app/cmd/root.go b/components/local-app/cmd/root.go new file mode 100644 index 00000000000000..6ea0cf3f0f09ed --- /dev/null +++ b/components/local-app/cmd/root.go @@ -0,0 +1,181 @@ +// Copyright (c) 2023 Gitpod GmbH. All rights reserved. +// Licensed under the GNU Affero General Public License (AGPL). +// See License.AGPL.txt in the project root for license information. + +package cmd + +import ( + "context" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "os" + "time" + + "github.com/gitpod-io/gitpod/components/public-api/go/client" + "github.com/gitpod-io/local-app/pkg/auth" + "github.com/gitpod-io/local-app/pkg/config" + "github.com/gitpod-io/local-app/pkg/prettyprint" + "github.com/gookit/color" + "github.com/lmittmann/tint" + "github.com/mattn/go-isatty" + "github.com/spf13/cobra" +) + +var rootOpts struct { + ConfigLocation string + Verbose bool +} + +var rootCmd = &cobra.Command{ + Use: "gitpod", + Short: "Gitpod: Always ready to code.", + Long: color.Sprint(` + .-+*#+ Gitpod: Always ready to code. + :=*#####*. Try the following commands to get started: + .=*####*+-. .--: + +****=: :=*####+ gitpod login Login to Gitpod + ****: .-+*########. gitpod whoami Show information about the currently logged in user + +***: *****+--####. + +***: .-=:. .#*##. gitpod workspace list List your workspaces + +***+-. .-+**** gitpod workspace create Create a new workspace + .=*****+=::-+*****+: gitpod workspace open Open a running workspace + .:=+*********=-. gitpod workspace stop Stop a running workspace + .-++++=: + + `), + SilenceErrors: true, + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + level := slog.LevelInfo + if rootOpts.Verbose { + level = slog.LevelDebug + } + var noColor bool + if !isatty.IsTerminal(os.Stdout.Fd()) { + noColor = true + } + slog.SetDefault(slog.New(tint.NewHandler(os.Stdout, &tint.Options{ + Level: level, + NoColor: noColor, + TimeFormat: time.StampMilli, + }))) + + cfg, err := config.LoadConfig(rootOpts.ConfigLocation) + if errors.Is(err, os.ErrNotExist) { + err = nil + } + if err != nil { + return err + } + cmd.SetContext(config.ToContext(context.Background(), cfg)) + return nil + }, +} + +func Execute() { + err := rootCmd.Execute() + if err != nil { + prettyprint.PrintError(os.Stderr, os.Args[0], err) + + os.Exit(1) + } +} + +func init() { + if !isatty.IsTerminal(os.Stdout.Fd()) || !isatty.IsTerminal(os.Stderr.Fd()) { + color.Disable() + } + + configLocation := config.DEFAULT_LOCATION + if fn := os.Getenv("GITPOD_CONFIG"); fn != "" { + configLocation = fn + } + rootCmd.PersistentFlags().StringVar(&rootOpts.ConfigLocation, "config", configLocation, "Location of the configuration file") + rootCmd.PersistentFlags().BoolVarP(&rootOpts.Verbose, "verbose", "v", false, "Display verbose output for more detailed logging") +} + +var rootTestingOpts struct { + Client *client.Gitpod + WriterOut io.Writer +} + +func getGitpodClient(ctx context.Context) (*client.Gitpod, error) { + cfg := config.FromContext(ctx) + gpctx, err := cfg.GetActiveContext() + if err != nil { + return nil, err + } + + host := gpctx.Host + if host == nil { + return nil, prettyprint.AddResolution(fmt.Errorf("active context has no host configured"), + "set a host using `gitpod config set-context --current --host `", + "login again using `gitpod login`", + "change to a different context using `gitpod config use-context `", + ) + } + + if host.String() == "https://testing" && rootTestingOpts.Client != nil { + return rootTestingOpts.Client, nil + } + + token := gpctx.Token + if token == "" { + token = os.Getenv("GITPOD_TOKEN") + } + if token == "" { + var err error + token, err = auth.GetToken(host.String()) + if err != nil { + return nil, err + } + } + if token == "" { + return nil, prettyprint.AddResolution(fmt.Errorf("no token found for active context"), + "provide a token by setting the GITPOD_TOKEN environment variable", + "login again using `gitpod login`", + "change to a different context using `gitpod config use-context `", + "set a token explicitly using `gitpod config set-context --current --token `", + ) + } + + var apiHost = *gpctx.Host.URL + apiHost.Host = "api." + apiHost.Host + slog.Debug("establishing connection to Gitpod", "host", apiHost.String()) + res, err := client.New( + client.WithCredentials(token), + client.WithURL(apiHost.String()), + client.WithHTTPClient(&http.Client{ + Transport: &auth.AuthenticatedTransport{Token: token, T: http.DefaultTransport}, + }), + ) + if err != nil { + return nil, err + } + + return res, nil +} + +type formatOpts struct { + Field string +} + +// WriteTabular writes the given tabular data to the writer +func WriteTabular[T any](v []T, opts formatOpts, format prettyprint.WriterFormat) error { + var out io.Writer = os.Stdout + if rootTestingOpts.WriterOut != nil { + out = rootTestingOpts.WriterOut + } + w := &prettyprint.Writer[T]{ + Field: opts.Field, + Format: format, + Out: out, + } + return w.Write(v) +} + +func addFormatFlags(cmd *cobra.Command, opts *formatOpts) { + cmd.Flags().StringVarP(&opts.Field, "field", "f", "", "Only print the specified field") +} diff --git a/components/local-app/cmd/root_test.go b/components/local-app/cmd/root_test.go new file mode 100644 index 00000000000000..43d28b8a0249a0 --- /dev/null +++ b/components/local-app/cmd/root_test.go @@ -0,0 +1,128 @@ +// Copyright (c) 2023 Gitpod GmbH. All rights reserved. +// Licensed under the GNU Affero General Public License (AGPL). +// See License.AGPL.txt in the project root for license information. + +package cmd + +import ( + "bytes" + "net/http" + "net/http/httptest" + "net/url" + "os" + "strings" + "testing" + + "github.com/gitpod-io/gitpod/components/public-api/go/client" + v1 "github.com/gitpod-io/gitpod/components/public-api/go/experimental/v1" + "github.com/gitpod-io/local-app/pkg/config" + "github.com/google/go-cmp/cmp" +) + +type CommandTest struct { + Name string + Commandline []string + Config *config.Config + Expectation CommandTestExpectation + PrepServer func(mux *http.ServeMux) +} + +type CommandTestExpectation struct { + Error string + Output string +} + +func RunCommandTests(t *testing.T, tests []CommandTest) { + for _, test := range tests { + name := test.Name + if name == "" { + name = strings.Join(test.Commandline, " ") + } + t.Run(name, func(t *testing.T) { + actual := new(bytes.Buffer) + + cfgfn, err := os.CreateTemp("", "local-app-test-cfg-*.json") + if err != nil { + t.Fatal(err) + } + cfgfn.Close() + defer os.Remove(cfgfn.Name()) + + if test.Config != nil { + if test.Config.ActiveContext == "test" { + mux := http.NewServeMux() + if test.PrepServer != nil { + test.PrepServer(mux) + } + + apisrv := httptest.NewServer(mux) + t.Cleanup(apisrv.Close) + + clnt, err := client.New(client.WithURL(apisrv.URL), client.WithCredentials("hello world")) + if err != nil { + t.Fatal(err) + } + rootTestingOpts.Client = clnt + + test.Config.Contexts = map[string]*config.ConnectionContext{ + "test": { + Host: &config.YamlURL{URL: &url.URL{Scheme: "https", Host: "testing"}}, + Token: "hello world", + }, + } + } + + err = config.SaveConfig(cfgfn.Name(), test.Config) + if err != nil { + t.Fatal(err) + } + } + + rootCmd.SetArgs(test.Commandline) + rootCmd.SetOut(actual) + rootCmd.SetErr(actual) + rootTestingOpts.WriterOut = actual + rootOpts.ConfigLocation = cfgfn.Name() + err = rootCmd.Execute() + + var act CommandTestExpectation + if err != nil { + act.Error = err.Error() + } + act.Output = actual.String() + + if diff := cmp.Diff(test.Expectation, act); diff != "" { + t.Errorf("expectation mismatch (-want +got):\n%s", diff) + } + }) + } +} + +// fixtureWorkspace returns a workspace fixture +func fixtureWorkspace() *v1.Workspace { + return &v1.Workspace{ + WorkspaceId: "workspaceID", + OwnerId: "ownerId", + ProjectId: "projectId", + Context: &v1.WorkspaceContext{ + ContextUrl: "contextUrl", + Details: &v1.WorkspaceContext_Git_{ + Git: &v1.WorkspaceContext_Git{ + Repository: &v1.WorkspaceContext_Repository{Name: "name", Owner: "owner"}, + }, + }, + }, + Description: "description", + Status: &v1.WorkspaceStatus{ + Instance: &v1.WorkspaceInstance{ + InstanceId: "instanceId", + WorkspaceId: "workspaceId", + Status: &v1.WorkspaceInstanceStatus{ + StatusVersion: 1, + Phase: v1.WorkspaceInstanceStatus_PHASE_RUNNING, + Url: "url", + }, + }, + }, + } +} diff --git a/components/local-app/cmd/whoami.go b/components/local-app/cmd/whoami.go new file mode 100644 index 00000000000000..d61ad5ffec0d4a --- /dev/null +++ b/components/local-app/cmd/whoami.go @@ -0,0 +1,68 @@ +// Copyright (c) 2023 Gitpod GmbH. All rights reserved. +// Licensed under the GNU Affero General Public License (AGPL). +// See License.AGPL.txt in the project root for license information. + +package cmd + +import ( + "github.com/bufbuild/connect-go" + v1 "github.com/gitpod-io/gitpod/components/public-api/go/experimental/v1" + "github.com/gitpod-io/local-app/pkg/config" + "github.com/gitpod-io/local-app/pkg/prettyprint" + "github.com/spf13/cobra" +) + +var whoamiCmd = &cobra.Command{ + Use: "whoami", + Short: "Produces information about the current user", + RunE: func(cmd *cobra.Command, args []string) error { + cmd.SilenceUsage = true + + gpctx, err := config.FromContext(cmd.Context()).GetActiveContext() + if err != nil { + return err + } + + client, err := getGitpodClient(cmd.Context()) + if err != nil { + return err + } + + user, err := client.User.GetAuthenticatedUser(cmd.Context(), &connect.Request[v1.GetAuthenticatedUserRequest]{}) + if err != nil { + return err + } + org, err := client.Teams.GetTeam(cmd.Context(), &connect.Request[v1.GetTeamRequest]{Msg: &v1.GetTeamRequest{TeamId: gpctx.OrganizationID}}) + if err != nil { + return err + } + + return WriteTabular([]whoamiResult{ + { + Name: user.Msg.GetUser().Name, + ID: user.Msg.GetUser().Id, + Org: org.Msg.GetTeam().Name, + OrgID: org.Msg.GetTeam().Id, + Host: gpctx.Host.String(), + }, + }, whoamiOpts.Format, prettyprint.WriterFormatNarrow) + }, +} + +type whoamiResult struct { + Name string `print:"user name"` + ID string `print:"user id"` + Org string `print:"organization"` + OrgID string `print:"organization id"` + Host string `print:"host"` +} + +var whoamiOpts struct { + Format formatOpts +} + +func init() { + rootCmd.AddCommand(whoamiCmd) + + addFormatFlags(whoamiCmd, &whoamiOpts.Format) +} diff --git a/components/local-app/cmd/workspace-create.go b/components/local-app/cmd/workspace-create.go new file mode 100644 index 00000000000000..9c40350056c054 --- /dev/null +++ b/components/local-app/cmd/workspace-create.go @@ -0,0 +1,152 @@ +// Copyright (c) 2023 Gitpod GmbH. All rights reserved. +// Licensed under the GNU Affero General Public License (AGPL). +// See License.AGPL.txt in the project root for license information. + +package cmd + +import ( + "fmt" + "log/slog" + "strings" + + "github.com/bufbuild/connect-go" + v1 "github.com/gitpod-io/gitpod/components/public-api/go/experimental/v1" + "github.com/gitpod-io/local-app/pkg/config" + "github.com/gitpod-io/local-app/pkg/helper" + "github.com/gitpod-io/local-app/pkg/prettyprint" + "github.com/spf13/cobra" +) + +// workspaceCreateCmd creates a new workspace +var workspaceCreateCmd = &cobra.Command{ + Use: "create ", + Short: "Creates a new workspace based on a given context", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cmd.SilenceUsage = true + repoURL := args[0] + + cfg := config.FromContext(cmd.Context()) + gpctx, err := cfg.GetActiveContext() + if err != nil { + return err + } + gitpod, err := getGitpodClient(cmd.Context()) + if err != nil { + return err + } + + if workspaceCreateOpts.WorkspaceClass != "" { + resp, err := gitpod.Workspaces.ListWorkspaceClasses(cmd.Context(), connect.NewRequest(&v1.ListWorkspaceClassesRequest{})) + if err != nil { + return prettyprint.AddApology(prettyprint.AddResolution(fmt.Errorf("cannot list workspace classes: %w", err), + "don't pass an explicit workspace class, i.e. omit the --class flag", + )) + } + var ( + classes []string + found bool + ) + for _, cls := range resp.Msg.GetResult() { + classes = append(classes, cls.Id) + if cls.Id == workspaceCreateOpts.WorkspaceClass { + found = true + } + } + if !found { + return prettyprint.AddResolution(fmt.Errorf("workspace class %s not found", workspaceCreateOpts.WorkspaceClass), + fmt.Sprintf("use one of the available workspace classes: %s", strings.Join(classes, ", ")), + ) + } + } + + if workspaceCreateOpts.Editor != "" { + resp, err := gitpod.Editors.ListEditorOptions(cmd.Context(), connect.NewRequest(&v1.ListEditorOptionsRequest{})) + if err != nil { + return prettyprint.AddApology(prettyprint.AddResolution(fmt.Errorf("cannot list editor options: %w", err), + "don't pass an explicit editor, i.e. omit the --editor flag", + )) + } + var ( + editors []string + found bool + ) + for _, editor := range resp.Msg.GetResult() { + editors = append(editors, editor.Id) + if editor.Id == workspaceCreateOpts.Editor { + found = true + } + } + if !found { + return prettyprint.AddResolution(fmt.Errorf("editor %s not found", workspaceCreateOpts.Editor), + fmt.Sprintf("use one of the available editor options: %s", strings.Join(editors, ", ")), + ) + } + } + + var ( + orgId = gpctx.OrganizationID + ctx = cmd.Context() + ) + + slog.Debug("Attempting to create workspace...", "org", orgId, "repo", repoURL) + newWorkspace, err := gitpod.Workspaces.CreateAndStartWorkspace(ctx, connect.NewRequest( + &v1.CreateAndStartWorkspaceRequest{ + Source: &v1.CreateAndStartWorkspaceRequest_ContextUrl{ContextUrl: repoURL}, + OrganizationId: orgId, + StartSpec: &v1.StartWorkspaceSpec{ + IdeSettings: &v1.IDESettings{ + DefaultIde: workspaceCreateOpts.Editor, + UseLatestVersion: false, + }, + WorkspaceClass: workspaceCreateOpts.WorkspaceClass, + }, + }, + )) + if err != nil { + return err + } + + workspaceID := newWorkspace.Msg.WorkspaceId + if len(workspaceID) == 0 { + return prettyprint.AddApology(prettyprint.AddResolution(fmt.Errorf("workspace was not created"), + "try to create the workspace again", + )) + } + + if workspaceCreateOpts.StartOpts.DontWait { + // There is no more information to print other than the workspace ID. No need to faff with tabular pretty printing. + fmt.Println(workspaceID) + return nil + } + + _, err = helper.ObserveWorkspaceUntilStarted(ctx, gitpod, workspaceID) + if err != nil { + return err + } + + if workspaceCreateOpts.StartOpts.OpenSSH { + return helper.SSHConnectToWorkspace(ctx, gitpod, workspaceID, false) + } + if workspaceCreateOpts.StartOpts.OpenEditor { + return helper.OpenWorkspaceInPreferredEditor(ctx, gitpod, workspaceID) + } + + return nil + }, +} + +var workspaceCreateOpts struct { + StartOpts workspaceStartOptions + + WorkspaceClass string + Editor string +} + +func init() { + workspaceCmd.AddCommand(workspaceCreateCmd) + addWorkspaceStartOptions(workspaceCreateCmd, &workspaceCreateOpts.StartOpts) + + workspaceCreateCmd.Flags().StringVar(&workspaceCreateOpts.WorkspaceClass, "class", "", "the workspace class") + workspaceCreateCmd.Flags().StringVar(&workspaceCreateOpts.Editor, "editor", "code", "the editor to use") +} diff --git a/components/local-app/cmd/workspace-delete.go b/components/local-app/cmd/workspace-delete.go new file mode 100644 index 00000000000000..de5ebf2d73901e --- /dev/null +++ b/components/local-app/cmd/workspace-delete.go @@ -0,0 +1,42 @@ +// Copyright (c) 2023 Gitpod GmbH. All rights reserved. +// Licensed under the GNU Affero General Public License (AGPL). +// See License.AGPL.txt in the project root for license information. + +package cmd + +import ( + "context" + "log/slog" + "time" + + "github.com/bufbuild/connect-go" + v1 "github.com/gitpod-io/gitpod/components/public-api/go/experimental/v1" + "github.com/spf13/cobra" +) + +// stopWorkspaceCommand stops to a given workspace +var workspaceDeleteCmd = &cobra.Command{ + Use: "delete ", + Short: "Deletes a given workspace", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + workspaceID := args[0] + + ctx, cancel := context.WithTimeout(cmd.Context(), 30*time.Second) + defer cancel() + + gitpod, err := getGitpodClient(ctx) + if err != nil { + return err + } + + slog.Debug("Attempting to delete workspace...") + _, err = gitpod.Workspaces.DeleteWorkspace(ctx, connect.NewRequest(&v1.DeleteWorkspaceRequest{WorkspaceId: workspaceID})) + + return err + }, +} + +func init() { + workspaceCmd.AddCommand(workspaceDeleteCmd) +} diff --git a/components/local-app/cmd/workspace-get.go b/components/local-app/cmd/workspace-get.go new file mode 100644 index 00000000000000..04bbff87abbb7b --- /dev/null +++ b/components/local-app/cmd/workspace-get.go @@ -0,0 +1,56 @@ +// Copyright (c) 2023 Gitpod GmbH. All rights reserved. +// Licensed under the GNU Affero General Public License (AGPL). +// See License.AGPL.txt in the project root for license information. + +package cmd + +import ( + "context" + "log/slog" + "time" + + "github.com/bufbuild/connect-go" + v1 "github.com/gitpod-io/gitpod/components/public-api/go/experimental/v1" + "github.com/gitpod-io/local-app/pkg/prettyprint" + "github.com/spf13/cobra" +) + +var workspaceGetOpts struct { + Format formatOpts +} + +var workspaceGetCmd = &cobra.Command{ + Use: "get ", + Short: "Retrieves metadata about a given workspace", + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + var workspaces []tabularWorkspace + for _, workspaceID := range args { + ctx, cancel := context.WithTimeout(cmd.Context(), 30*time.Second) + defer cancel() + + gitpod, err := getGitpodClient(ctx) + if err != nil { + return err + } + + slog.Debug("Attempting to retrieve workspace info...", "workspaceID", workspaceID) + ws, err := gitpod.Workspaces.GetWorkspace(ctx, connect.NewRequest(&v1.GetWorkspaceRequest{WorkspaceId: workspaceID})) + if err != nil { + return err + } + + r := newTabularWorkspace(ws.Msg.GetResult()) + if r == nil { + continue + } + workspaces = append(workspaces, *r) + } + return WriteTabular(workspaces, workspaceGetOpts.Format, prettyprint.WriterFormatNarrow) + }, +} + +func init() { + workspaceCmd.AddCommand(workspaceGetCmd) + addFormatFlags(workspaceGetCmd, &workspaceGetOpts.Format) +} diff --git a/components/local-app/cmd/workspace-list-classes.go b/components/local-app/cmd/workspace-list-classes.go new file mode 100644 index 00000000000000..3f8e36e66e98b1 --- /dev/null +++ b/components/local-app/cmd/workspace-list-classes.go @@ -0,0 +1,63 @@ +// Copyright (c) 2023 Gitpod GmbH. All rights reserved. +// Licensed under the GNU Affero General Public License (AGPL). +// See License.AGPL.txt in the project root for license information. + +package cmd + +import ( + "context" + "time" + + "github.com/bufbuild/connect-go" + v1 "github.com/gitpod-io/gitpod/components/public-api/go/experimental/v1" + "github.com/gitpod-io/local-app/pkg/prettyprint" + "github.com/spf13/cobra" +) + +// workspaceListClassesCmd lists available workspace classes +var workspaceListClassesCmd = &cobra.Command{ + Use: "list-classes", + Short: "Lists workspace classes", + RunE: func(cmd *cobra.Command, args []string) error { + cmd.SilenceUsage = true + + ctx, cancel := context.WithTimeout(cmd.Context(), 5*time.Second) + defer cancel() + + gitpod, err := getGitpodClient(ctx) + if err != nil { + return err + } + + classes, err := gitpod.Workspaces.ListWorkspaceClasses(ctx, connect.NewRequest(&v1.ListWorkspaceClassesRequest{})) + if err != nil { + return err + } + + res := make([]tabularWorkspaceClass, 0, len(classes.Msg.GetResult())) + for _, class := range classes.Msg.GetResult() { + res = append(res, tabularWorkspaceClass{ + ID: class.Id, + Name: class.DisplayName, + Description: class.Description, + }) + } + + return WriteTabular(res, workspaceListClassesOpts.Format, prettyprint.WriterFormatNarrow) + }, +} + +type tabularWorkspaceClass struct { + ID string `print:"id"` + Name string `print:"name"` + Description string `print:"description"` +} + +var workspaceListClassesOpts struct { + Format formatOpts +} + +func init() { + workspaceCmd.AddCommand(workspaceListClassesCmd) + addFormatFlags(workspaceListClassesCmd, &workspaceListClassesOpts.Format) +} diff --git a/components/local-app/cmd/workspace-list-editors.go b/components/local-app/cmd/workspace-list-editors.go new file mode 100644 index 00000000000000..c40c990bf7b1ca --- /dev/null +++ b/components/local-app/cmd/workspace-list-editors.go @@ -0,0 +1,73 @@ +// Copyright (c) 2023 Gitpod GmbH. All rights reserved. +// Licensed under the GNU Affero General Public License (AGPL). +// See License.AGPL.txt in the project root for license information. + +package cmd + +import ( + "context" + "time" + + "github.com/bufbuild/connect-go" + v1 "github.com/gitpod-io/gitpod/components/public-api/go/experimental/v1" + "github.com/gitpod-io/local-app/pkg/prettyprint" + "github.com/spf13/cobra" +) + +type workspaceListEditorsOptions struct { + Latest bool +} + +// workspaceListEditors lists available editor options +var workspaceListEditors = &cobra.Command{ + Use: "list-editors", + Short: "Lists workspace editor options", + RunE: func(cmd *cobra.Command, args []string) error { + cmd.SilenceUsage = true + + ctx, cancel := context.WithTimeout(cmd.Context(), 5*time.Second) + defer cancel() + + gitpod, err := getGitpodClient(ctx) + if err != nil { + return err + } + + editors, err := gitpod.Editors.ListEditorOptions(ctx, connect.NewRequest(&v1.ListEditorOptionsRequest{})) + if err != nil { + return err + } + + res := make([]tabularWorkspaceEditor, 0, len(editors.Msg.GetResult())) + for _, editor := range editors.Msg.GetResult() { + res = append(res, tabularWorkspaceEditor{ + ID: editor.Id, + Name: editor.Title, + Flavor: editor.Label, + Version: editor.Stable.Version, + }) + } + + return WriteTabular(res, workspaceListEditorsOpts.Format, prettyprint.WriterFormatNarrow) + }, +} + +type tabularWorkspaceEditor struct { + ID string `print:"id"` + Name string `print:"name"` + Flavor string `print:"flavor"` + Version string `print:"version"` +} + +var workspaceListEditorsOpts struct { + Format formatOpts +} + +var workspaceListEditorOpts workspaceListEditorsOptions + +func init() { + workspaceCmd.AddCommand(workspaceListEditors) + + workspaceListEditors.Flags().BoolVar(&workspaceListEditorOpts.Latest, "latest", false, "show latest versions instead of stable") + addFormatFlags(workspaceListEditors, &workspaceListEditorsOpts.Format) +} diff --git a/components/local-app/cmd/workspace-list.go b/components/local-app/cmd/workspace-list.go new file mode 100644 index 00000000000000..51d06d0dfc44fa --- /dev/null +++ b/components/local-app/cmd/workspace-list.go @@ -0,0 +1,112 @@ +// Copyright (c) 2023 Gitpod GmbH. All rights reserved. +// Licensed under the GNU Affero General Public License (AGPL). +// See License.AGPL.txt in the project root for license information. + +package cmd + +import ( + "context" + "fmt" + "time" + + "github.com/bufbuild/connect-go" + v1 "github.com/gitpod-io/gitpod/components/public-api/go/experimental/v1" + "github.com/gitpod-io/local-app/pkg/config" + "github.com/gitpod-io/local-app/pkg/helper" + "github.com/gitpod-io/local-app/pkg/prettyprint" + "github.com/sagikazarmark/slog-shim" + "github.com/spf13/cobra" +) + +// workspaceListCmd lists all available workspaces +var workspaceListCmd = &cobra.Command{ + Use: "list", + Short: "Lists workspaces", + Aliases: []string{"ls"}, + RunE: func(cmd *cobra.Command, args []string) error { + cmd.SilenceUsage = true + + ctx, cancel := context.WithTimeout(cmd.Context(), 5*time.Second) + defer cancel() + + gitpod, err := getGitpodClient(ctx) + if err != nil { + return err + } + + cfg := config.FromContext(ctx) + gpctx, err := cfg.GetActiveContext() + if err != nil { + return err + } + orgId := gpctx.OrganizationID + + workspaces, err := gitpod.Workspaces.ListWorkspaces(ctx, connect.NewRequest(&v1.ListWorkspacesRequest{ + OrganizationId: orgId, + })) + if err != nil { + return err + } + + result := make([]tabularWorkspace, 0, len(workspaces.Msg.GetResult())) + for _, ws := range workspaces.Msg.GetResult() { + r := newTabularWorkspace(ws) + if r == nil { + continue + } + if workspaceListOpts.RunningOnly && ws.Status.Instance.Status.Phase != v1.WorkspaceInstanceStatus_PHASE_RUNNING { + continue + } + result = append(result, *r) + } + + return WriteTabular(result, workspaceListOpts.Format, prettyprint.WriterFormatWide) + }, +} + +func newTabularWorkspace(ws *v1.Workspace) *tabularWorkspace { + if !helper.HasInstanceStatus(ws) { + slog.Debug("workspace has no instance status - removing from output", "workspace", ws.WorkspaceId) + return nil + } + + var repo string + wsDetails := ws.Context.GetDetails() + switch d := wsDetails.(type) { + case *v1.WorkspaceContext_Git_: + repo = fmt.Sprintf("%s/%s", d.Git.Repository.Owner, d.Git.Repository.Name) + case *v1.WorkspaceContext_Prebuild_: + repo = fmt.Sprintf("%s/%s", d.Prebuild.OriginalContext.Repository.Owner, d.Prebuild.OriginalContext.Repository.Name) + } + var branch string + if ws.Status.Instance.Status.GitStatus != nil { + branch = ws.Status.Instance.Status.GitStatus.Branch + if branch == "" || branch == "(detached)" { + branch = "" + } + } + return &tabularWorkspace{ + ID: ws.WorkspaceId, + Repository: repo, + Branch: branch, + Status: prettyprint.FormatWorkspacePhase(ws.Status.Instance.Status.Phase), + } +} + +type tabularWorkspace struct { + ID string `print:"id"` + Repository string `print:"repository"` + Branch string `print:"branch"` + Status string `print:"status"` +} + +var workspaceListOpts struct { + Format formatOpts + RunningOnly bool +} + +func init() { + workspaceCmd.AddCommand(workspaceListCmd) + addFormatFlags(workspaceListCmd, &workspaceListOpts.Format) + workspaceListCmd.Flags().BoolVarP(&workspaceListOpts.RunningOnly, "running-only", "r", false, "Only list running workspaces") +} diff --git a/components/local-app/cmd/workspace-list_test.go b/components/local-app/cmd/workspace-list_test.go new file mode 100644 index 00000000000000..aba274eedaef38 --- /dev/null +++ b/components/local-app/cmd/workspace-list_test.go @@ -0,0 +1,73 @@ +// Copyright (c) 2023 Gitpod GmbH. All rights reserved. +// Licensed under the GNU Affero General Public License (AGPL). +// See License.AGPL.txt in the project root for license information. + +package cmd + +import ( + "context" + "net/http" + "testing" + + "github.com/bufbuild/connect-go" + v1 "github.com/gitpod-io/gitpod/components/public-api/go/experimental/v1" + gitpod_experimental_v1connect "github.com/gitpod-io/gitpod/components/public-api/go/experimental/v1/v1connect" + "github.com/gitpod-io/local-app/pkg/config" +) + +func TestWorkspaceListCmd(t *testing.T) { + RunCommandTests(t, []CommandTest{ + { + Name: "no config", + Commandline: []string{"workspace", "list"}, + Expectation: CommandTestExpectation{Error: config.ErrNoContext.Error()}, + }, + { + Name: "test one workspace", + Commandline: []string{"workspace", "list"}, + Config: &config.Config{ + ActiveContext: "test", + }, + PrepServer: func(mux *http.ServeMux) { + mux.Handle(gitpod_experimental_v1connect.NewWorkspacesServiceHandler(&testWorkspaceListCmdWorkspaceSrv{ + Resp: &v1.ListWorkspacesResponse{ + Result: []*v1.Workspace{fixtureWorkspace()}, + }, + })) + }, + Expectation: CommandTestExpectation{ + Output: "ID REPOSITORY BRANCH STATUS \nworkspaceID owner/name running \n", + }, + }, + { + Name: "test no workspace", + Commandline: []string{"workspace", "list"}, + Config: &config.Config{ + ActiveContext: "test", + }, + PrepServer: func(mux *http.ServeMux) { + mux.Handle(gitpod_experimental_v1connect.NewWorkspacesServiceHandler(&testWorkspaceListCmdWorkspaceSrv{ + Resp: &v1.ListWorkspacesResponse{ + Result: []*v1.Workspace{}, + }, + })) + }, + Expectation: CommandTestExpectation{ + Output: "ID REPOSITORY BRANCH STATUS \n", + }, + }, + }) +} + +type testWorkspaceListCmdWorkspaceSrv struct { + Resp *v1.ListWorkspacesResponse + Err error + gitpod_experimental_v1connect.UnimplementedWorkspacesServiceHandler +} + +func (srv testWorkspaceListCmdWorkspaceSrv) ListWorkspaces(context.Context, *connect.Request[v1.ListWorkspacesRequest]) (*connect.Response[v1.ListWorkspacesResponse], error) { + if srv.Err != nil { + return nil, srv.Err + } + return &connect.Response[v1.ListWorkspacesResponse]{Msg: srv.Resp}, nil +} diff --git a/components/local-app/cmd/workspace-open.go b/components/local-app/cmd/workspace-open.go new file mode 100644 index 00000000000000..67860f4a9f6345 --- /dev/null +++ b/components/local-app/cmd/workspace-open.go @@ -0,0 +1,36 @@ +// Copyright (c) 2023 Gitpod GmbH. All rights reserved. +// Licensed under the GNU Affero General Public License (AGPL). +// See License.AGPL.txt in the project root for license information. + +package cmd + +import ( + "log/slog" + + "github.com/gitpod-io/local-app/pkg/helper" + "github.com/spf13/cobra" +) + +// workspaceOpenCmd opens a given workspace in its pre-configured editor +var workspaceOpenCmd = &cobra.Command{ + Use: "open ", + Short: "Opens a given workspace in its pre-configured editor", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cmd.SilenceUsage = true + + workspaceID := args[0] + + gitpod, err := getGitpodClient(cmd.Context()) + if err != nil { + return err + } + + slog.Debug("Attempting to open workspace...") + return helper.OpenWorkspaceInPreferredEditor(cmd.Context(), gitpod, workspaceID) + }, +} + +func init() { + workspaceCmd.AddCommand(workspaceOpenCmd) +} diff --git a/components/local-app/cmd/workspace-ssh.go b/components/local-app/cmd/workspace-ssh.go new file mode 100644 index 00000000000000..065782e9ea6da6 --- /dev/null +++ b/components/local-app/cmd/workspace-ssh.go @@ -0,0 +1,36 @@ +// Copyright (c) 2023 Gitpod GmbH. All rights reserved. +// Licensed under the GNU Affero General Public License (AGPL). +// See License.AGPL.txt in the project root for license information. + +package cmd + +import ( + "github.com/gitpod-io/local-app/pkg/helper" + "github.com/spf13/cobra" +) + +var dryRun bool + +// workspaceSSHCmd connects to a given workspace +var workspaceSSHCmd = &cobra.Command{ + Use: "ssh ", + Short: "Connects to a workspace via SSH", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cmd.SilenceUsage = true + + workspaceID := args[0] + + gitpod, err := getGitpodClient(cmd.Context()) + if err != nil { + return err + } + + return helper.SSHConnectToWorkspace(cmd.Context(), gitpod, workspaceID, dryRun) + }, +} + +func init() { + workspaceCmd.AddCommand(workspaceSSHCmd) + workspaceSSHCmd.Flags().BoolVarP(&dryRun, "dry-run", "n", false, "Dry run the command") +} diff --git a/components/local-app/cmd/workspace-start.go b/components/local-app/cmd/workspace-start.go new file mode 100644 index 00000000000000..07ac4defab7ca5 --- /dev/null +++ b/components/local-app/cmd/workspace-start.go @@ -0,0 +1,87 @@ +// Copyright (c) 2023 Gitpod GmbH. All rights reserved. +// Licensed under the GNU Affero General Public License (AGPL). +// See License.AGPL.txt in the project root for license information. + +package cmd + +import ( + "context" + "log/slog" + "time" + + "github.com/bufbuild/connect-go" + v1 "github.com/gitpod-io/gitpod/components/public-api/go/experimental/v1" + "github.com/gitpod-io/local-app/pkg/helper" + "github.com/spf13/cobra" +) + +// workspaceStartCmd starts to a given workspace +var workspaceStartCmd = &cobra.Command{ + Use: "start ", + Short: "Start a given workspace", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cmd.SilenceUsage = true + + workspaceID := args[0] + + ctx, cancel := context.WithTimeout(cmd.Context(), 5*time.Minute) + defer cancel() + + gitpod, err := getGitpodClient(ctx) + if err != nil { + return err + } + + slog.Info("starting workspace...") + wsInfo, err := gitpod.Workspaces.StartWorkspace(ctx, connect.NewRequest(&v1.StartWorkspaceRequest{WorkspaceId: workspaceID})) + if err != nil { + return err + } + + if wsInfo.Msg.GetResult().Status.Instance.Status.Phase == v1.WorkspaceInstanceStatus_PHASE_RUNNING { + slog.Info("workspace already running") + return nil + } + + if workspaceStartOpts.DontWait { + slog.Info("workspace initialization started") + return nil + } + + _, err = helper.ObserveWorkspaceUntilStarted(ctx, gitpod, workspaceID) + if err != nil { + return err + } + + switch { + case workspaceStartOpts.OpenSSH: + return helper.SSHConnectToWorkspace(ctx, gitpod, workspaceID, false) + case workspaceStartOpts.OpenEditor: + return helper.OpenWorkspaceInPreferredEditor(ctx, gitpod, workspaceID) + } + + return nil + }, +} + +type workspaceStartOptions struct { + DontWait bool + OpenSSH bool + OpenEditor bool +} + +func addWorkspaceStartOptions(cmd *cobra.Command, opts *workspaceStartOptions) { + cmd.Flags().BoolVar(&opts.DontWait, "dont-wait", false, "do not wait for workspace to fully start, only initialize") + cmd.Flags().BoolVar(&opts.OpenSSH, "ssh", false, "open an SSH connection to workspace after starting") + cmd.Flags().BoolVar(&opts.OpenEditor, "open", false, "open the workspace in an editor after starting") + + cmd.MarkFlagsMutuallyExclusive("ssh", "open") +} + +var workspaceStartOpts workspaceStartOptions + +func init() { + workspaceCmd.AddCommand(workspaceStartCmd) + addWorkspaceStartOptions(workspaceStartCmd, &workspaceStartOpts) +} diff --git a/components/local-app/cmd/workspace-stop.go b/components/local-app/cmd/workspace-stop.go new file mode 100644 index 00000000000000..32207dc49f8fa0 --- /dev/null +++ b/components/local-app/cmd/workspace-stop.go @@ -0,0 +1,99 @@ +// Copyright (c) 2023 Gitpod GmbH. All rights reserved. +// Licensed under the GNU Affero General Public License (AGPL). +// See License.AGPL.txt in the project root for license information. + +package cmd + +import ( + "context" + "log/slog" + "time" + + "github.com/bufbuild/connect-go" + v1 "github.com/gitpod-io/gitpod/components/public-api/go/experimental/v1" + "github.com/gitpod-io/local-app/pkg/prettyprint" + "github.com/spf13/cobra" +) + +var stopDontWait = false + +// workspaceStopCommand stops to a given workspace +var workspaceStopCommand = &cobra.Command{ + Use: "stop ", + Short: "Stop a given workspace", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cmd.SilenceUsage = true + workspaceID := args[0] + + ctx, cancel := context.WithTimeout(cmd.Context(), 5*time.Minute) + defer cancel() + + gitpod, err := getGitpodClient(ctx) + if err != nil { + return err + } + + slog.Info("stopping workspace...") + wsInfo, err := gitpod.Workspaces.StopWorkspace(ctx, connect.NewRequest(&v1.StopWorkspaceRequest{WorkspaceId: workspaceID})) + if err != nil { + return err + } + + currentPhase := wsInfo.Msg.GetResult().Status.Instance.Status.Phase + + if currentPhase == v1.WorkspaceInstanceStatus_PHASE_STOPPED { + slog.Info("workspace is already stopped") + return nil + } + if currentPhase == v1.WorkspaceInstanceStatus_PHASE_STOPPING { + slog.Info("workspace is already stopping") + return nil + } + if stopDontWait { + slog.Info("workspace stopping") + return nil + } + + stream, err := gitpod.Workspaces.StreamWorkspaceStatus(ctx, connect.NewRequest(&v1.StreamWorkspaceStatusRequest{WorkspaceId: workspaceID})) + + if err != nil { + return err + } + + slog.Info("waiting for workspace to stop...") + slog.Info("workspace " + prettyprint.FormatWorkspacePhase(currentPhase)) + + previousStatus := "" + + for stream.Receive() { + msg := stream.Msg() + if msg == nil { + slog.Debug("no message received") + continue + } + + if msg.GetResult().Instance.Status.Phase == v1.WorkspaceInstanceStatus_PHASE_STOPPED { + slog.Info("workspace stopped") + break + } + + currentStatus := prettyprint.FormatWorkspacePhase(msg.GetResult().Instance.Status.Phase) + if currentStatus != previousStatus { + slog.Info("workspace " + currentStatus) + previousStatus = currentStatus + } + } + + if err := stream.Err(); err != nil { + return err + } + + return nil + }, +} + +func init() { + workspaceCmd.AddCommand(workspaceStopCommand) + workspaceStopCommand.Flags().BoolVarP(&stopDontWait, "dont-wait", "d", false, "do not wait for workspace to fully stop, only initialize") +} diff --git a/components/local-app/cmd/workspace-up.go b/components/local-app/cmd/workspace-up.go new file mode 100644 index 00000000000000..c7505ac40d3477 --- /dev/null +++ b/components/local-app/cmd/workspace-up.go @@ -0,0 +1,294 @@ +// Copyright (c) 2023 Gitpod GmbH. All rights reserved. +// Licensed under the GNU Affero General Public License (AGPL). +// See License.AGPL.txt in the project root for license information. + +package cmd + +import ( + "bytes" + "context" + "errors" + "fmt" + "log/slog" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/bufbuild/connect-go" + v1 "github.com/gitpod-io/gitpod/components/public-api/go/experimental/v1" + "github.com/gitpod-io/local-app/pkg/config" + "github.com/gitpod-io/local-app/pkg/helper" + "github.com/gitpod-io/local-app/pkg/prettyprint" + "github.com/go-git/go-git/v5" + gitcfg "github.com/go-git/go-git/v5/config" + "github.com/gookit/color" + "github.com/melbahja/goph" + "github.com/spf13/cobra" + "golang.org/x/crypto/ssh" +) + +// workspaceUpCmd creates a new workspace +var workspaceUpCmd = &cobra.Command{ + Use: "up [path/to/git/working-copy]", + Hidden: true, + Short: "Creates a new workspace, pushes the Git working copy and adds it as remote", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cmd.SilenceUsage = true + + workingDir := "." + if len(args) != 0 { + workingDir = args[0] + } + + cfg := config.FromContext(cmd.Context()) + gpctx, err := cfg.GetActiveContext() + if err != nil { + return err + } + gitpod, err := getGitpodClient(cmd.Context()) + if err != nil { + return err + } + + if workspaceCreateOpts.WorkspaceClass != "" { + resp, err := gitpod.Workspaces.ListWorkspaceClasses(cmd.Context(), connect.NewRequest(&v1.ListWorkspaceClassesRequest{})) + if err != nil { + return prettyprint.AddApology(prettyprint.AddResolution(fmt.Errorf("cannot list workspace classes: %w", err), + "don't pass an explicit workspace class, i.e. omit the --class flag", + )) + } + var ( + classes []string + found bool + ) + for _, cls := range resp.Msg.GetResult() { + classes = append(classes, cls.Id) + if cls.Id == workspaceCreateOpts.WorkspaceClass { + found = true + } + } + if !found { + return prettyprint.AddResolution(fmt.Errorf("workspace class %s not found", workspaceCreateOpts.WorkspaceClass), + fmt.Sprintf("use one of the available workspace classes: %s", strings.Join(classes, ", ")), + ) + } + } + + if workspaceCreateOpts.Editor != "" { + resp, err := gitpod.Editors.ListEditorOptions(cmd.Context(), connect.NewRequest(&v1.ListEditorOptionsRequest{})) + if err != nil { + return prettyprint.AddApology(prettyprint.AddResolution(fmt.Errorf("cannot list editor options: %w", err), + "don't pass an explicit editor, i.e. omit the --editor flag", + )) + } + var ( + editors []string + found bool + ) + for _, editor := range resp.Msg.GetResult() { + editors = append(editors, editor.Id) + if editor.Id == workspaceCreateOpts.Editor { + found = true + } + } + if !found { + return prettyprint.AddResolution(fmt.Errorf("editor %s not found", workspaceCreateOpts.Editor), + fmt.Sprintf("use one of the available editor options: %s", strings.Join(editors, ", ")), + ) + } + } + + var ( + orgId = gpctx.OrganizationID + ctx = cmd.Context() + ) + + defer func() { + // If the error doesn't have a resolution, assume it's a system error and add an apology + if err != nil && !errors.Is(err, &prettyprint.ErrResolution{}) { + err = prettyprint.AddApology(err) + } + }() + + currentDir, err := filepath.Abs(workingDir) + if err != nil { + return err + } + for { + // Check if current directory contains .git folder + _, err := os.Stat(filepath.Join(currentDir, ".git")) + if err == nil { + break + } + if !os.IsNotExist(err) { + return err + } + + // Move to the parent directory + parentDir := filepath.Dir(currentDir) + if parentDir == currentDir { + // No more parent directories + return prettyprint.AddResolution(fmt.Errorf("no Git repository found"), + fmt.Sprintf("make sure %s is a valid Git repository", workingDir), + "run `git clone` to clone an existing repository", + "open a remote repository using `{gitpod} workspace create `", + ) + } + currentDir = parentDir + } + + slog.Debug("found Git working copy", "dir", currentDir) + repo, err := git.PlainOpen(currentDir) + if err != nil { + return prettyprint.AddApology(fmt.Errorf("cannot open Git working copy at %s: %w", currentDir, err)) + } + _ = repo.DeleteRemote("gitpod") + head, err := repo.Head() + if err != nil { + return prettyprint.AddApology(fmt.Errorf("cannot get HEAD: %w", err)) + } + branch := head.Name().Short() + + newWorkspace, err := gitpod.Workspaces.CreateAndStartWorkspace(ctx, connect.NewRequest( + &v1.CreateAndStartWorkspaceRequest{ + Source: &v1.CreateAndStartWorkspaceRequest_ContextUrl{ContextUrl: "GITPODCLI_CONTENT_INIT=push/https://github.com/gitpod-io/empty"}, + OrganizationId: orgId, + StartSpec: &v1.StartWorkspaceSpec{ + IdeSettings: &v1.IDESettings{ + DefaultIde: workspaceCreateOpts.Editor, + UseLatestVersion: false, + }, + WorkspaceClass: workspaceCreateOpts.WorkspaceClass, + }, + }, + )) + if err != nil { + return err + } + workspaceID := newWorkspace.Msg.WorkspaceId + if len(workspaceID) == 0 { + return prettyprint.AddApology(prettyprint.AddResolution(fmt.Errorf("workspace was not created"), + "try to create the workspace again", + )) + } + ws, err := helper.ObserveWorkspaceUntilStarted(ctx, gitpod, workspaceID) + if err != nil { + return err + } + slog.Debug("workspace started", "workspaceID", workspaceID) + + token, err := gitpod.Workspaces.GetOwnerToken(ctx, connect.NewRequest(&v1.GetOwnerTokenRequest{WorkspaceId: workspaceID})) + if err != nil { + return err + } + var ( + ownerToken = token.Msg.Token + host = strings.TrimPrefix(strings.ReplaceAll(ws.Instance.Status.Url, workspaceID, workspaceID+".ssh"), "https://") + ) + sess, err := goph.NewConn(&goph.Config{ + User: fmt.Sprintf("%s#%s", workspaceID, ownerToken), + Addr: host, + Callback: ssh.InsecureIgnoreHostKey(), + Timeout: 10 * time.Second, + Port: 22, + }) + if err != nil { + return prettyprint.AddResolution(fmt.Errorf("cannot connect to workspace: %w", err), + "make sure you can connect to SSH servers on port 22", + ) + } + defer sess.Close() + + slog.Debug("initializing remote workspace Git repository") + err = runSSHCommand(ctx, sess, "rm", "-r", "/workspace/empty/.git") + if err != nil { + return err + } + err = runSSHCommand(ctx, sess, "git", "init", "/workspace/remote") + if err != nil { + return err + } + + slog.Debug("pushing to workspace") + sshRemote := fmt.Sprintf("%s#%s@%s:/workspace/remote", workspaceID, ownerToken, helper.WorkspaceSSHHost(&v1.Workspace{WorkspaceId: workspaceID, Status: ws})) + _, err = repo.CreateRemote(&gitcfg.RemoteConfig{ + Name: "gitpod", + URLs: []string{sshRemote}, + }) + if err != nil { + return fmt.Errorf("cannot create remote: %w", err) + } + + // Pushing using Go git is tricky because of the SSH host verification. Shelling out to git is easier. + slog.Info("pushing to local working copy to remote workspace") + pushcmd := exec.Command("git", "push", "--progress", "gitpod") + pushcmd.Stdout = os.Stdout + pushcmd.Stderr = os.Stderr + pushcmd.Dir = currentDir + pushcmd.Env = append(os.Environ(), "GIT_SSH_COMMAND=ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null") + err = pushcmd.Run() + if err != nil { + return fmt.Errorf("cannot push to remote: %w", err) + } + + slog.Debug("checking out branch in workspace") + err = runSSHCommand(ctx, sess, "sh -c 'cd /workspace/empty && git clone /workspace/remote .'") + if err != nil { + return err + } + err = runSSHCommand(ctx, sess, "sh -c 'cd /workspace/empty && git checkout "+branch+"'") + if err != nil { + return err + } + err = runSSHCommand(ctx, sess, "sh -c 'cd /workspace/empty && git config receive.denyCurrentBranch ignore'") + if err != nil { + return err + } + + doneBanner := fmt.Sprintf("\n\n%s\n\nDon't forget to pull your changes to your local working copy before stopping the workspace.\nUse `cd %s && git pull gitpod %s`\n\n", color.New(color.FgGreen, color.Bold).Sprintf("Workspace ready!"), currentDir, branch) + slog.Info(doneBanner) + + switch { + case workspaceCreateOpts.StartOpts.OpenSSH: + err = helper.SSHConnectToWorkspace(ctx, gitpod, workspaceID, false) + if err != nil && err.Error() == "exit status 255" { + err = nil + } else if err != nil { + return err + } + case workspaceCreateOpts.StartOpts.OpenEditor: + return helper.OpenWorkspaceInPreferredEditor(ctx, gitpod, workspaceID) + default: + slog.Info("Access your workspace at", "url", ws.Instance.Status.Url) + } + return nil + }, +} + +func runSSHCommand(ctx context.Context, sess *goph.Client, name string, args ...string) error { + cmd, err := sess.Command(name, args...) + if err != nil { + return err + } + out := bytes.NewBuffer(nil) + cmd.Stdout = out + cmd.Stderr = out + slog.Debug("running remote command", "cmd", name, "args", args) + + err = cmd.Run() + if err != nil { + return fmt.Errorf("%w: %s", err, out.String()) + } + return nil +} + +func init() { + workspaceCmd.AddCommand(workspaceUpCmd) + addWorkspaceStartOptions(workspaceUpCmd, &workspaceCreateOpts.StartOpts) + + workspaceUpCmd.Flags().StringVar(&workspaceCreateOpts.WorkspaceClass, "class", "", "the workspace class") + workspaceUpCmd.Flags().StringVar(&workspaceCreateOpts.Editor, "editor", "code", "the editor to use") +} diff --git a/components/local-app/cmd/workspace.go b/components/local-app/cmd/workspace.go new file mode 100644 index 00000000000000..e0b7870de46215 --- /dev/null +++ b/components/local-app/cmd/workspace.go @@ -0,0 +1,19 @@ +// Copyright (c) 2023 Gitpod GmbH. All rights reserved. +// Licensed under the GNU Affero General Public License (AGPL). +// See License.AGPL.txt in the project root for license information. + +package cmd + +import ( + "github.com/spf13/cobra" +) + +var workspaceCmd = &cobra.Command{ + Use: "workspace", + Short: "Interact with workspaces", + Aliases: []string{"workspaces", "ws"}, +} + +func init() { + rootCmd.AddCommand(workspaceCmd) +} diff --git a/components/local-app/go.mod b/components/local-app/go.mod index b2e52c3b40cbc8..b8ac4b9110bb21 100644 --- a/components/local-app/go.mod +++ b/components/local-app/go.mod @@ -9,42 +9,89 @@ require ( github.com/gitpod-io/gitpod/local-app/api v0.0.0-00010101000000-000000000000 github.com/gitpod-io/gitpod/supervisor/api v0.0.0-00010101000000-000000000000 github.com/golang/mock v1.6.0 - github.com/google/go-cmp v0.5.8 - github.com/google/uuid v1.1.2 + github.com/google/go-cmp v0.5.9 + github.com/google/uuid v1.3.0 github.com/improbable-eng/grpc-web v0.14.0 - github.com/kevinburke/ssh_config v1.1.0 + github.com/kevinburke/ssh_config v1.2.0 github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f // indirect github.com/rs/cors v1.8.0 // indirect - github.com/sirupsen/logrus v1.8.1 + github.com/sirupsen/logrus v1.9.3 github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 - github.com/urfave/cli/v2 v2.3.0 github.com/zalando/go-keyring v0.1.1 - golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a - golang.org/x/oauth2 v0.0.0-20220822191816-0ebed06d0094 - golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 - google.golang.org/grpc v1.49.0 - google.golang.org/protobuf v1.28.1 + golang.org/x/crypto v0.14.0 + golang.org/x/oauth2 v0.12.0 + golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 + google.golang.org/grpc v1.58.2 + google.golang.org/protobuf v1.31.0 nhooyr.io/websocket v1.8.7 // indirect ) +require ( + github.com/bufbuild/connect-go v1.10.0 + github.com/gitpod-io/gitpod/components/public-api/go v0.0.0-00010101000000-000000000000 + github.com/gookit/color v1.5.4 + github.com/lmittmann/tint v1.0.3 + github.com/mattn/go-isatty v0.0.17 + github.com/sagikazarmark/slog-shim v0.1.0 + github.com/spf13/cobra v1.7.0 + github.com/urfave/cli/v2 v2.19.3 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + dario.cat/mergo v1.0.0 // indirect + github.com/Microsoft/go-winio v0.6.1 // indirect + github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371 // indirect + github.com/acomagu/bufpipe v1.0.4 // indirect + github.com/cloudflare/circl v1.3.3 // indirect + github.com/cyphar/filepath-securejoin v0.2.4 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/emirpasic/gods v1.18.1 // indirect + github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect + github.com/go-git/go-billy/v5 v5.5.0 // indirect + github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect + github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/kr/fs v0.1.0 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/pjbgf/sha1cd v0.3.0 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/pkg/sftp v1.13.5 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/sergi/go-diff v1.1.0 // indirect + github.com/skeema/knownhosts v1.2.0 // indirect + github.com/stretchr/objx v0.5.0 // indirect + github.com/xanzy/ssh-agent v0.3.3 // indirect + github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 // indirect + golang.org/x/mod v0.12.0 // indirect + golang.org/x/tools v0.13.0 // indirect + gopkg.in/warnings.v0 v0.1.2 // indirect +) + require ( github.com/cenkalti/backoff/v4 v4.1.3 // indirect - github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d // indirect + github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect github.com/danieljoos/wincred v1.1.0 // indirect + github.com/go-git/go-git/v5 v5.10.0 github.com/godbus/dbus/v5 v5.0.3 // indirect - github.com/golang/protobuf v1.5.2 // indirect + github.com/golang/protobuf v1.5.3 // indirect github.com/gorilla/websocket v1.5.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.11.3 // indirect - github.com/klauspost/compress v1.10.3 // indirect - github.com/russross/blackfriday/v2 v2.0.1 // indirect - github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/klauspost/compress v1.17.0 // indirect + github.com/melbahja/goph v1.4.0 + github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/sourcegraph/jsonrpc2 v0.0.0-20200429184054-15c2290dcb37 // indirect - golang.org/x/net v0.0.0-20220624214902-1bab6f366d9e // indirect - golang.org/x/sys v0.5.0 // indirect - golang.org/x/text v0.10.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect + golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect + golang.org/x/net v0.17.0 // indirect + golang.org/x/sys v0.13.0 + golang.org/x/text v0.13.0 // indirect google.golang.org/appengine v1.6.7 // indirect - google.golang.org/genproto v0.0.0-20220822174746-9e6da59bd2fc // indirect - gopkg.in/yaml.v2 v2.4.0 // indirect + google.golang.org/genproto v0.0.0-20230913181813-007df8e322eb // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20230913181813-007df8e322eb // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20230920204549-e6e6cdab5c13 // indirect ) replace github.com/gitpod-io/gitpod/gitpod-protocol => ../gitpod-protocol/go // leeway @@ -52,3 +99,5 @@ replace github.com/gitpod-io/gitpod/gitpod-protocol => ../gitpod-protocol/go // replace github.com/gitpod-io/gitpod/local-app/api => ../local-app-api/go // leeway replace github.com/gitpod-io/gitpod/supervisor/api => ../supervisor-api/go // leeway + +replace github.com/gitpod-io/gitpod/components/public-api/go => ../public-api/go // leeway diff --git a/components/local-app/go.sum b/components/local-app/go.sum index 108c789b078ffa..98d487515a2aad 100644 --- a/components/local-app/go.sum +++ b/components/local-app/go.sum @@ -1,22 +1,56 @@ -github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= +dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= +github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow= +github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM= +github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371 h1:kkhsdkhsCvIsutKu5zLMgWtgh9YxGCNAw8Ad8hjwfYg= +github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0= +github.com/acomagu/bufpipe v1.0.4 h1:e3H4WUzM3npvo5uv95QuJM3cQspFNtFBzvJ2oNjKIDQ= +github.com/acomagu/bufpipe v1.0.4/go.mod h1:mxdxdup/WdsKVreO5GpW4+M/1CE2sMG4jeGJ2sYmHc4= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= +github.com/bufbuild/connect-go v1.10.0 h1:QAJ3G9A1OYQW2Jbk3DeoJbkCxuKArrvZgDt47mjdTbg= +github.com/bufbuild/connect-go v1.10.0/go.mod h1:CAIePUgkDR5pAFaylSMtNK45ANQjp9JvpluG20rhpV8= +github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0= github.com/cenkalti/backoff/v4 v4.1.3 h1:cFAlzYUlVYDysBEH2T5hyJZMh3+5+WCBvSnK6Q8UtC4= github.com/cenkalti/backoff/v4 v4.1.3/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInqkPWOWmG2CLw= -github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d h1:U+s90UTSYgptZMwQh2aRr3LuazLJIa+Pg3Kc1ylSYVY= -github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= +github.com/cloudflare/circl v1.3.3 h1:fE/Qz0QdIGqeWfnwq0RE0R7MI51s0M2E4Ga9kq5AEMs= +github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA= +github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w= +github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/cyphar/filepath-securejoin v0.2.4 h1:Ugdm7cg7i6ZK6x3xDF1oEu1nfkyfH53EtKeQYTC3kyg= +github.com/cyphar/filepath-securejoin v0.2.4/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4= github.com/danieljoos/wincred v1.1.0 h1:3RNcEpBg4IhIChZdFRSdlQt1QjCp1sMAPIrOnm7Yf8g= github.com/danieljoos/wincred v1.1.0/go.mod h1:XYlo+eRTsVA9aHGp7NGjFkPla4m+DCL7hqDjlFjiygg= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/desertbit/timer v0.0.0-20180107155436-c41aec40b27f h1:U5y3Y5UE0w7amNe7Z5G/twsBW0KEalRQXZzf8ufSh9I= github.com/desertbit/timer v0.0.0-20180107155436-c41aec40b27f/go.mod h1:xH/i4TFMt8koVQZ6WFms69WAsDWr2XsYL3Hkl7jkoLE= github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM= github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= +github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a h1:mATvB/9r/3gvcejNsXKSkQ6lcIaNec2nyfOdlTBR2lU= +github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a/go.mod h1:Ro8st/ElPeALwNFlcTpWmkr6IoMFfkjXAvTHpevnDsM= +github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= +github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= github.com/gin-gonic/gin v1.5.0/go.mod h1:Nd6IXA8m5kNZdNEHMBd93KT+mdY3+bewLgRvmCsR2Do= github.com/gin-gonic/gin v1.6.3 h1:ahKqKTFpO5KTPHxWZjEdPScmYaGtLo8Y4DMHoEsnp14= github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M= +github.com/gliderlabs/ssh v0.3.5 h1:OcaySEmAQJgyYcArR+gGGTHCyE7nvhEMTlYY+Dp8CpY= +github.com/gliderlabs/ssh v0.3.5/go.mod h1:8XB4KraRrX39qHhT6yxPsHedjA08I/uBVwj4xC+/+z4= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= +github.com/go-git/go-billy/v5 v5.5.0 h1:yEY4yhzCDuMGSv83oGxiBotRzhwhNr8VZyphhiu+mTU= +github.com/go-git/go-billy/v5 v5.5.0/go.mod h1:hmexnoNsr2SJU1Ju67OaNz5ASJY3+sHgFRpCtpDCKow= +github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= +github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= +github.com/go-git/go-git/v5 v5.10.0 h1:F0x3xXrAWmhwtzoCokU4IMPcBdncG+HAAqi9FcOOjbQ= +github.com/go-git/go-git/v5 v5.10.0/go.mod h1:1FOZ/pQnqw24ghP2n7cunVl0ON55BsjPYvhWHvZGhoo= github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.12.1/go.mod h1:IUMDtCfWo/w/mtMfIE/IG2K+Ey3ygWanZIBtBW0W2TM= github.com/go-playground/locales v0.13.0 h1:HyWk6mgj5qFqCT5fjGBuRArbVDfE4hi8+e8ceBS/t7Q= @@ -34,8 +68,10 @@ github.com/gobwas/ws v1.0.2 h1:CoAavW/wd/kulfZmSIBt6p24n4j7tHgNVCjsfHVNUbo= github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM= github.com/godbus/dbus/v5 v5.0.3 h1:ZqHaoEF7TBzh4jzPmqVhE/5A1z9of6orkAe5uHoAeME= github.com/godbus/dbus/v5 v5.0.3/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/golang/glog v1.0.0 h1:nfP3RFugxnNRyKgeWd4oI1nYvXpxrx8ck8ZrcizshdQ= -github.com/golang/glog v1.0.0/go.mod h1:EWib/APOK0SL3dFbYqvxE3UYd8E6s1ouQ7iEp/0LWV4= +github.com/golang/glog v1.1.0 h1:/d3pCKDPWNnvIWe0vVUpNP32qc8U3PDVxySP/y360qE= +github.com/golang/glog v1.1.0/go.mod h1:pfYeQZ3JWZoXTV5sFc986z3HTpwQs9At6P4ImfuP3NQ= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= @@ -43,15 +79,17 @@ github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5y github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= -github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw= -github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg= -github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/uuid v1.1.2 h1:EVhdT+1Kseyi1/pUmXKaFxYsDNy9RQYkMWRH68J/W7Y= -github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gookit/color v1.5.4 h1:FZmqs7XOyGgCAxmWyPslpiok1k05wmY3SJTytgvYFs0= +github.com/gookit/color v1.5.4/go.mod h1:pZJOeOS8DM43rXbp4AZo1n9zCU2qjpcRko0b6/QJi9w= github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= @@ -59,114 +97,230 @@ github.com/grpc-ecosystem/grpc-gateway/v2 v2.11.3 h1:lLT7ZLSzGLI08vc9cpd+tYmNWjd github.com/grpc-ecosystem/grpc-gateway/v2 v2.11.3/go.mod h1:o//XUCC/F+yRGJoPO/VU0GSB0f8Nhgmxx0VIRUvaC0w= github.com/improbable-eng/grpc-web v0.14.0 h1:GdoK+cXABdB+1keuqsV1drSFO2XLYIxqt/4Rj8SWGBk= github.com/improbable-eng/grpc-web v0.14.0/go.mod h1:6hRR09jOEG81ADP5wCQju1z71g6OL4eEvELdran/3cs= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= -github.com/json-iterator/go v1.1.9 h1:9yzud/Ht36ygwatGx56VwCZtlI/2AD15T1X2sjSuGns= github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= -github.com/kevinburke/ssh_config v1.1.0 h1:pH/t1WS9NzT8go394IqZeJTMHVm6Cr6ZJ6AQ+mdNo/o= -github.com/kevinburke/ssh_config v1.1.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= -github.com/klauspost/compress v1.10.3 h1:OP96hzwJVBIHYU52pVTI6CczrxPvrGfgqF9N5eTO0Q8= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= +github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= github.com/klauspost/compress v1.10.3/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= +github.com/klauspost/compress v1.17.0 h1:Rnbp4K9EjcDuVuHtd0dgA4qNuv9yKDYKK1ulpJwgrqM= +github.com/klauspost/compress v1.17.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= +github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/leodido/go-urn v1.1.0/go.mod h1:+cyI34gQWZcE1eQU7NVgKkkzdXDQHr1dBMtdAPozLkw= github.com/leodido/go-urn v1.2.0 h1:hpXL4XnriNwQ/ABnpepYM/1vCLWNDfUNts8dX3xTG6Y= github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII= +github.com/lmittmann/tint v1.0.3 h1:W5PHeA2D8bBJVvabNfQD/XW9HPLZK1XoPZH0cq8NouQ= +github.com/lmittmann/tint v1.0.3/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE= +github.com/matryer/is v1.2.0 h1:92UTHpy8CDwaJ08GqLDzhhuixiBUUD1p3AU6PHddz4A= +github.com/matryer/is v1.2.0/go.mod h1:2fLPjFQM9rhQ15aVEtbuwhJinnOqrmgXPNdZsdwlWXA= github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ= -github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= -github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc= +github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng= +github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/melbahja/goph v1.4.0 h1:z0PgDbBFe66lRYl3v5dGb9aFgPy0kotuQ37QOwSQFqs= +github.com/melbahja/goph v1.4.0/go.mod h1:uG+VfK2Dlhk+O32zFrRlc3kYKTlV6+BtvPWd/kK7U68= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742 h1:Esafd1046DLDQ0W1YjYsBW+p8U2u7vzgW2SQVmlNazg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f h1:KUppIJq7/+SVif2QVs3tOP0zanoHgBEVAwHxUSIzRqU= github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/onsi/gomega v1.27.10 h1:naR28SdDFlqrG6kScpT8VWpu1xWY5nJRCF3XaYyBjhI= +github.com/onsi/gomega v1.27.10/go.mod h1:RsS8tutOdbdgzbPtzzATp12yT7kM5I5aElG3evPbQ0M= +github.com/pjbgf/sha1cd v0.3.0 h1:4D5XXmUUBUl/xQ6IjCkEAbqXskkq/4O7LmGn0AqMDs4= +github.com/pjbgf/sha1cd v0.3.0/go.mod h1:nZ1rrWOcGJ5uZgEEVL1VUM9iRQiZvWdbZjkKyFzPPsI= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/sftp v1.13.5 h1:a3RLUqkyjYRtBTZJZ1VRrKbN3zhuPLlUc3sphVz81go= +github.com/pkg/sftp v1.13.5/go.mod h1:wHDZ0IZX6JcBYRK1TH9bcVq8G7TLpVHYIGJRFnmPfxg= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= +github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= github.com/rs/cors v1.8.0 h1:P2KMzcFwrPoSjkF1WLRPsp3UMLyql8L4v9hQpVeK5so= github.com/rs/cors v1.8.0/go.mod h1:EBwu+T5AvHOcXwvZIkQFjUN6s8Czyqw12GL/Y0tUyRM= -github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q= -github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= -github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= -github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE= -github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= +github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= +github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= +github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= +github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/skeema/knownhosts v1.2.0 h1:h9r9cf0+u7wSE+M183ZtMGgOJKiL96brpaz5ekfJCpM= +github.com/skeema/knownhosts v1.2.0/go.mod h1:g4fPeYpque7P0xefxtGzV81ihjC8sX2IqpAoNkjxbMo= github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EEf9cgbU6AtGPK4CTG3Zf6CKMNqf0MHTggAUA= github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog= github.com/sourcegraph/jsonrpc2 v0.0.0-20200429184054-15c2290dcb37 h1:marA1XQDC7N870zmSFIoHZpIUduK80USeY0Rkuflgp4= github.com/sourcegraph/jsonrpc2 v0.0.0-20200429184054-15c2290dcb37/go.mod h1:ZafdZgk/axhT1cvZAPOhw+95nz2I/Ra5qMlU4gTRwIo= -github.com/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4= +github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= +github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/ugorji/go v1.1.7 h1:/68gy2h+1mWMrwZFeD1kQialdSzAb432dtpeJ42ovdo= github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw= github.com/ugorji/go/codec v1.1.7 h1:2SvQaVZ1ouYrrKKwoSk2pzd4A9evlKJb9oTL+OaLUSs= github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY= -github.com/urfave/cli/v2 v2.3.0 h1:qph92Y649prgesehzOrQjdWyxFOp/QVM+6imKHad91M= -github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI= +github.com/urfave/cli/v2 v2.19.3 h1:GsJ5D2qZWF6hk/F276qqf8v/Ff4IzAUTB+CUFSNhN6M= +github.com/urfave/cli/v2 v2.19.3/go.mod h1:1CNUng3PtjQMtRzJO4FMXBQvkGtuYRxxiR9xMa7jMwI= +github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= +github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= +github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 h1:QldyIu/L63oPpyvQmHgvgickp1Yw510KJOqX7H24mg8= +github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778/go.mod h1:2MuV+tbUrU1zIOPMxZ5EncGwgmMJsa+9ucAQZXxsObs= +github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU= +github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/zalando/go-keyring v0.1.1 h1:w2V9lcx/Uj4l+dzAf1m9s+DJ1O8ROkEHnynonHjTcYE= github.com/zalando/go-keyring v0.1.1/go.mod h1:OIC+OZ28XbmwFxU/Rp9V7eKzZjamBJwRzC8UFJH9+L8= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a h1:kr2P4QFmQr29mSLA43kwrOcgcReGTfbE9N577tCTuBc= -golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= +golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= +golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= +golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc= +golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= +golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= +golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0 h1:rmsUpXtvNzj340zd98LZ4KntptpfRHwpFOHG188oHXc= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= -golang.org/x/net v0.0.0-20220624214902-1bab6f366d9e h1:TsQ7F31D3bUCLeqPT0u+yjp1guoArKaNKmCr22PYgTQ= -golang.org/x/net v0.0.0-20220624214902-1bab6f366d9e/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/oauth2 v0.0.0-20220822191816-0ebed06d0094 h1:2o1E+E8TpNLklK9nHiPiK1uzIYrIHt+cQx3ynCwq9V8= -golang.org/x/oauth2 v0.0.0-20220822191816-0ebed06d0094/go.mod h1:h4gKUeWbJ4rQPri7E0u6Gs4e9Ri2zaLxzw5DI5XGrYg= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= +golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= +golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= +golang.org/x/oauth2 v0.12.0 h1:smVPGxink+n1ZI5pkQa8y6fZT0RW0MgCO5bFpepy4B4= +golang.org/x/oauth2 v0.12.0/go.mod h1:A74bZ3aGXgCY0qaIC9Ahg6Lglin4AMAco8cIv9baba4= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= +golang.org/x/term v0.13.0 h1:bb+I9cTfFazGW51MZqBVmZy7+JEJMouUHTUSKVQLBek= +golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.10.0 h1:UpjohKhiEgNc0CSauXmwYftY1+LlaC75SJwh0SgCX58= -golang.org/x/text v0.10.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0 h1:Iey4qkscZuv0VvIt8E0neZjtPVQFSc870HQ448QgEmQ= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk= +golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/genproto v0.0.0-20220822174746-9e6da59bd2fc h1:Nf+EdcTLHR8qDNN/KfkQL0u0ssxt9OhbaWCl5C0ucEI= -google.golang.org/genproto v0.0.0-20220822174746-9e6da59bd2fc/go.mod h1:dbqgFATTzChvnt+ujMdZwITVAJHFtfyN1qUhDqEiIlk= -google.golang.org/grpc v1.49.0 h1:WTLtQzmQori5FUH25Pq4WT22oCsv8USpQ+F6rqtsmxw= -google.golang.org/grpc v1.49.0/go.mod h1:ZgQEeidpAuNRZ8iRrlBKXZQP1ghovWIVhdJRyCDK+GI= +google.golang.org/genproto v0.0.0-20230913181813-007df8e322eb h1:XFBgcDwm7irdHTbz4Zk2h7Mh+eis4nfJEFQFYzJzuIA= +google.golang.org/genproto v0.0.0-20230913181813-007df8e322eb/go.mod h1:yZTlhN0tQnXo3h00fuXNCxJdLdIdnVFVBaRJ5LWBbw4= +google.golang.org/genproto/googleapis/api v0.0.0-20230913181813-007df8e322eb h1:lK0oleSc7IQsUxO3U5TjL9DWlsxpEBemh+zpB7IqhWI= +google.golang.org/genproto/googleapis/api v0.0.0-20230913181813-007df8e322eb/go.mod h1:KjSP20unUpOx5kyQUFa7k4OJg0qeJ7DEZflGDu2p6Bk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20230920204549-e6e6cdab5c13 h1:N3bU/SQDCDyD6R528GJ/PwW9KjYcJA3dgyH+MovAkIM= +google.golang.org/genproto/googleapis/rpc v0.0.0-20230920204549-e6e6cdab5c13/go.mod h1:KSqppvjFjtoCI+KGd4PELB0qLNxdJHRGqRI09mB6pQA= +google.golang.org/grpc v1.58.2 h1:SXUpjxeVF3FKrTYQI4f4KvbGD5u2xccdYdurwowix5I= +google.golang.org/grpc v1.58.2/go.mod h1:tgX3ZQDlNJGU96V6yHh1T/JeoBQ2TXdr43YbYSsCJk0= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w= -google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= +google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE= gopkg.in/go-playground/validator.v9 v9.29.1/go.mod h1:+c9/zcJMFNgbLvly1L1V+PpxWdVbfP1avr/N00E2vyQ= +gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= +gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= -gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= nhooyr.io/websocket v1.8.7 h1:usjR2uOr/zjjkVMy0lW+PPohFok7PCow5sDjLgX4P4g= nhooyr.io/websocket v1.8.7/go.mod h1:B70DZP8IakI65RVQ51MsWP/8jndNma26DVA/nFSCgW0= diff --git a/components/local-app/leeway.Dockerfile b/components/local-app/leeway.Dockerfile index ab1daffb6c6e79..c3dcaab524405a 100644 --- a/components/local-app/leeway.Dockerfile +++ b/components/local-app/leeway.Dockerfile @@ -1,22 +1,11 @@ -# Copyright (c) 2021 Gitpod GmbH. All rights reserved. +# Copyright (c) 2023 Gitpod GmbH. All rights reserved. # Licensed under the GNU Affero General Public License (AGPL). # See License.AGPL.txt in the project root for license information. FROM cgr.dev/chainguard/wolfi-base:latest@sha256:a8c9c2888304e62c133af76f520c9c9e6b3ce6f1a45e3eaa57f6639eb8053c90 WORKDIR /app -COPY components-local-app--app/components-local-app--app-linux-amd64/local-app local-app-linux -COPY components-local-app--app/components-local-app--app-darwin-amd64/local-app local-app-darwin -COPY components-local-app--app/components-local-app--app-windows-amd64/local-app.exe local-app-windows.exe - -COPY components-local-app--app/components-local-app--app-linux-amd64/local-app local-app-linux-amd64 -COPY components-local-app--app/components-local-app--app-darwin-amd64/local-app local-app-darwin-amd64 -COPY components-local-app--app/components-local-app--app-windows-amd64/local-app.exe local-app-windows-amd64.exe - -COPY components-local-app--app/components-local-app--app-linux-arm64/local-app local-app-linux-arm64 -COPY components-local-app--app/components-local-app--app-darwin-arm64/local-app local-app-darwin-arm64 -COPY components-local-app--app/components-local-app--app-windows-arm64/local-app.exe local-app-windows-arm64.exe -COPY components-local-app--app/components-local-app--app-windows-386/local-app.exe local-app-windows-386.exe +COPY components-local-app--app/bin/* ./ ARG __GIT_COMMIT ARG VERSION diff --git a/components/local-app/main/gitpod-cli/main.go b/components/local-app/main/gitpod-cli/main.go new file mode 100644 index 00000000000000..4125a0d383b3ef --- /dev/null +++ b/components/local-app/main/gitpod-cli/main.go @@ -0,0 +1,13 @@ +// Copyright (c) 2023 Gitpod GmbH. All rights reserved. +// Licensed under the GNU Affero General Public License (AGPL). +// See License.AGPL.txt in the project root for license information. + +package main + +import ( + "github.com/gitpod-io/local-app/cmd" +) + +func main() { + cmd.Execute() +} diff --git a/components/local-app/main.go b/components/local-app/main/gitpod-local-companion/main.go similarity index 96% rename from components/local-app/main.go rename to components/local-app/main/gitpod-local-companion/main.go index 2f68d084cc9806..57b50a68ee666c 100644 --- a/components/local-app/main.go +++ b/components/local-app/main/gitpod-local-companion/main.go @@ -1,4 +1,4 @@ -// Copyright (c) 2021 Gitpod GmbH. All rights reserved. +// Copyright (c) 2023 Gitpod GmbH. All rights reserved. // Licensed under the GNU Affero General Public License (AGPL). // See License.AGPL.txt in the project root for license information. @@ -20,6 +20,7 @@ import ( appapi "github.com/gitpod-io/gitpod/local-app/api" "github.com/gitpod-io/local-app/pkg/auth" "github.com/gitpod-io/local-app/pkg/bastion" + "github.com/gitpod-io/local-app/pkg/constants" "github.com/improbable-eng/grpc-web/go/grpcweb" "github.com/sirupsen/logrus" "github.com/urfave/cli/v2" @@ -27,12 +28,8 @@ import ( "google.golang.org/grpc" ) -var ( - // Version - set during build - Version = "dev" -) - func main() { + // maintain compatibility with old keyring sshConfig := os.Getenv("GITPOD_LCA_SSH_CONFIG") if sshConfig == "" { sshConfig = filepath.Join(os.TempDir(), "gitpod_ssh_config") @@ -43,7 +40,7 @@ func main() { Usage: "connect your Gitpod workspaces", Action: DefaultCommand("run"), EnableBashCompletion: true, - Version: Version, + Version: constants.Version, Flags: []cli.Flag{ &cli.StringFlag{ Name: "gitpod-host", @@ -165,6 +162,8 @@ func run(opts runOptions) error { if opts.verbose { logrus.SetLevel(logrus.DebugLevel) } + + logrus.WithField("options", opts).Info("starting local companion") logrus.WithField("ssh_config", opts.sshConfigPath).Info("writing workspace ssh_config file") // Trailing slash(es) result in connection issues, so remove them preemptively @@ -284,7 +283,7 @@ func tryConnectToServer(gitpodUrl string, tkn string, reconnectionHandler func() CloseHandler: closeHandler, ExtraHeaders: map[string]string{ "User-Agent": "gitpod/local-companion", - "X-Client-Version": Version, + "X-Client-Version": constants.Version, }, }) if err != nil { diff --git a/components/local-app/pkg/auth/auth.go b/components/local-app/pkg/auth/auth.go index a3ba4fb10f8fbb..c0bedc3937234c 100644 --- a/components/local-app/pkg/auth/auth.go +++ b/components/local-app/pkg/auth/auth.go @@ -8,9 +8,11 @@ import ( "context" "crypto/sha256" "encoding/hex" + "encoding/json" "errors" "fmt" "io" + "log/slog" "net" "net/http" "net/url" @@ -19,18 +21,16 @@ import ( jwt "github.com/dgrijalva/jwt-go" gitpod "github.com/gitpod-io/gitpod/gitpod-protocol" - "github.com/sirupsen/logrus" + "github.com/gitpod-io/local-app/pkg/prettyprint" "github.com/skratchdot/open-golang/open" keyring "github.com/zalando/go-keyring" "golang.org/x/oauth2" "golang.org/x/xerrors" ) -const ( - keyringService = "gitpod-io" -) +const keychainServiceName = "gitpod-io" -var authScopes = []string{ +var authScopesLocalCompanion = []string{ "function:getGitpodTokenScopes", "function:getWorkspace", "function:getWorkspaces", @@ -38,6 +38,36 @@ var authScopes = []string{ "resource:default", } +func fetchValidCLIScopes(ctx context.Context, serviceURL string) ([]string, error) { + const clientId = "gitpod-cli" + + endpoint := serviceURL + "/api/oauth/inspect?client=" + clientId + + ctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil) + if err != nil { + return nil, err + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusOK { + err = json.NewDecoder(resp.Body).Decode(&authScopesLocalCompanion) + if err != nil { + return nil, err + } + return authScopesLocalCompanion, nil + } + + return nil, prettyprint.AddApology(errors.New(serviceURL + " did not provide valid scopes")) +} + type ErrInvalidGitpodToken struct { cause error } @@ -64,7 +94,7 @@ func ValidateToken(client gitpod.APIInterface, tkn string) error { for _, scope := range tknScopes { tknScopesMap[scope] = struct{}{} } - for _, scope := range authScopes { + for _, scope := range authScopesLocalCompanion { _, ok := tknScopesMap[scope] if !ok { return &ErrInvalidGitpodToken{fmt.Errorf("%v scope is missing in %v", scope, tknScopes)} @@ -75,12 +105,12 @@ func ValidateToken(client gitpod.APIInterface, tkn string) error { // SetToken returns the persisted Gitpod token func SetToken(host, token string) error { - return keyring.Set(keyringService, host, token) + return keyring.Set(keychainServiceName, host, token) } // GetToken returns the persisted Gitpod token func GetToken(host string) (token string, err error) { - tkn, err := keyring.Get(keyringService, host) + tkn, err := keyring.Get(keychainServiceName, host) if errors.Is(err, keyring.ErrNotFound) { return "", nil } @@ -89,7 +119,7 @@ func GetToken(host string) (token string, err error) { // DeleteToken deletes the persisted Gitpod token func DeleteToken(host string) error { - return keyring.Delete(keyringService, host) + return keyring.Delete(keychainServiceName, host) } // LoginOpts configure the login process @@ -97,6 +127,8 @@ type LoginOpts struct { GitpodURL string RedirectURL string AuthTimeout time.Duration + + ExtendScopes bool } const html = ` @@ -134,8 +166,9 @@ func Login(ctx context.Context, opts LoginOpts) (token string, err error) { } defer func() { - if closeErr := rl.Close(); closeErr != nil { - logrus.WithField("port", port).WithError(closeErr).Warn("Failed to to close listener") + closeErr := rl.Close() + if closeErr != nil { + slog.Debug("Failed to close listener", "port", port, "err", closeErr) } }() @@ -149,7 +182,7 @@ func Login(ctx context.Context, opts LoginOpts) (token string, err error) { if opts.RedirectURL != "" { http.Redirect(rw, req, opts.RedirectURL, http.StatusSeeOther) } else { - io.WriteString(rw, html) + _, _ = io.WriteString(rw, html) } } @@ -180,12 +213,28 @@ func Login(ctx context.Context, opts LoginOpts) (token string, err error) { conf := &oauth2.Config{ ClientID: "gplctl-1.0", ClientSecret: "gplctl-1.0-secret", // Required (even though it is marked as optional?!) - Scopes: authScopes, + Scopes: authScopesLocalCompanion, Endpoint: oauth2.Endpoint{ AuthURL: authURL.String(), TokenURL: tokenURL.String(), }, } + if opts.ExtendScopes { + authScopesLocalCompanion, err = fetchValidCLIScopes(ctx, opts.GitpodURL) + if err != nil { + return "", err + } + slog.Debug("Using CLI scopes", "scopes", authScopesLocalCompanion) + conf = &oauth2.Config{ + ClientID: "gitpod-cli", + ClientSecret: "gitpod-cli-secret", + Scopes: authScopesLocalCompanion, + Endpoint: oauth2.Endpoint{ + AuthURL: authURL.String(), + TokenURL: tokenURL.String(), + }, + } + } responseTypeParam := oauth2.SetAuthURLParam("response_type", "code") redirectURIParam := oauth2.SetAuthURLParam("redirect_uri", fmt.Sprintf("http://127.0.0.1:%d", rl.Addr().(*net.TCPAddr).Port)) codeChallengeMethodParam := oauth2.SetAuthURLParam("code_challenge_method", "S256") @@ -199,10 +248,11 @@ func Login(ctx context.Context, opts LoginOpts) (token string, err error) { // open a browser window to the authorizationURL err = open.Start(authorizationURL) if err != nil { - return "", xerrors.Errorf("cannot open browser to URL %s: %s\n", authorizationURL, err) + return "", prettyprint.AddResolution(fmt.Errorf("cannot open browser to URL %s: %s\n", authorizationURL, err), + "provide a personal access token using --token or the GITPOD_TOKEN environment variable", + ) } - authTimeout := time.NewTimer(opts.AuthTimeout * time.Second) var query url.Values var code, approved string select { @@ -213,7 +263,7 @@ func Login(ctx context.Context, opts LoginOpts) (token string, err error) { case query = <-queryChan: code = query.Get("code") approved = query.Get("approved") - case <-authTimeout.C: + case <-time.After(opts.AuthTimeout): return "", xerrors.Errorf("auth timeout after %d seconds", uint32(opts.AuthTimeout)) } @@ -248,7 +298,7 @@ func findOpenPortInRange(start, end int) (net.Listener, int, error) { for port := start; port < end; port++ { rl, err := net.Listen("tcp4", fmt.Sprintf("127.0.0.1:%d", port)) if err != nil { - logrus.WithField("port", port).WithError(err).Info("Could not open port, trying next port") + slog.Debug("could not open port, trying next port", "port", port, "err", err) continue } @@ -256,3 +306,13 @@ func findOpenPortInRange(start, end int) (net.Listener, int, error) { } return nil, 0, xerrors.Errorf("could not open any valid port in range %d - %d", start, end) } + +type AuthenticatedTransport struct { + T http.RoundTripper + Token string +} + +func (t *AuthenticatedTransport) RoundTrip(req *http.Request) (*http.Response, error) { + req.Header.Add("Authorization", "Bearer "+t.Token) + return t.T.RoundTrip(req) +} diff --git a/components/local-app/pkg/auth/auth_test.go b/components/local-app/pkg/auth/auth_test.go index 2fc10c31a32955..9adf8a8b59b4f8 100644 --- a/components/local-app/pkg/auth/auth_test.go +++ b/components/local-app/pkg/auth/auth_test.go @@ -56,7 +56,7 @@ func TestValidateToken(t *testing.T) { }, { Desc: "valid", - Scopes: authScopes, + Scopes: authScopesLocalCompanion, }, } for _, test := range tests { diff --git a/components/local-app/pkg/config/context.go b/components/local-app/pkg/config/context.go new file mode 100644 index 00000000000000..592258e00e6d36 --- /dev/null +++ b/components/local-app/pkg/config/context.go @@ -0,0 +1,145 @@ +// Copyright (c) 2023 Gitpod GmbH. All rights reserved. +// Licensed under the GNU Affero General Public License (AGPL). +// See License.AGPL.txt in the project root for license information. + +package config + +import ( + "context" + "fmt" + "net/url" + "os" + "path/filepath" + "strings" + + "github.com/gitpod-io/local-app/pkg/prettyprint" + "gopkg.in/yaml.v3" +) + +const DEFAULT_LOCATION = "~/.gitpod/config.yaml" + +type Config struct { + Filename string `yaml:"-"` + + ActiveContext string `yaml:"activeContext,omitempty"` + Contexts map[string]*ConnectionContext +} + +func (c *Config) GetActiveContext() (*ConnectionContext, error) { + if c == nil { + return nil, ErrNoContext + } + if c.ActiveContext == "" { + return nil, ErrNoContext + } + res := c.Contexts[c.ActiveContext] + if res == nil { + return nil, ErrNoContext + } + return res, nil +} + +var ErrNoContext = prettyprint.AddResolution(fmt.Errorf("no active context"), + "sign in using `{gitpod} login`", + "select an existing context using `{gitpod} config use-context`", + "create a new context using `{gitpod} config add-context`", +) + +type ConnectionContext struct { + Host *YamlURL `yaml:"host"` + OrganizationID string `yaml:"organizationID,omitempty"` + Token string `yaml:"token,omitempty"` +} + +type YamlURL struct { + *url.URL +} + +// UnmarshalYAML implements yaml.Unmarshaler +func (u *YamlURL) UnmarshalYAML(value *yaml.Node) error { + var s string + err := value.Decode(&s) + if err != nil { + return err + } + + res, err := url.Parse(s) + if err != nil { + return err + } + + *u = YamlURL{URL: res} + return nil +} + +// MarshalYAML implements yaml.Marshaler +func (u *YamlURL) MarshalYAML() (interface{}, error) { + return u.String(), nil +} + +func SaveConfig(fn string, cfg *Config) error { + fc, err := yaml.Marshal(cfg) + if err != nil { + return err + } + _ = os.MkdirAll(filepath.Dir(fn), 0755) + + err = os.WriteFile(fn, fc, 0644) + if err != nil { + return err + } + return nil +} + +func DefaultConfig() *Config { + return &Config{ + Filename: DEFAULT_LOCATION, + Contexts: make(map[string]*ConnectionContext), + } +} + +// LoadConfig loads the configuration from a file. If the file does not exist, it returns a default configuration. +// This function never returns nil, even in case of an error. If an error is returned, this function also returns the default configuration. +func LoadConfig(fn string) (res *Config, err error) { + defer func() { + if err != nil { + err = fmt.Errorf("failed to load config from %s: %w", fn, err) + } + }() + + if strings.HasPrefix(fn, "~/") { + homeDir, err := os.UserHomeDir() + if err != nil { + return nil, err + } + fn = filepath.Join(homeDir, fn[2:]) + } + + cfg := &Config{ + Filename: fn, + Contexts: make(map[string]*ConnectionContext), + } + fc, err := os.ReadFile(fn) + if err != nil { + return cfg, err + } + err = yaml.Unmarshal(fc, &cfg) + if err != nil { + return cfg, err + } + + return cfg, nil +} + +type configContextKeyTpe struct{} + +var configContextKey = configContextKeyTpe{} + +func ToContext(ctx context.Context, cfg *Config) context.Context { + return context.WithValue(ctx, configContextKey, cfg) +} + +func FromContext(ctx context.Context) *Config { + cfg, _ := ctx.Value(configContextKey).(*Config) + return cfg +} diff --git a/components/local-app/pkg/constants/constants.go b/components/local-app/pkg/constants/constants.go new file mode 100644 index 00000000000000..ab66d7e1060b6e --- /dev/null +++ b/components/local-app/pkg/constants/constants.go @@ -0,0 +1,10 @@ +// Copyright (c) 2023 Gitpod GmbH. All rights reserved. +// Licensed under the GNU Affero General Public License (AGPL). +// See License.AGPL.txt in the project root for license information. + +package constants + +var ( + // Version - set during build + Version = "dev" +) diff --git a/components/local-app/pkg/helper/workspace.go b/components/local-app/pkg/helper/workspace.go new file mode 100644 index 00000000000000..983816a08c85c3 --- /dev/null +++ b/components/local-app/pkg/helper/workspace.go @@ -0,0 +1,224 @@ +// Copyright (c) 2023 Gitpod GmbH. All rights reserved. +// Licensed under the GNU Affero General Public License (AGPL). +// See License.AGPL.txt in the project root for license information. + +package helper + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + "os" + "os/exec" + "strings" + "time" + + "github.com/bufbuild/connect-go" + "github.com/gitpod-io/gitpod/components/public-api/go/client" + v1 "github.com/gitpod-io/gitpod/components/public-api/go/experimental/v1" + "github.com/gitpod-io/local-app/pkg/prettyprint" + "github.com/skratchdot/open-golang/open" +) + +// OpenWorkspaceInPreferredEditor opens the workspace in the user's preferred editor +func OpenWorkspaceInPreferredEditor(ctx context.Context, clnt *client.Gitpod, workspaceID string) error { + workspace, err := clnt.Workspaces.GetWorkspace(ctx, connect.NewRequest(&v1.GetWorkspaceRequest{WorkspaceId: workspaceID})) + if err != nil { + return err + } + + if workspace.Msg.Result.Status.Instance.Status.Phase != v1.WorkspaceInstanceStatus_PHASE_RUNNING { + return fmt.Errorf("cannot open workspace, workspace is not running") + } + + wsUrl, err := url.Parse(workspace.Msg.Result.Status.Instance.Status.Url) + if err != nil { + return err + } + + wsHost := wsUrl.Host + + u := url.URL{ + Scheme: "https", + Host: wsHost, + Path: "_supervisor/v1/status/ide/wait/true", + } + + resp, err := http.Get(u.String()) + if err != nil { + return err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + + var response struct { + OK bool `json:"ok"` + Desktop struct { + Link string `json:"link"` + Label string `json:"label"` + ClientID string `json:"clientID"` + Kind string `json:"kind"` + } `json:"desktop"` + } + if err := json.Unmarshal(body, &response); err != nil { + return err + } + + if response.OK { + url := response.Desktop.Link + if url == "" && HasInstanceStatus(workspace.Msg.Result) { + url = workspace.Msg.Result.Status.Instance.Status.Url + } + + err := open.Run(url) + if err != nil { + if execErr, ok := err.(*exec.Error); ok && execErr.Err == exec.ErrNotFound { + return fmt.Errorf("executable file not found in $PATH: %s. Please open %s manually instead", execErr.Name, url) + } + return fmt.Errorf("failed to open workspace in editor: %w", err) + } + } else { + return fmt.Errorf("failed to open workspace in editor (workspace not ready yet)") + } + + return nil +} + +// SSHConnectToWorkspace connects to the workspace via SSH +func SSHConnectToWorkspace(ctx context.Context, clnt *client.Gitpod, workspaceID string, runDry bool) error { + workspace, err := clnt.Workspaces.GetWorkspace(ctx, connect.NewRequest(&v1.GetWorkspaceRequest{WorkspaceId: workspaceID})) + if err != nil { + return err + } + + wsInfo := workspace.Msg.GetResult() + + if wsInfo.Status.Instance.Status.Phase != v1.WorkspaceInstanceStatus_PHASE_RUNNING { + return fmt.Errorf("cannot connect, workspace is not running") + } + + token, err := clnt.Workspaces.GetOwnerToken(ctx, connect.NewRequest(&v1.GetOwnerTokenRequest{WorkspaceId: workspaceID})) + if err != nil { + return err + } + + ownerToken := token.Msg.Token + + host := WorkspaceSSHHost(wsInfo) + if runDry { + fmt.Println("ssh", fmt.Sprintf("%s#%s@%s", wsInfo.WorkspaceId, ownerToken, host), "-o", "StrictHostKeyChecking=no") + return nil + } + + slog.Debug("Connecting to" + wsInfo.Description) + command := exec.Command("ssh", fmt.Sprintf("%s#%s@%s", wsInfo.WorkspaceId, ownerToken, host), "-o", "StrictHostKeyChecking=no") + command.Stdin = os.Stdin + command.Stdout = os.Stdout + command.Stderr = os.Stderr + err = command.Run() + if err != nil { + return err + } + + return nil +} + +func WorkspaceSSHHost(ws *v1.Workspace) string { + if ws == nil || ws.Status == nil || ws.Status.Instance == nil || ws.Status.Instance.Status == nil { + return "" + } + + host := strings.Replace(ws.Status.Instance.Status.Url, ws.WorkspaceId, ws.WorkspaceId+".ssh", -1) + host = strings.Replace(host, "https://", "", -1) + + return host +} + +// HasInstanceStatus returns true if the workspace has an instance status +func HasInstanceStatus(ws *v1.Workspace) bool { + if ws == nil || ws.Status == nil || ws.Status.Instance == nil || ws.Status.Instance.Status == nil { + return false + } + + return true +} + +// ObserveWorkspaceUntilStarted waits for the workspace to start and prints the status +func ObserveWorkspaceUntilStarted(ctx context.Context, clnt *client.Gitpod, workspaceID string) (*v1.WorkspaceStatus, error) { + wsInfo, err := clnt.Workspaces.GetWorkspace(ctx, connect.NewRequest(&v1.GetWorkspaceRequest{WorkspaceId: workspaceID})) + if err != nil { + return nil, fmt.Errorf("cannot get workspace info: %w", err) + } + + ws := wsInfo.Msg.GetResult() + if ws.Status.Instance.Status.Phase == v1.WorkspaceInstanceStatus_PHASE_RUNNING { + // workspace is running - we're done + return ws.Status, nil + } + + slog.Info("waiting for workspace to start...", "workspaceID", workspaceID) + if HasInstanceStatus(wsInfo.Msg.Result) { + slog.Info("workspace " + prettyprint.FormatWorkspacePhase(wsInfo.Msg.Result.Status.Instance.Status.Phase)) + } + + var ( + maxRetries = 5 + retries = 0 + delay = 100 * time.Millisecond + ) + for { + stream, err := clnt.Workspaces.StreamWorkspaceStatus(ctx, connect.NewRequest(&v1.StreamWorkspaceStatusRequest{WorkspaceId: workspaceID})) + if err != nil { + if retries >= maxRetries { + return nil, prettyprint.AddApology(fmt.Errorf("failed to stream workspace status after %d retries: %w", maxRetries, err)) + } + retries++ + delay *= 2 + slog.Warn("failed to stream workspace status, retrying", "err", err, "retry", retries, "maxRetries", maxRetries) + continue + } + + previousStatus := "" + for stream.Receive() { + msg := stream.Msg() + if msg == nil { + slog.Debug("no message received") + continue + } + + ws := msg.GetResult() + if ws.Instance.Status.Phase == v1.WorkspaceInstanceStatus_PHASE_RUNNING { + slog.Info("workspace running") + return ws, nil + } + + var currentStatus string + if HasInstanceStatus(wsInfo.Msg.Result) { + currentStatus = prettyprint.FormatWorkspacePhase(wsInfo.Msg.Result.Status.Instance.Status.Phase) + } + if currentStatus != previousStatus { + slog.Info("workspace " + currentStatus) + previousStatus = currentStatus + } + } + if err := stream.Err(); err != nil { + if retries >= maxRetries { + return nil, prettyprint.AddApology(fmt.Errorf("failed to stream workspace status after %d retries: %w", maxRetries, err)) + } + retries++ + delay *= 2 + slog.Warn("failed to stream workspace status, retrying", "err", err, "retry", retries, "maxRetries", maxRetries) + continue + } + + return nil, prettyprint.AddApology(fmt.Errorf("workspace stream ended unexpectedly")) + } +} diff --git a/components/local-app/pkg/prettyprint/errors.go b/components/local-app/pkg/prettyprint/errors.go new file mode 100644 index 00000000000000..76bf57abc058c0 --- /dev/null +++ b/components/local-app/pkg/prettyprint/errors.go @@ -0,0 +1,88 @@ +// Copyright (c) 2023 Gitpod GmbH. All rights reserved. +// Licensed under the GNU Affero General Public License (AGPL). +// See License.AGPL.txt in the project root for license information. + +package prettyprint + +import ( + "errors" + "fmt" + "io" + "strings" + + "github.com/gookit/color" +) + +var ( + styleError = color.New(color.FgRed, color.Bold) + stylePossibleResolutions = color.New(color.FgGreen, color.Bold) + styleApology = color.New(color.FgYellow, color.Bold) +) + +// PrintError prints an error to the given writer. +func PrintError(out io.Writer, command string, err error) { + fmt.Fprintf(out, "%s%s\n\n", styleError.Sprint("Error: "), err.Error()) + + var ( + resolutions []string + apology bool + ) + for err != nil { + if r, ok := err.(*ErrResolution); ok { + resolutions = append(resolutions, r.Resolutions...) + } + if _, ok := err.(*ErrApology); ok { + apology = true + } + + err = errors.Unwrap(err) + } + if len(resolutions) > 0 { + fmt.Fprint(out, stylePossibleResolutions.Sprint("Possible resolutions:\n")) + for _, r := range resolutions { + r = strings.ReplaceAll(r, "{gitpod}", command) + fmt.Fprintf(out, " - %s\n", r) + } + fmt.Fprintln(out) + } + if apology { + fmt.Fprintf(out, "%sIt looks like the system decided to take a surprise coffee break. We're not saying it's a bug... but it's a bug. While we can't promise moonshots, our team is peeking under the hood.\n\n", styleApology.Sprint("Our apologies πŸ™‡\n")) + } +} + +// AddResolution adds a resolution to an error. Resolutions are hints that tell the user how to resolve the error. +func AddResolution(err error, resolution ...string) *ErrResolution { + return &ErrResolution{ + Err: err, + Resolutions: resolution, + } +} + +type ErrResolution struct { + Err error + Resolutions []string +} + +func (e *ErrResolution) Error() string { + return e.Err.Error() +} + +func (e *ErrResolution) Unwrap() error { + return e.Err +} + +func AddApology(err error) *ErrApology { + return &ErrApology{err} +} + +type ErrApology struct { + Err error +} + +func (e ErrApology) Error() string { + return e.Err.Error() +} + +func (e ErrApology) Unwrap() error { + return e.Err +} diff --git a/components/local-app/pkg/prettyprint/prettyprint.go b/components/local-app/pkg/prettyprint/prettyprint.go new file mode 100644 index 00000000000000..c0a6b9803cca6d --- /dev/null +++ b/components/local-app/pkg/prettyprint/prettyprint.go @@ -0,0 +1,198 @@ +// Copyright (c) 2023 Gitpod GmbH. All rights reserved. +// Licensed under the GNU Affero General Public License (AGPL). +// See License.AGPL.txt in the project root for license information. + +package prettyprint + +import ( + "fmt" + "io" + "reflect" + "strconv" + "strings" + "text/tabwriter" + + v1 "github.com/gitpod-io/gitpod/components/public-api/go/experimental/v1" +) + +func reflectTabular[T any](data []T) (header []string, rows []map[string]string, err error) { + type field struct { + Name string + Field *reflect.StructField + } + var fields []field + + var dt T + t := reflect.TypeOf(dt) + if t.Kind() == reflect.Ptr { + t = t.Elem() + } + if t.Kind() != reflect.Struct { + return nil, nil, AddApology(fmt.Errorf("can only reflect tabular data from structs")) + } + for i := 0; i < t.NumField(); i++ { + f := t.Field(i) + switch f.Type.Kind() { + case reflect.String, reflect.Bool, reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + default: + continue + } + name := f.Tag.Get("print") + if name == "" { + name = f.Name + } + fields = append(fields, field{Name: name, Field: &f}) + header = append(header, name) + } + + rows = make([]map[string]string, 0, len(rows)) + for _, row := range data { + r := make(map[string]string) + for _, f := range fields { + v := reflect.ValueOf(row) + if v.Kind() == reflect.Ptr { + v = v.Elem() + } + v = v.FieldByName(f.Field.Name) + switch v.Kind() { + case reflect.String: + r[f.Name] = v.String() + case reflect.Bool: + r[f.Name] = FormatBool(v.Bool()) + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + r[f.Name] = strconv.FormatInt(v.Int(), 10) + } + } + rows = append(rows, r) + } + + return header, rows, nil +} + +type WriterFormat int + +const ( + // WriterFormatWide makes the writer produce wide formatted output, e.g. + // FIELD ONE FIELD TWO FIELD THREE + // valueOne valueTwo valueThree + // valueOne valueTwo valueThree + WriterFormatWide WriterFormat = iota + + // WriterFormatNarrow makes the writer produce narrow formatted output, e.g. + // Field: value + WriterFormatNarrow +) + +type Writer[T any] struct { + Out io.Writer + Format WriterFormat + Field string +} + +// Write writes the given tabular data to the writer +func (w Writer[T]) Write(data []T) error { + header, rows, err := reflectTabular(data) + if err != nil { + return err + } + + tw := tabwriter.NewWriter(w.Out, 0, 4, 1, ' ', 0) + defer tw.Flush() + + switch { + case w.Field != "": + return w.writeField(tw, header, rows) + case w.Format == WriterFormatNarrow: + return w.writeNarrowFormat(tw, header, rows) + default: + return w.writeWideFormat(tw, header, rows) + } +} + +// writeField writes a single field of the given tabular data to the writer +func (w Writer[T]) writeField(tw *tabwriter.Writer, header []string, rows []map[string]string) error { + var found bool + for _, h := range header { + if h == w.Field { + found = true + break + } + } + if !found { + return AddResolution(fmt.Errorf("unknown field: %s", w.Field), "use one of the following fields: "+strings.Join(header, ", ")) + } + + for _, row := range rows { + val := row[w.Field] + if val == "" { + continue + } + _, err := tw.Write([]byte(fmt.Sprintf("%s\n", val))) + if err != nil { + return err + } + } + return nil +} + +// writeNarrowFormat writes the given tabular data to the writer in a long format +func (w Writer[T]) writeNarrowFormat(tw *tabwriter.Writer, header []string, rows []map[string]string) error { + for _, row := range rows { + for _, h := range header { + fieldName := Capitalize(h) + fieldName = strings.ReplaceAll(fieldName, "id", "ID") + + _, err := tw.Write([]byte(fmt.Sprintf("%s:\t%s\n", fieldName, row[h]))) + if err != nil { + return err + } + } + } + return nil +} + +// writeWideFormat writes the given tabular data to the writer in a short format +func (w Writer[T]) writeWideFormat(tw *tabwriter.Writer, header []string, rows []map[string]string) error { + for _, h := range header { + _, err := tw.Write([]byte(fmt.Sprintf("%s\t", strings.ToUpper(h)))) + if err != nil { + return err + } + } + _, _ = tw.Write([]byte("\n")) + for _, row := range rows { + for _, h := range header { + _, err := tw.Write([]byte(fmt.Sprintf("%s\t", row[h]))) + if err != nil { + return err + } + } + _, err := tw.Write([]byte("\n")) + if err != nil { + return err + } + } + return nil +} + +// FormatBool returns "true" or "false" depending on the value of b. +func FormatBool(b bool) string { + return strconv.FormatBool(b) +} + +// FormatWorkspacePhase returns a user-facing representation of the given workspace phase +func FormatWorkspacePhase(phase v1.WorkspaceInstanceStatus_Phase) string { + return strings.ToLower(strings.TrimPrefix(phase.String(), "PHASE_")) +} + +// Capitalize capitalizes the first letter of the given string +func Capitalize(s string) string { + if s == "" { + return "" + } + if len(s) == 1 { + return strings.ToUpper(s) + } + + return strings.ToUpper(s[0:1]) + s[1:] +} diff --git a/components/local-app/pkg/prettyprint/prettyprint_test.go b/components/local-app/pkg/prettyprint/prettyprint_test.go new file mode 100644 index 00000000000000..1d81a8109e0903 --- /dev/null +++ b/components/local-app/pkg/prettyprint/prettyprint_test.go @@ -0,0 +1,118 @@ +// Copyright (c) 2023 Gitpod GmbH. All rights reserved. +// Licensed under the GNU Affero General Public License (AGPL). +// See License.AGPL.txt in the project root for license information. + +package prettyprint + +import ( + "bytes" + "testing" + + "github.com/google/go-cmp/cmp" +) + +func TestWriterWrite(t *testing.T) { + type R struct { + Foo string `print:"foo"` + Number int `print:"number"` + DiffName string `print:"foobar"` + } + type Expectation struct { + Error string + Out string + } + tests := []struct { + Name string + Expectation Expectation + Format WriterFormat + Field string + Data []R + }{ + { + Name: "wide format with data", + Expectation: Expectation{ + Out: "FOO NUMBER FOOBAR \nfoo 42 bar \n", + }, + Format: WriterFormatWide, + Data: []R{ + {Foo: "foo", Number: 42, DiffName: "bar"}, + }, + }, + { + Name: "narrow format with data", + Expectation: Expectation{ + Out: "Foo: foo\nNumber: 42\nFoobar: bar\n", + }, + Format: WriterFormatNarrow, + Data: []R{ + {Foo: "foo", Number: 42, DiffName: "bar"}, + }, + }, + { + Name: "empty", + Expectation: Expectation{ + Out: "", + }, + Format: WriterFormatNarrow, + Field: "", + Data: nil, + }, + { + Name: "empty wide", + Expectation: Expectation{ + Out: "FOO NUMBER FOOBAR \n", + }, + Format: WriterFormatWide, + }, + { + Name: "empty field", + Expectation: Expectation{}, + Format: WriterFormatNarrow, + Field: "foo", + Data: nil, + }, + { + Name: "empty field wide", + Expectation: Expectation{}, + Format: WriterFormatWide, + Field: "foo", + Data: nil, + }, + { + Name: "empty field with data", + Expectation: Expectation{}, + Format: WriterFormatNarrow, + Field: "foo", + Data: []R{{}}, + }, + { + Name: "empty field with data wide", + Expectation: Expectation{}, + Format: WriterFormatWide, + Field: "foo", + Data: []R{{}}, + }, + } + + for _, test := range tests { + t.Run(test.Name, func(t *testing.T) { + var act Expectation + + out := bytes.NewBuffer(nil) + w := Writer[R]{ + Out: out, + Format: test.Format, + Field: test.Field, + } + err := w.Write(test.Data) + if err != nil { + act.Error = err.Error() + } + act.Out = out.String() + + if diff := cmp.Diff(test.Expectation, act); diff != "" { + t.Errorf("Write() mismatch (-want +got):\n%s", diff) + } + }) + } +} diff --git a/components/proxy/conf/Caddyfile b/components/proxy/conf/Caddyfile index 214f531678a863..fae3467ca8b897 100644 --- a/components/proxy/conf/Caddyfile +++ b/components/proxy/conf/Caddyfile @@ -335,10 +335,10 @@ https://{$GITPOD_DOMAIN} { } } - @local_app { - path /static/bin/gitpod-local-companion-* + @ide_bin { + path /static/bin/gitpod-* } - handle @local_app { + handle @ide_bin { import compression reverse_proxy ide-proxy.{$KUBE_NAMESPACE}.{$KUBE_DOMAIN}:80 { diff --git a/components/public-api/go/client/client.go b/components/public-api/go/client/client.go index cfcd46445cba1c..04463d45721d9c 100644 --- a/components/public-api/go/client/client.go +++ b/components/public-api/go/client/client.go @@ -17,10 +17,12 @@ type Gitpod struct { cfg *options Workspaces gitpod_experimental_v1connect.WorkspacesServiceClient + Editors gitpod_experimental_v1connect.EditorServiceClient Teams gitpod_experimental_v1connect.TeamsServiceClient Projects gitpod_experimental_v1connect.ProjectsServiceClient PersonalAccessTokens gitpod_experimental_v1connect.TokensServiceClient IdentityProvider gitpod_experimental_v1connect.IdentityProviderServiceClient + User gitpod_experimental_v1connect.UserServiceClient } func New(options ...Option) (*Gitpod, error) { @@ -42,19 +44,15 @@ func New(options ...Option) (*Gitpod, error) { ), } - teams := gitpod_experimental_v1connect.NewTeamsServiceClient(client, url, serviceOpts...) - projects := gitpod_experimental_v1connect.NewProjectsServiceClient(client, url, serviceOpts...) - tokens := gitpod_experimental_v1connect.NewTokensServiceClient(client, url, serviceOpts...) - workspaces := gitpod_experimental_v1connect.NewWorkspacesServiceClient(client, url, serviceOpts...) - idp := gitpod_experimental_v1connect.NewIdentityProviderServiceClient(client, url, serviceOpts...) - return &Gitpod{ cfg: opts, - Teams: teams, - Projects: projects, - PersonalAccessTokens: tokens, - Workspaces: workspaces, - IdentityProvider: idp, + Teams: gitpod_experimental_v1connect.NewTeamsServiceClient(client, url, serviceOpts...), + Projects: gitpod_experimental_v1connect.NewProjectsServiceClient(client, url, serviceOpts...), + PersonalAccessTokens: gitpod_experimental_v1connect.NewTokensServiceClient(client, url, serviceOpts...), + Workspaces: gitpod_experimental_v1connect.NewWorkspacesServiceClient(client, url, serviceOpts...), + Editors: gitpod_experimental_v1connect.NewEditorServiceClient(client, url, serviceOpts...), + IdentityProvider: gitpod_experimental_v1connect.NewIdentityProviderServiceClient(client, url, serviceOpts...), + User: gitpod_experimental_v1connect.NewUserServiceClient(client, url, serviceOpts...), }, nil } diff --git a/components/public-api/go/client/client_test.go b/components/public-api/go/client/client_test.go index d3c6a35fe6d07f..b5d7fdd8bd3310 100644 --- a/components/public-api/go/client/client_test.go +++ b/components/public-api/go/client/client_test.go @@ -5,9 +5,10 @@ package client import ( - "github.com/stretchr/testify/require" "net/http" "testing" + + "github.com/stretchr/testify/require" ) func TestNew(t *testing.T) { @@ -30,6 +31,7 @@ func TestNew(t *testing.T) { require.NotNil(t, gitpod.Workspaces) require.NotNil(t, gitpod.Projects) require.NotNil(t, gitpod.PersonalAccessTokens) + require.NotNil(t, gitpod.User) }) t.Run("fails when no credentials specified", func(t *testing.T) { diff --git a/components/server/src/oauth-server/db.ts b/components/server/src/oauth-server/db.ts index 026b4d00af032d..496054bbfae290 100644 --- a/components/server/src/oauth-server/db.ts +++ b/components/server/src/oauth-server/db.ts @@ -17,6 +17,7 @@ export interface InMemory { } // Clients + const localAppClientID = "gplctl-1.0"; const localClient: OAuthClient = { id: localAppClientID, @@ -35,6 +36,40 @@ const localClient: OAuthClient = { ], }; +const localCliClientID = "gitpod-cli"; +const localCli: OAuthClient = { + id: localCliClientID, + secret: `${localCliClientID}-secret`, + name: "Gitpod CLI", + // Set of valid redirect URIs + // NOTE: these need to be kept in sync with the port range in the local app + redirectUris: Array.from({ length: 10 }, (_, i) => "http://127.0.0.1:" + (63110 + i)), + allowedGrants: ["authorization_code"], + scopes: [ + { name: "function:listenForWorkspaceInstanceUpdates" }, + { name: "function:getGitpodTokenScopes" }, + { name: "function:getLoggedInUser" }, + { name: "function:accessCodeSyncStorage" }, + { name: "function:getOwnerToken" }, + { name: "function:getWorkspace" }, + { name: "function:getWorkspaces" }, + { name: "function:getSSHPublicKeys" }, + { name: "function:startWorkspace" }, + { name: "function:stopWorkspace" }, + { name: "function:deleteWorkspace" }, + { name: "function:getTeam" }, + { name: "function:getTeams" }, + { name: "function:getTeamMembers" }, + { name: "function:getTeamProjects" }, + { name: "function:createWorkspace" }, + { name: "function:getToken" }, + { name: "function:getSupportedWorkspaceClasses" }, + { name: "function:getSuggestedContextURLs" }, + { name: "function:getIDEOptions" }, + { name: "resource:default" }, + ], +}; + const jetBrainsGateway: OAuthClient = { id: "jetbrains-gateway-gitpod-plugin", name: "JetBrains Gateway Gitpod Plugin", @@ -117,6 +152,7 @@ const cursor = createVSCodeClient("cursor", "Cursor"); export const inMemoryDatabase: InMemory = { clients: { [localClient.id]: localClient, + [localCli.id]: localCli, [jetBrainsGateway.id]: jetBrainsGateway, [vscode.id]: vscode, [vscodeInsiders.id]: vscodeInsiders,