[
  {
    "path": ".github/copilot-instructions.md",
    "content": "## Project structure\n\n[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.\n\n```\n.\n└── internal: the core package where this utility is implemented\n```\n\n## Coding instructions\n\n- Read the `Makefile` in the root directory for a list of targets and commands you can run\n- Add the necessary package dependencies before running unit tests, especially new mocks\n- 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\n- When creating new unit tests, append `_test.go` to the basename of the file that the unit tests should be covering.\n- 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.\n- Create all unit testing fixtures in the folder `fixtures` which must be a subdirectory of where the test files are located.\n- When implementing unit tests make sure to read the function you're implementing the test for first.\n- 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.\n- 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 `_`.\n\n## git operations\n\n- Never stage or commit changes without prompting the user for approval\n- Start commit messages with a verb (`Add`, `Update`, `Fix` etc.)\n- Do not use `feat:`, `chore:` or anything in that style for commit messages\n- Add details of what was changed to the body of the commit message. Be concise.\n- Never use: `git pull` `git push` `git merge` `git rebase` `git rm`"
  },
  {
    "path": ".github/dependabot.yml",
    "content": "version: 2\nupdates:\n  - package-ecosystem: gomod\n    directory: \"/\"\n    schedule:\n      interval: weekly\n\n  - package-ecosystem: \"github-actions\"\n    directory: \"/\"\n    schedule:\n      # Check for updates to GitHub Actions every week\n      interval: \"weekly\""
  },
  {
    "path": ".github/workflows/codeql.yml",
    "content": "name: \"CodeQL\"\n\non:\n  push:\n    branches:\n      - main\n  pull_request:\n    branches:\n      - main\n  schedule:\n    - cron: '41 2 * * 6'\n\nconcurrency:\n  group: ${{ github.ref }}-${{ github.workflow }}\n  cancel-in-progress: true\n\njobs:\n  analyze:\n    name: Analyze\n    runs-on: 'ubuntu-latest'\n    timeout-minutes: 15\n    permissions:\n      actions: read\n      contents: read\n      security-events: write\n\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v6\n\n      - name: Initialize CodeQL\n        uses: github/codeql-action/init@v4\n        with:\n          languages: go\n\n      - name: Autobuild\n        uses: github/codeql-action/autobuild@v4\n\n      - name: Perform CodeQL Analysis\n        uses: github/codeql-action/analyze@v4\n        with:\n          category: \"/language:go\"\n"
  },
  {
    "path": ".github/workflows/linter.yml",
    "content": "name: Lint\n\non:\n  pull_request:\n    branches:\n      - main\n\nconcurrency:\n  group: ${{ github.ref }}-${{ github.workflow }}\n  cancel-in-progress: true\n\npermissions:\n  contents: read\n\njobs:\n  lint:\n    name: Lint\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout Code\n        uses: actions/checkout@v6\n        with:\n          fetch-depth: 0\n      - name: Lint Code\n        uses: golangci/golangci-lint-action@v9\n"
  },
  {
    "path": ".github/workflows/release.yml",
    "content": "name: Release\non:\n  push:\n    tags:\n      - \"v*\"\npermissions:\n  contents: write\n\njobs:\n  release:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v6\n      - name: Verify tag matches version.go\n        run: |\n          TAG=\"${GITHUB_REF#refs/tags/v}\"\n          VERSION=\"$(grep -oP 'Version = \"\\K[^\"]+' version.go)\"\n          if [ \"$TAG\" != \"$VERSION\" ]; then\n            echo \"::error::Tag v${TAG} does not match Version in version.go (${VERSION}). Please update version.go.\"\n            exit 1\n          fi\n      - uses: cli/gh-extension-precompile@v2\n        with:\n          go_version_file: go.mod\n"
  },
  {
    "path": ".github/workflows/test.yml",
    "content": "name: Test\n\non:\n  push:\n    branches:\n      - main\n  pull_request:\n    branches:\n      - main\n  workflow_dispatch:\n    inputs:\n      branch:\n        description: \"Branch name to checkout and run tests for\"\n        required: false\n        default: \"refs/heads/main\"\n        type: string\n\nconcurrency:\n  group: ${{ github.ref }}-${{ github.workflow }}\n  cancel-in-progress: true\n\npermissions:\n  contents: read\n\njobs:\n  test:\n    name: Unit Tests\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout Code\n        uses: actions/checkout@v6\n        with:\n          ref: ${{ inputs.branch || 'refs/heads/main' }}\n          fetch-depth: 0\n\n      - uses: actions/setup-go@v6\n        with:\n          go-version-file: go.mod\n      - run: go version\n\n      - name: Run unit tests\n        run: make test\n\n  integration:\n    name: Integration Tests\n    runs-on: ubuntu-latest\n    if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'workflow_dispatch'\n    steps:\n      - name: Checkout Code\n        uses: actions/checkout@v6\n        with:\n          ref: ${{ inputs.branch || 'refs/heads/main' }}\n          fetch-depth: 0\n\n      - uses: actions/setup-go@v6\n        with:\n          go-version-file: go.mod\n      - run: go version\n\n      - name: Build\n        run: make build\n\n      - name: Generate installation access token from PEM key file\n        run: |\n          printf \"%s\" \"$APP_PRIVATE_KEY\" > private_key.pem\n          ./gh-token \\\n            generate \\\n            -i \"$APP_ID\" \\\n            -k private_key.pem > /dev/null 2\n        env:\n          APP_ID: ${{ secrets.APP_ID }}\n          APP_PRIVATE_KEY: ${{ secrets.APP_PRIVATE_KEY }}\n\n      - name: List installations for the app from PEM key file\n        run: |\n          printf \"%s\" \"$APP_PRIVATE_KEY\" > private_key.pem\n          ./gh-token \\\n            installations \\\n            -i \"$APP_ID\" \\\n            -k private_key.pem > /dev/null 2\n        env:\n          APP_ID: ${{ secrets.APP_ID }}\n          APP_PRIVATE_KEY: ${{ secrets.APP_PRIVATE_KEY }}\n\n      - name: Generate installation access token with base64 key\n        run: |\n          ./gh-token \\\n            generate \\\n            -i \"$APP_ID\" \\\n            -b \"$(echo \"$APP_PRIVATE_KEY\" | base64)\" > /dev/null 2\n        env:\n          APP_ID: ${{ secrets.APP_ID }}\n          APP_PRIVATE_KEY: ${{ secrets.APP_PRIVATE_KEY }}\n\n      - name: List installations for the app with base64 key\n        run: |\n          ./gh-token \\\n            installations \\\n            -i \"$APP_ID\" \\\n            -b \"$(echo \"$APP_PRIVATE_KEY\" | base64)\" > /dev/null 2\n        env:\n          APP_ID: ${{ secrets.APP_ID }}\n          APP_PRIVATE_KEY: ${{ secrets.APP_PRIVATE_KEY }}\n\n      - name: Generate then revoke token\n        run: |\n          printf \"%s\" \"$APP_PRIVATE_KEY\" > private_key.pemm\n          token=\"$(./gh-token generate -i $APP_ID -k private_key.pem | jq -r '.token')\"\n          ./gh-token revoke -t $token\n        env:\n          APP_ID: ${{ secrets.APP_ID }}\n          APP_PRIVATE_KEY: ${{ secrets.APP_PRIVATE_KEY }}\n"
  },
  {
    "path": ".gitignore",
    "content": "### macOS ###\n# General\n.DS_Store\n.AppleDouble\n.LSOverride\n\n# Icon must end with two \\r\nIcon\n\n\n# Thumbnails\n._*\n\n# Files that might appear in the root of a volume\n.DocumentRevisions-V100\n.fseventsd\n.Spotlight-V100\n.TemporaryItems\n.Trashes\n.VolumeIcon.icns\n.com.apple.timemachine.donotpresent\n\n# Directories potentially created on remote AFP share\n.AppleDB\n.AppleDesktop\nNetwork Trash Folder\nTemporary Items\n.apdisk\n\n### Vim ###\n# Swap\n[._]*.s[a-v][a-z]\n!*.svg  # comment out if you don't need vector files\n[._]*.sw[a-p]\n[._]s[a-rt-v][a-z]\n[._]ss[a-gi-z]\n[._]sw[a-p]\n\n# Session\nSession.vim\nSessionx.vim\n\n# Temporary\n.netrwhist\n*~\n# Auto-generated tag files\ntags\n# Persistent undo\n[._]*.un~\n\n### VisualStudioCode ###\n.vscode/*\n!.vscode/tasks.json\n!.vscode/launch.json\n*.code-workspace\n\n### VisualStudioCode Patch ###\n# Ignore all local history of files\n.history\n.ionide\n.keys\n\n### Project files\njwt\n\n# Generated files\ngh-token\ngh-token.exe\n*.out\n.bin/\n\n# Test app keys\n*.pem\n!*.test.pem"
  },
  {
    "path": ".vscode/launch.json",
    "content": "{\n    \"version\": \"0.2.0\",\n    \"configurations\": [\n        {\n            \"name\": \"Debug gh token\",\n            \"type\": \"go\",\n            \"request\": \"launch\",\n            \"mode\": \"debug\",\n            \"program\": \"${workspaceFolder}\",\n            \"env\": {},\n            \"args\": [\n                \"installations\",\n                \"-k\",\n                \"${workspaceFolder}/.keys/test-key.pem\",\n                \"--app-id\",\n                \"394554\"\n            ],\n            \"showLog\": true,\n            \"trace\": \"verbose\",\n        }\n    ]\n}"
  },
  {
    "path": "LICENSE",
    "content": "Copyright 2024 Link-\n\nPermission 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:\n\nThe above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.\n\nTHE 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.\n"
  },
  {
    "path": "Makefile",
    "content": "# Makefile with the following targets:\n#   all: build the project\n#   clean: remove all build artifacts\n#   build: build the project\n#   test: run all unit tests\n#   lint: run linting checks (golangci-lint)\n#   install-lint-deps: install linting dependencies\n#   release: create a new release by updating version numbers and committing changes\n#   help: print this help message\n#   .PHONY: mark targets as phony\n#   .DEFAULT_GOAL: set the default goal to all\n\n# Set the default goal to all\n.DEFAULT_GOAL := all\nPROJECT_NAME := \"gh-token\"\n\n# Mark targets as phony\n.PHONY: all clean build test lint install-lint-deps release\n\n# Build the project\nall: clean build\n\n# Remove all build artifacts\nclean:\n\trm -f gh-token\n\trm -rf .bin\n\n# Build the project\nbuild:\n\tgo build -o gh-token .\n\n# Run all unit tests\ntest:\n\tgo test ./...\n\n# Run linting checks\nlint:\n\t@test -f .bin/golangci-lint || $(MAKE) install-lint-deps\n\t./.bin/golangci-lint run\n\n# Install linting dependencies\ninstall-lint-deps:\n\t@mkdir -p .bin\n\tcurl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh | sh -s -- -b .bin v2.4.0\n\n# Create a new release\nrelease:\n\t@echo \"Current version in version.go: $$(grep 'Version' version.go | sed 's/.*Version = \"\\(.*\\)\".*/\\1/')\"\n\t@echo \"Current version in SECURITY.md: $$(grep -A2 '| Version' SECURITY.md | tail -1 | sed 's/| *\\([0-9]*\\.[0-9]*\\.[0-9]*\\).*/\\1/')\"\n\t@echo \"\"\n\t@read -p \"Enter the new semver version (e.g., 2.1.0): \" VERSION; \\\n\tif [ -z \"$$VERSION\" ]; then \\\n\t\techo \"Error: Version cannot be empty\"; \\\n\t\texit 1; \\\n\tfi; \\\n\tif ! echo \"$$VERSION\" | grep -E '^[0-9]+\\.[0-9]+\\.[0-9]+$$' > /dev/null; then \\\n\t\techo \"Error: Version must be in semver format (e.g., 2.1.0)\"; \\\n\t\texit 1; \\\n\tfi; \\\n\tMAJOR_MINOR=$$(echo \"$$VERSION\" | sed 's/\\([0-9]*\\.[0-9]*\\)\\.[0-9]*/\\1/'); \\\n\techo \"Updating version to $$VERSION...\"; \\\n\tsed -i.bak 's/Version = \"[^\"]*\"/Version = \"'\"$$VERSION\"'\"/' version.go && rm version.go.bak; \\\n\tsed -i.bak 's/| [0-9]*\\.[0-9]*\\.[0-9]* *|/| '\"$$MAJOR_MINOR\"'.x   |/' SECURITY.md && rm SECURITY.md.bak; \\\n\techo \"Files updated successfully.\"; \\\n\techo \"\"; \\\n\techo \"Staging and committing changes...\"; \\\n\tgit add version.go SECURITY.md; \\\n\tgit commit -m \"Update version to $$VERSION\"; \\\n\techo \"\"; \\\n\techo \"Changes committed successfully!\"; \\\n\techo \"\"; \\\n\techo \"Next steps:\"; \\\n\techo \"- Go to https://github.com/Link-/gh-token/releases/new to create a new release\"; \\\n\techo \"- Create a tag with the same version as the release ($$VERSION)\"; \\\n\techo \"- The binaries will automatically be uploaded as assets once the release has been created\"\n"
  },
  {
    "path": "README.md",
    "content": "# GH Token\n\n```shell\n* _____ _   *_   _______ *  _      *  *    **   *\n / ____| |* | | |__   __|  | |  *       *         🦄  *\n| | *__| |_*| | ⭐️ | | ___ | | _____*_ __  *     *\n| | |_ |* __ *|    |*|/ _ \\| |/ / _ \\ '_ \\     *   *\n| |__| | |  | | *  | | (_)*|   <  __/ | | |  *\n \\_____|_|  |_|    |_|\\___/|_|\\_\\___|_| |_|   *\n```\n\n<!-- markdownlint-disable -->\n\n> Manage installation access tokens for GitHub apps from your terminal\n\n[![License](https://img.shields.io/github/license/link-/gh-token?style=flat-square)](LICENSE)\n\n<!-- markdownlint-restore -->\n\n[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.\n\nInstallation 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.\n\nYou can use this access token to make pretty much any REST or GraphQL API call the app is authorized to make!\n\n![gh-token demo](./images/gh-token.png)\n\n## Why?\n\nIn 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).\n\n**PATs are dangerous, they:**\n\n1. have a very wide scope that spans across multiple organizations\n1. never (automatically) expire. They have an indefinite lifetime (or at least until you regenerate them)\n1. cannot be revoked (they're only revoked when a new one is generated)\n\nWith 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.**\n\n## Installation\n\n### Download as a standalone binary\n\nDownload `gh-token` from the [latest release](https://github.com/Link-/gh-token/releases/latest) for your platform.\n\n### Install as a `gh` cli extension\n\nYou can install `gh-token` as a [gh cli](https://github.com/cli/cli) extension!\n\n```shell\n$ gh extension install Link-/gh-token\n\n# Verify installation\n$ gh token\n```\n\nAll the commands and parameters remain the same, the only different is you now can use `gh token` instead of `gh-token`.\n\n### Creating a GitHub App\n\nFollow [these steps](https://docs.github.com/en/developers/apps/creating-a-github-app)\n\n## Usage\n\nCompatible with [GitHub Enterprise Server](https://github.com/enterprise).\n\n```text\nNAME:\n   gh-token - Manage GitHub App installation tokens\n\nUSAGE:\n   gh-token [global options] command [command options] [arguments...]\n\nVERSION:\n   2.0.2\n\nCOMMANDS:\n   generate       Generate a new GitHub App installation token\n   revoke         Revoke a GitHub App installation token\n   installations  List GitHub App installations\n   help, h        Shows a list of commands or help for one command\n\nGLOBAL OPTIONS:\n   --help, -h     show help\n   --version, -v  print the version\n```\n\n### Examples in the Terminal\n\n#### Run `gh token` as a `gh` CLI extension\n\n```shell\ngh token generate \\\n    --key ./.keys/private-key.pem \\\n    --app-id 1122334 \\\n    --installation-id 5566778\n```\n\n```json\n{\n  \"token\": \"ghs_8Joht_______________bLCMS___M0EPOhJ\",\n  \"expires_at\": \"2023-09-08T18:11:34Z\",\n  \"permissions\": {\n    \"actions\": \"write\",\n    \"administration\": \"write\",\n    \"metadata\": \"read\",\n    \"members\": \"read\",\n    \"organization_administration\": \"read\"\n  }\n}\n```\n\n#### Run `gh token` and pass the key as a base64 encoded string\n\n```shell\ngh token generate \\\n    --base64-key $(printf \"%s\" $APP_KEY | base64) \\\n    --app-id 1122334 \\\n    --installation-id 5566778\n```\n\n```json\n{\n  \"token\": \"ghs_8Joht_______________bLCMS___M0EPOhJ\",\n  \"expires_at\": \"2023-09-08T18:11:34Z\",\n  \"permissions\": {\n    \"actions\": \"write\",\n    \"administration\": \"write\",\n    \"metadata\": \"read\",\n    \"members\": \"read\",\n    \"organization_administration\": \"read\"\n  }\n}\n```\n\n#### Run `gh token` with GitHub Enterprise Server\n\n```shell\ngh token generate \\\n    --base64-key $(printf \"%s\" $APP_KEY | base64) \\\n    --app-id 1122334 \\\n    --installation-id 5566778 \\\n    --hostname \"github.example.com\"\n```\n\n```json\n{\n  \"token\": \"ghs_8Joht_______________bLCMS___M0EPOhJ\",\n  \"expires_at\": \"2023-09-08T18:11:34Z\",\n  \"permissions\": {\n    \"actions\": \"write\",\n    \"administration\": \"write\",\n    \"metadata\": \"read\",\n    \"members\": \"read\",\n    \"organization_administration\": \"read\"\n  }\n}\n```\n\n#### Fetch list of installations for an app\n\n```shell\ngh token installations \\\n    --key ./private-key.pem \\\n    --app-id 2233445\n```\n\n<details>\n  <summary>Response</summary>\n\n  ```json\n  [\n    {\n      \"id\": 1,\n      \"account\": {\n        \"login\": \"octocat\",\n        \"id\": 1,\n        \"node_id\": \"MDQ6VXNlcjE=\",\n        \"avatar_url\": \"https://github.com/images/error/octocat_happy.gif\",\n        \"gravatar_id\": \"\",\n        \"url\": \"https://api.github.com/users/octocat\",\n        \"html_url\": \"https://github.com/octocat\",\n        \"followers_url\": \"https://api.github.com/users/octocat/followers\",\n        \"following_url\": \"https://api.github.com/users/octocat/following{/other_user}\",\n        \"gists_url\": \"https://api.github.com/users/octocat/gists{/gist_id}\",\n        \"starred_url\": \"https://api.github.com/users/octocat/starred{/owner}{/repo}\",\n        \"subscriptions_url\": \"https://api.github.com/users/octocat/subscriptions\",\n        \"organizations_url\": \"https://api.github.com/users/octocat/orgs\",\n        \"repos_url\": \"https://api.github.com/users/octocat/repos\",\n        \"events_url\": \"https://api.github.com/users/octocat/events{/privacy}\",\n        \"received_events_url\": \"https://api.github.com/users/octocat/received_events\",\n        \"type\": \"User\",\n        \"site_admin\": false\n      },\n      \"access_tokens_url\": \"https://api.github.com/installations/1/access_tokens\",\n      \"repositories_url\": \"https://api.github.com/installation/repositories\",\n      \"html_url\": \"https://github.com/organizations/github/settings/installations/1\",\n      \"app_id\": 1,\n      \"target_id\": 1,\n      \"target_type\": \"Organization\",\n      \"permissions\": {\n        \"checks\": \"write\",\n        \"metadata\": \"read\",\n        \"contents\": \"read\"\n      },\n      \"events\": [\n        \"push\",\n        \"pull_request\"\n      ],\n      \"single_file_name\": \"config.yaml\",\n      \"has_multiple_single_files\": true,\n      \"single_file_paths\": [\n        \"config.yml\",\n        \".github/issue_TEMPLATE.md\"\n      ],\n      \"repository_selection\": \"selected\",\n      \"created_at\": \"2017-07-08T16:18:44-04:00\",\n      \"updated_at\": \"2017-07-08T16:18:44-04:00\",\n      \"app_slug\": \"github-actions\",\n      \"suspended_at\": null,\n      \"suspended_by\": null\n    }\n  ]\n  ```\n\n</details>\n\n#### Revoke an installation access token\n\n```shell\ngh token revoke \\\n    --token \"v1.bb1___168d_____________1202bb8753b133919\" \\\n    --hostname \"github.example.com\"\n```\n\n```text\nSuccessfully revoked installation token\n```\n\n### Example in a workflow\n\n<details>\n\n  <summary>Expand to show instructions</summary>\n\n1. You need to create a secret to store the **applications private key** securely (this can be an organization or a repository secret):\n    ![Create private key secret](images/create_secret.png)\n\n1. You need to create another secret to store the **application id** security (same as the step above).\n\n1. The secrets need to be provided as an environment variable then encoded into base64 as show in the workflow example:\n\nThis 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.\n\n```yaml\nname: Create access token via GitHub Apps Workflow\n\non:\n  workflow_dispatch:\n\njobs:\n  Test:\n    # The type of runner that the job will run on\n    runs-on: [ self-hosted ]\n\n    steps:\n    - name: \"Install gh-token\"\n      run: gh extension install Link-/gh-token\n    # Create access token with a GitHub App ID and Key\n    # We use the private key stored as a secret and encode it into base64\n    # before passing it to gh-token\n    - name: \"Create access token\"\n      run: |\n        token=$(gh token generate \\\n          --base64-key $(printf \"%s\" \"$APP_PRIVATE_KEY\" | base64 -w 0) \\\n          --app-id $APP_ID \\\n          --hostname \"github.example.com\" \\\n          | jq -r \".token\")\n        echo \"token=$token\" >> $GITHUB_OUTPUT\n      env:\n        APP_ID: ${{ secrets.APP_ID }}\n        APP_PRIVATE_KEY: ${{ secrets.APP_KEY }}\n    # To test the token we will use it to fetch the list of repositories\n    # belonging to our organization\n    - name: \"Fetch organization repositories\"\n      run: |\n        curl -X GET \\\n          -H \"Authorization: token $token\" \\\n          -H \"Accept: application/vnd.github.v3+json\" \\\n          https://github.example.com/api/v3/orgs/<ORGNAME>/repos\n```\n\n</details>\n\n## Similar projects\n\n_These are not endorsements, just a listing of similar art work_\n\n### CLI\n\n- [apptokit](https://github.com/jakewilkins/apptokit) in Ruby\n- [gha-token](https://github.com/slawekzachcial/gha-token) in Go\n\n### Actions\n\n- [create-github-app-token](https://github.com/actions/create-github-app-token) (GitHub official)\n- [workflow-application-token-action](https://github.com/peter-murray/workflow-application-token-action)\n- [action-github-app-token](https://github.com/getsentry/action-github-app-token)\n- [github-app-token-generator](https://github.com/navikt/github-app-token-generator)\n"
  },
  {
    "path": "SECURITY.md",
    "content": "# Security Policy\n\n## Reporting a Vulnerability\n\nUse the [private vulnerability reporting feature](https://github.com/Link-/gh-token/security/advisories) to report security problems.\n"
  },
  {
    "path": "go.mod",
    "content": "module github.com/Link-/gh-token\n\ngo 1.24.0\n\nrequire (\n\tgithub.com/golang-jwt/jwt/v5 v5.3.1\n\tgithub.com/google/go-github/v55 v55.0.0\n\tgithub.com/jarcoal/httpmock v1.4.1\n\tgithub.com/stretchr/testify v1.11.1\n\tgithub.com/urfave/cli/v2 v2.27.7\n)\n\nrequire (\n\tgithub.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371 // indirect\n\tgithub.com/cloudflare/circl v1.6.3 // indirect\n\tgithub.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect\n\tgithub.com/davecgh/go-spew v1.1.1 // indirect\n\tgithub.com/google/go-cmp v0.7.0 // indirect\n\tgithub.com/google/go-querystring v1.1.0 // indirect\n\tgithub.com/kr/pretty v0.3.1 // indirect\n\tgithub.com/pmezard/go-difflib v1.0.0 // indirect\n\tgithub.com/rogpeppe/go-internal v1.14.1 // indirect\n\tgithub.com/russross/blackfriday/v2 v2.1.0 // indirect\n\tgithub.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect\n\tgolang.org/x/crypto v0.45.0 // indirect\n\tgolang.org/x/sys v0.38.0 // indirect\n\tgopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect\n\tgopkg.in/yaml.v3 v3.0.1 // indirect\n)\n"
  },
  {
    "path": "go.sum",
    "content": "github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371 h1:kkhsdkhsCvIsutKu5zLMgWtgh9YxGCNAw8Ad8hjwfYg=\ngithub.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0=\ngithub.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0=\ngithub.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA=\ngithub.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=\ngithub.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=\ngithub.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo=\ngithub.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=\ngithub.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=\ngithub.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=\ngithub.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=\ngithub.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=\ngithub.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=\ngithub.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=\ngithub.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=\ngithub.com/google/go-github/v55 v55.0.0 h1:4pp/1tNMB9X/LuAhs5i0KQAE40NmiR/y6prLNb9x9cg=\ngithub.com/google/go-github/v55 v55.0.0/go.mod h1:JLahOTA1DnXzhxEymmFF5PP2tSS9JVNj68mSZNDwskA=\ngithub.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=\ngithub.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=\ngithub.com/jarcoal/httpmock v1.4.1 h1:0Ju+VCFuARfFlhVXFc2HxlcQkfB+Xq12/EotHko+x2A=\ngithub.com/jarcoal/httpmock v1.4.1/go.mod h1:ftW1xULwo+j0R0JJkJIIi7UKigZUXCLLanykgjwBXL0=\ngithub.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=\ngithub.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=\ngithub.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=\ngithub.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=\ngithub.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=\ngithub.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=\ngithub.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=\ngithub.com/maxatome/go-testdeep v1.14.0 h1:rRlLv1+kI8eOI3OaBXZwb3O7xY3exRzdW5QyX48g9wI=\ngithub.com/maxatome/go-testdeep v1.14.0/go.mod h1:lPZc/HAcJMP92l7yI6TRz1aZN5URwUBUAfUNvrclaNM=\ngithub.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=\ngithub.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=\ngithub.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=\ngithub.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=\ngithub.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=\ngithub.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=\ngithub.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=\ngithub.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=\ngithub.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=\ngithub.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=\ngithub.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU=\ngithub.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4=\ngithub.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4=\ngithub.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=\ngithub.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=\ngolang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=\ngolang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=\ngolang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=\ngolang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=\ngolang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=\ngolang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=\ngolang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=\ngolang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=\ngolang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=\ngolang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=\ngolang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=\ngolang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=\ngolang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=\ngolang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=\ngolang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=\ngolang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=\ngolang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=\ngolang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=\ngolang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=\ngolang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=\ngolang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=\ngolang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=\ngolang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=\ngolang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=\ngolang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=\ngolang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=\ngolang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=\ngolang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=\ngolang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=\ngolang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=\ngolang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=\ngolang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=\ngolang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngolang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=\ngopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=\ngopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=\ngopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=\ngopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=\n"
  },
  {
    "path": "internal/fixtures/test-private-key.test.pem",
    "content": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCL02nUZNMcAaOs\nJ+L5qKErTjABtmS6CBgCbDbUN1u8Ds7hNMbYM/+P4W6OWbITnJD4V1sHpEj4/5u9\ntkqQt0x7IrXEAE2D+zWd4ntkdT7qDO3hpxj4/nPRakp7W2w9iLJNSeVLTjoVrnuQ\nGsGpR4NCu4PJOerLpmZXF6V+5IUyTnK2TC6NkXMl1YQ+plo0tmLjayOYOFyNhSk0\n2fNUo+Iwzse5XUqDncBkBKwHjwYnRVjB+fcn5WT88VWz4ejaXLD2DMcZmQmU+clP\n3r+AJddbk4cVHlGAW99uQ+c6M8M215wOg6cwpvXX20DY+Z4I56TIVq7WpHQyyrev\nnoZ6O5/LAgMBAAECggEAA0arSpooJhZVvuFaXI4aZJja4BdlacRpx5jAeh1n7VKN\nf1JMvGEPgk/+VqB8XyBCd0cYr2emfAsFG59LRPO+e34XMyXsqwR2P6JAUNy8YiB2\nbFyNZbwUe5oZb6V3NkPfJZdvI2IMU1i4tWojEnPF/AjHsC3GtgnKiQzZSE1TX5fV\nE1PnG9u1x4FmcPBv6eL0mQ20MGYpt+l2kB1xSfFE72P4MYZ+xEAlbr94yA8ISVtp\nkrh0xB77qU/kWnVw07DOneWhAJdRAhNRD+ZcU+UYfBzkvCNOprjiMYQxjjmmjouh\nRrkZd8AGnbCDe6SW2fhY6On0Cp8o/Wuz4NI58JfivQKBgQDAHjarGMeNanvxLDlx\nshY1xoLOxF/1ikhn+CFTW4sPZQOxOcsb/GmjwON1/4we2c8uZGdNd2tEPvDgfWhu\nXTDyOHU0JjsQEs0TsvUr1Jf1BVecFnu8dTC+sfUmNCBwwqyfp6KqDjrBmRVHRiRn\nL8rCQBDf3bk9ib2t0PNjk/v2DQKBgQC6UeXs3RpROxyQQ7Y05diyfPjL2V8tOzms\nt61NzH1LDv9snEyG7sB12b8sm0HIeka+uZkVdiqjZvbtXDAS2ZhlWBn3clr1Bmnf\ngdVIRQicXNb+aZRc2pBCGLCbcRNxGQYWRKNv3RFdDlG93ILwO1yXoY3Bie6yL+Vk\neoj/lasPNwKBgQC3pupJqvlwBUAQL1+GgWBb7bUz5WN5/MP0p61r2xHXGJBsBbxU\nt3lg8c4/CZgwEbTNO2vJEQR4i9aGMzv2bJ2Sn0fjHzzMw7xJPYTDbooIzx+N9aw5\nXqnHUaTw7VmpkV+li4GjINEoKqe9p567CWPBR68Z4gHngtnQ4/MW2Os+rQKBgDGU\n2brOm9JCCLfbTQGGqMPWvd6BWfKPcCmmN1gcsrrmotIkRbkij9TMvTMBnd/bqjfW\n7AXqDC6vl8ZSYfiiLwvJBh/zLoFF06bGxhsVQ9VYX14Ueoa7Iuhz6Ytz69iM8DG8\n0kFScuxwgxAjPjTvlxRCyZZXPk3ssP6sHQjmqz7BAoGAHONxPaL5narYxGgySg49\nj3Ib9NROs2ys05dlTOZd0NYfkOsxtlNNncHGZ9NiikwtuJLeDibormsJhIgi8XXd\nkB8fa/kQXRWBNbD56ExVhSQiTPMNQXKXYXX2Ix/xcrHgzZzS0gAof7NKxwiuyhyi\nXeN8Q9ABpmAa5V1YvCP4qKM=\n-----END PRIVATE KEY-----\n"
  },
  {
    "path": "internal/generate.go",
    "content": "package internal\n\nimport (\n\t\"crypto/rsa\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/google/go-github/v55/github\"\n\t\"github.com/urfave/cli/v2\"\n)\n\n// Generate is the entrypoint for the generate command\nfunc Generate(c *cli.Context) error {\n\tappID := c.String(\"app-id\")\n\tinstallationID := c.String(\"installation-id\")\n\tkeyPath := c.String(\"key\")\n\tkeyBase64 := c.String(\"base64-key\")\n\tprintJWT := c.Bool(\"jwt\")\n\tjwtExpiry := c.Int(\"duration\")\n\thostname := strings.ToLower(c.String(\"hostname\"))\n\ttokenOnly := c.Bool(\"token-only\")\n\tsilent := c.Bool(\"silent\")\n\n\tif keyPath == \"\" && keyBase64 == \"\" {\n\t\treturn fmt.Errorf(\"either --key or --base64-key must be specified\")\n\t}\n\n\tif keyPath != \"\" && keyBase64 != \"\" {\n\t\treturn fmt.Errorf(\"only one of --key or --base64-key may be specified\")\n\t}\n\n\tif hostname != \"api.github.com\" && !strings.Contains(hostname, \"/api/v3\") {\n\t\tendpoint := fmt.Sprintf(\"%s/api/v3\", hostname)\n\t\thostname = strings.TrimSuffix(endpoint, \"/\")\n\t}\n\n\tif jwtExpiry < 1 || jwtExpiry > 10 {\n\t\tjwtExpiry = 10\n\t}\n\n\tvar err error\n\tvar privateKey *rsa.PrivateKey\n\tif keyPath != \"\" {\n\t\tprivateKey, err = readKey(keyPath)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t} else {\n\t\tprivateKey, err = readKeyBase64(keyBase64)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tjsonWebToken, err := generateJWT(appID, jwtExpiry, privateKey)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed generating JWT: %w\", err)\n\t}\n\n\tif printJWT {\n\t\tif !silent {\n\t\t\tfmt.Println(jsonWebToken)\n\t\t}\n\n\t\treturn nil\n\t}\n\n\tif installationID == \"\" {\n\t\tinstallationID, err = retrieveDefaultInstallationID(hostname, jsonWebToken)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed retrieving default installation ID: %w\", err)\n\t\t}\n\t}\n\n\ttoken, err := generateToken(hostname, jsonWebToken, installationID)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed generating installation token: %w\", err)\n\t}\n\n\tif !silent {\n\t\tbytes, err := json.MarshalIndent(token, \"\", \"  \")\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to marshal token to JSON: %w\", err)\n\t\t}\n\n\t\tif tokenOnly {\n\t\t\tfmt.Println(*token.Token)\n\t\t} else {\n\t\t\tfmt.Println(string(bytes))\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc retrieveDefaultInstallationID(hostname, jwt string) (string, error) {\n\tendpoint := fmt.Sprintf(\"https://%s/app/installations?per_page=1\", hostname)\n\treq, err := http.NewRequest(\"GET\", endpoint, nil)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"unable to create GET request to %s: %w\", endpoint, err)\n\t}\n\treq.Header.Add(\"Authorization\", fmt.Sprintf(\"Bearer %s\", jwt))\n\treq.Header.Add(\"Accept\", \"application/vnd.github+json\")\n\treq.Header.Add(\"X-GitHub-Api-Version\", \"2022-11-28\")\n\treq.Header.Add(\"User-Agent\", \"Link-/gh-token\")\n\n\tclient := &http.Client{}\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"unable to GET %s: %w\", endpoint, err)\n\t}\n\tdefer func() {\n\t\t_ = resp.Body.Close()\n\t}()\n\n\tif resp.StatusCode != 200 {\n\t\treturn \"\", fmt.Errorf(\"unexpected status code: %d\", resp.StatusCode)\n\t}\n\n\tvar response []github.Installation\n\tbytes, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"unable to read response body: %w\", err)\n\t}\n\n\terr = json.Unmarshal(bytes, &response)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"unable to unmarshal response body: %w\", err)\n\t}\n\n\treturn strconv.FormatInt(*response[0].ID, 10), nil\n}\n\nfunc generateToken(hostname, jwt, installationID string) (*github.InstallationToken, error) {\n\tendpoint := fmt.Sprintf(\"https://%s/app/installations/%s/access_tokens\", hostname, installationID)\n\treq, err := http.NewRequest(\"POST\", endpoint, nil)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"unable to create POST request to %s: %w\", endpoint, err)\n\t}\n\treq.Header.Add(\"Authorization\", fmt.Sprintf(\"Bearer %s\", jwt))\n\treq.Header.Add(\"Accept\", \"application/vnd.github+json\")\n\treq.Header.Add(\"X-GitHub-Api-Version\", \"2022-11-28\")\n\treq.Header.Add(\"User-Agent\", \"Link-/gh-token\")\n\n\tclient := &http.Client{}\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"unable to POST to %s: %w\", endpoint, err)\n\t}\n\tdefer func() {\n\t\t_ = resp.Body.Close()\n\t}()\n\n\tif resp.StatusCode != 201 {\n\t\treturn nil, fmt.Errorf(\"unexpected status code: %d\", resp.StatusCode)\n\t}\n\n\tvar response *github.InstallationToken\n\tbytes, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"unable to read response body: %w\", err)\n\t}\n\n\terr = json.Unmarshal(bytes, &response)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"unable to unmarshal response body: %w\", err)\n\t}\n\n\treturn response, nil\n}\n"
  },
  {
    "path": "internal/generate_flags.go",
    "content": "package internal\n\nimport \"github.com/urfave/cli/v2\"\n\n// GenerateFlags returns the CLI flags for the generate command\nfunc GenerateFlags() []cli.Flag {\n\treturn []cli.Flag{\n\t\t&cli.StringFlag{\n\t\t\tName:     \"app-id\",\n\t\t\tUsage:    \"GitHub App ID\",\n\t\t\tRequired: true,\n\t\t\tAliases:  []string{\"i\", \"app_id\"},\n\t\t},\n\t\t&cli.StringFlag{\n\t\t\tName:     \"installation-id\",\n\t\t\tUsage:    \"GitHub App installation ID. Defaults to the first installation returned by the GitHub API if not specified\",\n\t\t\tRequired: false,\n\t\t\tAliases:  []string{\"l\", \"installation_id\"},\n\t\t},\n\t\t&cli.StringFlag{\n\t\t\tName:     \"key\",\n\t\t\tUsage:    \"Path to private key\",\n\t\t\tRequired: false,\n\t\t\tAliases:  []string{\"k\"},\n\t\t},\n\t\t&cli.StringFlag{\n\t\t\tName:     \"base64-key\",\n\t\t\tUsage:    \"A base64 encoded private key\",\n\t\t\tRequired: false,\n\t\t\tAliases:  []string{\"b\", \"base64_key\"},\n\t\t},\n\t\t&cli.StringFlag{\n\t\t\tName:     \"hostname\",\n\t\t\tUsage:    \"GitHub Enterprise Server API endpoint, example: github.example.com\",\n\t\t\tRequired: false,\n\t\t\tAliases:  []string{\"o\"},\n\t\t\tValue:    \"api.github.com\",\n\t\t},\n\t\t&cli.BoolFlag{\n\t\t\tName:    \"token-only\",\n\t\t\tUsage:   \"Only print the token to stdout, not the full JSON response, useful for piping to other commands\",\n\t\t\tAliases: []string{\"t\"},\n\t\t\tValue:   false,\n\t\t},\n\t\t&cli.BoolFlag{\n\t\t\tName:     \"jwt\",\n\t\t\tUsage:    \"Return the JWT instead of generating an installation token, useful for calling API's requiring a JWT\",\n\t\t\tRequired: false,\n\t\t\tAliases:  []string{\"j\"},\n\t\t\tValue:    false,\n\t\t},\n\t\t&cli.IntFlag{\n\t\t\tName:     \"duration\",\n\t\t\tUsage:    \"The expiry time of the JWT in minutes up to a maximum value of 10, useful when using the --jwt flag\",\n\t\t\tRequired: false,\n\t\t\tAliases:  []string{\"d\"},\n\t\t\tValue:    1,\n\t\t},\n\t\t&cli.BoolFlag{\n\t\t\tName:    \"silent\",\n\t\t\tUsage:   \"Do not print token to stdout\",\n\t\t\tAliases: []string{\"s\"},\n\t\t\tValue:   false,\n\t\t},\n\t}\n}\n"
  },
  {
    "path": "internal/generate_test.go",
    "content": "package internal\n\nimport (\n\t\"encoding/base64\"\n\t\"encoding/json\"\n\t\"flag\"\n\t\"fmt\"\n\t\"os\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/google/go-github/v55/github\"\n\t\"github.com/jarcoal/httpmock\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/urfave/cli/v2\"\n)\n\n// createTestContext creates a test CLI context with the given flags\nfunc createTestContext(flags map[string]interface{}) *cli.Context {\n\tapp := &cli.App{}\n\tset := flag.NewFlagSet(\"test\", flag.ContinueOnError)\n\n\t// Set default values\n\tdefaults := map[string]interface{}{\n\t\t\"app-id\":          \"\",\n\t\t\"installation-id\": \"\",\n\t\t\"key\":             \"\",\n\t\t\"base64-key\":      \"\",\n\t\t\"jwt\":             false,\n\t\t\"jwt-expiry\":      10,\n\t\t\"hostname\":        \"api.github.com\",\n\t\t\"token-only\":      false,\n\t\t\"silent\":          false,\n\t}\n\n\t// Override with test-specific flags\n\tfor k, v := range flags {\n\t\tdefaults[k] = v\n\t}\n\n\t// Set up flags based on type\n\tfor key, value := range defaults {\n\t\tswitch v := value.(type) {\n\t\tcase string:\n\t\t\tset.String(key, v, \"\")\n\t\tcase bool:\n\t\t\tset.Bool(key, v, \"\")\n\t\tcase int:\n\t\t\tset.Int(key, v, \"\")\n\t\t}\n\t}\n\n\treturn cli.NewContext(app, set, nil)\n}\n\n// getBoolFlag safely gets bool values from flags map\nfunc getBoolFlag(flags map[string]interface{}, key string) bool {\n\tif val, ok := flags[key].(bool); ok {\n\t\treturn val\n\t}\n\treturn false\n}\n\nfunc TestGenerate(t *testing.T) {\n\t// Setup\n\thttpmock.Activate()\n\tdefer httpmock.DeactivateAndReset()\n\n\t// Read test key for base64 encoding\n\tkeyBytes, err := os.ReadFile(\"fixtures/test-private-key.test.pem\")\n\tassert.NoError(t, err)\n\tkeyBase64 := base64.StdEncoding.EncodeToString(keyBytes)\n\n\tinstallationResponse := []github.Installation{\n\t\t{ID: github.Int64(12345)},\n\t}\n\tinstallationJSON, _ := json.Marshal(installationResponse)\n\n\ttokenResponse := &github.InstallationToken{\n\t\tToken:     github.String(\"ghs_test_token_123\"),\n\t\tExpiresAt: &github.Timestamp{Time: time.Now().Add(time.Hour)},\n\t}\n\ttokenJSON, _ := json.Marshal(tokenResponse)\n\n\ttests := []struct {\n\t\tname          string\n\t\tflags         map[string]interface{}\n\t\tsetupMocks    func()\n\t\texpectedError string\n\t}{\n\t\t{\n\t\t\tname: \"successful_token_generation_with_key_file\",\n\t\t\tflags: map[string]interface{}{\n\t\t\t\t\"app-id\":          \"123456\",\n\t\t\t\t\"installation-id\": \"12345\",\n\t\t\t\t\"key\":             \"fixtures/test-private-key.test.pem\",\n\t\t\t\t\"hostname\":        \"api.github.com\",\n\t\t\t\t\"jwt-expiry\":      10,\n\t\t\t\t\"silent\":          true,\n\t\t\t},\n\t\t\tsetupMocks: func() {\n\t\t\t\thttpmock.RegisterResponder(\"POST\", \"https://api.github.com/app/installations/12345/access_tokens\",\n\t\t\t\t\thttpmock.NewStringResponder(201, string(tokenJSON)))\n\t\t\t},\n\t\t\texpectedError: \"\",\n\t\t},\n\t\t{\n\t\t\tname: \"successful_token_generation_with_base64_key\",\n\t\t\tflags: map[string]interface{}{\n\t\t\t\t\"app-id\":          \"123456\",\n\t\t\t\t\"installation-id\": \"12345\",\n\t\t\t\t\"base64-key\":      keyBase64,\n\t\t\t\t\"hostname\":        \"api.github.com\",\n\t\t\t\t\"jwt-expiry\":      10,\n\t\t\t\t\"silent\":          true,\n\t\t\t},\n\t\t\tsetupMocks: func() {\n\t\t\t\thttpmock.RegisterResponder(\"POST\", \"https://api.github.com/app/installations/12345/access_tokens\",\n\t\t\t\t\thttpmock.NewStringResponder(201, string(tokenJSON)))\n\t\t\t},\n\t\t\texpectedError: \"\",\n\t\t},\n\t\t{\n\t\t\tname: \"successful_with_auto_installation_id\",\n\t\t\tflags: map[string]interface{}{\n\t\t\t\t\"app-id\":     \"123456\",\n\t\t\t\t\"key\":        \"fixtures/test-private-key.test.pem\",\n\t\t\t\t\"hostname\":   \"api.github.com\",\n\t\t\t\t\"jwt-expiry\": 10,\n\t\t\t\t\"silent\":     true,\n\t\t\t},\n\t\t\tsetupMocks: func() {\n\t\t\t\thttpmock.RegisterResponder(\"GET\", \"https://api.github.com/app/installations?per_page=1\",\n\t\t\t\t\thttpmock.NewStringResponder(200, string(installationJSON)))\n\t\t\t\thttpmock.RegisterResponder(\"POST\", \"https://api.github.com/app/installations/12345/access_tokens\",\n\t\t\t\t\thttpmock.NewStringResponder(201, string(tokenJSON)))\n\t\t\t},\n\t\t\texpectedError: \"\",\n\t\t},\n\t\t{\n\t\t\tname: \"successful_jwt_only\",\n\t\t\tflags: map[string]interface{}{\n\t\t\t\t\"app-id\":     \"123456\",\n\t\t\t\t\"key\":        \"fixtures/test-private-key.test.pem\",\n\t\t\t\t\"jwt\":        true,\n\t\t\t\t\"jwt-expiry\": 10,\n\t\t\t\t\"silent\":     true,\n\t\t\t},\n\t\t\tsetupMocks:    func() {},\n\t\t\texpectedError: \"\",\n\t\t},\n\t\t{\n\t\t\tname: \"error_no_key_specified\",\n\t\t\tflags: map[string]interface{}{\n\t\t\t\t\"app-id\": \"123456\",\n\t\t\t},\n\t\t\tsetupMocks:    func() {},\n\t\t\texpectedError: \"either --key or --base64-key must be specified\",\n\t\t},\n\t\t{\n\t\t\tname: \"error_both_keys_specified\",\n\t\t\tflags: map[string]interface{}{\n\t\t\t\t\"app-id\":     \"123456\",\n\t\t\t\t\"key\":        \"fixtures/test-private-key.test.pem\",\n\t\t\t\t\"base64-key\": keyBase64,\n\t\t\t},\n\t\t\tsetupMocks:    func() {},\n\t\t\texpectedError: \"only one of --key or --base64-key may be specified\",\n\t\t},\n\t\t{\n\t\t\tname: \"error_invalid_key_file\",\n\t\t\tflags: map[string]interface{}{\n\t\t\t\t\"app-id\": \"123456\",\n\t\t\t\t\"key\":    \"fixtures/nonexistent.pem\",\n\t\t\t},\n\t\t\tsetupMocks:    func() {},\n\t\t\texpectedError: \"unable to read key file\",\n\t\t},\n\t\t{\n\t\t\tname: \"error_invalid_base64_key\",\n\t\t\tflags: map[string]interface{}{\n\t\t\t\t\"app-id\":     \"123456\",\n\t\t\t\t\"base64-key\": \"invalid-base64-string\",\n\t\t\t},\n\t\t\tsetupMocks:    func() {},\n\t\t\texpectedError: \"unable to decode key from base64\",\n\t\t},\n\t\t{\n\t\t\tname: \"error_installation_not_found\",\n\t\t\tflags: map[string]interface{}{\n\t\t\t\t\"app-id\": \"123456\",\n\t\t\t\t\"key\":    \"fixtures/test-private-key.test.pem\",\n\t\t\t},\n\t\t\tsetupMocks: func() {\n\t\t\t\thttpmock.RegisterResponder(\"GET\", \"https://api.github.com/app/installations?per_page=1\",\n\t\t\t\t\thttpmock.NewStringResponder(404, `{\"message\": \"Not Found\"}`))\n\t\t\t},\n\t\t\texpectedError: \"failed retrieving default installation ID: unexpected status code: 404\",\n\t\t},\n\t\t{\n\t\t\tname: \"error_token_generation_fails\",\n\t\t\tflags: map[string]interface{}{\n\t\t\t\t\"app-id\":          \"123456\",\n\t\t\t\t\"installation-id\": \"12345\",\n\t\t\t\t\"key\":             \"fixtures/test-private-key.test.pem\",\n\t\t\t},\n\t\t\tsetupMocks: func() {\n\t\t\t\thttpmock.RegisterResponder(\"POST\", \"https://api.github.com/app/installations/12345/access_tokens\",\n\t\t\t\t\thttpmock.NewStringResponder(403, `{\"message\": \"Forbidden\"}`))\n\t\t\t},\n\t\t\texpectedError: \"failed generating installation token: unexpected status code: 403\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\t// Reset mocks for each test\n\t\t\thttpmock.Reset()\n\t\t\ttt.setupMocks()\n\n\t\t\t// Create CLI context\n\t\t\tctx := createTestContext(tt.flags)\n\n\t\t\t// Execute the function\n\t\t\terr := Generate(ctx)\n\n\t\t\t// Assert results\n\t\t\tif tt.expectedError != \"\" {\n\t\t\t\tassert.Error(t, err)\n\t\t\t\tassert.Contains(t, err.Error(), tt.expectedError)\n\t\t\t} else {\n\t\t\t\tassert.NoError(t, err)\n\t\t\t}\n\n\t\t\t// Verify HTTP calls were made as expected\n\t\t\tif tt.expectedError == \"\" && !getBoolFlag(tt.flags, \"jwt\") {\n\t\t\t\tinfo := httpmock.GetCallCountInfo()\n\t\t\t\tassert.Greater(t, len(info), 0, \"Expected HTTP calls to be made\")\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestRetrieveDefaultInstallationID(t *testing.T) {\n\thttpmock.Activate()\n\tdefer httpmock.DeactivateAndReset()\n\n\ttests := []struct {\n\t\tname           string\n\t\thostname       string\n\t\tjwt            string\n\t\tresponseCode   int\n\t\tresponseBody   string\n\t\texpectedResult string\n\t\texpectedError  string\n\t}{\n\t\t{\n\t\t\tname:           \"successful_retrieval\",\n\t\t\thostname:       \"api.github.com\",\n\t\t\tjwt:            \"test.jwt.token\",\n\t\t\tresponseCode:   200,\n\t\t\tresponseBody:   `[{\"id\": 12345}]`,\n\t\t\texpectedResult: \"12345\",\n\t\t\texpectedError:  \"\",\n\t\t},\n\t\t{\n\t\t\tname:           \"not_found\",\n\t\t\thostname:       \"api.github.com\",\n\t\t\tjwt:            \"test.jwt.token\",\n\t\t\tresponseCode:   404,\n\t\t\tresponseBody:   `{\"message\": \"Not Found\"}`,\n\t\t\texpectedResult: \"\",\n\t\t\texpectedError:  \"unexpected status code: 404\",\n\t\t},\n\t\t{\n\t\t\tname:           \"invalid_json_response\",\n\t\t\thostname:       \"api.github.com\",\n\t\t\tjwt:            \"test.jwt.token\",\n\t\t\tresponseCode:   200,\n\t\t\tresponseBody:   \"invalid json\",\n\t\t\texpectedResult: \"\",\n\t\t\texpectedError:  \"unable to unmarshal response body\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\thttpmock.Reset()\n\n\t\t\tendpoint := fmt.Sprintf(\"https://%s/app/installations?per_page=1\", tt.hostname)\n\t\t\thttpmock.RegisterResponder(\"GET\", endpoint,\n\t\t\t\thttpmock.NewStringResponder(tt.responseCode, tt.responseBody))\n\n\t\t\tresult, err := retrieveDefaultInstallationID(tt.hostname, tt.jwt)\n\n\t\t\tif tt.expectedError != \"\" {\n\t\t\t\tassert.Error(t, err)\n\t\t\t\tassert.Contains(t, err.Error(), tt.expectedError)\n\t\t\t\tassert.Equal(t, \"\", result)\n\t\t\t} else {\n\t\t\t\tassert.NoError(t, err)\n\t\t\t\tassert.Equal(t, tt.expectedResult, result)\n\t\t\t}\n\n\t\t\t// Verify the request was made with correct headers\n\t\t\tinfo := httpmock.GetCallCountInfo()\n\t\t\tassert.Equal(t, 1, info[fmt.Sprintf(\"GET %s\", endpoint)])\n\t\t})\n\t}\n}\n\nfunc TestGenerateToken(t *testing.T) {\n\thttpmock.Activate()\n\tdefer httpmock.DeactivateAndReset()\n\n\ttokenResponse := &github.InstallationToken{\n\t\tToken:     github.String(\"ghs_test_token_123\"),\n\t\tExpiresAt: &github.Timestamp{Time: time.Now().Add(time.Hour)},\n\t}\n\ttokenJSON, _ := json.Marshal(tokenResponse)\n\n\ttests := []struct {\n\t\tname           string\n\t\thostname       string\n\t\tjwt            string\n\t\tinstallationID string\n\t\tresponseCode   int\n\t\tresponseBody   string\n\t\texpectedError  string\n\t}{\n\t\t{\n\t\t\tname:           \"successful_token_generation\",\n\t\t\thostname:       \"api.github.com\",\n\t\t\tjwt:            \"test.jwt.token\",\n\t\t\tinstallationID: \"12345\",\n\t\t\tresponseCode:   201,\n\t\t\tresponseBody:   string(tokenJSON),\n\t\t\texpectedError:  \"\",\n\t\t},\n\t\t{\n\t\t\tname:           \"forbidden_error\",\n\t\t\thostname:       \"api.github.com\",\n\t\t\tjwt:            \"test.jwt.token\",\n\t\t\tinstallationID: \"12345\",\n\t\t\tresponseCode:   403,\n\t\t\tresponseBody:   `{\"message\": \"Forbidden\"}`,\n\t\t\texpectedError:  \"unexpected status code: 403\",\n\t\t},\n\t\t{\n\t\t\tname:           \"invalid_json_response\",\n\t\t\thostname:       \"api.github.com\",\n\t\t\tjwt:            \"test.jwt.token\",\n\t\t\tinstallationID: \"12345\",\n\t\t\tresponseCode:   201,\n\t\t\tresponseBody:   \"invalid json\",\n\t\t\texpectedError:  \"unable to unmarshal response body\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\thttpmock.Reset()\n\n\t\t\tendpoint := fmt.Sprintf(\"https://%s/app/installations/%s/access_tokens\", tt.hostname, tt.installationID)\n\t\t\thttpmock.RegisterResponder(\"POST\", endpoint,\n\t\t\t\thttpmock.NewStringResponder(tt.responseCode, tt.responseBody))\n\n\t\t\tresult, err := generateToken(tt.hostname, tt.jwt, tt.installationID)\n\n\t\t\tif tt.expectedError != \"\" {\n\t\t\t\tassert.Error(t, err)\n\t\t\t\tassert.Contains(t, err.Error(), tt.expectedError)\n\t\t\t\tassert.Nil(t, result)\n\t\t\t} else {\n\t\t\t\tassert.NoError(t, err)\n\t\t\t\tassert.NotNil(t, result)\n\t\t\t\tassert.Equal(t, \"ghs_test_token_123\", *result.Token)\n\t\t\t}\n\n\t\t\t// Verify the request was made with correct headers\n\t\t\tinfo := httpmock.GetCallCountInfo()\n\t\t\tassert.Equal(t, 1, info[fmt.Sprintf(\"POST %s\", endpoint)])\n\t\t})\n\t}\n}\n\nfunc TestGenerateAdvancedCases(t *testing.T) {\n\thttpmock.Activate()\n\tdefer httpmock.DeactivateAndReset()\n\n\ttests := []struct {\n\t\tname          string\n\t\tsetupTest     func() *cli.Context\n\t\tsetupMocks    func()\n\t\texpectedError string\n\t}{\n\t\t{\n\t\t\tname: \"jwt_expiry_below_minimum\",\n\t\t\tsetupTest: func() *cli.Context {\n\t\t\t\treturn createTestContext(map[string]interface{}{\n\t\t\t\t\t\"app-id\":     \"123456\",\n\t\t\t\t\t\"key\":        \"fixtures/test-private-key.test.pem\",\n\t\t\t\t\t\"jwt-expiry\": 0, // Below minimum, should be adjusted to 10\n\t\t\t\t\t\"jwt\":        true,\n\t\t\t\t\t\"silent\":     true,\n\t\t\t\t})\n\t\t\t},\n\t\t\tsetupMocks:    func() {},\n\t\t\texpectedError: \"\",\n\t\t},\n\t\t{\n\t\t\tname: \"jwt_expiry_above_maximum\",\n\t\t\tsetupTest: func() *cli.Context {\n\t\t\t\treturn createTestContext(map[string]interface{}{\n\t\t\t\t\t\"app-id\":     \"123456\",\n\t\t\t\t\t\"key\":        \"fixtures/test-private-key.test.pem\",\n\t\t\t\t\t\"jwt-expiry\": 15, // Above maximum, should be adjusted to 10\n\t\t\t\t\t\"jwt\":        true,\n\t\t\t\t\t\"silent\":     true,\n\t\t\t\t})\n\t\t\t},\n\t\t\tsetupMocks:    func() {},\n\t\t\texpectedError: \"\",\n\t\t},\n\t\t{\n\t\t\tname: \"hostname_without_api_path\",\n\t\t\tsetupTest: func() *cli.Context {\n\t\t\t\treturn createTestContext(map[string]interface{}{\n\t\t\t\t\t\"app-id\":          \"123456\",\n\t\t\t\t\t\"installation-id\": \"12345\",\n\t\t\t\t\t\"key\":             \"fixtures/test-private-key.test.pem\",\n\t\t\t\t\t\"hostname\":        \"github.company.com\", // Without /api/v3\n\t\t\t\t\t\"silent\":          true,\n\t\t\t\t})\n\t\t\t},\n\t\t\tsetupMocks: func() {\n\t\t\t\ttokenResponse := &github.InstallationToken{\n\t\t\t\t\tToken:     github.String(\"ghs_test_token_123\"),\n\t\t\t\t\tExpiresAt: &github.Timestamp{Time: time.Now().Add(time.Hour)},\n\t\t\t\t}\n\t\t\t\ttokenJSON, _ := json.Marshal(tokenResponse)\n\t\t\t\thttpmock.RegisterResponder(\"POST\", \"https://github.company.com/api/v3/app/installations/12345/access_tokens\",\n\t\t\t\t\thttpmock.NewStringResponder(201, string(tokenJSON)))\n\t\t\t},\n\t\t\texpectedError: \"\",\n\t\t},\n\t\t{\n\t\t\tname: \"hostname_with_api_path_already_included\",\n\t\t\tsetupTest: func() *cli.Context {\n\t\t\t\treturn createTestContext(map[string]interface{}{\n\t\t\t\t\t\"app-id\":          \"123456\",\n\t\t\t\t\t\"installation-id\": \"12345\",\n\t\t\t\t\t\"key\":             \"fixtures/test-private-key.test.pem\",\n\t\t\t\t\t\"hostname\":        \"github.company.com/api/v3\", // Already has /api/v3\n\t\t\t\t\t\"silent\":          true,\n\t\t\t\t})\n\t\t\t},\n\t\t\tsetupMocks: func() {\n\t\t\t\ttokenResponse := &github.InstallationToken{\n\t\t\t\t\tToken:     github.String(\"ghs_test_token_123\"),\n\t\t\t\t\tExpiresAt: &github.Timestamp{Time: time.Now().Add(time.Hour)},\n\t\t\t\t}\n\t\t\t\ttokenJSON, _ := json.Marshal(tokenResponse)\n\t\t\t\thttpmock.RegisterResponder(\"POST\", \"https://github.company.com/api/v3/app/installations/12345/access_tokens\",\n\t\t\t\t\thttpmock.NewStringResponder(201, string(tokenJSON)))\n\t\t\t},\n\t\t\texpectedError: \"\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\thttpmock.Reset()\n\t\t\ttt.setupMocks()\n\n\t\t\tctx := tt.setupTest()\n\t\t\terr := Generate(ctx)\n\n\t\t\tif tt.expectedError != \"\" {\n\t\t\t\tassert.Error(t, err)\n\t\t\t\tassert.Contains(t, err.Error(), tt.expectedError)\n\t\t\t} else {\n\t\t\t\tassert.NoError(t, err)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// TestGenerateWithOutputFormats tests different output formats\nfunc TestGenerateWithOutputFormats(t *testing.T) {\n\thttpmock.Activate()\n\tdefer httpmock.DeactivateAndReset()\n\n\ttokenResponse := &github.InstallationToken{\n\t\tToken:     github.String(\"ghs_test_token_123\"),\n\t\tExpiresAt: &github.Timestamp{Time: time.Now().Add(time.Hour)},\n\t}\n\ttokenJSON, _ := json.Marshal(tokenResponse)\n\n\ttests := []struct {\n\t\tname       string\n\t\tflags      map[string]interface{}\n\t\tsetupMocks func()\n\t}{\n\t\t{\n\t\t\tname: \"json_output_format\",\n\t\t\tflags: map[string]interface{}{\n\t\t\t\t\"app-id\":          \"123456\",\n\t\t\t\t\"installation-id\": \"12345\",\n\t\t\t\t\"key\":             \"fixtures/test-private-key.test.pem\",\n\t\t\t\t\"hostname\":        \"api.github.com\",\n\t\t\t\t\"jwt-expiry\":      10,\n\t\t\t\t\"silent\":          false, // To test JSON output\n\t\t\t},\n\t\t\tsetupMocks: func() {\n\t\t\t\thttpmock.RegisterResponder(\"POST\", \"https://api.github.com/app/installations/12345/access_tokens\",\n\t\t\t\t\thttpmock.NewStringResponder(201, string(tokenJSON)))\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"token_only_output_format\",\n\t\t\tflags: map[string]interface{}{\n\t\t\t\t\"app-id\":          \"123456\",\n\t\t\t\t\"installation-id\": \"12345\",\n\t\t\t\t\"key\":             \"fixtures/test-private-key.test.pem\",\n\t\t\t\t\"hostname\":        \"api.github.com\",\n\t\t\t\t\"jwt-expiry\":      10,\n\t\t\t\t\"token-only\":      true,\n\t\t\t\t\"silent\":          false, // To test token-only output\n\t\t\t},\n\t\t\tsetupMocks: func() {\n\t\t\t\thttpmock.RegisterResponder(\"POST\", \"https://api.github.com/app/installations/12345/access_tokens\",\n\t\t\t\t\thttpmock.NewStringResponder(201, string(tokenJSON)))\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"jwt_output_format\",\n\t\t\tflags: map[string]interface{}{\n\t\t\t\t\"app-id\":     \"123456\",\n\t\t\t\t\"key\":        \"fixtures/test-private-key.test.pem\",\n\t\t\t\t\"jwt\":        true,\n\t\t\t\t\"jwt-expiry\": 10,\n\t\t\t\t\"silent\":     false, // To test JWT output\n\t\t\t},\n\t\t\tsetupMocks: func() {},\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\thttpmock.Reset()\n\t\t\ttt.setupMocks()\n\n\t\t\tctx := createTestContext(tt.flags)\n\t\t\terr := Generate(ctx)\n\n\t\t\tassert.NoError(t, err)\n\n\t\t\t// Verify HTTP calls were made as expected (except for JWT-only)\n\t\t\tif !getBoolFlag(tt.flags, \"jwt\") {\n\t\t\t\tinfo := httpmock.GetCallCountInfo()\n\t\t\t\tassert.Greater(t, len(info), 0, \"Expected HTTP calls to be made\")\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "internal/installations.go",
    "content": "package internal\n\nimport (\n\t\"crypto/rsa\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/google/go-github/v55/github\"\n\t\"github.com/urfave/cli/v2\"\n)\n\n// Installations is the entrypoint for the installations command\nfunc Installations(c *cli.Context) error {\n\tappID := c.String(\"app-id\")\n\tkeyPath := c.String(\"key\")\n\tkeyBase64 := c.String(\"base64-key\")\n\thostname := strings.ToLower(c.String(\"hostname\"))\n\n\tif keyPath == \"\" && keyBase64 == \"\" {\n\t\treturn fmt.Errorf(\"either --key or --base64-key must be specified\")\n\t}\n\n\tif keyPath != \"\" && keyBase64 != \"\" {\n\t\treturn fmt.Errorf(\"only one of --key or --base64-key may be specified\")\n\t}\n\n\tif hostname != \"api.github.com\" && !strings.Contains(hostname, \"/api/v3\") {\n\t\tendpoint := fmt.Sprintf(\"%s/api/v3\", hostname)\n\t\thostname = strings.TrimSuffix(endpoint, \"/\")\n\t}\n\n\tvar err error\n\tvar privateKey *rsa.PrivateKey\n\tif keyPath != \"\" {\n\t\tprivateKey, err = readKey(keyPath)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t} else {\n\t\tprivateKey, err = readKeyBase64(keyBase64)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tjsonWebToken, err := generateJWT(appID, 1, privateKey)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed generating JWT: %w\", err)\n\t}\n\n\tinstallations, err := listInstallations(hostname, jsonWebToken)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed listing installations: %w\", err)\n\t}\n\n\tbytes, err := json.MarshalIndent(installations, \"\", \"  \")\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed marshalling installations to JSON: %w\", err)\n\t}\n\n\tif len(*installations) < 1 {\n\t\tfmt.Println(\"[]\")\n\t\treturn nil\n\t}\n\n\tfmt.Println(string(bytes))\n\n\treturn nil\n}\n\nfunc listInstallations(hostname, jwt string) (*[]github.Installation, error) {\n\tpage := 0\n\tvar responses []github.Installation\n\tfor {\n\t\tendpoint := fmt.Sprintf(\"https://%s/app/installations?per_page=100&page=%d\", hostname, page)\n\t\treq, err := http.NewRequest(\"GET\", endpoint, nil)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"unable to create GET request to %s: %w\", endpoint, err)\n\t\t}\n\t\treq.Header.Add(\"Authorization\", fmt.Sprintf(\"Bearer %s\", jwt))\n\t\treq.Header.Add(\"Accept\", \"application/vnd.github+json\")\n\t\treq.Header.Add(\"X-GitHub-Api-Version\", \"2022-11-28\")\n\t\treq.Header.Add(\"User-Agent\", \"Link-/gh-token\")\n\n\t\tclient := &http.Client{}\n\t\tresp, err := client.Do(req)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"unable to POST to %s: %w\", endpoint, err)\n\t\t}\n\t\tdefer func() {\n\t\t\t_ = resp.Body.Close()\n\t\t}()\n\n\t\tif resp.StatusCode != 200 {\n\t\t\treturn nil, fmt.Errorf(\"unexpected status code: %d\", resp.StatusCode)\n\t\t}\n\n\t\tvar response *[]github.Installation\n\t\tbytes, err := io.ReadAll(resp.Body)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"unable to read response body: %w\", err)\n\t\t}\n\n\t\terr = json.Unmarshal(bytes, &response)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"unable to unmarshal response body: %w\", err)\n\t\t}\n\t\tresponses = append(responses, *response...)\n\n\t\tif len(*response) < 100 {\n\t\t\tbreak\n\t\t}\n\t\tpage++\n\n\t\ttime.Sleep(1 * time.Second)\n\t}\n\n\treturn &responses, nil\n}\n"
  },
  {
    "path": "internal/installations_flags.go",
    "content": "package internal\n\nimport \"github.com/urfave/cli/v2\"\n\n// InstallationsFlags returns the CLI flags for the generate command\nfunc InstallationsFlags() []cli.Flag {\n\treturn []cli.Flag{\n\t\t&cli.StringFlag{\n\t\t\tName:     \"app-id\",\n\t\t\tUsage:    \"GitHub App ID\",\n\t\t\tRequired: true,\n\t\t\tAliases:  []string{\"i\", \"app_id\"},\n\t\t},\n\t\t&cli.StringFlag{\n\t\t\tName:     \"key\",\n\t\t\tUsage:    \"Path to private key\",\n\t\t\tRequired: false,\n\t\t\tAliases:  []string{\"k\"},\n\t\t},\n\t\t&cli.StringFlag{\n\t\t\tName:     \"base64-key\",\n\t\t\tUsage:    \"A base64 encoded private key\",\n\t\t\tRequired: false,\n\t\t\tAliases:  []string{\"b\", \"base64_key\"},\n\t\t},\n\t\t&cli.StringFlag{\n\t\t\tName:     \"hostname\",\n\t\t\tUsage:    \"GitHub Enterprise Server API endpoint, example: github.example.com\",\n\t\t\tRequired: false,\n\t\t\tAliases:  []string{\"o\"},\n\t\t\tValue:    \"api.github.com\",\n\t\t},\n\t}\n}\n"
  },
  {
    "path": "internal/installations_test.go",
    "content": "package internal\n\nimport (\n\t\"encoding/base64\"\n\t\"encoding/json\"\n\t\"flag\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"os\"\n\t\"testing\"\n\n\t\"github.com/google/go-github/v55/github\"\n\t\"github.com/jarcoal/httpmock\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/urfave/cli/v2\"\n)\n\n// createTestContextForInstallations creates a test CLI context with the given flags for installations command\nfunc createTestContextForInstallations(flags map[string]interface{}) *cli.Context {\n\tapp := &cli.App{}\n\tset := flag.NewFlagSet(\"test\", flag.ContinueOnError)\n\n\t// Set default values\n\tdefaults := map[string]interface{}{\n\t\t\"app-id\":     \"\",\n\t\t\"key\":        \"\",\n\t\t\"base64-key\": \"\",\n\t\t\"hostname\":   \"api.github.com\",\n\t}\n\n\t// Override with test-specific flags\n\tfor k, v := range flags {\n\t\tdefaults[k] = v\n\t}\n\n\t// Set up flags based on type\n\tfor key, value := range defaults {\n\t\tswitch v := value.(type) {\n\t\tcase string:\n\t\t\tset.String(key, v, \"\")\n\t\tcase bool:\n\t\t\tset.Bool(key, v, \"\")\n\t\tcase int:\n\t\t\tset.Int(key, v, \"\")\n\t\t}\n\t}\n\n\treturn cli.NewContext(app, set, nil)\n}\n\nfunc TestInstallations(t *testing.T) {\n\t// Setup\n\thttpmock.Activate()\n\tdefer httpmock.DeactivateAndReset()\n\n\t// Read test key for base64 encoding\n\tkeyBytes, err := os.ReadFile(\"fixtures/test-private-key.test.pem\")\n\tassert.NoError(t, err)\n\tkeyBase64 := base64.StdEncoding.EncodeToString(keyBytes)\n\n\t// Sample installation responses\n\tsingleInstallationResponse := []github.Installation{\n\t\t{\n\t\t\tID:      github.Int64(12345),\n\t\t\tAccount: &github.User{Login: github.String(\"testuser\")},\n\t\t},\n\t}\n\tsingleInstallationJSON, _ := json.Marshal(singleInstallationResponse)\n\n\tmultipleInstallationsResponse := []github.Installation{\n\t\t{\n\t\t\tID:      github.Int64(12345),\n\t\t\tAccount: &github.User{Login: github.String(\"testuser1\")},\n\t\t},\n\t\t{\n\t\t\tID:      github.Int64(67890),\n\t\t\tAccount: &github.User{Login: github.String(\"testuser2\")},\n\t\t},\n\t}\n\tmultipleInstallationsJSON, _ := json.Marshal(multipleInstallationsResponse)\n\n\temptyInstallationsResponse := []github.Installation{}\n\temptyInstallationsJSON, _ := json.Marshal(emptyInstallationsResponse)\n\n\ttests := []struct {\n\t\tname          string\n\t\tflags         map[string]interface{}\n\t\tsetupMocks    func()\n\t\texpectedError string\n\t}{\n\t\t{\n\t\t\tname: \"successful_list_installations_with_key_file\",\n\t\t\tflags: map[string]interface{}{\n\t\t\t\t\"app-id\":   \"123456\",\n\t\t\t\t\"key\":      \"fixtures/test-private-key.test.pem\",\n\t\t\t\t\"hostname\": \"api.github.com\",\n\t\t\t},\n\t\t\tsetupMocks: func() {\n\t\t\t\thttpmock.RegisterResponder(\"GET\", \"https://api.github.com/app/installations?per_page=100&page=0\",\n\t\t\t\t\thttpmock.NewStringResponder(200, string(singleInstallationJSON)))\n\t\t\t},\n\t\t\texpectedError: \"\",\n\t\t},\n\t\t{\n\t\t\tname: \"successful_list_installations_with_base64_key\",\n\t\t\tflags: map[string]interface{}{\n\t\t\t\t\"app-id\":     \"123456\",\n\t\t\t\t\"base64-key\": keyBase64,\n\t\t\t\t\"hostname\":   \"api.github.com\",\n\t\t\t},\n\t\t\tsetupMocks: func() {\n\t\t\t\thttpmock.RegisterResponder(\"GET\", \"https://api.github.com/app/installations?per_page=100&page=0\",\n\t\t\t\t\thttpmock.NewStringResponder(200, string(singleInstallationJSON)))\n\t\t\t},\n\t\t\texpectedError: \"\",\n\t\t},\n\t\t{\n\t\t\tname: \"successful_list_multiple_installations\",\n\t\t\tflags: map[string]interface{}{\n\t\t\t\t\"app-id\":   \"123456\",\n\t\t\t\t\"key\":      \"fixtures/test-private-key.test.pem\",\n\t\t\t\t\"hostname\": \"api.github.com\",\n\t\t\t},\n\t\t\tsetupMocks: func() {\n\t\t\t\thttpmock.RegisterResponder(\"GET\", \"https://api.github.com/app/installations?per_page=100&page=0\",\n\t\t\t\t\thttpmock.NewStringResponder(200, string(multipleInstallationsJSON)))\n\t\t\t},\n\t\t\texpectedError: \"\",\n\t\t},\n\t\t{\n\t\t\tname: \"successful_empty_installations_list\",\n\t\t\tflags: map[string]interface{}{\n\t\t\t\t\"app-id\":   \"123456\",\n\t\t\t\t\"key\":      \"fixtures/test-private-key.test.pem\",\n\t\t\t\t\"hostname\": \"api.github.com\",\n\t\t\t},\n\t\t\tsetupMocks: func() {\n\t\t\t\thttpmock.RegisterResponder(\"GET\", \"https://api.github.com/app/installations?per_page=100&page=0\",\n\t\t\t\t\thttpmock.NewStringResponder(200, string(emptyInstallationsJSON)))\n\t\t\t},\n\t\t\texpectedError: \"\",\n\t\t},\n\t\t{\n\t\t\tname: \"successful_with_custom_hostname_without_api_path\",\n\t\t\tflags: map[string]interface{}{\n\t\t\t\t\"app-id\":   \"123456\",\n\t\t\t\t\"key\":      \"fixtures/test-private-key.test.pem\",\n\t\t\t\t\"hostname\": \"github.company.com\",\n\t\t\t},\n\t\t\tsetupMocks: func() {\n\t\t\t\thttpmock.RegisterResponder(\"GET\", \"https://github.company.com/api/v3/app/installations?per_page=100&page=0\",\n\t\t\t\t\thttpmock.NewStringResponder(200, string(singleInstallationJSON)))\n\t\t\t},\n\t\t\texpectedError: \"\",\n\t\t},\n\t\t{\n\t\t\tname: \"successful_with_custom_hostname_with_api_path\",\n\t\t\tflags: map[string]interface{}{\n\t\t\t\t\"app-id\":   \"123456\",\n\t\t\t\t\"key\":      \"fixtures/test-private-key.test.pem\",\n\t\t\t\t\"hostname\": \"github.company.com/api/v3\",\n\t\t\t},\n\t\t\tsetupMocks: func() {\n\t\t\t\thttpmock.RegisterResponder(\"GET\", \"https://github.company.com/api/v3/app/installations?per_page=100&page=0\",\n\t\t\t\t\thttpmock.NewStringResponder(200, string(singleInstallationJSON)))\n\t\t\t},\n\t\t\texpectedError: \"\",\n\t\t},\n\t\t{\n\t\t\tname: \"successful_with_mixed_case_hostname\",\n\t\t\tflags: map[string]interface{}{\n\t\t\t\t\"app-id\":   \"123456\",\n\t\t\t\t\"key\":      \"fixtures/test-private-key.test.pem\",\n\t\t\t\t\"hostname\": \"GitHub.Company.COM\",\n\t\t\t},\n\t\t\tsetupMocks: func() {\n\t\t\t\thttpmock.RegisterResponder(\"GET\", \"https://github.company.com/api/v3/app/installations?per_page=100&page=0\",\n\t\t\t\t\thttpmock.NewStringResponder(200, string(singleInstallationJSON)))\n\t\t\t},\n\t\t\texpectedError: \"\",\n\t\t},\n\t\t{\n\t\t\tname: \"error_no_key_specified\",\n\t\t\tflags: map[string]interface{}{\n\t\t\t\t\"app-id\": \"123456\",\n\t\t\t},\n\t\t\tsetupMocks:    func() {},\n\t\t\texpectedError: \"either --key or --base64-key must be specified\",\n\t\t},\n\t\t{\n\t\t\tname: \"error_both_keys_specified\",\n\t\t\tflags: map[string]interface{}{\n\t\t\t\t\"app-id\":     \"123456\",\n\t\t\t\t\"key\":        \"fixtures/test-private-key.test.pem\",\n\t\t\t\t\"base64-key\": keyBase64,\n\t\t\t},\n\t\t\tsetupMocks:    func() {},\n\t\t\texpectedError: \"only one of --key or --base64-key may be specified\",\n\t\t},\n\t\t{\n\t\t\tname: \"error_invalid_key_file\",\n\t\t\tflags: map[string]interface{}{\n\t\t\t\t\"app-id\": \"123456\",\n\t\t\t\t\"key\":    \"fixtures/nonexistent.pem\",\n\t\t\t},\n\t\t\tsetupMocks:    func() {},\n\t\t\texpectedError: \"unable to read key file\",\n\t\t},\n\t\t{\n\t\t\tname: \"error_invalid_base64_key\",\n\t\t\tflags: map[string]interface{}{\n\t\t\t\t\"app-id\":     \"123456\",\n\t\t\t\t\"base64-key\": \"invalid-base64-string\",\n\t\t\t},\n\t\t\tsetupMocks:    func() {},\n\t\t\texpectedError: \"unable to decode key from base64\",\n\t\t},\n\t\t{\n\t\t\tname: \"error_http_request_fails\",\n\t\t\tflags: map[string]interface{}{\n\t\t\t\t\"app-id\":   \"123456\",\n\t\t\t\t\"key\":      \"fixtures/test-private-key.test.pem\",\n\t\t\t\t\"hostname\": \"api.github.com\",\n\t\t\t},\n\t\t\tsetupMocks: func() {\n\t\t\t\thttpmock.RegisterResponder(\"GET\", \"https://api.github.com/app/installations?per_page=100&page=0\",\n\t\t\t\t\thttpmock.NewErrorResponder(fmt.Errorf(\"network error\")))\n\t\t\t},\n\t\t\texpectedError: \"failed listing installations\",\n\t\t},\n\t\t{\n\t\t\tname: \"error_http_status_not_200\",\n\t\t\tflags: map[string]interface{}{\n\t\t\t\t\"app-id\":   \"123456\",\n\t\t\t\t\"key\":      \"fixtures/test-private-key.test.pem\",\n\t\t\t\t\"hostname\": \"api.github.com\",\n\t\t\t},\n\t\t\tsetupMocks: func() {\n\t\t\t\thttpmock.RegisterResponder(\"GET\", \"https://api.github.com/app/installations?per_page=100&page=0\",\n\t\t\t\t\thttpmock.NewStringResponder(404, `{\"message\": \"Not Found\"}`))\n\t\t\t},\n\t\t\texpectedError: \"failed listing installations: unexpected status code: 404\",\n\t\t},\n\t\t{\n\t\t\tname: \"error_invalid_json_response\",\n\t\t\tflags: map[string]interface{}{\n\t\t\t\t\"app-id\":   \"123456\",\n\t\t\t\t\"key\":      \"fixtures/test-private-key.test.pem\",\n\t\t\t\t\"hostname\": \"api.github.com\",\n\t\t\t},\n\t\t\tsetupMocks: func() {\n\t\t\t\thttpmock.RegisterResponder(\"GET\", \"https://api.github.com/app/installations?per_page=100&page=0\",\n\t\t\t\t\thttpmock.NewStringResponder(200, \"invalid json\"))\n\t\t\t},\n\t\t\texpectedError: \"failed listing installations: unable to unmarshal response body\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\t// Reset mocks for each test\n\t\t\thttpmock.Reset()\n\t\t\ttt.setupMocks()\n\n\t\t\t// Create CLI context\n\t\t\tctx := createTestContextForInstallations(tt.flags)\n\n\t\t\t// Execute the function\n\t\t\terr := Installations(ctx)\n\n\t\t\t// Assert results\n\t\t\tif tt.expectedError != \"\" {\n\t\t\t\tassert.Error(t, err)\n\t\t\t\tassert.Contains(t, err.Error(), tt.expectedError)\n\t\t\t} else {\n\t\t\t\tassert.NoError(t, err)\n\n\t\t\t\t// Verify HTTP calls were made as expected\n\t\t\t\tinfo := httpmock.GetCallCountInfo()\n\t\t\t\tassert.Greater(t, len(info), 0, \"Expected HTTP calls to be made\")\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestListInstallations(t *testing.T) {\n\thttpmock.Activate()\n\tdefer httpmock.DeactivateAndReset()\n\n\t// Sample installation responses\n\tfirstPageResponse := []github.Installation{\n\t\t{ID: github.Int64(12345), Account: &github.User{Login: github.String(\"user1\")}},\n\t\t{ID: github.Int64(67890), Account: &github.User{Login: github.String(\"user2\")}},\n\t}\n\tfirstPageJSON, _ := json.Marshal(firstPageResponse)\n\n\tsecondPageResponse := []github.Installation{\n\t\t{ID: github.Int64(11111), Account: &github.User{Login: github.String(\"user3\")}},\n\t}\n\tsecondPageJSON, _ := json.Marshal(secondPageResponse)\n\n\temptyResponse := []github.Installation{}\n\temptyResponseJSON, _ := json.Marshal(emptyResponse)\n\n\t// Create a response with 100 items to test pagination\n\tfullPageResponse := make([]github.Installation, 100)\n\tfor i := 0; i < 100; i++ {\n\t\tfullPageResponse[i] = github.Installation{\n\t\t\tID:      github.Int64(int64(i + 1000)),\n\t\t\tAccount: &github.User{Login: github.String(fmt.Sprintf(\"user%d\", i))},\n\t\t}\n\t}\n\tfullPageJSON, _ := json.Marshal(fullPageResponse)\n\n\ttests := []struct {\n\t\tname          string\n\t\thostname      string\n\t\tjwt           string\n\t\tsetupMocks    func()\n\t\texpectedCount int\n\t\texpectedError string\n\t}{\n\t\t{\n\t\t\tname:     \"successful_single_page\",\n\t\t\thostname: \"api.github.com\",\n\t\t\tjwt:      \"test.jwt.token\",\n\t\t\tsetupMocks: func() {\n\t\t\t\thttpmock.RegisterResponder(\"GET\", \"https://api.github.com/app/installations?per_page=100&page=0\",\n\t\t\t\t\thttpmock.NewStringResponder(200, string(firstPageJSON)))\n\t\t\t},\n\t\t\texpectedCount: 2,\n\t\t\texpectedError: \"\",\n\t\t},\n\t\t{\n\t\t\tname:     \"successful_multiple_pages\",\n\t\t\thostname: \"api.github.com\",\n\t\t\tjwt:      \"test.jwt.token\",\n\t\t\tsetupMocks: func() {\n\t\t\t\thttpmock.RegisterResponder(\"GET\", \"https://api.github.com/app/installations?per_page=100&page=0\",\n\t\t\t\t\thttpmock.NewStringResponder(200, string(fullPageJSON)))\n\t\t\t\thttpmock.RegisterResponder(\"GET\", \"https://api.github.com/app/installations?per_page=100&page=1\",\n\t\t\t\t\thttpmock.NewStringResponder(200, string(secondPageJSON)))\n\t\t\t},\n\t\t\texpectedCount: 101,\n\t\t\texpectedError: \"\",\n\t\t},\n\t\t{\n\t\t\tname:     \"successful_empty_response\",\n\t\t\thostname: \"api.github.com\",\n\t\t\tjwt:      \"test.jwt.token\",\n\t\t\tsetupMocks: func() {\n\t\t\t\thttpmock.RegisterResponder(\"GET\", \"https://api.github.com/app/installations?per_page=100&page=0\",\n\t\t\t\t\thttpmock.NewStringResponder(200, string(emptyResponseJSON)))\n\t\t\t},\n\t\t\texpectedCount: 0,\n\t\t\texpectedError: \"\",\n\t\t},\n\t\t{\n\t\t\tname:     \"successful_with_custom_hostname\",\n\t\t\thostname: \"github.company.com/api/v3\",\n\t\t\tjwt:      \"test.jwt.token\",\n\t\t\tsetupMocks: func() {\n\t\t\t\thttpmock.RegisterResponder(\"GET\", \"https://github.company.com/api/v3/app/installations?per_page=100&page=0\",\n\t\t\t\t\thttpmock.NewStringResponder(200, string(firstPageJSON)))\n\t\t\t},\n\t\t\texpectedCount: 2,\n\t\t\texpectedError: \"\",\n\t\t},\n\t\t{\n\t\t\tname:     \"error_http_request_fails\",\n\t\t\thostname: \"api.github.com\",\n\t\t\tjwt:      \"test.jwt.token\",\n\t\t\tsetupMocks: func() {\n\t\t\t\thttpmock.RegisterResponder(\"GET\", \"https://api.github.com/app/installations?per_page=100&page=0\",\n\t\t\t\t\thttpmock.NewErrorResponder(fmt.Errorf(\"network error\")))\n\t\t\t},\n\t\t\texpectedCount: 0,\n\t\t\texpectedError: \"unable to POST to\",\n\t\t},\n\t\t{\n\t\t\tname:     \"error_status_not_200\",\n\t\t\thostname: \"api.github.com\",\n\t\t\tjwt:      \"test.jwt.token\",\n\t\t\tsetupMocks: func() {\n\t\t\t\thttpmock.RegisterResponder(\"GET\", \"https://api.github.com/app/installations?per_page=100&page=0\",\n\t\t\t\t\thttpmock.NewStringResponder(401, `{\"message\": \"Unauthorized\"}`))\n\t\t\t},\n\t\t\texpectedCount: 0,\n\t\t\texpectedError: \"unexpected status code: 401\",\n\t\t},\n\t\t{\n\t\t\tname:     \"error_invalid_json_response\",\n\t\t\thostname: \"api.github.com\",\n\t\t\tjwt:      \"test.jwt.token\",\n\t\t\tsetupMocks: func() {\n\t\t\t\thttpmock.RegisterResponder(\"GET\", \"https://api.github.com/app/installations?per_page=100&page=0\",\n\t\t\t\t\thttpmock.NewStringResponder(200, \"invalid json\"))\n\t\t\t},\n\t\t\texpectedCount: 0,\n\t\t\texpectedError: \"unable to unmarshal response body\",\n\t\t},\n\t\t{\n\t\t\tname:     \"error_on_second_page\",\n\t\t\thostname: \"api.github.com\",\n\t\t\tjwt:      \"test.jwt.token\",\n\t\t\tsetupMocks: func() {\n\t\t\t\thttpmock.RegisterResponder(\"GET\", \"https://api.github.com/app/installations?per_page=100&page=0\",\n\t\t\t\t\thttpmock.NewStringResponder(200, string(fullPageJSON)))\n\t\t\t\thttpmock.RegisterResponder(\"GET\", \"https://api.github.com/app/installations?per_page=100&page=1\",\n\t\t\t\t\thttpmock.NewStringResponder(500, `{\"message\": \"Internal Server Error\"}`))\n\t\t\t},\n\t\t\texpectedCount: 0,\n\t\t\texpectedError: \"unexpected status code: 500\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\t// Reset mocks for each test\n\t\t\thttpmock.Reset()\n\t\t\ttt.setupMocks()\n\n\t\t\t// Execute the function\n\t\t\tresult, err := listInstallations(tt.hostname, tt.jwt)\n\n\t\t\t// Assert results\n\t\t\tif tt.expectedError != \"\" {\n\t\t\t\tassert.Error(t, err)\n\t\t\t\tassert.Contains(t, err.Error(), tt.expectedError)\n\t\t\t\tassert.Nil(t, result)\n\t\t\t} else {\n\t\t\t\tassert.NoError(t, err)\n\t\t\t\tassert.NotNil(t, result)\n\t\t\t\tassert.Equal(t, tt.expectedCount, len(*result))\n\n\t\t\t\t// Verify HTTP calls were made as expected\n\t\t\t\tinfo := httpmock.GetCallCountInfo()\n\t\t\t\tassert.Greater(t, len(info), 0, \"Expected HTTP calls to be made\")\n\n\t\t\t\t// Verify request headers for the first request\n\t\t\t\tif tt.expectedCount > 0 {\n\t\t\t\t\t// Check that the Authorization header was set correctly\n\t\t\t\t\tendpoint := fmt.Sprintf(\"https://%s/app/installations?per_page=100&page=0\", tt.hostname)\n\t\t\t\t\tassert.Equal(t, 1, info[fmt.Sprintf(\"GET %s\", endpoint)], \"Expected exactly one call to first page\")\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestInstallationsPaginationBehavior(t *testing.T) {\n\thttpmock.Activate()\n\tdefer httpmock.DeactivateAndReset()\n\n\t// Test that pagination stops when we get less than 100 results\n\t// Create responses for multiple pages\n\tpage0Response := make([]github.Installation, 100)\n\tfor i := 0; i < 100; i++ {\n\t\tpage0Response[i] = github.Installation{\n\t\t\tID:      github.Int64(int64(i)),\n\t\t\tAccount: &github.User{Login: github.String(fmt.Sprintf(\"user%d\", i))},\n\t\t}\n\t}\n\tpage0JSON, _ := json.Marshal(page0Response)\n\n\tpage1Response := make([]github.Installation, 100)\n\tfor i := 0; i < 100; i++ {\n\t\tpage1Response[i] = github.Installation{\n\t\t\tID:      github.Int64(int64(i + 100)),\n\t\t\tAccount: &github.User{Login: github.String(fmt.Sprintf(\"user%d\", i+100))},\n\t\t}\n\t}\n\tpage1JSON, _ := json.Marshal(page1Response)\n\n\t// Page 2 has less than 100 results, should stop pagination\n\tpage2Response := make([]github.Installation, 50)\n\tfor i := 0; i < 50; i++ {\n\t\tpage2Response[i] = github.Installation{\n\t\t\tID:      github.Int64(int64(i + 200)),\n\t\t\tAccount: &github.User{Login: github.String(fmt.Sprintf(\"user%d\", i+200))},\n\t\t}\n\t}\n\tpage2JSON, _ := json.Marshal(page2Response)\n\n\tt.Run(\"pagination_stops_when_less_than_100_results\", func(t *testing.T) {\n\t\thttpmock.Reset()\n\n\t\thttpmock.RegisterResponder(\"GET\", \"https://api.github.com/app/installations?per_page=100&page=0\",\n\t\t\thttpmock.NewStringResponder(200, string(page0JSON)))\n\t\thttpmock.RegisterResponder(\"GET\", \"https://api.github.com/app/installations?per_page=100&page=1\",\n\t\t\thttpmock.NewStringResponder(200, string(page1JSON)))\n\t\thttpmock.RegisterResponder(\"GET\", \"https://api.github.com/app/installations?per_page=100&page=2\",\n\t\t\thttpmock.NewStringResponder(200, string(page2JSON)))\n\n\t\tresult, err := listInstallations(\"api.github.com\", \"test.jwt.token\")\n\n\t\tassert.NoError(t, err)\n\t\tassert.NotNil(t, result)\n\t\tassert.Equal(t, 250, len(*result)) // 100 + 100 + 50\n\n\t\t// Verify all three pages were called\n\t\tinfo := httpmock.GetCallCountInfo()\n\t\tassert.Equal(t, 1, info[\"GET https://api.github.com/app/installations?per_page=100&page=0\"])\n\t\tassert.Equal(t, 1, info[\"GET https://api.github.com/app/installations?per_page=100&page=1\"])\n\t\tassert.Equal(t, 1, info[\"GET https://api.github.com/app/installations?per_page=100&page=2\"])\n\t\t// Page 3 should not be called\n\t\tassert.Equal(t, 0, info[\"GET https://api.github.com/app/installations?per_page=100&page=3\"])\n\t})\n}\n\nfunc TestInstallationsRequestHeaders(t *testing.T) {\n\thttpmock.Activate()\n\tdefer httpmock.DeactivateAndReset()\n\n\tinstallationResponse := []github.Installation{\n\t\t{ID: github.Int64(12345)},\n\t}\n\tinstallationJSON, _ := json.Marshal(installationResponse)\n\n\tt.Run(\"correct_headers_are_set\", func(t *testing.T) {\n\t\thttpmock.Reset()\n\n\t\t// Use a custom responder to check headers\n\t\thttpmock.RegisterResponder(\"GET\", \"https://api.github.com/app/installations?per_page=100&page=0\",\n\t\t\tfunc(req *http.Request) (*http.Response, error) {\n\t\t\t\t// Verify headers\n\t\t\t\tassert.Equal(t, \"Bearer test.jwt.token\", req.Header.Get(\"Authorization\"))\n\t\t\t\tassert.Equal(t, \"application/vnd.github+json\", req.Header.Get(\"Accept\"))\n\t\t\t\tassert.Equal(t, \"2022-11-28\", req.Header.Get(\"X-GitHub-Api-Version\"))\n\t\t\t\tassert.Equal(t, \"Link-/gh-token\", req.Header.Get(\"User-Agent\"))\n\n\t\t\t\treturn httpmock.NewStringResponse(200, string(installationJSON)), nil\n\t\t\t})\n\n\t\tresult, err := listInstallations(\"api.github.com\", \"test.jwt.token\")\n\n\t\tassert.NoError(t, err)\n\t\tassert.NotNil(t, result)\n\t\tassert.Equal(t, 1, len(*result))\n\t})\n}\n"
  },
  {
    "path": "internal/key.go",
    "content": "package internal\n\nimport (\n\t\"crypto/rsa\"\n\t\"encoding/base64\"\n\t\"fmt\"\n\t\"os\"\n\t\"time\"\n\n\t\"github.com/golang-jwt/jwt/v5\"\n)\n\nfunc readKey(path string) (*rsa.PrivateKey, error) {\n\tkeyBytes, err := os.ReadFile(path)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"unable to read key file: %w\", err)\n\t}\n\tkey, err := jwt.ParseRSAPrivateKeyFromPEM(keyBytes)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"unable to parse key from PEM to RSA format: %w\", err)\n\t}\n\n\treturn key, nil\n}\n\nfunc readKeyBase64(keyBase64 string) (*rsa.PrivateKey, error) {\n\tkeyBytes, err := base64.StdEncoding.DecodeString(keyBase64)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"unable to decode key from base64: %w\", err)\n\t}\n\tkey, err := jwt.ParseRSAPrivateKeyFromPEM(keyBytes)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"unable to parse key from PEM to RSA format: %w\", err)\n\t}\n\n\treturn key, nil\n}\n\nfunc generateJWT(appID string, expiry int, key *rsa.PrivateKey) (string, error) {\n\tiat := jwt.NewNumericDate(time.Now().Add(-60 * time.Second))\n\texp := jwt.NewNumericDate(time.Now().Add(time.Duration(expiry) * 60 * time.Second))\n\ttoken := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{\n\t\t\"iat\": iat,\n\t\t\"exp\": exp,\n\t\t\"iss\": appID,\n\t})\n\tsignedToken, err := token.SignedString(key)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"unable to sign JWT: %w\", err)\n\t}\n\n\treturn signedToken, nil\n}\n"
  },
  {
    "path": "internal/revoke.go",
    "content": "package internal\n\nimport (\n\t\"fmt\"\n\t\"net/http\"\n\t\"strings\"\n\n\t\"github.com/urfave/cli/v2\"\n)\n\n// Revoke is the entrypoint for the revoke command\nfunc Revoke(c *cli.Context) error {\n\ttoken := c.String(\"token\")\n\thostname := strings.ToLower(c.String(\"hostname\"))\n\tsilent := c.Bool(\"silent\")\n\n\tif hostname != \"api.github.com\" && !strings.Contains(hostname, \"/api/v3\") {\n\t\tendpoint := fmt.Sprintf(\"%s/api/v3\", hostname)\n\t\thostname = strings.TrimSuffix(endpoint, \"/\")\n\t}\n\n\terr := revokeToken(hostname, token)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed revoking installation token: %w\", err)\n\t}\n\tif !silent {\n\t\tfmt.Println(\"Successfully revoked installation token\")\n\t}\n\n\treturn nil\n}\n\nfunc revokeToken(hostname, token string) error {\n\tendpoint := fmt.Sprintf(\"https://%s/installation/token\", hostname)\n\treq, err := http.NewRequest(\"DELETE\", endpoint, nil)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"unable to create DELETE request to %s: %w\", endpoint, err)\n\t}\n\treq.Header.Add(\"Authorization\", fmt.Sprintf(\"Bearer %s\", token))\n\treq.Header.Add(\"Accept\", \"application/vnd.github+json\")\n\treq.Header.Add(\"X-GitHub-Api-Version\", \"2022-11-28\")\n\treq.Header.Add(\"User-Agent\", \"Link-/gh-token\")\n\n\tclient := &http.Client{}\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"unable to DELETE to %s: %w\", endpoint, err)\n\t}\n\tdefer func() {\n\t\t_ = resp.Body.Close()\n\t}()\n\n\tif resp.StatusCode != 204 {\n\t\treturn fmt.Errorf(\"token might be invalid or not properly formatted. Unexpected status code: %d\", resp.StatusCode)\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "internal/revoke_flags.go",
    "content": "package internal\n\nimport \"github.com/urfave/cli/v2\"\n\n// RevokeFlags returns the CLI flags for the revoke command\nfunc RevokeFlags() []cli.Flag {\n\treturn []cli.Flag{\n\t\t&cli.StringFlag{\n\t\t\tName:     \"token\",\n\t\t\tUsage:    \"GitHub App installation Token\",\n\t\t\tRequired: true,\n\t\t\tAliases:  []string{\"t\"},\n\t\t},\n\t\t&cli.StringFlag{\n\t\t\tName:     \"hostname\",\n\t\t\tUsage:    \"GitHub Enterprise Server API endpoint, example: github.example.com\",\n\t\t\tRequired: false,\n\t\t\tAliases:  []string{\"o\"},\n\t\t\tValue:    \"api.github.com\",\n\t\t},\n\t\t&cli.BoolFlag{\n\t\t\tName:    \"silent\",\n\t\t\tUsage:   \"Do not print to stdout\",\n\t\t\tAliases: []string{\"s\"},\n\t\t\tValue:   false,\n\t\t},\n\t}\n}\n"
  },
  {
    "path": "internal/revoke_test.go",
    "content": "package internal\n\nimport (\n\t\"flag\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"testing\"\n\n\t\"github.com/jarcoal/httpmock\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/urfave/cli/v2\"\n)\n\n// createTestContextForRevoke creates a test CLI context with the given flags for revoke command\nfunc createTestContextForRevoke(flags map[string]interface{}) *cli.Context {\n\tapp := &cli.App{}\n\tset := flag.NewFlagSet(\"test\", flag.ContinueOnError)\n\n\t// Set default values\n\tdefaults := map[string]interface{}{\n\t\t\"token\":    \"\",\n\t\t\"hostname\": \"api.github.com\",\n\t\t\"silent\":   false,\n\t}\n\n\t// Override with test-specific flags\n\tfor k, v := range flags {\n\t\tdefaults[k] = v\n\t}\n\n\t// Set up flags based on type\n\tfor key, value := range defaults {\n\t\tswitch v := value.(type) {\n\t\tcase string:\n\t\t\tset.String(key, v, \"\")\n\t\tcase bool:\n\t\t\tset.Bool(key, v, \"\")\n\t\t}\n\t}\n\n\treturn cli.NewContext(app, set, nil)\n}\n\nfunc TestRevoke(t *testing.T) {\n\t// Setup\n\thttpmock.Activate()\n\tdefer httpmock.DeactivateAndReset()\n\n\ttests := []struct {\n\t\tname          string\n\t\tflags         map[string]interface{}\n\t\tsetupMocks    func()\n\t\texpectedError string\n\t}{\n\t\t{\n\t\t\tname: \"successful_token_revocation_default_hostname\",\n\t\t\tflags: map[string]interface{}{\n\t\t\t\t\"token\":    \"ghs_test_token_123\",\n\t\t\t\t\"hostname\": \"api.github.com\",\n\t\t\t\t\"silent\":   true,\n\t\t\t},\n\t\t\tsetupMocks: func() {\n\t\t\t\thttpmock.RegisterResponder(\"DELETE\", \"https://api.github.com/installation/token\",\n\t\t\t\t\thttpmock.NewStringResponder(204, \"\"))\n\t\t\t},\n\t\t\texpectedError: \"\",\n\t\t},\n\t\t{\n\t\t\tname: \"successful_token_revocation_custom_hostname_without_api_path\",\n\t\t\tflags: map[string]interface{}{\n\t\t\t\t\"token\":    \"ghs_test_token_456\",\n\t\t\t\t\"hostname\": \"github.company.com\",\n\t\t\t\t\"silent\":   true,\n\t\t\t},\n\t\t\tsetupMocks: func() {\n\t\t\t\thttpmock.RegisterResponder(\"DELETE\", \"https://github.company.com/api/v3/installation/token\",\n\t\t\t\t\thttpmock.NewStringResponder(204, \"\"))\n\t\t\t},\n\t\t\texpectedError: \"\",\n\t\t},\n\t\t{\n\t\t\tname: \"successful_token_revocation_custom_hostname_with_api_path\",\n\t\t\tflags: map[string]interface{}{\n\t\t\t\t\"token\":    \"ghs_test_token_789\",\n\t\t\t\t\"hostname\": \"github.company.com/api/v3\",\n\t\t\t\t\"silent\":   true,\n\t\t\t},\n\t\t\tsetupMocks: func() {\n\t\t\t\thttpmock.RegisterResponder(\"DELETE\", \"https://github.company.com/api/v3/installation/token\",\n\t\t\t\t\thttpmock.NewStringResponder(204, \"\"))\n\t\t\t},\n\t\t\texpectedError: \"\",\n\t\t},\n\t\t{\n\t\t\tname: \"successful_token_revocation_verbose_output\",\n\t\t\tflags: map[string]interface{}{\n\t\t\t\t\"token\":    \"ghs_test_token_verbose\",\n\t\t\t\t\"hostname\": \"api.github.com\",\n\t\t\t\t\"silent\":   false,\n\t\t\t},\n\t\t\tsetupMocks: func() {\n\t\t\t\thttpmock.RegisterResponder(\"DELETE\", \"https://api.github.com/installation/token\",\n\t\t\t\t\thttpmock.NewStringResponder(204, \"\"))\n\t\t\t},\n\t\t\texpectedError: \"\",\n\t\t},\n\t\t{\n\t\t\tname: \"error_invalid_token_401\",\n\t\t\tflags: map[string]interface{}{\n\t\t\t\t\"token\":    \"invalid_token\",\n\t\t\t\t\"hostname\": \"api.github.com\",\n\t\t\t\t\"silent\":   true,\n\t\t\t},\n\t\t\tsetupMocks: func() {\n\t\t\t\thttpmock.RegisterResponder(\"DELETE\", \"https://api.github.com/installation/token\",\n\t\t\t\t\thttpmock.NewStringResponder(401, `{\"message\": \"Bad credentials\"}`))\n\t\t\t},\n\t\t\texpectedError: \"failed revoking installation token: token might be invalid or not properly formatted. Unexpected status code: 401\",\n\t\t},\n\t\t{\n\t\t\tname: \"error_forbidden_403\",\n\t\t\tflags: map[string]interface{}{\n\t\t\t\t\"token\":    \"forbidden_token\",\n\t\t\t\t\"hostname\": \"api.github.com\",\n\t\t\t\t\"silent\":   true,\n\t\t\t},\n\t\t\tsetupMocks: func() {\n\t\t\t\thttpmock.RegisterResponder(\"DELETE\", \"https://api.github.com/installation/token\",\n\t\t\t\t\thttpmock.NewStringResponder(403, `{\"message\": \"Forbidden\"}`))\n\t\t\t},\n\t\t\texpectedError: \"failed revoking installation token: token might be invalid or not properly formatted. Unexpected status code: 403\",\n\t\t},\n\t\t{\n\t\t\tname: \"error_not_found_404\",\n\t\t\tflags: map[string]interface{}{\n\t\t\t\t\"token\":    \"not_found_token\",\n\t\t\t\t\"hostname\": \"api.github.com\",\n\t\t\t\t\"silent\":   true,\n\t\t\t},\n\t\t\tsetupMocks: func() {\n\t\t\t\thttpmock.RegisterResponder(\"DELETE\", \"https://api.github.com/installation/token\",\n\t\t\t\t\thttpmock.NewStringResponder(404, `{\"message\": \"Not Found\"}`))\n\t\t\t},\n\t\t\texpectedError: \"failed revoking installation token: token might be invalid or not properly formatted. Unexpected status code: 404\",\n\t\t},\n\t\t{\n\t\t\tname: \"error_server_error_500\",\n\t\t\tflags: map[string]interface{}{\n\t\t\t\t\"token\":    \"server_error_token\",\n\t\t\t\t\"hostname\": \"api.github.com\",\n\t\t\t\t\"silent\":   true,\n\t\t\t},\n\t\t\tsetupMocks: func() {\n\t\t\t\thttpmock.RegisterResponder(\"DELETE\", \"https://api.github.com/installation/token\",\n\t\t\t\t\thttpmock.NewStringResponder(500, `{\"message\": \"Internal Server Error\"}`))\n\t\t\t},\n\t\t\texpectedError: \"failed revoking installation token: token might be invalid or not properly formatted. Unexpected status code: 500\",\n\t\t},\n\t\t{\n\t\t\tname: \"hostname_case_insensitive\",\n\t\t\tflags: map[string]interface{}{\n\t\t\t\t\"token\":    \"ghs_case_test_token\",\n\t\t\t\t\"hostname\": \"API.GITHUB.COM\",\n\t\t\t\t\"silent\":   true,\n\t\t\t},\n\t\t\tsetupMocks: func() {\n\t\t\t\thttpmock.RegisterResponder(\"DELETE\", \"https://api.github.com/installation/token\",\n\t\t\t\t\thttpmock.NewStringResponder(204, \"\"))\n\t\t\t},\n\t\t\texpectedError: \"\",\n\t\t},\n\t\t{\n\t\t\tname: \"custom_hostname_with_trailing_slash\",\n\t\t\tflags: map[string]interface{}{\n\t\t\t\t\"token\":    \"ghs_trailing_slash_token\",\n\t\t\t\t\"hostname\": \"github.company.com/api/v3/\",\n\t\t\t\t\"silent\":   true,\n\t\t\t},\n\t\t\tsetupMocks: func() {\n\t\t\t\t// The hostname processing logic preserves the trailing slash when /api/v3 is already present,\n\t\t\t\t// resulting in a double slash in the final URL\n\t\t\t\thttpmock.RegisterResponder(\"DELETE\", \"https://github.company.com/api/v3//installation/token\",\n\t\t\t\t\thttpmock.NewStringResponder(204, \"\"))\n\t\t\t},\n\t\t\texpectedError: \"\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\t// Reset mocks for each test\n\t\t\thttpmock.Reset()\n\t\t\ttt.setupMocks()\n\n\t\t\t// Create CLI context\n\t\t\tctx := createTestContextForRevoke(tt.flags)\n\n\t\t\t// Execute the function\n\t\t\terr := Revoke(ctx)\n\n\t\t\t// Assert results\n\t\t\tif tt.expectedError != \"\" {\n\t\t\t\tassert.Error(t, err)\n\t\t\t\tassert.Equal(t, tt.expectedError, err.Error())\n\t\t\t} else {\n\t\t\t\tassert.NoError(t, err)\n\t\t\t}\n\n\t\t\t// Verify HTTP calls were made as expected\n\t\t\tinfo := httpmock.GetCallCountInfo()\n\t\t\tassert.Greater(t, len(info), 0, \"Expected HTTP calls to be made\")\n\t\t})\n\t}\n}\n\nfunc TestRevokeToken(t *testing.T) {\n\thttpmock.Activate()\n\tdefer httpmock.DeactivateAndReset()\n\n\ttests := []struct {\n\t\tname          string\n\t\thostname      string\n\t\ttoken         string\n\t\tresponseCode  int\n\t\tresponseBody  string\n\t\texpectedError string\n\t}{\n\t\t{\n\t\t\tname:          \"successful_revocation_github_com\",\n\t\t\thostname:      \"api.github.com\",\n\t\t\ttoken:         \"ghs_test_token_123\",\n\t\t\tresponseCode:  204,\n\t\t\tresponseBody:  \"\",\n\t\t\texpectedError: \"\",\n\t\t},\n\t\t{\n\t\t\tname:          \"successful_revocation_custom_hostname\",\n\t\t\thostname:      \"github.company.com/api/v3\",\n\t\t\ttoken:         \"ghs_test_token_456\",\n\t\t\tresponseCode:  204,\n\t\t\tresponseBody:  \"\",\n\t\t\texpectedError: \"\",\n\t\t},\n\t\t{\n\t\t\tname:          \"error_bad_credentials_401\",\n\t\t\thostname:      \"api.github.com\",\n\t\t\ttoken:         \"invalid_token\",\n\t\t\tresponseCode:  401,\n\t\t\tresponseBody:  `{\"message\": \"Bad credentials\"}`,\n\t\t\texpectedError: \"token might be invalid or not properly formatted. Unexpected status code: 401\",\n\t\t},\n\t\t{\n\t\t\tname:          \"error_forbidden_403\",\n\t\t\thostname:      \"api.github.com\",\n\t\t\ttoken:         \"forbidden_token\",\n\t\t\tresponseCode:  403,\n\t\t\tresponseBody:  `{\"message\": \"Forbidden\"}`,\n\t\t\texpectedError: \"token might be invalid or not properly formatted. Unexpected status code: 403\",\n\t\t},\n\t\t{\n\t\t\tname:          \"error_not_found_404\",\n\t\t\thostname:      \"api.github.com\",\n\t\t\ttoken:         \"not_found_token\",\n\t\t\tresponseCode:  404,\n\t\t\tresponseBody:  `{\"message\": \"Not Found\"}`,\n\t\t\texpectedError: \"token might be invalid or not properly formatted. Unexpected status code: 404\",\n\t\t},\n\t\t{\n\t\t\tname:          \"error_unprocessable_entity_422\",\n\t\t\thostname:      \"api.github.com\",\n\t\t\ttoken:         \"malformed_token\",\n\t\t\tresponseCode:  422,\n\t\t\tresponseBody:  `{\"message\": \"Unprocessable Entity\"}`,\n\t\t\texpectedError: \"token might be invalid or not properly formatted. Unexpected status code: 422\",\n\t\t},\n\t\t{\n\t\t\tname:          \"error_internal_server_error_500\",\n\t\t\thostname:      \"api.github.com\",\n\t\t\ttoken:         \"server_error_token\",\n\t\t\tresponseCode:  500,\n\t\t\tresponseBody:  `{\"message\": \"Internal Server Error\"}`,\n\t\t\texpectedError: \"token might be invalid or not properly formatted. Unexpected status code: 500\",\n\t\t},\n\t\t{\n\t\t\tname:          \"error_service_unavailable_503\",\n\t\t\thostname:      \"api.github.com\",\n\t\t\ttoken:         \"service_unavailable_token\",\n\t\t\tresponseCode:  503,\n\t\t\tresponseBody:  `{\"message\": \"Service Unavailable\"}`,\n\t\t\texpectedError: \"token might be invalid or not properly formatted. Unexpected status code: 503\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\thttpmock.Reset()\n\n\t\t\tendpoint := fmt.Sprintf(\"https://%s/installation/token\", tt.hostname)\n\t\t\thttpmock.RegisterResponder(\"DELETE\", endpoint,\n\t\t\t\thttpmock.NewStringResponder(tt.responseCode, tt.responseBody))\n\n\t\t\terr := revokeToken(tt.hostname, tt.token)\n\n\t\t\tif tt.expectedError != \"\" {\n\t\t\t\tassert.Error(t, err)\n\t\t\t\tassert.Equal(t, tt.expectedError, err.Error())\n\t\t\t} else {\n\t\t\t\tassert.NoError(t, err)\n\t\t\t}\n\n\t\t\t// Verify the request was made with correct method and endpoint\n\t\t\tinfo := httpmock.GetCallCountInfo()\n\t\t\tassert.Equal(t, 1, info[fmt.Sprintf(\"DELETE %s\", endpoint)], \"Expected exactly one DELETE request to be made\")\n\t\t})\n\t}\n}\n\nfunc TestRevokeTokenNetworkErrors(t *testing.T) {\n\thttpmock.Activate()\n\tdefer httpmock.DeactivateAndReset()\n\n\ttests := []struct {\n\t\tname          string\n\t\thostname      string\n\t\ttoken         string\n\t\tsetupMock     func()\n\t\texpectedError string\n\t\terrorContains string\n\t}{\n\t\t{\n\t\t\tname:     \"network_connection_error\",\n\t\t\thostname: \"api.github.com\",\n\t\t\ttoken:    \"ghs_network_error_token\",\n\t\t\tsetupMock: func() {\n\t\t\t\thttpmock.RegisterResponder(\"DELETE\", \"https://api.github.com/installation/token\",\n\t\t\t\t\thttpmock.NewErrorResponder(fmt.Errorf(\"connection refused\")))\n\t\t\t},\n\t\t\terrorContains: \"unable to DELETE to https://api.github.com/installation/token\",\n\t\t},\n\t\t{\n\t\t\tname:     \"timeout_error\",\n\t\t\thostname: \"api.github.com\",\n\t\t\ttoken:    \"ghs_timeout_token\",\n\t\t\tsetupMock: func() {\n\t\t\t\thttpmock.RegisterResponder(\"DELETE\", \"https://api.github.com/installation/token\",\n\t\t\t\t\thttpmock.NewErrorResponder(fmt.Errorf(\"request timeout\")))\n\t\t\t},\n\t\t\terrorContains: \"unable to DELETE to https://api.github.com/installation/token\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\thttpmock.Reset()\n\t\t\ttt.setupMock()\n\n\t\t\terr := revokeToken(tt.hostname, tt.token)\n\n\t\t\tassert.Error(t, err)\n\t\t\tif tt.expectedError != \"\" {\n\t\t\t\tassert.Equal(t, tt.expectedError, err.Error())\n\t\t\t} else if tt.errorContains != \"\" {\n\t\t\t\tassert.Contains(t, err.Error(), tt.errorContains)\n\t\t\t}\n\n\t\t\t// Verify the request was attempted\n\t\t\tinfo := httpmock.GetCallCountInfo()\n\t\t\tendpoint := fmt.Sprintf(\"https://%s/installation/token\", tt.hostname)\n\t\t\tassert.Equal(t, 1, info[fmt.Sprintf(\"DELETE %s\", endpoint)], \"Expected exactly one DELETE request to be attempted\")\n\t\t})\n\t}\n}\n\nfunc TestRevokeAdvancedCases(t *testing.T) {\n\thttpmock.Activate()\n\tdefer httpmock.DeactivateAndReset()\n\n\ttests := []struct {\n\t\tname       string\n\t\tsetupTest  func() *cli.Context\n\t\tsetupMocks func()\n\t\tverifyFunc func(t *testing.T)\n\t}{\n\t\t{\n\t\t\tname: \"verify_request_headers\",\n\t\t\tsetupTest: func() *cli.Context {\n\t\t\t\treturn createTestContextForRevoke(map[string]interface{}{\n\t\t\t\t\t\"token\":    \"ghs_header_test_token\",\n\t\t\t\t\t\"hostname\": \"api.github.com\",\n\t\t\t\t\t\"silent\":   true,\n\t\t\t\t})\n\t\t\t},\n\t\t\tsetupMocks: func() {\n\t\t\t\thttpmock.RegisterResponder(\"DELETE\", \"https://api.github.com/installation/token\",\n\t\t\t\t\tfunc(req *http.Request) (*http.Response, error) {\n\t\t\t\t\t\t// Verify required headers are present\n\t\t\t\t\t\tassert.Equal(t, \"Bearer ghs_header_test_token\", req.Header.Get(\"Authorization\"))\n\t\t\t\t\t\tassert.Equal(t, \"application/vnd.github+json\", req.Header.Get(\"Accept\"))\n\t\t\t\t\t\tassert.Equal(t, \"2022-11-28\", req.Header.Get(\"X-GitHub-Api-Version\"))\n\t\t\t\t\t\tassert.Equal(t, \"Link-/gh-token\", req.Header.Get(\"User-Agent\"))\n\t\t\t\t\t\treturn httpmock.NewStringResponse(204, \"\"), nil\n\t\t\t\t\t})\n\t\t\t},\n\t\t\tverifyFunc: func(t *testing.T) {\n\t\t\t\t// Headers are verified in the mock responder\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"hostname_normalization_mixed_case\",\n\t\t\tsetupTest: func() *cli.Context {\n\t\t\t\treturn createTestContextForRevoke(map[string]interface{}{\n\t\t\t\t\t\"token\":    \"ghs_mixed_case_token\",\n\t\t\t\t\t\"hostname\": \"GitHub.Company.Com\",\n\t\t\t\t\t\"silent\":   true,\n\t\t\t\t})\n\t\t\t},\n\t\t\tsetupMocks: func() {\n\t\t\t\t// The hostname should be normalized to lowercase and have /api/v3 appended\n\t\t\t\thttpmock.RegisterResponder(\"DELETE\", \"https://github.company.com/api/v3/installation/token\",\n\t\t\t\t\thttpmock.NewStringResponder(204, \"\"))\n\t\t\t},\n\t\t\tverifyFunc: func(t *testing.T) {\n\t\t\t\tinfo := httpmock.GetCallCountInfo()\n\t\t\t\tassert.Equal(t, 1, info[\"DELETE https://github.company.com/api/v3/installation/token\"])\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"empty_token_string\",\n\t\t\tsetupTest: func() *cli.Context {\n\t\t\t\treturn createTestContextForRevoke(map[string]interface{}{\n\t\t\t\t\t\"token\":    \"\",\n\t\t\t\t\t\"hostname\": \"api.github.com\",\n\t\t\t\t\t\"silent\":   true,\n\t\t\t\t})\n\t\t\t},\n\t\t\tsetupMocks: func() {\n\t\t\t\thttpmock.RegisterResponder(\"DELETE\", \"https://api.github.com/installation/token\",\n\t\t\t\t\thttpmock.NewStringResponder(401, `{\"message\": \"Bad credentials\"}`))\n\t\t\t},\n\t\t\tverifyFunc: func(t *testing.T) {\n\t\t\t\t// Should still attempt the request even with empty token\n\t\t\t\tinfo := httpmock.GetCallCountInfo()\n\t\t\t\tassert.Equal(t, 1, info[\"DELETE https://api.github.com/installation/token\"])\n\t\t\t},\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\thttpmock.Reset()\n\t\t\ttt.setupMocks()\n\n\t\t\tctx := tt.setupTest()\n\t\t\t_ = Revoke(ctx) // We don't check error here as we're testing specific behaviors\n\n\t\t\ttt.verifyFunc(t)\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "main.go",
    "content": "// Package main is the entrypoint for the gh-token CLI\npackage main\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\n\t\"github.com/Link-/gh-token/internal\"\n\t\"github.com/urfave/cli/v2\"\n)\n\nfunc main() {\n\tapp := &cli.App{\n\t\tName:                 \"gh-token\",\n\t\tUsage:                \"Manage GitHub App installation tokens\",\n\t\tVersion:              Version,\n\t\tEnableBashCompletion: true,\n\t\tSuggest:              true,\n\t\tCommands: []*cli.Command{\n\t\t\t{\n\t\t\t\tName:   \"generate\",\n\t\t\t\tUsage:  \"Generate a new GitHub App installation token\",\n\t\t\t\tFlags:  internal.GenerateFlags(),\n\t\t\t\tAction: internal.Generate,\n\t\t\t},\n\t\t\t{\n\t\t\t\tName:   \"revoke\",\n\t\t\t\tUsage:  \"Revoke a GitHub App installation token\",\n\t\t\t\tFlags:  internal.RevokeFlags(),\n\t\t\t\tAction: internal.Revoke,\n\t\t\t},\n\t\t\t{\n\t\t\t\tName:   \"installations\",\n\t\t\t\tUsage:  \"List GitHub App installations\",\n\t\t\t\tFlags:  internal.InstallationsFlags(),\n\t\t\t\tAction: internal.Installations,\n\t\t\t},\n\t\t},\n\t}\n\n\terr := app.Run(os.Args)\n\tif err != nil {\n\t\tfmt.Fprintf(os.Stderr, \"Error: %v\\n\", err)\n\t\tos.Exit(1)\n\t}\n}\n"
  },
  {
    "path": "version.go",
    "content": "package main\n\n// Version is the current version of the gh-token CLI.\nconst Version = \"2.0.10\"\n"
  }
]