├── .github ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── general.yml │ ├── question.yml │ ├── feature-request.yml │ ├── bug-report.yml │ └── support-request.yml ├── FUNDING.yml ├── workflows │ ├── autofix.yaml │ ├── watch-star.yaml │ ├── actionlint.yaml │ ├── release.yaml │ ├── workflow_call_test.yaml │ ├── check-commit-signing.yaml │ ├── test.yaml │ └── workflow_call_integration_test.yaml ├── pull_request_template.md └── copilot-instructions.md ├── .gitignore ├── aqua ├── imports │ ├── syft.yaml │ ├── cmdx.yaml │ ├── cosign.yaml │ ├── gofumpt.yaml │ ├── typos.yaml │ ├── actionlint.yaml │ ├── ghtkn.yaml │ ├── go-licenses.yaml │ ├── nllint.yaml │ ├── pinact.yaml │ ├── reviewdog.yaml │ ├── ghalint.yaml │ ├── goreleser.yaml │ └── golangci-lint.yaml ├── aqua.yaml └── aqua-checksums.json ├── _typos.toml ├── helpers ├── ghtkn-wrap └── ghtkn-gen-wrap ├── cmd └── ghtkn │ └── main.go ├── scripts ├── generate-usage.sh └── coverage.sh ├── renovate.json5 ├── pkg ├── controller │ ├── initcmd │ │ ├── controller.go │ │ ├── controller_test.go │ │ ├── init.go │ │ └── init_test.go │ └── get │ │ ├── get.go │ │ ├── output.go │ │ ├── controller.go │ │ ├── controller_test.go │ │ ├── get_test.go │ │ └── output_internal_test.go ├── log │ ├── log_test.go │ └── log.go └── cli │ ├── runner.go │ ├── flag │ └── flag.go │ ├── initcmd │ └── command.go │ └── get │ ├── git_credential.go │ └── command.go ├── .golangci.yml ├── LICENSE ├── go.mod ├── CLAUDE.md ├── cmdx.yaml ├── .goreleaser.yml ├── INSTALL.md ├── USAGE.md ├── go.sum ├── AGENTS.md └── README.md /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | .coverage 3 | third_party_licenses 4 | .serena 5 | -------------------------------------------------------------------------------- /aqua/imports/syft.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - name: anchore/syft@v1.38.2 3 | -------------------------------------------------------------------------------- /aqua/imports/cmdx.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - name: suzuki-shunsuke/cmdx@v2.0.2 3 | -------------------------------------------------------------------------------- /aqua/imports/cosign.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - name: sigstore/cosign@v3.0.3 3 | -------------------------------------------------------------------------------- /aqua/imports/gofumpt.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - name: mvdan/gofumpt@v0.9.2 3 | -------------------------------------------------------------------------------- /aqua/imports/typos.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - name: crate-ci/typos@v1.40.0 3 | -------------------------------------------------------------------------------- /aqua/imports/actionlint.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - name: rhysd/actionlint@v1.7.9 3 | -------------------------------------------------------------------------------- /aqua/imports/ghtkn.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - name: suzuki-shunsuke/ghtkn@v0.2.4 3 | -------------------------------------------------------------------------------- /aqua/imports/go-licenses.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - name: google/go-licenses@v2.0.1 3 | -------------------------------------------------------------------------------- /aqua/imports/nllint.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - name: suzuki-shunsuke/nllint@v1.0.0 3 | -------------------------------------------------------------------------------- /aqua/imports/pinact.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - name: suzuki-shunsuke/pinact@v3.6.0 3 | -------------------------------------------------------------------------------- /aqua/imports/reviewdog.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - name: reviewdog/reviewdog@v0.21.0 3 | -------------------------------------------------------------------------------- /aqua/imports/ghalint.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - name: suzuki-shunsuke/ghalint@v1.5.4 3 | -------------------------------------------------------------------------------- /aqua/imports/goreleser.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - name: goreleaser/goreleaser@v2.13.1 3 | -------------------------------------------------------------------------------- /aqua/imports/golangci-lint.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - name: golangci/golangci-lint@v2.7.2 3 | -------------------------------------------------------------------------------- /_typos.toml: -------------------------------------------------------------------------------- 1 | [default.extend-words] 2 | ERRO = "ERRO" 3 | intoto = "intoto" 4 | typ = "typ" 5 | -------------------------------------------------------------------------------- /helpers/ghtkn-wrap: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -eu 4 | 5 | cmd=$1 6 | shift 7 | 8 | GITHUB_TOKEN="$(ghtkn get)" 9 | export GITHUB_TOKEN 10 | exec "$cmd" "$@" 11 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/displaying-a-sponsor-button-in-your-repository 2 | github: 3 | - suzuki-shunsuke 4 | -------------------------------------------------------------------------------- /helpers/ghtkn-gen-wrap: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -eu 4 | 5 | abs=$1 6 | cmd=$(basename "$abs") 7 | 8 | p="$HOME/bin/$cmd" 9 | 10 | echo "#!/usr/bin/env bash 11 | 12 | exec ghtkn-wrap \"$abs\" \"\$@\"" > "$p" 13 | chmod +x "$p" 14 | -------------------------------------------------------------------------------- /cmd/ghtkn/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/suzuki-shunsuke/ghtkn/pkg/cli" 5 | "github.com/suzuki-shunsuke/urfave-cli-v3-util/urfave" 6 | ) 7 | 8 | var version = "" 9 | 10 | func main() { 11 | urfave.Main("ghtkn", version, cli.Run) 12 | } 13 | -------------------------------------------------------------------------------- /scripts/generate-usage.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -eu 4 | 5 | cd "$(dirname "$0")/.." 6 | 7 | help=$(ghtkn help-all) 8 | 9 | echo "# Usage 10 | 11 | 12 | 13 | $help" > USAGE.md 14 | -------------------------------------------------------------------------------- /.github/workflows/autofix.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: autofix.ci 3 | on: pull_request 4 | permissions: {} 5 | jobs: 6 | autofix: 7 | runs-on: ubuntu-24.04 8 | permissions: {} 9 | timeout-minutes: 15 10 | steps: 11 | - uses: suzuki-shunsuke/go-autofix-action@2a159c8f21af356c29ee26d76821b5c15956c1d6 # v0.1.11 12 | with: 13 | aqua_version: v2.56.0 14 | -------------------------------------------------------------------------------- /renovate.json5: -------------------------------------------------------------------------------- 1 | { 2 | extends: [ 3 | "github>suzuki-shunsuke/renovate-config#3.3.1", 4 | "github>suzuki-shunsuke/renovate-config:nolimit#3.3.1", 5 | "github>suzuki-shunsuke/renovate-config:go-directive#3.3.1", 6 | "github>aquaproj/aqua-renovate-config#2.9.0", 7 | "github>aquaproj/aqua-renovate-config:file#2.9.0(aqua/imports/.*\\.ya?ml)", 8 | ], 9 | } 10 | -------------------------------------------------------------------------------- /aqua/aqua.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | # yaml-language-server: $schema=https://raw.githubusercontent.com/aquaproj/aqua/v2.56.0/json-schema/aqua-yaml.json 3 | # aqua - Declarative CLI Version Manager 4 | # https://aquaproj.github.io/ 5 | checksum: 6 | enabled: true 7 | require_checksum: true 8 | registries: 9 | - type: standard 10 | ref: v4.447.1 # renovate: depName=aquaproj/aqua-registry 11 | import_dir: imports 12 | -------------------------------------------------------------------------------- /.github/workflows/watch-star.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: watch-star 3 | on: 4 | watch: 5 | types: 6 | - started 7 | jobs: 8 | watch-star: 9 | timeout-minutes: 10 10 | runs-on: ubuntu-24.04 11 | permissions: 12 | issues: write 13 | steps: 14 | - uses: suzuki-shunsuke/watch-star-action@2b3d259ce2ea06d53270dfe33a66d5642c8010ca # v0.1.1 15 | with: 16 | number: 4 17 | -------------------------------------------------------------------------------- /.github/workflows/actionlint.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: actionlint 3 | on: pull_request 4 | concurrency: 5 | group: ${{ github.workflow }}--${{ github.ref }} 6 | cancel-in-progress: true 7 | jobs: 8 | actionlint: 9 | runs-on: ubuntu-24.04 10 | timeout-minutes: 10 11 | permissions: 12 | contents: read 13 | pull-requests: write 14 | steps: 15 | - uses: suzuki-shunsuke/actionlint-action@29e0b7cda52e51a495d15f22759745ef6e19583a # v0.1.1 16 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Release 3 | on: 4 | push: 5 | tags: [v*] 6 | permissions: {} 7 | jobs: 8 | release: 9 | uses: suzuki-shunsuke/go-release-workflow/.github/workflows/release.yaml@4db55f2aeaca166b2655fa4a80658df1050dc203 # v7.0.0 10 | with: 11 | go-version-file: go.mod 12 | aqua_version: v2.56.0 13 | permissions: 14 | contents: write 15 | id-token: write 16 | actions: read 17 | attestations: write 18 | -------------------------------------------------------------------------------- /.github/workflows/workflow_call_test.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: test (workflow_call) 3 | on: workflow_call 4 | permissions: {} 5 | jobs: 6 | integration-test: 7 | uses: ./.github/workflows/workflow_call_integration_test.yaml 8 | permissions: {} 9 | 10 | test: 11 | uses: suzuki-shunsuke/go-test-full-workflow/.github/workflows/test.yaml@596bc52a7f02dd896d3351e61e4dfa661c1f3304 # v5.0.1 12 | with: 13 | aqua_version: v2.56.0 14 | golangci-lint-timeout: 120s 15 | permissions: 16 | pull-requests: write 17 | contents: read 18 | -------------------------------------------------------------------------------- /.github/workflows/check-commit-signing.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Check if all commits are signed 3 | on: 4 | pull_request_target: 5 | branches: [main] 6 | concurrency: 7 | group: ${{ github.workflow }}--${{ github.head_ref }} # github.ref is unavailable in case of pull_request_target 8 | cancel-in-progress: true 9 | jobs: 10 | check-commit-signing: 11 | uses: suzuki-shunsuke/check-commit-signing-workflow/.github/workflows/check.yaml@547eee345f56310a656f271ec5eaa900af46b0fb # v0.1.0 12 | permissions: 13 | contents: read 14 | pull-requests: write 15 | -------------------------------------------------------------------------------- /scripts/coverage.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -euo pipefail 4 | 5 | cd "$(dirname "$0")/.." 6 | 7 | if [ $# -eq 0 ]; then 8 | target="$(go list ./... | fzf)" 9 | profile=.coverage/$target/coverage.txt 10 | mkdir -p .coverage/"$target" 11 | elif [ $# -eq 1 ]; then 12 | target=$1 13 | mkdir -p .coverage/"$target" 14 | profile=.coverage/$target/coverage.txt 15 | target=./$target 16 | else 17 | echo "too many arguments are given: $*" >&2 18 | exit 1 19 | fi 20 | 21 | go test "$target" -coverprofile="$profile" -covermode=atomic 22 | go tool cover -html="$profile" 23 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: test 3 | on: pull_request 4 | permissions: {} 5 | concurrency: 6 | group: ${{ github.workflow }}--${{ github.ref }} 7 | cancel-in-progress: true 8 | jobs: 9 | status-check: 10 | runs-on: ubuntu-24.04 11 | if: always() && (contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')) 12 | timeout-minutes: 10 13 | permissions: {} 14 | needs: 15 | - test 16 | steps: 17 | - run: exit 1 18 | test: 19 | uses: ./.github/workflows/workflow_call_test.yaml 20 | permissions: 21 | pull-requests: write 22 | contents: read 23 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/general.yml: -------------------------------------------------------------------------------- 1 | name: General 2 | description: Please use this template only when other templates don't meet your requirement 3 | labels: 4 | - no-fit-template 5 | body: 6 | - type: textarea 7 | id: what 8 | attributes: 9 | label: What 10 | validations: 11 | required: true 12 | - type: textarea 13 | id: why 14 | attributes: 15 | label: Why 16 | description: Please explain the background and the reason of this issue 17 | validations: 18 | required: false 19 | - type: textarea 20 | id: note 21 | attributes: 22 | label: Note 23 | description: Please write any additional information 24 | validations: 25 | required: false 26 | -------------------------------------------------------------------------------- /pkg/controller/initcmd/controller.go: -------------------------------------------------------------------------------- 1 | // Package initcmd implements the business logic for the 'ghtkn init' command. 2 | // It handles the creation of ghtkn configuration files with default templates. 3 | package initcmd 4 | 5 | import ( 6 | "github.com/spf13/afero" 7 | ) 8 | 9 | // Controller manages the initialization of ghtkn configuration. 10 | // It provides methods to create configuration files with appropriate permissions. 11 | type Controller struct { 12 | fs afero.Fs 13 | } 14 | 15 | // New creates a new Controller instance with the provided filesystem and environment. 16 | // The filesystem is used for all file operations, allowing for easy testing with mock filesystems. 17 | func New(fs afero.Fs) *Controller { 18 | return &Controller{ 19 | fs: fs, 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Check List 2 | 3 | 4 | 5 | - [ ] Read [CONTRIBUTING.md](https://github.com/suzuki-shunsuke/ghtkn/blob/main/CONTRIBUTING.md) 6 | - [ ] [Write a GitHub Issue before creating a Pull Request](https://github.com/suzuki-shunsuke/oss-contribution-guide/blob/main/README.md#create-an-issue-before-creating-a-pull-request) 7 | - Link to the issue: 8 | - [ ] [All commits are signed](https://github.com/suzuki-shunsuke/oss-contribution-guide/blob/main/docs/commit-signing.md) 9 | - This repository enables `Require signed commits`, so all commits must be signed 10 | - [Avoid force push](https://github.com/suzuki-shunsuke/oss-contribution-guide?tab=readme-ov-file#dont-do-force-pushes-after-opening-pull-requests) 11 | 12 | 13 | -------------------------------------------------------------------------------- /pkg/controller/get/get.go: -------------------------------------------------------------------------------- 1 | package get 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log/slog" 7 | 8 | "github.com/suzuki-shunsuke/ghtkn-go-sdk/ghtkn" 9 | ) 10 | 11 | // Run executes the main logic for retrieving a GitHub App access token. 12 | // It reads configuration, checks for cached tokens, creates new tokens if needed, 13 | // retrieves the authenticated user's login for Git Credential Helper if necessary, 14 | // and outputs the result in the requested format. 15 | func (c *Controller) Run(ctx context.Context, logger *slog.Logger, input *ghtkn.InputGet) error { 16 | token, app, err := c.input.Client.Get(ctx, logger, input) 17 | if err != nil { 18 | return fmt.Errorf("get or create access token: %w", err) 19 | } 20 | 21 | // Output access token 22 | if err := c.output(app.Name, token); err != nil { 23 | return err 24 | } 25 | 26 | return nil 27 | } 28 | -------------------------------------------------------------------------------- /.github/workflows/workflow_call_integration_test.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: integration test 3 | on: workflow_call 4 | jobs: 5 | integration-test: 6 | permissions: {} 7 | timeout-minutes: 20 8 | runs-on: ${{ matrix.runs-on }} 9 | strategy: 10 | matrix: 11 | runs-on: 12 | - windows-latest 13 | - ubuntu-24.04 14 | steps: 15 | - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 16 | with: 17 | persist-credentials: false 18 | - uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6.1.0 19 | with: 20 | go-version-file: go.mod 21 | - run: | 22 | go install -ldflags "-X main.version=v1.0.0 -X main.commit=$GITHUB_SHA -X main.date=$(date +"%Y-%m-%dT%H:%M:%SZ%:z" | tr -d '+')" ./cmd/ghtkn 23 | - run: ghtkn -v 24 | - run: ghtkn init 25 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | linters: 3 | default: all 4 | disable: 5 | - depguard 6 | - err113 7 | - exhaustive 8 | - exhaustruct 9 | - godot 10 | - godox 11 | - ireturn 12 | - lll 13 | - musttag 14 | - nlreturn 15 | - nonamedreturns 16 | - tagalign 17 | - tagliatelle 18 | - varnamelen 19 | - wsl 20 | - wsl_v5 21 | - noinlineerr 22 | - embeddedstructfieldcheck 23 | exclusions: 24 | generated: lax 25 | presets: 26 | - comments 27 | - common-false-positives 28 | - legacy 29 | - std-error-handling 30 | paths: 31 | - third_party$ 32 | - builtin$ 33 | - examples$ 34 | formatters: 35 | enable: 36 | - gci 37 | - gofmt 38 | - gofumpt 39 | - goimports 40 | exclusions: 41 | generated: lax 42 | paths: 43 | - third_party$ 44 | - builtin$ 45 | - examples$ 46 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/question.yml: -------------------------------------------------------------------------------- 1 | name: Question 2 | description: | 3 | Please use this template when you have any questions. 4 | Please don't hesitate to ask any questions via Issues. 5 | labels: 6 | - question 7 | body: 8 | - type: textarea 9 | id: question 10 | attributes: 11 | label: Question 12 | description: | 13 | Please explain your question in details. 14 | If example code is useful to explain your question, please write it. 15 | validations: 16 | required: true 17 | - type: textarea 18 | id: background 19 | attributes: 20 | label: Background 21 | description: Please explain the background and why you have the question 22 | validations: 23 | required: false 24 | - type: textarea 25 | id: note 26 | attributes: 27 | label: Note 28 | description: Please write any additional information 29 | validations: 30 | required: false 31 | -------------------------------------------------------------------------------- /pkg/controller/initcmd/controller_test.go: -------------------------------------------------------------------------------- 1 | package initcmd_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/spf13/afero" 7 | "github.com/suzuki-shunsuke/ghtkn/pkg/controller/initcmd" 8 | ) 9 | 10 | func TestNew(t *testing.T) { 11 | t.Parallel() 12 | tests := []struct { 13 | name string 14 | fs afero.Fs 15 | }{ 16 | { 17 | name: "create controller with memory filesystem", 18 | fs: afero.NewMemMapFs(), 19 | }, 20 | { 21 | name: "create controller with nil env", 22 | fs: afero.NewMemMapFs(), 23 | }, 24 | { 25 | name: "create controller with empty env", 26 | fs: afero.NewMemMapFs(), 27 | }, 28 | { 29 | name: "create controller with os filesystem", 30 | fs: afero.NewOsFs(), 31 | }, 32 | } 33 | 34 | for _, tt := range tests { 35 | t.Run(tt.name, func(t *testing.T) { 36 | t.Parallel() 37 | if ctrl := initcmd.New(tt.fs); ctrl == nil { 38 | t.Fatal("New() returned nil controller") 39 | } 40 | }) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature-request.yml: -------------------------------------------------------------------------------- 1 | name: Feature Request 2 | description: If you want to add a feature. 3 | labels: 4 | - enhancement 5 | body: 6 | - type: textarea 7 | id: feature-overview 8 | attributes: 9 | label: Feature Overview 10 | validations: 11 | required: true 12 | - type: textarea 13 | id: why 14 | attributes: 15 | label: Why is the feature needed? 16 | description: Please explain the problem you want to solve. 17 | validations: 18 | required: true 19 | - type: textarea 20 | id: example-code 21 | attributes: 22 | label: Example Code 23 | description: | 24 | Please explain the feature with code. For example, if you want a new subcommand, please explain the usage of the subcommand. 25 | value: | 26 | ```console 27 | $ 28 | ``` 29 | 30 | Configuration 31 | 32 | ```yaml 33 | 34 | ``` 35 | validations: 36 | required: false 37 | - type: textarea 38 | id: note 39 | attributes: 40 | label: Note 41 | validations: 42 | required: false 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Shunsuke Suzuki 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /pkg/log/log_test.go: -------------------------------------------------------------------------------- 1 | package log_test 2 | 3 | import ( 4 | "io" 5 | "log/slog" 6 | "testing" 7 | 8 | "github.com/suzuki-shunsuke/ghtkn/pkg/log" 9 | ) 10 | 11 | func TestNew(t *testing.T) { 12 | t.Parallel() 13 | logger, _ := log.New(io.Discard, "v0.1.0") 14 | if logger == nil { 15 | t.Fatal("New() returned nil logger") 16 | } 17 | } 18 | 19 | func TestSetLevel(t *testing.T) { 20 | t.Parallel() 21 | tests := []struct { 22 | name string 23 | input string 24 | wantErr bool 25 | }{ 26 | { 27 | name: "parse debug level", 28 | input: "debug", 29 | }, 30 | { 31 | name: "unknown level", 32 | input: "unknown", 33 | wantErr: true, 34 | }, 35 | { 36 | name: "empty string", 37 | input: "", 38 | wantErr: true, 39 | }, 40 | } 41 | 42 | for _, tt := range tests { 43 | t.Run(tt.name, func(t *testing.T) { 44 | t.Parallel() 45 | if err := log.SetLevel(&slog.LevelVar{}, tt.input); err != nil { 46 | if tt.wantErr { 47 | return 48 | } 49 | t.Fatalf("SetLevel() unexpected error: %v", err) 50 | } 51 | if tt.wantErr { 52 | t.Fatal("SetLevel() expected error but got nil") 53 | } 54 | }) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/suzuki-shunsuke/ghtkn 2 | 3 | go 1.25.5 4 | 5 | require ( 6 | github.com/lmittmann/tint v1.1.2 7 | github.com/spf13/afero v1.15.0 8 | github.com/suzuki-shunsuke/ghtkn-go-sdk v0.2.2 9 | github.com/suzuki-shunsuke/slog-error v0.2.1 10 | github.com/suzuki-shunsuke/slog-util v0.3.0 11 | github.com/suzuki-shunsuke/urfave-cli-v3-util v0.2.0 12 | github.com/urfave/cli/v3 v3.6.1 13 | ) 14 | 15 | require ( 16 | al.essio.dev/pkg/shellescape v1.5.1 // indirect 17 | github.com/danieljoos/wincred v1.2.2 // indirect 18 | github.com/godbus/dbus/v5 v5.1.0 // indirect 19 | github.com/google/go-github/v80 v80.0.0 // indirect 20 | github.com/google/go-querystring v1.1.0 // indirect 21 | github.com/mattn/go-colorable v0.1.14 // indirect 22 | github.com/mattn/go-isatty v0.0.20 // indirect 23 | github.com/suzuki-shunsuke/go-error-with-exit-code v1.0.0 // indirect 24 | github.com/suzuki-shunsuke/go-exec v0.0.1 // indirect 25 | github.com/zalando/go-keyring v0.2.6 // indirect 26 | golang.org/x/oauth2 v0.34.0 // indirect 27 | golang.org/x/sys v0.39.0 // indirect 28 | golang.org/x/term v0.37.0 // indirect 29 | golang.org/x/text v0.28.0 // indirect 30 | gopkg.in/yaml.v3 v3.0.1 // indirect 31 | ) 32 | -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # Claude-Specific Guidelines for ghtkn 2 | 3 | This document contains Claude-specific guidelines. For general project guidelines, see [AGENTS.md](AGENTS.md). 4 | 5 | ## Core Guidelines 6 | 7 | All general project guidelines are documented in [AGENTS.md](AGENTS.md). Please refer to that document for: 8 | 9 | - Language conventions 10 | - Commit message format 11 | - Code validation and testing 12 | - Project structure 13 | - Code style guidelines 14 | - Common tasks and workflows 15 | 16 | ## Claude-Specific Notes 17 | 18 | ### Context Window Management 19 | 20 | - Be mindful of context window limits when reading large files 21 | - Use file offset and limit parameters when appropriate 22 | - Summarize lengthy outputs to conserve context 23 | 24 | ### Tool Usage 25 | 26 | - Prefer batch operations when possible to improve efficiency 27 | - Use the Task tool for complex multi-step operations 28 | - Always validate changes with `cmdx v` and `cmdx t` 29 | 30 | ### Communication Style 31 | 32 | - Keep responses concise and to the point 33 | - Focus on actionable information 34 | - Avoid unnecessary explanations unless requested 35 | 36 | ## Quick Reference 37 | 38 | For quick access to common commands and guidelines, see [AGENTS.md](AGENTS.md#important-commands). 39 | -------------------------------------------------------------------------------- /.github/copilot-instructions.md: -------------------------------------------------------------------------------- 1 | # GitHub Copilot Instructions for ghtkn 2 | 3 | This document contains GitHub Copilot-specific instructions. For general project guidelines, see [AI_GUIDE.md](../AI_GUIDE.md). 4 | 5 | ## Core Guidelines 6 | 7 | Refer to [AI_GUIDE.md](../AI_GUIDE.md) for all project conventions, including: 8 | - Language, commit messages, code style 9 | - Project structure and package responsibilities 10 | - Testing and validation commands 11 | - Error handling patterns 12 | 13 | ## Copilot-Specific Instructions 14 | 15 | ### Code Suggestions 16 | 17 | When suggesting code completions: 18 | - Prioritize consistency with existing code patterns in the file 19 | - Complete imports based on packages already used in the project 20 | - Suggest idiomatic Go patterns 21 | 22 | ### Context Awareness 23 | 24 | - Use surrounding code context to infer appropriate variable names 25 | - Match the indentation and formatting style of the current file 26 | - Suggest appropriate error messages based on function context 27 | 28 | ### Autocomplete Behavior 29 | 30 | - For test files, automatically suggest table-driven test patterns 31 | - For CLI commands, follow the `urfave/cli/v3` patterns used throughout the project 32 | - For error handling, wrap errors with context using `fmt.Errorf` with `%w` 33 | 34 | ## Quick Reference 35 | 36 | See [AI_GUIDE.md](../AI_GUIDE.md) for project-specific patterns and commands. 37 | -------------------------------------------------------------------------------- /cmdx.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | # the configuration file of cmdx - task runner 3 | # https://github.com/suzuki-shunsuke/cmdx 4 | tasks: 5 | - name: test 6 | short: t 7 | description: test 8 | usage: test 9 | script: go test ./... -race -covermode=atomic 10 | - name: vet 11 | short: v 12 | description: go vet 13 | usage: go vet 14 | script: go vet ./... 15 | - name: lint 16 | short: l 17 | description: lint the go code 18 | usage: lint the go code 19 | script: golangci-lint run 20 | - name: coverage 21 | short: c 22 | description: coverage test 23 | usage: coverage test 24 | script: "bash scripts/coverage.sh {{.target}}" 25 | args: 26 | - name: target 27 | - name: install 28 | short: i 29 | description: Build and install ghtkn 30 | usage: Build and install ghtkn by "go install" command 31 | script: | 32 | sha="" 33 | if git diff --quiet; then 34 | sha=$(git rev-parse HEAD) 35 | fi 36 | go install \ 37 | -ldflags "-X main.version=v3.0.0-local -X main.commit=$sha -X main.date=$(date +"%Y-%m-%dT%H:%M:%SZ%:z" | tr -d '+')" \ 38 | ./cmd/ghtkn 39 | - name: usage 40 | description: Generate USAGE.md 41 | usage: Generate USAGE.md 42 | script: bash scripts/generate-usage.sh 43 | - name: js 44 | description: Generate JSON Schema 45 | usage: Generate JSON Schema 46 | script: "go run ./cmd/gen-jsonschema" 47 | - name: fmt 48 | description: Format code 49 | usage: Format code 50 | script: | 51 | git ls-files --modified --others --exclude-standard | (grep -E "\.go$" || true) | xargs -r gofumpt -l -w 52 | git ls-files --modified --others --exclude-standard | xargs -r nllint -s 53 | -------------------------------------------------------------------------------- /pkg/log/log.go: -------------------------------------------------------------------------------- 1 | // Package log provides structured logging functionality for ghtkn. 2 | // It uses slog with tint handler for colored output to stderr. 3 | package log 4 | 5 | import ( 6 | "errors" 7 | "io" 8 | "log/slog" 9 | 10 | "github.com/lmittmann/tint" 11 | ) 12 | 13 | // New creates a new structured logger with the specified version and log level. 14 | // The logger outputs to stderr with colored formatting using tint handler. 15 | // It includes "program" and "version" attributes in all log entries. 16 | func New(w io.Writer, version string) (*slog.Logger, *slog.LevelVar) { 17 | level := &slog.LevelVar{} 18 | return slog.New(tint.NewHandler(w, &tint.Options{ 19 | Level: level, 20 | })).With("program", "ghtkn", "version", version), level 21 | } 22 | 23 | // ErrUnknownLogLevel is returned when an invalid log level string is provided to ParseLevel. 24 | var ErrUnknownLogLevel = errors.New("unknown log level") 25 | 26 | func SetLevel(levelVar *slog.LevelVar, level string) error { 27 | lvl, err := parseLevel(level) 28 | if err != nil { 29 | return err 30 | } 31 | levelVar.Set(lvl) 32 | return nil 33 | } 34 | 35 | // parseLevel converts a string log level to slog.Level. 36 | // Supported levels are: "debug", "info", "warn", "error". 37 | // Returns ErrUnknownLogLevel if the level string is not recognized. 38 | func parseLevel(lvl string) (slog.Level, error) { 39 | switch lvl { 40 | case "debug": 41 | return slog.LevelDebug, nil 42 | case "info": 43 | return slog.LevelInfo, nil 44 | case "warn": 45 | return slog.LevelWarn, nil 46 | case "error": 47 | return slog.LevelError, nil 48 | default: 49 | return 0, ErrUnknownLogLevel 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /pkg/cli/runner.go: -------------------------------------------------------------------------------- 1 | // Package cli provides the command-line interface layer for ghtkn. 2 | // This package serves as the main entry point for all CLI operations, 3 | // handling command parsing, flag processing, and routing to appropriate subcommands. 4 | // It orchestrates the overall CLI structure using urfave/cli framework and delegates 5 | // actual business logic to controller packages. 6 | package cli 7 | 8 | import ( 9 | "context" 10 | 11 | "github.com/suzuki-shunsuke/ghtkn/pkg/cli/flag" 12 | "github.com/suzuki-shunsuke/ghtkn/pkg/cli/get" 13 | "github.com/suzuki-shunsuke/ghtkn/pkg/cli/initcmd" 14 | "github.com/suzuki-shunsuke/slog-util/slogutil" 15 | "github.com/suzuki-shunsuke/urfave-cli-v3-util/urfave" 16 | "github.com/urfave/cli/v3" 17 | ) 18 | 19 | // Run creates and executes the main ghtkn CLI application. 20 | // It configures the command structure with global flags and subcommands, 21 | // then runs the CLI with the provided arguments. 22 | // args are command line arguments to parse and execute 23 | // Returns an error if command parsing or execution fails. 24 | func Run(ctx context.Context, logger *slogutil.Logger, env *urfave.Env) error { 25 | gFlags := &flag.GlobalFlags{} 26 | return urfave.Command(env, &cli.Command{ //nolint:wrapcheck 27 | Name: "ghtkn", 28 | Usage: "Create GitHub App User Access Tokens for secure local development. https://github.com/suzuki-shunsuke/ghtkn", 29 | Flags: []cli.Flag{ 30 | flag.LogLevel(&gFlags.LogLevel), 31 | flag.Config(&gFlags.Config), 32 | }, 33 | Commands: []*cli.Command{ 34 | initcmd.New(logger, gFlags), 35 | get.New(logger, env, true, gFlags), 36 | get.New(logger, env, false, gFlags), 37 | }, 38 | }).Run(ctx, env.Args) 39 | } 40 | -------------------------------------------------------------------------------- /pkg/controller/get/output.go: -------------------------------------------------------------------------------- 1 | package get 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/suzuki-shunsuke/ghtkn-go-sdk/ghtkn" 9 | ) 10 | 11 | type JSONOutputToken struct { 12 | ExpirationDate string `json:"expiration_date"` 13 | AccessToken string `json:"access_token"` 14 | Login string `json:"login,omitempty"` 15 | AppName string `json:"app_name,omitempty"` 16 | } 17 | 18 | // output writes the access token to stdout in the configured format. 19 | // For Git credential helper mode, it outputs both username and password in the format: 20 | // 21 | // username= 22 | // password= 23 | // 24 | // For standard mode, it outputs either the raw token string (default) or a JSON object based on OutputFormat. 25 | func (c *Controller) output(appName string, token *ghtkn.AccessToken) error { 26 | if c.input.IsGitCredential { 27 | fmt.Fprintf(c.input.Stdout, "username=%s\n", token.Login) 28 | fmt.Fprintf(c.input.Stdout, "password=%s\n\n", token.AccessToken) 29 | return nil 30 | } 31 | 32 | if c.input.IsJSON() { 33 | // JSON format 34 | if err := c.outputJSON(&JSONOutputToken{ 35 | ExpirationDate: token.ExpirationDate.Format(time.RFC3339), 36 | AccessToken: token.AccessToken, 37 | Login: token.Login, 38 | AppName: appName, 39 | }); err != nil { 40 | return fmt.Errorf("output access token: %w", err) 41 | } 42 | return nil 43 | } 44 | fmt.Fprintln(c.input.Stdout, token.AccessToken) 45 | return nil 46 | } 47 | 48 | // outputJSON encodes the given data as formatted JSON and writes it to stdout. 49 | // The JSON is indented with two spaces for readability. 50 | func (c *Controller) outputJSON(data any) error { 51 | encoder := json.NewEncoder(c.input.Stdout) 52 | encoder.SetIndent("", " ") 53 | if err := encoder.Encode(data); err != nil { 54 | return fmt.Errorf("encode as JSON: %w", err) 55 | } 56 | return nil 57 | } 58 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: 2 3 | project_name: ghtkn 4 | archives: 5 | - name_template: "{{.ProjectName}}_{{.Os}}_{{.Arch}}" 6 | files: 7 | - LICENSE 8 | - README.md 9 | - third_party_licenses/**/* 10 | format_overrides: 11 | - goos: windows 12 | formats: [zip] 13 | checksum: 14 | name_template: "{{ .ProjectName }}_checksums.txt" 15 | builds: 16 | - binary: ghtkn 17 | main: cmd/ghtkn/main.go 18 | env: 19 | - CGO_ENABLED=0 20 | goos: 21 | - windows 22 | - darwin 23 | - linux 24 | goarch: 25 | - amd64 26 | - arm64 27 | release: 28 | prerelease: "true" 29 | header: | 30 | [Pull Requests](https://github.com/suzuki-shunsuke/ghtkn/pulls?q=is%3Apr+milestone%3A{{.Tag}}) | [Issues](https://github.com/suzuki-shunsuke/ghtkn/issues?q=is%3Aissue+milestone%3A{{.Tag}}) | https://github.com/suzuki-shunsuke/ghtkn/compare/{{.PreviousTag}}...{{.Tag}} 31 | 32 | sboms: 33 | - id: default 34 | disable: false 35 | 36 | homebrew_casks: 37 | - repository: 38 | owner: suzuki-shunsuke 39 | name: homebrew-ghtkn 40 | # The project name and current git tag are used in the format string. 41 | commit_msg_template: "Brew formula update for {{ .ProjectName }} version {{ .Tag }}" 42 | homepage: https://github.com/suzuki-shunsuke/ghtkn 43 | description: Create GitHub App User Access Token for secure local development 44 | license: MIT 45 | skip_upload: true 46 | hooks: 47 | post: 48 | install: | 49 | if OS.mac? 50 | system_command "/usr/bin/xattr", args: ["-dr", "com.apple.quarantine", "#{staged_path}/ghtkn"] 51 | end 52 | 53 | scoops: 54 | - 55 | description: | 56 | Create GitHub App User Access Token for secure local development 57 | license: MIT 58 | skip_upload: true 59 | repository: 60 | owner: suzuki-shunsuke 61 | name: scoop-bucket 62 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: | 3 | Please report the bug of ghtkn. 4 | If you're not sure if it's a bug or not, please use the template `Support Request` instead. 5 | labels: 6 | - bug 7 | body: 8 | - type: textarea 9 | id: info 10 | attributes: 11 | label: ghtkn info 12 | description: | 13 | Please use the latest version. 14 | If you checked multiple versions, please write the result too. 15 | e.g. This issue occurs with v0.2.0 too, but doesn't occur with v0.1.0. 16 | value: | 17 | ```console 18 | $ ghtkn -v 19 | 20 | ``` 21 | validations: 22 | required: true 23 | - type: textarea 24 | id: overview 25 | attributes: 26 | label: Overview 27 | validations: 28 | required: true 29 | - type: textarea 30 | id: how-to-reproduce 31 | attributes: 32 | label: How to reproduce 33 | description: | 34 | Please see [the guide](https://github.com/suzuki-shunsuke/oss-contribution-guide#write-good-how-to-reproduce) too. 35 | ghtkn.yaml should be not partial but complete configuration. 36 | Please remove unneeded configuration to reproduce the issue. 37 | value: | 38 | Configuration file `ghtkn.yaml` (Please mask client ids): 39 | 40 | ```yaml 41 | ``` 42 | 43 | Environment variables: 44 | 45 | ``` 46 | ``` 47 | 48 | Command: 49 | 50 | ```sh 51 | ``` 52 | validations: 53 | required: true 54 | - type: textarea 55 | id: expected-behaviour 56 | attributes: 57 | label: Expected behaviour 58 | validations: 59 | required: true 60 | - type: textarea 61 | id: actual-behaviour 62 | attributes: 63 | label: Actual behaviour 64 | validations: 65 | required: true 66 | - type: textarea 67 | id: note 68 | attributes: 69 | label: Note 70 | validations: 71 | required: false 72 | -------------------------------------------------------------------------------- /pkg/controller/initcmd/init.go: -------------------------------------------------------------------------------- 1 | package initcmd 2 | 3 | import ( 4 | "fmt" 5 | "log/slog" 6 | "os" 7 | "path/filepath" 8 | 9 | "github.com/spf13/afero" 10 | ) 11 | 12 | // defaultConfig provides a default configuration template for ghtkn. 13 | // This template can be used to create an initial configuration file. 14 | const defaultConfig = `# yaml-language-server: $schema=https://raw.githubusercontent.com/suzuki-shunsuke/ghtkn-go-sdk/refs/heads/main/json-schema/ghtkn.json 15 | # ghtkn - https://github.com/suzuki-shunsuke/ghtkn 16 | apps: 17 | - name: suzuki-shunsuke/write # The name to identify the app 18 | client_id: xxx # Your GitHub App Client ID 19 | ` 20 | 21 | // File and directory permissions for created configuration files 22 | const ( 23 | filePermission os.FileMode = 0o644 // Standard file permissions (rw-r--r--) 24 | dirPermission os.FileMode = 0o755 // Standard directory permissions (rwxr-xr-x) 25 | ) 26 | 27 | // Init creates a new ghtkn configuration file if it doesn't exist. 28 | // It checks if the configuration file already exists and creates it with 29 | // a template configuration if it doesn't exist. 30 | // Returns an error if file operations fail, nil if successful or file already exists. 31 | func (c *Controller) Init(logger *slog.Logger, configFilePath string) error { 32 | f, err := afero.Exists(c.fs, configFilePath) 33 | if err != nil { 34 | return fmt.Errorf("check if a configuration file exists: %w", err) 35 | } 36 | if f { 37 | logger.Warn("The configuration file already exists", "path", configFilePath) 38 | return nil 39 | } 40 | if err := c.fs.MkdirAll(filepath.Dir(configFilePath), dirPermission); err != nil { 41 | return fmt.Errorf("create config dir: %w", err) 42 | } 43 | if err := afero.WriteFile(c.fs, configFilePath, []byte(defaultConfig), filePermission); err != nil { 44 | return fmt.Errorf("create a configuration file: %w", err) 45 | } 46 | logger.Info("The configuration file has been created", slog.String("path", configFilePath)) 47 | return nil 48 | } 49 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/support-request.yml: -------------------------------------------------------------------------------- 1 | name: Support Request 2 | description: | 3 | Please use this template when you face any problem (not bug) and need our help. 4 | If you're not sure if it's a bug or not, please use this template. 5 | labels: 6 | - support-request 7 | body: 8 | - type: textarea 9 | id: info 10 | attributes: 11 | label: ghtkn info 12 | description: | 13 | Please use the latest version. 14 | If you checked multiple versions, please write the result too. 15 | e.g. This issue occurs with v0.2.0 too, but doesn't occur with v0.1.0. 16 | value: | 17 | ```console 18 | $ ghtkn -v 19 | 20 | ``` 21 | validations: 22 | required: true 23 | - type: textarea 24 | id: overview 25 | attributes: 26 | label: Overview 27 | validations: 28 | required: true 29 | - type: textarea 30 | id: how-to-reproduce 31 | attributes: 32 | label: How to reproduce 33 | description: | 34 | Please see [the guide](https://github.com/suzuki-shunsuke/oss-contribution-guide#write-good-how-to-reproduce) too. 35 | ghtkn.yaml should be not partial but complete configuration. 36 | Please remove unneeded configuration to reproduce the issue. 37 | value: | 38 | Configuration file `ghtkn.yaml` (Please mask client ids): 39 | 40 | ```yaml 41 | ``` 42 | 43 | Environment variables: 44 | 45 | ``` 46 | ``` 47 | 48 | Command: 49 | 50 | ```sh 51 | ``` 52 | validations: 53 | required: true 54 | - type: textarea 55 | id: expected-behaviour 56 | attributes: 57 | label: Expected behaviour 58 | validations: 59 | required: true 60 | - type: textarea 61 | id: actual-behaviour 62 | attributes: 63 | label: Actual behaviour 64 | validations: 65 | required: true 66 | - type: textarea 67 | id: note 68 | attributes: 69 | label: Note 70 | validations: 71 | required: false 72 | -------------------------------------------------------------------------------- /pkg/cli/flag/flag.go: -------------------------------------------------------------------------------- 1 | // Package flag provides common command-line flags for ghtkn CLI. 2 | // It defines reusable flag definitions for consistent flag handling across all commands. 3 | package flag 4 | 5 | import ( 6 | "github.com/urfave/cli/v3" 7 | ) 8 | 9 | // GlobalFlags holds the global flag values for the root command. 10 | type GlobalFlags struct { 11 | LogLevel string 12 | Config string 13 | } 14 | 15 | // LogLevel returns a flag for setting the logging level. 16 | // Supported values are: debug, info, warn, error. 17 | // Can be set via GHTKN_LOG_LEVEL environment variable. 18 | func LogLevel(dest *string) *cli.StringFlag { 19 | return &cli.StringFlag{ 20 | Name: "log-level", 21 | Usage: "Log level (debug, info, warn, error)", 22 | Sources: cli.EnvVars("GHTKN_LOG_LEVEL"), 23 | Destination: dest, 24 | } 25 | } 26 | 27 | // Config returns a flag for specifying the configuration file path. 28 | // Can be set via GHTKN_CONFIG environment variable. 29 | // Alias: -c 30 | func Config(dest *string) *cli.StringFlag { 31 | return &cli.StringFlag{ 32 | Name: "config", 33 | Aliases: []string{"c"}, 34 | Usage: "configuration file path", 35 | Sources: cli.EnvVars("GHTKN_CONFIG"), 36 | Destination: dest, 37 | } 38 | } 39 | 40 | // Format returns a flag for specifying the output format. 41 | // Currently supports: json. 42 | // Can be set via GHTKN_OUTPUT_FORMAT environment variable. 43 | // Alias: -f 44 | func Format(dest *string) *cli.StringFlag { 45 | return &cli.StringFlag{ 46 | Name: "format", 47 | Aliases: []string{"f"}, 48 | Usage: "output format (json)", 49 | Sources: cli.EnvVars("GHTKN_OUTPUT_FORMAT"), 50 | Destination: dest, 51 | } 52 | } 53 | 54 | // MinExpiration returns a flag for specifying the minimum token expiration duration. 55 | // Accepts duration strings like "1h", "30m", "30s". 56 | // Alias: -m 57 | func MinExpiration(dest *string) *cli.StringFlag { 58 | return &cli.StringFlag{ 59 | Name: "min-expiration", 60 | Aliases: []string{"m"}, 61 | Usage: "minimum expiration duration (e.g. 1h, 30m, 30s)", 62 | Sources: cli.EnvVars("GHTKN_MIN_EXPIRATION"), 63 | Destination: dest, 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /pkg/controller/get/controller.go: -------------------------------------------------------------------------------- 1 | // Package get provides functionality to retrieve GitHub App access tokens. 2 | // It serves both the standard 'get' command and the 'git-credential' helper command. 3 | // It handles token retrieval from the keyring cache and token generation/renewal when needed. 4 | package get 5 | 6 | import ( 7 | "context" 8 | "errors" 9 | "io" 10 | "log/slog" 11 | "os" 12 | 13 | "github.com/suzuki-shunsuke/ghtkn-go-sdk/ghtkn" 14 | ) 15 | 16 | // Controller manages the process of retrieving GitHub App access tokens. 17 | // It coordinates between configuration reading, token caching, and token generation. 18 | type Controller struct { 19 | input *Input 20 | } 21 | 22 | // New creates a new Controller instance with the provided input configuration. 23 | func New(input *Input) *Controller { 24 | return &Controller{ 25 | input: input, 26 | } 27 | } 28 | 29 | type Client interface { 30 | Get(ctx context.Context, logger *slog.Logger, input *ghtkn.InputGet) (*ghtkn.AccessToken, *ghtkn.AppConfig, error) 31 | } 32 | 33 | // Input contains all the dependencies and configuration needed by the Controller. 34 | // It encapsulates file system access, configuration reading, token generation, and output handling. 35 | // The IsGitCredential flag determines whether to format output for Git's credential helper protocol. 36 | type Input struct { 37 | OutputFormat string // Output format ("json" or empty for plain text) 38 | Stdout io.Writer // Output writer 39 | IsGitCredential bool // Whether to output in Git credential helper format 40 | Client Client 41 | } 42 | 43 | // NewInput creates a new Input instance with default production values. 44 | // It sets up all necessary dependencies including file system, HTTP client, and keyring access. 45 | func NewInput() *Input { 46 | return &Input{ 47 | Stdout: os.Stdout, 48 | Client: ghtkn.New(), 49 | } 50 | } 51 | 52 | // IsJSON returns true if the output format is set to JSON. 53 | func (i *Input) IsJSON() bool { 54 | return i.OutputFormat == "json" 55 | } 56 | 57 | // Validate checks if the Input configuration is valid. 58 | // It returns an error if the output format is neither empty nor "json". 59 | func (i *Input) Validate() error { 60 | if i.OutputFormat != "" && !i.IsJSON() { 61 | return errors.New("output format must be empty or 'json'") 62 | } 63 | return nil 64 | } 65 | -------------------------------------------------------------------------------- /pkg/cli/initcmd/command.go: -------------------------------------------------------------------------------- 1 | // Package initcmd implements the 'ghtkn init' command. 2 | // This package is responsible for generating ghtkn configuration files (.ghtkn.yaml) 3 | // with default settings to help users quickly set up ghtkn in their repositories. 4 | // It creates configuration templates that define target workflow files and 5 | // action ignore patterns for the pinning process. 6 | package initcmd 7 | 8 | import ( 9 | "context" 10 | "fmt" 11 | 12 | "github.com/spf13/afero" 13 | "github.com/suzuki-shunsuke/ghtkn-go-sdk/ghtkn" 14 | "github.com/suzuki-shunsuke/ghtkn/pkg/cli/flag" 15 | "github.com/suzuki-shunsuke/ghtkn/pkg/controller/initcmd" 16 | "github.com/suzuki-shunsuke/slog-util/slogutil" 17 | "github.com/urfave/cli/v3" 18 | ) 19 | 20 | // Args holds the flag and argument values for the init command. 21 | type Args struct { 22 | *flag.GlobalFlags 23 | 24 | ConfigFilePath string // positional argument 25 | } 26 | 27 | // New creates a new init command instance with the provided logger. 28 | // It returns a CLI command that can be registered with the main CLI application. 29 | func New(logger *slogutil.Logger, gFlags *flag.GlobalFlags) *cli.Command { 30 | args := &Args{ 31 | GlobalFlags: gFlags, 32 | } 33 | return &cli.Command{ 34 | Name: "init", 35 | Usage: "Create ghtkn.yaml if it doesn't exist", 36 | Action: func(ctx context.Context, _ *cli.Command) error { 37 | return action(ctx, logger, args) 38 | }, 39 | Flags: []cli.Flag{ 40 | flag.LogLevel(&args.LogLevel), 41 | flag.Config(&args.Config), 42 | }, 43 | Arguments: []cli.Argument{ 44 | &cli.StringArg{ 45 | Name: "config-file-path", 46 | Destination: &args.ConfigFilePath, 47 | }, 48 | }, 49 | } 50 | } 51 | 52 | func action(_ context.Context, logger *slogutil.Logger, args *Args) error { 53 | if err := logger.SetLevel(args.LogLevel); err != nil { 54 | return fmt.Errorf("set log level: %w", err) 55 | } 56 | 57 | configFilePath := args.ConfigFilePath 58 | if configFilePath == "" { 59 | configFilePath = args.Config 60 | } 61 | if configFilePath == "" { 62 | p, err := ghtkn.GetConfigPath() 63 | if err != nil { 64 | return fmt.Errorf("get the config path: %w", err) 65 | } 66 | configFilePath = p 67 | } 68 | fs := afero.NewOsFs() 69 | ctrl := initcmd.New(fs) 70 | return ctrl.Init(logger.Logger, configFilePath) //nolint:wrapcheck 71 | } 72 | -------------------------------------------------------------------------------- /pkg/controller/get/controller_test.go: -------------------------------------------------------------------------------- 1 | package get_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/suzuki-shunsuke/ghtkn/pkg/controller/get" 7 | ) 8 | 9 | func TestNew(t *testing.T) { 10 | t.Parallel() 11 | 12 | input := &get.Input{} 13 | controller := get.New(input) 14 | if controller == nil { 15 | t.Error("New() returned nil") 16 | } 17 | } 18 | 19 | func TestNewInput(t *testing.T) { 20 | t.Parallel() 21 | 22 | input := get.NewInput() 23 | if input == nil { 24 | t.Error("NewInput() returned nil") 25 | return 26 | } 27 | 28 | if input.Stdout == nil { 29 | t.Error("NewInput().Stdout is nil") 30 | } 31 | } 32 | 33 | func TestInput_IsJSON(t *testing.T) { 34 | t.Parallel() 35 | 36 | tests := []struct { 37 | name string 38 | outputFormat string 39 | want bool 40 | }{ 41 | { 42 | name: "json format", 43 | outputFormat: "json", 44 | want: true, 45 | }, 46 | { 47 | name: "empty format", 48 | outputFormat: "", 49 | want: false, 50 | }, 51 | { 52 | name: "other format", 53 | outputFormat: "yaml", 54 | want: false, 55 | }, 56 | } 57 | 58 | for _, tt := range tests { 59 | t.Run(tt.name, func(t *testing.T) { 60 | t.Parallel() 61 | 62 | input := &get.Input{ 63 | OutputFormat: tt.outputFormat, 64 | } 65 | 66 | got := input.IsJSON() 67 | if got != tt.want { 68 | t.Errorf("IsJSON() = %v, want %v", got, tt.want) 69 | } 70 | }) 71 | } 72 | } 73 | 74 | func TestInput_Validate(t *testing.T) { 75 | t.Parallel() 76 | 77 | tests := []struct { 78 | name string 79 | outputFormat string 80 | wantErr bool 81 | }{ 82 | { 83 | name: "valid json format", 84 | outputFormat: "json", 85 | wantErr: false, 86 | }, 87 | { 88 | name: "valid empty format", 89 | outputFormat: "", 90 | wantErr: false, 91 | }, 92 | { 93 | name: "invalid format", 94 | outputFormat: "yaml", 95 | wantErr: true, 96 | }, 97 | } 98 | 99 | for _, tt := range tests { 100 | t.Run(tt.name, func(t *testing.T) { 101 | t.Parallel() 102 | 103 | input := &get.Input{ 104 | OutputFormat: tt.outputFormat, 105 | } 106 | 107 | err := input.Validate() 108 | if (err != nil) != tt.wantErr { 109 | t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr) 110 | } 111 | }) 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /pkg/cli/get/git_credential.go: -------------------------------------------------------------------------------- 1 | package get 2 | 3 | import ( 4 | "bufio" 5 | "context" 6 | "fmt" 7 | "log/slog" 8 | "strings" 9 | 10 | "github.com/suzuki-shunsuke/ghtkn-go-sdk/ghtkn" 11 | "github.com/suzuki-shunsuke/ghtkn/pkg/controller/get" 12 | ) 13 | 14 | type scanResult struct { 15 | Protocol string 16 | Host string 17 | Username string 18 | Path string 19 | Password string 20 | Owner string 21 | Err error 22 | } 23 | 24 | func (r *runner) handleGitCredential(ctx context.Context, logger *slog.Logger, arg string, input *get.Input, inputGet *ghtkn.InputGet) error { 25 | logger.Debug("running in Git Credential Helper mode", "arg", arg) 26 | input.IsGitCredential = true 27 | if arg != "get" { 28 | return nil 29 | } 30 | result, err := r.readStdinForGitCredentialHelper(ctx, logger) 31 | if err != nil { 32 | return fmt.Errorf("read stdin: %w", err) 33 | } 34 | if result.Owner == "" { 35 | logger.Warn("failed to get the repository owner from stdin for Git Credential Helper") 36 | } 37 | inputGet.AppOwner = result.Owner 38 | return nil 39 | } 40 | 41 | func (r *runner) readStdinForGitCredentialHelper(ctx context.Context, logger *slog.Logger) (*scanResult, error) { //nolint:cyclop 42 | inputCh := make(chan *scanResult, 1) 43 | 44 | go func() { 45 | scanner := bufio.NewScanner(r.stdin) 46 | result := &scanResult{} 47 | for scanner.Scan() { 48 | line := scanner.Text() 49 | if line == "" { 50 | break // empty line means the end of input 51 | } 52 | key, value, ok := strings.Cut(line, "=") 53 | if !ok { 54 | continue // ignore invalid stdin 55 | } 56 | if key != "password" { 57 | logger.Debug("read a parameter from stdin for Git Credential Helper", key, value) 58 | } 59 | switch key { 60 | case "protocol": 61 | result.Protocol = value 62 | case "host": 63 | result.Host = value 64 | case "username": 65 | result.Username = value 66 | case "path": 67 | // path is used to switch GitHub Apps by repository 68 | // But path may not be passed. 69 | // To guarantee the path is passed, you can configure Git like below: 70 | // 71 | // $ git config credential.useHttpPath true 72 | a, _, ok := strings.Cut(value, "/") 73 | if !ok { 74 | logger.Warn("the path from stdin for Git Credential Helper is unexpected", "path", value) 75 | continue 76 | } 77 | result.Path = value 78 | result.Owner = a 79 | case "password": 80 | result.Password = value 81 | default: 82 | continue // ignore unknown keys 83 | } 84 | } 85 | result.Err = scanner.Err() 86 | inputCh <- result 87 | close(inputCh) 88 | }() 89 | 90 | select { 91 | case <-ctx.Done(): 92 | return nil, ctx.Err() //nolint:wrapcheck 93 | case result := <-inputCh: 94 | return result, result.Err 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /pkg/controller/get/get_test.go: -------------------------------------------------------------------------------- 1 | //nolint:forcetypeassert,funlen 2 | package get_test 3 | 4 | import ( 5 | "bytes" 6 | "context" 7 | "errors" 8 | "log/slog" 9 | "testing" 10 | "time" 11 | 12 | "github.com/suzuki-shunsuke/ghtkn-go-sdk/ghtkn" 13 | "github.com/suzuki-shunsuke/ghtkn/pkg/controller/get" 14 | ) 15 | 16 | type mockClient struct { 17 | token *ghtkn.AccessToken 18 | app *ghtkn.AppConfig 19 | err error 20 | } 21 | 22 | func (m *mockClient) Get(_ context.Context, _ *slog.Logger, _ *ghtkn.InputGet) (*ghtkn.AccessToken, *ghtkn.AppConfig, error) { 23 | return m.token, m.app, m.err 24 | } 25 | 26 | func TestController_Run(t *testing.T) { 27 | t.Parallel() 28 | 29 | tests := []struct { 30 | name string 31 | setupInput func() *get.Input 32 | wantErr bool 33 | wantOutput string 34 | checkKeyring bool 35 | }{ 36 | { 37 | name: "successful token creation", 38 | setupInput: func() *get.Input { 39 | return &get.Input{ 40 | OutputFormat: "", 41 | Stdout: &bytes.Buffer{}, 42 | Client: &mockClient{ 43 | token: &ghtkn.AccessToken{ 44 | AccessToken: "test-token-123", 45 | ExpirationDate: time.Date(2024, 12, 31, 23, 59, 59, 0, time.UTC), 46 | }, 47 | app: &ghtkn.AppConfig{ 48 | Name: "test", 49 | }, 50 | }, 51 | } 52 | }, 53 | wantErr: false, 54 | wantOutput: "test-token-123\n", 55 | }, 56 | { 57 | name: "token creation error", 58 | setupInput: func() *get.Input { 59 | return &get.Input{ 60 | OutputFormat: "", 61 | Stdout: &bytes.Buffer{}, 62 | Client: &mockClient{ 63 | err: errors.New("failed to create token"), 64 | }, 65 | } 66 | }, 67 | wantErr: true, 68 | }, 69 | { 70 | name: "JSON output format", 71 | setupInput: func() *get.Input { 72 | return &get.Input{ 73 | OutputFormat: "json", 74 | Stdout: &bytes.Buffer{}, 75 | Client: &mockClient{ 76 | token: &ghtkn.AccessToken{ 77 | AccessToken: "test-token-123", 78 | ExpirationDate: time.Date(2024, 12, 31, 23, 59, 59, 0, time.UTC), 79 | }, 80 | app: &ghtkn.AppConfig{ 81 | Name: "test", 82 | }, 83 | }, 84 | } 85 | }, 86 | wantErr: false, 87 | }, 88 | } 89 | 90 | for _, tt := range tests { 91 | t.Run(tt.name, func(t *testing.T) { 92 | t.Parallel() 93 | 94 | input := tt.setupInput() 95 | controller := get.New(input) 96 | ctx := t.Context() 97 | logger := slog.New(slog.NewTextHandler(bytes.NewBuffer(nil), nil)) 98 | 99 | err := controller.Run(ctx, logger, &ghtkn.InputGet{}) 100 | if (err != nil) != tt.wantErr { 101 | t.Errorf("Run() error = %v, wantErr %v", err, tt.wantErr) 102 | return 103 | } 104 | 105 | if !tt.wantErr && input.OutputFormat != "json" { 106 | output := input.Stdout.(*bytes.Buffer).String() 107 | if output != tt.wantOutput { 108 | t.Errorf("Run() output = %v, want %v", output, tt.wantOutput) 109 | } 110 | } 111 | }) 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /INSTALL.md: -------------------------------------------------------------------------------- 1 | # Install 2 | 3 | ghtkn is written in Go. So you only have to install a binary in your `PATH`. 4 | 5 | There are some ways to install ghtkn. 6 | 7 | 1. [Homebrew](#homebrew) 8 | 1. [Scoop](#scoop) 9 | 1. [aqua](#aqua) 10 | 1. [GitHub Releases](#github-releases) 11 | 1. [Build an executable binary from source code yourself using Go](#build-an-executable-binary-from-source-code-yourself-using-go) 12 | 13 | ## Homebrew 14 | 15 | You can install ghtkn using [Homebrew](https://brew.sh/). 16 | 17 | ```sh 18 | brew install suzuki-shunsuke/ghtkn/ghtkn --cask 19 | ``` 20 | 21 | ## Scoop 22 | 23 | You can install ghtkn using [Scoop](https://scoop.sh/). 24 | 25 | ```sh 26 | scoop bucket add suzuki-shunsuke https://github.com/suzuki-shunsuke/scoop-bucket 27 | scoop install ghtkn 28 | ``` 29 | 30 | ## aqua 31 | 32 | [aqua-registry >= v4.407.0 is required](https://github.com/aquaproj/aqua-registry/releases/tag/v4.407.0). 33 | 34 | You can install ghtkn using [aqua](https://aquaproj.github.io/). 35 | 36 | ```sh 37 | aqua g -i suzuki-shunsuke/ghtkn 38 | ``` 39 | 40 | ## Build an executable binary from source code yourself using Go 41 | 42 | ```sh 43 | go install github.com/suzuki-shunsuke/ghtkn/cmd/ghtkn@latest 44 | ``` 45 | 46 | ## GitHub Releases 47 | 48 | You can download an asset from [GitHub Releases](https://github.com/suzuki-shunsuke/ghtkn/releases). 49 | Please unarchive it and install a pre built binary into `$PATH`. 50 | 51 | ### Verify downloaded assets from GitHub Releases 52 | 53 | You can verify downloaded assets using some tools. 54 | 55 | 1. [GitHub CLI](https://cli.github.com/) 56 | 1. [slsa-verifier](https://github.com/slsa-framework/slsa-verifier) 57 | 1. [Cosign](https://github.com/sigstore/cosign) 58 | 59 | ### 1. GitHub CLI 60 | 61 | You can install GitHub CLI by aqua. 62 | 63 | ```sh 64 | aqua g -i cli/cli 65 | ``` 66 | 67 | ```sh 68 | version=v0.1.0 69 | asset=ghtkn_darwin_arm64.tar.gz 70 | gh release download -R suzuki-shunsuke/ghtkn "$version" -p "$asset" 71 | gh attestation verify "$asset" \ 72 | -R suzuki-shunsuke/ghtkn \ 73 | --signer-workflow suzuki-shunsuke/go-release-workflow/.github/workflows/release.yaml 74 | ``` 75 | 76 | ### 2. slsa-verifier 77 | 78 | You can install slsa-verifier by aqua. 79 | 80 | ```sh 81 | aqua g -i slsa-framework/slsa-verifier 82 | ``` 83 | 84 | ```sh 85 | version=v0.1.0 86 | asset=ghtkn_darwin_arm64.tar.gz 87 | gh release download -R suzuki-shunsuke/ghtkn "$version" -p "$asset" -p multiple.intoto.jsonl 88 | slsa-verifier verify-artifact "$asset" \ 89 | --provenance-path multiple.intoto.jsonl \ 90 | --source-uri github.com/suzuki-shunsuke/ghtkn \ 91 | --source-tag "$version" 92 | ``` 93 | 94 | ### 3. Cosign 95 | 96 | You can install Cosign by aqua. 97 | 98 | ```sh 99 | aqua g -i sigstore/cosign 100 | ``` 101 | 102 | ```sh 103 | version=v0.1.0 104 | checksum_file="ghtkn_checksums.txt" 105 | asset=ghtkn_darwin_arm64.tar.gz 106 | gh release download "$version" \ 107 | -R suzuki-shunsuke/ghtkn \ 108 | -p "$asset" \ 109 | -p "$checksum_file" \ 110 | -p "${checksum_file}.pem" \ 111 | -p "${checksum_file}.sig" 112 | cosign verify-blob \ 113 | --signature "${checksum_file}.sig" \ 114 | --certificate "${checksum_file}.pem" \ 115 | --certificate-identity-regexp 'https://github\.com/suzuki-shunsuke/go-release-workflow/\.github/workflows/release\.yaml@.*' \ 116 | --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \ 117 | "$checksum_file" 118 | cat "$checksum_file" | sha256sum -c --ignore-missing - 119 | ``` 120 | -------------------------------------------------------------------------------- /USAGE.md: -------------------------------------------------------------------------------- 1 | # Usage 2 | 3 | 4 | 5 | ```console 6 | $ ghtkn --help 7 | NAME: 8 | ghtkn - Create GitHub App User Access Tokens for secure local development. https://github.com/suzuki-shunsuke/ghtkn 9 | 10 | USAGE: 11 | ghtkn [global options] [command [command options]] 12 | 13 | VERSION: 14 | 0.2.4 15 | 16 | COMMANDS: 17 | init Create ghtkn.yaml if it doesn't exist 18 | git-credential Git Credential Helper 19 | get Output a GitHub App User Access Token to stdout 20 | version Show version 21 | help, h Shows a list of commands or help for one command 22 | completion Output shell completion script for bash, zsh, fish, or Powershell 23 | 24 | GLOBAL OPTIONS: 25 | --log-level string Log level (debug, info, warn, error) [$GHTKN_LOG_LEVEL] 26 | --config string, -c string configuration file path [$GHTKN_CONFIG] 27 | --help, -h show help 28 | --version, -v print the version 29 | ``` 30 | 31 | ## ghtkn init 32 | 33 | ```console 34 | $ ghtkn init --help 35 | NAME: 36 | ghtkn init - Create ghtkn.yaml if it doesn't exist 37 | 38 | USAGE: 39 | ghtkn init [arguments...] 40 | 41 | OPTIONS: 42 | --log-level string Log level (debug, info, warn, error) [$GHTKN_LOG_LEVEL] 43 | --config string, -c string configuration file path [$GHTKN_CONFIG] 44 | --help, -h show help 45 | ``` 46 | 47 | ## ghtkn git-credential 48 | 49 | ```console 50 | $ ghtkn git-credential --help 51 | NAME: 52 | ghtkn git-credential - Git Credential Helper 53 | 54 | USAGE: 55 | ghtkn git-credential [arguments...] 56 | 57 | OPTIONS: 58 | --log-level string Log level (debug, info, warn, error) [$GHTKN_LOG_LEVEL] 59 | --config string, -c string configuration file path [$GHTKN_CONFIG] 60 | --min-expiration string, -m string minimum expiration duration (e.g. 1h, 30m, 30s) [$GHTKN_MIN_EXPIRATION] 61 | --help, -h show help 62 | ``` 63 | 64 | ## ghtkn get 65 | 66 | ```console 67 | $ ghtkn get --help 68 | NAME: 69 | ghtkn get - Output a GitHub App User Access Token to stdout 70 | 71 | USAGE: 72 | ghtkn get [arguments...] 73 | 74 | OPTIONS: 75 | --log-level string Log level (debug, info, warn, error) [$GHTKN_LOG_LEVEL] 76 | --config string, -c string configuration file path [$GHTKN_CONFIG] 77 | --format string, -f string output format (json) [$GHTKN_OUTPUT_FORMAT] 78 | --min-expiration string, -m string minimum expiration duration (e.g. 1h, 30m, 30s) [$GHTKN_MIN_EXPIRATION] 79 | --help, -h show help 80 | ``` 81 | 82 | ## ghtkn version 83 | 84 | ```console 85 | $ ghtkn version --help 86 | NAME: 87 | ghtkn version - Show version 88 | 89 | USAGE: 90 | ghtkn version 91 | 92 | OPTIONS: 93 | --json, -j Output version in JSON format 94 | --help, -h show help 95 | ``` 96 | 97 | ## ghtkn completion 98 | 99 | ```console 100 | $ ghtkn completion --help 101 | NAME: 102 | ghtkn completion - Output shell completion script for bash, zsh, fish, or Powershell 103 | 104 | USAGE: 105 | ghtkn completion 106 | 107 | DESCRIPTION: 108 | Output shell completion script for bash, zsh, fish, or Powershell. 109 | Source the output to enable completion. 110 | 111 | # .bashrc 112 | source <(ghtkn completion bash) 113 | 114 | # .zshrc 115 | source <(ghtkn completion zsh) 116 | 117 | # fish 118 | ghtkn completion fish > ~/.config/fish/completions/ghtkn.fish 119 | 120 | # Powershell 121 | Output the script to path/to/autocomplete/ghtkn.ps1 an run it. 122 | 123 | 124 | OPTIONS: 125 | --help, -h show help 126 | ``` 127 | -------------------------------------------------------------------------------- /pkg/controller/get/output_internal_test.go: -------------------------------------------------------------------------------- 1 | //nolint:funlen,gocognit,gocritic,nestif 2 | package get 3 | 4 | import ( 5 | "bytes" 6 | "encoding/json" 7 | "strings" 8 | "testing" 9 | "time" 10 | 11 | "github.com/suzuki-shunsuke/ghtkn-go-sdk/ghtkn" 12 | ) 13 | 14 | func TestController_output(t *testing.T) { 15 | t.Parallel() 16 | 17 | tests := []struct { 18 | name string 19 | token *ghtkn.AccessToken 20 | outputFormat string 21 | isGitCredential bool 22 | wantOutput string 23 | wantErr bool 24 | }{ 25 | { 26 | name: "plain text output", 27 | token: &ghtkn.AccessToken{ 28 | AccessToken: "test-token-123", 29 | ExpirationDate: time.Date(2024, 12, 31, 23, 59, 59, 0, time.UTC), 30 | }, 31 | outputFormat: "", 32 | isGitCredential: false, 33 | wantOutput: "test-token-123\n", 34 | wantErr: false, 35 | }, 36 | { 37 | name: "JSON output", 38 | token: &ghtkn.AccessToken{ 39 | AccessToken: "test-token-json", 40 | ExpirationDate: time.Date(2024, 12, 31, 23, 59, 59, 0, time.UTC), 41 | }, 42 | outputFormat: "json", 43 | isGitCredential: false, 44 | wantOutput: "", 45 | wantErr: false, 46 | }, 47 | { 48 | name: "Git credential helper output", 49 | token: &ghtkn.AccessToken{ 50 | AccessToken: "test-token-git", 51 | ExpirationDate: time.Time{}, 52 | Login: "testuser", 53 | }, 54 | outputFormat: "", 55 | isGitCredential: true, 56 | wantOutput: "username=testuser\npassword=test-token-git\n\n", 57 | wantErr: false, 58 | }, 59 | } 60 | 61 | for _, tt := range tests { 62 | t.Run(tt.name, func(t *testing.T) { 63 | t.Parallel() 64 | 65 | buf := &bytes.Buffer{} 66 | input := &Input{ 67 | OutputFormat: tt.outputFormat, 68 | IsGitCredential: tt.isGitCredential, 69 | Stdout: buf, 70 | } 71 | controller := &Controller{input: input} 72 | 73 | err := controller.output("", tt.token) 74 | if (err != nil) != tt.wantErr { 75 | t.Errorf("output() error = %v, wantErr %v", err, tt.wantErr) 76 | return 77 | } 78 | 79 | if !tt.wantErr { 80 | output := buf.String() 81 | if tt.outputFormat == "json" { 82 | // Verify it's valid JSON and contains expected fields 83 | var result map[string]any 84 | if err := json.Unmarshal(buf.Bytes(), &result); err != nil { 85 | t.Errorf("output() produced invalid JSON: %v", err) 86 | } 87 | if result["access_token"] != tt.token.AccessToken { 88 | t.Errorf("JSON output missing or incorrect access_token") 89 | } 90 | if result["expiration_date"] != tt.token.ExpirationDate.Format(time.RFC3339) { 91 | t.Errorf("expiration_date: wanted %s, got %s", tt.token.ExpirationDate.Format(time.RFC3339), result["expiration_date"]) 92 | } 93 | } else { 94 | if output != tt.wantOutput { 95 | t.Errorf("output() = %v, want %v", output, tt.wantOutput) 96 | } 97 | } 98 | } 99 | }) 100 | } 101 | } 102 | 103 | func TestController_outputJSON(t *testing.T) { 104 | t.Parallel() 105 | 106 | tests := []struct { 107 | name string 108 | data any 109 | wantErr bool 110 | }{ 111 | { 112 | name: "valid data", 113 | data: map[string]string{ 114 | "key1": "value1", 115 | "key2": "value2", 116 | }, 117 | wantErr: false, 118 | }, 119 | { 120 | name: "access token", 121 | data: &ghtkn.AccessToken{ 122 | AccessToken: "test-token", 123 | ExpirationDate: time.Date(2024, 12, 31, 23, 59, 59, 0, time.UTC), 124 | }, 125 | wantErr: false, 126 | }, 127 | { 128 | name: "nil data", 129 | data: nil, 130 | wantErr: false, 131 | }, 132 | } 133 | 134 | for _, tt := range tests { 135 | t.Run(tt.name, func(t *testing.T) { 136 | t.Parallel() 137 | 138 | buf := &bytes.Buffer{} 139 | input := &Input{ 140 | Stdout: buf, 141 | } 142 | controller := &Controller{input: input} 143 | 144 | err := controller.outputJSON(tt.data) 145 | if (err != nil) != tt.wantErr { 146 | t.Errorf("outputJSON() error = %v, wantErr %v", err, tt.wantErr) 147 | return 148 | } 149 | 150 | if !tt.wantErr { 151 | output := buf.String() 152 | // Verify it's valid JSON 153 | if !strings.HasPrefix(output, "{") && !strings.HasPrefix(output, "null") { 154 | t.Errorf("outputJSON() produced invalid JSON output") 155 | } 156 | // Verify indentation 157 | if tt.data != nil && !strings.Contains(output, "\n") { 158 | t.Error("outputJSON() should produce indented JSON") 159 | } 160 | } 161 | }) 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | al.essio.dev/pkg/shellescape v1.5.1 h1:86HrALUujYS/h+GtqoB26SBEdkWfmMI6FubjXlsXyho= 2 | al.essio.dev/pkg/shellescape v1.5.1/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890= 3 | github.com/danieljoos/wincred v1.2.2 h1:774zMFJrqaeYCK2W57BgAem/MLi6mtSE47MB6BOJ0i0= 4 | github.com/danieljoos/wincred v1.2.2/go.mod h1:w7w4Utbrz8lqeMbDAK0lkNJUv5sAOkFi7nd/ogr0Uh8= 5 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 6 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 7 | github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= 8 | github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 9 | github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 10 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 11 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 12 | github.com/google/go-github/v80 v80.0.0 h1:BTyk3QOHekrk5VF+jIGz1TNEsmeoQG9K/UWaaP+EWQs= 13 | github.com/google/go-github/v80 v80.0.0/go.mod h1:pRo4AIMdHW83HNMGfNysgSAv0vmu+/pkY8nZO9FT9Yo= 14 | github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= 15 | github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= 16 | github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= 17 | github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= 18 | github.com/lmittmann/tint v1.1.2 h1:2CQzrL6rslrsyjqLDwD11bZ5OpLBPU+g3G/r5LSfS8w= 19 | github.com/lmittmann/tint v1.1.2/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE= 20 | github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= 21 | github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= 22 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 23 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 24 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 25 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 26 | github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= 27 | github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= 28 | github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= 29 | github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= 30 | github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= 31 | github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 32 | github.com/suzuki-shunsuke/ghtkn-go-sdk v0.2.2 h1:rgGrzb4VDfGSFCXecxKbzJ2PxcJyplfKIu8wkyWGZ1U= 33 | github.com/suzuki-shunsuke/ghtkn-go-sdk v0.2.2/go.mod h1:RqXFhArJSKR/D+42ptl9pQFQ5ikIexxB7AxiFB1gOOo= 34 | github.com/suzuki-shunsuke/go-error-with-exit-code v1.0.0 h1:oVXrrYNGBq4POyITQNWKzwsYz7B2nUcqtDbeX4BfeEc= 35 | github.com/suzuki-shunsuke/go-error-with-exit-code v1.0.0/go.mod h1:kDFtLeftDiIUUHXGI3xq5eJ+uAOi50FPrxPENTHktJ0= 36 | github.com/suzuki-shunsuke/go-exec v0.0.1 h1:xn/lvYnRQOujUd46ph6f6IT0gVJIC8+3liSZKOjNj44= 37 | github.com/suzuki-shunsuke/go-exec v0.0.1/go.mod h1:KstSwIiQTKY34wEurUcFyKkaJDogBr5E3xxfdkkzvb0= 38 | github.com/suzuki-shunsuke/slog-error v0.2.1 h1:zcWOEo451RWmgusiONt/GueyvkTL7n4qA0ZJ3gTEjbA= 39 | github.com/suzuki-shunsuke/slog-error v0.2.1/go.mod h1:w45QyO2G0uiEuo9hhrcLqqRl3hmYon9jGgq9CrCxxOY= 40 | github.com/suzuki-shunsuke/slog-util v0.3.0 h1:s+Go2yZqBnJCyV4kj1MDJEITfS7ELdDAEKk/aCulBkQ= 41 | github.com/suzuki-shunsuke/slog-util v0.3.0/go.mod h1:PgZMd+2rC8pA9jBbXDfkI8mTuWYAiaVkKxjrbLtfN5I= 42 | github.com/suzuki-shunsuke/urfave-cli-v3-util v0.2.0 h1:ORT/qQxsKuWwuy2N/z2f2hmbKWmlS346/j4jGhxsxLo= 43 | github.com/suzuki-shunsuke/urfave-cli-v3-util v0.2.0/go.mod h1:BYtzUgA4oeUVUFoJIONWOquvIUy0cl7DpAeCya3mVJU= 44 | github.com/urfave/cli/v3 v3.6.1 h1:j8Qq8NyUawj/7rTYdBGrxcH7A/j7/G8Q5LhWEW4G3Mo= 45 | github.com/urfave/cli/v3 v3.6.1/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso= 46 | github.com/zalando/go-keyring v0.2.6 h1:r7Yc3+H+Ux0+M72zacZoItR3UDxeWfKTcabvkI8ua9s= 47 | github.com/zalando/go-keyring v0.2.6/go.mod h1:2TCrxYrbUNYfNS/Kgy/LSrkSQzZ5UPVH85RwfczwvcI= 48 | golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= 49 | golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= 50 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 51 | golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= 52 | golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 53 | golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= 54 | golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= 55 | golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= 56 | golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= 57 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 58 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 59 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 60 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 61 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 62 | -------------------------------------------------------------------------------- /pkg/cli/get/command.go: -------------------------------------------------------------------------------- 1 | // Package get implements both the 'ghtkn get' command and 'ghtkn git-credential' command. 2 | // These commands retrieve or create GitHub App User Access Tokens and output them to stdout. 3 | // The 'get' command outputs tokens in plain text or JSON format for general use. 4 | // The 'git-credential' command outputs tokens in Git's credential helper format for seamless Git authentication. 5 | // Both commands handle token persistence, expiration checking, and automatic renewal when needed. 6 | package get 7 | 8 | import ( 9 | "context" 10 | "fmt" 11 | "io" 12 | "time" 13 | 14 | "github.com/suzuki-shunsuke/ghtkn-go-sdk/ghtkn" 15 | "github.com/suzuki-shunsuke/ghtkn/pkg/cli/flag" 16 | "github.com/suzuki-shunsuke/ghtkn/pkg/controller/get" 17 | "github.com/suzuki-shunsuke/slog-error/slogerr" 18 | "github.com/suzuki-shunsuke/slog-util/slogutil" 19 | "github.com/suzuki-shunsuke/urfave-cli-v3-util/urfave" 20 | "github.com/urfave/cli/v3" 21 | ) 22 | 23 | // Args holds the flag and argument values for the get and git-credential commands. 24 | type Args struct { 25 | *flag.GlobalFlags 26 | 27 | Format string 28 | MinExpiration string 29 | AppName string // positional argument for 'get' command 30 | SubCommand string // positional argument for 'git-credential' command (e.g., "get") 31 | } 32 | 33 | // New creates either a 'get' or 'git-credential' command instance based on the isGitCredential flag. 34 | // When isGitCredential is true, it creates a Git credential helper command. 35 | // When false, it creates a standard get command for general token retrieval. 36 | // It returns a CLI command that can be registered with the main CLI application. 37 | func New(logger *slogutil.Logger, env *urfave.Env, isGitCredential bool, gFlags *flag.GlobalFlags) *cli.Command { 38 | args := &Args{ 39 | GlobalFlags: gFlags, 40 | } 41 | r := &runner{ 42 | isGitCredential: isGitCredential, 43 | stdin: env.Stdin, 44 | } 45 | return r.Command(logger, args) 46 | } 47 | 48 | // runner encapsulates the state and behavior for both the get and git-credential commands. 49 | type runner struct { 50 | isGitCredential bool 51 | stdin io.Reader 52 | } 53 | 54 | // Command returns the CLI command definition for either the get or git-credential subcommand. 55 | // For git-credential, it creates a command compatible with Git's credential helper protocol. 56 | // For get, it creates a standard command with output format options. 57 | // It defines the command name, usage, action handler, and available flags. 58 | func (r *runner) Command(logger *slogutil.Logger, args *Args) *cli.Command { 59 | if r.isGitCredential { 60 | return &cli.Command{ 61 | Name: "git-credential", 62 | Usage: "Git Credential Helper", 63 | Action: func(ctx context.Context, _ *cli.Command) error { 64 | return r.action(ctx, logger, args) 65 | }, 66 | Flags: []cli.Flag{ 67 | flag.LogLevel(&args.LogLevel), 68 | flag.Config(&args.Config), 69 | flag.MinExpiration(&args.MinExpiration), 70 | }, 71 | Arguments: []cli.Argument{ 72 | &cli.StringArg{ 73 | Name: "subcommand", 74 | Destination: &args.SubCommand, 75 | }, 76 | }, 77 | } 78 | } 79 | return &cli.Command{ 80 | Name: "get", 81 | Usage: "Output a GitHub App User Access Token to stdout", 82 | Action: func(ctx context.Context, _ *cli.Command) error { 83 | return r.action(ctx, logger, args) 84 | }, 85 | Flags: []cli.Flag{ 86 | flag.LogLevel(&args.LogLevel), 87 | flag.Config(&args.Config), 88 | flag.Format(&args.Format), 89 | flag.MinExpiration(&args.MinExpiration), 90 | }, 91 | Arguments: []cli.Argument{ 92 | &cli.StringArg{ 93 | Name: "app-name", 94 | Destination: &args.AppName, 95 | }, 96 | }, 97 | } 98 | } 99 | 100 | // action implements the main logic for both the get and git-credential commands. 101 | // For git-credential, it follows Git's credential helper protocol and only processes 'get' operations. 102 | // For get command, it supports different output formats (plain text or JSON). 103 | // It configures the controller with flags and arguments, then executes the token retrieval. 104 | // Returns an error if configuration is invalid or token retrieval fails. 105 | func (r *runner) action(ctx context.Context, logger *slogutil.Logger, args *Args) error { 106 | if err := logger.SetLevel(args.LogLevel); err != nil { 107 | return fmt.Errorf("set log level: %w", err) 108 | } 109 | inputGet := &ghtkn.InputGet{} 110 | if args.MinExpiration != "" { 111 | d, err := time.ParseDuration(args.MinExpiration) 112 | if err != nil { 113 | return fmt.Errorf("parse the min expiration: %w", slogerr.With(err, "min_expiration", args.MinExpiration)) 114 | } 115 | inputGet.MinExpiration = d 116 | } 117 | inputGet.ConfigFilePath = args.Config 118 | 119 | input := get.NewInput() 120 | if r.isGitCredential { 121 | if err := r.handleGitCredential(ctx, logger.Logger, args.SubCommand, input, inputGet); err != nil { 122 | return err 123 | } 124 | } else { 125 | input.OutputFormat = args.Format 126 | if args.AppName != "" { 127 | inputGet.AppName = args.AppName 128 | } 129 | } 130 | if inputGet.ConfigFilePath == "" { 131 | p, err := ghtkn.GetConfigPath() 132 | if err != nil { 133 | return fmt.Errorf("get the config path: %w", err) 134 | } 135 | inputGet.ConfigFilePath = p 136 | } 137 | if err := input.Validate(); err != nil { 138 | return err //nolint:wrapcheck 139 | } 140 | return get.New(input).Run(ctx, logger.Logger, inputGet) //nolint:wrapcheck 141 | } 142 | -------------------------------------------------------------------------------- /AGENTS.md: -------------------------------------------------------------------------------- 1 | # AI Assistant Guidelines for ghtkn 2 | 3 | This document contains common guidelines for AI assistants working on the ghtkn project. 4 | Individual AI-specific documents (like CLAUDE.md, CLINE.md) should reference this guide. 5 | 6 | ## Language 7 | 8 | This project uses **English** for all code comments, documentation, and communication. 9 | 10 | ## Commit Messages 11 | 12 | Follow [Conventional Commits](https://www.conventionalcommits.org/) specification: 13 | 14 | ### Format 15 | 16 | ``` 17 | [optional scope]: 18 | 19 | [optional body] 20 | 21 | [optional footer(s)] 22 | ``` 23 | 24 | ### Common Types 25 | 26 | - `feat`: A new feature 27 | - `fix`: A bug fix 28 | - `docs`: Documentation only changes 29 | - `style`: Changes that do not affect the meaning of the code 30 | - `refactor`: A code change that neither fixes a bug nor adds a feature 31 | - `test`: Adding missing tests or correcting existing tests 32 | - `chore`: Changes to the build process or auxiliary tools 33 | - `ci`: Changes to CI configuration files and scripts 34 | 35 | ### Examples 36 | 37 | ``` 38 | feat: add GitHub token management via keyring 39 | fix: handle empty configuration file correctly 40 | docs: add function documentation to controller package 41 | chore(deps): update dependency aquaproj/aqua-registry to v4.403.0 42 | ``` 43 | 44 | ## Code Validation 45 | 46 | After making code changes, **always run** the following commands to validate and test: 47 | 48 | ### Validation (go vet) 49 | 50 | ```bash 51 | cmdx v 52 | ``` 53 | This command runs `go vet ./...` to check for common Go mistakes. 54 | 55 | ### Testing 56 | 57 | ```bash 58 | cmdx t 59 | ``` 60 | This command runs all tests in the project. 61 | 62 | Both commands should pass before committing changes. 63 | 64 | ## Project Structure 65 | 66 | ``` 67 | ghtkn/ 68 | ├── cmd/ # Main applications 69 | ├── pkg/ # Go packages 70 | │ ├── cli/ # CLI interface layer 71 | │ ├── config/ # Configuration management 72 | │ └── controller/ # Utility functions 73 | ├── testdata/ # Test fixtures 74 | ├── json-schema/ # JSON schema definitions 75 | └── scripts/ # Build and utility scripts 76 | ``` 77 | 78 | ## Package Responsibilities 79 | 80 | ### pkg/cli 81 | Command-line interface layer that handles command parsing, flag processing, and routing to appropriate subcommands. 82 | 83 | ### pkg/config 84 | Configuration management including reading, parsing, and validating .ghtkn.yaml files. 85 | 86 | ### pkg/controller 87 | Business logic layer containing: 88 | 89 | ## Testing 90 | 91 | ### Test Framework Guidelines 92 | 93 | - **DO NOT** use `testify` for writing tests 94 | - **DO** use `google/go-cmp` for comparing expected and actual values 95 | - Use standard Go testing package (`testing`) for all tests 96 | 97 | ### Running Tests 98 | 99 | - Run all tests: `cmdx t` or `go test ./...` 100 | - Run specific package tests: `go test ./pkg/controller/initcmd` 101 | - Generate coverage: `./scripts/coverage.sh` 102 | 103 | ## Dependencies 104 | 105 | This project uses: 106 | 107 | - [aqua](https://aquaproj.github.io/) for tool version management 108 | - [cmdx](https://github.com/suzuki-shunsuke/cmdx) for task runner 109 | - [goreleaser](https://goreleaser.com/) for releases 110 | 111 | ## Code Style Guidelines 112 | 113 | 1. Follow standard Go conventions 114 | 2. Use meaningful variable and function names 115 | 3. Add comments for exported functions and types 116 | 4. Keep functions focused and small 117 | 5. Handle errors explicitly 118 | 6. Use context for cancellation and timeouts 119 | 7. Always end files with a newline character 120 | 121 | ## Pull Request Process 122 | 123 | 1. Create a feature branch from `main` 124 | 2. Make changes and ensure `cmdx v` and `cmdx t` pass 125 | 3. Write clear commit messages following Conventional Commits 126 | 4. Create PR with descriptive title and body 127 | 5. Wait for CI checks to pass 128 | 6. Request review if needed 129 | 130 | ## Important Commands 131 | 132 | ```bash 133 | # Validate code (go vet) 134 | cmdx v 135 | 136 | # Run tests 137 | cmdx t 138 | 139 | # Build the project 140 | go build ./cmd/ghtkn 141 | 142 | # Generate JSON schema 143 | cmdx js 144 | 145 | # Run ghtkn locally 146 | go run ./cmd/ghtkn run 147 | ``` 148 | 149 | ## GitHub Actions Integration 150 | 151 | The project includes GitHub Actions for: 152 | 153 | - Testing on multiple platforms 154 | - Linting and validation 155 | - Release automation 156 | - Security scanning 157 | 158 | ## Configuration 159 | 160 | ## Environment Variables 161 | 162 | ## Debugging 163 | 164 | Enable debug logging: 165 | 166 | ```bash 167 | export GHTKN_LOG_LEVEL=debug 168 | ``` 169 | 170 | ## Common Tasks 171 | 172 | ### Adding a New Command 173 | 174 | 1. Create new package under `pkg/cli/` 175 | 2. Implement command structure with `urfave/cli/v3` 176 | 3. Add controller logic under `pkg/controller/` 177 | 4. Register command in `pkg/cli/runner.go` 178 | 5. Add tests for new functionality 179 | 180 | ## File Naming Conventions 181 | 182 | - Go source files: lowercase with underscores (e.g., `parse_line.go`) 183 | - Test files: append `_test.go` to the source file name 184 | - Internal test files: append `_internal_test.go` for internal testing 185 | 186 | ## Error Handling 187 | 188 | - Always check and handle errors explicitly 189 | - Use `fmt.Errorf` with `%w` for wrapping errors 190 | - Add context to errors to aid debugging 191 | - Use structured logging with slog 192 | 193 | ## Documentation 194 | 195 | - Add package-level documentation comments 196 | - Document all exported functions, types, and constants 197 | - Use examples in documentation where helpful 198 | - Keep README and other docs up to date 199 | 200 | ## Resources 201 | 202 | - [Project README](README.md) 203 | - [Contributing Guidelines](CONTRIBUTING.md) 204 | - [Installation Guide](INSTALL.md) 205 | - [Usage Documentation](USAGE.md) 206 | -------------------------------------------------------------------------------- /pkg/controller/initcmd/init_test.go: -------------------------------------------------------------------------------- 1 | //nolint:wrapcheck 2 | package initcmd_test 3 | 4 | import ( 5 | "bytes" 6 | "log/slog" 7 | "os" 8 | "path/filepath" 9 | "strings" 10 | "testing" 11 | "time" 12 | 13 | "github.com/spf13/afero" 14 | "github.com/suzuki-shunsuke/ghtkn/pkg/controller/initcmd" 15 | ) 16 | 17 | func TestController_Init(t *testing.T) { //nolint:gocognit,cyclop,funlen 18 | t.Parallel() 19 | tests := []struct { 20 | name string 21 | configFilePath string 22 | setupFS func(fs afero.Fs) 23 | wantErr bool 24 | errContains string 25 | checkFile bool 26 | wantLogContains string 27 | }{ 28 | { 29 | name: "create new config file", 30 | configFilePath: "/home/user/.config/ghtkn/ghtkn.yaml", 31 | setupFS: func(_ afero.Fs) {}, 32 | wantErr: false, 33 | checkFile: true, 34 | wantLogContains: "The configuration file has been created", 35 | }, 36 | { 37 | name: "config file already exists", 38 | configFilePath: "/home/user/.config/ghtkn/ghtkn.yaml", 39 | setupFS: func(fs afero.Fs) { 40 | _ = fs.MkdirAll("/home/user/.config/ghtkn", 0o755) 41 | _ = afero.WriteFile(fs, "/home/user/.config/ghtkn/ghtkn.yaml", []byte("existing content"), 0o644) 42 | }, 43 | wantErr: false, 44 | checkFile: false, 45 | wantLogContains: "The configuration file already exists", 46 | }, 47 | { 48 | name: "create config in nested directory", 49 | configFilePath: "/home/user/.config/ghtkn/subdir/ghtkn.yaml", 50 | setupFS: func(_ afero.Fs) {}, 51 | wantErr: false, 52 | checkFile: true, 53 | wantLogContains: "The configuration file has been created", 54 | }, 55 | { 56 | name: "create config in current directory", 57 | configFilePath: "ghtkn.yaml", 58 | setupFS: func(_ afero.Fs) {}, 59 | wantErr: false, 60 | checkFile: true, 61 | wantLogContains: "The configuration file has been created", 62 | }, 63 | { 64 | name: "create config with absolute path", 65 | configFilePath: "/etc/ghtkn/ghtkn.yaml", 66 | setupFS: func(_ afero.Fs) {}, 67 | wantErr: false, 68 | checkFile: true, 69 | wantLogContains: "The configuration file has been created", 70 | }, 71 | { 72 | name: "directory exists but file doesn't", 73 | configFilePath: "/home/user/.config/ghtkn/ghtkn.yaml", 74 | setupFS: func(fs afero.Fs) { 75 | _ = fs.MkdirAll("/home/user/.config/ghtkn", 0o755) 76 | }, 77 | wantErr: false, 78 | checkFile: true, 79 | wantLogContains: "The configuration file has been created", 80 | }, 81 | } 82 | 83 | for _, tt := range tests { 84 | t.Run(tt.name, func(t *testing.T) { 85 | t.Parallel() 86 | 87 | // Setup filesystem 88 | fs := afero.NewMemMapFs() 89 | if tt.setupFS != nil { 90 | tt.setupFS(fs) 91 | } 92 | 93 | // Setup logger with buffer to capture logs 94 | var buf bytes.Buffer 95 | logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{ 96 | Level: slog.LevelDebug, 97 | })) 98 | 99 | // Create controller 100 | ctrl := initcmd.New(fs) 101 | 102 | // Execute Init 103 | err := ctrl.Init(logger, tt.configFilePath) 104 | 105 | // Check error 106 | if tt.wantErr { 107 | if err == nil { 108 | t.Fatal("expected error but got nil") 109 | } 110 | if tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) { 111 | t.Errorf("error = %v, want error containing %v", err, tt.errContains) 112 | } 113 | return 114 | } 115 | if err != nil { 116 | t.Fatalf("unexpected error: %v", err) 117 | } 118 | 119 | // Check log output 120 | logOutput := buf.String() 121 | if tt.wantLogContains != "" && !strings.Contains(logOutput, tt.wantLogContains) { 122 | t.Errorf("log output does not contain expected string\ngot: %v\nwant substring: %v", logOutput, tt.wantLogContains) 123 | } 124 | 125 | // Check file creation 126 | if tt.checkFile { //nolint:nestif 127 | exists, err := afero.Exists(fs, tt.configFilePath) 128 | if err != nil { 129 | t.Fatalf("failed to check file existence: %v", err) 130 | } 131 | if !exists { 132 | t.Errorf("config file was not created at %s", tt.configFilePath) 133 | } 134 | 135 | // Check file content 136 | content, err := afero.ReadFile(fs, tt.configFilePath) 137 | if err != nil { 138 | t.Fatalf("failed to read created file: %v", err) 139 | } 140 | 141 | // Should contain the default template 142 | if !strings.Contains(string(content), "apps:") { 143 | t.Error("created file does not contain expected 'apps:' field") 144 | } 145 | if !strings.Contains(string(content), "client_id:") { 146 | t.Error("created file does not contain expected 'client_id:' field") 147 | } 148 | 149 | // Check file permissions 150 | info, err := fs.Stat(tt.configFilePath) 151 | if err != nil { 152 | t.Fatalf("failed to stat created file: %v", err) 153 | } 154 | mode := info.Mode() 155 | expectedMode := os.FileMode(0o644) 156 | if mode != expectedMode { 157 | t.Errorf("file permissions = %v, want %v", mode, expectedMode) 158 | } 159 | 160 | // Check directory exists 161 | dirPath := filepath.Dir(tt.configFilePath) 162 | if dirPath != "." && dirPath != "/" { 163 | dirInfo, err := fs.Stat(dirPath) 164 | if err != nil { 165 | t.Fatalf("failed to stat directory: %v", err) 166 | } 167 | // Check if it's a directory 168 | if !dirInfo.IsDir() { 169 | t.Errorf("expected %s to be a directory", dirPath) 170 | } 171 | } 172 | } 173 | }) 174 | } 175 | } 176 | 177 | func TestController_Init_ErrorCases(t *testing.T) { 178 | t.Parallel() 179 | 180 | t.Run("filesystem error on exists check", func(t *testing.T) { 181 | t.Parallel() 182 | 183 | // Create a mock filesystem that returns an error 184 | fs := &errorFS{ 185 | existsErr: true, 186 | } 187 | 188 | ctrl := initcmd.New(fs) 189 | logger := slog.New(slog.NewTextHandler(bytes.NewBuffer(nil), nil)) 190 | 191 | if err := ctrl.Init(logger, "/test/config.yaml"); err != nil { 192 | if !strings.Contains(err.Error(), "check if a configuration file exists") { 193 | t.Errorf("unexpected error message: %v", err) 194 | } 195 | return 196 | } 197 | t.Fatal("expected error but got nil") 198 | }) 199 | 200 | t.Run("filesystem error on mkdir", func(t *testing.T) { 201 | t.Parallel() 202 | 203 | fs := &errorFS{ 204 | mkdirErr: true, 205 | } 206 | 207 | ctrl := initcmd.New(fs) 208 | logger := slog.New(slog.NewTextHandler(bytes.NewBuffer(nil), nil)) 209 | 210 | if err := ctrl.Init(logger, "/test/config.yaml"); err != nil { 211 | if !strings.Contains(err.Error(), "create config dir") { 212 | t.Errorf("unexpected error message: %v", err) 213 | } 214 | return 215 | } 216 | t.Fatal("expected error but got nil") 217 | }) 218 | 219 | t.Run("filesystem error on write file", func(t *testing.T) { 220 | t.Parallel() 221 | 222 | fs := &errorFS{ 223 | writeErr: true, 224 | } 225 | 226 | ctrl := initcmd.New(fs) 227 | logger := slog.New(slog.NewTextHandler(bytes.NewBuffer(nil), nil)) 228 | 229 | if err := ctrl.Init(logger, "/test/config.yaml"); err != nil { 230 | if !strings.Contains(err.Error(), "create a configuration file") { 231 | t.Errorf("unexpected error message: %v", err) 232 | } 233 | return 234 | } 235 | t.Fatal("expected error but got nil") 236 | }) 237 | } 238 | 239 | // errorFS is a mock filesystem that returns errors for testing 240 | type errorFS struct { 241 | afero.Fs 242 | existsErr bool 243 | mkdirErr bool 244 | writeErr bool 245 | } 246 | 247 | func (fs *errorFS) Name() string { 248 | return "errorFS" 249 | } 250 | 251 | func (fs *errorFS) Create(name string) (afero.File, error) { 252 | if fs.writeErr { 253 | return nil, os.ErrPermission 254 | } 255 | return afero.NewMemMapFs().Create(name) 256 | } 257 | 258 | func (fs *errorFS) MkdirAll(_ string, _ os.FileMode) error { 259 | if fs.mkdirErr { 260 | return os.ErrPermission 261 | } 262 | return nil 263 | } 264 | 265 | func (fs *errorFS) OpenFile(name string, flag int, perm os.FileMode) (afero.File, error) { 266 | if fs.writeErr { 267 | return nil, os.ErrPermission 268 | } 269 | return afero.NewMemMapFs().OpenFile(name, flag, perm) 270 | } 271 | 272 | func (fs *errorFS) Stat(_ string) (os.FileInfo, error) { 273 | if fs.existsErr { 274 | return nil, os.ErrPermission 275 | } 276 | // Return not found for new files 277 | return nil, os.ErrNotExist 278 | } 279 | 280 | func (fs *errorFS) Remove(_ string) error { 281 | return nil 282 | } 283 | 284 | func (fs *errorFS) RemoveAll(_ string) error { 285 | return nil 286 | } 287 | 288 | func (fs *errorFS) Rename(_, _ string) error { 289 | return nil 290 | } 291 | 292 | func (fs *errorFS) Chmod(_ string, _ os.FileMode) error { 293 | return nil 294 | } 295 | 296 | func (fs *errorFS) Chown(_ string, _, _ int) error { 297 | return nil 298 | } 299 | 300 | func (fs *errorFS) Chtimes(_ string, _, _ time.Time) error { 301 | return nil 302 | } 303 | 304 | func (fs *errorFS) Open(name string) (afero.File, error) { 305 | return afero.NewMemMapFs().Open(name) 306 | } 307 | 308 | func (fs *errorFS) Mkdir(_ string, _ os.FileMode) error { 309 | return nil 310 | } 311 | -------------------------------------------------------------------------------- /aqua/aqua-checksums.json: -------------------------------------------------------------------------------- 1 | { 2 | "checksums": [ 3 | { 4 | "id": "github_release/github.com/anchore/syft/v1.38.2/syft_1.38.2_darwin_amd64.tar.gz", 5 | "checksum": "D345DE5C7DCBD8E258D568CA40786768BB654CBA62F54CB8CA83C2C90A9D4422", 6 | "algorithm": "sha256" 7 | }, 8 | { 9 | "id": "github_release/github.com/anchore/syft/v1.38.2/syft_1.38.2_darwin_arm64.tar.gz", 10 | "checksum": "3B53CDFF2A1C66792329D91A914276E98EFBE548901978FE42B991EFC5DF90CF", 11 | "algorithm": "sha256" 12 | }, 13 | { 14 | "id": "github_release/github.com/anchore/syft/v1.38.2/syft_1.38.2_linux_amd64.tar.gz", 15 | "checksum": "6AA9A7B9E53C0F06E2D79FB24641CC1C856BB41702C282A577691F54BAD94996", 16 | "algorithm": "sha256" 17 | }, 18 | { 19 | "id": "github_release/github.com/anchore/syft/v1.38.2/syft_1.38.2_linux_arm64.tar.gz", 20 | "checksum": "CCD95833D4124F0E30925033908934978086727AF69585F015F28A4F355FDA1B", 21 | "algorithm": "sha256" 22 | }, 23 | { 24 | "id": "github_release/github.com/anchore/syft/v1.38.2/syft_1.38.2_windows_amd64.zip", 25 | "checksum": "74D57DACA2A9D08F0D470D3C4C11E8B98ED4A5EE5ABB70F1ED9D207D319FCF51", 26 | "algorithm": "sha256" 27 | }, 28 | { 29 | "id": "github_release/github.com/crate-ci/typos/v1.40.0/typos-v1.40.0-aarch64-apple-darwin.tar.gz", 30 | "checksum": "1EA9ED6520B94D0E1148942E3EF80A997FF8DB856E1389EDAA9A5BDAFF658FA4", 31 | "algorithm": "sha256" 32 | }, 33 | { 34 | "id": "github_release/github.com/crate-ci/typos/v1.40.0/typos-v1.40.0-aarch64-unknown-linux-musl.tar.gz", 35 | "checksum": "349B2C3F7C7FBA125E978DF232FAA9C5A57C33AA144F88CBC250C8C6D3E8E054", 36 | "algorithm": "sha256" 37 | }, 38 | { 39 | "id": "github_release/github.com/crate-ci/typos/v1.40.0/typos-v1.40.0-x86_64-apple-darwin.tar.gz", 40 | "checksum": "51368551A37E15464438EA5C95AD29CB7239BFDEFD69EE9A9BE5FF3D45FC4D19", 41 | "algorithm": "sha256" 42 | }, 43 | { 44 | "id": "github_release/github.com/crate-ci/typos/v1.40.0/typos-v1.40.0-x86_64-pc-windows-msvc.zip", 45 | "checksum": "F13426420749FAE31136E15A245C8EB144D6D3D681B3300D54D1A129999A140D", 46 | "algorithm": "sha256" 47 | }, 48 | { 49 | "id": "github_release/github.com/crate-ci/typos/v1.40.0/typos-v1.40.0-x86_64-unknown-linux-musl.tar.gz", 50 | "checksum": "485405D0A92871F45EAD0703D23C04AE6969AD4A6E5799794F55EB04B9F07801", 51 | "algorithm": "sha256" 52 | }, 53 | { 54 | "id": "github_release/github.com/golangci/golangci-lint/v2.7.2/golangci-lint-2.7.2-darwin-amd64.tar.gz", 55 | "checksum": "6966554840A02229A14C52641BC38C2C7A14D396F4C59BA0C7C8BB0675CA25C9", 56 | "algorithm": "sha256" 57 | }, 58 | { 59 | "id": "github_release/github.com/golangci/golangci-lint/v2.7.2/golangci-lint-2.7.2-darwin-arm64.tar.gz", 60 | "checksum": "6CE86A00E22B3709F7B994838659C322FDC9EAE09E263DB50439AD4F6EC5785C", 61 | "algorithm": "sha256" 62 | }, 63 | { 64 | "id": "github_release/github.com/golangci/golangci-lint/v2.7.2/golangci-lint-2.7.2-linux-amd64.tar.gz", 65 | "checksum": "CE46A1F1D890E7B667259F70BB236297F5CF8791A9B6B98B41B283D93B5B6E88", 66 | "algorithm": "sha256" 67 | }, 68 | { 69 | "id": "github_release/github.com/golangci/golangci-lint/v2.7.2/golangci-lint-2.7.2-linux-arm64.tar.gz", 70 | "checksum": "7028E810837722683DAB679FB121336CFA303FECFF39DFE248E3E36BC18D941B", 71 | "algorithm": "sha256" 72 | }, 73 | { 74 | "id": "github_release/github.com/golangci/golangci-lint/v2.7.2/golangci-lint-2.7.2-windows-amd64.zip", 75 | "checksum": "D48F456944C5850CA408FEB0CAC186345F0A6D8CF5DC31875C8F63D3DFF5EE4C", 76 | "algorithm": "sha256" 77 | }, 78 | { 79 | "id": "github_release/github.com/golangci/golangci-lint/v2.7.2/golangci-lint-2.7.2-windows-arm64.zip", 80 | "checksum": "E5FC39E0F3FE817F093B5467BFC60D2A9D1292DE930B29322D2A1F8AFF2A3BBF", 81 | "algorithm": "sha256" 82 | }, 83 | { 84 | "id": "github_release/github.com/goreleaser/goreleaser/v2.13.1/goreleaser_Darwin_all.tar.gz", 85 | "checksum": "C7B5C26953E59B7E4B50913738C7FF2C371C95B5145BD0A2F93CFA5571D3BE97", 86 | "algorithm": "sha256" 87 | }, 88 | { 89 | "id": "github_release/github.com/goreleaser/goreleaser/v2.13.1/goreleaser_Linux_arm64.tar.gz", 90 | "checksum": "97051DE56BDCC4A76B2AA552FA85B633EBFFEA47B44BED85CD3580F12FC82651", 91 | "algorithm": "sha256" 92 | }, 93 | { 94 | "id": "github_release/github.com/goreleaser/goreleaser/v2.13.1/goreleaser_Linux_x86_64.tar.gz", 95 | "checksum": "04764528D7344BC5EFAE80EF62467480578A37DB0BB98EA2CEE185E04AEB1A7D", 96 | "algorithm": "sha256" 97 | }, 98 | { 99 | "id": "github_release/github.com/goreleaser/goreleaser/v2.13.1/goreleaser_Windows_arm64.zip", 100 | "checksum": "B4BAB00ED850E7E30054A462587FB7076A548CC137C5587694D2B8E5E65DFFA6", 101 | "algorithm": "sha256" 102 | }, 103 | { 104 | "id": "github_release/github.com/goreleaser/goreleaser/v2.13.1/goreleaser_Windows_x86_64.zip", 105 | "checksum": "25CB285AB0481A9456CA8EF8E39147D4CF018F0990BC560EFA3ED2A14E9D7DA7", 106 | "algorithm": "sha256" 107 | }, 108 | { 109 | "id": "github_release/github.com/mvdan/gofumpt/v0.9.2/gofumpt_v0.9.2_darwin_amd64", 110 | "checksum": "4172B912EC514038605F334FEF9ED7B3F12CA3E40024CB0A622EAB3073A55E57", 111 | "algorithm": "sha256" 112 | }, 113 | { 114 | "id": "github_release/github.com/mvdan/gofumpt/v0.9.2/gofumpt_v0.9.2_darwin_arm64", 115 | "checksum": "C241FB742599A6CB0563D7377F59DEF65D451B23DD718DBC6DDF4AB5E695E8F1", 116 | "algorithm": "sha256" 117 | }, 118 | { 119 | "id": "github_release/github.com/mvdan/gofumpt/v0.9.2/gofumpt_v0.9.2_linux_amd64", 120 | "checksum": "72CF61B12FEF91EAB6DF6DB4A4284F30616B5EAD330112E28A1FA1CB15E57339", 121 | "algorithm": "sha256" 122 | }, 123 | { 124 | "id": "github_release/github.com/mvdan/gofumpt/v0.9.2/gofumpt_v0.9.2_linux_arm64", 125 | "checksum": "5ACAA5A554050F55FC81EF02A8B0D14AB6B3C058A84513885286DC52D3451645", 126 | "algorithm": "sha256" 127 | }, 128 | { 129 | "id": "github_release/github.com/mvdan/gofumpt/v0.9.2/gofumpt_v0.9.2_windows_amd64.exe", 130 | "checksum": "067236B55A8EF4547DDC7D78FBB7A38169DE15BAB02A1763CDE6A132C59DD35C", 131 | "algorithm": "sha256" 132 | }, 133 | { 134 | "id": "github_release/github.com/reviewdog/reviewdog/v0.21.0/reviewdog_0.21.0_Darwin_arm64.tar.gz", 135 | "checksum": "C28DEF83AF6C5AA8728D6D18160546AFD3E5A219117715A2C6C023BD16F14D10", 136 | "algorithm": "sha256" 137 | }, 138 | { 139 | "id": "github_release/github.com/reviewdog/reviewdog/v0.21.0/reviewdog_0.21.0_Darwin_x86_64.tar.gz", 140 | "checksum": "9BAADB110C87F22C55688CF4A966ACCE3006C7A4A962732D6C8B45234C454C6E", 141 | "algorithm": "sha256" 142 | }, 143 | { 144 | "id": "github_release/github.com/reviewdog/reviewdog/v0.21.0/reviewdog_0.21.0_Linux_arm64.tar.gz", 145 | "checksum": "B6AFF657B39E9267A258E8FA66D616F7221AEC5975D0251DAC76043BAD0FA177", 146 | "algorithm": "sha256" 147 | }, 148 | { 149 | "id": "github_release/github.com/reviewdog/reviewdog/v0.21.0/reviewdog_0.21.0_Linux_x86_64.tar.gz", 150 | "checksum": "AD5CE7D5FFA52AAA7EC8710A8FA764181B6CECAAB843CC791E1CCE1680381569", 151 | "algorithm": "sha256" 152 | }, 153 | { 154 | "id": "github_release/github.com/reviewdog/reviewdog/v0.21.0/reviewdog_0.21.0_Windows_arm64.tar.gz", 155 | "checksum": "72ABE9907454C5697777CFFF1D0D03DB8F5A9FD6950C609CA397A90D41AB65D7", 156 | "algorithm": "sha256" 157 | }, 158 | { 159 | "id": "github_release/github.com/reviewdog/reviewdog/v0.21.0/reviewdog_0.21.0_Windows_x86_64.tar.gz", 160 | "checksum": "97C733E492DEC1FD83B9342C25A384D5AB6EBFA72B6978346E9A436CAD1853F6", 161 | "algorithm": "sha256" 162 | }, 163 | { 164 | "id": "github_release/github.com/rhysd/actionlint/v1.7.9/actionlint_1.7.9_darwin_amd64.tar.gz", 165 | "checksum": "F89A910E90E536F60DF7C504160247DB01DD67CAB6F08C064C1C397B76C91A79", 166 | "algorithm": "sha256" 167 | }, 168 | { 169 | "id": "github_release/github.com/rhysd/actionlint/v1.7.9/actionlint_1.7.9_darwin_arm64.tar.gz", 170 | "checksum": "855E49E823FC68C6371FD6967E359CDE11912D8D44FED343283C8E6E943BD789", 171 | "algorithm": "sha256" 172 | }, 173 | { 174 | "id": "github_release/github.com/rhysd/actionlint/v1.7.9/actionlint_1.7.9_linux_amd64.tar.gz", 175 | "checksum": "233B280D05E100837F4AF1433C7B40A5DCB306E3AA68FB4F17F8A7F45A7DF7B4", 176 | "algorithm": "sha256" 177 | }, 178 | { 179 | "id": "github_release/github.com/rhysd/actionlint/v1.7.9/actionlint_1.7.9_linux_arm64.tar.gz", 180 | "checksum": "6B82A3B8C808BF1BCD39A95ACED22FC1A026EEF08EDE410F81E274AF8DEADBBC", 181 | "algorithm": "sha256" 182 | }, 183 | { 184 | "id": "github_release/github.com/rhysd/actionlint/v1.7.9/actionlint_1.7.9_windows_amd64.zip", 185 | "checksum": "7C8B10A93723838BC3533F6E1886D868FDBB109B81606EBE6D1A533D11D8E978", 186 | "algorithm": "sha256" 187 | }, 188 | { 189 | "id": "github_release/github.com/rhysd/actionlint/v1.7.9/actionlint_1.7.9_windows_arm64.zip", 190 | "checksum": "7ACA9BF09EEDF0A743E08C7CB9F1712467A7324A9342A029AE4536FB4BE95C25", 191 | "algorithm": "sha256" 192 | }, 193 | { 194 | "id": "github_release/github.com/sigstore/cosign/v3.0.3/cosign-darwin-amd64", 195 | "checksum": "6C75981E85E081A73F0B4087F58E0AD5FD4712C71B37FA0B6AD774C1F965BAFA", 196 | "algorithm": "sha256" 197 | }, 198 | { 199 | "id": "github_release/github.com/sigstore/cosign/v3.0.3/cosign-darwin-arm64", 200 | "checksum": "38349E45A8BB0D1ED3A7AFFB8BDD2E9D597CEE08B6800C395A926B4D9ADB84D2", 201 | "algorithm": "sha256" 202 | }, 203 | { 204 | "id": "github_release/github.com/sigstore/cosign/v3.0.3/cosign-linux-amd64", 205 | "checksum": "052363A0E23E2E7ED53641351B8B420918E7E08F9C1D8A42A3DD3877A78A2E10", 206 | "algorithm": "sha256" 207 | }, 208 | { 209 | "id": "github_release/github.com/sigstore/cosign/v3.0.3/cosign-linux-arm64", 210 | "checksum": "81398231362031E3C7AFD6A7508C57049460CD7E02736F1EBE89A452102253E5", 211 | "algorithm": "sha256" 212 | }, 213 | { 214 | "id": "github_release/github.com/sigstore/cosign/v3.0.3/cosign-windows-amd64.exe", 215 | "checksum": "2593655025B52B5B1C99E43464459B645A3ACBE5D4A5A9F3A766E77BEEC5A441", 216 | "algorithm": "sha256" 217 | }, 218 | { 219 | "id": "github_release/github.com/suzuki-shunsuke/cmdx/v2.0.2/cmdx_darwin_amd64.tar.gz", 220 | "checksum": "768B8517666A15D25A6870307231416016FC1165F8A1C1743B6AACDBAC7A5FAC", 221 | "algorithm": "sha256" 222 | }, 223 | { 224 | "id": "github_release/github.com/suzuki-shunsuke/cmdx/v2.0.2/cmdx_darwin_arm64.tar.gz", 225 | "checksum": "FBD7DADDBB65ABD0DE5C6B898F2219588C7D1A71DF6808137D0A628858E7777B", 226 | "algorithm": "sha256" 227 | }, 228 | { 229 | "id": "github_release/github.com/suzuki-shunsuke/cmdx/v2.0.2/cmdx_linux_amd64.tar.gz", 230 | "checksum": "40BC7B5F472211B22C4786D55F6859FA8093F1A373FF40A2DCCD29BD3D11CF96", 231 | "algorithm": "sha256" 232 | }, 233 | { 234 | "id": "github_release/github.com/suzuki-shunsuke/cmdx/v2.0.2/cmdx_linux_arm64.tar.gz", 235 | "checksum": "691EB4CC3929A5E065F7C2F977CEE8306D817CB0F8DE9D5B4B4ED38C027CEC41", 236 | "algorithm": "sha256" 237 | }, 238 | { 239 | "id": "github_release/github.com/suzuki-shunsuke/cmdx/v2.0.2/cmdx_windows_amd64.zip", 240 | "checksum": "4452010897556935E3F94A11AF2B2889563E05073A6DEA72FCF40B83B7F4AE5B", 241 | "algorithm": "sha256" 242 | }, 243 | { 244 | "id": "github_release/github.com/suzuki-shunsuke/cmdx/v2.0.2/cmdx_windows_arm64.zip", 245 | "checksum": "156D02F4E784E237B0661464D6FF76D6C4EFC4E01F858F8A9734364CD41BC98E", 246 | "algorithm": "sha256" 247 | }, 248 | { 249 | "id": "github_release/github.com/suzuki-shunsuke/ghalint/v1.5.4/ghalint_1.5.4_darwin_amd64.tar.gz", 250 | "checksum": "BA9604B55E512447803A2AD754749A82E3048DCF27B630D2FC068F9C9AB221F2", 251 | "algorithm": "sha256" 252 | }, 253 | { 254 | "id": "github_release/github.com/suzuki-shunsuke/ghalint/v1.5.4/ghalint_1.5.4_darwin_arm64.tar.gz", 255 | "checksum": "3DE0A438EBB34A88F9D6AF23FAC75B698E04597DDD7098115C2273414FF31527", 256 | "algorithm": "sha256" 257 | }, 258 | { 259 | "id": "github_release/github.com/suzuki-shunsuke/ghalint/v1.5.4/ghalint_1.5.4_linux_amd64.tar.gz", 260 | "checksum": "977555B7142CC057AAD4663679772510A9ED3F1687F26F2359D18BB6B67314FE", 261 | "algorithm": "sha256" 262 | }, 263 | { 264 | "id": "github_release/github.com/suzuki-shunsuke/ghalint/v1.5.4/ghalint_1.5.4_linux_arm64.tar.gz", 265 | "checksum": "9B5C41332EE0C83C36D003A95813320A41DD0BE9186C4EB1DCB119908ADBC0A6", 266 | "algorithm": "sha256" 267 | }, 268 | { 269 | "id": "github_release/github.com/suzuki-shunsuke/ghalint/v1.5.4/ghalint_1.5.4_windows_amd64.zip", 270 | "checksum": "8D32A5EC2623A52A1EE5755131C6C73E5593E22B5795B1918F319C08970073E0", 271 | "algorithm": "sha256" 272 | }, 273 | { 274 | "id": "github_release/github.com/suzuki-shunsuke/ghalint/v1.5.4/ghalint_1.5.4_windows_arm64.zip", 275 | "checksum": "C816F297D658E466F59BBCADC521C6F7053C2DFBA89AFBABA8D2E542AFD6A72E", 276 | "algorithm": "sha256" 277 | }, 278 | { 279 | "id": "github_release/github.com/suzuki-shunsuke/ghtkn/v0.2.4/ghtkn_darwin_amd64.tar.gz", 280 | "checksum": "07D8695C71735197F599C2377F9F784DA01350CB8629D8F8634F133F6B73F6CE", 281 | "algorithm": "sha256" 282 | }, 283 | { 284 | "id": "github_release/github.com/suzuki-shunsuke/ghtkn/v0.2.4/ghtkn_darwin_arm64.tar.gz", 285 | "checksum": "DDBF283A1330DE914B60F7317BD7D3233054C03AAB294246B19B018ECAB0757A", 286 | "algorithm": "sha256" 287 | }, 288 | { 289 | "id": "github_release/github.com/suzuki-shunsuke/ghtkn/v0.2.4/ghtkn_linux_amd64.tar.gz", 290 | "checksum": "A381C7B9C8BE70376ADE91A6E70166777756E114001BC42F142D481193919E75", 291 | "algorithm": "sha256" 292 | }, 293 | { 294 | "id": "github_release/github.com/suzuki-shunsuke/ghtkn/v0.2.4/ghtkn_linux_arm64.tar.gz", 295 | "checksum": "90EB4EC8C93A131F0682C3EC16E03AA5BC15A9D9EE9233659921811F0769A625", 296 | "algorithm": "sha256" 297 | }, 298 | { 299 | "id": "github_release/github.com/suzuki-shunsuke/ghtkn/v0.2.4/ghtkn_windows_amd64.zip", 300 | "checksum": "2BBD58EDE5A24BA140927AB84CD7F72C57EC93971A0A7D58142AF85529007E02", 301 | "algorithm": "sha256" 302 | }, 303 | { 304 | "id": "github_release/github.com/suzuki-shunsuke/ghtkn/v0.2.4/ghtkn_windows_arm64.zip", 305 | "checksum": "07D4EA8389F6047EF98542835677D6511F539F1FC2F096985252D6553428248D", 306 | "algorithm": "sha256" 307 | }, 308 | { 309 | "id": "github_release/github.com/suzuki-shunsuke/nllint/v1.0.0/nllint_darwin_amd64.tar.gz", 310 | "checksum": "5733518D44F7EAE2B4B16B0EA6617597C89B2EB084A4E6101A1DC03716266B6C", 311 | "algorithm": "sha256" 312 | }, 313 | { 314 | "id": "github_release/github.com/suzuki-shunsuke/nllint/v1.0.0/nllint_darwin_arm64.tar.gz", 315 | "checksum": "E854EE0AA0DD83273D3B68E335BC025FDA721A32A3373091DFDEC3A582D1EC41", 316 | "algorithm": "sha256" 317 | }, 318 | { 319 | "id": "github_release/github.com/suzuki-shunsuke/nllint/v1.0.0/nllint_linux_amd64.tar.gz", 320 | "checksum": "F54EC24CE1C344B611F6D80155396101D38E72B7F88E2CA8B9BFADC16307AE35", 321 | "algorithm": "sha256" 322 | }, 323 | { 324 | "id": "github_release/github.com/suzuki-shunsuke/nllint/v1.0.0/nllint_linux_arm64.tar.gz", 325 | "checksum": "EC8D94494C70F4285A39375F385A4E52F945C597A3A5361ADF3DD9F1C8263CE4", 326 | "algorithm": "sha256" 327 | }, 328 | { 329 | "id": "github_release/github.com/suzuki-shunsuke/nllint/v1.0.0/nllint_windows_amd64.zip", 330 | "checksum": "4ABEBC98AF160C06C988F1421F0CF991FBDBFB6789CC1681C5C8E33C48A70160", 331 | "algorithm": "sha256" 332 | }, 333 | { 334 | "id": "github_release/github.com/suzuki-shunsuke/nllint/v1.0.0/nllint_windows_arm64.zip", 335 | "checksum": "7493FF32F027507BDA9E9308E4AA25D640AA16B0287F7656A7C0A61AF907D270", 336 | "algorithm": "sha256" 337 | }, 338 | { 339 | "id": "github_release/github.com/suzuki-shunsuke/pinact/v3.6.0/pinact_darwin_amd64.tar.gz", 340 | "checksum": "49D10A46D9E1C87541BFE8551964868F3F62CF33BF6A80A9C68F7D3696651420", 341 | "algorithm": "sha256" 342 | }, 343 | { 344 | "id": "github_release/github.com/suzuki-shunsuke/pinact/v3.6.0/pinact_darwin_arm64.tar.gz", 345 | "checksum": "B5B8229A2F00B209D7168E41A335E612BD2CCF6E0E41DD830A7CFC65A06532BF", 346 | "algorithm": "sha256" 347 | }, 348 | { 349 | "id": "github_release/github.com/suzuki-shunsuke/pinact/v3.6.0/pinact_linux_amd64.tar.gz", 350 | "checksum": "BAAD9DCFC298B1281BDA3E29A977C4B2F6979DFD80718ABD8CCB23D170005D5E", 351 | "algorithm": "sha256" 352 | }, 353 | { 354 | "id": "github_release/github.com/suzuki-shunsuke/pinact/v3.6.0/pinact_linux_arm64.tar.gz", 355 | "checksum": "971CDF464BE2AC09E2600307571AD5FBE78E1AA1F978F4CAD3B1C56EFA4A39E4", 356 | "algorithm": "sha256" 357 | }, 358 | { 359 | "id": "github_release/github.com/suzuki-shunsuke/pinact/v3.6.0/pinact_windows_amd64.zip", 360 | "checksum": "B510414A442473AC96F4F5D20C57AC1F5E8BA502C9DCAB985AB0A9FBFCD40826", 361 | "algorithm": "sha256" 362 | }, 363 | { 364 | "id": "github_release/github.com/suzuki-shunsuke/pinact/v3.6.0/pinact_windows_arm64.zip", 365 | "checksum": "083510E344D403E98AFF942A619FA7A96B35E42F36B24B901FA3D6AA217E1B2A", 366 | "algorithm": "sha256" 367 | }, 368 | { 369 | "id": "registries/github_content/github.com/aquaproj/aqua-registry/v4.447.1/registry.yaml", 370 | "checksum": "324ED65ED60627EBE90CFD5A63985ECFDA545954EFC0B9208A9B054FAD86ED4226AAD93D7314666F170A53DF89EE90769FF74D8A71AE90B47C5D2F00F3C01CF5", 371 | "algorithm": "sha512" 372 | } 373 | ] 374 | } 375 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ghtkn (GH-Token) 2 | 3 | [![DeepWiki](https://img.shields.io/badge/DeepWiki-suzuki--shunsuke%2Fghtkn-blue.svg?logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACwAAAAyCAYAAAAnWDnqAAAAAXNSR0IArs4c6QAAA05JREFUaEPtmUtyEzEQhtWTQyQLHNak2AB7ZnyXZMEjXMGeK/AIi+QuHrMnbChYY7MIh8g01fJoopFb0uhhEqqcbWTp06/uv1saEDv4O3n3dV60RfP947Mm9/SQc0ICFQgzfc4CYZoTPAswgSJCCUJUnAAoRHOAUOcATwbmVLWdGoH//PB8mnKqScAhsD0kYP3j/Yt5LPQe2KvcXmGvRHcDnpxfL2zOYJ1mFwrryWTz0advv1Ut4CJgf5uhDuDj5eUcAUoahrdY/56ebRWeraTjMt/00Sh3UDtjgHtQNHwcRGOC98BJEAEymycmYcWwOprTgcB6VZ5JK5TAJ+fXGLBm3FDAmn6oPPjR4rKCAoJCal2eAiQp2x0vxTPB3ALO2CRkwmDy5WohzBDwSEFKRwPbknEggCPB/imwrycgxX2NzoMCHhPkDwqYMr9tRcP5qNrMZHkVnOjRMWwLCcr8ohBVb1OMjxLwGCvjTikrsBOiA6fNyCrm8V1rP93iVPpwaE+gO0SsWmPiXB+jikdf6SizrT5qKasx5j8ABbHpFTx+vFXp9EnYQmLx02h1QTTrl6eDqxLnGjporxl3NL3agEvXdT0WmEost648sQOYAeJS9Q7bfUVoMGnjo4AZdUMQku50McDcMWcBPvr0SzbTAFDfvJqwLzgxwATnCgnp4wDl6Aa+Ax283gghmj+vj7feE2KBBRMW3FzOpLOADl0Isb5587h/U4gGvkt5v60Z1VLG8BhYjbzRwyQZemwAd6cCR5/XFWLYZRIMpX39AR0tjaGGiGzLVyhse5C9RKC6ai42ppWPKiBagOvaYk8lO7DajerabOZP46Lby5wKjw1HCRx7p9sVMOWGzb/vA1hwiWc6jm3MvQDTogQkiqIhJV0nBQBTU+3okKCFDy9WwferkHjtxib7t3xIUQtHxnIwtx4mpg26/HfwVNVDb4oI9RHmx5WGelRVlrtiw43zboCLaxv46AZeB3IlTkwouebTr1y2NjSpHz68WNFjHvupy3q8TFn3Hos2IAk4Ju5dCo8B3wP7VPr/FGaKiG+T+v+TQqIrOqMTL1VdWV1DdmcbO8KXBz6esmYWYKPwDL5b5FA1a0hwapHiom0r/cKaoqr+27/XcrS5UwSMbQAAAABJRU5ErkJggg==)](https://deepwiki.com/suzuki-shunsuke/ghtkn) [![License](http://img.shields.io/badge/license-mit-blue.svg?style=flat-square)](https://raw.githubusercontent.com/suzuki-shunsuke/ghtkn/main/LICENSE) | [Install](INSTALL.md) | [Usage](USAGE.md) 4 | 5 | **Stop risking token leaks - Use secure, short-lived GitHub tokens for local development** 6 | 7 | ## ⚠️ The Security Problem 8 | 9 | Are you still using Personal Access Tokens (PATs) or GitHub CLI OAuth tokens stored on your local machine? These long-lived tokens pose **significant security risks**: 10 | - **Indefinite or months-long validity** - A leaked token remains dangerous for extended periods 11 | - **Broad permissions** - Often configured with wide access for convenience 12 | - **Difficult to rotate** - Manual management leads to tokens being used far longer than they should 13 | 14 | ## ✅ The ghtkn Solution 15 | 16 | ghtkn generates **8-hour User Access Tokens** from GitHub Apps using Device Flow - a fundamentally more secure approach: 17 | - **Short-lived tokens** - Only 8 hours validity minimizes damage from any potential leak 18 | - **No secrets required** - Only needs a Client ID (which isn't secret), no Private Keys or Client Secrets 19 | - **User-attributed actions** - Operations are performed as you, not as an app 20 | - **Automatic token management** - Integrates with OS keychains for secure storage and reuse 21 | 22 | ghtkn (pronounced `GH-Token`) allows you to manage multiple GitHub Apps through configuration files and securely store tokens using Windows Credential Manager, macOS Keychain, or GNOME Keyring. 23 | 24 | > [!NOTE] 25 | > In this document, we call Windows Credential Manger, macOS KeyChain, and GNOME Keyring as secret manager. 26 | 27 | ## Requirements 28 | 29 | A secret manager is required. 30 | 31 | ## :rocket: Getting Started 32 | 33 | 1. [Install ghtkn](INSTALL.md) 34 | 2. Create a GitHub App 35 | 36 | - Enable Device Flow 37 | - Disable Webhook 38 | - Homepage URL: https://github.com/suzuki-shunsuke/ghtkn (You can change this freely. If you share the GitHub App in your development team, it's good to prepare the document and set it to Homepage URL) 39 | - `Only on this account` 40 | - Permissions: Nothing 41 | - Repositories: Nothing 42 | 43 | You don't need to create secrets such as Client Secrets and Private Keys. 44 | 45 | 3. Create a configuration file by `ghtkn init` and modify it 46 | 47 | ```sh 48 | ghtkn init 49 | ``` 50 | 51 | - Windows: `%APPDATA%\ghtkn\ghtkn.yaml` 52 | - macOS, Linux: `${XDG_CONFIG_HOME:-${HOME}/.config}/ghtkn/ghtkn.yaml` 53 | 54 | ```yaml:ghtkn.yaml 55 | apps: 56 | - name: suzuki-shunsuke/none 57 | client_id: xxx # Mandatory. GitHub App Client ID 58 | ``` 59 | 60 | > [!NOTE] 61 | > The GitHub App Client ID is not a secret, so there's generally no problem writing it in plain text in local configuration files. 62 | 63 | 4. Run `ghtkn get` and create a user access token 64 | 65 | ```sh 66 | ghtkn get 67 | ``` 68 | 69 | https://github.com/login/device will open in your browser, so enter the code displayed in the terminal and approve it. 70 | Then a user access token starting with `ghu_` is outputted. 71 | You can close the opened tab. 72 | 73 | With Device Flow, access tokens cannot be generated in non-interactive environments like CI. 74 | ghtkn is primarily intended for local development. 75 | 76 | If you run the same command immediately, it will now run without the authorization flow because ghtkn stores access tokens into the secret manager and reuse them. 77 | 78 | ```sh 79 | ghtkn get 80 | ``` 81 | 82 | 5. Run `gh issue create` using the access token 83 | 84 | ```sh 85 | REPO=suzuki-shunsuke/ghtkn # Please change this to your public repository 86 | env GH_TOKEN=$(ghtkn get) gh issue create -R "$REPO" --title "Hello, ghtkn" --body "This is created by ghtkn" 87 | ``` 88 | 89 | Then it fails due to the permission error even if you have the permission. 90 | 91 | ``` 92 | GraphQL: Resource not accessible by integration (createIssue) 93 | ``` 94 | 95 | Please grant the permission `issues:write` to the GitHub App and run again, then it still fails. 96 | Please install the app to the repository and run again, then it succeeds. 97 | At this time, the issue creator will be you, not the App. 98 | 99 | The permissions (Permissions and Repositories) of a user access token are held by both the authorized user (i.e. you) and the GitHub App. 100 | Therefore, as shown above, the GitHub App cannot perform operations that it is not permitted to perform, and conversely, the user cannot perform operations that they are not authorized to perform. 101 | 102 | ## Wrapping commands 103 | 104 | You can wrap commands using shell functions or scripts. 105 | 106 | Shell functions: 107 | 108 | ```sh 109 | gh() { 110 | env GH_TOKEN=$(ghtkn get) command gh "$@" # Be careful to use 'command' to avoid infinite loops 111 | } 112 | ``` 113 | 114 | Shell scripts: 115 | 116 | 1. Put shell scripts in $PATH: 117 | 118 | e.g. ~/bin/gh: 119 | 120 | ```sh 121 | #!/usr/bin/env bash 122 | 123 | set -eu 124 | 125 | # If GH_TOKEN or GITHUB_TOKEN is set, use it. 126 | if [ -z "${GH_TOKEN:-}" ] && [ -z "${GITHUB_TOKEN:-}" ]; then 127 | GH_TOKEN="$(ghtkn get)" 128 | export GH_TOKEN 129 | fi 130 | 131 | exec /opt/homebrew/bin/gh "$@" # Specify the absolute path to avoid infinite loop 132 | ``` 133 | 134 | If the command is managed by [aqua](https://aquaproj.github.io/), `aqua exec` is useful: 135 | 136 | ```sh 137 | exec aqua exec -- gh "$@" 138 | ``` 139 | 140 | 2. Make scripts executable 141 | 142 | ```sh 143 | chmod +x ~/bin/gh 144 | ``` 145 | 146 | It's useful to wrap `gh` using shell script as gh always requires GitHub access tokens. 147 | 148 | ## Git Credential Helper 149 | 150 | ghtkn >= v0.1.2 151 | 152 | You can use ghtkn as a [Git Credential Helper](https://git-scm.com/book/en/v2/Git-Tools-Credential-Storage): 153 | 154 | ```sh 155 | git config --global credential.helper '!ghtkn git-credential' 156 | ``` 157 | 158 | ```ini 159 | [credential] 160 | helper = 161 | helper = !ghtkn git-credential 162 | ``` 163 | 164 | > [!IMPORTANT] 165 | > `helper =` is necessary to disable other helpers. 166 | > https://git-scm.com/docs/gitcredentials#_configuration_options 167 | > > If credential.helper is configured to the empty string, this resets the helper list to empty 168 | > > (so you may override a helper set by a lower-priority config file by configuring the empty-string helper, followed by whatever set of helpers you would like). 169 | 170 | ### Switching GitHub Apps by repository owner 171 | 172 | If you want to switch GitHub Apps by repository owner, 173 | 174 | 1. Set `.apps[].git_owner` in a configuration file 175 | 1. Configure Git `git config credential.useHttpPath true` 176 | 177 | ```sh 178 | git config --global credential.useHttpPath true 179 | ``` 180 | 181 | ```yaml 182 | apps: 183 | - name: suzuki-shunsuke/write 184 | client_id: xxx 185 | git_owner: suzuki-shunsuke # Using this app if the repository owner is suzuki-shunsuke 186 | ``` 187 | 188 | > [!WARNING] 189 | > `git_owner` must be unique. 190 | > Please set `git_owner` to only one app per repository owner (organization and user). 191 | > For instance, if you use a read-only app and a write app for a repository owner and you want to push commits, you should set `git_owner` to the write app. 192 | > 193 | > ```yaml 194 | > apps: 195 | > - name: suzuki-shunsuke/write 196 | > client_id: xxx 197 | > git_owner: suzuki-shunsuke # Using this app if the repository owner is suzuki-shunsuke 198 | > - name: suzuki-shunsuke/read-only 199 | > client_id: xxx 200 | > # git_owner: suzuki-shunsuke # Don't set `git_owner` to read-only app to push commits 201 | > ``` 202 | 203 | ### :warning: Troubleshooting of Git Credential Helper on macOS 204 | 205 | If Git Credential Helper doesn't work on macOS, please check if osxkeychain is used. 206 | 207 | You can check the trace log of Git by `GIT_TRACE=1 GIT_CURL_VERBOSE=1`. 208 | 209 | ```sh 210 | GIT_TRACE=1 GIT_CURL_VERBOSE=1 git push origin 211 | ``` 212 | 213 | If git outputs the following log, Git uses `git-credential-osxkeychain`, not ghtkn. 214 | 215 | ``` 216 | 09:25:49.373133 git.c:750 trace: exec: git-credential-osxkeychain get 217 | 09:25:49.373152 run-command.c:655 trace: run_command: git-credential-osxkeychain get 218 | ``` 219 | 220 | Please check the git config. 221 | 222 | ```sh 223 | git config --get-all --show-origin credential.helper 224 | ``` 225 | 226 | The following output shows osxkeychain is used by the system setting `/Library/Developer/CommandLineTools/usr/share/git-core/gitconfig`. 227 | 228 | ``` 229 | file:/Library/Developer/CommandLineTools/usr/share/git-core/gitconfig osxkeychain 230 | file:/Users/shunsukesuzuki/.gitconfig !ghtkn git-credential 231 | ``` 232 | 233 | To solve the problem, please set credential.helper to the empty string. 234 | 235 | ```ini 236 | [credential] 237 | helper = 238 | helper = !ghtkn git-credential 239 | ``` 240 | 241 | https://git-scm.com/docs/gitcredentials#_configuration_options 242 | 243 | > If credential.helper is configured to the empty string, this resets the helper list to empty 244 | > (so you may override a helper set by a lower-priority config file by configuring the empty-string helper, followed by whatever set of helpers you would like). 245 | 246 | ## Using Multiple Apps 247 | 248 | You can configure multiple GitHub Apps in the `apps` section of the configuration file and create and use different Apps for each Organization or User. 249 | By default, the first App in `apps` is used. 250 | 251 | You can specify the App by command line argument: 252 | 253 | ```sh 254 | ghtkn get suzuki-shunsuke/write 255 | ``` 256 | 257 | The value is the app name defined in the configuration file. 258 | Alternatively, you can specify it with the environment variable `GHTKN_APP`. 259 | For example, it might be convenient to switch `GHTKN_APP` for each directory using a tool like [direnv](https://direnv.net/). 260 | 261 | I check out my repositories from [https://github.com/suzuki-shunsuke](https://github.com/suzuki-shunsuke) into the `~/repos/src/github.com/suzuki-shunsuke` directory. 262 | I then place a `.envrc` file in that directory with the following content: 263 | 264 | ```sh 265 | source_up 266 | 267 | export GHTKN_APP=suzuki-shunsuke/write 268 | ``` 269 | 270 | Similarly, I place a `.envrc` file in `~/repos/src/github.com/aquaproj` as well: 271 | 272 | ```sh 273 | source_up 274 | 275 | export GHTKN_APP=aquaproj/write 276 | ``` 277 | 278 | I've also set up a default App that has no permissions. 279 | While some might think an access token with no permissions is useless, it can still be used to read public repositories and helps you avoid hitting API rate limits compared to not using an access token at all. 280 | So, it's quite useful. 281 | 282 | ```yaml 283 | apps: 284 |   - name: suzuki-shunsuke/none 285 |     client_id: xxx 286 | ``` 287 | 288 | With this setup, the access token is transparently switched depending on the working directory. What's written in the `.envrc` is the `GHTKN_APP`, not the access token itself, which is safe because it's not a secret. 289 | 290 | ## Access Token Regeneration 291 | 292 | ghtkn stores generated access tokens and their expiration dates in the secret manager. 293 | `ghtkn get` retrieves these, and if the expiration has passed, regenerates the access token through Device Flow. 294 | The access token validity period is 8 hours. 295 | 296 | By default, if the access token hasn't expired, it returns it, but this may result in a short-lived access token being returned. 297 | By specifying `-min-expiration (-m) `, the access token will be regenerated if its validity period is shorter than the specified duration. 298 | 299 | ```sh 300 | ghtkn get -m 1h 301 | ``` 302 | 303 | `2h`, `30m`, `30s` etc. are also valid. Units are required. 304 | 305 | You can also set this using an environment variable. 306 | 307 | ```sh 308 | export GHTKN_MIN_EXPIRATION=10m 309 | ``` 310 | 311 | If you're only using the GitHub CLI to call an API, it usually finishes in an instant, so you probably won't need to set this. 312 | However, if you're passing the access token to a script that takes, say, 30 minutes to run, setting it to something like `50m` will prevent the token from expiring in the middle of the script. 313 | 314 | By the way, if you set the value to 8 hours or more, you can replicate how ghtkn regenerates the access token. 315 | This could be useful if you want to test how `ghtkn` behaves. 316 | 317 | ## Using ghtkn in Enterprise Organizations 318 | 319 | When using ghtkn in a company's GitHub Organization, it may not be practical for each developer to create their own GitHub App in organizations with a certain scale. In such cases, you can create a shared GitHub App and share the Client ID within the company. 320 | 321 | User Access Tokens cannot generate tokens with permissions beyond what the user has, and users cannot impersonate others. API rate limits are also per-user. 322 | 323 | Therefore, the risk of sharing within a limited internal space is considered to be low. 324 | 325 | From a company's perspective, this can prevent the leakage of developers' PATs or GitHub CLI OAuth App access tokens that have access to the company's Organization. Even if a Client ID is leaked outside the company, it doesn't provide direct access to the company's Organization, and even if an access token is leaked, the risk can be minimized due to its short validity period (8 hours). 326 | 327 | ## Environment Variables 328 | 329 | All environment variables are optional. 330 | 331 | - GHTKN_LOG_LEVEL: Log level. One of `debug`, `info` (default), `warn`, `error`. 332 | - GHTKN_OUTPUT_FORMAT: The output format of `ghtkn get` command 333 | - `json`: JSON Format 334 | - GHTKN_APP: The app identifier to get an access token 335 | - GHTKN_MIN_EXPIRATION: The minimum expiration duration of access token. If `ghtkn get` gets the access token from keying but the expiration duration is shorter than the minimum expiratino duration, `ghtkn get` creates a new access token via Device Flow 336 | - GHTKN_CONFIG: The configuration file path 337 | - XDG_CONFIG_HOME 338 | 339 | ## Go SDK 340 | 341 | You can enable your CLI application to create GitHub User Access Tokens using [ghtkn Go SDK](pkg.go.dev/github.com/suzuki-shunsuke/ghtkn-go-sdk). 342 | ghtkn itself uses this. 343 | 344 | ## How does ghtkn work? 345 | 346 | ghtkn gets and outputs an access token in the following way: 347 | 348 | 1. Read command line options and environment variables 349 | 2. Read a configuration file. It has pairs of app name and client id 350 | 3. [Determine the GitHub App](#using-multiple-apps) 351 | 4. Get the client id from the configuration file 352 | 5. Get the access token by client id from the keyring 353 | 6. If the access token isn't found in the keyring or the access token expires, [creating a new access token through Device Flow. A user need to input the device code and approve the request](https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/generating-a-user-access-token-for-a-github-app#using-the-device-flow-to-generate-a-user-access-token) 354 | 7. Get the authenticated user login by GitHub API for Git Credential Helper 355 | 8. Store the access token, expiration date, and authenticated user login in the keyring 356 | 9. Output the access token 357 | 358 | ## How To Revoke Access Tokens 359 | 360 | If an access token is leaked, it must be immediately invalidated. 361 | [You can confirm if the leaked access token expires or not by GitHub API.](https://docs.github.com/en/rest/users/users?apiVersion=2022-11-28#get-the-authenticated-user) 362 | 363 | ```sh 364 | env GH_TOKEN=$LEAKED_GITHUB_TOKEN gh api \ 365 | -H "Accept: application/vnd.github+json" \ 366 | -H "X-GitHub-Api-Version: 2022-11-28" \ 367 | /user 368 | ``` 369 | 370 | You can revoke access tokens by `Revoke all user tokens` button in the GitHub App setting page. 371 | 372 | If you want to revoke only a specific access token, [you can revoke it via GitHub API](https://docs.github.com/en/rest/apps/oauth-applications?apiVersion=2022-11-28#delete-an-app-token). 373 | This API requires a client secret. You should manage it securely. 374 | 375 | If you don't want to create a client secret, [you can revoke the target app from the `Authorized GitHub Apps` section in the user’s settings page](https://github.com/settings/apps/authorizations). 376 | Revoking the app will invalidate all User Access Tokens for the user. 377 | However, if the user reauthorizes the app, previously issued access tokens will become valid again as long as they have not yet expired. 378 | This means the app cannot be re-enabled until the leaked access token expires (up to 8 hours). 379 | During that time, it may be necessary to temporarily use another GitHub App instead. 380 | 381 | ### Revoking a user access token by GitHub Actions 382 | 383 | If you create a shared GitHub App and share it within the company, it's useful to allow users to revoke their access tokens themselves by GitHub Actions when their access tokens are leaked accidentally. 384 | It's so important to revoke leaked access tokens immediately, so it's undesirable that only administrators can revoke them. 385 | Revoking a user access token by GitHub API requires a client secret, but you should not share it widely. 386 | Instead, you can manage it by GitHub Environment Secret or Secrets Manager such as AWS SecretsManager securely, allowing people to revoke their access tokens via `workflow_dispatch` workflow. 387 | 388 | > [!WARNING] 389 | > Generally, passing secrets via inputs of `workflow_dispatch` isn't good, but in this case it can't be helped and the passed access token is revoked so there is no problem. 390 | 391 |
392 | GitHub Actions Workflow 393 | 394 | ```yaml 395 | --- 396 | name: Revoke a User Access Token 397 | run-name: Revoke a User Access Token (${{github.actor}}) 398 | on: 399 | workflow_dispatch: 400 | inputs: 401 | access_token: 402 | description: 'The access token to revoke' 403 | required: true 404 | type: string 405 | jobs: 406 | revoke: 407 | timeout-minutes: 10 408 | runs-on: ubuntu-24.04 409 | permissions: {} 410 | steps: 411 | - name: Check if the access token is available 412 | # This step is optional. 413 | continue-on-error: true # Continue even if the access token is unavailable 414 | env: 415 | GH_TOKEN: ${{inputs.access_token}} # GitHub Actions automatically masks access tokens 416 | run: | 417 | gh api \ 418 | -H "Accept: application/vnd.github+json" \ 419 | -H "X-GitHub-Api-Version: 2022-11-28" \ 420 | /user 421 | - name: Revoke the access token 422 | env: 423 | GH_TOKEN: ${{inputs.access_token}} # GitHub Actions automatically masks access tokens 424 | CLIENT_ID: ${{secrets.CLIENT_ID}} 425 | CLIENT_SECRET: ${{secrets.CLIENT_SECRET}} 426 | run: | 427 | curl -L \ 428 | -X DELETE \ 429 | -H "Accept: application/vnd.github+json" \ 430 | -u "${CLIENT_ID}:${CLIENT_SECRET}" \ 431 | -H "X-GitHub-Api-Version: 2022-11-28" \ 432 | "https://api.github.com/applications/${CLIENT_ID}/token" \ 433 | -d '{"access_token":"'"${GH_TOKEN}"'"}' 434 | ``` 435 | 436 |
437 | 438 |
439 | GitHub Actions Workflow For Multiple GitHub Apps 440 | 441 | 1. Register client ids and secrets to GitHub Environment Secrets, allowing only the default branch to access secrets 442 | 443 | Secret names: 444 | 445 | - `CLIENT_ID_${NAME}` 446 | - `CLIENT_SECRET_${NAME}` 447 | 448 | ```yaml 449 | --- 450 | name: Revoke a User Access Token 451 | run-name: Revoke a User Access Token (${{github.actor}}/${{inputs.app_name}}) 452 | on: 453 | workflow_dispatch: 454 | inputs: 455 | app_name: 456 | description: GitHub App name 457 | required: true 458 | type: choice 459 | default: write 460 | options: # PLEASE CHANGE 461 | - none 462 | - read 463 | - write 464 | - full 465 | access_token: 466 | description: 'The access token to revoke' 467 | required: true 468 | type: string 469 | jobs: 470 | revoke: 471 | timeout-minutes: 10 472 | runs-on: ubuntu-24.04 473 | permissions: {} 474 | environment: main 475 | steps: 476 | - name: Check if the access token is available 477 | # This step is optional. 478 | continue-on-error: true # Continue even if the access token is unavailable 479 | env: 480 | GH_TOKEN: ${{inputs.access_token}} # GitHub Actions automatically masks access tokens 481 | run: | 482 | gh api \ 483 | -H "Accept: application/vnd.github+json" \ 484 | -H "X-GitHub-Api-Version: 2022-11-28" \ 485 | /user 486 | - name: Choose the client id and client secret 487 | id: secret_names 488 | env: 489 | APP_NAME: ${{inputs.app_name}} 490 | run: | 491 | UPPER_APP_NAME=$(echo "$APP_NAME" | tr '[:lower:]' '[:upper:]') 492 | echo CLIENT_ID_NAME="CLIENT_ID_${UPPER_APP_NAME}" >> "$GITHUB_OUTPUT" 493 | echo CLIENT_SECRET_NAME="CLIENT_SECRET_${UPPER_APP_NAME}" >> "$GITHUB_OUTPUT" 494 | - name: Revoke the access token 495 | env: 496 | GH_TOKEN: ${{inputs.access_token}} # GitHub Actions automatically masks access tokens 497 | CLIENT_ID: ${{secrets[steps.secret_names.outputs.CLIENT_ID_NAME]}} 498 | CLIENT_SECRET: ${{secrets[steps.secret_names.outputs.CLIENT_SECRET_NAME]}} 499 | run: | 500 | curl -L \ 501 | -X DELETE \ 502 | -H "Accept: application/vnd.github+json" \ 503 | -u "${CLIENT_ID}:${CLIENT_SECRET}" \ 504 | -H "X-GitHub-Api-Version: 2022-11-28" \ 505 | "https://api.github.com/applications/${CLIENT_ID}/token" \ 506 | -d '{"access_token":"'"${GH_TOKEN}"'"}' 507 | - name: Check if the access token has been revoked 508 | # This step is optional. 509 | continue-on-error: true # Continue even if the access token is unavailable 510 | env: 511 | GH_TOKEN: ${{inputs.access_token}} # GitHub Actions automatically masks access tokens 512 | run: | 513 | gh api \ 514 | -H "Accept: application/vnd.github+json" \ 515 | -H "X-GitHub-Api-Version: 2022-11-28" \ 516 | /user 517 | ``` 518 | 519 |
520 | 521 | ## Comparison between GitHub App User Access Token and other access tokens 522 | 523 | ### GitHub CLI OAuth App access token 524 | 525 | https://cli.github.com/manual/gh_auth_token 526 | 527 | This can be easily generated with `gh auth login`, `gh auth token` in GitHub CLI. 528 | You don't need to generate Personal Access Tokens, and it's convenient. 529 | Also, when scopes across Users or Organizations are needed, it's difficult with non-Public GitHub Apps, but installing GitHub CLI OAuth App across multiple Users or Organizations solves such problems. 530 | 531 | However, this access token is not very good from a security perspective. 532 | While you can restrict the scope (permission) and target Organizations, these tend to be quite broad for convenience. 533 | Also, it's basically indefinite. 534 | Therefore, the risk when this token is leaked is very high. 535 | 536 | So, a more secure mechanism is needed. 537 | 538 | ### fine-grained Personal Access Token 539 | 540 | https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens 541 | 542 | We'll ignore Legacy PAT as it's almost the same as OAuth App tokens. 543 | 544 | Fine-grained access tokens have the following disadvantages compared to User Access Tokens: 545 | 546 | - Regular rotation is cumbersome 547 | - Management is cumbersome 548 | - High risk when leaked 549 | - While the validity period is not indefinite, it tends to be quite long 550 | - Since short periods make rotation cumbersome, it tends to be 1 year or 6 months 551 | - Not on the order of a few hours 552 | 553 | ### GitHub App installation access token 554 | 555 | https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/authenticating-as-a-github-app-installation 556 | 557 | - Pros 558 | - Can change permissions, repositories, and validity period when generating tokens 559 | - Cons 560 | - Cannot operate as a User 561 | - e.g., PR creator becomes the App 562 | - Private Key management is cumbersome 563 | - High risk when Private Key is leaked 564 | 565 | ## :memo: Note 566 | 567 | ### API rate limit 568 | 569 | https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api?apiVersion=2022-11-28#primary-rate-limit-for-github-app-installations 570 | 571 | > Primary rate limits for GitHub App user access tokens (as opposed to installation access tokens) are dictated by the primary rate limits for the authenticated user. 572 | > This rate limit is combined with any requests that another GitHub App or OAuth app makes on that user's behalf and any requests that the user makes with a personal access token. 573 | > For more information, see Rate limits for the REST API. 574 | 575 | The rate limit for authenticated users is 5,000 per hour, so it should be fine for normal use. 576 | 577 | > All of these requests count towards your personal rate limit of 5,000 requests per hour. 578 | 579 | ### Limitation 580 | 581 | GitHub App User Access Tokens can't write repositories where the GitHub App isn't installed. For instance, you can't create pull requests by `gh pr create` command to repositories where your GitHub App isn't installed. 582 | In case of `gh pr create`, `--web` option of `gh pr create` is useful. 583 | 584 | ## LICENSE 585 | 586 | [MIT](LICENSE) 587 | --------------------------------------------------------------------------------