├── .github └── workflows │ ├── go.yml │ └── release.yml ├── .gitignore ├── .goreleaser.yml ├── LICENSE ├── README.md ├── cmd └── ghawrap │ └── main.go ├── githubapps ├── auth.go ├── credential.go └── store.go ├── go.mod ├── go.sum └── main.go /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | name: Go 2 | on: [push] 3 | jobs: 4 | 5 | build: 6 | name: Build 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: Check out code into the Go module directory 10 | uses: actions/checkout@v4 11 | 12 | - name: Set up Go 1.23 13 | uses: actions/setup-go@v5 14 | with: 15 | go-version: 1.23 16 | id: go 17 | 18 | - name: Build 19 | run: go build -v . 20 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - v** 7 | 8 | jobs: 9 | release: 10 | 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v4 16 | with: 17 | fetch-depth: 0 18 | 19 | - name: Go Setup 20 | uses: actions/setup-go@v5 21 | with: 22 | go-version: 1.23 23 | 24 | - name: GoReleaser Action 25 | uses: goreleaser/goreleaser-action@v6 26 | with: 27 | distribution: goreleaser 28 | version: latest 29 | args: release --clean 30 | env: 31 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | # This is an example goreleaser.yaml file with some sane defaults. 2 | # Make sure to check the documentation at http://goreleaser.com 3 | version: 2 4 | before: 5 | hooks: 6 | # you may remove this if you don't use vgo 7 | - go mod tidy 8 | builds: 9 | - id: git-credential-github-apps 10 | env: 11 | - CGO_ENABLED=0 12 | goos: 13 | - linux 14 | - darwin 15 | - windows 16 | goarch: 17 | - amd64 18 | - arm64 19 | goarm: 20 | - "6" 21 | goamd64: 22 | - "v2" 23 | main: main.go 24 | - id: ghawrap 25 | binary: ghawrap 26 | env: 27 | - CGO_ENABLED=0 28 | goos: 29 | - linux 30 | - darwin 31 | - windows 32 | goarch: 33 | - amd64 34 | - arm64 35 | goarm: 36 | - "6" 37 | goamd64: 38 | - "v2" 39 | main: cmd/ghawrap/main.go 40 | archives: 41 | - id: git-credential-github-apps 42 | builds: 43 | - git-credential-github-apps 44 | name_template: '{{.Binary}}_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}' 45 | format_overrides: 46 | - goos: windows 47 | format: zip 48 | - goos: darwin 49 | format: zip 50 | - id: ghawrap 51 | builds: 52 | - ghawrap 53 | name_template: '{{.Binary}}_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}' 54 | format_overrides: 55 | - goos: windows 56 | format: zip 57 | - goos: darwin 58 | format: zip 59 | checksum: 60 | name_template: 'checksums.txt' 61 | snapshot: 62 | version_template: "{{ .Tag }}-next" 63 | changelog: 64 | sort: asc 65 | filters: 66 | exclude: 67 | - '^docs:' 68 | - '^test:' 69 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2019 mackee 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 14 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 15 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 16 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, 17 | DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 18 | OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE 19 | OR OTHER DEALINGS IN THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # git-credential-github-apps 2 | 3 | A git credential helper with GitHub Apps 4 | 5 | ## Overview 6 | 7 | `git-credential-github-apps` provides authentication behavior in GitHub Apps on git commands. 8 | 9 | This command returns credentials that GitHub Token. Also, that response contains a cached token while during in not expire. 10 | 11 | `git-credential-github-apps` is work as git-credential-helper. If you want to know more details, see the `Install` section in this document and [this document](https://git-scm.com/docs/api-credentials). 12 | 13 | ## Install 14 | 15 | Download latest version from [Releases](https://github.com/mackee/git-credential-github-apps/releases). 16 | 17 | Extract into a directory that written in your PATH environment variable. 18 | 19 | ## Usage 20 | 21 | ### Prepare 22 | 23 | Using this tool requires a private key, App ID and Installation ID or organization name. 24 | 25 | You will get a private key and App ID on the Config page at GitHub Apps. 26 | 27 | More details for private key: [Generating a private key](https://developer.github.com/apps/building-github-apps/authenticating-with-github-apps/#generating-a-private-key) 28 | 29 | Installation ID is the identifier of installation organization on GitHub Apps 30 | 31 | Organization name can be alternate to installation ID. `git-credential-github-apps` detect installation ID from organization name. 32 | 33 | ### Install to git 34 | 35 | Type following this. This is set credential helper to git configuration in global. 36 | 37 | ```console 38 | $ git config --global credential.helper 'github-apps -privatekey -appid -login ' 39 | ``` 40 | 41 | If you want to set to repository local, you will type following this on directory of the repository. 42 | 43 | ```console 44 | git config --global credential.helper 'github-apps -privatekey -appid -login ' 45 | ``` 46 | 47 | ### More Options 48 | 49 | If you want to know more options, execution `git-credential-github-apps` with `-h`. 50 | 51 | ## Author 52 | 53 | mackee, KAYAC Inc. 54 | 55 | ## License 56 | 57 | [MIT](./LICENSE) 58 | -------------------------------------------------------------------------------- /cmd/ghawrap/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "os/exec" 8 | "syscall" 9 | 10 | "github.com/mackee/git-credential-github-apps/githubapps" 11 | ) 12 | 13 | func main() { 14 | runner, err := githubapps.ParseArgs() 15 | if err != nil { 16 | if err == githubapps.ErrShowHelp { 17 | os.Exit(0) 18 | } 19 | fmt.Printf("[ERROR] %s\n", err) 20 | os.Exit(1) 21 | } 22 | 23 | args := runner.Args() 24 | if len(args) == 0 || (len(args) == 1 && args[0] == "--") { 25 | fmt.Println("[ERROR] not provides command from args. eg. ghawrap -- yourcli options...") 26 | os.Exit(1) 27 | } 28 | if args[0] == "--" { 29 | args = args[1:] 30 | } 31 | 32 | ctx := context.Background() 33 | token, err := runner.Run(ctx) 34 | if err != nil { 35 | fmt.Printf("[ERROR] %s\n", err) 36 | os.Exit(1) 37 | } 38 | 39 | os.Setenv("GITHUB_TOKEN", token) 40 | err = runCommand(args, os.Environ()) 41 | if err != nil { 42 | fmt.Printf("[ERROR] %s\n", err) 43 | os.Exit(1) 44 | } 45 | } 46 | 47 | func runCommand(command []string, envVars []string) error { 48 | bin, err := exec.LookPath(command[0]) 49 | if err != nil { 50 | return err 51 | } 52 | 53 | return syscall.Exec(bin, command, envVars) 54 | } 55 | -------------------------------------------------------------------------------- /githubapps/auth.go: -------------------------------------------------------------------------------- 1 | package githubapps 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | 8 | "github.com/bradleyfalzon/ghinstallation/v2" 9 | "github.com/google/go-github/v68/github" 10 | ) 11 | 12 | // Auther provides API Token with authentication on GitHub Apps. 13 | type Auther interface { 14 | FetchToken(context.Context) (string, error) 15 | } 16 | 17 | type auther struct { 18 | atr *ghinstallation.AppsTransport 19 | installationID int64 20 | store Store 21 | } 22 | 23 | // NewAutherFromFile is constructor with filename that private key for Auther. 24 | func NewAutherFromFile(privateKey string, appID int64, options ...AutherOption) (Auther, error) { 25 | tr := http.DefaultTransport 26 | atr, err := ghinstallation.NewAppsTransportKeyFromFile(tr, appID, privateKey) 27 | if err != nil { 28 | return nil, fmt.Errorf("cannot create apps transport: %w", err) 29 | } 30 | 31 | a := &auther{atr: atr} 32 | for _, o := range options { 33 | err := o(a) 34 | if err != nil { 35 | return nil, fmt.Errorf("fail to initialize by option: %w", err) 36 | } 37 | } 38 | 39 | return a, nil 40 | } 41 | 42 | func (a *auther) FetchToken(ctx context.Context) (string, error) { 43 | if a.store != nil && !a.store.Expired() { 44 | return a.store.Token(), nil 45 | } 46 | 47 | t, err := a.fetchToken(ctx) 48 | if err != nil { 49 | return "", err 50 | } 51 | 52 | if a.store != nil { 53 | err := a.store.Save(t.GetToken(), t.GetExpiresAt().Time) 54 | if err != nil { 55 | return "", err 56 | } 57 | } 58 | 59 | return t.GetToken(), nil 60 | } 61 | 62 | func (a *auther) fetchToken(ctx context.Context) (*github.InstallationToken, error) { 63 | client := github.NewClient(&http.Client{Transport: a.atr}) 64 | 65 | token, _, err := client.Apps.CreateInstallationToken(ctx, a.installationID, nil) 66 | if err != nil { 67 | return nil, fmt.Errorf("fail to create installation token: %w", err) 68 | } 69 | return token, nil 70 | } 71 | 72 | // AutherOption is set parameter. This is like a Functional Option Pattern. 73 | type AutherOption func(*auther) error 74 | 75 | // WithLogin is optional parameter for Auther. 76 | // This provides to search a installation ID from login name. 77 | func WithLogin(ctx context.Context, login string) AutherOption { 78 | return func(a *auther) error { 79 | client := github.NewClient(&http.Client{Transport: a.atr}) 80 | lo := &github.ListOptions{ 81 | PerPage: 100, 82 | Page: 1, 83 | } 84 | OUTER: 85 | for { 86 | ins, resp, err := client.Apps.ListInstallations(ctx, lo) 87 | if err != nil { 88 | return fmt.Errorf("fail to fetch installations: %w", err) 89 | } 90 | for _, in := range ins { 91 | if login == in.GetAccount().GetLogin() { 92 | a.installationID = in.GetID() 93 | break OUTER 94 | } 95 | } 96 | if resp.LastPage == 0 || lo.Page == resp.LastPage { 97 | return fmt.Errorf("%s is not found in installations", login) 98 | } 99 | lo.Page++ 100 | } 101 | return nil 102 | } 103 | } 104 | 105 | // WithInstallationID is option parameter with installation ID for Auther. 106 | func WithInstallationID(id int64) AutherOption { 107 | return func(a *auther) error { 108 | a.installationID = id 109 | return nil 110 | } 111 | } 112 | 113 | // WithBaseURL provides change api endpoint. For GitHub Enterprise 114 | func WithBaseURL(base string) AutherOption { 115 | return func(a *auther) error { 116 | a.atr.BaseURL = base 117 | return nil 118 | } 119 | } 120 | 121 | // WithStore provides injection token store to Auther. 122 | func WithStore(store Store) AutherOption { 123 | return func(a *auther) error { 124 | a.store = store 125 | return nil 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /githubapps/credential.go: -------------------------------------------------------------------------------- 1 | package githubapps 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "flag" 7 | "fmt" 8 | "os" 9 | "path/filepath" 10 | ) 11 | 12 | const ( 13 | defaultAPIBaseURL = "api.github.com" 14 | defaultCacheFilename = "git-credential-github-apps-token-cache" 15 | ) 16 | 17 | var ( 18 | ErrShowHelp = errors.New("receive show help args") 19 | ) 20 | 21 | // Runner provides retrieving token 22 | type Runner struct { 23 | hostname string 24 | privateKey string 25 | appID int64 26 | args []string 27 | options []AutherOption 28 | } 29 | 30 | // Hostname returns hostname from arguments 31 | func (r *Runner) Hostname() string { 32 | return r.hostname 33 | } 34 | 35 | // Args returns command options not contains parsed. 36 | func (r *Runner) Args() []string { 37 | return r.args 38 | } 39 | 40 | // Run returns credentials 41 | func (r *Runner) Run(ctx context.Context) (string, error) { 42 | return readCredential(ctx, r.privateKey, r.appID, r.options) 43 | } 44 | 45 | // ParseArgs is retrieve github apps credentials with command line options. 46 | func ParseArgs() (*Runner, error) { 47 | var ( 48 | privateKey string 49 | appID int64 50 | installationID int64 51 | login string 52 | hostname string 53 | apibase string 54 | cachefile string 55 | showHelp bool 56 | ) 57 | cachedir, err := os.UserCacheDir() 58 | if err != nil { 59 | return nil, fmt.Errorf("fail to detect cache dir: %s", err) 60 | } 61 | 62 | flag.StringVar(&privateKey, "privatekey", "private_key.pem", "private key of GitHub Apps") 63 | flag.Int64Var(&appID, "appid", 0, "App ID of GitHub Apps") 64 | flag.Int64Var(&installationID, "installationid", 0, "Installation ID of organization or user on GitHub Apps") 65 | flag.StringVar(&login, "login", "", "login name of organization or user. if not set -installationid, search from Installation ID by use value.") 66 | flag.StringVar(&hostname, "hostname", "github.com", "hostname as using for an accessing in git") 67 | flag.StringVar(&apibase, "apibase", "api.github.com", "API hostname as using for a fetching GitHub APIs") 68 | flag.StringVar( 69 | &cachefile, "cachefile", filepath.Join(cachedir, defaultCacheFilename), 70 | "filename as save cached token", 71 | ) 72 | flag.BoolVar(&showHelp, "h", false, "show this help") 73 | 74 | flag.Parse() 75 | 76 | if showHelp { 77 | flag.PrintDefaults() 78 | return nil, ErrShowHelp 79 | } 80 | 81 | if privateKey == "" || appID == 0 { 82 | return nil, fmt.Errorf("must be set -privatekey and -appid") 83 | } 84 | if installationID == 0 && login == "" { 85 | return nil, fmt.Errorf("must be set -installationid or -login") 86 | } 87 | 88 | ctx := context.Background() 89 | 90 | var options []AutherOption 91 | if defaultAPIBaseURL != apibase { 92 | options = append(options, WithBaseURL(apibase)) 93 | } 94 | if installationID == 0 { 95 | options = append(options, WithLogin(ctx, login)) 96 | } else { 97 | options = append(options, WithInstallationID(installationID)) 98 | } 99 | if cachefile != "" { 100 | s, err := NewFileStore(cachefile) 101 | if err != nil { 102 | return nil, fmt.Errorf("%s", err) 103 | } 104 | options = append(options, WithStore(s)) 105 | } 106 | 107 | return &Runner{hostname: hostname, options: options, privateKey: privateKey, appID: appID, args: flag.Args()}, nil 108 | } 109 | 110 | func readCredential(ctx context.Context, privateKey string, appID int64, options []AutherOption) (string, error) { 111 | auther, err := NewAutherFromFile(privateKey, appID, options...) 112 | if err != nil { 113 | return "", err 114 | } 115 | 116 | return auther.FetchToken(ctx) 117 | } 118 | -------------------------------------------------------------------------------- /githubapps/store.go: -------------------------------------------------------------------------------- 1 | package githubapps 2 | 3 | import ( 4 | "encoding/gob" 5 | "fmt" 6 | "os" 7 | "time" 8 | ) 9 | 10 | type tokenInfo struct { 11 | Token string 12 | Expire time.Time 13 | } 14 | 15 | // Store is store for token and expires. 16 | type Store interface { 17 | Save(token string, expire time.Time) error 18 | Token() string 19 | Expired() bool 20 | } 21 | 22 | type fileStore struct { 23 | tokenInfo tokenInfo 24 | path string 25 | } 26 | 27 | // NewFileStore returns Store from path of credential file 28 | func NewFileStore(path string) (Store, error) { 29 | var ti tokenInfo 30 | c, err := os.Open(path) 31 | if err != nil && !os.IsNotExist(err) { 32 | return nil, fmt.Errorf("fail to open credential store: %w", err) 33 | } 34 | if !os.IsNotExist(err) { 35 | dec := gob.NewDecoder(c) 36 | if err := dec.Decode(&ti); err != nil { 37 | return nil, fmt.Errorf("fail to decode credential file: %w", err) 38 | } 39 | } 40 | 41 | fs := &fileStore{tokenInfo: ti, path: path} 42 | 43 | return fs, nil 44 | } 45 | 46 | func (f *fileStore) Save(token string, expire time.Time) error { 47 | c, err := os.Create(f.path) 48 | if err != nil { 49 | return fmt.Errorf("fail to create credential file: %w", err) 50 | } 51 | enc := gob.NewEncoder(c) 52 | err = enc.Encode(tokenInfo{Token: token, Expire: expire}) 53 | if err != nil { 54 | return fmt.Errorf("fail to encode credential: %w", err) 55 | } 56 | return nil 57 | } 58 | 59 | func (f *fileStore) Token() string { return f.tokenInfo.Token } 60 | func (f *fileStore) Expired() bool { return f.tokenInfo.Expire.Before(time.Now()) } 61 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/mackee/git-credential-github-apps 2 | 3 | go 1.23 4 | 5 | toolchain go1.23.5 6 | 7 | require ( 8 | github.com/bradleyfalzon/ghinstallation/v2 v2.13.0 9 | github.com/google/go-github/v68 v68.0.0 10 | ) 11 | 12 | require ( 13 | github.com/golang-jwt/jwt/v4 v4.5.1 // indirect 14 | github.com/google/go-querystring v1.1.0 // indirect 15 | ) 16 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/bradleyfalzon/ghinstallation/v2 v2.13.0 h1:5FhjW93/YLQJDmPdeyMPw7IjAPzqsr+0jHPfrPz0sZI= 2 | github.com/bradleyfalzon/ghinstallation/v2 v2.13.0/go.mod h1:EJ6fgedVEHa2kUyBTTvslJCXJafS/mhJNNKEOCspZXQ= 3 | github.com/golang-jwt/jwt/v4 v4.5.1 h1:JdqV9zKUdtaa9gdPlywC3aeoEsR681PlKC+4F5gQgeo= 4 | github.com/golang-jwt/jwt/v4 v4.5.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= 5 | github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 6 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 7 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 8 | github.com/google/go-github/v68 v68.0.0 h1:ZW57zeNZiXTdQ16qrDiZ0k6XucrxZ2CGmoTvcCyQG6s= 9 | github.com/google/go-github/v68 v68.0.0/go.mod h1:K9HAUBovM2sLwM408A18h+wd9vqdLOEqTUCbnRIcx68= 10 | github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= 11 | github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= 12 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 13 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "context" 7 | "fmt" 8 | "io" 9 | "os" 10 | "strings" 11 | 12 | "github.com/mackee/git-credential-github-apps/githubapps" 13 | ) 14 | 15 | func main() { 16 | runner, err := githubapps.ParseArgs() 17 | if err != nil { 18 | if err == githubapps.ErrShowHelp { 19 | os.Exit(0) 20 | } 21 | fmt.Printf("[ERROR] %s\n", err) 22 | os.Exit(1) 23 | } 24 | 25 | args := runner.Args() 26 | if len(args) != 1 || args[0] != "get" { 27 | fmt.Printf("[ERROR] unexpected args\n") 28 | os.Exit(1) 29 | } 30 | 31 | input := &credentialInput{} 32 | if _, err := input.ReadFrom(os.Stdin); err != nil { 33 | fmt.Printf("[ERROR] %s\n", err) 34 | os.Exit(1) 35 | } 36 | if input.host != runner.Hostname() || !strings.HasPrefix(input.protocol, "http") { 37 | fmt.Print(input) 38 | os.Exit(0) 39 | } 40 | 41 | token, err := runner.Run(context.Background()) 42 | if err != nil { 43 | fmt.Printf("[ERROR] %s\n", err) 44 | os.Exit(1) 45 | } 46 | 47 | printCredential(token) 48 | 49 | os.Exit(0) 50 | } 51 | 52 | func printCredential(token string) { 53 | fmt.Println("protocol=https") 54 | fmt.Println("host=github.com") 55 | fmt.Println("username=x-access-token") 56 | fmt.Printf("password=%s\n", token) 57 | } 58 | 59 | type credentialInput struct { 60 | host string 61 | protocol string 62 | username string 63 | password string 64 | wwwauthHeaders []string 65 | } 66 | 67 | func (c *credentialInput) ReadFrom(r io.Reader) (int64, error) { 68 | input := bufio.NewScanner(r) 69 | for input.Scan() && input.Text() != "" { 70 | text := input.Text() 71 | kv := strings.SplitN(text, "=", 2) 72 | if len(kv) != 2 { 73 | return 0, fmt.Errorf("input text is invalid: input line=%s", text) 74 | } 75 | switch kv[0] { 76 | case "host": 77 | c.host = kv[1] 78 | case "protocol": 79 | c.protocol = kv[1] 80 | case "username": 81 | c.username = kv[1] 82 | case "password": 83 | c.password = kv[1] 84 | case "wwwauth[]": 85 | c.wwwauthHeaders = append(c.wwwauthHeaders, kv[1]) 86 | default: 87 | return 0, fmt.Errorf("input text is invalid: input line=%s", text) 88 | } 89 | } 90 | if err := input.Err(); err != nil { 91 | return 0, fmt.Errorf("fail to scan from reader: %w", err) 92 | } 93 | return 0, nil 94 | } 95 | 96 | func (c *credentialInput) String() string { 97 | out := &bytes.Buffer{} 98 | fmt.Fprintf(out, "host=%s\n", c.host) 99 | fmt.Fprintf(out, "protocol=%s\n", c.protocol) 100 | fmt.Fprintf(out, "username=%s\n", c.username) 101 | fmt.Fprintf(out, "password=%s\n", c.password) 102 | 103 | return out.String() 104 | } 105 | --------------------------------------------------------------------------------