├── .gitignore ├── .goreleaser.yaml ├── .recode └── hooks │ └── init.sh ├── .vscode └── launch.json ├── LICENSE ├── README.md ├── go.mod ├── go.sum ├── install.sh ├── internal ├── agent │ ├── client.go │ ├── client_builder.go │ ├── init_instance.go │ └── start_dev_env.go ├── aws │ ├── errors_presenter.go │ ├── user_config_local_resolver.go │ └── user_config_local_resolver_test.go ├── cmd │ ├── aws.go │ ├── aws_remove.go │ ├── aws_start.go │ ├── aws_stop.go │ ├── aws_uninstall.go │ ├── login.go │ └── root.go ├── config │ ├── github.go │ ├── github_prod.go │ └── user_config.go ├── constants │ └── colors.go ├── dependencies │ ├── aws_remove.go │ ├── aws_shared.go │ ├── aws_start.go │ ├── aws_stop.go │ ├── aws_uninstall.go │ ├── entities.go │ ├── hooks.go │ ├── login.go │ ├── shared.go │ └── wire_gen.go ├── entities │ ├── dev_env.go │ ├── dev_env_repository_resolver.go │ └── dev_env_user_config_resolver.go ├── exceptions │ ├── requirements.go │ └── user.go ├── features │ ├── login.go │ ├── remove.go │ ├── start.go │ ├── stop.go │ └── uninstall.go ├── hooks │ ├── pre_remove.go │ └── pre_stop.go ├── interfaces │ ├── browser.go │ ├── github.go │ ├── logger.go │ ├── sleeper.go │ ├── ssh.go │ ├── user_config.go │ └── vscode.go ├── mocks │ ├── aws_user_config_env_vars_resolver.go │ ├── aws_user_config_files_resolver.go │ └── views_displayer.go ├── presenters │ ├── errors.go │ ├── login.go │ ├── remove.go │ ├── start.go │ ├── stop.go │ └── uninstall.go ├── ssh │ ├── config.go │ ├── config_add_host_test.go │ ├── config_remove_host_test.go │ ├── config_update_host_test.go │ ├── keys.go │ ├── keys_add_pem_test.go │ ├── keys_remove_pem_test.go │ ├── known_hosts.go │ ├── known_hosts_add_test.go │ ├── known_hosts_remove_test.go │ ├── port_forwarding.go │ └── testdata │ │ ├── empty_known_hosts │ │ ├── empty_ssh_config │ │ ├── non_empty_known_hosts │ │ └── non_empty_ssh_config ├── stepper │ ├── step.go │ └── stepper.go ├── system │ ├── browser.go │ ├── cli.go │ ├── displayer.go │ ├── env_vars.go │ ├── logger.go │ ├── new_line.go │ ├── paths.go │ ├── paths_test.go │ └── sleeper.go ├── views │ ├── base.go │ ├── login.go │ ├── remove.go │ ├── start.go │ ├── stop.go │ └── uninstall.go └── vscode │ ├── cli.go │ ├── extensions.go │ └── process.go └── main.go /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | out/ 3 | vendor/ 4 | *.out 5 | dist/ 6 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | # This is an example .goreleaser.yml file with some sensible defaults. 2 | # Make sure to check the documentation at https://goreleaser.com 3 | before: 4 | hooks: 5 | - go mod tidy -compat=1.17 6 | - wire gen ./... 7 | builds: 8 | - 9 | tags: 10 | - prod 11 | env: 12 | - CGO_ENABLED=0 13 | goos: 14 | - linux 15 | - darwin 16 | - windows 17 | goarch: 18 | - 386 19 | - amd64 20 | - arm 21 | - arm64 22 | binary: recode 23 | archives: 24 | - 25 | # Additional files/template/globs you want to add to the archive. 26 | # Defaults are any files matching `LICENSE*`, `README*`, `CHANGELOG*`, 27 | # `license*`, `readme*` and `changelog*`. 28 | files: 29 | checksum: 30 | name_template: 'checksums.txt' 31 | snapshot: 32 | name_template: "{{ incpatch .Version }}-next" 33 | changelog: 34 | sort: asc 35 | filters: 36 | exclude: 37 | - '^docs:' 38 | - '^test:' 39 | -------------------------------------------------------------------------------- /.recode/hooks/init.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | 4 | log () { 5 | echo -e "${1}" >&2 6 | } 7 | 8 | log "Downloading dependencies listed in go.mod" 9 | 10 | go mod download 11 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Launch Package", 9 | "type": "go", 10 | "request": "launch", 11 | "mode": "auto", 12 | "program": "main.go", 13 | "args": [ 14 | "aws", 15 | "--profile", 16 | "production", 17 | "--region", 18 | "eu-west-3", 19 | "start", 20 | "recode-sh/workspace", 21 | //"--rebuild", 22 | ] 23 | } 24 | ] 25 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright © 2022 Jeremy Levy jje.levy@gmail.com 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/recode-sh/cli 2 | 3 | go 1.17 4 | 5 | replace github.com/recode-sh/aws-cloud-provider v0.0.0 => ../aws-cloud-provider 6 | 7 | replace github.com/recode-sh/recode v0.0.0 => ../recode 8 | 9 | replace github.com/recode-sh/agent v0.0.0 => ../agent 10 | 11 | require ( 12 | github.com/aws/aws-sdk-go-v2/config v1.13.1 13 | github.com/briandowns/spinner v1.18.1 14 | github.com/golang/mock v1.6.0 15 | github.com/google/go-github/v43 v43.0.0 16 | github.com/google/wire v0.5.0 17 | github.com/jwalton/gchalk v1.3.0 18 | github.com/kevinburke/ssh_config v1.1.0 19 | github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 20 | github.com/recode-sh/agent v0.0.0 21 | github.com/recode-sh/aws-cloud-provider v0.0.0 22 | github.com/recode-sh/recode v0.0.0 23 | github.com/spf13/cobra v1.3.0 24 | github.com/spf13/viper v1.10.1 25 | golang.org/x/crypto v0.0.0-20220313003712-b769efc7c000 26 | golang.org/x/oauth2 v0.0.0-20220411215720-9780585627b5 27 | google.golang.org/grpc v1.46.2 28 | ) 29 | 30 | require ( 31 | github.com/aws/aws-sdk-go-v2 v1.15.0 // indirect 32 | github.com/aws/aws-sdk-go-v2/credentials v1.8.0 // indirect 33 | github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue v1.6.0 // indirect 34 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.10.0 // indirect 35 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.6 // indirect 36 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.0 // indirect 37 | github.com/aws/aws-sdk-go-v2/internal/ini v1.3.5 // indirect 38 | github.com/aws/aws-sdk-go-v2/service/dynamodb v1.13.0 // indirect 39 | github.com/aws/aws-sdk-go-v2/service/dynamodbstreams v1.11.0 // indirect 40 | github.com/aws/aws-sdk-go-v2/service/ec2 v1.29.0 // indirect 41 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.7.0 // indirect 42 | github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.5.0 // indirect 43 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.7.0 // indirect 44 | github.com/aws/aws-sdk-go-v2/service/sso v1.9.0 // indirect 45 | github.com/aws/aws-sdk-go-v2/service/sts v1.14.0 // indirect 46 | github.com/aws/smithy-go v1.11.1 // indirect 47 | github.com/fatih/color v1.13.0 // indirect 48 | github.com/fsnotify/fsnotify v1.5.1 // indirect 49 | github.com/golang/protobuf v1.5.2 // indirect 50 | github.com/google/go-querystring v1.1.0 // indirect 51 | github.com/google/uuid v1.3.0 // indirect 52 | github.com/gosimple/slug v1.12.0 // indirect 53 | github.com/gosimple/unidecode v1.0.1 // indirect 54 | github.com/hashicorp/hcl v1.0.0 // indirect 55 | github.com/inconshreveable/mousetrap v1.0.0 // indirect 56 | github.com/jmespath/go-jmespath v0.4.0 // indirect 57 | github.com/jsonmaur/aws-regions/v2 v2.3.1 // indirect 58 | github.com/jwalton/go-supportscolor v1.1.0 // indirect 59 | github.com/kr/text v0.2.0 // indirect 60 | github.com/magiconair/properties v1.8.5 // indirect 61 | github.com/mattn/go-colorable v0.1.12 // indirect 62 | github.com/mattn/go-isatty v0.0.14 // indirect 63 | github.com/mitchellh/mapstructure v1.4.3 // indirect 64 | github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect 65 | github.com/pelletier/go-toml v1.9.4 // indirect 66 | github.com/spf13/afero v1.6.0 // indirect 67 | github.com/spf13/cast v1.4.1 // indirect 68 | github.com/spf13/jwalterweatherman v1.1.0 // indirect 69 | github.com/spf13/pflag v1.0.5 // indirect 70 | github.com/subosito/gotenv v1.2.0 // indirect 71 | github.com/whilp/git-urls v1.0.0 // indirect 72 | golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd // indirect 73 | golang.org/x/sys v0.0.0-20220224120231-95c6836cb0e7 // indirect 74 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect 75 | golang.org/x/text v0.3.7 // indirect 76 | google.golang.org/appengine v1.6.7 // indirect 77 | google.golang.org/genproto v0.0.0-20220519153652-3a47de7e79bd // indirect 78 | google.golang.org/protobuf v1.28.0 // indirect 79 | gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect 80 | gopkg.in/ini.v1 v1.66.2 // indirect 81 | gopkg.in/yaml.v2 v2.4.0 // indirect 82 | ) 83 | -------------------------------------------------------------------------------- /install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | # Code generated by godownloader on 2022-05-30T14:47:28Z. DO NOT EDIT. 4 | # 5 | 6 | usage() { 7 | this=$1 8 | cat </dev/null 121 | } 122 | echoerr() { 123 | echo "$@" 1>&2 124 | } 125 | log_prefix() { 126 | echo "$0" 127 | } 128 | _logp=6 129 | log_set_priority() { 130 | _logp="$1" 131 | } 132 | log_priority() { 133 | if test -z "$1"; then 134 | echo "$_logp" 135 | return 136 | fi 137 | [ "$1" -le "$_logp" ] 138 | } 139 | log_tag() { 140 | case $1 in 141 | 0) echo "emerg" ;; 142 | 1) echo "alert" ;; 143 | 2) echo "crit" ;; 144 | 3) echo "err" ;; 145 | 4) echo "warning" ;; 146 | 5) echo "notice" ;; 147 | 6) echo "info" ;; 148 | 7) echo "debug" ;; 149 | *) echo "$1" ;; 150 | esac 151 | } 152 | log_debug() { 153 | log_priority 7 || return 0 154 | echoerr "$(log_prefix)" "$(log_tag 7)" "$@" 155 | } 156 | log_info() { 157 | log_priority 6 || return 0 158 | echoerr "$(log_prefix)" "$(log_tag 6)" "$@" 159 | } 160 | log_err() { 161 | log_priority 3 || return 0 162 | echoerr "$(log_prefix)" "$(log_tag 3)" "$@" 163 | } 164 | log_crit() { 165 | log_priority 2 || return 0 166 | echoerr "$(log_prefix)" "$(log_tag 2)" "$@" 167 | } 168 | uname_os() { 169 | os=$(uname -s | tr '[:upper:]' '[:lower:]') 170 | case "$os" in 171 | cygwin_nt*) os="windows" ;; 172 | mingw*) os="windows" ;; 173 | msys_nt*) os="windows" ;; 174 | esac 175 | echo "$os" 176 | } 177 | uname_arch() { 178 | arch=$(uname -m) 179 | case $arch in 180 | x86_64) arch="amd64" ;; 181 | x86) arch="386" ;; 182 | i686) arch="386" ;; 183 | i386) arch="386" ;; 184 | aarch64) arch="arm64" ;; 185 | armv5*) arch="armv5" ;; 186 | armv6*) arch="armv6" ;; 187 | armv7*) arch="armv7" ;; 188 | esac 189 | echo ${arch} 190 | } 191 | uname_os_check() { 192 | os=$(uname_os) 193 | case "$os" in 194 | darwin) return 0 ;; 195 | dragonfly) return 0 ;; 196 | freebsd) return 0 ;; 197 | linux) return 0 ;; 198 | android) return 0 ;; 199 | nacl) return 0 ;; 200 | netbsd) return 0 ;; 201 | openbsd) return 0 ;; 202 | plan9) return 0 ;; 203 | solaris) return 0 ;; 204 | windows) return 0 ;; 205 | esac 206 | log_crit "uname_os_check '$(uname -s)' got converted to '$os' which is not a GOOS value. Please file bug at https://github.com/client9/shlib" 207 | return 1 208 | } 209 | uname_arch_check() { 210 | arch=$(uname_arch) 211 | case "$arch" in 212 | 386) return 0 ;; 213 | amd64) return 0 ;; 214 | arm64) return 0 ;; 215 | armv5) return 0 ;; 216 | armv6) return 0 ;; 217 | armv7) return 0 ;; 218 | ppc64) return 0 ;; 219 | ppc64le) return 0 ;; 220 | mips) return 0 ;; 221 | mipsle) return 0 ;; 222 | mips64) return 0 ;; 223 | mips64le) return 0 ;; 224 | s390x) return 0 ;; 225 | amd64p32) return 0 ;; 226 | esac 227 | log_crit "uname_arch_check '$(uname -m)' got converted to '$arch' which is not a GOARCH value. Please file bug report at https://github.com/client9/shlib" 228 | return 1 229 | } 230 | untar() { 231 | tarball=$1 232 | case "${tarball}" in 233 | *.tar.gz | *.tgz) tar --no-same-owner -xzf "${tarball}" ;; 234 | *.tar) tar --no-same-owner -xf "${tarball}" ;; 235 | *.zip) unzip "${tarball}" ;; 236 | *) 237 | log_err "untar unknown archive format for ${tarball}" 238 | return 1 239 | ;; 240 | esac 241 | } 242 | http_download_curl() { 243 | local_file=$1 244 | source_url=$2 245 | header=$3 246 | if [ -z "$header" ]; then 247 | code=$(curl -w '%{http_code}' -sL -o "$local_file" "$source_url") 248 | else 249 | code=$(curl -w '%{http_code}' -sL -H "$header" -o "$local_file" "$source_url") 250 | fi 251 | if [ "$code" != "200" ]; then 252 | log_debug "http_download_curl received HTTP status $code" 253 | return 1 254 | fi 255 | return 0 256 | } 257 | http_download_wget() { 258 | local_file=$1 259 | source_url=$2 260 | header=$3 261 | if [ -z "$header" ]; then 262 | wget -q -O "$local_file" "$source_url" 263 | else 264 | wget -q --header "$header" -O "$local_file" "$source_url" 265 | fi 266 | } 267 | http_download() { 268 | log_debug "http_download $2" 269 | if is_command curl; then 270 | http_download_curl "$@" 271 | return 272 | elif is_command wget; then 273 | http_download_wget "$@" 274 | return 275 | fi 276 | log_crit "http_download unable to find wget or curl" 277 | return 1 278 | } 279 | http_copy() { 280 | tmp=$(mktemp) 281 | http_download "${tmp}" "$1" "$2" || return 1 282 | body=$(cat "$tmp") 283 | rm -f "${tmp}" 284 | echo "$body" 285 | } 286 | github_release() { 287 | owner_repo=$1 288 | version=$2 289 | test -z "$version" && version="latest" 290 | giturl="https://github.com/${owner_repo}/releases/${version}" 291 | json=$(http_copy "$giturl" "Accept:application/json") 292 | test -z "$json" && return 1 293 | version=$(echo "$json" | tr -s '\n' ' ' | sed 's/.*"tag_name":"//' | sed 's/".*//') 294 | test -z "$version" && return 1 295 | echo "$version" 296 | } 297 | hash_sha256() { 298 | TARGET=${1:-/dev/stdin} 299 | if is_command gsha256sum; then 300 | hash=$(gsha256sum "$TARGET") || return 1 301 | echo "$hash" | cut -d ' ' -f 1 302 | elif is_command sha256sum; then 303 | hash=$(sha256sum "$TARGET") || return 1 304 | echo "$hash" | cut -d ' ' -f 1 305 | elif is_command shasum; then 306 | hash=$(shasum -a 256 "$TARGET" 2>/dev/null) || return 1 307 | echo "$hash" | cut -d ' ' -f 1 308 | elif is_command openssl; then 309 | hash=$(openssl -dst openssl dgst -sha256 "$TARGET") || return 1 310 | echo "$hash" | cut -d ' ' -f a 311 | else 312 | log_crit "hash_sha256 unable to find command to compute sha-256 hash" 313 | return 1 314 | fi 315 | } 316 | hash_sha256_verify() { 317 | TARGET=$1 318 | checksums=$2 319 | if [ -z "$checksums" ]; then 320 | log_err "hash_sha256_verify checksum file not specified in arg2" 321 | return 1 322 | fi 323 | BASENAME=${TARGET##*/} 324 | want=$(grep "${BASENAME}" "${checksums}" 2>/dev/null | tr '\t' ' ' | cut -d ' ' -f 1) 325 | if [ -z "$want" ]; then 326 | log_err "hash_sha256_verify unable to find checksum for '${TARGET}' in '${checksums}'" 327 | return 1 328 | fi 329 | got=$(hash_sha256 "$TARGET") 330 | if [ "$want" != "$got" ]; then 331 | log_err "hash_sha256_verify checksum for '$TARGET' did not verify ${want} vs $got" 332 | return 1 333 | fi 334 | } 335 | cat /dev/null < 0 { 49 | bold := constants.Bold 50 | fmt.Println(bold("[" + startDevEnvReply.LogLineHeader + "]\n")) 51 | } 52 | 53 | if len(startDevEnvReply.LogLine) > 0 { 54 | log.Println(startDevEnvReply.LogLine) 55 | } 56 | } 57 | 58 | return nil 59 | } 60 | -------------------------------------------------------------------------------- /internal/aws/errors_presenter.go: -------------------------------------------------------------------------------- 1 | package aws 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | 7 | "github.com/recode-sh/aws-cloud-provider/config" 8 | "github.com/recode-sh/aws-cloud-provider/service" 9 | "github.com/recode-sh/aws-cloud-provider/userconfig" 10 | "github.com/recode-sh/cli/internal/presenters" 11 | "github.com/recode-sh/recode/entities" 12 | ) 13 | 14 | type AWSViewableErrorBuilder struct { 15 | presenters.RecodeViewableErrorBuilder 16 | } 17 | 18 | func NewAWSViewableErrorBuilder() AWSViewableErrorBuilder { 19 | return AWSViewableErrorBuilder{} 20 | } 21 | 22 | func (a AWSViewableErrorBuilder) Build(err error) (viewableError *presenters.ViewableError) { 23 | viewableError = &presenters.ViewableError{} 24 | 25 | if errors.Is(err, entities.ErrRecodeNotInstalled) { 26 | viewableError.Title = "Recode not installed" 27 | viewableError.Message = "Recode is not installed in this region on this AWS account.\n\n" + 28 | "Please double check the passed credentials and region." 29 | 30 | return 31 | } 32 | 33 | if errors.Is(err, entities.ErrUninstallExistingDevEnvs) { 34 | viewableError.Title = "Existing development environments" 35 | viewableError.Message = "All development environments need to be removed before uninstalling Recode." 36 | 37 | return 38 | } 39 | 40 | if errors.Is(err, userconfig.ErrMissingConfig) { 41 | viewableError.Title = "No AWS account found" 42 | viewableError.Message = fmt.Sprintf(`An AWS account can be configured: 43 | 44 | - by setting the "%s", "%s" and "%s" environment variables. 45 | 46 | - by installing the AWS CLI and running "aws configure".`, 47 | userconfig.AWSAccessKeyIDEnvVar, 48 | userconfig.AWSSecretAccessKeyEnvVar, 49 | userconfig.AWSRegionEnvVar, 50 | ) 51 | 52 | return 53 | } 54 | 55 | if errors.Is(err, userconfig.ErrMissingAccessKeyInEnv) { 56 | viewableError.Title = "Missing environment variable" 57 | viewableError.Message = fmt.Sprintf( 58 | "The environment variable \"%s\" needs to be set.", 59 | userconfig.AWSAccessKeyIDEnvVar, 60 | ) 61 | 62 | return 63 | } 64 | 65 | if errors.Is(err, userconfig.ErrMissingSecretInEnv) { 66 | viewableError.Title = "Missing environment variable" 67 | viewableError.Message = fmt.Sprintf( 68 | "The environment variable \"%s\" needs to be set.", 69 | userconfig.AWSSecretAccessKeyEnvVar, 70 | ) 71 | 72 | return 73 | } 74 | 75 | if errors.Is(err, userconfig.ErrMissingRegionInEnv) { 76 | viewableError.Title = "Missing region" 77 | viewableError.Message = fmt.Sprintf( 78 | "A region needs to be specified by setting the \"%s\" environment variable or by using the \"--region\" flag.", 79 | userconfig.AWSRegionEnvVar, 80 | ) 81 | 82 | return 83 | } 84 | 85 | if errors.Is(err, userconfig.ErrMissingRegionInFiles) { 86 | viewableError.Title = "Missing region" 87 | viewableError.Message = "A region needs to be specified by using the \"--region\" flag." 88 | 89 | return 90 | } 91 | 92 | if typedError, ok := err.(userconfig.ErrProfileNotFound); ok { 93 | viewableError.Title = "Configuration profile not found" 94 | viewableError.Message = fmt.Sprintf( 95 | "The profile \"%s\" was not found in your AWS configuration.\n\n(Searched in \"%s\" and \"%s\").", 96 | typedError.Profile, 97 | typedError.CredentialsFilePath, 98 | typedError.ConfigFilePath, 99 | ) 100 | 101 | return 102 | } 103 | 104 | if typedError, ok := err.(config.ErrInvalidRegion); ok { 105 | viewableError.Title = "Invalid region" 106 | viewableError.Message = fmt.Sprintf( 107 | "The region \"%s\" is invalid.", 108 | typedError.Region, 109 | ) 110 | 111 | return 112 | } 113 | 114 | if typedError, ok := err.(config.ErrInvalidAccessKeyID); ok { 115 | viewableError.Title = "Invalid access key ID" 116 | viewableError.Message = fmt.Sprintf( 117 | "The access key ID \"%s\" is invalid.", 118 | typedError.AccessKeyID, 119 | ) 120 | 121 | return 122 | } 123 | 124 | if typedError, ok := err.(config.ErrInvalidSecretAccessKey); ok { 125 | viewableError.Title = "Invalid secret access key" 126 | viewableError.Message = fmt.Sprintf( 127 | "The secret access key \"%s\" is invalid.", 128 | typedError.SecretAccessKey, 129 | ) 130 | 131 | return 132 | } 133 | 134 | if typedError, ok := err.(service.ErrInvalidInstanceType); ok { 135 | viewableError.Title = "Invalid instance type" 136 | viewableError.Message = fmt.Sprintf( 137 | "The instance type \"%s\" is invalid in the region \"%s\".", 138 | typedError.InstanceType, 139 | typedError.Region, 140 | ) 141 | 142 | return 143 | } 144 | 145 | if typedError, ok := err.(service.ErrInvalidInstanceTypeArch); ok { 146 | viewableError.Title = "Unsupported instance type" 147 | viewableError.Message = fmt.Sprintf( 148 | "The instance type \"%s\" is not supported by Recode.\n\n"+ 149 | "Only on-demand linux instances with EBS and \"%s\" architectures are supported.", 150 | typedError.InstanceType, 151 | typedError.SupportedArchs, 152 | ) 153 | 154 | return 155 | } 156 | 157 | viewableError = a.RecodeViewableErrorBuilder.Build(err) 158 | return 159 | } 160 | -------------------------------------------------------------------------------- /internal/aws/user_config_local_resolver.go: -------------------------------------------------------------------------------- 1 | package aws 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/recode-sh/aws-cloud-provider/userconfig" 7 | ) 8 | 9 | type UserConfigLocalResolverOpts struct { 10 | Profile string 11 | } 12 | 13 | //go:generate mockgen -destination=../mocks/aws_user_config_env_vars_resolver.go -package=mocks -mock_names UserConfigEnvVarsResolver=AWSUserConfigEnvVarsResolver github.com/recode-sh/cli/internal/aws UserConfigEnvVarsResolver 14 | type UserConfigEnvVarsResolver interface { 15 | Resolve() (*userconfig.Config, error) 16 | } 17 | 18 | //go:generate mockgen -destination=../mocks/aws_user_config_files_resolver.go -package=mocks -mock_names UserConfigFilesResolver=AWSUserConfigFilesResolver github.com/recode-sh/cli/internal/aws UserConfigFilesResolver 19 | type UserConfigFilesResolver interface { 20 | Resolve() (*userconfig.Config, error) 21 | } 22 | 23 | // UserConfigLocalResolver represents the default implementation 24 | // of the UserConfigResolver interface, used by most AWS commands via 25 | // the SDKConfigStaticBuilder. 26 | // 27 | // It retrieves the AWS account configuration from environment variables 28 | // (via the UserConfigLocalEnvVarsResolver interface) and fallback to config 29 | // files (via the UserConfigLocalFilesResolver interface) otherwise. 30 | // 31 | type UserConfigLocalResolver struct { 32 | envVarsResolver UserConfigEnvVarsResolver 33 | configFilesResolver UserConfigFilesResolver 34 | opts UserConfigLocalResolverOpts 35 | } 36 | 37 | // NewUserConfigLocalResolver constructs 38 | // the UserConfigLocalResolver struct. 39 | // Used by Wire in dependencies. 40 | // 41 | func NewUserConfigLocalResolver( 42 | envVarsResolver UserConfigEnvVarsResolver, 43 | configFilesResolver UserConfigFilesResolver, 44 | opts UserConfigLocalResolverOpts, 45 | ) UserConfigLocalResolver { 46 | 47 | return UserConfigLocalResolver{ 48 | envVarsResolver: envVarsResolver, 49 | configFilesResolver: configFilesResolver, 50 | opts: opts, 51 | } 52 | } 53 | 54 | // Resolve retrieves the AWS account configuration from environment variables 55 | // and fallback to config files if no environment variables were found. 56 | // 57 | // If the Profile option is set, environment variables are ignored 58 | // and the profile is directly loaded from config files. 59 | // 60 | func (u UserConfigLocalResolver) Resolve() (*userconfig.Config, error) { 61 | var userConfig *userconfig.Config 62 | var err error 63 | 64 | if len(u.opts.Profile) == 0 { 65 | userConfig, err = u.envVarsResolver.Resolve() 66 | 67 | if err != nil && !errors.Is(err, userconfig.ErrMissingConfig) { 68 | return nil, err 69 | } 70 | } 71 | 72 | if userConfig == nil { 73 | userConfig, err = u.configFilesResolver.Resolve() 74 | 75 | if err != nil { 76 | return nil, err 77 | } 78 | } 79 | 80 | return userConfig, nil 81 | } 82 | -------------------------------------------------------------------------------- /internal/aws/user_config_local_resolver_test.go: -------------------------------------------------------------------------------- 1 | package aws 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | 7 | "github.com/golang/mock/gomock" 8 | "github.com/recode-sh/aws-cloud-provider/userconfig" 9 | "github.com/recode-sh/cli/internal/mocks" 10 | ) 11 | 12 | func TestUserConfigLocalResolving(t *testing.T) { 13 | testCases := []struct { 14 | test string 15 | configInEnvVars *userconfig.Config 16 | errorDuringEnvVarsResolving error 17 | configInFiles *userconfig.Config 18 | errorDuringConfigFilesResolving error 19 | profileOpts string 20 | expectedConfig *userconfig.Config 21 | expectedError error 22 | }{ 23 | { 24 | test: "no env vars, no config files", 25 | errorDuringEnvVarsResolving: userconfig.ErrMissingConfig, 26 | errorDuringConfigFilesResolving: userconfig.ErrMissingConfig, 27 | expectedConfig: nil, 28 | expectedError: userconfig.ErrMissingConfig, 29 | }, 30 | 31 | { 32 | test: "only env vars", 33 | configInEnvVars: userconfig.NewConfig("a", "b", "c"), 34 | errorDuringConfigFilesResolving: userconfig.ErrMissingConfig, 35 | expectedConfig: userconfig.NewConfig("a", "b", "c"), 36 | expectedError: nil, 37 | }, 38 | 39 | { 40 | test: "only config files", 41 | errorDuringEnvVarsResolving: userconfig.ErrMissingConfig, 42 | configInFiles: userconfig.NewConfig("a", "b", "c"), 43 | expectedConfig: userconfig.NewConfig("a", "b", "c"), 44 | expectedError: nil, 45 | }, 46 | 47 | { 48 | test: "env vars and config files", 49 | configInEnvVars: userconfig.NewConfig("a", "b", "c"), 50 | configInFiles: userconfig.NewConfig("d", "e", "f"), 51 | expectedConfig: userconfig.NewConfig("a", "b", "c"), 52 | expectedError: nil, 53 | }, 54 | 55 | { 56 | test: "env vars, config files and profile", 57 | configInEnvVars: userconfig.NewConfig("a", "b", "c"), 58 | configInFiles: userconfig.NewConfig("d", "e", "f"), 59 | profileOpts: "production", 60 | expectedConfig: userconfig.NewConfig("d", "e", "f"), 61 | expectedError: nil, 62 | }, 63 | 64 | { 65 | test: "errored env vars and config files", 66 | errorDuringEnvVarsResolving: userconfig.ErrMissingAccessKeyInEnv, 67 | configInFiles: userconfig.NewConfig("d", "e", "f"), 68 | expectedConfig: nil, 69 | expectedError: userconfig.ErrMissingAccessKeyInEnv, 70 | }, 71 | 72 | { 73 | test: "env vars and errored config files", 74 | configInEnvVars: userconfig.NewConfig("a", "b", "c"), 75 | errorDuringConfigFilesResolving: userconfig.ErrMissingRegionInFiles, 76 | expectedConfig: userconfig.NewConfig("a", "b", "c"), 77 | expectedError: nil, 78 | }, 79 | 80 | { 81 | test: "env vars, errored config files and profile", 82 | configInEnvVars: userconfig.NewConfig("a", "b", "c"), 83 | errorDuringConfigFilesResolving: userconfig.ErrMissingRegionInFiles, 84 | profileOpts: "production", 85 | expectedConfig: nil, 86 | expectedError: userconfig.ErrMissingRegionInFiles, 87 | }, 88 | } 89 | 90 | for _, tc := range testCases { 91 | t.Run(tc.test, func(t *testing.T) { 92 | mockCtrl := gomock.NewController(t) 93 | defer mockCtrl.Finish() 94 | 95 | userConfigEnvVarsResolverMock := mocks.NewAWSUserConfigEnvVarsResolver(mockCtrl) 96 | userConfigEnvVarsResolverMock.EXPECT().Resolve().Return( 97 | tc.configInEnvVars, 98 | tc.errorDuringEnvVarsResolving, 99 | ).AnyTimes() 100 | 101 | userConfigFilesResolverMock := mocks.NewAWSUserConfigFilesResolver(mockCtrl) 102 | userConfigFilesResolverMock.EXPECT().Resolve().Return( 103 | tc.configInFiles, 104 | tc.errorDuringConfigFilesResolving, 105 | ).AnyTimes() 106 | 107 | resolver := NewUserConfigLocalResolver( 108 | userConfigEnvVarsResolverMock, 109 | userConfigFilesResolverMock, 110 | UserConfigLocalResolverOpts{ 111 | Profile: tc.profileOpts, 112 | }, 113 | ) 114 | 115 | resolvedConfig, err := resolver.Resolve() 116 | 117 | if tc.expectedError == nil && err != nil { 118 | t.Fatalf("expected no error, got '%+v'", err) 119 | } 120 | 121 | if tc.expectedError != nil && !errors.Is(err, tc.expectedError) { 122 | t.Fatalf("expected error to equal '%+v', got '%+v'", tc.expectedError, err) 123 | } 124 | 125 | if tc.expectedConfig != nil && *resolvedConfig != *tc.expectedConfig { 126 | t.Fatalf("expected config to equal '%+v', got '%+v'", *tc.expectedConfig, *resolvedConfig) 127 | } 128 | 129 | if tc.expectedConfig == nil && resolvedConfig != nil { 130 | t.Fatalf("expected no config, got '%+v'", *resolvedConfig) 131 | } 132 | }) 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /internal/cmd/aws.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2022 Jeremy Levy jje.levy@gmail.com 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in 12 | all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 20 | THE SOFTWARE. 21 | */ 22 | package cmd 23 | 24 | import ( 25 | "github.com/aws/aws-sdk-go-v2/config" 26 | "github.com/spf13/cobra" 27 | ) 28 | 29 | var awsProfile string 30 | var awsRegion string 31 | 32 | var awsCredentialsFilePath string 33 | var awsConfigFilePath string 34 | 35 | // awsCmd represents the aws command 36 | var awsCmd = &cobra.Command{ 37 | Use: "aws", 38 | 39 | Short: "Use Recode on Amazon Web Services", 40 | 41 | Long: `Use Recode on Amazon Web Services. 42 | 43 | To begin, create your first development environment using the command: 44 | 45 | recode aws start 46 | 47 | Once started, you could stop it at any time, to save costs, using the command: 48 | 49 | recode aws stop 50 | 51 | If you don't plan to use this development environment again, you could remove it using the command: 52 | 53 | recode aws remove 54 | 55 | may be relative to your personal GitHub account (eg: cli) or fully qualified (eg: my-organization/api). `, 56 | 57 | Example: ` recode aws start recode-sh/api --instance-type m4.large 58 | recode aws stop recode-sh/api 59 | recode aws remove recode-sh/api`, 60 | } 61 | 62 | func init() { 63 | awsCmd.Flags().StringVar( 64 | &awsProfile, 65 | "profile", 66 | "", 67 | "the configuration profile to use to access your AWS account", 68 | ) 69 | 70 | awsCmd.Flags().StringVar( 71 | &awsRegion, 72 | "region", 73 | "", 74 | "the region to use to access your AWS account", 75 | ) 76 | 77 | awsCredentialsFilePath = config.DefaultSharedCredentialsFilename() 78 | awsConfigFilePath = config.DefaultSharedConfigFilename() 79 | 80 | rootCmd.AddCommand(awsCmd) 81 | } 82 | -------------------------------------------------------------------------------- /internal/cmd/aws_remove.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2022 Jeremy Levy jje.levy@gmail.com 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in 12 | all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 20 | THE SOFTWARE. 21 | */ 22 | package cmd 23 | 24 | import ( 25 | "os" 26 | 27 | "github.com/recode-sh/cli/internal/dependencies" 28 | "github.com/recode-sh/cli/internal/system" 29 | "github.com/recode-sh/recode/features" 30 | "github.com/spf13/cobra" 31 | ) 32 | 33 | var awsRemoveForceDevEnvRemove bool 34 | 35 | // awsRemoveCmd represents the aws remove command 36 | var awsRemoveCmd = &cobra.Command{ 37 | Use: "remove ", 38 | 39 | Short: "Remove a development environment", 40 | 41 | Long: `Remove an existing development environment. 42 | 43 | The development environment will be PERMANENTLY removed along with ALL your data. 44 | 45 | There is no going back, so please be sure to save your work before running this command.`, 46 | 47 | Example: ` recode aws remove api 48 | recode aws remove recode-sh/cli --force`, 49 | 50 | Args: cobra.ExactArgs(1), 51 | 52 | Run: func(cmd *cobra.Command, args []string) { 53 | 54 | recodeViewableErrorBuilder := dependencies.ProvideRecodeViewableErrorBuilder() 55 | baseView := dependencies.ProvideBaseView() 56 | 57 | repository := args[0] 58 | checkForRepositoryExistence := false 59 | 60 | repositoryResolver := dependencies.ProvideDevEnvRepositoryResolver() 61 | resolvedRepository, err := repositoryResolver.Resolve( 62 | repository, 63 | checkForRepositoryExistence, 64 | ) 65 | 66 | if err != nil { 67 | baseView.ShowErrorViewWithStartingNewLine( 68 | recodeViewableErrorBuilder.Build( 69 | err, 70 | ), 71 | ) 72 | 73 | os.Exit(1) 74 | } 75 | 76 | awsRemoveInput := features.RemoveInput{ 77 | ResolvedRepository: *resolvedRepository, 78 | PreRemoveHook: dependencies.ProvidePreRemoveHook(), 79 | ForceRemove: awsRemoveForceDevEnvRemove, 80 | ConfirmRemove: func() (bool, error) { 81 | logger := system.NewLogger() 82 | return system.AskForConfirmation( 83 | logger, 84 | os.Stdin, 85 | "All your un-pushed work will be lost.", 86 | ) 87 | }, 88 | } 89 | 90 | awsRemove := dependencies.ProvideAWSRemoveFeature( 91 | awsRegion, 92 | awsProfile, 93 | awsCredentialsFilePath, 94 | awsConfigFilePath, 95 | ) 96 | 97 | err = awsRemove.Execute(awsRemoveInput) 98 | 99 | if err != nil { 100 | os.Exit(1) 101 | } 102 | }, 103 | } 104 | 105 | func init() { 106 | awsRemoveCmd.Flags().BoolVar( 107 | &awsRemoveForceDevEnvRemove, 108 | "force", 109 | false, 110 | "avoid remove confirmation", 111 | ) 112 | 113 | awsCmd.AddCommand(awsRemoveCmd) 114 | } 115 | -------------------------------------------------------------------------------- /internal/cmd/aws_start.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2022 Jeremy Levy jje.levy@gmail.com 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in 12 | all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 20 | THE SOFTWARE. 21 | */ 22 | package cmd 23 | 24 | import ( 25 | "os" 26 | 27 | "github.com/recode-sh/cli/internal/dependencies" 28 | "github.com/recode-sh/cli/internal/system" 29 | "github.com/recode-sh/recode/features" 30 | "github.com/spf13/cobra" 31 | ) 32 | 33 | var awsStartInstanceType string 34 | var awsStartDevEnvRebuildAsked bool 35 | var awsStartForceDevEnvRebuild bool 36 | 37 | // awsStartCmd represents the aws start command 38 | var awsStartCmd = &cobra.Command{ 39 | Use: "start ", 40 | 41 | Short: "Start a development environment", 42 | 43 | Long: `Start a development environment for a specific GitHub repository. 44 | 45 | If the passed repository doesn't contain an account name, your personal account is assumed.`, 46 | 47 | Example: ` recode aws start api 48 | recode aws start recode-sh/cli --instance-type m4.large`, 49 | 50 | Args: cobra.ExactArgs(1), 51 | 52 | Run: func(cmd *cobra.Command, args []string) { 53 | 54 | recodeViewableErrorBuilder := dependencies.ProvideRecodeViewableErrorBuilder() 55 | baseView := dependencies.ProvideBaseView() 56 | 57 | devEnvUserConfigResolver := dependencies.ProvideDevEnvUserConfigResolver() 58 | resolvedDevEnvUserConfig, err := devEnvUserConfigResolver.Resolve() 59 | 60 | if err != nil { 61 | baseView.ShowErrorViewWithStartingNewLine( 62 | recodeViewableErrorBuilder.Build( 63 | err, 64 | ), 65 | ) 66 | 67 | os.Exit(1) 68 | } 69 | 70 | repository := args[0] 71 | checkForRepositoryExistence := true 72 | 73 | repositoryResolver := dependencies.ProvideDevEnvRepositoryResolver() 74 | resolvedRepository, err := repositoryResolver.Resolve( 75 | repository, 76 | checkForRepositoryExistence, 77 | ) 78 | 79 | if err != nil { 80 | baseView.ShowErrorViewWithStartingNewLine( 81 | recodeViewableErrorBuilder.Build( 82 | err, 83 | ), 84 | ) 85 | 86 | os.Exit(1) 87 | } 88 | 89 | awsStartInput := features.StartInput{ 90 | InstanceType: awsStartInstanceType, 91 | DevEnvRebuildAsked: awsStartDevEnvRebuildAsked, 92 | ResolvedDevEnvUserConfig: *resolvedDevEnvUserConfig, 93 | ResolvedRepository: *resolvedRepository, 94 | ForceDevEnvRevuild: awsStartForceDevEnvRebuild, 95 | ConfirmDevEnvRebuild: func() (bool, error) { 96 | logger := system.NewLogger() 97 | return system.AskForConfirmation( 98 | logger, 99 | os.Stdin, 100 | "All your un-pushed work will be lost", 101 | ) 102 | }, 103 | } 104 | 105 | awsStart := dependencies.ProvideAWSStartFeature( 106 | awsRegion, 107 | awsProfile, 108 | awsCredentialsFilePath, 109 | awsConfigFilePath, 110 | ) 111 | 112 | err = awsStart.Execute(awsStartInput) 113 | 114 | if err != nil { 115 | os.Exit(1) 116 | } 117 | }, 118 | } 119 | 120 | func init() { 121 | awsStartCmd.Flags().StringVar( 122 | &awsStartInstanceType, 123 | "instance-type", 124 | "t2.medium", 125 | "the instance type used by this development environment", 126 | ) 127 | 128 | awsStartCmd.Flags().BoolVar( 129 | &awsStartDevEnvRebuildAsked, 130 | "rebuild", 131 | false, 132 | "rebuild the development environment", 133 | ) 134 | 135 | awsStartCmd.Flags().BoolVar( 136 | &awsStartForceDevEnvRebuild, 137 | "force", 138 | false, 139 | "avoid rebuild confirmation", 140 | ) 141 | 142 | awsCmd.AddCommand(awsStartCmd) 143 | } 144 | -------------------------------------------------------------------------------- /internal/cmd/aws_stop.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2022 Jeremy Levy jje.levy@gmail.com 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in 12 | all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 20 | THE SOFTWARE. 21 | */ 22 | package cmd 23 | 24 | import ( 25 | "os" 26 | 27 | "github.com/recode-sh/cli/internal/dependencies" 28 | "github.com/recode-sh/recode/features" 29 | "github.com/spf13/cobra" 30 | ) 31 | 32 | // awsStopCmd represents the aws stop command 33 | var awsStopCmd = &cobra.Command{ 34 | Use: "stop ", 35 | 36 | Short: "Stop a development environment", 37 | 38 | Long: `Stop an existing development environment. 39 | 40 | The development environment will be stopped but your data will be conserved. 41 | 42 | You may still incur charges for the storage used. If you don't plan to use this development environment again, use the remove command instead.`, 43 | 44 | Example: ` recode aws stop api 45 | recode aws stop recode-sh/cli`, 46 | 47 | Args: cobra.ExactArgs(1), 48 | 49 | Run: func(cmd *cobra.Command, args []string) { 50 | 51 | recodeViewableErrorBuilder := dependencies.ProvideRecodeViewableErrorBuilder() 52 | baseView := dependencies.ProvideBaseView() 53 | 54 | repository := args[0] 55 | checkForRepositoryExistence := false 56 | 57 | repositoryResolver := dependencies.ProvideDevEnvRepositoryResolver() 58 | resolvedRepository, err := repositoryResolver.Resolve( 59 | repository, 60 | checkForRepositoryExistence, 61 | ) 62 | 63 | if err != nil { 64 | baseView.ShowErrorViewWithStartingNewLine( 65 | recodeViewableErrorBuilder.Build( 66 | err, 67 | ), 68 | ) 69 | 70 | os.Exit(1) 71 | } 72 | 73 | awsStopInput := features.StopInput{ 74 | ResolvedRepository: *resolvedRepository, 75 | PreStopHook: dependencies.ProvidePreStopHook(), 76 | } 77 | 78 | awsStop := dependencies.ProvideAWSStopFeature( 79 | awsRegion, 80 | awsProfile, 81 | awsCredentialsFilePath, 82 | awsConfigFilePath, 83 | ) 84 | 85 | err = awsStop.Execute(awsStopInput) 86 | 87 | if err != nil { 88 | os.Exit(1) 89 | } 90 | }, 91 | } 92 | 93 | func init() { 94 | awsCmd.AddCommand(awsStopCmd) 95 | } 96 | -------------------------------------------------------------------------------- /internal/cmd/aws_uninstall.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2022 Jeremy Levy jje.levy@gmail.com 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in 12 | all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 20 | THE SOFTWARE. 21 | */ 22 | package cmd 23 | 24 | import ( 25 | "os" 26 | 27 | "github.com/recode-sh/cli/internal/dependencies" 28 | "github.com/recode-sh/recode/features" 29 | "github.com/spf13/cobra" 30 | ) 31 | 32 | // awsUninstallCmd represents the aws uninstall command 33 | var awsUninstallCmd = &cobra.Command{ 34 | Use: "uninstall", 35 | 36 | Short: "Uninstall Recode from your AWS account", 37 | 38 | Long: `Uninstall Recode from your AWS account. 39 | 40 | All your development environments must be removed before running this command.`, 41 | 42 | Example: " recode aws uninstall", 43 | 44 | Run: func(cmd *cobra.Command, args []string) { 45 | 46 | awsUninstallInput := features.UninstallInput{ 47 | SuccessMessage: "Recode has been uninstalled from this region on this AWS account.", 48 | AlreadyUninstalledMessage: "Recode is already uninstalled in this region on this AWS account.", 49 | } 50 | 51 | awsUninstall := dependencies.ProvideAWSUninstallFeature( 52 | awsRegion, 53 | awsProfile, 54 | awsCredentialsFilePath, 55 | awsConfigFilePath, 56 | ) 57 | 58 | err := awsUninstall.Execute(awsUninstallInput) 59 | 60 | if err != nil { 61 | os.Exit(1) 62 | } 63 | }, 64 | } 65 | 66 | func init() { 67 | awsCmd.AddCommand(awsUninstallCmd) 68 | } 69 | -------------------------------------------------------------------------------- /internal/cmd/login.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2022 Jeremy Levy jje.levy@gmail.com 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in 12 | all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 20 | THE SOFTWARE. 21 | */ 22 | package cmd 23 | 24 | import ( 25 | "os" 26 | 27 | "github.com/recode-sh/cli/internal/dependencies" 28 | "github.com/recode-sh/cli/internal/features" 29 | "github.com/spf13/cobra" 30 | ) 31 | 32 | // loginCmd represents the "recode login" command 33 | var loginCmd = &cobra.Command{ 34 | Use: "login", 35 | 36 | Short: "Connect a GitHub account to use with Recode", 37 | 38 | Long: `Connect a GitHub account to use with Recode. 39 | 40 | Recode requires the following permissions: 41 | 42 | - "Public SSH keys" and "Repositories" to let you access your repositories from your development environments 43 | 44 | - "GPG Keys" and "Personal user data" to configure Git and sign your commits (verified badge) 45 | 46 | All your data (including the OAuth access token) will only be stored locally.`, 47 | 48 | Example: " recode login", 49 | 50 | Run: func(cmd *cobra.Command, args []string) { 51 | loginInput := features.LoginInput{} 52 | 53 | login := dependencies.ProvideLoginFeature() 54 | 55 | err := login.Execute(loginInput) 56 | 57 | if err != nil { 58 | os.Exit(1) 59 | } 60 | }, 61 | } 62 | 63 | func init() { 64 | rootCmd.AddCommand(loginCmd) 65 | } 66 | -------------------------------------------------------------------------------- /internal/cmd/root.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2022 Jeremy Levy jje.levy@gmail.com 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in 12 | all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 20 | THE SOFTWARE. 21 | */ 22 | package cmd 23 | 24 | import ( 25 | "fmt" 26 | "io/fs" 27 | "os" 28 | "os/exec" 29 | "runtime" 30 | "strings" 31 | 32 | "github.com/recode-sh/cli/internal/config" 33 | "github.com/recode-sh/cli/internal/dependencies" 34 | "github.com/recode-sh/cli/internal/exceptions" 35 | "github.com/recode-sh/cli/internal/system" 36 | "github.com/recode-sh/cli/internal/vscode" 37 | "github.com/recode-sh/recode/github" 38 | "github.com/spf13/cobra" 39 | "github.com/spf13/viper" 40 | ) 41 | 42 | // rootCmd represents the base command when called without any subcommands 43 | var rootCmd = &cobra.Command{ 44 | Use: "recode", 45 | 46 | Short: "Remote development environments defined as code", 47 | 48 | Long: `Recode - Remote development environments defined as code 49 | 50 | To begin, run the command "recode login" to connect your GitHub account. 51 | 52 | From there, the most common workflow is: 53 | 54 | - recode start : to start a development environment for a specific GitHub repository 55 | - recode stop : to stop a development environment (without removing your data) 56 | - recode remove : to remove a development environment AND your data 57 | 58 | may be relative to your personal GitHub account (eg: cli) or fully qualified (eg: my-organization/api).`, 59 | 60 | PersistentPreRun: func(cmd *cobra.Command, args []string) { 61 | ensureUserIsLoggedIn(cmd) 62 | }, 63 | 64 | TraverseChildren: true, 65 | 66 | Version: "v0.1.0", 67 | } 68 | 69 | // Execute adds all child commands to the root command and sets flags appropriately. 70 | // This is called by main.main(). It only needs to happen once to the rootCmd. 71 | func Execute() { 72 | err := rootCmd.Execute() 73 | if err != nil { 74 | os.Exit(1) 75 | } 76 | } 77 | 78 | func init() { 79 | cobra.OnInitialize( 80 | ensureRecodeCLIRequirements, 81 | initializeRecodeCLIConfig, 82 | ensureGitHubAccessTokenValidity, 83 | ) 84 | } 85 | 86 | func ensureRecodeCLIRequirements() { 87 | missingRequirements := []string{} 88 | 89 | vscodeCLI := vscode.CLI{} 90 | _, err := vscodeCLI.LookupPath(runtime.GOOS) 91 | 92 | if vscodeCLINotFoundErr, ok := err.(vscode.ErrCLINotFound); ok { 93 | missingRequirements = append( 94 | missingRequirements, 95 | fmt.Sprintf( 96 | "Visual Studio Code (looked in \"%s)", 97 | strings.Join(vscodeCLINotFoundErr.VisitedPaths, "\", \"")+"\"", 98 | ), 99 | ) 100 | } 101 | 102 | sshCommand := "ssh" 103 | _, err = exec.LookPath(sshCommand) 104 | 105 | if err != nil { 106 | missingRequirements = append( 107 | missingRequirements, 108 | fmt.Sprintf( 109 | "OpenSSH client (looked for an \"%s\" command available)", 110 | sshCommand, 111 | ), 112 | ) 113 | } 114 | 115 | if len(missingRequirements) > 0 { 116 | recodeViewableErrorBuilder := dependencies.ProvideRecodeViewableErrorBuilder() 117 | baseView := dependencies.ProvideBaseView() 118 | 119 | missingRequirementsErr := exceptions.ErrMissingRequirements{ 120 | MissingRequirements: missingRequirements, 121 | } 122 | 123 | baseView.ShowErrorViewWithStartingNewLine( 124 | recodeViewableErrorBuilder.Build( 125 | missingRequirementsErr, 126 | ), 127 | ) 128 | 129 | os.Exit(1) 130 | } 131 | } 132 | 133 | func initializeRecodeCLIConfig() { 134 | configDir := system.UserConfigDir() 135 | configDirPerms := fs.FileMode(0700) 136 | 137 | // Ensure configuration dir exists 138 | err := os.MkdirAll( 139 | configDir, 140 | configDirPerms, 141 | ) 142 | cobra.CheckErr(err) 143 | 144 | configFilePath := system.UserConfigFilePath() 145 | configFilePerms := fs.FileMode(0600) 146 | 147 | // Ensure configuration file exists 148 | f, err := os.OpenFile( 149 | configFilePath, 150 | os.O_CREATE, 151 | configFilePerms, 152 | ) 153 | cobra.CheckErr(err) 154 | defer f.Close() 155 | 156 | viper.SetConfigFile(configFilePath) 157 | cobra.CheckErr(viper.ReadInConfig()) 158 | } 159 | 160 | // ensureGitHubAccessTokenValidity ensures that 161 | // the github access token has not been 162 | // revoked by user 163 | func ensureGitHubAccessTokenValidity() { 164 | userConfig := config.NewUserConfig() 165 | userIsLoggedIn := userConfig.GetBool(config.UserConfigKeyUserIsLoggedIn) 166 | 167 | if !userIsLoggedIn { 168 | return 169 | } 170 | 171 | gitHubService := github.NewService() 172 | 173 | githubUser, err := gitHubService.GetAuthenticatedUser( 174 | userConfig.GetString( 175 | config.UserConfigKeyGitHubAccessToken, 176 | ), 177 | ) 178 | 179 | if err != nil && 180 | gitHubService.IsInvalidAccessTokenError(err) { // User has revoked access token 181 | 182 | userIsLoggedIn = false 183 | 184 | userConfig.Set( 185 | config.UserConfigKeyUserIsLoggedIn, 186 | userIsLoggedIn, 187 | ) 188 | 189 | // Error is swallowed here to 190 | // not confuse user with unexpected error 191 | _ = userConfig.WriteConfig() 192 | } 193 | 194 | if err == nil { 195 | // Update config with updated values from GitHub 196 | userConfig.PopulateFromGitHubUser(githubUser) 197 | 198 | // Error is swallowed here to 199 | // not confuse user with unexpected error 200 | _ = userConfig.WriteConfig() 201 | } 202 | } 203 | 204 | func ensureUserIsLoggedIn(cmd *cobra.Command) { 205 | userConfig := config.NewUserConfig() 206 | userIsLoggedIn := userConfig.GetBool(config.UserConfigKeyUserIsLoggedIn) 207 | 208 | if !userIsLoggedIn && cmd != loginCmd { 209 | recodeViewableErrorBuilder := dependencies.ProvideRecodeViewableErrorBuilder() 210 | baseView := dependencies.ProvideBaseView() 211 | 212 | baseView.ShowErrorViewWithStartingNewLine( 213 | recodeViewableErrorBuilder.Build( 214 | exceptions.ErrUserNotLoggedIn, 215 | ), 216 | ) 217 | 218 | os.Exit(1) 219 | } 220 | } 221 | -------------------------------------------------------------------------------- /internal/config/github.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | var ( 4 | GitHubOAuthClientID = "a8c368bfe297f0b1808a" 5 | GitHubOAuthCLIToAPIURL = "http://127.0.0.1:8080/github/oauth/callback" 6 | 7 | GitHubOAuthAPIToCLIURLPath = "/github/oauth/callback" 8 | 9 | GitHubOAuthScopes = []string{ 10 | "read:user", 11 | "user:email", 12 | "repo", 13 | "admin:public_key", 14 | "admin:gpg_key", 15 | } 16 | ) 17 | -------------------------------------------------------------------------------- /internal/config/github_prod.go: -------------------------------------------------------------------------------- 1 | //go:build prod 2 | 3 | package config 4 | 5 | func init() { 6 | GitHubOAuthClientID = "7e1b6c93f4ba81819162" 7 | GitHubOAuthCLIToAPIURL = "https://api.recode.sh/github/oauth/callback" 8 | } 9 | -------------------------------------------------------------------------------- /internal/config/user_config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "github.com/recode-sh/recode/github" 5 | "github.com/spf13/viper" 6 | ) 7 | 8 | type UserConfigKey string 9 | 10 | const ( 11 | UserConfigKeyUserIsLoggedIn UserConfigKey = "user_is_logged_in" 12 | UserConfigKeyGitHubAccessToken UserConfigKey = "github_access_token" 13 | UserConfigKeyGitHubUsername UserConfigKey = "github_username" 14 | UserConfigKeyGitHubEmail UserConfigKey = "github_email" 15 | UserConfigKeyGitHubFullName UserConfigKey = "github_full_name" 16 | ) 17 | 18 | type UserConfig struct{} 19 | 20 | func NewUserConfig() UserConfig { 21 | return UserConfig{} 22 | } 23 | 24 | func (UserConfig) GetString(key UserConfigKey) string { 25 | return viper.GetString(string(key)) 26 | } 27 | 28 | func (UserConfig) GetBool(key UserConfigKey) bool { 29 | return viper.GetBool(string(key)) 30 | } 31 | 32 | func (UserConfig) Set(key UserConfigKey, value interface{}) { 33 | viper.Set(string(key), value) 34 | } 35 | 36 | func (u UserConfig) PopulateFromGitHubUser(githubUser *github.AuthenticatedUser) { 37 | u.Set( 38 | UserConfigKeyGitHubEmail, 39 | githubUser.PrimaryEmail, 40 | ) 41 | 42 | u.Set( 43 | UserConfigKeyGitHubFullName, 44 | githubUser.FullName, 45 | ) 46 | 47 | u.Set( 48 | UserConfigKeyGitHubUsername, 49 | githubUser.Username, 50 | ) 51 | } 52 | 53 | func (UserConfig) WriteConfig() error { 54 | return viper.WriteConfig() 55 | } 56 | -------------------------------------------------------------------------------- /internal/constants/colors.go: -------------------------------------------------------------------------------- 1 | package constants 2 | 3 | import "github.com/jwalton/gchalk" 4 | 5 | var ( 6 | Underline = gchalk.Underline 7 | Bold = gchalk.Bold 8 | Green = gchalk.RGB(175, 202, 90) 9 | Blue = gchalk.RGB(130, 189, 237) 10 | Cyan = gchalk.RGB(125, 192, 203) 11 | Red = gchalk.RGB(218, 136, 138) 12 | Yellow = gchalk.RGB(241, 199, 141) 13 | ) 14 | -------------------------------------------------------------------------------- /internal/dependencies/aws_remove.go: -------------------------------------------------------------------------------- 1 | // go:build wireinject 2 | //go:build wireinject 3 | // +build wireinject 4 | 5 | package dependencies 6 | 7 | import ( 8 | "github.com/google/wire" 9 | awsProviderUserConfig "github.com/recode-sh/aws-cloud-provider/userconfig" 10 | awsCLI "github.com/recode-sh/cli/internal/aws" 11 | featuresCLI "github.com/recode-sh/cli/internal/features" 12 | "github.com/recode-sh/cli/internal/presenters" 13 | "github.com/recode-sh/cli/internal/views" 14 | "github.com/recode-sh/recode/features" 15 | ) 16 | 17 | func ProvideAWSRemoveFeature(region, profile, credentialsFilePath, configFilePath string) features.RemoveFeature { 18 | return provideAWSRemoveFeature( 19 | awsProviderUserConfig.EnvVarsResolverOpts{ 20 | Region: region, 21 | }, 22 | 23 | awsProviderUserConfig.FilesResolverOpts{ 24 | Region: region, 25 | Profile: profile, 26 | CredentialsFilePath: credentialsFilePath, 27 | ConfigFilePath: configFilePath, 28 | }, 29 | 30 | awsCLI.UserConfigLocalResolverOpts{ 31 | Profile: profile, 32 | }, 33 | ) 34 | } 35 | 36 | func provideAWSRemoveFeature( 37 | userConfigEnvVarsResolverOpts awsProviderUserConfig.EnvVarsResolverOpts, 38 | userConfigFilesResolverOpts awsProviderUserConfig.FilesResolverOpts, 39 | userConfigLocalResolverOpts awsCLI.UserConfigLocalResolverOpts, 40 | ) features.RemoveFeature { 41 | panic( 42 | wire.Build( 43 | viewSet, 44 | awsServiceBuilderSet, 45 | awsViewableErrorBuilder, 46 | 47 | stepperSet, 48 | 49 | wire.Bind(new(features.RemoveOutputHandler), new(featuresCLI.RemoveOutputHandler)), 50 | featuresCLI.NewRemoveOutputHandler, 51 | 52 | wire.Bind(new(featuresCLI.RemovePresenter), new(presenters.RemovePresenter)), 53 | presenters.NewRemovePresenter, 54 | 55 | wire.Bind(new(presenters.RemoveViewer), new(views.RemoveView)), 56 | views.NewRemoveView, 57 | 58 | features.NewRemoveFeature, 59 | ), 60 | ) 61 | } 62 | -------------------------------------------------------------------------------- /internal/dependencies/aws_shared.go: -------------------------------------------------------------------------------- 1 | // go:build wireinject 2 | //go:build wireinject 3 | // +build wireinject 4 | 5 | package dependencies 6 | 7 | import ( 8 | "github.com/google/wire" 9 | awsProviderConfig "github.com/recode-sh/aws-cloud-provider/config" 10 | awsProviderService "github.com/recode-sh/aws-cloud-provider/service" 11 | awsProviderUserConfig "github.com/recode-sh/aws-cloud-provider/userconfig" 12 | awsCLI "github.com/recode-sh/cli/internal/aws" 13 | "github.com/recode-sh/cli/internal/presenters" 14 | "github.com/recode-sh/cli/internal/system" 15 | "github.com/recode-sh/recode/entities" 16 | ) 17 | 18 | var awsViewableErrorBuilder = wire.NewSet( 19 | wire.Bind(new(presenters.ViewableErrorBuilder), new(awsCLI.AWSViewableErrorBuilder)), 20 | awsCLI.NewAWSViewableErrorBuilder, 21 | ) 22 | 23 | var awsServiceBuilderSet = wire.NewSet( 24 | wire.Bind(new(awsProviderUserConfig.ProfileLoader), new(awsProviderConfig.ProfileLoader)), 25 | awsProviderConfig.NewProfileLoader, 26 | 27 | wire.Bind(new(awsCLI.UserConfigFilesResolver), new(awsProviderUserConfig.FilesResolver)), 28 | awsProviderUserConfig.NewFilesResolver, 29 | 30 | wire.Bind(new(awsProviderUserConfig.EnvVarsGetter), new(system.EnvVars)), 31 | system.NewEnvVars, 32 | 33 | wire.Bind(new(awsCLI.UserConfigEnvVarsResolver), new(awsProviderUserConfig.EnvVarsResolver)), 34 | awsProviderUserConfig.NewEnvVarsResolver, 35 | 36 | wire.Bind(new(awsProviderService.UserConfigResolver), new(awsCLI.UserConfigLocalResolver)), 37 | awsCLI.NewUserConfigLocalResolver, 38 | 39 | wire.Bind(new(awsProviderService.UserConfigValidator), new(awsProviderConfig.UserConfigValidator)), 40 | awsProviderConfig.NewUserConfigValidator, 41 | 42 | wire.Bind(new(awsProviderService.UserConfigLoader), new(awsProviderConfig.UserConfigLoader)), 43 | awsProviderConfig.NewUserConfigLoader, 44 | 45 | wire.Bind(new(entities.CloudServiceBuilder), new(awsProviderService.Builder)), 46 | awsProviderService.NewBuilder, 47 | ) 48 | -------------------------------------------------------------------------------- /internal/dependencies/aws_start.go: -------------------------------------------------------------------------------- 1 | // go:build wireinject 2 | //go:build wireinject 3 | // +build wireinject 4 | 5 | package dependencies 6 | 7 | import ( 8 | "github.com/google/wire" 9 | awsProviderUserConfig "github.com/recode-sh/aws-cloud-provider/userconfig" 10 | "github.com/recode-sh/cli/internal/agent" 11 | awsCLI "github.com/recode-sh/cli/internal/aws" 12 | featuresCLI "github.com/recode-sh/cli/internal/features" 13 | "github.com/recode-sh/cli/internal/presenters" 14 | "github.com/recode-sh/cli/internal/views" 15 | "github.com/recode-sh/recode/features" 16 | ) 17 | 18 | func ProvideAWSStartFeature(region, profile, credentialsFilePath, configFilePath string) features.StartFeature { 19 | return provideAWSStartFeature( 20 | awsProviderUserConfig.EnvVarsResolverOpts{ 21 | Region: region, 22 | }, 23 | 24 | awsProviderUserConfig.FilesResolverOpts{ 25 | Region: region, 26 | Profile: profile, 27 | CredentialsFilePath: credentialsFilePath, 28 | ConfigFilePath: configFilePath, 29 | }, 30 | 31 | awsCLI.UserConfigLocalResolverOpts{ 32 | Profile: profile, 33 | }, 34 | ) 35 | } 36 | 37 | func provideAWSStartFeature( 38 | userConfigEnvVarsResolverOpts awsProviderUserConfig.EnvVarsResolverOpts, 39 | userConfigFilesResolverOpts awsProviderUserConfig.FilesResolverOpts, 40 | userConfigLocalResolverOpts awsCLI.UserConfigLocalResolverOpts, 41 | ) features.StartFeature { 42 | panic( 43 | wire.Build( 44 | viewSet, 45 | awsServiceBuilderSet, 46 | awsViewableErrorBuilder, 47 | 48 | userConfigManagerSet, 49 | 50 | wire.Bind(new(agent.ClientBuilder), new(agent.DefaultClientBuilder)), 51 | agent.NewDefaultClientBuilder, 52 | 53 | githubManagerSet, 54 | 55 | loggerSet, 56 | 57 | sshConfigManagerSet, 58 | 59 | sshKnownHostsManagerSet, 60 | 61 | sshKeysManagerSet, 62 | 63 | vscodeProcessManagerSet, 64 | 65 | vscodeExtensionsManagerSet, 66 | 67 | stepperSet, 68 | 69 | wire.Bind(new(features.StartOutputHandler), new(featuresCLI.StartOutputHandler)), 70 | featuresCLI.NewStartOutputHandler, 71 | 72 | wire.Bind(new(featuresCLI.StartPresenter), new(presenters.StartPresenter)), 73 | presenters.NewStartPresenter, 74 | 75 | wire.Bind(new(presenters.StartViewer), new(views.StartView)), 76 | views.NewStartView, 77 | 78 | features.NewStartFeature, 79 | ), 80 | ) 81 | } 82 | -------------------------------------------------------------------------------- /internal/dependencies/aws_stop.go: -------------------------------------------------------------------------------- 1 | // go:build wireinject 2 | //go:build wireinject 3 | // +build wireinject 4 | 5 | package dependencies 6 | 7 | import ( 8 | "github.com/google/wire" 9 | awsProviderUserConfig "github.com/recode-sh/aws-cloud-provider/userconfig" 10 | awsCLI "github.com/recode-sh/cli/internal/aws" 11 | featuresCLI "github.com/recode-sh/cli/internal/features" 12 | "github.com/recode-sh/cli/internal/presenters" 13 | "github.com/recode-sh/cli/internal/views" 14 | "github.com/recode-sh/recode/features" 15 | ) 16 | 17 | func ProvideAWSStopFeature(region, profile, credentialsFilePath, configFilePath string) features.StopFeature { 18 | return provideAWSStopFeature( 19 | awsProviderUserConfig.EnvVarsResolverOpts{ 20 | Region: region, 21 | }, 22 | 23 | awsProviderUserConfig.FilesResolverOpts{ 24 | Region: region, 25 | Profile: profile, 26 | CredentialsFilePath: credentialsFilePath, 27 | ConfigFilePath: configFilePath, 28 | }, 29 | 30 | awsCLI.UserConfigLocalResolverOpts{ 31 | Profile: profile, 32 | }, 33 | ) 34 | } 35 | 36 | func provideAWSStopFeature( 37 | userConfigEnvVarsResolverOpts awsProviderUserConfig.EnvVarsResolverOpts, 38 | userConfigFilesResolverOpts awsProviderUserConfig.FilesResolverOpts, 39 | userConfigLocalResolverOpts awsCLI.UserConfigLocalResolverOpts, 40 | ) features.StopFeature { 41 | panic( 42 | wire.Build( 43 | viewSet, 44 | awsServiceBuilderSet, 45 | awsViewableErrorBuilder, 46 | 47 | sshKnownHostsManagerSet, 48 | 49 | stepperSet, 50 | 51 | wire.Bind(new(features.StopOutputHandler), new(featuresCLI.StopOutputHandler)), 52 | featuresCLI.NewStopOutputHandler, 53 | 54 | wire.Bind(new(featuresCLI.StopPresenter), new(presenters.StopPresenter)), 55 | presenters.NewStopPresenter, 56 | 57 | wire.Bind(new(presenters.StopViewer), new(views.StopView)), 58 | views.NewStopView, 59 | 60 | features.NewStopFeature, 61 | ), 62 | ) 63 | } 64 | -------------------------------------------------------------------------------- /internal/dependencies/aws_uninstall.go: -------------------------------------------------------------------------------- 1 | // go:build wireinject 2 | //go:build wireinject 3 | // +build wireinject 4 | 5 | package dependencies 6 | 7 | import ( 8 | "github.com/google/wire" 9 | awsProviderUserConfig "github.com/recode-sh/aws-cloud-provider/userconfig" 10 | awsCLI "github.com/recode-sh/cli/internal/aws" 11 | featuresCLI "github.com/recode-sh/cli/internal/features" 12 | "github.com/recode-sh/cli/internal/presenters" 13 | "github.com/recode-sh/cli/internal/views" 14 | "github.com/recode-sh/recode/features" 15 | ) 16 | 17 | func ProvideAWSUninstallFeature(region, profile, credentialsFilePath, configFilePath string) features.UninstallFeature { 18 | return provideAWSUninstallFeature( 19 | awsProviderUserConfig.EnvVarsResolverOpts{ 20 | Region: region, 21 | }, 22 | 23 | awsProviderUserConfig.FilesResolverOpts{ 24 | Region: region, 25 | Profile: profile, 26 | CredentialsFilePath: credentialsFilePath, 27 | ConfigFilePath: configFilePath, 28 | }, 29 | 30 | awsCLI.UserConfigLocalResolverOpts{ 31 | Profile: profile, 32 | }, 33 | ) 34 | } 35 | 36 | func provideAWSUninstallFeature( 37 | userConfigEnvVarsResolverOpts awsProviderUserConfig.EnvVarsResolverOpts, 38 | userConfigFilesResolverOpts awsProviderUserConfig.FilesResolverOpts, 39 | userConfigLocalResolverOpts awsCLI.UserConfigLocalResolverOpts, 40 | ) features.UninstallFeature { 41 | panic( 42 | wire.Build( 43 | viewSet, 44 | awsServiceBuilderSet, 45 | awsViewableErrorBuilder, 46 | 47 | stepperSet, 48 | 49 | wire.Bind(new(features.UninstallOutputHandler), new(featuresCLI.UninstallOutputHandler)), 50 | featuresCLI.NewUninstallOutputHandler, 51 | 52 | wire.Bind(new(featuresCLI.UninstallPresenter), new(presenters.UninstallPresenter)), 53 | presenters.NewUninstallPresenter, 54 | 55 | wire.Bind(new(presenters.UninstallViewer), new(views.UninstallView)), 56 | views.NewUninstallView, 57 | 58 | features.NewUninstallFeature, 59 | ), 60 | ) 61 | } 62 | -------------------------------------------------------------------------------- /internal/dependencies/entities.go: -------------------------------------------------------------------------------- 1 | // go:build wireinject 2 | //go:build wireinject 3 | // +build wireinject 4 | 5 | package dependencies 6 | 7 | import ( 8 | "github.com/google/wire" 9 | "github.com/recode-sh/cli/internal/entities" 10 | ) 11 | 12 | func ProvideDevEnvUserConfigResolver() entities.DevEnvUserConfigResolver { 13 | panic( 14 | wire.Build( 15 | loggerSet, 16 | 17 | userConfigManagerSet, 18 | 19 | githubManagerSet, 20 | 21 | entities.NewDevEnvUserConfigResolver, 22 | ), 23 | ) 24 | } 25 | 26 | func ProvideDevEnvRepositoryResolver() entities.DevEnvRepositoryResolver { 27 | panic( 28 | wire.Build( 29 | loggerSet, 30 | 31 | userConfigManagerSet, 32 | 33 | githubManagerSet, 34 | 35 | entities.NewDevEnvRepositoryResolver, 36 | ), 37 | ) 38 | } 39 | -------------------------------------------------------------------------------- /internal/dependencies/hooks.go: -------------------------------------------------------------------------------- 1 | // go:build wireinject 2 | //go:build wireinject 3 | // +build wireinject 4 | 5 | package dependencies 6 | 7 | import ( 8 | "github.com/google/wire" 9 | "github.com/recode-sh/cli/internal/hooks" 10 | ) 11 | 12 | func ProvidePreRemoveHook() hooks.PreRemove { 13 | panic( 14 | wire.Build( 15 | sshConfigManagerSet, 16 | 17 | sshKnownHostsManagerSet, 18 | 19 | sshKeysManagerSet, 20 | 21 | userConfigManagerSet, 22 | 23 | githubManagerSet, 24 | 25 | hooks.NewPreRemove, 26 | ), 27 | ) 28 | } 29 | 30 | func ProvidePreStopHook() hooks.PreStop { 31 | panic( 32 | wire.Build( 33 | 34 | sshKnownHostsManagerSet, 35 | 36 | hooks.NewPreStop, 37 | ), 38 | ) 39 | } 40 | -------------------------------------------------------------------------------- /internal/dependencies/login.go: -------------------------------------------------------------------------------- 1 | // go:build wireinject 2 | //go:build wireinject 3 | // +build wireinject 4 | 5 | package dependencies 6 | 7 | import ( 8 | "github.com/google/wire" 9 | "github.com/recode-sh/cli/internal/features" 10 | "github.com/recode-sh/cli/internal/presenters" 11 | "github.com/recode-sh/cli/internal/views" 12 | ) 13 | 14 | func ProvideLoginFeature() features.LoginFeature { 15 | panic( 16 | wire.Build( 17 | viewSet, 18 | recodeViewableErrorBuilder, 19 | 20 | loggerSet, 21 | 22 | browserManagerSet, 23 | 24 | userConfigManagerSet, 25 | 26 | sleeperSet, 27 | 28 | githubManagerSet, 29 | 30 | wire.Bind(new(features.LoginPresenter), new(presenters.LoginPresenter)), 31 | presenters.NewLoginPresenter, 32 | 33 | wire.Bind(new(presenters.LoginViewer), new(views.LoginView)), 34 | views.NewLoginView, 35 | 36 | features.NewLoginFeature, 37 | ), 38 | ) 39 | } 40 | -------------------------------------------------------------------------------- /internal/dependencies/shared.go: -------------------------------------------------------------------------------- 1 | // go:build wireinject 2 | //go:build wireinject 3 | // +build wireinject 4 | 5 | package dependencies 6 | 7 | import ( 8 | "github.com/google/wire" 9 | "github.com/recode-sh/cli/internal/config" 10 | "github.com/recode-sh/cli/internal/interfaces" 11 | "github.com/recode-sh/cli/internal/presenters" 12 | "github.com/recode-sh/cli/internal/ssh" 13 | stepperCLI "github.com/recode-sh/cli/internal/stepper" 14 | "github.com/recode-sh/cli/internal/system" 15 | "github.com/recode-sh/cli/internal/views" 16 | "github.com/recode-sh/cli/internal/vscode" 17 | "github.com/recode-sh/recode/github" 18 | "github.com/recode-sh/recode/stepper" 19 | ) 20 | 21 | var viewSet = wire.NewSet( 22 | wire.Bind(new(views.Displayer), new(system.Displayer)), 23 | system.NewDisplayer, 24 | views.NewBaseView, 25 | ) 26 | 27 | func ProvideBaseView() views.BaseView { 28 | panic( 29 | wire.Build( 30 | viewSet, 31 | ), 32 | ) 33 | } 34 | 35 | var recodeViewableErrorBuilder = wire.NewSet( 36 | wire.Bind(new(presenters.ViewableErrorBuilder), new(presenters.RecodeViewableErrorBuilder)), 37 | presenters.NewRecodeViewableErrorBuilder, 38 | ) 39 | 40 | func ProvideRecodeViewableErrorBuilder() presenters.RecodeViewableErrorBuilder { 41 | panic( 42 | wire.Build( 43 | recodeViewableErrorBuilder, 44 | ), 45 | ) 46 | } 47 | 48 | var githubManagerSet = wire.NewSet( 49 | wire.Bind(new(interfaces.GitHubManager), new(github.Service)), 50 | github.NewService, 51 | ) 52 | 53 | var userConfigManagerSet = wire.NewSet( 54 | wire.Bind(new(interfaces.UserConfigManager), new(config.UserConfig)), 55 | config.NewUserConfig, 56 | ) 57 | 58 | var loggerSet = wire.NewSet( 59 | wire.Bind(new(interfaces.Logger), new(system.Logger)), 60 | system.NewLogger, 61 | ) 62 | 63 | var sshConfigManagerSet = wire.NewSet( 64 | wire.Bind(new(interfaces.SSHConfigManager), new(ssh.Config)), 65 | ssh.NewConfigWithDefaultConfigFilePath, 66 | ) 67 | 68 | var sshKnownHostsManagerSet = wire.NewSet( 69 | wire.Bind(new(interfaces.SSHKnownHostsManager), new(ssh.KnownHosts)), 70 | ssh.NewKnownHostsWithDefaultKnownHostsFilePath, 71 | ) 72 | 73 | var sshKeysManagerSet = wire.NewSet( 74 | wire.Bind(new(interfaces.SSHKeysManager), new(ssh.Keys)), 75 | ssh.NewKeysWithDefaultDir, 76 | ) 77 | 78 | var vscodeProcessManagerSet = wire.NewSet( 79 | wire.Bind(new(interfaces.VSCodeProcessManager), new(vscode.Process)), 80 | vscode.NewProcess, 81 | ) 82 | 83 | var vscodeExtensionsManagerSet = wire.NewSet( 84 | wire.Bind(new(interfaces.VSCodeExtensionsManager), new(vscode.Extensions)), 85 | vscode.NewExtensions, 86 | ) 87 | 88 | var browserManagerSet = wire.NewSet( 89 | wire.Bind(new(interfaces.BrowserManager), new(system.Browser)), 90 | system.NewBrowser, 91 | ) 92 | 93 | var sleeperSet = wire.NewSet( 94 | wire.Bind(new(interfaces.Sleeper), new(system.Sleeper)), 95 | system.NewSleeper, 96 | ) 97 | 98 | var stepperSet = wire.NewSet( 99 | wire.Bind(new(stepper.Stepper), new(stepperCLI.Stepper)), 100 | stepperCLI.NewStepper, 101 | ) 102 | -------------------------------------------------------------------------------- /internal/dependencies/wire_gen.go: -------------------------------------------------------------------------------- 1 | // Code generated by Wire. DO NOT EDIT. 2 | 3 | //go:generate go run github.com/google/wire/cmd/wire 4 | //go:build !wireinject 5 | // +build !wireinject 6 | 7 | package dependencies 8 | 9 | import ( 10 | "github.com/google/wire" 11 | "github.com/recode-sh/aws-cloud-provider/config" 12 | "github.com/recode-sh/aws-cloud-provider/service" 13 | "github.com/recode-sh/aws-cloud-provider/userconfig" 14 | "github.com/recode-sh/cli/internal/agent" 15 | "github.com/recode-sh/cli/internal/aws" 16 | config2 "github.com/recode-sh/cli/internal/config" 17 | "github.com/recode-sh/cli/internal/entities" 18 | features2 "github.com/recode-sh/cli/internal/features" 19 | "github.com/recode-sh/cli/internal/hooks" 20 | "github.com/recode-sh/cli/internal/interfaces" 21 | "github.com/recode-sh/cli/internal/presenters" 22 | "github.com/recode-sh/cli/internal/ssh" 23 | "github.com/recode-sh/cli/internal/stepper" 24 | "github.com/recode-sh/cli/internal/system" 25 | "github.com/recode-sh/cli/internal/views" 26 | "github.com/recode-sh/cli/internal/vscode" 27 | "github.com/recode-sh/recode/features" 28 | "github.com/recode-sh/recode/github" 29 | stepper2 "github.com/recode-sh/recode/stepper" 30 | ) 31 | 32 | // Injectors from aws_remove.go: 33 | 34 | func provideAWSRemoveFeature(userConfigEnvVarsResolverOpts userconfig.EnvVarsResolverOpts, userConfigFilesResolverOpts userconfig.FilesResolverOpts, userConfigLocalResolverOpts aws.UserConfigLocalResolverOpts) features.RemoveFeature { 35 | stepperStepper := stepper.NewStepper() 36 | awsAWSViewableErrorBuilder := aws.NewAWSViewableErrorBuilder() 37 | displayer := system.NewDisplayer() 38 | baseView := views.NewBaseView(displayer) 39 | removeView := views.NewRemoveView(baseView) 40 | removePresenter := presenters.NewRemovePresenter(awsAWSViewableErrorBuilder, removeView) 41 | removeOutputHandler := features2.NewRemoveOutputHandler(removePresenter) 42 | envVars := system.NewEnvVars() 43 | envVarsResolver := userconfig.NewEnvVarsResolver(envVars, userConfigEnvVarsResolverOpts) 44 | profileLoader := config.NewProfileLoader() 45 | filesResolver := userconfig.NewFilesResolver(profileLoader, userConfigFilesResolverOpts) 46 | userConfigLocalResolver := aws.NewUserConfigLocalResolver(envVarsResolver, filesResolver, userConfigLocalResolverOpts) 47 | userConfigValidator := config.NewUserConfigValidator() 48 | userConfigLoader := config.NewUserConfigLoader() 49 | builder := service.NewBuilder(userConfigLocalResolver, userConfigValidator, userConfigLoader) 50 | removeFeature := features.NewRemoveFeature(stepperStepper, removeOutputHandler, builder) 51 | return removeFeature 52 | } 53 | 54 | // Injectors from aws_start.go: 55 | 56 | func provideAWSStartFeature(userConfigEnvVarsResolverOpts userconfig.EnvVarsResolverOpts, userConfigFilesResolverOpts userconfig.FilesResolverOpts, userConfigLocalResolverOpts aws.UserConfigLocalResolverOpts) features.StartFeature { 57 | stepperStepper := stepper.NewStepper() 58 | userConfig := config2.NewUserConfig() 59 | awsAWSViewableErrorBuilder := aws.NewAWSViewableErrorBuilder() 60 | displayer := system.NewDisplayer() 61 | baseView := views.NewBaseView(displayer) 62 | startView := views.NewStartView(baseView) 63 | startPresenter := presenters.NewStartPresenter(awsAWSViewableErrorBuilder, startView) 64 | defaultClientBuilder := agent.NewDefaultClientBuilder() 65 | githubService := github.NewService() 66 | logger := system.NewLogger() 67 | sshConfig := ssh.NewConfigWithDefaultConfigFilePath() 68 | keys := ssh.NewKeysWithDefaultDir() 69 | knownHosts := ssh.NewKnownHostsWithDefaultKnownHostsFilePath() 70 | process := vscode.NewProcess() 71 | extensions := vscode.NewExtensions() 72 | startOutputHandler := features2.NewStartOutputHandler(userConfig, startPresenter, defaultClientBuilder, githubService, logger, sshConfig, keys, knownHosts, process, extensions) 73 | envVars := system.NewEnvVars() 74 | envVarsResolver := userconfig.NewEnvVarsResolver(envVars, userConfigEnvVarsResolverOpts) 75 | profileLoader := config.NewProfileLoader() 76 | filesResolver := userconfig.NewFilesResolver(profileLoader, userConfigFilesResolverOpts) 77 | userConfigLocalResolver := aws.NewUserConfigLocalResolver(envVarsResolver, filesResolver, userConfigLocalResolverOpts) 78 | userConfigValidator := config.NewUserConfigValidator() 79 | userConfigLoader := config.NewUserConfigLoader() 80 | builder := service.NewBuilder(userConfigLocalResolver, userConfigValidator, userConfigLoader) 81 | startFeature := features.NewStartFeature(stepperStepper, startOutputHandler, builder) 82 | return startFeature 83 | } 84 | 85 | // Injectors from aws_stop.go: 86 | 87 | func provideAWSStopFeature(userConfigEnvVarsResolverOpts userconfig.EnvVarsResolverOpts, userConfigFilesResolverOpts userconfig.FilesResolverOpts, userConfigLocalResolverOpts aws.UserConfigLocalResolverOpts) features.StopFeature { 88 | stepperStepper := stepper.NewStepper() 89 | awsAWSViewableErrorBuilder := aws.NewAWSViewableErrorBuilder() 90 | displayer := system.NewDisplayer() 91 | baseView := views.NewBaseView(displayer) 92 | stopView := views.NewStopView(baseView) 93 | stopPresenter := presenters.NewStopPresenter(awsAWSViewableErrorBuilder, stopView) 94 | knownHosts := ssh.NewKnownHostsWithDefaultKnownHostsFilePath() 95 | stopOutputHandler := features2.NewStopOutputHandler(stopPresenter, knownHosts) 96 | envVars := system.NewEnvVars() 97 | envVarsResolver := userconfig.NewEnvVarsResolver(envVars, userConfigEnvVarsResolverOpts) 98 | profileLoader := config.NewProfileLoader() 99 | filesResolver := userconfig.NewFilesResolver(profileLoader, userConfigFilesResolverOpts) 100 | userConfigLocalResolver := aws.NewUserConfigLocalResolver(envVarsResolver, filesResolver, userConfigLocalResolverOpts) 101 | userConfigValidator := config.NewUserConfigValidator() 102 | userConfigLoader := config.NewUserConfigLoader() 103 | builder := service.NewBuilder(userConfigLocalResolver, userConfigValidator, userConfigLoader) 104 | stopFeature := features.NewStopFeature(stepperStepper, stopOutputHandler, builder) 105 | return stopFeature 106 | } 107 | 108 | // Injectors from aws_uninstall.go: 109 | 110 | func provideAWSUninstallFeature(userConfigEnvVarsResolverOpts userconfig.EnvVarsResolverOpts, userConfigFilesResolverOpts userconfig.FilesResolverOpts, userConfigLocalResolverOpts aws.UserConfigLocalResolverOpts) features.UninstallFeature { 111 | stepperStepper := stepper.NewStepper() 112 | awsAWSViewableErrorBuilder := aws.NewAWSViewableErrorBuilder() 113 | displayer := system.NewDisplayer() 114 | baseView := views.NewBaseView(displayer) 115 | uninstallView := views.NewUninstallView(baseView) 116 | uninstallPresenter := presenters.NewUninstallPresenter(awsAWSViewableErrorBuilder, uninstallView) 117 | uninstallOutputHandler := features2.NewUninstallOutputHandler(uninstallPresenter) 118 | envVars := system.NewEnvVars() 119 | envVarsResolver := userconfig.NewEnvVarsResolver(envVars, userConfigEnvVarsResolverOpts) 120 | profileLoader := config.NewProfileLoader() 121 | filesResolver := userconfig.NewFilesResolver(profileLoader, userConfigFilesResolverOpts) 122 | userConfigLocalResolver := aws.NewUserConfigLocalResolver(envVarsResolver, filesResolver, userConfigLocalResolverOpts) 123 | userConfigValidator := config.NewUserConfigValidator() 124 | userConfigLoader := config.NewUserConfigLoader() 125 | builder := service.NewBuilder(userConfigLocalResolver, userConfigValidator, userConfigLoader) 126 | uninstallFeature := features.NewUninstallFeature(stepperStepper, uninstallOutputHandler, builder) 127 | return uninstallFeature 128 | } 129 | 130 | // Injectors from entities.go: 131 | 132 | func ProvideDevEnvUserConfigResolver() entities.DevEnvUserConfigResolver { 133 | logger := system.NewLogger() 134 | userConfig := config2.NewUserConfig() 135 | githubService := github.NewService() 136 | devEnvUserConfigResolver := entities.NewDevEnvUserConfigResolver(logger, userConfig, githubService) 137 | return devEnvUserConfigResolver 138 | } 139 | 140 | func ProvideDevEnvRepositoryResolver() entities.DevEnvRepositoryResolver { 141 | logger := system.NewLogger() 142 | userConfig := config2.NewUserConfig() 143 | githubService := github.NewService() 144 | devEnvRepositoryResolver := entities.NewDevEnvRepositoryResolver(logger, userConfig, githubService) 145 | return devEnvRepositoryResolver 146 | } 147 | 148 | // Injectors from hooks.go: 149 | 150 | func ProvidePreRemoveHook() hooks.PreRemove { 151 | sshConfig := ssh.NewConfigWithDefaultConfigFilePath() 152 | keys := ssh.NewKeysWithDefaultDir() 153 | knownHosts := ssh.NewKnownHostsWithDefaultKnownHostsFilePath() 154 | userConfig := config2.NewUserConfig() 155 | githubService := github.NewService() 156 | preRemove := hooks.NewPreRemove(sshConfig, keys, knownHosts, userConfig, githubService) 157 | return preRemove 158 | } 159 | 160 | func ProvidePreStopHook() hooks.PreStop { 161 | knownHosts := ssh.NewKnownHostsWithDefaultKnownHostsFilePath() 162 | preStop := hooks.NewPreStop(knownHosts) 163 | return preStop 164 | } 165 | 166 | // Injectors from login.go: 167 | 168 | func ProvideLoginFeature() features2.LoginFeature { 169 | presentersRecodeViewableErrorBuilder := presenters.NewRecodeViewableErrorBuilder() 170 | displayer := system.NewDisplayer() 171 | baseView := views.NewBaseView(displayer) 172 | loginView := views.NewLoginView(baseView) 173 | loginPresenter := presenters.NewLoginPresenter(presentersRecodeViewableErrorBuilder, loginView) 174 | logger := system.NewLogger() 175 | browser := system.NewBrowser() 176 | userConfig := config2.NewUserConfig() 177 | sleeper := system.NewSleeper() 178 | githubService := github.NewService() 179 | loginFeature := features2.NewLoginFeature(loginPresenter, logger, browser, userConfig, sleeper, githubService) 180 | return loginFeature 181 | } 182 | 183 | // Injectors from shared.go: 184 | 185 | func ProvideBaseView() views.BaseView { 186 | displayer := system.NewDisplayer() 187 | baseView := views.NewBaseView(displayer) 188 | return baseView 189 | } 190 | 191 | func ProvideRecodeViewableErrorBuilder() presenters.RecodeViewableErrorBuilder { 192 | presentersRecodeViewableErrorBuilder := presenters.NewRecodeViewableErrorBuilder() 193 | return presentersRecodeViewableErrorBuilder 194 | } 195 | 196 | // aws_remove.go: 197 | 198 | func ProvideAWSRemoveFeature(region, profile, credentialsFilePath, configFilePath string) features.RemoveFeature { 199 | return provideAWSRemoveFeature(userconfig.EnvVarsResolverOpts{ 200 | Region: region, 201 | }, userconfig.FilesResolverOpts{ 202 | Region: region, 203 | Profile: profile, 204 | CredentialsFilePath: credentialsFilePath, 205 | ConfigFilePath: configFilePath, 206 | }, aws.UserConfigLocalResolverOpts{ 207 | Profile: profile, 208 | }, 209 | ) 210 | } 211 | 212 | // aws_start.go: 213 | 214 | func ProvideAWSStartFeature(region, profile, credentialsFilePath, configFilePath string) features.StartFeature { 215 | return provideAWSStartFeature(userconfig.EnvVarsResolverOpts{ 216 | Region: region, 217 | }, userconfig.FilesResolverOpts{ 218 | Region: region, 219 | Profile: profile, 220 | CredentialsFilePath: credentialsFilePath, 221 | ConfigFilePath: configFilePath, 222 | }, aws.UserConfigLocalResolverOpts{ 223 | Profile: profile, 224 | }, 225 | ) 226 | } 227 | 228 | // aws_stop.go: 229 | 230 | func ProvideAWSStopFeature(region, profile, credentialsFilePath, configFilePath string) features.StopFeature { 231 | return provideAWSStopFeature(userconfig.EnvVarsResolverOpts{ 232 | Region: region, 233 | }, userconfig.FilesResolverOpts{ 234 | Region: region, 235 | Profile: profile, 236 | CredentialsFilePath: credentialsFilePath, 237 | ConfigFilePath: configFilePath, 238 | }, aws.UserConfigLocalResolverOpts{ 239 | Profile: profile, 240 | }, 241 | ) 242 | } 243 | 244 | // aws_uninstall.go: 245 | 246 | func ProvideAWSUninstallFeature(region, profile, credentialsFilePath, configFilePath string) features.UninstallFeature { 247 | return provideAWSUninstallFeature(userconfig.EnvVarsResolverOpts{ 248 | Region: region, 249 | }, userconfig.FilesResolverOpts{ 250 | Region: region, 251 | Profile: profile, 252 | CredentialsFilePath: credentialsFilePath, 253 | ConfigFilePath: configFilePath, 254 | }, aws.UserConfigLocalResolverOpts{ 255 | Profile: profile, 256 | }, 257 | ) 258 | } 259 | 260 | // shared.go: 261 | 262 | var viewSet = wire.NewSet(wire.Bind(new(views.Displayer), new(system.Displayer)), system.NewDisplayer, views.NewBaseView) 263 | 264 | var recodeViewableErrorBuilder = wire.NewSet(wire.Bind(new(presenters.ViewableErrorBuilder), new(presenters.RecodeViewableErrorBuilder)), presenters.NewRecodeViewableErrorBuilder) 265 | 266 | var githubManagerSet = wire.NewSet(wire.Bind(new(interfaces.GitHubManager), new(github.Service)), github.NewService) 267 | 268 | var userConfigManagerSet = wire.NewSet(wire.Bind(new(interfaces.UserConfigManager), new(config2.UserConfig)), config2.NewUserConfig) 269 | 270 | var loggerSet = wire.NewSet(wire.Bind(new(interfaces.Logger), new(system.Logger)), system.NewLogger) 271 | 272 | var sshConfigManagerSet = wire.NewSet(wire.Bind(new(interfaces.SSHConfigManager), new(ssh.Config)), ssh.NewConfigWithDefaultConfigFilePath) 273 | 274 | var sshKnownHostsManagerSet = wire.NewSet(wire.Bind(new(interfaces.SSHKnownHostsManager), new(ssh.KnownHosts)), ssh.NewKnownHostsWithDefaultKnownHostsFilePath) 275 | 276 | var sshKeysManagerSet = wire.NewSet(wire.Bind(new(interfaces.SSHKeysManager), new(ssh.Keys)), ssh.NewKeysWithDefaultDir) 277 | 278 | var vscodeProcessManagerSet = wire.NewSet(wire.Bind(new(interfaces.VSCodeProcessManager), new(vscode.Process)), vscode.NewProcess) 279 | 280 | var vscodeExtensionsManagerSet = wire.NewSet(wire.Bind(new(interfaces.VSCodeExtensionsManager), new(vscode.Extensions)), vscode.NewExtensions) 281 | 282 | var browserManagerSet = wire.NewSet(wire.Bind(new(interfaces.BrowserManager), new(system.Browser)), system.NewBrowser) 283 | 284 | var sleeperSet = wire.NewSet(wire.Bind(new(interfaces.Sleeper), new(system.Sleeper)), system.NewSleeper) 285 | 286 | var stepperSet = wire.NewSet(wire.Bind(new(stepper2.Stepper), new(stepper.Stepper)), stepper.NewStepper) 287 | -------------------------------------------------------------------------------- /internal/entities/dev_env.go: -------------------------------------------------------------------------------- 1 | package entities 2 | 3 | type DevEnvAdditionalProperties struct { 4 | GitHubCreatedSSHKeyId *int64 `json:"github_created_ssh_key_id"` 5 | GitHubCreatedGPGKeyId *int64 `json:"github_created_gpg_key_id"` 6 | } 7 | -------------------------------------------------------------------------------- /internal/entities/dev_env_repository_resolver.go: -------------------------------------------------------------------------------- 1 | package entities 2 | 3 | import ( 4 | // "github.com/fatih/color" 5 | // "github.com/google/go-github/v43/github" 6 | "github.com/recode-sh/cli/internal/config" 7 | "github.com/recode-sh/cli/internal/interfaces" 8 | "github.com/recode-sh/recode/entities" 9 | "github.com/recode-sh/recode/github" 10 | ) 11 | 12 | type DevEnvRepositoryResolver struct { 13 | logger interfaces.Logger 14 | userConfig interfaces.UserConfigManager 15 | github interfaces.GitHubManager 16 | } 17 | 18 | func NewDevEnvRepositoryResolver( 19 | logger interfaces.Logger, 20 | userConfig interfaces.UserConfigManager, 21 | github interfaces.GitHubManager, 22 | ) DevEnvRepositoryResolver { 23 | 24 | return DevEnvRepositoryResolver{ 25 | logger: logger, 26 | userConfig: userConfig, 27 | github: github, 28 | } 29 | } 30 | 31 | func (d DevEnvRepositoryResolver) Resolve( 32 | repositoryName string, 33 | checkForRepositoryExistence bool, 34 | ) (*entities.ResolvedDevEnvRepository, error) { 35 | 36 | githubAccessToken := d.userConfig.GetString( 37 | config.UserConfigKeyGitHubAccessToken, 38 | ) 39 | 40 | githubUsername := d.userConfig.GetString( 41 | config.UserConfigKeyGitHubUsername, 42 | ) 43 | 44 | parsedRepoName, err := github.ParseRepositoryName( 45 | repositoryName, 46 | githubUsername, 47 | ) 48 | 49 | if err != nil { 50 | // If repository name is invalid, we are sure 51 | // that the repository doesn't exist. 52 | return nil, entities.ErrDevEnvRepositoryNotFound{ 53 | RepoOwner: githubUsername, 54 | RepoName: repositoryName, 55 | } 56 | } 57 | 58 | if checkForRepositoryExistence { 59 | repoExists, err := d.github.DoesRepositoryExist( 60 | githubAccessToken, 61 | parsedRepoName.Owner, 62 | parsedRepoName.Name, 63 | ) 64 | 65 | if err != nil { 66 | return nil, err 67 | } 68 | 69 | // if !repoExists && parsedRepoName.Owner != githubUsername { 70 | if !repoExists { 71 | return nil, entities.ErrDevEnvRepositoryNotFound{ 72 | RepoOwner: parsedRepoName.Owner, 73 | RepoName: parsedRepoName.Name, 74 | } 75 | } 76 | } 77 | 78 | // if !repoExists { 79 | // bold := color.New(color.Bold).SprintFunc() 80 | 81 | // d.logger.Log( 82 | // "\n%s "+bold("Repository \"%s\" not found. Creating now..."), 83 | // bold(color.YellowString("Warning!")), 84 | // parsedRepoName.Name, 85 | // ) 86 | 87 | // // Means that we want the repository to be created 88 | // // in the logged user personal account. See GitHub SDK docs. 89 | // createdRepoOrganization := "" 90 | 91 | // createdRepoIsPrivate := true 92 | // createdRepoProps := &github.Repository{ 93 | // Name: &parsedRepoName.Name, 94 | // Private: &createdRepoIsPrivate, 95 | // } 96 | 97 | // _, err := d.github.CreateRepository( 98 | // githubAccessToken, 99 | // createdRepoOrganization, 100 | // createdRepoProps, 101 | // ) 102 | 103 | // if err != nil { 104 | // return nil, err 105 | // } 106 | // } 107 | 108 | return &entities.ResolvedDevEnvRepository{ 109 | Owner: parsedRepoName.Owner, 110 | ExplicitOwner: parsedRepoName.ExplicitOwner, 111 | 112 | Name: parsedRepoName.Name, 113 | 114 | GitURL: github.BuildGitURL( 115 | parsedRepoName.Owner, 116 | parsedRepoName.Name, 117 | ), 118 | 119 | GitHTTPURL: github.BuildGitHTTPURL( 120 | parsedRepoName.Owner, 121 | parsedRepoName.Name, 122 | ), 123 | }, nil 124 | } 125 | -------------------------------------------------------------------------------- /internal/entities/dev_env_user_config_resolver.go: -------------------------------------------------------------------------------- 1 | package entities 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/recode-sh/cli/internal/config" 7 | "github.com/recode-sh/cli/internal/constants" 8 | "github.com/recode-sh/cli/internal/interfaces" 9 | "github.com/recode-sh/recode/entities" 10 | "github.com/recode-sh/recode/github" 11 | ) 12 | 13 | type DevEnvUserConfigResolver struct { 14 | logger interfaces.Logger 15 | userConfig interfaces.UserConfigManager 16 | github interfaces.GitHubManager 17 | } 18 | 19 | func NewDevEnvUserConfigResolver( 20 | logger interfaces.Logger, 21 | userConfig interfaces.UserConfigManager, 22 | github interfaces.GitHubManager, 23 | ) DevEnvUserConfigResolver { 24 | 25 | return DevEnvUserConfigResolver{ 26 | logger: logger, 27 | userConfig: userConfig, 28 | github: github, 29 | } 30 | } 31 | 32 | func (d DevEnvUserConfigResolver) Resolve() ( 33 | *entities.ResolvedDevEnvUserConfig, 34 | error, 35 | ) { 36 | 37 | githubAccessToken := d.userConfig.GetString( 38 | config.UserConfigKeyGitHubAccessToken, 39 | ) 40 | 41 | devEnvUserConfigRepoOwner := d.userConfig.GetString( 42 | config.UserConfigKeyGitHubUsername, 43 | ) 44 | 45 | userHasDevEnvUserConfigRepo, err := d.github.DoesRepositoryExist( 46 | githubAccessToken, 47 | devEnvUserConfigRepoOwner, 48 | entities.DevEnvUserConfigRepoName, 49 | ) 50 | 51 | if err != nil { 52 | return nil, err 53 | } 54 | 55 | if !userHasDevEnvUserConfigRepo { 56 | blue := constants.Blue 57 | 58 | d.logger.Log( 59 | "\n%s No repository \"%s\" found in GitHub account \"%s\". The repository \"%s\" will be used instead.", 60 | blue("[Info]"), 61 | entities.DevEnvUserConfigRepoName, 62 | devEnvUserConfigRepoOwner, 63 | entities.DevEnvUserConfigDefaultRepoOwner+"/"+entities.DevEnvUserConfigRepoName, 64 | ) 65 | 66 | devEnvUserConfigRepoOwner = entities.DevEnvUserConfigDefaultRepoOwner 67 | } 68 | 69 | _, err = d.github.GetFileContentFromRepository( 70 | githubAccessToken, 71 | devEnvUserConfigRepoOwner, 72 | entities.DevEnvUserConfigRepoName, 73 | entities.DevEnvUserConfigDockerfileFileName, 74 | ) 75 | 76 | if err != nil && d.github.IsNotFoundError(err) { 77 | return nil, entities.ErrInvalidDevEnvUserConfig{ 78 | RepoOwner: devEnvUserConfigRepoOwner, 79 | Reason: fmt.Sprintf( 80 | "Your repository must contain a file named \"%s\".", 81 | entities.DevEnvUserConfigDockerfileFileName, 82 | ), 83 | } 84 | } 85 | 86 | if err != nil { 87 | return nil, err 88 | } 89 | 90 | return &entities.ResolvedDevEnvUserConfig{ 91 | RepoOwner: devEnvUserConfigRepoOwner, 92 | RepoName: entities.DevEnvUserConfigRepoName, 93 | 94 | RepoGitURL: github.BuildGitURL( 95 | devEnvUserConfigRepoOwner, 96 | entities.DevEnvUserConfigRepoName, 97 | ), 98 | 99 | RepoGitHTTPURL: github.BuildGitHTTPURL( 100 | devEnvUserConfigRepoOwner, 101 | entities.DevEnvUserConfigRepoName, 102 | ), 103 | }, nil 104 | } 105 | -------------------------------------------------------------------------------- /internal/exceptions/requirements.go: -------------------------------------------------------------------------------- 1 | package exceptions 2 | 3 | type ErrMissingRequirements struct { 4 | MissingRequirements []string 5 | } 6 | 7 | func (ErrMissingRequirements) Error() string { 8 | return "ErrMissingRequirements" 9 | } 10 | -------------------------------------------------------------------------------- /internal/exceptions/user.go: -------------------------------------------------------------------------------- 1 | package exceptions 2 | 3 | import "errors" 4 | 5 | var ( 6 | ErrUserNotLoggedIn = errors.New("ErrUserNotLoggedIn") 7 | ) 8 | 9 | type ErrLoginError struct { 10 | Reason string 11 | } 12 | 13 | func (ErrLoginError) Error() string { 14 | return "ErrLoginError" 15 | } 16 | -------------------------------------------------------------------------------- /internal/features/login.go: -------------------------------------------------------------------------------- 1 | package features 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "html" 7 | "net" 8 | "net/http" 9 | "net/url" 10 | "time" 11 | 12 | "github.com/recode-sh/cli/internal/config" 13 | "github.com/recode-sh/cli/internal/constants" 14 | "github.com/recode-sh/cli/internal/exceptions" 15 | "github.com/recode-sh/cli/internal/interfaces" 16 | "golang.org/x/oauth2" 17 | ) 18 | 19 | type LoginInput struct{} 20 | 21 | type LoginResponseContent struct{} 22 | 23 | type LoginResponse struct { 24 | Error error 25 | Content LoginResponseContent 26 | } 27 | 28 | type LoginPresenter interface { 29 | PresentToView(LoginResponse) 30 | } 31 | 32 | type LoginFeature struct { 33 | presenter LoginPresenter 34 | logger interfaces.Logger 35 | browser interfaces.BrowserManager 36 | userConfig interfaces.UserConfigManager 37 | sleeper interfaces.Sleeper 38 | github interfaces.GitHubManager 39 | } 40 | 41 | func NewLoginFeature( 42 | presenter LoginPresenter, 43 | logger interfaces.Logger, 44 | browser interfaces.BrowserManager, 45 | config interfaces.UserConfigManager, 46 | sleeper interfaces.Sleeper, 47 | github interfaces.GitHubManager, 48 | ) LoginFeature { 49 | 50 | return LoginFeature{ 51 | presenter: presenter, 52 | logger: logger, 53 | browser: browser, 54 | userConfig: config, 55 | sleeper: sleeper, 56 | github: github, 57 | } 58 | } 59 | 60 | func (l LoginFeature) Execute(input LoginInput) error { 61 | handleError := func(err error) error { 62 | l.presenter.PresentToView(LoginResponse{ 63 | Error: exceptions.ErrLoginError{ 64 | Reason: err.Error(), 65 | }, 66 | }) 67 | 68 | return err 69 | } 70 | 71 | gitHubOAuthCbHandlerResp := struct { 72 | Error error 73 | AccessToken string 74 | }{} 75 | 76 | gitHubOauthCbHandlerDoneChan := make(chan struct{}) 77 | 78 | gitHubOAuthCbHandler := func(w http.ResponseWriter, r *http.Request) { 79 | defer close(gitHubOauthCbHandlerDoneChan) 80 | 81 | queryComponents, err := url.ParseQuery(r.URL.RawQuery) 82 | 83 | if err != nil { 84 | gitHubOAuthCbHandlerResp.Error = err 85 | return 86 | } 87 | 88 | errorInQuery, hasErrorInQuery := queryComponents["error"] 89 | 90 | if hasErrorInQuery { 91 | msg := "

Error!

" 92 | msg = msg + "

" + html.EscapeString(errorInQuery[0]) + "

" 93 | 94 | w.WriteHeader(500) 95 | w.Write([]byte(msg)) 96 | 97 | gitHubOAuthCbHandlerResp.Error = errors.New(errorInQuery[0]) 98 | return 99 | } 100 | 101 | accessTokenInQuery, hasAccessTokenInQuery := queryComponents["access_token"] 102 | 103 | if !hasAccessTokenInQuery { 104 | msg := "

Error!

" 105 | msg = msg + "

An unknown error occured during GitHub connection. Please retry.

" 106 | 107 | w.WriteHeader(500) 108 | w.Write([]byte(msg)) 109 | 110 | gitHubOAuthCbHandlerResp.Error = errors.New("no access token returned after authorization") 111 | return 112 | } 113 | 114 | msg := "

Success!

" 115 | msg = msg + "

Your GitHub account is now connected. You can close this tab and go back to the Recode CLI.

" 116 | 117 | w.WriteHeader(200) 118 | w.Write([]byte(msg)) 119 | 120 | gitHubOAuthCbHandlerResp.AccessToken = accessTokenInQuery[0] 121 | } // <- End of gitHubOAuthCbHandler 122 | 123 | http.HandleFunc( 124 | config.GitHubOAuthAPIToCLIURLPath, 125 | gitHubOAuthCbHandler, 126 | ) 127 | 128 | // Assign a random port to our http server 129 | httpListener, err := net.Listen("tcp", ":0") 130 | 131 | if err != nil { 132 | return handleError(err) 133 | } 134 | 135 | httpServerServeErrorChan := make(chan error, 1) 136 | go func() { 137 | httpServerServeErrorChan <- http.Serve(httpListener, nil) 138 | }() 139 | 140 | httpListenPort := httpListener.Addr().(*net.TCPAddr).Port 141 | 142 | gitHubOAuthClient := &oauth2.Config{ 143 | ClientID: config.GitHubOAuthClientID, 144 | Scopes: config.GitHubOAuthScopes, 145 | Endpoint: oauth2.Endpoint{ 146 | AuthURL: "https://github.com/login/oauth/authorize", 147 | }, 148 | RedirectURL: config.GitHubOAuthCLIToAPIURL, 149 | } 150 | 151 | // Listen port is passed through OAuth 152 | // state because GitHub doesn't support 153 | // dynamic redirect URIs 154 | gitHubOAuthAuthorizeURL := gitHubOAuthClient.AuthCodeURL( 155 | fmt.Sprintf("%d", httpListenPort), 156 | ) 157 | 158 | bold := constants.Bold 159 | l.logger.Log(bold("\nYou will be taken to your browser to connect your GitHub account...\n")) 160 | 161 | l.logger.Info("If your browser doesn't open automatically, go to the following link:\n") 162 | l.logger.Log("%s", gitHubOAuthAuthorizeURL) 163 | 164 | l.sleeper.Sleep(4 * time.Second) 165 | 166 | if err := l.browser.OpenURL(gitHubOAuthAuthorizeURL); err != nil { 167 | l.logger.Error( 168 | "\nCannot open browser! Please visit above URL ↑", 169 | ) 170 | } 171 | 172 | l.logger.Warning("\nWaiting for GitHub authorization... (Press Ctrl-C to quit)\n") 173 | 174 | select { 175 | case httpServerServeError := <-httpServerServeErrorChan: 176 | return handleError(httpServerServeError) 177 | case <-gitHubOauthCbHandlerDoneChan: 178 | // We swallow the httpListener.Close() error here 179 | // given that the CLI will exit and force all 180 | // resources to be released 181 | _ = httpListener.Close() 182 | } 183 | 184 | if gitHubOAuthCbHandlerResp.Error != nil { 185 | return handleError(gitHubOAuthCbHandlerResp.Error) 186 | } 187 | 188 | githubUser, err := l.github.GetAuthenticatedUser( 189 | gitHubOAuthCbHandlerResp.AccessToken, 190 | ) 191 | 192 | if err != nil { 193 | return handleError(err) 194 | } 195 | 196 | l.userConfig.Set( 197 | config.UserConfigKeyUserIsLoggedIn, 198 | true, 199 | ) 200 | 201 | l.userConfig.Set( 202 | config.UserConfigKeyGitHubAccessToken, 203 | gitHubOAuthCbHandlerResp.AccessToken, 204 | ) 205 | 206 | l.userConfig.PopulateFromGitHubUser( 207 | githubUser, 208 | ) 209 | 210 | if err := l.userConfig.WriteConfig(); err != nil { 211 | return handleError(err) 212 | } 213 | 214 | l.presenter.PresentToView(LoginResponse{}) 215 | return nil 216 | } 217 | -------------------------------------------------------------------------------- /internal/features/remove.go: -------------------------------------------------------------------------------- 1 | package features 2 | 3 | import ( 4 | "github.com/recode-sh/recode/features" 5 | ) 6 | 7 | type RemoveResponse struct { 8 | Error error 9 | Content RemoveResponseContent 10 | } 11 | 12 | type RemoveResponseContent struct { 13 | DevEnvName string 14 | } 15 | 16 | type RemovePresenter interface { 17 | PresentToView(RemoveResponse) 18 | } 19 | 20 | type RemoveOutputHandler struct { 21 | presenter RemovePresenter 22 | } 23 | 24 | func NewRemoveOutputHandler( 25 | presenter RemovePresenter, 26 | ) RemoveOutputHandler { 27 | 28 | return RemoveOutputHandler{ 29 | presenter: presenter, 30 | } 31 | } 32 | 33 | func (r RemoveOutputHandler) HandleOutput(output features.RemoveOutput) error { 34 | output.Stepper.StopCurrentStep() 35 | 36 | handleError := func(err error) error { 37 | r.presenter.PresentToView(RemoveResponse{ 38 | Error: err, 39 | }) 40 | 41 | return err 42 | } 43 | 44 | if output.Error != nil { 45 | return handleError(output.Error) 46 | } 47 | 48 | devEnv := output.Content.DevEnv 49 | 50 | r.presenter.PresentToView(RemoveResponse{ 51 | Content: RemoveResponseContent{ 52 | DevEnvName: devEnv.Name, 53 | }, 54 | }) 55 | 56 | return nil 57 | } 58 | -------------------------------------------------------------------------------- /internal/features/start.go: -------------------------------------------------------------------------------- 1 | package features 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "log" 9 | "strconv" 10 | "strings" 11 | 12 | "github.com/recode-sh/agent/constants" 13 | "github.com/recode-sh/agent/proto" 14 | "github.com/recode-sh/cli/internal/agent" 15 | "github.com/recode-sh/cli/internal/config" 16 | cliConstants "github.com/recode-sh/cli/internal/constants" 17 | cliEntities "github.com/recode-sh/cli/internal/entities" 18 | "github.com/recode-sh/cli/internal/interfaces" 19 | "github.com/recode-sh/recode/actions" 20 | "github.com/recode-sh/recode/entities" 21 | "github.com/recode-sh/recode/features" 22 | ) 23 | 24 | type StartResponse struct { 25 | Error error 26 | Content StartResponseContent 27 | } 28 | 29 | type StartResponseContent struct { 30 | DevEnvName string 31 | DevEnvAlreadyStarted bool 32 | DevEnvRebuilt bool 33 | } 34 | 35 | type StartPresenter interface { 36 | PresentToView(StartResponse) 37 | } 38 | 39 | type StartOutputHandler struct { 40 | userConfig interfaces.UserConfigManager 41 | presenter StartPresenter 42 | agentClientBuilder agent.ClientBuilder 43 | github interfaces.GitHubManager 44 | logger interfaces.Logger 45 | sshConfig interfaces.SSHConfigManager 46 | sshKeys interfaces.SSHKeysManager 47 | sshKnownHosts interfaces.SSHKnownHostsManager 48 | vscodeProcess interfaces.VSCodeProcessManager 49 | vscodeExtensions interfaces.VSCodeExtensionsManager 50 | } 51 | 52 | func NewStartOutputHandler( 53 | userConfig interfaces.UserConfigManager, 54 | presenter StartPresenter, 55 | agentClientBuilder agent.ClientBuilder, 56 | github interfaces.GitHubManager, 57 | logger interfaces.Logger, 58 | sshConfig interfaces.SSHConfigManager, 59 | sshKeys interfaces.SSHKeysManager, 60 | sshKnownHosts interfaces.SSHKnownHostsManager, 61 | vscodeProcess interfaces.VSCodeProcessManager, 62 | vscodeExtensions interfaces.VSCodeExtensionsManager, 63 | ) StartOutputHandler { 64 | 65 | return StartOutputHandler{ 66 | userConfig: userConfig, 67 | presenter: presenter, 68 | agentClientBuilder: agentClientBuilder, 69 | github: github, 70 | logger: logger, 71 | sshConfig: sshConfig, 72 | sshKnownHosts: sshKnownHosts, 73 | sshKeys: sshKeys, 74 | vscodeProcess: vscodeProcess, 75 | vscodeExtensions: vscodeExtensions, 76 | } 77 | } 78 | 79 | func (s StartOutputHandler) HandleOutput(output features.StartOutput) error { 80 | 81 | stepper := output.Stepper 82 | 83 | handleError := func(err error) error { 84 | stepper.StopCurrentStep() 85 | 86 | s.presenter.PresentToView(StartResponse{ 87 | Error: err, 88 | }) 89 | 90 | return err 91 | } 92 | 93 | if output.Error != nil { 94 | return handleError(output.Error) 95 | } 96 | 97 | devEnv := output.Content.DevEnv 98 | 99 | devEnvCreated := output.Content.DevEnvCreated 100 | devEnvStarted := output.Content.DevEnvStarted 101 | devEnvRebuildAsked := output.Content.DevEnvRebuildAsked 102 | 103 | devEnvAlreadyStarted := !devEnvCreated && !devEnvStarted && !devEnvRebuildAsked 104 | 105 | var devEnvAdditionalProperties *cliEntities.DevEnvAdditionalProperties 106 | 107 | if len(devEnv.AdditionalPropertiesJSON) > 0 { 108 | err := json.Unmarshal( 109 | []byte(devEnv.AdditionalPropertiesJSON), 110 | &devEnvAdditionalProperties, 111 | ) 112 | 113 | if err != nil { 114 | return handleError(err) 115 | } 116 | } 117 | 118 | if devEnvAdditionalProperties == nil { 119 | devEnvAdditionalProperties = &cliEntities.DevEnvAdditionalProperties{} 120 | } 121 | 122 | if devEnvCreated || devEnvRebuildAsked { 123 | if !devEnvRebuildAsked { 124 | stepper.StartTemporaryStep( 125 | "Building your development environment", 126 | ) 127 | } 128 | 129 | agentClient := s.agentClientBuilder.Build( 130 | agent.NewDefaultClientConfig( 131 | []byte(devEnv.SSHKeyPairPEMContent), 132 | devEnv.InstancePublicIPAddress, 133 | ), 134 | ) 135 | 136 | err := agentClient.InitInstance(&proto.InitInstanceRequest{ 137 | DevEnvNameSlug: devEnv.GetNameSlug(), 138 | GithubUserEmail: s.userConfig.GetString(config.UserConfigKeyGitHubEmail), 139 | UserFullName: s.userConfig.GetString(config.UserConfigKeyGitHubFullName), 140 | }, func(stream agent.InitInstanceStream) error { 141 | 142 | for { 143 | initInstanceReply, err := stream.Recv() 144 | 145 | if err == io.EOF { 146 | break 147 | } 148 | 149 | if err != nil { 150 | return err 151 | } 152 | 153 | if initInstanceReply.GithubSshPublicKeyContent != nil && 154 | devEnvAdditionalProperties.GitHubCreatedSSHKeyId == nil { 155 | 156 | sshKeyInGitHub, err := s.github.CreateSSHKey( 157 | s.userConfig.GetString(config.UserConfigKeyGitHubAccessToken), 158 | fmt.Sprintf("recode-%s", devEnv.GetNameSlug()), 159 | initInstanceReply.GetGithubSshPublicKeyContent(), 160 | ) 161 | 162 | if err != nil { 163 | return err 164 | } 165 | 166 | devEnvAdditionalProperties.GitHubCreatedSSHKeyId = sshKeyInGitHub.ID 167 | err = devEnv.SetAdditionalPropertiesJSON(devEnvAdditionalProperties) 168 | 169 | if err != nil { 170 | return err 171 | } 172 | 173 | err = actions.UpdateDevEnvInConfig( 174 | stepper, 175 | output.Content.CloudService, 176 | output.Content.RecodeConfig, 177 | output.Content.Cluster, 178 | devEnv, 179 | ) 180 | 181 | if err != nil { 182 | return err 183 | } 184 | } 185 | 186 | if initInstanceReply.GithubGpgPublicKeyContent != nil && 187 | devEnvAdditionalProperties.GitHubCreatedGPGKeyId == nil { 188 | 189 | gpgKeyInGitHub, err := s.github.CreateGPGKey( 190 | s.userConfig.GetString(config.UserConfigKeyGitHubAccessToken), 191 | initInstanceReply.GetGithubGpgPublicKeyContent(), 192 | ) 193 | 194 | if err != nil { 195 | return err 196 | } 197 | 198 | devEnvAdditionalProperties.GitHubCreatedGPGKeyId = gpgKeyInGitHub.ID 199 | err = devEnv.SetAdditionalPropertiesJSON(devEnvAdditionalProperties) 200 | 201 | if err != nil { 202 | return err 203 | } 204 | 205 | err = actions.UpdateDevEnvInConfig( 206 | stepper, 207 | output.Content.CloudService, 208 | output.Content.RecodeConfig, 209 | output.Content.Cluster, 210 | devEnv, 211 | ) 212 | 213 | if err != nil { 214 | return err 215 | } 216 | } 217 | } 218 | 219 | return nil 220 | }) 221 | 222 | if err != nil { 223 | return handleError(err) 224 | } 225 | 226 | resolvedDevEnvUserConfig := devEnv.ResolvedUserConfig 227 | resolvedRepository := devEnv.ResolvedRepository 228 | 229 | err = agentClient.BuildAndStartDevEnv( 230 | &proto.BuildAndStartDevEnvRequest{ 231 | DevEnvRepoOwner: resolvedRepository.Owner, 232 | DevEnvRepoName: resolvedRepository.Name, 233 | UserConfigRepoOwner: resolvedDevEnvUserConfig.RepoOwner, 234 | UserConfigRepoName: resolvedDevEnvUserConfig.RepoName, 235 | }, 236 | func(stream agent.BuildAndStartDevEnvStream) error { 237 | 238 | var hasUncompletedLogLine = false 239 | var uncompletedLogLineBuf bytes.Buffer 240 | 241 | newLineIndentSpaces := " " 242 | formatLogLine := func(logLine string) string { 243 | return strings.TrimPrefix(strings.ReplaceAll( 244 | strings.ReplaceAll( 245 | logLine, "\n", "\n"+newLineIndentSpaces, 246 | ), 247 | "\r", 248 | "\r"+newLineIndentSpaces, 249 | ), "\n"+newLineIndentSpaces) 250 | } 251 | 252 | for { 253 | startDevEnvReply, err := stream.Recv() 254 | 255 | // Make sure to display all logs 256 | // especially in case of error 257 | if uncompletedLogLineBuf.Len() > 0 { 258 | s.logger.LogNoNewline(strings.TrimSuffix(uncompletedLogLineBuf.String(), "\n")) 259 | uncompletedLogLineBuf = bytes.Buffer{} 260 | } 261 | 262 | if err != nil && hasUncompletedLogLine { 263 | s.logger.Log("") 264 | } 265 | 266 | if err == io.EOF { 267 | break 268 | } 269 | 270 | if err != nil { 271 | return err 272 | } 273 | 274 | stepper.StopCurrentStep() 275 | 276 | if len(startDevEnvReply.LogLineHeader) > 0 { 277 | bold := cliConstants.Bold 278 | blue := cliConstants.Blue 279 | s.logger.Log(bold(blue("> "+startDevEnvReply.LogLineHeader)) + "\n") 280 | } 281 | 282 | if len(startDevEnvReply.LogLine) > 0 { 283 | goLoggerNoNewLine := log.New(&uncompletedLogLineBuf, " ", log.Flags()) 284 | goLoggerNewLine := log.New(s.logger, " ", log.Flags()) 285 | 286 | // No prefix for empty new lines 287 | if startDevEnvReply.LogLine == "\n" { 288 | hasUncompletedLogLine = false 289 | 290 | s.logger.Log("\n") 291 | 292 | continue 293 | } 294 | 295 | if strings.HasSuffix(startDevEnvReply.LogLine, "\n") && 296 | !strings.Contains(startDevEnvReply.LogLine, "\r") { 297 | 298 | if hasUncompletedLogLine { 299 | hasUncompletedLogLine = false 300 | 301 | // Ends the log line 302 | // and add a new line at end 303 | s.logger.Log(strings.TrimSuffix( 304 | formatLogLine( 305 | startDevEnvReply.LogLine, 306 | ), 307 | "\n"+newLineIndentSpaces, 308 | ) + "\n") 309 | 310 | continue 311 | } 312 | 313 | hasUncompletedLogLine = false 314 | 315 | // Start a complete log line by prefix 316 | // and add a new line at end 317 | goLoggerNewLine.Print(strings.TrimSuffix( 318 | formatLogLine( 319 | startDevEnvReply.LogLine, 320 | ), 321 | "\n"+newLineIndentSpaces, 322 | ) + "\n") 323 | 324 | continue 325 | } 326 | 327 | if !hasUncompletedLogLine { 328 | // Start an uncompleted log line by prefix 329 | // but without a new line at end 330 | goLoggerNoNewLine.Print(formatLogLine( 331 | startDevEnvReply.LogLine, 332 | )) 333 | 334 | hasUncompletedLogLine = true 335 | continue 336 | } 337 | 338 | // Continue logging uncompleted log line 339 | // without adding prefix or new line 340 | s.logger.LogNoNewline(formatLogLine( 341 | startDevEnvReply.LogLine, 342 | )) 343 | } 344 | } 345 | 346 | return nil 347 | }, 348 | ) 349 | 350 | if err != nil { 351 | return handleError(err) 352 | } 353 | } 354 | 355 | if !devEnvAlreadyStarted { 356 | err := output.Content.SetDevEnvAsStarted() 357 | 358 | if err != nil { 359 | return handleError(err) 360 | } 361 | } 362 | 363 | stepper.StartTemporaryStepWithoutNewLine( 364 | "Updating your local SSH configuration", 365 | ) 366 | 367 | sshPEMPath, err := s.sshKeys.CreateOrReplacePEM( 368 | devEnv.GetSSHKeyPairName(), 369 | devEnv.SSHKeyPairPEMContent, 370 | ) 371 | 372 | if err != nil { 373 | return handleError(err) 374 | } 375 | 376 | sshServerListenPort, err := strconv.ParseInt( 377 | constants.SSHServerListenPort, 378 | 10, 379 | 64, 380 | ) 381 | 382 | if err != nil { 383 | return handleError(err) 384 | } 385 | 386 | sshConfigHostKey := devEnv.Name 387 | 388 | err = s.sshConfig.AddOrReplaceHost( 389 | sshConfigHostKey, 390 | devEnv.InstancePublicIPAddress, 391 | sshPEMPath, 392 | entities.DevEnvRootUser, 393 | sshServerListenPort, 394 | ) 395 | 396 | if err != nil { 397 | return handleError(err) 398 | } 399 | 400 | for _, sshHostKey := range devEnv.SSHHostKeys { 401 | err := s.sshKnownHosts.AddOrReplace( 402 | devEnv.InstancePublicIPAddress, 403 | sshHostKey.Algorithm, 404 | sshHostKey.Fingerprint, 405 | ) 406 | 407 | if err != nil { 408 | return handleError(err) 409 | } 410 | } 411 | 412 | stepper.StartTemporaryStepWithoutNewLine( 413 | "Installing Visual Studio Code Remote - SSH extension", 414 | ) 415 | 416 | _, err = s.vscodeExtensions.Install("ms-vscode-remote.remote-ssh") 417 | 418 | if err != nil { 419 | return handleError(err) 420 | } 421 | 422 | stepper.StartTemporaryStepWithoutNewLine( 423 | "Opening Visual Studio Code", 424 | ) 425 | 426 | _, err = s.vscodeProcess.OpenOnRemote( 427 | sshConfigHostKey, 428 | constants.DevEnvVSCodeWorkspaceConfigFilePath, 429 | ) 430 | 431 | if err != nil { 432 | return handleError(err) 433 | } 434 | 435 | stepper.StopCurrentStep() 436 | 437 | s.presenter.PresentToView(StartResponse{ 438 | Content: StartResponseContent{ 439 | DevEnvName: devEnv.Name, 440 | DevEnvAlreadyStarted: devEnvAlreadyStarted, 441 | DevEnvRebuilt: devEnvRebuildAsked, 442 | }, 443 | }) 444 | 445 | return nil 446 | } 447 | -------------------------------------------------------------------------------- /internal/features/stop.go: -------------------------------------------------------------------------------- 1 | package features 2 | 3 | import ( 4 | "github.com/recode-sh/cli/internal/interfaces" 5 | "github.com/recode-sh/recode/features" 6 | ) 7 | 8 | type StopResponse struct { 9 | Error error 10 | Content StopResponseContent 11 | } 12 | 13 | type StopResponseContent struct { 14 | DevEnvName string 15 | DevEnvAlreadyStopped bool 16 | } 17 | 18 | type StopPresenter interface { 19 | PresentToView(StopResponse) 20 | } 21 | 22 | type StopOutputHandler struct { 23 | presenter StopPresenter 24 | sshKnownHosts interfaces.SSHKnownHostsManager 25 | } 26 | 27 | func NewStopOutputHandler( 28 | presenter StopPresenter, 29 | sshKnownHosts interfaces.SSHKnownHostsManager, 30 | ) StopOutputHandler { 31 | 32 | return StopOutputHandler{ 33 | presenter: presenter, 34 | sshKnownHosts: sshKnownHosts, 35 | } 36 | } 37 | 38 | func (s StopOutputHandler) HandleOutput(output features.StopOutput) error { 39 | output.Stepper.StopCurrentStep() 40 | 41 | handleError := func(err error) error { 42 | s.presenter.PresentToView(StopResponse{ 43 | Error: err, 44 | }) 45 | 46 | return err 47 | } 48 | 49 | if output.Error != nil { 50 | return handleError(output.Error) 51 | } 52 | 53 | devEnv := output.Content.DevEnv 54 | devEnvAlreadyStopped := output.Content.DevEnvAlreadyStopped 55 | 56 | if !devEnvAlreadyStopped { 57 | err := output.Content.SetDevEnvAsStopped() 58 | 59 | if err != nil { 60 | return handleError(err) 61 | } 62 | } 63 | 64 | s.presenter.PresentToView(StopResponse{ 65 | Content: StopResponseContent{ 66 | DevEnvName: devEnv.Name, 67 | DevEnvAlreadyStopped: devEnvAlreadyStopped, 68 | }, 69 | }) 70 | 71 | return nil 72 | } 73 | -------------------------------------------------------------------------------- /internal/features/uninstall.go: -------------------------------------------------------------------------------- 1 | package features 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/recode-sh/cli/internal/system" 7 | "github.com/recode-sh/recode/features" 8 | ) 9 | 10 | type UninstallResponse struct { 11 | Error error 12 | Content UninstallResponseContent 13 | } 14 | 15 | type UninstallResponseContent struct { 16 | RecodeAlreadyUninstalled bool 17 | SuccessMessage string 18 | AlreadyUninstalledMessage string 19 | RecodeExecutablePath string 20 | RecodeConfigDirPath string 21 | } 22 | 23 | type UninstallPresenter interface { 24 | PresentToView(UninstallResponse) 25 | } 26 | 27 | type UninstallOutputHandler struct { 28 | presenter UninstallPresenter 29 | } 30 | 31 | func NewUninstallOutputHandler( 32 | presenter UninstallPresenter, 33 | ) UninstallOutputHandler { 34 | 35 | return UninstallOutputHandler{ 36 | presenter: presenter, 37 | } 38 | } 39 | 40 | func (u UninstallOutputHandler) HandleOutput(output features.UninstallOutput) error { 41 | output.Stepper.StopCurrentStep() 42 | 43 | handleError := func(err error) error { 44 | u.presenter.PresentToView(UninstallResponse{ 45 | Error: err, 46 | }) 47 | 48 | return err 49 | } 50 | 51 | if output.Error != nil { 52 | return handleError(output.Error) 53 | } 54 | 55 | recodeExecutablePath, err := os.Executable() 56 | 57 | if err != nil { 58 | return handleError(err) 59 | } 60 | 61 | recodeConfigDirPath := system.UserConfigDir() 62 | 63 | u.presenter.PresentToView(UninstallResponse{ 64 | Content: UninstallResponseContent{ 65 | RecodeAlreadyUninstalled: output.Content.RecodeAlreadyUninstalled, 66 | SuccessMessage: output.Content.SuccessMessage, 67 | AlreadyUninstalledMessage: output.Content.AlreadyUninstalledMessage, 68 | RecodeExecutablePath: recodeExecutablePath, 69 | RecodeConfigDirPath: recodeConfigDirPath, 70 | }, 71 | }) 72 | 73 | return nil 74 | } 75 | -------------------------------------------------------------------------------- /internal/hooks/pre_remove.go: -------------------------------------------------------------------------------- 1 | package hooks 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "github.com/recode-sh/cli/internal/config" 7 | cliEntities "github.com/recode-sh/cli/internal/entities" 8 | "github.com/recode-sh/cli/internal/interfaces" 9 | "github.com/recode-sh/recode/entities" 10 | ) 11 | 12 | type PreRemove struct { 13 | sshConfig interfaces.SSHConfigManager 14 | sshKeys interfaces.SSHKeysManager 15 | sshKnownHosts interfaces.SSHKnownHostsManager 16 | userConfig interfaces.UserConfigManager 17 | github interfaces.GitHubManager 18 | } 19 | 20 | func NewPreRemove( 21 | sshConfig interfaces.SSHConfigManager, 22 | sshKeys interfaces.SSHKeysManager, 23 | sshKnownHosts interfaces.SSHKnownHostsManager, 24 | userConfig interfaces.UserConfigManager, 25 | github interfaces.GitHubManager, 26 | ) PreRemove { 27 | 28 | return PreRemove{ 29 | sshConfig: sshConfig, 30 | sshKeys: sshKeys, 31 | sshKnownHosts: sshKnownHosts, 32 | userConfig: userConfig, 33 | github: github, 34 | } 35 | } 36 | 37 | func (p PreRemove) Run( 38 | cloudService entities.CloudService, 39 | recodeConfig *entities.Config, 40 | cluster *entities.Cluster, 41 | devEnv *entities.DevEnv, 42 | ) error { 43 | 44 | err := p.sshKeys.RemovePEMIfExists(devEnv.GetSSHKeyPairName()) 45 | 46 | if err != nil { 47 | return err 48 | } 49 | 50 | sshConfigHostKey := devEnv.Name 51 | err = p.sshConfig.RemoveHostIfExists(sshConfigHostKey) 52 | 53 | if err != nil { 54 | return err 55 | } 56 | 57 | sshHostname := devEnv.InstancePublicIPAddress 58 | err = p.sshKnownHosts.RemoveIfExists(sshHostname) 59 | 60 | if err != nil { 61 | return err 62 | } 63 | 64 | // User could remove dev env in creating state 65 | // (in case of error for example) 66 | if len(devEnv.AdditionalPropertiesJSON) == 0 { 67 | return nil 68 | } 69 | 70 | var devEnvAdditionalProperties *cliEntities.DevEnvAdditionalProperties 71 | err = json.Unmarshal( 72 | []byte(devEnv.AdditionalPropertiesJSON), 73 | &devEnvAdditionalProperties, 74 | ) 75 | 76 | if err != nil { 77 | return err 78 | } 79 | 80 | githubAccessToken := p.userConfig.GetString( 81 | config.UserConfigKeyGitHubAccessToken, 82 | ) 83 | 84 | if devEnvAdditionalProperties.GitHubCreatedSSHKeyId != nil { 85 | err = p.github.RemoveSSHKey( 86 | githubAccessToken, 87 | *devEnvAdditionalProperties.GitHubCreatedSSHKeyId, 88 | ) 89 | 90 | if err != nil && !p.github.IsNotFoundError(err) { 91 | return err 92 | } 93 | } 94 | 95 | if devEnvAdditionalProperties.GitHubCreatedGPGKeyId != nil { 96 | err = p.github.RemoveGPGKey( 97 | githubAccessToken, 98 | *devEnvAdditionalProperties.GitHubCreatedGPGKeyId, 99 | ) 100 | 101 | if err != nil && !p.github.IsNotFoundError(err) { 102 | return err 103 | } 104 | } 105 | 106 | return nil 107 | } 108 | -------------------------------------------------------------------------------- /internal/hooks/pre_stop.go: -------------------------------------------------------------------------------- 1 | package hooks 2 | 3 | import ( 4 | "github.com/recode-sh/cli/internal/interfaces" 5 | "github.com/recode-sh/recode/entities" 6 | ) 7 | 8 | type PreStop struct { 9 | sshKnownHosts interfaces.SSHKnownHostsManager 10 | } 11 | 12 | func NewPreStop( 13 | sshKnownHosts interfaces.SSHKnownHostsManager, 14 | ) PreStop { 15 | 16 | return PreStop{ 17 | sshKnownHosts: sshKnownHosts, 18 | } 19 | } 20 | 21 | func (p PreStop) Run( 22 | cloudService entities.CloudService, 23 | recodeConfig *entities.Config, 24 | cluster *entities.Cluster, 25 | devEnv *entities.DevEnv, 26 | ) error { 27 | 28 | instanceIPAddress := devEnv.InstancePublicIPAddress 29 | 30 | return p.sshKnownHosts.RemoveIfExists(instanceIPAddress) 31 | } 32 | -------------------------------------------------------------------------------- /internal/interfaces/browser.go: -------------------------------------------------------------------------------- 1 | package interfaces 2 | 3 | type BrowserManager interface { 4 | OpenURL(url string) error 5 | } 6 | -------------------------------------------------------------------------------- /internal/interfaces/github.go: -------------------------------------------------------------------------------- 1 | package interfaces 2 | 3 | import ( 4 | "github.com/google/go-github/v43/github" 5 | cliGitHub "github.com/recode-sh/recode/github" 6 | ) 7 | 8 | type GitHubManager interface { 9 | GetAuthenticatedUser(accessToken string) (*cliGitHub.AuthenticatedUser, error) 10 | 11 | CreateRepository(accessToken string, organization string, properties *github.Repository) (*github.Repository, error) 12 | DoesRepositoryExist(accessToken, repositoryOwner, repositoryName string) (bool, error) 13 | GetFileContentFromRepository(accessToken, repositoryOwner, repositoryName, filePath string) (string, error) 14 | 15 | CreateSSHKey(accessToken string, keyPairName string, publicKeyContent string) (*github.Key, error) 16 | RemoveSSHKey(accessToken string, sshKeyID int64) error 17 | 18 | CreateGPGKey(accessToken string, publicKeyContent string) (*github.GPGKey, error) 19 | RemoveGPGKey(accessToken string, gpgKeyID int64) error 20 | 21 | IsNotFoundError(err error) bool 22 | } 23 | -------------------------------------------------------------------------------- /internal/interfaces/logger.go: -------------------------------------------------------------------------------- 1 | package interfaces 2 | 3 | type Logger interface { 4 | Info(format string, v ...interface{}) 5 | Warning(format string, v ...interface{}) 6 | Error(format string, v ...interface{}) 7 | Log(format string, v ...interface{}) 8 | LogNoNewline(format string, v ...interface{}) 9 | Write(p []byte) (n int, err error) 10 | } 11 | -------------------------------------------------------------------------------- /internal/interfaces/sleeper.go: -------------------------------------------------------------------------------- 1 | package interfaces 2 | 3 | import "time" 4 | 5 | type Sleeper interface { 6 | Sleep(d time.Duration) 7 | } 8 | -------------------------------------------------------------------------------- /internal/interfaces/ssh.go: -------------------------------------------------------------------------------- 1 | package interfaces 2 | 3 | // SSH known hosts 4 | 5 | type SSHKnownHostsManager interface { 6 | RemoveIfExists(hostname string) error 7 | AddOrReplace(hostname, algorithm, fingerprint string) error 8 | } 9 | 10 | // SSH Config 11 | 12 | type SSHConfigManager interface { 13 | AddOrReplaceHost(hostKey, hostName, identityFile, user string, port int64) error 14 | UpdateHost(hostKey string, hostName, identityFile, user *string) error 15 | RemoveHostIfExists(hostKey string) error 16 | } 17 | 18 | // SSH keys 19 | 20 | type SSHKeysManager interface { 21 | CreateOrReplacePEM(PEMName, PEMContent string) (string, error) 22 | RemovePEMIfExists(PEMPath string) error 23 | GetPEMFilePath(PEMName string) string 24 | } 25 | -------------------------------------------------------------------------------- /internal/interfaces/user_config.go: -------------------------------------------------------------------------------- 1 | package interfaces 2 | 3 | import ( 4 | "github.com/recode-sh/cli/internal/config" 5 | "github.com/recode-sh/recode/github" 6 | ) 7 | 8 | type UserConfigManager interface { 9 | GetString(key config.UserConfigKey) string 10 | GetBool(key config.UserConfigKey) bool 11 | Set(key config.UserConfigKey, value interface{}) 12 | WriteConfig() error 13 | PopulateFromGitHubUser(githubUser *github.AuthenticatedUser) 14 | } 15 | -------------------------------------------------------------------------------- /internal/interfaces/vscode.go: -------------------------------------------------------------------------------- 1 | package interfaces 2 | 3 | type VSCodeProcessManager interface { 4 | OpenOnRemote(hostKey, pathToOpen string) (cmdOutput string, cmdError error) 5 | } 6 | 7 | type VSCodeExtensionsManager interface { 8 | Install(extensionName string) (cmdOutput string, cmdError error) 9 | } 10 | -------------------------------------------------------------------------------- /internal/mocks/aws_user_config_env_vars_resolver.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: github.com/recode-sh/cli/internal/aws (interfaces: UserConfigEnvVarsResolver) 3 | 4 | // Package mocks is a generated GoMock package. 5 | package mocks 6 | 7 | import ( 8 | reflect "reflect" 9 | 10 | gomock "github.com/golang/mock/gomock" 11 | userconfig "github.com/recode-sh/aws-cloud-provider/userconfig" 12 | ) 13 | 14 | // AWSUserConfigEnvVarsResolver is a mock of UserConfigEnvVarsResolver interface. 15 | type AWSUserConfigEnvVarsResolver struct { 16 | ctrl *gomock.Controller 17 | recorder *AWSUserConfigEnvVarsResolverMockRecorder 18 | } 19 | 20 | // AWSUserConfigEnvVarsResolverMockRecorder is the mock recorder for AWSUserConfigEnvVarsResolver. 21 | type AWSUserConfigEnvVarsResolverMockRecorder struct { 22 | mock *AWSUserConfigEnvVarsResolver 23 | } 24 | 25 | // NewAWSUserConfigEnvVarsResolver creates a new mock instance. 26 | func NewAWSUserConfigEnvVarsResolver(ctrl *gomock.Controller) *AWSUserConfigEnvVarsResolver { 27 | mock := &AWSUserConfigEnvVarsResolver{ctrl: ctrl} 28 | mock.recorder = &AWSUserConfigEnvVarsResolverMockRecorder{mock} 29 | return mock 30 | } 31 | 32 | // EXPECT returns an object that allows the caller to indicate expected use. 33 | func (m *AWSUserConfigEnvVarsResolver) EXPECT() *AWSUserConfigEnvVarsResolverMockRecorder { 34 | return m.recorder 35 | } 36 | 37 | // Resolve mocks base method. 38 | func (m *AWSUserConfigEnvVarsResolver) Resolve() (*userconfig.Config, error) { 39 | m.ctrl.T.Helper() 40 | ret := m.ctrl.Call(m, "Resolve") 41 | ret0, _ := ret[0].(*userconfig.Config) 42 | ret1, _ := ret[1].(error) 43 | return ret0, ret1 44 | } 45 | 46 | // Resolve indicates an expected call of Resolve. 47 | func (mr *AWSUserConfigEnvVarsResolverMockRecorder) Resolve() *gomock.Call { 48 | mr.mock.ctrl.T.Helper() 49 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Resolve", reflect.TypeOf((*AWSUserConfigEnvVarsResolver)(nil).Resolve)) 50 | } 51 | -------------------------------------------------------------------------------- /internal/mocks/aws_user_config_files_resolver.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: github.com/recode-sh/cli/internal/aws (interfaces: UserConfigFilesResolver) 3 | 4 | // Package mocks is a generated GoMock package. 5 | package mocks 6 | 7 | import ( 8 | reflect "reflect" 9 | 10 | gomock "github.com/golang/mock/gomock" 11 | userconfig "github.com/recode-sh/aws-cloud-provider/userconfig" 12 | ) 13 | 14 | // AWSUserConfigFilesResolver is a mock of UserConfigFilesResolver interface. 15 | type AWSUserConfigFilesResolver struct { 16 | ctrl *gomock.Controller 17 | recorder *AWSUserConfigFilesResolverMockRecorder 18 | } 19 | 20 | // AWSUserConfigFilesResolverMockRecorder is the mock recorder for AWSUserConfigFilesResolver. 21 | type AWSUserConfigFilesResolverMockRecorder struct { 22 | mock *AWSUserConfigFilesResolver 23 | } 24 | 25 | // NewAWSUserConfigFilesResolver creates a new mock instance. 26 | func NewAWSUserConfigFilesResolver(ctrl *gomock.Controller) *AWSUserConfigFilesResolver { 27 | mock := &AWSUserConfigFilesResolver{ctrl: ctrl} 28 | mock.recorder = &AWSUserConfigFilesResolverMockRecorder{mock} 29 | return mock 30 | } 31 | 32 | // EXPECT returns an object that allows the caller to indicate expected use. 33 | func (m *AWSUserConfigFilesResolver) EXPECT() *AWSUserConfigFilesResolverMockRecorder { 34 | return m.recorder 35 | } 36 | 37 | // Resolve mocks base method. 38 | func (m *AWSUserConfigFilesResolver) Resolve() (*userconfig.Config, error) { 39 | m.ctrl.T.Helper() 40 | ret := m.ctrl.Call(m, "Resolve") 41 | ret0, _ := ret[0].(*userconfig.Config) 42 | ret1, _ := ret[1].(error) 43 | return ret0, ret1 44 | } 45 | 46 | // Resolve indicates an expected call of Resolve. 47 | func (mr *AWSUserConfigFilesResolverMockRecorder) Resolve() *gomock.Call { 48 | mr.mock.ctrl.T.Helper() 49 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Resolve", reflect.TypeOf((*AWSUserConfigFilesResolver)(nil).Resolve)) 50 | } 51 | -------------------------------------------------------------------------------- /internal/mocks/views_displayer.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: github.com/recode-sh/cli/internal/views (interfaces: Displayer) 3 | 4 | // Package mocks is a generated GoMock package. 5 | package mocks 6 | 7 | import ( 8 | io "io" 9 | reflect "reflect" 10 | 11 | gomock "github.com/golang/mock/gomock" 12 | ) 13 | 14 | // MockDisplayer is a mock of Displayer interface. 15 | type MockDisplayer struct { 16 | ctrl *gomock.Controller 17 | recorder *MockDisplayerMockRecorder 18 | } 19 | 20 | // MockDisplayerMockRecorder is the mock recorder for MockDisplayer. 21 | type MockDisplayerMockRecorder struct { 22 | mock *MockDisplayer 23 | } 24 | 25 | // NewMockDisplayer creates a new mock instance. 26 | func NewMockDisplayer(ctrl *gomock.Controller) *MockDisplayer { 27 | mock := &MockDisplayer{ctrl: ctrl} 28 | mock.recorder = &MockDisplayerMockRecorder{mock} 29 | return mock 30 | } 31 | 32 | // EXPECT returns an object that allows the caller to indicate expected use. 33 | func (m *MockDisplayer) EXPECT() *MockDisplayerMockRecorder { 34 | return m.recorder 35 | } 36 | 37 | // Display mocks base method. 38 | func (m *MockDisplayer) Display(arg0 io.Writer, arg1 string, arg2 ...interface{}) { 39 | m.ctrl.T.Helper() 40 | varargs := []interface{}{arg0, arg1} 41 | for _, a := range arg2 { 42 | varargs = append(varargs, a) 43 | } 44 | m.ctrl.Call(m, "Display", varargs...) 45 | } 46 | 47 | // Display indicates an expected call of Display. 48 | func (mr *MockDisplayerMockRecorder) Display(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call { 49 | mr.mock.ctrl.T.Helper() 50 | varargs := append([]interface{}{arg0, arg1}, arg2...) 51 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Display", reflect.TypeOf((*MockDisplayer)(nil).Display), varargs...) 52 | } 53 | -------------------------------------------------------------------------------- /internal/presenters/errors.go: -------------------------------------------------------------------------------- 1 | package presenters 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "strings" 7 | 8 | "github.com/recode-sh/cli/internal/constants" 9 | "github.com/recode-sh/cli/internal/exceptions" 10 | "github.com/recode-sh/cli/internal/system" 11 | "github.com/recode-sh/recode/entities" 12 | "google.golang.org/grpc/codes" 13 | "google.golang.org/grpc/status" 14 | ) 15 | 16 | type ViewableError struct { 17 | Title string 18 | Message string 19 | } 20 | 21 | type ViewableErrorBuilder interface { 22 | Build(error) *ViewableError 23 | } 24 | 25 | type RecodeViewableErrorBuilder struct{} 26 | 27 | func NewRecodeViewableErrorBuilder() RecodeViewableErrorBuilder { 28 | return RecodeViewableErrorBuilder{} 29 | } 30 | 31 | func (RecodeViewableErrorBuilder) Build(err error) (viewableError *ViewableError) { 32 | viewableError = &ViewableError{} 33 | 34 | if typedError, ok := err.(entities.ErrClusterNotExists); ok { 35 | viewableError.Title = "Cluster not found" 36 | viewableError.Message = fmt.Sprintf( 37 | "The cluster \"%s\" was not found.", 38 | typedError.ClusterName, 39 | ) 40 | 41 | return 42 | } 43 | 44 | if typedError, ok := err.(entities.ErrClusterAlreadyExists); ok { 45 | viewableError.Title = "Cluster already exists" 46 | viewableError.Message = fmt.Sprintf( 47 | "The cluster \"%s\" already exists.", 48 | typedError.ClusterName, 49 | ) 50 | 51 | return 52 | } 53 | 54 | if typedError, ok := err.(entities.ErrDevEnvNotExists); ok { 55 | viewableError.Title = "Development environment not found" 56 | 57 | if typedError.ClusterName != entities.DefaultClusterName { 58 | viewableError.Message = fmt.Sprintf( 59 | "The development environment \"%s\" was not found in the cluster \"%s\".", 60 | typedError.DevEnvName, 61 | typedError.ClusterName, 62 | ) 63 | return 64 | } 65 | 66 | viewableError.Message = fmt.Sprintf( 67 | "The development environment \"%s\" was not found.", 68 | typedError.DevEnvName, 69 | ) 70 | return 71 | } 72 | 73 | if errors.Is(err, exceptions.ErrUserNotLoggedIn) { 74 | viewableError.Title = "GitHub account not connected" 75 | viewableError.Message = fmt.Sprintf( 76 | "You must first connect your GitHub account using the command \"recode login\".\n\n"+ 77 | "Recode requires the following permissions:\n\n"+ 78 | " - \"Public SSH keys\" and \"Repositories\" to let you access your repositories from your development environments\n\n"+ 79 | " - \"GPG Keys\" and \"Personal user data\" to configure Git and sign your commits (verified badge)\n\n"+ 80 | "All your data (including the OAuth access token) will only be stored locally (in \"%s\").", 81 | system.UserConfigFilePath(), 82 | ) 83 | 84 | return 85 | } 86 | 87 | if typedError, ok := err.(entities.ErrInvalidDevEnvUserConfig); ok { 88 | viewableError.Title = "Invalid user configuration" 89 | 90 | bold := constants.Bold 91 | viewableError.Message = fmt.Sprintf( 92 | "The repository \"%s/%s\" contains an invalid configuration.\n\n"+bold("Reason:")+" %s", 93 | typedError.RepoOwner, 94 | entities.DevEnvUserConfigRepoName, 95 | typedError.Reason, 96 | ) 97 | 98 | return 99 | } 100 | 101 | if typedError, ok := err.(entities.ErrDevEnvRepositoryNotFound); ok { 102 | viewableError.Title = "Repository not found" 103 | viewableError.Message = fmt.Sprintf( 104 | "The repository \"%s/%s\" was not found.\n\n"+ 105 | "Please double check that this repository exists and that you can access it.", 106 | typedError.RepoOwner, 107 | typedError.RepoName, 108 | ) 109 | 110 | return 111 | } 112 | 113 | if typedError, ok := err.(entities.ErrStartRemovingDevEnv); ok { 114 | viewableError.Title = "Invalid development environment state" 115 | viewableError.Message = fmt.Sprintf( 116 | "The development environment \"%s\" cannot be started because it is currently removing.\n\n"+ 117 | "You must wait for the removing process to terminate.", 118 | typedError.DevEnvName, 119 | ) 120 | 121 | return 122 | } 123 | 124 | if typedError, ok := err.(entities.ErrStartStoppingDevEnv); ok { 125 | viewableError.Title = "Invalid development environment state" 126 | viewableError.Message = fmt.Sprintf( 127 | "The development environment \"%s\" cannot be started because it is currently stopping.\n\n"+ 128 | "You must wait for the stopping process to terminate.", 129 | typedError.DevEnvName, 130 | ) 131 | 132 | return 133 | } 134 | 135 | if typedError, ok := err.(entities.ErrStopRemovingDevEnv); ok { 136 | viewableError.Title = "Invalid development environment state" 137 | viewableError.Message = fmt.Sprintf( 138 | "The development environment \"%s\" cannot be stopped because it is currently removing.\n\n"+ 139 | "You must wait for the removing process to terminate.", 140 | typedError.DevEnvName, 141 | ) 142 | 143 | return 144 | } 145 | 146 | if typedError, ok := err.(entities.ErrStopCreatingDevEnv); ok { 147 | viewableError.Title = "Invalid development environment state" 148 | viewableError.Message = fmt.Sprintf( 149 | "The development environment \"%s\" cannot be stopped because it is currently creating.\n\n"+ 150 | "You must wait for the creation process to terminate.", 151 | typedError.DevEnvName, 152 | ) 153 | 154 | return 155 | } 156 | 157 | if typedError, ok := err.(entities.ErrStopStartingDevEnv); ok { 158 | viewableError.Title = "Invalid development environment state" 159 | viewableError.Message = fmt.Sprintf( 160 | "The development environment \"%s\" cannot be stopped because it is currently starting.\n\n"+ 161 | "You must wait for the starting process to terminate.", 162 | typedError.DevEnvName, 163 | ) 164 | 165 | return 166 | } 167 | 168 | if typedError, ok := err.(exceptions.ErrLoginError); ok { 169 | viewableError.Title = "GitHub connection error" 170 | viewableError.Message = fmt.Sprintf( 171 | "An error has occured during the authorization of the Recode application.\n\n%s", 172 | typedError.Reason, 173 | ) 174 | 175 | return 176 | } 177 | 178 | if typedError, ok := err.(exceptions.ErrMissingRequirements); ok { 179 | viewableError.Title = "Missing requirements" 180 | viewableError.Message = fmt.Sprintf( 181 | "The following requirements are missing:\n\n - %s", 182 | strings.Join(typedError.MissingRequirements, "\n\n - "), 183 | ) 184 | 185 | return 186 | } 187 | 188 | bold := constants.Bold 189 | 190 | if status, ok := status.FromError(err); ok { 191 | viewableError.Title = "Recode agent error" 192 | 193 | errorMessage := status.Message() 194 | 195 | if len(errorMessage) >= 2 { 196 | errorMessage = strings.ToTitle(errorMessage[0:1]) + errorMessage[1:] + "." 197 | } 198 | 199 | viewableError.Message = errorMessage 200 | 201 | if status.Code() != codes.Unknown { 202 | viewableError.Message += "\n\n" + 203 | bold("Error code: ") + 204 | status.Code().String() 205 | } 206 | 207 | return 208 | } 209 | 210 | viewableError.Title = "Unknown error" 211 | viewableError.Message = fmt.Sprintf( 212 | "An unknown error occurred.\n\n"+ 213 | "You could try to fix it (using the details below) or open a new issue at: https://github.com/recode-sh/cli/issues/new\n\n"+ 214 | bold("%s"), 215 | err.Error(), 216 | ) 217 | 218 | return 219 | } 220 | -------------------------------------------------------------------------------- /internal/presenters/login.go: -------------------------------------------------------------------------------- 1 | package presenters 2 | 3 | import ( 4 | "github.com/recode-sh/cli/internal/features" 5 | ) 6 | 7 | type LoginViewDataContent struct { 8 | Message string 9 | } 10 | 11 | type LoginViewData struct { 12 | Error *ViewableError 13 | Content LoginViewDataContent 14 | } 15 | 16 | type LoginViewer interface { 17 | View(LoginViewData) 18 | } 19 | 20 | type LoginPresenter struct { 21 | viewableErrorBuilder ViewableErrorBuilder 22 | viewer LoginViewer 23 | } 24 | 25 | func NewLoginPresenter( 26 | viewableErrorBuilder ViewableErrorBuilder, 27 | viewer LoginViewer, 28 | ) LoginPresenter { 29 | 30 | return LoginPresenter{ 31 | viewableErrorBuilder: viewableErrorBuilder, 32 | viewer: viewer, 33 | } 34 | } 35 | 36 | func (l LoginPresenter) PresentToView(response features.LoginResponse) { 37 | viewData := LoginViewData{} 38 | 39 | if response.Error == nil { 40 | viewDataMessage := "Your GitHub account is now connected." 41 | 42 | viewData.Content = LoginViewDataContent{ 43 | Message: viewDataMessage, 44 | } 45 | 46 | l.viewer.View(viewData) 47 | 48 | return 49 | } 50 | 51 | viewData.Error = l.viewableErrorBuilder.Build(response.Error) 52 | 53 | l.viewer.View(viewData) 54 | } 55 | -------------------------------------------------------------------------------- /internal/presenters/remove.go: -------------------------------------------------------------------------------- 1 | package presenters 2 | 3 | import ( 4 | "github.com/recode-sh/cli/internal/features" 5 | ) 6 | 7 | type RemoveViewDataContent struct { 8 | Message string 9 | } 10 | 11 | type RemoveViewData struct { 12 | Error *ViewableError 13 | Content RemoveViewDataContent 14 | } 15 | 16 | type RemoveViewer interface { 17 | View(RemoveViewData) 18 | } 19 | 20 | type RemovePresenter struct { 21 | viewableErrorBuilder ViewableErrorBuilder 22 | viewer RemoveViewer 23 | } 24 | 25 | func NewRemovePresenter( 26 | viewableErrorBuilder ViewableErrorBuilder, 27 | viewer RemoveViewer, 28 | ) RemovePresenter { 29 | 30 | return RemovePresenter{ 31 | viewableErrorBuilder: viewableErrorBuilder, 32 | viewer: viewer, 33 | } 34 | } 35 | 36 | func (r RemovePresenter) PresentToView(response features.RemoveResponse) { 37 | viewData := RemoveViewData{} 38 | 39 | if response.Error == nil { 40 | devEnvName := response.Content.DevEnvName 41 | viewDataMessage := "The development environment \"" + devEnvName + "\" was removed." 42 | 43 | viewData.Content = RemoveViewDataContent{ 44 | Message: viewDataMessage, 45 | } 46 | 47 | r.viewer.View(viewData) 48 | 49 | return 50 | } 51 | 52 | viewData.Error = r.viewableErrorBuilder.Build(response.Error) 53 | 54 | r.viewer.View(viewData) 55 | } 56 | -------------------------------------------------------------------------------- /internal/presenters/start.go: -------------------------------------------------------------------------------- 1 | package presenters 2 | 3 | import ( 4 | "github.com/recode-sh/cli/internal/constants" 5 | "github.com/recode-sh/cli/internal/features" 6 | ) 7 | 8 | type StartViewData struct { 9 | Error *ViewableError 10 | Content StartViewDataContent 11 | } 12 | 13 | type StartViewDataContent struct { 14 | ShowAsWarning bool 15 | Message string 16 | Subtext string 17 | } 18 | 19 | type StartViewer interface { 20 | View(StartViewData) 21 | } 22 | 23 | type StartPresenter struct { 24 | viewableErrorBuilder ViewableErrorBuilder 25 | viewer StartViewer 26 | } 27 | 28 | func NewStartPresenter( 29 | viewableErrorBuilder ViewableErrorBuilder, 30 | viewer StartViewer, 31 | ) StartPresenter { 32 | 33 | return StartPresenter{ 34 | viewableErrorBuilder: viewableErrorBuilder, 35 | viewer: viewer, 36 | } 37 | } 38 | 39 | func (s StartPresenter) PresentToView(response features.StartResponse) { 40 | viewData := StartViewData{} 41 | 42 | if response.Error == nil { 43 | devEnvName := response.Content.DevEnvName 44 | 45 | viewDataMessage := "The development environment \"" + devEnvName + "\" was started." 46 | viewDataSubtext := "Run `" + constants.Blue("ssh "+devEnvName) + "` (or use your code editor's integrated terminal)." 47 | 48 | devEnvAlreadyStarted := response.Content.DevEnvAlreadyStarted 49 | 50 | if devEnvAlreadyStarted { 51 | viewDataMessage = "The development environment \"" + devEnvName + "\" is already started." 52 | viewDataSubtext = "" 53 | } 54 | 55 | devEnvRebuilt := response.Content.DevEnvRebuilt 56 | 57 | if devEnvRebuilt { 58 | viewDataMessage = "The development environment \"" + devEnvName + "\" was rebuilt." 59 | viewDataSubtext = "" 60 | } 61 | 62 | viewData.Content = StartViewDataContent{ 63 | ShowAsWarning: devEnvAlreadyStarted, 64 | Message: viewDataMessage, 65 | Subtext: viewDataSubtext, 66 | } 67 | 68 | s.viewer.View(viewData) 69 | 70 | return 71 | } 72 | 73 | viewData.Error = s.viewableErrorBuilder.Build(response.Error) 74 | 75 | s.viewer.View(viewData) 76 | } 77 | -------------------------------------------------------------------------------- /internal/presenters/stop.go: -------------------------------------------------------------------------------- 1 | package presenters 2 | 3 | import ( 4 | "github.com/recode-sh/cli/internal/features" 5 | ) 6 | 7 | type StopViewDataContent struct { 8 | ShowAsWarning bool 9 | Message string 10 | } 11 | 12 | type StopViewData struct { 13 | Error *ViewableError 14 | Content StopViewDataContent 15 | } 16 | 17 | type StopViewer interface { 18 | View(StopViewData) 19 | } 20 | 21 | type StopPresenter struct { 22 | viewableErrorBuilder ViewableErrorBuilder 23 | viewer StopViewer 24 | } 25 | 26 | func NewStopPresenter( 27 | viewableErrorBuilder ViewableErrorBuilder, 28 | viewer StopViewer, 29 | ) StopPresenter { 30 | 31 | return StopPresenter{ 32 | viewableErrorBuilder: viewableErrorBuilder, 33 | viewer: viewer, 34 | } 35 | } 36 | 37 | func (s StopPresenter) PresentToView(response features.StopResponse) { 38 | viewData := StopViewData{} 39 | 40 | if response.Error == nil { 41 | devEnvName := response.Content.DevEnvName 42 | devEnvAlreadyStopped := response.Content.DevEnvAlreadyStopped 43 | 44 | viewDataMessage := "The development environment \"" + devEnvName + "\" was stopped." 45 | 46 | if devEnvAlreadyStopped { 47 | viewDataMessage = "The development environment \"" + devEnvName + "\" is already stopped. Nothing to do." 48 | } 49 | 50 | viewData.Content = StopViewDataContent{ 51 | ShowAsWarning: devEnvAlreadyStopped, 52 | Message: viewDataMessage, 53 | } 54 | 55 | s.viewer.View(viewData) 56 | 57 | return 58 | } 59 | 60 | viewData.Error = s.viewableErrorBuilder.Build(response.Error) 61 | 62 | s.viewer.View(viewData) 63 | } 64 | -------------------------------------------------------------------------------- /internal/presenters/uninstall.go: -------------------------------------------------------------------------------- 1 | package presenters 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/recode-sh/cli/internal/constants" 7 | "github.com/recode-sh/cli/internal/features" 8 | ) 9 | 10 | type UninstallViewDataContent struct { 11 | ShowAsWarning bool 12 | Message string 13 | Subtext string 14 | } 15 | 16 | type UninstallViewData struct { 17 | Error *ViewableError 18 | Content UninstallViewDataContent 19 | } 20 | 21 | type UninstallViewer interface { 22 | View(UninstallViewData) 23 | } 24 | 25 | type UninstallPresenter struct { 26 | viewableErrorBuilder ViewableErrorBuilder 27 | viewer UninstallViewer 28 | } 29 | 30 | func NewUninstallPresenter( 31 | viewableErrorBuilder ViewableErrorBuilder, 32 | viewer UninstallViewer, 33 | ) UninstallPresenter { 34 | 35 | return UninstallPresenter{ 36 | viewableErrorBuilder: viewableErrorBuilder, 37 | viewer: viewer, 38 | } 39 | } 40 | 41 | func (u UninstallPresenter) PresentToView(response features.UninstallResponse) { 42 | viewData := UninstallViewData{} 43 | 44 | if response.Error == nil { 45 | bold := constants.Bold 46 | 47 | recodeAlreadyUninstalled := response.Content.RecodeAlreadyUninstalled 48 | 49 | viewDataMessage := response.Content.SuccessMessage 50 | viewDataSubtext := fmt.Sprintf( 51 | "If you want to remove Recode entirely:\n\n"+ 52 | " - Remove the Recode CLI (located at %s)\n\n"+ 53 | " - Remove the Recode configuration (located at %s)\n\n"+ 54 | " - Unauthorize the Recode application on GitHub by going to: %s", 55 | bold(response.Content.RecodeExecutablePath), 56 | bold(response.Content.RecodeConfigDirPath), 57 | bold("https://github.com/settings/applications"), 58 | ) 59 | 60 | if recodeAlreadyUninstalled { 61 | viewDataMessage = response.Content.AlreadyUninstalledMessage 62 | } 63 | 64 | viewData.Content = UninstallViewDataContent{ 65 | ShowAsWarning: recodeAlreadyUninstalled, 66 | Message: viewDataMessage, 67 | Subtext: viewDataSubtext, 68 | } 69 | 70 | u.viewer.View(viewData) 71 | 72 | return 73 | } 74 | 75 | viewData.Error = u.viewableErrorBuilder.Build(response.Error) 76 | u.viewer.View(viewData) 77 | } 78 | -------------------------------------------------------------------------------- /internal/ssh/config.go: -------------------------------------------------------------------------------- 1 | package ssh 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strings" 7 | 8 | "github.com/kevinburke/ssh_config" 9 | "github.com/recode-sh/cli/internal/system" 10 | ) 11 | 12 | const ConfigFilePerm os.FileMode = 0644 13 | 14 | type Config struct { 15 | configFilePath string 16 | } 17 | 18 | func NewConfig(configFilePath string) Config { 19 | return Config{ 20 | configFilePath: configFilePath, 21 | } 22 | } 23 | 24 | func NewConfigWithDefaultConfigFilePath() Config { 25 | return NewConfig(system.DefaultSSHConfigFilePath()) 26 | } 27 | 28 | func (c Config) AddOrReplaceHost( 29 | hostKey string, 30 | hostName string, 31 | identityFile string, 32 | user string, 33 | port int64, 34 | ) error { 35 | 36 | cfg, err := c.parse() 37 | 38 | if err != nil { 39 | return err 40 | } 41 | 42 | hostPattern, err := ssh_config.NewPattern(hostKey) 43 | 44 | if err != nil { 45 | return err 46 | } 47 | 48 | hostNodes := []ssh_config.Node{ 49 | &ssh_config.Empty{ 50 | Comment: " added by Recode", 51 | }, 52 | 53 | &ssh_config.KV{ 54 | Key: " HostName", 55 | Value: hostName, 56 | }, 57 | 58 | &ssh_config.KV{ 59 | Key: " IdentityFile", 60 | Value: identityFile, 61 | }, 62 | 63 | &ssh_config.KV{ 64 | Key: " User", 65 | Value: user, 66 | }, 67 | 68 | &ssh_config.KV{ 69 | Key: " Port", 70 | Value: fmt.Sprint(port), 71 | }, 72 | 73 | &ssh_config.KV{ 74 | Key: " ForwardAgent", 75 | Value: "yes", 76 | }, 77 | } 78 | 79 | hostToAdd := &ssh_config.Host{ 80 | Patterns: []*ssh_config.Pattern{ 81 | hostPattern, 82 | }, 83 | Nodes: hostNodes, 84 | } 85 | 86 | hostToAddIndex := c.lookupHostIndex( 87 | cfg, 88 | hostKey, 89 | ) 90 | 91 | if hostToAddIndex == -1 { 92 | cfg.Hosts = append(cfg.Hosts, hostToAdd) 93 | } else { 94 | cfg.Hosts[hostToAddIndex] = hostToAdd 95 | } 96 | 97 | return c.save(cfg) 98 | } 99 | 100 | func (c Config) UpdateHost( 101 | hostKey string, 102 | hostName *string, 103 | identityFile *string, 104 | user *string, 105 | ) error { 106 | 107 | cfg, err := c.parse() 108 | 109 | if err != nil { 110 | return err 111 | } 112 | 113 | updatedHosts := []*ssh_config.Host{} 114 | 115 | for _, host := range cfg.Hosts { 116 | // We don't use "host.Matches()" 117 | // here because we don't want the 118 | // wildcard host ("Host *") to match 119 | if len(host.Patterns) == 1 && host.Patterns[0].String() == hostKey { 120 | for _, node := range host.Nodes { 121 | switch t := node.(type) { 122 | case *ssh_config.KV: 123 | lowercasedKey := strings.ToLower(t.Key) 124 | 125 | if lowercasedKey == "hostname" && hostName != nil { 126 | t.Value = *hostName 127 | } 128 | 129 | if lowercasedKey == "identityfile" && identityFile != nil { 130 | t.Value = *identityFile 131 | } 132 | 133 | if lowercasedKey == "user" && user != nil { 134 | t.Value = *user 135 | } 136 | } 137 | } 138 | } 139 | 140 | updatedHosts = append(updatedHosts, host) 141 | } 142 | 143 | cfg.Hosts = updatedHosts 144 | 145 | return c.save(cfg) 146 | } 147 | 148 | func (c Config) RemoveHostIfExists(hostKey string) error { 149 | cfg, err := c.parse() 150 | 151 | if err != nil { 152 | return err 153 | } 154 | 155 | updatedHosts := []*ssh_config.Host{} 156 | 157 | for _, host := range cfg.Hosts { 158 | // We don't use "host.Matches()" 159 | // here because we don't want the 160 | // wildcard host ("Host *") to match 161 | if len(host.Patterns) == 1 && host.Patterns[0].String() == hostKey { 162 | continue 163 | } 164 | 165 | updatedHosts = append(updatedHosts, host) 166 | } 167 | 168 | cfg.Hosts = updatedHosts 169 | 170 | return c.save(cfg) 171 | } 172 | 173 | func (c Config) lookupHostIndex( 174 | cfg *ssh_config.Config, 175 | hostKey string, 176 | ) int { 177 | 178 | for hostIndex, host := range cfg.Hosts { 179 | // We don't use "host.Matches()" 180 | // here because we don't want the 181 | // wildcard host ("Host *") to match 182 | if len(host.Patterns) == 1 && host.Patterns[0].String() == hostKey { 183 | return hostIndex 184 | } 185 | 186 | continue 187 | } 188 | 189 | return -1 190 | } 191 | 192 | func (c Config) parse() (*ssh_config.Config, error) { 193 | f, err := os.OpenFile( 194 | c.configFilePath, 195 | os.O_CREATE|os.O_RDONLY, 196 | ConfigFilePerm, 197 | ) 198 | 199 | if err != nil { 200 | return nil, err 201 | } 202 | 203 | defer f.Close() 204 | 205 | return ssh_config.Decode(f) 206 | } 207 | 208 | func (c Config) save(cfg *ssh_config.Config) error { 209 | return os.WriteFile( 210 | c.configFilePath, 211 | []byte(cfg.String()), 212 | ConfigFilePerm, 213 | ) 214 | } 215 | -------------------------------------------------------------------------------- /internal/ssh/config_add_host_test.go: -------------------------------------------------------------------------------- 1 | package ssh_test 2 | 3 | import ( 4 | "os" 5 | "strings" 6 | "testing" 7 | 8 | "github.com/recode-sh/cli/internal/ssh" 9 | "github.com/recode-sh/cli/internal/system" 10 | ) 11 | 12 | func TestConfigAddOrReplaceHostWithNonEmptyConfig(t *testing.T) { 13 | configPath := "./testdata/non_empty_ssh_config" 14 | configAtStart, err := os.ReadFile(configPath) 15 | 16 | if err != nil { 17 | t.Fatalf("expected no error, got '%+v'", err) 18 | } 19 | 20 | defer func() { // Reset modified config file 21 | err = os.WriteFile( 22 | configPath, 23 | configAtStart, 24 | ssh.ConfigFilePerm, 25 | ) 26 | 27 | if err != nil { 28 | t.Fatalf("expected no error, got '%+v'", err) 29 | } 30 | }() 31 | 32 | expectedConfig := string(configAtStart) + ` 33 | Host hostkey 34 | # added by Recode 35 | HostName hostname 36 | IdentityFile identityFile 37 | User user 38 | Port 2200 39 | ForwardAgent yes` 40 | 41 | config := ssh.NewConfig(configPath) 42 | err = config.AddOrReplaceHost( 43 | "hostkey", 44 | "hostname", 45 | "identityFile", 46 | "user", 47 | 2200, 48 | ) 49 | 50 | if err != nil { 51 | t.Fatalf("expected no error, got '%+v'", err) 52 | } 53 | 54 | configAtEnd, err := os.ReadFile(configPath) 55 | 56 | if err != nil { 57 | t.Fatalf("expected no error, got '%+v'", err) 58 | } 59 | 60 | configAtEndString := strings.TrimSuffix( 61 | string(configAtEnd), 62 | system.NewLineChar, 63 | ) 64 | 65 | if configAtEndString != expectedConfig { 66 | t.Fatalf( 67 | "expected config to equal '%s', got '%s'", 68 | expectedConfig, 69 | configAtEndString, 70 | ) 71 | } 72 | } 73 | 74 | func TestConfigAddOrReplaceHostWithEmptyConfig(t *testing.T) { 75 | configPath := "./testdata/empty_ssh_config" 76 | expectedConfig := `Host hostkey 77 | # added by Recode 78 | HostName hostname 79 | IdentityFile identityFile 80 | User user 81 | Port 2200 82 | ForwardAgent yes` 83 | 84 | defer func() { // Reset modified config file 85 | err := os.WriteFile( 86 | configPath, 87 | []byte(""), 88 | ssh.ConfigFilePerm, 89 | ) 90 | 91 | if err != nil { 92 | t.Fatalf("expected no error, got '%+v'", err) 93 | } 94 | }() 95 | 96 | config := ssh.NewConfig(configPath) 97 | err := config.AddOrReplaceHost( 98 | "hostkey", 99 | "hostname", 100 | "identityFile", 101 | "user", 102 | 2200, 103 | ) 104 | 105 | if err != nil { 106 | t.Fatalf("expected no error, got '%+v'", err) 107 | } 108 | 109 | configAtEnd, err := os.ReadFile(configPath) 110 | 111 | if err != nil { 112 | t.Fatalf("expected no error, got '%+v'", err) 113 | } 114 | 115 | configAtEndString := strings.TrimSuffix( 116 | string(configAtEnd), 117 | system.NewLineChar, 118 | ) 119 | 120 | if configAtEndString != expectedConfig { 121 | t.Fatalf( 122 | "expected config to equal '%s', got '%s'", 123 | expectedConfig, 124 | configAtEndString, 125 | ) 126 | } 127 | } 128 | 129 | func TestConfigAddOrReplaceHostWithNonExistingConfig(t *testing.T) { 130 | configPath := "./testdata/non_existing_ssh_config" 131 | expectedConfig := `Host hostkey 132 | # added by Recode 133 | HostName hostname 134 | IdentityFile identityFile 135 | User user 136 | Port 2200 137 | ForwardAgent yes` 138 | 139 | defer func() { // Remove created config file 140 | err := os.Remove(configPath) 141 | 142 | if err != nil { 143 | t.Fatalf("expected no error, got '%+v'", err) 144 | } 145 | }() 146 | 147 | config := ssh.NewConfig(configPath) 148 | err := config.AddOrReplaceHost( 149 | "hostkey", 150 | "hostname", 151 | "identityFile", 152 | "user", 153 | 2200, 154 | ) 155 | 156 | if err != nil { 157 | t.Fatalf("expected no error, got '%+v'", err) 158 | } 159 | 160 | configAtEnd, err := os.ReadFile(configPath) 161 | 162 | if err != nil { 163 | t.Fatalf("expected no error, got '%+v'", err) 164 | } 165 | 166 | configAtEndString := strings.TrimSuffix( 167 | string(configAtEnd), 168 | system.NewLineChar, 169 | ) 170 | 171 | if configAtEndString != expectedConfig { 172 | t.Fatalf( 173 | "expected config to equal '%s', got '%s'", 174 | expectedConfig, 175 | configAtEndString, 176 | ) 177 | } 178 | } 179 | 180 | func TestConfigAddOrReplaceHostWithInvalidHostKey(t *testing.T) { 181 | configPath := "./testdata/non_empty_ssh_config" 182 | invalidHostKey := "" 183 | 184 | config := ssh.NewConfig(configPath) 185 | err := config.AddOrReplaceHost( 186 | invalidHostKey, 187 | "hostname", 188 | "identityFile", 189 | "user", 190 | 2200, 191 | ) 192 | 193 | if err == nil { 194 | t.Fatalf("expected error, got nothing") 195 | } 196 | } 197 | 198 | func TestConfigAddOrReplaceHostWithExistingHostConfig(t *testing.T) { 199 | configPath := "./testdata/non_empty_ssh_config" 200 | configAtStart, err := os.ReadFile(configPath) 201 | 202 | if err != nil { 203 | t.Fatalf("expected no error, got '%+v'", err) 204 | } 205 | 206 | defer func() { // Reset modified config file 207 | err = os.WriteFile( 208 | configPath, 209 | configAtStart, 210 | ssh.ConfigFilePerm, 211 | ) 212 | 213 | if err != nil { 214 | t.Fatalf("expected no error, got '%+v'", err) 215 | } 216 | }() 217 | 218 | expectedConfig := `Host * 219 | AddKeysToAgent yes 220 | UseKeychain yes 221 | IdentityFile ~/.ssh/id_rsa 222 | IdentitiesOnly yes 223 | ServerAliveInterval 240 224 | 225 | Host 34.128.204.12 226 | # added by Recode 227 | HostName hostname_replaced 228 | IdentityFile identityFile_replaced 229 | User user_replaced 230 | Port 2200 231 | ForwardAgent yes` 232 | 233 | config := ssh.NewConfig(configPath) 234 | err = config.AddOrReplaceHost( 235 | "34.128.204.12", 236 | "hostname_replaced", 237 | "identityFile_replaced", 238 | "user_replaced", 239 | 2200, 240 | ) 241 | 242 | if err != nil { 243 | t.Fatalf("expected no error, got '%+v'", err) 244 | } 245 | 246 | configAtEnd, err := os.ReadFile(configPath) 247 | 248 | if err != nil { 249 | t.Fatalf("expected no error, got '%+v'", err) 250 | } 251 | 252 | configAtEndString := strings.TrimSuffix( 253 | string(configAtEnd), 254 | system.NewLineChar, 255 | ) 256 | 257 | if configAtEndString != expectedConfig { 258 | t.Fatalf( 259 | "expected config to equal '%s', got '%s'", 260 | expectedConfig, 261 | configAtEndString, 262 | ) 263 | } 264 | } 265 | -------------------------------------------------------------------------------- /internal/ssh/config_remove_host_test.go: -------------------------------------------------------------------------------- 1 | package ssh_test 2 | 3 | import ( 4 | "os" 5 | "strings" 6 | "testing" 7 | 8 | "github.com/recode-sh/cli/internal/ssh" 9 | "github.com/recode-sh/cli/internal/system" 10 | ) 11 | 12 | func TestConfigRemoveHostWithExistingHost(t *testing.T) { 13 | configPath := "./testdata/non_empty_ssh_config" 14 | configAtStart, err := os.ReadFile(configPath) 15 | 16 | if err != nil { 17 | t.Fatalf("expected no error, got '%+v'", err) 18 | } 19 | 20 | defer func() { // Reset modified config file 21 | err = os.WriteFile( 22 | configPath, 23 | configAtStart, 24 | ssh.ConfigFilePerm, 25 | ) 26 | 27 | if err != nil { 28 | t.Fatalf("expected no error, got '%+v'", err) 29 | } 30 | }() 31 | 32 | expectedConfig := `Host * 33 | AddKeysToAgent yes 34 | UseKeychain yes 35 | IdentityFile ~/.ssh/id_rsa 36 | IdentitiesOnly yes 37 | ServerAliveInterval 240 38 | ` 39 | 40 | config := ssh.NewConfig(configPath) 41 | err = config.RemoveHostIfExists("34.128.204.12") 42 | 43 | if err != nil { 44 | t.Fatalf("expected no error, got '%+v'", err) 45 | } 46 | 47 | configAtEnd, err := os.ReadFile(configPath) 48 | 49 | if err != nil { 50 | t.Fatalf("expected no error, got '%+v'", err) 51 | } 52 | 53 | configAtEndString := strings.TrimSuffix( 54 | string(configAtEnd), 55 | system.NewLineChar, 56 | ) 57 | 58 | if configAtEndString != expectedConfig { 59 | t.Fatalf( 60 | "expected config to equal '%s', got '%s'", 61 | expectedConfig, 62 | configAtEndString, 63 | ) 64 | } 65 | } 66 | 67 | func TestConfigRemoveHostWithNonExistingHost(t *testing.T) { 68 | configPath := "./testdata/non_empty_ssh_config" 69 | configAtStart, err := os.ReadFile(configPath) 70 | 71 | if err != nil { 72 | t.Fatalf("expected no error, got '%+v'", err) 73 | } 74 | 75 | defer func() { // Reset modified config file 76 | err = os.WriteFile( 77 | configPath, 78 | configAtStart, 79 | ssh.ConfigFilePerm, 80 | ) 81 | 82 | if err != nil { 83 | t.Fatalf("expected no error, got '%+v'", err) 84 | } 85 | }() 86 | 87 | expectedConfig := string(configAtStart) 88 | 89 | config := ssh.NewConfig(configPath) 90 | err = config.RemoveHostIfExists("34.228.204.12") 91 | 92 | if err != nil { 93 | t.Fatalf("expected no error, got '%+v'", err) 94 | } 95 | 96 | configAtEnd, err := os.ReadFile(configPath) 97 | 98 | if err != nil { 99 | t.Fatalf("expected no error, got '%+v'", err) 100 | } 101 | 102 | configAtEndString := strings.TrimSuffix( 103 | string(configAtEnd), 104 | system.NewLineChar, 105 | ) 106 | 107 | if configAtEndString != expectedConfig { 108 | t.Fatalf( 109 | "expected config to equal '%s', got '%s'", 110 | expectedConfig, 111 | configAtEndString, 112 | ) 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /internal/ssh/config_update_host_test.go: -------------------------------------------------------------------------------- 1 | package ssh_test 2 | 3 | import ( 4 | "os" 5 | "strings" 6 | "testing" 7 | 8 | "github.com/recode-sh/cli/internal/ssh" 9 | "github.com/recode-sh/cli/internal/system" 10 | ) 11 | 12 | func TestConfigUpdateHostWithExistingHost(t *testing.T) { 13 | configPath := "./testdata/non_empty_ssh_config" 14 | configAtStart, err := os.ReadFile(configPath) 15 | 16 | if err != nil { 17 | t.Fatalf("expected no error, got '%+v'", err) 18 | } 19 | 20 | defer func() { // Reset modified config file 21 | err = os.WriteFile( 22 | configPath, 23 | configAtStart, 24 | ssh.ConfigFilePerm, 25 | ) 26 | 27 | if err != nil { 28 | t.Fatalf("expected no error, got '%+v'", err) 29 | } 30 | }() 31 | 32 | expectedConfig := `Host * 33 | AddKeysToAgent yes 34 | UseKeychain yes 35 | IdentityFile ~/.ssh/id_rsa 36 | IdentitiesOnly yes 37 | ServerAliveInterval 240 38 | 39 | Host 34.128.204.12 40 | HostName updated_hostname 41 | IdentityFile updated_identityFile 42 | User updated_user 43 | ForwardAgent yes` 44 | 45 | config := ssh.NewConfig(configPath) 46 | 47 | updatedHostName := "updated_hostname" 48 | updatedUser := "updated_user" 49 | updatedIdentityFile := "updated_identityFile" 50 | 51 | err = config.UpdateHost( 52 | "34.128.204.12", 53 | &updatedHostName, 54 | &updatedIdentityFile, 55 | &updatedUser, 56 | ) 57 | 58 | if err != nil { 59 | t.Fatalf("expected no error, got '%+v'", err) 60 | } 61 | 62 | configAtEnd, err := os.ReadFile(configPath) 63 | 64 | if err != nil { 65 | t.Fatalf("expected no error, got '%+v'", err) 66 | } 67 | 68 | configAtEndString := strings.TrimSuffix( 69 | string(configAtEnd), 70 | system.NewLineChar, 71 | ) 72 | 73 | if configAtEndString != expectedConfig { 74 | t.Fatalf( 75 | "expected config to equal '%s', got '%s'", 76 | expectedConfig, 77 | configAtEndString, 78 | ) 79 | } 80 | } 81 | 82 | func TestConfigUpdateHostWithExistingHostAndPartialConfig(t *testing.T) { 83 | configPath := "./testdata/non_empty_ssh_config" 84 | configAtStart, err := os.ReadFile(configPath) 85 | 86 | if err != nil { 87 | t.Fatalf("expected no error, got '%+v'", err) 88 | } 89 | 90 | defer func() { // Reset modified config file 91 | err = os.WriteFile( 92 | configPath, 93 | configAtStart, 94 | ssh.ConfigFilePerm, 95 | ) 96 | 97 | if err != nil { 98 | t.Fatalf("expected no error, got '%+v'", err) 99 | } 100 | }() 101 | 102 | expectedConfig := `Host * 103 | AddKeysToAgent yes 104 | UseKeychain yes 105 | IdentityFile ~/.ssh/id_rsa 106 | IdentitiesOnly yes 107 | ServerAliveInterval 240 108 | 109 | Host 34.128.204.12 110 | HostName updated_hostname 111 | IdentityFile identityFile 112 | User user 113 | ForwardAgent yes` 114 | 115 | config := ssh.NewConfig(configPath) 116 | 117 | updatedHostName := "updated_hostname" 118 | 119 | err = config.UpdateHost( 120 | "34.128.204.12", 121 | &updatedHostName, 122 | nil, 123 | nil, 124 | ) 125 | 126 | if err != nil { 127 | t.Fatalf("expected no error, got '%+v'", err) 128 | } 129 | 130 | configAtEnd, err := os.ReadFile(configPath) 131 | 132 | if err != nil { 133 | t.Fatalf("expected no error, got '%+v'", err) 134 | } 135 | 136 | configAtEndString := strings.TrimSuffix( 137 | string(configAtEnd), 138 | system.NewLineChar, 139 | ) 140 | 141 | if configAtEndString != expectedConfig { 142 | t.Fatalf( 143 | "expected config to equal '%s', got '%s'", 144 | expectedConfig, 145 | configAtEndString, 146 | ) 147 | } 148 | } 149 | 150 | func TestConfigUpdateHostWithNonExistingHost(t *testing.T) { 151 | configPath := "./testdata/non_empty_ssh_config" 152 | configAtStart, err := os.ReadFile(configPath) 153 | 154 | if err != nil { 155 | t.Fatalf("expected no error, got '%+v'", err) 156 | } 157 | 158 | defer func() { // Reset modified config file 159 | err = os.WriteFile( 160 | configPath, 161 | configAtStart, 162 | ssh.ConfigFilePerm, 163 | ) 164 | 165 | if err != nil { 166 | t.Fatalf("expected no error, got '%+v'", err) 167 | } 168 | }() 169 | 170 | expectedConfig := string(configAtStart) 171 | 172 | config := ssh.NewConfig(configPath) 173 | 174 | updatedHostName := "updated_hostname" 175 | updatedUser := "updated_user" 176 | updatedIdentityFile := "updated_identityFile" 177 | 178 | err = config.UpdateHost( 179 | "34.228.204.12", 180 | &updatedHostName, 181 | &updatedIdentityFile, 182 | &updatedUser, 183 | ) 184 | 185 | if err != nil { 186 | t.Fatalf("expected no error, got '%+v'", err) 187 | } 188 | 189 | configAtEnd, err := os.ReadFile(configPath) 190 | 191 | if err != nil { 192 | t.Fatalf("expected no error, got '%+v'", err) 193 | } 194 | 195 | configAtEndString := strings.TrimSuffix( 196 | string(configAtEnd), 197 | system.NewLineChar, 198 | ) 199 | 200 | if configAtEndString != expectedConfig { 201 | t.Fatalf( 202 | "expected config to equal '%s', got '%s'", 203 | expectedConfig, 204 | configAtEndString, 205 | ) 206 | } 207 | } 208 | -------------------------------------------------------------------------------- /internal/ssh/keys.go: -------------------------------------------------------------------------------- 1 | package ssh 2 | 3 | import ( 4 | "errors" 5 | "io/fs" 6 | "os" 7 | "path/filepath" 8 | 9 | "github.com/recode-sh/cli/internal/system" 10 | ) 11 | 12 | const PrivateKeyFilePerm os.FileMode = 0600 13 | 14 | type Keys struct { 15 | sshDir string 16 | } 17 | 18 | func NewKeys(SSHDir string) Keys { 19 | return Keys{ 20 | sshDir: SSHDir, 21 | } 22 | } 23 | 24 | func NewKeysWithDefaultDir() Keys { 25 | return NewKeys( 26 | system.DefaultSSHDir(), 27 | ) 28 | } 29 | 30 | func (k Keys) CreateOrReplacePEM( 31 | PEMName string, 32 | PEMContent string, 33 | ) (pathWritten string, err error) { 34 | 35 | pathWritten = filepath.Join(k.sshDir, PEMName+".pem") 36 | 37 | err = os.WriteFile( 38 | pathWritten, 39 | []byte(PEMContent), 40 | PrivateKeyFilePerm, 41 | ) 42 | 43 | return 44 | } 45 | 46 | func (k Keys) RemovePEMIfExists(PEMName string) error { 47 | err := os.Remove( 48 | k.GetPEMFilePath(PEMName), 49 | ) 50 | 51 | if err != nil && errors.Is(err, fs.ErrNotExist) { 52 | return nil 53 | } 54 | 55 | return err 56 | } 57 | 58 | func (k Keys) GetPEMFilePath(PEMName string) string { 59 | return filepath.Join( 60 | k.sshDir, 61 | PEMName+".pem", 62 | ) 63 | } 64 | -------------------------------------------------------------------------------- /internal/ssh/keys_add_pem_test.go: -------------------------------------------------------------------------------- 1 | package ssh_test 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/recode-sh/cli/internal/ssh" 8 | ) 9 | 10 | func TestKeysCreateOrReplacePEM(t *testing.T) { 11 | keys := ssh.NewKeys("./testdata") 12 | 13 | expectedPEMName := "pem_name" 14 | expectedPEMContent := "pem_content" 15 | expectedPEMPath := "./testdata/" + expectedPEMName + ".pem" 16 | 17 | PEMPath, err := keys.CreateOrReplacePEM( 18 | expectedPEMName, 19 | expectedPEMContent, 20 | ) 21 | 22 | if err != nil { 23 | t.Fatalf("expected no error, got '%+v'", err) 24 | } 25 | 26 | defer func() { // Remove created PEM file 27 | err = os.Remove(PEMPath) 28 | 29 | if err != nil { 30 | t.Fatalf("expected no error, got '%+v'", err) 31 | } 32 | }() 33 | 34 | if "./"+PEMPath != expectedPEMPath { 35 | t.Fatalf( 36 | "expected PEM path to equal '%s', got '%s'", 37 | expectedPEMPath, 38 | PEMPath, 39 | ) 40 | } 41 | 42 | createdPEMContent, err := os.ReadFile(expectedPEMPath) 43 | 44 | if err != nil { 45 | t.Fatalf("expected no error, got '%+v'", err) 46 | } 47 | 48 | if string(createdPEMContent) != expectedPEMContent { 49 | t.Fatalf( 50 | "expected PEM to equal '%s', got '%s'", 51 | expectedPEMContent, 52 | string(createdPEMContent), 53 | ) 54 | } 55 | 56 | createdPEMFileInfo, err := os.Stat(expectedPEMPath) 57 | 58 | if err != nil { 59 | t.Fatalf("expected no error, got '%+v'", err) 60 | } 61 | 62 | if createdPEMFileInfo.Mode().Perm() != ssh.PrivateKeyFilePerm { 63 | t.Fatalf( 64 | "expected created PEM file to have permission '%o', got '%o'", 65 | ssh.PrivateKeyFilePerm, 66 | createdPEMFileInfo.Mode().Perm(), 67 | ) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /internal/ssh/keys_remove_pem_test.go: -------------------------------------------------------------------------------- 1 | package ssh_test 2 | 3 | import ( 4 | "errors" 5 | "os" 6 | "testing" 7 | 8 | "github.com/recode-sh/cli/internal/ssh" 9 | ) 10 | 11 | func TestKeysRemoveExistingPEM(t *testing.T) { 12 | keys := ssh.NewKeys("./testdata") 13 | 14 | PEMName := "pem_to_remove" 15 | PEMPath := "./testdata/" + PEMName + ".pem" 16 | 17 | _, err := os.Create(PEMPath) 18 | 19 | if err != nil { 20 | t.Fatalf("expected no error, got '%+v'", err) 21 | } 22 | 23 | err = keys.RemovePEMIfExists(PEMName) 24 | 25 | if err != nil { 26 | t.Fatalf("expected no error, got '%+v'", err) 27 | } 28 | 29 | _, err = os.Stat(PEMPath) 30 | 31 | if err == nil { 32 | t.Fatalf("expected file not exists error, got nothing") 33 | } 34 | 35 | if !errors.Is(err, os.ErrNotExist) { 36 | t.Fatalf("expected file not exists error, got '%+v'", err) 37 | } 38 | } 39 | 40 | func TestKeysRemoveNonExistingPEM(t *testing.T) { 41 | keys := ssh.NewKeys("./testdata") 42 | err := keys.RemovePEMIfExists("non_existing_pem") 43 | 44 | if err != nil { 45 | t.Fatalf("expected no error, got '%+v'", err) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /internal/ssh/known_hosts.go: -------------------------------------------------------------------------------- 1 | package ssh 2 | 3 | import ( 4 | "bufio" 5 | "os" 6 | "strings" 7 | 8 | "github.com/recode-sh/cli/internal/system" 9 | ) 10 | 11 | const KnownHostsFilePerm os.FileMode = 0644 12 | 13 | type KnownHosts struct { 14 | knownHostsFilePath string 15 | } 16 | 17 | func NewKnownHosts(knownHostsFilePath string) KnownHosts { 18 | return KnownHosts{ 19 | knownHostsFilePath: knownHostsFilePath, 20 | } 21 | } 22 | 23 | func NewKnownHostsWithDefaultKnownHostsFilePath() KnownHosts { 24 | return NewKnownHosts( 25 | system.DefaultSSHKnownHostsFilePath(), 26 | ) 27 | } 28 | 29 | func (k KnownHosts) AddOrReplace(hostname, algorithm, fingerprint string) error { 30 | f, err := k.openFile() 31 | 32 | if err != nil { 33 | return err 34 | } 35 | 36 | defer f.Close() 37 | 38 | knownHostToAdd := hostname + " " + algorithm + " " + fingerprint 39 | knownHostToAddReplaced := false 40 | 41 | scanner := bufio.NewScanner(f) 42 | newKnownHostsContent := "" 43 | 44 | for scanner.Scan() { 45 | knownHostLine := scanner.Text() 46 | 47 | if strings.HasPrefix(knownHostLine, hostname+" "+algorithm) { 48 | newKnownHostsContent += knownHostToAdd + system.NewLineChar 49 | knownHostToAddReplaced = true 50 | continue 51 | } 52 | 53 | newKnownHostsContent += knownHostLine + system.NewLineChar 54 | } 55 | 56 | if err := scanner.Err(); err != nil { 57 | return err 58 | } 59 | 60 | if !knownHostToAddReplaced { 61 | newKnownHostsContent += knownHostToAdd + system.NewLineChar 62 | } 63 | 64 | return os.WriteFile( 65 | k.knownHostsFilePath, 66 | []byte(newKnownHostsContent), 67 | KnownHostsFilePerm, 68 | ) 69 | } 70 | 71 | func (k KnownHosts) RemoveIfExists(hostname string) error { 72 | if len(hostname) == 0 { 73 | // Nothing to do. 74 | // We don't want to remove all hostnames 75 | // (the function "hasPrefix" will always return "true" if prefix is empty). 76 | // See below. 77 | return nil 78 | } 79 | 80 | f, err := k.openFile() 81 | 82 | if err != nil { 83 | return err 84 | } 85 | 86 | defer f.Close() 87 | 88 | scanner := bufio.NewScanner(f) 89 | newKnownHostsContent := "" 90 | 91 | for scanner.Scan() { 92 | knownHostLine := scanner.Text() 93 | 94 | if strings.HasPrefix(knownHostLine, hostname) { 95 | continue 96 | } 97 | 98 | newKnownHostsContent += knownHostLine + system.NewLineChar 99 | } 100 | 101 | if err := scanner.Err(); err != nil { 102 | return err 103 | } 104 | 105 | /* We only want one new line 106 | at the end of the file */ 107 | 108 | for { 109 | trimedNewKnownHostsContent := strings.TrimSuffix( 110 | newKnownHostsContent, 111 | system.NewLineChar, 112 | ) 113 | 114 | if trimedNewKnownHostsContent == newKnownHostsContent { 115 | break 116 | } 117 | 118 | newKnownHostsContent = trimedNewKnownHostsContent 119 | } 120 | 121 | newKnownHostsContent += system.NewLineChar 122 | 123 | return os.WriteFile( 124 | k.knownHostsFilePath, 125 | []byte(newKnownHostsContent), 126 | KnownHostsFilePerm, 127 | ) 128 | } 129 | 130 | func (k KnownHosts) openFile() (*os.File, error) { 131 | // create the "known_hosts" file if necessary 132 | return os.OpenFile( 133 | k.knownHostsFilePath, 134 | os.O_APPEND|os.O_CREATE|os.O_RDWR, 135 | KnownHostsFilePerm, 136 | ) 137 | } 138 | -------------------------------------------------------------------------------- /internal/ssh/known_hosts_add_test.go: -------------------------------------------------------------------------------- 1 | package ssh_test 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/recode-sh/cli/internal/ssh" 8 | "github.com/recode-sh/cli/internal/system" 9 | ) 10 | 11 | func TestKnownHostsAddOrReplaceWithNonEmptyKnownHostsFile(t *testing.T) { 12 | knownHostsPath := "./testdata/non_empty_known_hosts" 13 | knownHostsAtStart, err := os.ReadFile(knownHostsPath) 14 | 15 | if err != nil { 16 | t.Fatalf("expected no error, got '%+v'", err) 17 | } 18 | 19 | defer func() { // Reset modified known hosts file 20 | err = os.WriteFile( 21 | knownHostsPath, 22 | knownHostsAtStart, 23 | os.FileMode(ssh.KnownHostsFilePerm), 24 | ) 25 | 26 | if err != nil { 27 | t.Fatalf("expected no error, got '%+v'", err) 28 | } 29 | }() 30 | 31 | expectedKnownHosts := string(knownHostsAtStart) + 32 | "hostname algorithm fingerprint" + 33 | system.NewLineChar 34 | 35 | knownHosts := ssh.NewKnownHosts(knownHostsPath) 36 | err = knownHosts.AddOrReplace("hostname", "algorithm", "fingerprint") 37 | 38 | if err != nil { 39 | t.Fatalf("expected no error, got '%+v'", err) 40 | } 41 | 42 | knownHostsAtEnd, err := os.ReadFile(knownHostsPath) 43 | 44 | if err != nil { 45 | t.Fatalf("expected no error, got '%+v'", err) 46 | } 47 | 48 | if string(knownHostsAtEnd) != expectedKnownHosts { 49 | t.Fatalf( 50 | "expected known hosts to equal '%s', got '%s'", 51 | expectedKnownHosts, 52 | string(knownHostsAtEnd), 53 | ) 54 | } 55 | } 56 | 57 | func TestKnownHostsAddOrReplaceWithEmptyKnownHostsFile(t *testing.T) { 58 | knownHostsPath := "./testdata/empty_known_hosts" 59 | expectedKnownHosts := 60 | "hostname algorithm fingerprint" + 61 | system.NewLineChar 62 | 63 | defer func() { // Reset modified known hosts file 64 | err := os.WriteFile( 65 | knownHostsPath, 66 | []byte(""), 67 | os.FileMode(ssh.KnownHostsFilePerm), 68 | ) 69 | 70 | if err != nil { 71 | t.Fatalf("expected no error, got '%+v'", err) 72 | } 73 | }() 74 | 75 | knownHosts := ssh.NewKnownHosts(knownHostsPath) 76 | err := knownHosts.AddOrReplace("hostname", "algorithm", "fingerprint") 77 | 78 | if err != nil { 79 | t.Fatalf("expected no error, got '%+v'", err) 80 | } 81 | 82 | knownHostsAtEnd, err := os.ReadFile(knownHostsPath) 83 | 84 | if err != nil { 85 | t.Fatalf("expected no error, got '%+v'", err) 86 | } 87 | 88 | if string(knownHostsAtEnd) != expectedKnownHosts { 89 | t.Fatalf( 90 | "expected known hosts to equal '%s', got '%s'", 91 | expectedKnownHosts, 92 | string(knownHostsAtEnd), 93 | ) 94 | } 95 | } 96 | 97 | func TestKnownHostsAddOrReplaceWithNonExistingKnownHostsFile(t *testing.T) { 98 | knownHostsPath := "./testdata/non_existing_known_hosts" 99 | expectedKnownHosts := 100 | "hostname algorithm fingerprint" + 101 | system.NewLineChar 102 | 103 | defer func() { // Remove created known hosts file 104 | err := os.Remove(knownHostsPath) 105 | 106 | if err != nil { 107 | t.Fatalf("expected no error, got '%+v'", err) 108 | } 109 | }() 110 | 111 | knownHosts := ssh.NewKnownHosts(knownHostsPath) 112 | err := knownHosts.AddOrReplace("hostname", "algorithm", "fingerprint") 113 | 114 | if err != nil { 115 | t.Fatalf("expected no error, got '%+v'", err) 116 | } 117 | 118 | knownHostsAtEnd, err := os.ReadFile(knownHostsPath) 119 | 120 | if err != nil { 121 | t.Fatalf("expected no error, got '%+v'", err) 122 | } 123 | 124 | if string(knownHostsAtEnd) != expectedKnownHosts { 125 | t.Fatalf( 126 | "expected known hosts to equal '%s', got '%s'", 127 | expectedKnownHosts, 128 | string(knownHostsAtEnd), 129 | ) 130 | } 131 | 132 | createdKnownHostsFileInfo, err := os.Stat(knownHostsPath) 133 | 134 | if err != nil { 135 | t.Fatalf("expected no error, got '%+v'", err) 136 | } 137 | 138 | if createdKnownHostsFileInfo.Mode().Perm() != ssh.KnownHostsFilePerm { 139 | t.Fatalf( 140 | "expected created known hosts file to have permission '%o', got '%o'", 141 | ssh.KnownHostsFilePerm, 142 | createdKnownHostsFileInfo.Mode().Perm(), 143 | ) 144 | } 145 | } 146 | 147 | func TestKnownHostsAddOrReplaceWitExistingHost(t *testing.T) { 148 | knownHostsPath := "./testdata/non_empty_known_hosts" 149 | knownHostsAtStart, err := os.ReadFile(knownHostsPath) 150 | 151 | if err != nil { 152 | t.Fatalf("expected no error, got '%+v'", err) 153 | } 154 | 155 | defer func() { // Reset modified known hosts file 156 | err = os.WriteFile( 157 | knownHostsPath, 158 | knownHostsAtStart, 159 | os.FileMode(ssh.KnownHostsFilePerm), 160 | ) 161 | 162 | if err != nil { 163 | t.Fatalf("expected no error, got '%+v'", err) 164 | } 165 | }() 166 | 167 | expectedKnownHosts := `github.com,140.82.118.3 ssh-rsa fingerprint 168 | 34.229.126.51 ssh-rsa fingerprint 169 | 170 | github.com ecdsa-sha2-nistp256 fingerprint 171 | github.com ssh-ed25519 fingerprint 172 | 173 | 34.229.126.51 ecdsa-sha2-nistp256 fingerprint_replaced 174 | 34.229.126.51 ssh-ed25519 fingerprint 175 | ` 176 | 177 | knownHosts := ssh.NewKnownHosts(knownHostsPath) 178 | 179 | err = knownHosts.AddOrReplace( 180 | "34.229.126.51", 181 | "ecdsa-sha2-nistp256", 182 | "fingerprint_replaced", 183 | ) 184 | 185 | if err != nil { 186 | t.Fatalf("expected no error, got '%+v'", err) 187 | } 188 | 189 | knownHostsAtEnd, err := os.ReadFile(knownHostsPath) 190 | 191 | if err != nil { 192 | t.Fatalf("expected no error, got '%+v'", err) 193 | } 194 | 195 | if string(knownHostsAtEnd) != expectedKnownHosts { 196 | t.Fatalf( 197 | "expected known hosts to equal '%s', got '%s'", 198 | expectedKnownHosts, 199 | string(knownHostsAtEnd), 200 | ) 201 | } 202 | } 203 | -------------------------------------------------------------------------------- /internal/ssh/known_hosts_remove_test.go: -------------------------------------------------------------------------------- 1 | package ssh_test 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/recode-sh/cli/internal/ssh" 8 | ) 9 | 10 | func TestKnownHostsRemoveWithExistingHostname(t *testing.T) { 11 | knownHostsPath := "./testdata/non_empty_known_hosts" 12 | knownHostsAtStart, err := os.ReadFile(knownHostsPath) 13 | 14 | if err != nil { 15 | t.Fatalf("expected no error, got '%+v'", err) 16 | } 17 | 18 | defer func() { // Reset modified known hosts file 19 | err = os.WriteFile( 20 | knownHostsPath, 21 | knownHostsAtStart, 22 | ssh.KnownHostsFilePerm, 23 | ) 24 | 25 | if err != nil { 26 | t.Fatalf("expected no error, got '%+v'", err) 27 | } 28 | }() 29 | 30 | expectedKnownHosts := `github.com,140.82.118.3 ssh-rsa fingerprint 31 | 32 | github.com ecdsa-sha2-nistp256 fingerprint 33 | github.com ssh-ed25519 fingerprint 34 | ` 35 | 36 | knownHosts := ssh.NewKnownHosts(knownHostsPath) 37 | err = knownHosts.RemoveIfExists("34.229.126.51") 38 | 39 | if err != nil { 40 | t.Fatalf("expected no error, got '%+v'", err) 41 | } 42 | 43 | knownHostsAtEnd, err := os.ReadFile(knownHostsPath) 44 | 45 | if err != nil { 46 | t.Fatalf("expected no error, got '%+v'", err) 47 | } 48 | 49 | if string(knownHostsAtEnd) != expectedKnownHosts { 50 | t.Fatalf( 51 | "expected known hosts to equal '%s', got '%s'", 52 | expectedKnownHosts, 53 | string(knownHostsAtEnd), 54 | ) 55 | } 56 | } 57 | 58 | func TestKnownHostsRemoveWithNonExistingHostname(t *testing.T) { 59 | knownHostsPath := "./testdata/non_empty_known_hosts" 60 | knownHostsAtStart, err := os.ReadFile(knownHostsPath) 61 | 62 | if err != nil { 63 | t.Fatalf("expected no error, got '%+v'", err) 64 | } 65 | 66 | defer func() { // Reset modified known hosts file 67 | err = os.WriteFile( 68 | knownHostsPath, 69 | knownHostsAtStart, 70 | ssh.KnownHostsFilePerm, 71 | ) 72 | 73 | if err != nil { 74 | t.Fatalf("expected no error, got '%+v'", err) 75 | } 76 | }() 77 | 78 | expectedKnownHosts := knownHostsAtStart 79 | 80 | knownHosts := ssh.NewKnownHosts(knownHostsPath) 81 | err = knownHosts.RemoveIfExists("104.78.1.4") 82 | 83 | if err != nil { 84 | t.Fatalf("expected no error, got '%+v'", err) 85 | } 86 | 87 | knownHostsAtEnd, err := os.ReadFile(knownHostsPath) 88 | 89 | if err != nil { 90 | t.Fatalf("expected no error, got '%+v'", err) 91 | } 92 | 93 | if string(expectedKnownHosts) != string(knownHostsAtEnd) { 94 | t.Fatalf( 95 | "expected known hosts to equal '%s', got '%s'", 96 | string(expectedKnownHosts), 97 | string(knownHostsAtEnd), 98 | ) 99 | } 100 | } 101 | 102 | func TestKnownHostsRemoveWithEmptyHostname(t *testing.T) { 103 | knownHostsPath := "./testdata/non_empty_known_hosts" 104 | knownHostsAtStart, err := os.ReadFile(knownHostsPath) 105 | 106 | if err != nil { 107 | t.Fatalf("expected no error, got '%+v'", err) 108 | } 109 | 110 | defer func() { // Reset modified known hosts file 111 | err = os.WriteFile( 112 | knownHostsPath, 113 | knownHostsAtStart, 114 | ssh.KnownHostsFilePerm, 115 | ) 116 | 117 | if err != nil { 118 | t.Fatalf("expected no error, got '%+v'", err) 119 | } 120 | }() 121 | 122 | expectedKnownHosts := knownHostsAtStart 123 | 124 | knownHosts := ssh.NewKnownHosts(knownHostsPath) 125 | err = knownHosts.RemoveIfExists("") 126 | 127 | if err != nil { 128 | t.Fatalf("expected no error, got '%+v'", err) 129 | } 130 | 131 | knownHostsAtEnd, err := os.ReadFile(knownHostsPath) 132 | 133 | if err != nil { 134 | t.Fatalf("expected no error, got '%+v'", err) 135 | } 136 | 137 | if string(expectedKnownHosts) != string(knownHostsAtEnd) { 138 | t.Fatalf( 139 | "expected known hosts to equal '%s', got '%s'", 140 | string(expectedKnownHosts), 141 | string(knownHostsAtEnd), 142 | ) 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /internal/ssh/port_forwarding.go: -------------------------------------------------------------------------------- 1 | package ssh 2 | 3 | import ( 4 | "io" 5 | "net" 6 | "time" 7 | 8 | "golang.org/x/crypto/ssh" 9 | ) 10 | 11 | type PortForwarder struct{} 12 | 13 | func NewPortForwarder() PortForwarder { 14 | return PortForwarder{} 15 | } 16 | 17 | type PortForwarderReadyResp struct { 18 | Error error 19 | LocalAddr string 20 | } 21 | 22 | func (p PortForwarder) Forward( 23 | onReadyChan chan<- PortForwarderReadyResp, 24 | privateKeyBytes []byte, 25 | user string, 26 | serverAddr string, 27 | localAddr string, 28 | remoteAddrProtocol string, 29 | remoteAddr string, 30 | ) error { 31 | 32 | sshConfig, err := p.buildSSHConfig( 33 | 8*time.Second, 34 | user, 35 | privateKeyBytes, 36 | ) 37 | 38 | if err != nil { 39 | onReadyChan <- PortForwarderReadyResp{ 40 | Error: err, 41 | } 42 | return nil 43 | } 44 | 45 | // Establish connection with server through SSH 46 | serverSSHConn, err := ssh.Dial("tcp", serverAddr, sshConfig) 47 | 48 | if err != nil { 49 | onReadyChan <- PortForwarderReadyResp{ 50 | Error: err, 51 | } 52 | return nil 53 | } 54 | 55 | defer serverSSHConn.Close() 56 | 57 | // Establish connection with remoteAddr from server 58 | remoteConn, err := serverSSHConn.Dial(remoteAddrProtocol, remoteAddr) 59 | 60 | if err != nil { 61 | onReadyChan <- PortForwarderReadyResp{ 62 | Error: err, 63 | } 64 | return nil 65 | } 66 | 67 | // Start local TCP server to forward traffic to remote connection 68 | localTCPServer, err := net.Listen("tcp", localAddr) 69 | 70 | if err != nil { 71 | onReadyChan <- PortForwarderReadyResp{ 72 | Error: err, 73 | } 74 | return nil 75 | } 76 | 77 | defer localTCPServer.Close() 78 | 79 | onReadyChan <- PortForwarderReadyResp{ 80 | LocalAddr: localTCPServer.Addr().String(), 81 | } 82 | 83 | localConn, err := localTCPServer.Accept() 84 | 85 | if err != nil { 86 | return err 87 | } 88 | 89 | return p.forwardLocalToRemoteConn( 90 | localConn, 91 | remoteConn, 92 | ) 93 | } 94 | 95 | // Get ssh client config for our connection 96 | func (p PortForwarder) buildSSHConfig( 97 | connTimeout time.Duration, 98 | user string, 99 | privateKeyBytes []byte, 100 | ) (*ssh.ClientConfig, error) { 101 | 102 | parsedPrivateKey, err := ssh.ParsePrivateKey(privateKeyBytes) 103 | 104 | if err != nil { 105 | return nil, err 106 | } 107 | 108 | config := ssh.ClientConfig{ 109 | User: user, 110 | Auth: []ssh.AuthMethod{ 111 | ssh.PublicKeys(parsedPrivateKey), 112 | }, 113 | HostKeyCallback: ssh.InsecureIgnoreHostKey(), 114 | Timeout: connTimeout, 115 | } 116 | 117 | return &config, nil 118 | } 119 | 120 | // Handle local TCP server connections and tunnel data to the remote server 121 | func (p PortForwarder) forwardLocalToRemoteConn( 122 | localConn net.Conn, 123 | remoteConn net.Conn, 124 | ) error { 125 | 126 | defer func() { 127 | localConn.Close() 128 | remoteConn.Close() 129 | }() 130 | 131 | remoteConnRespChan := make(chan error, 1) 132 | localConnRespChan := make(chan error, 1) 133 | 134 | // Forward remote -> local 135 | go func() { 136 | _, err := io.Copy(localConn, remoteConn) 137 | remoteConnRespChan <- err 138 | }() 139 | 140 | // Forward local -> remote 141 | go func() { 142 | _, err := io.Copy(remoteConn, localConn) 143 | localConnRespChan <- err 144 | }() 145 | 146 | select { 147 | case remoteConnErr := <-remoteConnRespChan: 148 | return remoteConnErr 149 | case localConnErr := <-localConnRespChan: 150 | return localConnErr 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /internal/ssh/testdata/empty_known_hosts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/recode-sh/cli/ced506be070aa194d6be6d8e2cc7a446cccac7c8/internal/ssh/testdata/empty_known_hosts -------------------------------------------------------------------------------- /internal/ssh/testdata/empty_ssh_config: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/recode-sh/cli/ced506be070aa194d6be6d8e2cc7a446cccac7c8/internal/ssh/testdata/empty_ssh_config -------------------------------------------------------------------------------- /internal/ssh/testdata/non_empty_known_hosts: -------------------------------------------------------------------------------- 1 | github.com,140.82.118.3 ssh-rsa fingerprint 2 | 34.229.126.51 ssh-rsa fingerprint 3 | 4 | github.com ecdsa-sha2-nistp256 fingerprint 5 | github.com ssh-ed25519 fingerprint 6 | 7 | 34.229.126.51 ecdsa-sha2-nistp256 fingerprint 8 | 34.229.126.51 ssh-ed25519 fingerprint 9 | -------------------------------------------------------------------------------- /internal/ssh/testdata/non_empty_ssh_config: -------------------------------------------------------------------------------- 1 | Host * 2 | AddKeysToAgent yes 3 | UseKeychain yes 4 | IdentityFile ~/.ssh/id_rsa 5 | IdentitiesOnly yes 6 | ServerAliveInterval 240 7 | 8 | Host 34.128.204.12 9 | HostName hostname 10 | IdentityFile identityFile 11 | User user 12 | ForwardAgent yes -------------------------------------------------------------------------------- /internal/stepper/step.go: -------------------------------------------------------------------------------- 1 | package stepper 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/briandowns/spinner" 7 | ) 8 | 9 | type Step struct { 10 | spin *spinner.Spinner 11 | removeAfterDone bool 12 | } 13 | 14 | func (s *Step) Done() { 15 | s.spin.Stop() 16 | 17 | if !s.removeAfterDone { 18 | fmt.Println(s.spin.Prefix + "... done") 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /internal/stepper/stepper.go: -------------------------------------------------------------------------------- 1 | package stepper 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/briandowns/spinner" 8 | "github.com/recode-sh/cli/internal/constants" 9 | "github.com/recode-sh/recode/stepper" 10 | ) 11 | 12 | var currentStep *Step 13 | 14 | type Stepper struct{} 15 | 16 | func NewStepper() Stepper { 17 | return Stepper{} 18 | } 19 | 20 | func (s Stepper) startStep( 21 | step string, 22 | removeAfterDone bool, 23 | noNewLineAtStart bool, 24 | ) stepper.Step { 25 | 26 | if currentStep == nil && !noNewLineAtStart { 27 | fmt.Println("") 28 | } 29 | 30 | if currentStep != nil { 31 | currentStep.Done() 32 | currentStep = nil 33 | } 34 | 35 | bold := constants.Bold 36 | 37 | spin := spinner.New(spinner.CharSets[26], 400*time.Millisecond) 38 | spin.Prefix = bold(step) 39 | spin.Start() 40 | 41 | currentStep = &Step{ 42 | spin: spin, 43 | removeAfterDone: removeAfterDone, 44 | } 45 | 46 | return currentStep 47 | } 48 | 49 | func (s Stepper) StartStep( 50 | step string, 51 | ) stepper.Step { 52 | 53 | removeAfterDone := false 54 | noNewLineAtStart := false 55 | 56 | return s.startStep( 57 | step, 58 | removeAfterDone, 59 | noNewLineAtStart, 60 | ) 61 | } 62 | 63 | func (s Stepper) StartTemporaryStep( 64 | step string, 65 | ) stepper.Step { 66 | 67 | removeAfterDone := true 68 | noNewLineAtStart := false 69 | 70 | return s.startStep( 71 | step, 72 | removeAfterDone, 73 | noNewLineAtStart, 74 | ) 75 | } 76 | 77 | func (s Stepper) StartTemporaryStepWithoutNewLine( 78 | step string, 79 | ) stepper.Step { 80 | 81 | removeAfterDone := true 82 | noNewLineAtStart := true 83 | 84 | return s.startStep( 85 | step, 86 | removeAfterDone, 87 | noNewLineAtStart, 88 | ) 89 | } 90 | 91 | func (s Stepper) StopCurrentStep() { 92 | 93 | if currentStep != nil { 94 | currentStep.Done() 95 | currentStep = nil 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /internal/system/browser.go: -------------------------------------------------------------------------------- 1 | package system 2 | 3 | import ( 4 | "github.com/pkg/browser" 5 | ) 6 | 7 | type Browser struct{} 8 | 9 | func NewBrowser() Browser { 10 | return Browser{} 11 | } 12 | 13 | func (Browser) OpenURL(url string) error { 14 | return browser.OpenURL(url) 15 | } 16 | -------------------------------------------------------------------------------- /internal/system/cli.go: -------------------------------------------------------------------------------- 1 | package system 2 | 3 | import ( 4 | "bufio" 5 | "io" 6 | "strings" 7 | 8 | "github.com/recode-sh/cli/internal/constants" 9 | "github.com/recode-sh/cli/internal/interfaces" 10 | ) 11 | 12 | func AskForConfirmation( 13 | logger interfaces.Logger, 14 | stdin io.Reader, 15 | question string, 16 | ) (bool, error) { 17 | 18 | stdinReader := bufio.NewReader(stdin) 19 | 20 | logger.Log(constants.Bold(constants.Yellow("Warning!") + " " + question)) 21 | 22 | logger.Log("\nOnly \"yes\" will be accepted to confirm. (You could use \"--force\" next time).\n") 23 | logger.LogNoNewline(constants.Bold("Confirm? ")) 24 | 25 | response, err := stdinReader.ReadString('\n') 26 | 27 | if err != nil { 28 | return false, err 29 | } 30 | 31 | sanitizedResponse := strings.TrimSpace(response) 32 | 33 | if sanitizedResponse == "yes" { 34 | return true, nil 35 | } 36 | 37 | logger.Log("") 38 | 39 | return false, nil 40 | } 41 | -------------------------------------------------------------------------------- /internal/system/displayer.go: -------------------------------------------------------------------------------- 1 | package system 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | ) 7 | 8 | type Displayer struct{} 9 | 10 | func NewDisplayer() Displayer { 11 | return Displayer{} 12 | } 13 | 14 | func (Displayer) Display(w io.Writer, format string, args ...interface{}) { 15 | fmt.Fprintf(w, format, args...) 16 | } 17 | -------------------------------------------------------------------------------- /internal/system/env_vars.go: -------------------------------------------------------------------------------- 1 | package system 2 | 3 | import "os" 4 | 5 | type EnvVars struct{} 6 | 7 | func NewEnvVars() EnvVars { 8 | return EnvVars{} 9 | } 10 | 11 | func (EnvVars) Get(key string) string { 12 | return os.Getenv(key) 13 | } 14 | -------------------------------------------------------------------------------- /internal/system/logger.go: -------------------------------------------------------------------------------- 1 | package system 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/recode-sh/cli/internal/constants" 8 | ) 9 | 10 | type Logger struct{} 11 | 12 | func NewLogger() Logger { 13 | return Logger{} 14 | } 15 | 16 | func (Logger) Info(format string, v ...interface{}) { 17 | fmt.Fprintf(os.Stderr, constants.Cyan(format)+"\n", v...) 18 | } 19 | 20 | func (Logger) Warning(format string, v ...interface{}) { 21 | fmt.Fprintf(os.Stderr, constants.Yellow(format)+"\n", v...) 22 | } 23 | 24 | func (Logger) Error(format string, v ...interface{}) { 25 | fmt.Fprintf(os.Stderr, constants.Red(format)+"\n", v...) 26 | } 27 | 28 | func (Logger) Log(format string, v ...interface{}) { 29 | fmt.Fprintf(os.Stderr, format+"\n", v...) 30 | } 31 | 32 | func (Logger) LogNoNewline(format string, v ...interface{}) { 33 | fmt.Fprintf(os.Stderr, format, v...) 34 | } 35 | 36 | func (l Logger) Write(p []byte) (n int, err error) { 37 | l.Log(string(p)) 38 | return len(p), nil 39 | } 40 | -------------------------------------------------------------------------------- /internal/system/new_line.go: -------------------------------------------------------------------------------- 1 | package system 2 | 3 | import "runtime" 4 | 5 | var NewLineChar = "\n" 6 | 7 | func init() { 8 | if runtime.GOOS == "windows" { 9 | NewLineChar = "\r\n" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /internal/system/paths.go: -------------------------------------------------------------------------------- 1 | package system 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | ) 7 | 8 | func PathExists(path string) bool { 9 | _, err := os.Stat(path) 10 | 11 | if err == nil || !os.IsNotExist(err) { 12 | return true 13 | } 14 | 15 | return false 16 | } 17 | 18 | func UserHomeDir() string { 19 | // Ignore errors since we only care about Windows and *nix. 20 | homedir, _ := os.UserHomeDir() 21 | return homedir 22 | } 23 | 24 | // UserConfigDir returns the path where 25 | // the user config files should be stored 26 | // following XDG Base Directory Specification. 27 | // Ref: https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html 28 | func UserConfigDir() string { 29 | baseConfigDir := os.Getenv("XDG_CONFIG_HOME") 30 | 31 | if len(baseConfigDir) == 0 { 32 | baseConfigDir = filepath.Join(UserHomeDir(), ".config") 33 | } 34 | 35 | return filepath.Join(baseConfigDir, "recode") 36 | } 37 | 38 | func UserConfigFilePath() string { 39 | return filepath.Join(UserConfigDir(), "recode.yml") 40 | } 41 | 42 | func DefaultSSHDir() string { 43 | return filepath.Join(UserHomeDir(), ".ssh") 44 | } 45 | 46 | func DefaultSSHDirExists() bool { 47 | return PathExists(DefaultSSHDir()) 48 | } 49 | 50 | func DefaultSSHConfigFilePath() string { 51 | return filepath.Join(DefaultSSHDir(), "config") 52 | } 53 | 54 | func DefaultSSHConfigFileExists() bool { 55 | return PathExists(DefaultSSHConfigFilePath()) 56 | } 57 | 58 | func DefaultSSHKnownHostsFilePath() string { 59 | return filepath.Join(DefaultSSHDir(), "known_hosts") 60 | } 61 | 62 | func DefaultSSHKnownHostsFileExists() bool { 63 | return PathExists(DefaultSSHKnownHostsFilePath()) 64 | } 65 | -------------------------------------------------------------------------------- /internal/system/paths_test.go: -------------------------------------------------------------------------------- 1 | package system_test 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "testing" 7 | 8 | "github.com/recode-sh/cli/internal/system" 9 | ) 10 | 11 | func TestPathExistsWithExistingPath(t *testing.T) { 12 | existingPath := "./paths_test.go" 13 | pathExists := system.PathExists(existingPath) 14 | 15 | if !pathExists { 16 | t.Fatalf("expected 'true', got 'false'") 17 | } 18 | } 19 | 20 | func TestPathExistsWithNonExistingPath(t *testing.T) { 21 | nonExistingPath := "./path-that-doesnt-exist" 22 | pathExists := system.PathExists(nonExistingPath) 23 | 24 | if pathExists { 25 | t.Fatalf("expected 'false', got 'true'") 26 | } 27 | } 28 | 29 | func TestUserHomeDir(t *testing.T) { 30 | expectedHomeDir, err := os.UserHomeDir() 31 | 32 | if err != nil { 33 | t.Fatalf("expected no error, got '%+v'", err) 34 | } 35 | 36 | if system.UserHomeDir() != expectedHomeDir { 37 | t.Fatalf( 38 | "expected user home directory to equal '%s', got '%s'", 39 | expectedHomeDir, 40 | system.UserHomeDir(), 41 | ) 42 | } 43 | } 44 | 45 | func TestDefaultSSHDir(t *testing.T) { 46 | homeDir, err := os.UserHomeDir() 47 | 48 | if err != nil { 49 | t.Fatalf("expected no error, got '%+v'", err) 50 | } 51 | 52 | expectedSSHDir := filepath.Join(homeDir, ".ssh") 53 | 54 | if system.DefaultSSHDir() != expectedSSHDir { 55 | t.Fatalf( 56 | "expected default SSH directory to equal '%s', got '%s'", 57 | expectedSSHDir, 58 | system.DefaultSSHDir(), 59 | ) 60 | } 61 | } 62 | 63 | func TestDefaultSSHConfigFilePath(t *testing.T) { 64 | homeDir, err := os.UserHomeDir() 65 | 66 | if err != nil { 67 | t.Fatalf("expected no error, got '%+v'", err) 68 | } 69 | 70 | expectedSSHConfigFilePath := filepath.Join(homeDir, ".ssh/config") 71 | 72 | if system.DefaultSSHConfigFilePath() != expectedSSHConfigFilePath { 73 | t.Fatalf( 74 | "expected default SSH config file path to equal '%s', got '%s'", 75 | expectedSSHConfigFilePath, 76 | system.DefaultSSHConfigFilePath(), 77 | ) 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /internal/system/sleeper.go: -------------------------------------------------------------------------------- 1 | package system 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | type Sleeper struct{} 8 | 9 | func NewSleeper() Sleeper { 10 | return Sleeper{} 11 | } 12 | 13 | func (Sleeper) Sleep(d time.Duration) { 14 | time.Sleep(d) 15 | } 16 | -------------------------------------------------------------------------------- /internal/views/base.go: -------------------------------------------------------------------------------- 1 | package views 2 | 3 | import ( 4 | "io" 5 | "os" 6 | 7 | "github.com/recode-sh/cli/internal/constants" 8 | "github.com/recode-sh/cli/internal/presenters" 9 | ) 10 | 11 | //go:generate mockgen -destination=../mocks/views_displayer.go -package=mocks github.com/recode-sh/cli/internal/views Displayer 12 | type Displayer interface { 13 | Display(w io.Writer, format string, args ...interface{}) 14 | } 15 | 16 | type BaseView struct { 17 | Displayer Displayer 18 | } 19 | 20 | func NewBaseView(displayer Displayer) BaseView { 21 | return BaseView{ 22 | Displayer: displayer, 23 | } 24 | } 25 | 26 | func (b BaseView) showErrorView( 27 | err *presenters.ViewableError, 28 | startWithNewLine bool, 29 | ) { 30 | 31 | bold := constants.Bold 32 | red := constants.Red 33 | 34 | if startWithNewLine { 35 | b.Displayer.Display( 36 | os.Stdout, 37 | "\n", 38 | ) 39 | } 40 | 41 | b.Displayer.Display( 42 | os.Stdout, 43 | "%s %s\n\n%s\n\n", 44 | bold(red("Error!")), 45 | bold(err.Title), 46 | err.Message, 47 | ) 48 | } 49 | 50 | func (b BaseView) ShowErrorView(err *presenters.ViewableError) { 51 | b.showErrorView(err, false) 52 | } 53 | 54 | func (b BaseView) ShowErrorViewWithStartingNewLine(err *presenters.ViewableError) { 55 | b.showErrorView(err, true) 56 | } 57 | 58 | func (b BaseView) ShowWarningView(warningText, subtext string) { 59 | bold := constants.Bold 60 | yellow := constants.Yellow 61 | 62 | if len(subtext) > 0 { 63 | b.Displayer.Display( 64 | os.Stdout, 65 | "%s %s\n\n%s\n\n", 66 | bold(yellow("Warning!")), 67 | bold(warningText), 68 | subtext, 69 | ) 70 | 71 | return 72 | } 73 | 74 | b.Displayer.Display( 75 | os.Stdout, 76 | "%s %s\n\n", 77 | bold(yellow("Warning!")), 78 | bold(warningText), 79 | ) 80 | } 81 | 82 | func (b BaseView) ShowSuccessView(successText, subtext string) { 83 | bold := constants.Bold 84 | green := constants.Green 85 | 86 | if len(subtext) > 0 { 87 | b.Displayer.Display( 88 | os.Stdout, 89 | "%s %s\n\n%s\n\n", 90 | bold(green("Success!")), 91 | bold(successText), 92 | subtext, 93 | ) 94 | 95 | return 96 | } 97 | 98 | b.Displayer.Display( 99 | os.Stdout, 100 | "%s %s\n\n", 101 | bold(green("Success!")), 102 | bold(successText), 103 | ) 104 | } 105 | -------------------------------------------------------------------------------- /internal/views/login.go: -------------------------------------------------------------------------------- 1 | package views 2 | 3 | import "github.com/recode-sh/cli/internal/presenters" 4 | 5 | type LoginView struct { 6 | BaseView 7 | } 8 | 9 | func NewLoginView(baseView BaseView) LoginView { 10 | return LoginView{ 11 | BaseView: baseView, 12 | } 13 | } 14 | 15 | func (l LoginView) View(data presenters.LoginViewData) { 16 | if data.Error == nil { 17 | l.ShowSuccessView(data.Content.Message, "") 18 | return 19 | } 20 | 21 | l.ShowErrorView(data.Error) 22 | } 23 | -------------------------------------------------------------------------------- /internal/views/remove.go: -------------------------------------------------------------------------------- 1 | package views 2 | 3 | import "github.com/recode-sh/cli/internal/presenters" 4 | 5 | type RemoveView struct { 6 | BaseView 7 | } 8 | 9 | func NewRemoveView(baseView BaseView) RemoveView { 10 | return RemoveView{ 11 | BaseView: baseView, 12 | } 13 | } 14 | 15 | func (r RemoveView) View(data presenters.RemoveViewData) { 16 | if data.Error == nil { 17 | r.ShowSuccessView(data.Content.Message, "") 18 | return 19 | } 20 | 21 | r.ShowErrorView(data.Error) 22 | } 23 | -------------------------------------------------------------------------------- /internal/views/start.go: -------------------------------------------------------------------------------- 1 | package views 2 | 3 | import "github.com/recode-sh/cli/internal/presenters" 4 | 5 | type StartView struct { 6 | BaseView 7 | } 8 | 9 | func NewStartView(baseView BaseView) StartView { 10 | return StartView{ 11 | BaseView: baseView, 12 | } 13 | } 14 | 15 | func (s StartView) View(data presenters.StartViewData) { 16 | if data.Error == nil { 17 | if data.Content.ShowAsWarning { 18 | s.ShowWarningView( 19 | data.Content.Message, 20 | data.Content.Subtext, 21 | ) 22 | return 23 | } 24 | 25 | s.ShowSuccessView( 26 | data.Content.Message, 27 | data.Content.Subtext, 28 | ) 29 | return 30 | } 31 | 32 | s.ShowErrorView(data.Error) 33 | } 34 | -------------------------------------------------------------------------------- /internal/views/stop.go: -------------------------------------------------------------------------------- 1 | package views 2 | 3 | import "github.com/recode-sh/cli/internal/presenters" 4 | 5 | type StopView struct { 6 | BaseView 7 | } 8 | 9 | func NewStopView(baseView BaseView) StopView { 10 | return StopView{ 11 | BaseView: baseView, 12 | } 13 | } 14 | 15 | func (s StopView) View(data presenters.StopViewData) { 16 | if data.Error == nil { 17 | if data.Content.ShowAsWarning { 18 | s.ShowWarningView(data.Content.Message, "") 19 | return 20 | } 21 | 22 | s.ShowSuccessView(data.Content.Message, "") 23 | return 24 | } 25 | 26 | s.ShowErrorView(data.Error) 27 | } 28 | -------------------------------------------------------------------------------- /internal/views/uninstall.go: -------------------------------------------------------------------------------- 1 | package views 2 | 3 | import "github.com/recode-sh/cli/internal/presenters" 4 | 5 | type UninstallView struct { 6 | BaseView 7 | } 8 | 9 | func NewUninstallView(baseView BaseView) UninstallView { 10 | return UninstallView{ 11 | BaseView: baseView, 12 | } 13 | } 14 | 15 | func (u UninstallView) View(data presenters.UninstallViewData) { 16 | if data.Error == nil { 17 | if data.Content.ShowAsWarning { 18 | u.ShowWarningView( 19 | data.Content.Message, 20 | data.Content.Subtext, 21 | ) 22 | return 23 | } 24 | 25 | u.ShowSuccessView( 26 | data.Content.Message, 27 | data.Content.Subtext, 28 | ) 29 | return 30 | } 31 | 32 | u.ShowErrorView(data.Error) 33 | } 34 | -------------------------------------------------------------------------------- /internal/vscode/cli.go: -------------------------------------------------------------------------------- 1 | package vscode 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "os" 7 | "os/exec" 8 | "path/filepath" 9 | "regexp" 10 | "runtime" 11 | "strings" 12 | 13 | "github.com/recode-sh/cli/internal/system" 14 | ) 15 | 16 | type ErrCLINotFound struct { 17 | VisitedPaths []string 18 | } 19 | 20 | func (ErrCLINotFound) Error() string { 21 | return "ErrCLINotFound" 22 | } 23 | 24 | type CLI struct{} 25 | 26 | func (c CLI) Exec(arg ...string) (string, error) { 27 | CLIPath, err := c.LookupPath(runtime.GOOS) 28 | 29 | if err != nil { 30 | return "", err 31 | } 32 | 33 | cmd := exec.Command(CLIPath, arg...) 34 | 35 | var stdout bytes.Buffer 36 | var stderr bytes.Buffer 37 | 38 | cmd.Stdout = &stdout 39 | cmd.Stderr = &stderr 40 | 41 | err = cmd.Run() 42 | 43 | if err != nil { 44 | newLineRegExp := regexp.MustCompile(`\n+`) 45 | 46 | return "", fmt.Errorf( 47 | "Error while calling the Visual Studio Code CLI:\n\n%s\n\n%s", 48 | strings.TrimSpace( 49 | newLineRegExp.ReplaceAllLiteralString(stderr.String(), " "), 50 | ), 51 | err.Error(), 52 | ) 53 | } 54 | 55 | return stdout.String(), nil 56 | } 57 | 58 | func (c CLI) LookupPath(operatingSystem string) (string, error) { 59 | // First, we look for the 'code-insiders' command 60 | insidersCLIPath, err := exec.LookPath("code-insiders") 61 | 62 | if err == nil { // 'code-insiders' command exists 63 | return insidersCLIPath, nil 64 | } 65 | 66 | // If the 'code-insiders' command was not found, we look for the 'code' one 67 | CLIPath, err := exec.LookPath("code") 68 | 69 | if err == nil { // 'code' command exists 70 | return CLIPath, nil 71 | } 72 | 73 | // Finally, we fallback to default paths 74 | possibleCLIPaths := []string{} 75 | 76 | if operatingSystem == "darwin" { // macOS 77 | possibleCLIPaths = c.macOSPossibleCLIPaths() 78 | } 79 | 80 | if operatingSystem == "windows" { 81 | possibleCLIPaths = c.windowsPossibleCLIPaths() 82 | } 83 | 84 | if operatingSystem == "linux" { 85 | possibleCLIPaths = c.linuxPossibleCLIPaths() 86 | } 87 | 88 | for _, possibleCLIPath := range possibleCLIPaths { 89 | if system.PathExists(possibleCLIPath) { 90 | return possibleCLIPath, nil 91 | } 92 | } 93 | 94 | return "", ErrCLINotFound{ 95 | VisitedPaths: possibleCLIPaths, 96 | } 97 | } 98 | 99 | func (c CLI) macOSPossibleCLIPaths() []string { 100 | rootApplicationsDir := fmt.Sprintf("%cApplications", os.PathSeparator) // /Applications 101 | 102 | // Order matter here. 103 | // We want the insiders version to be matched first. 104 | possiblePaths := []string{} 105 | 106 | possiblePaths = append(possiblePaths, filepath.Join( 107 | rootApplicationsDir, 108 | "Visual Studio Code - Insiders.app", 109 | "Contents", 110 | "Resources", 111 | "app", 112 | "bin", 113 | "code-insiders", 114 | )) 115 | 116 | possiblePaths = append(possiblePaths, filepath.Join( 117 | rootApplicationsDir, 118 | "Visual Studio Code.app", 119 | "Contents", 120 | "Resources", 121 | "app", 122 | "bin", 123 | "code", 124 | )) 125 | 126 | return possiblePaths 127 | } 128 | 129 | func (c CLI) windowsPossibleCLIPaths() []string { 130 | programFilesPath := os.Getenv("ProgramFiles") 131 | 132 | // Order matter here. 133 | // We want the insiders version to be matched first. 134 | 135 | // -- Insiders VSCode versions 136 | 137 | possiblePaths := []string{} 138 | 139 | possiblePaths = append(possiblePaths, filepath.Join( 140 | system.UserHomeDir(), 141 | "AppData", 142 | "Local", 143 | "Programs", 144 | "Microsoft VS Code Insiders", 145 | "bin", 146 | "code-insiders.cmd", 147 | )) 148 | 149 | possiblePaths = append(possiblePaths, filepath.Join( 150 | programFilesPath, 151 | "Microsoft VS Code Insiders", 152 | "bin", 153 | "code-insiders.cmd", 154 | )) 155 | 156 | // -- Regular VSCode versions 157 | 158 | possiblePaths = append(possiblePaths, filepath.Join( 159 | system.UserHomeDir(), 160 | "AppData", 161 | "Local", 162 | "Programs", 163 | "Microsoft VS Code", 164 | "bin", 165 | "code.cmd", 166 | )) 167 | 168 | possiblePaths = append(possiblePaths, filepath.Join( 169 | programFilesPath, 170 | "Microsoft VS Code", 171 | "bin", 172 | "code.cmd", 173 | )) 174 | 175 | return possiblePaths 176 | } 177 | 178 | func (c CLI) linuxPossibleCLIPaths() []string { 179 | // Order matter here. 180 | // We want the insiders version to be matched first. 181 | possiblePaths := []string{ 182 | "/usr/bin/code-insiders", 183 | "/snap/bin/code-insiders", 184 | "/usr/share/code/bin/code-insiders", 185 | 186 | "/usr/bin/code", 187 | "/snap/bin/code", 188 | "/usr/share/code/bin/code", 189 | } 190 | 191 | return possiblePaths 192 | } 193 | -------------------------------------------------------------------------------- /internal/vscode/extensions.go: -------------------------------------------------------------------------------- 1 | package vscode 2 | 3 | type Extensions struct{} 4 | 5 | func NewExtensions() Extensions { 6 | return Extensions{} 7 | } 8 | 9 | func (e Extensions) Install(extensionName string) (string, error) { 10 | c := CLI{} 11 | 12 | return c.Exec( 13 | "--install-extension", 14 | extensionName, 15 | "--force", 16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /internal/vscode/process.go: -------------------------------------------------------------------------------- 1 | package vscode 2 | 3 | type Process struct{} 4 | 5 | func NewProcess() Process { 6 | return Process{} 7 | } 8 | 9 | func (p Process) OpenOnRemote(hostKey, pathToOpen string) (string, error) { 10 | c := CLI{} 11 | 12 | return c.Exec( 13 | "--new-window", 14 | "--skip-release-notes", 15 | "--skip-welcome", 16 | "--skip-add-to-recently-opened", 17 | "--disable-workspace-trust", 18 | "--remote", 19 | "ssh-remote+"+hostKey, 20 | pathToOpen, 21 | ) 22 | } 23 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2022 Jeremy Levy jje.levy@gmail.com 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in 12 | all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 20 | THE SOFTWARE. 21 | */ 22 | package main 23 | 24 | import "github.com/recode-sh/cli/internal/cmd" 25 | 26 | func main() { 27 | cmd.Execute() 28 | } 29 | --------------------------------------------------------------------------------