github.com/andrewhsu/cli/v2@v2.0.1-0.20210910131313-d4b4061f5b89/pkg/cmd/secret/set/set.go (about)

     1  package set
     2  
     3  import (
     4  	"encoding/base64"
     5  	"errors"
     6  	"fmt"
     7  	"io"
     8  	"io/ioutil"
     9  	"net/http"
    10  	"regexp"
    11  	"strings"
    12  
    13  	"github.com/AlecAivazis/survey/v2"
    14  	"github.com/MakeNowJust/heredoc"
    15  	"github.com/andrewhsu/cli/v2/api"
    16  	"github.com/andrewhsu/cli/v2/internal/config"
    17  	"github.com/andrewhsu/cli/v2/internal/ghrepo"
    18  	"github.com/andrewhsu/cli/v2/pkg/cmd/secret/shared"
    19  	"github.com/andrewhsu/cli/v2/pkg/cmdutil"
    20  	"github.com/andrewhsu/cli/v2/pkg/iostreams"
    21  	"github.com/andrewhsu/cli/v2/pkg/prompt"
    22  	"github.com/spf13/cobra"
    23  	"golang.org/x/crypto/nacl/box"
    24  )
    25  
    26  type SetOptions struct {
    27  	HttpClient func() (*http.Client, error)
    28  	IO         *iostreams.IOStreams
    29  	Config     func() (config.Config, error)
    30  	BaseRepo   func() (ghrepo.Interface, error)
    31  
    32  	RandomOverride io.Reader
    33  
    34  	SecretName      string
    35  	OrgName         string
    36  	EnvName         string
    37  	Body            string
    38  	Visibility      string
    39  	RepositoryNames []string
    40  }
    41  
    42  func NewCmdSet(f *cmdutil.Factory, runF func(*SetOptions) error) *cobra.Command {
    43  	opts := &SetOptions{
    44  		IO:         f.IOStreams,
    45  		Config:     f.Config,
    46  		HttpClient: f.HttpClient,
    47  	}
    48  
    49  	cmd := &cobra.Command{
    50  		Use:   "set <secret-name>",
    51  		Short: "Create or update secrets",
    52  		Long:  "Locally encrypt a new or updated secret at either the repository, environment, or organization level and send it to GitHub for storage.",
    53  		Example: heredoc.Doc(`
    54  			Paste secret in prompt
    55  			$ gh secret set MYSECRET
    56  
    57  			Use environment variable as secret value
    58  			$ gh secret set MYSECRET  -b"${ENV_VALUE}"
    59  
    60  			Use file as secret value
    61  			$ gh secret set MYSECRET < file.json
    62  
    63  			Set environment level secret
    64  			$ gh secret set MYSECRET -bval --env=anEnv
    65  
    66  			Set organization level secret visible to entire organization
    67  			$ gh secret set MYSECRET -bval --org=anOrg --visibility=all
    68  
    69  			Set organization level secret visible only to certain repositories
    70  			$ gh secret set MYSECRET -bval --org=anOrg --repos="repo1,repo2,repo3"
    71  `),
    72  		Args: func(cmd *cobra.Command, args []string) error {
    73  			if len(args) != 1 {
    74  				return &cmdutil.FlagError{Err: errors.New("must pass single secret name")}
    75  			}
    76  			return nil
    77  		},
    78  		RunE: func(cmd *cobra.Command, args []string) error {
    79  			// support `-R, --repo` override
    80  			opts.BaseRepo = f.BaseRepo
    81  
    82  			if err := cmdutil.MutuallyExclusive("specify only one of `--org` or `--env`", opts.OrgName != "", opts.EnvName != ""); err != nil {
    83  				return err
    84  			}
    85  
    86  			opts.SecretName = args[0]
    87  
    88  			err := validSecretName(opts.SecretName)
    89  			if err != nil {
    90  				return err
    91  			}
    92  
    93  			if cmd.Flags().Changed("visibility") {
    94  				if opts.OrgName == "" {
    95  					return &cmdutil.FlagError{Err: errors.New(
    96  						"--visibility not supported for repository secrets; did you mean to pass --org?")}
    97  				}
    98  
    99  				if opts.Visibility != shared.All && opts.Visibility != shared.Private && opts.Visibility != shared.Selected {
   100  					return &cmdutil.FlagError{Err: errors.New(
   101  						"--visibility must be one of `all`, `private`, or `selected`")}
   102  				}
   103  
   104  				if opts.Visibility != shared.Selected && cmd.Flags().Changed("repos") {
   105  					return &cmdutil.FlagError{Err: errors.New(
   106  						"--repos only supported when --visibility='selected'")}
   107  				}
   108  
   109  				if opts.Visibility == shared.Selected && !cmd.Flags().Changed("repos") {
   110  					return &cmdutil.FlagError{Err: errors.New(
   111  						"--repos flag required when --visibility='selected'")}
   112  				}
   113  			} else {
   114  				if cmd.Flags().Changed("repos") {
   115  					opts.Visibility = shared.Selected
   116  				}
   117  			}
   118  
   119  			if runF != nil {
   120  				return runF(opts)
   121  			}
   122  
   123  			return setRun(opts)
   124  		},
   125  	}
   126  	cmd.Flags().StringVarP(&opts.OrgName, "org", "o", "", "Set a secret for an organization")
   127  	cmd.Flags().StringVarP(&opts.EnvName, "env", "e", "", "Set a secret for an environment")
   128  	cmd.Flags().StringVarP(&opts.Visibility, "visibility", "v", "private", "Set visibility for an organization secret: `all`, `private`, or `selected`")
   129  	cmd.Flags().StringSliceVarP(&opts.RepositoryNames, "repos", "r", []string{}, "List of repository names for `selected` visibility")
   130  	cmd.Flags().StringVarP(&opts.Body, "body", "b", "", "A value for the secret. Reads from STDIN if not specified.")
   131  
   132  	return cmd
   133  }
   134  
   135  func setRun(opts *SetOptions) error {
   136  	body, err := getBody(opts)
   137  	if err != nil {
   138  		return fmt.Errorf("did not understand secret body: %w", err)
   139  	}
   140  
   141  	c, err := opts.HttpClient()
   142  	if err != nil {
   143  		return fmt.Errorf("could not create http client: %w", err)
   144  	}
   145  	client := api.NewClientFromHTTP(c)
   146  
   147  	orgName := opts.OrgName
   148  	envName := opts.EnvName
   149  
   150  	var baseRepo ghrepo.Interface
   151  	if orgName == "" {
   152  		baseRepo, err = opts.BaseRepo()
   153  		if err != nil {
   154  			return fmt.Errorf("could not determine base repo: %w", err)
   155  		}
   156  	}
   157  
   158  	cfg, err := opts.Config()
   159  	if err != nil {
   160  		return err
   161  	}
   162  
   163  	host, err := cfg.DefaultHost()
   164  	if err != nil {
   165  		return err
   166  	}
   167  
   168  	var pk *PubKey
   169  	if orgName != "" {
   170  		pk, err = getOrgPublicKey(client, host, orgName)
   171  	} else if envName != "" {
   172  		pk, err = getEnvPubKey(client, baseRepo, envName)
   173  	} else {
   174  		pk, err = getRepoPubKey(client, baseRepo)
   175  	}
   176  	if err != nil {
   177  		return fmt.Errorf("failed to fetch public key: %w", err)
   178  	}
   179  
   180  	eBody, err := box.SealAnonymous(nil, body, &pk.Raw, opts.RandomOverride)
   181  	if err != nil {
   182  		return fmt.Errorf("failed to encrypt body: %w", err)
   183  	}
   184  
   185  	encoded := base64.StdEncoding.EncodeToString(eBody)
   186  
   187  	if orgName != "" {
   188  		err = putOrgSecret(client, host, pk, *opts, encoded)
   189  	} else if envName != "" {
   190  		err = putEnvSecret(client, pk, baseRepo, envName, opts.SecretName, encoded)
   191  	} else {
   192  		err = putRepoSecret(client, pk, baseRepo, opts.SecretName, encoded)
   193  	}
   194  	if err != nil {
   195  		return fmt.Errorf("failed to set secret: %w", err)
   196  	}
   197  
   198  	if opts.IO.IsStdoutTTY() {
   199  		target := orgName
   200  		if orgName == "" {
   201  			target = ghrepo.FullName(baseRepo)
   202  		}
   203  		cs := opts.IO.ColorScheme()
   204  		fmt.Fprintf(opts.IO.Out, "%s Set secret %s for %s\n", cs.SuccessIconWithColor(cs.Green), opts.SecretName, target)
   205  	}
   206  
   207  	return nil
   208  }
   209  
   210  func validSecretName(name string) error {
   211  	if name == "" {
   212  		return errors.New("secret name cannot be blank")
   213  	}
   214  
   215  	if strings.HasPrefix(name, "GITHUB_") {
   216  		return errors.New("secret name cannot begin with GITHUB_")
   217  	}
   218  
   219  	leadingNumber := regexp.MustCompile(`^[0-9]`)
   220  	if leadingNumber.MatchString(name) {
   221  		return errors.New("secret name cannot start with a number")
   222  	}
   223  
   224  	validChars := regexp.MustCompile(`^([0-9]|[a-z]|[A-Z]|_)+$`)
   225  	if !validChars.MatchString(name) {
   226  		return errors.New("secret name can only contain letters, numbers, and _")
   227  	}
   228  
   229  	return nil
   230  }
   231  
   232  func getBody(opts *SetOptions) ([]byte, error) {
   233  	if opts.Body == "" {
   234  		if opts.IO.CanPrompt() {
   235  			err := prompt.SurveyAskOne(&survey.Password{
   236  				Message: "Paste your secret",
   237  			}, &opts.Body)
   238  			if err != nil {
   239  				return nil, err
   240  			}
   241  			fmt.Fprintln(opts.IO.Out)
   242  		} else {
   243  			body, err := ioutil.ReadAll(opts.IO.In)
   244  			if err != nil {
   245  				return nil, fmt.Errorf("failed to read from STDIN: %w", err)
   246  			}
   247  
   248  			return body, nil
   249  		}
   250  	}
   251  
   252  	return []byte(opts.Body), nil
   253  }