├── .gitignore ├── commands ├── start_background_command_windows.go ├── start_background_command_unix.go ├── debug_action.go ├── action_warpper.go ├── upload_action.go ├── third_party_command.go ├── publish_action.go ├── info_action.go ├── login_action.go ├── up_action.go ├── logs_action.go ├── cql_action.go ├── preview_action.go ├── new_action.go ├── db_action.go ├── env_action.go ├── switch_action.go └── deploy_action.go ├── packaging ├── deb │ ├── control-x86 │ └── control-x64 └── msi │ ├── lean-cli-x86.wxs │ ├── tds-cli-x64.wxs │ ├── tds-cli-x86.wxs │ └── lean-cli-x64.wxs ├── .editorconfig ├── console ├── sign_test.go ├── sign.go ├── resources │ ├── json5.js │ └── index.html └── server.go ├── test.sh ├── utils ├── bom.go ├── file_exists.go ├── file_exists_test.go ├── api.go ├── home.go └── archive.go ├── misc ├── lean-bash-completion └── lean-zsh-completion ├── api ├── client_test.go ├── app_router.go ├── access_token.go ├── cql.go ├── error.go ├── lean_db.go ├── event_poller.go ├── apps.go ├── domain_center.go ├── regions │ └── regions.go ├── account.go ├── file.go ├── log.go ├── client.go └── engine.go ├── runtimes ├── router.php.go ├── ignorefiles.go └── runtime.go ├── stats ├── deviceid_test.go ├── deviceid.go └── collect.go ├── .github └── workflows │ └── test.yml ├── proxy ├── exec.go └── proxy.go ├── apps ├── region_cache.go └── app.go ├── lean ├── check_update.go ├── main_test.go └── main.go ├── README.md ├── version └── version.go ├── go.mod ├── rediscommands └── commands.go ├── Makefile ├── boilerplate └── boilerplate.go └── LICENSE.txt /.gitignore: -------------------------------------------------------------------------------- 1 | lean/lean 2 | lean/lean.exe 3 | _build/ 4 | __pycache__/ 5 | coverage.txt 6 | .idea/ -------------------------------------------------------------------------------- /commands/start_background_command_windows.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import "os/exec" 4 | 5 | func StartBackgroundCommand(cmd *exec.Cmd) error { 6 | return cmd.Start() 7 | } 8 | -------------------------------------------------------------------------------- /packaging/deb/control-x86: -------------------------------------------------------------------------------- 1 | Package: lean-cli 2 | Section: devel 3 | Version: 1.2.4 4 | Priority: optional 5 | Architecture: i386 6 | Maintainer: LeanCloud 7 | Homepage: https://leancloud.cn 8 | Description: LeanCloud Command Line Tool 9 | -------------------------------------------------------------------------------- /packaging/deb/control-x64: -------------------------------------------------------------------------------- 1 | Package: lean-cli 2 | Section: devel 3 | Version: 1.2.4 4 | Priority: optional 5 | Architecture: amd64 6 | Maintainer: LeanCloud 7 | Homepage: https://leancloud.cn 8 | Description: LeanCloud Command Line Tool 9 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | max_line_length = 120 6 | 7 | [*.go] 8 | indent_style = tab 9 | indent_size = 4 10 | 11 | [*.js] 12 | indent_style = space 13 | indent_size = 2 14 | 15 | [*.html] 16 | indent_style = space 17 | indent_size = 2 18 | -------------------------------------------------------------------------------- /console/sign_test.go: -------------------------------------------------------------------------------- 1 | package console 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestSignCloudFunc(t *testing.T) { 8 | expected := "1468317699700,79ba1b1e1f8348a657151715d5ee4d46d60d0a4b" 9 | if signCloudFunc("aaa", "bbb", "1468317699700") != expected { 10 | t.Error("invalid sign") 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /test.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | echo "" > coverage.txt 5 | 6 | for d in $(go list ./... | grep -v vendor); do 7 | go test -race -coverprofile=profile.out -covermode=atomic $d 8 | if [ -f profile.out ]; then 9 | cat profile.out >> coverage.txt 10 | rm profile.out 11 | fi 12 | done 13 | -------------------------------------------------------------------------------- /utils/bom.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "bytes" 5 | ) 6 | 7 | // StripUTF8BOM try to remove a file byte stream's UTF8 BOM header. 8 | func StripUTF8BOM(stream []byte) []byte { 9 | if len(stream) > 3 && bytes.HasPrefix(stream, []byte{0xef, 0xbb, 0xbf}) { 10 | return stream[3:] 11 | } 12 | return stream 13 | } 14 | -------------------------------------------------------------------------------- /misc/lean-bash-completion: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | 3 | _cli_bash_autocomplete() { 4 | local cur opts base 5 | COMPREPLY=() 6 | cur="${COMP_WORDS[COMP_CWORD]}" 7 | opts=$( ${COMP_WORDS[@]:0:$COMP_CWORD} --generate-bash-completion ) 8 | COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) ) 9 | return 0 10 | } 11 | 12 | complete -F _cli_bash_autocomplete lean 13 | -------------------------------------------------------------------------------- /utils/file_exists.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import "os" 4 | 5 | // IsFileExists returns whether the file exists 6 | func IsFileExists(path string) bool { 7 | file, err := os.Open(path) 8 | if err != nil { 9 | return false 10 | } 11 | defer file.Close() 12 | fileInfo, err := file.Stat() 13 | if err != nil { 14 | return false 15 | } 16 | if fileInfo.IsDir() { 17 | return false 18 | } 19 | return true 20 | } 21 | -------------------------------------------------------------------------------- /utils/file_exists_test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestIsFileExists(t *testing.T) { 8 | if IsFileExists("/tmp") { 9 | t.Error("/tmp is a directory, should not exits") 10 | } 11 | 12 | if IsFileExists("a_invalid_file_path") { 13 | t.Error("a_invalid_file_path should not exits") 14 | } 15 | 16 | if !IsFileExists("file_exists_test.go") { 17 | t.Error("file_exists_test.go") 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /misc/lean-zsh-completion: -------------------------------------------------------------------------------- 1 | autoload -U compinit && compinit 2 | autoload -U bashcompinit && bashcompinit 3 | 4 | _cli_bash_autocomplete() { 5 | local cur opts base 6 | COMPREPLY=() 7 | cur="${COMP_WORDS[COMP_CWORD]}" 8 | opts=$( ${COMP_WORDS[@]:0:$COMP_CWORD} --generate-bash-completion ) 9 | COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) ) 10 | return 0 11 | } 12 | 13 | complete -F _cli_bash_autocomplete lean 14 | -------------------------------------------------------------------------------- /utils/api.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import "encoding/json" 4 | 5 | // ErrorResult is the LeanCloud API Server API common error format 6 | type ErrorResult struct { 7 | Code int `json:"code"` 8 | Error string `json:"error"` 9 | } 10 | 11 | // FormatServerErrorResult format LeanCloud Server 12 | func FormatServerErrorResult(body string) string { 13 | var result ErrorResult 14 | json.Unmarshal([]byte(body), &result) 15 | return result.Error 16 | } 17 | -------------------------------------------------------------------------------- /api/client_test.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/leancloud/lean-cli/api/regions" 7 | ) 8 | 9 | func TestClient(t *testing.T) { 10 | f := func(r regions.Region) { 11 | client := NewClientByRegion(r) 12 | resp, err := client.get("/1.1/date", nil) 13 | if err != nil { 14 | t.FailNow() 15 | } 16 | if resp.StatusCode != 200 { 17 | t.FailNow() 18 | } 19 | } 20 | f(regions.ChinaNorth) 21 | f(regions.USWest) 22 | f(regions.ChinaEast) 23 | } 24 | -------------------------------------------------------------------------------- /commands/start_background_command_unix.go: -------------------------------------------------------------------------------- 1 | //go:build aix || darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris 2 | // +build aix darwin dragonfly freebsd linux netbsd openbsd solaris 3 | 4 | package commands 5 | 6 | import ( 7 | "os/exec" 8 | "runtime" 9 | "syscall" 10 | ) 11 | 12 | func StartBackgroundCommand(cmd *exec.Cmd) error { 13 | if runtime.GOOS == "darwin" { 14 | syscall.Sync() // workaround for https://github.com/golang/go/issues/33565 15 | } 16 | return cmd.Start() 17 | } 18 | -------------------------------------------------------------------------------- /console/sign.go: -------------------------------------------------------------------------------- 1 | package console 2 | 3 | import ( 4 | "crypto/hmac" 5 | "crypto/sha1" 6 | "encoding/hex" 7 | "strconv" 8 | "time" 9 | ) 10 | 11 | func timeStamp() string { 12 | return strconv.FormatInt(time.Now().UnixNano()/int64(time.Millisecond), 10) 13 | } 14 | 15 | func signCloudFunc(masterKey string, funcName string, ts string) string { 16 | key := []byte(masterKey) 17 | h := hmac.New(sha1.New, key) 18 | h.Write([]byte(funcName + ":" + ts)) 19 | return ts + "," + hex.EncodeToString(h.Sum(nil)) 20 | } 21 | -------------------------------------------------------------------------------- /runtimes/router.php.go: -------------------------------------------------------------------------------- 1 | package runtimes 2 | 3 | import ( 4 | "io/ioutil" 5 | ) 6 | 7 | var routerPHPContent = `= blockSize { 44 | zippedFile.Write(bytes) 45 | continue 46 | } 47 | zippedFile.Write(bytes[:readedBytes]) 48 | } 49 | } 50 | return nil 51 | } 52 | -------------------------------------------------------------------------------- /commands/debug_action.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "strconv" 5 | 6 | "github.com/aisk/logp" 7 | "github.com/fatih/color" 8 | "github.com/leancloud/lean-cli/api" 9 | "github.com/leancloud/lean-cli/apps" 10 | "github.com/leancloud/lean-cli/console" 11 | "github.com/leancloud/lean-cli/version" 12 | "github.com/urfave/cli" 13 | ) 14 | 15 | func debugAction(c *cli.Context) error { 16 | version.PrintVersionAndEnvironment() 17 | remote := c.String("remote") 18 | port := strconv.Itoa(c.Int("port")) 19 | appID := c.String("app-id") 20 | 21 | if appID == "" { 22 | var err error 23 | appID, err = apps.GetCurrentAppID(".") 24 | if err != nil { 25 | return err 26 | } 27 | } 28 | 29 | logp.Info("Retrieving app info ...") 30 | appInfo, err := api.GetAppInfo(appID) 31 | if err != nil { 32 | return err 33 | } 34 | logp.Infof("Current app: %s (%s)\r\n", color.RedString(appInfo.AppName), appID) 35 | 36 | cons := &console.Server{ 37 | AppID: appInfo.AppID, 38 | AppKey: appInfo.AppKey, 39 | MasterKey: appInfo.MasterKey, 40 | HookKey: appInfo.HookKey, 41 | RemoteURL: remote, 42 | ConsolePort: port, 43 | Errors: make(chan error), 44 | } 45 | 46 | cons.Run() 47 | for { 48 | select { 49 | case err = <-cons.Errors: 50 | panic(err) 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /stats/deviceid.go: -------------------------------------------------------------------------------- 1 | package stats 2 | 3 | import ( 4 | "crypto/rand" 5 | "fmt" 6 | "io" 7 | "io/ioutil" 8 | "os" 9 | "path/filepath" 10 | 11 | "github.com/leancloud/lean-cli/utils" 12 | ) 13 | 14 | func newDeviceID() (string, error) { 15 | uuid := make([]byte, 16) 16 | n, err := io.ReadFull(rand.Reader, uuid) 17 | if n != len(uuid) || err != nil { 18 | return "", err 19 | } 20 | uuid[8] = uuid[8]&^0xc0 | 0x80 21 | return fmt.Sprintf("%x-%x-%x-%x-%x", uuid[0:4], uuid[4:6], uuid[6:8], uuid[8:10], uuid[10:]), nil 22 | } 23 | 24 | // GetDeviceID returns the current machine's device ID 25 | func GetDeviceID() (string, error) { 26 | err := os.MkdirAll(filepath.Join(utils.ConfigDir(), "leancloud"), 0755) 27 | if err != nil && !os.IsExist(err) { 28 | return "", err 29 | } 30 | 31 | var deviceID string 32 | path := filepath.Join(utils.ConfigDir(), "leancloud", "device_id") 33 | content, err := ioutil.ReadFile(path) 34 | if os.IsNotExist(err) { 35 | // generate new device id 36 | deviceID, err = newDeviceID() 37 | if err != nil { 38 | return "", err 39 | } 40 | err = ioutil.WriteFile(path, []byte(deviceID), 0644) 41 | if err != nil { 42 | return "", err 43 | } 44 | } else if err != nil { 45 | return "", err 46 | } else { 47 | deviceID = string(content) 48 | } 49 | return deviceID, nil 50 | } 51 | -------------------------------------------------------------------------------- /api/error.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "strings" 8 | 9 | "github.com/levigross/grequests" 10 | ) 11 | 12 | var ( 13 | // ErrNotLogined means user was not logined 14 | ErrNotLogined = errors.New("not logined") 15 | ) 16 | 17 | // Error is the LeanCloud API Server API common error format 18 | type Error struct { 19 | Code int `json:"code"` 20 | Content string `json:"error"` 21 | ErrorEventID string `json:"errorEventID"` 22 | } 23 | 24 | func (err Error) Error() string { 25 | return fmt.Sprintf("LeanCloud API error %d: %s", err.Code, err.Content) 26 | } 27 | 28 | // NewErrorFromBody build an error value from JSON string 29 | func NewErrorFromBody(body string) error { 30 | var err Error 31 | err2 := json.Unmarshal([]byte(body), &err) 32 | if err2 != nil { 33 | panic(err2) 34 | } 35 | return err 36 | } 37 | 38 | // NewErrorFromResponse build an error value from *grequest.Response 39 | func NewErrorFromResponse(resp *grequests.Response) error { 40 | contentType := resp.Header.Get("Content-Type") 41 | if strings.HasPrefix(contentType, strings.TrimSpace("application/json")) { 42 | return NewErrorFromBody(resp.String()) 43 | } 44 | 45 | return &Error{ 46 | Code: resp.StatusCode, 47 | Content: fmt.Sprintf("Got invalid HTML response, status code: %d", resp.StatusCode), 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /commands/action_warpper.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/fatih/color" 8 | "github.com/leancloud/lean-cli/api" 9 | "github.com/leancloud/lean-cli/apps" 10 | "github.com/urfave/cli" 11 | ) 12 | 13 | func msgWithRegion(msg string) string { 14 | // If failed to detect the current region, just return the error message as is. 15 | appID, err := apps.GetCurrentAppID(".") 16 | if err != nil { 17 | return msg 18 | } 19 | region, err := apps.GetAppRegion(appID) 20 | if err != nil { 21 | return msg 22 | } 23 | return fmt.Sprintf("User doesn't sign in at region %s.", region) 24 | } 25 | 26 | func wrapAction(action cli.ActionFunc) cli.ActionFunc { 27 | prefix := color.RedString("[ERROR]") 28 | return func(c *cli.Context) error { 29 | err := action(c) 30 | switch e := err.(type) { 31 | case nil: 32 | return nil 33 | case api.Error: 34 | var msg string 35 | // Make error message more friendly to users having applications at multi regions. 36 | if strings.HasPrefix(e.Content, "unauthorized") { 37 | msg = msgWithRegion("User doesn't sign in.") 38 | } else { 39 | msg = e.Content 40 | } 41 | return cli.NewExitError(fmt.Sprintf("%s %s", prefix, msg), 1) 42 | case *cli.ExitError: 43 | return e 44 | default: 45 | return cli.NewExitError(fmt.Sprintf("%s %s", prefix, err.Error()), 1) 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /packaging/msi/lean-cli-x86.wxs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /packaging/msi/tds-cli-x64.wxs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /packaging/msi/tds-cli-x86.wxs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /packaging/msi/lean-cli-x64.wxs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /stats/collect.go: -------------------------------------------------------------------------------- 1 | package stats 2 | 3 | import ( 4 | "fmt" 5 | "runtime" 6 | 7 | "github.com/levigross/grequests" 8 | ) 9 | 10 | const GA_TRACK_ID = "UA-42629236-12" 11 | 12 | // Client is current user's client info 13 | var Client ClientType 14 | 15 | func Init() error { 16 | deviceID, err := GetDeviceID() 17 | if err != nil { 18 | return err 19 | } 20 | 21 | Client.ID = deviceID 22 | Client.Platform = runtime.GOOS 23 | return err 24 | } 25 | 26 | // Event is collect payload's evnets field type 27 | type Event struct { 28 | Event string `json:"event"` 29 | } 30 | 31 | // ClientType is collect payload's client field type 32 | type ClientType struct { 33 | ID string `json:"id"` 34 | Platform string `json:"platform"` 35 | AppVersion string `json:"app_version"` 36 | AppChannel string `json:"app_channel"` 37 | Distribution string `json:"distribution"` 38 | } 39 | 40 | // Collect the user's stats 41 | func Collect(event Event) { 42 | _, err := grequests.Post("https://www.google-analytics.com/collect", &grequests.RequestOptions{ 43 | Data: map[string]string{ 44 | "aid": Client.Platform, 45 | "aiid": Client.AppChannel, 46 | "an": Client.Distribution, 47 | "av": Client.AppVersion, 48 | "cid": Client.ID, 49 | "ea": event.Event, 50 | "ec": "run", 51 | "t": "event", 52 | "tid": GA_TRACK_ID, 53 | "v": "1", 54 | }, 55 | }) 56 | if err != nil { 57 | fmt.Println("Failed to send statistics to Google Analytics.") 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /commands/upload_action.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | 7 | "github.com/aisk/logp" 8 | "github.com/leancloud/lean-cli/api" 9 | "github.com/leancloud/lean-cli/apps" 10 | "github.com/urfave/cli" 11 | ) 12 | 13 | func uploadFile(appID string, filePath string) error { 14 | logp.Info("Uploading file: " + filePath) 15 | file, err := api.UploadFile(appID, filePath) 16 | if err != nil { 17 | return err 18 | } 19 | logp.Infof("Upload succeeded. URL: %s\r\n", file.URL) 20 | return nil 21 | } 22 | 23 | func uploadAction(c *cli.Context) error { 24 | if c.NArg() < 1 { 25 | cli.ShowCommandHelp(c, "upload") 26 | return cli.NewExitError("", 1) 27 | } 28 | 29 | appID, err := apps.GetCurrentAppID(".") 30 | if err != nil { 31 | return err 32 | } 33 | 34 | for _, filePath := range c.Args() { 35 | f, err := os.Open(filePath) 36 | if err != nil { 37 | return err 38 | } 39 | defer f.Close() 40 | stat, err := f.Stat() 41 | if err != nil { 42 | return err 43 | } 44 | if stat.IsDir() { 45 | err := filepath.Walk(filePath, func(path string, info os.FileInfo, err error) error { 46 | if err != nil { 47 | return err 48 | } 49 | if info.IsDir() { 50 | return nil 51 | } 52 | return uploadFile(appID, path) 53 | }) 54 | if err != nil { 55 | return err 56 | } 57 | } else { 58 | err := uploadFile(appID, filePath) 59 | if err != nil { 60 | return err 61 | } 62 | } 63 | } 64 | 65 | return nil 66 | } 67 | -------------------------------------------------------------------------------- /proxy/exec.go: -------------------------------------------------------------------------------- 1 | package proxy 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os/exec" 7 | ) 8 | 9 | var RuntimeClis = map[string][]string{ 10 | "udb": {"mycli", "mysql"}, 11 | "mysql": {"mycli", "mysql"}, 12 | "redis": {"iredis", "memurai-cli", "redis-cli"}, 13 | "mongo": {"mongo"}, 14 | } 15 | 16 | func GetCli(p *ProxyInfo, lookPath bool) ([]string, error) { 17 | var cli string 18 | if p.Runtime == "es" { 19 | cli = "curl" 20 | } else { 21 | clis := RuntimeClis[p.Runtime] 22 | if clis == nil { 23 | panic("invalid runtime") 24 | } 25 | 26 | if lookPath { 27 | for _, v := range clis { 28 | _, err := exec.LookPath(v) 29 | if err == nil { 30 | cli = v 31 | break 32 | } 33 | } 34 | if cli == "" { 35 | msg := fmt.Sprintf("No cli client for LeanDB runtime %s. Please install cli for runtime first.", p.Runtime) 36 | return nil, errors.New(msg) 37 | } 38 | } else { 39 | cli = clis[len(clis)-1] 40 | } 41 | } 42 | 43 | switch p.Runtime { 44 | case "redis": 45 | return []string{cli, "-h", "127.0.0.1", "-a", p.AuthPassword, "-p", p.LocalPort}, nil 46 | case "mongo": 47 | return []string{cli, "--host", "127.0.0.1", "-u", p.AuthUser, "-p", p.AuthPassword, "-port", p.LocalPort}, nil 48 | case "udb", "mysql": 49 | pass := fmt.Sprintf("-p%s", p.AuthPassword) 50 | return []string{cli, "-h", "127.0.0.1", "-u", p.AuthUser, pass, "-P", p.LocalPort}, nil 51 | case "es": 52 | return []string{cli, p.AuthUser + ":" + p.AuthPassword + "@" + "127.0.0.1" + ":" + p.LocalPort}, nil 53 | } 54 | 55 | panic("invalid runtime") 56 | } 57 | -------------------------------------------------------------------------------- /api/lean_db.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "strings" 7 | ) 8 | 9 | type LeanDBCluster struct { 10 | ID int `json:"id"` 11 | AppID string `json:"appId"` 12 | Name string `json:"name"` 13 | Runtime string `json:"runtime"` 14 | NodeQuota string `json:"nodeQuota"` 15 | Status string `json:"status"` 16 | AuthUser string `json:"authUser"` 17 | AuthPassword string `json:"authPassword"` 18 | } 19 | 20 | type LeanDBClusterSlice []*LeanDBCluster 21 | 22 | func (x LeanDBClusterSlice) Len() int { 23 | return len(x) 24 | } 25 | 26 | // NodeQuota: es-512 es-1024 mongo-512 redis-128 udb-500 27 | // compare: runtime -> quota -> id 28 | func (x LeanDBClusterSlice) Less(i, j int) bool { 29 | l, r := x[i], x[j] 30 | if l.Runtime == r.Runtime { 31 | lm, _ := strconv.Atoi(strings.Split(l.NodeQuota, "-")[1]) 32 | rm, _ := strconv.Atoi(strings.Split(r.NodeQuota, "-")[1]) 33 | if lm == rm { 34 | return l.ID < r.ID 35 | } 36 | return lm < rm 37 | } 38 | return l.Runtime < r.Runtime 39 | } 40 | 41 | func (x LeanDBClusterSlice) Swap(i, j int) { 42 | x[i], x[j] = x[j], x[i] 43 | } 44 | 45 | func GetLeanDBClusterList(appID string) (LeanDBClusterSlice, error) { 46 | client := NewClientByApp(appID) 47 | 48 | url := fmt.Sprintf("/1.1/leandb/apps/%s/clusters", appID) 49 | resp, err := client.get(url, nil) 50 | if err != nil { 51 | return nil, err 52 | } 53 | 54 | var result LeanDBClusterSlice 55 | err = resp.JSON(&result) 56 | 57 | if err != nil { 58 | return nil, err 59 | } 60 | 61 | return result, err 62 | } 63 | -------------------------------------------------------------------------------- /apps/region_cache.go: -------------------------------------------------------------------------------- 1 | package apps 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "io/ioutil" 7 | "os" 8 | "path/filepath" 9 | 10 | "github.com/leancloud/lean-cli/api/regions" 11 | "github.com/leancloud/lean-cli/utils" 12 | ) 13 | 14 | var ErrMissingRegionCache = errors.New("App configuration is incomplete. Please run `lean switch` to configure the app.") 15 | 16 | var regionCache = make(map[string]regions.Region) 17 | 18 | func GetAppRegion(appID string) (regions.Region, error) { 19 | if r, ok := regionCache[appID]; ok { 20 | return r, nil 21 | } else { 22 | return regions.Invalid, ErrMissingRegionCache 23 | } 24 | } 25 | 26 | func GetRegionCache() map[string]regions.Region { 27 | return regionCache 28 | } 29 | 30 | func SetRegionCache(appID string, region regions.Region) { 31 | regionCache[appID] = region 32 | } 33 | 34 | func SaveRegionCache() error { 35 | data, err := json.MarshalIndent(regionCache, "", " ") 36 | if err != nil { 37 | return err 38 | } 39 | return ioutil.WriteFile(filepath.Join(utils.ConfigDir(), "leancloud", "app_router.json"), data, 0644) 40 | } 41 | 42 | func init() { 43 | data, err := ioutil.ReadFile(filepath.Join(utils.ConfigDir(), "leancloud", "app_router.json")) 44 | if err != nil { 45 | if !os.IsNotExist(err) { 46 | panic(err) 47 | } 48 | } else { 49 | if err := json.Unmarshal(data, ®ionCache); err != nil { 50 | panic(err) 51 | } 52 | } 53 | } 54 | 55 | func regionInArray(region regions.Region, list []regions.Region) bool { 56 | for _, r := range list { 57 | if r == region { 58 | return true 59 | } 60 | } 61 | return false 62 | } 63 | -------------------------------------------------------------------------------- /lean/check_update.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "os" 7 | "strings" 8 | 9 | "github.com/coreos/go-semver/semver" 10 | "github.com/fatih/color" 11 | "github.com/leancloud/lean-cli/version" 12 | "github.com/levigross/grequests" 13 | ) 14 | 15 | const checkUpdateURL = "https://releases.leanapp.cn/leancloud/lean-cli/version.json" 16 | 17 | var pkgType = "go" 18 | 19 | func updateCommand() string { 20 | switch pkgType { 21 | case "go": 22 | return "go get -u github.com/leancloud/lean-cli/lean" 23 | case "homebrew": 24 | return "brew update && brew upgrade lean-cli" 25 | case "binary": 26 | return "Visit https://github.com/leancloud/lean-cli/releases" 27 | default: 28 | fmt.Fprintln(os.Stderr, "invalid pkgType: "+pkgType) 29 | return "" 30 | } 31 | } 32 | 33 | func checkUpdate() error { 34 | if pkgType == "homebrew-head" || pkgType == "aur-git" { 35 | return nil 36 | } 37 | resp, err := grequests.Get(checkUpdateURL, &grequests.RequestOptions{ 38 | UserAgent: "LeanCloud-CLI/" + version.Version, 39 | }) 40 | if err != nil { 41 | return err 42 | } 43 | 44 | var result struct { 45 | Version string `json:"version"` 46 | Message string `json:"message"` 47 | } 48 | if err := json.Unmarshal(resp.Bytes(), &result); err != nil { 49 | return err 50 | } 51 | 52 | current := semver.New(version.Version) 53 | latest := semver.New(strings.TrimPrefix(result.Version, "v")) 54 | 55 | if current.LessThan(*latest) { 56 | notice := color.New(color.FgGreen).FprintfFunc() 57 | notice(os.Stderr, "New version found: %s. Update message:\r\n%s \r\nYou can upgrade by: %s", result.Version, result.Message, updateCommand()) 58 | } 59 | 60 | return nil 61 | } 62 | -------------------------------------------------------------------------------- /commands/third_party_command.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "log" 5 | "os" 6 | "os/exec" 7 | "path/filepath" 8 | 9 | "github.com/leancloud/lean-cli/api" 10 | "github.com/leancloud/lean-cli/apps" 11 | "github.com/urfave/cli" 12 | ) 13 | 14 | func thirdPartyCommand(c *cli.Context, _cmdName string) { 15 | cmdName := "lean-" + _cmdName 16 | 17 | // executeble not found: 18 | 19 | execPath, err := exec.LookPath(filepath.Join(".leancloud", "bin", cmdName)) 20 | 21 | if err != nil { 22 | execPath, err = exec.LookPath(cmdName) 23 | if e, ok := err.(*exec.Error); ok { 24 | if e.Err == exec.ErrNotFound { 25 | cli.ShowAppHelp(c) 26 | return 27 | } 28 | log.Fatal(err) 29 | } else if err != nil { 30 | log.Fatal(err) 31 | } 32 | } 33 | 34 | cmd := exec.Command(execPath, c.Args()[1:]...) 35 | cmd.Stderr = os.Stderr 36 | cmd.Stdin = os.Stdin 37 | cmd.Stdout = os.Stdout 38 | 39 | appID, err := apps.GetCurrentAppID(".") 40 | if err == nil { 41 | region, err := apps.GetAppRegion(appID) 42 | if err != nil { 43 | log.Fatal(err) 44 | } 45 | appInfo, err := api.GetAppInfo(appID) 46 | if err != nil { 47 | log.Fatal(err) 48 | } 49 | envs := []string{ 50 | "LEANCLOUD_APP_ID=" + appInfo.AppID, 51 | "LEANCLOUD_APP_KEY=" + appInfo.AppKey, 52 | "LEANCLOUD_APP_MASTER_KEY=" + appInfo.MasterKey, 53 | "LEANCLOUD_APP_HOOK_KEY=" + appInfo.HookKey, 54 | "LEANCLOUD_APP_ENV=" + "development", 55 | "LEANCLOUD_REGION=" + region.EnvString(), 56 | } 57 | for _, env := range envs { 58 | cmd.Env = append(cmd.Env, env) 59 | } 60 | } 61 | if err != nil && err != apps.ErrNoAppLinked { 62 | log.Fatal(err) 63 | } 64 | 65 | err = cmd.Run() 66 | if err != nil { 67 | log.Fatal(err) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /api/event_poller.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "time" 7 | 8 | "github.com/fatih/color" 9 | "github.com/mattn/go-colorable" 10 | ) 11 | 12 | type deployEvent struct { 13 | MoreEvent bool `json:"moreEvent"` 14 | Events []struct { 15 | Content string `json:"content"` 16 | Level string `json:"level"` 17 | Production int `json:"production"` 18 | Time string `json:"time"` 19 | } `json:"events"` 20 | } 21 | 22 | // PollEvents will poll the server's event logs and print the result to the given io.Writer 23 | func PollEvents(appID string, tok string) (bool, error) { 24 | client := NewClientByApp(appID) 25 | 26 | opts, err := client.options() 27 | if err != nil { 28 | return false, err 29 | } 30 | opts.Headers["X-LC-Id"] = appID 31 | 32 | from := "" 33 | ok := true 34 | retryCount := 0 35 | for { 36 | time.Sleep(700 * time.Millisecond) 37 | url := "/1.1/engine/events/poll/" + tok 38 | if from != "" { 39 | url = url + "?from=" + from 40 | } 41 | resp, err := client.get(url, opts) 42 | if err != nil { 43 | retryCount++ 44 | if retryCount > 3 { 45 | return false, err 46 | } 47 | continue 48 | } 49 | event := new(deployEvent) 50 | err = resp.JSON(&event) 51 | if err != nil { 52 | return false, err 53 | } 54 | for i := len(event.Events) - 1; i >= 0; i-- { 55 | e := event.Events[i] 56 | ok = strings.ToLower(e.Level) != "error" 57 | from = e.Time 58 | if ok { 59 | fmt.Fprintf(colorable.NewColorableStderr(), color.YellowString("[REMOTE] ")+e.Content+"\r\n") 60 | } else { 61 | fmt.Fprintf(colorable.NewColorableStderr(), color.YellowString("[REMOTE] ")+color.RedString("[ERROR] ")+e.Content+"\r\n") 62 | } 63 | } 64 | if !event.MoreEvent { 65 | break 66 | } 67 | } 68 | return ok, nil 69 | } 70 | -------------------------------------------------------------------------------- /lean/main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io/ioutil" 7 | "os" 8 | "os/exec" 9 | "path/filepath" 10 | "testing" 11 | ) 12 | 13 | var testUsername, testPassword, testRegion, testGroup, testAppID, repoURL string 14 | 15 | func TestMain(m *testing.M) { 16 | testUsername, testPassword, testRegion = os.Getenv("TEST_USERNAME"), os.Getenv("TEST_PASSWORD"), os.Getenv("TEST_REGION") 17 | repoURL, testGroup, testAppID = os.Getenv("REPO_URL"), os.Getenv("TEST_GROUP"), os.Getenv("TEST_APPID") 18 | 19 | dir, err := ioutil.TempDir("", "*") 20 | if err != nil { 21 | fmt.Println(err) 22 | panic(err) 23 | } 24 | 25 | if err := os.Chdir(dir); err != nil { 26 | fmt.Println(err) 27 | panic(err) 28 | } 29 | 30 | gitExec, err := exec.LookPath("git") 31 | if err != nil { 32 | fmt.Println("can't find git executable file") 33 | panic(errors.New("can't find git executable file")) 34 | } 35 | if err := exec.Command(gitExec, "clone", repoURL, "lean-cli-deployment").Run(); err != nil { 36 | fmt.Println(err) 37 | panic(err) 38 | } 39 | 40 | gitDir := filepath.Join(dir, "lean-cli-deployment") 41 | if err := os.Chdir(gitDir); err != nil { 42 | fmt.Println(err) 43 | panic(err) 44 | } 45 | 46 | os.Exit(m.Run()) 47 | 48 | defer os.RemoveAll(dir) 49 | 50 | } 51 | 52 | func TestLogin(t *testing.T) { 53 | os.Args = []string{"lean", "login", "--username", testUsername, "--password", testPassword, "--region", testRegion} 54 | main() 55 | } 56 | 57 | func TestSwitch(t *testing.T) { 58 | os.Args = []string{"lean", "switch", "--region", testRegion, "--group", testGroup, testAppID} 59 | main() 60 | } 61 | 62 | func TestDeploy(t *testing.T) { 63 | os.Args = []string{"lean", "deploy", "--prod", "0"} 64 | main() 65 | } 66 | 67 | func TestPublish(t *testing.T) { 68 | os.Args = []string{"lean", "publish"} 69 | main() 70 | } 71 | -------------------------------------------------------------------------------- /api/apps.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "github.com/leancloud/lean-cli/api/regions" 5 | "github.com/leancloud/lean-cli/apps" 6 | ) 7 | 8 | // GetAppListResult is GetAppList function's result type 9 | type GetAppListResult struct { 10 | AppID string `json:"appId"` 11 | AppKey string `json:"appKey"` 12 | AppName string `json:"appName"` 13 | MasterKey string `json:"masterKey"` 14 | AppDomain string `json:"appDomain"` 15 | } 16 | 17 | // GetAppList returns the current user's all LeanCloud application 18 | // this will also update the app router cache 19 | func GetAppList(region regions.Region) ([]*GetAppListResult, error) { 20 | client := NewClientByRegion(region) 21 | 22 | resp, err := client.get("/client-center/2/clients/self/apps", nil) 23 | if err != nil { 24 | return nil, err 25 | } 26 | 27 | var result []*GetAppListResult 28 | err = resp.JSON(&result) 29 | if err != nil { 30 | return nil, err 31 | } 32 | 33 | for _, app := range result { 34 | apps.SetRegionCache(app.AppID, region) 35 | } 36 | 37 | if err = apps.SaveRegionCache(); err != nil { 38 | return nil, err 39 | } 40 | 41 | return result, nil 42 | } 43 | 44 | // GetAppInfoResult is GetAppInfo function's result type 45 | type GetAppInfoResult struct { 46 | AppDomain string `json:"appDomain"` 47 | AppID string `json:"appId"` 48 | AppKey string `json:"appKey"` 49 | AppName string `json:"appName"` 50 | HookKey string `json:"hookKey"` 51 | MasterKey string `json:"masterKey"` 52 | } 53 | 54 | // GetAppInfo returns the application's detail info 55 | func GetAppInfo(appID string) (*GetAppInfoResult, error) { 56 | client := NewClientByApp(appID) 57 | 58 | resp, err := client.get("/client-center/2/clients/self/apps/"+appID, nil) 59 | if err != nil { 60 | return nil, err 61 | } 62 | result := new(GetAppInfoResult) 63 | err = resp.JSON(result) 64 | return result, err 65 | } 66 | -------------------------------------------------------------------------------- /commands/publish_action.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | 7 | "github.com/aisk/logp" 8 | "github.com/fatih/color" 9 | "github.com/leancloud/lean-cli/api" 10 | "github.com/leancloud/lean-cli/apps" 11 | "github.com/leancloud/lean-cli/version" 12 | "github.com/urfave/cli" 13 | ) 14 | 15 | func publishAction(c *cli.Context) error { 16 | version.PrintVersionAndEnvironment() 17 | appID, err := apps.GetCurrentAppID("") 18 | if err == apps.ErrNoAppLinked { 19 | return cli.NewExitError("Please use `lean checkout` to designate a LeanCloud app first.", 1) 20 | } 21 | if err != nil { 22 | return err 23 | } 24 | 25 | groupName, err := apps.GetCurrentGroup(".") 26 | if err != nil { 27 | return err 28 | } 29 | 30 | region, err := apps.GetAppRegion(appID) 31 | if err != nil { 32 | return err 33 | } 34 | appInfo, err := api.GetAppInfo(appID) 35 | if err != nil { 36 | return err 37 | } 38 | groupInfo, err := api.GetGroup(appID, groupName) 39 | if err != nil { 40 | return err 41 | } 42 | 43 | if !groupInfo.Staging.Deployable { 44 | return errors.New("staging environment not available for trial version") 45 | } 46 | 47 | logp.Info(fmt.Sprintf("Current app: %s (%s), group: %s, region: %s", color.GreenString(appInfo.AppName), appID, color.GreenString(groupName), region)) 48 | logp.Info(fmt.Sprintf("Deploying version %s to %s", groupInfo.Staging.Version.VersionTag, color.GreenString("production"))) 49 | 50 | tok, err := api.DeployImage(appID, groupName, "1", groupInfo.Staging.Version.VersionTag, &api.DeployOptions{ 51 | OverwriteFuncs: c.Bool("overwrite-functions"), 52 | Options: c.String("options"), 53 | }) 54 | 55 | if err != nil { 56 | return err 57 | } 58 | 59 | ok, err := api.PollEvents(appID, tok) 60 | if err != nil { 61 | return err 62 | } 63 | if !ok { 64 | return errors.New("Deployment failed") 65 | } 66 | return nil 67 | } 68 | -------------------------------------------------------------------------------- /lean/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | "runtime" 8 | 9 | "github.com/aisk/logp" 10 | "github.com/getsentry/raven-go" 11 | "github.com/leancloud/lean-cli/commands" 12 | "github.com/leancloud/lean-cli/stats" 13 | "github.com/leancloud/lean-cli/version" 14 | ) 15 | 16 | func run() { 17 | if len(os.Args) >= 2 && os.Args[1] == "--_collect-stats" { 18 | if err := stats.Init(); err != nil { 19 | raven.CaptureError(err, nil, nil) 20 | } 21 | 22 | stats.Client.AppVersion = version.Version 23 | stats.Client.AppChannel = pkgType 24 | 25 | var event string 26 | 27 | if len(os.Args) >= 3 { 28 | event = os.Args[2] 29 | } 30 | 31 | stats.Collect(stats.Event{ 32 | Event: event, 33 | }) 34 | return 35 | } 36 | 37 | // disable the log prefix 38 | log.SetFlags(0) 39 | 40 | go func() { 41 | defer func() { 42 | if err := recover(); err != nil { 43 | logp.Warnf("Failed to check updates: %s\n", err) 44 | } 45 | }() 46 | _ = checkUpdate() 47 | }() 48 | 49 | // In v1.0 `--prod 1` changed to `--prod`, and `--prod 0` changed to `--staging`. 50 | for idx, arg := range os.Args { 51 | if arg == "--prod" && idx+1 < len(os.Args) { 52 | if os.Args[idx+1] == "0" { 53 | os.Args[idx] = "--staging" 54 | os.Args = append(os.Args[:idx+1], os.Args[idx+2:]...) 55 | } else if os.Args[idx+1] == "1" { 56 | os.Args = append(os.Args[:idx+1], os.Args[idx+2:]...) 57 | } 58 | } 59 | } 60 | 61 | commands.Run(os.Args) 62 | } 63 | 64 | func init() { 65 | err := raven.SetDSN("https://985d436efdb544c49e9389e59724ddce:6a831597d45b4309923f2567bbe7db82@sentry.lcops.cn/9") 66 | if err != nil { 67 | panic(err) 68 | } 69 | } 70 | 71 | func main() { 72 | if os.Getenv("LEAN_CLI_DEBUG") == "1" { 73 | run() 74 | return 75 | } 76 | 77 | raven.SetTagsContext(map[string]string{ 78 | "version": version.Version, 79 | "OS": runtime.GOOS, 80 | "arch": runtime.GOARCH, 81 | }) 82 | err, id := raven.CapturePanicAndWait(run, nil) 83 | if err != nil { 84 | fmt.Printf("panic: %s, Error ID: %s\r\n", err, id) 85 | os.Exit(1) 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /runtimes/ignorefiles.go: -------------------------------------------------------------------------------- 1 | package runtimes 2 | 3 | import ( 4 | "io/ioutil" 5 | "path/filepath" 6 | "strings" 7 | 8 | "github.com/aisk/logp" 9 | "github.com/facebookgo/parseignore" 10 | "github.com/leancloud/lean-cli/utils" 11 | ) 12 | 13 | // defaultIgnorePatterns returns current runtime's default ignore patterns 14 | func (runtime *Runtime) defaultIgnorePatterns() []string { 15 | switch runtime.Name { 16 | case "node.js", "static": 17 | return []string{ 18 | ".git/", 19 | ".DS_Store", 20 | ".avoscloud/", 21 | ".leancloud/", 22 | "node_modules/", 23 | } 24 | case "java": 25 | return []string{ 26 | ".git/", 27 | ".DS_Store", 28 | ".avoscloud/", 29 | ".leancloud/", 30 | ".project", 31 | ".classpath", 32 | ".settings/", 33 | "target/", 34 | } 35 | case "php": 36 | return []string{ 37 | ".git/", 38 | ".DS_Store", 39 | ".avoscloud/", 40 | ".leancloud/", 41 | "vendor/", 42 | } 43 | case "python": 44 | return []string{ 45 | ".git/", 46 | ".DS_Store", 47 | ".avoscloud/", 48 | ".leancloud/", 49 | "venv", 50 | "*.pyc", 51 | "__pycache__/", 52 | } 53 | case "dotnet": 54 | return []string{ 55 | ".git/", 56 | ".DS_Store", 57 | ".avoscloud/", 58 | ".leancloud/", 59 | "web/bin/", 60 | "web/obj/", 61 | } 62 | default: 63 | return []string{} 64 | } 65 | } 66 | 67 | func (runtime *Runtime) readIgnore(ignoreFilePath string) (parseignore.Matcher, error) { 68 | if ignoreFilePath == ".leanignore" && !utils.IsFileExists(filepath.Join(runtime.ProjectPath, ".leanignore")) { 69 | logp.Warn(".leanignore Not found. Default .leanignore created.") 70 | content := strings.Join(runtime.defaultIgnorePatterns(), "\r\n") 71 | err := ioutil.WriteFile(filepath.Join(runtime.ProjectPath, ".leanignore"), []byte(content), 0644) 72 | if err != nil { 73 | return nil, err 74 | } 75 | } 76 | 77 | content, err := ioutil.ReadFile(ignoreFilePath) 78 | if err != nil { 79 | return nil, err 80 | } 81 | 82 | matcher, errs := parseignore.CompilePatterns(content) 83 | if len(errs) != 0 { 84 | return nil, errs[0] 85 | } 86 | 87 | return matcher, nil 88 | } 89 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # lean(1) 2 | 3 | [![Build Status](https://travis-ci.org/leancloud/lean-cli.svg?branch=master)](https://travis-ci.org/leancloud/lean-cli) [![GoDoc](https://godoc.org/github.com/leancloud/lean-cli?status.svg)](https://godoc.org/github.com/leancloud/lean-cli) 4 | 5 | Command-line tool to develop and manage [LeanCloud](https://leancloud.cn) and TapTap Developer Services apps. 6 | 7 | ## Install 8 | 9 | - Homebrew: `brew install lean-cli` 10 | - Download from [GitHub Release](https://github.com/leancloud/lean-cli/releases) 11 | - Download from [releases.leanapp.cn](https://releases.leanapp.cn/#/leancloud/lean-cli/releases) (CDN Accelerated in China mainland) 12 | 13 | lean-cli will send statistics information such as your os version and lean-cli version to Google Analytics. 14 | This statistics information helps us to improve LeanEngine services. 15 | To opt out, you can set the environment variable `NO_ANALYTICS` to `true`. 16 | 17 | ## Develop 18 | 19 | Install the toolchains: 20 | 21 | - [go](https://golang.org) 22 | - [msitools](https://wiki.gnome.org/msitools) 23 | - [dpkg](https://wiki.debian.org/Teams/Dpkg) 24 | 25 | > You can install them via homebrew 26 | 27 | Clone this repo then run `make all` to build releases. 28 | 29 | Please run `go mod tidy` and `go mod vendor` to make vendored copy of dependencies after importing new dependencies. 30 | 31 | Ensure all codes is formatted by [gofmt](https://golang.org/cmd/gofmt/). Commit message should write in [gitmoji](https://gitmoji.carloscuesta.me/). 32 | 33 | Command-line interface design following [docopt](http://docopt.org/). 34 | 35 | ## Release 36 | 37 | Tag the current commit with version name, and create a [release](https://github.com/leancloud/lean-cli/releases) with this tag. run `$ make all` and attach the build result (under `./_build` folder) to the release. 38 | 39 | The homebrew guys will update the home brew [formula](https://github.com/Homebrew/homebrew-core/blob/master/Formula/lean-cli.rb). If not, or we are in a hurry, just make a pull request to them. 40 | 41 | [Releases](https://releases.leanapp.cn) will fetch from GitHub automatically. If not, or we are in a hurry, just execute cloud function `updateRepo` with argument `{"repo": "leancloud/lean-cli"}` to update. 42 | -------------------------------------------------------------------------------- /version/version.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "runtime" 8 | "strings" 9 | 10 | "github.com/aisk/logp" 11 | "github.com/leancloud/lean-cli/api/regions" 12 | ) 13 | 14 | // Version is lean-cli's version. 15 | const Version = "1.2.4" 16 | 17 | var Distribution string 18 | 19 | var defaultRegionMapping = map[string]regions.Region{ 20 | "lean": regions.ChinaNorth, 21 | "tds": regions.ChinaTDS1, 22 | } 23 | 24 | var availableRegionsMapping = map[string][]regions.Region{ 25 | "lean": {regions.ChinaNorth, regions.USWest, regions.ChinaEast}, 26 | "tds": {regions.ChinaTDS1, regions.APSG}, 27 | } 28 | 29 | var brandNameMapping = map[string]string{ 30 | "lean": "LeanCloud", 31 | "tds": "TapTap Developer Services", 32 | } 33 | 34 | var engineBrandNameMapping = map[string]string{ 35 | "lean": "LeanEngine", 36 | "tds": "Cloud Engine", 37 | } 38 | 39 | var dbBrandNameMapping = map[string]string{ 40 | "lean": "LeanDB", 41 | "tds": "Database", 42 | } 43 | 44 | var LoginViaAccessTokenOnly bool 45 | var DefaultRegion regions.Region 46 | var AvailableRegions []regions.Region 47 | 48 | var BrandName string 49 | var EngineBrandName string 50 | var DBBrandName string 51 | 52 | func init() { 53 | Distribution = filepath.Base(os.Args[0]) 54 | Distribution = strings.TrimSuffix(Distribution, filepath.Ext(Distribution)) 55 | if idx := strings.Index(Distribution, "-"); idx != -1 { 56 | Distribution = Distribution[:idx] 57 | } 58 | if Distribution != "lean" && Distribution != "tds" { 59 | logp.Warnf("Invalid executable name: `%s`, falling back to `lean`.\n", Distribution) 60 | logp.Warn("Please rename the executable to `lean` or `tds` depending on whether your app is on LeanCloud or TDS.") 61 | Distribution = "lean" 62 | } 63 | 64 | LoginViaAccessTokenOnly = Distribution == "tds" 65 | DefaultRegion = defaultRegionMapping[Distribution] 66 | AvailableRegions = availableRegionsMapping[Distribution] 67 | 68 | BrandName = brandNameMapping[Distribution] 69 | EngineBrandName = engineBrandNameMapping[Distribution] 70 | DBBrandName = dbBrandNameMapping[Distribution] 71 | } 72 | 73 | func PrintVersionAndEnvironment() { 74 | // Print all environment info to improve the efficiency of technical support 75 | logp.Info(fmt.Sprintf("%s (v%s) running on %s/%s", os.Args[0], Version, runtime.GOOS, runtime.GOARCH)) 76 | } 77 | 78 | func GetUserAgent() string { 79 | switch Distribution { 80 | case "lean": 81 | return "LeanCloud-CLI/" + Version 82 | case "tds": 83 | return "TDS-CLI/" + Version 84 | } 85 | 86 | panic("invalid distribution") 87 | } 88 | -------------------------------------------------------------------------------- /api/domain_center.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | type Type string 8 | 9 | const ( 10 | Platform Type = "platform" 11 | File Type = "file" 12 | Engine Type = "engine" 13 | EngineCdn Type = "engine-cdn" 14 | EnginePreview Type = "engine-preview" 15 | Billboard Type = "billboard" 16 | ) 17 | 18 | type SSLType string 19 | 20 | const ( 21 | None SSLType = "none" 22 | Automatic SSLType = "automatic" 23 | Uploaded SSLType = "uploaded" 24 | ) 25 | 26 | type State string 27 | 28 | const ( 29 | VerifyingIcp State = "verifyingIcp" 30 | VerifyingCname State = "verifyingCname" 31 | IssuingCert State = "issuingCert" 32 | Normal State = "normal" 33 | Suspended State = "suspended" 34 | Failed State = "failed" 35 | ) 36 | 37 | type DomainBinding struct { 38 | Type Type `json:"type"` 39 | AppId string `json:"appId"` 40 | GroupName string `json:"groupName"` 41 | Domain string `json:"domain"` 42 | CnameTarget *string `json:"cnameTarget"` 43 | IcpLicense string `json:"icpLicense"` 44 | State State `json:"state"` 45 | FailedReason *string `json:"failedReason"` 46 | SslType SSLType `json:"sslType"` 47 | SslExpiredAt *time.Time `json:"sslExpiredAt"` 48 | CreatedAt time.Time `json:"createdAt"` 49 | UpdatedAt time.Time `json:"updatedAt"` 50 | MultiAppsOnThisDomain bool `json:"multiAppsOnThisDomain"` 51 | SharedDomain bool `json:"sharedDomain"` 52 | DedicatedIPs []string `json:"dedicatedIPs"` 53 | ForceHttps int `json:"forceHttps"` 54 | } 55 | 56 | func GetDomainBindings(appID string, domainType Type, groupName string) ([]DomainBinding, error) { 57 | client := NewClientByApp(appID) 58 | opts, err := client.options() 59 | if err != nil { 60 | return nil, err 61 | } 62 | opts.Headers["X-LC-Id"] = appID 63 | 64 | url := "/1.1/domain-center/domain-bindings?type=" + string(domainType) 65 | resp, err := client.get(url, opts) 66 | if err != nil { 67 | return nil, err 68 | } 69 | 70 | var domainBindings []DomainBinding 71 | err = resp.JSON(&domainBindings) 72 | 73 | if err != nil { 74 | return nil, err 75 | } 76 | 77 | i := 0 78 | for _, domain := range domainBindings { 79 | if domain.GroupName == groupName { 80 | domainBindings[i] = domain 81 | i += 1 82 | } 83 | } 84 | domainBindings = domainBindings[:i] 85 | 86 | return domainBindings, err 87 | } 88 | -------------------------------------------------------------------------------- /apps/app.go: -------------------------------------------------------------------------------- 1 | package apps 2 | 3 | import ( 4 | "errors" 5 | "io/ioutil" 6 | "os" 7 | "path/filepath" 8 | "strings" 9 | ) 10 | 11 | var ( 12 | // ErrNoAppLinked means no app was linked to the project 13 | ErrNoAppLinked = errors.New("No Leancloud Application was linked to the project") 14 | ) 15 | 16 | func appDirPath(projectPath string) string { 17 | return filepath.Join(projectPath, ".leancloud") 18 | } 19 | 20 | func currentAppIDFilePath(projectPath string) string { 21 | return filepath.Join(appDirPath(projectPath), "current_app_id") 22 | } 23 | 24 | func currentGroupFilePath(projectPath string) string { 25 | return filepath.Join(appDirPath(projectPath), "current_group") 26 | } 27 | 28 | // LinkApp will write the specific appID to ${projectPath}/.leancloud/current_app_id 29 | func LinkApp(projectPath string, appID string) error { 30 | err := os.Mkdir(appDirPath(projectPath), 0775) 31 | if err != nil && !os.IsExist(err) { 32 | return err 33 | } 34 | 35 | return ioutil.WriteFile(currentAppIDFilePath(projectPath), []byte(appID), 0644) 36 | } 37 | 38 | // LinkGroup will write the specific groupName to ${projectPath}/.leancloud/current_group 39 | func LinkGroup(projectPath string, groupName string) error { 40 | err := os.Mkdir(appDirPath(projectPath), 0775) 41 | if err != nil && !os.IsExist(err) { 42 | return err 43 | } 44 | 45 | return ioutil.WriteFile(currentGroupFilePath(projectPath), []byte(groupName), 0644) 46 | } 47 | 48 | // GetCurrentAppID will return the content of ${projectPath}/.leancloud/current_app_id 49 | func GetCurrentAppID(projectPath string) (string, error) { 50 | content, err := ioutil.ReadFile(currentAppIDFilePath(projectPath)) 51 | if err != nil && os.IsNotExist(err) { 52 | return "", ErrNoAppLinked 53 | } else if err != nil { 54 | return "", err 55 | } 56 | appID := strings.TrimSpace(string(content)) 57 | if appID == "" { 58 | msg := "Invalid app, please check the `.leancloud/current_app_id`'s content." 59 | return "", errors.New(msg) 60 | } 61 | 62 | if _, err = GetAppRegion(appID); err != nil { 63 | return "", err 64 | } 65 | 66 | return appID, nil 67 | } 68 | 69 | // GetCurrentGroup returns the content of ${projectPath}/.leancloud/current_group if it exists, 70 | // or migrate the project's primary group. 71 | func GetCurrentGroup(projectPath string) (string, error) { 72 | content, err := ioutil.ReadFile(currentGroupFilePath(projectPath)) 73 | if err != nil { 74 | return "", err 75 | } 76 | groupName := strings.TrimSpace(string(content)) 77 | if groupName == "" { 78 | msg := "Invalid group, please check the `.leancloud/current_group`'s content." 79 | return "", errors.New(msg) 80 | } 81 | return groupName, nil 82 | } 83 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/leancloud/lean-cli 2 | 3 | go 1.16 4 | 5 | require ( 6 | github.com/ahmetalpbalkan/go-linq v2.0.0-rc0.0.20161205073338-ba42ddae458c+incompatible 7 | github.com/aisk/logp v0.0.0-20170801054927-af23a256979e 8 | github.com/aisk/wizard v0.0.0-20170904055312-348bc5188016 9 | github.com/alessio/shellescape v1.4.1 // indirect 10 | github.com/cbroglie/mustache v0.0.0-20161020193316-6857e4b493bd 11 | github.com/certifi/gocertifi v0.0.0-20160926115448-a61bf5eafa3a // indirect 12 | github.com/cheggaaa/pb v1.0.15 13 | github.com/chzyer/logex v1.1.10 // indirect 14 | github.com/chzyer/readline v0.0.0-20161106042343-c914be64f07d 15 | github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 // indirect 16 | github.com/cloudfoundry-attic/jibber_jabber v0.0.0-20151120183258-bcc4c8345a21 17 | github.com/cloudfoundry/jibber_jabber v0.0.0-20151120183258-bcc4c8345a21 // indirect 18 | github.com/coreos/go-semver v0.2.0 19 | github.com/facebookgo/ensure v0.0.0-20200202191622-63f1cf65ac4c // indirect 20 | github.com/facebookgo/parseignore v0.0.0-20150727182244-41ebb753c47c 21 | github.com/facebookgo/stack v0.0.0-20160209184415-751773369052 // indirect 22 | github.com/facebookgo/subset v0.0.0-20200203212716-c811ad88dec4 // indirect 23 | github.com/facebookgo/symwalk v0.0.0-20150726040526-42004b9f3222 24 | github.com/facebookgo/testname v0.0.0-20150612200628-5443337c3a12 // indirect 25 | github.com/fatih/color v1.5.0 26 | github.com/getsentry/raven-go v0.0.0-20161115135411-3f7439d3e74d 27 | github.com/google/go-querystring v0.0.0-20160401233042-9235644dd9e5 // indirect 28 | github.com/gorilla/context v1.1.1 // indirect 29 | github.com/gorilla/mux v1.4.0 30 | github.com/juju/go4 v0.0.0-20160222163258-40d72ab9641a // indirect 31 | github.com/juju/persistent-cookiejar v0.0.0-20161115133328-5243747bf8f2 32 | github.com/juju/testing v0.0.0-20200706033705-4c23f9c453cd // indirect 33 | github.com/leancloud/go-upload v0.0.0-20180201132422-99b47d07b1d1 34 | github.com/levigross/grequests v0.0.0-20161120011735-14e4175cc49c 35 | github.com/mattn/go-colorable v0.0.8-0.20170210172801-5411d3eea597 36 | github.com/mattn/go-isatty v0.0.12 37 | github.com/mattn/go-runewidth v0.0.2-0.20161012013512-737072b4e32b // indirect 38 | github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect 39 | github.com/onsi/ginkgo v1.14.0 // indirect 40 | github.com/urfave/cli v1.19.1 41 | golang.org/x/sys v0.0.0-20220624220833-87e55d714810 // indirect 42 | gopkg.in/alessio/shellescape.v1 v1.0.0-20170105083845-52074bc9df61 43 | gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect 44 | gopkg.in/cheggaaa/pb.v1 v1.0.28 // indirect 45 | gopkg.in/errgo.v1 v1.0.0-20151007153157-66cb46252b94 // indirect 46 | gopkg.in/retry.v1 v1.0.0-20161025181430-c09f6b86ba4d // indirect 47 | launchpad.net/gocheck v0.0.0-20140225173054-000000000087 // indirect 48 | nhooyr.io/websocket v1.8.7 49 | ) 50 | -------------------------------------------------------------------------------- /commands/info_action.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/aisk/logp" 7 | "github.com/leancloud/lean-cli/api" 8 | "github.com/leancloud/lean-cli/api/regions" 9 | "github.com/leancloud/lean-cli/apps" 10 | "github.com/leancloud/lean-cli/version" 11 | "github.com/urfave/cli" 12 | ) 13 | 14 | func infoAction(c *cli.Context) error { 15 | callbacks := make([]func(), 0) 16 | 17 | loginedRegions := regions.GetLoginedRegions(version.AvailableRegions) 18 | 19 | if len(loginedRegions) == 0 { 20 | return cli.NewExitError("Please log in first", 1) 21 | } 22 | 23 | for _, loginedRegion := range loginedRegions { 24 | loginedRegion := loginedRegion 25 | logp.Infof("Retrieving user info from region: %s\r\n", loginedRegion) 26 | userInfo, err := api.GetUserInfo(loginedRegion) 27 | if err != nil { 28 | e, ok := err.(api.Error) 29 | if ok && strings.HasPrefix(e.Content, "unauthorized") { 30 | logp.Errorf("User doesn't sign in at region: %s\r\n", loginedRegion) 31 | } else { 32 | callbacks = append(callbacks, func() { 33 | logp.Errorf("Failed to retrieve user info from region: %s: %v\r\n", loginedRegion, err) 34 | }) 35 | } 36 | } else { 37 | callbacks = append(callbacks, func() { 38 | logp.Infof("Current region: %s User: %s (%s)\r\n", loginedRegion, userInfo.UserName, userInfo.Email) 39 | }) 40 | } 41 | } 42 | 43 | logp.Info("Retrieving app info ...") 44 | appID, err := apps.GetCurrentAppID(".") 45 | 46 | if err == apps.ErrNoAppLinked { 47 | callbacks = append(callbacks, func() { 48 | logp.Warn("There is no LeanCloud app associated with the current directory") 49 | }) 50 | } else if err != nil { 51 | callbacks = append(callbacks, func() { 52 | logp.Error("Failed to retrieve the app associated with the current directory", err) 53 | }) 54 | } else { 55 | appInfo, err := api.GetAppInfo(appID) 56 | if err != nil { 57 | e, ok := err.(api.Error) 58 | if ok && strings.HasPrefix(e.Content, "unauthorized") { 59 | logp.Errorf("User doesn't sign in.\r\n") 60 | } else { 61 | callbacks = append(callbacks, func() { 62 | logp.Error("Failed to retrieve app info: ", err) 63 | }) 64 | } 65 | } else { 66 | region, err := apps.GetAppRegion(appID) 67 | if err != nil { 68 | callbacks = append(callbacks, func() { 69 | logp.Error("Failed to retrieve app region: ", err) 70 | }) 71 | } else { 72 | callbacks = append(callbacks, func() { 73 | logp.Infof("Current region: %s App: %s (%s)\r\n", region, appInfo.AppName, appInfo.AppID) 74 | }) 75 | group, err := apps.GetCurrentGroup(".") 76 | if err != nil { 77 | callbacks = append(callbacks, func() { 78 | logp.Error("Failed to retrieve group info: ", err) 79 | }) 80 | } else { 81 | callbacks = append(callbacks, func() { 82 | logp.Infof("Current group: %s\r\n", group) 83 | }) 84 | } 85 | } 86 | } 87 | } 88 | 89 | for _, callback := range callbacks { 90 | callback() 91 | } 92 | 93 | return nil 94 | } 95 | -------------------------------------------------------------------------------- /proxy/proxy.go: -------------------------------------------------------------------------------- 1 | package proxy 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "net" 8 | "net/http" 9 | "os" 10 | "strings" 11 | "time" 12 | 13 | "github.com/aisk/logp" 14 | "github.com/leancloud/lean-cli/api" 15 | "nhooyr.io/websocket" 16 | ) 17 | 18 | type ProxyInfo struct { 19 | AppID string 20 | ClusterId int 21 | Name string 22 | Runtime string 23 | AuthUser string 24 | AuthPassword string 25 | LocalPort string 26 | } 27 | 28 | var connTimeout = 2 * time.Hour 29 | var pingInterval = 4 * time.Minute 30 | 31 | func RunProxy(p *ProxyInfo) error { 32 | l, err := net.Listen("tcp", ":"+p.LocalPort) 33 | if err != nil { 34 | return err 35 | } 36 | 37 | cli, err := GetCli(p, false) 38 | if err != nil { 39 | return err 40 | } 41 | logp.Infof("Now, you can connect [%s] via %s\r\n", p.Name, cli) 42 | 43 | for { 44 | conn, err := l.Accept() 45 | if err != nil { 46 | return err 47 | } 48 | go proxy(conn, p) 49 | } 50 | } 51 | 52 | func RunShellProxy(p *ProxyInfo, started, term chan bool) error { 53 | l, err := net.Listen("tcp", ":"+p.LocalPort) 54 | if err != nil { 55 | return err 56 | } 57 | 58 | started <- true 59 | go func() { 60 | select { 61 | case <-term: 62 | os.Exit(0) 63 | } 64 | }() 65 | 66 | for { 67 | conn, err := l.Accept() 68 | if err != nil { 69 | return err 70 | } 71 | go proxy(conn, p) 72 | } 73 | } 74 | 75 | func proxy(conn net.Conn, p *ProxyInfo) { 76 | client := api.NewClientByApp(p.AppID) 77 | path := fmt.Sprintf("/1.1/leandb/proxy/ws?clusterid=%d", p.ClusterId) 78 | remoteURL := strings.Replace(client.GetBaseURL(), "https", "wss", 1) + path 79 | 80 | defer conn.Close() 81 | 82 | ctx, cancel := context.WithTimeout(context.Background(), connTimeout) 83 | defer cancel() 84 | 85 | c, _, err := websocket.Dial(ctx, remoteURL, buildOpts(p, client)) 86 | if err != nil { 87 | logp.Warnf("Dial remote websocket endpoint get error: %s", err) 88 | return 89 | } 90 | 91 | pingWithTicker(ctx, c) 92 | 93 | remote := websocket.NetConn(ctx, c, websocket.MessageBinary) 94 | defer remote.Close() 95 | 96 | go io.Copy(remote, conn) 97 | io.Copy(conn, remote) 98 | } 99 | 100 | func buildOpts(p *ProxyInfo, client *api.Client) *websocket.DialOptions { 101 | opts := &websocket.DialOptions{ 102 | HTTPHeader: http.Header{}, 103 | HTTPClient: &http.Client{}, 104 | } 105 | for k, v := range client.GetAuthHeaders() { 106 | opts.HTTPHeader.Add(k, v) 107 | } 108 | if client.AccessToken == "" && client.CookieJar != nil { 109 | opts.HTTPClient.Jar = client.CookieJar 110 | } 111 | 112 | return opts 113 | } 114 | 115 | func pingWithTicker(ctx context.Context, c *websocket.Conn) { 116 | ticker := time.NewTicker(pingInterval) 117 | 118 | go func() { 119 | for { 120 | select { 121 | case <-ctx.Done(): 122 | ticker.Stop() 123 | return 124 | case <-ticker.C: 125 | c.Ping(ctx) 126 | } 127 | } 128 | }() 129 | } 130 | -------------------------------------------------------------------------------- /api/regions/regions.go: -------------------------------------------------------------------------------- 1 | package regions 2 | 3 | import ( 4 | "encoding/json" 5 | "io/ioutil" 6 | "os" 7 | "path/filepath" 8 | 9 | "github.com/leancloud/lean-cli/utils" 10 | ) 11 | 12 | type Region int 13 | 14 | const ( 15 | Invalid Region = iota 16 | ChinaNorth 17 | USWest 18 | ChinaEast 19 | ChinaTDS1 20 | APSG 21 | ) 22 | 23 | var regionLoginStatus = make(map[Region]bool) 24 | 25 | func init() { 26 | regionStatus, err := ioutil.ReadFile(filepath.Join(utils.ConfigDir(), "leancloud", "logined_regions.json")) 27 | if err != nil { 28 | if !os.IsNotExist(err) { 29 | panic(err) 30 | } 31 | } else { 32 | if err := json.Unmarshal(regionStatus, ®ionLoginStatus); err != nil { 33 | panic(err) 34 | } 35 | } 36 | } 37 | 38 | func (r Region) String() string { 39 | switch r { 40 | case ChinaNorth: 41 | return "cn-n1" 42 | case ChinaEast: 43 | return "cn-e1" 44 | case USWest: 45 | return "us-w1" 46 | case ChinaTDS1: 47 | return "cn-tds1" 48 | case APSG: 49 | return "ap-sg" 50 | default: 51 | return "invalid" 52 | } 53 | } 54 | 55 | func (r Region) EnvString() string { 56 | switch r { 57 | case ChinaNorth, ChinaEast, ChinaTDS1: 58 | return "CN" 59 | case USWest: 60 | return "US" 61 | case APSG: 62 | return "AP" 63 | default: 64 | return "invalid" 65 | } 66 | } 67 | 68 | func (r Region) Description() string { 69 | switch r { 70 | case ChinaNorth: 71 | return "LeanCloud (China North)" 72 | case USWest: 73 | return "LeanCloud (International)" 74 | case ChinaEast: 75 | return "LeanCloud (China East)" 76 | case ChinaTDS1: 77 | return "TDS (China Mainland)" 78 | case APSG: 79 | return "TDS (Global)" 80 | default: 81 | return "Invalid" 82 | } 83 | } 84 | 85 | func Parse(region string) Region { 86 | switch region { 87 | case "cn", "CN", "cn-n1": 88 | return ChinaNorth 89 | case "tab", "TAB", "cn-e1": 90 | return ChinaEast 91 | case "us", "US", "us-w1": 92 | return USWest 93 | case "cn-tds1": 94 | return ChinaTDS1 95 | case "ap-sg", "AP": 96 | return APSG 97 | default: 98 | return Invalid 99 | } 100 | } 101 | 102 | func (r Region) InChina() bool { 103 | switch r { 104 | case ChinaNorth, ChinaEast, ChinaTDS1: 105 | return true 106 | case USWest, APSG: 107 | return false 108 | } 109 | panic("invalid region") 110 | } 111 | 112 | // Only return available regions 113 | func GetLoginedRegions(availableRegions []Region) []Region { 114 | var regions []Region 115 | for _, region := range availableRegions { 116 | if regionLoginStatus[region] { 117 | regions = append(regions, region) 118 | } 119 | } 120 | 121 | return regions 122 | } 123 | 124 | func SetRegionLoginStatus(region Region) { 125 | regionLoginStatus[region] = true 126 | } 127 | 128 | func SaveRegionLoginStatus() error { 129 | data, err := json.MarshalIndent(regionLoginStatus, "", " ") 130 | if err != nil { 131 | return err 132 | } 133 | 134 | return ioutil.WriteFile(filepath.Join(utils.ConfigDir(), "leancloud", "logined_regions.json"), data, 0644) 135 | } 136 | -------------------------------------------------------------------------------- /api/account.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "github.com/leancloud/lean-cli/api/regions" 5 | "github.com/leancloud/lean-cli/version" 6 | "github.com/levigross/grequests" 7 | ) 8 | 9 | // Login LeanCloud account 10 | func Login(email string, password string, region regions.Region) (*GetUserInfoResult, error) { 11 | jar := newCookieJar() 12 | 13 | options := &grequests.RequestOptions{ 14 | JSON: map[string]string{ 15 | "email": email, 16 | "password": password, 17 | }, 18 | CookieJar: jar, 19 | UseCookieJar: true, 20 | UserAgent: version.GetUserAgent(), 21 | } 22 | client := NewClientByRegion(region) 23 | 24 | resp, err := grequests.Post(client.GetBaseURL()+"/client-center/2/signin", options) 25 | if resp.StatusCode == 401 { 26 | var result struct { 27 | Token string `json:"token"` 28 | } 29 | 30 | if err = resp.JSON(&result); err != nil { 31 | return nil, err 32 | } 33 | token := result.Token 34 | if token != "" { 35 | code, err := Get2FACode() 36 | if err != nil { 37 | return nil, err 38 | } 39 | options.JSON = map[string]string{ 40 | "email": email, 41 | "password": password, 42 | "code": code, 43 | } 44 | resp, err = grequests.Post(client.GetBaseURL()+"/client-center/2/signin", options) 45 | } 46 | } 47 | 48 | if err != nil { 49 | return nil, err 50 | } 51 | 52 | if !resp.Ok { 53 | return nil, NewErrorFromResponse(resp) 54 | } 55 | 56 | if err := jar.Save(); err != nil { 57 | return nil, err 58 | } 59 | 60 | regions.SetRegionLoginStatus(region) 61 | if err := regions.SaveRegionLoginStatus(); err != nil { 62 | return nil, err 63 | } 64 | 65 | result := new(GetUserInfoResult) 66 | err = resp.JSON(result) 67 | return result, err 68 | } 69 | 70 | func LoginWithAccessToken(accessToken string, region regions.Region) (*GetUserInfoResult, error) { 71 | client := NewClientByRegion(region) 72 | client.AccessToken = accessToken 73 | 74 | resp, err := client.get("/client-center/2/clients/self", nil) 75 | if err != nil { 76 | return nil, err 77 | } 78 | 79 | userInfo := new(GetUserInfoResult) 80 | if err := resp.JSON(userInfo); err != nil { 81 | return nil, err 82 | } 83 | 84 | if err := accessTokenCache.Add(accessToken, region).Save(); err != nil { 85 | return nil, err 86 | } 87 | regions.SetRegionLoginStatus(region) 88 | if err := regions.SaveRegionLoginStatus(); err != nil { 89 | return nil, err 90 | } 91 | 92 | return userInfo, nil 93 | } 94 | 95 | // GetUserInfoResult is the return type of GetUserInfo 96 | type GetUserInfoResult struct { 97 | Email string `json:"email"` 98 | UserName string `json:"username"` 99 | } 100 | 101 | // GetUserInfo returns the current logined user info 102 | func GetUserInfo(region regions.Region) (*GetUserInfoResult, error) { 103 | client := NewClientByRegion(region) 104 | 105 | resp, err := client.get("/client-center/2/clients/self", nil) 106 | if err != nil { 107 | return nil, err 108 | } 109 | 110 | result := new(GetUserInfoResult) 111 | err = resp.JSON(result) 112 | return result, err 113 | } 114 | -------------------------------------------------------------------------------- /rediscommands/commands.go: -------------------------------------------------------------------------------- 1 | package rediscommands 2 | 3 | // Commands is a string slice that contains all redis's command 4 | var Commands = []string{ 5 | "append", 6 | "asking", 7 | "auth", 8 | "bgrewriteaof", 9 | "bgsave", 10 | "bitcount", 11 | "bitfield", 12 | "bitop", 13 | "bitpos", 14 | "blpop", 15 | "brpop", 16 | "brpoplpush", 17 | "client", 18 | "cluster", 19 | "command", 20 | "config", 21 | "dbsize", 22 | "debug", 23 | "decr", 24 | "decrby", 25 | "del", 26 | "discard", 27 | "dump", 28 | "echo", 29 | "eval", 30 | "evalsha", 31 | "exec", 32 | "exists", 33 | "expire", 34 | "expireat", 35 | "flushall", 36 | "flushdb", 37 | "geoadd", 38 | "geodist", 39 | "geohash", 40 | "geopos", 41 | "georadius", 42 | "georadiusbymember", 43 | "get", 44 | "getbit", 45 | "getrange", 46 | "getset", 47 | "hdel", 48 | "hexists", 49 | "hget", 50 | "hgetall", 51 | "hincrby", 52 | "hincrbyfloat", 53 | "hkeys", 54 | "hlen", 55 | "hmget", 56 | "hmset", 57 | "hscan", 58 | "hset", 59 | "hsetnx", 60 | "hstrlen", 61 | "hvals", 62 | "incr", 63 | "incrby", 64 | "incrbyfloat", 65 | "info", 66 | "keys", 67 | "lastsave", 68 | "latency", 69 | "lindex", 70 | "linsert", 71 | "llen", 72 | "lpop", 73 | "lpush", 74 | "lpushx", 75 | "lrange", 76 | "lrem", 77 | "lset", 78 | "ltrim", 79 | "mget", 80 | "migrate", 81 | "monitor", 82 | "move", 83 | "mset", 84 | "msetnx", 85 | "multi", 86 | "object", 87 | "persist", 88 | "pexpire", 89 | "pexpireat", 90 | "pfadd", 91 | "pfcount", 92 | "pfdebug", 93 | "pfmerge", 94 | "pfselftest", 95 | "ping", 96 | "psetex", 97 | "psubscribe", 98 | "psync", 99 | "pttl", 100 | "publish", 101 | "pubsub", 102 | "punsubscribe", 103 | "quit", 104 | "randomkey", 105 | "readonly", 106 | "readwrite", 107 | "rename", 108 | "renamenx", 109 | "replconf", 110 | "restore", 111 | "restore-asking", 112 | "role", 113 | "rpop", 114 | "rpoplpush", 115 | "rpush", 116 | "rpushx", 117 | "sadd", 118 | "save", 119 | "scan", 120 | "scard", 121 | "script", 122 | "sdiff", 123 | "sdiffstore", 124 | "select", 125 | "set", 126 | "setbit", 127 | "setex", 128 | "setnx", 129 | "setrange", 130 | "shutdown", 131 | "sinter", 132 | "sinterstore", 133 | "sismember", 134 | "slaveof", 135 | "slowlog", 136 | "smembers", 137 | "smove", 138 | "sort", 139 | "spop", 140 | "srandmember", 141 | "srem", 142 | "sscan", 143 | "strlen", 144 | "subscribe", 145 | "substr", 146 | "sunion", 147 | "sunionstore", 148 | "sync", 149 | "time", 150 | "ttl", 151 | "type", 152 | "unlink", 153 | "unsubscribe", 154 | "unwatch", 155 | "wait", 156 | "watch", 157 | "zadd", 158 | "zcard", 159 | "zcount", 160 | "zincrby", 161 | "zinterstore", 162 | "zlexcount", 163 | "zrange", 164 | "zrangebylex", 165 | "zrangebyscore", 166 | "zrank", 167 | "zrem", 168 | "zremrangebylex", 169 | "zremrangebyrank", 170 | "zremrangebyscore", 171 | "zrevrange", 172 | "zrevrangebylex", 173 | "zrevrangebyscore", 174 | "zrevrank", 175 | "zscan", 176 | "zscore", 177 | "zunionstore", 178 | } 179 | 180 | // ForEach is a command generator 181 | func ForEach(f func(string, ...interface{}) interface{}) []interface{} { 182 | var result []interface{} 183 | for _, command := range Commands { 184 | result = append(result, f(command)) 185 | } 186 | return result 187 | } 188 | -------------------------------------------------------------------------------- /api/file.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "errors" 5 | "mime" 6 | "os" 7 | "path/filepath" 8 | 9 | "github.com/cheggaaa/pb" 10 | "github.com/fatih/color" 11 | "github.com/leancloud/go-upload" 12 | "github.com/leancloud/lean-cli/api/regions" 13 | "github.com/leancloud/lean-cli/apps" 14 | "github.com/mattn/go-colorable" 15 | ) 16 | 17 | type fileBarReaderSeeker struct { 18 | file *os.File 19 | reader *pb.Reader 20 | } 21 | 22 | func (f *fileBarReaderSeeker) Seek(offset int64, whence int) (ret int64, err error) { 23 | return f.file.Seek(offset, whence) 24 | } 25 | 26 | func (f *fileBarReaderSeeker) Read(b []byte) (n int, err error) { 27 | return f.reader.Read(b) 28 | } 29 | 30 | // UploadFile upload specific file to LeanCloud 31 | func UploadFile(appID string, filePath string) (*upload.File, error) { 32 | appInfo, err := GetAppInfo(appID) 33 | if err != nil { 34 | return nil, err 35 | } 36 | 37 | region, err := apps.GetAppRegion(appID) 38 | 39 | if err != nil { 40 | return nil, err 41 | } 42 | 43 | return UploadFileEx(appInfo.AppID, appInfo.AppKey, region, filePath) 44 | } 45 | 46 | // UploadFileEx upload specific file to LeanCloud 47 | func UploadFileEx(appID string, appKey string, region regions.Region, filePath string) (*upload.File, error) { 48 | _, fileName := filepath.Split(filePath) 49 | mimeType := mime.TypeByExtension(filepath.Ext(filePath)) 50 | 51 | f, err := os.Open(filePath) 52 | if err != nil { 53 | return nil, err 54 | } 55 | 56 | stat, err := f.Stat() 57 | if err != nil { 58 | return nil, err 59 | } 60 | bar := pb.New(int(stat.Size())).SetUnits(pb.U_BYTES).SetMaxWidth(80) 61 | bar.Output = colorable.NewColorableStderr() 62 | bar.Prefix(color.GreenString("[INFO]") + " Uploading file") 63 | bar.Start() 64 | 65 | // qiniu want a io.ReadSeeker to get file's size 66 | readSeeker := &fileBarReaderSeeker{ 67 | file: f, 68 | reader: bar.NewProxyReader(f), 69 | } 70 | 71 | file, err := upload.Upload(fileName, mimeType, readSeeker, &upload.Options{ 72 | AppID: appID, 73 | AppKey: appKey, 74 | APIServer: GetAppAPIURL(region, appID), 75 | }) 76 | if err != nil { 77 | return nil, err 78 | } 79 | 80 | bar.Finish() 81 | if err != nil { 82 | return nil, err 83 | } 84 | 85 | if file.URL == "" { 86 | return nil, errors.New("Failed to upload file") 87 | } 88 | return file, err 89 | } 90 | 91 | // upload code zip package to a concentrated app in specific region 92 | func UploadToRepoStorage(region regions.Region, filePath string) (*upload.File, error) { 93 | appID, appKey, uploadRegion := getRepoStorageInfo(region) 94 | return UploadFileEx(appID, appKey, uploadRegion, filePath) 95 | } 96 | 97 | func DeleteFromRepoStorage(region regions.Region, objectID string) error { 98 | appID, appKey, uploadRegion := getRepoStorageInfo(region) 99 | 100 | client := NewClientByRegion(uploadRegion) 101 | opts, err := client.options() 102 | if err != nil { 103 | return err 104 | } 105 | opts.Headers["X-LC-Id"] = appID 106 | opts.Headers["X-LC-Key"] = appKey 107 | _, err = client.delete("/1.1/files/"+objectID, opts) 108 | return err 109 | } 110 | 111 | func getRepoStorageInfo(region regions.Region) (appId string, appKey string, uploadRegion regions.Region) { 112 | switch region { 113 | case regions.USWest: 114 | return "iuuztdrr4mj683kbsmwoalt1roaypb5d25eu0f23lrfsthgn", "exhqkdnvtjw34p5670r4zlofdsc91likhzfxmr9jz7vnbc07", regions.USWest 115 | default: 116 | return "x7WmVG0x63V6u8MCYM8qxKo8-gzGzoHsz", "PcDNOjiEpYc0DTz2E9kb5fvu", regions.ChinaNorth 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /commands/login_action.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "github.com/aisk/logp" 5 | "github.com/aisk/wizard" 6 | "github.com/leancloud/lean-cli/api" 7 | "github.com/leancloud/lean-cli/api/regions" 8 | "github.com/leancloud/lean-cli/version" 9 | "github.com/urfave/cli" 10 | ) 11 | 12 | func inputAccountInfo() (string, string, error) { 13 | email := new(string) 14 | password := new(string) 15 | err := wizard.Ask([]wizard.Question{ 16 | { 17 | Content: "Email: ", 18 | Input: &wizard.Input{ 19 | Result: email, 20 | Hidden: false, 21 | }, 22 | }, 23 | { 24 | Content: "Password: ", 25 | Input: &wizard.Input{ 26 | Result: password, 27 | Hidden: true, 28 | }, 29 | }, 30 | }) 31 | 32 | return *email, *password, err 33 | } 34 | 35 | func inputAcessToken() (string, error) { 36 | accessToken := new(string) 37 | err := wizard.Ask([]wizard.Question{ 38 | { 39 | Content: func() string { 40 | if version.Distribution == "lean" { 41 | return "Paste AccessToken from LeanCloud Console => Account settings => Access tokens: " 42 | } else { 43 | return "Paste AccessToken from TapTap Developer Center => your Game => Game Services => Cloud Services => Cloud Engine => Deploy of your group => Deploy using CLI: " 44 | } 45 | }(), 46 | Input: &wizard.Input{ 47 | Result: accessToken, 48 | Hidden: false, 49 | }, 50 | }, 51 | }) 52 | 53 | return *accessToken, err 54 | } 55 | 56 | func loginWithPassword(username string, password string, region regions.Region) (*api.GetUserInfoResult, error) { 57 | if username == "" || password == "" { 58 | var err error 59 | username, password, err = inputAccountInfo() 60 | if err != nil { 61 | return nil, err 62 | } 63 | } 64 | 65 | return api.Login(username, password, region) 66 | } 67 | 68 | func loginWithAccessToken(token string, region regions.Region) (*api.GetUserInfoResult, error) { 69 | if token == "" { 70 | var err error 71 | token, err = inputAcessToken() 72 | if err != nil { 73 | return nil, err 74 | } 75 | } 76 | 77 | return api.LoginWithAccessToken(token, region) 78 | } 79 | 80 | func loginAction(c *cli.Context) error { 81 | username := c.String("username") 82 | password := c.String("password") 83 | regionString := c.String("region") 84 | useToken := c.Bool("use-token") 85 | token := c.String("token") 86 | var region regions.Region 87 | var err error 88 | var userInfo *api.GetUserInfoResult 89 | 90 | if len(version.AvailableRegions) > 1 { 91 | if regionString == "" { 92 | region, err = selectRegion(version.AvailableRegions) 93 | if err != nil { 94 | return err 95 | } 96 | } else { 97 | region = regions.Parse(regionString) 98 | } 99 | 100 | if region == regions.Invalid { 101 | cli.ShowCommandHelp(c, "login") 102 | return cli.NewExitError("Wrong region parameter", 1) 103 | } 104 | } else { 105 | region = version.AvailableRegions[0] 106 | } 107 | 108 | if version.LoginViaAccessTokenOnly || useToken || token != "" { 109 | if token == "" { 110 | token, err = inputAcessToken() 111 | if err != nil { 112 | return err 113 | } 114 | } 115 | 116 | userInfo, err = loginWithAccessToken(token, region) 117 | if err != nil { 118 | return err 119 | } 120 | } else { 121 | userInfo, err = loginWithPassword(username, password, region) 122 | if err != nil { 123 | return err 124 | } 125 | } 126 | 127 | _, err = api.GetAppList(region) // load region cache 128 | if err != nil { 129 | return err 130 | } 131 | logp.Info("Login succeeded: ") 132 | logp.Infof("Username: %s\r\n", userInfo.UserName) 133 | logp.Infof("Email: %s\r\n", userInfo.Email) 134 | 135 | return nil 136 | } 137 | -------------------------------------------------------------------------------- /api/log.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strconv" 7 | "time" 8 | 9 | "github.com/levigross/grequests" 10 | ) 11 | 12 | // Log is EngineLogs's type structure 13 | type Log struct { 14 | InstanceName string `json:"instanceName"` 15 | Content string `json:"content"` 16 | Type string `json:"type"` 17 | Time string `json:"time"` 18 | GroupName string `json:"groupName"` 19 | Production int `json:"prod"` 20 | Stream string `json:"stream"` 21 | ID string `json:"id"` 22 | } 23 | 24 | // LogReceiver is print func interface to PrintLogs 25 | type LogReceiver func(*Log) error 26 | 27 | // ReceiveLogsByLimit will poll the leanengine's log and print it to the giver io.Writer 28 | func ReceiveLogsByLimit(printer LogReceiver, appID string, masterKey string, isProd bool, group string, limit int, follow bool) error { 29 | params := map[string]string{ 30 | "limit": strconv.Itoa(limit), 31 | "prod": "0", 32 | "groupName": group, 33 | } 34 | if isProd { 35 | params["prod"] = "1" 36 | } 37 | 38 | logIDSet := map[string]bool{} 39 | for { 40 | logs, err := fetchLogs(appID, masterKey, params, isProd) 41 | if err != nil { 42 | return err 43 | } 44 | 45 | for i := len(logs) - 1; i >= 0; i-- { 46 | log := logs[i] 47 | if _, ok := logIDSet[log.ID]; ok { 48 | continue 49 | } 50 | logIDSet[log.ID] = true 51 | err = printer(&log) 52 | if err != nil { 53 | fmt.Fprintf(os.Stderr, "error \"%v\" while parsing log: %v\r\n", err, log) 54 | } 55 | } 56 | 57 | if !follow { 58 | break 59 | } 60 | 61 | // limit is not necessary in second round of fetch 62 | delete(params, "limit") 63 | 64 | if len(logs) > 0 { 65 | params["to"] = logs[0].Time 66 | } 67 | params["from"] = time.Now().UTC().Format("2006-01-02T15:04:05.000000000Z") 68 | 69 | time.Sleep(5 * time.Second) 70 | } 71 | 72 | return nil 73 | } 74 | 75 | // ReceiveLogsByRange will poll the leanengine's log and print it to the giver io.Writer 76 | func ReceiveLogsByRange(printer LogReceiver, appID string, masterKey string, isProd bool, group string, from time.Time, to time.Time) error { 77 | params := map[string]string{ 78 | "prod": "0", 79 | "groupName": group, 80 | } 81 | 82 | if isProd { 83 | params["prod"] = "1" 84 | } 85 | 86 | params["from"] = from.UTC().Format("2006-01-02T15:04:05.000000000Z") 87 | if to == (time.Time{}) { 88 | to = time.Now() 89 | } 90 | params["to"] = to.UTC().Format("2006-01-02T15:04:05.000000000Z") 91 | 92 | logIDSet := map[string]bool{} 93 | for { 94 | logs, err := fetchLogs(appID, masterKey, params, isProd) 95 | if err != nil { 96 | return err 97 | } 98 | 99 | // 去重后的日志数量 100 | uniqueLogsCount := 0 101 | for i := 0; i < len(logs); i++ { 102 | log := logs[i] 103 | 104 | logTime, err := time.Parse("2006-01-02T15:04:05.999999999Z", log.Time) 105 | if err != nil { 106 | return err 107 | } 108 | 109 | if logTime.After(to) { 110 | return nil 111 | } 112 | 113 | if _, ok := logIDSet[log.ID]; ok { 114 | continue 115 | } 116 | logIDSet[log.ID] = true 117 | uniqueLogsCount++ 118 | 119 | err = printer(&log) 120 | if err != nil { 121 | fmt.Fprintf(os.Stderr, "error \"%v\" while parsing log: %v\r\n", err, log) 122 | } 123 | } 124 | 125 | if uniqueLogsCount == 0 { 126 | return nil 127 | } 128 | 129 | params["from"] = logs[len(logs)-1].Time 130 | } 131 | } 132 | 133 | func fetchLogs(appID string, masterKey string, params map[string]string, isProd bool) ([]Log, error) { 134 | client := NewClientByApp(appID) 135 | url := "/1.1/engine/logs" 136 | 137 | opts, err := client.options() 138 | if err != nil { 139 | return nil, err 140 | } 141 | opts.Headers["X-LC-Id"] = appID 142 | opts.Params = params 143 | 144 | var resp *grequests.Response 145 | retryCount := 0 146 | for { 147 | resp, err = client.get(url, opts) 148 | if err == nil { 149 | break 150 | } 151 | if retryCount >= 3 { 152 | return nil, err 153 | } 154 | retryCount++ 155 | time.Sleep(1123 * time.Millisecond) // 1123 is a prime number, prime number makes less bugs. 156 | } 157 | 158 | var logs []Log 159 | err = resp.JSON(&logs) 160 | return logs, err 161 | } 162 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | OUTPUT=./_build 2 | SRC=$(shell find . -iname "*.go") 3 | LDFLAGS='-X main.pkgType=binary -s -w' 4 | 5 | all: binaries msi deb 6 | 7 | msi: 8 | make $(OUTPUT)/lean-cli-setup-x86.msi 9 | make $(OUTPUT)/lean-cli-setup-x64.msi 10 | make $(OUTPUT)/tds-cli-setup-x86.msi 11 | make $(OUTPUT)/tds-cli-setup-x64.msi 12 | 13 | $(OUTPUT)/lean-cli-setup-x86.msi: $(OUTPUT)/lean-windows-x86.exe 14 | wixl -a x86 packaging/msi/lean-cli-x86.wxs -o $@ 15 | 16 | $(OUTPUT)/lean-cli-setup-x64.msi: $(OUTPUT)/lean-windows-x64.exe 17 | wixl -a x64 packaging/msi/lean-cli-x64.wxs -o $@ 18 | 19 | $(OUTPUT)/tds-cli-setup-x86.msi: $(OUTPUT)/tds-windows-x86.exe 20 | wixl -a x86 packaging/msi/tds-cli-x86.wxs -o $@ 21 | 22 | $(OUTPUT)/tds-cli-setup-x64.msi: $(OUTPUT)/tds-windows-x64.exe 23 | wixl -a x64 packaging/msi/tds-cli-x64.wxs -o $@ 24 | 25 | deb: 26 | make $(OUTPUT)/lean-cli-x86.deb 27 | make $(OUTPUT)/lean-cli-x64.deb 28 | make $(OUTPUT)/tds-cli-x86.deb 29 | make $(OUTPUT)/tds-cli-x64.deb 30 | 31 | $(OUTPUT)/lean-cli-x86.deb: $(OUTPUT)/lean-linux-x86 32 | mkdir -p $(OUTPUT)/x86-deb/DEBIAN/ 33 | mkdir -p $(OUTPUT)/x86-deb/usr/bin/ 34 | cp $(OUTPUT)/lean-linux-x86 $(OUTPUT)/x86-deb/usr/bin/lean 35 | cp packaging/deb/control-x86 $(OUTPUT)/x86-deb/DEBIAN/control 36 | dpkg-deb --build $(OUTPUT)/x86-deb $@ 37 | rm -rf $(OUTPUT)/x86-deb 38 | 39 | $(OUTPUT)/lean-cli-x64.deb: $(OUTPUT)/lean-linux-x64 40 | mkdir -p $(OUTPUT)/x64-deb/DEBIAN/ 41 | mkdir -p $(OUTPUT)/x64-deb/usr/bin/ 42 | cp $(OUTPUT)/lean-linux-x64 $(OUTPUT)/x64-deb/usr/bin/lean 43 | cp packaging/deb/control-x64 $(OUTPUT)/x64-deb/DEBIAN/control 44 | dpkg-deb --build $(OUTPUT)/x64-deb $@ 45 | rm -rf $(OUTPUT)/x64-deb 46 | 47 | $(OUTPUT)/tds-cli-x86.deb: $(OUTPUT)/tds-linux-x86 48 | mkdir -p $(OUTPUT)/x86-deb/DEBIAN/ 49 | mkdir -p $(OUTPUT)/x86-deb/usr/bin/ 50 | cp $(OUTPUT)/tds-linux-x86 $(OUTPUT)/x86-deb/usr/bin/tds 51 | cp packaging/deb/control-x86 $(OUTPUT)/x86-deb/DEBIAN/control 52 | dpkg-deb --build $(OUTPUT)/x86-deb $@ 53 | rm -rf $(OUTPUT)/x86-deb 54 | 55 | $(OUTPUT)/tds-cli-x64.deb: $(OUTPUT)/tds-linux-x64 56 | mkdir -p $(OUTPUT)/x64-deb/DEBIAN/ 57 | mkdir -p $(OUTPUT)/x64-deb/usr/bin/ 58 | cp $(OUTPUT)/tds-linux-x64 $(OUTPUT)/x64-deb/usr/bin/tds 59 | cp packaging/deb/control-x64 $(OUTPUT)/x64-deb/DEBIAN/control 60 | dpkg-deb --build $(OUTPUT)/x64-deb $@ 61 | rm -rf $(OUTPUT)/x64-deb 62 | 63 | binaries: $(SRC) 64 | make $(OUTPUT)/lean-windows-x86.exe 65 | make $(OUTPUT)/lean-windows-x64.exe 66 | make $(OUTPUT)/lean-macos-x64 67 | make $(OUTPUT)/lean-macos-arm64 68 | make $(OUTPUT)/lean-linux-x86 69 | make $(OUTPUT)/lean-linux-x64 70 | make $(OUTPUT)/tds-windows-x86.exe 71 | make $(OUTPUT)/tds-windows-x64.exe 72 | make $(OUTPUT)/tds-macos-x64 73 | make $(OUTPUT)/tds-macos-arm64 74 | make $(OUTPUT)/tds-linux-x86 75 | make $(OUTPUT)/tds-linux-x64 76 | 77 | $(OUTPUT)/lean-windows-x86.exe: $(SRC) 78 | GOOS=windows GOARCH=386 go build -o $@ -ldflags=$(LDFLAGS) github.com/leancloud/lean-cli/lean 79 | 80 | $(OUTPUT)/lean-windows-x64.exe: $(SRC) 81 | GOOS=windows GOARCH=amd64 go build -o $@ -ldflags=$(LDFLAGS) github.com/leancloud/lean-cli/lean 82 | 83 | $(OUTPUT)/lean-macos-x64: $(SRC) 84 | GOOS=darwin GOARCH=amd64 go build -o $@ -ldflags=$(LDFLAGS) github.com/leancloud/lean-cli/lean 85 | 86 | $(OUTPUT)/lean-macos-arm64: $(SRC) 87 | GOOS=darwin GOARCH=arm64 go build -o $@ -ldflags=$(LDFLAGS) github.com/leancloud/lean-cli/lean 88 | 89 | $(OUTPUT)/lean-linux-x86: $(SRC) 90 | GOOS=linux GOARCH=386 go build -o $@ -ldflags=$(LDFLAGS) github.com/leancloud/lean-cli/lean 91 | 92 | $(OUTPUT)/lean-linux-x64: $(SRC) 93 | GOOS=linux GOARCH=amd64 go build -o $@ -ldflags=$(LDFLAGS) github.com/leancloud/lean-cli/lean 94 | 95 | $(OUTPUT)/tds-windows-x86.exe: $(OUTPUT)/lean-windows-x86.exe 96 | cp $< $@ 97 | 98 | $(OUTPUT)/tds-windows-x64.exe: $(OUTPUT)/lean-windows-x64.exe 99 | cp $< $@ 100 | 101 | $(OUTPUT)/tds-macos-x64: $(OUTPUT)/lean-macos-x64 102 | cp $< $@ 103 | 104 | $(OUTPUT)/tds-macos-arm64: $(OUTPUT)/lean-macos-arm64 105 | cp $< $@ 106 | 107 | $(OUTPUT)/tds-linux-x86: $(OUTPUT)/lean-linux-x86 108 | cp $< $@ 109 | 110 | $(OUTPUT)/tds-linux-x64: $(OUTPUT)/lean-linux-x64 111 | cp $< $@ 112 | 113 | install: 114 | GOOS=$(GOOS) go install github.com/leancloud/lean-cli/lean 115 | 116 | test: 117 | go test github.com/leancloud/lean-cli/lean -v 118 | 119 | clean: 120 | rm -rf $(OUTPUT) 121 | 122 | .PHONY: test msi deb install clean 123 | -------------------------------------------------------------------------------- /commands/up_action.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/exec" 7 | "regexp" 8 | "strconv" 9 | "strings" 10 | "time" 11 | 12 | "github.com/aisk/logp" 13 | "github.com/fatih/color" 14 | "github.com/leancloud/lean-cli/api" 15 | "github.com/leancloud/lean-cli/apps" 16 | "github.com/leancloud/lean-cli/console" 17 | "github.com/leancloud/lean-cli/runtimes" 18 | "github.com/leancloud/lean-cli/version" 19 | "github.com/urfave/cli" 20 | ) 21 | 22 | var ( 23 | errDoNotSupportCloudCode = cli.NewExitError(`This tool no long supports cloudcode 2.0 projects. Please update your project according to: 24 | https://leancloud.cn/docs/leanengine_upgrade_3.html`, 1) 25 | ) 26 | 27 | // get the console port. now console port is just runtime port plus one. 28 | func getConsolePort(runtimePort int) int { 29 | return runtimePort + 1 30 | } 31 | 32 | func upAction(c *cli.Context) error { 33 | version.PrintVersionAndEnvironment() 34 | customArgs := c.Args() 35 | customCommand := c.String("cmd") 36 | rtmPort := c.Int("port") 37 | consPort := c.Int("console-port") 38 | if consPort == 0 { 39 | consPort = getConsolePort(rtmPort) 40 | } 41 | 42 | appID, err := apps.GetCurrentAppID(".") 43 | if err != nil { 44 | return err 45 | } 46 | 47 | region, err := apps.GetAppRegion(appID) 48 | if err != nil { 49 | return err 50 | } 51 | 52 | apiServer := api.GetAppAPIURL(region, appID) 53 | 54 | rtm, err := runtimes.DetectRuntime("") 55 | if err != nil { 56 | return err 57 | } 58 | rtm.Port = strconv.Itoa(rtmPort) 59 | rtm.Args = append(rtm.Args, customArgs...) 60 | if customCommand != "" { 61 | customCommand = strings.TrimSpace(customCommand) 62 | cmds := regexp.MustCompile(" +").Split(customCommand, -1) 63 | rtm.Exec = cmds[0] 64 | rtm.Args = cmds[1:] 65 | } 66 | 67 | if rtm.Name == "cloudcode" { 68 | return errDoNotSupportCloudCode 69 | } 70 | 71 | logp.Info("Retrieving app info ...") 72 | appInfo, err := api.GetAppInfo(appID) 73 | if err != nil { 74 | return err 75 | } 76 | logp.Infof("Current app: %s (%s)\r\n", color.RedString(appInfo.AppName), appID) 77 | 78 | groupName, err := apps.GetCurrentGroup(".") 79 | if err != nil { 80 | return err 81 | } 82 | groupInfo, err := api.GetGroup(appID, groupName) 83 | if err != nil { 84 | return err 85 | } 86 | 87 | haveStaging := "false" 88 | 89 | if groupInfo.Staging.Deployable { 90 | haveStaging = "true" 91 | } 92 | 93 | rtm.Envs = append(rtm.Envs, []string{ 94 | "LC_APP_ID=" + appInfo.AppID, 95 | "LC_APP_KEY=" + appInfo.AppKey, 96 | "LC_APP_MASTER_KEY=" + appInfo.MasterKey, 97 | "LC_APP_PORT=" + strconv.Itoa(rtmPort), 98 | "LC_API_SERVER=" + apiServer, 99 | "LEANCLOUD_APP_ID=" + appInfo.AppID, 100 | "LEANCLOUD_APP_KEY=" + appInfo.AppKey, 101 | "LEANCLOUD_APP_MASTER_KEY=" + appInfo.MasterKey, 102 | "LEANCLOUD_APP_HOOK_KEY=" + appInfo.HookKey, 103 | "LEANCLOUD_APP_PORT=" + strconv.Itoa(rtmPort), 104 | "LEANCLOUD_API_SERVER=" + apiServer, 105 | "LEANCLOUD_APP_ENV=" + "development", 106 | "LEANCLOUD_REGION=" + region.EnvString(), 107 | "LEANCLOUD_APP_DOMAIN=" + groupInfo.Domain, 108 | "LEAN_CLI_HAVE_STAGING=" + haveStaging, 109 | "LEANCLOUD_APP_GROUP=" + groupName, 110 | }...) 111 | 112 | if c.Bool("fetch-env") { 113 | for k, v := range groupInfo.Environments { 114 | localVar := os.Getenv(k) 115 | if localVar == "" { 116 | logp.Info("Exporting custome environment variables from LeanEngine: ", k) 117 | rtm.Envs = append(rtm.Envs, fmt.Sprintf("%s=%s", k, v)) 118 | } else { 119 | logp.Info("Using local environment variables: ", k) 120 | rtm.Envs = append(rtm.Envs, fmt.Sprintf("%s=%s", k, localVar)) 121 | } 122 | } 123 | } 124 | 125 | cons := &console.Server{ 126 | AppID: appInfo.AppID, 127 | AppKey: appInfo.AppKey, 128 | MasterKey: appInfo.MasterKey, 129 | HookKey: appInfo.HookKey, 130 | RemoteURL: "http://localhost:" + strconv.Itoa(rtmPort), 131 | ConsolePort: strconv.Itoa(consPort), 132 | Errors: make(chan error), 133 | } 134 | 135 | rtm.Run() 136 | time.Sleep(time.Millisecond * 300) 137 | cons.Run() 138 | 139 | for { 140 | select { 141 | case err = <-cons.Errors: 142 | if err != nil { 143 | panic(err) 144 | } else { 145 | return cli.NewExitError("", 0) 146 | } 147 | case err = <-rtm.Errors: 148 | if _, ok := err.(*exec.ExitError); ok { 149 | return cli.NewExitError("", 1) 150 | } 151 | panic(err) 152 | } 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /commands/logs_action.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "strings" 8 | "time" 9 | 10 | "github.com/fatih/color" 11 | "github.com/leancloud/lean-cli/api" 12 | "github.com/leancloud/lean-cli/apps" 13 | "github.com/urfave/cli" 14 | ) 15 | 16 | func parseDateString(str string) (time.Time, error) { 17 | if str == "" { 18 | return time.Time{}, nil 19 | } else if strings.Contains(str, "T") { 20 | return time.Parse(time.RFC3339, str) 21 | } else { 22 | return time.ParseInLocation("2006-01-02", str, time.Now().Location()) 23 | } 24 | } 25 | 26 | func extractDateParams(c *cli.Context) (time.Time, time.Time, error) { 27 | dateFormat := "format error. The correct format is YYYY-MM-DD (local time) or RFC3339, e.g., 2006-01-02 or 2006-01-02T15:04:05Z" 28 | from, err := parseDateString(c.String("from")) 29 | if err != nil { 30 | err = errors.New("from " + dateFormat) 31 | return time.Time{}, time.Time{}, err 32 | } 33 | to, err := parseDateString(c.String("to")) 34 | if err != nil { 35 | err = errors.New("to " + dateFormat) 36 | return time.Time{}, time.Time{}, err 37 | } 38 | return from, to, nil 39 | } 40 | 41 | func logsAction(c *cli.Context) error { 42 | follow := c.Bool("f") 43 | env := c.String("e") 44 | limit := c.Int("limit") 45 | format := c.String("format") 46 | isProd := false 47 | 48 | groupName, err := apps.GetCurrentGroup(".") 49 | if err != nil { 50 | return err 51 | } 52 | 53 | from, to, err := extractDateParams(c) 54 | if err != nil { 55 | return err 56 | } 57 | 58 | if env == "staging" || env == "stag" { 59 | isProd = false 60 | } else if env == "production" || env == "" || env == "prod" { 61 | isProd = true 62 | } else { 63 | return cli.NewExitError("environment must be staging or production", 1) 64 | } 65 | 66 | appID, err := apps.GetCurrentAppID("") 67 | if err == apps.ErrNoAppLinked { 68 | return cli.NewExitError("Please use `lean checkout` designate a LeanCloud app first.", 1) 69 | } 70 | if err != nil { 71 | return err 72 | } 73 | info, err := api.GetAppInfo(appID) 74 | if err != nil { 75 | return err 76 | } 77 | 78 | var printer api.LogReceiver 79 | if format == "default" { 80 | printer = getDefaultLogPrinter(isProd) 81 | } else if strings.ToLower(format) == "json" { 82 | printer = jsonLogPrinter 83 | } else { 84 | return cli.NewExitError("format must be json or default.", 1) 85 | } 86 | 87 | if from != (time.Time{}) { 88 | // 如果包含 from,则使用范围查询,此时忽略到 limit 89 | return api.ReceiveLogsByRange(printer, info.AppID, info.MasterKey, isProd, groupName, from, to) 90 | } 91 | return api.ReceiveLogsByLimit(printer, info.AppID, info.MasterKey, isProd, groupName, limit, follow) 92 | } 93 | 94 | func getDefaultLogPrinter(isProd bool) api.LogReceiver { 95 | // 根据文档描述,有些类型的日志中的 production 字段,不论生产环境还是预备环境都会为 1,因此不能以此字段 96 | // 为依据来决定展示样式。 97 | return func(log *api.Log) error { 98 | t, err := time.Parse(time.RFC3339, log.Time) 99 | if err != nil { 100 | return err 101 | } 102 | content := strings.TrimSuffix(log.Content, "\n") 103 | stream := log.Stream 104 | var streamSprintf func(string, ...interface{}) string 105 | if stream == "stdout" { 106 | streamSprintf = color.New(color.BgGreen, color.FgWhite).SprintfFunc() 107 | } else { 108 | streamSprintf = color.New(color.BgRed, color.FgWhite).SprintfFunc() 109 | } 110 | var instance string 111 | if log.InstanceName == "" { 112 | instance = " " 113 | } else { 114 | instance = log.InstanceName 115 | } 116 | 117 | if isProd { 118 | fmt.Fprintf(color.Output, "%s %s %s\r\n", instance, streamSprintf(" %s ", formatTime(&t)), content) 119 | } else { 120 | // no instance column 121 | fmt.Fprintf(color.Output, "%s %s\r\n", streamSprintf(" %s ", formatTime(&t)), content) 122 | } 123 | 124 | return nil 125 | } 126 | } 127 | 128 | func jsonLogPrinter(log *api.Log) error { 129 | content, err := json.Marshal(log) 130 | if err != nil { 131 | return err 132 | } 133 | fmt.Println(string(content)) 134 | return nil 135 | } 136 | 137 | func isTimeInToday(t *time.Time) bool { 138 | now := time.Now() 139 | beginOfToday := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) 140 | endOfToday := beginOfToday.AddDate(0, 0, 1) 141 | return t.After(beginOfToday) && t.Before(endOfToday) 142 | } 143 | 144 | func formatTime(t *time.Time) string { 145 | if isTimeInToday(t) { 146 | return t.Local().Format("15:04:05") 147 | } else { 148 | return t.Local().Format("2006-01-02 15:04:05") 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /commands/cql_action.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "os" 8 | "path/filepath" 9 | "sort" 10 | "strings" 11 | "text/tabwriter" 12 | 13 | "github.com/chzyer/readline" 14 | "github.com/leancloud/lean-cli/api" 15 | "github.com/leancloud/lean-cli/apps" 16 | "github.com/leancloud/lean-cli/utils" 17 | "github.com/urfave/cli" 18 | ) 19 | 20 | const ( 21 | printCQLResultFormatInvalid = iota 22 | printCQLResultFormatTable 23 | printCQLResultFormatJSON 24 | ) 25 | 26 | func enterCQLREPL(appInfo *api.GetAppInfoResult, format int) error { 27 | l, err := readline.NewEx(&readline.Config{ 28 | Prompt: "CQL > ", 29 | HistoryFile: filepath.Join(utils.ConfigDir(), "leancloud", "cql_history"), 30 | InterruptPrompt: "^C", 31 | EOFPrompt: "quit", 32 | }) 33 | if err != nil { 34 | return err 35 | } 36 | defer l.Close() 37 | 38 | for { 39 | line, err := l.Readline() 40 | if err == readline.ErrInterrupt { 41 | if len(line) == 0 { 42 | break 43 | } else { 44 | continue 45 | } 46 | } else if err == io.EOF { 47 | break 48 | } 49 | 50 | line = strings.TrimSpace(line) 51 | 52 | if line == "" { 53 | continue 54 | } 55 | if strings.HasSuffix(line, ";") { 56 | line = line[:len(line)-1] 57 | } 58 | 59 | result, err := api.ExecuteCQL(appInfo.AppID, appInfo.MasterKey, line) 60 | if err != nil { 61 | fmt.Println(err) 62 | continue 63 | } 64 | if format == printCQLResultFormatJSON { 65 | printJSONCQLResult(result) 66 | } else { 67 | printTableCQLResult(result) 68 | } 69 | } 70 | return nil 71 | } 72 | 73 | func printTableCQLResult(result *api.ExecuteCQLResult) { 74 | t := tabwriter.NewWriter(os.Stdout, 0, 1, 3, ' ', 0) 75 | if result.Count != -1 { // This is a count query 76 | fmt.Fprintf(t, "count\r\n") 77 | fmt.Fprintf(t, "%d\r\n", result.Count) 78 | t.Flush() 79 | return 80 | } 81 | 82 | if len(result.Results) == 0 { 83 | fmt.Println("** EMPTY **") 84 | return 85 | } 86 | 87 | keysSet := map[string]bool{ 88 | "objectId": true, 89 | "createdAt": true, 90 | "updatedAt": true, 91 | } // add this keys latter after sort 92 | keys := []string{} 93 | for _, obj := range result.Results { 94 | for key := range obj { 95 | if _, ok := keysSet[key]; !ok { 96 | keysSet[key] = true 97 | keys = append(keys, key) 98 | } 99 | } 100 | } 101 | sort.Strings(keys) 102 | 103 | // add this keys after sort 104 | keys = append([]string{"objectId"}, keys...) 105 | keys = append(keys, []string{"createdAt", "updatedAt"}...) 106 | 107 | // print table header 108 | fmt.Fprintf(t, "%s\r\n", strings.Join(keys, "\t")) 109 | 110 | for _, obj := range result.Results { 111 | for _, key := range keys { 112 | switch field := obj[key].(type) { 113 | case map[string]interface{}: 114 | if field["__type"] == "Date" { 115 | fmt.Fprintf(t, "%s\t", field["iso"]) 116 | } else if field["__type"] == "GeoPoint" { 117 | fmt.Fprintf(t, "\t", field["longitude"], field["latitude"]) 118 | } else if field["__type"] == "Pointer" { 119 | if field["className"] == "_File" { 120 | fmt.Fprintf(t, "\t", field["objectId"]) 121 | } else { 122 | fmt.Fprintf(t, "\t", field["className"], field["objectId"]) 123 | } 124 | } else if field["__type"] == "Relation" { 125 | fmt.Fprintf(t, "\t") 126 | } else { 127 | fmt.Fprintf(t, "\t") 128 | } 129 | case []interface{}: 130 | fmt.Fprintf(t, "\t") 131 | case nil: 132 | fmt.Fprintf(t, "\t") 133 | default: 134 | fmt.Fprintf(t, "%v\t", obj[key]) 135 | } 136 | } 137 | fmt.Fprintln(t) 138 | } 139 | t.Flush() 140 | } 141 | 142 | func printJSONCQLResult(result *api.ExecuteCQLResult) { 143 | if result.Count != -1 { // This is a count query 144 | encoded, err := json.MarshalIndent(map[string]interface{}{ 145 | "count": result.Count, 146 | }, "", " ") 147 | if err != nil { 148 | panic(err) 149 | } 150 | fmt.Printf("%s\r\n", encoded) 151 | return 152 | } 153 | 154 | encoded, err := json.MarshalIndent(result.Results, "", " ") 155 | if err != nil { 156 | panic(err) 157 | } 158 | fmt.Printf("%s\r\n", encoded) 159 | } 160 | 161 | func cqlAction(c *cli.Context) error { 162 | eval := c.String("eval") 163 | format := printCQLResultFormatInvalid 164 | _format := c.String("format") 165 | switch _format { 166 | case "json", "JSON", "j", "J": 167 | format = printCQLResultFormatJSON 168 | case "table", "tab", "t", "T": 169 | format = printCQLResultFormatTable 170 | default: 171 | return cli.NewExitError("invalid format argument", 1) 172 | } 173 | 174 | appID, err := apps.GetCurrentAppID(".") 175 | if err != nil { 176 | return err 177 | } 178 | appInfo, err := api.GetAppInfo(appID) 179 | if err != nil { 180 | return err 181 | } 182 | 183 | if eval != "" { 184 | result, err := api.ExecuteCQL(appInfo.AppID, appInfo.MasterKey, eval) 185 | if err != nil { 186 | return err 187 | } 188 | if format == printCQLResultFormatJSON { 189 | printJSONCQLResult(result) 190 | } else { 191 | printTableCQLResult(result) 192 | } 193 | } else { 194 | err = enterCQLREPL(appInfo, format) 195 | if err != nil { 196 | return err 197 | } 198 | } 199 | return nil 200 | } 201 | -------------------------------------------------------------------------------- /commands/preview_action.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io/ioutil" 7 | "os" 8 | "strings" 9 | 10 | "github.com/aisk/logp" 11 | "github.com/fatih/color" 12 | "github.com/leancloud/lean-cli/api" 13 | "github.com/leancloud/lean-cli/apps" 14 | "github.com/mattn/go-isatty" 15 | "github.com/urfave/cli" 16 | ) 17 | 18 | func getEnvInfo(c *cli.Context) (name, commit, url string, err error) { 19 | var pr, ciCommit, ciUrl string 20 | if os.Getenv("GITLAB_CI") == "true" { 21 | pr = os.Getenv("CI_MERGE_REQUEST_IID") 22 | ciCommit = os.Getenv("CI_COMMIT_SHA") 23 | projectUrl := os.Getenv("CI_PROJECT_URL") 24 | ciUrl = fmt.Sprintf("%s/merge_request/%s", projectUrl, pr) 25 | } else if os.Getenv("GITHUB_ACTIONS") == "true" { 26 | // $GITHUB_SHA and $GITHUB_REF environment variables are not reliable, see 27 | // https://github.com/orgs/community/discussions/26325 28 | // https://github.com/actions/runner/issues/256 29 | eventPath := os.Getenv("GITHUB_EVENT_PATH") 30 | var data []byte 31 | data, err = ioutil.ReadFile(eventPath) 32 | if err != nil { 33 | return 34 | } 35 | var githubEvent struct { 36 | PullRequest struct { 37 | Number int `json:"number"` 38 | Head struct { 39 | Sha string `json:"sha"` 40 | } `json:"head"` 41 | } `json:"pull_request"` 42 | } 43 | if err = json.Unmarshal(data, &githubEvent); err != nil { 44 | return 45 | } 46 | ciCommit = githubEvent.PullRequest.Head.Sha 47 | pr = fmt.Sprint(githubEvent.PullRequest.Number) 48 | repo := os.Getenv("GITHUB_REPOSITORY") 49 | ciUrl = fmt.Sprintf("https://github.com/%s/pull/%s", repo, pr) 50 | } 51 | 52 | commit = c.String("commit") 53 | if commit == "" { 54 | commit = ciCommit 55 | } 56 | url = c.String("url") 57 | if url == "" { 58 | url = ciUrl 59 | } 60 | name = c.String("name") 61 | if name == "" { 62 | if pr == "" { 63 | err = cli.NewExitError("Not running in GitLab CI / GitHub Actions. Please set `--name`", 1) 64 | return 65 | } 66 | name = fmt.Sprintf("pr-%s", pr) 67 | } else if name == "1" || name == "0" { 68 | err = cli.NewExitError("Preview environment name can't be 1 or 0", 1) 69 | return 70 | } 71 | return 72 | } 73 | 74 | func deployPreviewAction(c *cli.Context) error { 75 | name, commit, url, err := getEnvInfo(c) 76 | if err != nil { 77 | return err 78 | } 79 | buildLogs := c.Bool("build-logs") 80 | isDeployFromGit := c.Bool("g") 81 | noDepsCache := c.Bool("no-cache") 82 | isDeployFromJavaWar := c.Bool("war") 83 | ignoreFilePath := c.String("leanignore") 84 | isDirect := c.Bool("direct") 85 | directUpload := &isDirect 86 | if !c.IsSet("direct") { 87 | directUpload = nil 88 | } 89 | 90 | appID, err := apps.GetCurrentAppID(".") 91 | if err != nil { 92 | return err 93 | } 94 | 95 | appInfo, err := api.GetAppInfo(appID) 96 | if err != nil { 97 | return err 98 | } 99 | groupName, err := apps.GetCurrentGroup(".") 100 | if err != nil { 101 | return err 102 | } 103 | 104 | region, err := apps.GetAppRegion(appID) 105 | if err != nil { 106 | return err 107 | } 108 | 109 | logp.Info(fmt.Sprintf("Current app: %s (%s), group: %s, region: %s", color.GreenString(appInfo.AppName), appID, color.GreenString(groupName), region)) 110 | logp.Info(fmt.Sprintf("Deploying %s to preview environment %s", color.GreenString(commit), color.GreenString(name))) 111 | 112 | opts := &api.DeployOptions{ 113 | Commit: commit, 114 | Url: url, 115 | NoDepsCache: noDepsCache, 116 | BuildLogs: buildLogs, 117 | Options: c.String("options"), 118 | } 119 | 120 | if isDeployFromGit { 121 | err = deployFromGit(appID, groupName, name, commit, opts) 122 | if err != nil { 123 | return err 124 | } 125 | } else { 126 | err = deployFromLocal(appID, groupName, name, isDeployFromJavaWar, ignoreFilePath, false, directUpload, opts) 127 | if err != nil { 128 | return err 129 | } 130 | } 131 | 132 | domainBindings, err := api.GetDomainBindings(appID, api.EnginePreview, groupName) 133 | if err != nil { 134 | return err 135 | } 136 | if len(domainBindings) == 0 { 137 | logp.Warn("There are no preview domains associated with this group. Please bind one first.") 138 | } else { 139 | getUrl := func(domain api.DomainBinding) string { 140 | proto := "http" 141 | if domain.SslType != "none" { 142 | proto = "https" 143 | } 144 | return fmt.Sprintf("%s://%s.%s", proto, name, strings.TrimPrefix(domain.Domain, "*.")) 145 | } 146 | for _, domain := range domainBindings { 147 | logp.Info("Preview URL:", color.GreenString(getUrl(domain))) 148 | } 149 | // Print preview URL to *stdout* when used as URL=$(lean preview deploy ...) 150 | if !isatty.IsTerminal(1) { 151 | fmt.Println(getUrl(domainBindings[0])) 152 | } 153 | } 154 | 155 | return nil 156 | } 157 | 158 | func deletePreviewAction(c *cli.Context) error { 159 | name, _, _, err := getEnvInfo(c) 160 | if err != nil { 161 | return err 162 | } 163 | 164 | appID, err := apps.GetCurrentAppID(".") 165 | if err != nil { 166 | return err 167 | } 168 | 169 | groupName, err := apps.GetCurrentGroup(".") 170 | if err != nil { 171 | return err 172 | } 173 | 174 | err = api.DeleteEnvironment(appID, groupName, name) 175 | if err != nil { 176 | return err 177 | } 178 | 179 | logp.Infof("Deleted preview environment %s", name) 180 | return nil 181 | } 182 | -------------------------------------------------------------------------------- /commands/new_action.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | 8 | "github.com/ahmetalpbalkan/go-linq" 9 | "github.com/aisk/wizard" 10 | "github.com/leancloud/lean-cli/api" 11 | "github.com/leancloud/lean-cli/api/regions" 12 | "github.com/leancloud/lean-cli/apps" 13 | "github.com/leancloud/lean-cli/boilerplate" 14 | "github.com/leancloud/lean-cli/version" 15 | "github.com/urfave/cli" 16 | ) 17 | 18 | func selectApp(appList []*api.GetAppListResult) (*api.GetAppListResult, error) { 19 | var selectedApp *api.GetAppListResult 20 | question := wizard.Question{ 21 | Content: "Please select an app: ", 22 | Answers: []wizard.Answer{}, 23 | } 24 | for _, app := range appList { 25 | answer := wizard.Answer{ 26 | Content: app.AppName, 27 | } 28 | // for scope problem 29 | func(app *api.GetAppListResult) { 30 | answer.Handler = func() { 31 | selectedApp = app 32 | } 33 | }(app) 34 | question.Answers = append(question.Answers, answer) 35 | } 36 | err := wizard.Ask([]wizard.Question{question}) 37 | return selectedApp, err 38 | } 39 | 40 | func selectGroup(groupList []*api.GetGroupsResult) (*api.GetGroupsResult, error) { 41 | if len(groupList) == 1 { 42 | return groupList[0], nil 43 | } 44 | 45 | var selectedGroup *api.GetGroupsResult 46 | question := wizard.Question{ 47 | Content: "Please select a LeanEngine group", 48 | Answers: []wizard.Answer{}, 49 | } 50 | for _, group := range groupList { 51 | answer := wizard.Answer{ 52 | Content: group.GroupName, 53 | } 54 | func(group *api.GetGroupsResult) { 55 | answer.Handler = func() { 56 | selectedGroup = group 57 | } 58 | }(group) 59 | question.Answers = append(question.Answers, answer) 60 | } 61 | err := wizard.Ask([]wizard.Question{question}) 62 | return selectedGroup, err 63 | } 64 | 65 | func selectBoilerplate() (*boilerplate.Boilerplate, error) { 66 | var selectedBoilerplate boilerplate.Boilerplate 67 | 68 | question := wizard.Question{ 69 | Content: "Please select an app template: ", 70 | Answers: []wizard.Answer{}, 71 | } 72 | for _, boil := range boilerplate.Boilerplates { 73 | answer := wizard.Answer{ 74 | Content: boil.Name, 75 | } 76 | func(boil boilerplate.Boilerplate) { 77 | answer.Handler = func() { 78 | selectedBoilerplate = boil 79 | } 80 | }(boil) 81 | question.Answers = append(question.Answers, answer) 82 | } 83 | err := wizard.Ask([]wizard.Question{question}) 84 | return &selectedBoilerplate, err 85 | } 86 | 87 | func selectRegion(loginedRegions []regions.Region) (regions.Region, error) { 88 | region := regions.Invalid 89 | question := wizard.Question{ 90 | Content: "Please select a region: ", 91 | Answers: []wizard.Answer{}, 92 | } 93 | 94 | for _, r := range loginedRegions { 95 | answer := wizard.Answer{ 96 | Content: r.Description(), 97 | } 98 | func(r regions.Region) { 99 | answer.Handler = func() { 100 | region = r 101 | } 102 | }(r) 103 | question.Answers = append(question.Answers, answer) 104 | } 105 | err := wizard.Ask([]wizard.Question{question}) 106 | return region, err 107 | } 108 | 109 | func newAction(c *cli.Context) error { 110 | groupName := c.String("group") 111 | regionString := c.String("region") 112 | if c.NArg() < 1 { 113 | return cli.NewExitError(fmt.Sprintf("You must specify a directory name like `%s new engine-project`", os.Args[0]), 1) 114 | } 115 | dest := c.Args()[0] 116 | 117 | boil, err := selectBoilerplate() 118 | if err != nil { 119 | return err 120 | } 121 | 122 | var region regions.Region 123 | if regionString == "" { 124 | loginedRegions := regions.GetLoginedRegions(version.AvailableRegions) 125 | if len(loginedRegions) == 0 { 126 | return cli.NewExitError("Please log in first.", 1) 127 | } else if len(loginedRegions) == 1 { 128 | region = loginedRegions[0] 129 | } else { 130 | region, err = selectRegion(loginedRegions) 131 | if err != nil { 132 | return err 133 | } 134 | } 135 | } else { 136 | region = regions.Parse(regionString) 137 | } 138 | 139 | if region == regions.Invalid { 140 | cli.NewExitError("Invalid region", 1) 141 | } 142 | 143 | appList, err := api.GetAppList(region) 144 | if err != nil { 145 | return err 146 | } 147 | 148 | if len(apps.GetRegionCache()) == 0 { 149 | return cli.NewExitError("Please create an app first.", 1) 150 | } 151 | 152 | var orderedAppList []*api.GetAppListResult 153 | linq.From(appList).OrderBy(func(in interface{}) interface{} { 154 | return in.(*api.GetAppListResult).AppName[0] 155 | }).ToSlice(&orderedAppList) 156 | 157 | app, err := selectApp(orderedAppList) 158 | if err != nil { 159 | return err 160 | } 161 | 162 | groupList, err := api.GetGroups(app.AppID) 163 | if err != nil { 164 | return err 165 | } 166 | if groupName == "" { 167 | group, err := selectGroup(groupList) 168 | if err != nil { 169 | return err 170 | } 171 | groupName = group.GroupName 172 | } else { 173 | err = func() error { 174 | for _, group := range groupList { 175 | if group.GroupName == groupName { 176 | return nil 177 | } 178 | } 179 | return errors.New("Failed to find group " + groupName) 180 | }() 181 | if err != nil { 182 | return err 183 | } 184 | } 185 | 186 | if err = boilerplate.CreateProject(boil, dest, app.AppID, region); err != nil { 187 | return err 188 | } 189 | 190 | err = apps.LinkApp(dest, app.AppID) 191 | if err != nil { 192 | return err 193 | } 194 | 195 | err = apps.LinkGroup(dest, groupName) 196 | if err != nil { 197 | return err 198 | } 199 | 200 | return nil 201 | } 202 | -------------------------------------------------------------------------------- /console/resources/json5.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Minified by jsDelivr using UglifyJS v3.1.10. 3 | * Original file: /npm/json5@0.5.1/lib/json5.js 4 | * 5 | * Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files 6 | */ 7 | var JSON5="object"==typeof exports?exports:{};JSON5.parse=function(){"use strict";var r,n,t,e,i,f,o={"'":"'",'"':'"',"\\":"\\","/":"/","\n":"",b:"\b",f:"\f",n:"\n",r:"\r",t:"\t"},u=[" ","\t","\r","\n","\v","\f"," ","\ufeff"],a=function(r){return""===r?"EOF":"'"+r+"'"},c=function(e){var f=new SyntaxError;throw f.message=e+" at line "+n+" column "+t+" of the JSON5 data. Still to read: "+JSON.stringify(i.substring(r-1,r+19)),f.at=r,f.lineNumber=n,f.columnNumber=t,f},s=function(f){return f&&f!==e&&c("Expected "+a(f)+" instead of "+a(e)),e=i.charAt(r),r++,t++,("\n"===e||"\r"===e&&"\n"!==l())&&(n++,t=0),e},l=function(){return i.charAt(r)},p=function(){var r=e;for("_"!==e&&"$"!==e&&(e<"a"||e>"z")&&(e<"A"||e>"Z")&&c("Bad identifier as unquoted key");s()&&("_"===e||"$"===e||e>="a"&&e<="z"||e>="A"&&e<="Z"||e>="0"&&e<="9");)r+=e;return r},d=function(){var r,n="",t="",i=10;if("-"!==e&&"+"!==e||(n=e,s(e)),"I"===e)return("number"!=typeof(r=y())||isNaN(r))&&c("Unexpected word for number"),"-"===n?-r:r;if("N"===e)return r=y(),isNaN(r)||c("expected word to be NaN"),r;switch("0"===e&&(t+=e,s(),"x"===e||"X"===e?(t+=e,s(),i=16):e>="0"&&e<="9"&&c("Octal literal")),i){case 10:for(;e>="0"&&e<="9";)t+=e,s();if("."===e)for(t+=".";s()&&e>="0"&&e<="9";)t+=e;if("e"===e||"E"===e)for(t+=e,s(),"-"!==e&&"+"!==e||(t+=e,s());e>="0"&&e<="9";)t+=e,s();break;case 16:for(;e>="0"&&e<="9"||e>="A"&&e<="F"||e>="a"&&e<="f";)t+=e,s()}if(r="-"===n?-t:+t,isFinite(r))return r;c("Bad number")},g=function(){var r,n,t,i,f="";if('"'===e||"'"===e)for(t=e;s();){if(e===t)return s(),f;if("\\"===e)if(s(),"u"===e){for(i=0,n=0;n<4&&(r=parseInt(s(),16),isFinite(r));n+=1)i=16*i+r;f+=String.fromCharCode(i)}else if("\r"===e)"\n"===l()&&s();else{if("string"!=typeof o[e])break;f+=o[e]}else{if("\n"===e)break;f+=e}}c("Bad string")},b=function(){"/"!==e&&c("Not a comment"),s("/"),"/"===e?function(){"/"!==e&&c("Not an inline comment");do{if(s(),"\n"===e||"\r"===e)return void s()}while(e)}():"*"===e?function(){"*"!==e&&c("Not a block comment");do{for(s();"*"===e;)if(s("*"),"/"===e)return void s("/")}while(e);c("Unterminated block comment")}():c("Unrecognized comment")},v=function(){for(;e;)if("/"===e)b();else{if(!(u.indexOf(e)>=0))return;s()}},y=function(){switch(e){case"t":return s("t"),s("r"),s("u"),s("e"),!0;case"f":return s("f"),s("a"),s("l"),s("s"),s("e"),!1;case"n":return s("n"),s("u"),s("l"),s("l"),null;case"I":return s("I"),s("n"),s("f"),s("i"),s("n"),s("i"),s("t"),s("y"),1/0;case"N":return s("N"),s("a"),s("N"),NaN}c("Unexpected "+a(e))};return f=function(){switch(v(),e){case"{":return function(){var r,n={};if("{"===e)for(s("{"),v();e;){if("}"===e)return s("}"),n;if(r='"'===e||"'"===e?g():p(),v(),s(":"),n[r]=f(),v(),","!==e)return s("}"),n;s(","),v()}c("Bad object")}();case"[":return function(){var r=[];if("["===e)for(s("["),v();e;){if("]"===e)return s("]"),r;if(","===e?c("Missing array element"):r.push(f()),v(),","!==e)return s("]"),r;s(","),v()}c("Bad array")}();case'"':case"'":return g();case"-":case"+":case".":return d();default:return e>="0"&&e<="9"?d():y()}},function(o,u){var a;return i=String(o),r=0,n=1,t=1,e=" ",a=f(),v(),e&&c("Syntax error"),"function"==typeof u?function r(n,t){var e,i,f=n[t];if(f&&"object"==typeof f)for(e in f)Object.prototype.hasOwnProperty.call(f,e)&&(void 0!==(i=r(f,e))?f[e]=i:delete f[e]);return u.call(n,t,f)}({"":a},""):a}}(),JSON5.stringify=function(r,n,t){function e(r){return r>="a"&&r<="z"||r>="A"&&r<="Z"||r>="0"&&r<="9"||"_"===r||"$"===r}function i(r){if("string"!=typeof r)return!1;if(!function(r){return r>="a"&&r<="z"||r>="A"&&r<="Z"||"_"===r||"$"===r}(r[0]))return!1;for(var n=1,t=r.length;n10&&(r=r.substring(0,10));for(var e=t?"":"\n",i=0;i=0?i:void 0:i};JSON5.isWord=i;var l,p=[];t&&("string"==typeof t?l=t:"number"==typeof t&&t>=0&&(l=u(" ",t,!0)));var d=/[\\\"\x00-\x1f\x7f-\x9f\u00ad\u0600-\u0604\u070f\u17b4\u17b5\u200c-\u200f\u2028-\u202f\u2060-\u206f\ufeff\ufff0-\uffff]/g,g={"\b":"\\b","\t":"\\t","\n":"\\n","\f":"\\f","\r":"\\r",'"':'\\"',"\\":"\\\\"},b={"":r};return void 0===r?s(b,"",!0):c(b,"",!0)}; 8 | //# sourceMappingURL=/sm/ba15e0b489cbfb028dece31e7feabc42e70579d59742794e7a94c5bc73b38e90.map -------------------------------------------------------------------------------- /commands/db_action.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io" 7 | "os" 8 | "os/exec" 9 | "sort" 10 | "strconv" 11 | "strings" 12 | "text/tabwriter" 13 | 14 | "github.com/aisk/logp" 15 | "github.com/aisk/wizard" 16 | "github.com/leancloud/lean-cli/api" 17 | "github.com/leancloud/lean-cli/apps" 18 | "github.com/leancloud/lean-cli/proxy" 19 | "github.com/urfave/cli" 20 | ) 21 | 22 | func getLeanDBClusterList(appID string) (api.LeanDBClusterSlice, error) { 23 | clusters, err := api.GetLeanDBClusterList(appID) 24 | if err != nil { 25 | return nil, err 26 | } 27 | 28 | if len(clusters) == 0 { 29 | return nil, cli.NewExitError("This app doesn't have any LeanDB instance", 1) 30 | } 31 | 32 | sort.Sort(sort.Reverse(clusters)) 33 | 34 | return clusters, nil 35 | } 36 | 37 | func dbListAction(c *cli.Context) error { 38 | appID, err := apps.GetCurrentAppID(".") 39 | if err != nil { 40 | return err 41 | } 42 | 43 | clusters, err := getLeanDBClusterList(appID) 44 | if err != nil { 45 | return err 46 | } 47 | 48 | t := tabwriter.NewWriter(os.Stdout, 0, 1, 3, ' ', 0) 49 | 50 | m := make(map[string]bool) 51 | fmt.Fprintln(t, "InstanceName\t\t\tQuota") 52 | for _, cluster := range clusters { 53 | runtimeName := fmt.Sprintf("%s-%s", cluster.Runtime, cluster.Name) 54 | if ok := m[runtimeName]; ok { 55 | fmt.Fprintf(t, "%s (shared from %s)\t\t\t%s\r\n", cluster.Name, cluster.AppID, cluster.NodeQuota) 56 | } else { 57 | fmt.Fprintf(t, "%s\t\t\t%s\r\n", cluster.Name, cluster.NodeQuota) 58 | } 59 | m[runtimeName] = true 60 | } 61 | t.Flush() 62 | 63 | return nil 64 | } 65 | 66 | func selectDbCluster(clusters []*api.LeanDBCluster) (*api.LeanDBCluster, error) { 67 | var selectedCluster *api.LeanDBCluster 68 | question := wizard.Question{ 69 | Content: "Please choose a LeanDB instance", 70 | Answers: []wizard.Answer{}, 71 | } 72 | m := make(map[string]bool) 73 | for _, cluster := range clusters { 74 | runtimeName := fmt.Sprintf("%s-%s", cluster.Runtime, cluster.Name) 75 | var content string 76 | if ok := m[runtimeName]; ok { 77 | content = fmt.Sprintf("%s (shared from %s) - %s", cluster.Name, cluster.AppID, cluster.NodeQuota) 78 | } else { 79 | content = fmt.Sprintf("%s - %s", cluster.Name, cluster.NodeQuota) 80 | } 81 | m[runtimeName] = true 82 | 83 | answer := wizard.Answer{ 84 | Content: content, 85 | } 86 | // for scope problem 87 | func(cluster *api.LeanDBCluster) { 88 | answer.Handler = func() { 89 | selectedCluster = cluster 90 | } 91 | }(cluster) 92 | question.Answers = append(question.Answers, answer) 93 | } 94 | err := wizard.Ask([]wizard.Question{question}) 95 | return selectedCluster, err 96 | } 97 | 98 | func parseProxyInfo(c *cli.Context) (*proxy.ProxyInfo, error) { 99 | appID, err := apps.GetCurrentAppID(".") 100 | if err != nil { 101 | return nil, err 102 | } 103 | clusters, err := getLeanDBClusterList(appID) 104 | if err != nil { 105 | return nil, err 106 | } 107 | 108 | var cluster *api.LeanDBCluster 109 | localPort := c.Int("port") 110 | instanceName := c.Args().Get(0) 111 | 112 | if instanceName == "" { 113 | instance, err := selectDbCluster(clusters) 114 | if err != nil { 115 | return nil, err 116 | } 117 | cluster = instance 118 | } else { 119 | proxyAppID := c.String("app-id") 120 | if proxyAppID == "" { 121 | proxyAppID = appID 122 | } 123 | for _, c := range clusters { 124 | if c.Name == instanceName && c.AppID == proxyAppID { 125 | cluster = c 126 | } 127 | } 128 | if cluster == nil { 129 | s := fmt.Sprintf("No instance for [%s (%s)]", instanceName, proxyAppID) 130 | return nil, cli.NewExitError(s, 1) 131 | } 132 | } 133 | 134 | if cluster.Status != "running" && cluster.Status != "updating" && cluster.Status != "recovering" { 135 | s := fmt.Sprintf("instance [%s] is in [%s] status, not one of accessible status [running, updating, recovering]", cluster.Name, cluster.Status) 136 | return nil, cli.NewExitError(s, 1) 137 | } 138 | 139 | p := &proxy.ProxyInfo{ 140 | AppID: cluster.AppID, 141 | ClusterId: cluster.ID, 142 | Name: cluster.Name, 143 | Runtime: cluster.Runtime, 144 | AuthUser: cluster.AuthUser, 145 | AuthPassword: cluster.AuthPassword, 146 | LocalPort: strconv.Itoa(localPort), 147 | } 148 | 149 | return p, nil 150 | } 151 | 152 | func dbProxyAction(c *cli.Context) error { 153 | p, err := parseProxyInfo(c) 154 | if err != nil { 155 | return err 156 | } 157 | 158 | return proxy.RunProxy(p) 159 | } 160 | 161 | func runDBShell(c *cli.Context, stdin io.Reader) error { 162 | p, err := parseProxyInfo(c) 163 | if err != nil { 164 | return err 165 | } 166 | if clis := proxy.RuntimeClis[p.Runtime]; clis == nil { 167 | s := fmt.Sprintf("LeanDB runtime %s don't support shell proxy.", p.Runtime) 168 | return cli.NewExitError(s, 1) 169 | } 170 | 171 | started := make(chan bool, 1) 172 | term := make(chan bool, 1) 173 | cli, err := proxy.GetCli(p, true) 174 | if err != nil { 175 | return err 176 | } 177 | go proxy.RunShellProxy(p, started, term) 178 | cmd := exec.Command(cli[0], cli[1:]...) 179 | cmd.Stdin = stdin 180 | cmd.Stdout = os.Stdout 181 | cmd.Stderr = os.Stderr 182 | <-started 183 | err = cmd.Run() 184 | term <- true 185 | if err != nil { 186 | logp.Warnf("Start cli get error: %s", err) 187 | } 188 | return err 189 | } 190 | 191 | func dbShellAction(c *cli.Context) error { 192 | return runDBShell(c, os.Stdin) 193 | } 194 | 195 | func dbExecAction(c *cli.Context) error { 196 | if c.NArg() != 2 { 197 | return errors.New("instance and db commands are required") 198 | } 199 | dbCmds := c.Args().Get(1) 200 | return runDBShell(c, strings.NewReader(dbCmds)) 201 | } 202 | -------------------------------------------------------------------------------- /commands/env_action.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | "runtime" 8 | "strconv" 9 | "strings" 10 | 11 | "github.com/aisk/logp" 12 | "github.com/cbroglie/mustache" 13 | "github.com/leancloud/lean-cli/api" 14 | "github.com/leancloud/lean-cli/apps" 15 | "github.com/urfave/cli" 16 | "gopkg.in/alessio/shellescape.v1" 17 | ) 18 | 19 | var ( 20 | defaultBashEnvTemplateString = "export {{{name}}}={{{value}}}" 21 | defaultDOSEnvTemplateString = "SET {{{name}}}={{{value}}}" 22 | ) 23 | 24 | // this function is not reliable 25 | func detectDOS() bool { 26 | if runtime.GOOS != "windows" { 27 | return false 28 | } 29 | shell := os.Getenv("SHELL") 30 | if strings.Contains(shell, "bash") || 31 | strings.Contains(shell, "zsh") || 32 | strings.Contains(shell, "fish") || 33 | strings.Contains(shell, "csh") || 34 | strings.Contains(shell, "ksh") || 35 | strings.Contains(shell, "ash") { 36 | return false 37 | } 38 | return true 39 | } 40 | 41 | func envAction(c *cli.Context) error { 42 | port := strconv.Itoa(c.Int("port")) 43 | tmplString := c.String("template") 44 | shellEscape := true 45 | if tmplString == "" { 46 | if detectDOS() { 47 | tmplString = defaultDOSEnvTemplateString 48 | // DOS SET command already allows spaces in value: SET name=v a l 49 | shellEscape = false 50 | } else { 51 | tmplString = defaultBashEnvTemplateString 52 | } 53 | } 54 | 55 | tmpl, err := mustache.ParseString(tmplString) 56 | if err != nil { 57 | return err 58 | } 59 | 60 | appID, err := apps.GetCurrentAppID(".") 61 | if err != nil { 62 | return err 63 | } 64 | 65 | region, err := apps.GetAppRegion(appID) 66 | if err != nil { 67 | return err 68 | } 69 | 70 | apiServer := api.GetAppAPIURL(region, appID) 71 | 72 | appInfo, err := api.GetAppInfo(appID) 73 | if err != nil { 74 | return err 75 | } 76 | 77 | haveStaging := "false" 78 | 79 | groupName, err := apps.GetCurrentGroup(".") 80 | if err != nil { 81 | return err 82 | } 83 | groupInfo, err := api.GetGroup(appID, groupName) 84 | if err != nil { 85 | return err 86 | } 87 | 88 | if groupInfo.Staging.Deployable { 89 | haveStaging = "true" 90 | } 91 | 92 | envs := []map[string]string{ 93 | {"name": "LC_APP_ID", "value": appInfo.AppID}, 94 | {"name": "LC_APP_KEY", "value": appInfo.AppKey}, 95 | {"name": "LC_APP_MASTER_KEY", "value": appInfo.MasterKey}, 96 | {"name": "LC_APP_PORT", "value": port}, 97 | {"name": "LC_API_SERVER", "value": apiServer}, 98 | {"name": "LEANCLOUD_APP_ID", "value": appInfo.AppID}, 99 | {"name": "LEANCLOUD_APP_KEY", "value": appInfo.AppKey}, 100 | {"name": "LEANCLOUD_APP_MASTER_KEY", "value": appInfo.MasterKey}, 101 | {"name": "LEANCLOUD_APP_HOOK_KEY", "value": appInfo.HookKey}, 102 | {"name": "LEANCLOUD_APP_PORT", "value": port}, 103 | {"name": "LEANCLOUD_API_SERVER", "value": apiServer}, 104 | {"name": "LEANCLOUD_APP_ENV", "value": "development"}, 105 | {"name": "LEANCLOUD_REGION", "value": region.EnvString()}, 106 | {"name": "LEANCLOUD_APP_DOMAIN", "value": groupInfo.Domain}, 107 | {"name": "LEAN_CLI_HAVE_STAGING", "value": haveStaging}, 108 | {"name": "LEANCLOUD_APP_GROUP", "value": groupName}, 109 | } 110 | 111 | for name, value := range groupInfo.Environments { 112 | envs = append(envs, map[string]string{"name": name, "value": value}) 113 | } 114 | 115 | for _, env := range envs { 116 | if shellEscape { 117 | env["value"] = shellescape.Quote(env["value"]) 118 | } 119 | result, err := tmpl.Render(env) 120 | if err != nil { 121 | return err 122 | } 123 | fmt.Println(result) 124 | } 125 | 126 | return nil 127 | } 128 | 129 | func envSetAction(c *cli.Context) error { 130 | if c.NArg() != 2 { 131 | cli.ShowSubcommandHelp(c) 132 | return cli.NewExitError("", 1) 133 | } 134 | envName := c.Args()[0] 135 | envValue := c.Args()[1] 136 | 137 | if strings.HasPrefix(strings.ToUpper(envName), "LEANCLOUD") { 138 | return errors.New("Do not set any environment variable starting with `LEANCLOUD`") 139 | } 140 | 141 | if strings.HasPrefix(strings.ToUpper(envName), "LEAN_CLI") { 142 | return errors.New("Do not set any environment variable starting with `LEAN_CLI`") 143 | } 144 | 145 | appID, err := apps.GetCurrentAppID(".") 146 | if err != nil { 147 | return err 148 | } 149 | 150 | logp.Info("Retriving LeanEngine info ...") 151 | group, err := apps.GetCurrentGroup(".") 152 | if err != nil { 153 | return err 154 | } 155 | 156 | groupInfo, err := api.GetGroup(appID, group) 157 | if err != nil { 158 | return err 159 | } 160 | 161 | envs := groupInfo.Environments 162 | envs[envName] = envValue 163 | logp.Info("Updating environment variables for group: " + group) 164 | return api.PutEnvironments(appID, group, envs) 165 | } 166 | 167 | func envUnsetAction(c *cli.Context) error { 168 | if c.NArg() != 1 { 169 | cli.ShowSubcommandHelp(c) 170 | return cli.NewExitError("", 1) 171 | } 172 | env := c.Args()[0] 173 | 174 | if strings.HasPrefix(strings.ToUpper(env), "LEANCLOUD") { 175 | return errors.New("Please do not unset any environment variable starting with `LEANCLOUD`") 176 | } 177 | 178 | if strings.HasPrefix(strings.ToUpper(env), "LEAN_CLI") { 179 | return errors.New("Please do not unset any environment variable starting with `LEAN_CLI`") 180 | } 181 | 182 | appID, err := apps.GetCurrentAppID(".") 183 | if err != nil { 184 | return err 185 | } 186 | 187 | logp.Info("Retrieving LeanEngine info ...") 188 | group, err := apps.GetCurrentGroup(".") 189 | if err != nil { 190 | return err 191 | } 192 | groupInfo, err := api.GetGroup(appID, group) 193 | if err != nil { 194 | return err 195 | } 196 | 197 | envs := groupInfo.Environments 198 | delete(envs, env) 199 | 200 | logp.Info("Updating environment variables for group: " + group) 201 | return api.PutEnvironments(appID, group, envs) 202 | } 203 | -------------------------------------------------------------------------------- /commands/switch_action.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | 7 | "github.com/ahmetalpbalkan/go-linq" 8 | "github.com/aisk/logp" 9 | "github.com/aisk/wizard" 10 | "github.com/fatih/color" 11 | "github.com/leancloud/lean-cli/api" 12 | "github.com/leancloud/lean-cli/api/regions" 13 | "github.com/leancloud/lean-cli/apps" 14 | "github.com/leancloud/lean-cli/version" 15 | "github.com/urfave/cli" 16 | ) 17 | 18 | func selectCheckOutApp(appList []*api.GetAppListResult, currentAppID string) (*api.GetAppListResult, error) { 19 | var selectedApp *api.GetAppListResult 20 | question := wizard.Question{ 21 | Content: "Please select an app: ", 22 | Answers: []wizard.Answer{}, 23 | } 24 | for _, app := range appList { 25 | answer := wizard.Answer{ 26 | Content: app.AppName, 27 | } 28 | if app.AppID == currentAppID { 29 | answer.Content += color.RedString(" (current)") 30 | } 31 | // for scope problem 32 | func(app *api.GetAppListResult) { 33 | answer.Handler = func() { 34 | selectedApp = app 35 | } 36 | }(app) 37 | question.Answers = append(question.Answers, answer) 38 | } 39 | err := wizard.Ask([]wizard.Question{question}) 40 | return selectedApp, err 41 | } 42 | 43 | func checkOutWithAppInfo(arg string, regionString string, groupName string) error { 44 | var region regions.Region 45 | 46 | if regionString != "" { 47 | region = regions.Parse(regionString) 48 | 49 | if region == regions.Invalid { 50 | return cli.NewExitError("Wrong region parameter", 1) 51 | } 52 | } else { 53 | region = version.DefaultRegion 54 | } 55 | 56 | currentApps, err := api.GetAppList(region) 57 | if err != nil { 58 | return err 59 | } 60 | 61 | if len(apps.GetRegionCache()) == 0 { 62 | return cli.NewExitError("Please create an app first.", 1) 63 | } 64 | 65 | // check if arg is an app id 66 | for _, app := range currentApps { 67 | if app.AppID == arg { 68 | err = apps.LinkApp(".", app.AppID) 69 | if err != nil { 70 | return err 71 | } 72 | if groupName == "" { 73 | groupList, err := api.GetGroups(app.AppID) 74 | if err != nil { 75 | return err 76 | } 77 | if len(groupList) != 1 { 78 | return cli.NewExitError("This app has multiple groups, please use --group specify one", 1) 79 | } 80 | groupName = groupList[0].GroupName 81 | } 82 | fmt.Printf("Switching to %s (region: %s, group: %s)\r\n", app.AppName, region, groupName) 83 | return apps.LinkGroup(".", groupName) 84 | } 85 | } 86 | 87 | // check if arg is a app name, and is the app name is unique 88 | matchedApps := make([]*api.GetAppListResult, 0) 89 | for _, app := range currentApps { 90 | if app.AppName == arg { 91 | matchedApps = append(matchedApps, app) 92 | } 93 | } 94 | if len(matchedApps) == 1 { 95 | matchedApp := matchedApps[0] 96 | err = apps.LinkApp(".", matchedApps[0].AppID) 97 | if err != nil { 98 | return err 99 | } 100 | if groupName == "" { 101 | groupList, err := api.GetGroups(matchedApp.AppID) 102 | if err != nil { 103 | return err 104 | } 105 | if len(groupList) != 1 { 106 | return cli.NewExitError("This app has multiple groups, please use --group specify one.", 1) 107 | } 108 | groupName = groupList[0].GroupName 109 | } 110 | fmt.Printf("Switching to %s (region: %s, group: %s)\r\n", matchedApp.AppName, region, groupName) 111 | 112 | return apps.LinkGroup(".", groupName) 113 | } else if len(matchedApps) > 1 { 114 | return cli.NewExitError("Multiple apps are using this name. Please use app ID to identify the app instead.", 1) 115 | } 116 | 117 | return cli.NewExitError("Failed to find the designated app.", 1) 118 | } 119 | 120 | func checkOutWithWizard(regionString string, groupName string) error { 121 | var region regions.Region 122 | var err error 123 | if regionString == "" { 124 | loginedRegions := regions.GetLoginedRegions(version.AvailableRegions) 125 | if len(loginedRegions) == 0 { 126 | return cli.NewExitError("Please login first.", 1) 127 | } else if len(loginedRegions) == 1 { 128 | region = loginedRegions[0] 129 | } else { 130 | region, err = selectRegion(loginedRegions) 131 | if err != nil { 132 | return err 133 | } 134 | } 135 | } else { 136 | region = regions.Parse(regionString) 137 | } 138 | 139 | if region == regions.Invalid { 140 | return cli.NewExitError("Wrong region parameter", 1) 141 | } 142 | 143 | logp.Info("Retrieve app list ...") 144 | appList, err := api.GetAppList(region) 145 | if err != nil { 146 | return err 147 | } 148 | 149 | if len(apps.GetRegionCache()) == 0 { 150 | return cli.NewExitError("Please create an app first.", 1) 151 | } 152 | 153 | var sortedAppList []*api.GetAppListResult 154 | linq.From(appList).OrderBy(func(in interface{}) interface{} { 155 | return in.(*api.GetAppListResult).AppName[0] 156 | }).ToSlice(&sortedAppList) 157 | 158 | currentAppID, err := apps.GetCurrentAppID(".") 159 | if err != nil { 160 | if err != apps.ErrNoAppLinked && err != apps.ErrMissingRegionCache { 161 | return err 162 | } 163 | } 164 | 165 | app, err := selectCheckOutApp(sortedAppList, currentAppID) 166 | if err != nil { 167 | return err 168 | } 169 | 170 | groupList, err := api.GetGroups(app.AppID) 171 | if err != nil { 172 | return err 173 | } 174 | 175 | var filtedGroups []*api.GetGroupsResult 176 | 177 | linq.From(groupList).Where(func(group interface{}) bool { 178 | return group.(*api.GetGroupsResult).Staging.Deployable || group.(*api.GetGroupsResult).Production.Deployable 179 | }).ToSlice(&filtedGroups) 180 | 181 | var group *api.GetGroupsResult 182 | if groupName == "" { 183 | group, err = selectGroup(filtedGroups) 184 | if err != nil { 185 | return err 186 | } 187 | } else { 188 | err = func() error { 189 | for _, group = range filtedGroups { 190 | if group.GroupName == groupName { 191 | return nil 192 | } 193 | } 194 | return errors.New("Cannot find group " + groupName) 195 | }() 196 | if err != nil { 197 | return err 198 | } 199 | } 200 | 201 | fmt.Printf("Switching to %s (region: %s, group: %s)\r\n", app.AppName, region, group.GroupName) 202 | 203 | err = apps.LinkApp(".", app.AppID) 204 | if err != nil { 205 | return err 206 | } 207 | err = apps.LinkGroup(".", group.GroupName) 208 | if err != nil { 209 | return err 210 | } 211 | return nil 212 | } 213 | 214 | func switchAction(c *cli.Context) error { 215 | group := c.String("group") 216 | region := c.String("region") 217 | if c.NArg() > 0 { 218 | arg := c.Args()[0] 219 | err := checkOutWithAppInfo(arg, region, group) 220 | if err != nil { 221 | return err 222 | } 223 | return nil 224 | } 225 | return checkOutWithWizard(region, group) 226 | } 227 | 228 | func checkOutAction(c *cli.Context) error { 229 | logp.Warn("`lean checkout` is deprecated, please use `lean switch` instead") 230 | return switchAction(c) 231 | } 232 | -------------------------------------------------------------------------------- /api/client.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "net/url" 7 | "os" 8 | "path/filepath" 9 | "strconv" 10 | "strings" 11 | 12 | "github.com/aisk/wizard" 13 | "github.com/cloudfoundry-attic/jibber_jabber" 14 | cookiejar "github.com/juju/persistent-cookiejar" 15 | "github.com/leancloud/lean-cli/api/regions" 16 | "github.com/leancloud/lean-cli/apps" 17 | "github.com/leancloud/lean-cli/utils" 18 | "github.com/leancloud/lean-cli/version" 19 | "github.com/levigross/grequests" 20 | ) 21 | 22 | var requestsCount = 0 23 | 24 | var dashboardBaseUrls = map[regions.Region]string{ 25 | regions.ChinaNorth: "https://cn-n1-console-api.leancloud.cn", 26 | regions.USWest: "https://us-w1-console-api.leancloud.app", 27 | regions.ChinaEast: "https://cn-e1-console-api.leancloud.cn", 28 | regions.ChinaTDS1: "https://console-api.cloud.developer.taptap.cn", 29 | regions.APSG: "https://console-api.ap-sg.cloud.developer.taptap.io", 30 | } 31 | 32 | var ( 33 | // Get2FACode is the function to get the user's two-factor-authentication code. 34 | // You can override it with your custom function. 35 | Get2FACode = func() (string, error) { 36 | result := new(string) 37 | wizard.Ask([]wizard.Question{ 38 | { 39 | Content: "Please input 2-factor auth code", 40 | Input: &wizard.Input{ 41 | Result: result, 42 | Hidden: false, 43 | }, 44 | }, 45 | }) 46 | code, err := strconv.Atoi(*result) 47 | if err != nil { 48 | return "", errors.New("2-factor auth code should be numerical") 49 | } 50 | return strconv.Itoa(code), nil 51 | } 52 | ) 53 | 54 | type Client struct { 55 | CookieJar *cookiejar.Jar 56 | Region regions.Region 57 | AppID string 58 | AccessToken string 59 | } 60 | 61 | func NewClientByRegion(region regions.Region) *Client { 62 | client := &Client{ 63 | AccessToken: accessTokenCache[region], 64 | Region: region, 65 | } 66 | 67 | if !version.LoginViaAccessTokenOnly && client.AccessToken == "" { 68 | client.CookieJar = newCookieJar() 69 | } 70 | 71 | return client 72 | } 73 | 74 | func NewClientByApp(appID string) *Client { 75 | region, err := apps.GetAppRegion(appID) 76 | 77 | if err != nil { 78 | panic(err) 79 | } 80 | 81 | client := &Client{ 82 | AppID: appID, 83 | AccessToken: accessTokenCache[region], 84 | Region: region, 85 | } 86 | 87 | if !version.LoginViaAccessTokenOnly && client.AccessToken == "" { 88 | client.CookieJar = newCookieJar() 89 | } 90 | 91 | return client 92 | } 93 | 94 | func (client *Client) GetBaseURL() string { 95 | envBaseURL := os.Getenv("LEANCLOUD_DASHBOARD") 96 | 97 | if envBaseURL != "" { 98 | return envBaseURL 99 | } 100 | 101 | region := client.Region 102 | 103 | if url, ok := dashboardBaseUrls[region]; ok { 104 | return url 105 | } 106 | 107 | panic("invalid region") 108 | } 109 | 110 | func (client *Client) options() (*grequests.RequestOptions, error) { 111 | return &grequests.RequestOptions{ 112 | UserAgent: version.GetUserAgent(), 113 | Headers: map[string]string{ 114 | "Accept-Language": getSystemLanguage(), 115 | }, 116 | }, nil 117 | } 118 | 119 | func (client *Client) GetAuthHeaders() map[string]string { 120 | headers := make(map[string]string) 121 | 122 | if client.AccessToken != "" { 123 | headers["Authorization"] = fmt.Sprint("Token ", client.AccessToken) 124 | } else if client.CookieJar != nil { 125 | url, err := url.Parse(client.GetBaseURL()) 126 | 127 | if err != nil { 128 | panic(err) 129 | } 130 | 131 | cookies := client.CookieJar.Cookies(url) 132 | csrf := "" 133 | 134 | for _, cookie := range cookies { 135 | if cookie.Name == "csrf-token" { 136 | csrf = cookie.Value 137 | } 138 | 139 | // unsupported uluru cookie 140 | if cookie.Name == "uluru_user" || cookie.Name == "XSRF-TOKEN" { 141 | client.CookieJar.RemoveAllHost(url.Host) 142 | client.CookieJar.Save() 143 | } 144 | } 145 | 146 | headers["X-CSRF-TOKEN"] = csrf 147 | } 148 | 149 | return headers 150 | } 151 | 152 | func doRequest(client *Client, method string, path string, params map[string]interface{}, options *grequests.RequestOptions) (*grequests.Response, error) { 153 | requestsCount += 1 154 | requestId := requestsCount 155 | 156 | var err error 157 | if options == nil { 158 | if options, err = client.options(); err != nil { 159 | return nil, err 160 | } 161 | } 162 | 163 | for k, v := range client.GetAuthHeaders() { 164 | options.Headers[k] = v 165 | } 166 | 167 | if client.CookieJar != nil { 168 | options.CookieJar = client.CookieJar 169 | options.UseCookieJar = true 170 | } 171 | 172 | if params != nil { 173 | options.JSON = params 174 | } 175 | var fn func(string, *grequests.RequestOptions) (*grequests.Response, error) 176 | switch method { 177 | case "GET": 178 | fn = grequests.Get 179 | case "POST": 180 | fn = grequests.Post 181 | case "PUT": 182 | fn = grequests.Put 183 | case "DELETE": 184 | fn = grequests.Delete 185 | case "PATCH": 186 | fn = grequests.Patch 187 | default: 188 | panic("invalid method: " + method) 189 | } 190 | 191 | url := client.GetBaseURL() + path 192 | 193 | if debuggingRequests() { 194 | fmt.Fprintf(os.Stderr, "request(%v) [%s %s] %v %v\n", requestId, method, url, params, options.Headers) 195 | } 196 | 197 | resp, err := fn(url, options) 198 | 199 | if debuggingRequests() { 200 | fmt.Fprintf(os.Stderr, "response(%v) [%s %s] %v %v %v\n", requestId, method, url, resp.StatusCode, resp.String(), resp.Header) 201 | } 202 | 203 | if err != nil { 204 | return nil, err 205 | } 206 | 207 | if !resp.Ok { 208 | if strings.HasPrefix(strings.TrimSpace(resp.Header.Get("Content-Type")), "application/json") { 209 | return nil, NewErrorFromResponse(resp) 210 | } 211 | return nil, fmt.Errorf("HTTP Error: %d, %s %s", resp.StatusCode, method, path) 212 | } 213 | 214 | if client.CookieJar != nil { 215 | if err = client.CookieJar.Save(); err != nil { 216 | return nil, err 217 | } 218 | } 219 | 220 | return resp, nil 221 | } 222 | 223 | func (client *Client) get(path string, options *grequests.RequestOptions) (*grequests.Response, error) { 224 | return doRequest(client, "GET", path, nil, options) 225 | } 226 | 227 | func (client *Client) post(path string, params map[string]interface{}, options *grequests.RequestOptions) (*grequests.Response, error) { 228 | return doRequest(client, "POST", path, params, options) 229 | } 230 | 231 | func (client *Client) patch(path string, params map[string]interface{}, options *grequests.RequestOptions) (*grequests.Response, error) { 232 | return doRequest(client, "PATCH", path, params, options) 233 | } 234 | 235 | func (client *Client) put(path string, params map[string]interface{}, options *grequests.RequestOptions) (*grequests.Response, error) { 236 | return doRequest(client, "PUT", path, params, options) 237 | } 238 | 239 | func (client *Client) delete(path string, options *grequests.RequestOptions) (*grequests.Response, error) { 240 | return doRequest(client, "DELETE", path, nil, options) 241 | } 242 | 243 | func newCookieJar() *cookiejar.Jar { 244 | jarFileDir := filepath.Join(utils.ConfigDir(), "leancloud") 245 | 246 | os.MkdirAll(jarFileDir, 0775) 247 | 248 | jar, err := cookiejar.New(&cookiejar.Options{ 249 | Filename: filepath.Join(jarFileDir, "cookies"), 250 | }) 251 | if err != nil { 252 | panic(err) 253 | } 254 | return jar 255 | } 256 | 257 | func getSystemLanguage() string { 258 | language, err := jibber_jabber.DetectLanguage() 259 | 260 | if err != nil { 261 | // unsupported locale setting (Could not detect Language) 262 | language = "en" 263 | } 264 | 265 | return language 266 | } 267 | 268 | func debuggingRequests() bool { 269 | return strings.Contains(os.Getenv("DEBUG"), "lean") || strings.Contains(os.Getenv("DEBUG"), "tds") 270 | } 271 | -------------------------------------------------------------------------------- /boilerplate/boilerplate.go: -------------------------------------------------------------------------------- 1 | package boilerplate 2 | 3 | import ( 4 | "archive/zip" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "io/ioutil" 9 | "os" 10 | "os/exec" 11 | "path/filepath" 12 | "strconv" 13 | "strings" 14 | 15 | "github.com/aisk/logp" 16 | "github.com/cheggaaa/pb" 17 | "github.com/fatih/color" 18 | "github.com/leancloud/lean-cli/api/regions" 19 | "github.com/leancloud/lean-cli/utils" 20 | "github.com/leancloud/lean-cli/version" 21 | "github.com/levigross/grequests" 22 | "github.com/mattn/go-colorable" 23 | ) 24 | 25 | func CreateProject(boil *Boilerplate, dest string, appID string, region regions.Region) error { 26 | if boil.DownloadURL != "" { 27 | if err := os.Mkdir(dest, 0775); err != nil { 28 | return err 29 | } 30 | 31 | dir, err := ioutil.TempDir("", "leanengine") 32 | if err != nil { 33 | return err 34 | } 35 | defer os.RemoveAll(dir) 36 | zipFilePath := filepath.Join(dir, "getting-started.zip") 37 | 38 | var downloadURLs []string 39 | if region.InChina() { 40 | downloadURLs = []string{"https://releases.leanapp.cn", "https://api.github.com/repos"} 41 | } else { 42 | downloadURLs = []string{"https://api.github.com/repos", "https://releases.leanapp.cn"} 43 | } 44 | err = downloadToFile(downloadURLs[0]+boil.DownloadURL, zipFilePath) 45 | if err != nil { 46 | err = downloadToFile(downloadURLs[1]+boil.DownloadURL, zipFilePath) 47 | if err != nil { 48 | return err 49 | } 50 | } 51 | 52 | logp.Info("Creating project...") 53 | 54 | zipFile, err := zip.OpenReader(zipFilePath) 55 | if err != nil { 56 | return err 57 | } 58 | defer zipFile.Close() 59 | for _, f := range zipFile.File { 60 | // Remove outer directory name. 61 | f.Name = f.Name[strings.Index(f.Name, "/"):] 62 | err := extractAndWriteFile(f, dest) 63 | if err != nil { 64 | return err 65 | } 66 | } 67 | } 68 | 69 | if boil.CMD != nil { 70 | args := boil.CMD(dest) 71 | 72 | logp.Info(fmt.Sprintf("Executing `%s`", strings.Join(args, " "))) 73 | 74 | _, err := exec.LookPath(args[0]) 75 | 76 | if err != nil { 77 | return fmt.Errorf("You should install `%s` before create %s project", args[0], boil.Name) 78 | } 79 | 80 | cmd := exec.Command(args[0], args[1:]...) 81 | cmd.Stdin = os.Stdin 82 | cmd.Stdout = os.Stdout 83 | cmd.Stderr = os.Stderr 84 | 85 | if err := cmd.Run(); err != nil { 86 | return err 87 | } 88 | } 89 | 90 | if boil.Files != nil { 91 | for name, body := range boil.Files { 92 | if err := ioutil.WriteFile(filepath.Join(dest, name), []byte(body), 0644); err != nil { 93 | return err 94 | } 95 | } 96 | } 97 | 98 | logp.Info(fmt.Sprintf("Created %s project in `%s`", boil.Name, dest)) 99 | 100 | if boil.Message != "" { 101 | logp.Info(boil.Message) 102 | } 103 | 104 | return nil 105 | } 106 | 107 | type Boilerplate struct { 108 | Name string 109 | Message string 110 | DownloadURL string 111 | CMD func(dest string) []string 112 | Files map[string]string 113 | } 114 | 115 | var Boilerplates = []Boilerplate{ 116 | { 117 | Name: "Node.js - Express", 118 | Message: "Lean how to use Express at https://expressjs.com", 119 | DownloadURL: "/leancloud/node-js-getting-started/zipball/master", 120 | }, 121 | { 122 | Name: "Node.js - Koa", 123 | DownloadURL: "/leancloud/koa-getting-started/zipball/master", 124 | Message: "Lean how to use Koa at https://koajs.com", 125 | }, 126 | { 127 | Name: "Python - Flask", 128 | DownloadURL: "/leancloud/python-getting-started/zipball/master", 129 | Message: "Lean how to use Flask at https://flask.palletsprojects.com", 130 | }, 131 | { 132 | Name: "Python - Django", 133 | DownloadURL: "/leancloud/django-getting-started/zipball/master", 134 | Message: "Lean how to use Django at https://docs.djangoproject.com", 135 | }, 136 | { 137 | Name: "Java - Servlet", 138 | DownloadURL: "/leancloud/servlet-getting-started/zipball/master", 139 | }, 140 | { 141 | Name: "Java - Spring Boot", 142 | DownloadURL: "/leancloud/spring-boot-getting-started/zipball/master", 143 | Message: "Lean how to use Spring Boot at https://spring.io/projects/spring-boot", 144 | }, 145 | { 146 | Name: "PHP - Slim", 147 | DownloadURL: "/leancloud/slim-getting-started/zipball/master", 148 | Message: "Lean how to use Slim at https://www.slimframework.com", 149 | }, 150 | { 151 | Name: ".NET Core", 152 | DownloadURL: "/leancloud/dotnet-core-getting-started/zipball/master", 153 | Message: "Lean how to use .NET Core at https://docs.microsoft.com/aspnet/core/", 154 | }, 155 | { 156 | Name: "Go - Echo", 157 | DownloadURL: "/leancloud/golang-getting-started/zipball/master", 158 | Message: "Lean how to use Echo at https://echo.labstack.com/", 159 | }, 160 | { 161 | Name: "React Web App (via create-react-app)", 162 | Files: prepareWebAppFiles("build"), 163 | CMD: func(dest string) []string { 164 | return []string{"npx", "create-react-app", dest, "--use-npm"} 165 | }, 166 | }, 167 | { 168 | Name: "Vue Web App (via @vue/cli)", 169 | Files: prepareWebAppFiles("dist"), 170 | CMD: func(dest string) []string { 171 | return []string{"npx", "@vue/cli", "create", "--default", "--packageManager", "npm", dest} 172 | }, 173 | }, 174 | } 175 | 176 | // don't know why archive/zip.Reader.File[0].FileInfo().IsDir() always return true, 177 | // this is a trick hack to void this. 178 | func isDir(path string) bool { 179 | return os.IsPathSeparator(path[len(path)-1]) 180 | } 181 | 182 | func extractAndWriteFile(f *zip.File, dest string) error { 183 | rc, err := f.Open() 184 | if err != nil { 185 | return err 186 | } 187 | defer rc.Close() 188 | 189 | path := filepath.Join(dest, f.Name) 190 | 191 | if isDir(f.Name) { 192 | if err := os.MkdirAll(path, f.Mode()); err != nil { 193 | return err 194 | } 195 | } else { 196 | // Use os.Create() since Zip don't store file permissions. 197 | f, err := os.Create(path) 198 | if err != nil { 199 | return err 200 | } 201 | defer f.Close() 202 | 203 | _, err = io.Copy(f, rc) 204 | if err != nil { 205 | return err 206 | } 207 | } 208 | return nil 209 | } 210 | 211 | // downloadToFile allows you to download the contents of the URL to a file 212 | func downloadToFile(url string, fileName string) error { 213 | resp, err := grequests.Get(url, &grequests.RequestOptions{ 214 | UserAgent: "LeanCloud-CLI/" + version.Version, 215 | }) 216 | if err != nil { 217 | return err 218 | } 219 | defer resp.Close() 220 | 221 | if resp.StatusCode != 200 { 222 | return errors.New(utils.FormatServerErrorResult(resp.String())) 223 | } 224 | if resp.Error != nil { 225 | return resp.Error 226 | } 227 | 228 | fd, err := os.Create(fileName) 229 | 230 | if err != nil { 231 | return err 232 | } 233 | 234 | defer resp.Close() // This is a noop if we use the internal ByteBuffer 235 | defer fd.Close() 236 | 237 | if length, err := strconv.Atoi(resp.Header.Get("Content-Length")); err == nil { 238 | bar := pb.New(length).SetUnits(pb.U_BYTES).SetMaxWidth(80) 239 | bar.Output = colorable.NewColorableStderr() 240 | bar.Prefix(color.GreenString("[INFO]") + " Downloading templates") 241 | bar.Start() 242 | defer bar.Finish() 243 | reader := bar.NewProxyReader(resp) 244 | if _, err := io.Copy(fd, reader); err != nil && err != io.EOF { 245 | return err 246 | } 247 | } else { 248 | if _, err := io.Copy(fd, resp); err != nil && err != io.EOF { 249 | return err 250 | } 251 | } 252 | 253 | return nil 254 | } 255 | 256 | func prepareWebAppFiles(webRoot string) map[string]string { 257 | return map[string]string{ 258 | "leanengine.yaml": "build: npm run build", 259 | "static.json": fmt.Sprintf(`{ 260 | "public": "%s", 261 | "rewrites": [ 262 | { "source": "**", "destination": "/index.html" } 263 | ] 264 | }`, webRoot), 265 | } 266 | } 267 | -------------------------------------------------------------------------------- /commands/deploy_action.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io/ioutil" 7 | "os" 8 | "os/exec" 9 | "path/filepath" 10 | "strings" 11 | 12 | "github.com/aisk/logp" 13 | "github.com/aisk/wizard" 14 | "github.com/fatih/color" 15 | "github.com/leancloud/lean-cli/api" 16 | "github.com/leancloud/lean-cli/api/regions" 17 | "github.com/leancloud/lean-cli/apps" 18 | "github.com/leancloud/lean-cli/runtimes" 19 | "github.com/leancloud/lean-cli/utils" 20 | "github.com/leancloud/lean-cli/version" 21 | "github.com/urfave/cli" 22 | ) 23 | 24 | func deployAction(c *cli.Context) error { 25 | version.PrintVersionAndEnvironment() 26 | isDeployFromGit := c.Bool("g") 27 | isDeployFromJavaWar := c.Bool("war") 28 | ignoreFilePath := c.String("leanignore") 29 | noDepsCache := c.Bool("no-cache") 30 | overwriteFuncs := c.Bool("overwrite-functions") 31 | message := c.String("message") 32 | keepFile := c.Bool("keep-deploy-file") 33 | revision := c.String("revision") 34 | prodBool := c.Bool("prod") 35 | staging := c.Bool("staging") 36 | isDirect := c.Bool("direct") 37 | directUpload := &isDirect 38 | if !c.IsSet("direct") { 39 | directUpload = nil 40 | } 41 | buildLogs := c.Bool("build-logs") 42 | 43 | var env string 44 | 45 | appID, err := apps.GetCurrentAppID(".") 46 | if err != nil { 47 | return err 48 | } 49 | 50 | groupName, err := apps.GetCurrentGroup(".") 51 | if err != nil { 52 | return err 53 | } 54 | 55 | region, err := apps.GetAppRegion(appID) 56 | if err != nil { 57 | return err 58 | } 59 | 60 | if staging && prodBool { 61 | return cli.NewExitError("`--prod` and `--staging` flags are mutually exclusive", 1) 62 | } 63 | if staging { 64 | env = "0" 65 | } else if prodBool { 66 | env = "1" 67 | } else { 68 | logp.Info("`lean deploy` now has no default target. Specify the environment by `--prod` or `--staging` flag to avoid this prompt:") 69 | question := wizard.Question{ 70 | Content: "Please select the environment: ", 71 | Answers: []wizard.Answer{ 72 | { 73 | Content: "Production", 74 | Handler: func() { 75 | env = "1" 76 | }, 77 | }, 78 | { 79 | Content: "Staging", 80 | Handler: func() { 81 | env = "0" 82 | }, 83 | }, 84 | }, 85 | } 86 | err = wizard.Ask([]wizard.Question{question}) 87 | if err != nil { 88 | return err 89 | } 90 | } 91 | 92 | appInfo, err := api.GetAppInfo(appID) 93 | if err != nil { 94 | return err 95 | } 96 | 97 | envText := "production" 98 | 99 | if env == "0" { 100 | envText = "staging" 101 | } 102 | 103 | logp.Info(fmt.Sprintf("Current app: %s (%s), group: %s, region: %s", color.GreenString(appInfo.AppName), appID, color.GreenString(groupName), region)) 104 | logp.Info(fmt.Sprintf("Deploying new version to %s", color.GreenString(envText))) 105 | 106 | groupInfo, err := api.GetGroup(appID, groupName) 107 | if err != nil { 108 | return err 109 | } 110 | 111 | if env == "0" && !groupInfo.Staging.Deployable { 112 | return cli.NewExitError("Deployment failed: no staging instance", 1) 113 | } else if env == "1" && !groupInfo.Production.Deployable { 114 | return cli.NewExitError("Deployment failed: no production instance", 1) 115 | } 116 | 117 | opts := &api.DeployOptions{ 118 | NoDepsCache: noDepsCache, 119 | OverwriteFuncs: overwriteFuncs, 120 | BuildLogs: buildLogs, 121 | Options: c.String("options"), 122 | } 123 | 124 | if isDeployFromGit { 125 | err = deployFromGit(appID, groupName, env, revision, opts) 126 | if err != nil { 127 | return err 128 | } 129 | } else { 130 | opts.Message = getCommentMessage(message) 131 | err = deployFromLocal(appID, groupName, env, isDeployFromJavaWar, ignoreFilePath, keepFile, directUpload, opts) 132 | if err != nil { 133 | return err 134 | } 135 | } 136 | return nil 137 | } 138 | 139 | func packageProject(repoPath, ignoreFilePath string) (string, error) { 140 | fileDir, err := ioutil.TempDir("", "leanengine") 141 | if err != nil { 142 | return "", err 143 | } 144 | 145 | archiveFile := filepath.Join(fileDir, "leanengine.zip") 146 | 147 | runtime, err := runtimes.DetectRuntime(repoPath) 148 | if err == runtimes.ErrRuntimeNotFound { 149 | logp.Warn("Failed to recognize project type. Please inspect the directory structure if the deployment failed.") 150 | } else if err != nil { 151 | return "", err 152 | } 153 | 154 | if err := runtime.ArchiveUploadFiles(archiveFile, ignoreFilePath); err != nil { 155 | return "", err 156 | } 157 | 158 | return archiveFile, nil 159 | } 160 | 161 | func packageWar(repoPath string) (string, error) { 162 | var warPath string 163 | files, err := ioutil.ReadDir(filepath.Join(repoPath, "target")) 164 | if err != nil { 165 | return "", err 166 | } 167 | for _, file := range files { 168 | if strings.HasSuffix(file.Name(), ".war") && !file.IsDir() { 169 | warPath = filepath.Join(repoPath, "target", file.Name()) 170 | } 171 | } 172 | if warPath == "" { 173 | return "", errors.New("cannot find .war file in ./target") 174 | } 175 | 176 | logp.Info("Found .war file:", warPath) 177 | 178 | file := []struct{ Name, Path string }{{ 179 | Name: "ROOT.war", 180 | Path: warPath, 181 | }} 182 | 183 | for _, filename := range []string{"leanengine.yaml", "system.properties"} { 184 | path := filepath.Join(repoPath, filename) 185 | if utils.IsFileExists(path) { 186 | file = append(file, struct{ Name, Path string }{ 187 | Name: filename, 188 | Path: path, 189 | }) 190 | } 191 | } 192 | 193 | fileDir, err := ioutil.TempDir("", "leanengine") 194 | if err != nil { 195 | return "", err 196 | } 197 | archivePath := filepath.Join(fileDir, "ROOT.war.zip") 198 | if err = utils.ArchiveFiles(archivePath, file); err != nil { 199 | return "", err 200 | } 201 | 202 | return archivePath, nil 203 | } 204 | 205 | func deployFromLocal(appID string, group string, env string, isDeployFromJavaWar bool, ignoreFilePath string, keepFile bool, directUpload *bool, opts *api.DeployOptions) error { 206 | region, err := apps.GetAppRegion(appID) 207 | if err != nil { 208 | return err 209 | } 210 | 211 | var archiveFilePath string 212 | if isDeployFromJavaWar { 213 | archiveFilePath, err = packageWar(".") 214 | } else { 215 | archiveFilePath, err = packageProject(".", ignoreFilePath) 216 | } 217 | if directUpload != nil { 218 | opts.DirectUpload = *directUpload 219 | } else { 220 | if region != regions.USWest { 221 | opts.DirectUpload = false 222 | } else { 223 | fileInfo, err := os.Stat(archiveFilePath) 224 | if err != nil { 225 | return err 226 | } 227 | if fileInfo.Size() < 100*1024*1024 { 228 | opts.DirectUpload = true 229 | } else { 230 | opts.DirectUpload = false 231 | } 232 | } 233 | } 234 | var eventTok string 235 | if opts.DirectUpload { 236 | eventTok, err = api.DeployAppFromFile(appID, group, env, archiveFilePath, opts) 237 | if err != nil { 238 | return err 239 | } 240 | } else { 241 | file, err := api.UploadToRepoStorage(region, archiveFilePath) 242 | if err != nil { 243 | return err 244 | } 245 | eventTok, err = api.DeployAppFromFile(appID, group, env, file.URL, opts) 246 | if err != nil { 247 | return err 248 | } 249 | if !keepFile { 250 | defer func() { 251 | err := api.DeleteFromRepoStorage(region, file.ObjectID) 252 | if err != nil { 253 | logp.Error(err) 254 | } 255 | }() 256 | } 257 | } 258 | 259 | ok, err := api.PollEvents(appID, eventTok) 260 | if err != nil { 261 | return err 262 | } 263 | if !ok { 264 | return cli.NewExitError("Deployment failed", 1) 265 | } 266 | return nil 267 | } 268 | 269 | func deployFromGit(appID string, group string, env string, revision string, opts *api.DeployOptions) error { 270 | eventTok, err := api.DeployAppFromGit(appID, group, env, revision, opts) 271 | if err != nil { 272 | return err 273 | } 274 | ok, err := api.PollEvents(appID, eventTok) 275 | if err != nil { 276 | return err 277 | } 278 | if !ok { 279 | return cli.NewExitError("Deployment failed", 1) 280 | } 281 | return nil 282 | } 283 | 284 | func getCommentMessage(message string) string { 285 | if message == "" { 286 | _, err := exec.LookPath("git") 287 | 288 | if err == nil { 289 | if _, err := os.Stat("./.git"); !os.IsNotExist(err) { 290 | messageBuf, err := exec.Command("git", "log", "-1", "--no-color", "--pretty=%B").CombinedOutput() 291 | messageStr := string(messageBuf) 292 | 293 | if err != nil { 294 | logp.Error("failed to load git message: ", err) 295 | } else { 296 | message = "WIP on: " + strings.TrimSpace(messageStr) 297 | } 298 | } 299 | } 300 | } 301 | 302 | if message == "" { 303 | message = "Creating from the CLI" 304 | } 305 | 306 | return message 307 | } 308 | -------------------------------------------------------------------------------- /api/engine.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "net/url" 7 | "os" 8 | 9 | "github.com/cheggaaa/pb" 10 | "github.com/fatih/color" 11 | "github.com/levigross/grequests" 12 | "github.com/mattn/go-colorable" 13 | ) 14 | 15 | type EngineInfo struct { 16 | AppID string `json:"appId"` 17 | } 18 | 19 | type VersionInfo struct { 20 | VersionTag string `json:"versionTag"` 21 | } 22 | 23 | type GroupDeployInfo struct { 24 | Deployable bool `json:"deployable"` 25 | Version VersionInfo `json:"version"` 26 | } 27 | 28 | type InstanceInfo struct { 29 | Name string `json:"name"` 30 | Prod int `json:"prod"` 31 | } 32 | 33 | type GetGroupsResult struct { 34 | GroupName string `json:"groupName"` 35 | Repository string `json:"repository"` 36 | Domain string `json:"domain"` 37 | Instances []InstanceInfo `json:"instances"` 38 | Staging GroupDeployInfo `json:"staging"` 39 | Production GroupDeployInfo `json:"production"` 40 | Environments map[string]string `json:"environments"` 41 | } 42 | 43 | type DeployOptions struct { 44 | DirectUpload bool 45 | Message string 46 | NoDepsCache bool 47 | OverwriteFuncs bool 48 | BuildLogs bool 49 | Commit string 50 | Url string 51 | Options string // Additional options in urlencode format 52 | } 53 | 54 | func deploy(appID string, group string, env string, params map[string]interface{}) (*grequests.Response, error) { 55 | client := NewClientByApp(appID) 56 | 57 | opts, err := client.options() 58 | if err != nil { 59 | return nil, err 60 | } 61 | opts.Headers["X-LC-Id"] = appID 62 | 63 | url := fmt.Sprintf("/1.1/engine/groups/%s/envs/%s/version", group, env) 64 | 65 | directUpload, _ := params["direct"].(bool) 66 | delete(params, "direct") 67 | if directUpload { 68 | opts.Data = func() map[string]string { 69 | data := make(map[string]string) 70 | for k, v := range params { 71 | data[k] = fmt.Sprint(v) 72 | } 73 | return data 74 | }() 75 | archiveFilePath := opts.Data["zipUrl"] 76 | delete(opts.Data, "zipUrl") 77 | fd, err := os.Open(archiveFilePath) 78 | if err != nil { 79 | return nil, err 80 | } 81 | 82 | stats, err := fd.Stat() 83 | if err != nil { 84 | return nil, err 85 | } 86 | 87 | bar := pb.New(int(stats.Size())).SetUnits(pb.U_BYTES).SetMaxWidth(80) 88 | bar.Output = colorable.NewColorableStderr() 89 | bar.Prefix(color.GreenString("[INFO]") + " Uploading file") 90 | bar.Start() 91 | barProxy := bar.NewProxyReader(fd) 92 | 93 | opts.Files = []grequests.FileUpload{ 94 | { 95 | FileName: "leanengine.zip", 96 | FileContents: barProxy, 97 | FieldName: "zip", 98 | }, 99 | } 100 | 101 | resp, err := client.post(url, nil, opts) 102 | if err != nil { 103 | return nil, err 104 | } 105 | bar.Finish() 106 | 107 | return resp, nil 108 | } 109 | return client.post(url, params, opts) 110 | } 111 | 112 | // DeployImage will deploy the engine group with specify image tag 113 | func DeployImage(appID string, group string, env string, imageTag string, opts *DeployOptions) (string, error) { 114 | params, err := prepareDeployParams(opts) 115 | 116 | if err != nil { 117 | return "", err 118 | } 119 | 120 | params["versionTag"] = imageTag 121 | 122 | resp, err := deploy(appID, group, env, params) 123 | if err != nil { 124 | return "", err 125 | } 126 | result := new(struct { 127 | EventToken string `json:"eventToken"` 128 | }) 129 | err = resp.JSON(result) 130 | return result.EventToken, err 131 | } 132 | 133 | // DeployAppFromGit will deploy applications with user's git repo 134 | // returns the event token for polling deploy log 135 | func DeployAppFromGit(appID string, group string, env string, revision string, opts *DeployOptions) (string, error) { 136 | params, err := prepareDeployParams(opts) 137 | 138 | if err != nil { 139 | return "", err 140 | } 141 | 142 | params["gitTag"] = revision 143 | 144 | resp, err := deploy(appID, group, env, params) 145 | if err != nil { 146 | return "", err 147 | } 148 | result := new(struct { 149 | EventToken string `json:"eventToken"` 150 | }) 151 | err = resp.JSON(result) 152 | return result.EventToken, err 153 | } 154 | 155 | // DeployAppFromFile will deploy applications with specific file 156 | // returns the event token for polling deploy log 157 | func DeployAppFromFile(appID string, group string, env string, fileURL string, opts *DeployOptions) (string, error) { 158 | params, err := prepareDeployParams(opts) 159 | 160 | if err != nil { 161 | return "", err 162 | } 163 | 164 | params["direct"] = opts.DirectUpload 165 | params["zipUrl"] = fileURL 166 | 167 | resp, err := deploy(appID, group, env, params) 168 | if err != nil { 169 | return "", err 170 | } 171 | 172 | result := new(struct { 173 | EventToken string `json:"eventToken"` 174 | }) 175 | err = resp.JSON(result) 176 | return result.EventToken, err 177 | } 178 | 179 | func DeleteEnvironment(appID string, group string, env string) error { 180 | client := NewClientByApp(appID) 181 | 182 | opts, err := client.options() 183 | if err != nil { 184 | return err 185 | } 186 | opts.Headers["X-LC-Id"] = appID 187 | 188 | _, err = client.delete(fmt.Sprintf("/1.1/engine/groups/%s/envs/%s", group, env), opts) 189 | return err 190 | } 191 | 192 | // GetGroups returns the application's engine groups 193 | func GetGroups(appID string) ([]*GetGroupsResult, error) { 194 | client := NewClientByApp(appID) 195 | 196 | opts, err := client.options() 197 | if err != nil { 198 | return nil, err 199 | } 200 | opts.Headers["X-LC-Id"] = appID 201 | 202 | resp, err := client.get("/1.1/engine/groups?all=true", opts) 203 | if err != nil { 204 | return nil, err 205 | } 206 | 207 | var result []*GetGroupsResult 208 | err = resp.JSON(&result) 209 | if err != nil { 210 | return nil, err 211 | } 212 | 213 | // filter the staging group, since it's not used anymore 214 | var filtered []*GetGroupsResult 215 | for _, group := range result { 216 | if group.GroupName == "staging" { 217 | continue 218 | } 219 | filtered = append(filtered, group) 220 | } 221 | 222 | return filtered, nil 223 | } 224 | 225 | // GetGroup will fetch all groups from API and return the current group info 226 | func GetGroup(appID string, groupName string) (*GetGroupsResult, error) { 227 | groups, err := GetGroups(appID) 228 | if err != nil { 229 | return nil, err 230 | } 231 | for _, group := range groups { 232 | if group.GroupName == groupName { 233 | return group, nil 234 | } 235 | } 236 | return nil, errors.New("Failed to find group: " + groupName) 237 | } 238 | 239 | func GetEngineInfo(appID string) (*EngineInfo, error) { 240 | client := NewClientByApp(appID) 241 | 242 | opts, err := client.options() 243 | if err != nil { 244 | return nil, err 245 | } 246 | opts.Headers["X-LC-Id"] = appID 247 | 248 | response, err := client.get("/1.1/engine", opts) 249 | if err != nil { 250 | return nil, err 251 | } 252 | var result = new(EngineInfo) 253 | err = response.JSON(result) 254 | return result, err 255 | } 256 | 257 | func PutEnvironments(appID string, group string, envs map[string]string) error { 258 | client := NewClientByApp(appID) 259 | 260 | opts, err := client.options() 261 | if err != nil { 262 | return err 263 | } 264 | opts.Headers["X-LC-Id"] = appID 265 | 266 | params := make(map[string]interface{}) 267 | environments := make(map[string]interface{}) 268 | for k, v := range envs { 269 | environments[k] = v 270 | } 271 | params["environments"] = environments 272 | 273 | url := "/1.1/engine/groups/" + group 274 | response, err := client.patch(url, params, opts) 275 | if err != nil { 276 | return err 277 | } 278 | if response.StatusCode != 200 { 279 | return fmt.Errorf("Error updating environment variable, code: %d", response.StatusCode) 280 | } 281 | return nil 282 | } 283 | 284 | func prepareDeployParams(options *DeployOptions) (map[string]interface{}, error) { 285 | params := map[string]interface{}{ 286 | "noDependenciesCache": options.NoDepsCache, 287 | "overwriteFunctions": options.OverwriteFuncs, 288 | "async": true, 289 | "printBuildLogs": options.BuildLogs, 290 | } 291 | 292 | if options.Message != "" { 293 | params["comment"] = options.Message 294 | } 295 | if options.Commit != "" { 296 | params["commit"] = options.Commit 297 | } 298 | if options.Url != "" { 299 | params["url"] = options.Url 300 | } 301 | 302 | if options.Options != "" { 303 | queryString, err := url.ParseQuery(options.Options) 304 | 305 | if err != nil { 306 | return nil, err 307 | } 308 | 309 | for k, v := range queryString { 310 | params[k] = v[0] 311 | } 312 | } 313 | 314 | return params, nil 315 | } 316 | -------------------------------------------------------------------------------- /console/resources/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | LeanCloud Cloud Function Debug Tool 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 21 | 22 | 27 | 32 | 33 | 34 | 35 |
36 | 45 | 46 |
47 |
48 |
49 |
50 | {{ warning }} 51 |
52 |
53 |
54 | 55 | 63 | 64 | 65 |
66 |

Function defined by AV.Cloud.define

67 |
68 | 69 | 70 | 71 |
72 |
73 | 74 | 75 |
76 |
77 | 78 | 79 | » 80 | 81 | 82 | 83 |
84 | 85 |
86 | 87 |
88 |

Class Hooks (e.g., beforeSave, afterSave)

89 |
90 | 91 | 92 | 93 |
94 |
95 | 96 | 97 |
98 |
99 | 100 | 101 |
102 |
103 | 104 | 105 |
106 |
107 | 108 | 109 |
110 |
111 | 112 | 113 |
114 | 115 |
116 | 117 |
118 |

onAuthData

119 |
120 | 121 | 122 |
123 | 124 |
125 | 126 |
127 |

onLogin

128 |
129 | 130 | 131 |
132 | 133 |
134 | 135 |
136 |

onVerified('sms')

137 |
138 | 139 | 140 |
141 | 142 |
143 | 144 |
145 |

onVerified('sms')

146 |
147 | 148 | 149 |
150 | 151 |
152 |
153 | 154 | 177 | 178 |
179 |
180 | 185 |
186 |
187 | 188 | 189 | -------------------------------------------------------------------------------- /console/server.go: -------------------------------------------------------------------------------- 1 | package console 2 | 3 | import ( 4 | "embed" 5 | "encoding/json" 6 | "fmt" 7 | "mime" 8 | "net/http" 9 | "os" 10 | "path" 11 | "strings" 12 | 13 | "github.com/ahmetalpbalkan/go-linq" 14 | "github.com/aisk/logp" 15 | "github.com/gorilla/mux" 16 | "github.com/leancloud/lean-cli/api" 17 | "github.com/levigross/grequests" 18 | ) 19 | 20 | var hookNames = map[string]string{ 21 | "__before_save_for_": "beforeSave", 22 | "__after_save_for_": "afterSave", 23 | "__before_update_for_": "beforeUpdate", 24 | "__after_update_for_": "afterUpdate", 25 | "__before_delete_for_": "beforeDelete", 26 | "__after_delete_for_": "afterDelete", 27 | } 28 | var userHookNames = map[string]string{ 29 | "__on_authdata_": "onAuthData", 30 | "__on_login_": "onLogin", 31 | "__on_verified_sms": "onVerifiedSms", 32 | "__on_verified_email": "onVerifiedEmail", 33 | } 34 | 35 | // Server is a struct for develoment console server 36 | type Server struct { 37 | AppID string 38 | AppKey string 39 | MasterKey string 40 | HookKey string 41 | RemoteURL string 42 | ConsolePort string 43 | Errors chan error 44 | } 45 | 46 | //go:embed resources 47 | var resources embed.FS 48 | 49 | func init() { 50 | // for Windows compatibility 51 | mime.AddExtensionType(".js", "text/javascript; charset=utf-8") 52 | mime.AddExtensionType(".css", "text/css; charset=utf-8") 53 | } 54 | 55 | func (server *Server) getFunctions() ([]string, error) { 56 | url := fmt.Sprintf("%s/1.1/functions/_ops/metadatas", server.RemoteURL) 57 | response, err := grequests.Get(url, &grequests.RequestOptions{ 58 | Headers: map[string]string{ 59 | "x-avoscloud-application-id": server.AppID, 60 | "x-avoscloud-master-key": server.MasterKey, 61 | }, 62 | }) 63 | if err != nil { 64 | return nil, err 65 | } 66 | 67 | if !response.Ok { 68 | return nil, api.NewErrorFromResponse(response) 69 | } 70 | 71 | result := new(struct { 72 | Result []string `json:"result"` 73 | }) 74 | err = response.JSON(result) 75 | if err != nil { 76 | return nil, err 77 | } 78 | return result.Result, nil 79 | } 80 | 81 | func (server *Server) indexHandler(w http.ResponseWriter, req *http.Request) { 82 | bytes, err := resources.ReadFile("resources/index.html") 83 | 84 | if err != nil { 85 | panic(err) 86 | } 87 | 88 | w.Write(bytes) 89 | } 90 | 91 | func (server *Server) resourcesHandler(w http.ResponseWriter, req *http.Request) { 92 | filename := mux.Vars(req)["filename"] 93 | 94 | bytes, err := resources.ReadFile(path.Join("resources", filename)) 95 | 96 | if err != nil { 97 | if os.IsNotExist(err) { 98 | http.NotFound(w, req) 99 | } else { 100 | panic(err) 101 | } 102 | } 103 | 104 | w.Header().Set("Content-Type", mime.TypeByExtension(path.Ext(filename))) 105 | w.Write(bytes) 106 | } 107 | 108 | func (server *Server) appInfoHandler(w http.ResponseWriter, req *http.Request) { 109 | url := fmt.Sprintf("%s/1.1/functions/_ops/metadatas", server.RemoteURL) 110 | response, err := grequests.Options(url, &grequests.RequestOptions{ 111 | Headers: map[string]string{ 112 | "Access-Control-Request-Method": "GET", 113 | "Origin": fmt.Sprint("http://localhost:", server.ConsolePort), 114 | }, 115 | }) 116 | if err != nil { 117 | panic(err) 118 | } 119 | if !response.Ok { 120 | panic(api.NewErrorFromResponse(response)) 121 | } 122 | 123 | content, err := json.Marshal(map[string]interface{}{ 124 | "appId": server.AppID, 125 | "appKey": server.AppKey, 126 | "masterKey": server.MasterKey, 127 | "hookKey": server.HookKey, 128 | "sendHookKey": strings.Contains(response.Header.Get("Access-Control-Allow-Headers"), "X-LC-Hook-Key"), 129 | "remoteUrl": server.RemoteURL, 130 | "warnings": []string{}, 131 | }) 132 | if err != nil { 133 | panic(err) 134 | } 135 | w.Header().Set("Content-Type", "application/json") 136 | w.Write(content) 137 | } 138 | 139 | func (server *Server) functionsHandler(w http.ResponseWriter, req *http.Request) { 140 | functions, err := server.getFunctions() 141 | if err != nil { 142 | fmt.Println("get functions error: ", err) 143 | return 144 | } 145 | 146 | result := linq.From(functions).Where(func(in interface{}) bool { 147 | function := in.(string) 148 | return !strings.HasPrefix(function, "__") 149 | }).Results() 150 | if len(result) > 0 { 151 | result = linq.From(result).OrderBy(func(in interface{}) interface{} { 152 | function := in.(string) 153 | if function == "" { 154 | return " "[0] 155 | } 156 | return function[0] 157 | }).Select(func(in interface{}) interface{} { 158 | function := in.(string) 159 | return map[string]string{ 160 | "name": function, 161 | "sign": signCloudFunc(server.MasterKey, function, timeStamp()), 162 | } 163 | }).Results() 164 | } 165 | 166 | w.Header().Set("Content-Type", "application/json") 167 | j, err := json.MarshalIndent(result, "", " ") 168 | if err != nil { 169 | panic(err) 170 | } 171 | w.Write(j) 172 | } 173 | 174 | func (server *Server) classesHandler(w http.ResponseWriter, req *http.Request) { 175 | functions, err := server.getFunctions() 176 | if err != nil { 177 | fmt.Println("get functions error: ", err) 178 | return 179 | } 180 | 181 | result := linq.From(functions).Where(func(in interface{}) bool { 182 | funcName := in.(string) 183 | for key := range hookNames { 184 | if strings.HasPrefix(funcName, key) { 185 | return true 186 | } 187 | } 188 | return false 189 | }).Select(func(in interface{}) interface{} { 190 | funcName := in.(string) 191 | for key := range hookNames { 192 | if strings.HasPrefix(funcName, key) { 193 | return strings.TrimPrefix(funcName, key) 194 | } 195 | } 196 | panic("impossible") 197 | }).Distinct().Results() 198 | 199 | if len(result) > 0 { 200 | result = linq.From(result).OrderBy(func(in interface{}) interface{} { 201 | function := in.(string) 202 | return function[0] 203 | }).Results() 204 | } 205 | 206 | w.Header().Set("Content-Type", "application/json") 207 | j, _ := json.MarshalIndent(result, "", " ") 208 | w.Write(j) 209 | } 210 | 211 | func (server *Server) classActionHandler(w http.ResponseWriter, req *http.Request) { 212 | className := mux.Vars(req)["className"] 213 | 214 | functions, err := server.getFunctions() 215 | if err != nil { 216 | fmt.Println("get functions error: ", err) 217 | return 218 | } 219 | 220 | result := linq.From(functions).Where(func(in interface{}) bool { 221 | funcName := in.(string) 222 | if strings.HasPrefix(funcName, "__") && strings.HasSuffix(funcName, className) { 223 | return true 224 | } 225 | return false 226 | }).Select(func(in interface{}) interface{} { 227 | funcName := in.(string) 228 | action := "" 229 | for key, value := range hookNames { 230 | if strings.HasPrefix(funcName, key) { 231 | action = value 232 | } 233 | } 234 | signFuncName := funcName 235 | if strings.HasPrefix(funcName, "__before") { 236 | signFuncName = "__before_for_" + className 237 | } else if strings.HasPrefix(funcName, "__after") { 238 | signFuncName = "__after_for_" + className 239 | } 240 | return map[string]string{ 241 | "className": className, 242 | "action": action, 243 | "sign": signCloudFunc(server.MasterKey, signFuncName, timeStamp()), 244 | } 245 | }).Results() 246 | 247 | w.Header().Set("Content-Type", "application/json") 248 | j, _ := json.MarshalIndent(result, "", " ") 249 | w.Write(j) 250 | } 251 | 252 | func (server *Server) userHooksHandler(w http.ResponseWriter, req *http.Request) { 253 | functions, err := server.getFunctions() 254 | if err != nil { 255 | fmt.Println("get functions error: ", err) 256 | return 257 | } 258 | 259 | result := linq.From(functions).Where(func(in interface{}) bool { 260 | funcName := in.(string) 261 | for key := range userHookNames { 262 | if strings.HasPrefix(funcName, key) { 263 | return true 264 | } 265 | } 266 | return false 267 | }).Select(func(in interface{}) interface{} { 268 | funcName := in.(string) 269 | action := "" 270 | for key, value := range userHookNames { 271 | if strings.HasPrefix(funcName, key) { 272 | action = value 273 | } 274 | } 275 | return map[string]string{ 276 | "className": "_User", 277 | "action": action, 278 | "sign": signCloudFunc(server.MasterKey, funcName, timeStamp()), 279 | } 280 | }).Results() 281 | 282 | w.Header().Set("Content-Type", "application/json") 283 | j, _ := json.MarshalIndent(result, "", " ") 284 | w.Write(j) 285 | } 286 | 287 | // Run the dev server 288 | func (server *Server) Run() { 289 | router := mux.NewRouter() 290 | 291 | router.HandleFunc("/", server.indexHandler) 292 | router.HandleFunc("/__engine/1/appInfo", server.appInfoHandler) 293 | router.HandleFunc("/__engine/1/functions", server.functionsHandler) 294 | router.HandleFunc("/__engine/1/classes", server.classesHandler) 295 | router.HandleFunc("/__engine/1/classes/{className}/actions", server.classActionHandler) 296 | router.HandleFunc("/__engine/1/userHooks", server.userHooksHandler) 297 | router.HandleFunc("/resources/{filename}", server.resourcesHandler) 298 | 299 | addr := "localhost:" + server.ConsolePort 300 | logp.Info("Cloud function debug console (if available) is accessible at: http://" + addr) 301 | 302 | go func() { 303 | server.Errors <- http.ListenAndServe(addr, router) 304 | }() 305 | } 306 | -------------------------------------------------------------------------------- /runtimes/runtime.go: -------------------------------------------------------------------------------- 1 | package runtimes 2 | 3 | import ( 4 | "encoding/json" 5 | "encoding/xml" 6 | "errors" 7 | "fmt" 8 | "io/ioutil" 9 | "os" 10 | "os/exec" 11 | "path/filepath" 12 | "strings" 13 | 14 | "github.com/aisk/logp" 15 | "github.com/facebookgo/parseignore" 16 | "github.com/facebookgo/symwalk" 17 | "github.com/leancloud/lean-cli/utils" 18 | ) 19 | 20 | var ErrRuntimeNotFound = errors.New("Unsupported project structure. Please inspect your directory structure to make sure it is a valid LeanEngine project.") 21 | 22 | type filesPattern struct { 23 | Includes []string 24 | Excludes []string 25 | } 26 | 27 | // Runtime stands for a language runtime 28 | type Runtime struct { 29 | command *exec.Cmd 30 | WorkDir string 31 | ProjectPath string 32 | Name string 33 | Exec string 34 | Args []string 35 | Envs []string 36 | Remote string 37 | Port string 38 | // DeployFiles is the patterns for source code to deploy to the remote server 39 | DeployFiles filesPattern 40 | // Errors is the channel that receives the command's error result 41 | Errors chan error 42 | } 43 | 44 | // Run the project, and watch file changes 45 | func (runtime *Runtime) Run() { 46 | go func() { 47 | runtime.command = exec.Command(runtime.Exec, runtime.Args...) 48 | runtime.command.Dir = runtime.WorkDir 49 | runtime.command.Stdin = os.Stdin 50 | runtime.command.Stdout = os.Stdout 51 | runtime.command.Stderr = os.Stderr 52 | runtime.command.Env = os.Environ() 53 | 54 | for _, env := range runtime.Envs { 55 | runtime.command.Env = append(runtime.command.Env, env) 56 | } 57 | 58 | logp.Infof("Use %s to start the project\r\n", runtime.command.Args) 59 | logp.Infof("The project is running at: http://localhost:%s\r\n", runtime.Port) 60 | runtime.Errors <- runtime.command.Run() 61 | }() 62 | } 63 | 64 | func (runtime *Runtime) ArchiveUploadFiles(archiveFile string, ignoreFilePath string) error { 65 | return runtime.defaultArchive(archiveFile, ignoreFilePath) 66 | } 67 | 68 | func (runtime *Runtime) defaultArchive(archiveFile string, ignoreFilePath string) error { 69 | matcher, err := runtime.readIgnore(ignoreFilePath) 70 | if os.IsNotExist(err) { 71 | return fmt.Errorf("The designated ignore file '%s' doesn't exist", ignoreFilePath) 72 | } else if err != nil { 73 | return err 74 | } 75 | 76 | files := []struct{ Name, Path string }{} 77 | err = symwalk.Walk(".", func(path string, info os.FileInfo, err error) error { 78 | if err != nil { 79 | return err 80 | } 81 | // convert DOS's '\' path seprater to UNIX style 82 | path = filepath.ToSlash(path) 83 | decision, err := matcher.Match(path, info) 84 | if err != nil { 85 | return err 86 | } 87 | 88 | if info.IsDir() { 89 | if decision == parseignore.Exclude { 90 | return filepath.SkipDir 91 | } 92 | return nil 93 | } 94 | 95 | if decision != parseignore.Exclude { 96 | files = append(files, struct{ Name, Path string }{ 97 | Name: path, 98 | Path: path, 99 | }) 100 | } 101 | return nil 102 | }) 103 | 104 | if err != nil { 105 | return err 106 | } 107 | 108 | return utils.ArchiveFiles(archiveFile, files) 109 | } 110 | 111 | // DetectRuntime returns the project's runtime 112 | func DetectRuntime(projectPath string) (*Runtime, error) { 113 | // order is important 114 | if utils.IsFileExists(filepath.Join(projectPath, "cloud", "main.js")) { 115 | logp.Info("cloudcode runtime detected") 116 | return &Runtime{ 117 | Name: "cloudcode", 118 | }, nil 119 | } 120 | packageFilePath := filepath.Join(projectPath, "package.json") 121 | if utils.IsFileExists(filepath.Join(projectPath, "server.js")) && utils.IsFileExists(packageFilePath) { 122 | logp.Info("Node.js runtime detected") 123 | return newNodeRuntime(projectPath) 124 | } 125 | if utils.IsFileExists(packageFilePath) { 126 | data, err := ioutil.ReadFile(packageFilePath) 127 | if err == nil { 128 | data = utils.StripUTF8BOM(data) 129 | var result struct { 130 | Scripts struct { 131 | Start string `json:"start"` 132 | } `json:"scripts"` 133 | } 134 | if err = json.Unmarshal(data, &result); err == nil { 135 | if result.Scripts.Start != "" { 136 | logp.Info("Node.js runtime detected") 137 | return newNodeRuntime(projectPath) 138 | } 139 | } 140 | } 141 | } 142 | if utils.IsFileExists(filepath.Join(projectPath, "requirements.txt")) && utils.IsFileExists(filepath.Join(projectPath, "wsgi.py")) { 143 | logp.Info("Python runtime detected") 144 | return newPythonRuntime(projectPath) 145 | } 146 | if utils.IsFileExists(filepath.Join(projectPath, "composer.json")) && utils.IsFileExists(filepath.Join("public", "index.php")) { 147 | logp.Info("PHP runtime detected") 148 | return newPhpRuntime(projectPath) 149 | } 150 | if utils.IsFileExists(filepath.Join(projectPath, "pom.xml")) || utils.IsFileExists(filepath.Join(projectPath, "gradlew")) { 151 | logp.Info("Java runtime detected") 152 | return newJavaRuntime(projectPath) 153 | } 154 | if utils.IsFileExists(filepath.Join(projectPath, "app.sln")) { 155 | logp.Info("DotNet runtime detected") 156 | return newDotnetRuntime(projectPath) 157 | } 158 | if utils.IsFileExists(filepath.Join(projectPath, "index.html")) || utils.IsFileExists(filepath.Join(projectPath, "static.json")) { 159 | logp.Info("Static runtime detected") 160 | return newStaticRuntime(projectPath) 161 | } 162 | if utils.IsFileExists(filepath.Join(projectPath, "go.mod")) { 163 | logp.Info("Go runtime detected") 164 | return newGoRuntime(projectPath) 165 | } 166 | 167 | return &Runtime{ 168 | ProjectPath: projectPath, 169 | Name: "Unknown", 170 | Errors: make(chan error), 171 | }, ErrRuntimeNotFound 172 | } 173 | 174 | func lookupBin(fallbacks []string) (string, error) { 175 | for i, bin := range fallbacks { 176 | binPath, err := exec.LookPath(bin) 177 | if err == nil { // found 178 | if i == 0 { 179 | logp.Infof("Found executable file: `%s`\r\n", binPath) 180 | } else { 181 | logp.Warnf("Cannot find command `%s`, using `%s` instead of \r\n", fallbacks[i-1], fallbacks[i]) 182 | } 183 | return bin, nil 184 | } 185 | } 186 | 187 | return "", fmt.Errorf("`%s` not found", fallbacks[0]) 188 | } 189 | 190 | func newPythonRuntime(projectPath string) (*Runtime, error) { 191 | runtime := func(version string) *Runtime { 192 | var python string 193 | if version == "" { 194 | python = "python" 195 | } else { 196 | parts := strings.SplitN(version, ".", 3) 197 | major, minor := parts[0], parts[1] 198 | python, _ = lookupBin([]string{"python" + major + "." + minor, "python" + major, "python"}) 199 | } 200 | return &Runtime{ 201 | ProjectPath: projectPath, 202 | Name: "python", 203 | Exec: python, 204 | Args: []string{"wsgi.py"}, 205 | Errors: make(chan error), 206 | } 207 | } 208 | content, err := ioutil.ReadFile(filepath.Join(projectPath, ".python-version")) 209 | if err == nil { 210 | pythonVersion := string(content) 211 | if strings.HasPrefix(pythonVersion, "2.") || strings.HasPrefix(pythonVersion, "3.") { 212 | logp.Info("pyenv detected. Please make sure pyenv is configured properly.") 213 | return runtime(pythonVersion), nil 214 | } else { 215 | return nil, errors.New("Wrong pyenv version. We only support CPython. Please check and correct .python-version") 216 | } 217 | } else { 218 | if os.IsNotExist(err) { 219 | return runtime(""), nil 220 | } else { 221 | return nil, err 222 | } 223 | } 224 | } 225 | 226 | func newNodeRuntime(projectPath string) (*Runtime, error) { 227 | execName := "node" 228 | args := []string{"server.js"} 229 | pkgFile := filepath.Join(projectPath, "package.json") 230 | if content, err := ioutil.ReadFile(pkgFile); err == nil { 231 | content = utils.StripUTF8BOM(content) 232 | pkg := new(struct { 233 | Scripts struct { 234 | Start string `json:"start"` 235 | Dev string `json:"dev"` 236 | } `json:"scripts"` 237 | Dependencies map[string]string `json:"dependencies"` 238 | }) 239 | err = json.Unmarshal(content, pkg) 240 | if err != nil { 241 | return nil, err 242 | } 243 | 244 | if pkg.Scripts.Dev != "" { 245 | execName = "npm" 246 | args = []string{"run", "dev"} 247 | } else if pkg.Scripts.Start != "" { 248 | execName = "npm" 249 | args = []string{"start"} 250 | } 251 | 252 | if sdkVersion, ok := pkg.Dependencies["leanengine"]; ok { 253 | if strings.HasPrefix(sdkVersion, "0.") || 254 | strings.HasPrefix(sdkVersion, "~0.") || 255 | strings.HasPrefix(sdkVersion, "^0.") { 256 | logp.Warn("The current leanengine SDK is too low. Local debugging of cloud functions is not supported. Please refer to http://url.leanapp.cn/Og1cVia for upgrade instructions") 257 | } 258 | } 259 | 260 | } 261 | 262 | return &Runtime{ 263 | ProjectPath: projectPath, 264 | Name: "node.js", 265 | Exec: execName, 266 | Args: args, 267 | Errors: make(chan error), 268 | }, nil 269 | } 270 | 271 | func newJavaRuntime(projectPath string) (*Runtime, error) { 272 | exec := "mvn" 273 | args := []string{"jetty:run"} 274 | 275 | if utils.IsFileExists(filepath.Join(projectPath, "gradlew")) { 276 | exec = "./gradlew" 277 | args = []string{"appRun"} 278 | } else { 279 | // parse pom.xml to check if it's using spring-boot-maven-plugin and hence can be run with `mvn spring-boot:run` 280 | content, err := ioutil.ReadFile(filepath.Join(projectPath, "pom.xml")) 281 | if err != nil { 282 | return nil, err 283 | } 284 | var pom struct { 285 | Build struct { 286 | Plugins struct { 287 | Plugins []struct { 288 | ArtifactId string `xml:"artifactId"` 289 | } `xml:"plugin"` 290 | } `xml:"plugins"` 291 | } `xml:"build"` 292 | } 293 | if err := xml.Unmarshal(content, &pom); err != nil { 294 | return nil, err 295 | } 296 | for _, plugin := range pom.Build.Plugins.Plugins { 297 | if plugin.ArtifactId == "spring-boot-maven-plugin" { 298 | args = []string{"spring-boot:run"} 299 | break 300 | } 301 | } 302 | } 303 | 304 | return &Runtime{ 305 | ProjectPath: projectPath, 306 | Name: "java", 307 | Exec: exec, 308 | Args: args, 309 | Errors: make(chan error), 310 | }, nil 311 | } 312 | 313 | func newPhpRuntime(projectPath string) (*Runtime, error) { 314 | entryScript, err := getPHPEntryScriptPath() 315 | if err != nil { 316 | return nil, err 317 | } 318 | return &Runtime{ 319 | ProjectPath: projectPath, 320 | Name: "php", 321 | Exec: "php", 322 | Args: []string{"-S", "127.0.0.1:3000", "-t", "public", entryScript}, 323 | Errors: make(chan error), 324 | }, nil 325 | } 326 | 327 | func newDotnetRuntime(projectPath string) (*Runtime, error) { 328 | return &Runtime{ 329 | WorkDir: filepath.Join(projectPath, "web"), 330 | ProjectPath: projectPath, 331 | Name: "dotnet", 332 | Exec: "dotnet", 333 | Args: []string{"run"}, 334 | Envs: []string{"ASPNETCORE_URLS=http://0.0.0.0:3000"}, 335 | Errors: make(chan error), 336 | }, nil 337 | } 338 | 339 | func newStaticRuntime(projectPath string) (*Runtime, error) { 340 | return &Runtime{ 341 | ProjectPath: projectPath, 342 | Name: "static", 343 | Exec: "npx", 344 | Args: []string{"serve", "--listen=3000"}, 345 | Errors: make(chan error), 346 | }, nil 347 | } 348 | 349 | func newGoRuntime(projectPath string) (*Runtime, error) { 350 | return &Runtime{ 351 | ProjectPath: projectPath, 352 | Name: "go", 353 | Exec: "go", 354 | Args: []string{"run", "main.go"}, 355 | Errors: make(chan error), 356 | }, nil 357 | } 358 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | --------------------------------------------------------------------------------