diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..18f822e --- /dev/null +++ b/.travis.yml @@ -0,0 +1,25 @@ +language: go +go: + - 1.5.3 +script: + - go test -v ./... +before_deploy: + - go get github.com/inconshreveable/mousetrap # temporary fix for travis build breaks for windows due to cobra dependency on mousetrap + - GOOS=darwin GOARCH=amd64 go build -o parse -v github.com/ParsePlatform/parse-cli + - GOOS=windows GOARCH=386 go build -o parse.exe -v github.com/ParsePlatform/parse-cli + - GOOS=linux GOARCH=386 go build -o parse_linux -v github.com/ParsePlatform/parse-cli + - GOOS=linux GOARCH=arm go build -o parse_linux_arm -v github.com/ParsePlatform/parse-cli +deploy: + provider: releases + api_key: + secure: ZnquaugjmlJ6geWvXCVjZeN2Z1NZyOaNWbY1hY8LAZZR+QqlRUR+6+QZ85vHYnqHAqKHDoh8BcZUR+2hK9UV2Lqsq0aTzvuxxVIKFiF1Gc3eC6zK3FUPgk9eOnxPZM1REGM7/i977HckpNVirskdHK1esdkKOYIfLQaHW75+xZYNJ/RKUceVrrT99IlOLWH+52vd/xGY3osV65FWkF8QS8LsZrqDQlrGyeht/P5YxepkyvMxGhBs7O3j5Npldmvdce5AQttY4MXQkMXkIrbQ2u4auBtw94hn9POd2msO0pq9JF76Is3FsH4B0/3AUrWkxMHV5XAwK1ziXniVKqQeIzaPadV92r8ihySYOQABCfQgQrHaH03jG+zE4CqqxbQvVUCCoszTZRg0AFAbaYeTKngN8kaaEEiJrKsmEASHfc8cql2TYH9Tr+ZvF/dxyYiBDU/i+BjJ4Y5jpoyXrUFeeLKRSSN4WGYYaC0r4aCBNcVDQFo9sd0n1lUtznc6wtg671Qgrvq/aog7I4qJKYBeJ/OGm771IboPxLob3P5hBdMZwOFPbjFsOmMhyBvYwxBJz3SJfVW/+pCCQdfqbfHSB0ZawdeRBQdm3w24hvq5wdpk5nGTdbdCbxb/9LsVd1WuU4xE8PiTpXxn1WqiYEcVjFd2NtYkBtFliAvnEKax+Ew= + file: + - parse + - parse.exe + - parse_linux + - parse_linux_arm + skip_cleanup: true + on: + repo: ParsePlatform/parse-cli + tags: true + condition: $TRAVIS_TAG =~ ^release_.* diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..8ca0e00 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,76 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as +contributors and maintainers pledge to making participation in our project and +our community a harassment-free experience for everyone, regardless of age, body +size, disability, ethnicity, sex characteristics, gender identity and expression, +level of experience, education, socio-economic status, nationality, personal +appearance, race, religion, or sexual identity and orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment +include: + +* Using welcoming and inclusive language +* Being respectful of differing viewpoints and experiences +* Gracefully accepting constructive criticism +* Focusing on what is best for the community +* Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery and unwelcome sexual attention or + advances +* Trolling, insulting/derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or electronic + address, without explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable +behavior and are expected to take appropriate and fair corrective action in +response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or +reject comments, commits, code, wiki edits, issues, and other contributions +that are not aligned to this Code of Conduct, or to ban temporarily or +permanently any contributor for other behaviors that they deem inappropriate, +threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies both within project spaces and in public spaces +when an individual is representing the project or its community. Examples of +representing a project or community include using an official project e-mail +address, posting via an official social media account, or acting as an appointed +representative at an online or offline event. Representation of a project may be +further defined and clarified by project maintainers. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported by contacting the project team at codeofconduct@parseplatform.org. All +complaints will be reviewed and investigated and will result in a response that +is deemed necessary and appropriate to the circumstances. The project team is +obligated to maintain confidentiality with regard to the reporter of an incident. +Further details of specific enforcement policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good +faith may face temporary or permanent repercussions as determined by other +members of the project's leadership. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, +available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see +https://www.contributor-covenant.org/faq diff --git a/LICENSE b/LICENSE index ab89c2b..1b757f1 100644 --- a/LICENSE +++ b/LICENSE @@ -28,3 +28,7 @@ LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +----- + +As of April 5, 2017, Parse, LLC has transferred this code to the parse-community organization, and will no longer be contributing to or distributing this code. diff --git a/PATENTS b/PATENTS index 964cdb7..c0ac110 100644 --- a/PATENTS +++ b/PATENTS @@ -31,3 +31,7 @@ necessarily infringed by the Software standing alone. A "Patent Assertion" is any lawsuit or other action alleging direct, indirect, or contributory infringement or inducement to infringe any patent, including a cross-claim or counterclaim. + +----- + +As of April 5, 2017, Parse, LLC has transferred this code to the parse-community organization, and will no longer be contributing to or distributing this code. diff --git a/README.md b/README.md index be5afd0..a09abe5 100644 --- a/README.md +++ b/README.md @@ -1,40 +1,61 @@ +> ## WARNING ⚠️ - this project has been retired due to lack of use & contribution, if you wish to continue to use it please fork or if you wish to maintain this project make yourself known ⚠️ + Parse Command Line Tool ======= +[![Build Status](https://travis-ci.org/parse-community/parse-cli.svg?branch=master)](https://travis-ci.org/parse-community/parse-cli) +[![Join The Conversation](https://img.shields.io/discourse/https/community.parseplatform.org/topics.svg)](https://community.parseplatform.org/c/parse-server) +[![Backers on Open Collective](https://opencollective.com/parse-server/backers/badge.svg)][open-collective-link] +[![Sponsors on Open Collective](https://opencollective.com/parse-server/sponsors/badge.svg)][open-collective-link] +[![License][license-svg]][license-link] +[![Twitter Follow](https://img.shields.io/twitter/follow/ParsePlatform.svg?label=Follow%20us%20on%20Twitter&style=social)](https://twitter.com/intent/follow?screen_name=ParsePlatform) -The `Parse Command Line Tool` allows you to interact with your Cloud Code from the terminal. - -For more information please visit [Parse](https://parse.com/docs/js/guide#command-line). +The `Parse Command Line Tool` allows you to set up your Parse app's server-side code from the terminal. +You can deploy your server-side code to either Parse Cloud Code or Heroku Node.js. Overview -------- Parse Command Line Tool can be used to perform various actions on your Parse app. It can be used to create new Parse apps, deploy Cloud Code to an app, view all releases for an app, etc. -You can install `Parse Command Line Tool` by following instructions at [parse-cli](https://parse.com/apps/quickstart#cloud_code/). +To install `Parse Command Line Tool`, you can just type the following command. + +NOTE: You should already have [Go](https://golang.org/doc/install) installed and GOPATH, GOROOT set to appropriate values. -Alternatively, you can just type the following command (assuming you already have Go installed). ```bash - go get github.com/ParsePlatform/parse-cli + go get -t github.com/ParsePlatform/parse-cli ``` + This installs a binary called `parse-cli` at `$GOPATH/bin`. +Further, you can find the code for Parse CLI at: `$GOPATH/src/github.com/ParsePlatform/parse-cli`. The following commands are currently available in the `Parse Command Line Tool`: ```bash add Adds a new Parse app to config in current Cloud Code directory + configure Configure various Parse settings default Sets or gets the default Parse app deploy Deploys a Parse app develop Monitors for changes to code and deploys, also tails parse logs + download Downloads the Cloud Code project functions List Cloud Code functions and function webhooks generate Generates a sample express app in the current project directory jssdk Sets the Parse JavaScript SDK version to use in Cloud Code - list Lists Parse apps associated with current Parse account + list Lists properties of the given Parse app and Parse apps associated with given project logs Prints out recent log messages - new Creates a new Parse app and adds Cloud Code to an existing Parse app + migrate Migrate project config format to the preferred format + new Adds Cloud Code to an existing Parse app, can also create a new Parse app releases Gets the releases for a Parse app rollback Rolls back the version for the given app symbols Uploads symbol files - triggers Lists Cloud Code triggers and trigger webhooks + triggers List Cloud Code triggers and trigger webhooks update Updates this tool to the latest version version Gets the Command Line Tools version help Help about any command ``` + +----- + +As of April 5, 2017, Parse, LLC has transferred this code to the parse-community organization, and will no longer be contributing to or distributing this code. + +[license-svg]: https://img.shields.io/badge/license-BSD-lightgrey.svg +[license-link]: LICENSE +[open-collective-link]: https://opencollective.com/parse-server diff --git a/add.go b/add.go new file mode 100644 index 0000000..3e8e587 --- /dev/null +++ b/add.go @@ -0,0 +1,111 @@ +package main + +import ( + "github.com/ParsePlatform/parse-cli/herokucmd" + "github.com/ParsePlatform/parse-cli/parsecli" + "github.com/ParsePlatform/parse-cli/parsecmd" + "github.com/facebookgo/stackerr" + "github.com/spf13/cobra" +) + +type addCmd struct { + MakeDefault bool + apps *parsecli.Apps + verbose bool +} + +func (a *addCmd) addSelectedApp( + name string, + appConfig parsecli.AppConfig, + args []string, + e *parsecli.Env, +) error { + switch e.Type { + case parsecli.LegacyParseFormat, parsecli.ParseFormat: + parseAppConfig, ok := appConfig.(*parsecli.ParseAppConfig) + if !ok { + return stackerr.New("invalid parse app config passed.") + } + return parsecmd.AddSelectedParseApp(name, parseAppConfig, args, a.MakeDefault, a.verbose, e) + case parsecli.HerokuFormat: + herokuAppConfig, ok := appConfig.(*parsecli.HerokuAppConfig) + if !ok { + return stackerr.New("invalid heroku app config passed.") + } + return herokucmd.AddSelectedHerokuApp(name, herokuAppConfig, args, a.MakeDefault, a.verbose, e) + } + + return stackerr.Newf("Unknown project type: %d.", e.Type) +} + +func (a *addCmd) selectApp(e *parsecli.Env, appName string) (*parsecli.App, error) { + apps, err := a.apps.RestFetchApps(e) + if err != nil { + return nil, err + } + if appName != "" { + for _, app := range apps { + if app.Name == appName { + return app, nil + } + } + return nil, stackerr.Newf("You are not a collaborator on app: %s", appName) + } + + app, err := a.apps.SelectApp(apps, "Select an App to add to config: ", e) + if err != nil { + return nil, err + } + return app, nil +} + +func (a *addCmd) run(e *parsecli.Env, args []string) error { + + if err := a.apps.Login.AuthUser(e, false); err != nil { + return err + } + var appName string + if len(args) > 1 { + return stackerr.New("Only an optional Parse app name is expected.") + } + if len(args) == 1 { + appName = args[0] + } + + app, err := a.selectApp(e, appName) + if err != nil { + return err + } + + var appConfig parsecli.AppConfig + switch e.Type { + case parsecli.LegacyParseFormat, parsecli.ParseFormat: + appConfig = parsecmd.GetParseAppConfig(app) + + case parsecli.HerokuFormat: + _, appConfig, err = herokucmd.GetLinkedHerokuAppConfig(app, e) + if err != nil { + return err + } + } + + return a.addSelectedApp(app.Name, appConfig, args, e) +} + +func NewAddCmd(e *parsecli.Env) *cobra.Command { + a := &addCmd{ + MakeDefault: false, + apps: &parsecli.Apps{}, + verbose: true, + } + cmd := &cobra.Command{ + Use: "add [app]", + Short: "Adds a new Parse App to config in current Cloud Code directory", + Long: `Adds a new Parse App to config in current Cloud Code directory. +If an argument is given, the added application can also be referenced by that name.`, + Run: parsecli.RunWithArgs(e, a.run), + } + cmd.Flags().BoolVarP(&a.MakeDefault, "default", "d", a.MakeDefault, + "Make the selected app default") + return cmd +} diff --git a/add_cmd.go b/add_cmd.go deleted file mode 100644 index 9795a7d..0000000 --- a/add_cmd.go +++ /dev/null @@ -1,81 +0,0 @@ -package main - -import ( - "os" - "path/filepath" - - "github.com/facebookgo/stackerr" - "github.com/spf13/cobra" -) - -type addCmd struct { - MakeDefault bool - apps *apps -} - -func (a *addCmd) writeConfig( - app *app, - args []string, - e *env, - verbose bool, -) error { - config, err := configFromDir(e.Root) - if err != nil { - return err - } - - p := config.getProjectConfig() - if p.Type == legacy { - parseConfig, ok := config.(*parseConfig) - if !ok { - return stackerr.New("Invalid Cloud Code config.") - } - return a.writeParseConfig(parseConfig, app, args, e, verbose) - } - - return stackerr.Newf("Unknown project type: %s.", p.Type) -} - -func (a *addCmd) selectApp(e *env) (*app, error) { - apps, err := a.apps.restFetchApps(e) - if err != nil { - return nil, err - } - app, err := a.apps.selectApp(apps, "Select an App to add to config: ", e) - if err != nil { - return nil, err - } - return app, nil -} - -func (a *addCmd) run(e *env, args []string) error { - _, err := os.Lstat(filepath.Join(e.Root, legacyConfigFile)) - if os.IsNotExist(err) { - return stackerr.New("Please run add command inside a parse project.") - } - if err := a.apps.login.authUser(e); err != nil { - return err - } - app, err := a.selectApp(e) - if err != nil { - return err - } - return a.writeConfig(app, args, e, true) -} - -func newAddCmd(e *env) *cobra.Command { - a := &addCmd{ - MakeDefault: false, - apps: &apps{}, - } - cmd := &cobra.Command{ - Use: "add [app]", - Short: "Adds a new Parse App to config in current Cloud Code directory", - Long: `Adds a new Parse App to config in current Cloud Code directory. -If an argument is given, the added application can also be referenced by that name.`, - Run: runWithArgs(e, a.run), - } - cmd.Flags().BoolVarP(&a.MakeDefault, "default", "d", a.MakeDefault, - "Make the selected app default") - return cmd -} diff --git a/add_cmd_parse.go b/add_cmd_parse.go deleted file mode 100644 index 8201625..0000000 --- a/add_cmd_parse.go +++ /dev/null @@ -1,55 +0,0 @@ -package main - -import ( - "fmt" - - "github.com/facebookgo/stackerr" -) - -func (a *addCmd) writeParseConfig( - parseConfig *parseConfig, - app *app, - args []string, - e *env, - verbose bool, -) error { - // add app to config - if _, ok := parseConfig.Applications[app.Name]; ok { - return stackerr.Newf("App %s has already been added", app.Name) - } - parseConfig.Applications[app.Name] = &parseAppConfig{ - ApplicationID: app.ApplicationID, - MasterKey: app.MasterKey, - } - - if len(args) > 0 && args[0] != "" { - alias := args[0] - aliasConfig, ok := parseConfig.Applications[alias] - if !ok { - parseConfig.Applications[alias] = &parseAppConfig{Link: app.Name} - } - if ok && aliasConfig.getLink() != "" { - fmt.Fprintf(e.Out, "Overwriting alias: %q to point to %q\n", alias, app.Name) - parseConfig.Applications[alias] = &parseAppConfig{Link: app.Name} - } - } - - if a.MakeDefault { - if _, ok := parseConfig.Applications[defaultKey]; ok { - return stackerr.New(`Default key already set. To override default, use command "parse default"`) - } - parseConfig.Applications[defaultKey] = &parseAppConfig{Link: app.Name} - } - - if err := storeParseConfig(e, parseConfig); err != nil { - return err - } - if verbose { - fmt.Fprintf(e.Out, "Written config for %q\n", app.Name) - if a.MakeDefault { - fmt.Fprintf(e.Out, "Set %q as default\n", app.Name) - } - } - - return nil -} diff --git a/add_cmd_test.go b/add_cmd_test.go deleted file mode 100644 index ab479ee..0000000 --- a/add_cmd_test.go +++ /dev/null @@ -1,86 +0,0 @@ -package main - -import ( - "io/ioutil" - "net/http" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/facebookgo/ensure" - "github.com/facebookgo/parse" -) - -func newAddCmdHarness(t testing.TB) (*Harness, []*app) { - h := newHarness(t) - defer h.Stop() - apps := []*app{ - { - Name: "A", - ApplicationID: "appId.A", - MasterKey: "masterKey.A", - }, - { - Name: "B", - ApplicationID: "appId.B", - MasterKey: "masterKey.B", - }, - } - res := map[string][]*app{"results": apps} - ht := transportFunc(func(r *http.Request) (*http.Response, error) { - ensure.DeepEqual(t, r.URL.Path, "/1/apps") - email, password := r.Header.Get("X-Parse-Email"), r.Header.Get("X-Parse-Password") - if email == "email" && password == "password" { - return &http.Response{ - StatusCode: http.StatusOK, - Body: ioutil.NopCloser(strings.NewReader(jsonStr(t, &res))), - }, nil - } - return &http.Response{ - StatusCode: http.StatusUnauthorized, - Body: ioutil.NopCloser(strings.NewReader(`{"error": "incorrect username / password"}`)), - }, nil - }) - h.env.Client = &Client{client: &parse.Client{Transport: ht}} - return h, apps -} - -func newAddCmdHarnessWithConfig(t testing.TB) (*Harness, *addCmd, error) { - a := addCmd{apps: &defaultApps} - - h, _ := newAddCmdHarness(t) - h.makeEmptyRoot() - - if err := os.MkdirAll(filepath.Join(h.env.Root, configDir), 0755); err != nil { - return nil, nil, err - } - - if err := ioutil.WriteFile( - filepath.Join(h.env.Root, legacyConfigFile), - []byte("{}"), - 0600); err != nil { - return nil, nil, err - } - - return h, &a, nil -} - -func TestAddCmdGlobalMakeDefault(t *testing.T) { - t.Parallel() - h, a, err := newAddCmdHarnessWithConfig(t) - defer h.Stop() - a.MakeDefault = true - ensure.Nil(t, err) - h.env.In = ioutil.NopCloser(strings.NewReader("1\n")) - ensure.Nil(t, a.run(h.env, []string{})) -} - -func TestAddCmdGlobalNotDefault(t *testing.T) { - t.Parallel() - h, a, err := newAddCmdHarnessWithConfig(t) - defer h.Stop() - ensure.Nil(t, err) - h.env.In = ioutil.NopCloser(strings.NewReader("1\n")) - ensure.Nil(t, a.run(h.env, []string{})) -} diff --git a/add_test.go b/add_test.go new file mode 100644 index 0000000..c4c5ea2 --- /dev/null +++ b/add_test.go @@ -0,0 +1,189 @@ +package main + +import ( + "encoding/json" + "io/ioutil" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/ParsePlatform/parse-cli/parsecli" + "github.com/facebookgo/ensure" + "github.com/facebookgo/parse" +) + +var ( + defaultCredentials = parsecli.Credentials{Email: "email", Password: "password"} + defaultTokenCredentials = parsecli.Credentials{Email: "email", Token: "token"} + defaultApps = parsecli.Apps{Login: parsecli.Login{Credentials: defaultCredentials}} + defaultAppsWithToken = parsecli.Apps{Login: parsecli.Login{Credentials: defaultTokenCredentials}} +) + +func jsonStr(t testing.TB, v interface{}) string { + b, err := json.Marshal(v) + ensure.Nil(t, err) + return string(b) +} + +func newAddCmdHarness(t testing.TB) (*parsecli.Harness, []*parsecli.App) { + h := parsecli.NewHarness(t) + defer h.Stop() + + apps := []*parsecli.App{ + { + Name: "A", + ApplicationID: "appId.A", + MasterKey: "masterKey.A", + }, + { + Name: "B", + ApplicationID: "appId.B", + MasterKey: "masterKey.B", + }, + } + res := map[string][]*parsecli.App{"results": apps} + ht := parsecli.TransportFunc(func(r *http.Request) (*http.Response, error) { + ensure.DeepEqual(t, r.URL.Path, "/1/apps") + + email := r.Header.Get("X-Parse-Email") + password := r.Header.Get("X-Parse-Password") + token := r.Header.Get("X-Parse-Account-Key") + if !((email == "email" && password == "password") || (token == "token")) { + return &http.Response{ + StatusCode: http.StatusUnauthorized, + Body: ioutil.NopCloser(strings.NewReader(`{"error": "incorrect credentials"}`)), + }, nil + } + + return &http.Response{ + StatusCode: http.StatusOK, + Body: ioutil.NopCloser(strings.NewReader(jsonStr(t, &res))), + }, nil + + }) + h.Env.ParseAPIClient = &parsecli.ParseAPIClient{APIClient: &parse.Client{Transport: ht}} + return h, apps +} + +func newAddCmdHarnessWithConfig(t testing.TB) (*parsecli.Harness, *addCmd, error) { + a := addCmd{apps: &defaultApps} + + h, _ := newAddCmdHarness(t) + h.MakeEmptyRoot() + + ensure.Nil(t, parsecli.CloneSampleCloudCode(h.Env, true)) + + return h, &a, nil +} + +func TestAddCmdGlobalMakeDefault(t *testing.T) { + t.Parallel() + h, a, err := newAddCmdHarnessWithConfig(t) + defer h.Stop() + a.MakeDefault = true + ensure.Nil(t, err) + h.Env.Type = parsecli.ParseFormat + h.Env.In = ioutil.NopCloser(strings.NewReader("1\n")) + ensure.Nil(t, a.run(h.Env, []string{})) + + content, err := ioutil.ReadFile(filepath.Join(h.Env.Root, parsecli.ParseLocal)) + ensure.Nil(t, err) + ensure.DeepEqual(t, string(content), `{ + "applications": { + "A": { + "applicationId": "appId.A" + }, + "_default": { + "link": "A" + } + } +}`) +} + +func TestAddCmdGlobalNotDefault(t *testing.T) { + t.Parallel() + h, a, err := newAddCmdHarnessWithConfig(t) + defer h.Stop() + ensure.Nil(t, err) + h.Env.Type = parsecli.ParseFormat + h.Env.In = ioutil.NopCloser(strings.NewReader("1\n")) + ensure.Nil(t, a.run(h.Env, []string{})) + + content, err := ioutil.ReadFile(filepath.Join(h.Env.Root, parsecli.ParseLocal)) + ensure.Nil(t, err) + ensure.DeepEqual(t, string(content), `{ + "applications": { + "A": { + "applicationId": "appId.A" + } + } +}`) +} + +// NOTE: test functionality for legacy config format + +func newLegacyAddCmdHarnessWithConfig(t testing.TB) (*parsecli.Harness, *addCmd, error) { + a := addCmd{apps: &defaultApps} + + h, _ := newAddCmdHarness(t) + h.MakeEmptyRoot() + + ensure.Nil(t, parsecli.CloneSampleCloudCode(h.Env, true)) + ensure.Nil(t, os.MkdirAll(filepath.Join(h.Env.Root, parsecli.ConfigDir), 0755)) + ensure.Nil(t, + ioutil.WriteFile( + filepath.Join(h.Env.Root, parsecli.LegacyConfigFile), + []byte("{}"), + 0600, + ), + ) + return h, &a, nil +} + +func TestLegacyAddCmdGlobalMakeDefault(t *testing.T) { + t.Parallel() + h, a, err := newLegacyAddCmdHarnessWithConfig(t) + defer h.Stop() + a.MakeDefault = true + ensure.Nil(t, err) + h.Env.Type = parsecli.LegacyParseFormat + h.Env.In = ioutil.NopCloser(strings.NewReader("1\n")) + ensure.Nil(t, a.run(h.Env, []string{})) + + content, err := ioutil.ReadFile(filepath.Join(h.Env.Root, parsecli.LegacyConfigFile)) + ensure.Nil(t, err) + ensure.DeepEqual(t, string(content), `{ + "global": {}, + "applications": { + "A": { + "applicationId": "appId.A" + }, + "_default": { + "link": "A" + } + } +}`) +} + +func TestLegacyAddCmdGlobalNotDefault(t *testing.T) { + t.Parallel() + h, a, err := newLegacyAddCmdHarnessWithConfig(t) + defer h.Stop() + ensure.Nil(t, err) + h.Env.Type = parsecli.LegacyParseFormat + h.Env.In = ioutil.NopCloser(strings.NewReader("1\n")) + ensure.Nil(t, a.run(h.Env, []string{})) + + content, err := ioutil.ReadFile(filepath.Join(h.Env.Root, parsecli.LegacyConfigFile)) + ensure.Nil(t, err) + ensure.DeepEqual(t, string(content), `{ + "global": {}, + "applications": { + "A": { + "applicationId": "appId.A" + } + } +}`) +} diff --git a/apps_test.go b/apps_test.go deleted file mode 100644 index c5fa6ed..0000000 --- a/apps_test.go +++ /dev/null @@ -1,276 +0,0 @@ -package main - -import ( - "bytes" - "encoding/json" - "errors" - "fmt" - "io/ioutil" - "net/http" - "regexp" - "strings" - "testing" - - "github.com/facebookgo/ensure" - "github.com/facebookgo/parse" -) - -var ( - defaultCredentials = credentials{email: "email", password: "password"} - defaultTokenCredentials = credentials{email: "email", token: "token"} - defaultApps = apps{login: loginCmd{credentials: defaultCredentials}} - defaultAppsWithToken = apps{login: loginCmd{credentials: defaultTokenCredentials}} -) - -func newTestApp(suffix string) *app { - return &app{ - Name: suffix, - DashboardURL: fmt.Sprintf("https://api.example.com/dashboard/%s", suffix), - ApplicationID: fmt.Sprintf("applicationID.%s", suffix), - ClientKey: fmt.Sprintf("clientKey.%s", suffix), - JavaScriptKey: fmt.Sprintf("javaScriptKey.%s", suffix), - WindowsKey: fmt.Sprintf("windowsKey.%s", suffix), - WebhookKey: fmt.Sprintf("webhookKey.%s", suffix), - RestKey: fmt.Sprintf("restKey.%s", suffix), - MasterKey: fmt.Sprintf("masterKey.%s", suffix), - ClientPushEnabled: false, - ClientClassCreationEnabled: true, - RequireRevocableSessions: true, - RevokeSessionOnPasswordChange: true, - } -} - -func newAppHarness(t testing.TB) (*Harness, []*app) { - h := newHarness(t) - - apps := []*app{ - newTestApp("A"), - newTestApp("B"), - } - res := map[string][]*app{"results": apps} - ht := transportFunc(func(r *http.Request) (*http.Response, error) { - - email := r.Header.Get("X-Parse-Email") - password := r.Header.Get("X-Parse-Password") - token := r.Header.Get("X-Parse-Account-Key") - if !((email == "email" && password == "password") || (email == "email" && token == "token")) { - return &http.Response{ - StatusCode: http.StatusUnauthorized, - Body: ioutil.NopCloser(strings.NewReader(`{"error": "incorrect credentials"}`)), - }, nil - } - - switch r.URL.Path { - case "/1/apps": - if r.Method == "GET" { - return &http.Response{ - StatusCode: http.StatusOK, - Body: ioutil.NopCloser(strings.NewReader(jsonStr(t, &res))), - }, nil - - } - - if r.Method != "POST" || r.Body == nil { - return &http.Response{ - StatusCode: http.StatusNotFound, - }, errors.New("unknown resource") - } - - var params map[string]string - if err := json.NewDecoder(r.Body).Decode(¶ms); err != nil { - return &http.Response{ - StatusCode: http.StatusInternalServerError, - }, err - } - details, err := json.Marshal(newTestApp(params["appName"])) - if err != nil { - return &http.Response{ - StatusCode: http.StatusInternalServerError, - }, err - } - - return &http.Response{ - StatusCode: http.StatusOK, - Body: ioutil.NopCloser(bytes.NewReader(details)), - }, nil - - case "/1/apps/an-app": - ensure.DeepEqual(t, r.Method, "GET") - return &http.Response{ - StatusCode: http.StatusOK, - Body: ioutil.NopCloser(strings.NewReader(jsonStr(t, newTestApp("an-app")))), - }, nil - default: - return &http.Response{ - StatusCode: http.StatusNotFound, - }, nil - } - }) - - h.env.Client = &Client{client: &parse.Client{Transport: ht}} - return h, apps -} - -func TestFetchAppKeys(t *testing.T) { - t.Parallel() - - h, _ := newAppHarness(t) - defer h.Stop() - - h.env.In = ioutil.NopCloser(strings.NewReader("email\npassword\n")) - app, err := fetchAppKeys(h.env, "an-app") - ensure.Nil(t, err) - ensure.DeepEqual(t, app, newTestApp("an-app")) -} - -func TestFetchApps(t *testing.T) { - t.Parallel() - - h, apps := newAppHarness(t) - defer h.Stop() - - a := defaultApps - gotApps, err := a.restFetchApps(h.env) - ensure.Nil(t, err) - ensure.DeepEqual(t, gotApps, apps) - - a.login.credentials.password = "invalid" - _, err = a.restFetchApps(h.env) - ensure.DeepEqual(t, err, errAuth) -} - -func TestFetchAppsToken(t *testing.T) { - t.Parallel() - - h, apps := newAppHarness(t) - defer h.Stop() - - a := defaultAppsWithToken - gotApps, err := a.restFetchApps(h.env) - ensure.Nil(t, err) - ensure.DeepEqual(t, gotApps, apps) - - a.login.credentials.token = "invalid" - _, err = a.restFetchApps(h.env) - ensure.DeepEqual(t, err, errAuth) -} - -func TestSelectAppWithRetries(t *testing.T) { - t.Parallel() - - h, apps := newAppHarness(t) - - defer h.Stop() - - a := defaultApps - h.env.In = ioutil.NopCloser(strings.NewReader("3\n1")) - selectedApp, err := a.selectApp(apps, "test: ", h.env) - ensure.Nil(t, err) - ensure.DeepEqual(t, h.Out.String(), - `1: A -2: B -test: Invalid selection 3: must be between 1 and 2 -1: A -2: B -test: `) - ensure.DeepEqual(t, selectedApp, newTestApp("A")) -} - -func TestSelectApp(t *testing.T) { - t.Parallel() - - // apps = [B, A] & appNames=[A,B] - h, apps := newAppHarness(t) - apps[0], apps[1] = apps[1], apps[0] - - defer h.Stop() - - a := defaultApps - h.env.In = ioutil.NopCloser(strings.NewReader("1")) - selectedApp, err := a.selectApp(apps, "test: ", h.env) - ensure.Nil(t, err) - ensure.DeepEqual(t, h.Out.String(), - `1: A -2: B -test: `) - ensure.DeepEqual(t, selectedApp, newTestApp("A")) -} - -func TestGetAppName(t *testing.T) { - t.Parallel() - - h := newHarness(t) - defer h.Stop() - - a := defaultApps - h.env.In = ioutil.NopCloser(strings.NewReader("\n")) - appName, err := a.getAppName(h.env) - ensure.Err(t, err, regexp.MustCompile("App name cannot be empty")) - - h.env.In = ioutil.NopCloser(strings.NewReader("hello\n")) - appName, err = a.getAppName(h.env) - ensure.Nil(t, err) - ensure.DeepEqual(t, appName, "hello") -} - -func TestCreateApp(t *testing.T) { - t.Parallel() - - h, _ := newAppHarness(t) - defer h.Stop() - - a := defaultApps - app, err := a.restCreateApp(h.env, "C") - - ensure.Nil(t, err) - ensure.DeepEqual(t, app, newTestApp("C")) -} - -func TestCreateNewApp(t *testing.T) { - t.Parallel() - - h, _ := newAppHarness(t) - defer h.Stop() - - a := defaultApps - h.env.In = ioutil.NopCloser(strings.NewReader("D")) - app, err := a.createApp(h.env) - ensure.Nil(t, err) - - ensure.Nil(t, a.printApp(h.env, app)) - ensure.DeepEqual(t, h.Out.String(), - `Please choose a name for your Parse app. -Note that this name will appear on the Parse website, -but it does not have to be the same as your mobile app's public name. -Name: Name D -DashboardURL https://api.example.com/dashboard/D -ApplicationID applicationID.D -ClientKey clientKey.D -JavaScriptKey javaScriptKey.D -WindowsKey windowsKey.D -WebhookKey webhookKey.D -RestKey restKey.D -MasterKey masterKey.D -ClientPushEnabled false -ClientClassCreationEnabled true -RequireRevocableSessions true -RevokeSessionOnPasswordChange true -`) -} - -func TestNoPanicAppCreate(t *testing.T) { - t.Parallel() - h := newHarness(t) - defer h.Stop() - - ht := transportFunc(func(r *http.Request) (*http.Response, error) { - ensure.DeepEqual(t, r.URL.Path, "/1/apps") - return nil, errors.New("nil response panic") - }) - h.env.Client = &Client{client: &parse.Client{Transport: ht}} - - var apps apps - app, err := apps.restCreateApp(h.env, "panic!") - ensure.True(t, app == nil) - ensure.Err(t, err, regexp.MustCompile("nil response panic")) -} diff --git a/client.go b/client.go deleted file mode 100644 index c97b1b1..0000000 --- a/client.go +++ /dev/null @@ -1,63 +0,0 @@ -package main - -import ( - "net/http" - "net/url" - - "github.com/facebookgo/parse" -) - -// Client is the http client used by parse-cli -type Client struct { - client *parse.Client -} - -func (c *Client) appendCommonHeaders(header http.Header) http.Header { - if header == nil { - header = make(http.Header) - } - header.Add("User-Agent", userAgent) - return header -} - -// Get performs a GET method call on the given url and unmarshal response into -// result. -func (c *Client) Get(u *url.URL, result interface{}) (*http.Response, error) { - return c.Do(&http.Request{Method: "GET", URL: u}, nil, result) -} - -// Post performs a POST method call on the given url with the given body and -// unmarshal response into result. -func (c *Client) Post(u *url.URL, body, result interface{}) (*http.Response, error) { - return c.Do(&http.Request{Method: "POST", URL: u}, body, result) -} - -// Put performs a PUT method call on the given url with the given body and -// unmarshal response into result. -func (c *Client) Put(u *url.URL, body, result interface{}) (*http.Response, error) { - return c.Do(&http.Request{Method: "PUT", URL: u}, body, result) -} - -// Delete performs a DELETE method call on the given url and unmarshal response -// into result. -func (c *Client) Delete(u *url.URL, result interface{}) (*http.Response, error) { - return c.Do(&http.Request{Method: "DELETE", URL: u}, nil, result) -} - -// RoundTrip is a wrapper for parse.Client.RoundTrip -func (c *Client) RoundTrip(req *http.Request) (*http.Response, error) { - req.Header = c.appendCommonHeaders(req.Header) - return c.client.RoundTrip(req) -} - -// Do is a wrapper for parse.Client.Do -func (c *Client) Do(req *http.Request, body, result interface{}) (*http.Response, error) { - req.Header = c.appendCommonHeaders(req.Header) - return c.client.Do(req, body, result) -} - -// WithCredentials is a wrapper for parse.Client.WithCredentials -func (c *Client) WithCredentials(cr parse.Credentials) *Client { - c.client = c.client.WithCredentials(cr) - return c -} diff --git a/config.go b/config.go deleted file mode 100644 index 32d7c27..0000000 --- a/config.go +++ /dev/null @@ -1,88 +0,0 @@ -package main - -import ( - "os" - "path/filepath" - - "github.com/facebookgo/stackerr" -) - -const ( - defaultKey = "_default" - legacy = iota + 1 -) - -type appConfig interface { - getApplicationID() string - getMasterKey(e *env) (string, error) - getApplicationAuth(e *env) (string, error) - getLink() string -} - -type config interface { - app(string) (appConfig, error) - addAlias(string, string) error - setDefaultApp(string) error - - getProjectConfig() *projectConfig - getDefaultApp() string - getNumApps() int - pprintApps(*env) -} - -type projectConfig struct { - // Type indicates the type of config. - // Some examples are legacy, parse, etc. - Type int `json:"project_type,omitempty"` - // Parse stores the project config for Parse type. - // Currently jssdk version is the only - // project level config for Parse type. - Parse *parseProjectConfig `json:"parse,omitempty"` -} - -func getConfigFile(e *env) string { - if e.Type == legacy { - return filepath.Join(e.Root, legacyConfigFile) - } - return "" -} - -func configFromDir(dir string) (config, error) { - l, err := readLegacyConfigFile(filepath.Join(dir, legacyConfigFile)) - if err != nil { - if stackerr.HasUnderlying(err, stackerr.MatcherFunc(os.IsNotExist)) { - return nil, stackerr.New("Command must be run in a directory containing a Parse project.") - } - return nil, err - } - projectConfig := &projectConfig{ - Type: legacy, - Parse: &parseProjectConfig{JSSDK: l.Global.ParseVersion}, - } - applications := l.Applications - if applications == nil { - applications = make(map[string]*parseAppConfig) - } - return &parseConfig{ - Applications: applications, - projectConfig: projectConfig, - }, nil -} - -func storeProjectConfig(e *env, c config) error { - projectType := c.getProjectConfig().Type - if projectType == legacy { - p, ok := (c).(*parseConfig) - if !ok { - return stackerr.New("Invalid Cloud Code config.") - } - lconf := &legacyConfig{Applications: p.Applications} - lconf.Global.ParseVersion = p.projectConfig.Parse.JSSDK - return writeLegacyConfigFile( - lconf, - filepath.Join(e.Root, legacyConfigFile), - ) - } - - return stackerr.Newf("Unknown project type: %s.", projectType) -} diff --git a/configure.go b/configure.go new file mode 100644 index 0000000..3e6815e --- /dev/null +++ b/configure.go @@ -0,0 +1,238 @@ +package main + +import ( + "fmt" + "io" + "os" + "sort" + "strconv" + + "github.com/ParsePlatform/parse-cli/parsecli" + "github.com/ParsePlatform/parse-cli/webhooks" + "github.com/facebookgo/stackerr" + "github.com/spf13/cobra" +) + +type configureCmd struct { + login parsecli.Login + isDefault bool + tokenReader io.Reader // for testing + + hooks webhooks.Hooks +} + +func (c *configureCmd) accountKey(e *parsecli.Env) error { + token, err := c.login.HelpCreateToken(e) + if err != nil { + return err + } + + email, err := c.login.AuthToken(e, token) + if err != nil { + fmt.Fprintln(e.Err, "Could not store credentials. Please try again.\n") + return err + } + + if c.isDefault { + email = "" + } + + var l parsecli.Login + if c.tokenReader != nil { + l.TokenReader = c.tokenReader + } + foundEmail, creds, err := l.GetTokenCredentials(e, email) + firstEverConfigure := false + if stackerr.HasUnderlying(err, stackerr.MatcherFunc(os.IsNotExist)) && !c.isDefault { + firstEverConfigure = true + } + + if creds != nil { + if c.isDefault { + fmt.Fprintln( + e.Err, + "Note: this operation will overwrite the default account key", + ) + } else if foundEmail { + fmt.Fprintf( + e.Err, + `Note: this operation will overwrite the account key: + %q +for email: %q +`, + parsecli.Last4(token), + email, + ) + } + } + + err = c.login.StoreCredentials(e, email, &parsecli.Credentials{Token: token}) + if err == nil { + if c.isDefault { + fmt.Fprintln(e.Out, "Successfully stored default account key.") + } else { + fmt.Fprintf(e.Out, "Successfully stored account key for: %q.\n", email) + } + } + if err != nil { + fmt.Fprintln(e.Err, "Could not save account key.") + return stackerr.Wrap(err) + } + + if firstEverConfigure { + fmt.Fprintln( + e.Out, + ` +Looks like this is the first time you have configured an account key. +Note that "parse new" and "parse list" can automatically pick up a default key if present. +Otherwise, you'll have to explicitly set the PARSER_EMAIL common.Environment variable +for them to pick the correct account key. +Further, if the command line tool cannot find an account key for a configured email it will try to +use the default account key. +Hence, we are automatically configuring the default account key to be the same as current account key. +`, + ) + err = c.login.StoreCredentials(e, "", &parsecli.Credentials{Token: token}) + if err != nil { + fmt.Fprintln(e.Err, "Could not save account key.") + return stackerr.Wrap(err) + } + fmt.Fprintln(e.Out, `Successfully configured the default account key. +To change the default account key in future use: + + "parse configure accountkey -d" +`) + } + + return nil +} + +func (c *configureCmd) parserEmail(e *parsecli.Env, args []string) error { + if len(args) != 1 { + return fmt.Errorf("Invalid args: %v, only an email argument is expected.", args) + } + return parsecli.SetParserEmail(e, args[0]) +} + +func (c *configureCmd) projectType(e *parsecli.Env, args []string) error { + config, err := parsecli.ConfigFromDir(e.Root) + if err != nil { + return err + } + if len(args) > 1 { + return stackerr.Newf("Invalid args: %v, only an optional project type argument is expected.", args) + } + validTypes := map[string]int{"parse": parsecli.ParseFormat} + invertedTypes := map[int]string{parsecli.ParseFormat: "parse"} + numKeys := len(validTypes) + var validKeys []string + for key := range validTypes { + validKeys = append(validKeys, key) + } + sort.Strings(validKeys) + var selectionString string + for n, key := range validKeys { + selectionString += fmt.Sprintf("%d: %s\n", 1+n, key) + } + + selectedProjectType := -1 + if len(args) != 0 { + projectType, ok := validTypes[args[0]] + if !ok { + return stackerr.Newf("Invalid projectType: %v, valid types are: \n%s", selectionString) + } + selectedProjectType = projectType + } + + for i := 0; i < 3; i++ { + fmt.Fprintf(e.Out, `Select from the listed project types: +%s +Enter a number between 1 and %d: `, + selectionString, + numKeys, + ) + var selection string + fmt.Fscanf(e.In, "%s\n", &selection) + num, err := strconv.Atoi(selection) + if err != nil || num < 1 || num > numKeys { + fmt.Fprintf(e.Err, "Invalid selection. Please enter a number between 1 and %d\n", numKeys) + continue + } + projectType, ok := validTypes[validKeys[num-1]] + if !ok { + return stackerr.Newf("Invalid projectType: %v, valid types are: \n%s", selectionString) + } + selectedProjectType = projectType + break + } + if selectedProjectType == -1 { + return stackerr.Newf("Could not make a selection. Please try again.") + } + + config.GetProjectConfig().Type = selectedProjectType + if err := parsecli.StoreProjectConfig(e, config); err != nil { + fmt.Fprintln(e.Err, "Could not save selected project type to project config") + return err + } + fmt.Fprintf(e.Out, "Successfully set project type to: %v\n", invertedTypes[selectedProjectType]) + return nil +} + +func NewConfigureCmd(e *parsecli.Env) *cobra.Command { + var c configureCmd + + cmd := &cobra.Command{ + Use: "configure", + Short: "Configure various Parse settings", + Long: "Configure various Parse settings like account keys, project type, and more.", + Run: func(c *cobra.Command, args []string) { + c.Help() + }, + } + + keyCmd := &cobra.Command{ + Use: "accountkey", + Short: "Store Parse account key on machine", + Long: "Stores Parse account key in ~/.parse/netrc.", + Run: parsecli.RunNoArgs(e, c.accountKey), + Aliases: []string{"key"}, + } + keyCmd.Flags().BoolVarP(&c.isDefault, "default", "d", c.isDefault, + "Make this token a system default") + cmd.AddCommand(keyCmd) + + emailCmd := &cobra.Command{ + Use: "email [id]", + Short: "Configures the parser email for this project", + Long: "Configures the parser email for current project.", + Run: parsecli.RunWithArgs(e, c.parserEmail), + Aliases: []string{"user"}, + } + cmd.AddCommand(emailCmd) + + projectCmd := &cobra.Command{ + Use: "project [type]", + Short: "Set the project type to one among listed options", + Long: "Set the project type to one among listed options. For instance, 'parse'", + Run: parsecli.RunWithArgs(e, c.projectType), + } + cmd.AddCommand(projectCmd) + + hooksCmd := &cobra.Command{ + Use: "hooks [filename]", + Short: "Configure webhooks according to given config file", + Long: `Configure webhooks for the app based on the given configuration json file. +For more details read: https://parse.com/docs/cloudcode/guide#command-line-webhooks +`, + Run: parsecli.RunWithArgsClient(e, c.hooks.HooksCmd), + Aliases: []string{"webhooks"}, + } + hooksCmd.Flags().BoolVarP(&c.hooks.HooksStrict, "strict", "s", c.hooks.HooksStrict, + "Configure hooks in strict mode, i.e., do not automatically fix errors.") + hooksCmd.Flags().StringVarP(&c.hooks.BaseURL, "base", "b", c.hooks.BaseURL, + `Base url to use while parsing the webhook url field. +If provided, the config file can have relative urls.`) + cmd.AddCommand(hooksCmd) + + return cmd +} diff --git a/configure_test.go b/configure_test.go new file mode 100644 index 0000000..a60f546 --- /dev/null +++ b/configure_test.go @@ -0,0 +1,152 @@ +package main + +import ( + "io/ioutil" + "path/filepath" + "regexp" + "strings" + "testing" + + "github.com/ParsePlatform/parse-cli/parsecli" + "github.com/facebookgo/ensure" +) + +func TestConfigureAccountKey(t *testing.T) { + t.Parallel() + + h := parsecli.NewTokenHarness(t) + defer h.Stop() + + c := configureCmd{login: parsecli.Login{TokenReader: strings.NewReader("")}} + + h.Env.In = ioutil.NopCloser(strings.NewReader("token\n")) + ensure.Nil(t, c.accountKey(h.Env)) + ensure.StringContains( + t, + h.Out.String(), + ` +Input your account key or press ENTER to generate a new one. +`) + + h.Env.In = ioutil.NopCloser(strings.NewReader("invalid\n")) + ensure.Err(t, c.accountKey(h.Env), regexp.MustCompile("is not valid")) + ensure.DeepEqual(t, + h.Err.String(), + "Could not store credentials. Please try again.\n\n", + ) + + h.Env.Server = "http://api.parse.com/1/" + c.tokenReader = strings.NewReader( + `machine api.parse.com#email + login default + password token2 + `, + ) + h.Err.Reset() + h.Env.In = ioutil.NopCloser(strings.NewReader("token\n")) + ensure.Nil(t, c.accountKey(h.Env)) + ensure.DeepEqual(t, h.Err.String(), + `Note: this operation will overwrite the account key: + "*oken" +for email: "email" +`) + + h.Env.Server = "http://api.parse.com/1/" + c.tokenReader = strings.NewReader( + `machine api.parse.com#email + login default + password token2 + `, + ) + c.isDefault = true + h.Err.Reset() + h.Env.In = ioutil.NopCloser(strings.NewReader("token\n")) + ensure.Nil(t, c.accountKey(h.Env)) + ensure.DeepEqual(t, h.Err.String(), "") + + h.Env.Server = "http://api.parse.com/1/" + c.tokenReader = strings.NewReader( + `machine api.parse.com + login default + password token2 + `, + ) + c.isDefault = true + h.Err.Reset() + h.Env.In = ioutil.NopCloser(strings.NewReader("token\n")) + ensure.Nil(t, c.accountKey(h.Env)) + ensure.DeepEqual(t, h.Err.String(), "Note: this operation will overwrite the default account key\n") + + h.Env.Server = "http://api.parse.com/1/" + c.tokenReader = strings.NewReader( + `machine api.parse.com + login default + password token2 + `, + ) + h.Err.Reset() + c.isDefault = false + h.Env.In = ioutil.NopCloser(strings.NewReader("token\n")) + ensure.Nil(t, c.accountKey(h.Env)) + ensure.DeepEqual(t, h.Err.String(), "") +} + +func TestParserEmail(t *testing.T) { + t.Parallel() + + h := parsecli.NewTokenHarness(t) + h.MakeEmptyRoot() + defer h.Stop() + + ensure.Nil(t, parsecli.CreateConfigWithContent(filepath.Join(h.Env.Root, parsecli.ParseLocal), "{}")) + ensure.Nil(t, + parsecli.CreateConfigWithContent( + filepath.Join(h.Env.Root, parsecli.ParseProject), + `{"project_type": 1}`, + ), + ) + + var c configureCmd + ensure.Nil(t, c.parserEmail(h.Env, []string{"email2"})) + ensure.DeepEqual( + t, + h.Out.String(), + `Successfully configured email for current project to: "email2" +`, + ) + + ensure.Err(t, c.parserEmail(h.Env, nil), regexp.MustCompile("Invalid args:")) + ensure.Err(t, c.parserEmail(h.Env, []string{"a", "b"}), regexp.MustCompile("Invalid args:")) +} + +func TestProjectType(t *testing.T) { + t.Parallel() + h := parsecli.NewHarness(t) + defer h.Stop() + + h.MakeEmptyRoot() + ensure.Nil(t, parsecli.CloneSampleCloudCode(h.Env, false)) + + c := &configureCmd{} + err := c.projectType(h.Env, []string{"1", "2"}) + ensure.Err(t, err, regexp.MustCompile("only an optional project type argument is expected")) + + h.Env.In = ioutil.NopCloser(strings.NewReader("invalid\n")) + err = c.projectType(h.Env, nil) + ensure.StringContains(t, h.Err.String(), "Invalid selection. Please enter a number") + ensure.Err(t, err, regexp.MustCompile("Could not make a selection. Please try again.")) + h.Err.Reset() + h.Out.Reset() + + h.Env.In = ioutil.NopCloser(strings.NewReader("0\n")) + err = c.projectType(h.Env, nil) + ensure.StringContains(t, h.Err.String(), "Please enter a number between 1 and") + ensure.Err(t, err, regexp.MustCompile("Could not make a selection. Please try again.")) + h.Err.Reset() + h.Out.Reset() + + h.Env.In = ioutil.NopCloser(strings.NewReader("1\n")) + err = c.projectType(h.Env, nil) + ensure.StringContains(t, h.Out.String(), "Successfully set project type to: parse") + ensure.Nil(t, err) +} diff --git a/default.go b/default.go new file mode 100644 index 0000000..99da61a --- /dev/null +++ b/default.go @@ -0,0 +1,48 @@ +package main + +import ( + "github.com/ParsePlatform/parse-cli/parsecli" + "github.com/facebookgo/stackerr" + "github.com/spf13/cobra" +) + +type defaultCmd struct{} + +func (d *defaultCmd) run(e *parsecli.Env, args []string) error { + var newDefault string + if len(args) > 1 { + return stackerr.Newf("unexpected arguments, only an optional app name is expected: %v", args) + } + if len(args) == 1 { + newDefault = args[0] + } + + config, err := parsecli.ConfigFromDir(e.Root) + if err != nil { + return err + } + + if config.GetNumApps() == 0 { + return stackerr.New("No apps are associated with this project. You can add some with parse add") + } + + defaultApp := config.GetDefaultApp() + + switch newDefault { + case "": + return parsecli.PrintDefault(e, defaultApp) + default: + return parsecli.SetDefault(e, newDefault, defaultApp, config) + } +} + +func NewDefaultCmd(e *parsecli.Env) *cobra.Command { + d := defaultCmd{} + return &cobra.Command{ + Use: "default [app]", + Short: "Sets or gets the default Parse App", + Long: `Gets the default Parse App. If an argument is given, sets the ` + + `default Parse App.`, + Run: parsecli.RunWithArgs(e, d.run), + } +} diff --git a/default_cmd.go b/default_cmd.go deleted file mode 100644 index 1e471b0..0000000 --- a/default_cmd.go +++ /dev/null @@ -1,68 +0,0 @@ -package main - -import ( - "fmt" - - "github.com/facebookgo/stackerr" - "github.com/spf13/cobra" -) - -type defaultCmd struct{} - -func (d *defaultCmd) printDefault(e *env, defaultApp string) error { - if defaultApp == "" { - return stackerr.New("No app is set as default app") - } - fmt.Fprintf(e.Out, "Current default app is %s\n", defaultApp) - return nil -} - -func (d *defaultCmd) setDefault(e *env, newDefault, defaultApp string, c config) error { - if c.getProjectConfig().Type == legacy { - p, ok := c.(*parseConfig) - if !ok { - return stackerr.New("Invalid Cloud Code config.") - } - return d.setParseDefault(e, newDefault, defaultApp, p) - } - return stackerr.Newf("Project type not configured.") -} - -func (d *defaultCmd) run(e *env, args []string) error { - var newDefault string - if len(args) > 1 { - return stackerr.Newf("unexpected arguments, only an optional app name is expected: %v", args) - } - if len(args) == 1 { - newDefault = args[0] - } - - config, err := configFromDir(e.Root) - if err != nil { - return err - } - - if config.getNumApps() == 0 { - return stackerr.New("No apps are associated with this project. You can add some with parse add") - } - - defaultApp := config.getDefaultApp() - - switch newDefault { - case "": - return d.printDefault(e, defaultApp) - default: - return d.setDefault(e, newDefault, defaultApp, config) - } -} - -func newDefaultCmd(e *env) *cobra.Command { - d := defaultCmd{} - return &cobra.Command{ - Use: "default [app]", - Short: "Sets or gets the default Parse App", - Long: `Gets the default Parse App. If an argument is given, sets the ` + - `default Parse App.`, - Run: runWithArgs(e, d.run), - } -} diff --git a/default_cmd_parse.go b/default_cmd_parse.go deleted file mode 100644 index 662bdef..0000000 --- a/default_cmd_parse.go +++ /dev/null @@ -1,26 +0,0 @@ -package main - -import ( - "fmt" - - "github.com/facebookgo/stackerr" -) - -func (d *defaultCmd) setParseDefault(e *env, newDefault, defaultApp string, parseConfig *parseConfig) error { - apps := parseConfig.Applications - if _, ok := apps[newDefault]; !ok { - parseConfig.pprintApps(e) - return stackerr.Newf("Invalid application name \"%s\". Please select from the valid applications printed above.", newDefault) - } - - if defaultApp == "" { - apps[defaultKey] = &parseAppConfig{Link: newDefault} - } else { - apps[defaultKey].Link = newDefault - } - if err := storeParseConfig(e, parseConfig); err != nil { - return err - } - fmt.Fprintf(e.Out, "Default app set to %s.\n", newDefault) - return nil -} diff --git a/default_cmd_test.go b/default_cmd_test.go deleted file mode 100644 index 47c34a5..0000000 --- a/default_cmd_test.go +++ /dev/null @@ -1,85 +0,0 @@ -package main - -import ( - "regexp" - "testing" - - "github.com/facebookgo/ensure" -) - -func createNewProject(t testing.TB, h *Harness) (*parseConfig, error) { - var n newCmd - ensure.Nil(t, n.cloneSampleCloudCode(h.env, &app{Name: "test"}, false)) - c, err := configFromDir(h.env.Root) - if err != nil { - return nil, err - } - config, ok := c.(*parseConfig) - ensure.True(t, ok) - config.Applications = map[string]*parseAppConfig{"first": {}, "second": {}, defaultKey: {Link: "first"}} - config.projectConfig = &projectConfig{Type: legacy, Parse: &parseProjectConfig{}} - return config, storeParseConfig(h.env, config) -} - -func newDefaultCmdHarness(t testing.TB) (*Harness, *defaultCmd, *parseConfig) { - h := newHarness(t) - h.makeEmptyRoot() - - config, err := createNewProject(t, h) - ensure.Nil(t, err) - h.Out.Reset() - - d := &defaultCmd{} - return h, d, config -} - -func TestPrintDefaultNotSet(t *testing.T) { - t.Parallel() - h, d, _ := newDefaultCmdHarness(t) - defer h.Stop() - ensure.Err(t, d.printDefault(h.env, ""), regexp.MustCompile("No app is set as default app")) -} - -func TestPrintDefaultSet(t *testing.T) { - h, d, _ := newDefaultCmdHarness(t) - defer h.Stop() - d.printDefault(h.env, "first") - ensure.DeepEqual(t, h.Out.String(), - `Current default app is first -`) -} - -func TestSetDefaultInvalid(t *testing.T) { - t.Parallel() - h, d, config := newDefaultCmdHarness(t) - defer h.Stop() - ensure.Err(t, d.setDefault(h.env, "invalid", "", config), regexp.MustCompile(`Invalid application name "invalid". Please select from the valid applications printed above.`)) - ensure.DeepEqual(t, h.Out.String(), - `The following apps are associated with cloud code in the current directory: -* first - second -`) -} - -func TestSetDefault(t *testing.T) { - t.Parallel() - h, d, config := newDefaultCmdHarness(t) - defer h.Stop() - ensure.Nil(t, d.setDefault(h.env, "first", "", config)) - ensure.DeepEqual(t, h.Out.String(), "Default app set to first.\n") -} - -func TestSetDefaultWithApp(t *testing.T) { - t.Parallel() - h, d, config := newDefaultCmdHarness(t) - defer h.Stop() - ensure.Nil(t, d.setDefault(h.env, "first", "first", config)) - ensure.DeepEqual(t, h.Out.String(), "Default app set to first.\n") -} - -func TestDefaultRunMoreArgs(t *testing.T) { - t.Parallel() - h, d, _ := newDefaultCmdHarness(t) - defer h.Stop() - ensure.Err(t, d.run(h.env, []string{"one", "two"}), regexp.MustCompile(`unexpected arguments, only an optional app name is expected:`)) -} diff --git a/default_test.go b/default_test.go new file mode 100644 index 0000000..e1066ed --- /dev/null +++ b/default_test.go @@ -0,0 +1,152 @@ +package main + +import ( + "io/ioutil" + "os" + "path/filepath" + "regexp" + "testing" + + "github.com/ParsePlatform/parse-cli/parsecli" + "github.com/facebookgo/ensure" +) + +func newDefaultParseConfig(t testing.TB, h *parsecli.Harness) *parsecli.ParseConfig { + c, err := parsecli.ConfigFromDir(h.Env.Root) + ensure.Nil(t, err) + + config, ok := c.(*parsecli.ParseConfig) + ensure.True(t, ok) + config.Applications = map[string]*parsecli.ParseAppConfig{ + "first": {}, + "second": {}, + parsecli.DefaultKey: {Link: "first"}, + } + config.ProjectConfig = &parsecli.ProjectConfig{ + Type: parsecli.LegacyParseFormat, + Parse: &parsecli.ParseProjectConfig{}, + } + ensure.Nil(t, parsecli.StoreConfig(h.Env, config)) + + return config +} + +func newDefaultCmdHarness(t testing.TB) (*parsecli.Harness, *defaultCmd, *parsecli.ParseConfig) { + h := parsecli.NewHarness(t) + h.MakeEmptyRoot() + + ensure.Nil(t, parsecli.CloneSampleCloudCode(h.Env, true)) + + config := newDefaultParseConfig(t, h) + h.Out.Reset() + + d := &defaultCmd{} + return h, d, config +} + +func TestPrintDefaultNotSet(t *testing.T) { + t.Parallel() + h, _, _ := newDefaultCmdHarness(t) + defer h.Stop() + ensure.Err(t, parsecli.PrintDefault(h.Env, ""), regexp.MustCompile("No app is set as default app")) +} + +func TestPrintDefaultSet(t *testing.T) { + t.Parallel() + h, _, _ := newDefaultCmdHarness(t) + defer h.Stop() + parsecli.PrintDefault(h.Env, "first") + ensure.DeepEqual(t, h.Out.String(), + `Current default app is first +`) +} + +func TestSetDefaultInvalid(t *testing.T) { + t.Parallel() + h, _, config := newDefaultCmdHarness(t) + defer h.Stop() + ensure.Err( + t, + parsecli.SetDefault(h.Env, "invalid", "", config), + regexp.MustCompile(`Invalid application name "invalid". Please select from the valid applications printed above.`), + ) + ensure.DeepEqual(t, h.Out.String(), + `The following apps are associated with Cloud Code in the current directory: +* first + second +`) +} + +func TestSetDefault(t *testing.T) { + t.Parallel() + h, _, config := newDefaultCmdHarness(t) + defer h.Stop() + ensure.Nil(t, parsecli.SetDefault(h.Env, "first", "", config)) + ensure.DeepEqual(t, h.Out.String(), "Default app set to first.\n") +} + +func TestSetDefaultWithApp(t *testing.T) { + t.Parallel() + h, _, config := newDefaultCmdHarness(t) + defer h.Stop() + ensure.Nil(t, parsecli.SetDefault(h.Env, "first", "first", config)) + ensure.DeepEqual(t, h.Out.String(), "Default app set to first.\n") +} + +func TestDefaultRunMoreArgs(t *testing.T) { + t.Parallel() + h, d, _ := newDefaultCmdHarness(t) + defer h.Stop() + ensure.Err(t, d.run(h.Env, []string{"one", "two"}), regexp.MustCompile(`unexpected arguments, only an optional app name is expected:`)) +} + +// NOTE: testing for legacy format +func newLegacyDefaultCmdHarness(t testing.TB) (*parsecli.Harness, *defaultCmd, *parsecli.ParseConfig) { + h := parsecli.NewHarness(t) + h.MakeEmptyRoot() + + ensure.Nil(t, parsecli.CloneSampleCloudCode(h.Env, true)) + ensure.Nil(t, os.MkdirAll(filepath.Join(h.Env.Root, parsecli.ConfigDir), 0755)) + ensure.Nil(t, ioutil.WriteFile(filepath.Join(h.Env.Root, parsecli.LegacyConfigFile), + []byte("{}"), + 0600), + ) + + config := newDefaultParseConfig(t, h) + h.Out.Reset() + + d := &defaultCmd{} + return h, d, config +} + +func TestLegacySetDefaultInvalid(t *testing.T) { + t.Parallel() + h, _, config := newLegacyDefaultCmdHarness(t) + defer h.Stop() + ensure.Err( + t, + parsecli.SetDefault(h.Env, "invalid", "", config), + regexp.MustCompile(`Invalid application name "invalid". Please select from the valid applications printed above.`), + ) + ensure.DeepEqual(t, h.Out.String(), + `The following apps are associated with Cloud Code in the current directory: +* first + second +`) +} + +func TestLegacySetDefault(t *testing.T) { + t.Parallel() + h, _, config := newLegacyDefaultCmdHarness(t) + defer h.Stop() + ensure.Nil(t, parsecli.SetDefault(h.Env, "first", "", config)) + ensure.DeepEqual(t, h.Out.String(), "Default app set to first.\n") +} + +func TestLegacySetDefaultWithApp(t *testing.T) { + t.Parallel() + h, _, config := newLegacyDefaultCmdHarness(t) + defer h.Stop() + ensure.Nil(t, parsecli.SetDefault(h.Env, "first", "first", config)) + ensure.DeepEqual(t, h.Out.String(), "Default app set to first.\n") +} diff --git a/heroku_commands.go b/heroku_commands.go new file mode 100644 index 0000000..09e5eaf --- /dev/null +++ b/heroku_commands.go @@ -0,0 +1,64 @@ +package main + +import ( + "fmt" + "os" + "time" + + "github.com/ParsePlatform/parse-cli/herokucmd" + "github.com/ParsePlatform/parse-cli/parsecli" + "github.com/ParsePlatform/parse-cli/webhooks" + "github.com/spf13/cobra" +) + +func herokuRootCmd(e *parsecli.Env) ([]string, *cobra.Command) { + c := &cobra.Command{ + Use: "parse", + Long: fmt.Sprintf( + `Parse Command Line Tool + +Tools to help you set up server code on Heroku +Version %v +Copyright %d Parse, Inc. +https://www.parse.com`, + parsecli.Version, + time.Now().Year(), + ), + Run: func(cmd *cobra.Command, args []string) { + cmd.Help() + }, + } + + c.AddCommand(NewAddCmd(e)) + c.AddCommand(NewConfigureCmd(e)) + c.AddCommand(NewDefaultCmd(e)) + c.AddCommand(herokucmd.NewDownloadCmd(e)) + c.AddCommand(herokucmd.NewDeployCmd(e)) + c.AddCommand(webhooks.NewFunctionHooksCmd(e)) + c.AddCommand(NewListCmd(e)) + c.AddCommand(herokucmd.NewLogsCmd(e)) + c.AddCommand(NewNewCmd(e)) + c.AddCommand(herokucmd.NewReleasesCmd(e)) + c.AddCommand(herokucmd.NewRollbackCmd(e)) + c.AddCommand(webhooks.NewTriggerHooksCmd(e)) + c.AddCommand(NewUpdateCmd(e)) + c.AddCommand(NewVersionCmd(e)) + + if len(os.Args) <= 1 { + return nil, c + } + commands := []string{"help"} + for _, command := range c.Commands() { + commands = append(commands, command.Name()) + } + + args := make([]string, len(os.Args)-1) + copy(args, os.Args[1:]) + + if message := parsecli.MakeCorrections(commands, args); message != "" { + fmt.Fprintln(e.Out, message) + } + c.SetArgs(args) + + return args, c +} diff --git a/herokucmd/add.go b/herokucmd/add.go new file mode 100644 index 0000000..0195d7c --- /dev/null +++ b/herokucmd/add.go @@ -0,0 +1,221 @@ +package herokucmd + +import ( + "fmt" + "math/rand" + "strconv" + "strings" + "text/tabwriter" + "time" + + "github.com/ParsePlatform/parse-cli/parsecli" + "github.com/facebookgo/stackerr" + "github.com/skratchdot/open-golang/open" +) + +const linkHerokuURL = "https://parse.com/account/edit" + +func selectHerokuApp(apps nameIDs, e *parsecli.Env) (*nameID, error) { + fmt.Fprintf(e.Out, "Please select from the following Heroku apps: (Enter a number between 1 and %d)\n", len(apps)) + for i, app := range apps { + w := new(tabwriter.Writer) + w.Init(e.Out, 0, 8, 0, '\t', 0) + fmt.Fprintf(w, "%d: %s\t\t(%s)\n", i+1, app.name, app.id) + if err := w.Flush(); err != nil { + return nil, stackerr.Wrap(err) + } + } + fmt.Fprintf(e.Out, "Selection: ") + var selection string + fmt.Fscanf(e.In, "%s\n", &selection) + + n, err := strconv.Atoi(selection) + if err != nil { + return nil, err + } + + lapps := len(apps) + if n <= 0 || n > lapps { + return nil, stackerr.Newf("Invalid selection: can only be in range 1..%d", lapps) + } + return &apps[n-1], nil +} + +func getRandomAppName(app *parsecli.App) string { + rand.Seed(time.Now().Unix()) + + namePrefix := strings.Join(strings.Fields(strings.ToLower(app.Name)), "") + if namePrefix == "" { + namePrefix = "parsoku" + } + + construct := fmt.Sprintf( + "%s-%s-%d", + namePrefix, + strings.ToLower(app.ApplicationID[:6]), + rand.Intn(10000), + ) + l := len(construct) + if l > 25 { + l = 25 + } + if construct[l-1] == '-' { // ensure no dash at the end + l -= -1 + } + return construct[:l] +} + +func GetLinkedHerokuAppConfig(app *parsecli.App, e *parsecli.Env) (bool, *parsecli.HerokuAppConfig, error) { + h := &herokuLink{parseAppID: app.ApplicationID} + apps, err := h.getAppLinks(e) + if err != nil { + return false, nil, err + } + if len(apps) == 0 { + randomName := getRandomAppName(app) + appName := randomName + for i := 0; i < 3; i++ { + if appName == randomName { + fmt.Fprintf(e.Out, + `Let's create a new Heroku app in which server code will be run. +The Heroku app will be named: %q +Note that this can be changed later using Heroku API or Dashboard. + +`, + randomName, + ) + } else { + appName = h.getHerokuAppName(e) + } + + id, err := h.createNewLink(e, appName) + if err == nil { + return true, &parsecli.HerokuAppConfig{ + ParseAppID: app.ApplicationID, + HerokuAppID: id, + }, nil + } + if i == 2 { + return false, nil, err + } + + switch { + case stackerr.HasUnderlying(err, stackerr.MatcherFunc(herokuAppNameTaken)): + fmt.Fprintf(e.Err, "App name %s already taken.\nPlease try again...\n\n", appName) + appName = "" + + case stackerr.HasUnderlying(err, stackerr.MatcherFunc(herokuAccountNotLinked)): + fmt.Fprintf(e.Err, `Looks like you have not yet linked your Heroku Account to your Parse account. +Trying to open %q in the browser. +Please click "Link Heroku" button at the bottom. +`, + linkHerokuURL, + ) + appName = randomName + err := open.Run(linkHerokuURL) + if err != nil { + fmt.Fprintf(e.Err, + `Sorry, we couldn’t open the browser for you. Go to + %q +and click the "Link Heroku" button to link your Heroku account to Parse. +`, + linkHerokuURL, + ) + } + + fmt.Fprintf(e.Out, "Press ENTER when you are done linking your Heroku account to Parse: ") + var discard string + fmt.Fscanf(e.In, "%s\n", &discard) + + default: + return false, nil, err + } + } + } + + if len(apps) == 1 { + return false, &parsecli.HerokuAppConfig{ + ParseAppID: app.ApplicationID, + HerokuAppID: apps[0].id, + }, nil + } + + // NOTE: this part of code will not be used for now + for r := 0; r < 3; r++ { + selected, err := selectHerokuApp(apps, e) + if err != nil { + fmt.Fprintf(e.Err, "error: %s.\nPlease try again...\n", parsecli.ErrorString(e, err)) + continue + } + if selected.id != "" { + return false, &parsecli.HerokuAppConfig{ + ParseAppID: app.ApplicationID, + HerokuAppID: selected.id, + }, nil + } + id, err := h.createNewLink(e, selected.name) + if err != nil { + return false, nil, err + } + return false, &parsecli.HerokuAppConfig{ + ParseAppID: app.ApplicationID, + HerokuAppID: id, + }, nil + } + return false, nil, stackerr.New("failed to selected an heroku app in 3 attempts") +} + +func AddSelectedHerokuApp( + appName string, + appConfig *parsecli.HerokuAppConfig, + args []string, + makeDefault, verbose bool, + e *parsecli.Env, +) error { + config, err := parsecli.ConfigFromDir(e.Root) + if err != nil { + return err + } + herokuConfig, ok := config.(*parsecli.HerokuConfig) + if !ok { + return stackerr.New("Invalid Heroku config.") + } + + // add app to config + if _, ok := herokuConfig.Applications[appName]; ok { + return stackerr.Newf("App %s has already been added", appName) + } + + herokuConfig.Applications[appName] = appConfig + + if len(args) > 0 && args[0] != "" { + alias := args[0] + aliasConfig, ok := herokuConfig.Applications[alias] + if !ok { + herokuConfig.Applications[alias] = &parsecli.HerokuAppConfig{Link: appName} + } + if ok && aliasConfig.GetLink() != "" { + fmt.Fprintf(e.Out, "Overwriting alias: %q to point to %q\n", alias, appName) + herokuConfig.Applications[alias] = &parsecli.HerokuAppConfig{Link: appName} + } + } + + if makeDefault { + if _, ok := herokuConfig.Applications[parsecli.DefaultKey]; ok { + return stackerr.New(`Default key already set. To override default, use command "parse default"`) + } + herokuConfig.Applications[parsecli.DefaultKey] = &parsecli.HerokuAppConfig{Link: appName} + } + + if err := parsecli.StoreConfig(e, herokuConfig); err != nil { + return err + } + if verbose { + fmt.Fprintf(e.Out, "Written config for %q\n", appName) + if makeDefault { + fmt.Fprintf(e.Out, "Set %q as default\n", appName) + } + } + + return nil +} diff --git a/herokucmd/add_test.go b/herokucmd/add_test.go new file mode 100644 index 0000000..2382ce7 --- /dev/null +++ b/herokucmd/add_test.go @@ -0,0 +1,14 @@ +package herokucmd + +import ( + "testing" + + "github.com/ParsePlatform/parse-cli/parsecli" + "github.com/facebookgo/ensure" +) + +func TestGetRandomAppName(t *testing.T) { + app := &parsecli.App{Name: "test app", ApplicationID: "123456789"} + name := getRandomAppName(app) + ensure.StringContains(t, name, "testapp") +} diff --git a/herokucmd/deploy.go b/herokucmd/deploy.go new file mode 100644 index 0000000..ff98a05 --- /dev/null +++ b/herokucmd/deploy.go @@ -0,0 +1,142 @@ +package herokucmd + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/ParsePlatform/parse-cli/parsecli" + "github.com/ParsePlatform/parse-cli/webhooks" + "github.com/bgentry/heroku-go" + "github.com/facebookgo/stackerr" + "github.com/spf13/cobra" +) + +type deployCmd struct { + Force bool + Description string +} + +func fetchHerokuAppInfo(e *parsecli.Env, appConfig *parsecli.HerokuAppConfig) (*heroku.App, error) { + herokuAppInfo, err := e.HerokuAPIClient.AppInfo(appConfig.HerokuAppID) + if err != nil { + return nil, stackerr.Newf( + "Unable to fetch app info for: %q from heroku.", + appConfig.HerokuAppID, + ) + } + return herokuAppInfo, nil +} + +func setHerokuConfigs( + e *parsecli.Env, + appConfig *parsecli.HerokuAppConfig, + options map[string]*string, +) error { + varsSet, err := e.HerokuAPIClient.ConfigVarUpdate( + appConfig.HerokuAppID, + options, + ) + if err != nil { + stackerr.New("Unable to set config vars to heroku.") + } + fmt.Fprintln(e.Out, "Successfully set the following vars to heroku:") + for key, val := range varsSet { + if key != "HOOKS_URL" { + val = parsecli.Last4(val) + } + fmt.Fprintln(e.Out, key, val) + } + return nil +} + +func (d *deployCmd) deploy(e *parsecli.Env, ctx *parsecli.Context) error { + appConfig, ok := ctx.AppConfig.(*parsecli.HerokuAppConfig) + if !ok { + return stackerr.New("Invalid config type.") + } + + authToken, err := ctx.AppConfig.GetApplicationAuth(e) + if err != nil { + return err + } + appInfo, err := fetchHerokuAppInfo(e, appConfig) + if err != nil { + return err + } + + // set appropriate heroku config vars + appKeys, err := parsecli.FetchAppKeys(e, appConfig.ParseAppID) + if err != nil { + return err + } + fmt.Fprintln(e.Out, "This Node.js webhooks project will be deployed to Heroku.") + err = setHerokuConfigs(e, + appConfig, + map[string]*string{ + "HOOKS_URL": &appInfo.WebURL, + "PARSE_APP_ID": &appConfig.ParseAppID, + "PARSE_MASTER_KEY": &appKeys.MasterKey, + "PARSE_WEBHOOK_KEY": &appKeys.WebhookKey, + }, + ) + if err != nil { + return err + } + + // push to heroku git + g := gitInfo{ + repo: e.Root, + authToken: authToken, + remote: fmt.Sprintf("git.heroku.com/%s.git", appInfo.Name), + description: d.Description, + } + err = g.whichGit() + if err != nil { + return err + } + err = g.isGitRepo(e) + if err != nil { + err = g.init(e) + if err != nil { + return err + } + } + dirty, err := g.isDirty(e) + if err != nil { + return err + } + + if dirty { + err := g.commit(e) + if err != nil { + return err + } + } + if err := g.push(e, d.Force); err != nil { + return err + } + + webhooksConfig := filepath.Join(e.Root, "webhooks.json") + if _, err := os.Lstat(webhooksConfig); err == nil { + h := &webhooks.Hooks{BaseURL: appInfo.WebURL} + return h.HooksCmd(e, ctx, []string{webhooksConfig}) + } + + return nil +} + +func NewDeployCmd(e *parsecli.Env) *cobra.Command { + var d deployCmd + cmd := &cobra.Command{ + Use: "deploy [app]", + Short: "Deploys server code to Heroku", + Long: "Deploys server code to Heroku.", + Run: parsecli.RunWithClient(e, d.deploy), + } + cmd.Flags().BoolVarP(&d.Force, "force", "f", d.Force, + "Forcefully deploy current contents even if they differ hugely from existing deploy.") + cmd.Flags().StringVarP(&d.Description, "description", "d", d.Description, + "Description is used as git commit message, if repo is dirty, before pushing to Heroku remote.") + return cmd +} diff --git a/herokucmd/download.go b/herokucmd/download.go new file mode 100644 index 0000000..bc97e83 --- /dev/null +++ b/herokucmd/download.go @@ -0,0 +1,41 @@ +package herokucmd + +import ( + "fmt" + + "github.com/ParsePlatform/parse-cli/parsecli" + "github.com/facebookgo/stackerr" + "github.com/spf13/cobra" +) + +type downloadCmd struct{} + +func (h *downloadCmd) run(e *parsecli.Env, ctx *parsecli.Context) error { + appConfig, ok := ctx.AppConfig.(*parsecli.HerokuAppConfig) + if !ok { + return stackerr.New("expected heroku project config type") + } + + authToken, err := appConfig.GetApplicationAuth(e) + if err != nil { + return err + } + + herokuAppName, err := parsecli.FetchHerokuAppName(appConfig.HerokuAppID, e) + if err != nil { + return err + } + gitURL := fmt.Sprintf("https://:%s@git.heroku.com/%s.git", authToken, herokuAppName) + return (&gitInfo{}).pull(e, gitURL) +} + +func NewDownloadCmd(e *parsecli.Env) *cobra.Command { + d := &downloadCmd{} + cmd := &cobra.Command{ + Use: "download", + Short: "Downloads the latest deployed server code from Heroku servers", + Long: "Downloads the latest deployed server code from Heroku servers.", + Run: parsecli.RunWithClient(e, d.run), + } + return cmd +} diff --git a/herokucmd/git.go b/herokucmd/git.go new file mode 100644 index 0000000..4141823 --- /dev/null +++ b/herokucmd/git.go @@ -0,0 +1,175 @@ +package herokucmd + +import ( + "bytes" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/ParsePlatform/parse-cli/parsecli" + "github.com/facebookgo/stackerr" +) + +type gitInfo struct { + repo string + authToken string + remote string + description string +} + +func (g *gitInfo) whichGit() error { + _, err := exec.LookPath("git") + if err != nil { + return stackerr.New(`Unable to locate "git". +Please install "git" and ensure that you are able to run "git help" from the command prompt.`, + ) + } + return nil +} + +func (g *gitInfo) isGitRepo(e *parsecli.Env) error { + _, err := os.Lstat(filepath.Join(e.Root, ".git")) + if os.IsNotExist(err) { + return stackerr.Newf("%s is not a git repository. Please run 'git init'", e.Root) + } + return nil +} + +func (g *gitInfo) checkAbort(e *parsecli.Env) error { + err := g.whichGit() + if err != nil { + return err + } + return g.isGitRepo(e) +} + +func (g *gitInfo) runCmd(cmd *exec.Cmd, msg string) error { + err := cmd.Run() + if err == nil { + return nil + } + if _, ok := err.(*exec.ExitError); ok { + if msg != "" { + return stackerr.New(msg) + } + return stackerr.Newf("error executing: %q", strings.Join(cmd.Args, " ")) + } + return stackerr.Wrap(err) +} + +func (g *gitInfo) getDescription() string { + if g.description != "" { + return g.description + } + return fmt.Sprintf("committed by parse-cli at: %s", time.Now()) +} + +func (g *gitInfo) isDirty(e *parsecli.Env) (bool, error) { + if err := g.checkAbort(e); err != nil { + return false, err + } + + cmd := exec.Command("git", "status", "--porcelain") + var cmdOut, cmdErr bytes.Buffer + cmd.Stdout = &cmdOut + cmd.Stderr = &cmdErr + err := g.runCmd(cmd, fmt.Sprintf("Unable to determine the status of git repo: %s", g.repo)) + if err != nil { + fmt.Fprintln(e.Err, cmdErr.String()) + return false, err + } + + if cmdOut.Len() == 0 { + return false, nil + } + + fmt.Fprintf(e.Out, "Status of git repo: %q:\n%s", g.repo, cmdOut.String()) + return true, nil +} + +func (g *gitInfo) init(e *parsecli.Env) error { + err := g.whichGit() + if err != nil { + return err + } + + cmd := exec.Command("git", "init") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return g.runCmd(cmd, "") +} + +func (g *gitInfo) commit(e *parsecli.Env) error { + if err := g.checkAbort(e); err != nil { + return err + } + + cmd := exec.Command("git", "add", "-A", ".") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + err := g.runCmd(cmd, fmt.Sprintf("error executing: %s", strings.Join(cmd.Args, " "))) + if err != nil { + return err + } + + cmd = exec.Command("git", "commit", "-a", "-m", g.getDescription()) + return g.runCmd(cmd, fmt.Sprintf("error executing: %s", strings.Join(cmd.Args, " "))) +} + +func (g *gitInfo) push(e *parsecli.Env, force bool) error { + if err := g.checkAbort(e); err != nil { + return err + } + command := []string{"push"} + if force { + command = append(command, "-f") + } + command = append(command, + fmt.Sprintf("https://:%s@%s", g.authToken, g.remote), + "master", + ) + cmd := exec.Command("git", command...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return g.runCmd( + cmd, + fmt.Sprintf("Unable to push to git remote: %s", g.remote), + ) +} + +func (g *gitInfo) isEmptyRepository(path string) (bool, error) { + var files bytes.Buffer + cmd := exec.Command("git", "-C", path, "ls-files") + cmd.Stdout = &files + cmd.Stderr = os.Stderr + if err := g.runCmd(cmd, ""); err != nil { + return false, err + } + return files.Len() == 0, nil +} + +func (g *gitInfo) pull(e *parsecli.Env, gitURL string) error { + if err := g.checkAbort(e); err != nil { + return err + } + pullCmd := exec.Command("git", "pull", gitURL) + pullCmd.Stdout = os.Stdout + pullCmd.Stdin = os.Stdin + return g.runCmd(pullCmd, "") +} + +func (g *gitInfo) clone(gitURL, path string) error { + _, err := exec.LookPath("git") + if err != nil { + return stackerr.New(`Unable to locate "git". +Please install "git" and ensure that you are able to run "git help" from the command prompt.`, + ) + } + + cmd := exec.Command("git", "clone", gitURL, path) + cmd.Stderr = os.Stderr + return g.runCmd(cmd, fmt.Sprintf("error executing: %s", strings.Join(cmd.Args, " "))) +} diff --git a/herokucmd/link.go b/herokucmd/link.go new file mode 100644 index 0000000..6b561b8 --- /dev/null +++ b/herokucmd/link.go @@ -0,0 +1,225 @@ +package herokucmd + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "regexp" + "sort" + "sync" + + "github.com/ParsePlatform/parse-cli/parsecli" + "github.com/facebookgo/errgroup" + "github.com/facebookgo/jsonpipe" + "github.com/facebookgo/stackerr" +) + +const maxRequests = 5 + +type herokuLink struct { + parseAppID string +} + +var ( + herokuNameTakenRegex = regexp.MustCompile("Name is already taken") + herokuAccountNotLinkedRegex = regexp.MustCompile("Parse account is not linked to Heroku") +) + +func herokuAppNameTaken(err error) bool { + return herokuNameTakenRegex.MatchString(err.Error()) +} + +func herokuAccountNotLinked(err error) bool { + return herokuAccountNotLinkedRegex.MatchString(err.Error()) +} + +func (h *herokuLink) getHerokuAppName(e *parsecli.Env) string { + fmt.Fprint(e.Out, + `Let's create a new Heroku app in which server code will be run. +You can either provide a name for the Heroku app or +just hit ENTER to use a randomly generated name. +Note that this can be changed later using Heroku API or Dashboard. + +Heroku App Name: `) + var name string + fmt.Fscanf(e.In, "%s\n", &name) + return name +} + +func (h *herokuLink) createNewLink(e *parsecli.Env, herokuAppName string) (string, error) { + var l parsecli.Login + _, err := l.AuthUserWithToken(e, true) + if err != nil { + return "", stackerr.Wrap(err) + } + + req, err := http.NewRequest( + "POST", + "herokuLink", + ioutil.NopCloser( + jsonpipe.Encode( + map[string]string{"herokuAppName": herokuAppName}, + ), + ), + ) + if err != nil { + return "", stackerr.Wrap(err) + } + req.Header = make(http.Header) + req.Header.Set("X-Parse-Application-Id", h.parseAppID) + req.Header.Set("X-Parse-Account-Key", l.Credentials.Token) + + resp, err := e.ParseAPIClient.RoundTrip(req) + if err != nil { + if herokuAppNameTaken(err) { + fmt.Fprintln(e.Err, ` +Please provide a unique name that might not already be taken on Heroku. +`) + } + return "", stackerr.Wrap(err) + } + result := &struct { + Name string `json:"name"` + ID string `json:"id"` + }{} + if err := json.NewDecoder(resp.Body).Decode(result); err != nil { + return "", stackerr.Wrap(err) + } + if result.Name != herokuAppName || result.ID == "" { + return "", stackerr.New("could not create heroku app link") + } + return result.ID, nil +} + +type nameID struct { + name string + id string +} + +type nameIDs []nameID + +func (n nameIDs) Len() int { + return len(n) +} + +func (n nameIDs) Swap(i, j int) { + n[i], n[j] = n[j], n[i] +} + +func (n nameIDs) Less(i, j int) bool { + if n[i].name > n[j].name { + return false + } + return n[i].name < n[j].name || n[i].id < n[j].id +} + +func (h *herokuLink) herokuAppNames(ids []string, e *parsecli.Env) (nameIDs, []string, error) { + var wg errgroup.Group + wg.Add(len(ids)) + maxParallel := make(chan struct{}, maxRequests) + + var ( + ret nameIDs + deletedLinks []string + retMutex sync.Mutex + deletedLinksMutex sync.Mutex + ) + + getAppName := func(id string) { + defer func() { + wg.Done() + <-maxParallel + }() + appName, err := parsecli.FetchHerokuAppName(id, e) + if err != nil { + if stackerr.HasUnderlying(err, stackerr.MatcherFunc(parsecli.HerokuAppNotFound)) { + deletedLinksMutex.Lock() + defer deletedLinksMutex.Unlock() + deletedLinks = append(deletedLinks, id) + return + } + wg.Error(err) // ignore error if corresponding heroku app was deleted + return + } + + retMutex.Lock() + defer retMutex.Unlock() + ret = append(ret, nameID{id: id, name: appName}) + } + + for _, id := range ids { + go getAppName(id) + } + err := wg.Wait() + sort.Sort(ret) + return ret, deletedLinks, stackerr.Wrap(err) +} + +func (h *herokuLink) getAppLinks(e *parsecli.Env) (nameIDs, error) { + var l parsecli.Login + _, err := l.AuthUserWithToken(e, true) + if err != nil { + return nil, err + } + + req, err := http.NewRequest( + "GET", + "herokuLink", + nil, + ) + if err != nil { + return nil, stackerr.Wrap(err) + } + req.Header = make(http.Header) + req.Header.Set("X-Parse-Application-Id", h.parseAppID) + req.Header.Set("X-Parse-Account-Key", l.Credentials.Token) + + resp, err := e.ParseAPIClient.RoundTrip(req) + if err != nil { + return nil, stackerr.Wrap(err) + } + type herokuAppID struct { + ID string `json:"herokuAppId,omitempty"` + } + result := &struct { + Apps []*herokuAppID `json:"herokuApps"` + }{} + if err := json.NewDecoder(resp.Body).Decode(result); err != nil { + return nil, stackerr.Wrap(err) + } + var ret []string + for _, herokuApp := range result.Apps { + if herokuApp.ID != "" { + ret = append(ret, herokuApp.ID) + } + } + names, deletedIDs, err := h.herokuAppNames(ret, e) + h.removeStaleLinks(e, l.Credentials.Token, deletedIDs) + return names, err +} + +// removeStaleLinks tries to best effort delete stale links +// where corresponding heroku app was deleted +func (h *herokuLink) removeStaleLinks(e *parsecli.Env, token string, removeLinks []string) { + for _, removeLink := range removeLinks { + req, err := http.NewRequest( + "DELETE", + "herokuLink", + jsonpipe.Encode( + map[string]string{"herokuAppId": removeLink}, + ), + ) + if err != nil { + continue + } + req.Header = make(http.Header) + req.Header.Set("X-Parse-Application-Id", h.parseAppID) + req.Header.Set("X-Parse-Account-Key", token) + + _, err = e.ParseAPIClient.RoundTrip(req) + if err != nil { + continue + } + } +} diff --git a/herokucmd/logs.go b/herokucmd/logs.go new file mode 100644 index 0000000..efb71b3 --- /dev/null +++ b/herokucmd/logs.go @@ -0,0 +1,59 @@ +package herokucmd + +import ( + "io" + "net/http" + + "github.com/ParsePlatform/parse-cli/parsecli" + "github.com/bgentry/heroku-go" + "github.com/facebookgo/stackerr" + "github.com/spf13/cobra" +) + +type logsCmd struct { + tail bool + num int +} + +func (h *logsCmd) run(e *parsecli.Env, ctx *parsecli.Context) error { + hkConfig, ok := ctx.AppConfig.(*parsecli.HerokuAppConfig) + if !ok { + return stackerr.New("Unexpected config format") + } + + opts := &heroku.LogSessionCreateOpts{} + if h.num == 0 { + h.num = 50 + } + //source := "app" + //opts.Source = &source + opts.Lines = &h.num + opts.Tail = &h.tail + + session, err := e.HerokuAPIClient.LogSessionCreate( + hkConfig.HerokuAppID, + opts, + ) + if err != nil { + return stackerr.Wrap(err) + } + resp, err := http.Get(session.LogplexURL) + if err != nil { + return stackerr.Wrap(err) + } + _, err = io.Copy(e.Out, resp.Body) + return stackerr.Wrap(err) +} + +func NewLogsCmd(e *parsecli.Env) *cobra.Command { + h := &logsCmd{num: 50} + cmd := &cobra.Command{ + Use: "logs", + Short: "Fetch logs from heroku", + Long: "Fetch logs from heroku.", + Run: parsecli.RunWithClient(e, h.run), + } + cmd.Flags().BoolVarP(&h.tail, "follow", "f", h.tail, "Tail logs from server.") + cmd.Flags().IntVarP(&h.num, "num", "n", h.num, "Number of log lines to fetch.") + return cmd +} diff --git a/herokucmd/new.go b/herokucmd/new.go new file mode 100644 index 0000000..6c23860 --- /dev/null +++ b/herokucmd/new.go @@ -0,0 +1,168 @@ +package herokucmd + +import ( + "fmt" + "io" + "io/ioutil" + "net/http" + "net/url" + "os" + "path/filepath" + + "github.com/ParsePlatform/parse-cli/parsecli" + "github.com/facebookgo/stackerr" +) + +const ( + nodeSampleURL = "https://github.com/pavanka/parse-webhooks/releases/download/3.0.0/node-sample.zip" + nodeSampleBase = "node-sample" +) + +func recordDecision(e *parsecli.Env, decision string) { + v := make(url.Values) + v.Set("version", parsecli.Version) + v.Set("decision", decision) + req := &http.Request{ + Method: "GET", + URL: &url.URL{Path: "supported", RawQuery: v.Encode()}, + } + e.ParseAPIClient.Do(req, nil, nil) +} + +func PromptCreateWebhooks(e *parsecli.Env) (string, error) { + selections := map[int]string{ + 1: "Heroku (https://www.heroku.com)", + 2: "Parse (https://parse.com/docs/cloudcode/guide)", + } + + msg := "Sorry! CLI supports only Parse Cloud Code and Heroku." + + projectType := 0 + for i := 0; i < 3; i++ { + fmt.Fprintf( + e.Out, + `Which of these providers would you like use for running your server code: +%d) %s +%d) %s +Type 1 or 2 to make a selection: `, + 1, selections[1], + 2, selections[2], + ) + fmt.Fscanf(e.In, "%d\n", &projectType) + fmt.Fprintln(e.Out) + switch projectType { + case 1: + recordDecision(e, "heroku") + return "heroku", nil + case 2: + recordDecision(e, "parse") + return "parse", nil + } + fmt.Fprintln(e.Err, msg) + } + return "", stackerr.New(msg) +} + +func writeHerokuConfigs(e *parsecli.Env) error { + err := parsecli.CreateConfigWithContent( + filepath.Join(e.Root, parsecli.ParseProject), + fmt.Sprintf( + `{"project_type": %d}`, + parsecli.HerokuFormat, + ), + ) + if err != nil { + return err + } + return parsecli.CreateConfigWithContent( + filepath.Join(e.Root, parsecli.ParseLocal), + "{}", + ) +} + +func downloadNodeSample(e *parsecli.Env) (string, error) { + tmpZip, err := ioutil.TempFile("", "parse-node-sample") + if err != nil { + return "", stackerr.Wrap(err) + } + + resp, err := http.Get(nodeSampleURL) + if err != nil { + return "", stackerr.Wrap(err) + } + + _, err = io.Copy(tmpZip, resp.Body) + if err != nil { + return "", stackerr.Wrap(err) + } + return tmpZip.Name(), nil +} + +func setupNodeSample(e *parsecli.Env, dumpTemplate bool) error { + if !dumpTemplate { + err := os.MkdirAll(e.Root, 0755) + if err != nil { + return stackerr.Wrap(err) + } + return writeHerokuConfigs(e) + } + + sampleCode, err := downloadNodeSample(e) + if err != nil { + return err + } + defer os.RemoveAll(sampleCode) + + err = unzip(sampleCode, nodeSampleBase, e.Root) + if err != nil { + return err + } + return writeHerokuConfigs(e) +} + +func CloneNodeCode(e *parsecli.Env, isNew, onlyConfig bool, appConfig parsecli.AppConfig) (bool, error) { + cloneTemplate := false + if !isNew && !onlyConfig { + authToken, err := appConfig.GetApplicationAuth(e) + if err != nil { + return false, err + } + herokuAppConfig, ok := appConfig.(*parsecli.HerokuAppConfig) + if !ok { + return false, stackerr.New("invalid heroku app config") + } + + var gitURL string + g := &gitInfo{} + + herokuAppName, err := parsecli.FetchHerokuAppName(herokuAppConfig.HerokuAppID, e) + if err != nil { + return false, err + } + gitURL = fmt.Sprintf("https://:%s@git.heroku.com/%s.git", authToken, herokuAppName) + err = g.clone(gitURL, e.Root) + if err != nil { + fmt.Fprintf(e.Err, `Failed to fetch the latest deployed code from Heroku. +Please try "git clone %s %s". +Currently cloning the template project. +`, + gitURL, + e.Root, + ) + cloneTemplate = true + } else { + isEmpty, err := g.isEmptyRepository(e.Root) + if err != nil { + return false, err + } + if isEmpty { + if err := os.RemoveAll(e.Root); err != nil { + return false, stackerr.Wrap(err) + } + cloneTemplate = true + } + } + } + cloneTemplate = (isNew || cloneTemplate) && !onlyConfig + return cloneTemplate, setupNodeSample(e, cloneTemplate) +} diff --git a/herokucmd/releases.go b/herokucmd/releases.go new file mode 100644 index 0000000..95ed549 --- /dev/null +++ b/herokucmd/releases.go @@ -0,0 +1,55 @@ +package herokucmd + +import ( + "fmt" + "text/tabwriter" + + "github.com/ParsePlatform/parse-cli/parsecli" + "github.com/bgentry/heroku-go" + "github.com/facebookgo/stackerr" + "github.com/spf13/cobra" +) + +type releasesCmd struct{} + +func (r *releasesCmd) printHerokuReleases(releases []heroku.Release, e *parsecli.Env) { + w := new(tabwriter.Writer) + w.Init(e.Out, 20, 4, 2, ' ', 0) + fmt.Fprintln(w, "Version\tUpdate At\tDescription") + for _, release := range releases { + fmt.Fprintf( + w, + "%d\t%s\tDeployed by: %q. %s\n\n", + release.Version, + release.UpdatedAt, + release.User.Email, + release.Description, + ) + } + w.Flush() +} + +func (r *releasesCmd) run(e *parsecli.Env, c *parsecli.Context) error { + appConfig, ok := c.AppConfig.(*parsecli.HerokuAppConfig) + if !ok { + return stackerr.New("Invalid app config.") + } + lr := &heroku.ListRange{Field: "version", Max: 10, Descending: true} + releases, err := e.HerokuAPIClient.ReleaseList(appConfig.HerokuAppID, lr) + if err != nil { + return stackerr.Wrap(err) + } + r.printHerokuReleases(releases, e) + return nil +} + +func NewReleasesCmd(e *parsecli.Env) *cobra.Command { + r := &releasesCmd{} + cmd := &cobra.Command{ + Use: "releases [app]", + Short: "Gets the Heroku releases for a Parse app", + Long: "Prints the last 10 releases made on Heroku", + Run: parsecli.RunWithClient(e, r.run), + } + return cmd +} diff --git a/herokucmd/rollback.go b/herokucmd/rollback.go new file mode 100644 index 0000000..bedc964 --- /dev/null +++ b/herokucmd/rollback.go @@ -0,0 +1,62 @@ +package herokucmd + +import ( + "fmt" + + "github.com/ParsePlatform/parse-cli/parsecli" + "github.com/bgentry/heroku-go" + "github.com/facebookgo/stackerr" + "github.com/spf13/cobra" +) + +type rollbackCmd struct { + ReleaseName string +} + +func (r *rollbackCmd) getLastSecondVersion(e *parsecli.Env, name string) (int, error) { + releases, err := e.HerokuAPIClient.ReleaseList(name, &heroku.ListRange{Field: "version", Max: 2, Descending: true}) + if err != nil { + return 0, stackerr.Wrap(err) + } + return releases[1].Version, nil +} + +func (r *rollbackCmd) run(e *parsecli.Env, c *parsecli.Context) error { + appConfig, ok := c.AppConfig.(*parsecli.HerokuAppConfig) + if !ok { + return stackerr.New("Invalid Heroku app config") + } + releaseName := r.ReleaseName + if releaseName == "" { + lastSecond, err := r.getLastSecondVersion(e, appConfig.HerokuAppID) + if err != nil { + return err + } + releaseName = fmt.Sprintf("%d", lastSecond) + } + release, err := e.HerokuAPIClient.ReleaseRollback(appConfig.HerokuAppID, releaseName) + if err != nil { + return stackerr.Wrap(err) + } + fmt.Fprintf( + e.Out, + `%s +Current version: %d +`, + release.Description, + release.Version, + ) + return nil +} + +func NewRollbackCmd(e *parsecli.Env) *cobra.Command { + r := rollbackCmd{} + cmd := &cobra.Command{ + Use: "rollback [app]", + Short: "Rolls back the version for given app at Heroku", + Long: "Rolls back the version for given app at Heroku.", + Run: parsecli.RunWithClient(e, r.run), + } + cmd.Flags().StringVarP(&r.ReleaseName, "release", "r", r.ReleaseName, "The release to rollback to") + return cmd +} diff --git a/herokucmd/unzip.go b/herokucmd/unzip.go new file mode 100644 index 0000000..d9207ea --- /dev/null +++ b/herokucmd/unzip.go @@ -0,0 +1,50 @@ +package herokucmd + +import ( + "archive/zip" + "io" + "os" + "path/filepath" + + "github.com/facebookgo/stackerr" +) + +func unzip(zfile string, base string, target string) error { + r, err := zip.OpenReader(zfile) + if err != nil { + return stackerr.Wrap(err) + } + defer r.Close() + + for _, f := range r.Reader.File { + z, err := f.Open() + if err != nil { + return stackerr.Wrap(err) + } + defer z.Close() + + name, err := filepath.Rel(base, f.Name) + if err != nil { + return stackerr.Wrap(err) + } + path := filepath.Join(target, name) + if f.FileInfo().IsDir() { + err := os.MkdirAll(path, f.Mode()) + if err != nil { + return stackerr.Wrap(err) + } + } else { + w, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, f.Mode()) + if err != nil { + return stackerr.Wrap(err) + } + defer w.Close() + + _, err = io.Copy(w, z) + if err != nil { + return stackerr.Wrap(err) + } + } + } + return nil +} diff --git a/jssdk_cmd_test.go b/jssdk_cmd_test.go deleted file mode 100644 index bfe3d06..0000000 --- a/jssdk_cmd_test.go +++ /dev/null @@ -1,131 +0,0 @@ -package main - -import ( - "io/ioutil" - "net/http" - "os" - "path/filepath" - "regexp" - "strings" - "testing" - - "github.com/facebookgo/ensure" - "github.com/facebookgo/parse" -) - -func newJsSdkHarness(t testing.TB) *Harness { - h := newHarness(t) - ht := transportFunc(func(r *http.Request) (*http.Response, error) { - ensure.DeepEqual(t, r.URL.Path, "/1/jsVersions") - rows := jsSDKVersion{JS: []string{"1.2.8", "1.2.9", "1.2.10", "1.2.11", "0.2.0"}} - return &http.Response{ - StatusCode: http.StatusOK, - Body: ioutil.NopCloser(strings.NewReader(jsonStr(t, rows))), - }, nil - }) - h.env.Client = &Client{client: &parse.Client{Transport: ht}} - return h -} - -func newJsSdkHarnessError(t testing.TB) *Harness { - h := newHarness(t) - ht := transportFunc(func(r *http.Request) (*http.Response, error) { - ensure.DeepEqual(t, r.URL.Path, "/1/jsVersions") - return &http.Response{ - StatusCode: http.StatusExpectationFailed, - Body: ioutil.NopCloser(strings.NewReader(`{"error":"something is wrong"}`)), - }, nil - }) - h.env.Client = &Client{client: &parse.Client{Transport: ht}} - return h -} - -func newJsSdkHarnessWithConfig(t testing.TB) (*Harness, *client) { - h := newJsSdkHarness(t) - h.makeEmptyRoot() - ensure.Nil(t, os.Mkdir(filepath.Join(h.env.Root, configDir), 0755)) - path := filepath.Join(h.env.Root, legacyConfigFile) - ensure.Nil(t, ioutil.WriteFile(path, - []byte(`{ - "global": { - "parseVersion" : "1.2.9" - } - }`), - 0600)) - cfg, err := configFromDir(h.env.Root) - ensure.Nil(t, err) - config, ok := (cfg).(*parseConfig) - ensure.True(t, ok) - c := &client{Config: config} - return h, c -} - -func TestGetAllJSVersions(t *testing.T) { - t.Parallel() - h := newJsSdkHarness(t) - defer h.Stop() - j := jsSDKCmd{} - versions, err := j.getAllJSSdks(h.env) - ensure.Nil(t, err) - ensure.DeepEqual(t, versions, []string{"1.2.11", "1.2.10", "1.2.9", "1.2.8", "0.2.0"}) -} - -func TestGetAllJSVersionsError(t *testing.T) { - t.Parallel() - h := newJsSdkHarnessError(t) - defer h.Stop() - j := jsSDKCmd{} - _, err := j.getAllJSSdks(h.env) - ensure.Err(t, err, regexp.MustCompile(`something is wrong`)) -} - -func TestPrintVersions(t *testing.T) { - t.Parallel() - h, c := newJsSdkHarnessWithConfig(t) - defer h.Stop() - j := jsSDKCmd{} - ensure.Nil(t, j.printVersions(h.env, c)) - ensure.DeepEqual(t, h.Out.String(), - ` 1.2.11 - 1.2.10 -* 1.2.9 - 1.2.8 - 0.2.0 -`) -} - -func TestSetVersionInvalid(t *testing.T) { - t.Parallel() - h, c := newJsSdkHarnessWithConfig(t) - defer h.Stop() - j := jsSDKCmd{newVersion: "1.2.12"} - ensure.Err(t, j.setVersion(h.env, c), regexp.MustCompile("Invalid SDK version selected")) -} - -func TestSetVersionNoneSelected(t *testing.T) { - t.Parallel() - h := newHarness(t) - defer h.Stop() - - c := &client{Config: defaulParseConfig} - var j jsSDKCmd - - c.Config.getProjectConfig().Parse.JSSDK = "1.2.1" - ensure.Nil(t, j.getVersion(h.env, c)) - ensure.DeepEqual(t, "Current JavaScript SDK version is 1.2.1\n", - h.Out.String()) - - c.Config.getProjectConfig().Parse.JSSDK = "" - ensure.Err(t, j.getVersion(h.env, c), - regexp.MustCompile("JavaScript SDK version not set for this project.")) -} - -func TestSetValidVersion(t *testing.T) { - t.Parallel() - - h, c := newJsSdkHarnessWithConfig(t) - defer h.Stop() - j := jsSDKCmd{newVersion: "1.2.11"} - ensure.Nil(t, j.setVersion(h.env, c)) - ensure.DeepEqual(t, h.Out.String(), "Current JavaScript SDK version is 1.2.11\n") -} diff --git a/legacy_config.go b/legacy_config.go deleted file mode 100644 index c3c390f..0000000 --- a/legacy_config.go +++ /dev/null @@ -1,42 +0,0 @@ -package main - -import ( - "encoding/json" - "io/ioutil" - "os" - "path/filepath" - - "github.com/facebookgo/stackerr" -) - -const configDir = "config" - -var legacyConfigFile = filepath.Join(configDir, "global.json") - -type legacyConfig struct { - Global struct { - ParseVersion string `json:"parseVersion,omitempty"` - } `json:"global,omitempty"` - Applications map[string]*parseAppConfig `json:"applications,omitempty"` -} - -func readLegacyConfigFile(path string) (*legacyConfig, error) { - f, err := os.Open(path) - if err != nil { - return nil, stackerr.Wrap(err) - } - defer f.Close() - var c legacyConfig - if err := json.NewDecoder(f).Decode(&c); err != nil { - return nil, stackerr.Newf("Config file %q is not valid JSON.", path) - } - return &c, nil -} - -func writeLegacyConfigFile(c *legacyConfig, path string) error { - b, err := json.MarshalIndent(c, "", " ") - if err != nil { - return stackerr.Wrap(err) - } - return stackerr.Wrap(ioutil.WriteFile(path, b, 0600)) -} diff --git a/list.go b/list.go new file mode 100644 index 0000000..cd9e803 --- /dev/null +++ b/list.go @@ -0,0 +1,57 @@ +package main + +import ( + "fmt" + "os" + + "github.com/ParsePlatform/parse-cli/parsecli" + "github.com/facebookgo/stackerr" + "github.com/spf13/cobra" +) + +const ( + applications = "applications" +) + +type listCmd struct{} + +func (l *listCmd) printListOfApps(e *parsecli.Env) error { + config, err := parsecli.ConfigFromDir(e.Root) + if err != nil { + if os.IsNotExist(err) { + // print nothing if we are not in a parse app directory + return nil + } + return stackerr.Wrap(err) + } + config.PrettyPrintApps(e) + fmt.Fprintln(e.Out, "") + return nil +} + +func (l *listCmd) run(e *parsecli.Env, args []string) error { + l.printListOfApps(e) + + var apps parsecli.Apps + if err := apps.Login.AuthUser(e, false); err != nil { + return err + } + var appName string + if len(args) > 0 { + appName = args[0] + } + return apps.ShowApps(e, appName) +} + +func NewListCmd(e *parsecli.Env) *cobra.Command { + l := listCmd{} + return &cobra.Command{ + Use: "list [app]", + Short: "Lists properties of given Parse app and Parse apps associated with given project", + Long: `Lists Parse apps and aliases added to the current Cloud Code directory +when executed inside the directory. +Additionally, it prints the list of Parse apps associated with current Parse account. +If an optional app name is provided, it prints all the keys for that app.`, + Run: parsecli.RunWithArgs(e, l.run), + } +} diff --git a/list_cmd.go b/list_cmd.go deleted file mode 100644 index fc85c57..0000000 --- a/list_cmd.go +++ /dev/null @@ -1,49 +0,0 @@ -package main - -import ( - "os" - - "github.com/facebookgo/stackerr" - "github.com/spf13/cobra" -) - -const ( - applications = "applications" -) - -type listCmd struct{} - -func (l *listCmd) printListOfApps(e *env) error { - config, err := configFromDir(e.Root) - if err != nil { - if os.IsNotExist(err) { - // print nothing if we are not in a parse app directory - return nil - } - return stackerr.Wrap(err) - } - config.pprintApps(e) - return nil -} - -func (l *listCmd) run(e *env) error { - l.printListOfApps(e) - - var apps apps - if err := apps.login.authUser(e); err != nil { - return err - } - return apps.showApps(e) -} - -func newListCmd(e *env) *cobra.Command { - l := listCmd{} - return &cobra.Command{ - Use: "list", - Short: "Lists Parse apps associated with current Parse account", - Long: `Lists Parse apps and aliases added to the current Cloud Code directory -when executed inside the directory. -Additionally, it prints the list of Parse apps associated with current Parse account.`, - Run: runNoArgs(e, l.run), - } -} diff --git a/list_cmd_test.go b/list_cmd_test.go deleted file mode 100644 index b43bfe4..0000000 --- a/list_cmd_test.go +++ /dev/null @@ -1,72 +0,0 @@ -package main - -import ( - "testing" - - "github.com/facebookgo/ensure" -) - -func newListCmdHarness(t testing.TB) (*Harness, *listCmd) { - h := newHarness(t) - l := &listCmd{} - return h, l -} - -func TestPrintListOneAppNoDefaultKey(t *testing.T) { - t.Parallel() - conf := &parseConfig{Applications: map[string]*parseAppConfig{"first": {}}} - h := newHarness(t) - defer h.Stop() - conf.pprintApps(h.env) - ensure.DeepEqual(t, h.Out.String(), "The following apps are associated with cloud code in the current directory:\n first\n") -} - -func TestPrintListOneAppWithDefaultKey(t *testing.T) { - t.Parallel() - conf := &parseConfig{Applications: map[string]*parseAppConfig{"first": {}}} - conf.Applications[defaultKey] = &parseAppConfig{Link: "first"} - - h := newHarness(t) - defer h.Stop() - conf.pprintApps(h.env) - ensure.DeepEqual(t, h.Out.String(), "The following apps are associated with cloud code in the current directory:\n* first\n") -} - -func TestPrintListTwoAppsWithDefaultKey(t *testing.T) { - t.Parallel() - conf := &parseConfig{Applications: map[string]*parseAppConfig{"first": {}, "second": {}}} - conf.Applications[defaultKey] = &parseAppConfig{Link: "first"} - - h := newHarness(t) - defer h.Stop() - conf.pprintApps(h.env) - ensure.DeepEqual(t, h.Out.String(), "The following apps are associated with cloud code in the current directory:\n* first\n second\n") -} - -func TestPrintListTwoAppsWithLinks(t *testing.T) { - t.Parallel() - conf := &parseConfig{Applications: map[string]*parseAppConfig{"first": {}, "second": {Link: "first"}}} - conf.Applications[defaultKey] = &parseAppConfig{Link: "first"} - h := newHarness(t) - defer h.Stop() - conf.pprintApps(h.env) - ensure.DeepEqual(t, h.Out.String(), "The following apps are associated with cloud code in the current directory:\n* first\n second -> first\n") -} -func TestPrintListNoConfig(t *testing.T) { - t.Parallel() - h, l := newListCmdHarness(t) - h.makeEmptyRoot() - defer h.Stop() - ensure.NotNil(t, l.printListOfApps(h.env)) - ensure.DeepEqual(t, h.Out.String(), "") -} - -func TestPrintListNoApps(t *testing.T) { - t.Parallel() - h, l := newListCmdHarness(t) - h.makeEmptyRoot() - defer h.Stop() - n := &newCmd{} - n.cloneSampleCloudCode(h.env, &app{Name: "test"}, false) - ensure.Nil(t, l.printListOfApps(h.env)) -} diff --git a/list_test.go b/list_test.go new file mode 100644 index 0000000..0b302e2 --- /dev/null +++ b/list_test.go @@ -0,0 +1,86 @@ +package main + +import ( + "io/ioutil" + "strings" + "testing" + + "github.com/ParsePlatform/parse-cli/parsecli" + "github.com/facebookgo/ensure" +) + +func newListCmdHarness(t testing.TB) (*parsecli.Harness, *listCmd) { + h := parsecli.NewHarness(t) + l := &listCmd{} + return h, l +} + +func TestPrintListOneAppNoDefaultKey(t *testing.T) { + t.Parallel() + conf := &parsecli.ParseConfig{Applications: map[string]*parsecli.ParseAppConfig{"first": {}}} + h := parsecli.NewHarness(t) + defer h.Stop() + conf.PrettyPrintApps(h.Env) + ensure.DeepEqual(t, h.Out.String(), "The following apps are associated with Cloud Code in the current directory:\n first\n") +} + +func TestPrintListOneAppWithDefaultKey(t *testing.T) { + t.Parallel() + conf := &parsecli.ParseConfig{Applications: map[string]*parsecli.ParseAppConfig{"first": {}}} + conf.Applications[parsecli.DefaultKey] = &parsecli.ParseAppConfig{Link: "first"} + + h := parsecli.NewHarness(t) + defer h.Stop() + conf.PrettyPrintApps(h.Env) + ensure.DeepEqual(t, h.Out.String(), "The following apps are associated with Cloud Code in the current directory:\n* first\n") +} + +func TestPrintListTwoAppsWithDefaultKey(t *testing.T) { + t.Parallel() + conf := &parsecli.ParseConfig{Applications: map[string]*parsecli.ParseAppConfig{"first": {}, "second": {}}} + conf.Applications[parsecli.DefaultKey] = &parsecli.ParseAppConfig{Link: "first"} + + h := parsecli.NewHarness(t) + defer h.Stop() + conf.PrettyPrintApps(h.Env) + ensure.DeepEqual(t, h.Out.String(), "The following apps are associated with Cloud Code in the current directory:\n* first\n second\n") +} + +func TestPrintListTwoAppsWithLinks(t *testing.T) { + t.Parallel() + conf := &parsecli.ParseConfig{Applications: map[string]*parsecli.ParseAppConfig{"first": {}, "second": {Link: "first"}}} + conf.Applications[parsecli.DefaultKey] = &parsecli.ParseAppConfig{Link: "first"} + h := parsecli.NewHarness(t) + defer h.Stop() + conf.PrettyPrintApps(h.Env) + ensure.DeepEqual(t, h.Out.String(), "The following apps are associated with Cloud Code in the current directory:\n* first\n second -> first\n") +} +func TestPrintListNoConfig(t *testing.T) { + t.Parallel() + h, l := newListCmdHarness(t) + h.MakeEmptyRoot() + defer h.Stop() + ensure.NotNil(t, l.printListOfApps(h.Env)) + ensure.DeepEqual(t, h.Out.String(), "") +} + +func TestPrintListNoApps(t *testing.T) { + t.Parallel() + h, l := newListCmdHarness(t) + h.MakeEmptyRoot() + defer h.Stop() + parsecli.CloneSampleCloudCode(h.Env, true) + ensure.Nil(t, l.printListOfApps(h.Env)) +} + +func TestRunListCmd(t *testing.T) { + t.Parallel() + + h, _ := parsecli.NewAppHarness(t) + defer h.Stop() + + l := &listCmd{} + h.Env.In = ioutil.NopCloser(strings.NewReader("email\npassword\n")) + ensure.Nil(t, l.run(h.Env, []string{"A"})) + ensure.StringContains(t, h.Out.String(), `Properties of the app "A"`) +} diff --git a/login_cmd.go b/login_cmd.go deleted file mode 100644 index 97f52da..0000000 --- a/login_cmd.go +++ /dev/null @@ -1,282 +0,0 @@ -package main - -import ( - "errors" - "fmt" - "io" - "os" - "path/filepath" - - "github.com/bgentry/go-netrc/netrc" - "github.com/bgentry/speakeasy" - "github.com/facebookgo/stackerr" - "github.com/mitchellh/go-homedir" - "github.com/skratchdot/open-golang/open" - "github.com/spf13/cobra" -) - -type credentials struct { - email string - password string - token string -} - -type loginCmd struct { - credentials credentials - tokenReader io.Reader -} - -var ( - errAuth = errors.New(`Sorry, we do not have a user with this username and password. -If you do not remember your password, please follow instructions at: - https://www.parse.com/login -to reset your password`) - - parseNetrc = filepath.Join(".parse", "netrc") -) - -func (l *loginCmd) populateCreds(e *env) error { - if l.credentials.email != "" && l.credentials.password != "" { - return nil - } - - fmt.Fprint(e.Out, "Email: ") - fmt.Fscanf(e.In, "%s\n", &l.credentials.email) - - var ( - password string - err error - ) - if e.In == os.Stdin { - password, err = speakeasy.Ask("Password (will be hidden): ") - if err != nil { - return err - } - } else { - // NOTE: only for testing - fmt.Fscanf(e.In, "%s\n", &password) - } - - if password != "" { - l.credentials.password = password - } - return nil -} - -func (l *loginCmd) getTokensReader() (io.Reader, error) { - if l.tokenReader != nil { - return l.tokenReader, nil - } - homeDir, err := homedir.Dir() - if err != nil { - return nil, stackerr.Wrap(err) - } - location := filepath.Join(homeDir, parseNetrc) - file, err := os.OpenFile(location, os.O_RDONLY, 0600) - if err != nil { - return nil, stackerr.Wrap(err) - } - l.tokenReader = file - return file, nil -} - -func (l *loginCmd) getTokenCredentials(e *env) (*credentials, error) { - reader, err := l.getTokensReader() - if err != nil { - return nil, stackerr.Wrap(err) - } - tokens, err := netrc.Parse(reader) - if err != nil { - return nil, stackerr.Wrap(err) - } - server, err := getHostFromURL(e.Server) - if err != nil { - return nil, err - } - machine := tokens.FindMachine(server) - if machine == nil { - return nil, stackerr.Newf("could not find token for %s", server) - } - return &credentials{ - email: machine.Login, - token: machine.Password, - }, nil -} - -func (l *loginCmd) updatedNetrcContent( - e *env, - content io.Reader, - credentials *credentials, -) ([]byte, error) { - tokens, err := netrc.Parse(content) - if err != nil { - return nil, stackerr.Wrap(err) - } - - server, err := getHostFromURL(e.Server) - if err != nil { - return nil, err - } - machine := tokens.FindMachine(server) - if machine == nil { - machine = tokens.NewMachine(server, credentials.email, credentials.token, "") - } else { - machine.UpdateLogin(credentials.email) - machine.UpdatePassword(credentials.token) - } - - updatedContent, err := tokens.MarshalText() - if err != nil { - return nil, stackerr.Wrap(err) - } - return updatedContent, nil -} - -func (l *loginCmd) storeCredentials(e *env, credentials *credentials) error { - if l.tokenReader != nil { - // tests should not store credentials - return nil - } - - homeDir, err := homedir.Dir() - if err != nil { - return stackerr.Wrap(err) - } - location := filepath.Join(homeDir, parseNetrc) - if err := os.MkdirAll(filepath.Dir(location), 0755); err != nil { - return stackerr.Wrap(err) - } - file, err := os.OpenFile(location, os.O_RDONLY|os.O_CREATE, 0600) - if err != nil { - return stackerr.Wrap(err) - } - - content, err := l.updatedNetrcContent(e, file, credentials) - - file, err = os.OpenFile(location, os.O_WRONLY|os.O_TRUNC, 0600) - _, err = file.Write(content) - return stackerr.Wrap(err) -} - -func (l *loginCmd) authUserWithToken(e *env) error { - tokenCredentials, err := l.getTokenCredentials(e) - - if err != nil { - return err - } - - apps := &apps{login: loginCmd{credentials: *tokenCredentials}} - _, err = apps.restFetchApps(e) - if err == errAuth { - fmt.Fprintf(e.Err, - `Sorry, you have an invalid token associated with the email: %q. -To avoid typing the email and password everytime, -please type "parse login" and provide a valid token for the email. -`, - tokenCredentials.email, - ) - } - if err != nil { - return stackerr.Wrap(err) - } - - l.credentials = *tokenCredentials - return nil -} - -func (l *loginCmd) authUser(e *env) error { - if l.authUserWithToken(e) == nil { - return nil - } - - apps := &apps{} - - fmt.Fprintln(e.Out, "Please log in to Parse using your email and password.") - for i := 0; i < numRetries; i++ { - err := l.populateCreds(e) - if err != nil { - return err - } - apps.login.credentials = l.credentials - _, err = apps.restFetchApps(e) - if err == nil { - return nil - } - - if i == numRetries-1 && err != nil { - return err - } - if err != errAuth { - fmt.Fprintf(e.Err, "Got error: %s", errorString(e, err)) - } - fmt.Fprintf(e.Err, "%s\nPlease try again...\n", err) - l.credentials.password = "" - } - return errAuth -} - -func (l *loginCmd) helpCreateToken(e *env) { - var shouldOpen string - fmt.Fscanf(e.In, "%s\n", &shouldOpen) - if shouldOpen == "n" { - return - } - err := open.Run(keysURL) - if err != nil { - fmt.Fprintf(e.Err, - `Sorry, we could not open %q in the browser. -Please open %q in the browser to create a new account key. -`, - keysURL, - keysURL, - ) - } -} - -const keysURL = "https://www.parse.com/account/keys" - -func (l *loginCmd) run(e *env) error { - fmt.Fprintf(e.Out, - `Please enter the email id you used to register with Parse -and an account key if you already generated it. -If you do not have an account key or would like to generate a new one, -please type: "y" to open the browser or "n" to continue: `, - ) - l.helpCreateToken(e) - - var credentials credentials - fmt.Fprintf(e.Out, "Email: ") - fmt.Fscanf(e.In, "%s\n", &credentials.email) - fmt.Fprintf(e.Out, "Account Key: ") - fmt.Fscanf(e.In, "%s\n", &credentials.token) - - _, err := (&apps{login: loginCmd{credentials: credentials}}).restFetchApps(e) - if err != nil { - if err == errAuth { - fmt.Fprintf(e.Err, `Sorry, we do not have a user with this email and account key. -Please follow instructions at %s to generate a new account key. -`, - keysURL, - ) - } else { - fmt.Fprintf(e.Err, "Unable to validate token with error:\n%s\n", err) - } - return stackerr.New("Could not store credentials. Please try again.") - } - - err = l.storeCredentials(e, &credentials) - if err == nil { - fmt.Fprintln(e.Out, "Successfully stored credentials.") - } - return stackerr.Wrap(err) -} - -func newLoginCmd(e *env) *cobra.Command { - l := &loginCmd{} - return &cobra.Command{ - Use: "login", - Short: "Login with your Parse credentials", - Long: `Login with your parse user name and an account key.`, - Run: runNoArgs(e, l.run), - } -} diff --git a/login_cmd_test.go b/login_cmd_test.go deleted file mode 100644 index e97b25d..0000000 --- a/login_cmd_test.go +++ /dev/null @@ -1,153 +0,0 @@ -package main - -import ( - "io/ioutil" - "regexp" - "strings" - "testing" - - "github.com/facebookgo/ensure" -) - -func TestPopulateCreds(t *testing.T) { - t.Parallel() - - h := newHarness(t) - defer h.Stop() - - l := &loginCmd{} - h.env.In = strings.NewReader("email\npassword\n") - ensure.Nil(t, l.populateCreds(h.env)) - ensure.DeepEqual(t, l.credentials.email, "email") - ensure.DeepEqual(t, l.credentials.password, "password") -} - -func TestGetTokenCredentials(t *testing.T) { - t.Parallel() - - h := newHarness(t) - defer h.Stop() - - l := &loginCmd{} - h.env.Server = "http://api.example.com/1/" - - l.tokenReader = strings.NewReader( - `machine api.example.com - login email - password token - `, - ) - credentials, err := l.getTokenCredentials(h.env) - ensure.Nil(t, err) - ensure.DeepEqual(t, credentials.email, "email") - ensure.DeepEqual(t, credentials.token, "token") - - h.env.Server = "http://api.parse.com" - credentials, err = l.getTokenCredentials(h.env) - ensure.Err(t, err, regexp.MustCompile("could not find token for")) -} - -func TestAuthUserWithToken(t *testing.T) { - t.Parallel() - - h, _ := newAppHarness(t) - defer h.Stop() - - l := &loginCmd{} - h.env.Server = "http://api.example.org/1/" - - l.tokenReader = strings.NewReader( - `machine api.example.org - login email - password token - `, - ) - ensure.Nil(t, l.authUserWithToken(h.env)) -} - -func TestUpdatedNetrcContent(t *testing.T) { - t.Parallel() - - h := newHarness(t) - defer h.Stop() - - l := &loginCmd{} - - h.env.Server = "https://api.example.com/1/" - updated, err := l.updatedNetrcContent(h.env, - strings.NewReader( - `machine api.example.com - login email - password token0 - -machine api.example.org - login email - password token -`, - ), - &credentials{email: "email", token: "token"}, - ) - - ensure.Nil(t, err) - ensure.DeepEqual(t, - string(updated), - `machine api.example.com - login email - password token - -machine api.example.org - login email - password token -`, - ) - - h.env.Server = "https://api.example.org/1/" - updated, err = l.updatedNetrcContent(h.env, - strings.NewReader( - `machine api.example.com - login email - password token -`, - ), - &credentials{email: "email", token: "token"}, - ) - - ensure.Nil(t, err) - ensure.DeepEqual(t, - string(updated), - `machine api.example.com - login email - password token - -machine api.example.org - login email - password token`, - ) -} - -func TestLogin(t *testing.T) { - t.Parallel() - - h, _ := newAppHarness(t) - defer h.Stop() - - l := &loginCmd{tokenReader: strings.NewReader("")} - - h.env.In = ioutil.NopCloser(strings.NewReader("n\nemail\ntoken\n")) - ensure.Nil(t, l.run(h.env)) - ensure.DeepEqual(t, - h.Out.String(), - `Please enter the email id you used to register with Parse -and an account key if you already generated it. -If you do not have an account key or would like to generate a new one, -please type: "y" to open the browser or "n" to continue: Email: Account Key: Successfully stored credentials. -`) - - h.env.In = ioutil.NopCloser(strings.NewReader("n\nemail\ninvalid\n")) - ensure.Err(t, l.run(h.env), regexp.MustCompile("Please try again")) - ensure.DeepEqual(t, - h.Err.String(), - `Sorry, we do not have a user with this email and account key. -Please follow instructions at https://www.parse.com/account/keys to generate a new account key. -`) -} diff --git a/main.go b/main.go index 60f03b6..78e1a3a 100644 --- a/main.go +++ b/main.go @@ -1,418 +1,148 @@ package main import ( - "errors" "fmt" - "io" + "net/http" "net/url" "os" - "os/exec" "os/signal" - "path/filepath" - "runtime" + "strings" "time" + "github.com/ParsePlatform/parse-cli/parsecli" "github.com/facebookgo/clock" - "github.com/facebookgo/errgroup" - "github.com/facebookgo/parse" "github.com/facebookgo/stackerr" "github.com/spf13/cobra" ) -const ( - version = "2.2.0" - cloudDir = "cloud" - hostingDir = "public" - defaultBaseURL = "https://api.parse.com/1/" -) - -var ( - autoUpdate = false - userAgent = fmt.Sprintf("parse-cli-%s-%s", runtime.GOOS, version) -) - -type versionCmd struct{} - -func (c *versionCmd) run(e *env) error { - fmt.Fprintln(e.Out, version) - return nil -} - -func newVersionCmd(e *env) *cobra.Command { - var c versionCmd - cmd := &cobra.Command{ - Use: "version", - Short: "Gets the Command Line Tools version", - Long: `Gets the Command Line Tools version.`, - Run: runNoArgs(e, c.run), - Aliases: []string{"cliversion"}, - } - return cmd -} +func main() { + // some parts of apps.go are unable to handle + // interrupts, this logic ensures we exit on system interrupts + interrupt := make(chan os.Signal, 1) + signal.Notify(interrupt, os.Interrupt) + go func() { + <-interrupt + os.Exit(1) + }() -// errorString returns the error string with our without the stack trace -// depending on the environment variable. this exists because we want plain -// messages for end users, but when we're working on the CLI we want the stack -// trace for debugging. -func errorString(e *env, err error) string { - type hasUnderlying interface { - HasUnderlying() error + e := parsecli.Env{ + Root: os.Getenv("PARSE_ROOT"), + Server: os.Getenv("PARSE_SERVER"), + ErrorStack: os.Getenv("PARSE_ERROR_STACK") == "1", + ParserEmail: os.Getenv("PARSER_EMAIL"), + Out: os.Stdout, + Err: os.Stderr, + In: os.Stdin, + Exit: os.Exit, + Clock: clock.New(), } - - parseErr := func(err error) error { - if apiErr, ok := err.(*parse.Error); ok { - return errors.New(apiErr.Message) + if e.Root == "" { + cur, err := os.Getwd() + if err != nil { + fmt.Fprintf(e.Err, "Failed to get current directory:\n%s\n", err) + os.Exit(1) } - return err - } - - lastErr := func(err error) error { - if serr, ok := err.(*stackerr.Error); ok { - if errs := stackerr.Underlying(serr); len(errs) != 0 { - err = errs[len(errs)-1] - } - } else { - if eu, ok := err.(hasUnderlying); ok { - err = eu.HasUnderlying() + root := parsecli.GetProjectRoot(&e, cur) + if parsecli.IsProjectDir(root) { + e.Root = root + config, err := parsecli.ConfigFromDir(root) + if err != nil { + fmt.Fprintln(e.Err, err) + os.Exit(1) } - } - - return parseErr(err) - } - - if !e.ErrorStack { - if merr, ok := err.(errgroup.MultiError); ok { - var multiError []error - for _, ierr := range []error(merr) { - multiError = append(multiError, lastErr(ierr)) + e.Type = config.GetProjectConfig().Type + if e.ParserEmail == "" { + e.ParserEmail = config.GetProjectConfig().ParserEmail } - err = errgroup.MultiError(multiError) } else { - err = lastErr(err) + e.Type = parsecli.LegacyParseFormat + e.Root = parsecli.GetLegacyProjectRoot(&e, cur) } - return parseErr(err).Error() } - - return err.Error() -} - -type env struct { - Root string // project root - Server string // parse api server - Type int // project type - ErrorStack bool - Out io.Writer - Err io.Writer - In io.Reader - Exit func(int) - Clock clock.Clock - Client *Client -} - -type client struct { - Config config - AppName string - AppConfig appConfig -} - -func newParseClient(e *env) (*Client, error) { - baseURL, err := url.Parse(e.Server) - if err != nil { - return nil, stackerr.Newf("invalid server URL %q: %s", e.Server, err) - } - return &Client{ - client: &parse.Client{ - BaseURL: baseURL, - }, - }, nil -} - -func newClient(e *env, appName string) (*client, error) { - config, err := configFromDir(e.Root) - if err != nil { - return nil, err + if e.Type != parsecli.LegacyParseFormat && e.Type != parsecli.ParseFormat && e.Type != parsecli.HerokuFormat { + fmt.Fprintf(e.Err, "Unknown project type %d.\n", e.Type) + os.Exit(1) } - app, err := config.app(appName) - if err != nil { - return nil, err + if e.Server == "" { + e.Server = parsecli.DefaultBaseURL } - masterKey, err := app.getMasterKey(e) + apiClient, err := parsecli.NewParseAPIClient(&e) if err != nil { - return nil, err - } - e.Client = e.Client.WithCredentials( - parse.MasterKey{ - ApplicationID: app.getApplicationID(), - MasterKey: masterKey, - }) - - return &client{ - AppName: appName, - AppConfig: app, - Config: config, - }, nil -} - -type cobraRun func(cmd *cobra.Command, args []string) - -// runNoArgs wraps a run function that shouldn't get any arguments. -func runNoArgs(e *env, f func(*env) error) cobraRun { - return func(cmd *cobra.Command, args []string) { - if len(args) != 0 { - fmt.Fprintf(e.Err, "unexpected arguments:%+v\n\n", args) - cmd.Help() - e.Exit(1) - } - if err := f(e); err != nil { - fmt.Fprintln(e.Err, errorString(e, err)) - e.Exit(1) - } - } -} - -// runWithArgs wraps a run function that can access arguments to cobraCmd -func runWithArgs(e *env, f func(*env, []string) error) cobraRun { - return func(cmd *cobra.Command, args []string) { - if err := f(e, args); err != nil { - fmt.Fprintln(e.Err, errorString(e, err)) - e.Exit(1) - } + fmt.Fprintln(e.Err, err) + os.Exit(1) } -} - -// runWithClient wraps a run function that should get an app, when the default is -// picked from the config in the current working directory. -func runWithClient(e *env, f func(*env, *client) error) cobraRun { - return func(cmd *cobra.Command, args []string) { - app := defaultKey - if len(args) > 1 { - fmt.Fprintf( - e.Err, - "unexpected arguments, only an optional app name is expected:%+v\n\n", - args, - ) - cmd.Help() - e.Exit(1) - } - if len(args) == 1 { - app = args[0] - } - cl, err := newClient(e, app) + e.ParseAPIClient = apiClient + if e.Type == parsecli.HerokuFormat { + apiClient, err := parsecli.NewHerokuAPIClient(&e) if err != nil { - fmt.Fprintln(e.Err, errorString(e, err)) - e.Exit(1) - } - if err := f(e, cl); err != nil { - fmt.Fprintln(e.Err, errorString(e, err)) - e.Exit(1) + fmt.Fprintln(e.Err, err) + os.Exit(1) } + e.HerokuAPIClient = apiClient } -} -// runWithAppClient wraps a run function that should get an app -func runWithAppClient(e *env, f func(*env, *client) error) cobraRun { - return func(cmd *cobra.Command, args []string) { - if len(args) == 0 { - fmt.Fprintf(e.Err, "please provide an app name\n\n") - cmd.Help() - e.Exit(1) - } - if len(args) > 1 { - fmt.Fprintf( - e.Err, - "unexpected arguments, only an app name is expected:%+v\n\n", - args, - ) - cmd.Help() - e.Exit(1) - } - app := args[0] - cl, err := newClient(e, app) - if err != nil { - fmt.Fprintln(e.Err, errorString(e, err)) - e.Exit(1) - } - if err := f(e, cl); err != nil { - fmt.Fprintln(e.Err, errorString(e, err)) - e.Exit(1) - } + var ( + rootCmd *cobra.Command + command []string + ) + var mode string + switch e.Type { + case parsecli.LegacyParseFormat, parsecli.ParseFormat: + mode = "parse" + command, rootCmd = parseRootCmd(&e) + case parsecli.HerokuFormat: + mode = "heroku" + command, rootCmd = herokuRootCmd(&e) } -} -// runWithArgsClient wraps a run function that should get an app, whee the default is -// picked from the config in the current working directory. It also passes args to the -// runner function -func runWithArgsClient(e *env, f func(*env, *client, []string) error) cobraRun { - return func(cmd *cobra.Command, args []string) { - app := defaultKey - if len(args) > 1 { - app = args[0] - args = args[1:] - } - cl, err := newClient(e, app) + if len(command) == 0 || command[0] != "update" { + message, err := checkIfSupported(&e, parsecli.Version, mode, command...) if err != nil { - fmt.Fprintln(e.Err, errorString(e, err)) - e.Exit(1) + fmt.Fprintln(e.Err, err) + os.Exit(1) } - if err := f(e, cl, args); err != nil { - fmt.Fprintln(e.Err, errorString(e, err)) - e.Exit(1) + if message != "" { + fmt.Fprintln(e.Err, message) } } -} - -func rootCmd(e *env) *cobra.Command { - c := &cobra.Command{ - Use: "parse", - Long: fmt.Sprintf( - `Parse Command Line Interface -Version %v -Copyright %d Parse, Inc. -http://parse.com`, - version, - time.Now().Year(), - ), - Run: func(cmd *cobra.Command, args []string) { - cmd.Help() - }, - } - - c.AddCommand(newAddCmd(e)) - c.AddCommand(newDefaultCmd(e)) - c.AddCommand(newDeployCmd(e)) - c.AddCommand(newDevelopCmd(e)) - c.AddCommand(newFunctionHooksCmd(e)) - c.AddCommand(newGenerateCmd(e)) - c.AddCommand(newJsSdkCmd(e)) - c.AddCommand(newListCmd(e)) - c.AddCommand(newLogsCmd(e)) - c.AddCommand(newNewCmd(e)) - c.AddCommand(newReleasesCmd(e)) - c.AddCommand(newRollbackCmd(e)) - c.AddCommand(newSymbolsCmd(e)) - c.AddCommand(newTriggerHooksCmd(e)) - c.AddCommand(newUpdateCmd(e)) - c.AddCommand(newVersionCmd(e)) - - if len(os.Args) <= 1 { - return c - } - - commands := []string{"help"} - for _, command := range c.Commands() { - commands = append(commands, command.Name()) - } - - args := make([]string, len(os.Args)-1) - copy(args, os.Args[1:]) - if message := makeCorrections(commands, args); message != "" { - fmt.Fprintln(e.Out, message) + if err := rootCmd.Execute(); err != nil { + // Error is already printed in Execute() + os.Exit(1) } - c.SetArgs(args) - - return c } -func getProjectRoot(e *env, cur string) string { - if _, err := os.Stat(filepath.Join(cur, legacyConfigFile)); err == nil { - return cur +func checkIfSupported(e *parsecli.Env, version string, mode string, args ...string) (string, error) { + v := make(url.Values) + v.Set("version", version) + v.Set("mode", mode) + v.Set("other", strings.Join(args, " ")) + req := &http.Request{ + Method: "GET", + URL: &url.URL{Path: "supported", RawQuery: v.Encode()}, } - root := cur - base := filepath.Base(root) - - for base != "." && base != string(filepath.Separator) { - base = filepath.Base(root) - root = filepath.Dir(root) - if base == "cloud" || base == "public" || base == "config" { - if _, err := os.Stat(filepath.Join(root, legacyConfigFile)); err == nil { - return root - } - } + type result struct { + warning string + err error } - return cur -} - -func main() { - // some parts of apps_cmd.go are unable to handle - // interrupts, this logic ensures we exit on system interrupts - interrupt := make(chan os.Signal, 1) - signal.Notify(interrupt, os.Interrupt) + timeout := make(chan *result, 1) go func() { - <-interrupt - os.Exit(1) - }() - - e := env{ - Root: os.Getenv("PARSE_ROOT"), - Server: os.Getenv("PARSE_SERVER"), - ErrorStack: os.Getenv("PARSE_ERROR_STACK") == "1", - Out: os.Stdout, - Err: os.Stderr, - In: os.Stdin, - Exit: os.Exit, - Clock: clock.New(), - } - if e.Root == "" { - cur, err := os.Getwd() - if err != nil { - fmt.Fprintf(e.Err, "Failed to get current directory:\n%s\n", err) - os.Exit(1) - } - e.Root = getProjectRoot(&e, cur) - } - e.Type = legacy - if e.Server == "" { - e.Server = defaultBaseURL - } - client, err := newParseClient(&e) - if err != nil { - fmt.Fprintln(e.Err, err) - os.Exit(1) - } - e.Client = client - - // autoUpdate is false for all non-production builds - // so we never auto update - // for production builds autoUpdate is true but - // we suppress auto-update iff PARSE_NOUPDATE is not set - if autoUpdate && os.Getenv("PARSE_NOUPDATE") == "" { - // Perform a best effort update - updated, err := (&updateCmd{}).updateCLI(&e) - if err != nil { - cmd := exec.Command(os.Args[0], "version") - if err := cmd.Run(); err != nil { - fmt.Fprintf(e.Out, `parse cli corrupted during update. -Please follow instructions at: - https://parse.com/apps/quickstart#cloud_code -to install a new cli.`) - os.Exit(1) - } - } - - if updated { - // Re-run the command with the updated CLI - cmd := exec.Cmd{ - Path: os.Args[0], - Args: os.Args, - Stdin: os.Stdin, - Stdout: os.Stdout, - Stderr: os.Stderr, - } - - if err := cmd.Run(); err != nil { - os.Exit(1) - } - return + var res struct { + Warning string `json:"warning"` } - } + _, err := e.ParseAPIClient.Do(req, nil, &res) + timeout <- &result{warning: res.Warning, err: err} + }() - if err := rootCmd(&e).Execute(); err != nil { - // Error is already printed in Execute() - os.Exit(1) + select { + case res := <-timeout: + return res.warning, stackerr.Wrap(res.err) + case <-time.After(time.Second): + return "", nil } + return "", nil } diff --git a/main_test.go b/main_test.go index 202123c..127470a 100644 --- a/main_test.go +++ b/main_test.go @@ -1,377 +1,73 @@ package main import ( - "bytes" - "encoding/json" - "errors" - "fmt" "io/ioutil" "net/http" - "os" - "path/filepath" "regexp" "testing" - "github.com/facebookgo/clock" + "github.com/ParsePlatform/parse-cli/parsecli" "github.com/facebookgo/ensure" - "github.com/facebookgo/errgroup" + "github.com/facebookgo/jsonpipe" "github.com/facebookgo/parse" - "github.com/facebookgo/stackerr" - "github.com/facebookgo/testname" - "github.com/spf13/cobra" ) -func noOpCmd() *cobra.Command { - var c cobra.Command - c.SetOutput(ioutil.Discard) - return &c -} - -type Harness struct { - T testing.TB - Out bytes.Buffer - Err bytes.Buffer - Clock *clock.Mock - env *env - remove []string -} - -func (h *Harness) makeEmptyRoot() { - var err error - prefix := fmt.Sprintf("%s-", testname.Get("parse-cli-")) - h.env.Root, err = ioutil.TempDir("", prefix) - ensure.Nil(h.T, err) - h.remove = append(h.remove, h.env.Root) -} - -func (h *Harness) makeWithConfig(global string) { - h.env.Root = makeDirWithConfig(h.T, global) -} - -func (h *Harness) Stop() { - for _, p := range h.remove { - os.RemoveAll(p) - } -} - -func newHarness(t testing.TB) *Harness { - te := Harness{ - T: t, - Clock: clock.NewMock(), - } - te.env = &env{ - Out: &te.Out, - Err: &te.Err, - Clock: te.Clock, - Client: &Client{client: &parse.Client{}}, - } - return &te -} - -// makes a temp directory with the given global config. -func makeDirWithConfig(t testing.TB, global string) string { - dir, err := ioutil.TempDir("", testname.Get("parse-cli-")) - ensure.Nil(t, err) - ensure.Nil(t, os.Mkdir(filepath.Join(dir, "config"), 0755)) - ensure.Nil(t, ioutil.WriteFile( - filepath.Join(dir, legacyConfigFile), - []byte(global), - os.FileMode(0600), - )) - return dir -} - -type transportFunc func(r *http.Request) (*http.Response, error) - -func (t transportFunc) RoundTrip(r *http.Request) (*http.Response, error) { - return t(r) -} - -func jsonStr(t testing.TB, v interface{}) string { - b, err := json.Marshal(v) - ensure.Nil(t, err) - return string(b) -} - -func TestVersion(t *testing.T) { - t.Parallel() - h := newHarness(t) - defer h.Stop() - var c versionCmd - err := c.run(h.env) - ensure.Nil(t, err) - ensure.DeepEqual(t, h.Out.String(), version+"\n") - ensure.DeepEqual(t, h.Err.String(), "") -} - -// this just exists to make code coverage less noisy. -func TestRootCommand(t *testing.T) { - t.Parallel() - h := newHarness(t) - defer h.Stop() - rootCmd(h.env) -} - -func TestErrorStringWithoutStack(t *testing.T) { - t.Parallel() - h := newHarness(t) - defer h.Stop() - h.env.ErrorStack = false - const message = "hello world" - actual := errorString(h.env, stackerr.New(message)) - ensure.StringContains(t, actual, message) - ensure.StringDoesNotContain(t, actual, ".go") -} - -type exitCode int - -func TestErrorStringWithStack(t *testing.T) { - t.Parallel() - h := newHarness(t) - defer h.Stop() - h.env.ErrorStack = true - const message = "hello world" - actual := errorString(h.env, stackerr.New(message)) - ensure.StringContains(t, actual, message) - ensure.StringContains(t, actual, ".go") -} - -func TestRunNoArgsWithArg(t *testing.T) { - t.Parallel() - h := newHarness(t) - defer h.Stop() - h.env.Exit = func(i int) { panic(exitCode(i)) } - func() { - defer ensure.PanicDeepEqual(t, exitCode(1)) - r := runNoArgs(h.env, nil) - r(noOpCmd(), []string{"foo"}) - }() - ensure.StringContains(t, h.Err.String(), "unexpected arguments") -} - -func TestRunNoArgsFuncError(t *testing.T) { - t.Parallel() - const message = "hello world" - h := newHarness(t) - defer h.Stop() - h.env.Exit = func(i int) { panic(exitCode(i)) } - func() { - defer ensure.PanicDeepEqual(t, exitCode(1)) - r := runNoArgs(h.env, func(*env) error { return errors.New(message) }) - r(noOpCmd(), nil) - }() - ensure.StringContains(t, h.Err.String(), message) -} - -func TestRunWithAppMultipleArgs(t *testing.T) { - t.Parallel() - h := newHarness(t) - defer h.Stop() - h.env.Exit = func(i int) { panic(exitCode(i)) } - func() { - defer ensure.PanicDeepEqual(t, exitCode(1)) - r := runWithClient(h.env, nil) - r(noOpCmd(), []string{"foo", "bar"}) - }() - ensure.StringContains( - t, - h.Err.String(), - "only an optional app name is expected", - ) -} - -func TestRunWithAppNonProjectDir(t *testing.T) { - t.Parallel() - h := newHarness(t) - defer h.Stop() - h.makeEmptyRoot() - h.env.Exit = func(i int) { panic(exitCode(i)) } - func() { - defer ensure.PanicDeepEqual(t, exitCode(1)) - r := runWithClient(h.env, nil) - r(noOpCmd(), nil) - }() - ensure.StringContains( - t, - h.Err.String(), - "Command must be run in a directory containing a Parse project", - ) -} - -func TestRunWithAppNotFound(t *testing.T) { - t.Parallel() - h := newHarness(t) - defer h.Stop() - c := legacyConfig{Applications: map[string]*parseAppConfig{"a": {}}} - h.makeWithConfig(jsonStr(t, c)) - h.env.Exit = func(i int) { panic(exitCode(i)) } - func() { - defer ensure.PanicDeepEqual(t, exitCode(1)) - r := runWithClient(h.env, nil) - r(noOpCmd(), []string{"b"}) - }() - ensure.StringContains(t, h.Err.String(), `App "b" wasn't found`) -} - -func TestRunWithAppNamed(t *testing.T) { - t.Parallel() - h := newHarness(t) - defer h.Stop() - const appName = "a" - c := &parseConfig{ - Applications: map[string]*parseAppConfig{ - appName: {ApplicationID: "id", MasterKey: "token"}, - }, - } - h.makeWithConfig(jsonStr(t, c)) - r := runWithClient(h.env, func(e *env, c *client) error { - ensure.NotNil(t, c) - return nil - }) - r(noOpCmd(), []string{"a"}) -} - -func TestRunWithDefault(t *testing.T) { - t.Parallel() - h := newHarness(t) - defer h.Stop() - const appName = "a" - c := &parseConfig{ - Applications: map[string]*parseAppConfig{ - appName: {ApplicationID: "id", MasterKey: "token"}, - defaultKey: {Link: appName}, - }, - } - h.makeWithConfig(jsonStr(t, c)) - r := runWithClient(h.env, func(e *env, c *client) error { - ensure.NotNil(t, c) - return nil - }) - r(noOpCmd(), []string{"a"}) -} - -func TestRunWithAppError(t *testing.T) { - t.Parallel() - h := newHarness(t) - defer h.Stop() - const appName = "a" - c := &parseConfig{ - Applications: map[string]*parseAppConfig{ - appName: {ApplicationID: "id", MasterKey: "token"}, - }, - } - h.makeWithConfig(jsonStr(t, c)) - h.env.Exit = func(i int) { panic(exitCode(i)) } - const message = "hello world" - func() { - defer ensure.PanicDeepEqual(t, exitCode(1)) - r := runWithClient(h.env, func(e *env, c *client) error { - ensure.NotNil(t, c) - return errors.New(message) - }) - r(noOpCmd(), []string{"a"}) - }() - ensure.StringContains(t, h.Err.String(), message) -} - func TestNewClientInvalidServerURL(t *testing.T) { t.Parallel() - c, err := newParseClient(&env{Server: ":"}) + c, err := parsecli.NewParseAPIClient(&parsecli.Env{Server: ":"}) ensure.True(t, c == nil) ensure.Err(t, err, regexp.MustCompile("invalid server URL")) } -func TestErrorString(t *testing.T) { +func TestIsSupportedWarning(t *testing.T) { t.Parallel() - h := newHarness(t) - defer h.Stop() - apiErr := &parse.Error{Code: -1, Message: "Error\nMessage"} - ensure.DeepEqual(t, `Error -Message`, - errorString(h.env, apiErr), - ) -} -func TestGetProjectRoot(t *testing.T) { - t.Parallel() - h := newHarness(t) - h.makeEmptyRoot() + h := parsecli.NewHarness(t) defer h.Stop() - ensure.Nil(t, os.Mkdir(filepath.Join(h.env.Root, "parse"), 0755)) - ensure.Nil(t, os.Mkdir(filepath.Join(h.env.Root, "parse", "config"), 0755)) - f, err := os.Create(filepath.Join(h.env.Root, "parse", legacyConfigFile)) + ht := parsecli.TransportFunc(func(r *http.Request) (*http.Response, error) { + ensure.DeepEqual(t, r.URL.Path, "/1/supported") + return &http.Response{ + StatusCode: http.StatusOK, + Body: ioutil.NopCloser( + jsonpipe.Encode( + map[string]string{"warning": "please update"}, + ), + ), + }, nil + }) + h.Env.ParseAPIClient = &parsecli.ParseAPIClient{APIClient: &parse.Client{Transport: ht}} + message, err := checkIfSupported(h.Env, "2.0.2", "parse") ensure.Nil(t, err) - defer f.Close() - ensure.Nil(t, os.Mkdir(filepath.Join(h.env.Root, "parse", "cloud"), 0755)) - ensure.Nil(t, os.Mkdir(filepath.Join(h.env.Root, "parse", "public"), 0755)) - ensure.Nil(t, os.MkdirAll(filepath.Join(h.env.Root, "parse", "cloud", "other", "config"), 0755)) - - ensure.DeepEqual(t, getProjectRoot(h.env, h.env.Root), h.env.Root) - - ensure.DeepEqual(t, getProjectRoot(h.env, filepath.Join(h.env.Root, "parse", "config")), filepath.Join(h.env.Root, "parse")) - - ensure.DeepEqual(t, getProjectRoot(h.env, filepath.Join(h.env.Root, "parse", "cloud")), filepath.Join(h.env.Root, "parse")) - - ensure.DeepEqual(t, getProjectRoot(h.env, filepath.Join(h.env.Root, "parse", "public")), filepath.Join(h.env.Root, "parse")) - - ensure.DeepEqual(t, getProjectRoot(h.env, filepath.Join(h.env.Root, "parse", "cloud", "other")), filepath.Join(h.env.Root, "parse")) + ensure.DeepEqual(t, message, "please update") } -func TestStackErrorString(t *testing.T) { +func TestIsSupportedError(t *testing.T) { t.Parallel() - h := newHarness(t) + h := parsecli.NewHarness(t) defer h.Stop() - err := stackerr.New("error") - - h.env.ErrorStack = false - errStr := errorString(h.env, err) - - ensure.DeepEqual(t, errStr, "error") - - h.env.ErrorStack = true - errStr = errorString(h.env, err) - - ensure.StringContains(t, errStr, "error") - ensure.StringContains(t, errStr, ".go") - - err = stackerr.Wrap(&parse.Error{Message: "message", Code: 1}) - h.env.ErrorStack = false - errStr = errorString(h.env, err) - - ensure.DeepEqual(t, errStr, "message") - - h.env.ErrorStack = true - errStr = errorString(h.env, err) - - ensure.StringContains(t, errStr, `parse: api error with code=1 and message="message`) - ensure.StringContains(t, errStr, ".go") + ht := parsecli.TransportFunc(func(r *http.Request) (*http.Response, error) { + ensure.DeepEqual(t, r.URL.Path, "/1/supported") + return &http.Response{ + StatusCode: http.StatusBadRequest, + Body: ioutil.NopCloser( + jsonpipe.Encode( + map[string]string{"error": "not supported"}, + ), + ), + }, nil + }) + h.Env.ParseAPIClient = &parsecli.ParseAPIClient{APIClient: &parse.Client{Transport: ht}} + _, err := checkIfSupported(h.Env, "2.0.2", "parse") + ensure.Err(t, err, regexp.MustCompile("not supported")) } -func TestMultiErrorString(t *testing.T) { +// this just exists to make code coverage less noisy. +func TestRootCommand(t *testing.T) { t.Parallel() - - h := newHarness(t) + h := parsecli.NewHarness(t) defer h.Stop() - - err := errgroup.MultiError( - []error{ - stackerr.New("error"), - stackerr.Wrap(&parse.Error{Message: "message", Code: 1}), - }, - ) - - h.env.ErrorStack = false - errStr := errorString(h.env, err) - - ensure.DeepEqual(t, errStr, "multiple errors: error | message") - - h.env.ErrorStack = true - errStr = errorString(h.env, err) - - ensure.StringContains(t, errStr, "multiple errors") - ensure.StringContains(t, errStr, `parse: api error with code=1 and message="message"`) - ensure.StringContains(t, errStr, ".go") + parseRootCmd(h.Env) } diff --git a/migrate.go b/migrate.go new file mode 100644 index 0000000..b88f678 --- /dev/null +++ b/migrate.go @@ -0,0 +1,82 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/ParsePlatform/parse-cli/parsecli" + "github.com/facebookgo/stackerr" + "github.com/spf13/cobra" +) + +type migrateCmd struct { + retainMaster bool +} + +func (m *migrateCmd) upgradeLegacy(e *parsecli.Env, c parsecli.Config) (*parsecli.ParseConfig, error) { + p := c.GetProjectConfig() + if p.Type != parsecli.LegacyParseFormat { + return nil, stackerr.New("Already using the preferred config format.") + } + p.Type = parsecli.ParseFormat + config, ok := (c).(*parsecli.ParseConfig) + if !ok { + return nil, stackerr.Newf("Unexpected config format: %d", p.Type) + } + if !m.retainMaster { + for _, app := range config.Applications { + app.MasterKey = "" + } + } + return config, nil +} + +func (m *migrateCmd) run(e *parsecli.Env) error { + c, err := parsecli.ConfigFromDir(e.Root) + if err != nil { + return err + } + + c, err = m.upgradeLegacy(e, c) + if err != nil { + return err + } + localErr := parsecli.StoreConfig(e, c) + projectErr := parsecli.StoreProjectConfig(e, c) + if localErr == nil && projectErr == nil { + legacy := filepath.Join(e.Root, parsecli.LegacyConfigFile) + err := os.Remove(legacy) + if err != nil { + fmt.Fprintf(e.Err, "Could not delete: %q. Please remove this file manually.\n", legacy) + } + } else { + local := filepath.Join(e.Root, parsecli.ParseLocal) + err := os.Remove(local) + if err != nil { + fmt.Fprintf(e.Err, "Failed to clean up: %q. Please remove this file manually.\n", local) + } + project := filepath.Join(e.Root, parsecli.ParseProject) + err = os.Remove(project) + if err != nil { + fmt.Fprintf(e.Err, "Failed to clean up: %q. Please remove this file manually.\n", project) + } + } + fmt.Fprintln(e.Out, "Successfully migrated to the preferred config format.") + return nil +} + +func NewMigrateCmd(e *parsecli.Env) *cobra.Command { + var m migrateCmd + cmd := &cobra.Command{ + Use: "migrate", + Short: "Migrate project config format to preferred format", + Long: `Use this on projects with legacy config format to migrate +to the preferred format. +`, + Run: parsecli.RunNoArgs(e, m.run), + } + cmd.Flags().BoolVarP(&m.retainMaster, "retain", "r", m.retainMaster, + "Retain any master keys present in config during migration") + return cmd +} diff --git a/migrate_test.go b/migrate_test.go new file mode 100644 index 0000000..10f7ead --- /dev/null +++ b/migrate_test.go @@ -0,0 +1,131 @@ +package main + +import ( + "io/ioutil" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + + "github.com/ParsePlatform/parse-cli/parsecli" + "github.com/facebookgo/ensure" +) + +func TestUpgradeLegacyNoOp(t *testing.T) { + t.Parallel() + + h := parsecli.NewHarness(t) + defer h.Stop() + + var m migrateCmd + c := &parsecli.ParseConfig{ProjectConfig: &parsecli.ProjectConfig{Type: parsecli.ParseFormat}} + _, err := m.upgradeLegacy(h.Env, c) + ensure.Err(t, err, regexp.MustCompile("Already using the preferred config format.")) +} + +func TestUpgradeLegacyRetainMaster(t *testing.T) { + t.Parallel() + + h := parsecli.NewHarness(t) + defer h.Stop() + + m := migrateCmd{retainMaster: true} + c := &parsecli.ParseConfig{ + Applications: map[string]*parsecli.ParseAppConfig{ + "app": {ApplicationID: "a", MasterKey: "m"}, + }, + ProjectConfig: &parsecli.ProjectConfig{Type: parsecli.LegacyParseFormat}, + } + config, err := m.upgradeLegacy(h.Env, c) + ensure.Nil(t, err) + ensure.DeepEqual(t, config.Applications["app"].MasterKey, "m") +} + +func TestUpgradeLegacy(t *testing.T) { + t.Parallel() + + h := parsecli.NewHarness(t) + defer h.Stop() + + m := migrateCmd{retainMaster: false} + c := &parsecli.ParseConfig{ + Applications: map[string]*parsecli.ParseAppConfig{ + "app": {ApplicationID: "a", MasterKey: "m"}, + }, + ProjectConfig: &parsecli.ProjectConfig{Type: parsecli.LegacyParseFormat}, + } + config, err := m.upgradeLegacy(h.Env, c) + ensure.Nil(t, err) + ensure.DeepEqual(t, config.Applications["app"].MasterKey, "") +} + +func TestUpgradeLegacyWithEmail(t *testing.T) { + t.Parallel() + + h := parsecli.NewHarness(t) + defer h.Stop() + + m := migrateCmd{retainMaster: false} + c := &parsecli.ParseConfig{ + Applications: map[string]*parsecli.ParseAppConfig{ + "app": {ApplicationID: "a", MasterKey: "m"}, + }, + ProjectConfig: &parsecli.ProjectConfig{ + Type: parsecli.LegacyParseFormat, + ParserEmail: "test@email.com", + }, + } + config, err := m.upgradeLegacy(h.Env, c) + ensure.Nil(t, err) + ensure.DeepEqual(t, config.Applications["app"].MasterKey, "") + ensure.DeepEqual(t, config.GetProjectConfig().ParserEmail, "test@email.com") +} + +func TestRunMigrateCmd(t *testing.T) { + t.Parallel() + + h := parsecli.NewHarness(t) + h.MakeEmptyRoot() + defer h.Stop() + + n := &newCmd{} + h.Env.Type = parsecli.ParseFormat + h.Env.In = ioutil.NopCloser(strings.NewReader("\n")) + _, err := n.setupSample(h.Env, + "yolo", + &parsecli.ParseAppConfig{ + ApplicationID: "yolo-id", + MasterKey: "yoda", + }, + false, + false, + ) + ensure.Nil(t, err) + + m := &migrateCmd{} + err = m.run(h.Env) + ensure.Err(t, err, regexp.MustCompile("Already using the preferred config format")) + + ensure.Nil(t, os.Remove(filepath.Join(h.Env.Root, parsecli.ParseLocal))) + ensure.Nil(t, os.Remove(filepath.Join(h.Env.Root, parsecli.ParseProject))) + + ensure.Nil(t, os.MkdirAll(filepath.Join(h.Env.Root, parsecli.ConfigDir), 0755)) + ensure.Nil(t, + ioutil.WriteFile( + filepath.Join(h.Env.Root, parsecli.LegacyConfigFile), + []byte(`{ + "applications": { + "yolo": { + "applicationId": "yolo-id", + "masterKey": "yoda" + } + } + }`), + 0600, + ), + ) + + ensure.Nil(t, m.run(h.Env)) + ensure.StringContains(t, h.Out.String(), "Successfully migrated") +} diff --git a/new.go b/new.go new file mode 100644 index 0000000..7660a64 --- /dev/null +++ b/new.go @@ -0,0 +1,400 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "strconv" + + "github.com/ParsePlatform/parse-cli/herokucmd" + "github.com/ParsePlatform/parse-cli/parsecli" + "github.com/ParsePlatform/parse-cli/parsecmd" + "github.com/facebookgo/parse" + "github.com/facebookgo/stackerr" + "github.com/spf13/cobra" +) + +type newCmd struct { + addApplication bool + + noCode bool // do not setup a Cloud Code project + configOnly bool // create a Cloud Code project with only configuration, no code + createNewApp bool // create a new app + parseAppName string // name of parse app + codeLocation string // location of Cloud Code project +} + +func (n *newCmd) curlCommand(e *parsecli.Env, app *parsecli.App) string { + args := "{}" + if e.Type == parsecli.HerokuFormat { + args = `{"a": "Adventurous ", "b": "Parser"}` + } + + return fmt.Sprintf( + `curl -X POST \ + -H "X-Parse-Application-Id: %s" \ + -H "X-Parse-REST-API-Key: %s" \ + -H "Content-Type: application/json" \ + -d %s \ + https://api.parse.com/1/functions/hello +`, + app.ApplicationID, + app.RestKey, + strconv.Quote(args), + ) +} + +func (n *newCmd) cloudCodeHelpMessage(e *parsecli.Env, app *parsecli.App) string { + code := "Cloud Code" + if e.Type == parsecli.HerokuFormat { + code = "server code" + } + return fmt.Sprintf( + `Your %s has been created at %s. + +This includes a "Hello world" cloud function, so once you deploy, +you can test that it works, with the printed curl command. + +Next, you might want to deploy this code with: + + cd %s + parse deploy + +Once deployed you can test that it works by running: +%s +`, + code, + e.Root, + e.Root, + n.curlCommand(e, app), + ) +} + +func (n *newCmd) getCloudCodeDir(e *parsecli.Env, appName string, isNew bool) (string, error) { + var cloudCodeDir string + if isNew { + fmt.Fprintf(e.Out, + `Awesome! Now it's time to set up some Cloud Code for the app: %q, +Next we will create a directory to hold your Cloud Code. +Please enter the name to use for this directory, +or hit ENTER to use %q as the directory name. + +Directory Name: `, + appName, + appName, + ) + } else { + fmt.Fprintf(e.Out, + `Please enter the name of the folder where we can download the latest deployed +Cloud Code for your app %q + +Directory Name: `, + appName, + ) + } + + fmt.Fscanf(e.In, "%s\n", &cloudCodeDir) + cloudCodeDir = strings.TrimSpace(cloudCodeDir) + if cloudCodeDir == "" { + cloudCodeDir = appName + } + + helpMsg := "Next time, please select the same app you selected now." + if isNew { + helpMsg = `Next time, please select to add Cloud Code to an 'existing' app +and select the app you just created.` + } + + configFile := filepath.Join(e.Root, cloudCodeDir, parsecli.LegacyConfigFile) + if _, err := os.Lstat(configFile); err == nil { + return "", stackerr.Newf( + `Sorry, we are unable to create Cloud Code at %s. +It seems that you already have Cloud Code at %s. +Please run "parse new" again. +%s +Please choose a different name for your Cloud Code directory, +so it does not conflict with any other Cloud Code in the current directory. +`, + cloudCodeDir, + cloudCodeDir, + helpMsg, + ) + } + + cloudCodeDirInfo, err := os.Stat(filepath.Join(e.Root, cloudCodeDir)) + if err == nil { + fileType := "file" + if cloudCodeDirInfo.IsDir() { + fileType = "directory" + } + return "", stackerr.Newf(`Sorry, we are unable to create Cloud Code at %s. +In the current directory a %s named: %q already exists. +Please run "parse new" again. +%s +Please choose a different name for your Cloud Code directory, +so it does not conflict with any other Cloud Code in the current directory. +`, + cloudCodeDir, + fileType, + cloudCodeDir, + helpMsg, + ) + } + if !os.IsNotExist(err) { + return "", stackerr.Wrap(err) + } + return cloudCodeDir, nil +} + +func (n *newCmd) promptCreateNewApp(e *parsecli.Env, nonInteractive bool) (string, error) { + if nonInteractive || n.noCode { + if n.createNewApp { + return "new", nil + } + return "existing", nil + } + + msg := `"new" and "existing" are the only valid options. +Please try again ...` + + var decision string + for i := 0; i < 3; i++ { + fmt.Fprintf(e.Out, + `Would you like to create a new app, or add Cloud Code to an existing app? +Type "(n)ew" or "(e)xisting": `, + ) + fmt.Fscanf(e.In, "%s\n", &decision) + decision = strings.ToLower(decision) + + if decision == "new" || decision == "n" || decision == "existing" || decision == "e" { + return decision, nil + } + fmt.Fprintln(e.Err, msg) + } + return "", stackerr.New(msg) +} + +func (n *newCmd) setupSample( + e *parsecli.Env, + name string, + appConfig parsecli.AppConfig, + isNew bool, + nonInteractive bool, +) (bool, error) { + found := parsecli.IsProjectDir(parsecli.GetProjectRoot(e, e.Root)) + if !found { + root := parsecli.GetLegacyProjectRoot(e, e.Root) + _, err := os.Lstat(filepath.Join(root, parsecli.LegacyConfigFile)) + found = err == nil + } + if found { + return false, stackerr.New( + `Detected that you are already inside a Parse project. +Please refrain from creating a Parse project inside another Parse project. +`, + ) + } + + var ( + cloudCodeDir string + err error + ) + + if nonInteractive { + cloudCodeDir = n.codeLocation + } else if n.configOnly { + cloudCodeDir = "" // ensures that "parse new --init" inits the current directory + } else { + cloudCodeDir, err = n.getCloudCodeDir(e, name, isNew) + if err != nil { + return false, err + } + } + e.Root = filepath.Join(e.Root, cloudCodeDir) + + switch e.Type { + case parsecli.ParseFormat: + if !n.configOnly { + var decision string + if isNew { + fmt.Fprint(e.Out, ` +You can either set up a blank project or create a sample Cloud Code project. +Please type "(b)lank" if you wish to setup a blank project, otherwise press ENTER: `) + } else { + fmt.Fprint(e.Out, ` +You can either set up a blank project or download the current deployed Cloud Code. +Please type "(b)lank" if you wish to setup a blank project, otherwise press ENTER: `) + } + fmt.Fscanf(e.In, "%s\n", &decision) + decision = strings.ToLower(strings.TrimSpace(decision)) + if decision != "" && decision == "b" || decision == "blank" { + n.configOnly = true + } + } + return parsecmd.CloneSampleCloudCode(e, isNew, n.configOnly, appConfig) + case parsecli.HerokuFormat: + if !n.configOnly { + var decision string + if isNew { + fmt.Fprint(e.Out, ` +You can either set up a blank project or create a sample Node.js project. +Please type "(b)lank" if you wish to setup a blank project, otherwise press ENTER: `) + } else { + fmt.Fprint(e.Out, ` +You can either set up a blank project or download the server code currently deployed to Heroku. +Please type "(b)lank" if you wish to setup a blank project, otherwise press ENTER: `) + } + fmt.Fscanf(e.In, "%s\n", &decision) + decision = strings.ToLower(strings.TrimSpace(decision)) + if decision != "" && decision == "b" || decision == "blank" { + n.configOnly = true + } + } + return herokucmd.CloneNodeCode(e, isNew, n.configOnly, appConfig) + } + return false, stackerr.Newf("Unknown project type: %d", e.Type) +} + +func (n *newCmd) configureSample( + add *addCmd, + name string, + appConfig parsecli.AppConfig, + args []string, + e *parsecli.Env, +) error { + if err := add.addSelectedApp(name, appConfig, args, e); err != nil { + return err + } + + masterKey, err := appConfig.GetMasterKey(e) + if err != nil { + return err + } + e.ParseAPIClient = e.ParseAPIClient.WithCredentials( + parse.MasterKey{ + ApplicationID: appConfig.GetApplicationID(), + MasterKey: masterKey, + }, + ) + + if e.Type == parsecli.ParseFormat { + return parsecmd.UseLatestJSSDK(e) + } + return nil +} + +func (n *newCmd) run(e *parsecli.Env) error { + apps := &parsecli.Apps{} + addCmd := &addCmd{MakeDefault: true, apps: apps} + + if err := apps.Login.AuthUser(e, false); err != nil { + return err + } + var ( + app *parsecli.App + err error + ) + + nonInteractive := n.parseAppName != "" && n.codeLocation != "" + decision, err := n.promptCreateNewApp(e, nonInteractive) + if err != nil { + return err + } + isNew := false + + switch decision { + case "new", "n": + isNew = true + var createRetries int + if n.noCode { + createRetries = 1 + } + // we pass retries so that even in non interactive mode we can create an app, + // and failure does not print 3 times to the screen + app, err = apps.CreateApp(e, n.parseAppName, createRetries) + if err != nil { + return err + } + if n.noCode { + fmt.Fprintln(e.Out, "Successfully created the app.") + return apps.PrintApp(e, app) + } + case "existing", "e": + app, err = addCmd.selectApp(e, n.parseAppName) + if err != nil { + return err + } + if n.noCode { + fmt.Fprintln(e.Out, "Successfully selected the app.") + return apps.PrintApp(e, app) + } + } + + projectType, err := herokucmd.PromptCreateWebhooks(e) + if err != nil { + return err + } + + var appConfig parsecli.AppConfig + switch projectType { + case "heroku": + e.Type = parsecli.HerokuFormat + var newHerokuApp bool + newHerokuApp, appConfig, err = herokucmd.GetLinkedHerokuAppConfig(app, e) + if err != nil { + return err + } + isNew = isNew || newHerokuApp + + case "parse": + e.Type = parsecli.ParseFormat + appConfig = parsecmd.GetParseAppConfig(app) + } + + dumpTemplate, err := n.setupSample(e, app.Name, appConfig, isNew, nonInteractive) + if err != nil { + return err + } + if err := n.configureSample(addCmd, app.Name, appConfig, nil, e); err != nil { + return err + } + if token := apps.Login.Credentials.Token; token != "" { + email, err := apps.Login.AuthToken(e, token) + if err != nil { + return err + } + if err := parsecli.SetParserEmail(e, email); err != nil { + return err + } + } + + if dumpTemplate { + fmt.Fprintf(e.Out, n.cloudCodeHelpMessage(e, app)) + } + + return nil +} + +func NewNewCmd(e *parsecli.Env) *cobra.Command { + nc := newCmd{addApplication: true} + cmd := &cobra.Command{ + Use: "new", + Short: "Adds Cloud Code to an existing Parse app, additional can create a new Parse app", + Long: `Adds Cloud Code to an existing Parse app, additional can create a new Parse app. +You can also use it in non-interactive mode by using the various flags available. +`, + Run: parsecli.RunNoArgs(e, nc.run), + } + cmd.Flags().BoolVarP(&nc.configOnly, "init", "i", nc.configOnly, + "Create a Cloud Code project only with configuration.") + cmd.Flags().BoolVarP(&nc.noCode, "nocode", "n", nc.noCode, + "Do not set up a Cloud Code project for the app. Typically used in conjunction with 'create' option") + cmd.Flags().BoolVarP(&nc.createNewApp, "create", "c", nc.createNewApp, + "Set this flag to true if you want to create a new Parse app.") + cmd.Flags().StringVarP(&nc.parseAppName, "app", "a", nc.parseAppName, + "Name of the Parse app you want to create or set up Cloud Code project for.") + cmd.Flags().StringVarP(&nc.codeLocation, "loc", "l", nc.codeLocation, + "Location, relative to the current directory, at which the Cloud Code project will be set up.") + return cmd +} diff --git a/new_cmd.go b/new_cmd.go deleted file mode 100644 index 2b7d578..0000000 --- a/new_cmd.go +++ /dev/null @@ -1,287 +0,0 @@ -package main - -import ( - "errors" - "fmt" - "os" - "path/filepath" - "strings" - - "github.com/facebookgo/parse" - "github.com/facebookgo/stackerr" - "github.com/spf13/cobra" -) - -type newCmd struct { - addApplication bool -} - -var newProjectFiles = []struct { - dirname, filename, content string -}{ - {"cloud", "main.js", sampleSource}, - {"public", "index.html", sampleHTML}, -} - -func (n *newCmd) curlCommand(app *app) string { - return fmt.Sprintf( - `curl -X POST \ - -H "X-Parse-Application-Id: %s" \ - -H "X-Parse-REST-API-Key: %s" \ - -H "Content-Type: application/json" \ - -d '{}' \ - https://api.parse.com/1/functions/hello -`, - app.ApplicationID, - app.RestKey, - ) -} - -func (n *newCmd) cloudCodeHelpMessage(e *env, app *app) string { - return fmt.Sprintf( - `Your Cloud Code has been created at %s. -Next, you might want to deploy this code with "parse deploy". -This includes a "Hello world" cloud function, so once you deploy -you can test that it works, with: - -%s -`, - e.Root, - n.curlCommand(app), - ) -} - -func (n *newCmd) getCloudCodeDir(e *env, appName string, isNew bool) (string, error) { - var cloudCodeDir string - fmt.Fprintf(e.Out, - `Awesome! Now it's time to setup some Cloud Code for the app: %q, -Next we will create a directory to hold your Cloud Code. -Please enter the name to use for this directory, -or hit ENTER to use %q as the directory name. - -Directory Name: `, - appName, - appName, - ) - - fmt.Scanf("%s\n", &cloudCodeDir) - cloudCodeDir = strings.TrimSpace(cloudCodeDir) - if cloudCodeDir == "" { - cloudCodeDir = appName - } - - helpMsg := "Next time, please select the same app you selected now." - if isNew { - helpMsg = `Next time, please select to add Cloud Code to an 'existing' app -and select the app you just created.` - } - - configFile := filepath.Join(e.Root, cloudCodeDir, legacyConfigFile) - if _, err := os.Lstat(configFile); err == nil { - return "", stackerr.Newf( - `Sorry, we are unable to create Cloud Code at %s. -It seems that you already have Cloud Code at %s. -Please run "parse new" again. -%s -Please choose a different name for your Cloud Code directory, -so it does not conflict with any other Cloud Code in the current directory. -`, - cloudCodeDir, - cloudCodeDir, - helpMsg, - ) - } - - cloudCodeDirInfo, err := os.Stat(filepath.Join(e.Root, cloudCodeDir)) - if err == nil { - if !cloudCodeDirInfo.IsDir() { - return "", stackerr.Newf(`Sorry, we are unable to create Cloud Code at %s. -In the current directory a file named: %s already exists. -Please run "parse new" again. -%s -Please choose a different name for your Cloud Code directory, -so it does not conflict with any other Cloud Code in the current directory. -`, - cloudCodeDir, - cloudCodeDir, - helpMsg, - ) - - } - return "", nil - } - if !os.IsNotExist(err) { - return "", stackerr.Wrap(err) - } - return cloudCodeDir, nil -} - -func (n *newCmd) cloneSampleCloudCode(e *env, app *app, isNew bool) error { - cloudCodeDir, err := n.getCloudCodeDir(e, app.Name, isNew) - if err != nil { - return err - } - e.Root = filepath.Join(e.Root, cloudCodeDir) - - if err != nil { - return err - } - err = os.MkdirAll(e.Root, 0755) - if err != nil { - return stackerr.Wrap(err) - } - - if err := os.Mkdir(filepath.Join(e.Root, configDir), 0755); err != nil { - return stackerr.Wrap(err) - } - file, err := os.OpenFile( - filepath.Join(e.Root, legacyConfigFile), - os.O_RDWR|os.O_CREATE|os.O_TRUNC, - 0600, - ) - if err != nil && !os.IsExist(err) { - return stackerr.Wrap(err) - } - defer file.Close() - if _, err := file.WriteString("{}"); err != nil { - return stackerr.Wrap(err) - } - if err := file.Close(); err != nil { - return stackerr.Wrap(err) - } - - for _, info := range newProjectFiles { - sampleDir := filepath.Join(e.Root, info.dirname) - if _, err := os.Stat(sampleDir); err != nil { - if !os.IsNotExist(err) { - return stackerr.Wrap(err) - } - if err := os.Mkdir(sampleDir, 0755); err != nil { - return stackerr.Wrap(err) - } - } - - sampleFile := filepath.Join(sampleDir, info.filename) - if _, err := os.Stat(sampleFile); err != nil { - if os.IsNotExist(err) { - file, err := os.Create(sampleFile) - if err != nil && !os.IsExist(err) { - return stackerr.Wrap(err) - } - - defer file.Close() - - if _, err := file.WriteString(info.content); err != nil { - return stackerr.Wrap(err) - } - if err := file.Close(); err != nil { - return stackerr.Wrap(err) - } - } - } - } - fmt.Fprintf(e.Out, n.cloudCodeHelpMessage(e, app)) - return nil -} - -func (n *newCmd) shouldCreateNewApp(e *env) string { - var decision string - fmt.Fprintf(e.Out, - `Would you like to create a new app, or add Cloud Code to an existing app? -Type "new" or "existing": `) - fmt.Fscanf(e.In, "%s\n", &decision) - return strings.ToLower(decision) -} - -func (n *newCmd) setupSample(e *env, app *app, isNew bool) error { - _, err := os.Lstat( - filepath.Join(e.Root, legacyConfigFile), - ) - if err == nil { - return stackerr.New(`Detected that you are already inside a Parse project. -Please refrain from creating a Parse project inside another Parse project. -`) - } - - return n.cloneSampleCloudCode(e, app, isNew) -} - -func (n *newCmd) configureSample( - add *addCmd, - app *app, - e *env, - args []string) error { - if err := add.writeConfig(app, args, e, false); err != nil { - return err - } - - // at this point user has already set a default app for the project - // use its properties to fetch latest jssdk version and set it in config - config, err := configFromDir(e.Root) - if err != nil { - return err - } - defaultApp, err := config.app(config.getDefaultApp()) - if err != nil { - return err - } - masterKey, err := defaultApp.getMasterKey(e) - if err != nil { - return err - } - e.Client = e.Client.WithCredentials( - parse.MasterKey{ - ApplicationID: defaultApp.getApplicationID(), - MasterKey: masterKey, - }, - ) - return useLatestJSSDK(e) -} - -func (n *newCmd) run(e *env) error { - apps := &apps{} - addCmd := &addCmd{MakeDefault: true, apps: apps} - - if err := apps.login.authUser(e); err != nil { - return err - } - var ( - app *app - err error - ) - decision := n.shouldCreateNewApp(e) - if decision != "new" && decision != "existing" { - return errors.New("`new` and `existing` are the only valid options") - } - - isNew := false - switch decision { - case "new": - isNew = true - app, err = apps.createApp(e) - if err != nil { - return err - } - case "existing": - app, err = addCmd.selectApp(e) - if err != nil { - return err - } - } - - if err := n.setupSample(e, app, isNew); err != nil { - return err - } - return n.configureSample(addCmd, app, e, nil) -} - -func newNewCmd(e *env) *cobra.Command { - nc := newCmd{addApplication: true} - cmd := &cobra.Command{ - Use: "new", - Short: "Creates a new Parse app and adds Cloud Code to an existing Parse app", - Long: `Creates a new Parse app and adds Cloud Code to an existing Parse app.`, - Run: runNoArgs(e, nc.run), - } - return cmd -} diff --git a/new_cmd_test.go b/new_cmd_test.go deleted file mode 100644 index feeb067..0000000 --- a/new_cmd_test.go +++ /dev/null @@ -1,106 +0,0 @@ -package main - -import ( - "io/ioutil" - "os" - "path/filepath" - "regexp" - "strings" - "testing" - - "github.com/facebookgo/ensure" -) - -func newNewCmdHarness(t testing.TB) (*Harness, *newCmd) { - h := newHarness(t) - h.makeEmptyRoot() - return h, &newCmd{} -} - -func TestNewCmdDirs(t *testing.T) { - t.Parallel() - - h, n := newNewCmdHarness(t) - defer h.Stop() - - ensure.Nil(t, n.cloneSampleCloudCode(h.env, &app{Name: "test"}, false)) - - var err error - - for _, newProjectFile := range newProjectFiles { - _, err = os.Stat(filepath.Join(h.env.Root, newProjectFile.dirname)) - ensure.Nil(t, err) - } - - _, err = os.Stat(filepath.Join(h.env.Root, configDir)) - ensure.Nil(t, err) -} - -func TestNewCmdContent(t *testing.T) { - t.Parallel() - - h, n := newNewCmdHarness(t) - defer h.Stop() - - ensure.Nil(t, n.cloneSampleCloudCode(h.env, &app{Name: "test"}, false)) - - for _, newProjectFile := range newProjectFiles { - content, err := ioutil.ReadFile( - filepath.Join(h.env.Root, newProjectFile.dirname, newProjectFile.filename), - ) - ensure.Nil(t, err) - ensure.DeepEqual(t, string(content), newProjectFile.content) - } - - content, err := ioutil.ReadFile(filepath.Join(h.env.Root, legacyConfigFile)) - ensure.Nil(t, err) - ensure.DeepEqual(t, string(content), "{}") -} - -func TestNewCmdConfigExists(t *testing.T) { - t.Parallel() - - h, n := newNewCmdHarness(t) - defer h.Stop() - - ensure.Nil(t, os.MkdirAll(filepath.Join(h.env.Root, "test", configDir), 0755)) - ensure.Nil(t, - ioutil.WriteFile(filepath.Join(h.env.Root, "test", legacyConfigFile), - []byte("{}"), - 0600, - ), - ) - - h.env.In = ioutil.NopCloser(strings.NewReader("\n")) - ensure.Err(t, - n.cloneSampleCloudCode(h.env, &app{Name: "test"}, false), - regexp.MustCompile("unable to create Cloud Code at test."), - ) -} - -func TestCurlCommand(t *testing.T) { - t.Parallel() - n := &newCmd{} - command := n.curlCommand(&app{ApplicationID: "AppID", RestKey: "RestKey"}) - ensure.DeepEqual(t, - command, - `curl -X POST \ - -H "X-Parse-Application-Id: AppID" \ - -H "X-Parse-REST-API-Key: RestKey" \ - -H "Content-Type: application/json" \ - -d '{}' \ - https://api.parse.com/1/functions/hello -`) -} - -func TestShouldCreateNewApp(t *testing.T) { - t.Parallel() - h := newHarness(t) - defer h.Stop() - - n := &newCmd{} - - h.env.In = ioutil.NopCloser(strings.NewReader("decide")) - decision := n.shouldCreateNewApp(h.env) - ensure.DeepEqual(t, decision, "decide") -} diff --git a/new_test.go b/new_test.go new file mode 100644 index 0000000..e29b173 --- /dev/null +++ b/new_test.go @@ -0,0 +1,241 @@ +package main + +import ( + "fmt" + "io/ioutil" + "net/http" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + + "github.com/ParsePlatform/parse-cli/parsecli" + "github.com/facebookgo/ensure" + "github.com/facebookgo/parse" +) + +func newNewCmdHarness(t testing.TB) (*parsecli.Harness, *newCmd) { + h := parsecli.NewHarness(t) + h.MakeEmptyRoot() + return h, &newCmd{} +} + +func TestCloudCodeHelpMessage(t *testing.T) { + t.Parallel() + h := parsecli.NewHarness(t) + defer h.Stop() + + n := &newCmd{} + msg := n.cloudCodeHelpMessage(h.Env, &parsecli.App{ApplicationID: "a", MasterKey: "m"}) + ensure.StringContains(t, + msg, + fmt.Sprintf( + `Your Cloud Code has been created at %s. + +This includes a "Hello world" cloud function, so once you deploy, +you can test that it works, with`, + h.Env.Root, + ), + ) +} + +func TestGetCloudCodeDir(t *testing.T) { + t.Parallel() + h := parsecli.NewHarness(t) + h.MakeEmptyRoot() + defer h.Stop() + + n := &newCmd{} + h.Env.In = ioutil.NopCloser(strings.NewReader("\n")) + name, err := n.getCloudCodeDir(h.Env, "myapp", true) + ensure.Nil(t, err) + ensure.StringContains(t, h.Out.String(), "Now it's time to set up some Cloud Code") + ensure.DeepEqual(t, name, "myapp") + + h.Out.Reset() + h.Env.In = ioutil.NopCloser(strings.NewReader("otherApp\n")) + name, err = n.getCloudCodeDir(h.Env, "myapp", true) + ensure.Nil(t, err) + ensure.StringContains(t, h.Out.String(), "Now it's time to set up some Cloud Code") + ensure.DeepEqual(t, name, "otherApp") + + _, err = os.Create(filepath.Join(h.Env.Root, "otherApp")) + ensure.Nil(t, err) + h.Out.Reset() + h.Env.In = ioutil.NopCloser(strings.NewReader("otherApp\n")) + name, err = n.getCloudCodeDir(h.Env, "myapp", true) + ensure.Err(t, err, regexp.MustCompile("already exists")) + ensure.StringContains(t, h.Out.String(), "Now it's time to set up some Cloud Code") + + ensure.Nil(t, os.MkdirAll(filepath.Join(h.Env.Root, "myapp", "config"), 0755)) + _, err = os.Create(filepath.Join(h.Env.Root, "myapp", "config", "global.json")) + ensure.Nil(t, err) + h.Out.Reset() + h.Env.In = ioutil.NopCloser(strings.NewReader("\n")) + name, err = n.getCloudCodeDir(h.Env, "myapp", true) + ensure.Err(t, err, regexp.MustCompile("you already have Cloud Code")) + ensure.Nil(t, os.Remove(filepath.Join(h.Env.Root, "myapp", "config", "global.json"))) + + h.Out.Reset() + h.Env.In = ioutil.NopCloser(strings.NewReader("\n")) + name, err = n.getCloudCodeDir(h.Env, "myapp", false) + ensure.Err(t, err, regexp.MustCompile("a directory named: \"myapp\" already exists")) +} + +func TestNewCmdDirs(t *testing.T) { + t.Parallel() + + h, _ := newNewCmdHarness(t) + defer h.Stop() + + ensure.Nil(t, parsecli.CloneSampleCloudCode(h.Env, true)) + + var err error + + for _, newProjectFile := range parsecli.NewProjectFiles { + _, err = os.Stat(filepath.Join(h.Env.Root, newProjectFile.Dirname)) + ensure.Nil(t, err) + } +} + +func TestNewCmdContent(t *testing.T) { + t.Parallel() + + h, _ := newNewCmdHarness(t) + defer h.Stop() + + ensure.Nil(t, parsecli.CloneSampleCloudCode(h.Env, true)) + + for _, newProjectFile := range parsecli.NewProjectFiles { + content, err := ioutil.ReadFile( + filepath.Join(h.Env.Root, newProjectFile.Dirname, newProjectFile.Filename), + ) + ensure.Nil(t, err) + ensure.DeepEqual(t, string(content), newProjectFile.Content) + } + + content, err := ioutil.ReadFile(filepath.Join(h.Env.Root, parsecli.ParseProject)) + ensure.Nil(t, err) + ensure.DeepEqual(t, + string(content), + fmt.Sprintf( + `{ + "project_type" : %d, + "parse": {"jssdk":""} +}`, + parsecli.ParseFormat, + ), + ) + + content, err = ioutil.ReadFile(filepath.Join(h.Env.Root, parsecli.ParseLocal)) + ensure.Nil(t, err) + ensure.DeepEqual(t, string(content), "{}") + +} + +func TestCurlCommand(t *testing.T) { + t.Parallel() + h := parsecli.NewHarness(t) + defer h.Stop() + + n := &newCmd{} + command := n.curlCommand(h.Env, &parsecli.App{ApplicationID: "AppID", RestKey: "RestKey"}) + ensure.DeepEqual(t, + command, + `curl -X POST \ + -H "X-Parse-Application-Id: AppID" \ + -H "X-Parse-REST-API-Key: RestKey" \ + -H "Content-Type: application/json" \ + -d "{}" \ + https://api.parse.com/1/functions/hello +`) +} + +func TestCreateNewAppNoCode(t *testing.T) { + t.Parallel() + h, _ := parsecli.NewAppHarness(t) + defer h.Stop() + + h.Env.In = ioutil.NopCloser(strings.NewReader("email\npassword")) + n := &newCmd{noCode: true, createNewApp: true, parseAppName: "yolo"} + ensure.Nil(t, n.run(h.Env)) + ensure.StringContains(t, h.Out.String(), "Successfully created") +} + +func TestSelectNewAppNoCode(t *testing.T) { + t.Parallel() + h, _ := parsecli.NewAppHarness(t) + defer h.Stop() + + h.Env.In = ioutil.NopCloser(strings.NewReader("email\npassword\n")) + n := &newCmd{noCode: true, parseAppName: "A"} + ensure.Nil(t, n.run(h.Env)) + ensure.StringContains(t, h.Out.String(), "Successfully selected") +} + +func TestShouldCreateNewApp(t *testing.T) { + t.Parallel() + h := parsecli.NewHarness(t) + defer h.Stop() + + n := &newCmd{} + + h.Env.In = ioutil.NopCloser(strings.NewReader("new")) + decision, err := n.promptCreateNewApp(h.Env, false) + ensure.Nil(t, err) + ensure.DeepEqual(t, decision, "new") + + h.Env.In = ioutil.NopCloser(strings.NewReader("existing")) + decision, err = n.promptCreateNewApp(h.Env, false) + ensure.Nil(t, err) + ensure.DeepEqual(t, decision, "existing") + + h.Env.In = ioutil.NopCloser(strings.NewReader("other")) + _, err = n.promptCreateNewApp(h.Env, false) + ensure.Err(t, err, regexp.MustCompile("are the only valid options")) + + decision, err = n.promptCreateNewApp(h.Env, true) + ensure.Nil(t, err) + ensure.DeepEqual(t, decision, "existing") + + n.createNewApp = true + decision, err = n.promptCreateNewApp(h.Env, true) + ensure.Nil(t, err) + ensure.DeepEqual(t, decision, "new") +} + +func TestSetupAndConfigure(t *testing.T) { + t.Parallel() + h := parsecli.NewHarness(t) + h.MakeEmptyRoot() + defer h.Stop() + + n := &newCmd{} + h.Env.Type = parsecli.ParseFormat + h.Env.In = ioutil.NopCloser(strings.NewReader("\n")) + code, err := n.setupSample(h.Env, "myapp", &parsecli.ParseAppConfig{ApplicationID: "a"}, true, false) + ensure.Nil(t, err) + ensure.True(t, code) + + type jsSDKVersion struct { + JS []string `json:"js"` + } + ht := parsecli.TransportFunc(func(r *http.Request) (*http.Response, error) { + ensure.DeepEqual(t, r.URL.Path, "/1/jsVersions") + rows := jsSDKVersion{JS: []string{"1.5.0", "1.6.0"}} + return &http.Response{ + StatusCode: http.StatusOK, + Body: ioutil.NopCloser(strings.NewReader(jsonStr(t, rows))), + }, nil + }) + h.Env.ParseAPIClient = &parsecli.ParseAPIClient{APIClient: &parse.Client{Transport: ht}} + err = n.configureSample( + &addCmd{MakeDefault: true}, + "yolo", + (&parsecli.ParseAppConfig{ApplicationID: "a"}).WithInternalMasterKey("m"), + nil, + h.Env, + ) + ensure.Nil(t, err) +} diff --git a/parse_commands.go b/parse_commands.go new file mode 100644 index 0000000..fbaff97 --- /dev/null +++ b/parse_commands.go @@ -0,0 +1,68 @@ +package main + +import ( + "fmt" + "os" + "time" + + "github.com/ParsePlatform/parse-cli/parsecli" + "github.com/ParsePlatform/parse-cli/parsecmd" + "github.com/ParsePlatform/parse-cli/webhooks" + "github.com/spf13/cobra" +) + +func parseRootCmd(e *parsecli.Env) ([]string, *cobra.Command) { + c := &cobra.Command{ + Use: "parse", + Long: fmt.Sprintf( + `Parse Command Line Interface +Version %v +Copyright %d Parse, Inc. +http://parse.com`, + parsecli.Version, + time.Now().Year(), + ), + Run: func(cmd *cobra.Command, args []string) { + cmd.Help() + }, + } + + c.AddCommand(NewAddCmd(e)) + c.AddCommand(NewConfigureCmd(e)) + c.AddCommand(NewDefaultCmd(e)) + c.AddCommand(parsecmd.NewDeployCmd(e)) + c.AddCommand(parsecmd.NewDevelopCmd(e)) + c.AddCommand(parsecmd.NewDownloadCmd(e)) + c.AddCommand(webhooks.NewFunctionHooksCmd(e)) + c.AddCommand(parsecmd.NewGenerateCmd(e)) + c.AddCommand(parsecmd.NewJsSdkCmd(e)) + c.AddCommand(NewListCmd(e)) + c.AddCommand(parsecmd.NewLogsCmd(e)) + c.AddCommand(NewMigrateCmd(e)) + c.AddCommand(NewNewCmd(e)) + c.AddCommand(parsecmd.NewReleasesCmd(e)) + c.AddCommand(parsecmd.NewRollbackCmd(e)) + c.AddCommand(parsecmd.NewSymbolsCmd(e)) + c.AddCommand(webhooks.NewTriggerHooksCmd(e)) + c.AddCommand(NewUpdateCmd(e)) + c.AddCommand(NewVersionCmd(e)) + + if len(os.Args) <= 1 { + return nil, c + } + + commands := []string{"help"} + for _, command := range c.Commands() { + commands = append(commands, command.Name()) + } + + args := make([]string, len(os.Args)-1) + copy(args, os.Args[1:]) + + if message := parsecli.MakeCorrections(commands, args); message != "" { + fmt.Fprintln(e.Out, message) + } + c.SetArgs(args) + + return args, c +} diff --git a/parse_config.go b/parse_config.go deleted file mode 100644 index 930433d..0000000 --- a/parse_config.go +++ /dev/null @@ -1,150 +0,0 @@ -package main - -import ( - "fmt" - "path/filepath" - "sort" - - "github.com/facebookgo/stackerr" -) - -type parseProjectConfig struct { - JSSDK string `json:"jssdk,omitempty"` -} - -type parseAppConfig struct { - ApplicationID string `json:"applicationId,omitempty"` - MasterKey string `json:"masterKey,omitempty"` - Link string `json:"link,omitempty"` - - masterKey string -} - -func (c *parseAppConfig) getApplicationID() string { - return c.ApplicationID -} - -func (c *parseAppConfig) getMasterKey(e *env) (string, error) { - if c.MasterKey != "" { - return c.MasterKey, nil - } - if c.masterKey != "" { - return c.masterKey, nil - } - app, err := fetchAppKeys(e, c.getApplicationID()) - if err != nil { - return "", err - } - c.masterKey = app.MasterKey - return app.MasterKey, nil -} - -func (c *parseAppConfig) getApplicationAuth(e *env) (string, error) { - return c.getMasterKey(e) -} - -func (c *parseAppConfig) getLink() string { - return c.Link -} - -type parseConfig struct { - Applications map[string]*parseAppConfig `json:"applications,omitempty"` - projectConfig *projectConfig -} - -func (c *parseConfig) addAlias(name, link string) error { - if _, found := c.Applications[name]; found { - return stackerr.Newf("App %q has already been added.", name) - } - if _, found := c.Applications[link]; !found { - return stackerr.Newf("App %q wasn't found.", link) - } - c.Applications[name] = &parseAppConfig{Link: link} - return nil -} - -func (c *parseConfig) setDefaultApp(name string) error { - delete(c.Applications, defaultKey) - return c.addAlias(defaultKey, name) -} - -func (c *parseConfig) app(name string) (appConfig, error) { - ac, found := c.Applications[name] - if !found { - if name == defaultKey { - return nil, stackerr.Newf("No default app configured.") - } - return nil, stackerr.Newf("App %q wasn't found.", name) - } - if ac.Link != "" { - return c.app(ac.Link) - } - return ac, nil -} - -func (c *parseConfig) getProjectConfig() *projectConfig { - return c.projectConfig -} - -func (c *parseConfig) getDefaultApp() string { - var defaultApp string - if defaultKeyLink, ok := c.Applications[defaultKey]; ok { - defaultApp = defaultKeyLink.Link - } - return defaultApp -} - -func (c *parseConfig) getNumApps() int { - return len(c.Applications) -} - -func (c *parseConfig) pprintApps(e *env) { - apps := c.Applications - - defaultApp := c.getDefaultApp() - - var appNames []string - for appName := range apps { - appNames = append(appNames, appName) - } - sort.Strings(appNames) - - if len(appNames) == 0 { - return - } - - fmt.Fprintln( - e.Out, - "The following apps are associated with cloud code in the current directory:", - ) - - for _, appName := range appNames { - if appName == defaultKey { - continue - } - if defaultApp == appName { - fmt.Fprint(e.Out, "* ") - } else { - fmt.Fprint(e.Out, " ") - } - fmt.Fprintf(e.Out, "%s", appName) - - if config, _ := apps[appName]; config.getLink() != "" { - fmt.Fprintf(e.Out, " -> %s", config.getLink()) - } - fmt.Fprintln(e.Out, "") - } -} - -func storeParseConfig(e *env, c *parseConfig) error { - projectType := c.projectConfig.Type - if projectType == legacy { - lconf := &legacyConfig{Applications: c.Applications} - lconf.Global.ParseVersion = c.projectConfig.Parse.JSSDK - return writeLegacyConfigFile( - lconf, - filepath.Join(e.Root, legacyConfigFile), - ) - } - return stackerr.Newf("Unknown project type: %s.", projectType) -} diff --git a/apps.go b/parsecli/apps.go similarity index 71% rename from apps.go rename to parsecli/apps.go index 8ccdf3d..0492261 100644 --- a/apps.go +++ b/parsecli/apps.go @@ -1,4 +1,4 @@ -package main +package parsecli import ( "bytes" @@ -17,7 +17,7 @@ import ( "github.com/facebookgo/stackerr" ) -type app struct { +type App struct { Name string `json:"appName"` DashboardURL string `json:"dashboardURL"` ApplicationID string `json:"applicationId"` @@ -32,14 +32,13 @@ type app struct { RequireRevocableSessions bool `json:"requireRevocableSessions"` RevokeSessionOnPasswordChange bool `json:"revokeSessionOnPasswordChange"` } - -type apps struct { - login loginCmd +type Apps struct { + Login Login } const numRetries = 3 -func allApps(apps []*app) []string { +func allApps(apps []*App) []string { var appNames []string for _, app := range apps { appNames = append(appNames, app.Name) @@ -56,9 +55,10 @@ func selectionString(appNames []string) string { return buffer.String() } -func (a *apps) printApp(e *env, params *app) error { - w := new(tabwriter.Writer) +func (a *Apps) PrintApp(e *Env, params *App) error { + fmt.Fprintf(e.Out, "Properties of the app %q:\n", params.Name) + w := new(tabwriter.Writer) w.Init(e.Out, 0, 8, 0, '\t', 0) fmt.Fprintf(w, "Name\t%s\n", params.Name) fmt.Fprintf(w, "DashboardURL\t%s\n", params.DashboardURL) @@ -77,35 +77,35 @@ func (a *apps) printApp(e *env, params *app) error { return stackerr.Wrap(w.Flush()) } -func getAuthHeaders(credentials *credentials, headers http.Header) http.Header { +func getAuthHeaders(credentials *Credentials, headers http.Header) http.Header { if headers == nil { headers = make(http.Header) } - headers.Add("X-Parse-Email", credentials.email) - if credentials.token != "" { - headers.Add("X-Parse-Account-Key", credentials.token) + if credentials.Token != "" { + headers.Add("X-Parse-Account-Key", credentials.Token) } else { - headers.Add("X-Parse-Password", credentials.password) + headers.Add("X-Parse-Email", credentials.Email) + headers.Add("X-Parse-Password", credentials.Password) } return headers } -func fetchAppKeys(e *env, appID string) (*app, error) { - l := &loginCmd{} - err := l.authUser(e) +func FetchAppKeys(e *Env, appID string) (*App, error) { + l := &Login{} + err := l.AuthUser(e, false) if err != nil { return nil, err } - credentials := &l.credentials + credentials := &l.Credentials req := &http.Request{ Method: "GET", URL: &url.URL{Path: path.Join("apps", appID)}, Header: getAuthHeaders(credentials, nil), } - res := &app{} + res := &App{} - if response, err := e.Client.Do(req, nil, res); err != nil { + if response, err := e.ParseAPIClient.Do(req, nil, res); err != nil { if response.StatusCode == http.StatusUnauthorized { return nil, errAuth } @@ -117,18 +117,18 @@ func fetchAppKeys(e *env, appID string) (*app, error) { return res, nil } -func (a *apps) restFetchApps(e *env) ([]*app, error) { +func (a *Apps) RestFetchApps(e *Env) ([]*App, error) { req := &http.Request{ Method: "GET", URL: &url.URL{Path: "apps"}, - Header: getAuthHeaders(&a.login.credentials, nil), + Header: getAuthHeaders(&a.Login.Credentials, nil), } var res struct { - Results []*app `json:"results"` + Results []*App `json:"results"` } - if response, err := e.Client.Do(req, nil, &res); err != nil { + if response, err := e.ParseAPIClient.Do(req, nil, &res); err != nil { if response.StatusCode == http.StatusUnauthorized { return nil, errAuth } @@ -137,7 +137,7 @@ func (a *apps) restFetchApps(e *env) ([]*app, error) { return res.Results, nil } -func (a *apps) selectApp(apps []*app, msg string, e *env) (*app, error) { +func (a *Apps) SelectApp(apps []*App, msg string, e *Env) (*App, error) { appNames := allApps(apps) appCount := len(apps) @@ -148,7 +148,7 @@ func (a *apps) selectApp(apps []*app, msg string, e *env) (*app, error) { fmt.Fprintf(e.Out, "%s%s", selectionString(appNames), msg) var selected string - fmt.Fscanf(e.In, "%s", &selected) + fmt.Fscanf(e.In, "%s\n", &selected) if pos, err = strconv.Atoi(strings.TrimSpace(selected)); err != nil { pos = -1 break @@ -173,24 +173,28 @@ func (a *apps) selectApp(apps []*app, msg string, e *env) (*app, error) { return nil, stackerr.Newf("Please try again. Please select from among the listed apps.") } -func (a *apps) showApps(e *env) error { - apps, err := a.restFetchApps(e) +func (a *Apps) ShowApps(e *Env, appName string) error { + apps, err := a.RestFetchApps(e) if err != nil { return err } - fmt.Fprintln(e.Out, "The following apps are currently owned by you:") - app, err := a.selectApp(apps, - "Select an app to view its properties: ", - e, - ) - if err != nil { - return err + if appName != "" { + for _, app := range apps { + if app.Name == appName { + return a.PrintApp(e, app) + } + } } - fmt.Fprintf(e.Out, "Properties of app: %q\n", app.Name) - return a.printApp(e, app) + + fmt.Fprintf( + e.Out, + "These are the apps you currently have access to:\n%s", + selectionString(allApps(apps)), + ) + return nil } -func (a *apps) getAppName(e *env) (string, error) { +func (a *Apps) getAppName(e *Env) (string, error) { var appName string fmt.Fprint(e.Out, `Please choose a name for your Parse app. Note that this name will appear on the Parse website, @@ -203,19 +207,16 @@ Name: `) return appName, nil } -func (a *apps) restCreateApp(e *env, appName string) (*app, error) { +func (a *Apps) restCreateApp(e *Env, appName string) (*App, error) { req := &http.Request{ Method: "POST", URL: &url.URL{Path: "apps"}, - Header: make(http.Header), + Header: getAuthHeaders(&a.Login.Credentials, nil), Body: ioutil.NopCloser(jsonpipe.Encode(map[string]string{"appName": appName})), } - req.Header.Add("X-Parse-Email", a.login.credentials.email) - req.Header.Add("X-Parse-Password", a.login.credentials.password) - - var res app - if response, err := e.Client.Do(req, nil, &res); err != nil { + var res App + if response, err := e.ParseAPIClient.Do(req, nil, &res); err != nil { if response != nil && response.StatusCode == http.StatusUnauthorized { return nil, errAuth } @@ -225,8 +226,8 @@ func (a *apps) restCreateApp(e *env, appName string) (*app, error) { return &res, nil } -func (a *apps) createApp(e *env) (*app, error) { - apps, err := a.restFetchApps(e) +func (a *Apps) CreateApp(e *Env, givenName string, retries int) (*App, error) { + apps, err := a.RestFetchApps(e) if err != nil { return nil, err } @@ -234,13 +235,22 @@ func (a *apps) createApp(e *env) (*app, error) { for _, app := range apps { appNames[app.Name] = struct{}{} } - for i := 0; i < numRetries; i++ { - appName, err := a.getAppName(e) - if err != nil { - return nil, err + if retries <= 0 { + retries = numRetries + } + for i := 0; i < retries; i++ { + var appName string + if givenName != "" { + appName = givenName + } else { + appName, err = a.getAppName(e) + if err != nil { + return nil, err + } } + if _, ok := appNames[appName]; ok { - fmt.Fprintf(e.Out, + fmt.Fprintf(e.Err, `Hey! you already created an app named %q. The apps associated with your account: %s diff --git a/parsecli/apps_test.go b/parsecli/apps_test.go new file mode 100644 index 0000000..fc41b01 --- /dev/null +++ b/parsecli/apps_test.go @@ -0,0 +1,199 @@ +package parsecli + +import ( + "errors" + "io/ioutil" + "net/http" + "regexp" + "strings" + "testing" + + "github.com/facebookgo/ensure" + "github.com/facebookgo/parse" +) + +var ( + defaultCredentials = Credentials{Email: "email", Password: "password"} + defaultTokenCredentials = Credentials{Email: "email", Token: "token"} + defaultApps = Apps{Login: Login{Credentials: defaultCredentials}} + defaultAppsWithToken = Apps{Login: Login{Credentials: defaultTokenCredentials}} +) + +func TestFetchAppKeys(t *testing.T) { + t.Parallel() + + h, _ := NewAppHarness(t) + defer h.Stop() + + h.Env.In = ioutil.NopCloser(strings.NewReader("email\npassword\n")) + app, err := FetchAppKeys(h.Env, "an-app") + ensure.Nil(t, err) + ensure.DeepEqual(t, app, newTestApp("an-app")) +} + +func TestFetchApps(t *testing.T) { + t.Parallel() + + h, apps := NewAppHarness(t) + defer h.Stop() + + a := defaultApps + gotApps, err := a.RestFetchApps(h.Env) + ensure.Nil(t, err) + ensure.DeepEqual(t, gotApps, apps) + + a.Login.Credentials.Password = "invalid" + _, err = a.RestFetchApps(h.Env) + ensure.DeepEqual(t, err, errAuth) +} + +func TestFetchAppsToken(t *testing.T) { + t.Parallel() + + h, apps := NewAppHarness(t) + defer h.Stop() + + a := defaultAppsWithToken + gotApps, err := a.RestFetchApps(h.Env) + ensure.Nil(t, err) + ensure.DeepEqual(t, gotApps, apps) + + a.Login.Credentials.Token = "invalid" + _, err = a.RestFetchApps(h.Env) + ensure.DeepEqual(t, err, errAuth) +} + +func TestSelectAppWithRetries(t *testing.T) { + t.Parallel() + + h, apps := NewAppHarness(t) + + defer h.Stop() + + a := defaultApps + h.Env.In = ioutil.NopCloser(strings.NewReader("3\n1")) + selectedApp, err := a.SelectApp(apps, "test: ", h.Env) + ensure.Nil(t, err) + ensure.DeepEqual(t, h.Out.String(), + `1: A +2: B +test: Invalid selection 3: must be between 1 and 2 +1: A +2: B +test: `) + ensure.DeepEqual(t, selectedApp, newTestApp("A")) +} + +func TestSelectApp(t *testing.T) { + t.Parallel() + + // apps = [B, A] & appNames=[A,B] + h, apps := NewAppHarness(t) + apps[0], apps[1] = apps[1], apps[0] + + defer h.Stop() + + a := defaultApps + h.Env.In = ioutil.NopCloser(strings.NewReader("1")) + selectedApp, err := a.SelectApp(apps, "test: ", h.Env) + ensure.Nil(t, err) + ensure.DeepEqual(t, h.Out.String(), + `1: A +2: B +test: `) + ensure.DeepEqual(t, selectedApp, newTestApp("A")) +} + +func TestGetAppName(t *testing.T) { + t.Parallel() + + h := NewHarness(t) + defer h.Stop() + + a := defaultApps + h.Env.In = ioutil.NopCloser(strings.NewReader("\n")) + appName, err := a.getAppName(h.Env) + ensure.Err(t, err, regexp.MustCompile("App name cannot be empty")) + + h.Env.In = ioutil.NopCloser(strings.NewReader("hello\n")) + appName, err = a.getAppName(h.Env) + ensure.Nil(t, err) + ensure.DeepEqual(t, appName, "hello") +} + +func TestCreateApp(t *testing.T) { + t.Parallel() + + h, _ := NewAppHarness(t) + defer h.Stop() + + a := defaultApps + app, err := a.restCreateApp(h.Env, "C") + + ensure.Nil(t, err) + ensure.DeepEqual(t, app, newTestApp("C")) +} + +func TestCreateNewApp(t *testing.T) { + t.Parallel() + + h, _ := NewAppHarness(t) + defer h.Stop() + + a := defaultApps + h.Env.In = ioutil.NopCloser(strings.NewReader("D")) + app, err := a.CreateApp(h.Env, "", 0) + ensure.Nil(t, err) + + ensure.Nil(t, a.PrintApp(h.Env, app)) + ensure.DeepEqual(t, h.Out.String(), + `Please choose a name for your Parse app. +Note that this name will appear on the Parse website, +but it does not have to be the same as your mobile app's public name. +Name: Properties of the app "D": +Name D +DashboardURL https://api.example.com/dashboard/D +ApplicationID applicationID.D +ClientKey clientKey.D +JavaScriptKey javaScriptKey.D +WindowsKey windowsKey.D +WebhookKey webhookKey.D +RestKey restKey.D +MasterKey masterKey.D +ClientPushEnabled false +ClientClassCreationEnabled true +RequireRevocableSessions true +RevokeSessionOnPasswordChange true +`) +} + +func TestCreateNewAppNameTaken(t *testing.T) { + t.Parallel() + + h, _ := NewAppHarness(t) + defer h.Stop() + + a := defaultApps + + h.Env.In = ioutil.NopCloser(strings.NewReader("A\nD")) + _, err := a.CreateApp(h.Env, "", 0) + ensure.Nil(t, err) + ensure.StringContains(t, h.Err.String(), "already created an app") +} + +func TestNoPanicAppCreate(t *testing.T) { + t.Parallel() + h := NewHarness(t) + defer h.Stop() + + ht := TransportFunc(func(r *http.Request) (*http.Response, error) { + ensure.DeepEqual(t, r.URL.Path, "/1/apps") + return nil, errors.New("nil response panic") + }) + h.Env.ParseAPIClient = &ParseAPIClient{APIClient: &parse.Client{Transport: ht}} + + var apps Apps + app, err := apps.restCreateApp(h.Env, "panic!") + ensure.True(t, app == nil) + ensure.Err(t, err, regexp.MustCompile("nil response panic")) +} diff --git a/auto_correct.go b/parsecli/auto_correct.go similarity index 96% rename from auto_correct.go rename to parsecli/auto_correct.go index 8cffaa9..5974429 100644 --- a/auto_correct.go +++ b/parsecli/auto_correct.go @@ -1,4 +1,4 @@ -package main +package parsecli import ( "bytes" @@ -108,7 +108,7 @@ func SuggestCommands(given string, available []string) []string { // MakeCorrections can be used to automatically correct the incorrect command in input args // and return either an action that was taken or a suggestions message. // One can also provide a map of aliases and related conversions -func makeCorrections(commands []string, args []string) string { +func MakeCorrections(commands []string, args []string) string { pos := getArgPos(args) if pos == -1 { return "" diff --git a/auto_correct_test.go b/parsecli/auto_correct_test.go similarity index 73% rename from auto_correct_test.go rename to parsecli/auto_correct_test.go index 5087a26..3b30a67 100644 --- a/auto_correct_test.go +++ b/parsecli/auto_correct_test.go @@ -1,4 +1,4 @@ -package main +package parsecli import ( "testing" @@ -37,22 +37,22 @@ func TestCmdScoreLess(t *testing.T) { ensure.False(t, scores.Less(0, 1)) } -func TestmakeCorrections(t *testing.T) { +func TestMakeCorrections(t *testing.T) { t.Parallel() - ensure.DeepEqual(t, makeCorrections(nil, nil), "") - ensure.DeepEqual(t, makeCorrections(nil, []string{"arg"}), "") + ensure.DeepEqual(t, MakeCorrections(nil, nil), "") + ensure.DeepEqual(t, MakeCorrections(nil, []string{"arg"}), "") subCommands := []string{"cmd"} - ensure.DeepEqual(t, makeCorrections(subCommands, nil), "") + ensure.DeepEqual(t, MakeCorrections(subCommands, nil), "") - ensure.DeepEqual(t, makeCorrections(subCommands, []string{"--file"}), "") - ensure.DeepEqual(t, makeCorrections(subCommands, []string{"-f"}), "") - ensure.DeepEqual(t, makeCorrections(subCommands, []string{"--file", "-p"}), "") - ensure.DeepEqual(t, makeCorrections(subCommands, []string{"-p", "--file"}), "") + ensure.DeepEqual(t, MakeCorrections(subCommands, []string{"--file"}), "") + ensure.DeepEqual(t, MakeCorrections(subCommands, []string{"-f"}), "") + ensure.DeepEqual(t, MakeCorrections(subCommands, []string{"--file", "-p"}), "") + ensure.DeepEqual(t, MakeCorrections(subCommands, []string{"-p", "--file"}), "") args := []string{"--flags", "cmd", "args"} - ensure.DeepEqual(t, makeCorrections(subCommands, args), "") + ensure.DeepEqual(t, MakeCorrections(subCommands, args), "") ensure.DeepEqual(t, args, []string{"--flags", "cmd", "args"}) } @@ -92,7 +92,7 @@ func TestMatchesForSubCommands(t *testing.T) { } for _, testCase := range testCases { - ensure.DeepEqual(t, makeCorrections(subCommands, testCase.args), testCase.message) + ensure.DeepEqual(t, MakeCorrections(subCommands, testCase.args), testCase.message) ensure.DeepEqual(t, testCase.args, testCase.modArgs) } @@ -110,6 +110,6 @@ func TestMatchesForSubCommands(t *testing.T) { }, } for _, errorCase := range errorCases { - ensure.DeepEqual(t, makeCorrections(subCommands, errorCase.args), "") + ensure.DeepEqual(t, MakeCorrections(subCommands, errorCase.args), "") } } diff --git a/parsecli/client.go b/parsecli/client.go new file mode 100644 index 0000000..a719ea7 --- /dev/null +++ b/parsecli/client.go @@ -0,0 +1,81 @@ +package parsecli + +import ( + "net/http" + "net/url" + + "github.com/bgentry/heroku-go" + "github.com/facebookgo/parse" + "github.com/facebookgo/stackerr" +) + +// ParseAPIClient is the http client used by parse-cli +type ParseAPIClient struct { + APIClient *parse.Client +} + +func NewParseAPIClient(e *Env) (*ParseAPIClient, error) { + baseURL, err := url.Parse(e.Server) + if err != nil { + return nil, stackerr.Newf("invalid server URL %q: %s", e.Server, err) + } + return &ParseAPIClient{ + APIClient: &parse.Client{ + BaseURL: baseURL, + }, + }, nil +} + +func NewHerokuAPIClient(e *Env) (*heroku.Client, error) { + return &heroku.Client{}, nil +} + +func (c *ParseAPIClient) appendCommonHeaders(header http.Header) http.Header { + if header == nil { + header = make(http.Header) + } + header.Add("User-Agent", UserAgent) + return header +} + +// Get performs a GET method call on the given url and unmarshal response into +// result. +func (c *ParseAPIClient) Get(u *url.URL, result interface{}) (*http.Response, error) { + return c.Do(&http.Request{Method: "GET", URL: u}, nil, result) +} + +// Post performs a POST method call on the given url with the given body and +// unmarshal response into result. +func (c *ParseAPIClient) Post(u *url.URL, body, result interface{}) (*http.Response, error) { + return c.Do(&http.Request{Method: "POST", URL: u}, body, result) +} + +// Put performs a PUT method call on the given url with the given body and +// unmarshal response into result. +func (c *ParseAPIClient) Put(u *url.URL, body, result interface{}) (*http.Response, error) { + return c.Do(&http.Request{Method: "PUT", URL: u}, body, result) +} + +// Delete performs a DELETE method call on the given url and unmarshal response +// into result. +func (c *ParseAPIClient) Delete(u *url.URL, result interface{}) (*http.Response, error) { + return c.Do(&http.Request{Method: "DELETE", URL: u}, nil, result) +} + +// RoundTrip is a wrapper for parse.Client.RoundTrip +func (c *ParseAPIClient) RoundTrip(req *http.Request) (*http.Response, error) { + req.Header = c.appendCommonHeaders(req.Header) + return c.APIClient.RoundTrip(req) +} + +// Do is a wrapper for parse.Client.Do +func (c *ParseAPIClient) Do(req *http.Request, body, result interface{}) (*http.Response, error) { + req.Header = c.appendCommonHeaders(req.Header) + return c.APIClient.Do(req, body, result) +} + +// WithCredentials is a wrapper for parse.Client.WithCredentials +func (c *ParseAPIClient) WithCredentials(cr parse.Credentials) *ParseAPIClient { + c.APIClient = c.APIClient.WithCredentials(cr) + return c +} diff --git a/parsecli/config.go b/parsecli/config.go new file mode 100644 index 0000000..ac518d6 --- /dev/null +++ b/parsecli/config.go @@ -0,0 +1,276 @@ +package parsecli + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "os" + "path/filepath" + + "github.com/facebookgo/stackerr" +) + +const ( + LegacyParseFormat = iota + ParseFormat + HerokuFormat + + ParseLocal = ".parse.local" + ParseProject = ".parse.project" + DefaultKey = "_default" +) + +type AppConfig interface { + GetApplicationID() string + GetMasterKey(e *Env) (string, error) + GetApplicationAuth(e *Env) (string, error) + GetLink() string +} + +type Config interface { + App(string) (AppConfig, error) + AddAlias(string, string) error + SetDefaultApp(string) error + + GetProjectConfig() *ProjectConfig + GetDefaultApp() string + GetNumApps() int + PrettyPrintApps(*Env) +} + +type ProjectConfig struct { + // Type indicates the type of config. + // Some examples are legacy, parse, etc. + Type int `json:"project_type,omitempty"` + // Parse stores the project config for Parse type. + // Currently jssdk version is the only + // project level config for Parse type. + Parse *ParseProjectConfig `json:"parse,omitempty"` + // ParserEmail is an email id of the Parse developer. + // It is associated with this project. + // It is used to fetch appropriate credentials from netrc. + ParserEmail string `json:"email,omitempty"` +} + +func GetConfigFile(e *Env) string { + switch e.Type { + case LegacyParseFormat: + return filepath.Join(e.Root, LegacyConfigFile) + case ParseFormat: + return filepath.Join(e.Root, ParseLocal) + } + return "" +} + +func ConfigFromDir(dir string) (Config, error) { + l, err := readLegacyConfigFile(filepath.Join(dir, LegacyConfigFile)) + if err != nil && !stackerr.HasUnderlying(err, stackerr.MatcherFunc(os.IsNotExist)) { + return nil, err + } + if l != nil { // legacy config format + projectConfig := &ProjectConfig{ + Type: LegacyParseFormat, + Parse: &ParseProjectConfig{ + JSSDK: l.Global.ParseVersion, + }, + ParserEmail: l.Global.ParserEmail, + } + applications := l.Applications + if applications == nil { + applications = make(map[string]*ParseAppConfig) + } + return &ParseConfig{ + Applications: applications, + ProjectConfig: projectConfig, + }, nil + } + + canonicalize := func(err error) error { + if err == nil { + return nil + } + if stackerr.HasUnderlying(err, stackerr.MatcherFunc(os.IsNotExist)) { + return stackerr.New("Command must be run inside a Parse project.") + } + return err + } + + // current config format + p, err := readProjectConfigFile(filepath.Join(dir, ParseProject)) + if err != nil { + return nil, canonicalize(err) + } + configFile := filepath.Join(dir, ParseLocal) + switch p.Type { + case ParseFormat: + c, err := readParseConfigFile(configFile) + if err != nil { + return nil, canonicalize(err) + } + if c.Applications == nil { + c.Applications = make(map[string]*ParseAppConfig) + } + c.ProjectConfig = p + return c, nil + + case HerokuFormat: + c, err := readHerokuConfigFile(configFile) + if err != nil { + return nil, canonicalize(err) + } + if c.Applications == nil { + c.Applications = make(map[string]*HerokuAppConfig) + } + c.ProjectConfig = p + return c, nil + } + + return nil, stackerr.Newf("Unknown project type: %d.", p.Type) +} + +func writeConfigFile(c Config, path string) error { + b, err := json.MarshalIndent(c, "", " ") + if err != nil { + return stackerr.Wrap(err) + } + return stackerr.Wrap(ioutil.WriteFile(path, b, 0600)) +} + +func StoreConfig(e *Env, c Config) error { + projectType := c.GetProjectConfig().Type + switch projectType { + case LegacyParseFormat: + pc, ok := (c).(*ParseConfig) + if !ok { + return stackerr.Newf("Incorrect project type: 'legacy'.") + } + lconf := &legacyConfig{Applications: pc.Applications} + lconf.Global.ParseVersion = pc.ProjectConfig.Parse.JSSDK + return writeLegacyConfigFile( + lconf, + filepath.Join(e.Root, LegacyConfigFile), + ) + case ParseFormat, HerokuFormat: + return writeConfigFile(c, filepath.Join(e.Root, ParseLocal)) + } + return stackerr.Newf("Unknown project type: %d.", projectType) +} + +func readProjectConfigFile(path string) (*ProjectConfig, error) { + f, err := os.Open(path) + if err != nil { + return nil, stackerr.Wrap(err) + } + defer f.Close() + var p ProjectConfig + if err := json.NewDecoder(f).Decode(&p); err != nil { + return nil, stackerr.Newf("Config file %q is not valid JSON.", path) + } + return &p, err +} + +func writeProjectConfig(p *ProjectConfig, path string) error { + b, err := json.MarshalIndent(p, "", " ") + if err != nil { + return stackerr.Wrap(err) + } + return stackerr.Wrap(ioutil.WriteFile(path, b, 0600)) +} + +func StoreProjectConfig(e *Env, c Config) error { + p := c.GetProjectConfig() + switch p.Type { + case LegacyParseFormat: + p, ok := (c).(*ParseConfig) + if !ok { + return stackerr.New("Invalid Cloud Code config.") + } + lconf := &legacyConfig{Applications: p.Applications} + lconf.Global.ParseVersion = p.ProjectConfig.Parse.JSSDK + lconf.Global.ParserEmail = p.ProjectConfig.ParserEmail + return writeLegacyConfigFile( + lconf, + filepath.Join(e.Root, LegacyConfigFile), + ) + + case ParseFormat, HerokuFormat: + return writeProjectConfig(p, filepath.Join(e.Root, ParseProject)) + } + + return stackerr.Newf("Unknown project type: %d.", p.Type) +} + +func IsProjectDir(cur string) bool { + _, err := os.Stat(filepath.Join(cur, ParseProject)) + if err != nil { + return false + } + _, err = os.Stat(filepath.Join(cur, ParseLocal)) + if err != nil { + return false + } + return true +} + +func GetProjectRoot(e *Env, cur string) string { + if IsProjectDir(cur) { + return cur + } + + root := cur + base := filepath.Base(root) + + for base != "." && base != string(filepath.Separator) { + base = filepath.Base(root) + root = filepath.Dir(root) + if IsProjectDir(root) { + return root + } + } + + return cur +} + +func PrintDefault(e *Env, defaultApp string) error { + if defaultApp == "" { + return stackerr.New("No app is set as default app") + } + fmt.Fprintf(e.Out, "Current default app is %s\n", defaultApp) + return nil +} + +func SetDefault(e *Env, newDefault, defaultApp string, c Config) error { + projectType := c.GetProjectConfig().Type + switch projectType { + case LegacyParseFormat, ParseFormat: + p, ok := c.(*ParseConfig) + if !ok { + return stackerr.New("Invalid Cloud Code config.") + } + return setParseDefault(e, newDefault, defaultApp, p) + + case HerokuFormat: + h, ok := c.(*HerokuConfig) + if !ok { + return stackerr.New("Invalid Cloud Code config.") + } + return SetHerokuDefault(e, newDefault, defaultApp, h) + } + + return stackerr.Newf("Unknown project type: %d.", projectType) +} + +func SetParserEmail(e *Env, email string) error { + config, err := ConfigFromDir(e.Root) + if err != nil { + return err + } + config.GetProjectConfig().ParserEmail = email + err = StoreProjectConfig(e, config) + if err != nil { + fmt.Fprintln(e.Err, "Could not set parser email for project.") + return err + } + fmt.Fprintf(e.Out, "Successfully configured email for current project to: %q\n", email) + return nil +} diff --git a/parsecli/config_test.go b/parsecli/config_test.go new file mode 100644 index 0000000..968ed71 --- /dev/null +++ b/parsecli/config_test.go @@ -0,0 +1,72 @@ +package parsecli + +import ( + "io/ioutil" + "os" + "path/filepath" + "testing" + + "github.com/facebookgo/ensure" +) + +func TestGetConfigFile(t *testing.T) { + t.Parallel() + h := NewHarness(t) + defer h.Stop() + + h.Env.Type = LegacyParseFormat + ensure.DeepEqual(t, GetConfigFile(h.Env), filepath.Join(h.Env.Root, LegacyConfigFile)) + + h.Env.Type = ParseFormat + ensure.DeepEqual(t, GetConfigFile(h.Env), filepath.Join(h.Env.Root, ParseLocal)) +} + +func TestConfigFromDirFormatType(t *testing.T) { + t.Parallel() + h := NewHarness(t) + h.MakeEmptyRoot() + defer h.Stop() + + ensure.Nil(t, CloneSampleCloudCode(h.Env, true)) + + c, err := ConfigFromDir(h.Env.Root) + ensure.Nil(t, err) + + ensure.DeepEqual(t, c.GetProjectConfig().Type, ParseFormat) + + ensure.Nil(t, os.MkdirAll(filepath.Join(h.Env.Root, ConfigDir), 0755)) + ensure.Nil(t, ioutil.WriteFile(filepath.Join(h.Env.Root, LegacyConfigFile), + []byte("{}"), + 0600), + ) + c, err = ConfigFromDir(h.Env.Root) + ensure.Nil(t, err) + + ensure.DeepEqual(t, c.GetProjectConfig().Type, LegacyParseFormat) +} + +func TestGetProjectRoot(t *testing.T) { + t.Parallel() + h := NewHarness(t) + h.MakeEmptyRoot() + defer h.Stop() + + ensure.Nil(t, os.Mkdir(filepath.Join(h.Env.Root, "parse"), 0755)) + ensure.Nil(t, os.Mkdir(filepath.Join(h.Env.Root, "parse", "config"), 0755)) + f, err := os.Create(filepath.Join(h.Env.Root, "parse", LegacyConfigFile)) + ensure.Nil(t, err) + defer f.Close() + ensure.Nil(t, os.Mkdir(filepath.Join(h.Env.Root, "parse", "cloud"), 0755)) + ensure.Nil(t, os.Mkdir(filepath.Join(h.Env.Root, "parse", "public"), 0755)) + ensure.Nil(t, os.MkdirAll(filepath.Join(h.Env.Root, "parse", "cloud", "other", "config"), 0755)) + + ensure.DeepEqual(t, GetLegacyProjectRoot(h.Env, h.Env.Root), h.Env.Root) + + ensure.DeepEqual(t, GetLegacyProjectRoot(h.Env, filepath.Join(h.Env.Root, "parse", "config")), filepath.Join(h.Env.Root, "parse")) + + ensure.DeepEqual(t, GetLegacyProjectRoot(h.Env, filepath.Join(h.Env.Root, "parse", "cloud")), filepath.Join(h.Env.Root, "parse")) + + ensure.DeepEqual(t, GetLegacyProjectRoot(h.Env, filepath.Join(h.Env.Root, "parse", "public")), filepath.Join(h.Env.Root, "parse")) + + ensure.DeepEqual(t, GetLegacyProjectRoot(h.Env, filepath.Join(h.Env.Root, "parse", "cloud", "other")), filepath.Join(h.Env.Root, "parse")) +} diff --git a/parsecli/context.go b/parsecli/context.go new file mode 100644 index 0000000..a337d82 --- /dev/null +++ b/parsecli/context.go @@ -0,0 +1,54 @@ +package parsecli + +import ( + "fmt" + "net/http" + + "github.com/facebookgo/parse" +) + +type Context struct { + Config Config + AppName string + AppConfig AppConfig +} + +func newContext(e *Env, appName string) (*Context, error) { + config, err := ConfigFromDir(e.Root) + if err != nil { + return nil, err + } + + app, err := config.App(appName) + if err != nil { + return nil, err + } + + masterKey, err := app.GetMasterKey(e) + if err != nil { + return nil, err + } + e.ParseAPIClient = e.ParseAPIClient.WithCredentials( + parse.MasterKey{ + ApplicationID: app.GetApplicationID(), + MasterKey: masterKey, + }, + ) + + if e.HerokuAPIClient != nil { + authToken, err := app.GetApplicationAuth(e) + if err != nil { + return nil, err + } + headers := make(http.Header) + headers.Add("Authorization", fmt.Sprintf("Bearer %s", authToken)) + e.HerokuAPIClient.AdditionalHeaders = headers + e.HerokuAPIClient.UserAgent = UserAgent + } + + return &Context{ + AppName: appName, + AppConfig: app, + Config: config, + }, nil +} diff --git a/parsecli/heroku_config.go b/parsecli/heroku_config.go new file mode 100644 index 0000000..850189c --- /dev/null +++ b/parsecli/heroku_config.go @@ -0,0 +1,258 @@ +package parsecli + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "os" + "regexp" + "sort" + + "github.com/facebookgo/jsonpipe" + "github.com/facebookgo/stackerr" +) + +type HerokuAppConfig struct { + ParseAppID string `json:"parseAppId,omitempty"` + MasterKey string `json:"masterKey,omitempty"` + HerokuAppID string `json:"herokuAppId,omitempty"` + HerokuAccessToken string `json:"herokuAccessToken,omitempty"` + Link string `json:"link,omitempty"` + + masterKey string + herokuAccessToken string +} + +func (c *HerokuAppConfig) WithHiddenMasterKey(masterKey string) *HerokuAppConfig { + c.masterKey = masterKey + return c +} + +func (c *HerokuAppConfig) WithHiddenAccessToken(accessToken string) *HerokuAppConfig { + c.herokuAccessToken = accessToken + return c +} + +func (c *HerokuAppConfig) GetApplicationID() string { + return c.ParseAppID +} + +func (c *HerokuAppConfig) GetMasterKey(e *Env) (string, error) { + if c.MasterKey != "" { + return c.MasterKey, nil + } + if c.masterKey != "" { + return c.masterKey, nil + } + app, err := FetchAppKeys(e, c.GetApplicationID()) + if err != nil { + return "", err + } + c.masterKey = app.MasterKey + return c.masterKey, nil +} + +func (c *HerokuAppConfig) GetApplicationAuth(e *Env) (string, error) { + if c.herokuAccessToken != "" { + return c.herokuAccessToken, nil + } + + var l Login + _, err := l.AuthUserWithToken(e, true) + if err != nil { + return "", err + } + req, err := http.NewRequest( + "POST", + "herokuToken", + ioutil.NopCloser( + jsonpipe.Encode( + map[string]string{ + "id": c.HerokuAppID, + }, + ), + ), + ) + if err != nil { + return "", stackerr.Wrap(err) + } + req.Header = make(http.Header) + req.Header.Set("X-Parse-Application-Id", c.ParseAppID) + req.Header.Set("X-Parse-Account-Key", l.Credentials.Token) + + resp, err := e.ParseAPIClient.RoundTrip(req) + if err != nil { + return "", stackerr.Wrap(err) + } + result := &struct { + Token string `json:"token"` + }{} + if err := json.NewDecoder(resp.Body).Decode(result); err != nil { + return "", stackerr.Wrap(err) + } + c.herokuAccessToken = result.Token + return result.Token, nil +} + +func (c *HerokuAppConfig) GetLink() string { + return c.Link +} + +type HerokuConfig struct { + Applications map[string]*HerokuAppConfig `json:"applications,omityempty"` + ProjectConfig *ProjectConfig `json:"-"` +} + +func (c *HerokuConfig) AddAlias(name, link string) error { + if _, found := c.Applications[name]; found { + return stackerr.Newf("App %q has already been added.", name) + } + if _, found := c.Applications[link]; !found { + return stackerr.Newf("App %q wasn't found.", link) + } + c.Applications[name] = &HerokuAppConfig{Link: link} + return nil +} + +func (c *HerokuConfig) SetDefaultApp(name string) error { + delete(c.Applications, DefaultKey) + return c.AddAlias(DefaultKey, name) +} + +func (c *HerokuConfig) App(name string) (AppConfig, error) { + ac, found := c.Applications[name] + if !found { + if name == DefaultKey { + return nil, stackerr.Newf("No default app configured.") + } + return nil, stackerr.Newf("App %q wasn't found.", name) + } + if ac.Link != "" { + return c.App(ac.Link) + } + return ac, nil +} + +func (c *HerokuConfig) GetProjectConfig() *ProjectConfig { + return c.ProjectConfig +} + +func (c *HerokuConfig) GetDefaultApp() string { + var defaultApp string + if defaultKeyLink, ok := c.Applications[DefaultKey]; ok { + defaultApp = defaultKeyLink.Link + } + return defaultApp +} + +func (c *HerokuConfig) GetNumApps() int { + return len(c.Applications) +} + +var herokuAppNotFoundRegex = regexp.MustCompile("App not found") + +func HerokuAppNotFound(err error) bool { + return herokuAppNotFoundRegex.MatchString(err.Error()) +} + +func FetchHerokuAppName(id string, e *Env) (string, error) { + req, err := http.NewRequest( + "GET", + fmt.Sprintf("https://api.heroku.com/apps/%s", id), + nil, + ) + if err != nil { + return "", stackerr.Wrap(err) + } + resp, err := e.ParseAPIClient.RoundTrip(req) + if err != nil { + return "", stackerr.Wrap(err) + } + app := struct { + Name string `json:"name"` + }{} + if err := json.NewDecoder(resp.Body).Decode(&app); err != nil { + return "", stackerr.Wrap(err) + } + return app.Name, nil +} + +func (c *HerokuConfig) PrettyPrintApps(e *Env) { + apps := c.Applications + + defaultApp := c.GetDefaultApp() + + var appNames []string + for appName := range apps { + appNames = append(appNames, appName) + } + sort.Strings(appNames) + + if len(appNames) == 0 { + return + } + + fmt.Fprintln( + e.Out, + "The following apps are associated with cloud code in the current directory:", + ) + + for _, appName := range appNames { + if appName == DefaultKey { + continue + } + if defaultApp == appName { + fmt.Fprint(e.Out, "* ") + } else { + fmt.Fprint(e.Out, " ") + } + fmt.Fprintf(e.Out, "%s", appName) + + config, _ := apps[appName] + if config.GetLink() != "" { + fmt.Fprintf(e.Out, " -> %s", config.GetLink()) + } + herokuAppName, err := FetchHerokuAppName(config.HerokuAppID, e) + if err != nil { + if stackerr.HasUnderlying(err, stackerr.MatcherFunc(HerokuAppNotFound)) { + herokuAppName = "" + } else { + herokuAppName = config.HerokuAppID + } + } + fmt.Fprintf(e.Out, " (%q)\n", herokuAppName) + } +} + +func readHerokuConfigFile(path string) (*HerokuConfig, error) { + f, err := os.Open(path) + if err != nil { + return nil, stackerr.Wrap(err) + } + defer f.Close() + var c HerokuConfig + if err := json.NewDecoder(f).Decode(&c); err != nil { + return nil, stackerr.Newf("Config file %q is not valid JSON.", path) + } + return &c, nil +} + +func SetHerokuDefault(e *Env, newDefault, defaultApp string, herokuConfig *HerokuConfig) error { + apps := herokuConfig.Applications + if _, ok := apps[newDefault]; !ok { + herokuConfig.PrettyPrintApps(e) + return stackerr.Newf("Invalid application name \"%s\". Please select from the valid applications printed above.", newDefault) + } + + if defaultApp == "" { + apps[DefaultKey] = &HerokuAppConfig{Link: newDefault} + } else { + apps[DefaultKey].Link = newDefault + } + if err := StoreConfig(e, herokuConfig); err != nil { + return err + } + fmt.Fprintf(e.Out, "Default app set to %s.\n", newDefault) + return nil +} diff --git a/parsecli/legacy_config.go b/parsecli/legacy_config.go new file mode 100644 index 0000000..e63a727 --- /dev/null +++ b/parsecli/legacy_config.go @@ -0,0 +1,69 @@ +package parsecli + +import ( + "encoding/json" + "io/ioutil" + "os" + "path/filepath" + + "github.com/facebookgo/stackerr" +) + +const ConfigDir = "config" + +var LegacyConfigFile = filepath.Join(ConfigDir, "global.json") + +type legacyConfig struct { + Global struct { + ParseVersion string `json:"parseVersion,omitempty"` + ParserEmail string `json:"email,omitempty"` + } `json:"global,omitempty"` + Applications map[string]*ParseAppConfig `json:"applications,omitempty"` +} + +func readLegacyConfigFile(path string) (*legacyConfig, error) { + f, err := os.Open(path) + if err != nil { + return nil, stackerr.Wrap(err) + } + defer f.Close() + var c legacyConfig + if err := json.NewDecoder(f).Decode(&c); err != nil { + return nil, stackerr.Newf("Config file %q is not valid JSON.", path) + } + return &c, nil +} + +func writeLegacyConfigFile(c *legacyConfig, path string) error { + // if config directory does not exist yet, first create it + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return stackerr.Wrap(err) + } + + b, err := json.MarshalIndent(c, "", " ") + if err != nil { + return stackerr.Wrap(err) + } + return stackerr.Wrap(ioutil.WriteFile(path, b, 0600)) +} + +func GetLegacyProjectRoot(e *Env, cur string) string { + if _, err := os.Stat(filepath.Join(cur, LegacyConfigFile)); err == nil { + return cur + } + + root := cur + base := filepath.Base(root) + + for base != "." && base != string(filepath.Separator) { + base = filepath.Base(root) + root = filepath.Dir(root) + if base == "cloud" || base == "public" || base == "config" { + if _, err := os.Stat(filepath.Join(root, LegacyConfigFile)); err == nil { + return root + } + } + } + + return cur +} diff --git a/legacy_config_test.go b/parsecli/legacy_config_test.go similarity index 57% rename from legacy_config_test.go rename to parsecli/legacy_config_test.go index 0c6ec1c..8f6823e 100644 --- a/legacy_config_test.go +++ b/parsecli/legacy_config_test.go @@ -1,4 +1,4 @@ -package main +package parsecli import ( "io/ioutil" @@ -9,10 +9,10 @@ import ( "github.com/facebookgo/ensure" ) -var defaulParseConfig = &parseConfig{ - projectConfig: &projectConfig{ - Type: legacy, - Parse: &parseProjectConfig{}, +var defaultParseConfig = &ParseConfig{ + ProjectConfig: &ProjectConfig{ + Type: LegacyParseFormat, + Parse: &ParseProjectConfig{}, }, } @@ -20,9 +20,9 @@ func TestConfigAddAlias(t *testing.T) { t.Parallel() const name = "foo" const link = "bar" - c := parseConfig{Applications: map[string]*parseAppConfig{link: {}}} - ensure.Nil(t, c.addAlias(name, link)) - ensure.DeepEqual(t, c.Applications, map[string]*parseAppConfig{ + c := ParseConfig{Applications: map[string]*ParseAppConfig{link: {}}} + ensure.Nil(t, c.AddAlias(name, link)) + ensure.DeepEqual(t, c.Applications, map[string]*ParseAppConfig{ link: {}, name: {Link: link}, }) @@ -32,52 +32,52 @@ func TestConfigAddAliasBadLink(t *testing.T) { t.Parallel() const name = "foo" const link = "bar" - c := parseConfig{} - ensure.Err(t, c.addAlias(name, link), regexp.MustCompile("wasn't found")) + c := ParseConfig{} + ensure.Err(t, c.AddAlias(name, link), regexp.MustCompile("wasn't found")) } func TestConfigAddAliasDupe(t *testing.T) { t.Parallel() const name = "foo" const link = "bar" - c := parseConfig{Applications: map[string]*parseAppConfig{link: {}}} - ensure.Nil(t, c.addAlias(name, link)) - ensure.Err(t, c.addAlias(name, link), regexp.MustCompile("has already been added")) + c := ParseConfig{Applications: map[string]*ParseAppConfig{link: {}}} + ensure.Nil(t, c.AddAlias(name, link)) + ensure.Err(t, c.AddAlias(name, link), regexp.MustCompile("has already been added")) } func TestConfigSetDefault(t *testing.T) { t.Parallel() const name = "foo" - c := parseConfig{Applications: map[string]*parseAppConfig{name: {}}} - ensure.Nil(t, c.setDefaultApp(name)) - ensure.DeepEqual(t, c.Applications, map[string]*parseAppConfig{ + c := ParseConfig{Applications: map[string]*ParseAppConfig{name: {}}} + ensure.Nil(t, c.SetDefaultApp(name)) + ensure.DeepEqual(t, c.Applications, map[string]*ParseAppConfig{ name: {}, - defaultKey: {Link: name}, + DefaultKey: {Link: name}, }) } func TestConfigApp(t *testing.T) { t.Parallel() const name = "foo" - c := parseConfig{Applications: map[string]*parseAppConfig{name: {}}} - ac, err := c.app(name) + c := ParseConfig{Applications: map[string]*ParseAppConfig{name: {}}} + ac, err := c.App(name) ensure.Nil(t, err) - ensure.DeepEqual(t, ac, &parseAppConfig{}) + ensure.DeepEqual(t, ac, &ParseAppConfig{}) } func TestConfigAppNotFound(t *testing.T) { t.Parallel() const name = "foo" - c := parseConfig{Applications: map[string]*parseAppConfig{}} - ac, err := c.app(name) + c := ParseConfig{Applications: map[string]*ParseAppConfig{}} + ac, err := c.App(name) ensure.True(t, ac == nil) ensure.Err(t, err, regexp.MustCompile(`App "foo" wasn't found`)) } func TestConfigAppDefaultNotFound(t *testing.T) { t.Parallel() - c := parseConfig{Applications: map[string]*parseAppConfig{}} - ac, err := c.app(defaultKey) + c := ParseConfig{Applications: map[string]*ParseAppConfig{}} + ac, err := c.App(DefaultKey) ensure.True(t, ac == nil) ensure.Err(t, err, regexp.MustCompile("No default app configured")) } @@ -86,12 +86,12 @@ func TestConfigAppLink(t *testing.T) { t.Parallel() const name = "foo" const link = "bar" - expected := &parseAppConfig{ApplicationID: "xyz"} - c := parseConfig{Applications: map[string]*parseAppConfig{ + expected := &ParseAppConfig{ApplicationID: "xyz"} + c := ParseConfig{Applications: map[string]*ParseAppConfig{ link: expected, name: {Link: link}, }} - ac, err := c.app(name) + ac, err := c.App(name) ensure.Nil(t, err) ensure.DeepEqual(t, ac, expected) } @@ -101,16 +101,16 @@ func TestConfigMissing(t *testing.T) { dir, err := ioutil.TempDir("", "parse-cli-config-") ensure.Nil(t, err) defer os.RemoveAll(dir) - c, err := configFromDir(dir) + c, err := ConfigFromDir(dir) ensure.True(t, c == nil) - ensure.Err(t, err, regexp.MustCompile("Command must be run in a directory containing a Parse project.")) + ensure.Err(t, err, regexp.MustCompile("Command must be run inside a Parse project.")) } func TestConfigGlobalMalformed(t *testing.T) { t.Parallel() dir := makeDirWithConfig(t, "foo") defer os.RemoveAll(dir) - c, err := configFromDir(dir) + c, err := ConfigFromDir(dir) ensure.True(t, c == nil) ensure.Err(t, err, regexp.MustCompile("is not valid JSON")) } @@ -119,7 +119,7 @@ func TestConfigGlobalEmpty(t *testing.T) { t.Parallel() dir := makeDirWithConfig(t, "") defer os.RemoveAll(dir) - c, err := configFromDir(dir) + c, err := ConfigFromDir(dir) ensure.True(t, c == nil) ensure.Err(t, err, regexp.MustCompile("is not valid JSON")) } @@ -128,7 +128,7 @@ func TestConfigEmptyGlobalOnly(t *testing.T) { t.Parallel() dir := makeDirWithConfig(t, "{}") defer os.RemoveAll(dir) - c, err := configFromDir(dir) + c, err := ConfigFromDir(dir) ensure.Nil(t, err) - ensure.DeepEqual(t, c.getNumApps(), 0) + ensure.DeepEqual(t, c.GetNumApps(), 0) } diff --git a/parsecli/login.go b/parsecli/login.go new file mode 100644 index 0000000..3f179f6 --- /dev/null +++ b/parsecli/login.go @@ -0,0 +1,352 @@ +package parsecli + +import ( + "errors" + "fmt" + "io" + "io/ioutil" + "net/http" + "net/url" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/bgentry/go-netrc/netrc" + "github.com/bgentry/speakeasy" + "github.com/facebookgo/jsonpipe" + "github.com/facebookgo/stackerr" + "github.com/mitchellh/go-homedir" + "github.com/skratchdot/open-golang/open" +) + +const keysURL = "https://www.parse.com/account/keys" + +type Credentials struct { + Email string + Password string + Token string +} + +type Login struct { + Credentials Credentials + TokenReader io.Reader +} + +var ( + errAuth = errors.New(`Sorry, we do not have a user with this username and password. +If you do not remember your password, please follow instructions at: + https://www.parse.com/login +to reset your password`) + + tokenErrMsgf = `Sorry, the account key: %q you provided is not valid. +Please follow instructions at %q to generate a new one. +` + keyNotConfigured = regexp.MustCompile("Account key not configured") + + parseNetrc = filepath.Join(".parse", "netrc") +) + +func accountKeyNotConfigured(err error) bool { + if err == nil { + return false + } + return keyNotConfigured.MatchString(err.Error()) +} + +func (l *Login) populateCreds(e *Env) error { + if l.Credentials.Email != "" && l.Credentials.Password != "" { + return nil + } + + fmt.Fprint(e.Out, "Email: ") + fmt.Fscanf(e.In, "%s\n", &l.Credentials.Email) + + var ( + password string + err error + ) + if e.In == os.Stdin { + password, err = speakeasy.Ask("Password (will be hidden): ") + if err != nil { + return err + } + } else { + // NOTE: only for testing + fmt.Fscanf(e.In, "%s\n", &password) + } + + if password != "" { + l.Credentials.Password = password + } + return nil +} + +func (l *Login) getTokensReader() (io.Reader, error) { + if l.TokenReader != nil { + return l.TokenReader, nil + } + homeDir, err := homedir.Dir() + if err != nil { + return nil, stackerr.Wrap(err) + } + location := filepath.Join(homeDir, parseNetrc) + file, err := os.OpenFile(location, os.O_RDONLY, 0600) + if err != nil { + return nil, stackerr.Wrap(err) + } + l.TokenReader = file + return file, nil +} + +func (l *Login) GetTokenCredentials(e *Env, email string) (bool, *Credentials, error) { + reader, err := l.getTokensReader() + if err != nil { + return false, nil, stackerr.Wrap(err) + } + tokens, err := netrc.Parse(reader) + if err != nil { + return false, nil, stackerr.Wrap(err) + } + server, err := getHostFromURL(e.Server, email) + if err != nil { + return false, nil, err + } + machine := tokens.FindMachine(server) + if machine != nil { + return true, + &Credentials{ + Token: machine.Password, + }, nil + } + + if email == "" { + return false, nil, stackerr.Newf("Could not find account key for %q", server) + } + + // check for system default account key for the given server + // since we could not find account key for the given account (email) + server, err = getHostFromURL(e.Server, "") + if err != nil { + return false, nil, err + } + machine = tokens.FindMachine(server) + if machine != nil { + return false, + &Credentials{ + Token: machine.Password, + }, nil + } + return false, + nil, + stackerr.Newf( + `Could not find account key for email: %q, +and default access key not configured for %q +`, + email, + e.Server, + ) +} + +func (l *Login) updatedNetrcContent( + e *Env, + content io.Reader, + email string, + credentials *Credentials, +) ([]byte, error) { + tokens, err := netrc.Parse(content) + if err != nil { + return nil, stackerr.Wrap(err) + } + + server, err := getHostFromURL(e.Server, email) + if err != nil { + return nil, err + } + machine := tokens.FindMachine(server) + if machine == nil { + machine = tokens.NewMachine(server, "default", credentials.Token, "") + } else { + machine.UpdatePassword(credentials.Token) + } + + updatedContent, err := tokens.MarshalText() + if err != nil { + return nil, stackerr.Wrap(err) + } + return updatedContent, nil +} + +func (l *Login) StoreCredentials(e *Env, email string, credentials *Credentials) error { + if l.TokenReader != nil { + // tests should not store credentials + return nil + } + + homeDir, err := homedir.Dir() + if err != nil { + return stackerr.Wrap(err) + } + + location := filepath.Join(homeDir, parseNetrc) + if err := os.MkdirAll(filepath.Dir(location), 0755); err != nil { + return stackerr.Wrap(err) + } + file, err := os.OpenFile(location, os.O_RDONLY|os.O_CREATE, 0600) + if err != nil { + return stackerr.Wrap(err) + } + content, err := l.updatedNetrcContent(e, file, email, credentials) + if err != nil { + return err + } + + file, err = os.OpenFile(location, os.O_WRONLY|os.O_TRUNC, 0600) + _, err = file.Write(content) + return stackerr.Wrap(err) +} + +func (l *Login) AuthToken(e *Env, token string) (string, error) { + req := &http.Request{ + Method: "POST", + URL: &url.URL{Path: "accountkey"}, + Body: ioutil.NopCloser( + jsonpipe.Encode( + map[string]string{ + "accountKey": token, + }, + ), + ), + } + + res := &struct { + Email string `json:"email"` + }{} + if response, err := e.ParseAPIClient.Do(req, nil, res); err != nil { + if response != nil && response.StatusCode == http.StatusUnauthorized { + return "", stackerr.Newf(tokenErrMsgf, Last4(token), keysURL) + } + return "", stackerr.Wrap(err) + } + + if e.ParserEmail != "" && res.Email != e.ParserEmail { + return "", stackerr.Newf("Account key %q does not belong to %q", Last4(token), e.ParserEmail) + } + return res.Email, nil +} + +func (l *Login) AuthUserWithToken(e *Env, strict bool) (string, error) { + _, tokenCredentials, err := l.GetTokenCredentials(e, e.ParserEmail) + if err != nil { + // user never created an account key: educate them + if stackerr.HasUnderlying(err, stackerr.MatcherFunc(os.IsNotExist)) { + if strict { + fmt.Fprintln( + e.Out, + `To proceed further, you must configure an account key. +`, + ) + } else { + fmt.Fprintln( + e.Out, + `We've changed the way the CLI works. +To save time logging in, you should create an account key. +`, + ) + + } + + fmt.Fprintln( + e.Out, + `Type "parse configure accountkey" to create a new account key. +Read more at: https://parse.com/docs/cloudcode/guide#command-line-account-keys`) + return "", stackerr.New("Account key not configured") + } + + return "", err + } + + email, err := l.AuthToken(e, tokenCredentials.Token) + if err != nil { + fmt.Fprintf(e.Err, "Account key could not be used.\nError: %s\n\n", ErrorString(e, err)) + return "", err + } + + l.Credentials = *tokenCredentials + return email, nil +} + +func (l *Login) AuthUser(e *Env, strict bool) error { + _, err := l.AuthUserWithToken(e, strict) + if err == nil { + return nil + } + if strict { + return err + } + + if !stackerr.HasUnderlying(err, stackerr.MatcherFunc(accountKeyNotConfigured)) { + fmt.Fprintln( + e.Out, + `Type "parse configure accountkey" to create a new account key. +Read more at: https://parse.com/docs/cloudcode/guide#command-line-account-keys + +Please login to Parse using your email and password.`, + ) + } + + apps := &Apps{} + for i := 0; i < numRetries; i++ { + err := l.populateCreds(e) + if err != nil { + return err + } + apps.Login.Credentials = l.Credentials + _, err = apps.RestFetchApps(e) + if err == nil { + return nil + } + + if i == numRetries-1 && err != nil { + return err + } + if err != errAuth { + fmt.Fprintf(e.Err, "Got error: %s", ErrorString(e, err)) + } + fmt.Fprintf(e.Err, "%s\nPlease try again...\n", err) + l.Credentials.Password = "" + } + return errAuth +} + +func (l *Login) HelpCreateToken(e *Env) (string, error) { + for i := 0; i < 4; i++ { + fmt.Fprintf(e.Out, ` +Input your account key or press ENTER to generate a new one. +NOTE: on pressing ENTER we'll try to open the url: + %q +in default browser. +`, + keysURL, + ) + fmt.Fprintf(e.Out, `Account Key: `) + + var token string + fmt.Fscanf(e.In, "%s\n", &token) + token = strings.TrimSpace(token) + if token != "" { + return token, nil + } + + err := open.Run(keysURL) + if err != nil { + fmt.Fprintf(e.Err, + `Sorry, we couldn’t open the browser for you. +Go here to generate an account key: %q +`, + keysURL, + ) + } + } + return "", stackerr.New("Account key cannot be empty. Please try again.") +} diff --git a/parsecli/login_test.go b/parsecli/login_test.go new file mode 100644 index 0000000..b865f99 --- /dev/null +++ b/parsecli/login_test.go @@ -0,0 +1,211 @@ +package parsecli + +import ( + "regexp" + "strings" + "testing" + + "github.com/facebookgo/ensure" +) + +func TestPopulateCreds(t *testing.T) { + t.Parallel() + + h := NewHarness(t) + defer h.Stop() + + l := &Login{} + h.Env.In = strings.NewReader("email\npassword\n") + ensure.Nil(t, l.populateCreds(h.Env)) + ensure.DeepEqual(t, l.Credentials.Email, "email") + ensure.DeepEqual(t, l.Credentials.Password, "password") +} + +func TestGetTokenCredentials(t *testing.T) { + t.Parallel() + + h := NewHarness(t) + defer h.Stop() + + l := &Login{} + h.Env.Server = "http://api.example.com/1/" + + l.TokenReader = strings.NewReader( + `machine api.example.com + login default + password token + `, + ) + _, credentials, err := l.GetTokenCredentials(h.Env, "") + ensure.Nil(t, err) + ensure.DeepEqual(t, credentials.Token, "token") + + l.TokenReader = strings.NewReader( + `machine api.example.com + login default + password token + `, + ) + h.Env.Server = "http://api.parse.com" + _, credentials, err = l.GetTokenCredentials(h.Env, "") + ensure.Err(t, err, regexp.MustCompile("Could not find account key")) + + l = &Login{} + h.Env.Server = "http://api.example.com/1/" + + l.TokenReader = strings.NewReader( + `machine api.example.com#email + login default + password token + `, + ) + _, credentials, err = l.GetTokenCredentials(h.Env, "email") + ensure.Nil(t, err) + ensure.DeepEqual(t, credentials.Token, "token") + + l.TokenReader = strings.NewReader( + `machine api.example.com#email + login default + password token + `, + ) + h.Env.Server = "http://api.parse.com" + _, credentials, err = l.GetTokenCredentials(h.Env, "email") + ensure.Err(t, err, regexp.MustCompile("Could not find account key")) + + l = &Login{} + h.Env.Server = "http://api.example.com/1/" + + l.TokenReader = strings.NewReader( + `machine api.example.com#email + login default + password token1 +machine api.example.com + login default + password token2 +`, + ) + _, credentials, err = l.GetTokenCredentials(h.Env, "email") + ensure.Nil(t, err) + ensure.DeepEqual(t, credentials.Token, "token1") + + l.TokenReader = strings.NewReader( + `machine api.example.com#email + login default + password token1 +machine api.example.com + login default + password token2 +`, + ) + + _, credentials, err = l.GetTokenCredentials(h.Env, "xmail") + ensure.Nil(t, err) + ensure.DeepEqual(t, credentials.Token, "token2") +} + +func TestAuthUserWithToken(t *testing.T) { + t.Parallel() + + h := NewTokenHarness(t) + defer h.Stop() + + l := &Login{} + h.Env.ParserEmail = "email" + h.Env.Server = "http://api.example.org/1/" + + l.TokenReader = strings.NewReader( + `machine api.example.org#email + login default + password token + `, + ) + _, err := l.AuthUserWithToken(h.Env, false) + ensure.Nil(t, err) + + h.Env.ParserEmail = "email2" + + l.TokenReader = strings.NewReader( + `machine api.example.org#email2 + login default + password token + `, + ) + _, err = l.AuthUserWithToken(h.Env, false) + ensure.Err(t, err, regexp.MustCompile(`does not belong to "email2"`)) + + h.Env.ParserEmail = "" + + l.TokenReader = strings.NewReader( + `machine api.example.org + login default + password token2 + `, + ) + _, err = l.AuthUserWithToken(h.Env, false) + ensure.Err(t, err, regexp.MustCompile("provided is not valid")) + +} + +func TestUpdatedNetrcContent(t *testing.T) { + t.Parallel() + + h := NewHarness(t) + defer h.Stop() + + l := &Login{} + + h.Env.Server = "https://api.example.com/1/" + updated, err := l.updatedNetrcContent( + h.Env, + strings.NewReader( + `machine api.example.com#email + login default + password token0 + +machine api.example.org + login default + password token +`, + ), + "email", + &Credentials{Token: "token"}, + ) + + ensure.Nil(t, err) + ensure.DeepEqual(t, + string(updated), + `machine api.example.com#email + login default + password token + +machine api.example.org + login default + password token +`, + ) + + h.Env.Server = "https://api.example.org/1/" + updated, err = l.updatedNetrcContent(h.Env, + strings.NewReader( + `machine api.example.com + login default + password token +`, + ), + "email", + &Credentials{Token: "token"}, + ) + + ensure.Nil(t, err) + ensure.DeepEqual(t, + string(updated), + `machine api.example.com + login default + password token + +machine api.example.org#email + login default + password token`, + ) +} diff --git a/parsecli/main.go b/parsecli/main.go new file mode 100644 index 0000000..14a7d18 --- /dev/null +++ b/parsecli/main.go @@ -0,0 +1,228 @@ +package parsecli + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "io/ioutil" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/bgentry/heroku-go" + "github.com/facebookgo/clock" + "github.com/facebookgo/ensure" + "github.com/facebookgo/parse" + "github.com/facebookgo/testname" +) + +const ( + Version = "3.0.5" + CloudDir = "cloud" + HostingDir = "public" + DefaultBaseURL = "https://api.parse.com/1/" +) + +var UserAgent = fmt.Sprintf("parse-cli-%s-%s", runtime.GOOS, Version) + +type Env struct { + Root string // project root + Server string // parse api server + Type int // project type + ParserEmail string // email associated with developer parse account + ErrorStack bool + Out io.Writer + Err io.Writer + In io.Reader + Exit func(int) + Clock clock.Clock + ParseAPIClient *ParseAPIClient + HerokuAPIClient *heroku.Client +} + +type Harness struct { + T testing.TB + Out bytes.Buffer + Err bytes.Buffer + Clock *clock.Mock + Env *Env + remove []string +} + +func (h *Harness) MakeEmptyRoot() { + var err error + prefix := fmt.Sprintf("%s-", testname.Get("parse-cli-")) + h.Env.Root, err = ioutil.TempDir("", prefix) + ensure.Nil(h.T, err) + h.remove = append(h.remove, h.Env.Root) +} + +func (h *Harness) MakeWithConfig(global string) { + h.Env.Root = makeDirWithConfig(h.T, global) +} + +func (h *Harness) Stop() { + for _, p := range h.remove { + os.RemoveAll(p) + } +} + +func NewHarness(t testing.TB) *Harness { + te := Harness{ + T: t, + Clock: clock.NewMock(), + } + te.Env = &Env{ + Out: &te.Out, + Err: &te.Err, + Clock: te.Clock, + ParseAPIClient: &ParseAPIClient{APIClient: &parse.Client{}}, + } + return &te +} + +// makes a temp directory with the given global config. +func makeDirWithConfig(t testing.TB, global string) string { + dir, err := ioutil.TempDir("", testname.Get("parse-cli-")) + ensure.Nil(t, err) + ensure.Nil(t, os.Mkdir(filepath.Join(dir, "config"), 0755)) + ensure.Nil(t, ioutil.WriteFile( + filepath.Join(dir, LegacyConfigFile), + []byte(global), + os.FileMode(0600), + )) + return dir +} + +type TransportFunc func(r *http.Request) (*http.Response, error) + +func (t TransportFunc) RoundTrip(r *http.Request) (*http.Response, error) { + return t(r) +} + +func NewTokenHarness(t testing.TB) *Harness { + h := NewHarness(t) + ht := TransportFunc(func(r *http.Request) (*http.Response, error) { + ensure.DeepEqual(t, r.URL.Path, "/1/accountkey") + ensure.DeepEqual(t, r.Method, "POST") + + key := &struct { + AccountKey string `json:"accountKey"` + }{} + ensure.Nil(t, json.NewDecoder(ioutil.NopCloser(r.Body)).Decode(key)) + + if key.AccountKey != "token" { + return &http.Response{ + StatusCode: http.StatusUnauthorized, + Body: ioutil.NopCloser(strings.NewReader(`{"error": "incorrect token"}`)), + }, nil + } + + return &http.Response{ + StatusCode: http.StatusOK, + Body: ioutil.NopCloser(strings.NewReader(`{"email": "email"}`)), + }, nil + }) + h.Env.ParseAPIClient = &ParseAPIClient{APIClient: &parse.Client{Transport: ht}} + return h +} + +func NewAppHarness(t testing.TB) (*Harness, []*App) { + h := NewHarness(t) + + apps := []*App{ + newTestApp("A"), + newTestApp("B"), + } + res := map[string][]*App{"results": apps} + ht := TransportFunc(func(r *http.Request) (*http.Response, error) { + + email := r.Header.Get("X-Parse-Email") + password := r.Header.Get("X-Parse-Password") + token := r.Header.Get("X-Parse-Account-Key") + if !((email == "email" && password == "password") || (token == "token")) { + return &http.Response{ + StatusCode: http.StatusUnauthorized, + Body: ioutil.NopCloser(strings.NewReader(`{"error": "incorrect credentials"}`)), + }, nil + } + + switch r.URL.Path { + case "/1/apps": + if r.Method == "GET" { + return &http.Response{ + StatusCode: http.StatusOK, + Body: ioutil.NopCloser(strings.NewReader(jsonStr(t, &res))), + }, nil + + } + + if r.Method != "POST" || r.Body == nil { + return &http.Response{ + StatusCode: http.StatusNotFound, + }, errors.New("unknown resource") + } + + var params map[string]string + if err := json.NewDecoder(r.Body).Decode(¶ms); err != nil { + return &http.Response{ + StatusCode: http.StatusInternalServerError, + }, err + } + details, err := json.Marshal(newTestApp(params["appName"])) + if err != nil { + return &http.Response{ + StatusCode: http.StatusInternalServerError, + }, err + } + + return &http.Response{ + StatusCode: http.StatusOK, + Body: ioutil.NopCloser(bytes.NewReader(details)), + }, nil + + case "/1/apps/an-app": + ensure.DeepEqual(t, r.Method, "GET") + return &http.Response{ + StatusCode: http.StatusOK, + Body: ioutil.NopCloser(strings.NewReader(jsonStr(t, newTestApp("an-app")))), + }, nil + default: + return &http.Response{ + StatusCode: http.StatusNotFound, + }, nil + } + }) + + h.Env.ParseAPIClient = &ParseAPIClient{APIClient: &parse.Client{Transport: ht}} + return h, apps +} + +func newTestApp(suffix string) *App { + return &App{ + Name: suffix, + DashboardURL: fmt.Sprintf("https://api.example.com/dashboard/%s", suffix), + ApplicationID: fmt.Sprintf("applicationID.%s", suffix), + ClientKey: fmt.Sprintf("clientKey.%s", suffix), + JavaScriptKey: fmt.Sprintf("javaScriptKey.%s", suffix), + WindowsKey: fmt.Sprintf("windowsKey.%s", suffix), + WebhookKey: fmt.Sprintf("webhookKey.%s", suffix), + RestKey: fmt.Sprintf("restKey.%s", suffix), + MasterKey: fmt.Sprintf("masterKey.%s", suffix), + ClientPushEnabled: false, + ClientClassCreationEnabled: true, + RequireRevocableSessions: true, + RevokeSessionOnPasswordChange: true, + } +} + +func jsonStr(t testing.TB, v interface{}) string { + b, err := json.Marshal(v) + ensure.Nil(t, err) + return string(b) +} diff --git a/parsecli/parse_config.go b/parsecli/parse_config.go new file mode 100644 index 0000000..acac657 --- /dev/null +++ b/parsecli/parse_config.go @@ -0,0 +1,175 @@ +package parsecli + +import ( + "encoding/json" + "fmt" + "os" + "sort" + + "github.com/facebookgo/stackerr" +) + +type ParseProjectConfig struct { + JSSDK string `json:"jssdk,omitempty"` +} + +type ParseAppConfig struct { + ApplicationID string `json:"applicationId,omitempty"` + MasterKey string `json:"masterKey,omitempty"` + Link string `json:"link,omitempty"` + + masterKey string +} + +func (c *ParseAppConfig) WithInternalMasterKey(masterKey string) *ParseAppConfig { + c.masterKey = masterKey + return c +} + +func (c *ParseAppConfig) GetApplicationID() string { + return c.ApplicationID +} + +func (c *ParseAppConfig) GetMasterKey(e *Env) (string, error) { + if c.MasterKey != "" { + return c.MasterKey, nil + } + if c.masterKey != "" { + return c.masterKey, nil + } + app, err := FetchAppKeys(e, c.GetApplicationID()) + if err != nil { + return "", err + } + c.masterKey = app.MasterKey + return app.MasterKey, nil +} + +func (c *ParseAppConfig) GetApplicationAuth(e *Env) (string, error) { + return c.GetMasterKey(e) +} + +func (c *ParseAppConfig) GetLink() string { + return c.Link +} + +type ParseConfig struct { + Applications map[string]*ParseAppConfig `json:"applications,omitempty"` + ProjectConfig *ProjectConfig `json:"-"` +} + +func (c *ParseConfig) AddAlias(name, link string) error { + if _, found := c.Applications[name]; found { + return stackerr.Newf("App %q has already been added.", name) + } + if _, found := c.Applications[link]; !found { + return stackerr.Newf("App %q wasn't found.", link) + } + c.Applications[name] = &ParseAppConfig{Link: link} + return nil +} + +func (c *ParseConfig) SetDefaultApp(name string) error { + delete(c.Applications, DefaultKey) + return c.AddAlias(DefaultKey, name) +} + +func (c *ParseConfig) App(name string) (AppConfig, error) { + ac, found := c.Applications[name] + if !found { + if name == DefaultKey { + return nil, stackerr.Newf("No default app configured.") + } + return nil, stackerr.Newf("App %q wasn't found.", name) + } + if ac.Link != "" { + return c.App(ac.Link) + } + return ac, nil +} + +func (c *ParseConfig) GetProjectConfig() *ProjectConfig { + return c.ProjectConfig +} + +func (c *ParseConfig) GetDefaultApp() string { + var defaultApp string + if DefaultKeyLink, ok := c.Applications[DefaultKey]; ok { + defaultApp = DefaultKeyLink.Link + } + return defaultApp +} + +func (c *ParseConfig) GetNumApps() int { + return len(c.Applications) +} + +func (c *ParseConfig) PrettyPrintApps(e *Env) { + apps := c.Applications + + defaultApp := c.GetDefaultApp() + + var appNames []string + for appName := range apps { + appNames = append(appNames, appName) + } + sort.Strings(appNames) + + if len(appNames) == 0 { + return + } + + fmt.Fprintln( + e.Out, + "The following apps are associated with Cloud Code in the current directory:", + ) + + for _, appName := range appNames { + if appName == DefaultKey { + continue + } + if defaultApp == appName { + fmt.Fprint(e.Out, "* ") + } else { + fmt.Fprint(e.Out, " ") + } + fmt.Fprintf(e.Out, "%s", appName) + + if config, _ := apps[appName]; config.GetLink() != "" { + fmt.Fprintf(e.Out, " -> %s", config.GetLink()) + } + fmt.Fprintln(e.Out, "") + } +} + +func readParseConfigFile(path string) (*ParseConfig, error) { + f, err := os.Open(path) + if err != nil { + return nil, stackerr.Wrap(err) + } + defer f.Close() + var c ParseConfig + if err := json.NewDecoder(f).Decode(&c); err != nil { + return nil, stackerr.Newf("Config file %q is not valid JSON.", path) + } + return &c, nil +} + +func setParseDefault(e *Env, newDefault, defaultApp string, parseConfig *ParseConfig) error { + apps := parseConfig.Applications + if _, ok := apps[newDefault]; !ok { + parseConfig.PrettyPrintApps(e) + return stackerr.Newf("Invalid application name \"%s\". Please select from the valid applications printed above.", newDefault) + } + + if defaultApp == "" { + apps[DefaultKey] = &ParseAppConfig{Link: newDefault} + } else { + apps[DefaultKey].Link = newDefault + } + if err := StoreConfig(e, parseConfig); err != nil { + return err + } + fmt.Fprintf(e.Out, "Default app set to %s.\n", newDefault) + return nil +} diff --git a/parsecli/runners.go b/parsecli/runners.go new file mode 100644 index 0000000..84247c3 --- /dev/null +++ b/parsecli/runners.go @@ -0,0 +1,136 @@ +package parsecli + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" +) + +type cobraRun func(cmd *cobra.Command, args []string) + +// RunNoArgs wraps a run function that shouldn't get any arguments. +func RunNoArgs(e *Env, f func(*Env) error) cobraRun { + return func(cmd *cobra.Command, args []string) { + if len(args) != 0 { + fmt.Fprintf(e.Err, "unexpected arguments:%+v\n\n", args) + cmd.Help() + e.Exit(1) + } + if err := f(e); err != nil { + fmt.Fprintln(e.Err, ErrorString(e, err)) + e.Exit(1) + } + } +} + +// RunWithArgs wraps a run function that can access arguments to cobraCmd +func RunWithArgs(e *Env, f func(*Env, []string) error) cobraRun { + return func(cmd *cobra.Command, args []string) { + if err := f(e, args); err != nil { + fmt.Fprintln(e.Err, ErrorString(e, err)) + e.Exit(1) + } + } +} + +// RunWithClient wraps a run function that should get an app, when the default is +// picked from the config in the current working directory. +func RunWithClient(e *Env, f func(*Env, *Context) error) cobraRun { + return func(cmd *cobra.Command, args []string) { + app := DefaultKey + if len(args) > 1 { + fmt.Fprintf( + e.Err, + "unexpected arguments, only an optional app name is expected:%+v\n\n", + args, + ) + cmd.Help() + e.Exit(1) + } + if len(args) == 1 { + app = args[0] + } + cl, err := newContext(e, app) + if err != nil { + fmt.Fprintln(e.Err, ErrorString(e, err)) + e.Exit(1) + } + if err := f(e, cl); err != nil { + fmt.Fprintln(e.Err, ErrorString(e, err)) + e.Exit(1) + } + } +} + +// RunWithClientConfirm wraps a run function that takes an app name +// or asks for confirmation to use the default app name instead +func RunWithClientConfirm(e *Env, f func(*Env, *Context) error) cobraRun { + return func(cmd *cobra.Command, args []string) { + app := DefaultKey + if len(args) > 1 { + fmt.Fprintf( + e.Err, + "unexpected arguments, only an optional app name is expected:%+v\n\n", + args, + ) + cmd.Help() + e.Exit(1) + } + if len(args) == 1 { + app = args[0] + } + cl, err := newContext(e, app) + if err != nil { + fmt.Fprintln(e.Err, ErrorString(e, err)) + e.Exit(1) + } + + if app == DefaultKey { + fmt.Fprintf( + e.Out, + `Did not provide app name as an argument to the command. +Please enter an app name to execute this command on +or press ENTER to use the default app %q: `, + cl.Config.GetDefaultApp(), + ) + var appName string + fmt.Fscanf(e.In, "%s\n", &appName) + appName = strings.TrimSpace(appName) + if appName != "" { + cl, err = newContext(e, appName) + if err != nil { + fmt.Fprintln(e.Err, ErrorString(e, err)) + e.Exit(1) + } + } + } + + if err := f(e, cl); err != nil { + fmt.Fprintln(e.Err, ErrorString(e, err)) + e.Exit(1) + } + } +} + +// RunWithArgsClient wraps a run function that should get an app, whee the default is +// picked from the config in the current working directory. It also passes args to the +// runner function +func RunWithArgsClient(e *Env, f func(*Env, *Context, []string) error) cobraRun { + return func(cmd *cobra.Command, args []string) { + app := DefaultKey + if len(args) > 1 { + app = args[0] + args = args[1:] + } + cl, err := newContext(e, app) + if err != nil { + fmt.Fprintln(e.Err, ErrorString(e, err)) + e.Exit(1) + } + if err := f(e, cl, args); err != nil { + fmt.Fprintln(e.Err, ErrorString(e, err)) + e.Exit(1) + } + } +} diff --git a/parsecli/runners_test.go b/parsecli/runners_test.go new file mode 100644 index 0000000..3127d4d --- /dev/null +++ b/parsecli/runners_test.go @@ -0,0 +1,141 @@ +package parsecli + +import ( + "errors" + "io/ioutil" + "testing" + + "github.com/facebookgo/ensure" + "github.com/spf13/cobra" +) + +func noOpCmd() *cobra.Command { + var c cobra.Command + c.SetOutput(ioutil.Discard) + return &c +} + +func TestRunNoArgsFuncError(t *testing.T) { + t.Parallel() + const message = "hello world" + h := NewHarness(t) + defer h.Stop() + h.Env.Exit = func(i int) { panic(exitCode(i)) } + func() { + defer ensure.PanicDeepEqual(t, exitCode(1)) + r := RunNoArgs(h.Env, func(*Env) error { return errors.New(message) }) + r(noOpCmd(), nil) + }() + ensure.StringContains(t, h.Err.String(), message) +} + +func TestRunWithAppMultipleArgs(t *testing.T) { + t.Parallel() + h := NewHarness(t) + defer h.Stop() + h.Env.Exit = func(i int) { panic(exitCode(i)) } + func() { + defer ensure.PanicDeepEqual(t, exitCode(1)) + r := RunWithClient(h.Env, nil) + r(noOpCmd(), []string{"foo", "bar"}) + }() + ensure.StringContains( + t, + h.Err.String(), + "only an optional app name is expected", + ) +} + +func TestRunWithAppNonProjectDir(t *testing.T) { + t.Parallel() + h := NewHarness(t) + defer h.Stop() + h.MakeEmptyRoot() + h.Env.Exit = func(i int) { panic(exitCode(i)) } + func() { + defer ensure.PanicDeepEqual(t, exitCode(1)) + r := RunWithClient(h.Env, nil) + r(noOpCmd(), nil) + }() + ensure.StringContains( + t, + h.Err.String(), + "Command must be run inside a Parse project.", + ) +} + +func TestRunWithAppNotFound(t *testing.T) { + t.Parallel() + h := NewHarness(t) + defer h.Stop() + c := legacyConfig{Applications: map[string]*ParseAppConfig{"a": {}}} + h.MakeWithConfig(jsonStr(t, c)) + h.Env.Exit = func(i int) { panic(exitCode(i)) } + func() { + defer ensure.PanicDeepEqual(t, exitCode(1)) + r := RunWithClient(h.Env, nil) + r(noOpCmd(), []string{"b"}) + }() + ensure.StringContains(t, h.Err.String(), `App "b" wasn't found`) +} + +func TestRunWithAppNamed(t *testing.T) { + t.Parallel() + h := NewHarness(t) + defer h.Stop() + const appName = "a" + c := &ParseConfig{ + Applications: map[string]*ParseAppConfig{ + appName: {ApplicationID: "id", MasterKey: "token"}, + }, + } + h.MakeWithConfig(jsonStr(t, c)) + r := RunWithClient(h.Env, func(e *Env, c *Context) error { + ensure.NotNil(t, c) + return nil + }) + r(noOpCmd(), []string{"a"}) +} + +func TestRunWithDefault(t *testing.T) { + t.Parallel() + h := NewHarness(t) + defer h.Stop() + const appName = "a" + c := &ParseConfig{ + Applications: map[string]*ParseAppConfig{ + appName: {ApplicationID: "id", MasterKey: "token"}, + DefaultKey: {Link: appName}, + }, + } + h.MakeWithConfig(jsonStr(t, c)) + r := RunWithClient(h.Env, func(e *Env, c *Context) error { + ensure.NotNil(t, c) + return nil + }) + r(noOpCmd(), []string{"a"}) +} + +func TestRunWithAppError(t *testing.T) { + t.Parallel() + h := NewHarness(t) + defer h.Stop() + const appName = "a" + c := &ParseConfig{ + Applications: map[string]*ParseAppConfig{ + appName: {ApplicationID: "id", MasterKey: "token"}, + }, + } + h.MakeWithConfig(jsonStr(t, c)) + h.Env.Exit = func(i int) { panic(exitCode(i)) } + const message = "hello world" + func() { + defer ensure.PanicDeepEqual(t, exitCode(1)) + r := RunWithClient(h.Env, func(e *Env, c *Context) error { + ensure.NotNil(t, c) + return errors.New(message) + }) + r(noOpCmd(), []string{"a"}) + }() + ensure.StringContains(t, h.Err.String(), message) +} diff --git a/parsecli/utils.go b/parsecli/utils.go new file mode 100644 index 0000000..f42a1ff --- /dev/null +++ b/parsecli/utils.go @@ -0,0 +1,208 @@ +package parsecli + +import ( + "errors" + "fmt" + "net/url" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/facebookgo/errgroup" + "github.com/facebookgo/parse" + "github.com/facebookgo/stackerr" +) + +const ( + SampleSource = ` +// Use Parse.Cloud.define to define as many cloud functions as you want. +// For example: +Parse.Cloud.define("hello", function(request, response) { + response.success("Hello world!"); +}); +` + + SampleHTML = ` + + + My ParseApp site + + + +
+

Congratulations! You're already hosting with Parse.

+

To get started, edit this file at public/index.html and start adding static content.

+

If you want something a bit more dynamic, delete this file and check out our hosting docs.

+
+ + +` +) + +func getHostFromURL(urlStr, email string) (string, error) { + netURL, err := url.Parse(urlStr) + if err != nil { + return "", stackerr.Wrap(err) + } + server := regexp.MustCompile(`(.*):\d+$`).ReplaceAllString(netURL.Host, "$1") + if server == "" { + return "", stackerr.Newf("%s is not a valid url", urlStr) + } + if email != "" { + return fmt.Sprintf("%s#%s", server, email), nil + } + return server, nil +} + +func Last4(str string) string { + l := len(str) + if l > 4 { + return fmt.Sprintf("%s%s", strings.Repeat("*", l-4), str[l-4:l]) + } + return str +} + +// errorString returns the error string with our without the stack trace +// depending on the Environment variable. this exists because we want plain +// messages for end users, but when we're working on the CLI we want the stack +// trace for debugging. +func ErrorString(e *Env, err error) string { + type hasUnderlying interface { + HasUnderlying() error + } + + parseErr := func(err error) error { + if apiErr, ok := err.(*parse.Error); ok { + return errors.New(apiErr.Message) + } + return err + } + + lastErr := func(err error) error { + if serr, ok := err.(*stackerr.Error); ok { + if errs := stackerr.Underlying(serr); len(errs) != 0 { + err = errs[len(errs)-1] + } + } else { + if eu, ok := err.(hasUnderlying); ok { + err = eu.HasUnderlying() + } + } + + return parseErr(err) + } + + if !e.ErrorStack { + if merr, ok := err.(errgroup.MultiError); ok { + var multiError []error + for _, ierr := range []error(merr) { + multiError = append(multiError, lastErr(ierr)) + } + err = errgroup.MultiError(multiError) + } else { + err = lastErr(err) + } + return parseErr(err).Error() + } + + return err.Error() +} + +func CreateConfigWithContent(path, content string) error { + file, err := os.OpenFile( + path, + os.O_RDWR|os.O_CREATE|os.O_TRUNC, + 0600, + ) + if err != nil && !os.IsExist(err) { + return stackerr.Wrap(err) + } + defer file.Close() + if _, err := file.WriteString(content); err != nil { + return stackerr.Wrap(err) + } + if err := file.Close(); err != nil { + return stackerr.Wrap(err) + } + return nil +} + +var NewProjectFiles = []struct { + Dirname, Filename, Content string +}{ + {"cloud", "main.js", SampleSource}, + {"public", "index.html", SampleHTML}, +} + +func CloneSampleCloudCode(e *Env, dumpTemplate bool) error { + err := os.MkdirAll(e.Root, 0755) + if err != nil { + return stackerr.Wrap(err) + } + + err = CreateConfigWithContent( + filepath.Join(e.Root, ParseProject), + fmt.Sprintf( + `{ + "project_type" : %d, + "parse": {"jssdk":""} +}`, + ParseFormat, + ), + ) + if err != nil { + return err + } + err = CreateConfigWithContent( + filepath.Join(e.Root, ParseLocal), + "{}", + ) + if err != nil { + return err + } + + // no need to set up the template code + if !dumpTemplate { + return nil + } + + for _, info := range NewProjectFiles { + sampleDir := filepath.Join(e.Root, info.Dirname) + if _, err := os.Stat(sampleDir); err != nil { + if !os.IsNotExist(err) { + return stackerr.Wrap(err) + } + if err := os.Mkdir(sampleDir, 0755); err != nil { + return stackerr.Wrap(err) + } + } + + sampleFile := filepath.Join(sampleDir, info.Filename) + if _, err := os.Stat(sampleFile); err != nil { + if os.IsNotExist(err) { + file, err := os.Create(sampleFile) + if err != nil && !os.IsExist(err) { + return stackerr.Wrap(err) + } + + defer file.Close() + + if _, err := file.WriteString(info.Content); err != nil { + return stackerr.Wrap(err) + } + if err := file.Close(); err != nil { + return stackerr.Wrap(err) + } + } + } + } + return nil +} diff --git a/parsecli/utils_test.go b/parsecli/utils_test.go new file mode 100644 index 0000000..7a294dc --- /dev/null +++ b/parsecli/utils_test.go @@ -0,0 +1,141 @@ +package parsecli + +import ( + "regexp" + "testing" + + "github.com/facebookgo/ensure" + "github.com/facebookgo/errgroup" + "github.com/facebookgo/parse" + "github.com/facebookgo/stackerr" +) + +func TestGetHostFromURL(t *testing.T) { + urlStr := "https://api.parse.com/1/" + host, err := getHostFromURL(urlStr, "") + ensure.Nil(t, err) + ensure.DeepEqual(t, host, "api.parse.com") + + urlStr = "https://api.parse.com/1/" + host, err = getHostFromURL(urlStr, "yolo@earth.com") + ensure.Nil(t, err) + ensure.DeepEqual(t, host, "api.parse.com#yolo@earth.com") + + urlStr = "https://api.example.com:8080/1/" + host, err = getHostFromURL(urlStr, "") + ensure.Nil(t, err) + ensure.DeepEqual(t, host, "api.example.com") + + urlStr = "https://api.example.com:8080/1/" + host, err = getHostFromURL(urlStr, "yolo@earth.com") + ensure.Nil(t, err) + ensure.DeepEqual(t, host, "api.example.com#yolo@earth.com") + + urlStr = "api.example.com:8080:90" + host, err = getHostFromURL(urlStr, "") + ensure.Err(t, err, regexp.MustCompile("not a valid url")) +} + +func TestLastFour(t *testing.T) { + t.Parallel() + + ensure.DeepEqual(t, Last4(""), "") + ensure.DeepEqual(t, Last4("a"), "a") + ensure.DeepEqual(t, Last4("ab"), "ab") + ensure.DeepEqual(t, Last4("abc"), "abc") + ensure.DeepEqual(t, Last4("abcd"), "abcd") + ensure.DeepEqual(t, Last4("abcdefg"), "***defg") +} + +func TestErrorStringWithoutStack(t *testing.T) { + t.Parallel() + h := NewHarness(t) + defer h.Stop() + h.Env.ErrorStack = false + const message = "hello world" + actual := ErrorString(h.Env, stackerr.New(message)) + ensure.StringContains(t, actual, message) + ensure.StringDoesNotContain(t, actual, ".go") +} + +type exitCode int + +func TestErrorStringWithStack(t *testing.T) { + t.Parallel() + h := NewHarness(t) + defer h.Stop() + h.Env.ErrorStack = true + const message = "hello world" + actual := ErrorString(h.Env, stackerr.New(message)) + ensure.StringContains(t, actual, message) + ensure.StringContains(t, actual, ".go") +} + +func TestErrorString(t *testing.T) { + t.Parallel() + h := NewHarness(t) + defer h.Stop() + apiErr := &parse.Error{Code: -1, Message: "Error\nMessage"} + ensure.DeepEqual(t, `Error +Message`, + ErrorString(h.Env, apiErr), + ) +} + +func TestStackErrorString(t *testing.T) { + t.Parallel() + + h := NewHarness(t) + defer h.Stop() + + err := stackerr.New("error") + + h.Env.ErrorStack = false + errStr := ErrorString(h.Env, err) + + ensure.DeepEqual(t, errStr, "error") + + h.Env.ErrorStack = true + errStr = ErrorString(h.Env, err) + + ensure.StringContains(t, errStr, "error") + ensure.StringContains(t, errStr, ".go") + + err = stackerr.Wrap(&parse.Error{Message: "message", Code: 1}) + h.Env.ErrorStack = false + errStr = ErrorString(h.Env, err) + + ensure.DeepEqual(t, errStr, "message") + + h.Env.ErrorStack = true + errStr = ErrorString(h.Env, err) + + ensure.StringContains(t, errStr, `parse: api error with code=1 and message="message`) + ensure.StringContains(t, errStr, ".go") +} + +func TestMultiErrorString(t *testing.T) { + t.Parallel() + + h := NewHarness(t) + defer h.Stop() + + err := errgroup.MultiError( + []error{ + stackerr.New("error"), + stackerr.Wrap(&parse.Error{Message: "message", Code: 1}), + }, + ) + + h.Env.ErrorStack = false + errStr := ErrorString(h.Env, err) + + ensure.DeepEqual(t, errStr, "multiple errors: error | message") + + h.Env.ErrorStack = true + errStr = ErrorString(h.Env, err) + + ensure.StringContains(t, errStr, "multiple errors") + ensure.StringContains(t, errStr, `parse: api error with code=1 and message="message"`) + ensure.StringContains(t, errStr, ".go") +} diff --git a/Resources/AndroidManifest.xml b/parsecmd/Resources/AndroidManifest.xml similarity index 100% rename from Resources/AndroidManifest.xml rename to parsecmd/Resources/AndroidManifest.xml diff --git a/Resources/Test.xcarchive/Info.plist b/parsecmd/Resources/Test.xcarchive/Info.plist similarity index 100% rename from Resources/Test.xcarchive/Info.plist rename to parsecmd/Resources/Test.xcarchive/Info.plist diff --git a/Resources/Test.xcarchive/dSYMs/Test.app.dSYM/Contents/Info.plist b/parsecmd/Resources/Test.xcarchive/dSYMs/Test.app.dSYM/Contents/Info.plist similarity index 100% rename from Resources/Test.xcarchive/dSYMs/Test.app.dSYM/Contents/Info.plist rename to parsecmd/Resources/Test.xcarchive/dSYMs/Test.app.dSYM/Contents/Info.plist diff --git a/Resources/Test.xcarchive/dSYMs/Test.app.dSYM/Contents/Resources/DWARF/Test b/parsecmd/Resources/Test.xcarchive/dSYMs/Test.app.dSYM/Contents/Resources/DWARF/Test similarity index 100% rename from Resources/Test.xcarchive/dSYMs/Test.app.dSYM/Contents/Resources/DWARF/Test rename to parsecmd/Resources/Test.xcarchive/dSYMs/Test.app.dSYM/Contents/Resources/DWARF/Test diff --git a/Resources/build_tools/.empty b/parsecmd/Resources/build_tools/.empty similarity index 100% rename from Resources/build_tools/.empty rename to parsecmd/Resources/build_tools/.empty diff --git a/Resources/mapping.txt b/parsecmd/Resources/mapping.txt similarity index 100% rename from Resources/mapping.txt rename to parsecmd/Resources/mapping.txt diff --git a/parsecmd/add.go b/parsecmd/add.go new file mode 100644 index 0000000..c9eb66a --- /dev/null +++ b/parsecmd/add.go @@ -0,0 +1,70 @@ +package parsecmd + +import ( + "fmt" + + "github.com/ParsePlatform/parse-cli/parsecli" + "github.com/facebookgo/stackerr" +) + +func GetParseAppConfig(app *parsecli.App) *parsecli.ParseAppConfig { + pc := &parsecli.ParseAppConfig{ + ApplicationID: app.ApplicationID, + } + return pc.WithInternalMasterKey(app.MasterKey) +} + +func AddSelectedParseApp( + appName string, + appConfig *parsecli.ParseAppConfig, + args []string, + makeDefault, verbose bool, + e *parsecli.Env, +) error { + config, err := parsecli.ConfigFromDir(e.Root) + if err != nil { + return err + } + parseConfig, ok := config.(*parsecli.ParseConfig) + if !ok { + return stackerr.New("Invalid Cloud Code config.") + } + + // add app to config + if _, ok := parseConfig.Applications[appName]; ok { + return stackerr.Newf("App %s has already been added", appName) + } + + parseConfig.Applications[appName] = appConfig + + if len(args) > 0 && args[0] != "" { + alias := args[0] + aliasConfig, ok := parseConfig.Applications[alias] + if !ok { + parseConfig.Applications[alias] = &parsecli.ParseAppConfig{Link: appName} + } + if ok && aliasConfig.GetLink() != "" { + fmt.Fprintf(e.Out, "Overwriting alias: %q to point to %q\n", alias, appName) + parseConfig.Applications[alias] = &parsecli.ParseAppConfig{Link: appName} + } + } + + if makeDefault { + if _, ok := parseConfig.Applications[parsecli.DefaultKey]; ok { + return stackerr.New(`Default key already set. To override default, use command "parse default"`) + } + parseConfig.Applications[parsecli.DefaultKey] = &parsecli.ParseAppConfig{Link: appName} + } + + if err := parsecli.StoreConfig(e, parseConfig); err != nil { + return err + } + if verbose { + fmt.Fprintf(e.Out, "Written config for %q\n", appName) + if makeDefault { + fmt.Fprintf(e.Out, "Set %q as default\n", appName) + } + } + + return nil +} diff --git a/android_symbol_uploader.go b/parsecmd/android_symbol_uploader.go similarity index 90% rename from android_symbol_uploader.go rename to parsecmd/android_symbol_uploader.go index d633581..f6e2d7e 100644 --- a/android_symbol_uploader.go +++ b/parsecmd/android_symbol_uploader.go @@ -1,4 +1,4 @@ -package main +package parsecmd import ( "bytes" @@ -10,6 +10,7 @@ import ( "regexp" "strconv" + "github.com/ParsePlatform/parse-cli/parsecli" "github.com/facebookgo/stackerr" ) @@ -130,9 +131,8 @@ func parseVersionFromBytes(bytes []byte) (int, error) { return int(version), nil } -func (a *androidSymbolUploader) getBuildVersion(e *env) (int, error) { - if a.Manifest != "" { - versionCode, err := a.getBuildVersionFromManifest() +func (a *androidSymbolUploader) getBuildVersion(e *parsecli.Env) (int, error) { + var handleError = func(versionCode int, err error) (int, error) { if err != nil { return 0, err } @@ -142,17 +142,14 @@ func (a *androidSymbolUploader) getBuildVersion(e *env) (int, error) { return versionCode, nil } - versionCode, err := a.getBuildVersionFromAPK() - if err != nil { - return 0, err - } - if versionCode == 1 { - fmt.Fprintln(e.Out, "Warning: build number is '1'.") + if a.Manifest != "" { + return handleError(a.getBuildVersionFromManifest()) } - return versionCode, nil + + return handleError(a.getBuildVersionFromAPK()) } -func (a *androidSymbolUploader) uploadSymbols(e *env) error { +func (a *androidSymbolUploader) uploadSymbols(e *parsecli.Env) error { appBuildVersion, err := a.getBuildVersion(e) if err != nil { return err diff --git a/android_symbol_uploader_test.go b/parsecmd/android_symbol_uploader_test.go similarity index 94% rename from android_symbol_uploader_test.go rename to parsecmd/android_symbol_uploader_test.go index eeaa516..fd880ae 100644 --- a/android_symbol_uploader_test.go +++ b/parsecmd/android_symbol_uploader_test.go @@ -1,4 +1,4 @@ -package main +package parsecmd import ( "fmt" @@ -8,6 +8,7 @@ import ( "regexp" "testing" + "github.com/ParsePlatform/parse-cli/parsecli" "github.com/facebookgo/ensure" ) @@ -106,7 +107,7 @@ func TestAndroidAcceptsPath(t *testing.T) { func TestFindAAPT(t *testing.T) { t.Parallel() - h := newHarness(t) + h := parsecli.NewHarness(t) defer h.Stop() aapts := findAAPT("Resources") @@ -158,15 +159,15 @@ func TestGetBuildVersion(t *testing.T) { AAPT: filepath.Join("Resources", "build_tools", "aapt"), } - h := newHarness(t) + h := parsecli.NewHarness(t) defer h.Stop() - manifestVersion, err := a.getBuildVersion(h.env) + manifestVersion, err := a.getBuildVersion(h.Env) ensure.Nil(t, err) ensure.DeepEqual(t, manifestVersion, 2778) a.Manifest = "" - apkVersion, err := a.getBuildVersion(h.env) + apkVersion, err := a.getBuildVersion(h.Env) ensure.Nil(t, err) ensure.DeepEqual(t, apkVersion, 2778) } diff --git a/deploy_cmd.go b/parsecmd/deploy.go similarity index 84% rename from deploy_cmd.go rename to parsecmd/deploy.go index 7706e30..d6c3e9f 100644 --- a/deploy_cmd.go +++ b/parsecmd/deploy.go @@ -1,4 +1,4 @@ -package main +package parsecmd import ( "crypto/md5" @@ -14,7 +14,9 @@ import ( "sort" "strings" "sync" + "time" + "github.com/ParsePlatform/parse-cli/parsecli" "github.com/facebookgo/errgroup" "github.com/facebookgo/jsonpipe" "github.com/facebookgo/stackerr" @@ -32,12 +34,13 @@ type deployCmd struct { Force bool Verbose bool Retries int + wait func(int) time.Duration } func (d *deployCmd) getSourceFiles( dirName string, suffixes map[string]struct{}, - e *env, + e *parsecli.Env, ) ([]string, []string, error) { ignoreFile := filepath.Join(e.Root, parseIgnore) @@ -156,7 +159,7 @@ func (d *deployCmd) computeChecksums(files []string, return checksums, nil } -func (d *deployCmd) uploadFile(filename, endpoint string, e *env, +func (d *deployCmd) uploadFile(filename, endpoint string, e *parsecli.Env, normalizeName func(string) string) (string, error) { content, err := ioutil.ReadFile(filename) if err != nil { @@ -189,7 +192,7 @@ func (d *deployCmd) uploadFile(filename, endpoint string, e *env, Version string `json:"version"` } - if _, err := e.Client.Do(req, nil, &res); err != nil { + if _, err := e.ParseAPIClient.Do(req, nil, &res); err != nil { return "", stackerr.Wrap(err) } @@ -203,7 +206,7 @@ type uploader struct { DirName string Suffixes map[string]struct{} EndPoint string - Env *env + Env *parsecli.Env PrevChecksums map[string]string PrevVersions map[string]string } @@ -307,24 +310,24 @@ type deployInfo struct { ParseVersion string `json:"parseVersion,omitempty"` Checksums deployFileData `json:"checksums,omitempty"` Versions deployFileData `json:"userFiles,omitempty"` - Warning string `json:"warning,omitempty"` + Warning string `json:"warning,omitempty"` // only populated by post to deploy } -func (d *deployCmd) makeNewRelease(info *deployInfo, e *env) (deployInfo, error) { +func (d *deployCmd) makeNewRelease(info *deployInfo, e *parsecli.Env) (deployInfo, error) { var res deployInfo u := url.URL{ Path: "deploy", } - _, err := e.Client.Post(&u, info, &res) + _, err := e.ParseAPIClient.Post(&u, info, &res) if err != nil { return res, stackerr.Wrap(err) } return res, nil } -func (d *deployCmd) getPrevDeplInfo(e *env) (*deployInfo, error) { +func (d *deployCmd) getPrevDeplInfo(e *parsecli.Env) (*deployInfo, error) { prevDeplInfo := &deployInfo{} - if _, err := e.Client.Get(&url.URL{Path: "deploy"}, prevDeplInfo); err != nil { + if _, err := e.ParseAPIClient.Get(&url.URL{Path: "deploy"}, prevDeplInfo); err != nil { return nil, stackerr.Wrap(err) } legacy := len(prevDeplInfo.Checksums.Cloud) == 0 && @@ -339,7 +342,7 @@ func (d *deployCmd) getPrevDeplInfo(e *env) (*deployInfo, error) { Checksums map[string]string `json:"checksums,omitempty"` Versions map[string]string `json:"userFiles,omitempty"` } - if _, err := e.Client.Get(&url.URL{Path: "deploy"}, &res); err != nil { + if _, err := e.ParseAPIClient.Get(&url.URL{Path: "deploy"}, &res); err != nil { return nil, stackerr.Wrap(err) } prevDeplInfo.ReleaseName = res.ReleaseName @@ -355,12 +358,12 @@ func (d *deployCmd) deploy( parseVersion string, prevDeplInfo *deployInfo, forDevelop bool, - e *env) (*deployInfo, error) { + e *parsecli.Env) (*deployInfo, error) { if parseVersion == "" { fmt.Fprintln(e.Err, "JS SDK version not set, setting it to latest available JS SDK version", ) - if err := useLatestJSSDK(e); err != nil { + if err := UseLatestJSSDK(e); err != nil { return nil, err } } @@ -463,39 +466,80 @@ func (d *deployCmd) deploy( }, nil } -func (d *deployCmd) run(e *env, c *client) error { - first := true - for i := 0; i < d.Retries; i++ { - if !first { - fmt.Fprintf(e.Err, "Deploy failed. Retrying deploy...\n\n") +func (d *deployCmd) handleError( + n int, + err, prevErr error, + e *parsecli.Env, +) error { + if err == nil { + return nil + } + if n == d.Retries-1 { + return err + } + + var waitTime time.Duration + if d.wait != nil { + waitTime = d.wait(n) + } + + errStr := parsecli.ErrorString(e, err) + if prevErr != nil { + prevErrStr := parsecli.ErrorString(e, prevErr) + if prevErrStr == errStr { + fmt.Fprintf( + e.Err, + "Sorry, deploy failed again with same error.\nWill retry in %d seconds.\n\n", + waitTime/time.Second, + ) + time.Sleep(waitTime) + return nil } - config := c.Config - parseVersion := config.getProjectConfig().Parse.JSSDK + } + + fmt.Fprintf( + e.Err, + "Deploy failed with error:\n%s\nWill retry in %d seconds.\n\n", + errStr, + waitTime/time.Second, + ) + time.Sleep(waitTime) + return nil +} + +func (d *deployCmd) run(e *parsecli.Env, c *parsecli.Context) error { + var prevErr error + for i := 0; i < d.Retries; i++ { + parseVersion := c.Config.GetProjectConfig().Parse.JSSDK newDeployInfo, err := d.deploy(parseVersion, nil, false, e) if err == nil { if parseVersion == "" && newDeployInfo != nil && newDeployInfo.ParseVersion != "" { - config.getProjectConfig().Parse.JSSDK = newDeployInfo.ParseVersion - return storeProjectConfig(e, config) + c.Config.GetProjectConfig().Parse.JSSDK = newDeployInfo.ParseVersion + return parsecli.StoreProjectConfig(e, c.Config) } return nil } - - first = false - if i == d.Retries-1 { + if err := d.handleError(i, err, prevErr, e); err != nil { return err } + prevErr = err } return nil } -func newDeployCmd(e *env) *cobra.Command { - d := deployCmd{Verbose: true, Retries: 5} +func NewDeployCmd(e *parsecli.Env) *cobra.Command { + d := deployCmd{ + Verbose: true, + Retries: 3, + wait: func(n int) time.Duration { return time.Duration(n) * time.Second }, + } + cmd := &cobra.Command{ Use: "deploy [app]", Short: "Deploys a Parse App", Long: `Deploys the code to the given app.`, - Run: runWithClient(e, d.run), + Run: parsecli.RunWithClient(e, d.run), } cmd.Flags().StringVarP(&d.Description, "description", "d", d.Description, "Add an optional description to the deploy") diff --git a/deploy_cmd_test.go b/parsecmd/deploy_test.go similarity index 68% rename from deploy_cmd_test.go rename to parsecmd/deploy_test.go index 47000d0..047ca1a 100644 --- a/deploy_cmd_test.go +++ b/parsecmd/deploy_test.go @@ -1,6 +1,7 @@ -package main +package parsecmd import ( + "encoding/json" "fmt" "io/ioutil" "net/http" @@ -11,24 +12,31 @@ import ( "strings" "testing" + "github.com/ParsePlatform/parse-cli/parsecli" "github.com/facebookgo/ensure" "github.com/facebookgo/parse" ) -func createParseProject(t testing.TB) *Harness { - h := newHarness(t) - h.makeEmptyRoot() +func jsonStr(t testing.TB, v interface{}) string { + b, err := json.Marshal(v) + ensure.Nil(t, err) + return string(b) +} - ensure.Nil(t, os.Mkdir(filepath.Join(h.env.Root, "parse"), 0755)) - h.env.Root = filepath.Join(h.env.Root, "parse") +func createParseProject(t testing.TB) *parsecli.Harness { + h := parsecli.NewHarness(t) + h.MakeEmptyRoot() - ensure.Nil(t, os.Mkdir(filepath.Join(h.env.Root, cloudDir), 0755)) - ensure.Nil(t, os.Mkdir(filepath.Join(h.env.Root, hostingDir), 0755)) - ensure.Nil(t, os.Mkdir(filepath.Join(h.env.Root, configDir), 0755)) + ensure.Nil(t, os.Mkdir(filepath.Join(h.Env.Root, "parse"), 0755)) + h.Env.Root = filepath.Join(h.Env.Root, "parse") + + ensure.Nil(t, os.Mkdir(filepath.Join(h.Env.Root, parsecli.CloudDir), 0755)) + ensure.Nil(t, os.Mkdir(filepath.Join(h.Env.Root, parsecli.HostingDir), 0755)) + ensure.Nil(t, os.Mkdir(filepath.Join(h.Env.Root, parsecli.ConfigDir), 0755)) ensure.Nil(t, ioutil.WriteFile( - filepath.Join(h.env.Root, hostingDir, "index.html"), + filepath.Join(h.Env.Root, parsecli.HostingDir, "index.html"), []byte(` Parse Project `), @@ -37,13 +45,13 @@ func createParseProject(t testing.TB) *Harness { ensure.Nil(t, ioutil.WriteFile( - filepath.Join(h.env.Root, cloudDir, "main.js"), + filepath.Join(h.Env.Root, parsecli.CloudDir, "main.js"), []byte(`echo {"success": "ok"}`), 0600), ) ensure.Nil(t, ioutil.WriteFile( - filepath.Join(h.env.Root, parseIgnore), + filepath.Join(h.Env.Root, parseIgnore), []byte(` *.swp *~ @@ -53,7 +61,7 @@ func createParseProject(t testing.TB) *Harness { ensure.Nil(t, ioutil.WriteFile( - filepath.Join(h.env.Root, legacyConfigFile), + filepath.Join(h.Env.Root, parsecli.LegacyConfigFile), []byte(`{ "global": {"parseVersion": "1.2.9"} } @@ -63,10 +71,10 @@ func createParseProject(t testing.TB) *Harness { ) ignoredFiles := []string{ - filepath.Join(h.env.Root, "public", ".ignore"), - filepath.Join(h.env.Root, "public", "#ignore"), - filepath.Join(h.env.Root, "cloud", "sample.txt"), - filepath.Join(h.env.Root, "cloud", "test~"), + filepath.Join(h.Env.Root, "public", ".ignore"), + filepath.Join(h.Env.Root, "public", "#ignore"), + filepath.Join(h.Env.Root, "cloud", "sample.txt"), + filepath.Join(h.Env.Root, "cloud", "test~"), } for _, ignoredFile := range ignoredFiles { @@ -78,10 +86,10 @@ func createParseProject(t testing.TB) *Harness { return h } -func setupForDeploy(t testing.TB, info *deployInfo) *Harness { +func setupForDeploy(t testing.TB, info *deployInfo) *parsecli.Harness { h := createParseProject(t) - ht := transportFunc(func(r *http.Request) (*http.Response, error) { + ht := parsecli.TransportFunc(func(r *http.Request) (*http.Response, error) { switch r.URL.Path { case "/1/deploy": return &http.Response{ @@ -105,36 +113,36 @@ func setupForDeploy(t testing.TB, info *deployInfo) *Harness { }, nil } }) - h.env.Client = &Client{client: &parse.Client{Transport: ht}} + h.Env.ParseAPIClient = &parsecli.ParseAPIClient{APIClient: &parse.Client{Transport: ht}} return h } func TestGenericGetSourceFiles(t *testing.T) { t.Parallel() - h := newHarness(t) - h.makeEmptyRoot() + h := parsecli.NewHarness(t) + h.MakeEmptyRoot() defer h.Stop() - ensure.Nil(t, os.Mkdir(filepath.Join(h.env.Root, "src"), 0755)) + ensure.Nil(t, os.Mkdir(filepath.Join(h.Env.Root, "src"), 0755)) for _, name := range []string{"index.html"} { - file, err := os.Create(filepath.Join(h.env.Root, "src", name)) + file, err := os.Create(filepath.Join(h.Env.Root, "src", name)) ensure.Nil(t, err) ensure.Nil(t, file.Close()) } - ensure.Nil(t, os.Symlink(filepath.Join(h.env.Root, "src"), filepath.Join(h.env.Root, "public"))) + ensure.Nil(t, os.Symlink(filepath.Join(h.Env.Root, "src"), filepath.Join(h.Env.Root, "public"))) var d deployCmd files, ignored, err := d.getSourceFiles( - filepath.Join(h.env.Root, "public"), + filepath.Join(h.Env.Root, "public"), map[string]struct{}{".html": {}}, - h.env, + h.Env, ) ensure.Nil(t, err) - ensure.DeepEqual(t, files, []string{filepath.Join(h.env.Root, "public", "index.html")}) + ensure.DeepEqual(t, files, []string{filepath.Join(h.Env.Root, "public", "index.html")}) ensure.DeepEqual(t, len(ignored), 0) } @@ -144,13 +152,13 @@ func TestComputeChecksums(t *testing.T) { defer h.Stop() files := []string{ - filepath.Join(h.env.Root, "cloud", "main.js"), - filepath.Join(h.env.Root, "public", "index.html"), + filepath.Join(h.Env.Root, "cloud", "main.js"), + filepath.Join(h.Env.Root, "public", "index.html"), } var d deployCmd - prefixLen := len(filepath.Join(h.env.Root, "1")) - 1 + prefixLen := len(filepath.Join(h.Env.Root, "1")) - 1 res, err := d.computeChecksums(files, func(name string) string { name = filepath.ToSlash(filepath.Clean(name)) return name[prefixLen:] @@ -163,11 +171,11 @@ func TestComputeChecksums(t *testing.T) { func TestUploadFileNoFile(t *testing.T) { t.Parallel() - h := newHarness(t) + h := parsecli.NewHarness(t) defer h.Stop() var d deployCmd - _, err := d.uploadFile("cloud/master.js", "", h.env, nil) + _, err := d.uploadFile("cloud/master.js", "", h.Env, nil) switch runtime.GOOS { case "windows": ensure.Err(t, err, regexp.MustCompile(`The system cannot find the path specified.`)) @@ -182,17 +190,17 @@ func TestUploadFileHttpError(t *testing.T) { defer h.Stop() var d deployCmd - ht := transportFunc(func(r *http.Request) (*http.Response, error) { + ht := parsecli.TransportFunc(func(r *http.Request) (*http.Response, error) { ensure.DeepEqual(t, r.URL.Path, "/1/uploads") return &http.Response{ StatusCode: http.StatusExpectationFailed, Body: ioutil.NopCloser(strings.NewReader(`{"error": "something is wrong"}`)), }, nil }) - h.env.Client = &Client{client: &parse.Client{Transport: ht}} - dirRoot := filepath.Join(h.env.Root, "cloud") + h.Env.ParseAPIClient = &parsecli.ParseAPIClient{APIClient: &parse.Client{Transport: ht}} + dirRoot := filepath.Join(h.Env.Root, "cloud") _, err := d.uploadFile(filepath.Join(dirRoot, "main.js"), "uploads", - h.env, func(name string) string { return "main.js" }) + h.Env, func(name string) string { return "main.js" }) ensure.Err(t, err, regexp.MustCompile("something is wrong")) } @@ -202,7 +210,7 @@ func TestUploadFileMalformed(t *testing.T) { defer h.Stop() var d deployCmd - ht := transportFunc(func(r *http.Request) (*http.Response, error) { + ht := parsecli.TransportFunc(func(r *http.Request) (*http.Response, error) { ensure.DeepEqual(t, r.URL.Path, "/1/uploads") return &http.Response{ StatusCode: http.StatusOK, @@ -210,16 +218,16 @@ func TestUploadFileMalformed(t *testing.T) { }, nil }) - h.env.Client = &Client{client: &parse.Client{Transport: ht}} - dirRoot := filepath.Join(h.env.Root, "cloud") - _, err := d.uploadFile(filepath.Join(dirRoot, "main.js"), "uploads", h.env, + h.Env.ParseAPIClient = &parsecli.ParseAPIClient{APIClient: &parse.Client{Transport: ht}} + dirRoot := filepath.Join(h.Env.Root, "cloud") + _, err := d.uploadFile(filepath.Join(dirRoot, "main.js"), "uploads", h.Env, func(name string) string { return "main.js" }) ensure.Err(t, err, regexp.MustCompile(`Malformed response when trying to upload `)) } func TestMakeNewRelease(t *testing.T) { t.Parallel() - h := newHarness(t) + h := parsecli.NewHarness(t) defer h.Stop() var d deployCmd @@ -235,40 +243,40 @@ func TestMakeNewRelease(t *testing.T) { Public: map[string]string{"index.html": "f2"}, }, } - ht := transportFunc(func(r *http.Request) (*http.Response, error) { + ht := parsecli.TransportFunc(func(r *http.Request) (*http.Response, error) { ensure.DeepEqual(t, r.URL.Path, "/1/deploy") return &http.Response{ StatusCode: http.StatusOK, Body: ioutil.NopCloser(strings.NewReader(jsonStr(t, &info))), }, nil }) - h.env.Client = &Client{client: &parse.Client{Transport: ht}} - res, err := d.makeNewRelease(&deployInfo{}, h.env) + h.Env.ParseAPIClient = &parsecli.ParseAPIClient{APIClient: &parse.Client{Transport: ht}} + res, err := d.makeNewRelease(&deployInfo{}, h.Env) ensure.Nil(t, err) ensure.DeepEqual(t, info, res) } func TestMakeNewReleaseError(t *testing.T) { t.Parallel() - h := newHarness(t) + h := parsecli.NewHarness(t) defer h.Stop() var d deployCmd - ht := transportFunc(func(r *http.Request) (*http.Response, error) { + ht := parsecli.TransportFunc(func(r *http.Request) (*http.Response, error) { ensure.DeepEqual(t, r.URL.Path, "/1/deploy") return &http.Response{ StatusCode: http.StatusExpectationFailed, Body: ioutil.NopCloser(strings.NewReader(`{"error": "something is wrong"}`)), }, nil }) - h.env.Client = &Client{client: &parse.Client{Transport: ht}} - _, err := d.makeNewRelease(&deployInfo{}, h.env) + h.Env.ParseAPIClient = &parsecli.ParseAPIClient{APIClient: &parse.Client{Transport: ht}} + _, err := d.makeNewRelease(&deployInfo{}, h.Env) ensure.Err(t, err, regexp.MustCompile("something is wrong")) } func TestGetPrevDeplInfo(t *testing.T) { t.Parallel() - h := newHarness(t) + h := parsecli.NewHarness(t) defer h.Stop() var d deployCmd @@ -284,22 +292,22 @@ func TestGetPrevDeplInfo(t *testing.T) { Public: map[string]string{"index.html": "f1"}, }, } - ht := transportFunc(func(r *http.Request) (*http.Response, error) { + ht := parsecli.TransportFunc(func(r *http.Request) (*http.Response, error) { ensure.DeepEqual(t, r.URL.Path, "/1/deploy") return &http.Response{ StatusCode: http.StatusOK, Body: ioutil.NopCloser(strings.NewReader(jsonStr(t, info))), }, nil }) - h.env.Client = &Client{client: &parse.Client{Transport: ht}} - res, err := d.getPrevDeplInfo(h.env) + h.Env.ParseAPIClient = &parsecli.ParseAPIClient{APIClient: &parse.Client{Transport: ht}} + res, err := d.getPrevDeplInfo(h.Env) ensure.Nil(t, err) ensure.DeepEqual(t, res, info) } func TestGetPrevDeplInfoLegacy(t *testing.T) { t.Parallel() - h := newHarness(t) + h := parsecli.NewHarness(t) defer h.Stop() var d deployCmd @@ -316,15 +324,15 @@ func TestGetPrevDeplInfoLegacy(t *testing.T) { Checksums: map[string]string{"main.js": "d41d8cd98f00b204e9800998ecf8427e"}, Versions: map[string]string{"main.js": "f1"}, } - ht := transportFunc(func(r *http.Request) (*http.Response, error) { + ht := parsecli.TransportFunc(func(r *http.Request) (*http.Response, error) { ensure.DeepEqual(t, r.URL.Path, "/1/deploy") return &http.Response{ StatusCode: http.StatusOK, Body: ioutil.NopCloser(strings.NewReader(jsonStr(t, info))), }, nil }) - h.env.Client = &Client{client: &parse.Client{Transport: ht}} - res, err := d.getPrevDeplInfo(h.env) + h.Env.ParseAPIClient = &parsecli.ParseAPIClient{APIClient: &parse.Client{Transport: ht}} + res, err := d.getPrevDeplInfo(h.Env) ensure.Nil(t, err) ensure.DeepEqual(t, res, &deployInfo{ ReleaseName: "v1", @@ -340,19 +348,19 @@ func TestGetPrevDeplInfoLegacy(t *testing.T) { func TestGetPrevDeplInfoError(t *testing.T) { t.Parallel() - h := newHarness(t) + h := parsecli.NewHarness(t) defer h.Stop() var d deployCmd - ht := transportFunc(func(r *http.Request) (*http.Response, error) { + ht := parsecli.TransportFunc(func(r *http.Request) (*http.Response, error) { ensure.DeepEqual(t, r.URL.Path, "/1/deploy") return &http.Response{ StatusCode: http.StatusExpectationFailed, Body: ioutil.NopCloser(strings.NewReader(`{"error": "something is wrong"}`)), }, nil }) - h.env.Client = &Client{client: &parse.Client{Transport: ht}} - _, err := d.getPrevDeplInfo(h.env) + h.Env.ParseAPIClient = &parsecli.ParseAPIClient{APIClient: &parse.Client{Transport: ht}} + _, err := d.getPrevDeplInfo(h.Env) ensure.Err(t, err, regexp.MustCompile("something is wrong")) } @@ -365,7 +373,7 @@ func TestUploadSourceFilesChanged(t *testing.T) { DirName: "cloud", Suffixes: map[string]struct{}{".js": {}}, EndPoint: "uploads", - Env: h.env, + Env: h.Env, PrevChecksums: map[string]string{ "main.js": "d41d8cd98f00b204e9800998ecf8427e", }, @@ -374,7 +382,7 @@ func TestUploadSourceFilesChanged(t *testing.T) { }, } - ht := transportFunc(func(r *http.Request) (*http.Response, error) { + ht := parsecli.TransportFunc(func(r *http.Request) (*http.Response, error) { ensure.DeepEqual(t, r.URL.Path, "/1/uploads") return &http.Response{ StatusCode: http.StatusOK, @@ -382,7 +390,7 @@ func TestUploadSourceFilesChanged(t *testing.T) { }, nil }) - h.env.Client = &Client{client: &parse.Client{Transport: ht}} + h.Env.ParseAPIClient = &parsecli.ParseAPIClient{APIClient: &parse.Client{Transport: ht}} var d deployCmd checksums, versions, err := d.uploadSourceFiles(u) @@ -412,7 +420,7 @@ func TestUploadSourceFilesUnChanged(t *testing.T) { DirName: "cloud", Suffixes: map[string]struct{}{".js": {}}, EndPoint: "scripts", - Env: h.env, + Env: h.Env, PrevChecksums: map[string]string{ "main.js": "4ece160cc8e5e828ee718e7367cf5d37", }, @@ -453,7 +461,7 @@ func TestDeployFilesChanged(t *testing.T) { defer h.Stop() d := deployCmd{Verbose: true} - res, err := d.deploy("old", nil, false, h.env) + res, err := d.deploy("old", nil, false, h.Env) ensure.Nil(t, err) expected := &deployInfo{ @@ -484,15 +492,15 @@ The following files will be ignored: Finished uploading files New release is named v1 (using Parse JavaScript SDK vlatest) `, - filepath.Join(h.env.Root, cloudDir, "main.js"), + filepath.Join(h.Env.Root, parsecli.CloudDir, "main.js"), strings.Join([]string{ - filepath.Join(h.env.Root, cloudDir, "sample.txt"), - filepath.Join(h.env.Root, cloudDir, "test~")}, + filepath.Join(h.Env.Root, parsecli.CloudDir, "sample.txt"), + filepath.Join(h.Env.Root, parsecli.CloudDir, "test~")}, "\n"), - filepath.Join(h.env.Root, hostingDir, "index.html"), + filepath.Join(h.Env.Root, parsecli.HostingDir, "index.html"), strings.Join([]string{ - filepath.Join(h.env.Root, hostingDir, "#ignore"), - filepath.Join(h.env.Root, hostingDir, ".ignore")}, + filepath.Join(h.Env.Root, parsecli.HostingDir, "#ignore"), + filepath.Join(h.Env.Root, parsecli.HostingDir, ".ignore")}, "\n"), ), ) @@ -516,7 +524,7 @@ func TestDeployFilesUnChanged(t *testing.T) { defer h.Stop() d := deployCmd{Verbose: true} - res, err := d.deploy("latest", nil, false, h.env) + res, err := d.deploy("latest", nil, false, h.Env) ensure.Nil(t, err) expected := &deployInfo{ @@ -556,7 +564,7 @@ func TestDeployFilesNoVersion(t *testing.T) { defer h.Stop() d := deployCmd{Verbose: true} - res, err := d.deploy("", nil, false, h.env) + res, err := d.deploy("", nil, false, h.Env) ensure.Nil(t, err) expected := &deployInfo{ @@ -588,24 +596,24 @@ The following files will be ignored: Finished uploading files New release is named v1 (using Parse JavaScript SDK vlatest) `, - filepath.Join(h.env.Root, cloudDir, "main.js"), + filepath.Join(h.Env.Root, parsecli.CloudDir, "main.js"), strings.Join([]string{ - filepath.Join(h.env.Root, cloudDir, "sample.txt"), - filepath.Join(h.env.Root, cloudDir, "test~")}, + filepath.Join(h.Env.Root, parsecli.CloudDir, "sample.txt"), + filepath.Join(h.Env.Root, parsecli.CloudDir, "test~")}, "\n"), - filepath.Join(h.env.Root, hostingDir, "index.html"), + filepath.Join(h.Env.Root, parsecli.HostingDir, "index.html"), strings.Join([]string{ - filepath.Join(h.env.Root, hostingDir, "#ignore"), - filepath.Join(h.env.Root, hostingDir, ".ignore")}, + filepath.Join(h.Env.Root, parsecli.HostingDir, "#ignore"), + filepath.Join(h.Env.Root, parsecli.HostingDir, ".ignore")}, "\n"), ), ) - c, err := configFromDir(h.env.Root) + c, err := parsecli.ConfigFromDir(h.Env.Root) ensure.Nil(t, err) - config, ok := (c).(*parseConfig) + config, ok := (c).(*parsecli.ParseConfig) ensure.True(t, ok) - ensure.DeepEqual(t, config.projectConfig.Parse.JSSDK, "2.0") + ensure.DeepEqual(t, config.ProjectConfig.Parse.JSSDK, "2.0") ensure.True(t, strings.Contains(h.Err.String(), `JS SDK version not set, setting it to latest available JS SDK version `), ) @@ -614,7 +622,7 @@ New release is named v1 (using Parse JavaScript SDK vlatest) func TestDeployRetries(t *testing.T) { t.Parallel() - h := newHarness(t) + h := parsecli.NewHarness(t) defer h.Stop() info := &struct { @@ -629,31 +637,37 @@ func TestDeployRetries(t *testing.T) { Checksums: map[string]string{"main.js": "d41d8cd98f00b204e9800998ecf8427e"}, Versions: map[string]string{"main.js": "f1"}, } - ht := transportFunc(func(r *http.Request) (*http.Response, error) { + ht := parsecli.TransportFunc(func(r *http.Request) (*http.Response, error) { ensure.DeepEqual(t, r.URL.Path, "/1/deploy") return &http.Response{ StatusCode: http.StatusOK, Body: ioutil.NopCloser(strings.NewReader(jsonStr(t, info))), }, nil }) - h.env.Client = &Client{client: &parse.Client{Transport: ht}} + h.Env.ParseAPIClient = &parsecli.ParseAPIClient{APIClient: &parse.Client{Transport: ht}} d := &deployCmd{Retries: 1} - client := client{Config: defaulParseConfig} - client.Config.getProjectConfig().Parse.JSSDK = "latest" + ctx := parsecli.Context{Config: defaultParseConfig} + ctx.Config.GetProjectConfig().Parse.JSSDK = "latest" - ensure.Err(t, d.run(h.env, &client), regexp.MustCompile("no such file or directory")) + ensure.Err(t, d.run(h.Env, &ctx), regexp.MustCompile("no such file or directory")) ensure.DeepEqual(t, h.Err.String(), "") h.Err.Reset() d.Retries = 2 - ensure.Err(t, d.run(h.env, &client), regexp.MustCompile("no such file or directory")) - ensure.DeepEqual(t, h.Err.String(), "Deploy failed. Retrying deploy...\n\n") + ensure.Err(t, d.run(h.Env, &ctx), regexp.MustCompile("no such file or directory")) + ensure.DeepEqual( + t, + h.Err.String(), + "Deploy failed with error:\nlstat cloud: no such file or directory\nWill retry in 0 seconds.\n\n", + ) h.Err.Reset() d.Retries = 5 - ensure.Err(t, d.run(h.env, &client), regexp.MustCompile("no such file or directory")) - ensure.DeepEqual(t, h.Err.String(), strings.Repeat("Deploy failed. Retrying deploy...\n\n", 4)) + ensure.Err(t, d.run(h.Env, &ctx), regexp.MustCompile("no such file or directory")) + errStr := "Deploy failed with error:\nlstat cloud: no such file or directory\nWill retry in 0 seconds.\n\n" + errStr += strings.Repeat("Sorry, deploy failed again with same error.\nWill retry in 0 seconds.\n\n", 3) + ensure.DeepEqual(t, h.Err.String(), errStr) } func TestIgnoredFiles(t *testing.T) { @@ -662,18 +676,18 @@ func TestIgnoredFiles(t *testing.T) { defer h.Stop() d := deployCmd{Verbose: true} - _, ignored, err := d.getSourceFiles(filepath.Join(h.env.Root, cloudDir), map[string]struct{}{}, h.env) + _, ignored, err := d.getSourceFiles(filepath.Join(h.Env.Root, parsecli.CloudDir), map[string]struct{}{}, h.Env) ensure.Nil(t, err) ensure.DeepEqual(t, ignored, []string{ - filepath.Join(h.env.Root, cloudDir, "test~")}) - _, ignored, err = d.getSourceFiles(filepath.Join(h.env.Root, hostingDir), map[string]struct{}{}, h.env) + filepath.Join(h.Env.Root, parsecli.CloudDir, "test~")}) + _, ignored, err = d.getSourceFiles(filepath.Join(h.Env.Root, parsecli.HostingDir), map[string]struct{}{}, h.Env) ensure.DeepEqual(t, ignored, []string{ - filepath.Join(h.env.Root, hostingDir, "#ignore"), - filepath.Join(h.env.Root, hostingDir, ".ignore"), + filepath.Join(h.Env.Root, parsecli.HostingDir, "#ignore"), + filepath.Join(h.Env.Root, parsecli.HostingDir, ".ignore"), }) } @@ -685,17 +699,17 @@ func TestIgnoredFilesUnderDotDir(t *testing.T) { d := deployCmd{Verbose: true} - _, ignored, err := d.getSourceFiles(filepath.Join(h.env.Root, cloudDir), map[string]struct{}{}, h.env) + _, ignored, err := d.getSourceFiles(filepath.Join(h.Env.Root, parsecli.CloudDir), map[string]struct{}{}, h.Env) ensure.Nil(t, err) ensure.DeepEqual(t, ignored, []string{ - filepath.Join(h.env.Root, cloudDir, "test~")}) - _, ignored, err = d.getSourceFiles(filepath.Join(h.env.Root, hostingDir), map[string]struct{}{}, h.env) + filepath.Join(h.Env.Root, parsecli.CloudDir, "test~")}) + _, ignored, err = d.getSourceFiles(filepath.Join(h.Env.Root, parsecli.HostingDir), map[string]struct{}{}, h.Env) ensure.DeepEqual(t, ignored, []string{ - filepath.Join(h.env.Root, hostingDir, "#ignore"), - filepath.Join(h.env.Root, hostingDir, ".ignore"), + filepath.Join(h.Env.Root, parsecli.HostingDir, "#ignore"), + filepath.Join(h.Env.Root, parsecli.HostingDir, ".ignore"), }) } diff --git a/develop_cmd.go b/parsecmd/develop.go similarity index 83% rename from develop_cmd.go rename to parsecmd/develop.go index a19fd80..4293df8 100644 --- a/develop_cmd.go +++ b/parsecmd/develop.go @@ -1,10 +1,11 @@ -package main +package parsecmd import ( "fmt" "net" "time" + "github.com/ParsePlatform/parse-cli/parsecli" "github.com/spf13/cobra" ) @@ -24,9 +25,9 @@ type developCmd struct { type deployFunc func(parseVersion string, prevDeplInfo *deployInfo, forDevelop bool, - e *env) (*deployInfo, error) + e *parsecli.Env) (*deployInfo, error) -func (d *developCmd) contDeploy(e *env, deployer deployFunc, first, done chan struct{}) { +func (d *developCmd) contDeploy(e *parsecli.Env, deployer deployFunc, first, done chan struct{}) { if d.deployInterval == 0 { d.deployInterval = time.Second } @@ -42,7 +43,7 @@ func (d *developCmd) contDeploy(e *env, deployer deployFunc, first, done chan st case <-done: return } - config, err := configFromDir(e.Root) + config, err := parsecli.ConfigFromDir(e.Root) if err != nil { if !latestError { latestError = true @@ -53,7 +54,7 @@ func (d *developCmd) contDeploy(e *env, deployer deployFunc, first, done chan st `Config malformed. Please fix your config file in %s and try again. `, - getConfigFile(e), + parsecli.GetConfigFile(e), ) } if first != nil { // first deploy aborted @@ -63,7 +64,7 @@ Please fix your config file in %s and try again. continue } latestError = false - newDeplInfo, _ := deployer(config.getProjectConfig().Parse.JSSDK, prevDeplInfo, true, e) + newDeplInfo, _ := deployer(config.GetProjectConfig().Parse.JSSDK, prevDeplInfo, true, e) if !d.mustFetch { prevDeplInfo = newDeplInfo } @@ -74,7 +75,7 @@ Please fix your config file in %s and try again. } } -func (d *developCmd) handleError(e *env, err error, sleep func(time.Duration)) error { +func (d *developCmd) handleError(e *parsecli.Env, err error, sleep func(time.Duration)) error { if err == nil { return nil } @@ -92,7 +93,7 @@ func (d *developCmd) handleError(e *env, err error, sleep func(time.Duration)) e return err } -func (d *developCmd) run(e *env, c *client) error { +func (d *developCmd) run(e *parsecli.Env, c *parsecli.Context) error { first := make(chan struct{}) go d.contDeploy(e, deployFunc((&deployCmd{Verbose: d.Verbose}).deploy), @@ -121,7 +122,7 @@ func (d *developCmd) run(e *env, c *client) error { return err } -func newDevelopCmd(e *env) *cobra.Command { +func NewDevelopCmd(e *parsecli.Env) *cobra.Command { d := &developCmd{deployInterval: time.Second} cmd := &cobra.Command{ Use: "develop app", @@ -130,7 +131,7 @@ func newDevelopCmd(e *env) *cobra.Command { `This will also monitor the parse INFO log for any new log messages and write ` + `out updates to the terminal. This requires an app to be provided, to ` + `avoid running develop on production apps accidently.`, - Run: runWithAppClient(e, d.run), + Run: parsecli.RunWithClientConfirm(e, d.run), } cmd.Flags().DurationVarP(&d.deployInterval, "interval", "i", d.deployInterval, "Number of seconds between deploys.") cmd.Flags().BoolVarP(&d.mustFetch, "fetch", "f", d.mustFetch, "Always fetch previous deployment info from server") diff --git a/develop_cmd_test.go b/parsecmd/develop_test.go similarity index 72% rename from develop_cmd_test.go rename to parsecmd/develop_test.go index dc65e00..11ad7d0 100644 --- a/develop_cmd_test.go +++ b/parsecmd/develop_test.go @@ -1,4 +1,4 @@ -package main +package parsecmd import ( "errors" @@ -9,6 +9,7 @@ import ( "testing" "time" + "github.com/ParsePlatform/parse-cli/parsecli" "github.com/facebookgo/ensure" ) @@ -21,7 +22,7 @@ func TestContDeploy(t *testing.T) { deployer := deployFunc(func(parseVersion string, prevDeplInfo *deployInfo, forDevelop bool, - e *env) (*deployInfo, error) { + e *parsecli.Env) (*deployInfo, error) { return &deployInfo{}, nil }) @@ -32,7 +33,7 @@ func TestContDeploy(t *testing.T) { }() first := make(chan struct{}) - (&developCmd{}).contDeploy(h.env, deployer, first, done) + (&developCmd{}).contDeploy(h.Env, deployer, first, done) _, opened := <-first ensure.False(t, opened) } @@ -45,17 +46,17 @@ func TestContDeployConfigErr(t *testing.T) { ensure.Nil(t, ioutil.WriteFile( - filepath.Join(h.env.Root, legacyConfigFile), + filepath.Join(h.Env.Root, parsecli.LegacyConfigFile), []byte("}"), 0600, ), ) - h.env.Type = legacy + h.Env.Type = parsecli.LegacyParseFormat deployer := deployFunc(func(parseVersion string, prevDeplInfo *deployInfo, forDevelop bool, - e *env) (*deployInfo, error) { + e *parsecli.Env) (*deployInfo, error) { return &deployInfo{}, nil }) @@ -66,7 +67,7 @@ func TestContDeployConfigErr(t *testing.T) { }() first := make(chan struct{}) - (&developCmd{}).contDeploy(h.env, deployer, first, done) + (&developCmd{}).contDeploy(h.Env, deployer, first, done) _, opened := <-first ensure.False(t, opened) @@ -77,25 +78,25 @@ func TestContDeployConfigErr(t *testing.T) { `Config malformed. Please fix your config file in %s and try again. `, - filepath.Join(h.env.Root, legacyConfigFile), + filepath.Join(h.Env.Root, parsecli.LegacyConfigFile), ), ) } func TestHandleError(t *testing.T) { t.Parallel() - h := newHarness(t) + h := parsecli.NewHarness(t) defer h.Stop() d := &developCmd{} netErr := &net.OpError{Err: errors.New("network")} sleep := func(d time.Duration) {} - ensure.DeepEqual(t, d.handleError(h.env, netErr, sleep), netErr) + ensure.DeepEqual(t, d.handleError(h.Env, netErr, sleep), netErr) ensure.DeepEqual(t, h.Err.String(), "Flaky network. Waiting 20s before trying to fetch logs again.") h.Out.Reset() h.Err.Reset() otherErr := errors.New("other") - ensure.DeepEqual(t, d.handleError(h.env, otherErr, sleep), otherErr) + ensure.DeepEqual(t, d.handleError(h.Env, otherErr, sleep), otherErr) ensure.DeepEqual(t, h.Err.Len(), 0) } diff --git a/parsecmd/download.go b/parsecmd/download.go new file mode 100644 index 0000000..ca8b3e7 --- /dev/null +++ b/parsecmd/download.go @@ -0,0 +1,291 @@ +package parsecmd + +import ( + "crypto/md5" + "errors" + "fmt" + "io" + "io/ioutil" + "net/url" + "os" + "path" + "path/filepath" + "sync/atomic" + + "github.com/ParsePlatform/parse-cli/parsecli" + "github.com/facebookgo/errgroup" + "github.com/facebookgo/stackerr" + "github.com/spf13/cobra" +) + +type downloadCmd struct { + release *deployInfo + destination string + force bool +} + +var errNoFiles = errors.New("Nothing to download. Not yet deployed to the app.") + +func (d *downloadCmd) verifyChecksum(path, checksum string) error { + file, err := os.Open(path) + if err != nil { + return stackerr.Wrap(err) + } + defer file.Close() + + h := md5.New() + _, err = io.Copy(h, file) + if err != nil { + return stackerr.Wrap(err) + } + + sum := fmt.Sprintf("%x", h.Sum(nil)) + if sum != checksum { + return stackerr.Newf("Invalid checksum for %s", path) + } + + return nil +} + +func (d *downloadCmd) moveFiles( + e *parsecli.Env, + destination string, + release *deployInfo) error { + var wg errgroup.Group + + maxParallel := make(chan struct{}, maxOpenFD) + numFiles := len(release.Versions.Cloud) + len(release.Versions.Public) + wg.Add(numFiles) + + var numErrors int32 + moveFile := func(destination, kind, file, checksum string) { + defer func() { + wg.Done() + <-maxParallel + }() + + err := os.MkdirAll( + filepath.Join(e.Root, kind, filepath.Dir(file)), + 0755, + ) + if err != nil { + atomic.AddInt32(&numErrors, 1) + wg.Error(stackerr.Wrap(err)) + return + } + + err = os.Rename( + filepath.Join(destination, kind, file), + filepath.Join(e.Root, kind, file), + ) + if err != nil { + atomic.AddInt32(&numErrors, 1) + wg.Error(stackerr.Wrap(err)) + return + } + + err = d.verifyChecksum( + filepath.Join(e.Root, kind, file), + checksum, + ) + if err != nil { + atomic.AddInt32(&numErrors, 1) + wg.Error(err) + return + } + } + + for file, checksum := range release.Checksums.Cloud { + maxParallel <- struct{}{} + go moveFile( + destination, + parsecli.CloudDir, + file, + checksum, + ) + } + for file, checksum := range release.Checksums.Public { + maxParallel <- struct{}{} + go moveFile( + destination, + parsecli.HostingDir, + file, + checksum, + ) + } + + if err := wg.Wait(); err != nil { + // could not move a single file so no corruption:w + if int(numErrors) == numFiles { + fmt.Fprintf( + e.Out, + `Failed to download Cloud Code to + %q +Try "parse download" and manually move contents from +the temporary download location. +`, + e.Root, + ) + return nil + } + + fmt.Fprintf( + e.Out, + `Failed to download Cloud Code to + %q + +It might have corrupted contents, due to partially moved files. + +Try "parse download" and manually move contents from +the temporary download location. +`, + e.Root, + ) + return err + } + return nil +} + +func (d *downloadCmd) download(e *parsecli.Env, destination string, release *deployInfo) error { + var wg errgroup.Group + maxParallel := make(chan struct{}, maxOpenFD) + wg.Add(len(release.Versions.Cloud) + len(release.Versions.Public)) + + downloadHosted := func(file, version, checksum string) { + defer func() { + wg.Done() + <-maxParallel + }() + + v := make(url.Values) + v.Set("version", version) + v.Set("checksum", checksum) + u := &url.URL{ + Path: path.Join("hosted_files", file), + RawQuery: v.Encode(), + } + var content []byte + _, err := e.ParseAPIClient.Get(u, &content) + if err != nil { + wg.Error(err) + return + } + + path := path.Join(destination, parsecli.HostingDir, file) + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + wg.Error(stackerr.Wrap(err)) + return + } + if err := ioutil.WriteFile(path, content, 0644); err != nil { + wg.Error(stackerr.Wrap(err)) + return + } + if err := d.verifyChecksum(path, checksum); err != nil { + wg.Error(err) + return + } + } + + downloadScript := func(file, version, checksum string) { + defer func() { + wg.Done() + <-maxParallel + }() + + v := make(url.Values) + v.Set("version", version) + v.Set("checksum", checksum) + u := &url.URL{ + Path: path.Join("scripts", file), + RawQuery: v.Encode(), + } + var content string + _, err := e.ParseAPIClient.Get(u, &content) + if err != nil { + wg.Error(err) + return + } + + path := path.Join(destination, parsecli.CloudDir, file) + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + wg.Error(stackerr.Wrap(err)) + return + } + if err := ioutil.WriteFile(path, []byte(content), 0644); err != nil { + wg.Error(stackerr.Wrap(err)) + return + } + } + + for file, version := range release.Versions.Public { + checksum, ok := release.Checksums.Public[file] + if !ok { + continue + } + maxParallel <- struct{}{} + go downloadHosted(file, version, checksum) + } + + for file, version := range release.Versions.Cloud { + checksum, ok := release.Checksums.Cloud[file] + if !ok { + continue + } + maxParallel <- struct{}{} + go downloadScript(file, version, checksum) + } + + return wg.Wait() +} + +func (d *downloadCmd) run(e *parsecli.Env, c *parsecli.Context) error { + var err error + + latestRelease := d.release + if latestRelease == nil { + latestRelease, err = (&deployCmd{}).getPrevDeplInfo(e) + if err != nil { + return err + } + if len(latestRelease.Versions.Cloud) == 0 && len(latestRelease.Versions.Public) == 0 { + return errNoFiles + } + } + + destination := d.destination + if destination == "" { + destination, err = ioutil.TempDir("", "parse_code_") + if err != nil { + return stackerr.Wrap(err) + } + } + + err = d.download(e, destination, latestRelease) + if err != nil { + fmt.Fprintln(e.Err, "Failed to download Cloud Code.") + return stackerr.Wrap(err) + } + if !d.force { + fmt.Fprintf(e.Out, "Successfully downloaded Cloud Code to %q.\n", destination) + return nil + } + + return stackerr.Wrap(d.moveFiles(e, destination, latestRelease)) +} + +func NewDownloadCmd(e *parsecli.Env) *cobra.Command { + d := &downloadCmd{} + cmd := &cobra.Command{ + Use: "download [app]", + Short: "Downloads the Cloud Code project", + Long: `Downloads the Cloud Code project at a given location, +or at a temporary location if nothing is explicitly provided through the -l flag. +`, + Run: parsecli.RunWithClient(e, d.run), + } + cmd.Flags().BoolVarP(&d.force, "force", "f", d.force, + "Force will overwrite any files in the current project directory") + cmd.Flags().StringVarP(&d.destination, "location", "l", d.destination, + "Download Cloud Code project at the given location.") + return cmd +} diff --git a/parsecmd/download_test.go b/parsecmd/download_test.go new file mode 100644 index 0000000..42dbde8 --- /dev/null +++ b/parsecmd/download_test.go @@ -0,0 +1,137 @@ +package parsecmd + +import ( + "io/ioutil" + "net/http" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + + "github.com/ParsePlatform/parse-cli/parsecli" + "github.com/facebookgo/ensure" + "github.com/facebookgo/parse" +) + +var ( + scriptPath = regexp.MustCompile("/1/scripts/.*") + hostedPath = regexp.MustCompile("/1/hosted_files/.*") +) + +func newDownloadHarness(t testing.TB) (*parsecli.Harness, *downloadCmd) { + h := createParseProject(t) + ht := parsecli.TransportFunc(func(r *http.Request) (*http.Response, error) { + ensure.DeepEqual(t, r.FormValue("version"), "version") + switch { + case scriptPath.MatchString(r.URL.Path): + return &http.Response{ + StatusCode: http.StatusOK, + Body: ioutil.NopCloser(strings.NewReader(`"content"`)), + }, nil + case hostedPath.MatchString(r.URL.Path): + return &http.Response{ + StatusCode: http.StatusOK, + Body: ioutil.NopCloser(strings.NewReader(`[1, 1, 2, 3, 5, 8, 13]`)), + }, nil + default: + return &http.Response{ + StatusCode: http.StatusNotFound, + Body: ioutil.NopCloser(strings.NewReader(`{"error": "something is wrong"}`)), + }, nil + } + }) + + h.MakeEmptyRoot() + h.Env.ParseAPIClient = &parsecli.ParseAPIClient{APIClient: &parse.Client{Transport: ht}} + + d := &downloadCmd{ + release: &deployInfo{ + Versions: deployFileData{ + Cloud: map[string]string{"main.js": "version"}, + Public: map[string]string{"index.html": "version"}, + }, + Checksums: deployFileData{ + Cloud: map[string]string{"main.js": "9a0364b9e99bb480dd25e1f0284c8555"}, + Public: map[string]string{"index.html": "ea46dea1ca5f0b7a728aa3c2a87ae8a1"}, + }, + }, + } + + return h, d +} + +func TestDownload(t *testing.T) { + t.Parallel() + + h, d := newDownloadHarness(t) + defer h.Stop() + + tempDir, err := ioutil.TempDir("", "download_") + ensure.Nil(t, err) + defer func() { + os.RemoveAll(tempDir) + }() + + err = d.download(h.Env, tempDir, d.release) + ensure.Nil(t, err) + for file := range d.release.Versions.Cloud { + _, err := os.Open(filepath.Join(tempDir, parsecli.CloudDir, file)) + ensure.Nil(t, err) + } + for file := range d.release.Versions.Public { + _, err := os.Open(filepath.Join(tempDir, parsecli.HostingDir, file)) + ensure.Nil(t, err) + } +} + +func TestDownloadMoveFiles(t *testing.T) { + t.Parallel() + h, d := newDownloadHarness(t) + defer h.Stop() + + content := []byte("content") + d.release.Checksums.Cloud["main.js"] = "9a0364b9e99bb480dd25e1f0284c8555" + d.release.Checksums.Public["index.html"] = "9a0364b9e99bb480dd25e1f0284c8555" + + tempDir, err := ioutil.TempDir("", "move_files_") + defer func() { + os.RemoveAll(tempDir) + }() + + err = os.MkdirAll( + filepath.Join(tempDir, parsecli.HostingDir), + 0755, + ) + ensure.Nil(t, err) + err = os.MkdirAll( + filepath.Join(tempDir, parsecli.CloudDir), + 0755, + ) + ensure.Nil(t, err) + + err = ioutil.WriteFile( + filepath.Join(tempDir, parsecli.HostingDir, "index.html"), + content, + 0644, + ) + ensure.Nil(t, err) + err = ioutil.WriteFile( + filepath.Join(tempDir, parsecli.CloudDir, "main.js"), + content, + 0644, + ) + ensure.Nil(t, err) + + err = d.moveFiles(h.Env, tempDir, d.release) + ensure.Nil(t, err) + + readData, err := ioutil.ReadFile( + filepath.Join(h.Env.Root, parsecli.HostingDir, "index.html"), + ) + ensure.DeepEqual(t, readData, content) + readData, err = ioutil.ReadFile( + filepath.Join(h.Env.Root, parsecli.CloudDir, "main.js"), + ) + ensure.DeepEqual(t, readData, content) +} diff --git a/generate_cmd.go b/parsecmd/generate.go similarity index 92% rename from generate_cmd.go rename to parsecmd/generate.go index 461d15c..56c61e8 100644 --- a/generate_cmd.go +++ b/parsecmd/generate.go @@ -1,4 +1,4 @@ -package main +package parsecmd import ( "bytes" @@ -7,6 +7,7 @@ import ( "path/filepath" "strings" + "github.com/ParsePlatform/parse-cli/parsecli" "github.com/facebookgo/stackerr" "github.com/spf13/cobra" ) @@ -38,12 +39,12 @@ func (g *generateCmd) validateArgs() error { return nil } -func (g *generateCmd) run(e *env) error { +func (g *generateCmd) run(e *parsecli.Env) error { if err := g.validateArgs(); err != nil { return err } - destDir := filepath.Join(e.Root, cloudDir) + destDir := filepath.Join(e.Root, parsecli.CloudDir) existingFiles := false appJs := filepath.Join(destDir, "app.js") @@ -137,13 +138,13 @@ func (g *generateCmd) run(e *env) error { return nil } -func newGenerateCmd(e *env) *cobra.Command { +func NewGenerateCmd(e *parsecli.Env) *cobra.Command { c := generateCmd{} cmd := &cobra.Command{ Use: "generate", Short: "Generates a sample express app in the current project directory", Long: "Generates a sample express app in the current project directory.", - Run: runNoArgs(e, c.run), + Run: parsecli.RunNoArgs(e, c.run), } cmd.Flags().StringVarP(&c.generateType, "type", "t", expressEjs, "Type of templates to use for generation.") return cmd diff --git a/generate_cmd_test.go b/parsecmd/generate_test.go similarity index 76% rename from generate_cmd_test.go rename to parsecmd/generate_test.go index 0eeedb6..f53cbee 100644 --- a/generate_cmd_test.go +++ b/parsecmd/generate_test.go @@ -1,4 +1,4 @@ -package main +package parsecmd import ( "fmt" @@ -8,6 +8,7 @@ import ( "strings" "testing" + "github.com/ParsePlatform/parse-cli/parsecli" "github.com/facebookgo/ensure" ) @@ -28,9 +29,9 @@ func TestGenerateCheckInvalidArgs(t *testing.T) { ensure.Err(t, err, regexp.MustCompile(`type can only be one of `)) } -func newGenerateCmdHarness(t testing.TB, generateType string) (*Harness, *generateCmd) { - h := newHarness(t) - h.makeEmptyRoot() +func newGenerateCmdHarness(t testing.TB, generateType string) (*parsecli.Harness, *generateCmd) { + h := parsecli.NewHarness(t) + h.MakeEmptyRoot() g := &generateCmd{generateType} return h, g } @@ -39,7 +40,7 @@ func TestGenerateEjsOutput(t *testing.T) { t.Parallel() h, g := newGenerateCmdHarness(t, expressEjs) defer h.Stop() - g.run(h.env) + g.run(h.Env) output := fmt.Sprintf(`Creating directory %s. Writing out sample file %s @@ -49,9 +50,9 @@ Almost done! Please add this line to the top of your main.js: require('cloud/app.js'); -`, filepath.Join(h.env.Root, "cloud", "views"), - filepath.Join(h.env.Root, "cloud", "app.js"), - filepath.Join(h.env.Root, "cloud", "views", "hello.ejs")) +`, filepath.Join(h.Env.Root, "cloud", "views"), + filepath.Join(h.Env.Root, "cloud", "app.js"), + filepath.Join(h.Env.Root, "cloud", "views", "hello.ejs")) ensure.DeepEqual(t, h.Out.String(), output) ensure.DeepEqual(t, h.Err.String(), "") @@ -61,9 +62,9 @@ func TestGenerateEjs(t *testing.T) { t.Parallel() h, g := newGenerateCmdHarness(t, expressEjs) defer h.Stop() - g.run(h.env) + g.run(h.Env) - cloudRoot := filepath.Join(h.env.Root, cloudDir) + cloudRoot := filepath.Join(h.Env.Root, parsecli.CloudDir) appJs := filepath.Join(cloudRoot, "app.js") content, err := ioutil.ReadFile(appJs) @@ -79,16 +80,16 @@ func TestGenerateEjsExists(t *testing.T) { t.Parallel() h, g := newGenerateCmdHarness(t, expressEjs) defer h.Stop() - g.run(h.env) + g.run(h.Env) - ensure.Err(t, g.run(h.env), regexp.MustCompile("Please remove the above existing files and try again.")) + ensure.Err(t, g.run(h.Env), regexp.MustCompile("Please remove the above existing files and try again.")) } func TestGenerateJadeOutput(t *testing.T) { t.Parallel() h, g := newGenerateCmdHarness(t, expressJade) defer h.Stop() - g.run(h.env) + g.run(h.Env) output := fmt.Sprintf(`Creating directory %s. Writing out sample file %s @@ -98,9 +99,9 @@ Almost done! Please add this line to the top of your main.js: require('cloud/app.js'); -`, filepath.Join(h.env.Root, "cloud", "views"), - filepath.Join(h.env.Root, "cloud", "app.js"), - filepath.Join(h.env.Root, "cloud", "views", "hello.jade")) +`, filepath.Join(h.Env.Root, "cloud", "views"), + filepath.Join(h.Env.Root, "cloud", "app.js"), + filepath.Join(h.Env.Root, "cloud", "views", "hello.jade")) ensure.DeepEqual(t, h.Out.String(), output) ensure.DeepEqual(t, h.Err.String(), "") @@ -110,9 +111,9 @@ func TestGenerateJade(t *testing.T) { t.Parallel() h, g := newGenerateCmdHarness(t, expressJade) defer h.Stop() - g.run(h.env) + g.run(h.Env) - cloudRoot := filepath.Join(h.env.Root, cloudDir) + cloudRoot := filepath.Join(h.Env.Root, parsecli.CloudDir) appJs := filepath.Join(cloudRoot, "app.js") content, err := ioutil.ReadFile(appJs) @@ -128,7 +129,7 @@ func TestGenerateJadeExists(t *testing.T) { t.Parallel() h, g := newGenerateCmdHarness(t, expressJade) defer h.Stop() - g.run(h.env) + g.run(h.Env) - ensure.Err(t, g.run(h.env), regexp.MustCompile("Please remove the above existing files and try again.")) + ensure.Err(t, g.run(h.Env), regexp.MustCompile("Please remove the above existing files and try again.")) } diff --git a/ios_symbol_uploader.go b/parsecmd/ios_symbol_uploader.go similarity index 91% rename from ios_symbol_uploader.go rename to parsecmd/ios_symbol_uploader.go index eec8dce..f022d7b 100644 --- a/ios_symbol_uploader.go +++ b/parsecmd/ios_symbol_uploader.go @@ -1,14 +1,16 @@ -package main +package parsecmd import ( "bytes" "fmt" + "net/http" "os" "os/exec" "path/filepath" "runtime" "github.com/DHowett/go-plist" + "github.com/ParsePlatform/parse-cli/parsecli" "github.com/facebookgo/stackerr" "github.com/inconshreveable/go-update" "github.com/mitchellh/go-homedir" @@ -102,7 +104,7 @@ func (i *iosSymbolUploader) getBuildVersionFromXcarchive() (string, error) { return properties.ApplicationProperties.CFBundleVersion, nil } -func (i *iosSymbolUploader) uploadSymbols(e *env) error { +func (i *iosSymbolUploader) uploadSymbols(e *parsecli.Env) error { appBuildVersion, err := i.getBuildVersionFromXcarchive() if err != nil { return err @@ -127,7 +129,7 @@ func (i *iosSymbolUploader) uploadSymbols(e *env) error { }, true, e) } -func (i *iosSymbolUploader) prepareSymbolsFolder(folderPath string, e *env) error { +func (i *iosSymbolUploader) prepareSymbolsFolder(folderPath string, e *parsecli.Env) error { if err := os.RemoveAll(folderPath); err != nil { return stackerr.Wrap(err) } @@ -141,7 +143,7 @@ type updateTool func(string, string) error func (i *iosSymbolUploader) symbolConversionTool(baseDir string, updater updateTool, - e *env) (string, error) { + e *parsecli.Env) (string, error) { converter := os.Getenv("PARSE_SYMBOL_CONVERTER") if converter != "" { @@ -177,7 +179,12 @@ func (i *iosSymbolUploader) symbolConversionTool(baseDir string, if updater == nil { if toUpdate { fmt.Fprintln(e.Out, "Fetching required resources...") - err, _ = update.New().Target(toolPath).FromUrl(osxSymbolConverterDownloadURL) + var resp *http.Response + resp, err = http.Get(osxSymbolConverterDownloadURL) + if err == nil { + defer resp.Body.Close() + err = update.Apply(resp.Body, update.Options{TargetPath: toolPath}) + } } } else { err = updater(toolPath, osxSymbolConverterDownloadURL) @@ -194,7 +201,7 @@ func (i *iosSymbolUploader) symbolConversionTool(baseDir string, return toolPath, nil } -func (i *iosSymbolUploader) convertSymbols(e *env) ([]string, error) { +func (i *iosSymbolUploader) convertSymbols(e *parsecli.Env) ([]string, error) { homedir, err := homedir.Dir() if err != nil { return nil, stackerr.Wrap(err) diff --git a/ios_symbol_uploader_test.go b/parsecmd/ios_symbol_uploader_test.go similarity index 78% rename from ios_symbol_uploader_test.go rename to parsecmd/ios_symbol_uploader_test.go index 3b28e8f..9ae5376 100644 --- a/ios_symbol_uploader_test.go +++ b/parsecmd/ios_symbol_uploader_test.go @@ -1,4 +1,4 @@ -package main +package parsecmd import ( "os" @@ -7,6 +7,7 @@ import ( "runtime" "testing" + "github.com/ParsePlatform/parse-cli/parsecli" "github.com/facebookgo/ensure" ) @@ -55,15 +56,15 @@ func TestBuildVersionFromXArchive(t *testing.T) { func TestPrepareSymbolsFolder(t *testing.T) { t.Parallel() - h := newHarness(t) + h := parsecli.NewHarness(t) defer h.Stop() - h.makeEmptyRoot() + h.MakeEmptyRoot() createRandomFiles(t, h) var i iosSymbolUploader - ensure.Nil(t, i.prepareSymbolsFolder(h.env.Root, h.env)) - files, err := readDirNames(h.env.Root) + ensure.Nil(t, i.prepareSymbolsFolder(h.Env.Root, h.Env)) + files, err := readDirNames(h.Env.Root) ensure.Nil(t, err) ensure.DeepEqual(t, len(files), 0) } @@ -71,18 +72,18 @@ func TestPrepareSymbolsFolder(t *testing.T) { func TestSymbolConversionToolPath(t *testing.T) { t.Parallel() var i iosSymbolUploader - h := newHarness(t) + h := parsecli.NewHarness(t) defer h.Stop() - h.makeEmptyRoot() - ensure.Nil(t, os.MkdirAll(filepath.Join(h.env.Root, ".parse"), 0755)) - path, err := i.symbolConversionTool(h.env.Root, + h.MakeEmptyRoot() + ensure.Nil(t, os.MkdirAll(filepath.Join(h.Env.Root, ".parse"), 0755)) + path, err := i.symbolConversionTool(h.Env.Root, func(path string, url string) error { return nil }, - h.env) + h.Env) ensure.Nil(t, err) - ensure.DeepEqual(t, path, filepath.Join(h.env.Root, ".parse", "parse_symbol_converter")) + ensure.DeepEqual(t, path, filepath.Join(h.Env.Root, ".parse", "parse_symbol_converter")) ensure.DeepEqual(t, h.Out.String(), `Additional resources installed. `) diff --git a/jssdk_cmd.go b/parsecmd/jssdk.go similarity index 64% rename from jssdk_cmd.go rename to parsecmd/jssdk.go index 1d081e9..2a534a3 100644 --- a/jssdk_cmd.go +++ b/parsecmd/jssdk.go @@ -1,10 +1,11 @@ -package main +package parsecmd import ( "fmt" "net/url" "sort" + "github.com/ParsePlatform/parse-cli/parsecli" "github.com/facebookgo/stackerr" "github.com/spf13/cobra" ) @@ -18,25 +19,25 @@ type jsSDKCmd struct { newVersion string } -func (j *jsSDKCmd) getAllJSSdks(e *env) ([]string, error) { +func (j *jsSDKCmd) getAllJSSdks(e *parsecli.Env) ([]string, error) { u := &url.URL{ Path: "jsVersions", } var response jsSDKVersion - if _, err := e.Client.Get(u, &response); err != nil { + if _, err := e.ParseAPIClient.Get(u, &response); err != nil { return nil, stackerr.Wrap(err) } sort.Sort(naturalStrings(response.JS)) return response.JS, nil } -func (j *jsSDKCmd) printVersions(e *env, c *client) error { +func (j *jsSDKCmd) printVersions(e *parsecli.Env, c *parsecli.Context) error { allVersions, err := j.getAllJSSdks(e) if err != nil { return err } - currentVersion := c.Config.getProjectConfig().Parse.JSSDK + currentVersion := c.Config.GetProjectConfig().Parse.JSSDK for _, version := range allVersions { prefix := " " if currentVersion == version { @@ -47,19 +48,19 @@ func (j *jsSDKCmd) printVersions(e *env, c *client) error { return nil } -func (j *jsSDKCmd) getVersion(e *env, c *client) error { - if c.Config.getProjectConfig().Parse.JSSDK == "" { +func (j *jsSDKCmd) getVersion(e *parsecli.Env, c *parsecli.Context) error { + if c.Config.GetProjectConfig().Parse.JSSDK == "" { return stackerr.New("JavaScript SDK version not set for this project.") } fmt.Fprintf( e.Out, "Current JavaScript SDK version is %s\n", - c.Config.getProjectConfig().Parse.JSSDK, + c.Config.GetProjectConfig().Parse.JSSDK, ) return nil } -func (j *jsSDKCmd) setVersion(e *env, c *client) error { +func (j *jsSDKCmd) setVersion(e *parsecli.Env, c *parsecli.Context) error { allVersions, err := j.getAllJSSdks(e) if err != nil { return err @@ -74,22 +75,22 @@ func (j *jsSDKCmd) setVersion(e *env, c *client) error { return stackerr.New("Invalid SDK version selected.") } - conf, err := configFromDir(e.Root) + conf, err := parsecli.ConfigFromDir(e.Root) if err != nil { return err } - conf.getProjectConfig().Parse.JSSDK = j.newVersion - if err := storeProjectConfig(e, conf); err != nil { + conf.GetProjectConfig().Parse.JSSDK = j.newVersion + if err := parsecli.StoreProjectConfig(e, conf); err != nil { return err } - fmt.Fprintf(e.Out, "Current JavaScript SDK version is %s\n", conf.getProjectConfig().Parse.JSSDK) + fmt.Fprintf(e.Out, "Current JavaScript SDK version is %s\n", conf.GetProjectConfig().Parse.JSSDK) return nil } // useLatestJSSDK is a utility method used by deploy & develop // to write set jssdk version to latest available, if none set -func useLatestJSSDK(e *env) error { +func UseLatestJSSDK(e *parsecli.Env) error { var j jsSDKCmd versions, err := j.getAllJSSdks(e) if err != nil { @@ -100,16 +101,16 @@ func useLatestJSSDK(e *env) error { return stackerr.New("No JavaScript SDK version is available.") } - config, err := configFromDir(e.Root) + config, err := parsecli.ConfigFromDir(e.Root) if err != nil { return err } - config.getProjectConfig().Parse.JSSDK = versions[0] - return storeProjectConfig(e, config) + config.GetProjectConfig().Parse.JSSDK = versions[0] + return parsecli.StoreProjectConfig(e, config) } -func (j *jsSDKCmd) run(e *env, c *client, args []string) error { +func (j *jsSDKCmd) run(e *parsecli.Env, c *parsecli.Context, args []string) error { switch { case j.all: return j.printVersions(e, c) @@ -122,14 +123,14 @@ func (j *jsSDKCmd) run(e *env, c *client, args []string) error { } } -func newJsSdkCmd(e *env) *cobra.Command { +func NewJsSdkCmd(e *parsecli.Env) *cobra.Command { j := jsSDKCmd{} cmd := &cobra.Command{ Use: "jssdk [version]", Short: "Sets the Parse JavaScript SDK version to use in Cloud Code", Long: `Sets the Parse JavaScript SDK version to use in Cloud Code. ` + `Prints the version number currently set if none is specified.`, - Run: runWithArgsClient(e, j.run), + Run: parsecli.RunWithArgsClient(e, j.run), } cmd.Flags().BoolVarP(&j.all, "all", "a", j.all, "Shows all available JavaScript SDK version") diff --git a/parsecmd/jssdk_test.go b/parsecmd/jssdk_test.go new file mode 100644 index 0000000..937f3b4 --- /dev/null +++ b/parsecmd/jssdk_test.go @@ -0,0 +1,178 @@ +package parsecmd + +import ( + "io/ioutil" + "net/http" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + + "github.com/ParsePlatform/parse-cli/parsecli" + "github.com/facebookgo/ensure" + "github.com/facebookgo/parse" +) + +var defaultParseConfig = &parsecli.ParseConfig{ + ProjectConfig: &parsecli.ProjectConfig{ + Type: parsecli.LegacyParseFormat, + Parse: &parsecli.ParseProjectConfig{}, + }, +} + +func newJsSdkHarness(t testing.TB) *parsecli.Harness { + h := parsecli.NewHarness(t) + ht := parsecli.TransportFunc(func(r *http.Request) (*http.Response, error) { + ensure.DeepEqual(t, r.URL.Path, "/1/jsVersions") + rows := jsSDKVersion{JS: []string{"1.2.8", "1.2.9", "1.2.10", "1.2.11", "0.2.0"}} + return &http.Response{ + StatusCode: http.StatusOK, + Body: ioutil.NopCloser(strings.NewReader(jsonStr(t, rows))), + }, nil + }) + h.Env.ParseAPIClient = &parsecli.ParseAPIClient{APIClient: &parse.Client{Transport: ht}} + return h +} + +func newJsSdkHarnessError(t testing.TB) *parsecli.Harness { + h := parsecli.NewHarness(t) + ht := parsecli.TransportFunc(func(r *http.Request) (*http.Response, error) { + ensure.DeepEqual(t, r.URL.Path, "/1/jsVersions") + return &http.Response{ + StatusCode: http.StatusExpectationFailed, + Body: ioutil.NopCloser(strings.NewReader(`{"error":"something is wrong"}`)), + }, nil + }) + h.Env.ParseAPIClient = &parsecli.ParseAPIClient{APIClient: &parse.Client{Transport: ht}} + return h +} + +func newJsSdkHarnessWithConfig(t testing.TB) (*parsecli.Harness, *parsecli.Context) { + h := newJsSdkHarness(t) + h.MakeEmptyRoot() + + ensure.Nil(t, parsecli.CloneSampleCloudCode(h.Env, true)) + h.Out.Reset() + + c, err := parsecli.ConfigFromDir(h.Env.Root) + ensure.Nil(t, err) + + config, ok := (c).(*parsecli.ParseConfig) + ensure.True(t, ok) + + return h, &parsecli.Context{Config: config} +} + +func TestGetAllJSVersions(t *testing.T) { + t.Parallel() + h := newJsSdkHarness(t) + defer h.Stop() + j := jsSDKCmd{} + versions, err := j.getAllJSSdks(h.Env) + ensure.Nil(t, err) + ensure.DeepEqual(t, versions, []string{"1.2.11", "1.2.10", "1.2.9", "1.2.8", "0.2.0"}) +} + +func TestGetAllJSVersionsError(t *testing.T) { + t.Parallel() + h := newJsSdkHarnessError(t) + defer h.Stop() + j := jsSDKCmd{} + _, err := j.getAllJSSdks(h.Env) + ensure.Err(t, err, regexp.MustCompile(`something is wrong`)) +} + +func TestPrintVersions(t *testing.T) { + t.Parallel() + h, c := newJsSdkHarnessWithConfig(t) + defer h.Stop() + j := jsSDKCmd{} + ensure.Nil(t, j.printVersions(h.Env, c)) + ensure.DeepEqual(t, h.Out.String(), + ` 1.2.11 + 1.2.10 + 1.2.9 + 1.2.8 + 0.2.0 +`) +} + +func TestSetVersionInvalid(t *testing.T) { + t.Parallel() + h, c := newJsSdkHarnessWithConfig(t) + defer h.Stop() + j := jsSDKCmd{newVersion: "1.2.12"} + ensure.Err(t, j.setVersion(h.Env, c), regexp.MustCompile("Invalid SDK version selected")) +} + +func TestSetVersionNoneSelected(t *testing.T) { + t.Parallel() + h := parsecli.NewHarness(t) + defer h.Stop() + + c := &parsecli.Context{Config: defaultParseConfig} + var j jsSDKCmd + + c.Config.GetProjectConfig().Parse.JSSDK = "1.2.1" + ensure.Nil(t, j.getVersion(h.Env, c)) + ensure.DeepEqual(t, "Current JavaScript SDK version is 1.2.1\n", + h.Out.String()) + + c.Config.GetProjectConfig().Parse.JSSDK = "" + ensure.Err(t, j.getVersion(h.Env, c), + regexp.MustCompile("JavaScript SDK version not set for this project.")) +} + +func TestSetValidVersion(t *testing.T) { + t.Parallel() + + h, c := newJsSdkHarnessWithConfig(t) + defer h.Stop() + j := jsSDKCmd{newVersion: "1.2.11"} + ensure.Nil(t, j.setVersion(h.Env, c)) + ensure.DeepEqual(t, h.Out.String(), "Current JavaScript SDK version is 1.2.11\n") + + content, err := ioutil.ReadFile(filepath.Join(h.Env.Root, parsecli.ParseProject)) + ensure.Nil(t, err) + ensure.DeepEqual(t, string(content), `{ + "project_type": 1, + "parse": { + "jssdk": "1.2.11" + } +}`) +} + +// NOTE: testing for legacy config format +func newLegacyJsSdkHarnessWithConfig(t testing.TB) (*parsecli.Harness, *parsecli.Context) { + h := newJsSdkHarness(t) + h.MakeEmptyRoot() + + ensure.Nil(t, os.Mkdir(filepath.Join(h.Env.Root, parsecli.ConfigDir), 0755)) + path := filepath.Join(h.Env.Root, parsecli.LegacyConfigFile) + ensure.Nil(t, ioutil.WriteFile(path, + []byte(`{ + "global": { + "parseVersion" : "1.2.9" + } + }`), + 0600)) + + c, err := parsecli.ConfigFromDir(h.Env.Root) + ensure.Nil(t, err) + + config, ok := (c).(*parsecli.ParseConfig) + ensure.True(t, ok) + + return h, &parsecli.Context{Config: config} +} + +func TestLegacySetValidVersion(t *testing.T) { + t.Parallel() + + h, c := newLegacyJsSdkHarnessWithConfig(t) + defer h.Stop() + j := jsSDKCmd{newVersion: "1.2.11"} + ensure.Nil(t, j.setVersion(h.Env, c)) + ensure.DeepEqual(t, h.Out.String(), "Current JavaScript SDK version is 1.2.11\n") +} diff --git a/logs_cmd.go b/parsecmd/logs.go similarity index 84% rename from logs_cmd.go rename to parsecmd/logs.go index c6b8a37..2cc19bd 100644 --- a/logs_cmd.go +++ b/parsecmd/logs.go @@ -1,4 +1,4 @@ -package main +package parsecmd import ( "encoding/json" @@ -7,6 +7,7 @@ import ( "strings" "time" + "github.com/ParsePlatform/parse-cli/parsecli" "github.com/facebookgo/stackerr" "github.com/spf13/cobra" ) @@ -29,7 +30,7 @@ type logsCmd struct { level string } -func (l *logsCmd) run(e *env, c *client) error { +func (l *logsCmd) run(e *parsecli.Env, c *parsecli.Context) error { level := strings.ToUpper(l.level) if level != "INFO" && level != "ERROR" { return stackerr.Newf("invalid level: %q", l.level) @@ -64,7 +65,7 @@ func (l *logsCmd) run(e *env, c *client) error { return nil } -func (l *logsCmd) round(e *env, c *client, startTime *parseTime) (*parseTime, error) { +func (l *logsCmd) round(e *parsecli.Env, c *parsecli.Context, startTime *parseTime) (*parseTime, error) { v := make(url.Values) v.Set("n", fmt.Sprint(l.num)) v.Set("level", l.level) @@ -82,7 +83,7 @@ func (l *logsCmd) round(e *env, c *client, startTime *parseTime) (*parseTime, er RawQuery: v.Encode(), } var rows []logResponse - if _, err := e.Client.Get(u, &rows); err != nil { + if _, err := e.ParseAPIClient.Get(u, &rows); err != nil { return nil, stackerr.Wrap(err) } // logs come back in reverse @@ -95,7 +96,7 @@ func (l *logsCmd) round(e *env, c *client, startTime *parseTime) (*parseTime, er return startTime, nil } -func newLogsCmd(e *env) *cobra.Command { +func NewLogsCmd(e *parsecli.Env) *cobra.Command { l := logsCmd{ level: "INFO", } @@ -103,7 +104,7 @@ func newLogsCmd(e *env) *cobra.Command { Use: "logs", Short: "Prints out recent log messages", Long: "Prints out recent log messages.", - Run: runWithClient(e, l.run), + Run: parsecli.RunWithClient(e, l.run), Aliases: []string{"log"}, } cmd.Flags().UintVarP(&l.num, "num", "n", l.num, diff --git a/logs_cmd_test.go b/parsecmd/logs_test.go similarity index 82% rename from logs_cmd_test.go rename to parsecmd/logs_test.go index c307bde..a54bea4 100644 --- a/logs_cmd_test.go +++ b/parsecmd/logs_test.go @@ -1,4 +1,4 @@ -package main +package parsecmd import ( "io/ioutil" @@ -8,6 +8,7 @@ import ( "sync/atomic" "testing" + "github.com/ParsePlatform/parse-cli/parsecli" "github.com/facebookgo/ensure" "github.com/facebookgo/parse" ) @@ -15,18 +16,18 @@ import ( func TestLogInvalidLevel(t *testing.T) { t.Parallel() l := logsCmd{} - h := newHarness(t) + h := parsecli.NewHarness(t) defer h.Stop() - err := l.run(h.env, nil) + err := l.run(h.Env, nil) ensure.Err(t, err, regexp.MustCompile(`invalid level: ""`)) } func TestLogWithoutFollow(t *testing.T) { t.Parallel() l := logsCmd{level: "INFO"} - h := newHarness(t) + h := parsecli.NewHarness(t) defer h.Stop() - ht := transportFunc(func(r *http.Request) (*http.Response, error) { + ht := parsecli.TransportFunc(func(r *http.Request) (*http.Response, error) { ensure.DeepEqual(t, r.URL.Path, "/1/scriptlog") rows := []logResponse{{Message: "foo bar"}, {Message: "baz"}} return &http.Response{ @@ -34,8 +35,8 @@ func TestLogWithoutFollow(t *testing.T) { Body: ioutil.NopCloser(strings.NewReader(jsonStr(t, rows))), }, nil }) - h.env.Client = &Client{client: &parse.Client{Transport: ht}} - err := l.run(h.env, &client{}) + h.Env.ParseAPIClient = &parsecli.ParseAPIClient{APIClient: &parse.Client{Transport: ht}} + err := l.run(h.Env, &parsecli.Context{}) ensure.Nil(t, err) ensure.DeepEqual(t, h.Out.String(), "baz\nfoo bar\n") } @@ -43,14 +44,14 @@ func TestLogWithoutFollow(t *testing.T) { func TestLogWithFollow(t *testing.T) { t.Parallel() l := logsCmd{level: "INFO", follow: true} - h := newHarness(t) + h := parsecli.NewHarness(t) defer h.Stop() var round int64 round1Time := parseTime{ISO: "iso1", Type: "type1"} round1TimeStr := jsonStr(t, round1Time) round3Time := parseTime{ISO: "iso2", Type: "type2"} round3TimeStr := jsonStr(t, round3Time) - ht := transportFunc(func(r *http.Request) (*http.Response, error) { + ht := parsecli.TransportFunc(func(r *http.Request) (*http.Response, error) { switch atomic.AddInt64(&round, 1) { case 1: // expect no timestamp, return some data @@ -100,8 +101,8 @@ func TestLogWithFollow(t *testing.T) { } }() - h.env.Client = &Client{client: &parse.Client{Transport: ht}} - err := l.run(h.env, &client{}) + h.Env.ParseAPIClient = &parsecli.ParseAPIClient{APIClient: &parse.Client{Transport: ht}} + err := l.run(h.Env, &parsecli.Context{}) close(stop) ensure.Err(t, err, regexp.MustCompile(`parse: error with status=500 and body="a"`)) diff --git a/parsecmd/new.go b/parsecmd/new.go new file mode 100644 index 0000000..a0e09f6 --- /dev/null +++ b/parsecmd/new.go @@ -0,0 +1,47 @@ +package parsecmd + +import ( + "fmt" + + "github.com/ParsePlatform/parse-cli/parsecli" + "github.com/facebookgo/parse" +) + +func CloneSampleCloudCode( + e *parsecli.Env, + isNew, configOnly bool, + appConfig parsecli.AppConfig) (bool, error) { + dumpTemplate := false + if !isNew && !configOnly { + // if parse app was already created try to fetch Cloud Code and populate dir + masterKey, err := appConfig.GetMasterKey(e) + if err != nil { + return false, err + } + e.ParseAPIClient = e.ParseAPIClient.WithCredentials( + parse.MasterKey{ + ApplicationID: appConfig.GetApplicationID(), + MasterKey: masterKey, + }, + ) + + d := &downloadCmd{destination: e.Root} + err = d.run(e, nil) + if err != nil { + if err == errNoFiles { + dumpTemplate = true + } else { + fmt.Fprintln( + e.Out, + ` +NOTE: If you like to fetch the latest deployed Cloud Code from Parse, +you can use the "parse download" command after finishing the set up. +This will download Cloud Code to a temporary location. +`, + ) + } + } + } + dumpTemplate = (isNew || dumpTemplate) && !configOnly + return dumpTemplate, parsecli.CloneSampleCloudCode(e, dumpTemplate) +} diff --git a/parse_symbol_uploader.go b/parsecmd/parse_symbol_uploader.go similarity index 90% rename from parse_symbol_uploader.go rename to parsecmd/parse_symbol_uploader.go index 445575d..f89b7dc 100644 --- a/parse_symbol_uploader.go +++ b/parsecmd/parse_symbol_uploader.go @@ -1,4 +1,4 @@ -package main +package parsecmd import ( "bufio" @@ -12,6 +12,7 @@ import ( "path" "path/filepath" + "github.com/ParsePlatform/parse-cli/parsecli" "github.com/facebookgo/errgroup" "github.com/facebookgo/stackerr" ) @@ -33,9 +34,9 @@ func base64MD5OfFile(filename string) (string, error) { return base64.StdEncoding.EncodeToString(h.Sum(nil)), nil } -func uploadSymbolFiles(files []string, commonHeaders map[string]string, removeFiles bool, e *env) error { +func uploadSymbolFiles(files []string, commonHeaders map[string]string, removeFiles bool, e *parsecli.Env) error { var wg errgroup.Group - uploadFile := func(filename string, e *env) { + uploadFile := func(filename string, e *parsecli.Env) { defer wg.Done() name := filepath.Base(filepath.Clean(filename)) file, err := os.Open(filename) @@ -69,7 +70,7 @@ func uploadSymbolFiles(files []string, commonHeaders map[string]string, removeFi } req.Header.Add("Content-Type", mimeType) res := make(map[string]interface{}) - if _, err := e.Client.Do(req, nil, &res); err != nil { + if _, err := e.ParseAPIClient.Do(req, nil, &res); err != nil { wg.Error(err) return } diff --git a/parse_symbol_uploader_test.go b/parsecmd/parse_symbol_uploader_test.go similarity index 72% rename from parse_symbol_uploader_test.go rename to parsecmd/parse_symbol_uploader_test.go index 59117f0..2a2e5ff 100644 --- a/parse_symbol_uploader_test.go +++ b/parsecmd/parse_symbol_uploader_test.go @@ -1,4 +1,4 @@ -package main +package parsecmd import ( "fmt" @@ -10,24 +10,25 @@ import ( "strings" "testing" + "github.com/ParsePlatform/parse-cli/parsecli" "github.com/facebookgo/ensure" "github.com/facebookgo/parse" ) -func createRandomFiles(t *testing.T, h *Harness) { +func createRandomFiles(t *testing.T, h *parsecli.Harness) { for _, filename := range []string{"a", "b"} { - ensure.Nil(t, ioutil.WriteFile(filepath.Join(h.env.Root, filename), + ensure.Nil(t, ioutil.WriteFile(filepath.Join(h.Env.Root, filename), []byte(fmt.Sprintf(`Content of file : %s`, filename)), 0755)) } } func TestBase64MD5OfFile(t *testing.T) { t.Parallel() - h := newHarness(t) + h := parsecli.NewHarness(t) defer h.Stop() - h.makeEmptyRoot() - testFile := filepath.Join(h.env.Root, "test") + h.MakeEmptyRoot() + testFile := filepath.Join(h.Env.Root, "test") ensure.Nil(t, ioutil.WriteFile(testFile, []byte(`A test string`), 0755)) @@ -38,42 +39,42 @@ func TestBase64MD5OfFile(t *testing.T) { func TestBase64MD5OfFileNoFile(t *testing.T) { t.Parallel() - h := newHarness(t) + h := parsecli.NewHarness(t) defer h.Stop() - h.makeEmptyRoot() - testFile := filepath.Join(h.env.Root, "test") + h.MakeEmptyRoot() + testFile := filepath.Join(h.Env.Root, "test") _, err := base64MD5OfFile(testFile) ensure.NotNil(t, err) } func TestReadDirNames(t *testing.T) { t.Parallel() - h := newHarness(t) + h := parsecli.NewHarness(t) defer h.Stop() - h.makeEmptyRoot() + h.MakeEmptyRoot() createRandomFiles(t, h) - files, err := readDirNames(h.env.Root) + files, err := readDirNames(h.Env.Root) sort.Strings(files) ensure.DeepEqual(t, len(files), 2) ensure.DeepEqual(t, files[:], []string{ - filepath.Join(h.env.Root, "a"), - filepath.Join(h.env.Root, "b"), + filepath.Join(h.Env.Root, "a"), + filepath.Join(h.Env.Root, "b"), }) ensure.Nil(t, err) } func TestUploadFiles(t *testing.T) { t.Parallel() - h := newHarness(t) + h := parsecli.NewHarness(t) defer h.Stop() - h.makeEmptyRoot() + h.MakeEmptyRoot() createRandomFiles(t, h) names := []string{"a", "b"} - ht := transportFunc(func(r *http.Request) (*http.Response, error) { + ht := parsecli.TransportFunc(func(r *http.Request) (*http.Response, error) { switch filepath.Base(r.URL.Path) { case names[0]: ensure.NotNil(t, r.Header) @@ -100,14 +101,14 @@ func TestUploadFiles(t *testing.T) { }, nil } }) - h.env.Client = &Client{client: &parse.Client{Transport: ht}} + h.Env.ParseAPIClient = &parsecli.ParseAPIClient{APIClient: &parse.Client{Transport: ht}} var filenames []string for _, name := range names { - filenames = append(filenames, filepath.Join(h.env.Root, name)) + filenames = append(filenames, filepath.Join(h.Env.Root, name)) } ensure.Nil(t, uploadSymbolFiles(filenames[:], - map[string]string{"Key": "Value"}, true, h.env)) + map[string]string{"Key": "Value"}, true, h.Env)) for _, filename := range filenames { _, err := os.Lstat(filename) ensure.NotNil(t, err) diff --git a/parseignore.go b/parsecmd/parseignore.go similarity index 92% rename from parseignore.go rename to parsecmd/parseignore.go index 89cc21b..f0d3699 100644 --- a/parseignore.go +++ b/parsecmd/parseignore.go @@ -1,4 +1,4 @@ -package main +package parsecmd import ( "bytes" @@ -6,16 +6,17 @@ import ( "path/filepath" "strings" + "github.com/ParsePlatform/parse-cli/parsecli" "github.com/facebookgo/parseignore" "github.com/facebookgo/symwalk" ) -func ignoreErrors(errors []error, e *env) string { +func ignoreErrors(errors []error, e *parsecli.Env) string { l := len(errors) var b bytes.Buffer for n, err := range errors { - b.WriteString(errorString(e, err)) + b.WriteString(parsecli.ErrorString(e, err)) if n != l-1 { b.WriteString("\n") } diff --git a/parseignore_test.go b/parsecmd/parseignore_test.go similarity index 99% rename from parseignore_test.go rename to parsecmd/parseignore_test.go index acc00c7..7dbe850 100644 --- a/parseignore_test.go +++ b/parsecmd/parseignore_test.go @@ -1,4 +1,4 @@ -package main +package parsecmd import ( "fmt" diff --git a/releases_cmd.go b/parsecmd/releases.go similarity index 85% rename from releases_cmd.go rename to parsecmd/releases.go index e3ef6f4..68615f7 100644 --- a/releases_cmd.go +++ b/parsecmd/releases.go @@ -1,4 +1,4 @@ -package main +package parsecmd import ( "encoding/json" @@ -8,6 +8,7 @@ import ( "strings" "text/tabwriter" + "github.com/ParsePlatform/parse-cli/parsecli" "github.com/facebookgo/stackerr" "github.com/spf13/cobra" ) @@ -30,7 +31,7 @@ type releasesCmd struct { func (r *releasesCmd) printFileNames( fileVersions map[string]interface{}, - e *env) { + e *parsecli.Env) { var files []string for name := range fileVersions { files = append(files, name) @@ -41,11 +42,12 @@ func (r *releasesCmd) printFileNames( func (r *releasesCmd) printFiles(version string, releases []releasesResponse, - e *env) error { + e *parsecli.Env) error { var files string for _, release := range releases { if release.Version == version { files = release.UserFiles + break } } if files == "" { @@ -60,7 +62,7 @@ Note that you can list files for all releases shown in "parse releases"`, return stackerr.Wrap(err) } if len(versionFileNames.Cloud) != 0 { - fmt.Fprintf(e.Out, "Deployed cloud code files:\n") + fmt.Fprintf(e.Out, "Deployed Cloud Code files:\n") r.printFileNames(versionFileNames.Cloud, e) } if len(versionFileNames.Cloud) != 0 && len(versionFileNames.Public) != 0 { @@ -73,12 +75,12 @@ Note that you can list files for all releases shown in "parse releases"`, return nil } -func (r *releasesCmd) run(e *env, c *client) error { +func (r *releasesCmd) run(e *parsecli.Env, c *parsecli.Context) error { u := &url.URL{ Path: "releases", } var releasesList []releasesResponse - if _, err := e.Client.Get(u, &releasesList); err != nil { + if _, err := e.ParseAPIClient.Get(u, &releasesList); err != nil { return stackerr.Wrap(err) } @@ -100,13 +102,13 @@ func (r *releasesCmd) run(e *env, c *client) error { return nil } -func newReleasesCmd(e *env) *cobra.Command { +func NewReleasesCmd(e *parsecli.Env) *cobra.Command { r := &releasesCmd{} cmd := &cobra.Command{ Use: "releases [app]", Short: "Gets the releases for a Parse App", Long: "Prints the releases the server knows about.", - Run: runWithClient(e, r.run), + Run: parsecli.RunWithClient(e, r.run), } cmd.Flags().StringVarP(&r.version, "version", "v", r.version, "List files names of the deployed version.") diff --git a/releases_cmd_test.go b/parsecmd/releases_test.go similarity index 60% rename from releases_cmd_test.go rename to parsecmd/releases_test.go index 008eb9a..3e85a3b 100644 --- a/releases_cmd_test.go +++ b/parsecmd/releases_test.go @@ -1,4 +1,4 @@ -package main +package parsecmd import ( "io/ioutil" @@ -7,14 +7,15 @@ import ( "strings" "testing" + "github.com/ParsePlatform/parse-cli/parsecli" "github.com/facebookgo/ensure" "github.com/facebookgo/parse" "github.com/facebookgo/stackerr" ) -func newReleasesCmdHarness(t testing.TB) (*Harness, *releasesCmd) { - h := newHarness(t) - h.makeEmptyRoot() +func newReleasesCmdHarness(t testing.TB) (*parsecli.Harness, *releasesCmd) { + h := parsecli.NewHarness(t) + h.MakeEmptyRoot() r := &releasesCmd{} return h, r } @@ -26,16 +27,16 @@ func TestReleasesCmd(t *testing.T) { {Version: "v1", Description: "version 1", Timestamp: "time 1"}, {Version: "v2", Description: "version 2", Timestamp: "time 2"}, } - ht := transportFunc(func(r *http.Request) (*http.Response, error) { + ht := parsecli.TransportFunc(func(r *http.Request) (*http.Response, error) { ensure.DeepEqual(t, r.URL.Path, "/1/releases") return &http.Response{ StatusCode: http.StatusOK, Body: ioutil.NopCloser(strings.NewReader(jsonStr(t, rows))), }, nil }) - h.env.Client = &Client{client: &parse.Client{Transport: ht}} + h.Env.ParseAPIClient = &parsecli.ParseAPIClient{APIClient: &parse.Client{Transport: ht}} - ensure.Nil(t, r.run(h.env, &client{})) + ensure.Nil(t, r.run(h.Env, &parsecli.Context{})) expected := `Name Description Date v1 version 1 time 1 @@ -47,15 +48,15 @@ v2 version 2 time 2 func TestReleasesCmdError(t *testing.T) { h, c := newReleasesCmdHarness(t) defer h.Stop() - ht := transportFunc(func(r *http.Request) (*http.Response, error) { + ht := parsecli.TransportFunc(func(r *http.Request) (*http.Response, error) { return nil, stackerr.New("Throws error") }) - h.env.Client = &Client{client: &parse.Client{Transport: ht}} + h.Env.ParseAPIClient = &parsecli.ParseAPIClient{APIClient: &parse.Client{Transport: ht}} - ensure.NotNil(t, c.run(h.env, &client{})) + ensure.NotNil(t, c.run(h.Env, &parsecli.Context{})) } -func TestReleasesCmdPrintVersin(t *testing.T) { +func TestReleasesCmdPrintVersion(t *testing.T) { h, r := newReleasesCmdHarness(t) releases := []releasesResponse{ {Version: "v1", @@ -69,23 +70,29 @@ func TestReleasesCmdPrintVersin(t *testing.T) { "public": {"index.html": "2", "docs.html": "2"} }`, }, + {Version: "v2", + UserFiles: `{ + "cloud": {"v2_main.js": "2", "v2_app.js": "2", "views/v2_docs.js": "2"}, + "public": {"v2_index.html": "2", "v2_docs.html": "2"} + }`, + }, } - err := r.printFiles("", releases, h.env) + err := r.printFiles("", releases, h.Env) ensure.Err(t, err, regexp.MustCompile("Unable to fetch files for release version")) h.Out.Reset() - err = r.printFiles("v1", releases, h.env) + err = r.printFiles("v1", releases, h.Env) ensure.Nil(t, err) - ensure.DeepEqual(t, h.Out.String(), `Deployed cloud code files: + ensure.DeepEqual(t, h.Out.String(), `Deployed Cloud Code files: app.js main.js views/index.js `) h.Out.Reset() - err = r.printFiles("v2", releases, h.env) + err = r.printFiles("v2", releases, h.Env) ensure.Nil(t, err) - ensure.DeepEqual(t, h.Out.String(), `Deployed cloud code files: + ensure.DeepEqual(t, h.Out.String(), `Deployed Cloud Code files: app.js main.js views/docs.js diff --git a/rollback_cmd.go b/parsecmd/rollback.go similarity index 77% rename from rollback_cmd.go rename to parsecmd/rollback.go index 2abbb41..df3efb0 100644 --- a/rollback_cmd.go +++ b/parsecmd/rollback.go @@ -1,9 +1,10 @@ -package main +package parsecmd import ( "fmt" "net/url" + "github.com/ParsePlatform/parse-cli/parsecli" "github.com/facebookgo/stackerr" "github.com/spf13/cobra" ) @@ -16,7 +17,7 @@ type rollbackInfo struct { ReleaseName string `json:"releaseName,omitEmpty"` } -func (r *rollbackCmd) run(e *env, c *client) error { +func (r *rollbackCmd) run(e *parsecli.Env, c *parsecli.Context) error { var req rollbackInfo message := "previous release" if r.ReleaseName != "" { @@ -27,7 +28,7 @@ func (r *rollbackCmd) run(e *env, c *client) error { fmt.Fprintf(e.Out, "Rolling back to %s\n", message) var response rollbackInfo - if _, err := e.Client.Post(&url.URL{Path: "deploy"}, + if _, err := e.ParseAPIClient.Post(&url.URL{Path: "deploy"}, &req, &response); err != nil { return stackerr.Newf("Rollback failed with %s", stackerr.Wrap(err)) } @@ -35,13 +36,13 @@ func (r *rollbackCmd) run(e *env, c *client) error { return nil } -func newRollbackCmd(e *env) *cobra.Command { +func NewRollbackCmd(e *parsecli.Env) *cobra.Command { r := rollbackCmd{} cmd := &cobra.Command{ Use: "rollback [app]", Short: "Rolls back the version for the given app", Long: "Rolls back the version for the given app.", - Run: runWithClient(e, r.run), + Run: parsecli.RunWithClient(e, r.run), } cmd.Flags().StringVarP(&r.ReleaseName, "release", "r", r.ReleaseName, "Provides an optional release to rollback to. If no release is provided, rolls back to the previous release.") diff --git a/rollback_cmd_test.go b/parsecmd/rollback_test.go similarity index 67% rename from rollback_cmd_test.go rename to parsecmd/rollback_test.go index 6594a15..dc4af2a 100644 --- a/rollback_cmd_test.go +++ b/parsecmd/rollback_test.go @@ -1,4 +1,4 @@ -package main +package parsecmd import ( "encoding/json" @@ -8,13 +8,14 @@ import ( "strings" "testing" + "github.com/ParsePlatform/parse-cli/parsecli" "github.com/facebookgo/ensure" "github.com/facebookgo/parse" ) -func newRollbackCmdHarness(t testing.TB) *Harness { - h := newHarness(t) - ht := transportFunc(func(r *http.Request) (*http.Response, error) { +func newRollbackCmdHarness(t testing.TB) *parsecli.Harness { + h := parsecli.NewHarness(t) + ht := parsecli.TransportFunc(func(r *http.Request) (*http.Response, error) { ensure.DeepEqual(t, r.URL.Path, "/1/deploy") var req, res rollbackInfo ensure.Nil(t, json.NewDecoder(r.Body).Decode(&req)) @@ -28,7 +29,7 @@ func newRollbackCmdHarness(t testing.TB) *Harness { Body: ioutil.NopCloser(strings.NewReader(jsonStr(t, &res))), }, nil }) - h.env.Client = &Client{client: &parse.Client{Transport: ht}} + h.Env.ParseAPIClient = &parsecli.ParseAPIClient{APIClient: &parse.Client{Transport: ht}} return h } @@ -37,7 +38,7 @@ func TestRollbackToPrevious(t *testing.T) { var r rollbackCmd h := newRollbackCmdHarness(t) defer h.Stop() - ensure.Nil(t, r.run(h.env, nil)) + ensure.Nil(t, r.run(h.Env, nil)) ensure.DeepEqual(t, h.Out.String(), `Rolling back to previous release Rolled back to version v0 @@ -49,7 +50,7 @@ func TestRollbackToVersionOne(t *testing.T) { r := rollbackCmd{ReleaseName: "v1"} h := newRollbackCmdHarness(t) defer h.Stop() - ensure.Nil(t, r.run(h.env, nil)) + ensure.Nil(t, r.run(h.Env, nil)) ensure.DeepEqual(t, h.Out.String(), `Rolling back to v1 Rolled back to version v1 @@ -59,17 +60,17 @@ Rolled back to version v1 func TestRollbackError(t *testing.T) { t.Parallel() var r rollbackCmd - h := newHarness(t) + h := parsecli.NewHarness(t) defer h.Stop() var res struct{ Error string } res.Error = "something is wrong" - ht := transportFunc(func(r *http.Request) (*http.Response, error) { + ht := parsecli.TransportFunc(func(r *http.Request) (*http.Response, error) { ensure.DeepEqual(t, r.URL.Path, "/1/deploy") return &http.Response{ StatusCode: http.StatusExpectationFailed, Body: ioutil.NopCloser(strings.NewReader(jsonStr(t, &res))), }, nil }) - h.env.Client = &Client{client: &parse.Client{Transport: ht}} - ensure.Err(t, r.run(h.env, nil), regexp.MustCompile("something is wrong")) + h.Env.ParseAPIClient = &parsecli.ParseAPIClient{APIClient: &parse.Client{Transport: ht}} + ensure.Err(t, r.run(h.Env, nil), regexp.MustCompile("something is wrong")) } diff --git a/symbols_cmd.go b/parsecmd/symbols.go similarity index 87% rename from symbols_cmd.go rename to parsecmd/symbols.go index 9a92956..ccba654 100644 --- a/symbols_cmd.go +++ b/parsecmd/symbols.go @@ -1,8 +1,9 @@ -package main +package parsecmd import ( "fmt" + "github.com/ParsePlatform/parse-cli/parsecli" "github.com/facebookgo/stackerr" "github.com/spf13/cobra" ) @@ -15,7 +16,7 @@ type symbolsCmd struct { skipOsCheck bool } -func (s *symbolsCmd) run(e *env, c *client) error { +func (s *symbolsCmd) run(e *parsecli.Env, c *parsecli.Context) error { android := &androidSymbolUploader{ Path: s.path, Apk: s.apk, @@ -45,14 +46,14 @@ func (s *symbolsCmd) run(e *env, c *client) error { } } -func newSymbolsCmd(e *env) *cobra.Command { +func NewSymbolsCmd(e *parsecli.Env) *cobra.Command { var s symbolsCmd cmd := &cobra.Command{ Use: "symbols [app]", Short: "Uploads symbol files", Long: `Uploads the symbol files for the application to symbolicate crash reports with. Path specifies the path to xcarchive/dSYM/DWARF for iOS or mapping.txt for Android.`, - Run: runWithClient(e, s.run), + Run: parsecli.RunWithClient(e, s.run), } cmd.Flags().StringVarP(&s.path, "path", "p", s.path, "Path to symbols files") diff --git a/parsecmd/symbols_test.go b/parsecmd/symbols_test.go new file mode 100644 index 0000000..dac7389 --- /dev/null +++ b/parsecmd/symbols_test.go @@ -0,0 +1,19 @@ +package parsecmd + +import ( + "regexp" + "testing" + + "github.com/ParsePlatform/parse-cli/parsecli" + "github.com/facebookgo/ensure" +) + +func TestRunSymbolsCmd(t *testing.T) { + t.Parallel() + + h := parsecli.NewHarness(t) + defer h.Stop() + + s := &symbolsCmd{} + ensure.Err(t, s.run(h.Env, nil), regexp.MustCompile("Please specify path")) +} diff --git a/templates.go b/parsecmd/templates.go similarity index 58% rename from templates.go rename to parsecmd/templates.go index 12d966b..5ff701e 100644 --- a/templates.go +++ b/parsecmd/templates.go @@ -1,37 +1,6 @@ -package main +package parsecmd const ( - sampleSource = ` -// Use Parse.Cloud.define to define as many cloud functions as you want. -// For example: -Parse.Cloud.define("hello", function(request, response) { - response.success("Hello world!"); -}); -` - - sampleHTML = ` - - - My ParseApp site - - - -
-

Congratulations! You're already hosting with Parse.

-

To get started, edit this file at public/index.html and start adding static content.

-

If you want something a bit more dynamic, delete this file and check out our hosting docs.

-
- - -` - sampleAppJS = ` // These two lines are required to initialize Express in Cloud Code. express = require('express'); diff --git a/parsecmd/utils.go b/parsecmd/utils.go new file mode 100644 index 0000000..fce19e9 --- /dev/null +++ b/parsecmd/utils.go @@ -0,0 +1,68 @@ +package parsecmd + +import ( + "regexp" + "strconv" +) + +var numbers = regexp.MustCompile("[0-9]+") + +func numericLessThan(a, b string) bool { + aLen, bLen := len(a), len(b) + minLen := aLen + if bLen < minLen { + minLen = bLen + } + if minLen == 0 { + if aLen == 0 { + return bLen != 0 + } + return false + } + + i := 0 + for ; i < minLen && a[i] == b[i]; i++ { + } + if i == minLen { + if aLen == bLen { + return false + } + if aLen == minLen { + return true + } + return false + } + + a, b = a[i:], b[i:] + aNos, bNos := numbers.FindAllStringIndex(a, 1), numbers.FindAllStringIndex(b, 1) + + if len(aNos) == 1 && aNos[0][0] == 0 && + len(bNos) == 1 && bNos[0][0] == 0 { + aNum, err := strconv.Atoi(a[:aNos[0][1]]) + if err != nil { + return a < b + } + bNum, err := strconv.Atoi(b[:bNos[0][1]]) + if err != nil { + return a < b + } + if aNum != bNum { + return aNum < bNum + } + } + return a < b +} + +type naturalStrings []string + +func (v naturalStrings) Len() int { + return len(v) +} + +func (v naturalStrings) Less(i, j int) bool { + return !numericLessThan(v[i], v[j]) // desc order +} + +func (v naturalStrings) Swap(i, j int) { + v[i], v[j] = v[j], v[i] +} diff --git a/utils_test.go b/parsecmd/utils_test.go similarity index 74% rename from utils_test.go rename to parsecmd/utils_test.go index 174c99d..29e3e1e 100644 --- a/utils_test.go +++ b/parsecmd/utils_test.go @@ -1,8 +1,7 @@ -package main +package parsecmd import ( "crypto/rand" - "regexp" "testing" "github.com/facebookgo/ensure" @@ -67,19 +66,3 @@ func TestABWithNumbers(t *testing.T) { ensure.False(t, numericLessThan("abc12", "abc9")) ensure.DeepEqual(t, numericLessThan("abc001", "abc1"), "abc001" < "abc1") } - -func TestGetHostFromURL(t *testing.T) { - urlStr := "https://api.parse.com/1/" - host, err := getHostFromURL(urlStr) - ensure.Nil(t, err) - ensure.DeepEqual(t, host, "api.parse.com") - - urlStr = "https://api.example.com:8080/1/" - host, err = getHostFromURL(urlStr) - ensure.Nil(t, err) - ensure.DeepEqual(t, host, "api.example.com") - - urlStr = "api.example.com:8080:90" - host, err = getHostFromURL(urlStr) - ensure.Err(t, err, regexp.MustCompile("not a valid url")) -} diff --git a/update.go b/update.go new file mode 100644 index 0000000..6ab320e --- /dev/null +++ b/update.go @@ -0,0 +1,119 @@ +package main + +import ( + "fmt" + "net/http" + "net/url" + "runtime" + + "github.com/ParsePlatform/parse-cli/parsecli" + "github.com/facebookgo/stackerr" + "github.com/inconshreveable/go-update" + "github.com/kardianos/osext" + "github.com/spf13/cobra" +) + +const ( + macDownload = "parse" + windowsDownload = "parse.exe" + linuxDownload = "parse_linux" + linuxArmDownload = "parse_linux_arm" + downloadURLFormat = "https://github.com/ParsePlatform/parse-cli/releases/download/release_%s/%s" +) + +type updateCmd struct{} + +func (u *updateCmd) latestVersion(e *parsecli.Env) (string, error) { + v := make(url.Values) + v.Set("version", "latest") + req := &http.Request{ + Method: "GET", + URL: &url.URL{Path: "supported", RawQuery: v.Encode()}, + } + + var res struct { + Version string `json:"version"` + } + + if _, err := e.ParseAPIClient.Do(req, nil, &res); err != nil { + return "", stackerr.Wrap(err) + } + + return res.Version, nil +} + +func (u *updateCmd) getDownloadURL(e *parsecli.Env) (string, error) { + ostype := runtime.GOOS + arch := runtime.GOARCH + + latestVersion, err := u.latestVersion(e) + if err != nil { + return "", err + } + if latestVersion == "" || latestVersion == parsecli.Version { + return "", nil + } + + var downloadURL string + switch ostype { + case "darwin": + downloadURL = fmt.Sprintf(downloadURLFormat, latestVersion, macDownload) + case "windows": + downloadURL = fmt.Sprintf(downloadURLFormat, latestVersion, windowsDownload) + case "linux": + if arch == "arm" { + downloadURL = fmt.Sprintf(downloadURLFormat, latestVersion, linuxArmDownload) + } else { + downloadURL = fmt.Sprintf(downloadURLFormat, latestVersion, linuxDownload) + } + } + return downloadURL, nil +} + +func (u *updateCmd) updateCLI(e *parsecli.Env) (bool, error) { + downloadURL, err := u.getDownloadURL(e) + if err != nil { + return false, err + } + if downloadURL == "" { + return false, nil + } + exec, err := osext.Executable() + if err != nil { + return false, stackerr.Wrap(err) + } + + fmt.Fprintf(e.Out, "Downloading binary from %s.\n", downloadURL) + resp, err := http.Get(downloadURL) + if err != nil { + return false, stackerr.Newf("Update failed with error: %v", err) + } + defer resp.Body.Close() + err = update.Apply(resp.Body, update.Options{TargetPath: exec}) + if err != nil { + return false, stackerr.Newf("Update failed with error: %v", err) + } + fmt.Fprintf(e.Out, "Successfully updated binary at: %s\n", exec) + return true, nil +} + +func (u *updateCmd) run(e *parsecli.Env) error { + updated, err := u.updateCLI(e) + if err != nil { + return err + } + if !updated { + fmt.Fprintf(e.Out, "Already using the latest cli version: %s\n", parsecli.Version) + } + return nil +} + +func NewUpdateCmd(e *parsecli.Env) *cobra.Command { + var u updateCmd + return &cobra.Command{ + Use: "update", + Short: "Updates this tool to the latest version", + Long: "Updates this tool to the latest version.", + Run: parsecli.RunNoArgs(e, u.run), + } +} diff --git a/update_cmd.go b/update_cmd.go deleted file mode 100644 index 59012b6..0000000 --- a/update_cmd.go +++ /dev/null @@ -1,94 +0,0 @@ -package main - -import ( - "fmt" - "net/http" - "net/url" - "path" - "runtime" - "strings" - - "github.com/facebookgo/stackerr" - "github.com/inconshreveable/go-update" - "github.com/kardianos/osext" - "github.com/spf13/cobra" -) - -const ( - macCliDownloadURL = "https://parse.com/downloads/cloud_code/cli/parse-osx/latest" - unixCliDownloadURL = "https://parse.com/downloads/cloud_code/cli/parse-linux/latest" - windowsCliDownloadURL = "https://parse.com/downloads/cloud_code/cli/parse-windows/latest" -) - -type updateCmd struct{} - -func (u *updateCmd) latestVersion(e *env, downloadURL string) (string, error) { - dURL, err := url.Parse(downloadURL) - if err != nil { - return "", stackerr.Wrap(err) - } - - resp, err := e.Client.Do(&http.Request{Method: "HEAD", URL: dURL}, nil, nil) - if err != nil { - return "", nil // if unable to fetch latest cli version, do not abort! - } - - base := path.Base(resp.Header.Get("Location")) - base = strings.TrimSuffix(base, ".exe") - //parse-os-2.0.2 - splits := strings.Split(base, "-") - - return splits[len(splits)-1], nil -} - -func (u *updateCmd) updateCLI(e *env) (bool, error) { - downloadURL := unixCliDownloadURL - switch runtime.GOOS { - case "windows": - downloadURL = windowsCliDownloadURL - case "darwin": - downloadURL = macCliDownloadURL - } - - latestVersion, err := u.latestVersion(e, downloadURL) - if err != nil { - return false, stackerr.Wrap(err) - } - - if latestVersion == "" || latestVersion == version { - return false, nil - } - - exec, err := osext.Executable() - if err != nil { - return false, stackerr.Wrap(err) - } - - fmt.Fprintf(e.Out, "Downloading binary from %s.\n", downloadURL) - if err, _ := update.New().Target(exec).FromUrl(downloadURL); err != nil { - return false, stackerr.Newf("Update failed: %v", err) - } - fmt.Fprintf(e.Out, "Successfully updated binary at: %s\n", exec) - return true, nil -} - -func (u *updateCmd) run(e *env) error { - updated, err := u.updateCLI(e) - if err != nil { - return err - } - if !updated { - fmt.Fprintf(e.Out, "Already using the latest cli version: %s\n", version) - } - return nil -} - -func newUpdateCmd(e *env) *cobra.Command { - var u updateCmd - return &cobra.Command{ - Use: "update", - Short: "Updates this tool to the latest version", - Long: "Updates this tool to the latest version.", - Run: runNoArgs(e, u.run), - } -} diff --git a/update_cmd_test.go b/update_cmd_test.go deleted file mode 100644 index 2ba0945..0000000 --- a/update_cmd_test.go +++ /dev/null @@ -1,53 +0,0 @@ -package main - -import ( - "io/ioutil" - "net/http" - "strings" - "testing" - - "github.com/facebookgo/ensure" - "github.com/facebookgo/parse" -) - -func TestLatestVersion(t *testing.T) { - t.Parallel() - - h := newHarness(t) - defer h.Stop() - - ht := transportFunc(func(r *http.Request) (*http.Response, error) { - var result string - switch r.URL.String() { - case windowsCliDownloadURL: - result = "http://parse-cli.aws.com/hash/parse-windows-2.0.2.exe" - case unixCliDownloadURL: - result = "http://parse-cli.aws.com/hash/parse-linux-2.0.2" - case macCliDownloadURL: - result = "http://parse-cli.aws.com/hash/parse-osx-2.0.2" - } - resp := http.Response{ - StatusCode: http.StatusOK, - Body: ioutil.NopCloser(strings.NewReader("Success!")), - } - if resp.Header == nil { - resp.Header = make(http.Header) - } - resp.Header.Set("Location", result) - return &resp, nil - }) - h.env.Client = &Client{client: &parse.Client{Transport: ht}} - u := new(updateCmd) - - version, err := u.latestVersion(h.env, unixCliDownloadURL) - ensure.Nil(t, err) - ensure.DeepEqual(t, version, "2.0.2") - - version, err = u.latestVersion(h.env, macCliDownloadURL) - ensure.Nil(t, err) - ensure.DeepEqual(t, version, "2.0.2") - - version, err = u.latestVersion(h.env, windowsCliDownloadURL) - ensure.Nil(t, err) - ensure.DeepEqual(t, version, "2.0.2") -} diff --git a/update_test.go b/update_test.go new file mode 100644 index 0000000..0127a72 --- /dev/null +++ b/update_test.go @@ -0,0 +1,41 @@ +package main + +import ( + "io/ioutil" + "net/http" + "testing" + + "github.com/ParsePlatform/parse-cli/parsecli" + "github.com/facebookgo/ensure" + "github.com/facebookgo/jsonpipe" + "github.com/facebookgo/parse" +) + +func TestLatestVersion(t *testing.T) { + t.Parallel() + + h := parsecli.NewHarness(t) + defer h.Stop() + + ht := parsecli.TransportFunc(func(r *http.Request) (*http.Response, error) { + ensure.DeepEqual(t, r.URL.Path, "/1/supported") + return &http.Response{ + StatusCode: http.StatusOK, + Body: ioutil.NopCloser( + jsonpipe.Encode( + map[string]string{"version": "2.0.2"}, + ), + ), + }, nil + }) + h.Env.ParseAPIClient = &parsecli.ParseAPIClient{APIClient: &parse.Client{Transport: ht}} + u := new(updateCmd) + + latestVersion, err := u.latestVersion(h.Env) + ensure.Nil(t, err) + ensure.DeepEqual(t, latestVersion, "2.0.2") + + downloadURL, err := u.getDownloadURL(h.Env) + ensure.StringContains(t, + downloadURL, "https://github.com/ParsePlatform/parse-cli/releases/download/release_2.0.2") +} diff --git a/utils.go b/utils.go index 05a0cdb..21e8e04 100644 --- a/utils.go +++ b/utils.go @@ -4,76 +4,13 @@ import ( "errors" "fmt" "net/url" - "regexp" - "strconv" "strings" + "github.com/ParsePlatform/parse-cli/parsecli" "github.com/facebookgo/stackerr" ) -var numbers = regexp.MustCompile("[0-9]+") - -func numericLessThan(a, b string) bool { - aLen, bLen := len(a), len(b) - minLen := aLen - if bLen < minLen { - minLen = bLen - } - if minLen == 0 { - if aLen == 0 { - return bLen != 0 - } - return false - } - - i := 0 - for ; i < minLen && a[i] == b[i]; i++ { - } - if i == minLen { - if aLen == bLen { - return false - } - if aLen == minLen { - return true - } - return false - } - - a, b = a[i:], b[i:] - aNos, bNos := numbers.FindAllStringIndex(a, 1), numbers.FindAllStringIndex(b, 1) - - if len(aNos) == 1 && aNos[0][0] == 0 && - len(bNos) == 1 && bNos[0][0] == 0 { - aNum, err := strconv.Atoi(a[:aNos[0][1]]) - if err != nil { - return a < b - } - bNum, err := strconv.Atoi(b[:bNos[0][1]]) - if err != nil { - return a < b - } - if aNum != bNum { - return aNum < bNum - } - } - return a < b -} - -type naturalStrings []string - -func (v naturalStrings) Len() int { - return len(v) -} - -func (v naturalStrings) Less(i, j int) bool { - return !numericLessThan(v[i], v[j]) // desc order -} - -func (v naturalStrings) Swap(i, j int) { - v[i], v[j] = v[j], v[i] -} - -func getConfirmation(message string, e *env) bool { +func getConfirmation(message string, e *parsecli.Env) bool { fmt.Fprintf(e.Out, message) var confirm string fmt.Fscanf(e.In, "%s\n", &confirm) @@ -92,15 +29,3 @@ func validateURL(urlStr string) error { } return nil } - -func getHostFromURL(urlStr string) (string, error) { - netURL, err := url.Parse(urlStr) - if err != nil { - return "", stackerr.Wrap(err) - } - server := regexp.MustCompile(`(.*):\d+$`).ReplaceAllString(netURL.Host, "$1") - if server == "" { - return "", stackerr.Newf("%s is not a valid url", urlStr) - } - return server, nil -} diff --git a/version.go b/version.go new file mode 100644 index 0000000..6569485 --- /dev/null +++ b/version.go @@ -0,0 +1,27 @@ +package main + +import ( + "fmt" + + "github.com/ParsePlatform/parse-cli/parsecli" + "github.com/spf13/cobra" +) + +type versionCmd struct{} + +func (c *versionCmd) run(e *parsecli.Env) error { + fmt.Fprintln(e.Out, parsecli.Version) + return nil +} + +func NewVersionCmd(e *parsecli.Env) *cobra.Command { + var c versionCmd + cmd := &cobra.Command{ + Use: "version", + Short: "Gets the Command Line Tools version", + Long: `Gets the Command Line Tools version.`, + Run: parsecli.RunNoArgs(e, c.run), + Aliases: []string{"cliversion"}, + } + return cmd +} diff --git a/version_test.go b/version_test.go new file mode 100644 index 0000000..238936e --- /dev/null +++ b/version_test.go @@ -0,0 +1,19 @@ +package main + +import ( + "testing" + + "github.com/ParsePlatform/parse-cli/parsecli" + "github.com/facebookgo/ensure" +) + +func TestVersion(t *testing.T) { + t.Parallel() + h := parsecli.NewHarness(t) + defer h.Stop() + var c versionCmd + err := c.run(h.Env) + ensure.Nil(t, err) + ensure.DeepEqual(t, h.Out.String(), parsecli.Version+"\n") + ensure.DeepEqual(t, h.Err.String(), "") +} diff --git a/functions_cmd.go b/webhooks/functions.go similarity index 59% rename from functions_cmd.go rename to webhooks/functions.go index deb46fb..3ec9cd2 100644 --- a/functions_cmd.go +++ b/webhooks/functions.go @@ -1,4 +1,4 @@ -package main +package webhooks import ( "errors" @@ -8,6 +8,8 @@ import ( "sort" "strings" + "github.com/ParsePlatform/parse-cli/parsecli" + "github.com/facebookgo/stackerr" "github.com/spf13/cobra" ) @@ -17,6 +19,14 @@ type functionHook struct { Warning string `json:"warning,omitempty"` } +func getConfirmation(message string, e *parsecli.Env) bool { + fmt.Fprintf(e.Out, message) + var confirm string + fmt.Fscanf(e.In, "%s\n", &confirm) + lower := strings.ToLower(confirm) + return lower != "" && strings.HasPrefix(lower, "y") +} + func (f functionHook) String() string { if f.URL != "" { return fmt.Sprintf("Function name: %q, URL: %q", f.FunctionName, f.URL) @@ -25,11 +35,16 @@ func (f functionHook) String() string { } type functionHooksCmd struct { - All bool - Function *functionHook + All bool + Function *functionHook + interactive bool } -func readFunctionName(e *env) (*functionHook, error) { +func readFunctionName(e *parsecli.Env, params *functionHook) (*functionHook, error) { + if params != nil && params.FunctionName != "" { + return params, nil + } + var f functionHook fmt.Fprintf(e.Out, "Please enter the function name: ") fmt.Fscanf(e.In, "%s\n", &f.FunctionName) @@ -39,8 +54,12 @@ func readFunctionName(e *env) (*functionHook, error) { return &f, nil } -func readFunctionParams(e *env) (*functionHook, error) { - f, err := readFunctionName(e) +func readFunctionParams(e *parsecli.Env, params *functionHook) (*functionHook, error) { + if params != nil && params.FunctionName != "" && params.URL != "" { + return params, nil + } + + f, err := readFunctionName(e, params) if err != nil { return nil, err } @@ -57,19 +76,20 @@ func readFunctionParams(e *env) (*functionHook, error) { const defaultFunctionsURL = "/1/hooks/functions" -func (h *functionHooksCmd) functionHooksCreate(e *env, client *client) error { - params, err := readFunctionParams(e) +func (h *functionHooksCmd) functionHooksCreate(e *parsecli.Env, ctx *parsecli.Context) error { + params, err := readFunctionParams(e, h.Function) if err != nil { return err } + var res functionHook functionsURL, err := url.Parse(defaultFunctionsURL) if err != nil { - return err + return stackerr.Wrap(err) } - _, err = e.Client.Post(functionsURL, params, &res) + _, err = e.ParseAPIClient.Post(functionsURL, params, &res) if err != nil { - return err + return stackerr.Wrap(err) } if res.Warning != "" { fmt.Fprintf(e.Err, "WARNING: %s\n", res.Warning) @@ -83,11 +103,11 @@ func (h *functionHooksCmd) functionHooksCreate(e *env, client *client) error { return nil } -func (h *functionHooksCmd) functionHooksRead(e *env, client *client) error { +func (h *functionHooksCmd) functionHooksRead(e *parsecli.Env, ctx *parsecli.Context) error { u := defaultFunctionsURL var function *functionHook if !h.All { - funct, err := readFunctionName(e) + funct, err := readFunctionName(e, h.Function) if err != nil { return err } @@ -96,15 +116,15 @@ func (h *functionHooksCmd) functionHooksRead(e *env, client *client) error { } functionsURL, err := url.Parse(u) if err != nil { - return err + return stackerr.Wrap(err) } var res struct { Results []*functionHook `json:"results,omitempty"` } - _, err = e.Client.Get(functionsURL, &res) + _, err = e.ParseAPIClient.Get(functionsURL, &res) if err != nil { - return err + return stackerr.Wrap(err) } var output []string for _, function := range res.Results { @@ -125,20 +145,20 @@ func (h *functionHooksCmd) functionHooksRead(e *env, client *client) error { return nil } -func (h *functionHooksCmd) functionHooksUpdate(e *env, client *client) error { - params, err := readFunctionParams(e) +func (h *functionHooksCmd) functionHooksUpdate(e *parsecli.Env, ctx *parsecli.Context) error { + params, err := readFunctionParams(e, h.Function) if err != nil { return err } var res functionHook functionsURL, err := url.Parse(path.Join(defaultFunctionsURL, params.FunctionName)) if err != nil { - return err + return stackerr.Wrap(err) } - _, err = e.Client.Put(functionsURL, &functionHook{URL: params.URL}, &res) + _, err = e.ParseAPIClient.Put(functionsURL, &functionHook{URL: params.URL}, &res) if err != nil { - return err + return stackerr.Wrap(err) } if res.Warning != "" { fmt.Fprintf(e.Err, "WARNING: %s\n", res.Warning) @@ -152,14 +172,14 @@ func (h *functionHooksCmd) functionHooksUpdate(e *env, client *client) error { return nil } -func (h *functionHooksCmd) functionHooksDelete(e *env, client *client) error { - params, err := readFunctionName(e) +func (h *functionHooksCmd) functionHooksDelete(e *parsecli.Env, ctx *parsecli.Context) error { + params, err := readFunctionName(e, h.Function) if err != nil { return err } functionsURL, err := url.Parse(path.Join(defaultFunctionsURL, params.FunctionName)) if err != nil { - return err + return stackerr.Wrap(err) } confirmMessage := fmt.Sprintf( @@ -168,41 +188,41 @@ func (h *functionHooksCmd) functionHooksDelete(e *env, client *client) error { ) var res functionHook - if getConfirmation(confirmMessage, e) { - _, err = e.Client.Put(functionsURL, map[string]interface{}{"__op": "Delete"}, &res) + if !h.interactive || getConfirmation(confirmMessage, e) { + _, err = e.ParseAPIClient.Put(functionsURL, map[string]interface{}{"__op": "Delete"}, &res) if err != nil { - return err + return stackerr.Wrap(err) } fmt.Fprintf(e.Out, "Successfully deleted webhook function %q\n", params.FunctionName) if res.FunctionName != "" { - fmt.Fprintf(e.Out, "Function %q defined in cloud code will be used henceforth\n", res.FunctionName) + fmt.Fprintf(e.Out, "Function %q defined in Cloud Code will be used henceforth\n", res.FunctionName) } } return nil } -func (h *functionHooksCmd) functionHooks(e *env, c *client) error { +func (h *functionHooksCmd) functionHooks(e *parsecli.Env, c *parsecli.Context) error { hp := *h hp.All = true return hp.functionHooksRead(e, c) } -func newFunctionHooksCmd(e *env) *cobra.Command { - var h functionHooksCmd +func NewFunctionHooksCmd(e *parsecli.Env) *cobra.Command { + h := &functionHooksCmd{interactive: true} c := &cobra.Command{ Use: "functions", - Short: "List cloud code functions and function webhooks", - Long: "List cloud code functions and function webhooks", - Run: runWithClient(e, h.functionHooks), + Short: "List Cloud Code functions and function webhooks", + Long: "List Cloud Code functions and function webhooks", + Run: parsecli.RunWithClient(e, h.functionHooks), } createCmd := &cobra.Command{ Use: "create", Short: "Create a function webhook", Long: "Create a function webhook", - Run: runWithClient(e, h.functionHooksCreate), + Run: parsecli.RunWithClient(e, h.functionHooksCreate), } c.AddCommand(createCmd) @@ -210,7 +230,7 @@ func newFunctionHooksCmd(e *env) *cobra.Command { Use: "edit", Short: "Edit the URL of a function webhook", Long: "Edit the URL of a function webhook", - Run: runWithClient(e, h.functionHooksUpdate), + Run: parsecli.RunWithClient(e, h.functionHooksUpdate), } c.AddCommand(changeCmd) @@ -218,7 +238,7 @@ func newFunctionHooksCmd(e *env) *cobra.Command { Use: "delete", Short: "Delete a function webhook", Long: "Delete a function webhook", - Run: runWithClient(e, h.functionHooksDelete), + Run: parsecli.RunWithClient(e, h.functionHooksDelete), } c.AddCommand(deleteCmd) diff --git a/functions_cmd_test.go b/webhooks/functions_test.go similarity index 77% rename from functions_cmd_test.go rename to webhooks/functions_test.go index 9167a3e..8f9f839 100644 --- a/functions_cmd_test.go +++ b/webhooks/functions_test.go @@ -1,4 +1,4 @@ -package main +package webhooks import ( "encoding/json" @@ -9,16 +9,17 @@ import ( "strings" "testing" + "github.com/ParsePlatform/parse-cli/parsecli" "github.com/facebookgo/ensure" "github.com/facebookgo/jsonpipe" "github.com/facebookgo/parse" ) -func newFunctionsHarness(t testing.TB) *Harness { - h := newHarness(t) +func newFunctionsHarness(t testing.TB) *parsecli.Harness { + h := parsecli.NewHarness(t) defer h.Stop() - ht := transportFunc(func(r *http.Request) (*http.Response, error) { + ht := parsecli.TransportFunc(func(r *http.Request) (*http.Response, error) { var body interface{} var params map[string]interface{} if r.Body != nil { @@ -37,8 +38,7 @@ func newFunctionsHarness(t testing.TB) *Harness { {"functionName": "foo", "url": "https://api.example.com/foo"}, }, } - } else { - ensure.DeepEqual(t, r.URL.Path, defaultFunctionsURL) + } else if r.URL.Path == defaultFunctionsURL { body = map[string]interface{}{ "results": []map[string]interface{}{ {"functionName": "foo"}, @@ -46,6 +46,10 @@ func newFunctionsHarness(t testing.TB) *Harness { {"functionName": "bar", "url": "https://api.example.com/bar"}, }, } + } else { + return &http.Response{StatusCode: http.StatusBadRequest}, + errors.New("no such function hook is defined") + } case "POST": ensure.DeepEqual(t, r.URL.Path, defaultFunctionsURL) @@ -95,7 +99,7 @@ func newFunctionsHarness(t testing.TB) *Harness { Body: ioutil.NopCloser(jsonpipe.Encode(body)), }, nil }) - h.env.Client = &Client{client: &parse.Client{Transport: ht}} + h.Env.ParseAPIClient = &parsecli.ParseAPIClient{APIClient: &parse.Client{Transport: ht}} return h } @@ -113,20 +117,20 @@ func TestFunctionHookString(t *testing.T) { func TestReadFuncParams(t *testing.T) { t.Parallel() - h := newHarness(t) + h := parsecli.NewHarness(t) defer h.Stop() - h.env.In = strings.NewReader("\n") - _, err := readFunctionName(h.env) + h.Env.In = strings.NewReader("\n") + _, err := readFunctionName(h.Env, nil) ensure.Err(t, err, regexp.MustCompile("Function name cannot be empty")) - h.env.In = strings.NewReader("foo\n") - hook, err := readFunctionName(h.env) + h.Env.In = strings.NewReader("foo\n") + hook, err := readFunctionName(h.Env, nil) ensure.Nil(t, err) ensure.DeepEqual(t, *hook, functionHook{FunctionName: "foo"}) - h.env.In = strings.NewReader("foo\napi.example.com/foo\n") - hook, err = readFunctionParams(h.env) + h.Env.In = strings.NewReader("foo\napi.example.com/foo\n") + hook, err = readFunctionParams(h.Env, nil) ensure.Nil(t, err) ensure.DeepEqual(t, *hook, functionHook{ FunctionName: "foo", @@ -140,7 +144,7 @@ func TestFunctionHooksRead(t *testing.T) { h := newFunctionsHarness(t) f := functionHooksCmd{All: true} - ensure.Nil(t, f.functionHooksRead(h.env, nil)) + ensure.Nil(t, f.functionHooksRead(h.Env, nil)) ensure.DeepEqual(t, h.Out.String(), `The following cloudcode or webhook functions are associated with this app: Function name: "bar", URL: "https://api.example.com/bar" Function name: "foo" @@ -148,9 +152,9 @@ Function name: "foo", URL: "https://api.example.com/foo" `) f.All = false - h.env.In = strings.NewReader("foo\napi.example.com/foo\n") + h.Env.In = strings.NewReader("foo\napi.example.com/foo\n") h.Out.Reset() - ensure.Nil(t, f.functionHooksRead(h.env, nil)) + ensure.Nil(t, f.functionHooksRead(h.Env, nil)) ensure.DeepEqual(t, h.Out.String(), `Please enter the function name: The following functions named: "foo" are associated with your app: Function name: "foo" Function name: "foo", URL: "https://api.example.com/foo" @@ -163,8 +167,8 @@ func TestFunctionHooksCreate(t *testing.T) { h := newFunctionsHarness(t) var f functionHooksCmd - h.env.In = strings.NewReader("foo\napi.example.com/foo\n") - ensure.Nil(t, f.functionHooksCreate(h.env, nil)) + h.Env.In = strings.NewReader("foo\napi.example.com/foo\n") + ensure.Nil(t, f.functionHooksCreate(h.Env, nil)) ensure.DeepEqual(t, h.Out.String(), `Please enter the function name: URL: https://Successfully created a webhook function "foo" pointing to "https://api.example.com/foo" @@ -174,8 +178,8 @@ func TestFunctionHooksCreate(t *testing.T) { h.Out.Reset() h.Err.Reset() - h.env.In = strings.NewReader("bar\napi.example.com/bar\n") - ensure.Nil(t, f.functionHooksCreate(h.env, nil)) + h.Env.In = strings.NewReader("bar\napi.example.com/bar\n") + ensure.Nil(t, f.functionHooksCreate(h.Env, nil)) ensure.DeepEqual(t, h.Out.String(), `Please enter the function name: URL: https://Successfully created a webhook function "bar" pointing to "https://api.example.com/bar" @@ -189,8 +193,8 @@ func TestFunctionHooksUpdate(t *testing.T) { h := newFunctionsHarness(t) var f functionHooksCmd - h.env.In = strings.NewReader("foo\napi.example.com/_foo\n") - ensure.Nil(t, f.functionHooksUpdate(h.env, nil)) + h.Env.In = strings.NewReader("foo\napi.example.com/_foo\n") + ensure.Nil(t, f.functionHooksUpdate(h.Env, nil)) ensure.DeepEqual(t, h.Out.String(), `Please enter the function name: URL: https://Successfully update the webhook function "foo" to point to "https://api.example.com/_foo" @@ -201,8 +205,8 @@ func TestFunctionHooksUpdate(t *testing.T) { h.Out.Reset() h.Err.Reset() - h.env.In = strings.NewReader("bar\napi.example.com/_bar\n") - ensure.Nil(t, f.functionHooksUpdate(h.env, nil)) + h.Env.In = strings.NewReader("bar\napi.example.com/_bar\n") + ensure.Nil(t, f.functionHooksUpdate(h.Env, nil)) ensure.DeepEqual(t, h.Out.String(), `Please enter the function name: URL: https://Successfully update the webhook function "bar" to point to "https://api.example.com/_bar" @@ -216,18 +220,18 @@ func TestFunctionHookDelete(t *testing.T) { h := newFunctionsHarness(t) - var f functionHooksCmd - h.env.In = strings.NewReader("foo\ny\n") - ensure.Nil(t, f.functionHooksDelete(h.env, nil)) + f := &functionHooksCmd{interactive: true} + h.Env.In = strings.NewReader("foo\ny\n") + ensure.Nil(t, f.functionHooksDelete(h.Env, nil)) ensure.DeepEqual(t, h.Out.String(), `Please enter the function name: Are you sure you want to delete webhook function: "foo" (y/n): Successfully deleted webhook function "foo" -Function "foo" defined in cloud code will be used henceforth +Function "foo" defined in Cloud Code will be used henceforth `) h.Out.Reset() - h.env.In = strings.NewReader("foo\nn\n") - ensure.Nil(t, f.functionHooksDelete(h.env, nil)) + h.Env.In = strings.NewReader("foo\nn\n") + ensure.Nil(t, f.functionHooksDelete(h.Env, nil)) ensure.DeepEqual(t, h.Out.String(), `Please enter the function name: Are you sure you want to delete webhook function: "foo" (y/n): `) diff --git a/webhooks/hooks.go b/webhooks/hooks.go new file mode 100644 index 0000000..eed3f41 --- /dev/null +++ b/webhooks/hooks.go @@ -0,0 +1,378 @@ +package webhooks + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "io/ioutil" + "net/url" + "os" + "path" + "strings" + + "github.com/ParsePlatform/parse-cli/parsecli" + "github.com/facebookgo/stackerr" +) + +var ( + errInvalidFormat = errors.New( + ` +invalid format. +valid formats should look like: +{"hooks": [OPERATION]} + +OPERATION -> +{"op": "put", "function": {"functionName": "name", "url": "https_url"}} +{"op": "post", "function": {"functionName": "name", "url": "https_url"}} +{"op": "delete", "function": {"functionName": "name"}} + +{"op": "put", "trigger": {"className": "cname", "triggerName": "tname", "url":"https_url"}} +{"op": "post", "trigger": {"className": "cname", "triggerName": "tname", "url":"https_url"}} +{"op": "delete", "trigger": {"className": "cname", "triggerName": "tname"}} +`) + + errPostToPut = errors.New( + `a hook with given name already exists: cannot create a new one. +`) + + errPutToPost = errors.New( + `a hook with the given name does not exist yet: cannot update the url. +`) + + errNotExist = errors.New( + `a hook with the given name does not exist. cannot delete it. + `) +) + +type hookOperation struct { + Method string `json:"op,omitempty"` + Function *functionHook `json:"function,omitempty"` + Trigger *triggerHook `json:"trigger,omitempty"` +} + +func validateURL(urlStr string) error { + netURL, err := url.Parse(urlStr) + if err != nil { + return stackerr.Wrap(err) + } + + if netURL.Scheme != "https" { + return errors.New("Please enter a valid https url") + } + return nil +} + +func checkTriggerName(s string) error { + switch strings.ToLower(s) { + case "beforesave", "beforedelete", "aftersave", "afterdelete": + return nil + } + return stackerr.Newf( + `invalid trigger name: %v. + This is the list of valid trigger names: + beforeSave + afterSave + beforeDelete + afterDelete +`, + s, + ) +} + +type Hooks struct { + HooksStrict bool + BaseURL string + baseWebhookURL *url.URL +} + +func (h *Hooks) checkTriggerName(s string) error { + switch strings.ToLower(s) { + case "beforesave", "beforedelete", "aftersave", "afterdelete": + return nil + } + return stackerr.Newf( + `invalid trigger name: %v. + This is the list of valid trigger names: + beforeSave + afterSave + beforeDelete + afterDelete +`, + s, + ) +} + +func (h *Hooks) appendHookOperation( + e *parsecli.Env, + hookOp *hookOperation, + hooksOps []*hookOperation, +) (bool, []*hookOperation, error) { + if hookOp == nil || (hookOp.Function == nil && hookOp.Trigger == nil) || + (hookOp.Function != nil && hookOp.Trigger != nil) { + return false, hooksOps, nil + } + + method := strings.ToUpper(hookOp.Method) + if method != "POST" && method != "PUT" && method != "DELETE" { + op, err := json.MarshalIndent(hookOp, "", " ") + if err == nil { + fmt.Fprintf(e.Out, "Please fix the incorrect operation in:\n%s\n", op) + } + return false, nil, stackerr.Wrap(errInvalidFormat) + } + + hookOp.Method = method + if hookOp.Trigger != nil { + if err := h.checkTriggerName(hookOp.Trigger.TriggerName); err != nil { + return false, nil, err + } + if hookOp.Trigger.URL != "" && h.baseWebhookURL != nil { + u, err := url.Parse(hookOp.Trigger.URL) + if err == nil { + hookOp.Trigger.URL = h.baseWebhookURL.ResolveReference(u).String() + } + } + } else { + if hookOp.Function.URL != "" && h.baseWebhookURL != nil { + u, err := url.Parse(hookOp.Function.URL) + if err == nil { + hookOp.Function.URL = h.baseWebhookURL.ResolveReference(u).String() + } + } + } + hooksOps = append(hooksOps, hookOp) + return true, hooksOps, nil +} + +func (h *Hooks) createHooksOperations( + e *parsecli.Env, + reader io.Reader, +) ([]*hookOperation, error) { + var input struct { + HooksOps []*hookOperation `json:"hooks,omitempty"` + } + err := json.NewDecoder(ioutil.NopCloser(reader)).Decode(&input) + if err != nil { + return nil, stackerr.Wrap(err) + } + + var ( + hooksOps []*hookOperation + added bool + ) + for _, hookOp := range input.HooksOps { + added, hooksOps, err = h.appendHookOperation(e, hookOp, hooksOps) + if err != nil { + return nil, err + } + if !added { + op, err := json.MarshalIndent(hookOp, "", " ") + if err == nil { + fmt.Fprintf(e.Out, "Ignoring hook operation: \n%s\n", op) + } + } + } + + return hooksOps, nil +} + +func (h *Hooks) checkStrictMode(restOp string, exists bool) (string, bool, error) { + restOp = strings.ToUpper(restOp) + if !exists { + if restOp == "PUT" { + if h.HooksStrict { + return "", false, stackerr.Wrap(errPutToPost) + } + return "POST", true, nil + } + if restOp == "DELETE" { + if h.HooksStrict { + return "", false, stackerr.Wrap(errNotExist) + } + return "DELETE", true, nil + } + } else if restOp == "POST" { + if h.HooksStrict { + return "", false, stackerr.Wrap(errPostToPut) + } + return "PUT", true, nil + } + return restOp, false, nil +} + +func (h *Hooks) functionHookExists(e *parsecli.Env, name string) (bool, error) { + functionsURL, err := url.Parse(path.Join(defaultFunctionsURL, name)) + if err != nil { + return false, stackerr.Wrap(err) + } + var results struct { + Results []*functionHook `json:"results,omitempty"` + } + _, err = e.ParseAPIClient.Get(functionsURL, &results) + if err != nil { + if strings.Contains(err.Error(), "is defined") { + return false, nil + } + return false, stackerr.Wrap(err) + } + for _, result := range results.Results { + if result.URL != "" && result.FunctionName == name { + return true, nil + } + } + return false, nil +} + +func (h *Hooks) deployFunctionHook(e *parsecli.Env, op *hookOperation) error { + if op.Function == nil { + return stackerr.New("cannot deploy nil function hook") + } + exists, err := h.functionHookExists(e, op.Function.FunctionName) + if err != nil { + return err + } + + restOp, suppressed, err := h.checkStrictMode(op.Method, exists) + if err != nil { + return err + } + + function := &functionHooksCmd{Function: op.Function} + switch restOp { + case "POST": + return function.functionHooksCreate(e, nil) + case "PUT": + return function.functionHooksUpdate(e, nil) + case "DELETE": + if suppressed { + return nil + } + return function.functionHooksDelete(e, nil) + } + return stackerr.Wrap(errInvalidFormat) +} + +func (h *Hooks) triggerHookExists(e *parsecli.Env, className, triggerName string) (bool, error) { + triggersURL, err := url.Parse(path.Join(defaultTriggersURL, className, triggerName)) + if err != nil { + return false, stackerr.Wrap(err) + } + var results struct { + Results []*triggerHook `json:"results,omitempty"` + } + _, err = e.ParseAPIClient.Get(triggersURL, &results) + if err != nil { + if strings.Contains(err.Error(), "defined for") { + return false, nil + } + return false, stackerr.Wrap(err) + } + for _, result := range results.Results { + if result.URL != "" && result.ClassName == className && result.TriggerName == triggerName { + return true, nil + } + } + return false, nil +} + +func (h *Hooks) deployTriggerHook(e *parsecli.Env, op *hookOperation) error { + if op.Trigger == nil { + return stackerr.New("cannot deploy nil trigger hook") + } + + exists, err := h.triggerHookExists(e, op.Trigger.ClassName, op.Trigger.TriggerName) + if err != nil { + return err + } + restOp, suppressed, err := h.checkStrictMode(op.Method, exists) + if err != nil { + return err + } + + trigger := &triggerHooksCmd{Trigger: op.Trigger, All: false} + switch restOp { + case "POST": + return trigger.triggerHooksCreate(e, nil) + case "PUT": + return trigger.triggerHooksUpdate(e, nil) + case "DELETE": + if suppressed { + return nil + } + return trigger.triggerHooksDelete(e, nil) + } + return stackerr.Wrap(errInvalidFormat) + +} + +func (h *Hooks) deployWebhooksConfig(e *parsecli.Env, hooksOps []*hookOperation) error { + for _, op := range hooksOps { + if op.Function == nil && op.Trigger == nil { + return stackerr.New("hook operation is neither a function, not a trigger.") + } + if op.Function != nil && op.Trigger != nil { + return stackerr.New("a hook cannot be both a function and a trigger.") + } + if op.Function != nil { + if err := h.deployFunctionHook(e, op); err != nil { + return err + } + } else { + if err := h.deployTriggerHook(e, op); err != nil { + return err + } + } + fmt.Fprintln(e.Out) + } + return nil +} + +func (h *Hooks) parseBaseURL(e *parsecli.Env) error { + if h.BaseURL != "" { + u, err := url.Parse(h.BaseURL) + if err != nil { + fmt.Fprintln(e.Err, "Invalid base webhook url provided") + return stackerr.Wrap(err) + } + if u.Scheme != "https" { + return stackerr.New("Please provide a valid https url") + } + h.baseWebhookURL = u + } + return nil +} + +func (h *Hooks) HooksCmd(e *parsecli.Env, ctx *parsecli.Context, args []string) error { + if len(args) > 1 { + return fmt.Errorf("Invalid args: %v, only an optional hooks config file is expected.", args) + } + if err := h.parseBaseURL(e); err != nil { + return err + } + + reader := e.In + if len(args) == 1 { + file, err := os.Open(args[0]) + if err != nil { + return stackerr.Wrap(err) + } + reader = file + } else { + fmt.Fprintln(e.Out, "Since a webhooks config file was not provided reading from stdin.") + } + hooksOps, err := h.createHooksOperations(e, reader) + if err != nil { + return err + } + err = h.deployWebhooksConfig(e, hooksOps) + if err != nil { + fmt.Fprintln( + e.Out, + "Failed to deploy the webhooks config. Please try again...", + ) + return err + } + fmt.Fprintln(e.Out, "Successfully configured the given webhooks for the app.") + return nil +} diff --git a/webhooks/hooks_test.go b/webhooks/hooks_test.go new file mode 100644 index 0000000..b84e83c --- /dev/null +++ b/webhooks/hooks_test.go @@ -0,0 +1,383 @@ +package webhooks + +import ( + "io/ioutil" + "os" + "path/filepath" + "regexp" + "testing" + + "github.com/ParsePlatform/parse-cli/parsecli" + "github.com/facebookgo/ensure" +) + +func TestCheckTriggerName(t *testing.T) { + t.Parallel() + + c := &Hooks{} + + ensure.Nil(t, c.checkTriggerName("beforeSave")) + ensure.Nil(t, c.checkTriggerName("afterSave")) + ensure.Nil(t, c.checkTriggerName("beforeDelete")) + ensure.Nil(t, c.checkTriggerName("afterDelete")) + + ensure.Nil(t, c.checkTriggerName("BeforeSAVE")) + ensure.Nil(t, c.checkTriggerName("AFTERsave")) + ensure.Nil(t, c.checkTriggerName("BeforeDELETE")) + ensure.Nil(t, c.checkTriggerName("AFTERdelete")) + + ensure.Err(t, c.checkTriggerName("invalid"), regexp.MustCompile("list of valid trigger names")) +} + +func TestAppendHookOperation(t *testing.T) { + t.Parallel() + + h := parsecli.NewHarness(t) + defer h.Stop() + + c := &Hooks{} + + var hooksOps []*hookOperation + _, ops, err := c.appendHookOperation(h.Env, nil, hooksOps) + ensure.Nil(t, err) + ensure.DeepEqual(t, hooksOps, ops) + + _, ops, err = c.appendHookOperation(h.Env, &hookOperation{}, nil) + ensure.Nil(t, err) + ensure.DeepEqual(t, hooksOps, ops) + + _, ops, err = c.appendHookOperation(h.Env, + &hookOperation{ + Method: "post", + Function: &functionHook{ + FunctionName: "call", + URL: "https://twilio.com/call", + }, + }, + hooksOps, + ) + ensure.Nil(t, err) + ensure.DeepEqual(t, ops[len(ops)-1].Method, "POST") + ensure.DeepEqual( + t, + *ops[len(ops)-1].Function, + functionHook{FunctionName: "call", URL: "https://twilio.com/call"}, + ) + + _, ops, err = c.appendHookOperation(h.Env, + &hookOperation{ + Method: "put", + Function: &functionHook{ + FunctionName: "call", + URL: "https://twilio.com/call_1", + }, + }, + hooksOps, + ) + ensure.Nil(t, err) + ensure.DeepEqual(t, ops[len(ops)-1].Method, "PUT") + ensure.DeepEqual( + t, + *ops[len(ops)-1].Function, + functionHook{FunctionName: "call", URL: "https://twilio.com/call_1"}, + ) + + //random stuff + _, ops, err = c.appendHookOperation(h.Env, + &hookOperation{ + Method: "posT", + Trigger: &triggerHook{ + ClassName: "_User", + TriggerName: "afterDelete", + URL: "https://twilio.com/message", + }, + }, + hooksOps, + ) + ensure.Nil(t, err) + ensure.DeepEqual(t, ops[len(ops)-1].Method, "POST") + ensure.DeepEqual( + t, + *ops[len(ops)-1].Trigger, + triggerHook{ClassName: "_User", TriggerName: "afterDelete", URL: "https://twilio.com/message"}, + ) + + _, ops, err = c.appendHookOperation(h.Env, + &hookOperation{ + Method: "pUt", + Trigger: &triggerHook{ + ClassName: "_User", + TriggerName: "afterDelete", + URL: "https://twilio.com/message_1", + }, + }, + hooksOps, + ) + ensure.Nil(t, err) + ensure.DeepEqual(t, ops[len(ops)-1].Method, "PUT") + ensure.DeepEqual( + t, + *ops[len(ops)-1].Trigger, + triggerHook{ClassName: "_User", TriggerName: "afterDelete", URL: "https://twilio.com/message_1"}, + ) + + // random stuff + _, ops, err = c.appendHookOperation(h.Env, + &hookOperation{ + Method: "pUt", + Trigger: &triggerHook{ + ClassName: "_User", + TriggerName: "afterDelete", + URL: "https://twilio.com/message_1,message_2", + }, + }, + hooksOps, + ) + ensure.Nil(t, err) + ensure.DeepEqual(t, ops[len(ops)-1].Method, "PUT") + ensure.DeepEqual( + t, + *ops[len(ops)-1].Trigger, + triggerHook{ClassName: "_User", TriggerName: "afterDelete", URL: "https://twilio.com/message_1,message_2"}, + ) +} + +func TestCheckStrictMode(t *testing.T) { + t.Parallel() + + testCases := []struct { + restOp string + exists bool + strictMode bool + + op string + updated bool + err *regexp.Regexp + }{ + {exists: false, restOp: "put", strictMode: false, op: "POST", updated: true, err: nil}, + {exists: false, restOp: "post", strictMode: false, op: "POST", updated: false, err: nil}, + {exists: false, restOp: "delete", strictMode: false, op: "DELETE", updated: true, err: nil}, + {exists: true, restOp: "put", strictMode: false, op: "PUT", updated: false, err: nil}, + {exists: true, restOp: "post", strictMode: false, op: "PUT", updated: true, err: nil}, + {exists: true, restOp: "delete", strictMode: false, op: "DELETE", updated: false, err: nil}, + + {exists: false, restOp: "put", strictMode: true, err: regexp.MustCompile("does not exist")}, + {exists: false, restOp: "post", strictMode: true, op: "POST", updated: false, err: nil}, + {exists: false, restOp: "delete", strictMode: true, err: regexp.MustCompile("cannot delete")}, + {exists: true, restOp: "put", strictMode: true, op: "PUT", updated: false, err: nil}, + {exists: true, restOp: "post", strictMode: true, err: regexp.MustCompile("already exists")}, + {exists: true, restOp: "delete", strictMode: true, op: "DELETE", updated: false, err: nil}, + } + + c := &Hooks{} + for _, testCase := range testCases { + c.HooksStrict = testCase.strictMode + op, updated, err := c.checkStrictMode(testCase.restOp, testCase.exists) + if testCase.err == nil { + ensure.Nil(t, err) + ensure.DeepEqual(t, op, testCase.op) + ensure.DeepEqual(t, updated, testCase.updated) + } else { + ensure.Err(t, err, testCase.err) + } + } +} + +func TestCreateHookOperations(t *testing.T) { + t.Parallel() + h := parsecli.NewHarness(t) + h.MakeEmptyRoot() + defer h.Stop() + + c := &Hooks{} + + hooksConfigFile := filepath.Join(h.Env.Root, "webhooks.json") + err := ioutil.WriteFile( + hooksConfigFile, + []byte("{}"), + 0666, + ) + ensure.Nil(t, err) + + f, err := os.Open(hooksConfigFile) + ensure.Nil(t, err) + ops, err := c.createHooksOperations(h.Env, f) + ensure.Nil(t, err) + ensure.DeepEqual(t, len(ops), 0) + ensure.Nil(t, f.Close()) + + err = ioutil.WriteFile( + hooksConfigFile, + []byte("{1}"), + 0666, + ) + ensure.Nil(t, err) + + f, err = os.Open(hooksConfigFile) + ensure.Nil(t, err) + ops, err = c.createHooksOperations(h.Env, f) + ensure.Err(t, err, regexp.MustCompile("invalid")) + ensure.DeepEqual(t, len(ops), 0) + ensure.Nil(t, f.Close()) + + err = ioutil.WriteFile( + hooksConfigFile, + []byte(`{ + "hooks": [ + { + "op": "put", + "function": { + "functionName": "message", + "url": "https://api.twilio.com/message" + } + } + ]}`), + 0666, + ) + + f, err = os.Open(hooksConfigFile) + ensure.Nil(t, err) + ops, err = c.createHooksOperations(h.Env, f) + ensure.Nil(t, err) + ensure.DeepEqual(t, len(ops), 1) + ensure.DeepEqual(t, + *ops[0].Function, + functionHook{FunctionName: "message", URL: "https://api.twilio.com/message"}, + ) + ensure.DeepEqual(t, ops[0].Method, "PUT") + ensure.Nil(t, f.Close()) + h.Out.Reset() + + err = ioutil.WriteFile( + hooksConfigFile, + []byte(`{ + "hooks": [ + { + "op": "post", + "function": { + "functionName": "message", + "url": "https://api.twilio.com/message" + } + }, + { + "op": "put", + "function": { + "functionName": "message", + "url": "https://api.twilio.com/message_1" + } + }, + { + "op": "post" + } + ]}`), + 0666, + ) + + f, err = os.Open(hooksConfigFile) + ensure.Nil(t, err) + ops, err = c.createHooksOperations(h.Env, f) + ensure.Nil(t, err) + ensure.DeepEqual(t, len(ops), 2) + ensure.DeepEqual(t, + *ops[0].Function, + functionHook{FunctionName: "message", URL: "https://api.twilio.com/message"}, + ) + ensure.DeepEqual(t, ops[0].Method, "POST") + + ensure.DeepEqual(t, + *ops[1].Function, + functionHook{FunctionName: "message", URL: "https://api.twilio.com/message_1"}, + ) + ensure.DeepEqual(t, + h.Out.String(), + `Ignoring hook operation: +{ + "op": "post" +} +`, + ) + ensure.DeepEqual(t, ops[1].Method, "PUT") + ensure.Nil(t, f.Close()) + h.Out.Reset() + +} + +func TestFunctionHookExists(t *testing.T) { + t.Parallel() + h := newFunctionsHarness(t) + c := &Hooks{} + exists, err := c.functionHookExists(h.Env, "foo") + ensure.Nil(t, err) + ensure.True(t, exists) + + exists, err = c.functionHookExists(h.Env, "bar") + ensure.Nil(t, err) + ensure.False(t, exists) +} + +func TestDeployFunctionHooks(t *testing.T) { + t.Parallel() + h := newFunctionsHarness(t) + c := &Hooks{} + + err := c.deployFunctionHook( + h.Env, + &hookOperation{ + Method: "post", + Function: &functionHook{ + FunctionName: "foo", + URL: "https://api.example.com/foo", + }, + }, + ) + // not an error -> will be converted to put + ensure.Nil(t, err) +} + +func TestTriggerHookExists(t *testing.T) { + t.Parallel() + h := newTriggersHarness(t) + c := &Hooks{} + exists, err := c.triggerHookExists(h.Env, "foo", "beforeSave") + ensure.Nil(t, err) + ensure.True(t, exists) + + exists, err = c.triggerHookExists(h.Env, "bar", "other") + ensure.Nil(t, err) + ensure.False(t, exists) +} + +func TestDeployTriggerHooks(t *testing.T) { + t.Parallel() + h := newTriggersHarness(t) + c := &Hooks{} + + err := c.deployTriggerHook( + h.Env, + &hookOperation{ + Method: "post", + Trigger: &triggerHook{ + ClassName: "foo", + TriggerName: "beforeSave", + URL: "https://api.example.com/foo", + }, + }, + ) + // not an error -> will be converted to put + ensure.Nil(t, err) +} + +func TestParseBaseURL(t *testing.T) { + t.Parallel() + + h := parsecli.NewHarness(t) + defer h.Stop() + + c := &Hooks{} + c.BaseURL = "http://hello" + ensure.Err(t, c.parseBaseURL(h.Env), regexp.MustCompile("valid https url")) + + c.BaseURL = "https://hello" + ensure.Nil(t, c.parseBaseURL(h.Env)) + ensure.DeepEqual(t, c.baseWebhookURL.String(), c.BaseURL) +} diff --git a/triggers_cmd.go b/webhooks/triggers.go similarity index 65% rename from triggers_cmd.go rename to webhooks/triggers.go index 1526c30..e015662 100644 --- a/triggers_cmd.go +++ b/webhooks/triggers.go @@ -1,4 +1,4 @@ -package main +package webhooks import ( "errors" @@ -8,6 +8,8 @@ import ( "sort" "strings" + "github.com/ParsePlatform/parse-cli/parsecli" + "github.com/facebookgo/stackerr" "github.com/spf13/cobra" ) @@ -26,11 +28,16 @@ func (t *triggerHook) String() string { } type triggerHooksCmd struct { - All bool - Trigger *triggerHook + All bool + Trigger *triggerHook + interactive bool } -func readTriggerName(e *env) (*triggerHook, error) { +func readTriggerName(e *parsecli.Env, params *triggerHook) (*triggerHook, error) { + if params != nil && params.ClassName != "" && params.TriggerName != "" { + return params, nil + } + var t triggerHook fmt.Fprintln(e.Out, "Please enter following details about the trigger webhook") fmt.Fprint(e.Out, "Class name: ") @@ -46,8 +53,12 @@ func readTriggerName(e *env) (*triggerHook, error) { return &t, nil } -func readTriggerParams(e *env) (*triggerHook, error) { - t, err := readTriggerName(e) +func readTriggerParams(e *parsecli.Env, params *triggerHook) (*triggerHook, error) { + if params != nil && params.ClassName != "" && params.TriggerName != "" && params.URL != "" { + return params, nil + } + + t, err := readTriggerName(e, params) if err != nil { return nil, err } @@ -62,19 +73,19 @@ func readTriggerParams(e *env) (*triggerHook, error) { const defaultTriggersURL = "/1/hooks/triggers" -func (h *triggerHooksCmd) triggerHooksCreate(e *env, client *client) error { - params, err := readTriggerParams(e) +func (h *triggerHooksCmd) triggerHooksCreate(e *parsecli.Env, ctx *parsecli.Context) error { + params, err := readTriggerParams(e, h.Trigger) if err != nil { return err } var res triggerHook triggersURL, err := url.Parse(defaultTriggersURL) if err != nil { - return err + return stackerr.Wrap(err) } - _, err = e.Client.Post(triggersURL, params, &res) + _, err = e.ParseAPIClient.Post(triggersURL, params, &res) if err != nil { - return err + return stackerr.Wrap(err) } if res.Warning != "" { fmt.Fprintf(e.Err, "WARNING: %s\n", res.Warning) @@ -88,11 +99,11 @@ func (h *triggerHooksCmd) triggerHooksCreate(e *env, client *client) error { return nil } -func (h *triggerHooksCmd) triggerHooksRead(e *env, client *client) error { +func (h *triggerHooksCmd) triggerHooksRead(e *parsecli.Env, ctx *parsecli.Context) error { u := defaultTriggersURL var trigger *triggerHook if !h.All { - trig, err := readTriggerName(e) + trig, err := readTriggerName(e, h.Trigger) if err != nil { return err } @@ -101,14 +112,14 @@ func (h *triggerHooksCmd) triggerHooksRead(e *env, client *client) error { } triggersURL, err := url.Parse(u) if err != nil { - return err + return stackerr.Wrap(err) } var res struct { Results []*triggerHook `json:"results,omitempty"` } - _, err = e.Client.Get(triggersURL, &res) + _, err = e.ParseAPIClient.Get(triggersURL, &res) if err != nil { - return err + return stackerr.Wrap(err) } var output []string for _, trigger := range res.Results { @@ -130,19 +141,19 @@ func (h *triggerHooksCmd) triggerHooksRead(e *env, client *client) error { return nil } -func (h *triggerHooksCmd) triggerHooksUpdate(e *env, client *client) error { - params, err := readTriggerParams(e) +func (h *triggerHooksCmd) triggerHooksUpdate(e *parsecli.Env, ctx *parsecli.Context) error { + params, err := readTriggerParams(e, h.Trigger) if err != nil { return err } var res triggerHook triggersURL, err := url.Parse(path.Join(defaultTriggersURL, params.ClassName, params.TriggerName)) if err != nil { - return err + return stackerr.Wrap(err) } - _, err = e.Client.Put(triggersURL, &triggerHook{URL: params.URL}, &res) + _, err = e.ParseAPIClient.Put(triggersURL, &triggerHook{URL: params.URL}, &res) if err != nil { - return err + return stackerr.Wrap(err) } if res.Warning != "" { fmt.Fprintf(e.Err, "WARNING: %s\n", res.Warning) @@ -157,14 +168,14 @@ func (h *triggerHooksCmd) triggerHooksUpdate(e *env, client *client) error { return nil } -func (h *triggerHooksCmd) triggerHooksDelete(e *env, client *client) error { - params, err := readTriggerName(e) +func (h *triggerHooksCmd) triggerHooksDelete(e *parsecli.Env, ctx *parsecli.Context) error { + params, err := readTriggerName(e, h.Trigger) if err != nil { return err } triggersURL, err := url.Parse(path.Join(defaultTriggersURL, params.ClassName, params.TriggerName)) if err != nil { - return err + return stackerr.Wrap(err) } confirmMessage := fmt.Sprintf("Are you sure you want to delete %q webhook trigger for class: %q (y/n): ", @@ -173,10 +184,10 @@ func (h *triggerHooksCmd) triggerHooksDelete(e *env, client *client) error { ) var res triggerHook - if getConfirmation(confirmMessage, e) { - _, err = e.Client.Put(triggersURL, map[string]interface{}{"__op": "Delete"}, &res) + if !h.interactive || getConfirmation(confirmMessage, e) { + _, err = e.ParseAPIClient.Put(triggersURL, map[string]interface{}{"__op": "Delete"}, &res) if err != nil { - return err + return stackerr.Wrap(err) } fmt.Fprintf(e.Out, "Successfully deleted %q webhook trigger for class %q\n", params.TriggerName, @@ -192,27 +203,27 @@ func (h *triggerHooksCmd) triggerHooksDelete(e *env, client *client) error { return nil } -func (h *triggerHooksCmd) triggerHooks(e *env, c *client) error { +func (h *triggerHooksCmd) triggerHooks(e *parsecli.Env, c *parsecli.Context) error { hp := *h hp.All = true return hp.triggerHooksRead(e, c) } -func newTriggerHooksCmd(e *env) *cobra.Command { - var h triggerHooksCmd +func NewTriggerHooksCmd(e *parsecli.Env) *cobra.Command { + h := &triggerHooksCmd{interactive: true} c := &cobra.Command{ Use: "triggers", - Short: "List cloud code triggers and trigger webhooks", - Long: "List cloud code triggers and trigger webhooks", - Run: runWithClient(e, h.triggerHooks), + Short: "List Cloud Code triggers and trigger webhooks", + Long: "List Cloud Code triggers and trigger webhooks", + Run: parsecli.RunWithClient(e, h.triggerHooks), } createCmd := &cobra.Command{ Use: "create", Short: "Create a trigger webhook", Long: "Create a trigger webhook", - Run: runWithClient(e, h.triggerHooksCreate), + Run: parsecli.RunWithClient(e, h.triggerHooksCreate), } c.AddCommand(createCmd) @@ -220,7 +231,7 @@ func newTriggerHooksCmd(e *env) *cobra.Command { Use: "edit", Short: "Edit the URL of a trigger webhook", Long: "Edit the URL of a trigger webhook", - Run: runWithClient(e, h.triggerHooksUpdate), + Run: parsecli.RunWithClient(e, h.triggerHooksUpdate), } c.AddCommand(changeCmd) @@ -228,7 +239,7 @@ func newTriggerHooksCmd(e *env) *cobra.Command { Use: "delete", Short: "Delete a trigger webhook", Long: "Delete a trigger webhook", - Run: runWithClient(e, h.triggerHooksDelete), + Run: parsecli.RunWithClient(e, h.triggerHooksDelete), } c.AddCommand(deleteCmd) diff --git a/triggers_cmd_test.go b/webhooks/triggers_test.go similarity index 81% rename from triggers_cmd_test.go rename to webhooks/triggers_test.go index c8abc40..4ddcdab 100644 --- a/triggers_cmd_test.go +++ b/webhooks/triggers_test.go @@ -1,4 +1,4 @@ -package main +package webhooks import ( "encoding/json" @@ -10,16 +10,17 @@ import ( "strings" "testing" + "github.com/ParsePlatform/parse-cli/parsecli" "github.com/facebookgo/ensure" "github.com/facebookgo/jsonpipe" "github.com/facebookgo/parse" ) -func newTriggersHarness(t testing.TB) *Harness { - h := newHarness(t) +func newTriggersHarness(t testing.TB) *parsecli.Harness { + h := parsecli.NewHarness(t) defer h.Stop() - ht := transportFunc(func(r *http.Request) (*http.Response, error) { + ht := parsecli.TransportFunc(func(r *http.Request) (*http.Response, error) { var body interface{} var params map[string]interface{} if r.Body != nil { @@ -38,8 +39,7 @@ func newTriggersHarness(t testing.TB) *Harness { {"className": "foo", "triggerName": "beforeSave", "url": "https://api.example.com/foo/beforeSave"}, }, } - } else { - ensure.DeepEqual(t, r.URL.Path, defaultTriggersURL) + } else if r.URL.Path == defaultTriggersURL { body = map[string]interface{}{ "results": []map[string]interface{}{ {"className": "foo", "triggerName": "beforeSave"}, @@ -47,6 +47,9 @@ func newTriggersHarness(t testing.TB) *Harness { {"className": "bar", "triggerName": "afterSave", "url": "https://api.example.com/bar/afterSave"}, }, } + } else { + return &http.Response{StatusCode: http.StatusBadRequest}, + errors.New("no such trigger is defined for class") } case "POST": ensure.DeepEqual(t, r.URL.Path, defaultTriggersURL) @@ -100,7 +103,7 @@ func newTriggersHarness(t testing.TB) *Harness { Body: ioutil.NopCloser(jsonpipe.Encode(body)), }, nil }) - h.env.Client = &Client{client: &parse.Client{Transport: ht}} + h.Env.ParseAPIClient = &parsecli.ParseAPIClient{APIClient: &parse.Client{Transport: ht}} return h } @@ -118,24 +121,24 @@ func TestTriggerHookString(t *testing.T) { func TestReadTriggerParams(t *testing.T) { t.Parallel() - h := newHarness(t) + h := parsecli.NewHarness(t) defer h.Stop() - h.env.In = ioutil.NopCloser(strings.NewReader("\n")) - _, err := readTriggerName(h.env) + h.Env.In = ioutil.NopCloser(strings.NewReader("\n")) + _, err := readTriggerName(h.Env, nil) ensure.Err(t, err, regexp.MustCompile("Class name cannot be empty")) - h.env.In = ioutil.NopCloser(strings.NewReader("foo\n")) - _, err = readTriggerName(h.env) + h.Env.In = ioutil.NopCloser(strings.NewReader("foo\n")) + _, err = readTriggerName(h.Env, nil) ensure.Err(t, err, regexp.MustCompile("Trigger name cannot be empty")) - h.env.In = ioutil.NopCloser(strings.NewReader("foo\nbeforeSave")) - hook, err := readTriggerName(h.env) + h.Env.In = ioutil.NopCloser(strings.NewReader("foo\nbeforeSave")) + hook, err := readTriggerName(h.Env, nil) ensure.Nil(t, err) ensure.DeepEqual(t, *hook, triggerHook{ClassName: "foo", TriggerName: "beforeSave"}) - h.env.In = ioutil.NopCloser(strings.NewReader("foo\nbeforeSave\napi.example.com/foo/beforeSave\n")) - hook, err = readTriggerParams(h.env) + h.Env.In = ioutil.NopCloser(strings.NewReader("foo\nbeforeSave\napi.example.com/foo/beforeSave\n")) + hook, err = readTriggerParams(h.Env, nil) ensure.Nil(t, err) ensure.DeepEqual(t, *hook, triggerHook{ ClassName: "foo", @@ -150,7 +153,7 @@ func TestTriggerHooksRead(t *testing.T) { h := newTriggersHarness(t) tr := triggerHooksCmd{All: true} - ensure.Nil(t, tr.triggerHooksRead(h.env, nil)) + ensure.Nil(t, tr.triggerHooksRead(h.Env, nil)) ensure.DeepEqual(t, h.Out.String(), `The following cloudcode or webhook triggers are associated with this app: Class name: "bar", Trigger name: "afterSave", URL: "https://api.example.com/bar/afterSave" Class name: "foo", Trigger name: "beforeSave" @@ -158,9 +161,9 @@ Class name: "foo", Trigger name: "beforeSave", URL: "https://api.example.com/foo `) tr.All = false - h.env.In = ioutil.NopCloser(strings.NewReader("foo\nbeforeSave\n")) + h.Env.In = ioutil.NopCloser(strings.NewReader("foo\nbeforeSave\n")) h.Out.Reset() - ensure.Nil(t, tr.triggerHooksRead(h.env, nil)) + ensure.Nil(t, tr.triggerHooksRead(h.Env, nil)) ensure.DeepEqual(t, h.Out.String(), `Please enter following details about the trigger webhook Class name: Trigger name: The following triggers named: "beforeSave" are associated with the class: "foo" Class name: "foo", Trigger name: "beforeSave" @@ -174,8 +177,8 @@ func TestTriggerHooksCreate(t *testing.T) { h := newTriggersHarness(t) var tr triggerHooksCmd - h.env.In = ioutil.NopCloser(strings.NewReader("foo\nbeforeSave\napi.example.com/foo/beforeSave\n")) - ensure.Nil(t, tr.triggerHooksCreate(h.env, nil)) + h.Env.In = ioutil.NopCloser(strings.NewReader("foo\nbeforeSave\napi.example.com/foo/beforeSave\n")) + ensure.Nil(t, tr.triggerHooksCreate(h.Env, nil)) ensure.DeepEqual(t, h.Out.String(), `Please enter following details about the trigger webhook @@ -187,8 +190,8 @@ Class name: Trigger name: URL: https://Successfully created a "beforeSave" trigg h.Out.Reset() h.Err.Reset() - h.env.In = ioutil.NopCloser(strings.NewReader("bar\nafterSave\napi.example.com/bar/afterSave\n")) - ensure.Nil(t, tr.triggerHooksCreate(h.env, nil)) + h.Env.In = ioutil.NopCloser(strings.NewReader("bar\nafterSave\napi.example.com/bar/afterSave\n")) + ensure.Nil(t, tr.triggerHooksCreate(h.Env, nil)) ensure.DeepEqual(t, h.Out.String(), `Please enter following details about the trigger webhook @@ -203,9 +206,9 @@ func TestTriggerHooksUpdate(t *testing.T) { h := newTriggersHarness(t) var tr triggerHooksCmd - h.env.In = + h.Env.In = ioutil.NopCloser(strings.NewReader("foo\nbeforeSave\napi.example.com/_foo/beforeSave\n")) - ensure.Nil(t, tr.triggerHooksUpdate(h.env, nil)) + ensure.Nil(t, tr.triggerHooksUpdate(h.Env, nil)) ensure.DeepEqual(t, h.Out.String(), `Please enter following details about the trigger webhook @@ -217,9 +220,9 @@ Class name: Trigger name: URL: https://Successfully update the "beforeSave" trig h.Out.Reset() h.Err.Reset() - h.env.In = + h.Env.In = ioutil.NopCloser(strings.NewReader("bar\nafterSave\napi.example.com/_bar/afterSave\n")) - ensure.Nil(t, tr.triggerHooksUpdate(h.env, nil)) + ensure.Nil(t, tr.triggerHooksUpdate(h.Env, nil)) ensure.DeepEqual(t, h.Out.String(), `Please enter following details about the trigger webhook @@ -234,9 +237,9 @@ func TestTriggerHookDelete(t *testing.T) { h := newTriggersHarness(t) - var tr triggerHooksCmd - h.env.In = ioutil.NopCloser(strings.NewReader("foo\nbeforeSave\ny\n")) - ensure.Nil(t, tr.triggerHooksDelete(h.env, nil)) + tr := triggerHooksCmd{interactive: true} + h.Env.In = ioutil.NopCloser(strings.NewReader("foo\nbeforeSave\ny\n")) + ensure.Nil(t, tr.triggerHooksDelete(h.Env, nil)) ensure.DeepEqual(t, h.Out.String(), `Please enter following details about the trigger webhook @@ -245,8 +248,8 @@ Class name: Trigger name: Are you sure you want to delete "beforeSave" webhook t `) h.Out.Reset() - h.env.In = ioutil.NopCloser(strings.NewReader("foo\nn\n")) - ensure.Nil(t, tr.triggerHooksDelete(h.env, nil)) + h.Env.In = ioutil.NopCloser(strings.NewReader("foo\nn\n")) + ensure.Nil(t, tr.triggerHooksDelete(h.Env, nil)) ensure.DeepEqual(t, h.Out.String(), `Please enter following details about the trigger webhook