diff --git a/go.mod b/go.mod index 51083f997..42cfe7ff8 100644 --- a/go.mod +++ b/go.mod @@ -25,7 +25,7 @@ require ( github.com/gorilla/websocket v1.5.3 github.com/hashicorp/go-multierror v1.1.1 github.com/hashicorp/go-version v1.3.0 - github.com/henvic/httpretty v0.1.3 + github.com/henvic/httpretty v0.1.4 github.com/in-toto/attestation v1.1.0 github.com/joho/godotenv v1.5.1 github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 diff --git a/go.sum b/go.sum index f0e05ee81..3b1fb2c2d 100644 --- a/go.sum +++ b/go.sum @@ -252,8 +252,8 @@ github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/hashicorp/vault/api v1.12.2 h1:7YkCTE5Ni90TcmYHDBExdt4WGJxhpzaHqR6uGbQb/rE= github.com/hashicorp/vault/api v1.12.2/go.mod h1:LSGf1NGT1BnvFFnKVtnvcaLBM2Lz+gJdpL6HUYed8KE= -github.com/henvic/httpretty v0.1.3 h1:4A6vigjz6Q/+yAfTD4wqipCv+Px69C7Th/NhT0ApuU8= -github.com/henvic/httpretty v0.1.3/go.mod h1:UUEv7c2kHZ5SPQ51uS3wBpzPDibg2U3Y+IaXyHy5GBg= +github.com/henvic/httpretty v0.1.4 h1:Jo7uwIRWVFxkqOnErcoYfH90o3ddQyVrSANeS4cxYmU= +github.com/henvic/httpretty v0.1.4/go.mod h1:Dn60sQTZfbt2dYsdUSNsCljyF4AfdqnuJFDLJA1I4AM= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog= diff --git a/internal/config/auth_config_test.go b/internal/config/auth_config_test.go index ed000ff18..61245c650 100644 --- a/internal/config/auth_config_test.go +++ b/internal/config/auth_config_test.go @@ -52,8 +52,32 @@ func TestTokenFromKeyringForUserErrorsIfUsernameIsBlank(t *testing.T) { require.ErrorContains(t, err, "username cannot be blank") } +func TestHasActiveToken(t *testing.T) { + // Given the user has logged in for a host + authCfg := newTestAuthConfig(t) + _, err := authCfg.Login("github.com", "test-user", "test-token", "", false) + require.NoError(t, err) + + // When we check if that host has an active token + hasActiveToken := authCfg.HasActiveToken("github.com") + + // Then there is an active token + require.True(t, hasActiveToken, "expected there to be an active token") +} + +func TestHasNoActiveToken(t *testing.T) { + // Given there are no users logged in for a host + authCfg := newTestAuthConfig(t) + + // When we check if any host has an active token + hasActiveToken := authCfg.HasActiveToken("github.com") + + // Then there is no active token + require.False(t, hasActiveToken, "expected there to be no active token") +} + func TestTokenStoredInConfig(t *testing.T) { - // When the user has logged in insecurely + // Given the user has logged in insecurely authCfg := newTestAuthConfig(t) _, err := authCfg.Login("github.com", "test-user", "test-token", "", false) require.NoError(t, err) diff --git a/internal/config/config.go b/internal/config/config.go index 29b66b73b..1b56d30b2 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -217,6 +217,12 @@ func (c *AuthConfig) ActiveToken(hostname string) (string, string) { return token, source } +// HasActiveToken returns true when a token for the hostname is present. +func (c *AuthConfig) HasActiveToken(hostname string) bool { + token, _ := c.ActiveToken(hostname) + return token != "" +} + // HasEnvToken returns true when a token has been specified in an // environment variable, else returns false. func (c *AuthConfig) HasEnvToken() bool { diff --git a/internal/gh/gh.go b/internal/gh/gh.go index c39734075..e4431fdab 100644 --- a/internal/gh/gh.go +++ b/internal/gh/gh.go @@ -93,6 +93,9 @@ type Migration interface { // with knowledge on how to access encrypted storage when neccesarry. // Behavior is scoped to authentication specific tasks. type AuthConfig interface { + // HasActiveToken returns true when a token for the hostname is present. + HasActiveToken(hostname string) bool + // ActiveToken will retrieve the active auth token for the given hostname, searching environment variables, // general configuration, and finally encrypted storage. ActiveToken(hostname string) (token string, source string) diff --git a/internal/prompter/prompter.go b/internal/prompter/prompter.go index 44579b189..1d4b11cbc 100644 --- a/internal/prompter/prompter.go +++ b/internal/prompter/prompter.go @@ -92,7 +92,7 @@ func (p *surveyPrompter) InputHostname() (string, error) { var result string err := p.ask( &survey.Input{ - Message: "GHE hostname:", + Message: "Hostname:", }, &result, survey.WithValidator(func(v interface{}) error { return ghinstance.HostnameValidator(v.(string)) })) diff --git a/pkg/cmd/attestation/trustedroot/trustedroot.go b/pkg/cmd/attestation/trustedroot/trustedroot.go index c9c3fdb04..5473adc0c 100644 --- a/pkg/cmd/attestation/trustedroot/trustedroot.go +++ b/pkg/cmd/attestation/trustedroot/trustedroot.go @@ -69,6 +69,15 @@ func NewTrustedRootCmd(f *cmdutil.Factory, runF func(*Options) error) *cobra.Com } if ghinstance.IsTenancy(opts.Hostname) { + c, err := f.Config() + if err != nil { + return err + } + + if !c.Authentication().HasActiveToken(opts.Hostname) { + return fmt.Errorf("not authenticated with %s", opts.Hostname) + } + hc, err := f.HttpClient() if err != nil { return err @@ -94,6 +103,7 @@ func NewTrustedRootCmd(f *cmdutil.Factory, runF func(*Options) error) *cobra.Com }, } + cmdutil.DisableAuthCheck(&trustedRootCmd) trustedRootCmd.Flags().StringVarP(&opts.TufUrl, "tuf-url", "", "", "URL to the TUF repository mirror") trustedRootCmd.Flags().StringVarP(&opts.TufRootPath, "tuf-root", "", "", "Path to the TUF root.json file on disk") trustedRootCmd.MarkFlagsRequiredTogether("tuf-url", "tuf-root") @@ -116,6 +126,12 @@ func getTrustedRoot(makeTUF tufClientInstantiator, opts *Options) error { // Disable local caching, so we get up-to-date response from TUF repository tufOpt.CacheValidity = 0 + // Target will be either the default trusted root, or the trust domain-qualified one + ghTR := defaultTR + if opts.TrustDomain != "" { + ghTR = fmt.Sprintf("%s.%s", opts.TrustDomain, defaultTR) + } + if opts.TufUrl != "" && opts.TufRootPath != "" { tufRoot, err := os.ReadFile(opts.TufRootPath) if err != nil { @@ -126,7 +142,7 @@ func getTrustedRoot(makeTUF tufClientInstantiator, opts *Options) error { tufOpt.RepositoryBaseURL = opts.TufUrl tufOptions = append(tufOptions, tufConfig{ tufOptions: tufOpt, - targets: []string{defaultTR}, + targets: []string{ghTR}, }) } else { // Get from both Sigstore public good and GitHub private instance @@ -137,14 +153,9 @@ func getTrustedRoot(makeTUF tufClientInstantiator, opts *Options) error { tufOpt = verification.GitHubTUFOptions() tufOpt.CacheValidity = 0 - targets := []string{defaultTR} - if opts.TrustDomain != "" { - targets = append(targets, fmt.Sprintf("%s.%s", - opts.TrustDomain, defaultTR)) - } tufOptions = append(tufOptions, tufConfig{ tufOptions: tufOpt, - targets: targets, + targets: []string{ghTR}, }) } diff --git a/pkg/cmd/attestation/trustedroot/trustedroot_test.go b/pkg/cmd/attestation/trustedroot/trustedroot_test.go index 70b5ae2a1..c4a259436 100644 --- a/pkg/cmd/attestation/trustedroot/trustedroot_test.go +++ b/pkg/cmd/attestation/trustedroot/trustedroot_test.go @@ -3,6 +3,7 @@ package trustedroot import ( "bytes" "fmt" + "net/http" "strings" "testing" @@ -10,8 +11,13 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/cli/cli/v2/internal/config" + "github.com/cli/cli/v2/internal/gh" + ghmock "github.com/cli/cli/v2/internal/gh/mock" + "github.com/cli/cli/v2/pkg/cmd/attestation/api" "github.com/cli/cli/v2/pkg/cmd/attestation/test" "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/httpmock" "github.com/cli/cli/v2/pkg/iostreams" ) @@ -19,6 +25,9 @@ func TestNewTrustedRootCmd(t *testing.T) { testIO, _, _, _ := iostreams.Test() f := &cmdutil.Factory{ IOStreams: testIO, + Config: func() (gh.Config, error) { + return &ghmock.ConfigMock{}, nil + }, } testcases := []struct { @@ -72,6 +81,83 @@ func TestNewTrustedRootCmd(t *testing.T) { } } +func TestNewTrustedRootWithTenancy(t *testing.T) { + testIO, _, _, _ := iostreams.Test() + var testReg httpmock.Registry + var metaResp = api.MetaResponse{ + Domains: api.Domain{ + ArtifactAttestations: api.ArtifactAttestations{ + TrustDomain: "foo", + }, + }, + } + testReg.Register(httpmock.REST(http.MethodGet, "meta"), + httpmock.StatusJSONResponse(200, &metaResp)) + + httpClientFunc := func() (*http.Client, error) { + reg := &testReg + client := &http.Client{} + httpmock.ReplaceTripper(client, reg) + return client, nil + } + + cli := "--hostname foo-bar.ghe.com" + + t.Run("Host with NO auth configured", func(t *testing.T) { + f := &cmdutil.Factory{ + IOStreams: testIO, + Config: func() (gh.Config, error) { + return &ghmock.ConfigMock{ + AuthenticationFunc: func() gh.AuthConfig { + return &stubAuthConfig{hasActiveToken: false} + }, + }, nil + }, + } + + cmd := NewTrustedRootCmd(f, func(_ *Options) error { + return nil + }) + + argv := strings.Split(cli, " ") + cmd.SetArgs(argv) + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + _, err := cmd.ExecuteC() + + assert.Error(t, err) + assert.ErrorContains(t, err, "not authenticated") + }) + + t.Run("Host with auth configured", func(t *testing.T) { + f := &cmdutil.Factory{ + IOStreams: testIO, + Config: func() (gh.Config, error) { + return &ghmock.ConfigMock{ + AuthenticationFunc: func() gh.AuthConfig { + return &stubAuthConfig{hasActiveToken: true} + }, + }, nil + }, + HttpClient: httpClientFunc, + } + + cmd := NewTrustedRootCmd(f, func(_ *Options) error { + return nil + }) + + argv := strings.Split(cli, " ") + cmd.SetArgs(argv) + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + _, err := cmd.ExecuteC() + assert.NoError(t, err) + }) +} + var newTUFErrClient tufClientInstantiator = func(o *tuf.Options) (*tuf.Client, error) { return nil, fmt.Errorf("failed to create TUF client") } @@ -99,3 +185,14 @@ func TestGetTrustedRoot(t *testing.T) { }) } + +type stubAuthConfig struct { + config.AuthConfig + hasActiveToken bool +} + +var _ gh.AuthConfig = (*stubAuthConfig)(nil) + +func (c *stubAuthConfig) HasActiveToken(host string) bool { + return c.hasActiveToken +} diff --git a/pkg/cmd/auth/login/login.go b/pkg/cmd/auth/login/login.go index 9e27f28ac..1480a29a0 100644 --- a/pkg/cmd/auth/login/login.go +++ b/pkg/cmd/auth/login/login.go @@ -59,6 +59,9 @@ func NewCmdLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.Comm Long: heredoc.Docf(` Authenticate with a GitHub host. + The default hostname is %[1]sgithub.com%[1]s. This can be overridden using the %[1]s--hostname%[1]s + flag. + The default authentication mode is a web-based browser flow. After completion, an authentication token will be stored securely in the system credential store. If a credential store is not found or there is an issue using it gh will fallback @@ -222,21 +225,19 @@ func loginRun(opts *LoginOptions) error { } func promptForHostname(opts *LoginOptions) (string, error) { - options := []string{"GitHub.com", "GitHub Enterprise Server"} + options := []string{"GitHub.com", "Other"} hostType, err := opts.Prompter.Select( - "What account do you want to log into?", + "Where do you use GitHub?", options[0], options) if err != nil { return "", err } - isEnterprise := hostType == 1 - - hostname := ghinstance.Default() - if isEnterprise { - hostname, err = opts.Prompter.InputHostname() + isGitHubDotCom := hostType == 0 + if isGitHubDotCom { + return ghinstance.Default(), nil } - return hostname, err + return opts.Prompter.InputHostname() } diff --git a/pkg/cmd/auth/login/login_test.go b/pkg/cmd/auth/login/login_test.go index d53dd3f0b..3264ed91c 100644 --- a/pkg/cmd/auth/login/login_test.go +++ b/pkg/cmd/auth/login/login_test.go @@ -2,6 +2,7 @@ package login import ( "bytes" + "fmt" "net/http" "regexp" "runtime" @@ -546,7 +547,7 @@ func Test_loginRun_Survey(t *testing.T) { wantErrOut: regexp.MustCompile("Tip: you can generate a Personal Access Token here https://rebecca.chambers/settings/tokens"), }, { - name: "choose enterprise", + name: "choose Other", wantHosts: heredoc.Doc(` brad.vickers: users: @@ -563,8 +564,8 @@ func Test_loginRun_Survey(t *testing.T) { prompterStubs: func(pm *prompter.PrompterMock) { pm.SelectFunc = func(prompt, _ string, opts []string) (int, error) { switch prompt { - case "What account do you want to log into?": - return prompter.IndexFor(opts, "GitHub Enterprise Server") + case "Where do you use GitHub?": + return prompter.IndexFor(opts, "Other") case "What is your preferred protocol for Git operations on this host?": return prompter.IndexFor(opts, "HTTPS") case "How would you like to authenticate GitHub CLI?": @@ -606,7 +607,7 @@ func Test_loginRun_Survey(t *testing.T) { prompterStubs: func(pm *prompter.PrompterMock) { pm.SelectFunc = func(prompt, _ string, opts []string) (int, error) { switch prompt { - case "What account do you want to log into?": + case "Where do you use GitHub?": return prompter.IndexFor(opts, "GitHub.com") case "What is your preferred protocol for Git operations on this host?": return prompter.IndexFor(opts, "HTTPS") @@ -640,7 +641,7 @@ func Test_loginRun_Survey(t *testing.T) { prompterStubs: func(pm *prompter.PrompterMock) { pm.SelectFunc = func(prompt, _ string, opts []string) (int, error) { switch prompt { - case "What account do you want to log into?": + case "Where do you use GitHub?": return prompter.IndexFor(opts, "GitHub.com") case "What is your preferred protocol for Git operations on this host?": return prompter.IndexFor(opts, "SSH") @@ -803,3 +804,50 @@ func Test_loginRun_Survey(t *testing.T) { }) } } + +func Test_promptForHostname(t *testing.T) { + tests := []struct { + name string + options []string + selectedIndex int + // This is so we can test that the options in the function don't change + expectedSelection string + inputHostname string + expect string + }{ + { + name: "select 'GitHub.com'", + selectedIndex: 0, + expectedSelection: "GitHub.com", + expect: "github.com", + }, + { + name: "select 'Other'", + selectedIndex: 1, + expectedSelection: "Other", + inputHostname: "github.enterprise.com", + expect: "github.enterprise.com", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + promptMock := &prompter.PrompterMock{ + SelectFunc: func(_ string, _ string, options []string) (int, error) { + if options[tt.selectedIndex] != tt.expectedSelection { + return 0, fmt.Errorf("expected %s at index %d, but got %s", tt.expectedSelection, tt.selectedIndex, options[tt.selectedIndex]) + } + return tt.selectedIndex, nil + }, + InputHostnameFunc: func() (string, error) { + return tt.inputHostname, nil + }, + } + opts := &LoginOptions{ + Prompter: promptMock, + } + hostname, err := promptForHostname(opts) + require.NoError(t, err) + require.Equal(t, tt.expect, hostname) + }) + } +} diff --git a/pkg/cmd/auth/setupgit/setupgit.go b/pkg/cmd/auth/setupgit/setupgit.go index c1200b475..0ff7b6903 100644 --- a/pkg/cmd/auth/setupgit/setupgit.go +++ b/pkg/cmd/auth/setupgit/setupgit.go @@ -36,7 +36,7 @@ func NewCmdSetupGit(f *cmdutil.Factory, runF func(*SetupGitOptions) error) *cobr Long: heredoc.Docf(` This command configures %[1]sgit%[1]s to use GitHub CLI as a credential helper. For more information on git credential helpers please reference: - https://git-scm.com/docs/gitcredentials. + . By default, GitHub CLI will be set as the credential helper for all authenticated hosts. If there is no authenticated hosts the command fails with an error. diff --git a/pkg/cmd/extension/manager.go b/pkg/cmd/extension/manager.go index 45186d689..410d4c224 100644 --- a/pkg/cmd/extension/manager.go +++ b/pkg/cmd/extension/manager.go @@ -280,9 +280,13 @@ func (m *Manager) installBin(repo ghrepo.Interface, target string) error { } if asset == nil { + cs := m.io.ColorScheme() + errorMessageInRed := fmt.Sprintf(cs.Red("%[1]s unsupported for %[2]s."), repo.RepoName(), platform) + issueCreateCommand := generateMissingBinaryIssueCreateCommand(repo.RepoOwner(), repo.RepoName(), platform) + return fmt.Errorf( - "%[1]s unsupported for %[2]s. Open an issue: `gh issue create -R %[3]s/%[1]s -t'Support %[2]s'`", - repo.RepoName(), platform, repo.RepoOwner()) + "%[1]s\n\nTo request support for %[2]s, open an issue on the extension's repo by running the following command:\n\n `%[3]s`", + errorMessageInRed, platform, issueCreateCommand) } name := repo.RepoName() @@ -334,6 +338,15 @@ func (m *Manager) installBin(repo ghrepo.Interface, target string) error { return nil } +func generateMissingBinaryIssueCreateCommand(repoOwner string, repoName string, currentPlatform string) string { + issueBody := generateMissingBinaryIssueBody(currentPlatform) + return fmt.Sprintf("gh issue create -R %[1]s/%[2]s --title \"Add support for the %[3]s architecture\" --body \"%[4]s\"", repoOwner, repoName, currentPlatform, issueBody) +} + +func generateMissingBinaryIssueBody(currentPlatform string) string { + return fmt.Sprintf("This extension does not support the %[1]s architecture. I tried to install it on a %[1]s machine, and it failed due to the lack of an available binary. Would you be able to update the extension's build and release process to include the relevant binary? For more details, see .", currentPlatform) +} + func writeManifest(dir, name string, data []byte) (writeErr error) { path := filepath.Join(dir, name) var f *os.File diff --git a/pkg/cmd/extension/manager_test.go b/pkg/cmd/extension/manager_test.go index 5de4e15cb..0ad8e991e 100644 --- a/pkg/cmd/extension/manager_test.go +++ b/pkg/cmd/extension/manager_test.go @@ -906,7 +906,7 @@ func TestManager_Install_binary_unsupported(t *testing.T) { m := newTestManager(tempDir, &client, nil, ios) err := m.Install(repo, "") - assert.EqualError(t, err, "gh-bin-ext unsupported for windows-amd64. Open an issue: `gh issue create -R owner/gh-bin-ext -t'Support windows-amd64'`") + assert.EqualError(t, err, "gh-bin-ext unsupported for windows-amd64.\n\nTo request support for windows-amd64, open an issue on the extension's repo by running the following command:\n\n\t`gh issue create -R owner/gh-bin-ext --title \"Add support for the windows-amd64 architecture\" --body \"This extension does not support the windows-amd64 architecture. I tried to install it on a windows-amd64 machine, and it failed due to the lack of an available binary. Would you be able to update the extension's build and release process to include the relevant binary? For more details, see .\"`") assert.Equal(t, "", stdout.String()) assert.Equal(t, "", stderr.String()) diff --git a/pkg/cmd/repo/create/create.go b/pkg/cmd/repo/create/create.go index 8b5dd515a..28ca0d1e8 100644 --- a/pkg/cmd/repo/create/create.go +++ b/pkg/cmd/repo/create/create.go @@ -93,7 +93,12 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co To create a remote repository from an existing local repository, specify the source directory with %[1]s--source%[1]s. By default, the remote repository name will be the name of the source directory. + Pass %[1]s--push%[1]s to push any local commits to the new repository. + + For language or platform .gitignore templates to use with %[1]s--gitignore%[1]s, . + + For license keywords to use with %[1]s--license%[1]s, . `, "`"), Example: heredoc.Doc(` # create a repository interactively