├── internal ├── config │ ├── .gitignore │ ├── version.go │ ├── config_platformsh.go │ ├── config_upsun.go │ ├── config_vendor.go │ ├── validator.go │ ├── alt │ │ ├── path.go │ │ ├── test-config.yaml │ │ ├── alt_test.go │ │ ├── alt.go │ │ ├── fs.go │ │ ├── fs_test.go │ │ ├── update.go │ │ ├── path_test.go │ │ ├── fetch_test.go │ │ ├── update_test.go │ │ └── fetch.go │ ├── test-data │ │ └── valid-config.yaml │ ├── config.go │ ├── config_test.go │ ├── dir.go │ ├── platformsh-cli.yaml │ └── upsun-cli.yaml ├── convert │ ├── testdata │ │ └── platformsh │ │ │ ├── .upsun │ │ │ └── .gitkeep │ │ │ ├── .platform │ │ │ ├── local │ │ │ │ └── .gitkeep │ │ │ ├── solr-config │ │ │ │ └── config.json │ │ │ ├── routes.yaml │ │ │ ├── services.yaml │ │ │ └── applications.yaml │ │ │ ├── README │ │ │ └── .platform.app.yaml │ ├── platformsh_test.go │ └── platformsh.go ├── legacy │ ├── php_linux_arm.go │ ├── php_darwin_amd64.go │ ├── php_darwin_arm64.go │ ├── php_linux_amd64.go │ ├── php_linux_arm64.go │ ├── php_manager_test.go │ ├── php_manager_unix.go │ ├── php_manager.go │ ├── legacy_test.go │ ├── php_manager_windows.go │ └── legacy.go ├── version │ ├── version.go │ └── version_test.go ├── init │ ├── init.go │ ├── streaming │ │ ├── types.go │ │ ├── client.go │ │ └── client_test.go │ ├── streaming.go │ └── format.go ├── api │ ├── organization.go │ ├── resource.go │ └── client.go ├── md │ ├── syntax.go │ └── builder.go ├── auth │ ├── jwt.go │ ├── client.go │ ├── legacy.go │ ├── transport.go │ └── transport_test.go ├── state │ └── state.go ├── file │ ├── file.go │ └── file_test.go └── update.go ├── .gitmodules ├── .gitignore ├── .github └── workflows │ ├── add-to-project.yml │ └── ci.yml ├── ext └── extensions.txt ├── commands ├── help.go ├── version.go ├── completion.go ├── list.go ├── config_install_test.go ├── list_input_options.go ├── project_convert.go ├── list_formatters.go └── config_install.go ├── scripts └── generate_completions.sh ├── pkg ├── mockapi │ ├── id.go │ ├── users.go │ ├── activities.go │ ├── subscriptions.go │ ├── orgs.go │ ├── projects.go │ ├── store.go │ ├── auth_server.go │ ├── api_server.go │ ├── environments.go │ └── variables.go └── mockssh │ └── server_test.go ├── LICENSE ├── cmd └── platform │ └── main.go ├── .gitlab-ci.yml ├── .golangci.yml ├── cloudsmith.sh ├── Dockerfile.php ├── .goreleaser.vendor.yaml.tpl ├── PHP-LICENSE └── Makefile /internal/config/.gitignore: -------------------------------------------------------------------------------- 1 | /embedded-config.yaml 2 | -------------------------------------------------------------------------------- /internal/convert/testdata/platformsh/.upsun/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /internal/convert/testdata/platformsh/.platform/local/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /internal/convert/testdata/platformsh/.platform/solr-config/config.json: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /internal/convert/testdata/platformsh/README: -------------------------------------------------------------------------------- 1 | This directory is embedded for testing. 2 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "ext/static-php-cli"] 2 | path = ext/static-php-cli 3 | url = https://github.com/crazywhalecc/static-php-cli 4 | -------------------------------------------------------------------------------- /internal/config/version.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | var ( 4 | Version = "0.0.0" 5 | Commit = "local" 6 | Date = "" 7 | BuiltBy = "local" 8 | ) 9 | -------------------------------------------------------------------------------- /internal/legacy/php_linux_arm.go: -------------------------------------------------------------------------------- 1 | package legacy 2 | 3 | import ( 4 | _ "embed" 5 | ) 6 | 7 | //go:embed archives/php_linux_arm 8 | var phpCLI []byte 9 | -------------------------------------------------------------------------------- /internal/legacy/php_darwin_amd64.go: -------------------------------------------------------------------------------- 1 | package legacy 2 | 3 | import ( 4 | _ "embed" 5 | ) 6 | 7 | //go:embed archives/php_darwin_amd64 8 | var phpCLI []byte 9 | -------------------------------------------------------------------------------- /internal/legacy/php_darwin_arm64.go: -------------------------------------------------------------------------------- 1 | package legacy 2 | 3 | import ( 4 | _ "embed" 5 | ) 6 | 7 | //go:embed archives/php_darwin_arm64 8 | var phpCLI []byte 9 | -------------------------------------------------------------------------------- /internal/legacy/php_linux_amd64.go: -------------------------------------------------------------------------------- 1 | package legacy 2 | 3 | import ( 4 | _ "embed" 5 | ) 6 | 7 | //go:embed archives/php_linux_amd64 8 | var phpCLI []byte 9 | -------------------------------------------------------------------------------- /internal/legacy/php_linux_arm64.go: -------------------------------------------------------------------------------- 1 | package legacy 2 | 3 | import ( 4 | _ "embed" 5 | ) 6 | 7 | //go:embed archives/php_linux_arm64 8 | var phpCLI []byte 9 | -------------------------------------------------------------------------------- /internal/config/config_platformsh.go: -------------------------------------------------------------------------------- 1 | //go:build !vendor 2 | // +build !vendor 3 | 4 | package config 5 | 6 | import _ "embed" 7 | 8 | //go:embed platformsh-cli.yaml 9 | var embedded []byte 10 | -------------------------------------------------------------------------------- /internal/config/config_upsun.go: -------------------------------------------------------------------------------- 1 | //go:build vendor && upsun 2 | // +build vendor,upsun 3 | 4 | package config 5 | 6 | import _ "embed" 7 | 8 | //go:embed upsun-cli.yaml 9 | var embedded []byte 10 | -------------------------------------------------------------------------------- /internal/config/config_vendor.go: -------------------------------------------------------------------------------- 1 | //go:build vendor && !upsun 2 | // +build vendor,!upsun 3 | 4 | package config 5 | 6 | import _ "embed" 7 | 8 | //go:embed embedded-config.yaml 9 | var embedded []byte 10 | -------------------------------------------------------------------------------- /internal/convert/testdata/platformsh/.platform/routes.yaml: -------------------------------------------------------------------------------- 1 | # This is my default route 2 | “https://{default}/“: 3 | type: upstream 4 | upstream: app:http 5 | # Redirect just... 6 | “http://{default}“: 7 | type: redirect 8 | to: “https://{default}/” -------------------------------------------------------------------------------- /internal/legacy/php_manager_test.go: -------------------------------------------------------------------------------- 1 | package legacy 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestPHPManager(t *testing.T) { 10 | tempDir := t.TempDir() 11 | 12 | pm := newPHPManager(tempDir) 13 | assert.NoError(t, pm.copy()) 14 | 15 | assert.FileExists(t, pm.binPath()) 16 | } 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | internal/legacy/archives/* 2 | dist/ 3 | php-* 4 | completion 5 | 6 | # Binaries for programs and plugins 7 | *.exe 8 | *.exe~ 9 | *.dll 10 | *.so 11 | *.dylib 12 | 13 | # Test binary, built with `go test -c` 14 | *.test 15 | 16 | # Output of the go coverage tool, specifically when used with LiteIDE 17 | *.out 18 | 19 | # Dependency directories (remove the comment below to include it) 20 | # vendor/ 21 | /.goreleaser.vendor.yaml 22 | -------------------------------------------------------------------------------- /internal/legacy/php_manager_unix.go: -------------------------------------------------------------------------------- 1 | //go:build darwin || linux 2 | 3 | package legacy 4 | 5 | import ( 6 | "path/filepath" 7 | 8 | "github.com/platformsh/cli/internal/file" 9 | ) 10 | 11 | func (m *phpManagerPerOS) copy() error { 12 | return file.WriteIfNeeded(m.binPath(), phpCLI, 0o755) 13 | } 14 | 15 | func (m *phpManagerPerOS) binPath() string { 16 | return filepath.Join(m.cacheDir, "php") 17 | } 18 | 19 | func (m *phpManagerPerOS) settings() []string { 20 | return nil 21 | } 22 | -------------------------------------------------------------------------------- /internal/legacy/php_manager.go: -------------------------------------------------------------------------------- 1 | package legacy 2 | 3 | type phpManager interface { 4 | // copy writes embedded PHP files to temporary files. 5 | copy() error 6 | 7 | // binPath returns the path to the temporary PHP binary. 8 | binPath() string 9 | 10 | // settings returns PHP INI entries (key=value format). 11 | settings() []string 12 | } 13 | 14 | type phpManagerPerOS struct { 15 | cacheDir string 16 | } 17 | 18 | func newPHPManager(cacheDir string) phpManager { 19 | return &phpManagerPerOS{cacheDir} 20 | } 21 | -------------------------------------------------------------------------------- /.github/workflows/add-to-project.yml: -------------------------------------------------------------------------------- 1 | name: Adds all issues to the project board 2 | 3 | on: 4 | issues: 5 | types: 6 | - opened 7 | 8 | jobs: 9 | add-to-project: 10 | name: Add issue to project 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/add-to-project@RELEASE_VERSION 14 | with: 15 | project-url: https://github.com/orgs/platformsh/projects/4 16 | # Token creation details: https://github.com/actions/add-to-project?tab=readme-ov-file#creating-a-pat-and-adding-it-to-your-repository 17 | github-token: ${{ secrets.ADD_TO_PROJECT_PAT }} 18 | -------------------------------------------------------------------------------- /internal/config/validator.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "reflect" 5 | 6 | "github.com/go-playground/validator/v10" 7 | 8 | "github.com/platformsh/cli/internal/version" 9 | ) 10 | 11 | var _validator *validator.Validate 12 | 13 | func getValidator() *validator.Validate { 14 | if _validator == nil { 15 | _validator = validator.New() 16 | initCustomValidators(_validator) 17 | } 18 | return _validator 19 | } 20 | 21 | func initCustomValidators(v *validator.Validate) { 22 | _ = v.RegisterValidation("version", func(fl validator.FieldLevel) bool { 23 | return fl.Field().Kind() == reflect.String && version.Validate(fl.Field().String()) 24 | }) 25 | } 26 | -------------------------------------------------------------------------------- /ext/extensions.txt: -------------------------------------------------------------------------------- 1 | # Start with '#' is comments 2 | # Start with '^' is deselecting extensions, which is not installed as default 3 | # Each line just leave the extension name or ^ character 4 | 5 | ^bcmath 6 | ^calendar 7 | ^ctype 8 | curl 9 | ^dom 10 | ^event 11 | ^exif 12 | ^fileinfo 13 | filter 14 | ^gd 15 | ^hash 16 | ^iconv 17 | ^inotify 18 | ^json 19 | ^libxml 20 | ^mbstring 21 | ^mongodb 22 | ^mysqlnd 23 | openssl 24 | pcntl 25 | ^pdo 26 | ^pdo_mysql 27 | ^pdo_sqlite 28 | phar 29 | posix 30 | ^protobuf 31 | ^readline 32 | ^redis 33 | ^shmop 34 | ^simplexml 35 | ^soap 36 | ^sockets 37 | ^sqlite3 38 | ^swoole 39 | ^tokenizer 40 | ^xml 41 | ^xmlreader 42 | ^xmlwriter 43 | zlib 44 | ^zip 45 | -------------------------------------------------------------------------------- /commands/help.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | 6 | "github.com/platformsh/cli/internal/config" 7 | ) 8 | 9 | func newHelpCommand(_ *config.Config) *cobra.Command { 10 | return &cobra.Command{ 11 | Use: "help", 12 | FParseErrWhitelist: cobra.FParseErrWhitelist{UnknownFlags: true}, 13 | Run: func(cmd *cobra.Command, args []string) { 14 | cmd, _, e := cmd.Root().Find(args) 15 | if cmd == nil || e != nil { 16 | cmd.Printf("Unknown help topic %#q\n", args) 17 | cobra.CheckErr(cmd.Root().Usage()) 18 | } else { 19 | cmd.InitDefaultHelpFlag() 20 | cmd.InitDefaultVersionFlag() 21 | cmd.HelpFunc()(cmd, args) 22 | } 23 | }, 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /scripts/generate_completions.sh: -------------------------------------------------------------------------------- 1 | set -e 2 | 3 | rm -rf completion 4 | mkdir -p completion/bash completion/zsh 5 | go run cmd/platform/main.go completion bash > completion/bash/platform.bash 6 | go run cmd/platform/main.go completion zsh > completion/zsh/_platform 7 | 8 | go run --tags=vendor,upsun cmd/platform/main.go completion bash > completion/bash/upsun.bash 9 | go run --tags=vendor,upsun cmd/platform/main.go completion zsh > completion/zsh/_upsun 10 | 11 | # if $VENDOR_BINARY is not empty 12 | if [ -nz "$VENDOR_BINARY" ]; then 13 | go run --tags=vendor cmd/platform/main.go completion bash > completion/bash/$VENDOR_BINARY.bash 14 | go run --tags=vendor cmd/platform/main.go completion zsh > completion/zsh/_$VENDOR_BINARY 15 | fi 16 | -------------------------------------------------------------------------------- /internal/version/version.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | import "github.com/Masterminds/semver/v3" 4 | 5 | // Compare parses and compares two semantic version numbers. 6 | // It returns -1, 0 or 1, representing whether v1 is less than, equal to or greater than v2. 7 | func Compare(v1, v2 string) (int, error) { 8 | if v1 == v2 { 9 | return 0, nil 10 | } 11 | version1, err := semver.NewVersion(v1) 12 | if err != nil { 13 | return 0, err 14 | } 15 | version2, err := semver.NewVersion(v2) 16 | if err != nil { 17 | return 0, err 18 | } 19 | return version1.Compare(version2), nil 20 | } 21 | 22 | // Validate tests if a version number is valid. 23 | func Validate(v string) bool { 24 | _, err := semver.NewVersion(v) 25 | return err == nil 26 | } 27 | -------------------------------------------------------------------------------- /internal/convert/platformsh_test.go: -------------------------------------------------------------------------------- 1 | package convert 2 | 3 | import ( 4 | _ "embed" 5 | "os" 6 | "path/filepath" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | //go:embed testdata/platformsh/.upsun/config-ref.yaml 14 | var configRef string 15 | 16 | func TestConvert(t *testing.T) { 17 | tmpDir := t.TempDir() 18 | require.NoError(t, os.CopyFS(tmpDir, os.DirFS("testdata/platformsh"))) 19 | assert.NoError(t, PlatformshToUpsun(tmpDir, t.Output())) 20 | assert.FileExists(t, filepath.Join(tmpDir, ".upsun", "config.yaml")) 21 | 22 | b, err := os.ReadFile(filepath.Join(tmpDir, ".upsun", "config.yaml")) 23 | assert.NoError(t, err) 24 | 25 | assert.Equal(t, configRef, string(b)) 26 | } 27 | -------------------------------------------------------------------------------- /internal/config/alt/path.go: -------------------------------------------------------------------------------- 1 | package alt 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "strings" 7 | ) 8 | 9 | // InPath tests if a directory is in the PATH. 10 | func InPath(dir string) bool { 11 | return inPathValue(dir, os.Getenv("PATH")) 12 | } 13 | 14 | func inPathValue(dir, path string) bool { 15 | homeDir, _ := os.UserHomeDir() 16 | normalized := normalizePathEntry(dir, homeDir) 17 | for _, e := range filepath.SplitList(path) { 18 | if normalizePathEntry(e, homeDir) == normalized { 19 | return true 20 | } 21 | } 22 | return false 23 | } 24 | 25 | func normalizePathEntry(path, homeDir string) string { 26 | if homeDir != "" && strings.HasPrefix(path, "~") { 27 | path = homeDir + path[1:] 28 | } 29 | if path == "" { 30 | path = "." 31 | } 32 | path = filepath.Clean(os.ExpandEnv(path)) 33 | if abs, err := filepath.Abs(path); err == nil { 34 | path = abs 35 | } 36 | return path 37 | } 38 | -------------------------------------------------------------------------------- /internal/config/test-data/valid-config.yaml: -------------------------------------------------------------------------------- 1 | # Example minimal valid CLI configuration for testing. 2 | application: 3 | name: 'Example CLI' 4 | slug: 'example-cli' 5 | executable: 'example' 6 | env_prefix: 'EXAMPLE_CLI_' 7 | user_config_dir: '.example-cli' 8 | 9 | service: 10 | name: 'Example Service' 11 | slug: 'example' 12 | env_prefix: 'EXAMPLE_' 13 | header_prefix: 'X-Example' 14 | 15 | project_config_dir: '.example' 16 | 17 | console_url: 'https://console.example.com' 18 | 19 | docs_url: 'https://docs.example.com' 20 | 21 | api: 22 | base_url: 'https://api.example.com' 23 | auth_url: 'https://auth.example.com' 24 | 25 | centralized_permissions: true 26 | metrics: true 27 | organizations: true 28 | user_verification: true 29 | 30 | ssh: 31 | domain_wildcards: ['*.example.com'] 32 | 33 | detection: 34 | git_remote_name: 'example' 35 | git_domain: 'example.com' 36 | site_domains: ['example.site'] 37 | -------------------------------------------------------------------------------- /internal/init/init.go: -------------------------------------------------------------------------------- 1 | package init 2 | 3 | import "github.com/upsun/whatsun/pkg/digest" 4 | 5 | // Input represents input for the /ai/generate-configuration API. 6 | type Input struct { 7 | Digest *digest.Digest 8 | ExtraContext string `json:"extra_context,omitempty"` 9 | 10 | OrganizationID string `json:"organization_id,omitempty"` 11 | ProjectID string `json:"project_id,omitempty"` 12 | 13 | Debug bool `json:"debug,omitempty"` 14 | } 15 | 16 | // Output represents output from the /ai/generate-configuration API. 17 | type Output struct { 18 | ConfigYAML string `json:"config_yaml"` 19 | Valid bool `json:"valid"` 20 | } 21 | 22 | var defaultIgnoredFiles = []string{"_www", ".platform", ".upsun"} 23 | 24 | func DefaultDigestConfig() (*digest.Config, error) { 25 | cnf, err := digest.DefaultConfig() 26 | if err != nil { 27 | return nil, err 28 | } 29 | cnf.IgnoreFiles = defaultIgnoredFiles 30 | return cnf, nil 31 | } 32 | -------------------------------------------------------------------------------- /internal/init/streaming/types.go: -------------------------------------------------------------------------------- 1 | package streaming 2 | 3 | import ( 4 | "encoding/json" 5 | "time" 6 | ) 7 | 8 | const ( 9 | MessageTypeLog = "log" 10 | MessageTypeOutputChunk = "output_chunk" 11 | MessageTypeData = "data" 12 | MessageTypeDone = "done" 13 | MessageTypeKeepAlive = "keep_alive" 14 | 15 | LogLevelDebug = "debug" 16 | LogLevelInfo = "info" 17 | LogLevelError = "error" 18 | LogLevelWarn = "warn" 19 | ) 20 | 21 | type Message struct { 22 | Type string `json:"type"` // See MessageType constants. 23 | Time time.Time `json:"time,omitempty"` 24 | 25 | Message string `json:"message,omitempty"` // For output or log messages. 26 | Level string `json:"level,omitempty"` // For log messages: see LogLevel constants. 27 | Tags []string `json:"tags,omitempty"` 28 | 29 | Key string `json:"key,omitempty"` // Used to identify data. 30 | Data json.RawMessage `json:"data,omitempty"` 31 | } 32 | -------------------------------------------------------------------------------- /internal/init/streaming.go: -------------------------------------------------------------------------------- 1 | package init 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | 8 | "github.com/briandowns/spinner" 9 | 10 | "github.com/platformsh/cli/internal/init/streaming" 11 | ) 12 | 13 | type dataHandler func(data json.RawMessage, key string) error 14 | 15 | func handleMessage(msg *streaming.Message, stdout, stderr io.Writer, spinr *spinner.Spinner, handleData dataHandler) error { //nolint:lll 16 | logger := &logPrinter{spinr: spinr, stderr: stderr} 17 | 18 | spinr.Stop() 19 | 20 | switch msg.Type { 21 | case streaming.MessageTypeLog: 22 | logger.print(msg.Level, msg.Message, msg.Tags...) 23 | case streaming.MessageTypeOutputChunk: 24 | fmt.Fprint(stdout, msg.Message) 25 | case streaming.MessageTypeData: 26 | if err := handleData(msg.Data, msg.Key); err != nil { 27 | return err 28 | } 29 | default: 30 | logger.print(streaming.LogLevelError, fmt.Sprintf("Unknown message type: %v\n", msg.Type)) 31 | } 32 | 33 | return nil 34 | } 35 | -------------------------------------------------------------------------------- /pkg/mockapi/id.go: -------------------------------------------------------------------------------- 1 | package mockapi 2 | 3 | import "math/rand/v2" 4 | 5 | const lowercaseAlphanumericChars = "abcdefghijklmnopqrstuvwxyz0123456789" 6 | 7 | func randomLength(minLen, maxLen int) int { 8 | return rand.IntN(maxLen-minLen) + minLen //nolint:gosec 9 | } 10 | 11 | // ProjectID generates a random project ID. 12 | func ProjectID() string { 13 | return lowercaseAlphanumericID(randomLength(10, 15)) 14 | } 15 | 16 | // lowercaseAlphanumericID generates a random lowercase alphanumeric ID. 17 | func lowercaseAlphanumericID(length int) string { 18 | id := make([]byte, length) 19 | for i := range id { 20 | id[i] = lowercaseAlphanumericChars[rand.IntN(len(lowercaseAlphanumericChars))] //nolint:gosec 21 | } 22 | 23 | return string(id) 24 | } 25 | 26 | // NumericID generates a random numeric ID. 27 | func NumericID() string { 28 | length := randomLength(6, 10) 29 | id := make([]byte, length) 30 | for i := range id { 31 | id[i] = '0' + byte(rand.IntN(10)) //nolint:gosec 32 | } 33 | 34 | return string(id) 35 | } 36 | -------------------------------------------------------------------------------- /internal/config/alt/test-config.yaml: -------------------------------------------------------------------------------- 1 | # Test CLI configuration 2 | application: 3 | name: 'Platform Test CLI' 4 | slug: 'platform-test-cli' 5 | version: '1.0.0' 6 | executable: 'platform-test' 7 | env_prefix: 'TEST_CLI_' 8 | user_config_dir: '.platform-test-cli' 9 | 10 | service: 11 | name: 'Platform.sh Testing' 12 | env_prefix: 'PLATFORM_' 13 | project_config_dir: '.platform' 14 | console_url: 'https://console.cli-tests.example.com' 15 | 16 | api: 17 | # Placeholder URLs which can be replaced during tests. 18 | base_url: 'http://127.0.0.1' 19 | auth_url: 'http://127.0.0.1' 20 | 21 | disable_credential_helpers: true 22 | 23 | organizations: true 24 | centralized_permissions: true 25 | teams: true 26 | user_verification: true 27 | metrics: true 28 | 29 | vendor_filter: 'test-vendor' 30 | 31 | ssh: 32 | domain_wildcards: ['*.cli-tests.example.com'] 33 | 34 | detection: 35 | git_remote_name: 'platform-test' 36 | git_domain: 'git.cli-tests.example.com' 37 | site_domains: ['cli-tests.example.com'] 38 | cluster_header: 'X-Platform-Cluster' 39 | -------------------------------------------------------------------------------- /internal/api/organization.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "context" 5 | "time" 6 | ) 7 | 8 | const ( 9 | OrgTypeFlexible = "flexible" 10 | OrgTypeFixed = "fixed" 11 | ) 12 | 13 | type Organization struct { 14 | ID string `json:"id"` 15 | Type string `json:"type"` 16 | OwnerID string `json:"owner_id"` 17 | Namespace string `json:"namespace"` 18 | Name string `json:"name"` 19 | Label string `json:"label"` 20 | Country string `json:"country"` 21 | Vendor string `json:"vendor"` 22 | Capabilities []string `json:"capabilities"` 23 | Status string `json:"status"` 24 | CreatedAt time.Time `json:"created_at"` 25 | UpdatedAt time.Time `json:"updated_at"` 26 | Links HALLinks `json:"_links"` 27 | } 28 | 29 | // GetOrganization gets a single organization by ID. 30 | func (c *Client) GetOrganization(ctx context.Context, id string) (o *Organization, err error) { 31 | u, err := c.baseURLWithSegments("organizations", id) 32 | if err != nil { 33 | return 34 | } 35 | err = c.getResource(ctx, u.String(), &o) 36 | return 37 | } 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Platform.sh 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 all 13 | 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /internal/api/resource.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "net/http" 7 | ) 8 | 9 | // Resource is a generic API resource. 10 | type Resource interface { 11 | GetLink(name string) (string, bool) 12 | } 13 | 14 | // HALLinks represents the HAL links on a resource. 15 | // Most are an object containing a single "href", but some are arrays e.g. "curies". 16 | type HALLinks map[string]any 17 | 18 | // getResource fetches an API resource from a URL and decodes it into an interface. 19 | func (c *Client) getResource(ctx context.Context, urlStr string, r any) error { 20 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, urlStr, http.NoBody) 21 | if err != nil { 22 | return Error{Original: err, URL: urlStr} 23 | } 24 | resp, err := c.HTTPClient.Do(req) 25 | if err != nil { 26 | return Error{Original: err, URL: urlStr, Response: resp} 27 | } 28 | defer resp.Body.Close() 29 | if resp.StatusCode != 200 { 30 | return Error{Response: resp, URL: urlStr} 31 | } 32 | if err := json.NewDecoder(resp.Body).Decode(&r); err != nil { 33 | return Error{Original: err, Response: resp, URL: urlStr} 34 | } 35 | return nil 36 | } 37 | -------------------------------------------------------------------------------- /cmd/platform/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strings" 7 | 8 | "github.com/spf13/cobra" 9 | "github.com/spf13/viper" 10 | "github.com/symfony-cli/terminal" 11 | 12 | "github.com/platformsh/cli/commands" 13 | "github.com/platformsh/cli/internal/config" 14 | ) 15 | 16 | func main() { 17 | // Load configuration. 18 | cnfYAML, err := config.LoadYAML() 19 | if err != nil { 20 | fmt.Fprintln(os.Stderr, err) 21 | os.Exit(1) 22 | } 23 | cnf, err := config.FromYAML(cnfYAML) 24 | if err != nil { 25 | fmt.Fprintln(os.Stderr, err) 26 | os.Exit(1) 27 | } 28 | 29 | // When Cobra starts, load Viper config from the environment. 30 | cobra.OnInitialize(func() { 31 | viper.SetEnvPrefix(strings.TrimSuffix(cnf.Application.EnvPrefix, "_")) 32 | viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_")) 33 | viper.AutomaticEnv() 34 | 35 | if os.Getenv(cnf.Application.EnvPrefix+"NO_INTERACTION") == "1" { 36 | viper.Set("no-interaction", true) 37 | } 38 | if viper.GetBool("no-interaction") { 39 | terminal.Stdin.SetInteractive(false) 40 | } 41 | }) 42 | 43 | if err := commands.Execute(cnf); err != nil { 44 | os.Exit(1) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /internal/init/streaming/client.go: -------------------------------------------------------------------------------- 1 | package streaming 2 | 3 | import ( 4 | "bufio" 5 | "context" 6 | "encoding/json" 7 | "fmt" 8 | "net/http" 9 | ) 10 | 11 | func HandleResponse(ctx context.Context, resp *http.Response, msgChan chan<- Message) error { 12 | if resp.StatusCode != http.StatusOK { 13 | return fmt.Errorf("unexpected status code %d", resp.StatusCode) 14 | } 15 | // Note: Transfer-Encoding can't be checked like this as it is a hop-by-hop header. 16 | if ct := resp.Header.Get("Content-Type"); ct != "application/x-ndjson" { 17 | return fmt.Errorf("unexpected content type: %s", ct) 18 | } 19 | 20 | scanner := bufio.NewScanner(resp.Body) 21 | // Start with default buffer, expand to 1MB if needed for large lines 22 | scanner.Buffer(nil, 1024*1024) 23 | for scanner.Scan() { 24 | select { 25 | case <-ctx.Done(): 26 | return ctx.Err() 27 | default: 28 | } 29 | var msg Message 30 | if err := json.Unmarshal(scanner.Bytes(), &msg); err != nil { 31 | return fmt.Errorf("failed to decode line: %w: %s", err, string(scanner.Bytes())) 32 | } 33 | if msg.Type == MessageTypeKeepAlive { 34 | continue 35 | } else if msg.Type == MessageTypeDone { 36 | break 37 | } 38 | msgChan <- msg 39 | } 40 | 41 | return scanner.Err() 42 | } 43 | -------------------------------------------------------------------------------- /internal/md/syntax.go: -------------------------------------------------------------------------------- 1 | package md 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | type HeadingLevel int 9 | 10 | const ( 11 | L1 HeadingLevel = iota + 1 12 | L2 13 | L3 14 | L4 15 | L5 16 | L6 17 | ) 18 | 19 | func Heading(level HeadingLevel, s string) string { 20 | if s == "" { 21 | return "" 22 | } 23 | return strings.Repeat("#", int(level)) + " " + s 24 | } 25 | 26 | func Bold(s string) string { 27 | if s == "" { 28 | return "" 29 | } 30 | return "**" + s + "**" 31 | } 32 | 33 | func Italic(s string) string { 34 | if s == "" { 35 | return "" 36 | } 37 | return "*" + s + "*" 38 | } 39 | 40 | func UnorderedListItem(s string) string { 41 | if s == "" { 42 | return "" 43 | } 44 | return "* " + s 45 | } 46 | 47 | func Code(s string) string { 48 | if s == "" { 49 | return "" 50 | } 51 | return "`" + s + "`" 52 | } 53 | 54 | func CodeBlock(s string) string { 55 | if s == "" { 56 | return "" 57 | } 58 | return "```\n" + s + "\n```" 59 | } 60 | 61 | func Link(text, url string) string { 62 | if text == "" || url == "" { 63 | return "" 64 | } 65 | return fmt.Sprintf("[%s](%s)", text, url) 66 | } 67 | 68 | func Anchor(s string) string { 69 | if s == "" { 70 | return "" 71 | } 72 | return "#" + strings.ReplaceAll(s, ":", "") 73 | } 74 | -------------------------------------------------------------------------------- /commands/version.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/fatih/color" 7 | "github.com/spf13/cobra" 8 | "github.com/spf13/viper" 9 | 10 | "github.com/platformsh/cli/internal/config" 11 | "github.com/platformsh/cli/internal/legacy" 12 | ) 13 | 14 | func newVersionCommand(cnf *config.Config) *cobra.Command { 15 | return &cobra.Command{ 16 | Use: "version", 17 | Short: "Print the version number of the " + cnf.Application.Name, 18 | FParseErrWhitelist: cobra.FParseErrWhitelist{UnknownFlags: true}, 19 | Run: func(_ *cobra.Command, _ []string) { 20 | fmt.Fprintf(color.Output, "%s %s\n", cnf.Application.Name, color.CyanString(config.Version)) 21 | 22 | if viper.GetBool("verbose") { 23 | fmt.Fprintf( 24 | color.Output, 25 | "Embedded PHP version %s\n", 26 | color.CyanString(legacy.PHPVersion), 27 | ) 28 | fmt.Fprintf( 29 | color.Output, 30 | "Embedded Legacy CLI version %s\n", 31 | color.CyanString(legacy.LegacyCLIVersion), 32 | ) 33 | fmt.Fprintf( 34 | color.Output, 35 | "Commit %s (built %s by %s)\n", 36 | color.CyanString(config.Commit), 37 | color.CyanString(config.Date), 38 | color.CyanString(config.BuiltBy), 39 | ) 40 | } 41 | }, 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /internal/convert/testdata/platformsh/.platform/services.yaml: -------------------------------------------------------------------------------- 1 | sqldb: 2 | # (https://docs.platform.sh/configuration/services/mysql.html#supported-versions) 3 | type: mysql:10.5 4 | disk: 1024 5 | size: M 6 | 7 | timedb: 8 | # (https://docs.platform.sh/configuration/services/influxdb.html#supported-versions) 9 | type: influxdb:1.8 10 | disk: 1024 11 | 12 | searchelastic: 13 | # (https://docs.platform.sh/configuration/services/elasticsearch.html#supported-versions) 14 | type: elasticsearch:7.10 15 | size: AUTO 16 | disk: 9216 17 | resources: 18 | base_memory: 512 19 | memory_ratio: 512 20 | 21 | queuerabbit: 22 | # (https://docs.platform.sh/configuration/services/rabbitmq.html#supported-versions) 23 | type: rabbitmq:3.8 24 | # Canot be down size at 512Mo but consom 500Mo by default => Alerting (but why 512Mo limit ???) 25 | # https://docs.platform.sh/configuration/services/rabbitmq.html#example-configuration 26 | # https://www.rabbitmq.com/quorum-queues.html#resource-use 27 | disk: 1024 28 | 29 | headlessbrowser: 30 | # (https://docs.platform.sh/configuration/services/headless-chrome.html#supported-versions) 31 | type: chrome-headless:91 32 | #size: 4XL 33 | resources: 34 | base_memory: 512 35 | memory_ratio: 512 -------------------------------------------------------------------------------- /internal/auth/jwt.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "encoding/base64" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "strings" 9 | "time" 10 | ) 11 | 12 | // unsafeGetJWTExpiry parses a JWT without verifying its signature and returns its expiry time. 13 | // WARNING: This is intentionally unsafe and must not be used for trust decisions. 14 | func unsafeGetJWTExpiry(token string) (time.Time, error) { 15 | if token == "" { 16 | return time.Time{}, errors.New("jwt: empty token") 17 | } 18 | parts := strings.Split(token, ".") 19 | if len(parts) < 2 { 20 | return time.Time{}, fmt.Errorf("jwt: malformed token, expected 3 parts, got %d", len(parts)) 21 | } 22 | payloadSeg := parts[1] 23 | 24 | // Base64 URL decode without padding as per RFC 7515. 25 | payloadBytes, err := base64.RawURLEncoding.DecodeString(payloadSeg) 26 | if err != nil { 27 | return time.Time{}, fmt.Errorf("jwt: decode payload: %w", err) 28 | } 29 | 30 | var claims struct { 31 | ExpiresAt *int64 `json:"exp,omitempty"` 32 | } 33 | if err := json.Unmarshal(payloadBytes, &claims); err != nil { 34 | return time.Time{}, fmt.Errorf("jwt: unmarshal claims: %w", err) 35 | } 36 | 37 | if claims.ExpiresAt == nil { 38 | return time.Time{}, errors.New("jwt: no expiry time found") 39 | } 40 | 41 | return time.Unix(*claims.ExpiresAt, 0), nil 42 | } 43 | -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | stages: 2 | - test 3 | 4 | image: ${CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX}/cimg/go:1.25 5 | 6 | workflow: 7 | auto_cancel: 8 | on_new_commit: interruptible 9 | 10 | .go-setup: 11 | interruptible: true 12 | cache: 13 | key: 14 | files: 15 | - go.sum 16 | paths: 17 | - ${GOMODCACHE}/ 18 | variables: 19 | GOMODCACHE: $CI_PROJECT_DIR/.gomodcache 20 | before_script: 21 | - mkdir -p ${GOMODCACHE} 22 | 23 | # Create fake embedded files for lint and test purposes: 24 | - mkdir -p internal/legacy/archives 25 | - touch internal/legacy/archives/platform.phar 26 | - touch internal/legacy/archives/php_windows_amd64 27 | - touch internal/legacy/archives/php_linux_amd64 28 | - touch internal/legacy/archives/php_linux_arm64 29 | - touch internal/legacy/archives/php_darwin_amd64 30 | - touch internal/legacy/archives/php_darwin_arm64 31 | - touch internal/config/embedded-config.yaml 32 | 33 | test: 34 | stage: test 35 | extends: .go-setup 36 | script: 37 | - go test -v -race -cover ./... 38 | coverage: '/total:\s+\(statements\)\s+\d+.\d+%/' 39 | 40 | golangci-lint: 41 | stage: test 42 | extends: .go-setup 43 | image: ${CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX}/golangci/golangci-lint:v2.4 44 | script: golangci-lint run --timeout 0 --verbose 45 | -------------------------------------------------------------------------------- /internal/auth/client.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | 8 | "golang.org/x/oauth2" 9 | 10 | "github.com/platformsh/cli/internal/legacy" 11 | ) 12 | 13 | type LegacyCLIClient struct { 14 | HTTPClient *http.Client 15 | tokenSource oauth2.TokenSource 16 | } 17 | 18 | func (c *LegacyCLIClient) EnsureAuthenticated(_ context.Context) error { 19 | _, err := c.tokenSource.Token() 20 | return err 21 | } 22 | 23 | // NewLegacyCLIClient creates an HTTP client authenticated through the legacy CLI. 24 | // The wrapper argument must be a dedicated wrapper, not used by other callers. 25 | func NewLegacyCLIClient(ctx context.Context, wrapper *legacy.CLIWrapper) (*LegacyCLIClient, error) { 26 | ts, err := NewLegacyCLITokenSource(ctx, wrapper) 27 | if err != nil { 28 | return nil, fmt.Errorf("oauth2: create token source: %w", err) 29 | } 30 | 31 | refresher, ok := ts.(refresher) 32 | if !ok { 33 | return nil, fmt.Errorf("token source does not implement refresher") 34 | } 35 | baseRT := http.DefaultTransport 36 | if rt, ok := TransportFromContext(ctx); ok && rt != nil { 37 | baseRT = rt 38 | } 39 | 40 | httpClient := &http.Client{ 41 | Transport: &Transport{ 42 | refresher: refresher, 43 | base: &oauth2.Transport{ 44 | Source: ts, 45 | Base: baseRT, 46 | }, 47 | }, 48 | } 49 | 50 | return &LegacyCLIClient{ 51 | HTTPClient: httpClient, 52 | tokenSource: ts, 53 | }, nil 54 | } 55 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | test: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - name: Check out repository code 18 | uses: actions/checkout@v4 19 | 20 | - name: Setup Go 21 | uses: actions/setup-go@v6 22 | with: 23 | go-version-file: ./go.mod 24 | 25 | - name: Create fake PHP and .phar files 26 | run: | 27 | # These are needed so that the linter does not complain 28 | mkdir -p internal/legacy/archives 29 | touch internal/legacy/archives/platform.phar 30 | touch internal/legacy/archives/php_windows_amd64 31 | touch internal/legacy/archives/php_linux_amd64 32 | touch internal/legacy/archives/php_linux_arm64 33 | touch internal/legacy/archives/php_darwin_amd64 34 | touch internal/legacy/archives/php_darwin_arm64 35 | touch internal/config/embedded-config.yaml 36 | 37 | - name: Run lint-gomod 38 | run: make lint-gomod 39 | 40 | - name: Run golangci-lint 41 | uses: golangci/golangci-lint-action@v8 42 | with: 43 | version: v2.4 44 | args: --timeout=5m 45 | 46 | - name: Run tests 47 | run: make test 48 | 49 | - name: Check goreleaser config 50 | run: make goreleaser-check 51 | -------------------------------------------------------------------------------- /commands/completion.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "path/filepath" 7 | "strings" 8 | 9 | "github.com/spf13/cobra" 10 | 11 | "github.com/platformsh/cli/internal/config" 12 | ) 13 | 14 | func newCompletionCommand(cnf *config.Config) *cobra.Command { 15 | return &cobra.Command{ 16 | Use: "completion", 17 | Short: "Print the completion script for your shell", 18 | Args: cobra.MaximumNArgs(1), 19 | SilenceErrors: true, 20 | Run: func(cmd *cobra.Command, args []string) { 21 | completionArgs := []string{"_completion", "-g", "--program", cnf.Application.Executable} 22 | if len(args) > 0 { 23 | completionArgs = append(completionArgs, "--shell-type", args[0]) 24 | } 25 | var b bytes.Buffer 26 | c := makeLegacyCLIWrapper(cnf, &b, cmd.ErrOrStderr(), cmd.InOrStdin()) 27 | 28 | if err := c.Exec(cmd.Context(), completionArgs...); err != nil { 29 | exitWithError(err) 30 | } 31 | 32 | pharPath, err := c.PharPath() 33 | if err != nil { 34 | exitWithError(err) 35 | } 36 | 37 | completions := strings.ReplaceAll( 38 | strings.ReplaceAll( 39 | b.String(), 40 | pharPath, 41 | cnf.Application.Executable, 42 | ), 43 | filepath.Base(pharPath), 44 | cnf.Application.Executable, 45 | ) 46 | fmt.Fprintln(cmd.OutOrStdout(), "#compdef "+cnf.Application.Executable) 47 | fmt.Fprintln(cmd.OutOrStdout(), completions) 48 | }, 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /internal/state/state.go: -------------------------------------------------------------------------------- 1 | package state 2 | 3 | import ( 4 | "encoding/json" 5 | "os" 6 | "path/filepath" 7 | 8 | "github.com/platformsh/cli/internal/config" 9 | ) 10 | 11 | type State struct { 12 | Updates struct { 13 | LastChecked int64 `json:"last_checked"` 14 | } `json:"updates,omitempty"` 15 | 16 | ConfigUpdates struct { 17 | LastChecked int64 `json:"last_checked"` 18 | } `json:"config_updates,omitempty"` 19 | } 20 | 21 | // Load reads state from the filesystem. 22 | func Load(cnf *config.Config) (state State, err error) { 23 | statePath, err := getPath(cnf) 24 | if err != nil { 25 | return 26 | } 27 | data, err := os.ReadFile(statePath) 28 | if err != nil { 29 | if os.IsNotExist(err) { 30 | err = nil 31 | } 32 | return 33 | } 34 | err = json.Unmarshal(data, &state) 35 | return 36 | } 37 | 38 | // Save writes state to the filesystem. 39 | func Save(state State, cnf *config.Config) error { 40 | statePath, err := getPath(cnf) 41 | if err != nil { 42 | return err 43 | } 44 | 45 | data, err := json.Marshal(state) 46 | if err != nil { 47 | return err 48 | } 49 | 50 | return os.WriteFile(statePath, data, 0o600) 51 | } 52 | 53 | // getPath determines the path to the state JSON file depending on config. 54 | func getPath(cnf *config.Config) (string, error) { 55 | writableDir, err := cnf.WritableUserDir() 56 | if err != nil { 57 | return "", err 58 | } 59 | 60 | return filepath.Join(writableDir, cnf.Application.UserStateFile), nil 61 | } 62 | -------------------------------------------------------------------------------- /internal/md/builder.go: -------------------------------------------------------------------------------- 1 | package md 2 | 3 | import ( 4 | "strings" 5 | ) 6 | 7 | type Builder struct { 8 | buff *strings.Builder 9 | } 10 | 11 | func NewBuilder() *Builder { 12 | return &Builder{ 13 | buff: new(strings.Builder), 14 | } 15 | } 16 | 17 | func (b *Builder) String() string { 18 | return b.buff.String() 19 | } 20 | 21 | func (b *Builder) H1(s string) *Builder { 22 | return b.heading(L1, s) 23 | } 24 | 25 | func (b *Builder) H2(s string) *Builder { 26 | return b.heading(L2, s) 27 | } 28 | 29 | func (b *Builder) H3(s string) *Builder { 30 | return b.heading(L3, s) 31 | } 32 | 33 | func (b *Builder) H4(s string) *Builder { 34 | return b.heading(L4, s) 35 | } 36 | 37 | func (b *Builder) H5(s string) *Builder { 38 | return b.heading(L5, s) 39 | } 40 | 41 | func (b *Builder) H6(s string) *Builder { 42 | return b.heading(L6, s) 43 | } 44 | 45 | func (b *Builder) Paragraph(s string) *Builder { 46 | return b.write(s) 47 | } 48 | 49 | func (b *Builder) Ln() *Builder { 50 | return b.ln() 51 | } 52 | 53 | func (b *Builder) CodeBlock(s string) *Builder { 54 | return b.write(CodeBlock(s)) 55 | } 56 | 57 | func (b *Builder) ListItem(s string) *Builder { 58 | return b.write(UnorderedListItem(s)) 59 | } 60 | 61 | func (b *Builder) heading(level HeadingLevel, s string) *Builder { 62 | return b.write(Heading(level, s)).ln() 63 | } 64 | 65 | func (b *Builder) write(s string) *Builder { 66 | if s == "" { 67 | return b 68 | } 69 | b.buff.WriteString(s) 70 | return b.ln() 71 | } 72 | 73 | func (b *Builder) ln() *Builder { 74 | b.buff.WriteString("\n") 75 | return b 76 | } 77 | -------------------------------------------------------------------------------- /internal/convert/testdata/platformsh/.platform.app.yaml: -------------------------------------------------------------------------------- 1 | name: app 2 | 3 | # Runtime pre-install 4 | type: 'php:8.2' 5 | 6 | # Disk for App 7 | disk: 2048 8 | 9 | # Flexible Ressources 10 | resources: 11 | base_memory: 1024 12 | memory_ratio: 1024 13 | 14 | dependencies: 15 | php: 16 | composer/composer: "^2" 17 | 18 | # vHost config 19 | web: 20 | locations: 21 | "/": 22 | root: "public" 23 | passthru: "/index.php" 24 | allow: true 25 | scripts: true 26 | 27 | relationships: 28 | database: "mysql:mysql" 29 | 30 | variables: 31 | env: 32 | CI_ENVIRONMENT: "production" 33 | 34 | # RW fs !! 35 | mounts: 36 | "writable/cache": 37 | source: local 38 | source_path: "writable/cache" 39 | "writable/debugbar": { source: local, source_path: "writable/debugbar"} 40 | "writable/logs": 41 | source: local 42 | source_path: "writable/logs" 43 | "writable/session": 44 | source: local 45 | source_path: "writable/session" 46 | "writable/upload": 47 | source: local 48 | source_path: "writable/upload" 49 | "config": 50 | source: local 51 | source_path: "config" 52 | 53 | # Custom commands 54 | hooks: 55 | build: | 56 | set -e 57 | composer install --no-dev --optimize-autoloader 58 | deploy: | 59 | set -e 60 | php generate_env.php 61 | 62 | source: 63 | operations: 64 | auto-update: 65 | command: | 66 | curl -fsS https://raw.githubusercontent.com/platformsh/source-operations/main/setup.sh | { bash /dev/fd/3 sop-autoupdate; } 3<&0 67 | 68 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | linters: 3 | default: none 4 | enable: 5 | - bodyclose 6 | - dogsled 7 | - errcheck 8 | - goconst 9 | - gocritic 10 | - goprintffuncname 11 | - gosec 12 | - govet 13 | - ineffassign 14 | - lll 15 | - misspell 16 | - nakedret 17 | - nolintlint 18 | - prealloc 19 | - predeclared 20 | - revive 21 | - staticcheck 22 | - unconvert 23 | - unused 24 | - whitespace 25 | settings: 26 | errcheck: 27 | check-type-assertions: true 28 | goconst: 29 | min-occurrences: 5 30 | gocritic: 31 | disabled-checks: 32 | - whyNoLint 33 | - emptyStringTest 34 | enabled-tags: 35 | - diagnostic 36 | - experimental 37 | - opinionated 38 | - performance 39 | - style 40 | lll: 41 | line-length: 120 42 | misspell: 43 | locale: US 44 | nolintlint: 45 | require-specific: true 46 | allow-unused: false 47 | prealloc: 48 | for-loops: true 49 | exclusions: 50 | generated: lax 51 | presets: 52 | - comments 53 | - common-false-positives 54 | - legacy 55 | - std-error-handling 56 | paths: 57 | - vendor 58 | - third_party$ 59 | - builtin$ 60 | - examples$ 61 | formatters: 62 | enable: 63 | - gofmt 64 | - goimports 65 | settings: 66 | goimports: 67 | local-prefixes: 68 | - github.com/platformsh/cli 69 | exclusions: 70 | generated: lax 71 | paths: 72 | - vendor 73 | - third_party$ 74 | - builtin$ 75 | - examples$ 76 | -------------------------------------------------------------------------------- /internal/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | 8 | "gopkg.in/yaml.v3" 9 | ) 10 | 11 | // LoadYAML reads the configuration file from the environment if specified, falling back to the embedded file. 12 | func LoadYAML() ([]byte, error) { 13 | if path := os.Getenv("CLI_CONFIG_FILE"); path != "" { 14 | b, err := os.ReadFile(path) 15 | if err != nil { 16 | return nil, fmt.Errorf("failed to load config: %w", err) 17 | } 18 | return b, nil 19 | } 20 | return embedded, nil 21 | } 22 | 23 | // FromYAML parses YAML configuration. 24 | func FromYAML(b []byte) (*Config, error) { 25 | c := &Config{} 26 | c.applyDefaults() 27 | if err := yaml.Unmarshal(b, c); err != nil { 28 | return nil, fmt.Errorf("invalid config YAML: %w", err) 29 | } 30 | if err := getValidator().Struct(c); err != nil { 31 | return nil, fmt.Errorf("invalid config: %w", err) 32 | } 33 | c.applyDynamicDefaults() 34 | c.raw = b 35 | return c, nil 36 | } 37 | 38 | type contextKey struct{} 39 | 40 | // ToContext adds configuration to a context so it can be later fetched using FromContext. 41 | func ToContext(ctx context.Context, cnf *Config) context.Context { 42 | return context.WithValue(ctx, contextKey{}, cnf) 43 | } 44 | 45 | // FromContext loads configuration that was set using ToContext, and panics if it is not set. 46 | func FromContext(ctx context.Context) *Config { 47 | v, ok := ctx.Value(contextKey{}).(*Config) 48 | if !ok { 49 | panic("Config not set or wrong format") 50 | } 51 | return v 52 | } 53 | 54 | func MaybeFromContext(ctx context.Context) (*Config, bool) { 55 | v, ok := ctx.Value(contextKey{}).(*Config) 56 | return v, ok 57 | } 58 | -------------------------------------------------------------------------------- /internal/file/file.go: -------------------------------------------------------------------------------- 1 | package file 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "io" 7 | "io/fs" 8 | "os" 9 | ) 10 | 11 | // WriteIfNeeded writes data to a destination file, only if the file does not exist or if it was partially written. 12 | // To save time, it only checks that the file size is correct, and then matches the end of its contents (up to 32KB). 13 | func WriteIfNeeded(destFilename string, source []byte, perm os.FileMode) error { 14 | matches, err := probablyMatches(destFilename, source, 32*1024) 15 | if err != nil || matches { 16 | return err 17 | } 18 | return Write(destFilename, source, perm) 19 | } 20 | 21 | // Write creates or overwrites a file, somewhat atomically, using a temporary file next to it. 22 | func Write(path string, content []byte, fileMode fs.FileMode) error { 23 | tmpFile := path + ".tmp" 24 | if err := os.WriteFile(tmpFile, content, fileMode); err != nil { 25 | return err 26 | } 27 | 28 | return os.Rename(tmpFile, path) 29 | } 30 | 31 | // probablyMatches checks if a file exists and matches the end of source data (up to checkSize bytes). 32 | func probablyMatches(filename string, data []byte, checkSize int) (bool, error) { 33 | f, err := os.Open(filename) 34 | if err != nil { 35 | if errors.Is(err, fs.ErrNotExist) { 36 | return false, nil 37 | } 38 | return false, err 39 | } 40 | defer f.Close() 41 | 42 | fi, err := f.Stat() 43 | if err != nil { 44 | return false, err 45 | } 46 | if fi.Size() != int64(len(data)) { 47 | return false, nil 48 | } 49 | 50 | buf := make([]byte, min(checkSize, len(data))) 51 | offset := max(0, len(data)-checkSize) 52 | n, err := f.ReadAt(buf, int64(offset)) 53 | if err != nil && err != io.EOF { 54 | return false, err 55 | } 56 | 57 | return bytes.Equal(data[offset:], buf[:n]), nil 58 | } 59 | -------------------------------------------------------------------------------- /cloudsmith.sh: -------------------------------------------------------------------------------- 1 | # Upload Platform.sh packages 2 | cloudsmith push deb platformsh/cli/any-distro/any-version dist/platformsh-cli_${VERSION}_linux_arm64.deb 3 | cloudsmith push deb platformsh/cli/any-distro/any-version dist/platformsh-cli_${VERSION}_linux_amd64.deb 4 | 5 | cloudsmith push alpine platformsh/cli/alpine/any-version dist/platformsh-cli_${VERSION}_linux_amd64.apk 6 | cloudsmith push alpine platformsh/cli/alpine/any-version dist/platformsh-cli_${VERSION}_linux_arm64.apk 7 | 8 | cloudsmith push rpm platformsh/cli/any-distro/any-version dist/platformsh-cli_${VERSION}_linux_amd64.rpm 9 | cloudsmith push rpm platformsh/cli/any-distro/any-version dist/platformsh-cli_${VERSION}_linux_arm64.rpm 10 | 11 | cloudsmith push raw platformsh/cli dist/platform_${VERSION}_linux_amd64.tar.gz --version ${VERSION} 12 | cloudsmith push raw platformsh/cli dist/platform_${VERSION}_linux_arm64.tar.gz --version ${VERSION} 13 | 14 | # Upload Upsun packages 15 | cloudsmith push deb platformsh/upsun-cli/any-distro/any-version dist/upsun-cli_${VERSION}_linux_arm64.deb 16 | cloudsmith push deb platformsh/upsun-cli/any-distro/any-version dist/upsun-cli_${VERSION}_linux_amd64.deb 17 | 18 | cloudsmith push alpine platformsh/upsun-cli/alpine/any-version dist/upsun-cli_${VERSION}_linux_amd64.apk 19 | cloudsmith push alpine platformsh/upsun-cli/alpine/any-version dist/upsun-cli_${VERSION}_linux_arm64.apk 20 | 21 | cloudsmith push rpm platformsh/upsun-cli/any-distro/any-version dist/upsun-cli_${VERSION}_linux_amd64.rpm 22 | cloudsmith push rpm platformsh/upsun-cli/any-distro/any-version dist/upsun-cli_${VERSION}_linux_arm64.rpm 23 | 24 | cloudsmith push raw platformsh/upsun-cli dist/upsun_${VERSION}_linux_amd64.tar.gz --version ${VERSION} 25 | cloudsmith push raw platformsh/upsun-cli dist/upsun_${VERSION}_linux_arm64.tar.gz --version ${VERSION} 26 | -------------------------------------------------------------------------------- /internal/legacy/legacy_test.go: -------------------------------------------------------------------------------- 1 | package legacy 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "io" 7 | "os" 8 | "path/filepath" 9 | "strings" 10 | "testing" 11 | 12 | "github.com/stretchr/testify/assert" 13 | "github.com/stretchr/testify/require" 14 | 15 | "github.com/platformsh/cli/internal/config" 16 | ) 17 | 18 | func TestLegacyCLI(t *testing.T) { 19 | if len(phar) == 0 || len(phpCLI) == 0 { 20 | t.Skip() 21 | } 22 | 23 | cnf := &config.Config{} 24 | cnf.Application.Name = "Test CLI" 25 | cnf.Application.Executable = "platform-test" 26 | cnf.Application.Slug = "test-cli" 27 | cnf.Application.EnvPrefix = "TEST_CLI_" 28 | cnf.Application.TempSubDir = "temp_sub_dir" 29 | 30 | tempDir := t.TempDir() 31 | 32 | _ = os.Setenv(cnf.Application.EnvPrefix+"TMP", tempDir) 33 | t.Cleanup(func() { 34 | _ = os.Unsetenv(cnf.Application.EnvPrefix + "TMP") 35 | }) 36 | 37 | stdout := &bytes.Buffer{} 38 | stdErr := io.Discard 39 | if testing.Verbose() { 40 | stdErr = os.Stderr 41 | } 42 | 43 | testCLIVersion := "1.2.3" 44 | 45 | wrapper := &CLIWrapper{ 46 | Stdout: stdout, 47 | Stderr: stdErr, 48 | Config: cnf, 49 | Version: testCLIVersion, 50 | DisableInteraction: true, 51 | } 52 | if testing.Verbose() { 53 | wrapper.DebugLogFunc = t.Logf 54 | } 55 | PHPVersion = "6.5.4" 56 | LegacyCLIVersion = "3.2.1" 57 | 58 | err := wrapper.Exec(context.Background(), "help") 59 | assert.NoError(t, err) 60 | assert.Contains(t, stdout.String(), "Displays help for a command") 61 | 62 | cacheDir, err := wrapper.cacheDir() 63 | require.NoError(t, err) 64 | 65 | pharPath, err := wrapper.PharPath() 66 | require.NoError(t, err) 67 | 68 | assert.Equal(t, filepath.Join(cacheDir, "platform-test.phar"), pharPath) 69 | 70 | stdout.Reset() 71 | err = wrapper.Exec(context.Background(), "--version") 72 | assert.NoError(t, err) 73 | assert.Equal(t, "Test CLI "+testCLIVersion, strings.TrimSuffix(stdout.String(), "\n")) 74 | } 75 | -------------------------------------------------------------------------------- /internal/config/alt/alt_test.go: -------------------------------------------------------------------------------- 1 | package alt_test 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "runtime" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | "gopkg.in/yaml.v3" 12 | 13 | "github.com/platformsh/cli/internal/config/alt" 14 | ) 15 | 16 | func TestAlt(t *testing.T) { 17 | if runtime.GOOS == "windows" { 18 | t.Skip() 19 | } 20 | tempDir := t.TempDir() 21 | 22 | configNode := &yaml.Node{} 23 | require.NoError(t, yaml.Unmarshal(testConfig, configNode)) 24 | 25 | binPath := filepath.Join(tempDir, "bin", "test-binary") 26 | configPath := filepath.Join(tempDir, "config", "config.yaml") 27 | 28 | a := alt.New( 29 | binPath, 30 | "Generated for test", 31 | "example-target", 32 | configPath, 33 | configNode, 34 | ) 35 | assert.NoError(t, a.GenerateAndSave()) 36 | 37 | assert.FileExists(t, binPath) 38 | assert.FileExists(t, configPath) 39 | 40 | binContent, err := os.ReadFile(binPath) 41 | assert.NoError(t, err) 42 | assert.Equal(t, `#!/bin/sh 43 | # Generated for test 44 | export CLI_CONFIG_FILE="`+configPath+`" 45 | example-target "$@" 46 | `, string(binContent)) 47 | 48 | require.NoError(t, os.Setenv("XDG_CONFIG_HOME", tempDir+"/config")) 49 | defer os.Unsetenv("XDG_CONFIG_HOME") 50 | 51 | assert.NoError(t, a.GenerateAndSave()) 52 | binContent, err = os.ReadFile(binPath) 53 | assert.NoError(t, err) 54 | assert.Equal(t, `#!/bin/sh 55 | # Generated for test 56 | export CLI_CONFIG_FILE="${XDG_CONFIG_HOME}/config.yaml" 57 | example-target "$@" 58 | `, string(binContent)) 59 | 60 | _ = os.Unsetenv("XDG_CONFIG_HOME") 61 | require.NoError(t, os.Setenv("HOME", tempDir)) 62 | defer os.Unsetenv("HOME") 63 | 64 | assert.NoError(t, a.GenerateAndSave()) 65 | binContent, err = os.ReadFile(binPath) 66 | assert.NoError(t, err) 67 | assert.Equal(t, `#!/bin/sh 68 | # Generated for test 69 | export CLI_CONFIG_FILE="${HOME}/config/config.yaml" 70 | example-target "$@" 71 | `, string(binContent)) 72 | } 73 | -------------------------------------------------------------------------------- /internal/config/config_test.go: -------------------------------------------------------------------------------- 1 | package config_test 2 | 3 | import ( 4 | _ "embed" 5 | "os" 6 | "path/filepath" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | 12 | "github.com/platformsh/cli/internal/config" 13 | ) 14 | 15 | //go:embed test-data/valid-config.yaml 16 | var validConfig string 17 | 18 | func TestFromYAML(t *testing.T) { 19 | t.Run("missing_values", func(t *testing.T) { 20 | _, err := config.FromYAML([]byte(`application: {name: Test CLI}`)) 21 | assert.Error(t, err) 22 | assert.Contains(t, err.Error(), `Error:Field validation for 'EnvPrefix' failed on the 'required' tag`) 23 | }) 24 | 25 | t.Run("complete", func(t *testing.T) { 26 | cnf, err := config.FromYAML([]byte(validConfig)) 27 | assert.NoError(t, err) 28 | 29 | tempDir := t.TempDir() 30 | require.NoError(t, os.Setenv(cnf.Application.EnvPrefix+"HOME", tempDir)) 31 | require.NoError(t, os.Setenv(cnf.Application.EnvPrefix+"TMP", filepath.Join(tempDir, "tmp"))) 32 | t.Cleanup(func() { 33 | _ = os.Unsetenv(cnf.Application.EnvPrefix + "HOME") 34 | _ = os.Unsetenv(cnf.Application.EnvPrefix + "TMP") 35 | }) 36 | 37 | // Test defaults 38 | assert.Equal(t, "state.json", cnf.Application.UserStateFile) 39 | assert.Equal(t, true, cnf.Updates.Check) 40 | assert.Equal(t, 3600, cnf.Updates.CheckInterval) 41 | assert.Equal(t, cnf.Application.UserConfigDir, cnf.Application.WritableUserDir) 42 | assert.Equal(t, "example-cli-tmp", cnf.Application.TempSubDir) 43 | assert.Equal(t, "platform", cnf.Service.ProjectConfigFlavor) 44 | 45 | homeDir, err := cnf.HomeDir() 46 | require.NoError(t, err) 47 | assert.Equal(t, tempDir, homeDir) 48 | 49 | writableDir, err := cnf.WritableUserDir() 50 | assert.NoError(t, err) 51 | assert.Equal(t, filepath.Join(homeDir, cnf.Application.WritableUserDir), writableDir) 52 | 53 | d, err := cnf.TempDir() 54 | assert.NoError(t, err) 55 | assert.Equal(t, filepath.Join(tempDir, "tmp", cnf.Application.TempSubDir), d) 56 | }) 57 | } 58 | -------------------------------------------------------------------------------- /pkg/mockapi/users.go: -------------------------------------------------------------------------------- 1 | package mockapi 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | "strings" 7 | 8 | "github.com/go-chi/chi/v5" 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | func (h *Handler) handleUsersMe(w http.ResponseWriter, _ *http.Request) { 13 | _ = json.NewEncoder(w).Encode(h.myUser) 14 | } 15 | 16 | func (h *Handler) handleUserRefs(w http.ResponseWriter, req *http.Request) { 17 | require.NoError(h.t, req.ParseForm()) 18 | ids := strings.Split(req.Form.Get("in"), ",") 19 | userRefs := make(map[string]UserRef, len(ids)) 20 | for _, id := range ids { 21 | userRefs[id] = UserRef{ID: id, Email: id + "@example.com", Username: id, FirstName: "User", LastName: id} 22 | } 23 | _ = json.NewEncoder(w).Encode(userRefs) 24 | } 25 | 26 | func (h *Handler) handleUserExtendedAccess(w http.ResponseWriter, req *http.Request) { 27 | h.RLock() 28 | defer h.RUnlock() 29 | userID := chi.URLParam(req, "user_id") 30 | require.NoError(h.t, req.ParseForm()) 31 | require.Equal(h.t, "project", req.Form.Get("filter[resource_type]")) 32 | var ( 33 | projectGrants = make([]*UserGrant, 0, len(h.userGrants)) 34 | projectIDs = make(uniqueMap) 35 | orgIDs = make(uniqueMap) 36 | ) 37 | for _, g := range h.userGrants { 38 | if g.ResourceType == "project" && g.UserID == userID { 39 | projectGrants = append(projectGrants, g) 40 | projectIDs[g.ResourceID] = struct{}{} 41 | orgIDs[g.OrganizationID] = struct{}{} 42 | } 43 | } 44 | ret := struct { 45 | Items []*UserGrant `json:"items"` 46 | Links HalLinks `json:"_links"` 47 | }{Items: projectGrants, Links: MakeHALLinks( 48 | "ref:projects:0=/ref/projects?in="+strings.Join(projectIDs.keys(), ","), 49 | "ref:organizations:0=/ref/organizations?in="+strings.Join(orgIDs.keys(), ","), 50 | )} 51 | _ = json.NewEncoder(w).Encode(ret) 52 | } 53 | 54 | type uniqueMap map[string]struct{} 55 | 56 | func (m uniqueMap) keys() []string { 57 | r := make([]string, 0, len(m)) 58 | for k := range m { 59 | r = append(r, k) 60 | } 61 | return r 62 | } 63 | -------------------------------------------------------------------------------- /internal/init/format.go: -------------------------------------------------------------------------------- 1 | package init 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "slices" 7 | "strings" 8 | "time" 9 | 10 | "github.com/briandowns/spinner" 11 | "github.com/fatih/color" 12 | 13 | "github.com/platformsh/cli/internal/init/streaming" 14 | ) 15 | 16 | var ( 17 | defaultMessageColor = "green" 18 | defaultColorFunc = color.GreenString 19 | levelColors = map[string]string{ 20 | streaming.LogLevelDebug: "cyan", 21 | streaming.LogLevelInfo: defaultMessageColor, 22 | streaming.LogLevelWarn: "yellow", 23 | streaming.LogLevelError: "red", 24 | } 25 | colorFuncs = map[string]func(string, ...any) string{ 26 | "blue": color.BlueString, 27 | "cyan": color.CyanString, 28 | "green": color.GreenString, 29 | "red": color.RedString, 30 | "white": color.WhiteString, 31 | "yellow": color.YellowString, 32 | } 33 | ) 34 | 35 | func levelColor(level string) string { 36 | if c, ok := levelColors[level]; ok { 37 | return c 38 | } 39 | return defaultMessageColor 40 | } 41 | 42 | func colorFunc(name string) func(string, ...any) string { 43 | if fn, ok := colorFuncs[name]; ok { 44 | return fn 45 | } 46 | return defaultColorFunc 47 | } 48 | 49 | func defaultSpinner(w io.Writer) *spinner.Spinner { 50 | return spinner.New(spinner.CharSets[23], 80*time.Millisecond, spinner.WithWriter(w)) 51 | } 52 | 53 | func printWithSpinner(spinr *spinner.Spinner, colorName, format string, args ...any) { 54 | _ = spinr.Color(colorName) 55 | spinr.Suffix = " " + colorFunc(colorName)(format, args...) 56 | spinr.Start() 57 | time.Sleep(time.Millisecond * 300) 58 | } 59 | 60 | type logPrinter struct { 61 | spinr *spinner.Spinner 62 | stderr io.Writer 63 | } 64 | 65 | // print a log message based on a streaming.Level constant, and tags such as "spin". 66 | func (p *logPrinter) print(level, msg string, tags ...string) { 67 | lc := levelColor(level) 68 | 69 | if slices.Contains(tags, "spin") { 70 | printWithSpinner(p.spinr, lc, msg) 71 | return 72 | } 73 | 74 | formatter := colorFunc(lc) 75 | fmt.Fprint(p.stderr, formatter(strings.TrimRight(msg, "\n")+"\n")) 76 | } 77 | -------------------------------------------------------------------------------- /internal/api/client.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "net/http/httputil" 7 | "net/url" 8 | "path" 9 | "strings" 10 | ) 11 | 12 | // Client is an API client. 13 | type Client struct { 14 | BaseURL *url.URL // The API base URL. 15 | HTTPClient *http.Client // An HTTP client which handles authentication. 16 | } 17 | 18 | // NewClient instantiates an API client using the passed absolute base URL. 19 | func NewClient(baseURL string, httpClient *http.Client) (*Client, error) { 20 | parsedBaseURL, err := url.Parse(baseURL) 21 | if err != nil { 22 | return nil, err 23 | } 24 | if parsedBaseURL.Host == "" { 25 | return nil, fmt.Errorf("invalid base URL (missing host): %s", baseURL) 26 | } 27 | return &Client{ 28 | BaseURL: parsedBaseURL, 29 | HTTPClient: httpClient, 30 | }, nil 31 | } 32 | 33 | // Error is an error returned from an API call, allowing access to the response. 34 | type Error struct { 35 | Original error 36 | URL string 37 | Response *http.Response 38 | } 39 | 40 | func (e Error) Error() string { 41 | var msg string 42 | switch { 43 | case e.Original != nil: 44 | msg = fmt.Sprintf("API error: %s: %s", e.Original.Error(), e.URL) 45 | case e.Response != nil: 46 | msg = fmt.Sprintf("API error: %s: %s", e.Response.Status, e.URL) 47 | default: 48 | msg = fmt.Sprintf("API error: %s", e.URL) 49 | } 50 | if e.Response != nil && e.Response.StatusCode != http.StatusNotFound { 51 | defer e.Response.Body.Close() 52 | d, _ := httputil.DumpResponse(e.Response, false) 53 | msg += "\n\nFull response:\n\n" + strings.TrimSpace(string(d)) + "\n" 54 | } 55 | return msg 56 | } 57 | 58 | // resolveURL adds path segments to the client's configured base URL, escaping each segment. 59 | func (c *Client) baseURLWithSegments(segments ...string) (*url.URL, error) { 60 | var relativeURL string 61 | for _, p := range segments { 62 | relativeURL = path.Join(relativeURL, url.PathEscape(p)) 63 | } 64 | return c.resolveURL(relativeURL) 65 | } 66 | 67 | // resolveURL resolves a relative URL according to the client's configured base URL. 68 | func (c *Client) resolveURL(relativeURL string) (*url.URL, error) { 69 | parsedRef, err := url.Parse(relativeURL) 70 | if err != nil { 71 | return nil, err 72 | } 73 | return c.BaseURL.ResolveReference(parsedRef), nil 74 | } 75 | -------------------------------------------------------------------------------- /internal/config/alt/alt.go: -------------------------------------------------------------------------------- 1 | // Package alt manages instances of alternative CLI configurations. 2 | package alt 3 | 4 | import ( 5 | "fmt" 6 | "os" 7 | "runtime" 8 | "strings" 9 | 10 | "gopkg.in/yaml.v3" 11 | ) 12 | 13 | type Alt struct { 14 | executablePath string 15 | comment string 16 | target string 17 | 18 | configPath string 19 | configNode *yaml.Node 20 | } 21 | 22 | func New(execPath, comment, target, configPath string, configNode *yaml.Node) *Alt { 23 | return &Alt{ 24 | executablePath: execPath, 25 | comment: comment, 26 | target: target, 27 | configPath: configPath, 28 | configNode: configNode, 29 | } 30 | } 31 | 32 | // GenerateAndSave creates and saves the executable and config files needed for the alt CLI instance. 33 | func (a *Alt) GenerateAndSave() error { 34 | configContent, err := yaml.Marshal(a.configNode) 35 | if err != nil { 36 | return err 37 | } 38 | if err := writeFile(a.configPath, configContent, 0o755, 0o644); err != nil { 39 | return err 40 | } 41 | executableContent := a.generateExecutable() 42 | return writeFile(a.executablePath, []byte(executableContent), 0o755, 0o755) 43 | } 44 | 45 | func (a *Alt) generateExecutable() string { 46 | if runtime.GOOS == "windows" { 47 | return ":: " + a.comment + "\r\n" + 48 | "@echo off\r\n" + 49 | "setlocal\r\n" + 50 | `set CLI_CONFIG_FILE=` + formatConfigPathForShell(a.configPath) + "\r\n" + 51 | a.target + " %*\r\n" + 52 | "endlocal\r\n" 53 | } 54 | 55 | return "#!/bin/sh\n" + 56 | "# " + a.comment + "\n" + 57 | "export CLI_CONFIG_FILE=" + formatConfigPathForShell(a.configPath) + "\n" + 58 | a.target + ` "$@"` + "\n" 59 | } 60 | 61 | func formatConfigPathForShell(configPath string) string { 62 | if runtime.GOOS == "windows" { 63 | if strings.HasPrefix(configPath, os.Getenv("AppData")) { 64 | return "%AppData%" + strings.TrimPrefix(configPath, os.Getenv("AppData")) 65 | } 66 | return configPath 67 | } 68 | vars := []string{"XDG_CONFIG_HOME", "HOME"} 69 | for _, v := range vars { 70 | val := os.Getenv(v) 71 | if val != "" && strings.HasPrefix(configPath, val) { 72 | return fmt.Sprintf(`"${%s}%s"`, v, strings.TrimPrefix(configPath, val)) 73 | } 74 | } 75 | return `"` + configPath + `"` 76 | } 77 | 78 | func GetExecutableFileExtension() string { 79 | if runtime.GOOS == "windows" { 80 | return ".bat" 81 | } 82 | return "" 83 | } 84 | -------------------------------------------------------------------------------- /pkg/mockapi/activities.go: -------------------------------------------------------------------------------- 1 | package mockapi 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | "slices" 7 | 8 | "github.com/go-chi/chi/v5" 9 | ) 10 | 11 | func (h *Handler) handleListProjectActivities(w http.ResponseWriter, req *http.Request) { 12 | h.RLock() 13 | defer h.RUnlock() 14 | projectID := chi.URLParam(req, "project_id") 15 | var activities = make([]*Activity, 0, len(h.activities[projectID])) 16 | for _, a := range h.activities[projectID] { 17 | activities = append(activities, a) 18 | } 19 | // Sort activities in descending order by created date. 20 | slices.SortFunc(activities, func(a, b *Activity) int { return -timeCompare(a.CreatedAt, b.CreatedAt) }) 21 | _ = json.NewEncoder(w).Encode(activities) 22 | } 23 | 24 | func (h *Handler) handleGetProjectActivity(w http.ResponseWriter, req *http.Request) { 25 | h.RLock() 26 | defer h.RUnlock() 27 | projectID := chi.URLParam(req, "project_id") 28 | activityID := chi.URLParam(req, "id") 29 | if projectActivities := h.activities[projectID]; projectActivities != nil { 30 | _ = json.NewEncoder(w).Encode(projectActivities[activityID]) 31 | return 32 | } 33 | w.WriteHeader(http.StatusNotFound) 34 | } 35 | 36 | func (h *Handler) handleListEnvironmentActivities(w http.ResponseWriter, req *http.Request) { 37 | h.RLock() 38 | defer h.RUnlock() 39 | projectID := chi.URLParam(req, "project_id") 40 | environmentID := chi.URLParam(req, "environment_id") 41 | var activities = make([]*Activity, 0, len(h.activities[projectID])) 42 | for _, a := range h.activities[projectID] { 43 | if slices.Contains(a.Environments, environmentID) { 44 | activities = append(activities, a) 45 | } 46 | } 47 | // Sort activities in descending order by created date. 48 | slices.SortFunc(activities, func(a, b *Activity) int { return -timeCompare(a.CreatedAt, b.CreatedAt) }) 49 | _ = json.NewEncoder(w).Encode(activities) 50 | } 51 | 52 | func (h *Handler) handleGetEnvironmentActivity(w http.ResponseWriter, req *http.Request) { 53 | h.RLock() 54 | defer h.RUnlock() 55 | projectID := chi.URLParam(req, "project_id") 56 | activityID := chi.URLParam(req, "id") 57 | if projectActivities := h.activities[projectID]; projectActivities != nil { 58 | environmentID := chi.URLParam(req, "environment_id") 59 | a := projectActivities[activityID] 60 | if a == nil || !slices.Contains(a.Environments, environmentID) { 61 | w.WriteHeader(http.StatusNotFound) 62 | return 63 | } 64 | _ = json.NewEncoder(w).Encode(a) 65 | return 66 | } 67 | w.WriteHeader(http.StatusNotFound) 68 | } 69 | -------------------------------------------------------------------------------- /internal/file/file_test.go: -------------------------------------------------------------------------------- 1 | package file 2 | 3 | import ( 4 | "bytes" 5 | "os" 6 | "path/filepath" 7 | "testing" 8 | "time" 9 | 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/require" 12 | ) 13 | 14 | func TestWriteIfNeeded(t *testing.T) { 15 | largeContentLength := 128 * 1024 16 | largeContent := make([]byte, largeContentLength) 17 | largeContent[0] = 'f' 18 | largeContent[largeContentLength-2] = 'o' 19 | largeContent[largeContentLength-1] = 'o' 20 | 21 | largeContent2 := make([]byte, largeContentLength) 22 | largeContent2[0] = 'b' 23 | largeContent2[largeContentLength-2] = 'a' 24 | largeContent2[largeContentLength-1] = 'r' 25 | 26 | assert.Equal(t, len(largeContent), len(largeContent2)) 27 | 28 | cases := []struct { 29 | name string 30 | initialData []byte 31 | sourceData []byte 32 | expectWrite bool 33 | }{ 34 | {"File does not exist", nil, []byte("new data"), true}, 35 | {"File matches source", []byte("same data"), []byte("same data"), false}, 36 | {"File content differs", []byte("old data"), []byte("new data"), true}, 37 | {"Larger file content differs", largeContent, largeContent2, true}, 38 | {"Larger file content matches", largeContent, largeContent, false}, 39 | {"File size differs", []byte("short"), []byte("much longer data"), true}, 40 | {"Empty source", []byte("existing data"), []byte{}, true}, 41 | } 42 | 43 | tmpDir := t.TempDir() 44 | for _, c := range cases { 45 | t.Run(c.name, func(t *testing.T) { 46 | destFile := filepath.Join(tmpDir, "testfile") 47 | 48 | if c.initialData != nil { 49 | require.NoError(t, os.WriteFile(destFile, c.initialData, 0o600)) 50 | time.Sleep(time.Millisecond * 5) 51 | } 52 | 53 | var modTimeBefore time.Time 54 | stat, err := os.Stat(destFile) 55 | if c.initialData == nil { 56 | require.True(t, os.IsNotExist(err)) 57 | } else { 58 | require.NoError(t, err) 59 | modTimeBefore = stat.ModTime() 60 | } 61 | 62 | err = WriteIfNeeded(destFile, c.sourceData, 0o600) 63 | require.NoError(t, err) 64 | 65 | statAfter, err := os.Stat(destFile) 66 | require.NoError(t, err) 67 | modTimeAfter := statAfter.ModTime() 68 | 69 | if c.expectWrite { 70 | assert.Greater(t, modTimeAfter.Truncate(time.Millisecond), modTimeBefore.Truncate(time.Millisecond)) 71 | } else { 72 | assert.Equal(t, modTimeBefore.Truncate(time.Millisecond), modTimeAfter.Truncate(time.Millisecond)) 73 | } 74 | 75 | data, err := os.ReadFile(destFile) 76 | require.NoError(t, err) 77 | 78 | assert.True(t, bytes.Equal(data, c.sourceData)) 79 | }) 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /internal/config/alt/fs.go: -------------------------------------------------------------------------------- 1 | package alt 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io/fs" 7 | "os" 8 | "path/filepath" 9 | "runtime" 10 | ) 11 | 12 | const ( 13 | subDir = "platform-alt" 14 | homeSubDir = ".platform-alt" 15 | ) 16 | 17 | // FindConfigDir finds an appropriate destination directory for an "alt" CLI configuration YAML file. 18 | func FindConfigDir() (string, error) { 19 | userConfigDir, err := os.UserConfigDir() 20 | if err != nil { 21 | return "", err 22 | } 23 | 24 | isDir, err := isExistingDirectory(userConfigDir) 25 | if err != nil { 26 | return "", err 27 | } 28 | if isDir { 29 | return filepath.Join(userConfigDir, subDir), nil 30 | } 31 | 32 | homeDir, err := os.UserHomeDir() 33 | if err != nil { 34 | return "", err 35 | } 36 | return filepath.Join(homeDir, homeSubDir), nil 37 | } 38 | 39 | // FindBinDir finds an appropriate destination directory for an "alt" CLI executable. 40 | func FindBinDir() (string, error) { 41 | homeDir, err := os.UserHomeDir() 42 | if err != nil { 43 | return "", fmt.Errorf("could not determine home directory: %w", err) 44 | } 45 | 46 | var candidates []string 47 | if runtime.GOOS == "windows" { 48 | candidates = []string{ 49 | filepath.Join(homeDir, "AppData", "Local", "Programs"), 50 | filepath.Join(homeDir, ".local", "bin"), 51 | filepath.Join(homeDir, "bin"), 52 | } 53 | } else { 54 | candidates = []string{ 55 | filepath.Join(homeDir, ".local", "bin"), 56 | filepath.Join(homeDir, "bin"), 57 | } 58 | } 59 | 60 | // Use the first candidate that is in the PATH. 61 | pathValue := os.Getenv("PATH") 62 | for _, c := range candidates { 63 | if inPathValue(c, pathValue) { 64 | return c, nil 65 | } 66 | } 67 | 68 | return filepath.Join(homeDir, homeSubDir, "bin"), nil 69 | } 70 | 71 | // isExistingDirectory checks if a path exists and is a directory. 72 | func isExistingDirectory(path string) (bool, error) { 73 | stat, err := os.Stat(path) 74 | if err != nil { 75 | if errors.Is(err, fs.ErrNotExist) { 76 | return false, nil 77 | } 78 | return false, err 79 | } 80 | return stat.IsDir(), nil 81 | } 82 | 83 | // writeFile creates or overwrites a file. 84 | // If dirMode is not 0, the containing directory will be created, if it does not already exist. 85 | func writeFile(path string, content []byte, dirMode, fileMode fs.FileMode) error { 86 | if dirMode != 0 { 87 | if err := os.MkdirAll(filepath.Dir(path), dirMode); err != nil { 88 | return err 89 | } 90 | } 91 | 92 | tmpFile := path + ".tmp" 93 | if err := os.WriteFile(tmpFile, content, fileMode); err != nil { 94 | return err 95 | } 96 | 97 | return os.Rename(tmpFile, path) 98 | } 99 | -------------------------------------------------------------------------------- /internal/config/alt/fs_test.go: -------------------------------------------------------------------------------- 1 | package alt 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "runtime" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | func TestFindConfigDir(t *testing.T) { 14 | tempDir := t.TempDir() 15 | 16 | t.Run("XDG_CONFIG_HOME exists", func(t *testing.T) { 17 | switch runtime.GOOS { 18 | case "windows", "darwin", "ios", "plan9": //nolint:goconst 19 | t.Skip() 20 | } 21 | err := os.Setenv("XDG_CONFIG_HOME", tempDir) 22 | require.NoError(t, err) 23 | defer os.Unsetenv("XDG_CONFIG_HOME") 24 | 25 | err = os.Mkdir(filepath.Join(tempDir, subDir), 0o755) 26 | require.NoError(t, err) 27 | 28 | result, err := FindConfigDir() 29 | assert.NoError(t, err) 30 | assert.Equal(t, filepath.Join(tempDir, subDir), result) 31 | }) 32 | 33 | t.Run("HOME fallback", func(t *testing.T) { 34 | err := os.Setenv("HOME", tempDir) 35 | require.NoError(t, err) 36 | defer os.Unsetenv("HOME") 37 | 38 | result, err := FindConfigDir() 39 | assert.NoError(t, err) 40 | assert.Equal(t, filepath.Join(tempDir, homeSubDir), result) 41 | }) 42 | } 43 | 44 | func TestFindBinDir(t *testing.T) { 45 | tempDir := t.TempDir() 46 | 47 | err := os.Setenv("HOME", tempDir) 48 | require.NoError(t, err) 49 | defer os.Unsetenv("HOME") 50 | 51 | result, err := FindBinDir() 52 | assert.NoError(t, err) 53 | assert.Equal(t, filepath.Join(tempDir, homeSubDir, "bin"), result) 54 | 55 | var standardDir string 56 | if runtime.GOOS == "windows" { 57 | standardDir = filepath.Join("AppData", "Local", "Programs") 58 | } else { 59 | standardDir = filepath.Join(".local", "bin") 60 | } 61 | err = os.Setenv("PATH", os.Getenv("PATH")+string(os.PathListSeparator)+filepath.Join(tempDir, standardDir)) 62 | require.NoError(t, err) 63 | 64 | result, err = FindBinDir() 65 | assert.NoError(t, err) 66 | assert.Equal(t, filepath.Join(tempDir, standardDir), result) 67 | } 68 | 69 | func TestFSHelpers(t *testing.T) { 70 | tempDir := t.TempDir() 71 | 72 | require.NoError(t, writeFile(filepath.Join(tempDir, "test.txt"), []byte("test"), 0, 0o644)) 73 | require.NoError(t, writeFile(filepath.Join(tempDir, "subdir", "test2.txt"), []byte("test2"), 0o755, 0o644)) 74 | 75 | dirExists, err := isExistingDirectory(filepath.Join(tempDir, "subdir")) 76 | assert.NoError(t, err) 77 | assert.True(t, dirExists) 78 | 79 | dirExists, err = isExistingDirectory(filepath.Join(tempDir, "not-a-subdir")) 80 | assert.NoError(t, err) 81 | assert.False(t, dirExists) 82 | 83 | dirExists, err = isExistingDirectory(filepath.Join(tempDir, "test.txt")) 84 | assert.NoError(t, err) 85 | assert.False(t, dirExists) 86 | } 87 | -------------------------------------------------------------------------------- /internal/auth/legacy.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "fmt" 7 | "io" 8 | "sync" 9 | 10 | "golang.org/x/oauth2" 11 | 12 | "github.com/platformsh/cli/internal/legacy" 13 | ) 14 | 15 | type legacyCLITokenSource struct { 16 | ctx context.Context 17 | cached *oauth2.Token 18 | wrapper *legacy.CLIWrapper 19 | mu sync.Mutex 20 | } 21 | 22 | func (ts *legacyCLITokenSource) unsafeGetLegacyCLIToken() (*oauth2.Token, error) { 23 | bt := bytes.NewBuffer(nil) 24 | ts.wrapper.Stdout = bt 25 | if err := ts.wrapper.Exec(ts.ctx, "auth:token", "-W"); err != nil { 26 | return nil, fmt.Errorf("cannot retrieve token: %w", err) 27 | } 28 | 29 | expiry, err := unsafeGetJWTExpiry(bt.String()) 30 | 31 | if err != nil { 32 | return nil, fmt.Errorf("cannot parse token: %w", err) 33 | } 34 | 35 | return &oauth2.Token{ 36 | AccessToken: bt.String(), 37 | TokenType: "Bearer", 38 | Expiry: expiry, 39 | }, nil 40 | } 41 | 42 | func (ts *legacyCLITokenSource) refreshToken() error { 43 | ts.mu.Lock() 44 | defer ts.mu.Unlock() 45 | 46 | return ts.unsafeRefreshToken() 47 | } 48 | 49 | func (ts *legacyCLITokenSource) unsafeRefreshToken() error { 50 | ts.cached = nil 51 | ts.wrapper.Stdout = io.Discard 52 | if err := ts.wrapper.Exec(ts.ctx, "auth:info", "--refresh"); err != nil { 53 | return fmt.Errorf("cannot refresh token: %w", err) 54 | } 55 | 56 | return nil 57 | } 58 | 59 | func (ts *legacyCLITokenSource) invalidateToken() error { 60 | ts.mu.Lock() 61 | defer ts.mu.Unlock() 62 | 63 | return ts.unsafeInvalidateToken() 64 | } 65 | 66 | func (ts *legacyCLITokenSource) unsafeInvalidateToken() error { 67 | if ts.cached != nil { 68 | ts.cached.AccessToken = "" 69 | } 70 | 71 | return nil 72 | } 73 | 74 | func (ts *legacyCLITokenSource) Token() (*oauth2.Token, error) { 75 | ts.mu.Lock() 76 | defer ts.mu.Unlock() 77 | 78 | if ts.cached == nil { 79 | tok, err := ts.unsafeGetLegacyCLIToken() 80 | if err != nil { 81 | return nil, err 82 | } 83 | ts.cached = tok 84 | } 85 | 86 | if ts.cached != nil && ts.cached.Valid() { 87 | return ts.cached, nil 88 | } 89 | 90 | if err := ts.unsafeRefreshToken(); err != nil { 91 | return nil, err 92 | } 93 | 94 | tok, err := ts.unsafeGetLegacyCLIToken() 95 | if err != nil { 96 | return nil, err 97 | } 98 | 99 | ts.cached = tok 100 | return ts.cached, nil 101 | } 102 | 103 | func NewLegacyCLITokenSource(ctx context.Context, wrapper *legacy.CLIWrapper) (oauth2.TokenSource, error) { 104 | return &legacyCLITokenSource{ 105 | ctx: ctx, 106 | wrapper: wrapper, 107 | }, nil 108 | } 109 | -------------------------------------------------------------------------------- /internal/version/version_test.go: -------------------------------------------------------------------------------- 1 | package version_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | 8 | "github.com/platformsh/cli/internal/version" 9 | ) 10 | 11 | func TestCompare(t *testing.T) { 12 | cases := []struct { 13 | v1 string 14 | v2 string 15 | cmp int 16 | fail bool 17 | }{ 18 | // Normal comparisons 19 | {v1: "1.0.0", v2: "1.0.0"}, 20 | {v1: "1.0.1", v2: "1.0.0", cmp: 1}, 21 | {v1: "2.1.0", v2: "2.1.1", cmp: -1}, 22 | {v1: "0.0.0", v2: "0.0.0"}, 23 | {v1: "0.1.0", v2: "0.0.1", cmp: 1}, 24 | {v1: "0.0.1", v2: "0.0.2", cmp: -1}, 25 | {v1: "1.0.0", v2: "", fail: true}, 26 | 27 | // Quasi-semver 28 | {v1: "1", v2: "1"}, 29 | {v1: "1", v2: "2", cmp: -1}, 30 | {v1: "2", v2: "1", cmp: 1}, 31 | {v1: "1", v2: "0.0.1", cmp: 1}, 32 | {v1: "1.0", v2: "1.0"}, 33 | {v1: "1.0", v2: "2.0", cmp: -1}, 34 | {v1: "1.0.1", v2: "2", cmp: -1}, 35 | {v1: "1.0.0", v2: "1.0"}, 36 | {v1: "1.01", v2: "1.2", cmp: -1}, 37 | 38 | // Suffixes 39 | {v1: "1.0.1-dev", v2: "1.0.1", cmp: -1}, 40 | {v1: "1.0.1+context", v2: "1.0.1"}, 41 | {v1: "3.0-beta.1", v2: "3.0-beta.2", cmp: -1}, 42 | {v1: "3.0-beta.3", v2: "3.0-beta.2", cmp: 1}, 43 | {v1: "3.0.1-beta.3", v2: "3.0.1-beta.2", cmp: 1}, 44 | {v1: "3.0.1-beta.3", v2: "3.0.2-beta.2", cmp: -1}, 45 | {v1: "1.0.0-beta.9", v2: "1.0.0-beta.10", cmp: -1}, 46 | {v1: "1.0.0-beta.10", v2: "1.0.0-beta.2", cmp: 1}, 47 | {v1: "1.0.0-beta.1", v2: "1.0.0-alpha.1", cmp: 1}, 48 | {v1: "1.0.0+001", v2: "1.0.0+002"}, 49 | {v1: "3.0-beta.03", v2: "3.0-beta.2", fail: true}, 50 | {v1: "1.0.1_invalid", v2: "1.0.1", fail: true}, 51 | {v1: "1.0.1", v2: "2.0.0_invalid", fail: true}, 52 | 53 | // Prefixes 54 | {v1: "v1.0.1", v2: "1.0.1"}, 55 | {v1: "v1.0.1", v2: "1.0.2", cmp: -1}, 56 | {v1: "v1.0.2", v2: "v1.0.1", cmp: 1}, 57 | } 58 | for _, c := range cases { 59 | comment := c.v1 + " <=> " + c.v2 60 | result, err := version.Compare(c.v1, c.v2) 61 | if c.fail { 62 | assert.Error(t, err, comment) 63 | } else { 64 | assert.NoError(t, err, comment) 65 | assert.Equal(t, c.cmp, result, comment) 66 | } 67 | } 68 | } 69 | 70 | func TestValidate(t *testing.T) { 71 | cases := []struct { 72 | v string 73 | valid bool 74 | }{ 75 | {"0", true}, 76 | {"1", true}, 77 | {"v0", true}, 78 | {"1.0.0", true}, 79 | {"v1.0.0", true}, 80 | {"1.0.0+build-info", true}, 81 | {"1.0.0-dev-suffix", true}, 82 | {"1.0.0-dev-suffix.with.numbers.1", true}, 83 | {"v1.0.0-dev-suffix.with.numbers.1", true}, 84 | {"2024.0.1", true}, 85 | 86 | {"1.01", true}, 87 | } 88 | for _, c := range cases { 89 | assert.Equal(t, c.valid, version.Validate(c.v), c.v) 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /internal/config/dir.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "errors" 5 | "os" 6 | "path/filepath" 7 | "runtime" 8 | "strings" 9 | "syscall" 10 | ) 11 | 12 | // TempDir returns the path to a user-specific temporary directory, suitable for caches. 13 | // 14 | // It creates the temporary directory if it does not already exist. 15 | // 16 | // The directory can be specified in the {ENV_PREFIX}TMP environment variable. 17 | // 18 | // This does not use os.TempDir, as on Linux/Unix systems that usually returns a 19 | // global /tmp directory, which could conflict with other users. It also does not 20 | // use os.MkdirTemp, as the CLI usually needs a stable (not random) directory 21 | // path. It therefore uses os.UserCacheDir which in turn will use XDG_CACHE_HOME 22 | // or the home directory. 23 | func (c *Config) TempDir() (string, error) { 24 | if c.tempDir != "" { 25 | return c.tempDir, nil 26 | } 27 | d := os.Getenv(c.Application.EnvPrefix + "TMP") 28 | if d == "" { 29 | ucd, err := os.UserCacheDir() 30 | if err != nil { 31 | return "", err 32 | } 33 | d = ucd 34 | } 35 | 36 | // Windows already has a user-specific temporary directory. 37 | if runtime.GOOS == "windows" { 38 | osTemp := os.TempDir() 39 | if strings.HasPrefix(osTemp, d) { 40 | d = osTemp 41 | } 42 | } 43 | 44 | path := filepath.Join(d, c.Application.TempSubDir) 45 | 46 | // If the subdirectory cannot be created due to a read-only filesystem, fall back to /tmp. 47 | if err := os.MkdirAll(path, 0o700); err != nil { 48 | if !errors.Is(err, syscall.EROFS) { 49 | return "", err 50 | } 51 | path = filepath.Join(os.TempDir(), c.Application.TempSubDir) 52 | if err := os.MkdirAll(path, 0o700); err != nil { 53 | return "", err 54 | } 55 | } 56 | c.tempDir = path 57 | 58 | return path, nil 59 | } 60 | 61 | // WritableUserDir returns the path to a writable user-level directory. 62 | // Deprecated: unless backwards compatibility is desired, TempDir is preferable. 63 | func (c *Config) WritableUserDir() (string, error) { 64 | if c.writableUserDir != "" { 65 | return c.writableUserDir, nil 66 | } 67 | hd, err := c.HomeDir() 68 | if err != nil { 69 | return "", err 70 | } 71 | path := filepath.Join(hd, c.Application.WritableUserDir) 72 | if err := os.MkdirAll(path, 0o700); err != nil { 73 | return "", err 74 | } 75 | c.writableUserDir = path 76 | 77 | return path, nil 78 | } 79 | 80 | // HomeDir returns the home directory configured via an environment variable, or the OS's user home directory otherwise. 81 | func (c *Config) HomeDir() (string, error) { 82 | if fromEnv := os.Getenv(c.Application.EnvPrefix + "HOME"); fromEnv != "" { 83 | return fromEnv, nil 84 | } 85 | return os.UserHomeDir() 86 | } 87 | -------------------------------------------------------------------------------- /pkg/mockapi/subscriptions.go: -------------------------------------------------------------------------------- 1 | package mockapi 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | "net/url" 8 | "time" 9 | 10 | "github.com/go-chi/chi/v5" 11 | "github.com/stretchr/testify/require" 12 | ) 13 | 14 | func (h *Handler) handleCreateSubscription(w http.ResponseWriter, req *http.Request) { 15 | var createOptions = struct { 16 | Region string `json:"project_region"` 17 | Title string `json:"project_title"` 18 | }{} 19 | err := json.NewDecoder(req.Body).Decode(&createOptions) 20 | require.NoError(h.t, err) 21 | orgID := chi.URLParam(req, "organization_id") 22 | id := NumericID() 23 | projectID := ProjectID() 24 | sub := Subscription{ 25 | ID: id, 26 | Links: MakeHALLinks( 27 | "self=" + "/organizations/" + url.PathEscape(orgID) + "/subscriptions/" + url.PathEscape(id), 28 | ), 29 | ProjectRegion: createOptions.Region, 30 | ProjectTitle: createOptions.Title, 31 | Status: "provisioning", 32 | } 33 | 34 | h.Lock() 35 | if h.subscriptions == nil { 36 | h.subscriptions = make(map[string]*Subscription) 37 | } 38 | h.subscriptions[sub.ID] = &sub 39 | if h.projects == nil { 40 | h.projects = make(map[string]*Project) 41 | } 42 | h.projects[projectID] = &Project{ 43 | ID: projectID, 44 | Links: MakeHALLinks("self=/projects/" + projectID), 45 | Repository: ProjectRepository{URL: projectID + "@git.example.com:" + projectID + ".git"}, 46 | SubscriptionID: sub.ID, 47 | Subscription: ProjectSubscriptionInfo{ 48 | LicenseURI: fmt.Sprintf("/licenses/%s", url.PathEscape(sub.ID)), 49 | }, 50 | Organization: chi.URLParam(req, "organization_id"), 51 | } 52 | h.Unlock() 53 | 54 | _ = json.NewEncoder(w).Encode(sub) 55 | 56 | // Imitate "provisioning": wait a little and then activate. 57 | go func(subID string, projectID string) { 58 | time.Sleep(time.Second * 2) 59 | h.Lock() 60 | defer h.Unlock() 61 | sub := h.subscriptions[subID] 62 | sub.Status = "active" 63 | sub.ProjectID = projectID 64 | sub.ProjectUI = "http://console.example.com/projects/" + url.PathEscape(projectID) 65 | }(sub.ID, projectID) 66 | } 67 | 68 | func (h *Handler) handleGetSubscription(w http.ResponseWriter, req *http.Request) { 69 | h.RLock() 70 | defer h.RUnlock() 71 | id := chi.URLParam(req, "subscription_id") 72 | sub := h.subscriptions[id] 73 | if sub == nil { 74 | w.WriteHeader(http.StatusNotFound) 75 | return 76 | } 77 | _ = json.NewEncoder(w).Encode(sub) 78 | } 79 | 80 | func (h *Handler) handleCanCreateSubscriptions(w http.ResponseWriter, req *http.Request) { 81 | h.RLock() 82 | defer h.RUnlock() 83 | id := chi.URLParam(req, "organization_id") 84 | cc := h.canCreate[id] 85 | if cc == nil { 86 | cc = &CanCreateResponse{CanCreate: true} 87 | } 88 | _ = json.NewEncoder(w).Encode(cc) 89 | } 90 | -------------------------------------------------------------------------------- /internal/auth/transport.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | ) 10 | 11 | type refresher interface { 12 | refreshToken() error 13 | invalidateToken() error 14 | } 15 | 16 | // Transport is an HTTP RoundTripper similar to golang.org/x/oauth2.Transport. 17 | // It injects Authorization headers using a savingSource and, on a 401 response, 18 | // clears the cached token and retries the request once. 19 | type Transport struct { 20 | // base is the underlying oauth2.Transport that adds the Authorization header. 21 | base http.RoundTripper 22 | 23 | // refresher is the savingSource used as the TokenSource for base; kept private 24 | // so we can clear its cached token on 401. 25 | refresher refresher 26 | 27 | LogFunc func(msg string, args ...any) 28 | } 29 | 30 | // RoundTrip adds Authorization via the underlying oauth2.Transport. If the 31 | // response is 401 Unauthorized, it clears the cached token and retries once. 32 | func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) { 33 | req.Body = wrapReader(req.Body) 34 | 35 | resp, err := t.base.RoundTrip(req) 36 | 37 | // Retry on 401 38 | if resp != nil && resp.StatusCode == http.StatusUnauthorized { 39 | _ = t.log("The access token needs to be refreshed. Retrying request.") 40 | if err := t.refresher.invalidateToken(); err != nil { 41 | return nil, fmt.Errorf("failed to invalidate token: %w", err) 42 | } 43 | flushReader(resp.Body) 44 | resp, err = t.base.RoundTrip(req) 45 | } 46 | 47 | return resp, err 48 | } 49 | 50 | func (t *Transport) log(msg string, args ...any) error { 51 | if t.LogFunc == nil { 52 | return nil 53 | } 54 | t.LogFunc(msg, args...) 55 | return nil 56 | } 57 | 58 | // context key for storing a custom RoundTripper. 59 | type transportCtxKey struct{} 60 | 61 | // WithTransport returns a new context that carries the provided RoundTripper. 62 | func WithTransport(ctx context.Context, rt http.RoundTripper) context.Context { 63 | return context.WithValue(ctx, transportCtxKey{}, rt) 64 | } 65 | 66 | // TransportFromContext retrieves a RoundTripper previously stored with 67 | // WithTransport. It returns (nil, false) if none is set. 68 | func TransportFromContext(ctx context.Context) (http.RoundTripper, bool) { 69 | v := ctx.Value(transportCtxKey{}) 70 | if v == nil { 71 | return nil, false 72 | } 73 | rt, ok := v.(http.RoundTripper) 74 | if !ok || rt == nil { 75 | return nil, false 76 | } 77 | return rt, true 78 | } 79 | 80 | func wrapReader(r io.ReadCloser) io.ReadCloser { 81 | if r == nil { 82 | return nil 83 | } 84 | bodyBytes, _ := io.ReadAll(r) 85 | _ = r.Close() 86 | return io.NopCloser(bytes.NewBuffer(bodyBytes)) 87 | } 88 | 89 | func flushReader(r io.ReadCloser) { 90 | if r == nil { 91 | return 92 | } 93 | _, _ = io.Copy(io.Discard, r) 94 | _ = r.Close() 95 | } 96 | -------------------------------------------------------------------------------- /Dockerfile.php: -------------------------------------------------------------------------------- 1 | FROM alpine:3.16 2 | 3 | # define script basic information 4 | # Version of this Dockerfile 5 | ENV SCRIPT_VERSION=1.5.1 6 | # Download address uses backup address 7 | 8 | ARG USE_BACKUP_ADDRESS 9 | ARG PHP_VERSION 10 | 11 | # (if downloading slowly, consider set it to yes) 12 | ENV USE_BACKUP="${USE_BACKUP_ADDRESS}" 13 | 14 | # APK repositories mirror address, if u r not in China, consider set USE_BACKUP=yes to boost 15 | ENV LINK_APK_REPO='mirrors.ustc.edu.cn' 16 | ENV LINK_APK_REPO_BAK='dl-cdn.alpinelinux.org' 17 | 18 | RUN if [ "${USE_BACKUP}" = "" ]; then \ 19 | export USE_BACKUP="no" ; \ 20 | fi 21 | 22 | RUN if [ "${USE_BACKUP}" = "yes" ]; then \ 23 | echo "Using backup original address..." ; \ 24 | else \ 25 | echo "Using mirror address..." && \ 26 | sed -i 's/dl-cdn.alpinelinux.org/'${LINK_APK_REPO}'/g' /etc/apk/repositories ; \ 27 | fi 28 | 29 | # build requirements 30 | RUN apk add bash file wget cmake gcc g++ jq autoconf git libstdc++ linux-headers make m4 libgcc binutils ncurses dialog > /dev/null 31 | # php zlib dependencies 32 | RUN apk add zlib-dev zlib-static > /dev/null 33 | # php mbstring dependencies 34 | RUN apk add oniguruma-dev > /dev/null 35 | # php openssl dependencies 36 | RUN apk add openssl-libs-static openssl-dev openssl > /dev/null 37 | # php gd dependencies 38 | RUN apk add libpng-dev libpng-static > /dev/null 39 | # curl c-ares dependencies 40 | RUN apk add c-ares-static c-ares-dev > /dev/null 41 | # php event dependencies 42 | RUN apk add libevent libevent-dev libevent-static > /dev/null 43 | # php sqlite3 dependencies 44 | RUN apk add sqlite sqlite-dev sqlite-libs sqlite-static > /dev/null 45 | # php libzip dependencies 46 | RUN apk add bzip2-dev bzip2-static bzip2 > /dev/null 47 | # php micro ffi dependencies 48 | RUN apk add libffi libffi-dev > /dev/null 49 | # php gd event parent dependencies 50 | RUN apk add zstd-static > /dev/null 51 | # php readline dependencies 52 | RUN apk add readline-static ncurses-static readline-dev > /dev/null 53 | 54 | RUN mkdir /app 55 | 56 | WORKDIR /app 57 | 58 | COPY ./* /app/ 59 | 60 | RUN chmod +x /app/*.sh 61 | 62 | RUN ./download.sh swoole ${USE_BACKUP} && \ 63 | ./download.sh inotify ${USE_BACKUP} && \ 64 | ./download.sh mongodb ${USE_BACKUP} && \ 65 | ./download.sh event ${USE_BACKUP} && \ 66 | ./download.sh redis ${USE_BACKUP} && \ 67 | ./download.sh libxml2 ${USE_BACKUP} && \ 68 | ./download.sh xz ${USE_BACKUP} && \ 69 | ./download.sh curl ${USE_BACKUP} && \ 70 | ./download.sh libzip ${USE_BACKUP} && \ 71 | ./download-git.sh dixyes/phpmicro phpmicro ${USE_BACKUP} 72 | 73 | RUN ./compile-deps.sh 74 | RUN echo -e "#!/usr/bin/env bash\n/app/compile-php.sh \$@" > /bin/build-php && chmod +x /bin/build-php 75 | 76 | RUN /bin/build-php no-mirror $PHP_VERSION all /dist 77 | 78 | FROM scratch 79 | ARG GOARCH 80 | COPY --from=0 /dist/php /php_linux_$GOARCH 81 | -------------------------------------------------------------------------------- /internal/config/alt/update.go: -------------------------------------------------------------------------------- 1 | package alt 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "time" 8 | 9 | "gopkg.in/yaml.v3" 10 | 11 | "github.com/platformsh/cli/internal/config" 12 | "github.com/platformsh/cli/internal/state" 13 | "github.com/platformsh/cli/internal/version" 14 | ) 15 | 16 | // ShouldUpdate returns whether the Update function may be run on configuration. 17 | func ShouldUpdate(cnf *config.Config) bool { 18 | return cnf.Updates.Check && 19 | cnf.SourceFile != "" && 20 | cnf.Metadata.URL != "" 21 | } 22 | 23 | // Update checks for configuration updates, when appropriate. 24 | // The "cnf" pointer will NOT be updated with the new configuration. 25 | func Update(ctx context.Context, cnf *config.Config, debugLog func(fmt string, i ...any)) error { 26 | s, err := state.Load(cnf) 27 | if err != nil { 28 | return err 29 | } 30 | interval := time.Second * time.Duration(cnf.Updates.CheckInterval) 31 | lastChecked := time.Unix(s.ConfigUpdates.LastChecked, 0) 32 | if time.Since(lastChecked) < interval { 33 | debugLog("Config updates checked recently (%v ago)", time.Since(lastChecked).Truncate(time.Second)) 34 | return nil 35 | } 36 | 37 | if cnf.SourceFile == "" { 38 | return fmt.Errorf("no config file path available") 39 | } 40 | if cnf.Metadata.URL == "" { 41 | return fmt.Errorf("no config URL available") 42 | } 43 | 44 | stat, err := os.Stat(cnf.SourceFile) 45 | if err != nil { 46 | return fmt.Errorf("could not stat config file %s: %w", cnf.SourceFile, err) 47 | } 48 | if time.Since(stat.ModTime()) < interval { 49 | debugLog("Config file updated recently (%v ago): %s", 50 | time.Since(stat.ModTime()).Truncate(time.Second), cnf.SourceFile) 51 | return nil 52 | } 53 | 54 | defer func() { 55 | s.ConfigUpdates.LastChecked = time.Now().Unix() 56 | if err := state.Save(s, cnf); err != nil { 57 | debugLog("Error saving state: %s", err) 58 | } 59 | }() 60 | 61 | debugLog("Checking for config updates from URL: %s", cnf.Metadata.URL) 62 | newCnfNode, newCnfStruct, err := FetchConfig(ctx, cnf.Metadata.URL) 63 | if err != nil { 64 | return err 65 | } 66 | if !newCnfStruct.Metadata.UpdatedAt.IsZero() && 67 | !newCnfStruct.Metadata.UpdatedAt.After(cnf.Metadata.UpdatedAt) { 68 | debugLog("Config is already up to date (updated at %v)", cnf.Metadata.UpdatedAt.Format(time.RFC3339)) 69 | return nil 70 | } 71 | if newCnfStruct.Metadata.Version != "" { 72 | cmp, err := version.Compare(cnf.Metadata.Version, newCnfStruct.Metadata.Version) 73 | if err != nil { 74 | return fmt.Errorf("could not compare config versions: %w", err) 75 | } 76 | if cmp >= 0 { 77 | debugLog("Config is already up to date (version %s)", cnf.Metadata.Version) 78 | return nil 79 | } 80 | } 81 | b, err := yaml.Marshal(newCnfNode) 82 | if err != nil { 83 | return err 84 | } 85 | 86 | if err := writeFile(cnf.SourceFile, b, 0, 0o644); err != nil { 87 | return err 88 | } 89 | debugLog("Automatically updated config file: %s", cnf.SourceFile) 90 | 91 | return nil 92 | } 93 | -------------------------------------------------------------------------------- /internal/legacy/php_manager_windows.go: -------------------------------------------------------------------------------- 1 | package legacy 2 | 3 | import ( 4 | "archive/zip" 5 | "bytes" 6 | _ "embed" 7 | "fmt" 8 | "io" 9 | "os" 10 | "path/filepath" 11 | "runtime" 12 | "strings" 13 | 14 | "golang.org/x/sync/errgroup" 15 | 16 | "github.com/platformsh/cli/internal/file" 17 | ) 18 | 19 | //go:embed archives/php_windows.zip 20 | var phpCLI []byte 21 | 22 | //go:embed archives/cacert.pem 23 | var caCert []byte 24 | 25 | func (m *phpManagerPerOS) copy() error { 26 | destDir := filepath.Join(m.cacheDir, "php") 27 | 28 | r, err := zip.NewReader(bytes.NewReader(phpCLI), int64(len(phpCLI))) 29 | if err != nil { 30 | return fmt.Errorf("could not open zip reader: %w", err) 31 | } 32 | 33 | g := errgroup.Group{} 34 | g.SetLimit(runtime.GOMAXPROCS(0)) 35 | for _, f := range r.File { 36 | g.Go(func() error { 37 | return copyZipFile(f, destDir) 38 | }) 39 | } 40 | if err := g.Wait(); err != nil { 41 | return err 42 | } 43 | 44 | if err := file.WriteIfNeeded(filepath.Join(destDir, "extras", "cacert.pem"), caCert, 0o644); err != nil { 45 | return err 46 | } 47 | 48 | return nil 49 | } 50 | 51 | func (m *phpManagerPerOS) binPath() string { 52 | return filepath.Join(m.cacheDir, "php", "php.exe") 53 | } 54 | 55 | func (m *phpManagerPerOS) settings() []string { 56 | return []string{ 57 | "extension=" + filepath.Join(m.cacheDir, "php", "ext", "php_curl.dll"), 58 | "extension=" + filepath.Join(m.cacheDir, "php", "ext", "php_openssl.dll"), 59 | "openssl.cafile=" + filepath.Join(m.cacheDir, "php", "extras", "cacert.pem"), 60 | } 61 | } 62 | 63 | // copyZipFile extracts a file from the Zip to the destination directory. 64 | // If the file already exists and has the correct size, it will be skipped. 65 | func copyZipFile(f *zip.File, destDir string) error { 66 | destPath := filepath.Join(destDir, f.Name) 67 | if !strings.HasPrefix(destPath, filepath.Clean(destDir)+string(os.PathSeparator)) { 68 | return fmt.Errorf("invalid file path: %s", destPath) 69 | } 70 | 71 | if f.FileInfo().IsDir() { 72 | if err := os.MkdirAll(destPath, 0755); err != nil { 73 | return fmt.Errorf("could not create extracted directory %s: %w", destPath, err) 74 | } 75 | return nil 76 | } 77 | 78 | if existingFileInfo, err := os.Lstat(destPath); err == nil && uint64(existingFileInfo.Size()) == f.UncompressedSize64 { 79 | return nil 80 | } 81 | 82 | if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil { 83 | return fmt.Errorf("could not create parent directory for extracted file %s: %w", destPath, err) 84 | } 85 | 86 | rc, err := f.Open() 87 | if err != nil { 88 | return fmt.Errorf("could not open file in zip archive %s: %w", f.Name, err) 89 | } 90 | defer rc.Close() 91 | 92 | b, err := io.ReadAll(rc) 93 | if err != nil { 94 | return fmt.Errorf("could not extract zipped file %s: %w", f.Name, err) 95 | } 96 | 97 | if err := file.Write(destPath, b, f.Mode()); err != nil { 98 | return fmt.Errorf("could not copy extracted file %s: %w", destPath, err) 99 | } 100 | 101 | return nil 102 | } 103 | -------------------------------------------------------------------------------- /internal/config/alt/path_test.go: -------------------------------------------------------------------------------- 1 | package alt_test 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "runtime" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | 12 | "github.com/platformsh/cli/internal/config/alt" 13 | ) 14 | 15 | func TestInPath(t *testing.T) { 16 | tempDir := t.TempDir() 17 | tempDir, _ = filepath.EvalSymlinks(tempDir) 18 | 19 | homeDir := "/custom/home/directory" 20 | require.NoError(t, os.Setenv("HOME", homeDir)) 21 | require.NoError(t, os.Setenv("CUSTOM_ENV_VAR", "/custom/path")) 22 | t.Cleanup(func() { 23 | _ = os.Unsetenv("PATH") 24 | _ = os.Unsetenv("HOME") 25 | _ = os.Unsetenv("CUSTOM_ENV_VAR") 26 | }) 27 | 28 | require.NoError(t, os.Chdir(tempDir)) 29 | 30 | if runtime.GOOS == "windows" { 31 | t.Skip() 32 | } 33 | 34 | cases := []struct { 35 | name string 36 | dir string 37 | pathEnv string 38 | inPath bool 39 | }{ 40 | { 41 | name: "double-dot-input", 42 | dir: tempDir + "/foo/../foo", 43 | pathEnv: tempDir + "/foo", 44 | inPath: true, 45 | }, 46 | { 47 | name: "double-dot-both", 48 | dir: tempDir + "/./foo//../foo", 49 | pathEnv: tempDir + "/foo/../foo//", 50 | inPath: true, 51 | }, 52 | { 53 | name: "home-tilde", 54 | dir: homeDir + "/foo/bar/.", 55 | pathEnv: "/usr/bin:~/foo/bar:/usr/local/bin" + "$HOME/.local/bin", 56 | inPath: true, 57 | }, 58 | { 59 | name: "home-variable", 60 | dir: homeDir + "/.local/bin", 61 | pathEnv: "/usr/bin:~/foo/bar:/usr/local/bin:$HOME/.local/bin", 62 | inPath: true, 63 | }, 64 | { 65 | name: "home-not-in", 66 | dir: homeDir + "/.local/bin", 67 | pathEnv: "/usr/bin:/usr/local/bin:/usr/.local/bin", 68 | }, 69 | { 70 | name: "custom-variable", 71 | dir: "/custom/path/foo", 72 | pathEnv: `$CUSTOM_ENV_VAR/foo/.:~/.local/bin:/nonexistent/dir`, 73 | inPath: true, 74 | }, 75 | { 76 | name: "custom-variable-prefixed", 77 | dir: "/prefix/custom/path/foo", 78 | pathEnv: `~/bin:/prefix/$CUSTOM_ENV_VAR//foo:/bin`, 79 | inPath: true, 80 | }, 81 | { 82 | name: "relative", 83 | dir: tempDir + "/foo", 84 | pathEnv: `/bin:./foo/bar/..:/usr/local/bin`, 85 | inPath: true, 86 | }, 87 | { 88 | name: "relative-not", 89 | dir: tempDir + "/foo", 90 | pathEnv: `/bin:/foo:/usr/local/bin`, 91 | }, 92 | { 93 | name: "this-dir-as-dot", 94 | dir: tempDir + "/x/..", 95 | pathEnv: `/bin:.:/usr/local/bin`, 96 | inPath: true, 97 | }, 98 | { 99 | name: "this-dir-as-empty-entry", 100 | dir: tempDir + "/foo/..", 101 | pathEnv: `/bin::/usr/local/bin`, 102 | inPath: true, 103 | }, 104 | { 105 | name: "this-dir-not", 106 | dir: tempDir, 107 | pathEnv: `/bin:/usr/local/bin`, 108 | }, 109 | } 110 | 111 | for _, c := range cases { 112 | t.Run(c.name, func(t *testing.T) { 113 | require.NoError(t, os.Setenv("PATH", c.pathEnv)) 114 | assert.Equal(t, alt.InPath(c.dir), c.inPath) 115 | }) 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /internal/update.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "os" 9 | "time" 10 | 11 | "github.com/symfony-cli/terminal" 12 | 13 | "github.com/platformsh/cli/internal/config" 14 | "github.com/platformsh/cli/internal/state" 15 | "github.com/platformsh/cli/internal/version" 16 | ) 17 | 18 | // ReleaseInfo stores information about a release 19 | type ReleaseInfo struct { 20 | Version string `json:"tag_name"` 21 | URL string `json:"html_url"` 22 | PublishedAt time.Time `json:"published_at"` 23 | } 24 | 25 | // CheckForUpdate checks whether this software has had a newer release on GitHub 26 | func CheckForUpdate(cnf *config.Config, currentVersion string) (*ReleaseInfo, error) { 27 | if !shouldCheckForUpdate(cnf) { 28 | return nil, nil 29 | } 30 | 31 | s, err := state.Load(cnf) 32 | if err == nil && time.Now().Unix()-s.Updates.LastChecked < int64(cnf.Updates.CheckInterval) { 33 | // Updates were already checked recently. 34 | return nil, nil 35 | } 36 | 37 | defer func() { 38 | // After checking, save the last check time. 39 | s.Updates.LastChecked = time.Now().Unix() 40 | //nolint:errcheck // not being able to set the state should have no impact on the rest of the program 41 | state.Save(s, cnf) 42 | }() 43 | 44 | releaseInfo, err := getLatestReleaseInfo(cnf.Wrapper.GitHubRepo) 45 | if err != nil { 46 | return nil, fmt.Errorf("could not determine latest release: %w", err) 47 | } 48 | 49 | cmp, err := version.Compare(releaseInfo.Version, currentVersion) 50 | if err != nil { 51 | return nil, fmt.Errorf("could not compare versions: %w", err) 52 | } 53 | if cmp > 0 { 54 | return releaseInfo, nil 55 | } 56 | 57 | return nil, nil 58 | } 59 | 60 | // shouldCheckForUpdate checks updates are not disabled and the environment is a terminal 61 | func shouldCheckForUpdate(cnf *config.Config) bool { 62 | return config.Version != "0.0.0" && 63 | cnf.Wrapper.GitHubRepo != "" && 64 | cnf.Updates.Check && 65 | os.Getenv(cnf.Application.EnvPrefix+"UPDATES_CHECK") != "0" && 66 | !isCI() && terminal.IsTerminal(os.Stdout) && terminal.IsTerminal(os.Stderr) 67 | } 68 | 69 | func isCI() bool { 70 | return os.Getenv("CI") != "" || // GitHub Actions, Travis CI, CircleCI, Cirrus CI, GitLab CI, AppVeyor, CodeShip, dsari 71 | os.Getenv("BUILD_NUMBER") != "" || // Jenkins, TeamCity 72 | os.Getenv("RUN_ID") != "" // TaskCluster, dsari 73 | } 74 | 75 | // getLatestReleaseInfo from GitHub 76 | func getLatestReleaseInfo(repo string) (*ReleaseInfo, error) { 77 | req, err := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", repo), http.NoBody) 78 | if err != nil { 79 | return nil, err 80 | } 81 | 82 | resp, err := http.DefaultClient.Do(req) 83 | if err != nil { 84 | return nil, err 85 | } 86 | defer resp.Body.Close() 87 | 88 | body, err := io.ReadAll(resp.Body) 89 | if err != nil { 90 | return nil, err 91 | } 92 | 93 | var latestRelease ReleaseInfo 94 | if err := json.Unmarshal(body, &latestRelease); err != nil { 95 | return nil, err 96 | } 97 | 98 | return &latestRelease, nil 99 | } 100 | -------------------------------------------------------------------------------- /.goreleaser.vendor.yaml.tpl: -------------------------------------------------------------------------------- 1 | # GoReleaser configuration for the ${VENDOR_NAME} CLI. 2 | version: 2 3 | project_name: ${VENDOR_BINARY} 4 | 5 | before: 6 | hooks: 7 | - go generate ./... 8 | - bash scripts/generate_completions.sh 9 | 10 | builds: 11 | - binary: ${VENDOR_BINARY} 12 | id: ${VENDOR_BINARY} 13 | env: 14 | - CGO_ENABLED=0 15 | tags: 16 | - vendor 17 | goos: 18 | - linux 19 | - windows 20 | goarch: 21 | - amd64 22 | - arm64 23 | ignore: 24 | - goos: windows 25 | goarch: arm64 26 | ldflags: 27 | - -s -w 28 | - -X "github.com/platformsh/cli/internal/legacy.PHPVersion={{.Env.PHP_VERSION}}" 29 | - -X "github.com/platformsh/cli/internal/legacy.LegacyCLIVersion={{.Env.LEGACY_CLI_VERSION}}" 30 | - -X "github.com/platformsh/cli/internal/config.Version={{.Version}}" 31 | - -X "github.com/platformsh/cli/internal/config.Commit={{.Commit}}" 32 | - -X "github.com/platformsh/cli/internal/config.Date={{.Date}}" 33 | - -X "github.com/platformsh/cli/internal/config.Vendor=${VENDOR_BINARY}" 34 | - -X "github.com/platformsh/cli/internal/config.BuiltBy=goreleaser" 35 | main: ./cmd/platform 36 | - binary: ${VENDOR_BINARY} 37 | id: ${VENDOR_BINARY}-macos 38 | env: 39 | - CGO_ENABLED=0 40 | tags: 41 | - vendor 42 | goos: 43 | - darwin 44 | goarch: 45 | - amd64 46 | - arm64 47 | ldflags: 48 | - -s -w 49 | - -X "github.com/platformsh/cli/internal/legacy.PHPVersion={{.Env.PHP_VERSION}}" 50 | - -X "github.com/platformsh/cli/internal/legacy.LegacyCLIVersion={{.Env.LEGACY_CLI_VERSION}}" 51 | - -X "github.com/platformsh/cli/internal/config.Version={{.Version}}" 52 | - -X "github.com/platformsh/cli/internal/config.Commit={{.Commit}}" 53 | - -X "github.com/platformsh/cli/internal/config.Date={{.Date}}" 54 | - -X "github.com/platformsh/cli/internal/config.Vendor=${VENDOR_BINARY}" 55 | - -X "github.com/platformsh/cli/internal/config.BuiltBy=goreleaser" 56 | main: ./cmd/platform 57 | 58 | checksum: 59 | name_template: checksums.txt 60 | 61 | snapshot: 62 | version_template: '{{ incpatch .Version }}-{{ .Now.Format "2006-01-02" }}-{{ .ShortCommit }}-next' 63 | 64 | universal_binaries: 65 | - id: ${VENDOR_BINARY}-macos 66 | name_template: ${VENDOR_BINARY} 67 | replace: true 68 | 69 | archives: 70 | - name_template: "${VENDOR_BINARY}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" 71 | files: 72 | - README.md 73 | - completion/* 74 | format_overrides: 75 | - goos: windows 76 | formats: [zip] 77 | 78 | nfpms: 79 | - homepage: https://docs.upsun.com/anchors/fixed/cli/ 80 | package_name: ${VENDOR_BINARY}-cli 81 | description: ${VENDOR_NAME} CLI 82 | maintainer: Antonis Kalipetis 83 | license: MIT 84 | vendor: Platform.sh 85 | ids: 86 | - ${VENDOR_BINARY} 87 | formats: 88 | - apk 89 | - deb 90 | - rpm 91 | contents: 92 | - src: completion/bash/${VENDOR_BINARY}.bash 93 | dst: /etc/bash_completion.d/${VENDOR_BINARY} 94 | - src: completion/zsh/_${VENDOR_BINARY} 95 | dst: /usr/local/share/zsh/site-functions/_${VENDOR_BINARY} 96 | 97 | release: 98 | disable: true 99 | -------------------------------------------------------------------------------- /pkg/mockssh/server_test.go: -------------------------------------------------------------------------------- 1 | package mockssh_test 2 | 3 | import ( 4 | "bytes" 5 | "crypto/ed25519" 6 | "crypto/rand" 7 | "encoding/json" 8 | "fmt" 9 | "net" 10 | "net/http" 11 | "path/filepath" 12 | "strings" 13 | "testing" 14 | 15 | "github.com/stretchr/testify/assert" 16 | "github.com/stretchr/testify/require" 17 | "golang.org/x/crypto/ssh" 18 | 19 | "github.com/platformsh/cli/pkg/mockapi" 20 | "github.com/platformsh/cli/pkg/mockssh" 21 | ) 22 | 23 | func TestServer(t *testing.T) { 24 | authServer := mockapi.NewAuthServer(t) 25 | defer authServer.Close() 26 | 27 | sshServer, err := mockssh.NewServer(t, authServer.URL+"/ssh/authority") 28 | if err != nil { 29 | t.Fatal(err) 30 | } 31 | t.Cleanup(func() { 32 | _ = sshServer.Stop() 33 | }) 34 | 35 | tempDir := t.TempDir() 36 | tempDir, _ = filepath.EvalSymlinks(tempDir) 37 | sshServer.CommandHandler = mockssh.ExecHandler(tempDir, []string{}) 38 | 39 | cert := getTestSSHAuth(t, authServer.URL) 40 | 41 | // Create the SSH client configuration 42 | address := fmt.Sprintf("127.0.0.1:%d", sshServer.Port()) 43 | config := &ssh.ClientConfig{ 44 | User: "test", 45 | Auth: []ssh.AuthMethod{ssh.PublicKeys(cert)}, 46 | HostKeyCallback: func(_ string, remote net.Addr, key ssh.PublicKey) error { 47 | if remote.String() != address { 48 | return fmt.Errorf("unexpected address: %s", remote.String()) 49 | } 50 | if bytes.Equal(sshServer.HostKey().Marshal(), key.Marshal()) { 51 | return nil 52 | } 53 | return fmt.Errorf("host key mismatch") 54 | }, 55 | } 56 | 57 | client, err := ssh.Dial("tcp", address, config) 58 | require.NoError(t, err) 59 | defer client.Close() 60 | 61 | session, err := client.NewSession() 62 | require.NoError(t, err) 63 | defer session.Close() 64 | 65 | stdOutBuffer := &bytes.Buffer{} 66 | session.Stdout = stdOutBuffer 67 | 68 | require.NoError(t, session.Run("pwd")) 69 | assert.Equal(t, tempDir, strings.TrimRight(stdOutBuffer.String(), "\n")) 70 | 71 | session2, err := client.NewSession() 72 | require.NoError(t, err) 73 | defer session2.Close() 74 | err = session2.Run("false") 75 | assert.Error(t, err) 76 | var exitErr *ssh.ExitError 77 | assert.ErrorAs(t, err, &exitErr) 78 | assert.Equal(t, 1, exitErr.ExitStatus()) 79 | } 80 | 81 | func getTestSSHAuth(t *testing.T, authServerURL string) ssh.Signer { 82 | t.Helper() 83 | 84 | // Generate a keypair 85 | _, priv, err := ed25519.GenerateKey(rand.Reader) 86 | require.NoError(t, err) 87 | s, err := ssh.NewSignerFromKey(priv) 88 | require.NoError(t, err) 89 | 90 | b, err := json.Marshal(struct{ Key string }{string(ssh.MarshalAuthorizedKey(s.PublicKey()))}) 91 | require.NoError(t, err) 92 | resp, err := http.DefaultClient.Post(authServerURL+"/ssh", "application/json", bytes.NewReader(b)) 93 | require.NoError(t, err) 94 | defer resp.Body.Close() 95 | 96 | var rs struct{ Certificate string } 97 | require.NoError(t, json.NewDecoder(resp.Body).Decode(&rs)) 98 | 99 | parsed, _, _, _, err := ssh.ParseAuthorizedKey([]byte(rs.Certificate)) //nolint: dogsled 100 | require.NoError(t, err) 101 | 102 | cert, _ := parsed.(*ssh.Certificate) 103 | certSigner, err := ssh.NewCertSigner(cert, s) 104 | require.NoError(t, err) 105 | 106 | return certSigner 107 | } 108 | -------------------------------------------------------------------------------- /pkg/mockapi/orgs.go: -------------------------------------------------------------------------------- 1 | package mockapi 2 | 3 | import ( 4 | "crypto/rand" 5 | "encoding/json" 6 | "net/http" 7 | "net/url" 8 | "path" 9 | "slices" 10 | "strings" 11 | 12 | "github.com/go-chi/chi/v5" 13 | "github.com/oklog/ulid/v2" 14 | "github.com/stretchr/testify/require" 15 | ) 16 | 17 | func (h *Handler) handleOrgRefs(w http.ResponseWriter, req *http.Request) { 18 | h.RLock() 19 | defer h.RUnlock() 20 | require.NoError(h.t, req.ParseForm()) 21 | ids := strings.Split(req.Form.Get("in"), ",") 22 | refs := make(map[string]*OrgRef, len(ids)) 23 | for _, id := range ids { 24 | if o, ok := h.orgs[id]; ok { 25 | refs[id] = o.AsRef() 26 | } else { 27 | refs[id] = nil 28 | } 29 | } 30 | _ = json.NewEncoder(w).Encode(refs) 31 | } 32 | 33 | func (h *Handler) handleListOrgs(w http.ResponseWriter, _ *http.Request) { 34 | h.RLock() 35 | defer h.RUnlock() 36 | var ( 37 | orgs = make([]*Org, 0, len(h.orgs)) 38 | ownerIDs = make(uniqueMap) 39 | ) 40 | for _, o := range h.orgs { 41 | orgs = append(orgs, o) 42 | ownerIDs[o.Owner] = struct{}{} 43 | } 44 | slices.SortFunc(orgs, func(a, b *Org) int { return strings.Compare(a.Name, b.Name) }) 45 | _ = json.NewEncoder(w).Encode(struct { 46 | Items []*Org `json:"items"` 47 | Links HalLinks `json:"_links"` 48 | }{ 49 | Items: orgs, 50 | Links: MakeHALLinks("ref:users:0=/ref/users?in=" + strings.Join(ownerIDs.keys(), ",")), 51 | }) 52 | } 53 | 54 | func (h *Handler) handleGetOrg(w http.ResponseWriter, req *http.Request) { 55 | h.RLock() 56 | defer h.RUnlock() 57 | var org *Org 58 | 59 | orgID := chi.URLParam(req, "organization_id") 60 | if strings.HasPrefix(orgID, "name%3D") { 61 | name := strings.TrimPrefix(orgID, "name%3D") 62 | for _, o := range h.orgs { 63 | if o.Name == name { 64 | org = o 65 | break 66 | } 67 | } 68 | } else { 69 | org = h.orgs[path.Base(req.URL.Path)] 70 | } 71 | 72 | if org == nil { 73 | w.WriteHeader(http.StatusNotFound) 74 | return 75 | } 76 | 77 | _ = json.NewEncoder(w).Encode(org) 78 | } 79 | 80 | func (h *Handler) handleCreateOrg(w http.ResponseWriter, req *http.Request) { 81 | h.Lock() 82 | defer h.Unlock() 83 | var org Org 84 | err := json.NewDecoder(req.Body).Decode(&org) 85 | if err != nil { 86 | w.WriteHeader(http.StatusBadRequest) 87 | return 88 | } 89 | for _, o := range h.orgs { 90 | if o.Name == org.Name { 91 | w.WriteHeader(http.StatusConflict) 92 | return 93 | } 94 | } 95 | org.ID = ulid.MustNew(ulid.Now(), rand.Reader).String() 96 | org.Owner = h.myUser.ID 97 | org.Capabilities = []string{} 98 | org.Links = MakeHALLinks("self=/organizations/" + url.PathEscape(org.ID)) 99 | h.orgs[org.ID] = &org 100 | _ = json.NewEncoder(w).Encode(&org) 101 | } 102 | 103 | func (h *Handler) handlePatchOrg(w http.ResponseWriter, req *http.Request) { 104 | h.Lock() 105 | defer h.Unlock() 106 | orgID := chi.URLParam(req, "organization_id") 107 | p, ok := h.orgs[orgID] 108 | if !ok { 109 | w.WriteHeader(http.StatusNotFound) 110 | return 111 | } 112 | patched := *p 113 | err := json.NewDecoder(req.Body).Decode(&patched) 114 | if err != nil { 115 | w.WriteHeader(http.StatusBadRequest) 116 | return 117 | } 118 | h.orgs[orgID] = &patched 119 | _ = json.NewEncoder(w).Encode(&patched) 120 | } 121 | -------------------------------------------------------------------------------- /internal/config/alt/fetch_test.go: -------------------------------------------------------------------------------- 1 | package alt_test 2 | 3 | import ( 4 | "context" 5 | _ "embed" 6 | "fmt" 7 | "net/http" 8 | "net/http/httptest" 9 | "testing" 10 | "time" 11 | 12 | "github.com/stretchr/testify/assert" 13 | "github.com/stretchr/testify/require" 14 | "gopkg.in/yaml.v3" 15 | 16 | "github.com/platformsh/cli/internal/config" 17 | "github.com/platformsh/cli/internal/config/alt" 18 | ) 19 | 20 | //go:embed test-config.yaml 21 | var testConfig []byte 22 | 23 | func TestFetchConfig(t *testing.T) { 24 | cases := []struct { 25 | path string 26 | handler http.HandlerFunc 27 | expectConfigURL string 28 | expectErrorContaining string 29 | }{ 30 | {path: "/success", handler: func(w http.ResponseWriter, _ *http.Request) { 31 | _, _ = w.Write(testConfig) 32 | }}, 33 | {path: "/withExistingURL", handler: func(w http.ResponseWriter, _ *http.Request) { 34 | cnf, err := config.FromYAML(testConfig) 35 | require.NoError(t, err) 36 | cnf.Metadata.URL = "https://example.com" 37 | _ = yaml.NewEncoder(w).Encode(cnf) 38 | }, expectConfigURL: "https://example.com"}, 39 | {path: "/error", handler: func(w http.ResponseWriter, _ *http.Request) { 40 | w.WriteHeader(http.StatusInternalServerError) 41 | }, expectErrorContaining: "received unexpected response code 500"}, 42 | {path: "/invalid", handler: func(w http.ResponseWriter, _ *http.Request) { 43 | _, _ = fmt.Fprintln(w, "[some invalid config]") 44 | }, expectErrorContaining: "invalid config YAML"}, 45 | } 46 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 47 | for _, c := range cases { 48 | if c.path == r.URL.Path { 49 | c.handler(w, r) 50 | return 51 | } 52 | } 53 | w.WriteHeader(http.StatusNotFound) 54 | })) 55 | defer server.Close() 56 | 57 | // TODO use test context 58 | ctx := context.Background() 59 | ctx = config.ToContext(ctx, &config.Config{}) 60 | 61 | for _, c := range cases { 62 | t.Run(c.path, func(t *testing.T) { 63 | result, cnfStruct, err := alt.FetchConfig(ctx, server.URL+c.path) 64 | if c.expectErrorContaining != "" { 65 | assert.Error(t, err, c.path) 66 | assert.ErrorContains(t, err, c.expectErrorContaining) 67 | } else { 68 | require.NoError(t, err, c.path) 69 | var decoded config.Config 70 | require.NoError(t, result.Decode(&decoded)) 71 | assert.NotEmpty(t, result.HeadComment) 72 | assert.Empty(t, decoded.Wrapper.GitHubRepo) 73 | assert.Empty(t, decoded.Wrapper.HomebrewTap) 74 | assert.Equal(t, decoded.Application.Executable, cnfStruct.Application.Executable) 75 | if c.expectConfigURL != "" { 76 | assert.Equal(t, c.expectConfigURL, decoded.Metadata.URL) 77 | } else { 78 | assert.Equal(t, server.URL+c.path, decoded.Metadata.URL) 79 | } 80 | assert.Greater(t, decoded.Metadata.DownloadedAt, time.Now().Add(-time.Second)) 81 | } 82 | }) 83 | } 84 | 85 | t.Run("invalid_url", func(t *testing.T) { 86 | _, _, err := alt.FetchConfig(ctx, "http://example.com") 87 | assert.ErrorContains(t, err, "invalid") 88 | 89 | _, _, err = alt.FetchConfig(ctx, "://example.com") 90 | assert.ErrorContains(t, err, "missing protocol scheme") 91 | 92 | _, _, err = alt.FetchConfig(ctx, "//example.com") 93 | assert.ErrorContains(t, err, "invalid") 94 | 95 | _, _, err = alt.FetchConfig(ctx, "/path/to/file") 96 | assert.ErrorContains(t, err, "invalid") 97 | }) 98 | } 99 | -------------------------------------------------------------------------------- /PHP-LICENSE: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------- 2 | The PHP License, version 3.01 3 | Copyright (c) 1999 - 2019 The PHP Group. All rights reserved. 4 | -------------------------------------------------------------------- 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, is permitted provided that the following conditions 8 | are met: 9 | 10 | 1. Redistributions of source code must retain the above copyright 11 | notice, this list of conditions and the following disclaimer. 12 | 13 | 2. Redistributions in binary form must reproduce the above copyright 14 | notice, this list of conditions and the following disclaimer in 15 | the documentation and/or other materials provided with the 16 | distribution. 17 | 18 | 3. The name "PHP" must not be used to endorse or promote products 19 | derived from this software without prior written permission. For 20 | written permission, please contact group@php.net. 21 | 22 | 4. Products derived from this software may not be called "PHP", nor 23 | may "PHP" appear in their name, without prior written permission 24 | from group@php.net. You may indicate that your software works in 25 | conjunction with PHP by saying "Foo for PHP" instead of calling 26 | it "PHP Foo" or "phpfoo" 27 | 28 | 5. The PHP Group may publish revised and/or new versions of the 29 | license from time to time. Each version will be given a 30 | distinguishing version number. 31 | Once covered code has been published under a particular version 32 | of the license, you may always continue to use it under the terms 33 | of that version. You may also choose to use such covered code 34 | under the terms of any subsequent version of the license 35 | published by the PHP Group. No one other than the PHP Group has 36 | the right to modify the terms applicable to covered code created 37 | under this License. 38 | 39 | 6. Redistributions of any form whatsoever must retain the following 40 | acknowledgment: 41 | "This product includes PHP software, freely available from 42 | ". 43 | 44 | THIS SOFTWARE IS PROVIDED BY THE PHP DEVELOPMENT TEAM ``AS IS'' AND 45 | ANY EXPRESSED OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, 46 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A 47 | PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE PHP 48 | DEVELOPMENT TEAM OR ITS CONTRIBUTORS BE LIABLE FOR ANY DIRECT, 49 | INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 50 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 51 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) 52 | HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, 53 | STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 54 | ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED 55 | OF THE POSSIBILITY OF SUCH DAMAGE. 56 | 57 | -------------------------------------------------------------------- 58 | 59 | This software consists of voluntary contributions made by many 60 | individuals on behalf of the PHP Group. 61 | 62 | The PHP Group can be contacted via Email at group@php.net. 63 | 64 | For more information on the PHP Group and the PHP project, 65 | please see . 66 | 67 | PHP includes the Zend Engine, freely available at 68 | . 69 | -------------------------------------------------------------------------------- /commands/list.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | 8 | "github.com/spf13/cobra" 9 | "github.com/spf13/viper" 10 | 11 | "github.com/platformsh/cli/internal/config" 12 | ) 13 | 14 | func newListCommand(cnf *config.Config) *cobra.Command { 15 | cmd := &cobra.Command{ 16 | Use: "list [flags] [namespace]", 17 | Short: "Lists commands", 18 | Args: cobra.MaximumNArgs(1), 19 | Run: func(cmd *cobra.Command, args []string) { 20 | arguments := []string{"list", "--format=json"} 21 | if viper.GetBool("all") { 22 | arguments = append(arguments, "--all") 23 | } 24 | if len(args) > 0 { 25 | arguments = append(arguments, args[0]) 26 | } 27 | 28 | var b bytes.Buffer 29 | c := makeLegacyCLIWrapper(cnf, &b, cmd.ErrOrStderr(), cmd.InOrStdin()) 30 | 31 | if err := c.Exec(cmd.Context(), arguments...); err != nil { 32 | exitWithError(err) 33 | } 34 | 35 | var list List 36 | if err := json.Unmarshal(b.Bytes(), &list); err != nil { 37 | exitWithError(err) 38 | } 39 | 40 | // Override the application name and executable with our own config. 41 | list.Application.Name = cnf.Application.Name 42 | list.Application.Executable = cnf.Application.Executable 43 | 44 | projectInitCommand := innerProjectInitCommand(cnf) 45 | 46 | if !list.DescribesNamespace() || list.Namespace == projectInitCommand.Name.Namespace { 47 | list.AddCommand(&projectInitCommand) 48 | } 49 | 50 | appConfigValidateCommand := innerAppConfigValidateCommand(cnf) 51 | 52 | if !list.DescribesNamespace() || list.Namespace == appConfigValidateCommand.Name.Namespace { 53 | list.AddCommand(&appConfigValidateCommand) 54 | } 55 | 56 | appProjectConvertCommand := innerProjectConvertCommand(cnf) 57 | 58 | if cnf.Service.ProjectConfigFlavor == "upsun" && 59 | (!list.DescribesNamespace() || list.Namespace == appProjectConvertCommand.Name.Namespace) { 60 | list.AddCommand(&appProjectConvertCommand) 61 | } 62 | 63 | format := viper.GetString("format") 64 | raw := viper.GetBool("raw") 65 | 66 | var formatter Formatter 67 | switch format { 68 | case "json": 69 | formatter = &JSONListFormatter{} 70 | case "md": 71 | formatter = &MDListFormatter{} 72 | case "txt": 73 | if raw { 74 | formatter = &RawListFormatter{} 75 | } else { 76 | formatter = &TXTListFormatter{} 77 | } 78 | default: 79 | c.Stdout = cmd.OutOrStdout() 80 | arguments := []string{"list", "--format=" + format} 81 | if err := c.Exec(cmd.Context(), arguments...); err != nil { 82 | exitWithError(err) 83 | } 84 | return 85 | } 86 | 87 | result, err := formatter.Format(&list, config.FromContext(cmd.Context())) 88 | if err != nil { 89 | exitWithError(err) 90 | } 91 | 92 | fmt.Fprintln(cmd.OutOrStdout(), string(result)) 93 | }, 94 | } 95 | 96 | cmd.Flags().String("format", "txt", "The output format (txt, json, or md) [default: \"txt\"]") 97 | cmd.Flags().Bool("raw", false, "To output raw command list") 98 | cmd.Flags().Bool("all", false, "Show all commands, including hidden ones") 99 | 100 | viper.BindPFlags(cmd.Flags()) //nolint:errcheck 101 | cmd.SetHelpFunc(func(cmd *cobra.Command, args []string) { 102 | cmd.Root().Run(cmd.Root(), append([]string{"help", "list"}, args...)) 103 | }) 104 | 105 | return cmd 106 | } 107 | -------------------------------------------------------------------------------- /pkg/mockapi/projects.go: -------------------------------------------------------------------------------- 1 | package mockapi 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | "strings" 7 | "time" 8 | 9 | "github.com/go-chi/chi/v5" 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | func (h *Handler) handleProjectRefs(w http.ResponseWriter, req *http.Request) { 14 | h.RLock() 15 | defer h.RUnlock() 16 | require.NoError(h.t, req.ParseForm()) 17 | ids := strings.Split(req.Form.Get("in"), ",") 18 | refs := make(map[string]*ProjectRef, len(ids)) 19 | for _, id := range ids { 20 | if p, ok := h.projects[id]; ok { 21 | refs[id] = p.AsRef() 22 | } else { 23 | refs[id] = nil 24 | } 25 | } 26 | _ = json.NewEncoder(w).Encode(refs) 27 | } 28 | 29 | func (h *Handler) handleGetProject(w http.ResponseWriter, req *http.Request) { 30 | h.RLock() 31 | defer h.RUnlock() 32 | projectID := chi.URLParam(req, "project_id") 33 | if p, ok := h.projects[projectID]; ok { 34 | _ = json.NewEncoder(w).Encode(p) 35 | return 36 | } 37 | w.WriteHeader(http.StatusNotFound) 38 | } 39 | 40 | func (h *Handler) handlePatchProject(w http.ResponseWriter, req *http.Request) { 41 | h.Lock() 42 | defer h.Unlock() 43 | projectID := chi.URLParam(req, "project_id") 44 | p, ok := h.projects[projectID] 45 | if !ok { 46 | w.WriteHeader(http.StatusNotFound) 47 | return 48 | } 49 | patched := *p 50 | err := json.NewDecoder(req.Body).Decode(&patched) 51 | if err != nil { 52 | w.WriteHeader(http.StatusBadRequest) 53 | return 54 | } 55 | patched.UpdatedAt = time.Now() 56 | h.projects[projectID] = &patched 57 | _ = json.NewEncoder(w).Encode(&patched) 58 | } 59 | 60 | func (h *Handler) handleListRegions(w http.ResponseWriter, _ *http.Request) { 61 | type region struct { 62 | ID string `json:"id"` 63 | Label string `json:"label"` 64 | SelectionLabel string `json:"selection_label"` 65 | Available bool `json:"available"` 66 | } 67 | type regions struct { 68 | Regions []region `json:"regions"` 69 | } 70 | _ = json.NewEncoder(w).Encode(regions{[]region{{ 71 | ID: "test-region", 72 | Label: "Test Region", 73 | SelectionLabel: "Test Region", 74 | Available: true, 75 | }}}) 76 | } 77 | 78 | func (h *Handler) handleProjectUserAccess(w http.ResponseWriter, req *http.Request) { 79 | h.RLock() 80 | defer h.RUnlock() 81 | projectID := chi.URLParam(req, "project_id") 82 | require.NoError(h.t, req.ParseForm()) 83 | var ( 84 | projectGrants = make([]*ProjectUserGrant, 0, len(h.userGrants)) 85 | userIDs = make(uniqueMap) 86 | orgIDs = make(uniqueMap) 87 | ) 88 | for _, g := range h.userGrants { 89 | if g.ResourceType == "project" && g.ResourceID == projectID { 90 | projectGrants = append(projectGrants, &ProjectUserGrant{ 91 | ProjectID: g.ResourceID, 92 | OrganizationID: g.OrganizationID, 93 | UserID: g.UserID, 94 | Permissions: g.Permissions, 95 | GrantedAt: g.GrantedAt, 96 | UpdatedAt: g.UpdatedAt, 97 | }) 98 | userIDs[g.UserID] = struct{}{} 99 | orgIDs[g.OrganizationID] = struct{}{} 100 | } 101 | } 102 | ret := struct { 103 | Items []*ProjectUserGrant `json:"items"` 104 | Links HalLinks `json:"_links"` 105 | }{Items: projectGrants, Links: MakeHALLinks( 106 | "ref:users:0=/ref/users?in="+strings.Join(userIDs.keys(), ","), 107 | "ref:organizations:0=/ref/organizations?in="+strings.Join(orgIDs.keys(), ","), 108 | )} 109 | _ = json.NewEncoder(w).Encode(ret) 110 | } 111 | -------------------------------------------------------------------------------- /commands/config_install_test.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "io" 7 | "net/http" 8 | "net/http/httptest" 9 | "os" 10 | "path/filepath" 11 | "testing" 12 | 13 | "github.com/stretchr/testify/assert" 14 | "github.com/stretchr/testify/require" 15 | "gopkg.in/yaml.v3" 16 | 17 | "github.com/platformsh/cli/internal/config" 18 | ) 19 | 20 | func TestConfigInstallCmd(t *testing.T) { 21 | tempDir := t.TempDir() 22 | tempBinDir := filepath.Join(tempDir, "bin") 23 | require.NoError(t, os.Mkdir(tempBinDir, 0o755)) 24 | _ = os.Setenv("HOME", tempDir) 25 | _ = os.Setenv("XDG_CONFIG_HOME", "") 26 | 27 | remoteConfig := testConfig() 28 | 29 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 30 | if req.URL.Path == "/test-config.yaml" { 31 | _ = yaml.NewEncoder(w).Encode(remoteConfig) 32 | } 33 | })) 34 | defer server.Close() 35 | testConfigURL := server.URL + "/test-config.yaml" 36 | 37 | cnf := testConfig() 38 | ctx, cancel := context.WithCancel(context.Background()) 39 | defer cancel() 40 | ctx = config.ToContext(ctx, cnf) 41 | 42 | cmd := newConfigInstallCommand() 43 | cmd.SetContext(ctx) 44 | cmd.SetOut(io.Discard) 45 | _ = cmd.Flags().Set("config-dir", tempDir) 46 | _ = cmd.Flags().Set("bin-dir", tempBinDir) 47 | 48 | args := []string{testConfigURL} 49 | 50 | stdErrBuf := &bytes.Buffer{} 51 | cmd.SetErr(stdErrBuf) 52 | err := cmd.RunE(cmd, args) 53 | assert.ErrorContains(t, err, "cannot install config for same executable name as this program: test") 54 | 55 | cnf.Application.Executable = "test-cli-executable-host" 56 | err = cmd.RunE(cmd, args) 57 | assert.NoError(t, err) 58 | assert.FileExists(t, filepath.Join(tempDir, "test-cli-executable.yaml")) 59 | assert.FileExists(t, filepath.Join(tempBinDir, "test-cli-executable")) 60 | assert.Contains(t, stdErrBuf.String(), "~/test-cli-executable.yaml") 61 | assert.Contains(t, stdErrBuf.String(), "~/bin/test-cli-executable") 62 | assert.Contains(t, stdErrBuf.String(), "Add the following directory to your PATH") 63 | 64 | b, err := os.ReadFile(filepath.Join(tempBinDir, "test-cli-executable")) 65 | require.NoError(t, err) 66 | assert.Contains(t, string(b), `"${HOME}/test-cli-executable.yaml"`) 67 | assert.Contains(t, string(b), `test-cli-executable-host "$@"`) 68 | 69 | _ = os.Setenv("PATH", tempBinDir+":"+os.Getenv("PATH")) 70 | remoteConfig.Application.Executable = "test-cli-executable2" 71 | err = cmd.RunE(cmd, args) 72 | assert.NoError(t, err) 73 | assert.FileExists(t, filepath.Join(tempDir, "test-cli-executable2.yaml")) 74 | assert.FileExists(t, filepath.Join(tempBinDir, "test-cli-executable2")) 75 | assert.Contains(t, stdErrBuf.String(), "~/test-cli-executable2.yaml") 76 | assert.Contains(t, stdErrBuf.String(), "~/bin/test-cli-executable2") 77 | assert.Contains(t, stdErrBuf.String(), "Run the new CLI with: test-cli-executable2") 78 | } 79 | 80 | func testConfig() *config.Config { 81 | cnf := &config.Config{} 82 | cnf.Application.Name = "Test CLI" 83 | cnf.Application.Executable = "test-cli-executable" // Not "test" as that is usually a real binary 84 | cnf.Application.EnvPrefix = "TEST_" 85 | cnf.Application.Slug = "test-cli" 86 | cnf.Application.UserConfigDir = ".test-cli" 87 | cnf.API.BaseURL = "https://localhost" 88 | cnf.API.AuthURL = "https://localhost" 89 | cnf.Detection.GitRemoteName = "platform" 90 | cnf.Service.Name = "Test" 91 | cnf.Service.EnvPrefix = "TEST_" 92 | cnf.Service.ProjectConfigDir = ".test" 93 | cnf.SSH.DomainWildcards = []string{"*"} 94 | return cnf 95 | } 96 | -------------------------------------------------------------------------------- /internal/convert/platformsh.go: -------------------------------------------------------------------------------- 1 | package convert 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "log" 7 | "os" 8 | "path/filepath" 9 | 10 | "github.com/fatih/color" 11 | "github.com/spf13/viper" 12 | "github.com/symfony-cli/terminal" 13 | "github.com/upsun/lib-sun/detector" 14 | "github.com/upsun/lib-sun/entity" 15 | "github.com/upsun/lib-sun/readers" 16 | utils "github.com/upsun/lib-sun/utility" 17 | "github.com/upsun/lib-sun/writers" 18 | ) 19 | 20 | // PlatformshToUpsun performs the conversion from Platform.sh config to Upsun config. 21 | func PlatformshToUpsun(path string, stderr io.Writer) error { 22 | cwd, err := filepath.Abs(filepath.Clean(path)) 23 | if err != nil { 24 | return fmt.Errorf("could not normalize project workspace path: %w", err) 25 | } 26 | 27 | upsunDir := filepath.Join(cwd, ".upsun") 28 | configPath := filepath.Join(upsunDir, "config.yaml") 29 | stat, err := os.Stat(configPath) 30 | if err == nil && !stat.IsDir() { 31 | fmt.Fprintln(stderr, "The file already exists:", color.YellowString(configPath)) 32 | if !viper.GetBool("yes") { 33 | if viper.GetBool("no-interaction") { 34 | return fmt.Errorf("use the -y option to overwrite the file") 35 | } 36 | 37 | if !terminal.AskConfirmation("Do you want to overwrite it?", true) { 38 | return nil 39 | } 40 | } 41 | } 42 | 43 | log.Default().SetOutput(stderr) 44 | 45 | // Find config files 46 | configFiles, err := detector.FindConfig(cwd) 47 | if err != nil { 48 | return fmt.Errorf("could not detect configuration files: %w", err) 49 | } 50 | 51 | // Read PSH application config files 52 | var metaConfig entity.MetaConfig 53 | readers.ReadApplications(&metaConfig, configFiles[entity.PSH_APPLICATION], cwd) 54 | readers.ReadPlatforms(&metaConfig, configFiles[entity.PSH_PLATFORM], cwd) 55 | if metaConfig.Applications.IsZero() { 56 | return fmt.Errorf("no Platform.sh applications found") 57 | } 58 | 59 | // Read PSH services and routes config files 60 | readers.ReadServices(&metaConfig, configFiles[entity.PSH_SERVICE]) 61 | readers.ReadRoutes(&metaConfig, configFiles[entity.PSH_ROUTE]) 62 | 63 | // Remove size and resources entries 64 | fmt.Fprintln(stderr, "Removing any `size`, `resources` or `disk` keys.") 65 | fmt.Fprintln(stderr, 66 | "Upsun disk sizes are set using Console or the "+color.GreenString("upsun resources:set")+" command.") 67 | readers.RemoveAllEntry(&metaConfig.Services, "size") 68 | readers.RemoveAllEntry(&metaConfig.Applications, "size") 69 | readers.RemoveAllEntry(&metaConfig.Services, "resources") 70 | readers.RemoveAllEntry(&metaConfig.Applications, "resources") 71 | readers.RemoveAllEntry(&metaConfig.Applications, "disk") 72 | readers.RemoveAllEntry(&metaConfig.Services, "disk") 73 | 74 | // Fix storage to match Upsun format 75 | fmt.Fprintln(stderr, "Replacing mount types (`local` becomes `instance`, and `shared` becomes `storage`).") 76 | readers.ReplaceAllEntry(&metaConfig.Applications, "local", "instance") 77 | readers.ReplaceAllEntry(&metaConfig.Applications, "shared", "storage") 78 | 79 | if err := os.MkdirAll(upsunDir, os.ModePerm); err != nil { 80 | return fmt.Errorf("could not create .upsun directory: %w", err) 81 | } 82 | 83 | fmt.Fprintln(stderr, "Creating combined configuration file.") 84 | writers.GenerateUpsunConfigFile(metaConfig, configPath) 85 | 86 | // Move extra config 87 | fmt.Fprintln(stderr, "Copying additional files if necessary.") 88 | utils.TransferConfigCustom(cwd, upsunDir) 89 | 90 | fmt.Fprintln(stderr, "Your configuration was successfully converted to the Upsun format.") 91 | fmt.Fprintln(stderr, "Check the generated files in:", color.GreenString(upsunDir)) 92 | return nil 93 | } 94 | -------------------------------------------------------------------------------- /internal/auth/transport_test.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "net/http" 7 | "net/http/httptest" 8 | "testing" 9 | "time" 10 | 11 | "github.com/stretchr/testify/assert" 12 | "github.com/stretchr/testify/require" 13 | "golang.org/x/oauth2" 14 | ) 15 | 16 | // mockRefresher implements both the refresher and oauth2.TokenSource interfaces for testing 17 | type mockRefresher struct { 18 | token *oauth2.Token 19 | } 20 | 21 | func (m *mockRefresher) refreshToken() error { 22 | m.token = &oauth2.Token{ 23 | AccessToken: "valid", 24 | TokenType: "Bearer", 25 | Expiry: time.Now().Add(time.Hour), 26 | } 27 | return nil 28 | } 29 | 30 | func (m *mockRefresher) invalidateToken() error { 31 | m.token = &oauth2.Token{ 32 | AccessToken: "", 33 | TokenType: "Bearer", 34 | Expiry: time.Now().Add(-time.Hour), 35 | } 36 | 37 | return nil 38 | } 39 | 40 | func (m *mockRefresher) Token() (*oauth2.Token, error) { 41 | if m.token == nil || !m.token.Valid() { 42 | if err := m.refreshToken(); err != nil { 43 | return nil, err 44 | } 45 | } 46 | return m.token, nil 47 | } 48 | 49 | func TestTransport_RoundTrip_RetryOn401(t *testing.T) { 50 | // Create a mock server that initially returns 401, then 200 51 | responseCodes := []int{} 52 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 53 | // Read and validate the request body 54 | body, err := io.ReadAll(r.Body) 55 | require.NoError(t, err) 56 | 57 | // Check that we have the expected POST body 58 | assert.Equal(t, "test-body-content", string(body)) 59 | 60 | if r.Header.Get("Authorization") != "Bearer valid" { 61 | w.WriteHeader(http.StatusUnauthorized) 62 | if _, err := w.Write([]byte(`{"error": "unauthorized"}`)); err != nil { 63 | require.NoError(t, err) 64 | } 65 | responseCodes = append(responseCodes, http.StatusUnauthorized) 66 | return 67 | } 68 | 69 | w.WriteHeader(http.StatusOK) 70 | if _, err := w.Write([]byte(`{"success": true}`)); err != nil { 71 | require.NoError(t, err) 72 | } 73 | responseCodes = append(responseCodes, http.StatusOK) 74 | })) 75 | defer server.Close() 76 | 77 | // Create mock refresher with token sequence: first invalid, then valid 78 | mockRef := &mockRefresher{ 79 | token: &oauth2.Token{ 80 | AccessToken: "invalid", 81 | TokenType: "Bearer", 82 | Expiry: time.Now().Add(time.Hour), 83 | }, 84 | } 85 | 86 | // Create our Transport with the mock refresher 87 | transport := &Transport{ 88 | base: &oauth2.Transport{ 89 | Source: mockRef, 90 | Base: http.DefaultTransport, 91 | }, 92 | refresher: mockRef, 93 | } 94 | 95 | // Create HTTP client with our transport 96 | client := &http.Client{Transport: transport} 97 | 98 | // Make a POST request with body content 99 | requestBody := "test-body-content" 100 | req, err := http.NewRequest("POST", server.URL, bytes.NewBufferString(requestBody)) 101 | require.NoError(t, err) 102 | req.Header.Set("Content-Type", "application/json") 103 | 104 | // Execute the request 105 | resp, err := client.Do(req) 106 | require.NoError(t, err) 107 | defer resp.Body.Close() 108 | 109 | // Verify we got a successful response after retry 110 | assert.Equal(t, http.StatusOK, resp.StatusCode) 111 | 112 | responseBody, err := io.ReadAll(resp.Body) 113 | require.NoError(t, err) 114 | assert.Equal(t, `{"success": true}`, string(responseBody)) 115 | 116 | // Assert the response codes (401 first and then a 200) 117 | assert.Equal(t, []int{http.StatusUnauthorized, http.StatusOK}, responseCodes) 118 | } 119 | -------------------------------------------------------------------------------- /commands/list_input_options.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/fatih/color" 7 | 8 | "github.com/platformsh/cli/internal/config" 9 | ) 10 | 11 | func globalOptions(cnf *config.Config) []Option { 12 | return []Option{ 13 | HelpOption, 14 | VerboseOption, 15 | VersionOption, 16 | YesOption, 17 | NoInteractionOption(cnf), 18 | AnsiOption, 19 | NoAnsiOption, 20 | NoOption, 21 | QuietOption, 22 | } 23 | } 24 | 25 | func NoInteractionOption(cnf *config.Config) Option { 26 | return Option{ 27 | Name: "--no-interaction", 28 | Shortcut: "", 29 | AcceptValue: false, 30 | IsValueRequired: false, 31 | IsMultiple: false, 32 | Description: CleanString("Do not ask any interactive questions; accept default values. " + 33 | "Equivalent to using the environment variable: " + 34 | color.YellowString(fmt.Sprintf("%sNO_INTERACTION=1", cnf.Application.EnvPrefix))), 35 | Default: Any{false}, 36 | Hidden: false, 37 | } 38 | } 39 | 40 | var ( 41 | HelpOption = Option{ 42 | Name: "--help", 43 | Shortcut: "-h", 44 | AcceptValue: false, 45 | IsValueRequired: false, 46 | IsMultiple: false, 47 | Description: "Display this help message", 48 | Default: Any{false}, 49 | Hidden: false, 50 | } 51 | VerboseOption = Option{ 52 | Name: "--verbose", 53 | Shortcut: "-v|vv|vvv", 54 | AcceptValue: false, 55 | IsValueRequired: false, 56 | IsMultiple: false, 57 | Description: "Increase the verbosity of messages", 58 | Default: Any{false}, 59 | Hidden: false, 60 | } 61 | VersionOption = Option{ 62 | Name: "--version", 63 | Shortcut: "-V", 64 | AcceptValue: false, 65 | IsValueRequired: false, 66 | IsMultiple: false, 67 | Description: "Display this application version", 68 | Default: Any{false}, 69 | Hidden: false, 70 | } 71 | YesOption = Option{ 72 | Name: "--yes", 73 | Shortcut: "-y", 74 | AcceptValue: false, 75 | IsValueRequired: false, 76 | IsMultiple: false, 77 | Description: "Answer \"yes\" to confirmation questions; " + 78 | "accept the default value for other questions; disable interaction", 79 | Default: Any{false}, 80 | Hidden: false, 81 | } 82 | AnsiOption = Option{ 83 | Name: "--ansi", 84 | Shortcut: "", 85 | AcceptValue: false, 86 | IsValueRequired: false, 87 | IsMultiple: false, 88 | Description: "Force ANSI output", 89 | Default: Any{false}, 90 | Hidden: true, 91 | } 92 | NoAnsiOption = Option{ 93 | Name: "--no-ansi", 94 | Shortcut: "", 95 | AcceptValue: false, 96 | IsValueRequired: false, 97 | IsMultiple: false, 98 | Description: "Disable ANSI output", 99 | Default: Any{false}, 100 | Hidden: true, 101 | } 102 | NoOption = Option{ 103 | Name: "--no", 104 | Shortcut: "-n", 105 | AcceptValue: false, 106 | IsValueRequired: false, 107 | IsMultiple: false, 108 | Description: "Answer \"no\" to confirmation questions; " + 109 | "accept the default value for other questions; disable interaction", 110 | Default: Any{false}, 111 | Hidden: true, 112 | } 113 | QuietOption = Option{ 114 | Name: "--quiet", 115 | Shortcut: "-q", 116 | AcceptValue: false, 117 | IsValueRequired: false, 118 | IsMultiple: false, 119 | Description: "Do not output any message", 120 | Default: Any{false}, 121 | Hidden: true, 122 | } 123 | ) 124 | -------------------------------------------------------------------------------- /internal/convert/testdata/platformsh/.platform/applications.yaml: -------------------------------------------------------------------------------- 1 | - name: drupal 2 | type: php:8.1 3 | source: 4 | root: drupal 5 | dependencies: 6 | php: 7 | composer/composer: ^2 8 | nodejs: 9 | n: "*" 10 | variables: 11 | env: 12 | N_PREFIX: /app/.global 13 | php: 14 | memory_limit: "256M" 15 | runtime: 16 | extensions: 17 | - redis 18 | - newrelic 19 | - apcu 20 | relationships: 21 | database: drupaldb:mysql 22 | databox: drupaldb:databox 23 | redis: cache:redis 24 | auctionssearch: search_solr:auctionssearch 25 | databasesearch: search_solr:databasesearch 26 | userssearch: search_solr:userssearch 27 | orderssearch: search_solr:orderssearch 28 | collectionsearch: search_solr:collectionsearch 29 | disk: 16384 30 | resources: 31 | base_memory: 1024 32 | memory_ratio: 1024 33 | mounts: 34 | web/sites/default/files: 35 | source: local 36 | source_path: files 37 | /tmp: 38 | source: local 39 | source_path: tmp 40 | /private: 41 | source: local 42 | source_path: private 43 | /.drush: 44 | source: local 45 | source_path: drush 46 | /drush-backups: 47 | source: local 48 | source_path: drush-backups 49 | /.console: 50 | source: local 51 | source_path: console 52 | /storage: 53 | source: local 54 | source_path: storage 55 | build: 56 | flavor: none 57 | hooks: 58 | build: | 59 | set -e 60 | n auto 61 | hash -r 62 | composer install --no-dev --prefer-dist --no-progress --no-interaction --optimize-autoloader --apcu-autoloader 63 | composer dumpautoload -o 64 | curl -fsS https://platform.sh/cli/installer | php 65 | deploy: | 66 | set -e 67 | php ./drush/platformsh_generate_drush_yml.php 68 | drush -y updatedb 69 | drush -y config-import 70 | drush -y cache-rebuild 71 | drush locale:check 72 | drush locale:update 73 | web: 74 | locations: 75 | /: 76 | root: web 77 | expires: 1d 78 | passthru: /index.php 79 | allow: false 80 | headers: 81 | Access-Control-Allow-Origin: "*" 82 | rules: 83 | \.(jpe?g|png|gif|svgz?|css|js|map|ico|bmp|eot|woff2?|otf|ttf|webmanifest)$: 84 | allow: true 85 | ^/robots\.txt: 86 | allow: true 87 | ^/sitemap\.xml$: 88 | allow: true 89 | ^/sites/sites\.php$: 90 | scripts: false 91 | ^/sites/[^/]+/settings.*?\.php$: 92 | scripts: false 93 | /sites/default/files: 94 | allow: true 95 | expires: 2w 96 | passthru: /index.php 97 | root: web/sites/default/files 98 | scripts: false 99 | rules: 100 | ^/sites/default/files/(css|js): 101 | expires: 2w 102 | crons: 103 | drupal: 104 | spec: '*/5 * * * *' 105 | cmd: drush core-cron 106 | backup: 107 | spec: '0 5 * * *' 108 | cmd: | 109 | if [ "$PLATFORM_ENVIRONMENT_TYPE" = production ]; then 110 | platform backup:create --yes --no-wait 111 | fi 112 | workers: 113 | queues: 114 | size: S 115 | disk: 1024 116 | commands: 117 | start: php worker.php 118 | - name: app2 119 | type: php:8.1 120 | source: 121 | root: app2 122 | -------------------------------------------------------------------------------- /commands/project_convert.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/spf13/cobra" 8 | "github.com/spf13/viper" 9 | orderedmap "github.com/wk8/go-ordered-map/v2" 10 | 11 | "github.com/platformsh/cli/internal/config" 12 | "github.com/platformsh/cli/internal/convert" 13 | ) 14 | 15 | // innerProjectConvertCommand returns the Command struct for the convert config command. 16 | func innerProjectConvertCommand(cnf *config.Config) Command { 17 | noInteractionOption := NoInteractionOption(cnf) 18 | providerOption := Option{ 19 | Name: "--provider", 20 | Shortcut: "-p", 21 | IsValueRequired: false, 22 | Default: Any{"platformsh"}, 23 | Description: "The provider from which to convert the configuration. Currently, only 'platformsh' is supported.", 24 | } 25 | 26 | return Command{ 27 | Name: CommandName{ 28 | Namespace: "project", 29 | Command: "convert", 30 | }, 31 | Usage: []string{ 32 | cnf.Application.Executable + " convert", 33 | }, 34 | Aliases: []string{ 35 | "convert", 36 | }, 37 | Description: "Generate an Upsun compatible configuration based on the configuration from another provider.", 38 | Help: "", 39 | Examples: []Example{ 40 | { 41 | Commandline: "--provider=platformsh", 42 | Description: "Convert the Platform.sh project configuration files in your current directory", 43 | }, 44 | }, 45 | Definition: Definition{ 46 | Arguments: &orderedmap.OrderedMap[string, Argument]{}, 47 | Options: orderedmap.New[string, Option](orderedmap.WithInitialData[string, Option]( 48 | orderedmap.Pair[string, Option]{ 49 | Key: HelpOption.GetName(), 50 | Value: HelpOption, 51 | }, 52 | orderedmap.Pair[string, Option]{ 53 | Key: VerboseOption.GetName(), 54 | Value: VerboseOption, 55 | }, 56 | orderedmap.Pair[string, Option]{ 57 | Key: VersionOption.GetName(), 58 | Value: VersionOption, 59 | }, 60 | orderedmap.Pair[string, Option]{ 61 | Key: YesOption.GetName(), 62 | Value: YesOption, 63 | }, 64 | orderedmap.Pair[string, Option]{ 65 | Key: noInteractionOption.GetName(), 66 | Value: noInteractionOption, 67 | }, 68 | orderedmap.Pair[string, Option]{ 69 | Key: "provider", 70 | Value: providerOption, 71 | }, 72 | )), 73 | }, 74 | Hidden: false, 75 | } 76 | } 77 | 78 | // newProjectConvertCommand creates the cobra command for converting config. 79 | func newProjectConvertCommand(cnf *config.Config) *cobra.Command { 80 | cmd := &cobra.Command{ 81 | Use: "project:convert", 82 | Short: "Generate locally Upsun configuration from another provider", 83 | Aliases: []string{"convert"}, 84 | RunE: runProjectConvert, 85 | } 86 | 87 | cmd.Flags().StringP( 88 | "provider", 89 | "p", 90 | "platformsh", 91 | "The provider from which to convert the configuration. Currently, only 'platformsh' is supported.", 92 | ) 93 | 94 | _ = viper.BindPFlag("provider", cmd.Flags().Lookup("provider")) 95 | cmd.SetHelpFunc(func(_ *cobra.Command, _ []string) { 96 | internalCmd := innerProjectConvertCommand(cnf) 97 | fmt.Println(internalCmd.HelpPage(cnf)) 98 | }) 99 | return cmd 100 | } 101 | 102 | // runProjectConvert is the entry point for the convert config command. 103 | func runProjectConvert(cmd *cobra.Command, _ []string) error { 104 | cwd, err := os.Getwd() 105 | if err != nil { 106 | return fmt.Errorf("could not get current working directory: %w", err) 107 | } 108 | 109 | if viper.GetString("provider") == "platformsh" { 110 | return convert.PlatformshToUpsun(cwd, cmd.ErrOrStderr()) 111 | } 112 | 113 | return fmt.Errorf("only the 'platformsh' provider is currently supported") 114 | } 115 | -------------------------------------------------------------------------------- /pkg/mockapi/store.go: -------------------------------------------------------------------------------- 1 | package mockapi 2 | 3 | import ( 4 | "sync" 5 | ) 6 | 7 | type store struct { 8 | sync.RWMutex 9 | myUser *User 10 | orgs map[string]*Org 11 | projects map[string]*Project 12 | environments map[string]*Environment 13 | subscriptions map[string]*Subscription 14 | userGrants []*UserGrant 15 | 16 | canCreate map[string]*CanCreateResponse 17 | 18 | activities map[string]map[string]*Activity 19 | projectBackups map[string]map[string]*Backup 20 | projectVariables map[string][]*Variable 21 | envLevelVariables map[string]map[string][]*EnvLevelVariable 22 | } 23 | 24 | func (s *store) SetEnvironments(envs []*Environment) { 25 | s.Lock() 26 | defer s.Unlock() 27 | s.environments = make(map[string]*Environment, len(envs)) 28 | for _, e := range envs { 29 | s.environments[e.ID] = e 30 | } 31 | } 32 | 33 | func (s *store) findEnvironment(projectID, envID string) *Environment { 34 | s.RLock() 35 | defer s.RUnlock() 36 | for id, e := range s.environments { 37 | if e.Project == projectID && id == envID { 38 | return e 39 | } 40 | } 41 | return nil 42 | } 43 | 44 | func (s *store) SetProjects(pros []*Project) { 45 | s.Lock() 46 | defer s.Unlock() 47 | s.projects = make(map[string]*Project, len(pros)) 48 | for _, p := range pros { 49 | s.projects[p.ID] = p 50 | } 51 | } 52 | 53 | func (s *store) SetOrgs(orgs []*Org) { 54 | s.Lock() 55 | defer s.Unlock() 56 | s.orgs = make(map[string]*Org, len(orgs)) 57 | for _, o := range orgs { 58 | s.orgs[o.ID] = o 59 | } 60 | } 61 | 62 | func (s *store) SetCanCreate(orgID string, r *CanCreateResponse) { 63 | s.Lock() 64 | defer s.Unlock() 65 | if s.canCreate == nil { 66 | s.canCreate = make(map[string]*CanCreateResponse) 67 | } 68 | s.canCreate[orgID] = r 69 | } 70 | 71 | func (s *store) SetUserGrants(grants []*UserGrant) { 72 | s.Lock() 73 | defer s.Unlock() 74 | s.userGrants = grants 75 | } 76 | 77 | func (s *store) SetMyUser(u *User) { 78 | s.myUser = u 79 | } 80 | 81 | func (s *store) SetProjectActivities(projectID string, activities []*Activity) { 82 | s.Lock() 83 | defer s.Unlock() 84 | if s.activities == nil { 85 | s.activities = make(map[string]map[string]*Activity) 86 | } 87 | if s.activities[projectID] == nil { 88 | s.activities[projectID] = make(map[string]*Activity) 89 | } 90 | for _, a := range activities { 91 | s.activities[projectID][a.ID] = a 92 | } 93 | } 94 | 95 | func (s *store) SetProjectBackups(projectID string, backups []*Backup) { 96 | s.Lock() 97 | defer s.Unlock() 98 | if s.projectBackups == nil { 99 | s.projectBackups = make(map[string]map[string]*Backup) 100 | } 101 | if s.projectBackups[projectID] == nil { 102 | s.projectBackups[projectID] = make(map[string]*Backup) 103 | } 104 | for _, b := range backups { 105 | s.projectBackups[projectID][b.ID] = b 106 | } 107 | } 108 | 109 | func (s *store) addProjectBackup(projectID string, backup *Backup) { 110 | s.Lock() 111 | defer s.Unlock() 112 | if s.projectBackups == nil { 113 | s.projectBackups = make(map[string]map[string]*Backup) 114 | } 115 | if s.projectBackups[projectID] == nil { 116 | s.projectBackups[projectID] = make(map[string]*Backup) 117 | } 118 | s.projectBackups[projectID][backup.ID] = backup 119 | } 120 | 121 | func (s *store) SetProjectVariables(projectID string, vars []*Variable) { 122 | s.Lock() 123 | defer s.Unlock() 124 | if s.projectVariables == nil { 125 | s.projectVariables = make(map[string][]*Variable) 126 | } 127 | s.projectVariables[projectID] = vars 128 | } 129 | 130 | func (s *store) SetEnvLevelVariables(projectID, environmentID string, vars []*EnvLevelVariable) { 131 | s.Lock() 132 | defer s.Unlock() 133 | if s.envLevelVariables == nil { 134 | s.envLevelVariables = make(map[string]map[string][]*EnvLevelVariable) 135 | } 136 | if s.envLevelVariables[projectID] == nil { 137 | s.envLevelVariables[projectID] = make(map[string][]*EnvLevelVariable) 138 | } 139 | s.envLevelVariables[projectID][environmentID] = vars 140 | } 141 | -------------------------------------------------------------------------------- /internal/config/alt/update_test.go: -------------------------------------------------------------------------------- 1 | package alt_test 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "net/http/httptest" 8 | "os" 9 | "path/filepath" 10 | "testing" 11 | "time" 12 | 13 | "github.com/stretchr/testify/assert" 14 | "github.com/stretchr/testify/require" 15 | 16 | "github.com/platformsh/cli/internal/config" 17 | "github.com/platformsh/cli/internal/config/alt" 18 | "github.com/platformsh/cli/internal/state" 19 | ) 20 | 21 | func TestUpdate(t *testing.T) { 22 | tempDir := t.TempDir() 23 | 24 | // Copy test config to a temporary directory, and fake its modification time. 25 | testConfigFilename := filepath.Join(tempDir, "config.yaml") 26 | err := os.WriteFile(testConfigFilename, testConfig, 0o600) 27 | require.NoError(t, err) 28 | 29 | cnf, err := config.FromYAML(testConfig) 30 | require.NoError(t, err) 31 | 32 | // Set up state so that it stays in a temporary directory. 33 | err = os.Setenv(cnf.Application.EnvPrefix+"HOME", tempDir) 34 | require.NoError(t, err) 35 | 36 | // Set up the config to be updated via a test HTTP server. 37 | remoteConfig := testConfig 38 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 39 | if req.URL.Path == "/config.yaml" { 40 | _, _ = w.Write(remoteConfig) 41 | return 42 | } 43 | w.WriteHeader(http.StatusNotFound) 44 | })) 45 | defer server.Close() 46 | 47 | cnf.SourceFile = testConfigFilename 48 | cnf.Updates.CheckInterval = 1 49 | cnf.Metadata.URL = server.URL + "/config.yaml" 50 | 51 | // TODO use test context 52 | ctx, cancel := context.WithCancel(context.Background()) 53 | defer cancel() 54 | ctx = config.ToContext(ctx, cnf) 55 | 56 | var lastLogged string 57 | logger := func(msg string, args ...any) { 58 | lastLogged = fmt.Sprintf(msg, args...) 59 | } 60 | 61 | assert.True(t, alt.ShouldUpdate(cnf)) 62 | 63 | err = alt.Update(ctx, cnf, logger) 64 | assert.NoError(t, err) 65 | assert.Contains(t, lastLogged, "Config file updated recently") 66 | 67 | hourAgo := time.Now().Add(-time.Hour) 68 | require.NoError(t, os.Chtimes(testConfigFilename, hourAgo, hourAgo)) 69 | 70 | err = alt.Update(ctx, cnf, logger) 71 | assert.NoError(t, err) 72 | assert.Contains(t, lastLogged, "Automatically updated config file") 73 | 74 | err = alt.Update(ctx, cnf, logger) 75 | assert.NoError(t, err) 76 | assert.Contains(t, lastLogged, "Config updates checked recently") 77 | 78 | // Reset the LastChecked time and file modified time. 79 | resetTimes := func() { 80 | s, err := state.Load(cnf) 81 | require.NoError(t, err) 82 | s.ConfigUpdates.LastChecked = 0 83 | require.NoError(t, state.Save(s, cnf)) 84 | require.NoError(t, os.Chtimes(testConfigFilename, hourAgo, hourAgo)) 85 | } 86 | resetTimes() 87 | 88 | remoteConfig = append(remoteConfig, []byte("\nmetadata: {version: 1.0.1}")...) 89 | cnf.Metadata.Version = "invalid" 90 | err = alt.Update(ctx, cnf, logger) 91 | assert.ErrorContains(t, err, "could not compare config versions") 92 | resetTimes() 93 | cnf.Metadata.Version = "1.0.1" 94 | err = alt.Update(ctx, cnf, logger) 95 | assert.NoError(t, err) 96 | assert.Contains(t, lastLogged, "Config is already up to date (version 1.0.1)") 97 | 98 | resetTimes() 99 | 100 | updated := time.Now() 101 | cnf.Metadata.Version = "" 102 | cnf.Metadata.UpdatedAt = updated 103 | remoteConfig = testConfig 104 | remoteConfig = append(remoteConfig, 105 | []byte(fmt.Sprintf("\nmetadata: {updated_at: %s}", updated.Add(-time.Minute).Format(time.RFC3339)))...) 106 | err = alt.Update(ctx, cnf, logger) 107 | assert.NoError(t, err) 108 | assert.Contains(t, lastLogged, "Config is already up to date") 109 | } 110 | 111 | func TestShouldUpdate(t *testing.T) { 112 | testConfigFilename := "/tmp/mock/path/to/config.yaml" 113 | 114 | cnf, err := config.FromYAML(testConfig) 115 | require.NoError(t, err) 116 | 117 | cnf.Updates.Check = true 118 | cnf.SourceFile = testConfigFilename 119 | cnf.Metadata.URL = "https://example.com/config.yaml" 120 | assert.True(t, alt.ShouldUpdate(cnf)) 121 | 122 | cnf.Updates.Check = false 123 | assert.False(t, alt.ShouldUpdate(cnf)) 124 | 125 | cnf.Updates.Check = true 126 | cnf.SourceFile = "" 127 | assert.False(t, alt.ShouldUpdate(cnf)) 128 | 129 | cnf.SourceFile = testConfigFilename 130 | cnf.Metadata.URL = "" 131 | assert.False(t, alt.ShouldUpdate(cnf)) 132 | } 133 | -------------------------------------------------------------------------------- /pkg/mockapi/auth_server.go: -------------------------------------------------------------------------------- 1 | package mockapi 2 | 3 | import ( 4 | "crypto" 5 | "crypto/ed25519" 6 | "crypto/rand" 7 | "encoding/json" 8 | "net/http" 9 | "net/http/httptest" 10 | "slices" 11 | "testing" 12 | "time" 13 | 14 | "github.com/go-chi/chi/v5" 15 | "github.com/go-chi/chi/v5/middleware" 16 | "github.com/stretchr/testify/require" 17 | "golang.org/x/crypto/ssh" 18 | ) 19 | 20 | var ValidAPITokens = []string{"api-token-1"} 21 | var accessTokens = []string{"access-token-1"} 22 | 23 | // NewAuthServer creates a new mock authentication server. 24 | // The caller must call Close() on the server when finished. 25 | func NewAuthServer(t *testing.T) *httptest.Server { 26 | mux := chi.NewRouter() 27 | if testing.Verbose() { 28 | mux.Use(middleware.DefaultLogger) 29 | } 30 | 31 | mux.Post("/oauth2/token", func(w http.ResponseWriter, req *http.Request) { 32 | require.NoError(t, req.ParseForm()) 33 | if gt := req.Form.Get("grant_type"); gt != "api_token" { 34 | w.WriteHeader(http.StatusBadRequest) 35 | _ = json.NewEncoder(w).Encode(map[string]string{"error": "invalid grant type: " + gt}) 36 | return 37 | } 38 | apiToken := req.Form.Get("api_token") 39 | if slices.Contains(ValidAPITokens, apiToken) { 40 | _ = json.NewEncoder(w).Encode(struct { 41 | AccessToken string `json:"access_token"` 42 | ExpiresIn int `json:"expires_in"` 43 | Type string `json:"token_type"` 44 | }{AccessToken: accessTokens[0], ExpiresIn: 60, Type: "bearer"}) 45 | return 46 | } 47 | w.WriteHeader(http.StatusBadRequest) 48 | _ = json.NewEncoder(w).Encode(map[string]string{"error": "invalid API token"}) 49 | }) 50 | 51 | mux.Get("/ssh/authority", func(w http.ResponseWriter, _ *http.Request) { 52 | pks, err := publicKeys() 53 | require.NoError(t, err) 54 | data := struct { 55 | Authorities []string `json:"authorities"` 56 | }{} 57 | for _, k := range pks { 58 | sshPubKey, err := ssh.NewPublicKey(k) 59 | require.NoError(t, err) 60 | data.Authorities = append(data.Authorities, string(ssh.MarshalAuthorizedKey(sshPubKey))) 61 | } 62 | _ = json.NewEncoder(w).Encode(data) 63 | }) 64 | 65 | mux.Post("/ssh", func(w http.ResponseWriter, req *http.Request) { 66 | var options struct { 67 | PublicKey string `json:"key"` 68 | } 69 | err := json.NewDecoder(req.Body).Decode(&options) 70 | require.NoError(t, err) 71 | key, _, _, _, err := ssh.ParseAuthorizedKey([]byte(options.PublicKey)) 72 | require.NoError(t, err) 73 | signer, err := sshSigner() 74 | require.NoError(t, err) 75 | extensions := make(map[string]string) 76 | 77 | // Add standard ssh options 78 | extensions["permit-X11-forwarding"] = "" 79 | extensions["permit-agent-forwarding"] = "" 80 | extensions["permit-port-forwarding"] = "" 81 | extensions["permit-pty"] = "" 82 | extensions["permit-user-rc"] = "" 83 | cert := &ssh.Certificate{ 84 | Key: key, 85 | Serial: 0, 86 | CertType: ssh.UserCert, 87 | KeyId: "test-key-id", 88 | ValidAfter: uint64(time.Now().Add(-1 * time.Second).Unix()), //nolint:gosec // G115 89 | ValidBefore: uint64(time.Now().Add(time.Minute).Unix()), //nolint:gosec // G115 90 | Permissions: ssh.Permissions{ 91 | Extensions: extensions, 92 | }, 93 | } 94 | err = cert.SignCert(rand.Reader, signer) 95 | require.NoError(t, err) 96 | _ = json.NewEncoder(w).Encode(struct { 97 | Cert string `json:"certificate"` 98 | }{string(ssh.MarshalAuthorizedKey(cert))}) 99 | }) 100 | 101 | return httptest.NewServer(mux) 102 | } 103 | 104 | // publicKeys returns the server's public keys, e.g. for SSH certificate generation. 105 | func publicKeys() ([]crypto.PublicKey, error) { 106 | pub, _, err := keyPair() 107 | if err != nil { 108 | return nil, err 109 | } 110 | 111 | return []crypto.PublicKey{pub}, nil 112 | } 113 | 114 | var ( 115 | privateKey crypto.PrivateKey 116 | publicKey crypto.PublicKey 117 | ) 118 | 119 | func keyPair() (crypto.PublicKey, crypto.PrivateKey, error) { 120 | if privateKey == nil || publicKey == nil { 121 | pub, priv, err := ed25519.GenerateKey(rand.Reader) 122 | if err != nil { 123 | return nil, nil, err 124 | } 125 | privateKey = priv 126 | publicKey = pub 127 | } 128 | return publicKey, privateKey, nil 129 | } 130 | 131 | var signer ssh.Signer 132 | 133 | func sshSigner() (ssh.Signer, error) { 134 | if signer != nil { 135 | return signer, nil 136 | } 137 | _, priv, err := keyPair() 138 | if err != nil { 139 | return nil, err 140 | } 141 | s, err := ssh.NewSignerFromKey(priv) 142 | if err != nil { 143 | return nil, err 144 | } 145 | signer = s 146 | return s, nil 147 | } 148 | -------------------------------------------------------------------------------- /pkg/mockapi/api_server.go: -------------------------------------------------------------------------------- 1 | // Package mockapi provides mocks of the HTTP API for use in integration tests. 2 | package mockapi 3 | 4 | import ( 5 | "encoding/json" 6 | "net/http" 7 | "strings" 8 | "testing" 9 | 10 | "github.com/go-chi/chi/v5" 11 | "github.com/go-chi/chi/v5/middleware" 12 | "github.com/stretchr/testify/require" 13 | ) 14 | 15 | type Handler struct { 16 | *chi.Mux 17 | 18 | t *testing.T 19 | 20 | store 21 | } 22 | 23 | func NewHandler(t *testing.T) *Handler { 24 | h := &Handler{t: t} 25 | h.Mux = chi.NewRouter() 26 | 27 | if testing.Verbose() { 28 | h.Use(middleware.DefaultLogger) 29 | } 30 | 31 | h.Use(func(next http.Handler) http.Handler { 32 | return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 33 | authHeader := req.Header.Get("Authorization") 34 | require.NotEmpty(t, authHeader) 35 | require.True(t, strings.HasPrefix(authHeader, "Bearer ")) 36 | next.ServeHTTP(w, req) 37 | }) 38 | }) 39 | 40 | h.Get("/users/me", h.handleUsersMe) 41 | h.Get("/users/{user_id}/extended-access", h.handleUserExtendedAccess) 42 | h.Get("/ref/users", h.handleUserRefs) 43 | h.Post("/me/verification", func(w http.ResponseWriter, _ *http.Request) { 44 | _ = json.NewEncoder(w).Encode(map[string]any{"state": false, "type": ""}) 45 | }) 46 | 47 | h.Get("/organizations", h.handleListOrgs) 48 | h.Post("/organizations", h.handleCreateOrg) 49 | h.Get("/organizations/{organization_id}", h.handleGetOrg) 50 | h.Patch("/organizations/{organization_id}", h.handlePatchOrg) 51 | h.Get("/users/{user_id}/organizations", h.handleListOrgs) 52 | h.Get("/ref/organizations", h.handleOrgRefs) 53 | 54 | h.Post("/organizations/{organization_id}/subscriptions", h.handleCreateSubscription) 55 | h.Get("/subscriptions/{subscription_id}", h.handleGetSubscription) 56 | h.Get("/organizations/{organization_id}/subscriptions/{subscription_id}", h.handleGetSubscription) 57 | h.Get("/organizations/{organization_id}/subscriptions/can-create", h.handleCanCreateSubscriptions) 58 | h.Get("/organizations/{organization_id}/setup/options", func(w http.ResponseWriter, _ *http.Request) { 59 | type options struct { 60 | Plans []string `json:"plans"` 61 | Regions []string `json:"regions"` 62 | } 63 | _ = json.NewEncoder(w).Encode(options{[]string{"development"}, []string{"test-region"}}) 64 | }) 65 | h.Get("/organizations/{organization_id}/subscriptions/estimate", func(w http.ResponseWriter, _ *http.Request) { 66 | _ = json.NewEncoder(w).Encode(map[string]any{"total": "$1,000 USD"}) 67 | }) 68 | 69 | h.Get("/projects/{project_id}", h.handleGetProject) 70 | h.Patch("/projects/{project_id}", h.handlePatchProject) 71 | h.Get("/projects/{project_id}/environments", h.handleListEnvironments) 72 | h.Get("/projects/{project_id}/environments/{environment_id}", h.handleGetEnvironment) 73 | h.Patch("/projects/{project_id}/environments/{environment_id}", h.handlePatchEnvironment) 74 | h.Get("/projects/{project_id}/environments/{environment_id}/settings", h.handleGetEnvironmentSettings) 75 | h.Patch("/projects/{project_id}/environments/{environment_id}/settings", h.handleSetEnvironmentSettings) 76 | h.Post("/projects/{project_id}/environments/{environment_id}/deploy", h.handleDeployEnvironment) 77 | h.Get("/projects/{project_id}/environments/{environment_id}/backups", h.handleListBackups) 78 | h.Post("/projects/{project_id}/environments/{environment_id}/backups", h.handleCreateBackup) 79 | h.Get("/projects/{project_id}/environments/{environment_id}/deployments/current", h.handleGetCurrentDeployment) 80 | h.Get("/projects/{project_id}/user-access", h.handleProjectUserAccess) 81 | h.Get("/ref/projects", h.handleProjectRefs) 82 | 83 | h.Get("/regions", h.handleListRegions) 84 | 85 | h.Get("/projects/{project_id}/activities", h.handleListProjectActivities) 86 | h.Get("/projects/{project_id}/activities/{id}", h.handleGetProjectActivity) 87 | h.Get("/projects/{project_id}/environments/{environment_id}/activities", h.handleListEnvironmentActivities) 88 | h.Get("/projects/{project_id}/environments/{environment_id}/activities/{id}", h.handleGetEnvironmentActivity) 89 | 90 | h.Get("/projects/{project_id}/variables", h.handleListProjectVariables) 91 | h.Post("/projects/{project_id}/variables", h.handleCreateProjectVariable) 92 | h.Get("/projects/{project_id}/variables/{name}", h.handleGetProjectVariable) 93 | h.Patch("/projects/{project_id}/variables/{name}", h.handlePatchProjectVariable) 94 | h.Get("/projects/{project_id}/environments/{environment_id}/variables", h.handleListEnvLevelVariables) 95 | h.Post("/projects/{project_id}/environments/{environment_id}/variables", h.handleCreateEnvLevelVariable) 96 | h.Get("/projects/{project_id}/environments/{environment_id}/variables/{name}", h.handleGetEnvLevelVariable) 97 | h.Patch("/projects/{project_id}/environments/{environment_id}/variables/{name}", h.handlePatchEnvLevelVariable) 98 | 99 | return h 100 | } 101 | -------------------------------------------------------------------------------- /internal/config/alt/fetch.go: -------------------------------------------------------------------------------- 1 | package alt 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "net/url" 9 | "time" 10 | 11 | "gopkg.in/yaml.v3" 12 | 13 | "github.com/platformsh/cli/internal/config" 14 | ) 15 | 16 | // FetchConfig makes an HTTP request to fetch some config YAML, validates it, and returns decoded versions. 17 | // 18 | // The config is unmarshalled to a YAML document node, "cnfNode", to avoid 19 | // needing to know the whole schema, and to preserve comments. A comment and some 20 | // metadata are added to the cnfNode. A "cnfStruct" is also returned to allow 21 | // reading some keys. 22 | // 23 | //nolint:gocritic // The "importShadow" rule complains about the url parameter. 24 | func FetchConfig(ctx context.Context, url string) (cnfNode *yaml.Node, cnfStruct *config.Config, err error) { 25 | if err := validateConfigURL(url); err != nil { 26 | return nil, nil, err 27 | } 28 | httpClient := &http.Client{Timeout: 10 * time.Second} 29 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody) 30 | if err != nil { 31 | return nil, nil, err 32 | } 33 | if cnf, ok := config.MaybeFromContext(ctx); ok { 34 | req.Header.Set("User-Agent", cnf.UserAgent()) 35 | } 36 | resp, err := httpClient.Do(req) 37 | if err != nil { 38 | return nil, nil, fmt.Errorf("failed to fetch config: %w", err) 39 | } 40 | defer resp.Body.Close() 41 | if resp.StatusCode != http.StatusOK { 42 | return nil, nil, fmt.Errorf("received unexpected response code %d from URL %s", resp.StatusCode, url) 43 | } 44 | b, err := io.ReadAll(resp.Body) 45 | if err != nil { 46 | return nil, nil, err 47 | } 48 | 49 | return processConfig(b, url, time.Now()) 50 | } 51 | 52 | func validateConfigURL(urlStr string) error { 53 | u, err := url.Parse(urlStr) 54 | if err != nil { 55 | return err 56 | } 57 | if u.Scheme != "https" && u.Hostname() != "127.0.0.1" { 58 | return fmt.Errorf("invalid config URL scheme (https required)") 59 | } 60 | if u.Host == "" { 61 | return fmt.Errorf("invalid config URL (no host)") 62 | } 63 | return nil 64 | } 65 | 66 | func processConfig(b []byte, downloadURL string, downloadedAt time.Time) (*yaml.Node, *config.Config, error) { 67 | // Validate the config. 68 | cnf, err := config.FromYAML(b) 69 | if err != nil { 70 | return nil, nil, err 71 | } 72 | 73 | // Unmarshal the source YAML to a generic YAML document node. 74 | node := &yaml.Node{} 75 | if err := yaml.Unmarshal(b, node); err != nil { 76 | return nil, nil, err 77 | } 78 | 79 | // Delete the "wrapper" key from the config document node, if defined. 80 | // This prevents a confusing update check. 81 | if err := deleteDocumentKey(node, "wrapper"); err != nil { 82 | return nil, nil, err 83 | } 84 | 85 | // Add metadata to the config document node. 86 | metadata := &cnf.Metadata 87 | if metadata.URL != "" { 88 | if err := validateConfigURL(metadata.URL); err != nil { 89 | return nil, nil, err 90 | } 91 | } else { 92 | metadata.URL = downloadURL 93 | } 94 | metadata.DownloadedAt = downloadedAt 95 | if _err := addMetadata(node, metadata); _err != nil { 96 | return nil, nil, fmt.Errorf("failed to add config metadata: %w", _err) 97 | } 98 | 99 | // Add a comment to the config document node. 100 | node.HeadComment = fmt.Sprintf( 101 | "%s configuration.\n"+ 102 | "Do not edit this file, as it will be replaced with updated versions automatically.\n", 103 | cnf.Application.Name, 104 | ) 105 | 106 | return node, cnf, nil 107 | } 108 | 109 | // addMetadata adds a "metadata" section to a config document node. 110 | func addMetadata(doc *yaml.Node, metadata *config.Metadata) error { 111 | node, err := structToYAMLNode(metadata) 112 | if err != nil { 113 | return err 114 | } 115 | 116 | // Delete the existing metadata key if it exists. 117 | if err := deleteDocumentKey(doc, "metadata"); err != nil { 118 | return err 119 | } 120 | 121 | // Add the new key and value. 122 | keyNode := &yaml.Node{ 123 | Kind: yaml.ScalarNode, 124 | Value: "metadata", 125 | } 126 | mapNode := doc.Content[0] 127 | mapNode.Content = append(mapNode.Content, keyNode, node) 128 | 129 | return nil 130 | } 131 | 132 | // structToYAMLNode converts a struct to a YAML mapping node. 133 | func structToYAMLNode(s any) (*yaml.Node, error) { 134 | b, err := yaml.Marshal(s) 135 | if err != nil { 136 | return nil, err 137 | } 138 | var doc = &yaml.Node{} 139 | if err := yaml.Unmarshal(b, doc); err != nil { 140 | return nil, err 141 | } 142 | // Unmarshal returns a document node containing 1 mapping node. 143 | return doc.Content[0], nil 144 | } 145 | 146 | func deleteDocumentKey(doc *yaml.Node, toDelete string) error { 147 | if doc.Kind != yaml.DocumentNode { 148 | return fmt.Errorf("unable to delete key: the node is not a document") 149 | } 150 | mapNode := doc.Content[0] 151 | // The map node contains keys and values in pairs, e.g. key at 0, value at 1, etc. 152 | for i := 0; i < len(mapNode.Content); i += 2 { 153 | keyNode := mapNode.Content[i] 154 | if keyNode.Kind == yaml.ScalarNode && keyNode.Value == toDelete { 155 | // Remove the key-value pair. 156 | mapNode.Content = append(mapNode.Content[:i], mapNode.Content[i+2:]...) 157 | return nil // Key found and deleted 158 | } 159 | } 160 | 161 | return nil // Key not found 162 | } 163 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | PHP_VERSION = 8.2.29 2 | LEGACY_CLI_VERSION = 4.28.2 3 | 4 | GORELEASER_ID ?= upsun 5 | 6 | ifeq ($(GOOS), darwin) 7 | GORELEASER_ID=$(GORELEASER_ID)-macos 8 | endif 9 | 10 | # The OpenSSL version must be compatible with the PHP version. 11 | # See: https://www.php.net/manual/en/openssl.requirements.php 12 | OPENSSL_VERSION = 1.1.1t 13 | 14 | GOOS := $(shell uname -s | tr '[:upper:]' '[:lower:]') 15 | GOARCH := $(shell uname -m) 16 | ifeq ($(GOARCH), x86_64) 17 | GOARCH=amd64 18 | endif 19 | ifeq ($(GOARCH), aarch64) 20 | GOARCH=arm64 21 | endif 22 | 23 | PHP_BINARY_PATH := internal/legacy/archives/php_$(GOOS)_$(GOARCH) 24 | VERSION := $(shell git describe --always) 25 | 26 | # Tooling versions 27 | GORELEASER_VERSION=v2.12.0 28 | 29 | internal/legacy/archives/platform.phar: 30 | curl -L https://github.com/platformsh/legacy-cli/releases/download/v$(LEGACY_CLI_VERSION)/platform.phar -o internal/legacy/archives/platform.phar 31 | 32 | internal/legacy/archives/php_windows_amd64: internal/legacy/archives/php_windows.zip internal/legacy/archives/cacert.pem 33 | 34 | internal/legacy/archives/php_darwin_$(GOARCH): 35 | bash build-php-brew.sh $(GOOS) $(PHP_VERSION) $(OPENSSL_VERSION) 36 | mv -f $(GOOS)/php-$(PHP_VERSION)/sapi/cli/php $(PHP_BINARY_PATH) 37 | rm -rf $(GOOS) 38 | 39 | internal/legacy/archives/php_linux_$(GOARCH): 40 | cp ext/extensions.txt ext/static-php-cli/docker 41 | docker buildx build \ 42 | --build-arg GOARCH=$(GOARCH) \ 43 | --build-arg PHP_VERSION=$(PHP_VERSION) \ 44 | --build-arg USE_BACKUP_ADDRESS=yes \ 45 | --file=./Dockerfile.php \ 46 | --platform=linux/$(GOARCH) \ 47 | --output=type=local,dest=./internal/legacy/archives/ \ 48 | --progress=plain \ 49 | ext/static-php-cli/docker 50 | 51 | PHP_WINDOWS_REMOTE_FILENAME := "php-$(PHP_VERSION)-nts-Win32-vs16-x64.zip" 52 | internal/legacy/archives/php_windows.zip: 53 | ( \ 54 | set -e ;\ 55 | mkdir -p internal/legacy/archives ;\ 56 | cd internal/legacy/archives ;\ 57 | curl -f "https://windows.php.net/downloads/releases/$(PHP_WINDOWS_REMOTE_FILENAME)" > php_windows.zip ;\ 58 | curl -f https://windows.php.net/downloads/releases/sha256sum.txt | grep "$(PHP_WINDOWS_REMOTE_FILENAME)" | sed s/"$(PHP_WINDOWS_REMOTE_FILENAME)"/"php_windows.zip"/g > php_windows.zip.sha256 ;\ 59 | sha256sum -c php_windows.zip.sha256 ;\ 60 | ) 61 | 62 | .PHONY: internal/legacy/archives/cacert.pem 63 | internal/legacy/archives/cacert.pem: 64 | mkdir -p internal/legacy/archives 65 | curl https://curl.se/ca/cacert.pem > internal/legacy/archives/cacert.pem 66 | 67 | php: $(PHP_BINARY_PATH) 68 | 69 | .PHONY: goreleaser 70 | goreleaser: 71 | command -v goreleaser >/dev/null || go install github.com/goreleaser/goreleaser/v2@$(GORELEASER_VERSION) 72 | 73 | .PHONY: single 74 | single: goreleaser internal/legacy/archives/platform.phar php ## Build a single target release 75 | PHP_VERSION=$(PHP_VERSION) LEGACY_CLI_VERSION=$(LEGACY_CLI_VERSION) goreleaser build --single-target --id=$(GORELEASER_ID) --snapshot --clean 76 | 77 | .PHONY: snapshot ## Build a snapshot release 78 | snapshot: goreleaser internal/legacy/archives/platform.phar php 79 | PHP_VERSION=$(PHP_VERSION) LEGACY_CLI_VERSION=$(LEGACY_CLI_VERSION) goreleaser build --snapshot --clean 80 | 81 | .PHONY: clean-phar 82 | clean-phar: ## Clean up the legacy CLI phar 83 | rm -f internal/legacy/archives/platform.phar 84 | 85 | .PHONY: release 86 | release: goreleaser clean-phar internal/legacy/archives/platform.phar php ## Create and publish a release 87 | PHP_VERSION=$(PHP_VERSION) LEGACY_CLI_VERSION=$(LEGACY_CLI_VERSION) goreleaser release --clean 88 | VERSION=$(VERSION) bash cloudsmith.sh 89 | 90 | .PHONY: test 91 | # "We encourage users of encoding/json to test their programs with GOEXPERIMENT=jsonv2 enabled" (https://tip.golang.org/doc/go1.25) 92 | test: ## Run unit tests 93 | GOEXPERIMENT=jsonv2 go test -v -race -cover -count=1 ./... 94 | 95 | .PHONY: lint 96 | lint: lint-gomod lint-golangci ## Run linters. 97 | 98 | .PHONY: lint-gomod 99 | lint-gomod: 100 | go mod tidy -diff 101 | 102 | .PHONY: lint-golangci 103 | lint-golangci: 104 | golangci-lint run --timeout=2m 105 | 106 | .goreleaser.vendor.yaml: check-vendor ## Generate the goreleaser vendor config 107 | cat .goreleaser.vendor.yaml.tpl | envsubst > .goreleaser.vendor.yaml 108 | 109 | .PHONY: check-vendor 110 | check-vendor: ## Check that the vendor CLI variables are set 111 | ifndef VENDOR_NAME 112 | $(error VENDOR_NAME is undefined) 113 | endif 114 | ifndef VENDOR_BINARY 115 | $(error VENDOR_BINARY is undefined) 116 | endif 117 | 118 | .PHONY: vendor-release 119 | vendor-release: check-vendor .goreleaser.vendor.yaml goreleaser clean-phar internal/legacy/archives/platform.phar php ## Release a vendor CLI 120 | PHP_VERSION=$(PHP_VERSION) LEGACY_CLI_VERSION=$(LEGACY_CLI_VERSION) VENDOR_BINARY="$(VENDOR_BINARY)" VENDOR_NAME="$(VENDOR_NAME)" goreleaser release --clean --config=.goreleaser.vendor.yaml 121 | 122 | .PHONY: vendor-snapshot 123 | vendor-snapshot: check-vendor .goreleaser.vendor.yaml goreleaser internal/legacy/archives/platform.phar php ## Build a vendor CLI snapshot 124 | PHP_VERSION=$(PHP_VERSION) LEGACY_CLI_VERSION=$(LEGACY_CLI_VERSION) VENDOR_BINARY="$(VENDOR_BINARY)" VENDOR_NAME="$(VENDOR_NAME)" goreleaser build --snapshot --clean --config=.goreleaser.vendor.yaml 125 | 126 | .PHONY: goreleaser-check 127 | goreleaser-check: goreleaser ## Check the goreleaser configs 128 | PHP_VERSION=$(PHP_VERSION) LEGACY_CLI_VERSION=$(LEGACY_CLI_VERSION) goreleaser check --config=.goreleaser.yaml 129 | -------------------------------------------------------------------------------- /internal/legacy/legacy.go: -------------------------------------------------------------------------------- 1 | package legacy 2 | 3 | import ( 4 | "context" 5 | _ "embed" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "io/fs" 10 | "os" 11 | "os/exec" 12 | "path/filepath" 13 | "sync" 14 | "time" 15 | 16 | "github.com/gofrs/flock" 17 | "golang.org/x/sync/errgroup" 18 | 19 | "github.com/platformsh/cli/internal/config" 20 | "github.com/platformsh/cli/internal/file" 21 | ) 22 | 23 | //go:embed archives/platform.phar 24 | var phar []byte 25 | 26 | var ( 27 | LegacyCLIVersion = "0.0.0" 28 | PHPVersion = "0.0.0" 29 | ) 30 | 31 | const configBasename = "config.yaml" 32 | 33 | // CLIWrapper wraps the legacy CLI 34 | type CLIWrapper struct { 35 | Stdout io.Writer 36 | Stderr io.Writer 37 | Stdin io.Reader 38 | Config *config.Config 39 | Version string 40 | Debug bool 41 | DisableInteraction bool 42 | ForceColor bool 43 | DebugLogFunc func(string, ...any) 44 | 45 | initOnce sync.Once 46 | _cacheDir string 47 | } 48 | 49 | func (c *CLIWrapper) debug(msg string, args ...any) { 50 | if c.DebugLogFunc != nil { 51 | c.DebugLogFunc(msg, args...) 52 | } 53 | } 54 | 55 | func (c *CLIWrapper) cacheDir() (string, error) { 56 | if c._cacheDir == "" { 57 | cd, err := c.Config.TempDir() 58 | if err != nil { 59 | return "", err 60 | } 61 | cd = filepath.Join(cd, fmt.Sprintf("legacy-%s-%s", PHPVersion, LegacyCLIVersion)) 62 | if err := os.Mkdir(cd, 0o700); err != nil && !errors.Is(err, fs.ErrExist) { 63 | return "", err 64 | } 65 | c._cacheDir = cd 66 | } 67 | 68 | return c._cacheDir, nil 69 | } 70 | 71 | // runInitOnce runs the init method, only once for this object. 72 | func (c *CLIWrapper) runInitOnce() error { 73 | var err error 74 | c.initOnce.Do(func() { err = c.init() }) 75 | return err 76 | } 77 | 78 | // init initializes the CLI wrapper, creating a temporary directory and copying over files. 79 | func (c *CLIWrapper) init() error { 80 | preInit := time.Now() 81 | 82 | cacheDir, err := c.cacheDir() 83 | if err != nil { 84 | return err 85 | } 86 | 87 | preLock := time.Now() 88 | fileLock := flock.New(filepath.Join(cacheDir, ".lock")) 89 | if err := fileLock.Lock(); err != nil { 90 | return fmt.Errorf("could not acquire lock: %w", err) 91 | } 92 | c.debug("lock acquired (%s): %s", time.Since(preLock), fileLock.Path()) 93 | defer fileLock.Unlock() //nolint:errcheck 94 | 95 | g := errgroup.Group{} 96 | g.Go(func() error { 97 | if err := file.WriteIfNeeded(c.pharPath(cacheDir), phar, 0o644); err != nil { 98 | return fmt.Errorf("could not copy phar file: %w", err) 99 | } 100 | return nil 101 | }) 102 | g.Go(func() error { 103 | configContent, err := c.Config.Raw() 104 | if err != nil { 105 | return fmt.Errorf("could not load config for checking: %w", err) 106 | } 107 | if err := file.WriteIfNeeded(filepath.Join(cacheDir, configBasename), configContent, 0o644); err != nil { 108 | return fmt.Errorf("could not write config: %w", err) 109 | } 110 | return nil 111 | }) 112 | 113 | g.Go(newPHPManager(cacheDir).copy) 114 | 115 | if err := g.Wait(); err != nil { 116 | return err 117 | } 118 | 119 | c.debug("Initialized PHP CLI (%s)", time.Since(preInit)) 120 | 121 | return nil 122 | } 123 | 124 | // Exec a legacy CLI command with the given arguments 125 | func (c *CLIWrapper) Exec(ctx context.Context, args ...string) error { 126 | if err := c.runInitOnce(); err != nil { 127 | return fmt.Errorf("failed to initialize PHP CLI: %w", err) 128 | } 129 | cacheDir, err := c.cacheDir() 130 | if err != nil { 131 | return err 132 | } 133 | cmd := c.makeCmd(ctx, args, cacheDir) 134 | cmd.Stdin = c.Stdin 135 | cmd.Stdout = c.Stdout 136 | cmd.Stderr = c.Stderr 137 | cmd.Env = append(cmd.Env, os.Environ()...) 138 | envPrefix := c.Config.Application.EnvPrefix 139 | cmd.Env = append( 140 | cmd.Env, 141 | "CLI_CONFIG_FILE="+filepath.Join(cacheDir, configBasename), 142 | envPrefix+"UPDATES_CHECK=0", 143 | envPrefix+"MIGRATE_CHECK=0", 144 | envPrefix+"APPLICATION_PROMPT_SELF_INSTALL=0", 145 | envPrefix+"WRAPPED=1", 146 | envPrefix+"APPLICATION_VERSION="+c.Version, 147 | ) 148 | if c.DisableInteraction { 149 | cmd.Env = append(cmd.Env, envPrefix+"NO_INTERACTION=1") 150 | } 151 | if c.ForceColor { 152 | cmd.Env = append(cmd.Env, "CLICOLOR_FORCE=1") 153 | } 154 | cmd.Env = append(cmd.Env, fmt.Sprintf( 155 | "%sUSER_AGENT={APP_NAME_DASH}/%s ({UNAME_S}; {UNAME_R}; PHP %s; WRAPPER %s)", 156 | envPrefix, 157 | LegacyCLIVersion, 158 | PHPVersion, 159 | c.Version, 160 | )) 161 | if err := cmd.Run(); err != nil { 162 | return fmt.Errorf("could not run PHP CLI command: %w", err) 163 | } 164 | 165 | return nil 166 | } 167 | 168 | // makeCmd makes a legacy CLI command with the given context and arguments. 169 | func (c *CLIWrapper) makeCmd(ctx context.Context, args []string, cacheDir string) *exec.Cmd { 170 | phpMgr := newPHPManager(cacheDir) 171 | settings := phpMgr.settings() 172 | var cmdArgs = make([]string, 0, len(args)+2+len(settings)*2) 173 | for _, s := range settings { 174 | cmdArgs = append(cmdArgs, "-d", s) 175 | } 176 | cmdArgs = append(cmdArgs, c.pharPath(cacheDir)) 177 | cmdArgs = append(cmdArgs, args...) 178 | return exec.CommandContext(ctx, phpMgr.binPath(), cmdArgs...) //nolint:gosec 179 | } 180 | 181 | // PharPath returns the path to the legacy CLI's Phar file. 182 | func (c *CLIWrapper) PharPath() (string, error) { 183 | cacheDir, err := c.cacheDir() 184 | if err != nil { 185 | return "", err 186 | } 187 | 188 | return c.pharPath(cacheDir), nil 189 | } 190 | 191 | func (c *CLIWrapper) pharPath(cacheDir string) string { 192 | if customPath := os.Getenv(c.Config.Application.EnvPrefix + "PHAR_PATH"); customPath != "" { 193 | return customPath 194 | } 195 | 196 | return filepath.Join(cacheDir, c.Config.Application.Executable+".phar") 197 | } 198 | -------------------------------------------------------------------------------- /pkg/mockapi/environments.go: -------------------------------------------------------------------------------- 1 | package mockapi 2 | 3 | import ( 4 | "crypto/rand" 5 | "encoding/json" 6 | "net/http" 7 | "slices" 8 | "time" 9 | 10 | "github.com/go-chi/chi/v5" 11 | "github.com/oklog/ulid/v2" 12 | "github.com/stretchr/testify/require" 13 | ) 14 | 15 | func (h *Handler) handleListEnvironments(w http.ResponseWriter, req *http.Request) { 16 | h.RLock() 17 | defer h.RUnlock() 18 | projectID := chi.URLParam(req, "project_id") 19 | var envs []*Environment 20 | for _, e := range h.environments { 21 | if e.Project == projectID { 22 | envs = append(envs, e) 23 | } 24 | } 25 | _ = json.NewEncoder(w).Encode(envs) 26 | } 27 | 28 | func (h *Handler) handleGetEnvironment(w http.ResponseWriter, req *http.Request) { 29 | h.RLock() 30 | defer h.RUnlock() 31 | projectID := chi.URLParam(req, "project_id") 32 | environmentID := chi.URLParam(req, "environment_id") 33 | for id, e := range h.environments { 34 | if e.Project == projectID && id == environmentID { 35 | _ = json.NewEncoder(w).Encode(e) 36 | break 37 | } 38 | } 39 | w.WriteHeader(http.StatusNotFound) 40 | } 41 | 42 | func (h *Handler) handlePatchEnvironment(w http.ResponseWriter, req *http.Request) { 43 | env := h.findEnvironment(chi.URLParam(req, "project_id"), chi.URLParam(req, "environment_id")) 44 | if env == nil { 45 | w.WriteHeader(http.StatusNotFound) 46 | return 47 | } 48 | h.Lock() 49 | defer h.Unlock() 50 | 51 | patched := *env 52 | err := json.NewDecoder(req.Body).Decode(&patched) 53 | if err != nil { 54 | w.WriteHeader(http.StatusBadRequest) 55 | return 56 | } 57 | 58 | patched.UpdatedAt = time.Now() 59 | h.environments[patched.ID] = &patched 60 | _ = json.NewEncoder(w).Encode(&patched) 61 | } 62 | 63 | func (h *Handler) handleGetEnvironmentSettings(w http.ResponseWriter, req *http.Request) { 64 | env := h.findEnvironment(chi.URLParam(req, "project_id"), chi.URLParam(req, "environment_id")) 65 | if env == nil { 66 | w.WriteHeader(http.StatusNotFound) 67 | return 68 | } 69 | h.Lock() 70 | defer h.Unlock() 71 | 72 | settings := make(map[string]any) 73 | if env.settings != nil { 74 | settings = env.settings 75 | } 76 | settings["_links"] = MakeHALLinks( 77 | "self=/projects/"+env.Project+"/environments/"+env.ID+"/settings", 78 | "#edit=/projects/"+env.Project+"/environments/"+env.ID+"/settings", 79 | ) 80 | 81 | _ = json.NewEncoder(w).Encode(settings) 82 | } 83 | 84 | func (h *Handler) handleSetEnvironmentSettings(w http.ResponseWriter, req *http.Request) { 85 | env := h.findEnvironment(chi.URLParam(req, "project_id"), chi.URLParam(req, "environment_id")) 86 | if env == nil { 87 | w.WriteHeader(http.StatusNotFound) 88 | return 89 | } 90 | h.Lock() 91 | defer h.Unlock() 92 | 93 | settings := make(map[string]any) 94 | err := json.NewDecoder(req.Body).Decode(&settings) 95 | if err != nil { 96 | w.WriteHeader(http.StatusBadRequest) 97 | return 98 | } 99 | 100 | for k, v := range settings { 101 | env.settings[k] = v 102 | } 103 | settings["_links"] = MakeHALLinks( 104 | "self=/projects/"+env.Project+"/environments/"+env.ID+"/settings", 105 | "#edit=/projects/"+env.Project+"/environments/"+env.ID+"/settings", 106 | ) 107 | 108 | h.environments[env.ID] = env 109 | _ = json.NewEncoder(w).Encode(map[string]any{ 110 | "_embedded": map[string]any{"entity": settings}, 111 | }) 112 | } 113 | 114 | func (h *Handler) handleDeployEnvironment(w http.ResponseWriter, req *http.Request) { 115 | env := h.findEnvironment(chi.URLParam(req, "project_id"), chi.URLParam(req, "environment_id")) 116 | if env == nil { 117 | w.WriteHeader(http.StatusNotFound) 118 | return 119 | } 120 | 121 | _ = json.NewEncoder(w).Encode(map[string]any{ 122 | "_embedded": map[string]any{"activities": []Activity{}}, 123 | }) 124 | } 125 | 126 | func (h *Handler) handleGetCurrentDeployment(w http.ResponseWriter, req *http.Request) { 127 | h.RLock() 128 | defer h.RUnlock() 129 | projectID := chi.URLParam(req, "project_id") 130 | environmentID := chi.URLParam(req, "environment_id") 131 | var d *Deployment 132 | for _, e := range h.environments { 133 | if e.Project == projectID && e.ID == environmentID { 134 | d = e.currentDeployment 135 | } 136 | } 137 | if d == nil { 138 | w.WriteHeader(http.StatusNotFound) 139 | return 140 | } 141 | _ = json.NewEncoder(w).Encode(d) 142 | } 143 | 144 | func (h *Handler) handleCreateBackup(w http.ResponseWriter, req *http.Request) { 145 | projectID := chi.URLParam(req, "project_id") 146 | environmentID := chi.URLParam(req, "environment_id") 147 | var options = struct { 148 | Safe bool `json:"safe"` 149 | }{} 150 | require.NoError(h.t, json.NewDecoder(req.Body).Decode(&options)) 151 | backup := &Backup{ 152 | ID: ulid.MustNew(ulid.Now(), rand.Reader).String(), 153 | EnvironmentID: environmentID, 154 | Status: "CREATED", 155 | Safe: options.Safe, 156 | Restorable: true, 157 | CreatedAt: time.Now(), 158 | UpdatedAt: time.Now(), 159 | } 160 | h.addProjectBackup(projectID, backup) 161 | _ = json.NewEncoder(w).Encode(backup) 162 | } 163 | 164 | func (h *Handler) handleListBackups(w http.ResponseWriter, req *http.Request) { 165 | h.RLock() 166 | defer h.RUnlock() 167 | projectID := chi.URLParam(req, "project_id") 168 | environmentID := chi.URLParam(req, "environment_id") 169 | var backups []*Backup 170 | if projectBackups, ok := h.projectBackups[projectID]; ok { 171 | for _, b := range projectBackups { 172 | if b.EnvironmentID == environmentID { 173 | backups = append(backups, b) 174 | } 175 | } 176 | } 177 | // Sort backups in descending order by created date. 178 | slices.SortFunc(backups, func(a, b *Backup) int { return -timeCompare(a.CreatedAt, b.CreatedAt) }) 179 | _ = json.NewEncoder(w).Encode(backups) 180 | } 181 | 182 | func timeCompare(a, b time.Time) int { 183 | if a.Equal(b) { 184 | return 0 185 | } 186 | if a.Before(b) { 187 | return -1 188 | } 189 | return 1 190 | } 191 | -------------------------------------------------------------------------------- /internal/init/streaming/client_test.go: -------------------------------------------------------------------------------- 1 | package streaming_test 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | "net/http/httptest" 8 | "sync" 9 | "testing" 10 | "time" 11 | 12 | "github.com/stretchr/testify/assert" 13 | "golang.org/x/sync/errgroup" 14 | 15 | "github.com/platformsh/cli/internal/init/streaming" 16 | ) 17 | 18 | func TestStreaming(t *testing.T) { 19 | s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { 20 | stream, err := NewServerHandler(w, &ServerConfig{KeepAliveInterval: 10 * time.Second}) 21 | if err != nil { 22 | t.Fatal(err) 23 | } 24 | defer stream.Close() 25 | stream.Info("first message") 26 | time.Sleep(20 * time.Millisecond) 27 | stream.Warn("warning") 28 | stream.Debug("debug") 29 | stream.Output("output chunk 1\n") 30 | time.Sleep(20 * time.Millisecond) 31 | stream.Output("output chunk 2\n") 32 | stream.Output("output chunk 3\n") 33 | stream.LogWithTags(streaming.LogLevelInfo, "tagged message", "example") 34 | stream.Error("error") 35 | stream.Info("more output") 36 | })) 37 | t.Cleanup(s.Close) 38 | 39 | resp, err := http.Get(s.URL) 40 | if err != nil { 41 | t.Fatal(err) 42 | } 43 | 44 | expectedMessages := []streaming.Message{ 45 | {Type: streaming.MessageTypeLog, Level: streaming.LogLevelInfo, Message: "first message"}, 46 | {Type: streaming.MessageTypeLog, Level: streaming.LogLevelWarn, Message: "warning"}, 47 | {Type: streaming.MessageTypeLog, Level: streaming.LogLevelDebug, Message: "debug"}, 48 | {Type: streaming.MessageTypeOutputChunk, Message: "output chunk 1\n"}, 49 | {Type: streaming.MessageTypeOutputChunk, Message: "output chunk 2\n"}, 50 | {Type: streaming.MessageTypeOutputChunk, Message: "output chunk 3\n"}, 51 | {Type: streaming.MessageTypeLog, Level: streaming.LogLevelInfo, 52 | Message: "tagged message", Tags: []string{"example"}}, 53 | {Type: streaming.MessageTypeLog, Level: streaming.LogLevelError, Message: "error"}, 54 | {Type: streaming.MessageTypeLog, Level: streaming.LogLevelInfo, Message: "more output"}, 55 | } 56 | 57 | var errGroup errgroup.Group 58 | var msgChan = make(chan streaming.Message) 59 | errGroup.Go(func() error { 60 | defer close(msgChan) 61 | return streaming.HandleResponse(t.Context(), resp, msgChan) 62 | }) 63 | errGroup.Go(func() error { 64 | i := 0 65 | for msg := range msgChan { 66 | assert.NotZero(t, msg.Time) 67 | if i >= len(expectedMessages) { 68 | t.Fatalf("expected %d messages but another was received: %s", len(expectedMessages), msg.Message) 69 | } 70 | expected := expectedMessages[i] 71 | assert.NotEmpty(t, expected) 72 | expected.Time = msg.Time 73 | assert.EqualValues(t, expected, msg) 74 | i++ 75 | } 76 | return nil 77 | }) 78 | 79 | if err := errGroup.Wait(); err != nil { 80 | t.Fatal(err) 81 | } 82 | } 83 | 84 | type ServerHandler struct { 85 | w http.ResponseWriter 86 | f http.Flusher 87 | enc *json.Encoder 88 | done chan struct{} 89 | mux sync.Mutex 90 | } 91 | 92 | type ServerConfig struct { 93 | KeepAliveInterval time.Duration 94 | } 95 | 96 | // NewServerHandler creates an HTTP streaming server handler and sets headers. 97 | func NewServerHandler(w http.ResponseWriter, cnf *ServerConfig) (*ServerHandler, error) { 98 | flusher, ok := w.(http.Flusher) 99 | if !ok { 100 | return nil, fmt.Errorf("streaming not supported: the writer must implement http.Flusher") 101 | } 102 | // See: https://github.com/ndjson/ndjson-spec 103 | w.Header().Set("Content-Type", "application/x-ndjson") 104 | w.Header().Set("Transfer-Encoding", "chunked") 105 | w.Header().Set("Cache-Control", "no-cache") 106 | w.WriteHeader(http.StatusOK) 107 | flusher.Flush() 108 | 109 | h := &ServerHandler{ 110 | w: w, 111 | f: flusher, 112 | enc: json.NewEncoder(w), 113 | done: make(chan struct{}), 114 | } 115 | 116 | go func() { 117 | ticker := time.NewTicker(cnf.KeepAliveInterval) 118 | defer ticker.Stop() 119 | for { 120 | select { 121 | case <-h.done: 122 | return 123 | case <-ticker.C: 124 | h.send(&streaming.Message{Type: streaming.MessageTypeKeepAlive}) 125 | } 126 | } 127 | }() 128 | 129 | return h, nil 130 | } 131 | 132 | func (h *ServerHandler) Close() { 133 | close(h.done) 134 | h.send(&streaming.Message{Type: streaming.MessageTypeDone}) 135 | } 136 | 137 | func (h *ServerHandler) Output(chunk string, tags ...string) { 138 | h.send(&streaming.Message{Type: streaming.MessageTypeOutputChunk, Message: chunk, Tags: tags}) 139 | } 140 | 141 | func (h *ServerHandler) SendData(data json.RawMessage, key string) { 142 | h.send(&streaming.Message{Type: streaming.MessageTypeData, Data: data, Key: key}) 143 | } 144 | 145 | func (h *ServerHandler) send(msg *streaming.Message) { 146 | if msg.Time.IsZero() { 147 | msg.Time = time.Now() 148 | } 149 | h.mux.Lock() 150 | if err := h.enc.Encode(msg); err != nil { 151 | panic(fmt.Sprintf("failed to encode message: %v", err)) 152 | } 153 | h.f.Flush() 154 | h.mux.Unlock() 155 | } 156 | 157 | func (h *ServerHandler) log(level, format string, args ...any) { 158 | h.send(&streaming.Message{ 159 | Type: streaming.MessageTypeLog, 160 | Level: level, 161 | Message: fmt.Sprintf(format, args...), 162 | }) 163 | } 164 | 165 | func (h *ServerHandler) LogWithTags(level, message string, tags ...string) { 166 | h.send(&streaming.Message{ 167 | Type: streaming.MessageTypeLog, 168 | Level: level, 169 | Message: message, 170 | Tags: tags, 171 | }) 172 | } 173 | 174 | func (h *ServerHandler) Debug(format string, args ...any) { 175 | h.log(streaming.LogLevelDebug, format, args...) 176 | } 177 | 178 | func (h *ServerHandler) Info(format string, args ...any) { 179 | h.log(streaming.LogLevelInfo, format, args...) 180 | } 181 | 182 | func (h *ServerHandler) Warn(format string, args ...any) { 183 | h.log(streaming.LogLevelWarn, format, args...) 184 | } 185 | 186 | func (h *ServerHandler) Error(format string, args ...any) { 187 | h.log(streaming.LogLevelError, format, args...) 188 | } 189 | -------------------------------------------------------------------------------- /commands/list_formatters.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "sort" 8 | "strings" 9 | "text/tabwriter" 10 | 11 | "github.com/fatih/color" 12 | 13 | "github.com/platformsh/cli/internal/config" 14 | "github.com/platformsh/cli/internal/md" 15 | ) 16 | 17 | type Formatter interface { 18 | Format(*List, *config.Config) ([]byte, error) 19 | } 20 | 21 | type JSONListFormatter struct{} 22 | 23 | func (f *JSONListFormatter) Format(list *List, _ *config.Config) ([]byte, error) { 24 | buff := new(bytes.Buffer) 25 | encoder := json.NewEncoder(buff) 26 | encoder.SetEscapeHTML(false) 27 | err := encoder.Encode(list) 28 | return buff.Bytes(), err 29 | } 30 | 31 | type TXTListFormatter struct{} 32 | 33 | func (f *TXTListFormatter) Format(list *List, cnf *config.Config) ([]byte, error) { 34 | var b bytes.Buffer 35 | writer := tabwriter.NewWriter(&b, 0, 8, 1, ' ', 0) 36 | fmt.Fprintf(writer, "%s %s\n", list.Application.Name, color.GreenString(list.Application.Version)) 37 | fmt.Fprintln(writer) 38 | fmt.Fprintln(writer, color.YellowString("Global options:")) 39 | for _, opt := range globalOptions(cnf) { 40 | shortcut := opt.Shortcut 41 | if shortcut == "" { 42 | shortcut = " " 43 | } 44 | fmt.Fprintf(writer, " %s\t%s %s\n", color.GreenString(opt.Name), color.GreenString(shortcut), opt.Description) 45 | } 46 | fmt.Fprintln(writer) 47 | 48 | writer.Init(&b, 0, 8, 4, ' ', 0) 49 | if list.DescribesNamespace() { 50 | fmt.Fprintln(writer, color.YellowString("Available commands for the \"%s\" namespace:", list.Namespace)) 51 | } else { 52 | fmt.Fprintln(writer, color.YellowString("Available commands:")) 53 | } 54 | 55 | cmds := make(map[string][]*Command) 56 | for _, cmd := range list.Commands { 57 | cmds[cmd.Name.Namespace] = append(cmds[cmd.Name.Namespace], cmd) 58 | } 59 | 60 | namespaces := make([]string, 0, len(cmds)) 61 | for namespace := range cmds { 62 | namespaces = append(namespaces, namespace) 63 | } 64 | sort.Strings(namespaces) 65 | 66 | for _, namespace := range namespaces { 67 | if !list.DescribesNamespace() && namespace != "" { 68 | fmt.Fprintln(writer, color.YellowString("%s\t", namespace)) 69 | } 70 | for _, cmd := range cmds[namespace] { 71 | name := color.GreenString(cmd.Name.String()) 72 | if len(cmd.Aliases) > 0 { 73 | name = name + " (" + strings.Join(cmd.Aliases, ", ") + ")" 74 | } 75 | fmt.Fprintf(writer, " %s\t%s\n", name, cmd.Description.String()) 76 | } 77 | } 78 | writer.Flush() 79 | 80 | return b.Bytes(), nil 81 | } 82 | 83 | type RawListFormatter struct{} 84 | 85 | func (f *RawListFormatter) Format(list *List, _ *config.Config) ([]byte, error) { 86 | var b bytes.Buffer 87 | writer := tabwriter.NewWriter(&b, 0, 8, 16, ' ', 0) 88 | for _, cmd := range list.Commands { 89 | fmt.Fprintf(writer, "%s\t%s\n", cmd.Name.String(), cmd.Description.String()) 90 | } 91 | writer.Flush() 92 | 93 | return b.Bytes(), nil 94 | } 95 | 96 | type MDListFormatter struct{} 97 | 98 | func (f *MDListFormatter) Format(list *List, cnf *config.Config) ([]byte, error) { 99 | b := md.NewBuilder() 100 | b.H1(list.Application.Name + " " + list.Application.Version) 101 | 102 | cmds := make(map[string][]*Command) 103 | for _, cmd := range list.Commands { 104 | cmds[cmd.Name.Namespace] = append(cmds[cmd.Name.Namespace], cmd) 105 | } 106 | 107 | namespaces := make([]string, 0, len(cmds)) 108 | for namespace := range cmds { 109 | namespaces = append(namespaces, namespace) 110 | } 111 | sort.Strings(namespaces) 112 | 113 | for _, namespace := range namespaces { 114 | if namespace != "" { 115 | b.Paragraph(md.Bold(namespace)).Ln() 116 | } 117 | for _, cmd := range cmds[namespace] { 118 | b.ListItem(md.Link(md.Code(cmd.Name.String()), md.Anchor(cmd.Name.String()))) 119 | } 120 | b.Ln() 121 | } 122 | 123 | for _, cmd := range list.Commands { 124 | b.H2(md.Code(cmd.Name.String())) 125 | b.Paragraph(cmd.Description.String()).Ln() 126 | 127 | if len(cmd.Aliases) > 0 { 128 | aliases := make([]string, 0, len(cmd.Aliases)) 129 | for _, alias := range cmd.Aliases { 130 | aliases = append(aliases, md.Code(alias)) 131 | } 132 | b.Paragraph("Aliases: " + strings.Join(aliases, ", ")).Ln() 133 | } 134 | 135 | if len(cmd.Usage) > 0 { 136 | b.H3("Usage") 137 | for _, usage := range cmd.Usage { 138 | b.CodeBlock(usage) 139 | } 140 | b.Ln() 141 | } 142 | 143 | if cmd.Help != "" { 144 | b.Paragraph(cmd.Help.String()).Ln() 145 | } 146 | 147 | if cmd.Definition.Arguments != nil && cmd.Definition.Arguments.Len() > 0 { 148 | b.H4("Arguments") 149 | for pair := cmd.Definition.Arguments.Oldest(); pair != nil; pair = pair.Next() { 150 | arg := pair.Value 151 | line := md.Code(arg.Name) 152 | opts := make([]string, 0, 2) 153 | if arg.IsRequired { 154 | opts = append(opts, "required") 155 | } else { 156 | opts = append(opts, "optional") 157 | } 158 | if arg.IsArray { 159 | opts = append(opts, "multiple values allowed") 160 | } 161 | line += "(" + strings.Join(opts, "; ") + ")" 162 | 163 | b.ListItem(line) 164 | if arg.Description != "" { 165 | b.Paragraph(" " + arg.Description.String()).Ln() 166 | } 167 | } 168 | } 169 | 170 | if cmd.Definition.Options != nil && cmd.Definition.Options.Len() > 0 { 171 | b.H4("Options") 172 | for pair := cmd.Definition.Options.Oldest(); pair != nil; pair = pair.Next() { 173 | opt := pair.Value 174 | line := md.Code(opt.Name) 175 | if opt.Shortcut != "" { 176 | line += " (" + md.Code(opt.Shortcut) + ")" 177 | } 178 | if opt.AcceptValue { 179 | line += " (expects a value)" 180 | } 181 | b.ListItem(line) 182 | if opt.Description != "" { 183 | b.Paragraph(" " + opt.Description.String()).Ln() 184 | } 185 | } 186 | } 187 | 188 | if len(cmd.Examples) > 0 { 189 | b.H3("Examples") 190 | for _, example := range cmd.Examples { 191 | b.ListItem(example.Description.String() + ":") 192 | b.CodeBlock(cnf.Application.Executable + " " + cmd.Name.String() + " " + example.Commandline).Ln() 193 | } 194 | } 195 | } 196 | 197 | return []byte(b.String()), nil 198 | } 199 | -------------------------------------------------------------------------------- /internal/config/platformsh-cli.yaml: -------------------------------------------------------------------------------- 1 | # Upsun CLI (Platform.sh compatibility) configuration 2 | # 3 | # Platform.sh is now Upsun. 4 | # 5 | # These are settings for the 'platform' command, which is available for backwards compatibility. 6 | wrapper: 7 | homebrew_tap: platformsh/tap/platformsh-cli 8 | github_repo: platformsh/cli 9 | 10 | application: 11 | name: 'Upsun CLI (Platform.sh compatibility)' 12 | slug: 'platformsh-cli' 13 | executable: 'platform' 14 | env_prefix: 'PLATFORMSH_CLI_' 15 | user_config_dir: '.platformsh' 16 | 17 | mark_unwrapped_legacy: true 18 | 19 | disabled_commands: 20 | - self:install 21 | - self:update 22 | 23 | service: 24 | name: 'Upsun (formerly Platform.sh)' 25 | env_prefix: 'PLATFORM_' 26 | 27 | project_config_dir: '.platform' 28 | app_config_file: '.platform.app.yaml' 29 | project_config_flavor: 'platform' 30 | 31 | console_url: 'https://console.upsun.com' 32 | 33 | docs_url: 'https://docs.upsun.com' 34 | docs_search_url: 'https://docs.upsun.com/search.html?q={{ terms }}' 35 | 36 | register_url: 'https://auth.upsun.com/register' 37 | reset_password_url: 'https://auth.upsun.com/reset-password' 38 | 39 | pricing_url: 'https://upsun.com/pricing' 40 | 41 | activity_type_list_url: 'https://docs.upsun.com/anchors/fixed/integrations/activity-scripts/type/' 42 | 43 | runtime_operations_help_url: 'https://docs.upsun.com/anchors/fixed/app/runtime-operations/' 44 | 45 | api: 46 | base_url: 'https://api.upsun.com' 47 | 48 | auth_url: 'https://auth.upsun.com' 49 | oauth2_client_id: 'platform-cli' 50 | 51 | organization_types: [flexible, fixed] 52 | default_organization_type: flexible 53 | 54 | organizations: true 55 | user_verification: true 56 | metrics: true 57 | teams: true 58 | 59 | vendor_filter: 'upsun' 60 | 61 | ssh: 62 | domain_wildcards: ['*.platform.sh'] 63 | 64 | detection: 65 | git_remote_name: 'platform' 66 | git_domain: 'platform.sh' # matches git.eu-5.platform.sh, etc. 67 | site_domains: ['platform.sh', 'platformsh.site', 'tst.site', 'upsunapp.com', 'upsun.app'] 68 | cluster_header: 'X-Platform-Cluster' 69 | 70 | browser_login: 71 | body: | 72 | 77 | 78 |

{{title}}

79 | 80 | {{content}} 81 | 82 | migrate: 83 | prompt: true 84 | docs_url: https://docs.upsun.com/anchors/fixed/cli/ 85 | 86 | warnings: 87 | non_production_domains_msg: | 88 | This feature is only available to Enterprise and Elite customers. 89 | If you're an Enterprise or Elite customer, contact support to enable the feature. 90 | Otherwise contact sales first to upgrade your plan. 91 | 92 | See: https://docs.upsun.com/anchors/fixed/get-support/ 93 | -------------------------------------------------------------------------------- /commands/config_install.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io/fs" 7 | "os" 8 | "os/exec" 9 | "path/filepath" 10 | "runtime" 11 | "strings" 12 | 13 | "github.com/fatih/color" 14 | "github.com/spf13/cobra" 15 | "github.com/symfony-cli/terminal" 16 | 17 | "github.com/platformsh/cli/internal/config" 18 | "github.com/platformsh/cli/internal/config/alt" 19 | ) 20 | 21 | func newConfigInstallCommand() *cobra.Command { 22 | cmd := &cobra.Command{ 23 | Use: "config:install [flags] [url]", 24 | Short: "Installs an alternative CLI, downloading new configuration from a URL", 25 | Args: cobra.ExactArgs(1), 26 | RunE: runConfigInstall, 27 | } 28 | cmd.Flags().String("bin-dir", "", "Install the executable in the given directory") 29 | cmd.Flags().String("config-dir", "", "Install the configuration in the given directory") 30 | cmd.Flags().Bool("absolute", false, 31 | "Use the absolute path to the current executable, instead of the configured name") 32 | cmd.Flags().BoolP("force", "f", false, "Force installation even if a duplicate executable exists") 33 | return cmd 34 | } 35 | 36 | func runConfigInstall(cmd *cobra.Command, args []string) error { 37 | cnf := config.FromContext(cmd.Context()) 38 | 39 | // Validate input. 40 | executableDir, err := getExecutableDir(cmd) 41 | if err != nil { 42 | return err 43 | } 44 | configDir, err := getConfigDir(cmd) 45 | if err != nil { 46 | return err 47 | } 48 | target, err := getExecutableTarget(cmd, cnf) 49 | if err != nil { 50 | return err 51 | } 52 | 53 | cmd.PrintErrln("Downloading and validating new CLI configuration...") 54 | cmd.PrintErrln() 55 | 56 | urlStr := args[0] 57 | if !strings.Contains(urlStr, "://") { 58 | urlStr = "https://" + urlStr 59 | } 60 | newCnfNode, newCnfStruct, err := alt.FetchConfig(cmd.Context(), urlStr) 61 | if err != nil { 62 | return err 63 | } 64 | newExecutable := newCnfStruct.Application.Executable 65 | if newExecutable == cnf.Application.Executable { 66 | return fmt.Errorf("cannot install config for same executable name as this program: %s", newExecutable) 67 | } 68 | 69 | configFilePath := filepath.Join(configDir, newExecutable) + ".yaml" 70 | executableFilePath := filepath.Join(executableDir, newExecutable) + alt.GetExecutableFileExtension() 71 | 72 | pathVariableName := "PATH" 73 | if runtime.GOOS == "windows" { 74 | pathVariableName = "Path" 75 | } 76 | 77 | // Check for duplicates. 78 | { 79 | force, err := cmd.Flags().GetBool("force") 80 | if err != nil { 81 | return err 82 | } 83 | if !force { 84 | if path, err := exec.LookPath(newExecutable); err == nil && path != executableFilePath { 85 | cmd.PrintErrln("An executable with the same name already exists at another location.") 86 | cmd.PrintErrf( 87 | "Use %s to ignore this check. "+ 88 | "You would need to verify the %s precedence manually.\n", 89 | color.RedString("--force"), 90 | pathVariableName, 91 | ) 92 | return fmt.Errorf("install failed due to duplicate executable with the name %s at: %s", newExecutable, path) 93 | } 94 | } 95 | } 96 | 97 | formatPath := pathFormatter() 98 | 99 | cmd.PrintErrln("The following files will be created or overwritten:") 100 | cmd.PrintErrf(" Configuration file: %s\n", color.CyanString(formatPath(configFilePath))) 101 | cmd.PrintErrf(" Executable: %s\n", color.CyanString(formatPath(executableFilePath))) 102 | cmd.PrintErrf("The executable runs %s with the new configuration.\n", 103 | color.CyanString(formatPath(target))) 104 | cmd.PrintErrln() 105 | if terminal.Stdin.IsInteractive() { 106 | if !terminal.AskConfirmation("Are you sure you want to continue?", true) { 107 | os.Exit(1) 108 | } 109 | cmd.PrintErrln() 110 | } 111 | 112 | // Create the files. 113 | a := alt.New( 114 | executableFilePath, 115 | fmt.Sprintf("Automatically generated by the %s", cnf.Application.Name), 116 | target, 117 | configFilePath, 118 | newCnfNode, 119 | ) 120 | if err := a.GenerateAndSave(); err != nil { 121 | return err 122 | } 123 | 124 | cmd.PrintErrln("The files have been saved successfully.") 125 | cmd.PrintErrln() 126 | 127 | if alt.InPath(executableDir) { 128 | cmd.PrintErrln("Run the new CLI with:", color.GreenString(newExecutable)) 129 | } else { 130 | cmd.PrintErrf( 131 | "Add the following directory to your %s: %s\n", 132 | pathVariableName, 133 | color.YellowString(formatPath(executableDir)), 134 | ) 135 | cmd.PrintErrln() 136 | cmd.PrintErrln("Then you will be able to run the new CLI with:", color.YellowString(newExecutable)) 137 | } 138 | 139 | return nil 140 | } 141 | 142 | // Returns a formatter for displaying file and directory names. 143 | func pathFormatter() func(string) string { 144 | sub := "~" 145 | if runtime.GOOS == "windows" { 146 | sub = "%USERPROFILE%" 147 | } 148 | hd, err := os.UserHomeDir() 149 | return func(p string) string { 150 | if err == nil && strings.HasPrefix(p, hd) { 151 | return sub + strings.TrimPrefix(p, hd) 152 | } 153 | return p 154 | } 155 | } 156 | 157 | func getExecutableTarget(cmd *cobra.Command, cnf *config.Config) (string, error) { 158 | abs, err := cmd.Flags().GetBool("absolute") 159 | if err != nil { 160 | return "", err 161 | } 162 | if abs { 163 | return os.Executable() 164 | } 165 | return cnf.Application.Executable, nil 166 | } 167 | 168 | func getConfigDir(cmd *cobra.Command) (string, error) { 169 | configDirOpt, err := cmd.Flags().GetString("config-dir") 170 | if err != nil { 171 | return "", err 172 | } 173 | if configDirOpt != "" { 174 | return validateUserProvidedDir(configDirOpt) 175 | } 176 | return alt.FindConfigDir() 177 | } 178 | 179 | func getExecutableDir(cmd *cobra.Command) (string, error) { 180 | binDirOpt, err := cmd.Flags().GetString("bin-dir") 181 | if err != nil { 182 | return "", err 183 | } 184 | if binDirOpt != "" { 185 | return validateUserProvidedDir(binDirOpt) 186 | } 187 | return alt.FindBinDir() 188 | } 189 | 190 | func validateUserProvidedDir(path string) (normalized string, err error) { 191 | normalized, err = filepath.Abs(path) 192 | if err != nil { 193 | return 194 | } 195 | 196 | lstat, err := os.Lstat(normalized) 197 | if err != nil { 198 | if errors.Is(err, fs.ErrNotExist) { 199 | err = fmt.Errorf("directory not found: %s", normalized) 200 | } 201 | return 202 | } 203 | if !lstat.IsDir() { 204 | err = fmt.Errorf("not a directory: %s", normalized) 205 | } 206 | 207 | return 208 | } 209 | -------------------------------------------------------------------------------- /pkg/mockapi/variables.go: -------------------------------------------------------------------------------- 1 | package mockapi 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | "net/url" 7 | "slices" 8 | "time" 9 | 10 | "github.com/go-chi/chi/v5" 11 | ) 12 | 13 | func (h *Handler) handleListProjectVariables(w http.ResponseWriter, req *http.Request) { 14 | h.RLock() 15 | defer h.RUnlock() 16 | projectID := chi.URLParam(req, "project_id") 17 | variables := h.projectVariables[projectID] 18 | // Sort variables in descending order by created date. 19 | slices.SortFunc(variables, func(a, b *Variable) int { return -timeCompare(a.CreatedAt, b.CreatedAt) }) 20 | _ = json.NewEncoder(w).Encode(variables) 21 | } 22 | 23 | func (h *Handler) handleGetProjectVariable(w http.ResponseWriter, req *http.Request) { 24 | h.RLock() 25 | defer h.RUnlock() 26 | projectID := chi.URLParam(req, "project_id") 27 | variableName, _ := url.PathUnescape(chi.URLParam(req, "name")) 28 | for _, v := range h.projectVariables[projectID] { 29 | if variableName == v.Name { 30 | _ = json.NewEncoder(w).Encode(v) 31 | return 32 | } 33 | } 34 | w.WriteHeader(http.StatusNotFound) 35 | } 36 | 37 | func (h *Handler) handleCreateProjectVariable(w http.ResponseWriter, req *http.Request) { 38 | h.Lock() 39 | defer h.Unlock() 40 | 41 | projectID := chi.URLParam(req, "project_id") 42 | 43 | newVar := Variable{} 44 | if err := json.NewDecoder(req.Body).Decode(&newVar); err != nil { 45 | w.WriteHeader(http.StatusBadRequest) 46 | return 47 | } 48 | newVar.CreatedAt = time.Now() 49 | newVar.UpdatedAt = time.Now() 50 | newVar.Links = MakeHALLinks( 51 | "self=/projects/"+projectID+"/variables/"+newVar.Name, 52 | "#edit=/projects/"+projectID+"/variables/"+newVar.Name, 53 | ) 54 | 55 | for _, v := range h.projectVariables[projectID] { 56 | if newVar.Name == v.Name { 57 | w.WriteHeader(http.StatusConflict) 58 | return 59 | } 60 | } 61 | 62 | if h.projectVariables[projectID] == nil { 63 | h.projectVariables = make(map[string][]*Variable) 64 | } 65 | h.projectVariables[projectID] = append(h.projectVariables[projectID], &newVar) 66 | 67 | _ = json.NewEncoder(w).Encode(map[string]any{ 68 | "_embedded": map[string]any{"entity": newVar}, 69 | }) 70 | } 71 | 72 | func (h *Handler) handlePatchProjectVariable(w http.ResponseWriter, req *http.Request) { 73 | h.Lock() 74 | defer h.Unlock() 75 | 76 | projectID := chi.URLParam(req, "project_id") 77 | variableName, _ := url.PathUnescape(chi.URLParam(req, "name")) 78 | var key = -1 79 | for k, v := range h.projectVariables[projectID] { 80 | if v.Name == variableName { 81 | key = k 82 | break 83 | } 84 | } 85 | if key == -1 { 86 | w.WriteHeader(http.StatusNotFound) 87 | } 88 | patched := *h.projectVariables[projectID][key] 89 | err := json.NewDecoder(req.Body).Decode(&patched) 90 | if err != nil { 91 | w.WriteHeader(http.StatusBadRequest) 92 | return 93 | } 94 | patched.UpdatedAt = time.Now() 95 | h.projectVariables[projectID][key] = &patched 96 | _ = json.NewEncoder(w).Encode(&patched) 97 | } 98 | 99 | func (h *Handler) handleListEnvLevelVariables(w http.ResponseWriter, req *http.Request) { 100 | h.RLock() 101 | defer h.RUnlock() 102 | projectID := chi.URLParam(req, "project_id") 103 | environmentID, _ := url.PathUnescape(chi.URLParam(req, "environment_id")) 104 | variables := h.envLevelVariables[projectID][environmentID] 105 | // Sort variables in descending order by created date. 106 | slices.SortFunc(variables, func(a, b *EnvLevelVariable) int { return -timeCompare(a.CreatedAt, b.CreatedAt) }) 107 | _ = json.NewEncoder(w).Encode(variables) 108 | } 109 | 110 | func (h *Handler) handleGetEnvLevelVariable(w http.ResponseWriter, req *http.Request) { 111 | h.RLock() 112 | defer h.RUnlock() 113 | projectID := chi.URLParam(req, "project_id") 114 | environmentID, _ := url.PathUnescape(chi.URLParam(req, "environment_id")) 115 | variableName, _ := url.PathUnescape(chi.URLParam(req, "name")) 116 | for _, v := range h.envLevelVariables[projectID][environmentID] { 117 | if variableName == v.Name { 118 | _ = json.NewEncoder(w).Encode(v) 119 | return 120 | } 121 | } 122 | w.WriteHeader(http.StatusNotFound) 123 | } 124 | 125 | func (h *Handler) handleCreateEnvLevelVariable(w http.ResponseWriter, req *http.Request) { 126 | h.Lock() 127 | defer h.Unlock() 128 | 129 | projectID := chi.URLParam(req, "project_id") 130 | environmentID, _ := url.PathUnescape(chi.URLParam(req, "environment_id")) 131 | 132 | newVar := EnvLevelVariable{} 133 | if err := json.NewDecoder(req.Body).Decode(&newVar); err != nil { 134 | w.WriteHeader(http.StatusBadRequest) 135 | return 136 | } 137 | newVar.CreatedAt = time.Now() 138 | newVar.UpdatedAt = time.Now() 139 | newVar.Links = MakeHALLinks( 140 | "self=/projects/"+projectID+"/environments/"+environmentID+"/variables/"+newVar.Name, 141 | "#edit=/projects/"+projectID+"/environments/"+environmentID+"/variables/"+newVar.Name, 142 | ) 143 | 144 | for _, v := range h.envLevelVariables[projectID][environmentID] { 145 | if newVar.Name == v.Name { 146 | w.WriteHeader(http.StatusConflict) 147 | return 148 | } 149 | } 150 | 151 | if h.envLevelVariables == nil { 152 | h.envLevelVariables = make(map[string]map[string][]*EnvLevelVariable) 153 | } 154 | if h.envLevelVariables[projectID] == nil { 155 | h.envLevelVariables[projectID] = make(map[string][]*EnvLevelVariable) 156 | } 157 | h.envLevelVariables[projectID][environmentID] = append( 158 | h.envLevelVariables[projectID][environmentID], 159 | &newVar, 160 | ) 161 | 162 | _ = json.NewEncoder(w).Encode(map[string]any{ 163 | "_embedded": map[string]any{"entity": newVar}, 164 | }) 165 | } 166 | 167 | func (h *Handler) handlePatchEnvLevelVariable(w http.ResponseWriter, req *http.Request) { 168 | h.Lock() 169 | defer h.Unlock() 170 | projectID := chi.URLParam(req, "project_id") 171 | environmentID, _ := url.PathUnescape(chi.URLParam(req, "environment_id")) 172 | variableName, _ := url.PathUnescape(chi.URLParam(req, "name")) 173 | var key = -1 174 | for k, v := range h.envLevelVariables[projectID][environmentID] { 175 | if variableName == v.Name { 176 | key = k 177 | break 178 | } 179 | } 180 | if key == -1 { 181 | w.WriteHeader(http.StatusNotFound) 182 | } 183 | patched := *h.envLevelVariables[projectID][environmentID][key] 184 | err := json.NewDecoder(req.Body).Decode(&patched) 185 | if err != nil { 186 | w.WriteHeader(http.StatusBadRequest) 187 | return 188 | } 189 | patched.UpdatedAt = time.Now() 190 | h.envLevelVariables[projectID][environmentID][key] = &patched 191 | _ = json.NewEncoder(w).Encode(&patched) 192 | } 193 | -------------------------------------------------------------------------------- /internal/config/upsun-cli.yaml: -------------------------------------------------------------------------------- 1 | # Upsun CLI configuration 2 | # 3 | # Based on the legacy CLI config in https://github.com/platformsh/legacy-cli/blob/main/config.yaml 4 | # 5 | # See ../internal/config/schema.go for the required keys 6 | wrapper: 7 | homebrew_tap: platformsh/tap/upsun-cli 8 | github_repo: platformsh/cli 9 | 10 | application: 11 | name: "Upsun CLI" 12 | slug: "upsun" 13 | executable: "upsun" 14 | env_prefix: "UPSUN_CLI_" 15 | user_config_dir: ".upsun-cli" 16 | 17 | mark_unwrapped_legacy: true 18 | 19 | disabled_commands: 20 | - self:install 21 | - self:update 22 | - local:build 23 | - local:drush-aliases 24 | - project:variable:delete 25 | - project:variable:get 26 | - project:variable:set 27 | - variable:disable 28 | - variable:enable 29 | - variable:set 30 | 31 | service: 32 | name: "Upsun" 33 | 34 | env_prefix: "PLATFORM_" 35 | 36 | project_config_dir: ".upsun" 37 | project_config_flavor: "upsun" 38 | 39 | console_url: "https://console.upsun.com" 40 | 41 | docs_url: "https://docs.upsun.com" 42 | docs_search_url: "https://docs.upsun.com/search.html?q={{ terms }}" 43 | 44 | register_url: "https://auth.upsun.com/register" 45 | reset_password_url: "https://auth.upsun.com/reset-password" 46 | 47 | pricing_url: "https://upsun.com/pricing" 48 | 49 | activity_type_list_url: "https://docs.upsun.com/anchors/integrations/activity-scripts/type/" 50 | 51 | runtime_operations_help_url: "https://docs.upsun.com/anchors/app/runtime-operations/" 52 | 53 | api: 54 | base_url: "https://api.upsun.com" 55 | 56 | auth_url: "https://auth.upsun.com" 57 | oauth2_client_id: "upsun-cli" 58 | 59 | ai_url: "https://ai.upsun.com" 60 | 61 | organization_types: [flexible, fixed] 62 | default_organization_type: flexible 63 | 64 | organizations: true 65 | user_verification: true 66 | metrics: true 67 | sizing: true 68 | teams: true 69 | 70 | vendor_filter: upsun 71 | 72 | ssh: 73 | domain_wildcards: ["*.platform.sh", "*.upsun.com"] 74 | 75 | detection: 76 | git_remote_name: "upsun" 77 | git_domain: "platform.sh" # matches git.eu-5.platform.sh, etc. 78 | site_domains: ["platformsh.site", "tst.site", "upsunapp.com", "upsun.app"] 79 | cluster_header: 'X-Platform-Cluster' 80 | 81 | browser_login: 82 | body: | 83 | 88 | 89 |

{{title}}

90 | 91 | {{content}} 92 | 93 | messages: 94 | region_discount: Get a 3% discount on resources for regions with a carbon intensity of less than 100 gCO2eq/kWh. 95 | 96 | warnings: 97 | guaranteed_resources_msg: | 98 | You have chosen to allocate guaranteed resources for app(s) and/or service(s). 99 | This change may affect resource costs. See: https://upsun.com/pricing/ 100 | 101 | This process requires a redeployment of containers on their own host, which may take a few minutes to complete. 102 | 103 | guaranteed_resources_branch_msg: | 104 | Guaranteed resources from the parent will be provisioned, impacting your bill. 105 | --------------------------------------------------------------------------------