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 ======= +[](https://travis-ci.org/parse-community/parse-cli) +[](https://community.parseplatform.org/c/parse-server) +[][open-collective-link] +[][open-collective-link] +[![License][license-svg]][license-link] +[](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 = ` + +
+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.
+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.
-