diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..a656fc12c --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +/vendor linguist-generated=true diff --git a/Makefile b/Makefile index a524f80c4..c6a9019c9 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,4 @@ VERSION=0.1 -DATE = $(shell date "+%FT%T%z") -SHA=$(shell git rev-parse --short HEAD) GOFILES = $(shell find . -name '*.go' -not -path './vendor/*') diff --git a/client/client.go b/client/client.go index 299140a82..b88d6c381 100644 --- a/client/client.go +++ b/client/client.go @@ -7,37 +7,36 @@ import ( "github.com/machinebox/graphql" ) -// Client wraps a graphql.Client and other fields for making API calls. -type Client struct { - endpoint string - token string - client *graphql.Client - logger *logger.Logger -} - // NewClient returns a reference to a Client. // We also call graphql.NewClient to initialize a new GraphQL Client. // Then we pass the Logger originally constructed as cmd.Logger. -func NewClient(endpoint string, token string, logger *logger.Logger) *Client { - return &Client{ - endpoint, - token, - graphql.NewClient(endpoint), - logger, +func NewClient(endpoint string, logger *logger.Logger) *graphql.Client { + + client := graphql.NewClient(endpoint) + + client.Log = func(s string) { + logger.Debug(s) } + + return client + +} + +// newAuthorizedRequest returns a new GraphQL request with the +// authorization headers set for CircleCI auth. +func newAuthorizedRequest(token, query string) *graphql.Request { + req := graphql.NewRequest(query) + req.Header.Set("Authorization", token) + return req } // Run will construct a request using graphql.NewRequest. // Then it will execute the given query using graphql.Client.Run. // This function will return the unmarshalled response as JSON. -func (c *Client) Run(query string) (map[string]interface{}, error) { - req := graphql.NewRequest(query) - req.Header.Set("Authorization", c.token) - +func Run(client *graphql.Client, token, query string) (map[string]interface{}, error) { + req := newAuthorizedRequest(token, query) ctx := context.Background() var resp map[string]interface{} - - c.logger.Debug("Querying %s with:\n\n%s\n\n", c.endpoint, query) - err := c.client.Run(ctx, req, &resp) + err := client.Run(ctx, req, &resp) return resp, err } diff --git a/cmd/config.go b/cmd/config.go new file mode 100644 index 000000000..3d208f833 --- /dev/null +++ b/cmd/config.go @@ -0,0 +1,95 @@ +package cmd + +import ( + "bytes" + "context" + "fmt" + "io/ioutil" + + "github.com/pkg/errors" + + "github.com/machinebox/graphql" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +// Path to the config.yml file to operate on. +var configPath string + +var configCmd = &cobra.Command{ + Use: "config", + Short: "Operate on build config files", +} + +var validateCommand = &cobra.Command{ + Use: "validate", + Aliases: []string{"check"}, + Short: "Check that the config file is well formed.", + RunE: validateConfig, +} + +func init() { + validateCommand.Flags().StringVarP(&configPath, "path", "p", ".circleci/config.yml", "path to build config") + configCmd.AddCommand(validateCommand) +} + +func validateConfig(cmd *cobra.Command, args []string) error { + + ctx := context.Background() + + // Define a structure that matches the result of the GQL + // query, so that we can use mapstructure to convert from + // nested maps to a strongly typed struct. + type validateResult struct { + BuildConfig struct { + Valid bool + SourceYaml string + Errors []struct { + Message string + } + } + } + + request := graphql.NewRequest(` + query ValidateConfig ($config: String!) { + buildConfig(configYaml: $config) { + valid, + errors { message }, + sourceYaml + } + }`) + + config, err := ioutil.ReadFile(configPath) + + if err != nil { + return errors.Wrapf(err, "Could not load config file at %s", configPath) + } + + request.Var("config", string(config)) + + client := graphql.NewClient(viper.GetString("endpoint")) + + var result validateResult + + err = client.Run(ctx, request, &result) + + if err != nil { + return errors.Wrap(err, "GraphQL query failed") + } + + if !result.BuildConfig.Valid { + + var buffer bytes.Buffer + + for i := range result.BuildConfig.Errors { + buffer.WriteString(result.BuildConfig.Errors[i].Message) + buffer.WriteString("\n") + } + + return fmt.Errorf("config file is invalid:\n%s", buffer.String()) + } + + fmt.Println("Config is valid") + return nil + +} diff --git a/cmd/diagnostic.go b/cmd/diagnostic.go index 53e53f290..c71ad6fb0 100644 --- a/cmd/diagnostic.go +++ b/cmd/diagnostic.go @@ -10,10 +10,10 @@ import ( var diagnosticCmd = &cobra.Command{ Use: "diagnostic", Short: "Check the status of your CircleCI CLI.", - Run: diagnostic, + RunE: diagnostic, } -func diagnostic(cmd *cobra.Command, args []string) { +func diagnostic(cmd *cobra.Command, args []string) error { endpoint := viper.GetString("endpoint") token := viper.GetString("token") @@ -23,10 +23,10 @@ func diagnostic(cmd *cobra.Command, args []string) { Logger.Infof("GraphQL API endpoint: %s\n", endpoint) if token == "token" || token == "" { - Logger.FatalOnError("Please set a token!", errors.New("")) - } else { - Logger.Infoln("OK, got a token.") + return errors.New("please set a token") } - + Logger.Infoln("OK, got a token.") Logger.Infof("Verbose mode: %v\n", viper.GetBool("verbose")) + + return nil } diff --git a/cmd/diagnostic_test.go b/cmd/diagnostic_test.go index 43412d190..9da949bc9 100644 --- a/cmd/diagnostic_test.go +++ b/cmd/diagnostic_test.go @@ -87,9 +87,9 @@ token: It("print error", func() { session, err := gexec.Start(command, GinkgoWriter, GinkgoWriter) Expect(err).ShouldNot(HaveOccurred()) - Eventually(session.Err).Should(gbytes.Say("Please set a token!")) + Eventually(session.Err).Should(gbytes.Say("Error: please set a token")) Eventually(session.Out).Should(gbytes.Say("GraphQL API endpoint: https://example.com/graphql")) - Eventually(session).Should(gexec.Exit(1)) + Eventually(session).Should(gexec.Exit(255)) }) }) }) diff --git a/cmd/query.go b/cmd/query.go index f3a674e9b..65cc5c8bb 100644 --- a/cmd/query.go +++ b/cmd/query.go @@ -5,6 +5,7 @@ import ( "os" "github.com/circleci/circleci-cli/client" + "github.com/pkg/errors" "github.com/spf13/cobra" "github.com/spf13/viper" ) @@ -12,21 +13,23 @@ import ( var queryCmd = &cobra.Command{ Use: "query", Short: "Query the CircleCI GraphQL API.", - Run: query, + RunE: query, } -func query(cmd *cobra.Command, args []string) { - client := client.NewClient(viper.GetString("endpoint"), viper.GetString("token"), Logger) +func query(cmd *cobra.Command, args []string) error { + c := client.NewClient(viper.GetString("endpoint"), Logger) query, err := ioutil.ReadAll(os.Stdin) if err != nil { - Logger.FatalOnError("Unable to read query", err) + return errors.Wrap(err, "Unable to read query from stdin") } - resp, err := client.Run(string(query)) + resp, err := client.Run(c, viper.GetString("token"), string(query)) if err != nil { - Logger.FatalOnError("Error occurred when running query", err) + return errors.Wrap(err, "Error occurred when running query") } Logger.Prettyify(resp) + + return nil } diff --git a/cmd/root.go b/cmd/root.go index b67c0ce5a..9a19ebd1d 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -3,9 +3,10 @@ package cmd import ( "os" "path" - "runtime" "github.com/circleci/circleci-cli/logger" + "github.com/circleci/circleci-cli/settings" + "github.com/pkg/errors" "github.com/spf13/cobra" "github.com/spf13/viper" ) @@ -34,22 +35,30 @@ func addCommands() { rootCmd.AddCommand(diagnosticCmd) rootCmd.AddCommand(queryCmd) rootCmd.AddCommand(configureCommand) + rootCmd.AddCommand(configCmd) + + // Cobra has a peculiar default behaviour: + // https://github.com/spf13/cobra/issues/340 + // If you expose a command with `RunE`, and return an error from your + // command, then Cobra will print the error message, followed by the usage + // infomation for the command. This makes it really difficult to see what's + // gone wrong. It usually prints a one line error message followed by 15 + // lines of usage information. + // This flag disables that behaviour, so that if a comment fails, it prints + // just the error message. + rootCmd.SilenceUsage = true } -func userHomeDir() string { - if runtime.GOOS == "windows" { - home := os.Getenv("HOMEDRIVE") + os.Getenv("HOMEPATH") - if home == "" { - home = os.Getenv("USERPROFILE") - } - return home +func bindCobraFlagToViper(flag string) { + if err := viper.BindPFlag(flag, rootCmd.PersistentFlags().Lookup(flag)); err != nil { + panic(errors.Wrapf(err, "internal error binding cobra flag '%s' to viper", flag)) } - return os.Getenv("HOME") } func init() { - configDir := path.Join(userHomeDir(), ".circleci") + configDir := path.Join(settings.UserHomeDir(), ".circleci") + cobra.OnInitialize(setup) viper.SetConfigName("cli") @@ -57,34 +66,21 @@ func init() { viper.SetEnvPrefix("circleci_cli") viper.AutomaticEnv() - err := viper.ReadInConfig() - - // If reading the config file failed, then we want to create it. - // TODO - handle invalid YAML config files. - if err != nil { - if _, err = os.Stat(configDir); os.IsNotExist(err) { - if err = os.MkdirAll(configDir, 0700); err != nil { - panic(err) - } - } - if _, err = os.Create(path.Join(configDir, "cli.yml")); err != nil { - panic(err) - } + if err := settings.EnsureSettingsFileExists(configDir, "cli.yml"); err != nil { + panic(err) + } + + if err := viper.ReadInConfig(); err != nil { + panic(err) } rootCmd.PersistentFlags().BoolP("verbose", "v", false, "Enable verbose logging.") rootCmd.PersistentFlags().StringP("endpoint", "e", "https://circleci.com/graphql", "the endpoint of your CircleCI GraphQL API") rootCmd.PersistentFlags().StringP("token", "t", "", "your token for using CircleCI") - Logger.FatalOnError("Error binding endpoint flag", viper.BindPFlag("endpoint", rootCmd.PersistentFlags().Lookup("endpoint"))) - Logger.FatalOnError("Error binding token flag", viper.BindPFlag("token", rootCmd.PersistentFlags().Lookup("token"))) - - err = viper.BindPFlag("verbose", rootCmd.PersistentFlags().Lookup("verbose")) - - if err != nil { - panic(err) + for _, flag := range []string{"endpoint", "token", "verbose"} { + bindCobraFlagToViper(flag) } - addCommands() } diff --git a/settings/settings.go b/settings/settings.go new file mode 100644 index 000000000..9dae7e143 --- /dev/null +++ b/settings/settings.go @@ -0,0 +1,39 @@ +package settings + +import ( + "os" + "path" + "runtime" +) + +// UserHomeDir returns the path to the current user's HOME directory. +func UserHomeDir() string { + if runtime.GOOS == "windows" { + home := os.Getenv("HOMEDRIVE") + os.Getenv("HOMEPATH") + if home == "" { + home = os.Getenv("USERPROFILE") + } + return home + } + return os.Getenv("HOME") +} + +// EnsureSettingsFileExists does just that. +func EnsureSettingsFileExists(filepath, filename string) error { + // TODO - handle invalid YAML config files. + _, err := os.Stat(filepath) + + if !os.IsNotExist(err) { + return nil + } + + if err = os.MkdirAll(filepath, 0700); err != nil { + return err + } + + if _, err = os.Create(path.Join(filepath, filename)); err != nil { + return err + } + + return nil +}