Repository: Link-/gh-token Branch: main Commit: 08e7acb18e22 Files: 27 Total size: 93.2 KB Directory structure: gitextract_vc2g9202/ ├── .github/ │ ├── copilot-instructions.md │ ├── dependabot.yml │ └── workflows/ │ ├── codeql.yml │ ├── linter.yml │ ├── release.yml │ └── test.yml ├── .gitignore ├── .vscode/ │ └── launch.json ├── LICENSE ├── Makefile ├── README.md ├── SECURITY.md ├── go.mod ├── go.sum ├── internal/ │ ├── fixtures/ │ │ └── test-private-key.test.pem │ ├── generate.go │ ├── generate_flags.go │ ├── generate_test.go │ ├── installations.go │ ├── installations_flags.go │ ├── installations_test.go │ ├── key.go │ ├── revoke.go │ ├── revoke_flags.go │ └── revoke_test.go ├── main.go └── version.go ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/copilot-instructions.md ================================================ ## Project structure [Creates an installation access token](https://docs.github.com/en/rest/reference/apps#create-an-installation-access-token-for-an-app) to make authenticated API requests to github.com. ``` . └── internal: the core package where this utility is implemented ``` ## Coding instructions - Read the `Makefile` in the root directory for a list of targets and commands you can run - Add the necessary package dependencies before running unit tests, especially new mocks - Attempt to edit the files directly in vscode instead of relying on CLI commands like `sed` to find and replace. Use `sed` as a last restort or when it is more efficient - When creating new unit tests, append `_test.go` to the basename of the file that the unit tests should be covering. - When implementing unit tests, adopt the same style of other tests in the same test suite and file. If tabular tests are used write the new tests in that same style. If there are no tests in the same suite, look at the other tests in the same package. - Create all unit testing fixtures in the folder `fixtures` which must be a subdirectory of where the test files are located. - When implementing unit tests make sure to read the function you're implementing the test for first. - When updating unit tests make sure to read the function you're updating the tests for first. Fixing when and how often certain mocks are called might be sufficient to fix the tests. - In tabular unit tests, the `description` or `name` of the test case is a string that might include white spaces. When searching or running a specific test, white spaces need to be substituted with `_`. ## git operations - Never stage or commit changes without prompting the user for approval - Start commit messages with a verb (`Add`, `Update`, `Fix` etc.) - Do not use `feat:`, `chore:` or anything in that style for commit messages - Add details of what was changed to the body of the commit message. Be concise. - Never use: `git pull` `git push` `git merge` `git rebase` `git rm` ================================================ FILE: .github/dependabot.yml ================================================ version: 2 updates: - package-ecosystem: gomod directory: "/" schedule: interval: weekly - package-ecosystem: "github-actions" directory: "/" schedule: # Check for updates to GitHub Actions every week interval: "weekly" ================================================ FILE: .github/workflows/codeql.yml ================================================ name: "CodeQL" on: push: branches: - main pull_request: branches: - main schedule: - cron: '41 2 * * 6' concurrency: group: ${{ github.ref }}-${{ github.workflow }} cancel-in-progress: true jobs: analyze: name: Analyze runs-on: 'ubuntu-latest' timeout-minutes: 15 permissions: actions: read contents: read security-events: write steps: - name: Checkout repository uses: actions/checkout@v6 - name: Initialize CodeQL uses: github/codeql-action/init@v4 with: languages: go - name: Autobuild uses: github/codeql-action/autobuild@v4 - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@v4 with: category: "/language:go" ================================================ FILE: .github/workflows/linter.yml ================================================ name: Lint on: pull_request: branches: - main concurrency: group: ${{ github.ref }}-${{ github.workflow }} cancel-in-progress: true permissions: contents: read jobs: lint: name: Lint runs-on: ubuntu-latest steps: - name: Checkout Code uses: actions/checkout@v6 with: fetch-depth: 0 - name: Lint Code uses: golangci/golangci-lint-action@v9 ================================================ FILE: .github/workflows/release.yml ================================================ name: Release on: push: tags: - "v*" permissions: contents: write jobs: release: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - name: Verify tag matches version.go run: | TAG="${GITHUB_REF#refs/tags/v}" VERSION="$(grep -oP 'Version = "\K[^"]+' version.go)" if [ "$TAG" != "$VERSION" ]; then echo "::error::Tag v${TAG} does not match Version in version.go (${VERSION}). Please update version.go." exit 1 fi - uses: cli/gh-extension-precompile@v2 with: go_version_file: go.mod ================================================ FILE: .github/workflows/test.yml ================================================ name: Test on: push: branches: - main pull_request: branches: - main workflow_dispatch: inputs: branch: description: "Branch name to checkout and run tests for" required: false default: "refs/heads/main" type: string concurrency: group: ${{ github.ref }}-${{ github.workflow }} cancel-in-progress: true permissions: contents: read jobs: test: name: Unit Tests runs-on: ubuntu-latest steps: - name: Checkout Code uses: actions/checkout@v6 with: ref: ${{ inputs.branch || 'refs/heads/main' }} fetch-depth: 0 - uses: actions/setup-go@v6 with: go-version-file: go.mod - run: go version - name: Run unit tests run: make test integration: name: Integration Tests runs-on: ubuntu-latest if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'workflow_dispatch' steps: - name: Checkout Code uses: actions/checkout@v6 with: ref: ${{ inputs.branch || 'refs/heads/main' }} fetch-depth: 0 - uses: actions/setup-go@v6 with: go-version-file: go.mod - run: go version - name: Build run: make build - name: Generate installation access token from PEM key file run: | printf "%s" "$APP_PRIVATE_KEY" > private_key.pem ./gh-token \ generate \ -i "$APP_ID" \ -k private_key.pem > /dev/null 2 env: APP_ID: ${{ secrets.APP_ID }} APP_PRIVATE_KEY: ${{ secrets.APP_PRIVATE_KEY }} - name: List installations for the app from PEM key file run: | printf "%s" "$APP_PRIVATE_KEY" > private_key.pem ./gh-token \ installations \ -i "$APP_ID" \ -k private_key.pem > /dev/null 2 env: APP_ID: ${{ secrets.APP_ID }} APP_PRIVATE_KEY: ${{ secrets.APP_PRIVATE_KEY }} - name: Generate installation access token with base64 key run: | ./gh-token \ generate \ -i "$APP_ID" \ -b "$(echo "$APP_PRIVATE_KEY" | base64)" > /dev/null 2 env: APP_ID: ${{ secrets.APP_ID }} APP_PRIVATE_KEY: ${{ secrets.APP_PRIVATE_KEY }} - name: List installations for the app with base64 key run: | ./gh-token \ installations \ -i "$APP_ID" \ -b "$(echo "$APP_PRIVATE_KEY" | base64)" > /dev/null 2 env: APP_ID: ${{ secrets.APP_ID }} APP_PRIVATE_KEY: ${{ secrets.APP_PRIVATE_KEY }} - name: Generate then revoke token run: | printf "%s" "$APP_PRIVATE_KEY" > private_key.pemm token="$(./gh-token generate -i $APP_ID -k private_key.pem | jq -r '.token')" ./gh-token revoke -t $token env: APP_ID: ${{ secrets.APP_ID }} APP_PRIVATE_KEY: ${{ secrets.APP_PRIVATE_KEY }} ================================================ FILE: .gitignore ================================================ ### macOS ### # General .DS_Store .AppleDouble .LSOverride # Icon must end with two \r Icon # Thumbnails ._* # Files that might appear in the root of a volume .DocumentRevisions-V100 .fseventsd .Spotlight-V100 .TemporaryItems .Trashes .VolumeIcon.icns .com.apple.timemachine.donotpresent # Directories potentially created on remote AFP share .AppleDB .AppleDesktop Network Trash Folder Temporary Items .apdisk ### Vim ### # Swap [._]*.s[a-v][a-z] !*.svg # comment out if you don't need vector files [._]*.sw[a-p] [._]s[a-rt-v][a-z] [._]ss[a-gi-z] [._]sw[a-p] # Session Session.vim Sessionx.vim # Temporary .netrwhist *~ # Auto-generated tag files tags # Persistent undo [._]*.un~ ### VisualStudioCode ### .vscode/* !.vscode/tasks.json !.vscode/launch.json *.code-workspace ### VisualStudioCode Patch ### # Ignore all local history of files .history .ionide .keys ### Project files jwt # Generated files gh-token gh-token.exe *.out .bin/ # Test app keys *.pem !*.test.pem ================================================ FILE: .vscode/launch.json ================================================ { "version": "0.2.0", "configurations": [ { "name": "Debug gh token", "type": "go", "request": "launch", "mode": "debug", "program": "${workspaceFolder}", "env": {}, "args": [ "installations", "-k", "${workspaceFolder}/.keys/test-key.pem", "--app-id", "394554" ], "showLog": true, "trace": "verbose", } ] } ================================================ FILE: LICENSE ================================================ Copyright 2024 Link- Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: Makefile ================================================ # Makefile with the following targets: # all: build the project # clean: remove all build artifacts # build: build the project # test: run all unit tests # lint: run linting checks (golangci-lint) # install-lint-deps: install linting dependencies # release: create a new release by updating version numbers and committing changes # help: print this help message # .PHONY: mark targets as phony # .DEFAULT_GOAL: set the default goal to all # Set the default goal to all .DEFAULT_GOAL := all PROJECT_NAME := "gh-token" # Mark targets as phony .PHONY: all clean build test lint install-lint-deps release # Build the project all: clean build # Remove all build artifacts clean: rm -f gh-token rm -rf .bin # Build the project build: go build -o gh-token . # Run all unit tests test: go test ./... # Run linting checks lint: @test -f .bin/golangci-lint || $(MAKE) install-lint-deps ./.bin/golangci-lint run # Install linting dependencies install-lint-deps: @mkdir -p .bin curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh | sh -s -- -b .bin v2.4.0 # Create a new release release: @echo "Current version in version.go: $$(grep 'Version' version.go | sed 's/.*Version = "\(.*\)".*/\1/')" @echo "Current version in SECURITY.md: $$(grep -A2 '| Version' SECURITY.md | tail -1 | sed 's/| *\([0-9]*\.[0-9]*\.[0-9]*\).*/\1/')" @echo "" @read -p "Enter the new semver version (e.g., 2.1.0): " VERSION; \ if [ -z "$$VERSION" ]; then \ echo "Error: Version cannot be empty"; \ exit 1; \ fi; \ if ! echo "$$VERSION" | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$$' > /dev/null; then \ echo "Error: Version must be in semver format (e.g., 2.1.0)"; \ exit 1; \ fi; \ MAJOR_MINOR=$$(echo "$$VERSION" | sed 's/\([0-9]*\.[0-9]*\)\.[0-9]*/\1/'); \ echo "Updating version to $$VERSION..."; \ sed -i.bak 's/Version = "[^"]*"/Version = "'"$$VERSION"'"/' version.go && rm version.go.bak; \ sed -i.bak 's/| [0-9]*\.[0-9]*\.[0-9]* *|/| '"$$MAJOR_MINOR"'.x |/' SECURITY.md && rm SECURITY.md.bak; \ echo "Files updated successfully."; \ echo ""; \ echo "Staging and committing changes..."; \ git add version.go SECURITY.md; \ git commit -m "Update version to $$VERSION"; \ echo ""; \ echo "Changes committed successfully!"; \ echo ""; \ echo "Next steps:"; \ echo "- Go to https://github.com/Link-/gh-token/releases/new to create a new release"; \ echo "- Create a tag with the same version as the release ($$VERSION)"; \ echo "- The binaries will automatically be uploaded as assets once the release has been created" ================================================ FILE: README.md ================================================ # GH Token ```shell * _____ _ *_ _______ * _ * * ** * / ____| |* | | |__ __| | | * * 🦄 * | | *__| |_*| | ⭐️ | | ___ | | _____*_ __ * * | | |_ |* __ *| |*|/ _ \| |/ / _ \ '_ \ * * | |__| | | | | * | | (_)*| < __/ | | | * \_____|_| |_| |_|\___/|_|\_\___|_| |_| * ``` > Manage installation access tokens for GitHub apps from your terminal [![License](https://img.shields.io/github/license/link-/gh-token?style=flat-square)](LICENSE) [Creates an installation access token](https://docs.github.com/en/rest/reference/apps#create-an-installation-access-token-for-an-app) to make authenticated API requests. Installation tokens expire **1 hour** from the time you create them. Using an expired token produces a status code of `401 - Unauthorized`, and requires creating a new installation token. You can use this access token to make pretty much any REST or GraphQL API call the app is authorized to make! ![gh-token demo](./images/gh-token.png) ## Why? In order to use GitHub's [REST](https://docs.github.com/en/rest) or [GraphQL](https://docs.github.com/en/graphql) APIs you will need either a [Personal Access Token](https://docs.github.com/en/developers/apps/about-apps#personal-access-tokens) (PAT) or a [GitHub App](https://docs.github.com/en/developers/apps/about-apps#about-github-apps). **PATs are dangerous, they:** 1. have a very wide scope that spans across multiple organizations 1. never (automatically) expire. They have an indefinite lifetime (or at least until you regenerate them) 1. cannot be revoked (they're only revoked when a new one is generated) With an access token generated with a GitHub App you don't have to worry about the concerns above. These tokens have a limited scope and lifetime. Just make sure you handle the token safely (avoid leaking). In the worst case scenario, the token will expire in **1 hour from creation time.** ## Installation ### Download as a standalone binary Download `gh-token` from the [latest release](https://github.com/Link-/gh-token/releases/latest) for your platform. ### Install as a `gh` cli extension You can install `gh-token` as a [gh cli](https://github.com/cli/cli) extension! ```shell $ gh extension install Link-/gh-token # Verify installation $ gh token ``` All the commands and parameters remain the same, the only different is you now can use `gh token` instead of `gh-token`. ### Creating a GitHub App Follow [these steps](https://docs.github.com/en/developers/apps/creating-a-github-app) ## Usage Compatible with [GitHub Enterprise Server](https://github.com/enterprise). ```text NAME: gh-token - Manage GitHub App installation tokens USAGE: gh-token [global options] command [command options] [arguments...] VERSION: 2.0.2 COMMANDS: generate Generate a new GitHub App installation token revoke Revoke a GitHub App installation token installations List GitHub App installations help, h Shows a list of commands or help for one command GLOBAL OPTIONS: --help, -h show help --version, -v print the version ``` ### Examples in the Terminal #### Run `gh token` as a `gh` CLI extension ```shell gh token generate \ --key ./.keys/private-key.pem \ --app-id 1122334 \ --installation-id 5566778 ``` ```json { "token": "ghs_8Joht_______________bLCMS___M0EPOhJ", "expires_at": "2023-09-08T18:11:34Z", "permissions": { "actions": "write", "administration": "write", "metadata": "read", "members": "read", "organization_administration": "read" } } ``` #### Run `gh token` and pass the key as a base64 encoded string ```shell gh token generate \ --base64-key $(printf "%s" $APP_KEY | base64) \ --app-id 1122334 \ --installation-id 5566778 ``` ```json { "token": "ghs_8Joht_______________bLCMS___M0EPOhJ", "expires_at": "2023-09-08T18:11:34Z", "permissions": { "actions": "write", "administration": "write", "metadata": "read", "members": "read", "organization_administration": "read" } } ``` #### Run `gh token` with GitHub Enterprise Server ```shell gh token generate \ --base64-key $(printf "%s" $APP_KEY | base64) \ --app-id 1122334 \ --installation-id 5566778 \ --hostname "github.example.com" ``` ```json { "token": "ghs_8Joht_______________bLCMS___M0EPOhJ", "expires_at": "2023-09-08T18:11:34Z", "permissions": { "actions": "write", "administration": "write", "metadata": "read", "members": "read", "organization_administration": "read" } } ``` #### Fetch list of installations for an app ```shell gh token installations \ --key ./private-key.pem \ --app-id 2233445 ```
Response ```json [ { "id": 1, "account": { "login": "octocat", "id": 1, "node_id": "MDQ6VXNlcjE=", "avatar_url": "https://github.com/images/error/octocat_happy.gif", "gravatar_id": "", "url": "https://api.github.com/users/octocat", "html_url": "https://github.com/octocat", "followers_url": "https://api.github.com/users/octocat/followers", "following_url": "https://api.github.com/users/octocat/following{/other_user}", "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}", "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}", "subscriptions_url": "https://api.github.com/users/octocat/subscriptions", "organizations_url": "https://api.github.com/users/octocat/orgs", "repos_url": "https://api.github.com/users/octocat/repos", "events_url": "https://api.github.com/users/octocat/events{/privacy}", "received_events_url": "https://api.github.com/users/octocat/received_events", "type": "User", "site_admin": false }, "access_tokens_url": "https://api.github.com/installations/1/access_tokens", "repositories_url": "https://api.github.com/installation/repositories", "html_url": "https://github.com/organizations/github/settings/installations/1", "app_id": 1, "target_id": 1, "target_type": "Organization", "permissions": { "checks": "write", "metadata": "read", "contents": "read" }, "events": [ "push", "pull_request" ], "single_file_name": "config.yaml", "has_multiple_single_files": true, "single_file_paths": [ "config.yml", ".github/issue_TEMPLATE.md" ], "repository_selection": "selected", "created_at": "2017-07-08T16:18:44-04:00", "updated_at": "2017-07-08T16:18:44-04:00", "app_slug": "github-actions", "suspended_at": null, "suspended_by": null } ] ```
#### Revoke an installation access token ```shell gh token revoke \ --token "v1.bb1___168d_____________1202bb8753b133919" \ --hostname "github.example.com" ``` ```text Successfully revoked installation token ``` ### Example in a workflow
Expand to show instructions 1. You need to create a secret to store the **applications private key** securely (this can be an organization or a repository secret): ![Create private key secret](images/create_secret.png) 1. You need to create another secret to store the **application id** security (same as the step above). 1. The secrets need to be provided as an environment variable then encoded into base64 as show in the workflow example: This example is designed to run on GitHub Enterprise Server. To use the same workflow with GitHub.com update the hostname to `api.github.com` and change the API URL in the testing step. ```yaml name: Create access token via GitHub Apps Workflow on: workflow_dispatch: jobs: Test: # The type of runner that the job will run on runs-on: [ self-hosted ] steps: - name: "Install gh-token" run: gh extension install Link-/gh-token # Create access token with a GitHub App ID and Key # We use the private key stored as a secret and encode it into base64 # before passing it to gh-token - name: "Create access token" run: | token=$(gh token generate \ --base64-key $(printf "%s" "$APP_PRIVATE_KEY" | base64 -w 0) \ --app-id $APP_ID \ --hostname "github.example.com" \ | jq -r ".token") echo "token=$token" >> $GITHUB_OUTPUT env: APP_ID: ${{ secrets.APP_ID }} APP_PRIVATE_KEY: ${{ secrets.APP_KEY }} # To test the token we will use it to fetch the list of repositories # belonging to our organization - name: "Fetch organization repositories" run: | curl -X GET \ -H "Authorization: token $token" \ -H "Accept: application/vnd.github.v3+json" \ https://github.example.com/api/v3/orgs//repos ```
## Similar projects _These are not endorsements, just a listing of similar art work_ ### CLI - [apptokit](https://github.com/jakewilkins/apptokit) in Ruby - [gha-token](https://github.com/slawekzachcial/gha-token) in Go ### Actions - [create-github-app-token](https://github.com/actions/create-github-app-token) (GitHub official) - [workflow-application-token-action](https://github.com/peter-murray/workflow-application-token-action) - [action-github-app-token](https://github.com/getsentry/action-github-app-token) - [github-app-token-generator](https://github.com/navikt/github-app-token-generator) ================================================ FILE: SECURITY.md ================================================ # Security Policy ## Reporting a Vulnerability Use the [private vulnerability reporting feature](https://github.com/Link-/gh-token/security/advisories) to report security problems. ================================================ FILE: go.mod ================================================ module github.com/Link-/gh-token go 1.24.0 require ( github.com/golang-jwt/jwt/v5 v5.3.1 github.com/google/go-github/v55 v55.0.0 github.com/jarcoal/httpmock v1.4.1 github.com/stretchr/testify v1.11.1 github.com/urfave/cli/v2 v2.27.7 ) require ( github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371 // indirect github.com/cloudflare/circl v1.6.3 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/go-querystring v1.1.0 // indirect github.com/kr/pretty v0.3.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect golang.org/x/crypto v0.45.0 // indirect golang.org/x/sys v0.38.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) ================================================ FILE: go.sum ================================================ github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371 h1:kkhsdkhsCvIsutKu5zLMgWtgh9YxGCNAw8Ad8hjwfYg= github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0= github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0= github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA= github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-github/v55 v55.0.0 h1:4pp/1tNMB9X/LuAhs5i0KQAE40NmiR/y6prLNb9x9cg= github.com/google/go-github/v55 v55.0.0/go.mod h1:JLahOTA1DnXzhxEymmFF5PP2tSS9JVNj68mSZNDwskA= github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= github.com/jarcoal/httpmock v1.4.1 h1:0Ju+VCFuARfFlhVXFc2HxlcQkfB+Xq12/EotHko+x2A= github.com/jarcoal/httpmock v1.4.1/go.mod h1:ftW1xULwo+j0R0JJkJIIi7UKigZUXCLLanykgjwBXL0= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/maxatome/go-testdeep v1.14.0 h1:rRlLv1+kI8eOI3OaBXZwb3O7xY3exRzdW5QyX48g9wI= github.com/maxatome/go-testdeep v1.14.0/go.mod h1:lPZc/HAcJMP92l7yI6TRz1aZN5URwUBUAfUNvrclaNM= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU= github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4= github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= ================================================ FILE: internal/fixtures/test-private-key.test.pem ================================================ -----BEGIN PRIVATE KEY----- MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCL02nUZNMcAaOs J+L5qKErTjABtmS6CBgCbDbUN1u8Ds7hNMbYM/+P4W6OWbITnJD4V1sHpEj4/5u9 tkqQt0x7IrXEAE2D+zWd4ntkdT7qDO3hpxj4/nPRakp7W2w9iLJNSeVLTjoVrnuQ GsGpR4NCu4PJOerLpmZXF6V+5IUyTnK2TC6NkXMl1YQ+plo0tmLjayOYOFyNhSk0 2fNUo+Iwzse5XUqDncBkBKwHjwYnRVjB+fcn5WT88VWz4ejaXLD2DMcZmQmU+clP 3r+AJddbk4cVHlGAW99uQ+c6M8M215wOg6cwpvXX20DY+Z4I56TIVq7WpHQyyrev noZ6O5/LAgMBAAECggEAA0arSpooJhZVvuFaXI4aZJja4BdlacRpx5jAeh1n7VKN f1JMvGEPgk/+VqB8XyBCd0cYr2emfAsFG59LRPO+e34XMyXsqwR2P6JAUNy8YiB2 bFyNZbwUe5oZb6V3NkPfJZdvI2IMU1i4tWojEnPF/AjHsC3GtgnKiQzZSE1TX5fV E1PnG9u1x4FmcPBv6eL0mQ20MGYpt+l2kB1xSfFE72P4MYZ+xEAlbr94yA8ISVtp krh0xB77qU/kWnVw07DOneWhAJdRAhNRD+ZcU+UYfBzkvCNOprjiMYQxjjmmjouh RrkZd8AGnbCDe6SW2fhY6On0Cp8o/Wuz4NI58JfivQKBgQDAHjarGMeNanvxLDlx shY1xoLOxF/1ikhn+CFTW4sPZQOxOcsb/GmjwON1/4we2c8uZGdNd2tEPvDgfWhu XTDyOHU0JjsQEs0TsvUr1Jf1BVecFnu8dTC+sfUmNCBwwqyfp6KqDjrBmRVHRiRn L8rCQBDf3bk9ib2t0PNjk/v2DQKBgQC6UeXs3RpROxyQQ7Y05diyfPjL2V8tOzms t61NzH1LDv9snEyG7sB12b8sm0HIeka+uZkVdiqjZvbtXDAS2ZhlWBn3clr1Bmnf gdVIRQicXNb+aZRc2pBCGLCbcRNxGQYWRKNv3RFdDlG93ILwO1yXoY3Bie6yL+Vk eoj/lasPNwKBgQC3pupJqvlwBUAQL1+GgWBb7bUz5WN5/MP0p61r2xHXGJBsBbxU t3lg8c4/CZgwEbTNO2vJEQR4i9aGMzv2bJ2Sn0fjHzzMw7xJPYTDbooIzx+N9aw5 XqnHUaTw7VmpkV+li4GjINEoKqe9p567CWPBR68Z4gHngtnQ4/MW2Os+rQKBgDGU 2brOm9JCCLfbTQGGqMPWvd6BWfKPcCmmN1gcsrrmotIkRbkij9TMvTMBnd/bqjfW 7AXqDC6vl8ZSYfiiLwvJBh/zLoFF06bGxhsVQ9VYX14Ueoa7Iuhz6Ytz69iM8DG8 0kFScuxwgxAjPjTvlxRCyZZXPk3ssP6sHQjmqz7BAoGAHONxPaL5narYxGgySg49 j3Ib9NROs2ys05dlTOZd0NYfkOsxtlNNncHGZ9NiikwtuJLeDibormsJhIgi8XXd kB8fa/kQXRWBNbD56ExVhSQiTPMNQXKXYXX2Ix/xcrHgzZzS0gAof7NKxwiuyhyi XeN8Q9ABpmAa5V1YvCP4qKM= -----END PRIVATE KEY----- ================================================ FILE: internal/generate.go ================================================ package internal import ( "crypto/rsa" "encoding/json" "fmt" "io" "net/http" "strconv" "strings" "github.com/google/go-github/v55/github" "github.com/urfave/cli/v2" ) // Generate is the entrypoint for the generate command func Generate(c *cli.Context) error { appID := c.String("app-id") installationID := c.String("installation-id") keyPath := c.String("key") keyBase64 := c.String("base64-key") printJWT := c.Bool("jwt") jwtExpiry := c.Int("duration") hostname := strings.ToLower(c.String("hostname")) tokenOnly := c.Bool("token-only") silent := c.Bool("silent") if keyPath == "" && keyBase64 == "" { return fmt.Errorf("either --key or --base64-key must be specified") } if keyPath != "" && keyBase64 != "" { return fmt.Errorf("only one of --key or --base64-key may be specified") } if hostname != "api.github.com" && !strings.Contains(hostname, "/api/v3") { endpoint := fmt.Sprintf("%s/api/v3", hostname) hostname = strings.TrimSuffix(endpoint, "/") } if jwtExpiry < 1 || jwtExpiry > 10 { jwtExpiry = 10 } var err error var privateKey *rsa.PrivateKey if keyPath != "" { privateKey, err = readKey(keyPath) if err != nil { return err } } else { privateKey, err = readKeyBase64(keyBase64) if err != nil { return err } } jsonWebToken, err := generateJWT(appID, jwtExpiry, privateKey) if err != nil { return fmt.Errorf("failed generating JWT: %w", err) } if printJWT { if !silent { fmt.Println(jsonWebToken) } return nil } if installationID == "" { installationID, err = retrieveDefaultInstallationID(hostname, jsonWebToken) if err != nil { return fmt.Errorf("failed retrieving default installation ID: %w", err) } } token, err := generateToken(hostname, jsonWebToken, installationID) if err != nil { return fmt.Errorf("failed generating installation token: %w", err) } if !silent { bytes, err := json.MarshalIndent(token, "", " ") if err != nil { return fmt.Errorf("failed to marshal token to JSON: %w", err) } if tokenOnly { fmt.Println(*token.Token) } else { fmt.Println(string(bytes)) } } return nil } func retrieveDefaultInstallationID(hostname, jwt string) (string, error) { endpoint := fmt.Sprintf("https://%s/app/installations?per_page=1", hostname) req, err := http.NewRequest("GET", endpoint, nil) if err != nil { return "", fmt.Errorf("unable to create GET request to %s: %w", endpoint, err) } req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", jwt)) req.Header.Add("Accept", "application/vnd.github+json") req.Header.Add("X-GitHub-Api-Version", "2022-11-28") req.Header.Add("User-Agent", "Link-/gh-token") client := &http.Client{} resp, err := client.Do(req) if err != nil { return "", fmt.Errorf("unable to GET %s: %w", endpoint, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != 200 { return "", fmt.Errorf("unexpected status code: %d", resp.StatusCode) } var response []github.Installation bytes, err := io.ReadAll(resp.Body) if err != nil { return "", fmt.Errorf("unable to read response body: %w", err) } err = json.Unmarshal(bytes, &response) if err != nil { return "", fmt.Errorf("unable to unmarshal response body: %w", err) } return strconv.FormatInt(*response[0].ID, 10), nil } func generateToken(hostname, jwt, installationID string) (*github.InstallationToken, error) { endpoint := fmt.Sprintf("https://%s/app/installations/%s/access_tokens", hostname, installationID) req, err := http.NewRequest("POST", endpoint, nil) if err != nil { return nil, fmt.Errorf("unable to create POST request to %s: %w", endpoint, err) } req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", jwt)) req.Header.Add("Accept", "application/vnd.github+json") req.Header.Add("X-GitHub-Api-Version", "2022-11-28") req.Header.Add("User-Agent", "Link-/gh-token") client := &http.Client{} resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("unable to POST to %s: %w", endpoint, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != 201 { return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) } var response *github.InstallationToken bytes, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("unable to read response body: %w", err) } err = json.Unmarshal(bytes, &response) if err != nil { return nil, fmt.Errorf("unable to unmarshal response body: %w", err) } return response, nil } ================================================ FILE: internal/generate_flags.go ================================================ package internal import "github.com/urfave/cli/v2" // GenerateFlags returns the CLI flags for the generate command func GenerateFlags() []cli.Flag { return []cli.Flag{ &cli.StringFlag{ Name: "app-id", Usage: "GitHub App ID", Required: true, Aliases: []string{"i", "app_id"}, }, &cli.StringFlag{ Name: "installation-id", Usage: "GitHub App installation ID. Defaults to the first installation returned by the GitHub API if not specified", Required: false, Aliases: []string{"l", "installation_id"}, }, &cli.StringFlag{ Name: "key", Usage: "Path to private key", Required: false, Aliases: []string{"k"}, }, &cli.StringFlag{ Name: "base64-key", Usage: "A base64 encoded private key", Required: false, Aliases: []string{"b", "base64_key"}, }, &cli.StringFlag{ Name: "hostname", Usage: "GitHub Enterprise Server API endpoint, example: github.example.com", Required: false, Aliases: []string{"o"}, Value: "api.github.com", }, &cli.BoolFlag{ Name: "token-only", Usage: "Only print the token to stdout, not the full JSON response, useful for piping to other commands", Aliases: []string{"t"}, Value: false, }, &cli.BoolFlag{ Name: "jwt", Usage: "Return the JWT instead of generating an installation token, useful for calling API's requiring a JWT", Required: false, Aliases: []string{"j"}, Value: false, }, &cli.IntFlag{ Name: "duration", Usage: "The expiry time of the JWT in minutes up to a maximum value of 10, useful when using the --jwt flag", Required: false, Aliases: []string{"d"}, Value: 1, }, &cli.BoolFlag{ Name: "silent", Usage: "Do not print token to stdout", Aliases: []string{"s"}, Value: false, }, } } ================================================ FILE: internal/generate_test.go ================================================ package internal import ( "encoding/base64" "encoding/json" "flag" "fmt" "os" "testing" "time" "github.com/google/go-github/v55/github" "github.com/jarcoal/httpmock" "github.com/stretchr/testify/assert" "github.com/urfave/cli/v2" ) // createTestContext creates a test CLI context with the given flags func createTestContext(flags map[string]interface{}) *cli.Context { app := &cli.App{} set := flag.NewFlagSet("test", flag.ContinueOnError) // Set default values defaults := map[string]interface{}{ "app-id": "", "installation-id": "", "key": "", "base64-key": "", "jwt": false, "jwt-expiry": 10, "hostname": "api.github.com", "token-only": false, "silent": false, } // Override with test-specific flags for k, v := range flags { defaults[k] = v } // Set up flags based on type for key, value := range defaults { switch v := value.(type) { case string: set.String(key, v, "") case bool: set.Bool(key, v, "") case int: set.Int(key, v, "") } } return cli.NewContext(app, set, nil) } // getBoolFlag safely gets bool values from flags map func getBoolFlag(flags map[string]interface{}, key string) bool { if val, ok := flags[key].(bool); ok { return val } return false } func TestGenerate(t *testing.T) { // Setup httpmock.Activate() defer httpmock.DeactivateAndReset() // Read test key for base64 encoding keyBytes, err := os.ReadFile("fixtures/test-private-key.test.pem") assert.NoError(t, err) keyBase64 := base64.StdEncoding.EncodeToString(keyBytes) installationResponse := []github.Installation{ {ID: github.Int64(12345)}, } installationJSON, _ := json.Marshal(installationResponse) tokenResponse := &github.InstallationToken{ Token: github.String("ghs_test_token_123"), ExpiresAt: &github.Timestamp{Time: time.Now().Add(time.Hour)}, } tokenJSON, _ := json.Marshal(tokenResponse) tests := []struct { name string flags map[string]interface{} setupMocks func() expectedError string }{ { name: "successful_token_generation_with_key_file", flags: map[string]interface{}{ "app-id": "123456", "installation-id": "12345", "key": "fixtures/test-private-key.test.pem", "hostname": "api.github.com", "jwt-expiry": 10, "silent": true, }, setupMocks: func() { httpmock.RegisterResponder("POST", "https://api.github.com/app/installations/12345/access_tokens", httpmock.NewStringResponder(201, string(tokenJSON))) }, expectedError: "", }, { name: "successful_token_generation_with_base64_key", flags: map[string]interface{}{ "app-id": "123456", "installation-id": "12345", "base64-key": keyBase64, "hostname": "api.github.com", "jwt-expiry": 10, "silent": true, }, setupMocks: func() { httpmock.RegisterResponder("POST", "https://api.github.com/app/installations/12345/access_tokens", httpmock.NewStringResponder(201, string(tokenJSON))) }, expectedError: "", }, { name: "successful_with_auto_installation_id", flags: map[string]interface{}{ "app-id": "123456", "key": "fixtures/test-private-key.test.pem", "hostname": "api.github.com", "jwt-expiry": 10, "silent": true, }, setupMocks: func() { httpmock.RegisterResponder("GET", "https://api.github.com/app/installations?per_page=1", httpmock.NewStringResponder(200, string(installationJSON))) httpmock.RegisterResponder("POST", "https://api.github.com/app/installations/12345/access_tokens", httpmock.NewStringResponder(201, string(tokenJSON))) }, expectedError: "", }, { name: "successful_jwt_only", flags: map[string]interface{}{ "app-id": "123456", "key": "fixtures/test-private-key.test.pem", "jwt": true, "jwt-expiry": 10, "silent": true, }, setupMocks: func() {}, expectedError: "", }, { name: "error_no_key_specified", flags: map[string]interface{}{ "app-id": "123456", }, setupMocks: func() {}, expectedError: "either --key or --base64-key must be specified", }, { name: "error_both_keys_specified", flags: map[string]interface{}{ "app-id": "123456", "key": "fixtures/test-private-key.test.pem", "base64-key": keyBase64, }, setupMocks: func() {}, expectedError: "only one of --key or --base64-key may be specified", }, { name: "error_invalid_key_file", flags: map[string]interface{}{ "app-id": "123456", "key": "fixtures/nonexistent.pem", }, setupMocks: func() {}, expectedError: "unable to read key file", }, { name: "error_invalid_base64_key", flags: map[string]interface{}{ "app-id": "123456", "base64-key": "invalid-base64-string", }, setupMocks: func() {}, expectedError: "unable to decode key from base64", }, { name: "error_installation_not_found", flags: map[string]interface{}{ "app-id": "123456", "key": "fixtures/test-private-key.test.pem", }, setupMocks: func() { httpmock.RegisterResponder("GET", "https://api.github.com/app/installations?per_page=1", httpmock.NewStringResponder(404, `{"message": "Not Found"}`)) }, expectedError: "failed retrieving default installation ID: unexpected status code: 404", }, { name: "error_token_generation_fails", flags: map[string]interface{}{ "app-id": "123456", "installation-id": "12345", "key": "fixtures/test-private-key.test.pem", }, setupMocks: func() { httpmock.RegisterResponder("POST", "https://api.github.com/app/installations/12345/access_tokens", httpmock.NewStringResponder(403, `{"message": "Forbidden"}`)) }, expectedError: "failed generating installation token: unexpected status code: 403", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Reset mocks for each test httpmock.Reset() tt.setupMocks() // Create CLI context ctx := createTestContext(tt.flags) // Execute the function err := Generate(ctx) // Assert results if tt.expectedError != "" { assert.Error(t, err) assert.Contains(t, err.Error(), tt.expectedError) } else { assert.NoError(t, err) } // Verify HTTP calls were made as expected if tt.expectedError == "" && !getBoolFlag(tt.flags, "jwt") { info := httpmock.GetCallCountInfo() assert.Greater(t, len(info), 0, "Expected HTTP calls to be made") } }) } } func TestRetrieveDefaultInstallationID(t *testing.T) { httpmock.Activate() defer httpmock.DeactivateAndReset() tests := []struct { name string hostname string jwt string responseCode int responseBody string expectedResult string expectedError string }{ { name: "successful_retrieval", hostname: "api.github.com", jwt: "test.jwt.token", responseCode: 200, responseBody: `[{"id": 12345}]`, expectedResult: "12345", expectedError: "", }, { name: "not_found", hostname: "api.github.com", jwt: "test.jwt.token", responseCode: 404, responseBody: `{"message": "Not Found"}`, expectedResult: "", expectedError: "unexpected status code: 404", }, { name: "invalid_json_response", hostname: "api.github.com", jwt: "test.jwt.token", responseCode: 200, responseBody: "invalid json", expectedResult: "", expectedError: "unable to unmarshal response body", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { httpmock.Reset() endpoint := fmt.Sprintf("https://%s/app/installations?per_page=1", tt.hostname) httpmock.RegisterResponder("GET", endpoint, httpmock.NewStringResponder(tt.responseCode, tt.responseBody)) result, err := retrieveDefaultInstallationID(tt.hostname, tt.jwt) if tt.expectedError != "" { assert.Error(t, err) assert.Contains(t, err.Error(), tt.expectedError) assert.Equal(t, "", result) } else { assert.NoError(t, err) assert.Equal(t, tt.expectedResult, result) } // Verify the request was made with correct headers info := httpmock.GetCallCountInfo() assert.Equal(t, 1, info[fmt.Sprintf("GET %s", endpoint)]) }) } } func TestGenerateToken(t *testing.T) { httpmock.Activate() defer httpmock.DeactivateAndReset() tokenResponse := &github.InstallationToken{ Token: github.String("ghs_test_token_123"), ExpiresAt: &github.Timestamp{Time: time.Now().Add(time.Hour)}, } tokenJSON, _ := json.Marshal(tokenResponse) tests := []struct { name string hostname string jwt string installationID string responseCode int responseBody string expectedError string }{ { name: "successful_token_generation", hostname: "api.github.com", jwt: "test.jwt.token", installationID: "12345", responseCode: 201, responseBody: string(tokenJSON), expectedError: "", }, { name: "forbidden_error", hostname: "api.github.com", jwt: "test.jwt.token", installationID: "12345", responseCode: 403, responseBody: `{"message": "Forbidden"}`, expectedError: "unexpected status code: 403", }, { name: "invalid_json_response", hostname: "api.github.com", jwt: "test.jwt.token", installationID: "12345", responseCode: 201, responseBody: "invalid json", expectedError: "unable to unmarshal response body", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { httpmock.Reset() endpoint := fmt.Sprintf("https://%s/app/installations/%s/access_tokens", tt.hostname, tt.installationID) httpmock.RegisterResponder("POST", endpoint, httpmock.NewStringResponder(tt.responseCode, tt.responseBody)) result, err := generateToken(tt.hostname, tt.jwt, tt.installationID) if tt.expectedError != "" { assert.Error(t, err) assert.Contains(t, err.Error(), tt.expectedError) assert.Nil(t, result) } else { assert.NoError(t, err) assert.NotNil(t, result) assert.Equal(t, "ghs_test_token_123", *result.Token) } // Verify the request was made with correct headers info := httpmock.GetCallCountInfo() assert.Equal(t, 1, info[fmt.Sprintf("POST %s", endpoint)]) }) } } func TestGenerateAdvancedCases(t *testing.T) { httpmock.Activate() defer httpmock.DeactivateAndReset() tests := []struct { name string setupTest func() *cli.Context setupMocks func() expectedError string }{ { name: "jwt_expiry_below_minimum", setupTest: func() *cli.Context { return createTestContext(map[string]interface{}{ "app-id": "123456", "key": "fixtures/test-private-key.test.pem", "jwt-expiry": 0, // Below minimum, should be adjusted to 10 "jwt": true, "silent": true, }) }, setupMocks: func() {}, expectedError: "", }, { name: "jwt_expiry_above_maximum", setupTest: func() *cli.Context { return createTestContext(map[string]interface{}{ "app-id": "123456", "key": "fixtures/test-private-key.test.pem", "jwt-expiry": 15, // Above maximum, should be adjusted to 10 "jwt": true, "silent": true, }) }, setupMocks: func() {}, expectedError: "", }, { name: "hostname_without_api_path", setupTest: func() *cli.Context { return createTestContext(map[string]interface{}{ "app-id": "123456", "installation-id": "12345", "key": "fixtures/test-private-key.test.pem", "hostname": "github.company.com", // Without /api/v3 "silent": true, }) }, setupMocks: func() { tokenResponse := &github.InstallationToken{ Token: github.String("ghs_test_token_123"), ExpiresAt: &github.Timestamp{Time: time.Now().Add(time.Hour)}, } tokenJSON, _ := json.Marshal(tokenResponse) httpmock.RegisterResponder("POST", "https://github.company.com/api/v3/app/installations/12345/access_tokens", httpmock.NewStringResponder(201, string(tokenJSON))) }, expectedError: "", }, { name: "hostname_with_api_path_already_included", setupTest: func() *cli.Context { return createTestContext(map[string]interface{}{ "app-id": "123456", "installation-id": "12345", "key": "fixtures/test-private-key.test.pem", "hostname": "github.company.com/api/v3", // Already has /api/v3 "silent": true, }) }, setupMocks: func() { tokenResponse := &github.InstallationToken{ Token: github.String("ghs_test_token_123"), ExpiresAt: &github.Timestamp{Time: time.Now().Add(time.Hour)}, } tokenJSON, _ := json.Marshal(tokenResponse) httpmock.RegisterResponder("POST", "https://github.company.com/api/v3/app/installations/12345/access_tokens", httpmock.NewStringResponder(201, string(tokenJSON))) }, expectedError: "", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { httpmock.Reset() tt.setupMocks() ctx := tt.setupTest() err := Generate(ctx) if tt.expectedError != "" { assert.Error(t, err) assert.Contains(t, err.Error(), tt.expectedError) } else { assert.NoError(t, err) } }) } } // TestGenerateWithOutputFormats tests different output formats func TestGenerateWithOutputFormats(t *testing.T) { httpmock.Activate() defer httpmock.DeactivateAndReset() tokenResponse := &github.InstallationToken{ Token: github.String("ghs_test_token_123"), ExpiresAt: &github.Timestamp{Time: time.Now().Add(time.Hour)}, } tokenJSON, _ := json.Marshal(tokenResponse) tests := []struct { name string flags map[string]interface{} setupMocks func() }{ { name: "json_output_format", flags: map[string]interface{}{ "app-id": "123456", "installation-id": "12345", "key": "fixtures/test-private-key.test.pem", "hostname": "api.github.com", "jwt-expiry": 10, "silent": false, // To test JSON output }, setupMocks: func() { httpmock.RegisterResponder("POST", "https://api.github.com/app/installations/12345/access_tokens", httpmock.NewStringResponder(201, string(tokenJSON))) }, }, { name: "token_only_output_format", flags: map[string]interface{}{ "app-id": "123456", "installation-id": "12345", "key": "fixtures/test-private-key.test.pem", "hostname": "api.github.com", "jwt-expiry": 10, "token-only": true, "silent": false, // To test token-only output }, setupMocks: func() { httpmock.RegisterResponder("POST", "https://api.github.com/app/installations/12345/access_tokens", httpmock.NewStringResponder(201, string(tokenJSON))) }, }, { name: "jwt_output_format", flags: map[string]interface{}{ "app-id": "123456", "key": "fixtures/test-private-key.test.pem", "jwt": true, "jwt-expiry": 10, "silent": false, // To test JWT output }, setupMocks: func() {}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { httpmock.Reset() tt.setupMocks() ctx := createTestContext(tt.flags) err := Generate(ctx) assert.NoError(t, err) // Verify HTTP calls were made as expected (except for JWT-only) if !getBoolFlag(tt.flags, "jwt") { info := httpmock.GetCallCountInfo() assert.Greater(t, len(info), 0, "Expected HTTP calls to be made") } }) } } ================================================ FILE: internal/installations.go ================================================ package internal import ( "crypto/rsa" "encoding/json" "fmt" "io" "net/http" "strings" "time" "github.com/google/go-github/v55/github" "github.com/urfave/cli/v2" ) // Installations is the entrypoint for the installations command func Installations(c *cli.Context) error { appID := c.String("app-id") keyPath := c.String("key") keyBase64 := c.String("base64-key") hostname := strings.ToLower(c.String("hostname")) if keyPath == "" && keyBase64 == "" { return fmt.Errorf("either --key or --base64-key must be specified") } if keyPath != "" && keyBase64 != "" { return fmt.Errorf("only one of --key or --base64-key may be specified") } if hostname != "api.github.com" && !strings.Contains(hostname, "/api/v3") { endpoint := fmt.Sprintf("%s/api/v3", hostname) hostname = strings.TrimSuffix(endpoint, "/") } var err error var privateKey *rsa.PrivateKey if keyPath != "" { privateKey, err = readKey(keyPath) if err != nil { return err } } else { privateKey, err = readKeyBase64(keyBase64) if err != nil { return err } } jsonWebToken, err := generateJWT(appID, 1, privateKey) if err != nil { return fmt.Errorf("failed generating JWT: %w", err) } installations, err := listInstallations(hostname, jsonWebToken) if err != nil { return fmt.Errorf("failed listing installations: %w", err) } bytes, err := json.MarshalIndent(installations, "", " ") if err != nil { return fmt.Errorf("failed marshalling installations to JSON: %w", err) } if len(*installations) < 1 { fmt.Println("[]") return nil } fmt.Println(string(bytes)) return nil } func listInstallations(hostname, jwt string) (*[]github.Installation, error) { page := 0 var responses []github.Installation for { endpoint := fmt.Sprintf("https://%s/app/installations?per_page=100&page=%d", hostname, page) req, err := http.NewRequest("GET", endpoint, nil) if err != nil { return nil, fmt.Errorf("unable to create GET request to %s: %w", endpoint, err) } req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", jwt)) req.Header.Add("Accept", "application/vnd.github+json") req.Header.Add("X-GitHub-Api-Version", "2022-11-28") req.Header.Add("User-Agent", "Link-/gh-token") client := &http.Client{} resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("unable to POST to %s: %w", endpoint, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != 200 { return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) } var response *[]github.Installation bytes, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("unable to read response body: %w", err) } err = json.Unmarshal(bytes, &response) if err != nil { return nil, fmt.Errorf("unable to unmarshal response body: %w", err) } responses = append(responses, *response...) if len(*response) < 100 { break } page++ time.Sleep(1 * time.Second) } return &responses, nil } ================================================ FILE: internal/installations_flags.go ================================================ package internal import "github.com/urfave/cli/v2" // InstallationsFlags returns the CLI flags for the generate command func InstallationsFlags() []cli.Flag { return []cli.Flag{ &cli.StringFlag{ Name: "app-id", Usage: "GitHub App ID", Required: true, Aliases: []string{"i", "app_id"}, }, &cli.StringFlag{ Name: "key", Usage: "Path to private key", Required: false, Aliases: []string{"k"}, }, &cli.StringFlag{ Name: "base64-key", Usage: "A base64 encoded private key", Required: false, Aliases: []string{"b", "base64_key"}, }, &cli.StringFlag{ Name: "hostname", Usage: "GitHub Enterprise Server API endpoint, example: github.example.com", Required: false, Aliases: []string{"o"}, Value: "api.github.com", }, } } ================================================ FILE: internal/installations_test.go ================================================ package internal import ( "encoding/base64" "encoding/json" "flag" "fmt" "net/http" "os" "testing" "github.com/google/go-github/v55/github" "github.com/jarcoal/httpmock" "github.com/stretchr/testify/assert" "github.com/urfave/cli/v2" ) // createTestContextForInstallations creates a test CLI context with the given flags for installations command func createTestContextForInstallations(flags map[string]interface{}) *cli.Context { app := &cli.App{} set := flag.NewFlagSet("test", flag.ContinueOnError) // Set default values defaults := map[string]interface{}{ "app-id": "", "key": "", "base64-key": "", "hostname": "api.github.com", } // Override with test-specific flags for k, v := range flags { defaults[k] = v } // Set up flags based on type for key, value := range defaults { switch v := value.(type) { case string: set.String(key, v, "") case bool: set.Bool(key, v, "") case int: set.Int(key, v, "") } } return cli.NewContext(app, set, nil) } func TestInstallations(t *testing.T) { // Setup httpmock.Activate() defer httpmock.DeactivateAndReset() // Read test key for base64 encoding keyBytes, err := os.ReadFile("fixtures/test-private-key.test.pem") assert.NoError(t, err) keyBase64 := base64.StdEncoding.EncodeToString(keyBytes) // Sample installation responses singleInstallationResponse := []github.Installation{ { ID: github.Int64(12345), Account: &github.User{Login: github.String("testuser")}, }, } singleInstallationJSON, _ := json.Marshal(singleInstallationResponse) multipleInstallationsResponse := []github.Installation{ { ID: github.Int64(12345), Account: &github.User{Login: github.String("testuser1")}, }, { ID: github.Int64(67890), Account: &github.User{Login: github.String("testuser2")}, }, } multipleInstallationsJSON, _ := json.Marshal(multipleInstallationsResponse) emptyInstallationsResponse := []github.Installation{} emptyInstallationsJSON, _ := json.Marshal(emptyInstallationsResponse) tests := []struct { name string flags map[string]interface{} setupMocks func() expectedError string }{ { name: "successful_list_installations_with_key_file", flags: map[string]interface{}{ "app-id": "123456", "key": "fixtures/test-private-key.test.pem", "hostname": "api.github.com", }, setupMocks: func() { httpmock.RegisterResponder("GET", "https://api.github.com/app/installations?per_page=100&page=0", httpmock.NewStringResponder(200, string(singleInstallationJSON))) }, expectedError: "", }, { name: "successful_list_installations_with_base64_key", flags: map[string]interface{}{ "app-id": "123456", "base64-key": keyBase64, "hostname": "api.github.com", }, setupMocks: func() { httpmock.RegisterResponder("GET", "https://api.github.com/app/installations?per_page=100&page=0", httpmock.NewStringResponder(200, string(singleInstallationJSON))) }, expectedError: "", }, { name: "successful_list_multiple_installations", flags: map[string]interface{}{ "app-id": "123456", "key": "fixtures/test-private-key.test.pem", "hostname": "api.github.com", }, setupMocks: func() { httpmock.RegisterResponder("GET", "https://api.github.com/app/installations?per_page=100&page=0", httpmock.NewStringResponder(200, string(multipleInstallationsJSON))) }, expectedError: "", }, { name: "successful_empty_installations_list", flags: map[string]interface{}{ "app-id": "123456", "key": "fixtures/test-private-key.test.pem", "hostname": "api.github.com", }, setupMocks: func() { httpmock.RegisterResponder("GET", "https://api.github.com/app/installations?per_page=100&page=0", httpmock.NewStringResponder(200, string(emptyInstallationsJSON))) }, expectedError: "", }, { name: "successful_with_custom_hostname_without_api_path", flags: map[string]interface{}{ "app-id": "123456", "key": "fixtures/test-private-key.test.pem", "hostname": "github.company.com", }, setupMocks: func() { httpmock.RegisterResponder("GET", "https://github.company.com/api/v3/app/installations?per_page=100&page=0", httpmock.NewStringResponder(200, string(singleInstallationJSON))) }, expectedError: "", }, { name: "successful_with_custom_hostname_with_api_path", flags: map[string]interface{}{ "app-id": "123456", "key": "fixtures/test-private-key.test.pem", "hostname": "github.company.com/api/v3", }, setupMocks: func() { httpmock.RegisterResponder("GET", "https://github.company.com/api/v3/app/installations?per_page=100&page=0", httpmock.NewStringResponder(200, string(singleInstallationJSON))) }, expectedError: "", }, { name: "successful_with_mixed_case_hostname", flags: map[string]interface{}{ "app-id": "123456", "key": "fixtures/test-private-key.test.pem", "hostname": "GitHub.Company.COM", }, setupMocks: func() { httpmock.RegisterResponder("GET", "https://github.company.com/api/v3/app/installations?per_page=100&page=0", httpmock.NewStringResponder(200, string(singleInstallationJSON))) }, expectedError: "", }, { name: "error_no_key_specified", flags: map[string]interface{}{ "app-id": "123456", }, setupMocks: func() {}, expectedError: "either --key or --base64-key must be specified", }, { name: "error_both_keys_specified", flags: map[string]interface{}{ "app-id": "123456", "key": "fixtures/test-private-key.test.pem", "base64-key": keyBase64, }, setupMocks: func() {}, expectedError: "only one of --key or --base64-key may be specified", }, { name: "error_invalid_key_file", flags: map[string]interface{}{ "app-id": "123456", "key": "fixtures/nonexistent.pem", }, setupMocks: func() {}, expectedError: "unable to read key file", }, { name: "error_invalid_base64_key", flags: map[string]interface{}{ "app-id": "123456", "base64-key": "invalid-base64-string", }, setupMocks: func() {}, expectedError: "unable to decode key from base64", }, { name: "error_http_request_fails", flags: map[string]interface{}{ "app-id": "123456", "key": "fixtures/test-private-key.test.pem", "hostname": "api.github.com", }, setupMocks: func() { httpmock.RegisterResponder("GET", "https://api.github.com/app/installations?per_page=100&page=0", httpmock.NewErrorResponder(fmt.Errorf("network error"))) }, expectedError: "failed listing installations", }, { name: "error_http_status_not_200", flags: map[string]interface{}{ "app-id": "123456", "key": "fixtures/test-private-key.test.pem", "hostname": "api.github.com", }, setupMocks: func() { httpmock.RegisterResponder("GET", "https://api.github.com/app/installations?per_page=100&page=0", httpmock.NewStringResponder(404, `{"message": "Not Found"}`)) }, expectedError: "failed listing installations: unexpected status code: 404", }, { name: "error_invalid_json_response", flags: map[string]interface{}{ "app-id": "123456", "key": "fixtures/test-private-key.test.pem", "hostname": "api.github.com", }, setupMocks: func() { httpmock.RegisterResponder("GET", "https://api.github.com/app/installations?per_page=100&page=0", httpmock.NewStringResponder(200, "invalid json")) }, expectedError: "failed listing installations: unable to unmarshal response body", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Reset mocks for each test httpmock.Reset() tt.setupMocks() // Create CLI context ctx := createTestContextForInstallations(tt.flags) // Execute the function err := Installations(ctx) // Assert results if tt.expectedError != "" { assert.Error(t, err) assert.Contains(t, err.Error(), tt.expectedError) } else { assert.NoError(t, err) // Verify HTTP calls were made as expected info := httpmock.GetCallCountInfo() assert.Greater(t, len(info), 0, "Expected HTTP calls to be made") } }) } } func TestListInstallations(t *testing.T) { httpmock.Activate() defer httpmock.DeactivateAndReset() // Sample installation responses firstPageResponse := []github.Installation{ {ID: github.Int64(12345), Account: &github.User{Login: github.String("user1")}}, {ID: github.Int64(67890), Account: &github.User{Login: github.String("user2")}}, } firstPageJSON, _ := json.Marshal(firstPageResponse) secondPageResponse := []github.Installation{ {ID: github.Int64(11111), Account: &github.User{Login: github.String("user3")}}, } secondPageJSON, _ := json.Marshal(secondPageResponse) emptyResponse := []github.Installation{} emptyResponseJSON, _ := json.Marshal(emptyResponse) // Create a response with 100 items to test pagination fullPageResponse := make([]github.Installation, 100) for i := 0; i < 100; i++ { fullPageResponse[i] = github.Installation{ ID: github.Int64(int64(i + 1000)), Account: &github.User{Login: github.String(fmt.Sprintf("user%d", i))}, } } fullPageJSON, _ := json.Marshal(fullPageResponse) tests := []struct { name string hostname string jwt string setupMocks func() expectedCount int expectedError string }{ { name: "successful_single_page", hostname: "api.github.com", jwt: "test.jwt.token", setupMocks: func() { httpmock.RegisterResponder("GET", "https://api.github.com/app/installations?per_page=100&page=0", httpmock.NewStringResponder(200, string(firstPageJSON))) }, expectedCount: 2, expectedError: "", }, { name: "successful_multiple_pages", hostname: "api.github.com", jwt: "test.jwt.token", setupMocks: func() { httpmock.RegisterResponder("GET", "https://api.github.com/app/installations?per_page=100&page=0", httpmock.NewStringResponder(200, string(fullPageJSON))) httpmock.RegisterResponder("GET", "https://api.github.com/app/installations?per_page=100&page=1", httpmock.NewStringResponder(200, string(secondPageJSON))) }, expectedCount: 101, expectedError: "", }, { name: "successful_empty_response", hostname: "api.github.com", jwt: "test.jwt.token", setupMocks: func() { httpmock.RegisterResponder("GET", "https://api.github.com/app/installations?per_page=100&page=0", httpmock.NewStringResponder(200, string(emptyResponseJSON))) }, expectedCount: 0, expectedError: "", }, { name: "successful_with_custom_hostname", hostname: "github.company.com/api/v3", jwt: "test.jwt.token", setupMocks: func() { httpmock.RegisterResponder("GET", "https://github.company.com/api/v3/app/installations?per_page=100&page=0", httpmock.NewStringResponder(200, string(firstPageJSON))) }, expectedCount: 2, expectedError: "", }, { name: "error_http_request_fails", hostname: "api.github.com", jwt: "test.jwt.token", setupMocks: func() { httpmock.RegisterResponder("GET", "https://api.github.com/app/installations?per_page=100&page=0", httpmock.NewErrorResponder(fmt.Errorf("network error"))) }, expectedCount: 0, expectedError: "unable to POST to", }, { name: "error_status_not_200", hostname: "api.github.com", jwt: "test.jwt.token", setupMocks: func() { httpmock.RegisterResponder("GET", "https://api.github.com/app/installations?per_page=100&page=0", httpmock.NewStringResponder(401, `{"message": "Unauthorized"}`)) }, expectedCount: 0, expectedError: "unexpected status code: 401", }, { name: "error_invalid_json_response", hostname: "api.github.com", jwt: "test.jwt.token", setupMocks: func() { httpmock.RegisterResponder("GET", "https://api.github.com/app/installations?per_page=100&page=0", httpmock.NewStringResponder(200, "invalid json")) }, expectedCount: 0, expectedError: "unable to unmarshal response body", }, { name: "error_on_second_page", hostname: "api.github.com", jwt: "test.jwt.token", setupMocks: func() { httpmock.RegisterResponder("GET", "https://api.github.com/app/installations?per_page=100&page=0", httpmock.NewStringResponder(200, string(fullPageJSON))) httpmock.RegisterResponder("GET", "https://api.github.com/app/installations?per_page=100&page=1", httpmock.NewStringResponder(500, `{"message": "Internal Server Error"}`)) }, expectedCount: 0, expectedError: "unexpected status code: 500", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Reset mocks for each test httpmock.Reset() tt.setupMocks() // Execute the function result, err := listInstallations(tt.hostname, tt.jwt) // Assert results if tt.expectedError != "" { assert.Error(t, err) assert.Contains(t, err.Error(), tt.expectedError) assert.Nil(t, result) } else { assert.NoError(t, err) assert.NotNil(t, result) assert.Equal(t, tt.expectedCount, len(*result)) // Verify HTTP calls were made as expected info := httpmock.GetCallCountInfo() assert.Greater(t, len(info), 0, "Expected HTTP calls to be made") // Verify request headers for the first request if tt.expectedCount > 0 { // Check that the Authorization header was set correctly endpoint := fmt.Sprintf("https://%s/app/installations?per_page=100&page=0", tt.hostname) assert.Equal(t, 1, info[fmt.Sprintf("GET %s", endpoint)], "Expected exactly one call to first page") } } }) } } func TestInstallationsPaginationBehavior(t *testing.T) { httpmock.Activate() defer httpmock.DeactivateAndReset() // Test that pagination stops when we get less than 100 results // Create responses for multiple pages page0Response := make([]github.Installation, 100) for i := 0; i < 100; i++ { page0Response[i] = github.Installation{ ID: github.Int64(int64(i)), Account: &github.User{Login: github.String(fmt.Sprintf("user%d", i))}, } } page0JSON, _ := json.Marshal(page0Response) page1Response := make([]github.Installation, 100) for i := 0; i < 100; i++ { page1Response[i] = github.Installation{ ID: github.Int64(int64(i + 100)), Account: &github.User{Login: github.String(fmt.Sprintf("user%d", i+100))}, } } page1JSON, _ := json.Marshal(page1Response) // Page 2 has less than 100 results, should stop pagination page2Response := make([]github.Installation, 50) for i := 0; i < 50; i++ { page2Response[i] = github.Installation{ ID: github.Int64(int64(i + 200)), Account: &github.User{Login: github.String(fmt.Sprintf("user%d", i+200))}, } } page2JSON, _ := json.Marshal(page2Response) t.Run("pagination_stops_when_less_than_100_results", func(t *testing.T) { httpmock.Reset() httpmock.RegisterResponder("GET", "https://api.github.com/app/installations?per_page=100&page=0", httpmock.NewStringResponder(200, string(page0JSON))) httpmock.RegisterResponder("GET", "https://api.github.com/app/installations?per_page=100&page=1", httpmock.NewStringResponder(200, string(page1JSON))) httpmock.RegisterResponder("GET", "https://api.github.com/app/installations?per_page=100&page=2", httpmock.NewStringResponder(200, string(page2JSON))) result, err := listInstallations("api.github.com", "test.jwt.token") assert.NoError(t, err) assert.NotNil(t, result) assert.Equal(t, 250, len(*result)) // 100 + 100 + 50 // Verify all three pages were called info := httpmock.GetCallCountInfo() assert.Equal(t, 1, info["GET https://api.github.com/app/installations?per_page=100&page=0"]) assert.Equal(t, 1, info["GET https://api.github.com/app/installations?per_page=100&page=1"]) assert.Equal(t, 1, info["GET https://api.github.com/app/installations?per_page=100&page=2"]) // Page 3 should not be called assert.Equal(t, 0, info["GET https://api.github.com/app/installations?per_page=100&page=3"]) }) } func TestInstallationsRequestHeaders(t *testing.T) { httpmock.Activate() defer httpmock.DeactivateAndReset() installationResponse := []github.Installation{ {ID: github.Int64(12345)}, } installationJSON, _ := json.Marshal(installationResponse) t.Run("correct_headers_are_set", func(t *testing.T) { httpmock.Reset() // Use a custom responder to check headers httpmock.RegisterResponder("GET", "https://api.github.com/app/installations?per_page=100&page=0", func(req *http.Request) (*http.Response, error) { // Verify headers assert.Equal(t, "Bearer test.jwt.token", req.Header.Get("Authorization")) assert.Equal(t, "application/vnd.github+json", req.Header.Get("Accept")) assert.Equal(t, "2022-11-28", req.Header.Get("X-GitHub-Api-Version")) assert.Equal(t, "Link-/gh-token", req.Header.Get("User-Agent")) return httpmock.NewStringResponse(200, string(installationJSON)), nil }) result, err := listInstallations("api.github.com", "test.jwt.token") assert.NoError(t, err) assert.NotNil(t, result) assert.Equal(t, 1, len(*result)) }) } ================================================ FILE: internal/key.go ================================================ package internal import ( "crypto/rsa" "encoding/base64" "fmt" "os" "time" "github.com/golang-jwt/jwt/v5" ) func readKey(path string) (*rsa.PrivateKey, error) { keyBytes, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("unable to read key file: %w", err) } key, err := jwt.ParseRSAPrivateKeyFromPEM(keyBytes) if err != nil { return nil, fmt.Errorf("unable to parse key from PEM to RSA format: %w", err) } return key, nil } func readKeyBase64(keyBase64 string) (*rsa.PrivateKey, error) { keyBytes, err := base64.StdEncoding.DecodeString(keyBase64) if err != nil { return nil, fmt.Errorf("unable to decode key from base64: %w", err) } key, err := jwt.ParseRSAPrivateKeyFromPEM(keyBytes) if err != nil { return nil, fmt.Errorf("unable to parse key from PEM to RSA format: %w", err) } return key, nil } func generateJWT(appID string, expiry int, key *rsa.PrivateKey) (string, error) { iat := jwt.NewNumericDate(time.Now().Add(-60 * time.Second)) exp := jwt.NewNumericDate(time.Now().Add(time.Duration(expiry) * 60 * time.Second)) token := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{ "iat": iat, "exp": exp, "iss": appID, }) signedToken, err := token.SignedString(key) if err != nil { return "", fmt.Errorf("unable to sign JWT: %w", err) } return signedToken, nil } ================================================ FILE: internal/revoke.go ================================================ package internal import ( "fmt" "net/http" "strings" "github.com/urfave/cli/v2" ) // Revoke is the entrypoint for the revoke command func Revoke(c *cli.Context) error { token := c.String("token") hostname := strings.ToLower(c.String("hostname")) silent := c.Bool("silent") if hostname != "api.github.com" && !strings.Contains(hostname, "/api/v3") { endpoint := fmt.Sprintf("%s/api/v3", hostname) hostname = strings.TrimSuffix(endpoint, "/") } err := revokeToken(hostname, token) if err != nil { return fmt.Errorf("failed revoking installation token: %w", err) } if !silent { fmt.Println("Successfully revoked installation token") } return nil } func revokeToken(hostname, token string) error { endpoint := fmt.Sprintf("https://%s/installation/token", hostname) req, err := http.NewRequest("DELETE", endpoint, nil) if err != nil { return fmt.Errorf("unable to create DELETE request to %s: %w", endpoint, err) } req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token)) req.Header.Add("Accept", "application/vnd.github+json") req.Header.Add("X-GitHub-Api-Version", "2022-11-28") req.Header.Add("User-Agent", "Link-/gh-token") client := &http.Client{} resp, err := client.Do(req) if err != nil { return fmt.Errorf("unable to DELETE to %s: %w", endpoint, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != 204 { return fmt.Errorf("token might be invalid or not properly formatted. Unexpected status code: %d", resp.StatusCode) } return nil } ================================================ FILE: internal/revoke_flags.go ================================================ package internal import "github.com/urfave/cli/v2" // RevokeFlags returns the CLI flags for the revoke command func RevokeFlags() []cli.Flag { return []cli.Flag{ &cli.StringFlag{ Name: "token", Usage: "GitHub App installation Token", Required: true, Aliases: []string{"t"}, }, &cli.StringFlag{ Name: "hostname", Usage: "GitHub Enterprise Server API endpoint, example: github.example.com", Required: false, Aliases: []string{"o"}, Value: "api.github.com", }, &cli.BoolFlag{ Name: "silent", Usage: "Do not print to stdout", Aliases: []string{"s"}, Value: false, }, } } ================================================ FILE: internal/revoke_test.go ================================================ package internal import ( "flag" "fmt" "net/http" "testing" "github.com/jarcoal/httpmock" "github.com/stretchr/testify/assert" "github.com/urfave/cli/v2" ) // createTestContextForRevoke creates a test CLI context with the given flags for revoke command func createTestContextForRevoke(flags map[string]interface{}) *cli.Context { app := &cli.App{} set := flag.NewFlagSet("test", flag.ContinueOnError) // Set default values defaults := map[string]interface{}{ "token": "", "hostname": "api.github.com", "silent": false, } // Override with test-specific flags for k, v := range flags { defaults[k] = v } // Set up flags based on type for key, value := range defaults { switch v := value.(type) { case string: set.String(key, v, "") case bool: set.Bool(key, v, "") } } return cli.NewContext(app, set, nil) } func TestRevoke(t *testing.T) { // Setup httpmock.Activate() defer httpmock.DeactivateAndReset() tests := []struct { name string flags map[string]interface{} setupMocks func() expectedError string }{ { name: "successful_token_revocation_default_hostname", flags: map[string]interface{}{ "token": "ghs_test_token_123", "hostname": "api.github.com", "silent": true, }, setupMocks: func() { httpmock.RegisterResponder("DELETE", "https://api.github.com/installation/token", httpmock.NewStringResponder(204, "")) }, expectedError: "", }, { name: "successful_token_revocation_custom_hostname_without_api_path", flags: map[string]interface{}{ "token": "ghs_test_token_456", "hostname": "github.company.com", "silent": true, }, setupMocks: func() { httpmock.RegisterResponder("DELETE", "https://github.company.com/api/v3/installation/token", httpmock.NewStringResponder(204, "")) }, expectedError: "", }, { name: "successful_token_revocation_custom_hostname_with_api_path", flags: map[string]interface{}{ "token": "ghs_test_token_789", "hostname": "github.company.com/api/v3", "silent": true, }, setupMocks: func() { httpmock.RegisterResponder("DELETE", "https://github.company.com/api/v3/installation/token", httpmock.NewStringResponder(204, "")) }, expectedError: "", }, { name: "successful_token_revocation_verbose_output", flags: map[string]interface{}{ "token": "ghs_test_token_verbose", "hostname": "api.github.com", "silent": false, }, setupMocks: func() { httpmock.RegisterResponder("DELETE", "https://api.github.com/installation/token", httpmock.NewStringResponder(204, "")) }, expectedError: "", }, { name: "error_invalid_token_401", flags: map[string]interface{}{ "token": "invalid_token", "hostname": "api.github.com", "silent": true, }, setupMocks: func() { httpmock.RegisterResponder("DELETE", "https://api.github.com/installation/token", httpmock.NewStringResponder(401, `{"message": "Bad credentials"}`)) }, expectedError: "failed revoking installation token: token might be invalid or not properly formatted. Unexpected status code: 401", }, { name: "error_forbidden_403", flags: map[string]interface{}{ "token": "forbidden_token", "hostname": "api.github.com", "silent": true, }, setupMocks: func() { httpmock.RegisterResponder("DELETE", "https://api.github.com/installation/token", httpmock.NewStringResponder(403, `{"message": "Forbidden"}`)) }, expectedError: "failed revoking installation token: token might be invalid or not properly formatted. Unexpected status code: 403", }, { name: "error_not_found_404", flags: map[string]interface{}{ "token": "not_found_token", "hostname": "api.github.com", "silent": true, }, setupMocks: func() { httpmock.RegisterResponder("DELETE", "https://api.github.com/installation/token", httpmock.NewStringResponder(404, `{"message": "Not Found"}`)) }, expectedError: "failed revoking installation token: token might be invalid or not properly formatted. Unexpected status code: 404", }, { name: "error_server_error_500", flags: map[string]interface{}{ "token": "server_error_token", "hostname": "api.github.com", "silent": true, }, setupMocks: func() { httpmock.RegisterResponder("DELETE", "https://api.github.com/installation/token", httpmock.NewStringResponder(500, `{"message": "Internal Server Error"}`)) }, expectedError: "failed revoking installation token: token might be invalid or not properly formatted. Unexpected status code: 500", }, { name: "hostname_case_insensitive", flags: map[string]interface{}{ "token": "ghs_case_test_token", "hostname": "API.GITHUB.COM", "silent": true, }, setupMocks: func() { httpmock.RegisterResponder("DELETE", "https://api.github.com/installation/token", httpmock.NewStringResponder(204, "")) }, expectedError: "", }, { name: "custom_hostname_with_trailing_slash", flags: map[string]interface{}{ "token": "ghs_trailing_slash_token", "hostname": "github.company.com/api/v3/", "silent": true, }, setupMocks: func() { // The hostname processing logic preserves the trailing slash when /api/v3 is already present, // resulting in a double slash in the final URL httpmock.RegisterResponder("DELETE", "https://github.company.com/api/v3//installation/token", httpmock.NewStringResponder(204, "")) }, expectedError: "", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Reset mocks for each test httpmock.Reset() tt.setupMocks() // Create CLI context ctx := createTestContextForRevoke(tt.flags) // Execute the function err := Revoke(ctx) // Assert results if tt.expectedError != "" { assert.Error(t, err) assert.Equal(t, tt.expectedError, err.Error()) } else { assert.NoError(t, err) } // Verify HTTP calls were made as expected info := httpmock.GetCallCountInfo() assert.Greater(t, len(info), 0, "Expected HTTP calls to be made") }) } } func TestRevokeToken(t *testing.T) { httpmock.Activate() defer httpmock.DeactivateAndReset() tests := []struct { name string hostname string token string responseCode int responseBody string expectedError string }{ { name: "successful_revocation_github_com", hostname: "api.github.com", token: "ghs_test_token_123", responseCode: 204, responseBody: "", expectedError: "", }, { name: "successful_revocation_custom_hostname", hostname: "github.company.com/api/v3", token: "ghs_test_token_456", responseCode: 204, responseBody: "", expectedError: "", }, { name: "error_bad_credentials_401", hostname: "api.github.com", token: "invalid_token", responseCode: 401, responseBody: `{"message": "Bad credentials"}`, expectedError: "token might be invalid or not properly formatted. Unexpected status code: 401", }, { name: "error_forbidden_403", hostname: "api.github.com", token: "forbidden_token", responseCode: 403, responseBody: `{"message": "Forbidden"}`, expectedError: "token might be invalid or not properly formatted. Unexpected status code: 403", }, { name: "error_not_found_404", hostname: "api.github.com", token: "not_found_token", responseCode: 404, responseBody: `{"message": "Not Found"}`, expectedError: "token might be invalid or not properly formatted. Unexpected status code: 404", }, { name: "error_unprocessable_entity_422", hostname: "api.github.com", token: "malformed_token", responseCode: 422, responseBody: `{"message": "Unprocessable Entity"}`, expectedError: "token might be invalid or not properly formatted. Unexpected status code: 422", }, { name: "error_internal_server_error_500", hostname: "api.github.com", token: "server_error_token", responseCode: 500, responseBody: `{"message": "Internal Server Error"}`, expectedError: "token might be invalid or not properly formatted. Unexpected status code: 500", }, { name: "error_service_unavailable_503", hostname: "api.github.com", token: "service_unavailable_token", responseCode: 503, responseBody: `{"message": "Service Unavailable"}`, expectedError: "token might be invalid or not properly formatted. Unexpected status code: 503", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { httpmock.Reset() endpoint := fmt.Sprintf("https://%s/installation/token", tt.hostname) httpmock.RegisterResponder("DELETE", endpoint, httpmock.NewStringResponder(tt.responseCode, tt.responseBody)) err := revokeToken(tt.hostname, tt.token) if tt.expectedError != "" { assert.Error(t, err) assert.Equal(t, tt.expectedError, err.Error()) } else { assert.NoError(t, err) } // Verify the request was made with correct method and endpoint info := httpmock.GetCallCountInfo() assert.Equal(t, 1, info[fmt.Sprintf("DELETE %s", endpoint)], "Expected exactly one DELETE request to be made") }) } } func TestRevokeTokenNetworkErrors(t *testing.T) { httpmock.Activate() defer httpmock.DeactivateAndReset() tests := []struct { name string hostname string token string setupMock func() expectedError string errorContains string }{ { name: "network_connection_error", hostname: "api.github.com", token: "ghs_network_error_token", setupMock: func() { httpmock.RegisterResponder("DELETE", "https://api.github.com/installation/token", httpmock.NewErrorResponder(fmt.Errorf("connection refused"))) }, errorContains: "unable to DELETE to https://api.github.com/installation/token", }, { name: "timeout_error", hostname: "api.github.com", token: "ghs_timeout_token", setupMock: func() { httpmock.RegisterResponder("DELETE", "https://api.github.com/installation/token", httpmock.NewErrorResponder(fmt.Errorf("request timeout"))) }, errorContains: "unable to DELETE to https://api.github.com/installation/token", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { httpmock.Reset() tt.setupMock() err := revokeToken(tt.hostname, tt.token) assert.Error(t, err) if tt.expectedError != "" { assert.Equal(t, tt.expectedError, err.Error()) } else if tt.errorContains != "" { assert.Contains(t, err.Error(), tt.errorContains) } // Verify the request was attempted info := httpmock.GetCallCountInfo() endpoint := fmt.Sprintf("https://%s/installation/token", tt.hostname) assert.Equal(t, 1, info[fmt.Sprintf("DELETE %s", endpoint)], "Expected exactly one DELETE request to be attempted") }) } } func TestRevokeAdvancedCases(t *testing.T) { httpmock.Activate() defer httpmock.DeactivateAndReset() tests := []struct { name string setupTest func() *cli.Context setupMocks func() verifyFunc func(t *testing.T) }{ { name: "verify_request_headers", setupTest: func() *cli.Context { return createTestContextForRevoke(map[string]interface{}{ "token": "ghs_header_test_token", "hostname": "api.github.com", "silent": true, }) }, setupMocks: func() { httpmock.RegisterResponder("DELETE", "https://api.github.com/installation/token", func(req *http.Request) (*http.Response, error) { // Verify required headers are present assert.Equal(t, "Bearer ghs_header_test_token", req.Header.Get("Authorization")) assert.Equal(t, "application/vnd.github+json", req.Header.Get("Accept")) assert.Equal(t, "2022-11-28", req.Header.Get("X-GitHub-Api-Version")) assert.Equal(t, "Link-/gh-token", req.Header.Get("User-Agent")) return httpmock.NewStringResponse(204, ""), nil }) }, verifyFunc: func(t *testing.T) { // Headers are verified in the mock responder }, }, { name: "hostname_normalization_mixed_case", setupTest: func() *cli.Context { return createTestContextForRevoke(map[string]interface{}{ "token": "ghs_mixed_case_token", "hostname": "GitHub.Company.Com", "silent": true, }) }, setupMocks: func() { // The hostname should be normalized to lowercase and have /api/v3 appended httpmock.RegisterResponder("DELETE", "https://github.company.com/api/v3/installation/token", httpmock.NewStringResponder(204, "")) }, verifyFunc: func(t *testing.T) { info := httpmock.GetCallCountInfo() assert.Equal(t, 1, info["DELETE https://github.company.com/api/v3/installation/token"]) }, }, { name: "empty_token_string", setupTest: func() *cli.Context { return createTestContextForRevoke(map[string]interface{}{ "token": "", "hostname": "api.github.com", "silent": true, }) }, setupMocks: func() { httpmock.RegisterResponder("DELETE", "https://api.github.com/installation/token", httpmock.NewStringResponder(401, `{"message": "Bad credentials"}`)) }, verifyFunc: func(t *testing.T) { // Should still attempt the request even with empty token info := httpmock.GetCallCountInfo() assert.Equal(t, 1, info["DELETE https://api.github.com/installation/token"]) }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { httpmock.Reset() tt.setupMocks() ctx := tt.setupTest() _ = Revoke(ctx) // We don't check error here as we're testing specific behaviors tt.verifyFunc(t) }) } } ================================================ FILE: main.go ================================================ // Package main is the entrypoint for the gh-token CLI package main import ( "fmt" "os" "github.com/Link-/gh-token/internal" "github.com/urfave/cli/v2" ) func main() { app := &cli.App{ Name: "gh-token", Usage: "Manage GitHub App installation tokens", Version: Version, EnableBashCompletion: true, Suggest: true, Commands: []*cli.Command{ { Name: "generate", Usage: "Generate a new GitHub App installation token", Flags: internal.GenerateFlags(), Action: internal.Generate, }, { Name: "revoke", Usage: "Revoke a GitHub App installation token", Flags: internal.RevokeFlags(), Action: internal.Revoke, }, { Name: "installations", Usage: "List GitHub App installations", Flags: internal.InstallationsFlags(), Action: internal.Installations, }, }, } err := app.Run(os.Args) if err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } } ================================================ FILE: version.go ================================================ package main // Version is the current version of the gh-token CLI. const Version = "2.0.10"