diff --git a/example_gh_test.go b/example_gh_test.go index f4c6af1..496ce56 100644 --- a/example_gh_test.go +++ b/example_gh_test.go @@ -1,10 +1,13 @@ package gh_test import ( + "encoding/json" "fmt" "io" "log" + "net/http" "os" + "regexp" "time" gh "github.com/cli/go-gh" @@ -12,7 +15,6 @@ import ( "github.com/cli/go-gh/pkg/tableprinter" "github.com/cli/go-gh/pkg/term" graphql "github.com/cli/shurcooL-graphql" - "github.com/shurcooL/githubv4" ) // Execute 'gh issue list -R cli/cli', and print the output. @@ -70,29 +72,66 @@ func ExampleRESTClient_request() { if err != nil { log.Fatal(err) } - // URL to cli/cli release v2.14.2 checksums.txt assetURL := "repos/cli/cli/releases/assets/71589494" - resp, err := client.Request("GET", assetURL, nil) + response, err := client.Request(http.MethodGet, assetURL, nil) if err != nil { log.Fatal(err) } - defer resp.Body.Close() - + defer response.Body.Close() f, err := os.CreateTemp("", "*_checksums.txt") if err != nil { log.Fatal(err) } defer f.Close() - - _, err = io.Copy(f, resp.Body) + _, err = io.Copy(f, response.Body) if err != nil { log.Fatal(err) } - fmt.Printf("Asset downloaded to %s\n", f.Name()) } +// Get releases from cli/cli repository using REST API with paginated results. +func ExampleRESTClient_pagination() { + var linkRE = regexp.MustCompile(`<([^>]+)>;\s*rel="([^"]+)"`) + findNextPage := func(response *http.Response) (string, bool) { + for _, m := range linkRE.FindAllStringSubmatch(response.Header.Get("Link"), -1) { + if len(m) > 2 && m[2] == "next" { + return m[1], true + } + } + return "", false + } + client, err := gh.RESTClient(nil) + if err != nil { + log.Fatal(err) + } + requestPath := "repos/cli/cli/releases" + page := 1 + for { + response, err := client.Request(http.MethodGet, requestPath, nil) + if err != nil { + log.Fatal(err) + } + data := []struct{ Name string }{} + decoder := json.NewDecoder(response.Body) + err = decoder.Decode(&data) + if err != nil { + log.Fatal(err) + } + if err := response.Body.Close(); err != nil { + log.Fatal(err) + } + fmt.Printf("Page: %d\n", page) + fmt.Println(data) + var hasNextPage bool + if requestPath, hasNextPage = findNextPage(response); !hasNextPage { + break + } + page++ + } +} + // Query tags from cli/cli repository using GQL API. func ExampleGQLClient_simple() { client, err := gh.GQLClient(nil) @@ -155,12 +194,12 @@ func ExampleGQLClient_advanced() { } // Add a star to the cli/go-gh repository using the GQL API. -func ExampleGQLClient_Mutate_simple() { +func ExampleGQLClient_mutate_simple() { client, err := gh.GQLClient(nil) if err != nil { log.Fatal(err) } - var m struct { + var mutation struct { AddStar struct { Starrable struct { Repository struct { @@ -172,16 +211,60 @@ func ExampleGQLClient_Mutate_simple() { } } `graphql:"addStar(input: $input)"` } + // Note that the shurcooL/githubv4 package has defined input structs generated from the + // GraphQL schema that can be used instead of writing your own. + type AddStarInput struct { + StarrableID string `json:"starrableId"` + } variables := map[string]interface{}{ - "input": githubv4.AddStarInput{ - StarrableID: githubv4.NewID("R_kgDOF_MgQQ"), + "input": AddStarInput{ + StarrableID: "R_kgDOF_MgQQ", }, } - err = client.Mutate("AddStar", &m, variables) + err = client.Mutate("AddStar", &mutation, variables) if err != nil { log.Fatal(err) } - fmt.Println(m.AddStar.Starrable.Repository.StargazerCount) + fmt.Println(mutation.AddStar.Starrable.Repository.StargazerCount) +} + +// Query releases from cli/cli repository using GQL API with paginated results. +func ExampleGQLClient_pagination() { + client, err := gh.GQLClient(nil) + if err != nil { + log.Fatal(err) + } + var query struct { + Repository struct { + Releases struct { + Nodes []struct { + Name string + } + PageInfo struct { + HasNextPage bool + EndCursor string + } + } `graphql:"releases(first: 30, after: $endCursor)"` + } `graphql:"repository(owner: $owner, name: $name)"` + } + variables := map[string]interface{}{ + "owner": graphql.String("cli"), + "name": graphql.String("cli"), + "endCursor": (*graphql.String)(nil), + } + page := 1 + for { + if err := client.Query("RepositoryReleases", &query, variables); err != nil { + log.Fatal(err) + } + fmt.Printf("Page: %d\n", page) + fmt.Println(query.Repository.Releases.Nodes) + if !query.Repository.Releases.PageInfo.HasNextPage { + break + } + variables["endCursor"] = graphql.String(query.Repository.Releases.PageInfo.EndCursor) + page++ + } } // Get repository for the current directory. diff --git a/go.mod b/go.mod index c87b1d7..8555e13 100644 --- a/go.mod +++ b/go.mod @@ -14,11 +14,10 @@ require ( github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d github.com/muesli/reflow v0.3.0 github.com/muesli/termenv v0.12.0 - github.com/shurcooL/githubv4 v0.0.0-20221229060216-a8d4a561cc93 github.com/stretchr/testify v1.7.0 github.com/thlib/go-timezone-local v0.0.0-20210907160436-ef149e42d28e - golang.org/x/sys v0.4.0 - golang.org/x/term v0.4.0 + golang.org/x/sys v0.5.0 + golang.org/x/term v0.5.0 gopkg.in/h2non/gock.v1 v1.1.2 gopkg.in/yaml.v3 v3.0.1 ) @@ -40,10 +39,8 @@ require ( github.com/olekukonko/tablewriter v0.0.5 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rivo/uniseg v0.2.0 // indirect - github.com/shurcooL/graphql v0.0.0-20220606043923-3cf50f8a0a29 // indirect github.com/yuin/goldmark v1.4.4 // indirect github.com/yuin/goldmark-emoji v1.0.1 // indirect - golang.org/x/net v0.5.0 // indirect - golang.org/x/oauth2 v0.4.0 // indirect + golang.org/x/net v0.7.0 // indirect gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect ) diff --git a/go.sum b/go.sum index 0a3587c..fa7a7d6 100644 --- a/go.sum +++ b/go.sum @@ -17,7 +17,6 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dlclark/regexp2 v1.4.0 h1:F1rxgk7p4uKjwIQxBs9oAXe5CqrXlCduYEJvrF4u93E= github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= -github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw= github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= @@ -66,10 +65,6 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/shurcooL/githubv4 v0.0.0-20221229060216-a8d4a561cc93 h1:JNy04upyaTaAGVlUFAL+60/1nphmJtuTu36tLhbaqXk= -github.com/shurcooL/githubv4 v0.0.0-20221229060216-a8d4a561cc93/go.mod h1:hAF0iLZy4td2EX+/8Tw+4nodhlMrwN3HupfaXj3zkGo= -github.com/shurcooL/graphql v0.0.0-20220606043923-3cf50f8a0a29 h1:B1PEwpArrNp4dkQrfxh/abbBAOZBVp0ds+fBEOUOqOc= -github.com/shurcooL/graphql v0.0.0-20220606043923-3cf50f8a0a29/go.mod h1:AuYgA5Kyo4c7HfUmvRGs/6rGlMMV/6B1bVnB9JxJEEg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= @@ -82,10 +77,8 @@ github.com/yuin/goldmark-emoji v1.0.1 h1:ctuWEyzGBwiucEqxzwe0SOYDXPAucOrE9NQC18W github.com/yuin/goldmark-emoji v1.0.1/go.mod h1:2w1E6FEWLcDQkoTE+7HU6QF1F6SLlNGjRIBbIZQFqkQ= golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220923203811-8be639271d50/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= -golang.org/x/net v0.5.0 h1:GyT4nK/YDHSqa1c4753ouYCDajOYKTja9Xb/OHtgvSw= -golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws= -golang.org/x/oauth2 v0.4.0 h1:NF0gk8LVPg1Ml7SSbGyySuoxdsXitj7TvgvuRxIMc/M= -golang.org/x/oauth2 v0.4.0/go.mod h1:RznEsdpjGAINPTOF0UH/t+xJ75L18YO3Ho6Pyn+uRec= +golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g= +golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210319071255-635bc2c9138d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -96,18 +89,16 @@ golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.4.0 h1:Zr2JFtRQNX3BCZ8YtxRE9hNJYC8J6I1MVbMg6owUp18= -golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.4.0 h1:O7UWfv5+A2qiuulQk30kVinPoMtoIPeVaKLEgLpVkvg= -golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ= +golang.org/x/term v0.5.0 h1:n2a8QNdAb0sZNpU9R1ALUXBbY+w51fCQDN+7EdxNBsY= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= -google.golang.org/protobuf v1.28.0 h1:w43yiav+6bVFTBQFZX0r7ipe9JQ1QsbMgHwbBziscLw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/api/cache.go b/internal/api/cache.go index 74542a7..7085d05 100644 --- a/internal/api/cache.go +++ b/internal/api/cache.go @@ -170,33 +170,39 @@ func (fs *fileStorage) read(key string) (*http.Response, error) { return res, err } -func (fs *fileStorage) store(key string, res *http.Response) error { +func (fs *fileStorage) store(key string, res *http.Response) (storeErr error) { cacheFile := fs.filePath(key) fs.mu.Lock() defer fs.mu.Unlock() - err := os.MkdirAll(filepath.Dir(cacheFile), 0755) - if err != nil { - return err + if storeErr = os.MkdirAll(filepath.Dir(cacheFile), 0755); storeErr != nil { + return } - f, err := os.OpenFile(cacheFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) - if err != nil { - return err + var f *os.File + if f, storeErr = os.OpenFile(cacheFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600); storeErr != nil { + return } - defer f.Close() + + defer func() { + if err := f.Close(); storeErr == nil && err != nil { + storeErr = err + } + }() var origBody io.ReadCloser if res.Body != nil { origBody, res.Body = copyStream(res.Body) defer res.Body.Close() } - err = res.Write(f) + + storeErr = res.Write(f) if origBody != nil { res.Body = origBody } - return err + + return } func copyStream(r io.ReadCloser) (io.ReadCloser, io.ReadCloser) { diff --git a/pkg/auth/auth.go b/pkg/auth/auth.go index 39caa8c..1ea32d5 100644 --- a/pkg/auth/auth.go +++ b/pkg/auth/auth.go @@ -4,10 +4,12 @@ package auth import ( "os" + "os/exec" "strconv" "strings" "github.com/cli/go-gh/pkg/config" + "github.com/cli/safeexec" ) const ( @@ -24,11 +26,29 @@ const ( oauthToken = "oauth_token" ) -// TokenForHost retrieves an authentication token and the source of -// that token for the specified host. The source can be either an -// environment variable or from the configuration file. +// TokenForHost retrieves an authentication token and the source of that token for the specified +// host. The source can be either an environment variable, configuration file, or the system +// keyring. In the latter case, this shells out to "gh auth token" to obtain the token. +// // Returns "", "default" if no applicable token is found. func TokenForHost(host string) (string, string) { + if token, source := TokenFromEnvOrConfig(host); token != "" { + return token, source + } + + if ghExe, err := safeexec.LookPath("gh"); err == nil { + if token, source := tokenFromGh(ghExe, host); token != "" { + return token, source + } + } + + return "", defaultSource +} + +// TokenFromEnvOrConfig retrieves an authentication token from environment variables or the config +// file as fallback, but does not support reading the token from system keyring. Most consumers +// should use TokenForHost. +func TokenFromEnvOrConfig(host string) (string, string) { cfg, _ := config.Read() return tokenForHost(cfg, host) } @@ -65,6 +85,15 @@ func tokenForHost(cfg *config.Config, host string) (string, string) { return "", defaultSource } +func tokenFromGh(path string, host string) (string, string) { + cmd := exec.Command(path, "auth", "token", "--secure-storage", "--hostname", host) + result, err := cmd.Output() + if err != nil { + return "", "gh" + } + return strings.TrimSpace(string(result)), "gh" +} + // KnownHosts retrieves a list of hosts that have corresponding // authentication tokens, either from environment variables // or from the configuration file. diff --git a/pkg/config/config.go b/pkg/config/config.go index 15668df..bfbea2b 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -281,18 +281,21 @@ func readFile(filename string) ([]byte, error) { return data, nil } -func writeFile(filename string, data []byte) error { - err := os.MkdirAll(filepath.Dir(filename), 0771) - if err != nil { - return err +func writeFile(filename string, data []byte) (writeErr error) { + if writeErr = os.MkdirAll(filepath.Dir(filename), 0771); writeErr != nil { + return } - file, err := os.OpenFile(filename, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600) - if err != nil { - return err + var file *os.File + if file, writeErr = os.OpenFile(filename, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600); writeErr != nil { + return } - defer file.Close() - _, err = file.Write(data) - return err + defer func() { + if err := file.Close(); writeErr == nil && err != nil { + writeErr = err + } + }() + _, writeErr = file.Write(data) + return } var defaultGeneralEntries = ` diff --git a/pkg/ssh/ssh.go b/pkg/ssh/ssh.go index 0ef9137..4e5216e 100644 --- a/pkg/ssh/ssh.go +++ b/pkg/ssh/ssh.go @@ -95,7 +95,11 @@ func (t *Translator) resolve(hostname string) (string, error) { } } - _ = sshCmd.Wait() + err = sshCmd.Wait() + if err != nil || resolvedHost == "" { + // handle failures by returning the original hostname unchanged + resolvedHost = hostname + } if t.cacheMap == nil { t.cacheMap = map[string]string{} diff --git a/pkg/ssh/ssh_test.go b/pkg/ssh/ssh_test.go index 46ea3d4..d61e4e6 100644 --- a/pkg/ssh/ssh_test.go +++ b/pkg/ssh/ssh_test.go @@ -1,6 +1,7 @@ package ssh import ( + "errors" "fmt" "net/url" "os" @@ -85,7 +86,13 @@ func TestHelperProcess(t *testing.T) { return } if err := func(args []string) error { - fmt.Fprint(os.Stdout, "hostname github.com\n") + if len(args) < 3 || args[2] == "error" { + return errors.New("fatal") + } + if args[2] == "empty.io" { + return nil + } + fmt.Fprintf(os.Stdout, "hostname %s\n", args[2]) return nil }(os.Args[3:]); err != nil { fmt.Fprintln(os.Stderr, err) @@ -111,32 +118,46 @@ func TestTranslator_caching(t *testing.T) { }, } - u1, err := url.Parse("ssh://github1.com/owner/repo.git") - if err != nil { - t.Fatalf("error parsing URL: %v", err) - } - if res := tr.Translate(u1); res.Host != "github.com" { - t.Errorf("expected github.com, got: %q", res.Host) - } - if res := tr.Translate(u1); res.Host != "github.com" { - t.Errorf("expected github.com, got: %q", res.Host) - } - - u2, err := url.Parse("ssh://github2.com/owner/repo.git") - if err != nil { - t.Fatalf("error parsing URL: %v", err) - } - if res := tr.Translate(u2); res.Host != "github.com" { - t.Errorf("expected github.com, got: %q", res.Host) + tests := []struct { + input string + result string + }{ + { + input: "ssh://github1.com/owner/repo.git", + result: "github1.com", + }, + { + input: "ssh://github2.com/owner/repo.git", + result: "github2.com", + }, + { + input: "ssh://empty.io/owner/repo.git", + result: "empty.io", + }, + { + input: "ssh://error/owner/repo.git", + result: "error", + }, } - if res := tr.Translate(u2); res.Host != "github.com" { - t.Errorf("expected github.com, got: %q", res.Host) + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + u, err := url.Parse(tt.input) + if err != nil { + t.Fatalf("error parsing URL: %v", err) + } + if res := tr.Translate(u); res.Host != tt.result { + t.Errorf("expected github.com, got: %q", res.Host) + } + if res := tr.Translate(u); res.Host != tt.result { + t.Errorf("expected github.com, got: %q (second call)", res.Host) + } + }) } if countLookPath != 1 { t.Errorf("expected lookPath to happen 1 time; actual: %d", countLookPath) } - if countNewCommand != 2 { - t.Errorf("expected ssh command to shell out 2 times; actual: %d", countNewCommand) + if countNewCommand != len(tests) { + t.Errorf("expected ssh command to shell out %d times; actual: %d", len(tests), countNewCommand) } } diff --git a/pkg/term/env.go b/pkg/term/env.go index 44b2983..a5a7d55 100644 --- a/pkg/term/env.go +++ b/pkg/term/env.go @@ -14,7 +14,9 @@ import ( // Term represents information about the terminal that a process is connected to. type Term struct { + in *os.File out *os.File + errOut *os.File isTTY bool colorEnabled bool is256enabled bool @@ -61,7 +63,9 @@ func FromEnv() Term { } return Term{ + in: os.Stdin, out: os.Stdout, + errOut: os.Stderr, isTTY: stdoutIsTTY, colorEnabled: isColorEnabled, is256enabled: isVirtualTerminal || is256ColorSupported(), @@ -71,11 +75,21 @@ func FromEnv() Term { } } +// In is the reader reading from standard input. +func (t Term) In() io.Reader { + return t.in +} + // Out is the writer writing to standard output. func (t Term) Out() io.Writer { return t.out } +// ErrOut is the writer writing to standard error. +func (t Term) ErrOut() io.Writer { + return t.errOut +} + // IsTerminalOutput returns true if standard output is connected to a terminal. func (t Term) IsTerminalOutput() bool { return t.isTTY