Full Code of Link-/gh-token for AI

main 08e7acb18e22 cached
27 files
93.2 KB
29.6k tokens
32 symbols
1 requests
Download .txt
Repository: Link-/gh-token
Branch: main
Commit: 08e7acb18e22
Files: 27
Total size: 93.2 KB

Directory structure:
gitextract_vc2g9202/

├── .github/
│   ├── copilot-instructions.md
│   ├── dependabot.yml
│   └── workflows/
│       ├── codeql.yml
│       ├── linter.yml
│       ├── release.yml
│       └── test.yml
├── .gitignore
├── .vscode/
│   └── launch.json
├── LICENSE
├── Makefile
├── README.md
├── SECURITY.md
├── go.mod
├── go.sum
├── internal/
│   ├── fixtures/
│   │   └── test-private-key.test.pem
│   ├── generate.go
│   ├── generate_flags.go
│   ├── generate_test.go
│   ├── installations.go
│   ├── installations_flags.go
│   ├── installations_test.go
│   ├── key.go
│   ├── revoke.go
│   ├── revoke_flags.go
│   └── revoke_test.go
├── main.go
└── version.go

================================================
FILE CONTENTS
================================================

================================================
FILE: .github/copilot-instructions.md
================================================
## Project structure

[Creates an installation access token](https://docs.github.com/en/rest/reference/apps#create-an-installation-access-token-for-an-app) to make authenticated API requests to github.com.

```
.
└── internal: the core package where this utility is implemented
```

## Coding instructions

- Read the `Makefile` in the root directory for a list of targets and commands you can run
- Add the necessary package dependencies before running unit tests, especially new mocks
- Attempt to edit the files directly in vscode instead of relying on CLI commands like `sed` to find and replace. Use `sed` as a last restort or when it is more efficient
- When creating new unit tests, append `_test.go` to the basename of the file that the unit tests should be covering.
- When implementing unit tests, adopt the same style of other tests in the same test suite and file. If tabular tests are used write the new tests in that same style. If there are no tests in the same suite, look at the other tests in the same package.
- Create all unit testing fixtures in the folder `fixtures` which must be a subdirectory of where the test files are located.
- When implementing unit tests make sure to read the function you're implementing the test for first.
- When updating unit tests make sure to read the function you're updating the tests for first. Fixing when and how often certain mocks are called might be sufficient to fix the tests.
- In tabular unit tests, the `description` or `name` of the test case is a string that might include white spaces. When searching or running a specific test, white spaces need to be substituted with `_`.

## git operations

- Never stage or commit changes without prompting the user for approval
- Start commit messages with a verb (`Add`, `Update`, `Fix` etc.)
- Do not use `feat:`, `chore:` or anything in that style for commit messages
- Add details of what was changed to the body of the commit message. Be concise.
- Never use: `git pull` `git push` `git merge` `git rebase` `git rm`

================================================
FILE: .github/dependabot.yml
================================================
version: 2
updates:
  - package-ecosystem: gomod
    directory: "/"
    schedule:
      interval: weekly

  - package-ecosystem: "github-actions"
    directory: "/"
    schedule:
      # Check for updates to GitHub Actions every week
      interval: "weekly"

================================================
FILE: .github/workflows/codeql.yml
================================================
name: "CodeQL"

on:
  push:
    branches:
      - main
  pull_request:
    branches:
      - main
  schedule:
    - cron: '41 2 * * 6'

concurrency:
  group: ${{ github.ref }}-${{ github.workflow }}
  cancel-in-progress: true

jobs:
  analyze:
    name: Analyze
    runs-on: 'ubuntu-latest'
    timeout-minutes: 15
    permissions:
      actions: read
      contents: read
      security-events: write

    steps:
      - name: Checkout repository
        uses: actions/checkout@v6

      - name: Initialize CodeQL
        uses: github/codeql-action/init@v4
        with:
          languages: go

      - name: Autobuild
        uses: github/codeql-action/autobuild@v4

      - name: Perform CodeQL Analysis
        uses: github/codeql-action/analyze@v4
        with:
          category: "/language:go"


================================================
FILE: .github/workflows/linter.yml
================================================
name: Lint

on:
  pull_request:
    branches:
      - main

concurrency:
  group: ${{ github.ref }}-${{ github.workflow }}
  cancel-in-progress: true

permissions:
  contents: read

jobs:
  lint:
    name: Lint
    runs-on: ubuntu-latest
    steps:
      - name: Checkout Code
        uses: actions/checkout@v6
        with:
          fetch-depth: 0
      - name: Lint Code
        uses: golangci/golangci-lint-action@v9


================================================
FILE: .github/workflows/release.yml
================================================
name: Release
on:
  push:
    tags:
      - "v*"
permissions:
  contents: write

jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - name: Verify tag matches version.go
        run: |
          TAG="${GITHUB_REF#refs/tags/v}"
          VERSION="$(grep -oP 'Version = "\K[^"]+' version.go)"
          if [ "$TAG" != "$VERSION" ]; then
            echo "::error::Tag v${TAG} does not match Version in version.go (${VERSION}). Please update version.go."
            exit 1
          fi
      - uses: cli/gh-extension-precompile@v2
        with:
          go_version_file: go.mod


================================================
FILE: .github/workflows/test.yml
================================================
name: Test

on:
  push:
    branches:
      - main
  pull_request:
    branches:
      - main
  workflow_dispatch:
    inputs:
      branch:
        description: "Branch name to checkout and run tests for"
        required: false
        default: "refs/heads/main"
        type: string

concurrency:
  group: ${{ github.ref }}-${{ github.workflow }}
  cancel-in-progress: true

permissions:
  contents: read

jobs:
  test:
    name: Unit Tests
    runs-on: ubuntu-latest
    steps:
      - name: Checkout Code
        uses: actions/checkout@v6
        with:
          ref: ${{ inputs.branch || 'refs/heads/main' }}
          fetch-depth: 0

      - uses: actions/setup-go@v6
        with:
          go-version-file: go.mod
      - run: go version

      - name: Run unit tests
        run: make test

  integration:
    name: Integration Tests
    runs-on: ubuntu-latest
    if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'workflow_dispatch'
    steps:
      - name: Checkout Code
        uses: actions/checkout@v6
        with:
          ref: ${{ inputs.branch || 'refs/heads/main' }}
          fetch-depth: 0

      - uses: actions/setup-go@v6
        with:
          go-version-file: go.mod
      - run: go version

      - name: Build
        run: make build

      - name: Generate installation access token from PEM key file
        run: |
          printf "%s" "$APP_PRIVATE_KEY" > private_key.pem
          ./gh-token \
            generate \
            -i "$APP_ID" \
            -k private_key.pem > /dev/null 2
        env:
          APP_ID: ${{ secrets.APP_ID }}
          APP_PRIVATE_KEY: ${{ secrets.APP_PRIVATE_KEY }}

      - name: List installations for the app from PEM key file
        run: |
          printf "%s" "$APP_PRIVATE_KEY" > private_key.pem
          ./gh-token \
            installations \
            -i "$APP_ID" \
            -k private_key.pem > /dev/null 2
        env:
          APP_ID: ${{ secrets.APP_ID }}
          APP_PRIVATE_KEY: ${{ secrets.APP_PRIVATE_KEY }}

      - name: Generate installation access token with base64 key
        run: |
          ./gh-token \
            generate \
            -i "$APP_ID" \
            -b "$(echo "$APP_PRIVATE_KEY" | base64)" > /dev/null 2
        env:
          APP_ID: ${{ secrets.APP_ID }}
          APP_PRIVATE_KEY: ${{ secrets.APP_PRIVATE_KEY }}

      - name: List installations for the app with base64 key
        run: |
          ./gh-token \
            installations \
            -i "$APP_ID" \
            -b "$(echo "$APP_PRIVATE_KEY" | base64)" > /dev/null 2
        env:
          APP_ID: ${{ secrets.APP_ID }}
          APP_PRIVATE_KEY: ${{ secrets.APP_PRIVATE_KEY }}

      - name: Generate then revoke token
        run: |
          printf "%s" "$APP_PRIVATE_KEY" > private_key.pemm
          token="$(./gh-token generate -i $APP_ID -k private_key.pem | jq -r '.token')"
          ./gh-token revoke -t $token
        env:
          APP_ID: ${{ secrets.APP_ID }}
          APP_PRIVATE_KEY: ${{ secrets.APP_PRIVATE_KEY }}


================================================
FILE: .gitignore
================================================
### macOS ###
# General
.DS_Store
.AppleDouble
.LSOverride

# Icon must end with two \r
Icon


# Thumbnails
._*

# Files that might appear in the root of a volume
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent

# Directories potentially created on remote AFP share
.AppleDB
.AppleDesktop
Network Trash Folder
Temporary Items
.apdisk

### Vim ###
# Swap
[._]*.s[a-v][a-z]
!*.svg  # comment out if you don't need vector files
[._]*.sw[a-p]
[._]s[a-rt-v][a-z]
[._]ss[a-gi-z]
[._]sw[a-p]

# Session
Session.vim
Sessionx.vim

# Temporary
.netrwhist
*~
# Auto-generated tag files
tags
# Persistent undo
[._]*.un~

### VisualStudioCode ###
.vscode/*
!.vscode/tasks.json
!.vscode/launch.json
*.code-workspace

### VisualStudioCode Patch ###
# Ignore all local history of files
.history
.ionide
.keys

### Project files
jwt

# Generated files
gh-token
gh-token.exe
*.out
.bin/

# Test app keys
*.pem
!*.test.pem

================================================
FILE: .vscode/launch.json
================================================
{
    "version": "0.2.0",
    "configurations": [
        {
            "name": "Debug gh token",
            "type": "go",
            "request": "launch",
            "mode": "debug",
            "program": "${workspaceFolder}",
            "env": {},
            "args": [
                "installations",
                "-k",
                "${workspaceFolder}/.keys/test-key.pem",
                "--app-id",
                "394554"
            ],
            "showLog": true,
            "trace": "verbose",
        }
    ]
}

================================================
FILE: LICENSE
================================================
Copyright 2024 Link-

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.


================================================
FILE: Makefile
================================================
# Makefile with the following targets:
#   all: build the project
#   clean: remove all build artifacts
#   build: build the project
#   test: run all unit tests
#   lint: run linting checks (golangci-lint)
#   install-lint-deps: install linting dependencies
#   release: create a new release by updating version numbers and committing changes
#   help: print this help message
#   .PHONY: mark targets as phony
#   .DEFAULT_GOAL: set the default goal to all

# Set the default goal to all
.DEFAULT_GOAL := all
PROJECT_NAME := "gh-token"

# Mark targets as phony
.PHONY: all clean build test lint install-lint-deps release

# Build the project
all: clean build

# Remove all build artifacts
clean:
	rm -f gh-token
	rm -rf .bin

# Build the project
build:
	go build -o gh-token .

# Run all unit tests
test:
	go test ./...

# Run linting checks
lint:
	@test -f .bin/golangci-lint || $(MAKE) install-lint-deps
	./.bin/golangci-lint run

# Install linting dependencies
install-lint-deps:
	@mkdir -p .bin
	curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh | sh -s -- -b .bin v2.4.0

# Create a new release
release:
	@echo "Current version in version.go: $$(grep 'Version' version.go | sed 's/.*Version = "\(.*\)".*/\1/')"
	@echo "Current version in SECURITY.md: $$(grep -A2 '| Version' SECURITY.md | tail -1 | sed 's/| *\([0-9]*\.[0-9]*\.[0-9]*\).*/\1/')"
	@echo ""
	@read -p "Enter the new semver version (e.g., 2.1.0): " VERSION; \
	if [ -z "$$VERSION" ]; then \
		echo "Error: Version cannot be empty"; \
		exit 1; \
	fi; \
	if ! echo "$$VERSION" | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$$' > /dev/null; then \
		echo "Error: Version must be in semver format (e.g., 2.1.0)"; \
		exit 1; \
	fi; \
	MAJOR_MINOR=$$(echo "$$VERSION" | sed 's/\([0-9]*\.[0-9]*\)\.[0-9]*/\1/'); \
	echo "Updating version to $$VERSION..."; \
	sed -i.bak 's/Version = "[^"]*"/Version = "'"$$VERSION"'"/' version.go && rm version.go.bak; \
	sed -i.bak 's/| [0-9]*\.[0-9]*\.[0-9]* *|/| '"$$MAJOR_MINOR"'.x   |/' SECURITY.md && rm SECURITY.md.bak; \
	echo "Files updated successfully."; \
	echo ""; \
	echo "Staging and committing changes..."; \
	git add version.go SECURITY.md; \
	git commit -m "Update version to $$VERSION"; \
	echo ""; \
	echo "Changes committed successfully!"; \
	echo ""; \
	echo "Next steps:"; \
	echo "- Go to https://github.com/Link-/gh-token/releases/new to create a new release"; \
	echo "- Create a tag with the same version as the release ($$VERSION)"; \
	echo "- The binaries will automatically be uploaded as assets once the release has been created"


================================================
FILE: README.md
================================================
# GH Token

```shell
* _____ _   *_   _______ *  _      *  *    **   *
 / ____| |* | | |__   __|  | |  *       *         🦄  *
| | *__| |_*| | ⭐️ | | ___ | | _____*_ __  *     *
| | |_ |* __ *|    |*|/ _ \| |/ / _ \ '_ \     *   *
| |__| | |  | | *  | | (_)*|   <  __/ | | |  *
 \_____|_|  |_|    |_|\___/|_|\_\___|_| |_|   *
```

<!-- markdownlint-disable -->

> Manage installation access tokens for GitHub apps from your terminal

[![License](https://img.shields.io/github/license/link-/gh-token?style=flat-square)](LICENSE)

<!-- markdownlint-restore -->

[Creates an installation access token](https://docs.github.com/en/rest/reference/apps#create-an-installation-access-token-for-an-app) to make authenticated API requests.

Installation tokens expire **1 hour** from the time you create them. Using an expired token produces a status code of `401 - Unauthorized`, and requires creating a new installation token.

You can use this access token to make pretty much any REST or GraphQL API call the app is authorized to make!

![gh-token demo](./images/gh-token.png)

## Why?

In order to use GitHub's [REST](https://docs.github.com/en/rest) or [GraphQL](https://docs.github.com/en/graphql) APIs you will need either a [Personal Access Token](https://docs.github.com/en/developers/apps/about-apps#personal-access-tokens) (PAT) or a [GitHub App](https://docs.github.com/en/developers/apps/about-apps#about-github-apps).

**PATs are dangerous, they:**

1. have a very wide scope that spans across multiple organizations
1. never (automatically) expire. They have an indefinite lifetime (or at least until you regenerate them)
1. cannot be revoked (they're only revoked when a new one is generated)

With an access token generated with a GitHub App you don't have to worry about the concerns above. These tokens have a limited scope and lifetime. Just make sure you handle the token safely (avoid leaking). In the worst case scenario, the token will expire in **1 hour from creation time.**

## Installation

### Download as a standalone binary

Download `gh-token` from the [latest release](https://github.com/Link-/gh-token/releases/latest) for your platform.

### Install as a `gh` cli extension

You can install `gh-token` as a [gh cli](https://github.com/cli/cli) extension!

```shell
$ gh extension install Link-/gh-token

# Verify installation
$ gh token
```

All the commands and parameters remain the same, the only different is you now can use `gh token` instead of `gh-token`.

### Creating a GitHub App

Follow [these steps](https://docs.github.com/en/developers/apps/creating-a-github-app)

## Usage

Compatible with [GitHub Enterprise Server](https://github.com/enterprise).

```text
NAME:
   gh-token - Manage GitHub App installation tokens

USAGE:
   gh-token [global options] command [command options] [arguments...]

VERSION:
   2.0.2

COMMANDS:
   generate       Generate a new GitHub App installation token
   revoke         Revoke a GitHub App installation token
   installations  List GitHub App installations
   help, h        Shows a list of commands or help for one command

GLOBAL OPTIONS:
   --help, -h     show help
   --version, -v  print the version
```

### Examples in the Terminal

#### Run `gh token` as a `gh` CLI extension

```shell
gh token generate \
    --key ./.keys/private-key.pem \
    --app-id 1122334 \
    --installation-id 5566778
```

```json
{
  "token": "ghs_8Joht_______________bLCMS___M0EPOhJ",
  "expires_at": "2023-09-08T18:11:34Z",
  "permissions": {
    "actions": "write",
    "administration": "write",
    "metadata": "read",
    "members": "read",
    "organization_administration": "read"
  }
}
```

#### Run `gh token` and pass the key as a base64 encoded string

```shell
gh token generate \
    --base64-key $(printf "%s" $APP_KEY | base64) \
    --app-id 1122334 \
    --installation-id 5566778
```

```json
{
  "token": "ghs_8Joht_______________bLCMS___M0EPOhJ",
  "expires_at": "2023-09-08T18:11:34Z",
  "permissions": {
    "actions": "write",
    "administration": "write",
    "metadata": "read",
    "members": "read",
    "organization_administration": "read"
  }
}
```

#### Run `gh token` with GitHub Enterprise Server

```shell
gh token generate \
    --base64-key $(printf "%s" $APP_KEY | base64) \
    --app-id 1122334 \
    --installation-id 5566778 \
    --hostname "github.example.com"
```

```json
{
  "token": "ghs_8Joht_______________bLCMS___M0EPOhJ",
  "expires_at": "2023-09-08T18:11:34Z",
  "permissions": {
    "actions": "write",
    "administration": "write",
    "metadata": "read",
    "members": "read",
    "organization_administration": "read"
  }
}
```

#### Fetch list of installations for an app

```shell
gh token installations \
    --key ./private-key.pem \
    --app-id 2233445
```

<details>
  <summary>Response</summary>

  ```json
  [
    {
      "id": 1,
      "account": {
        "login": "octocat",
        "id": 1,
        "node_id": "MDQ6VXNlcjE=",
        "avatar_url": "https://github.com/images/error/octocat_happy.gif",
        "gravatar_id": "",
        "url": "https://api.github.com/users/octocat",
        "html_url": "https://github.com/octocat",
        "followers_url": "https://api.github.com/users/octocat/followers",
        "following_url": "https://api.github.com/users/octocat/following{/other_user}",
        "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}",
        "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}",
        "subscriptions_url": "https://api.github.com/users/octocat/subscriptions",
        "organizations_url": "https://api.github.com/users/octocat/orgs",
        "repos_url": "https://api.github.com/users/octocat/repos",
        "events_url": "https://api.github.com/users/octocat/events{/privacy}",
        "received_events_url": "https://api.github.com/users/octocat/received_events",
        "type": "User",
        "site_admin": false
      },
      "access_tokens_url": "https://api.github.com/installations/1/access_tokens",
      "repositories_url": "https://api.github.com/installation/repositories",
      "html_url": "https://github.com/organizations/github/settings/installations/1",
      "app_id": 1,
      "target_id": 1,
      "target_type": "Organization",
      "permissions": {
        "checks": "write",
        "metadata": "read",
        "contents": "read"
      },
      "events": [
        "push",
        "pull_request"
      ],
      "single_file_name": "config.yaml",
      "has_multiple_single_files": true,
      "single_file_paths": [
        "config.yml",
        ".github/issue_TEMPLATE.md"
      ],
      "repository_selection": "selected",
      "created_at": "2017-07-08T16:18:44-04:00",
      "updated_at": "2017-07-08T16:18:44-04:00",
      "app_slug": "github-actions",
      "suspended_at": null,
      "suspended_by": null
    }
  ]
  ```

</details>

#### Revoke an installation access token

```shell
gh token revoke \
    --token "v1.bb1___168d_____________1202bb8753b133919" \
    --hostname "github.example.com"
```

```text
Successfully revoked installation token
```

### Example in a workflow

<details>

  <summary>Expand to show instructions</summary>

1. You need to create a secret to store the **applications private key** securely (this can be an organization or a repository secret):
    ![Create private key secret](images/create_secret.png)

1. You need to create another secret to store the **application id** security (same as the step above).

1. The secrets need to be provided as an environment variable then encoded into base64 as show in the workflow example:

This example is designed to run on GitHub Enterprise Server. To use the same workflow with GitHub.com update the hostname to `api.github.com` and change the API URL in the testing step.

```yaml
name: Create access token via GitHub Apps Workflow

on:
  workflow_dispatch:

jobs:
  Test:
    # The type of runner that the job will run on
    runs-on: [ self-hosted ]

    steps:
    - name: "Install gh-token"
      run: gh extension install Link-/gh-token
    # Create access token with a GitHub App ID and Key
    # We use the private key stored as a secret and encode it into base64
    # before passing it to gh-token
    - name: "Create access token"
      run: |
        token=$(gh token generate \
          --base64-key $(printf "%s" "$APP_PRIVATE_KEY" | base64 -w 0) \
          --app-id $APP_ID \
          --hostname "github.example.com" \
          | jq -r ".token")
        echo "token=$token" >> $GITHUB_OUTPUT
      env:
        APP_ID: ${{ secrets.APP_ID }}
        APP_PRIVATE_KEY: ${{ secrets.APP_KEY }}
    # To test the token we will use it to fetch the list of repositories
    # belonging to our organization
    - name: "Fetch organization repositories"
      run: |
        curl -X GET \
          -H "Authorization: token $token" \
          -H "Accept: application/vnd.github.v3+json" \
          https://github.example.com/api/v3/orgs/<ORGNAME>/repos
```

</details>

## Similar projects

_These are not endorsements, just a listing of similar art work_

### CLI

- [apptokit](https://github.com/jakewilkins/apptokit) in Ruby
- [gha-token](https://github.com/slawekzachcial/gha-token) in Go

### Actions

- [create-github-app-token](https://github.com/actions/create-github-app-token) (GitHub official)
- [workflow-application-token-action](https://github.com/peter-murray/workflow-application-token-action)
- [action-github-app-token](https://github.com/getsentry/action-github-app-token)
- [github-app-token-generator](https://github.com/navikt/github-app-token-generator)


================================================
FILE: SECURITY.md
================================================
# Security Policy

## Reporting a Vulnerability

Use the [private vulnerability reporting feature](https://github.com/Link-/gh-token/security/advisories) to report security problems.


================================================
FILE: go.mod
================================================
module github.com/Link-/gh-token

go 1.24.0

require (
	github.com/golang-jwt/jwt/v5 v5.3.1
	github.com/google/go-github/v55 v55.0.0
	github.com/jarcoal/httpmock v1.4.1
	github.com/stretchr/testify v1.11.1
	github.com/urfave/cli/v2 v2.27.7
)

require (
	github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371 // indirect
	github.com/cloudflare/circl v1.6.3 // indirect
	github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
	github.com/davecgh/go-spew v1.1.1 // indirect
	github.com/google/go-cmp v0.7.0 // indirect
	github.com/google/go-querystring v1.1.0 // indirect
	github.com/kr/pretty v0.3.1 // indirect
	github.com/pmezard/go-difflib v1.0.0 // indirect
	github.com/rogpeppe/go-internal v1.14.1 // indirect
	github.com/russross/blackfriday/v2 v2.1.0 // indirect
	github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
	golang.org/x/crypto v0.45.0 // indirect
	golang.org/x/sys v0.38.0 // indirect
	gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
	gopkg.in/yaml.v3 v3.0.1 // indirect
)


================================================
FILE: go.sum
================================================
github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371 h1:kkhsdkhsCvIsutKu5zLMgWtgh9YxGCNAw8Ad8hjwfYg=
github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0=
github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0=
github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA=
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo=
github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-github/v55 v55.0.0 h1:4pp/1tNMB9X/LuAhs5i0KQAE40NmiR/y6prLNb9x9cg=
github.com/google/go-github/v55 v55.0.0/go.mod h1:JLahOTA1DnXzhxEymmFF5PP2tSS9JVNj68mSZNDwskA=
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
github.com/jarcoal/httpmock v1.4.1 h1:0Ju+VCFuARfFlhVXFc2HxlcQkfB+Xq12/EotHko+x2A=
github.com/jarcoal/httpmock v1.4.1/go.mod h1:ftW1xULwo+j0R0JJkJIIi7UKigZUXCLLanykgjwBXL0=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/maxatome/go-testdeep v1.14.0 h1:rRlLv1+kI8eOI3OaBXZwb3O7xY3exRzdW5QyX48g9wI=
github.com/maxatome/go-testdeep v1.14.0/go.mod h1:lPZc/HAcJMP92l7yI6TRz1aZN5URwUBUAfUNvrclaNM=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU=
github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4=
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4=
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=


================================================
FILE: internal/fixtures/test-private-key.test.pem
================================================
-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCL02nUZNMcAaOs
J+L5qKErTjABtmS6CBgCbDbUN1u8Ds7hNMbYM/+P4W6OWbITnJD4V1sHpEj4/5u9
tkqQt0x7IrXEAE2D+zWd4ntkdT7qDO3hpxj4/nPRakp7W2w9iLJNSeVLTjoVrnuQ
GsGpR4NCu4PJOerLpmZXF6V+5IUyTnK2TC6NkXMl1YQ+plo0tmLjayOYOFyNhSk0
2fNUo+Iwzse5XUqDncBkBKwHjwYnRVjB+fcn5WT88VWz4ejaXLD2DMcZmQmU+clP
3r+AJddbk4cVHlGAW99uQ+c6M8M215wOg6cwpvXX20DY+Z4I56TIVq7WpHQyyrev
noZ6O5/LAgMBAAECggEAA0arSpooJhZVvuFaXI4aZJja4BdlacRpx5jAeh1n7VKN
f1JMvGEPgk/+VqB8XyBCd0cYr2emfAsFG59LRPO+e34XMyXsqwR2P6JAUNy8YiB2
bFyNZbwUe5oZb6V3NkPfJZdvI2IMU1i4tWojEnPF/AjHsC3GtgnKiQzZSE1TX5fV
E1PnG9u1x4FmcPBv6eL0mQ20MGYpt+l2kB1xSfFE72P4MYZ+xEAlbr94yA8ISVtp
krh0xB77qU/kWnVw07DOneWhAJdRAhNRD+ZcU+UYfBzkvCNOprjiMYQxjjmmjouh
RrkZd8AGnbCDe6SW2fhY6On0Cp8o/Wuz4NI58JfivQKBgQDAHjarGMeNanvxLDlx
shY1xoLOxF/1ikhn+CFTW4sPZQOxOcsb/GmjwON1/4we2c8uZGdNd2tEPvDgfWhu
XTDyOHU0JjsQEs0TsvUr1Jf1BVecFnu8dTC+sfUmNCBwwqyfp6KqDjrBmRVHRiRn
L8rCQBDf3bk9ib2t0PNjk/v2DQKBgQC6UeXs3RpROxyQQ7Y05diyfPjL2V8tOzms
t61NzH1LDv9snEyG7sB12b8sm0HIeka+uZkVdiqjZvbtXDAS2ZhlWBn3clr1Bmnf
gdVIRQicXNb+aZRc2pBCGLCbcRNxGQYWRKNv3RFdDlG93ILwO1yXoY3Bie6yL+Vk
eoj/lasPNwKBgQC3pupJqvlwBUAQL1+GgWBb7bUz5WN5/MP0p61r2xHXGJBsBbxU
t3lg8c4/CZgwEbTNO2vJEQR4i9aGMzv2bJ2Sn0fjHzzMw7xJPYTDbooIzx+N9aw5
XqnHUaTw7VmpkV+li4GjINEoKqe9p567CWPBR68Z4gHngtnQ4/MW2Os+rQKBgDGU
2brOm9JCCLfbTQGGqMPWvd6BWfKPcCmmN1gcsrrmotIkRbkij9TMvTMBnd/bqjfW
7AXqDC6vl8ZSYfiiLwvJBh/zLoFF06bGxhsVQ9VYX14Ueoa7Iuhz6Ytz69iM8DG8
0kFScuxwgxAjPjTvlxRCyZZXPk3ssP6sHQjmqz7BAoGAHONxPaL5narYxGgySg49
j3Ib9NROs2ys05dlTOZd0NYfkOsxtlNNncHGZ9NiikwtuJLeDibormsJhIgi8XXd
kB8fa/kQXRWBNbD56ExVhSQiTPMNQXKXYXX2Ix/xcrHgzZzS0gAof7NKxwiuyhyi
XeN8Q9ABpmAa5V1YvCP4qKM=
-----END PRIVATE KEY-----


================================================
FILE: internal/generate.go
================================================
package internal

import (
	"crypto/rsa"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"strconv"
	"strings"

	"github.com/google/go-github/v55/github"
	"github.com/urfave/cli/v2"
)

// Generate is the entrypoint for the generate command
func Generate(c *cli.Context) error {
	appID := c.String("app-id")
	installationID := c.String("installation-id")
	keyPath := c.String("key")
	keyBase64 := c.String("base64-key")
	printJWT := c.Bool("jwt")
	jwtExpiry := c.Int("duration")
	hostname := strings.ToLower(c.String("hostname"))
	tokenOnly := c.Bool("token-only")
	silent := c.Bool("silent")

	if keyPath == "" && keyBase64 == "" {
		return fmt.Errorf("either --key or --base64-key must be specified")
	}

	if keyPath != "" && keyBase64 != "" {
		return fmt.Errorf("only one of --key or --base64-key may be specified")
	}

	if hostname != "api.github.com" && !strings.Contains(hostname, "/api/v3") {
		endpoint := fmt.Sprintf("%s/api/v3", hostname)
		hostname = strings.TrimSuffix(endpoint, "/")
	}

	if jwtExpiry < 1 || jwtExpiry > 10 {
		jwtExpiry = 10
	}

	var err error
	var privateKey *rsa.PrivateKey
	if keyPath != "" {
		privateKey, err = readKey(keyPath)
		if err != nil {
			return err
		}
	} else {
		privateKey, err = readKeyBase64(keyBase64)
		if err != nil {
			return err
		}
	}

	jsonWebToken, err := generateJWT(appID, jwtExpiry, privateKey)
	if err != nil {
		return fmt.Errorf("failed generating JWT: %w", err)
	}

	if printJWT {
		if !silent {
			fmt.Println(jsonWebToken)
		}

		return nil
	}

	if installationID == "" {
		installationID, err = retrieveDefaultInstallationID(hostname, jsonWebToken)
		if err != nil {
			return fmt.Errorf("failed retrieving default installation ID: %w", err)
		}
	}

	token, err := generateToken(hostname, jsonWebToken, installationID)
	if err != nil {
		return fmt.Errorf("failed generating installation token: %w", err)
	}

	if !silent {
		bytes, err := json.MarshalIndent(token, "", "  ")
		if err != nil {
			return fmt.Errorf("failed to marshal token to JSON: %w", err)
		}

		if tokenOnly {
			fmt.Println(*token.Token)
		} else {
			fmt.Println(string(bytes))
		}
	}

	return nil
}

func retrieveDefaultInstallationID(hostname, jwt string) (string, error) {
	endpoint := fmt.Sprintf("https://%s/app/installations?per_page=1", hostname)
	req, err := http.NewRequest("GET", endpoint, nil)
	if err != nil {
		return "", fmt.Errorf("unable to create GET request to %s: %w", endpoint, err)
	}
	req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", jwt))
	req.Header.Add("Accept", "application/vnd.github+json")
	req.Header.Add("X-GitHub-Api-Version", "2022-11-28")
	req.Header.Add("User-Agent", "Link-/gh-token")

	client := &http.Client{}
	resp, err := client.Do(req)
	if err != nil {
		return "", fmt.Errorf("unable to GET %s: %w", endpoint, err)
	}
	defer func() {
		_ = resp.Body.Close()
	}()

	if resp.StatusCode != 200 {
		return "", fmt.Errorf("unexpected status code: %d", resp.StatusCode)
	}

	var response []github.Installation
	bytes, err := io.ReadAll(resp.Body)
	if err != nil {
		return "", fmt.Errorf("unable to read response body: %w", err)
	}

	err = json.Unmarshal(bytes, &response)
	if err != nil {
		return "", fmt.Errorf("unable to unmarshal response body: %w", err)
	}

	return strconv.FormatInt(*response[0].ID, 10), nil
}

func generateToken(hostname, jwt, installationID string) (*github.InstallationToken, error) {
	endpoint := fmt.Sprintf("https://%s/app/installations/%s/access_tokens", hostname, installationID)
	req, err := http.NewRequest("POST", endpoint, nil)
	if err != nil {
		return nil, fmt.Errorf("unable to create POST request to %s: %w", endpoint, err)
	}
	req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", jwt))
	req.Header.Add("Accept", "application/vnd.github+json")
	req.Header.Add("X-GitHub-Api-Version", "2022-11-28")
	req.Header.Add("User-Agent", "Link-/gh-token")

	client := &http.Client{}
	resp, err := client.Do(req)
	if err != nil {
		return nil, fmt.Errorf("unable to POST to %s: %w", endpoint, err)
	}
	defer func() {
		_ = resp.Body.Close()
	}()

	if resp.StatusCode != 201 {
		return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
	}

	var response *github.InstallationToken
	bytes, err := io.ReadAll(resp.Body)
	if err != nil {
		return nil, fmt.Errorf("unable to read response body: %w", err)
	}

	err = json.Unmarshal(bytes, &response)
	if err != nil {
		return nil, fmt.Errorf("unable to unmarshal response body: %w", err)
	}

	return response, nil
}


================================================
FILE: internal/generate_flags.go
================================================
package internal

import "github.com/urfave/cli/v2"

// GenerateFlags returns the CLI flags for the generate command
func GenerateFlags() []cli.Flag {
	return []cli.Flag{
		&cli.StringFlag{
			Name:     "app-id",
			Usage:    "GitHub App ID",
			Required: true,
			Aliases:  []string{"i", "app_id"},
		},
		&cli.StringFlag{
			Name:     "installation-id",
			Usage:    "GitHub App installation ID. Defaults to the first installation returned by the GitHub API if not specified",
			Required: false,
			Aliases:  []string{"l", "installation_id"},
		},
		&cli.StringFlag{
			Name:     "key",
			Usage:    "Path to private key",
			Required: false,
			Aliases:  []string{"k"},
		},
		&cli.StringFlag{
			Name:     "base64-key",
			Usage:    "A base64 encoded private key",
			Required: false,
			Aliases:  []string{"b", "base64_key"},
		},
		&cli.StringFlag{
			Name:     "hostname",
			Usage:    "GitHub Enterprise Server API endpoint, example: github.example.com",
			Required: false,
			Aliases:  []string{"o"},
			Value:    "api.github.com",
		},
		&cli.BoolFlag{
			Name:    "token-only",
			Usage:   "Only print the token to stdout, not the full JSON response, useful for piping to other commands",
			Aliases: []string{"t"},
			Value:   false,
		},
		&cli.BoolFlag{
			Name:     "jwt",
			Usage:    "Return the JWT instead of generating an installation token, useful for calling API's requiring a JWT",
			Required: false,
			Aliases:  []string{"j"},
			Value:    false,
		},
		&cli.IntFlag{
			Name:     "duration",
			Usage:    "The expiry time of the JWT in minutes up to a maximum value of 10, useful when using the --jwt flag",
			Required: false,
			Aliases:  []string{"d"},
			Value:    1,
		},
		&cli.BoolFlag{
			Name:    "silent",
			Usage:   "Do not print token to stdout",
			Aliases: []string{"s"},
			Value:   false,
		},
	}
}


================================================
FILE: internal/generate_test.go
================================================
package internal

import (
	"encoding/base64"
	"encoding/json"
	"flag"
	"fmt"
	"os"
	"testing"
	"time"

	"github.com/google/go-github/v55/github"
	"github.com/jarcoal/httpmock"
	"github.com/stretchr/testify/assert"
	"github.com/urfave/cli/v2"
)

// createTestContext creates a test CLI context with the given flags
func createTestContext(flags map[string]interface{}) *cli.Context {
	app := &cli.App{}
	set := flag.NewFlagSet("test", flag.ContinueOnError)

	// Set default values
	defaults := map[string]interface{}{
		"app-id":          "",
		"installation-id": "",
		"key":             "",
		"base64-key":      "",
		"jwt":             false,
		"jwt-expiry":      10,
		"hostname":        "api.github.com",
		"token-only":      false,
		"silent":          false,
	}

	// Override with test-specific flags
	for k, v := range flags {
		defaults[k] = v
	}

	// Set up flags based on type
	for key, value := range defaults {
		switch v := value.(type) {
		case string:
			set.String(key, v, "")
		case bool:
			set.Bool(key, v, "")
		case int:
			set.Int(key, v, "")
		}
	}

	return cli.NewContext(app, set, nil)
}

// getBoolFlag safely gets bool values from flags map
func getBoolFlag(flags map[string]interface{}, key string) bool {
	if val, ok := flags[key].(bool); ok {
		return val
	}
	return false
}

func TestGenerate(t *testing.T) {
	// Setup
	httpmock.Activate()
	defer httpmock.DeactivateAndReset()

	// Read test key for base64 encoding
	keyBytes, err := os.ReadFile("fixtures/test-private-key.test.pem")
	assert.NoError(t, err)
	keyBase64 := base64.StdEncoding.EncodeToString(keyBytes)

	installationResponse := []github.Installation{
		{ID: github.Int64(12345)},
	}
	installationJSON, _ := json.Marshal(installationResponse)

	tokenResponse := &github.InstallationToken{
		Token:     github.String("ghs_test_token_123"),
		ExpiresAt: &github.Timestamp{Time: time.Now().Add(time.Hour)},
	}
	tokenJSON, _ := json.Marshal(tokenResponse)

	tests := []struct {
		name          string
		flags         map[string]interface{}
		setupMocks    func()
		expectedError string
	}{
		{
			name: "successful_token_generation_with_key_file",
			flags: map[string]interface{}{
				"app-id":          "123456",
				"installation-id": "12345",
				"key":             "fixtures/test-private-key.test.pem",
				"hostname":        "api.github.com",
				"jwt-expiry":      10,
				"silent":          true,
			},
			setupMocks: func() {
				httpmock.RegisterResponder("POST", "https://api.github.com/app/installations/12345/access_tokens",
					httpmock.NewStringResponder(201, string(tokenJSON)))
			},
			expectedError: "",
		},
		{
			name: "successful_token_generation_with_base64_key",
			flags: map[string]interface{}{
				"app-id":          "123456",
				"installation-id": "12345",
				"base64-key":      keyBase64,
				"hostname":        "api.github.com",
				"jwt-expiry":      10,
				"silent":          true,
			},
			setupMocks: func() {
				httpmock.RegisterResponder("POST", "https://api.github.com/app/installations/12345/access_tokens",
					httpmock.NewStringResponder(201, string(tokenJSON)))
			},
			expectedError: "",
		},
		{
			name: "successful_with_auto_installation_id",
			flags: map[string]interface{}{
				"app-id":     "123456",
				"key":        "fixtures/test-private-key.test.pem",
				"hostname":   "api.github.com",
				"jwt-expiry": 10,
				"silent":     true,
			},
			setupMocks: func() {
				httpmock.RegisterResponder("GET", "https://api.github.com/app/installations?per_page=1",
					httpmock.NewStringResponder(200, string(installationJSON)))
				httpmock.RegisterResponder("POST", "https://api.github.com/app/installations/12345/access_tokens",
					httpmock.NewStringResponder(201, string(tokenJSON)))
			},
			expectedError: "",
		},
		{
			name: "successful_jwt_only",
			flags: map[string]interface{}{
				"app-id":     "123456",
				"key":        "fixtures/test-private-key.test.pem",
				"jwt":        true,
				"jwt-expiry": 10,
				"silent":     true,
			},
			setupMocks:    func() {},
			expectedError: "",
		},
		{
			name: "error_no_key_specified",
			flags: map[string]interface{}{
				"app-id": "123456",
			},
			setupMocks:    func() {},
			expectedError: "either --key or --base64-key must be specified",
		},
		{
			name: "error_both_keys_specified",
			flags: map[string]interface{}{
				"app-id":     "123456",
				"key":        "fixtures/test-private-key.test.pem",
				"base64-key": keyBase64,
			},
			setupMocks:    func() {},
			expectedError: "only one of --key or --base64-key may be specified",
		},
		{
			name: "error_invalid_key_file",
			flags: map[string]interface{}{
				"app-id": "123456",
				"key":    "fixtures/nonexistent.pem",
			},
			setupMocks:    func() {},
			expectedError: "unable to read key file",
		},
		{
			name: "error_invalid_base64_key",
			flags: map[string]interface{}{
				"app-id":     "123456",
				"base64-key": "invalid-base64-string",
			},
			setupMocks:    func() {},
			expectedError: "unable to decode key from base64",
		},
		{
			name: "error_installation_not_found",
			flags: map[string]interface{}{
				"app-id": "123456",
				"key":    "fixtures/test-private-key.test.pem",
			},
			setupMocks: func() {
				httpmock.RegisterResponder("GET", "https://api.github.com/app/installations?per_page=1",
					httpmock.NewStringResponder(404, `{"message": "Not Found"}`))
			},
			expectedError: "failed retrieving default installation ID: unexpected status code: 404",
		},
		{
			name: "error_token_generation_fails",
			flags: map[string]interface{}{
				"app-id":          "123456",
				"installation-id": "12345",
				"key":             "fixtures/test-private-key.test.pem",
			},
			setupMocks: func() {
				httpmock.RegisterResponder("POST", "https://api.github.com/app/installations/12345/access_tokens",
					httpmock.NewStringResponder(403, `{"message": "Forbidden"}`))
			},
			expectedError: "failed generating installation token: unexpected status code: 403",
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			// Reset mocks for each test
			httpmock.Reset()
			tt.setupMocks()

			// Create CLI context
			ctx := createTestContext(tt.flags)

			// Execute the function
			err := Generate(ctx)

			// Assert results
			if tt.expectedError != "" {
				assert.Error(t, err)
				assert.Contains(t, err.Error(), tt.expectedError)
			} else {
				assert.NoError(t, err)
			}

			// Verify HTTP calls were made as expected
			if tt.expectedError == "" && !getBoolFlag(tt.flags, "jwt") {
				info := httpmock.GetCallCountInfo()
				assert.Greater(t, len(info), 0, "Expected HTTP calls to be made")
			}
		})
	}
}

func TestRetrieveDefaultInstallationID(t *testing.T) {
	httpmock.Activate()
	defer httpmock.DeactivateAndReset()

	tests := []struct {
		name           string
		hostname       string
		jwt            string
		responseCode   int
		responseBody   string
		expectedResult string
		expectedError  string
	}{
		{
			name:           "successful_retrieval",
			hostname:       "api.github.com",
			jwt:            "test.jwt.token",
			responseCode:   200,
			responseBody:   `[{"id": 12345}]`,
			expectedResult: "12345",
			expectedError:  "",
		},
		{
			name:           "not_found",
			hostname:       "api.github.com",
			jwt:            "test.jwt.token",
			responseCode:   404,
			responseBody:   `{"message": "Not Found"}`,
			expectedResult: "",
			expectedError:  "unexpected status code: 404",
		},
		{
			name:           "invalid_json_response",
			hostname:       "api.github.com",
			jwt:            "test.jwt.token",
			responseCode:   200,
			responseBody:   "invalid json",
			expectedResult: "",
			expectedError:  "unable to unmarshal response body",
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			httpmock.Reset()

			endpoint := fmt.Sprintf("https://%s/app/installations?per_page=1", tt.hostname)
			httpmock.RegisterResponder("GET", endpoint,
				httpmock.NewStringResponder(tt.responseCode, tt.responseBody))

			result, err := retrieveDefaultInstallationID(tt.hostname, tt.jwt)

			if tt.expectedError != "" {
				assert.Error(t, err)
				assert.Contains(t, err.Error(), tt.expectedError)
				assert.Equal(t, "", result)
			} else {
				assert.NoError(t, err)
				assert.Equal(t, tt.expectedResult, result)
			}

			// Verify the request was made with correct headers
			info := httpmock.GetCallCountInfo()
			assert.Equal(t, 1, info[fmt.Sprintf("GET %s", endpoint)])
		})
	}
}

func TestGenerateToken(t *testing.T) {
	httpmock.Activate()
	defer httpmock.DeactivateAndReset()

	tokenResponse := &github.InstallationToken{
		Token:     github.String("ghs_test_token_123"),
		ExpiresAt: &github.Timestamp{Time: time.Now().Add(time.Hour)},
	}
	tokenJSON, _ := json.Marshal(tokenResponse)

	tests := []struct {
		name           string
		hostname       string
		jwt            string
		installationID string
		responseCode   int
		responseBody   string
		expectedError  string
	}{
		{
			name:           "successful_token_generation",
			hostname:       "api.github.com",
			jwt:            "test.jwt.token",
			installationID: "12345",
			responseCode:   201,
			responseBody:   string(tokenJSON),
			expectedError:  "",
		},
		{
			name:           "forbidden_error",
			hostname:       "api.github.com",
			jwt:            "test.jwt.token",
			installationID: "12345",
			responseCode:   403,
			responseBody:   `{"message": "Forbidden"}`,
			expectedError:  "unexpected status code: 403",
		},
		{
			name:           "invalid_json_response",
			hostname:       "api.github.com",
			jwt:            "test.jwt.token",
			installationID: "12345",
			responseCode:   201,
			responseBody:   "invalid json",
			expectedError:  "unable to unmarshal response body",
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			httpmock.Reset()

			endpoint := fmt.Sprintf("https://%s/app/installations/%s/access_tokens", tt.hostname, tt.installationID)
			httpmock.RegisterResponder("POST", endpoint,
				httpmock.NewStringResponder(tt.responseCode, tt.responseBody))

			result, err := generateToken(tt.hostname, tt.jwt, tt.installationID)

			if tt.expectedError != "" {
				assert.Error(t, err)
				assert.Contains(t, err.Error(), tt.expectedError)
				assert.Nil(t, result)
			} else {
				assert.NoError(t, err)
				assert.NotNil(t, result)
				assert.Equal(t, "ghs_test_token_123", *result.Token)
			}

			// Verify the request was made with correct headers
			info := httpmock.GetCallCountInfo()
			assert.Equal(t, 1, info[fmt.Sprintf("POST %s", endpoint)])
		})
	}
}

func TestGenerateAdvancedCases(t *testing.T) {
	httpmock.Activate()
	defer httpmock.DeactivateAndReset()

	tests := []struct {
		name          string
		setupTest     func() *cli.Context
		setupMocks    func()
		expectedError string
	}{
		{
			name: "jwt_expiry_below_minimum",
			setupTest: func() *cli.Context {
				return createTestContext(map[string]interface{}{
					"app-id":     "123456",
					"key":        "fixtures/test-private-key.test.pem",
					"jwt-expiry": 0, // Below minimum, should be adjusted to 10
					"jwt":        true,
					"silent":     true,
				})
			},
			setupMocks:    func() {},
			expectedError: "",
		},
		{
			name: "jwt_expiry_above_maximum",
			setupTest: func() *cli.Context {
				return createTestContext(map[string]interface{}{
					"app-id":     "123456",
					"key":        "fixtures/test-private-key.test.pem",
					"jwt-expiry": 15, // Above maximum, should be adjusted to 10
					"jwt":        true,
					"silent":     true,
				})
			},
			setupMocks:    func() {},
			expectedError: "",
		},
		{
			name: "hostname_without_api_path",
			setupTest: func() *cli.Context {
				return createTestContext(map[string]interface{}{
					"app-id":          "123456",
					"installation-id": "12345",
					"key":             "fixtures/test-private-key.test.pem",
					"hostname":        "github.company.com", // Without /api/v3
					"silent":          true,
				})
			},
			setupMocks: func() {
				tokenResponse := &github.InstallationToken{
					Token:     github.String("ghs_test_token_123"),
					ExpiresAt: &github.Timestamp{Time: time.Now().Add(time.Hour)},
				}
				tokenJSON, _ := json.Marshal(tokenResponse)
				httpmock.RegisterResponder("POST", "https://github.company.com/api/v3/app/installations/12345/access_tokens",
					httpmock.NewStringResponder(201, string(tokenJSON)))
			},
			expectedError: "",
		},
		{
			name: "hostname_with_api_path_already_included",
			setupTest: func() *cli.Context {
				return createTestContext(map[string]interface{}{
					"app-id":          "123456",
					"installation-id": "12345",
					"key":             "fixtures/test-private-key.test.pem",
					"hostname":        "github.company.com/api/v3", // Already has /api/v3
					"silent":          true,
				})
			},
			setupMocks: func() {
				tokenResponse := &github.InstallationToken{
					Token:     github.String("ghs_test_token_123"),
					ExpiresAt: &github.Timestamp{Time: time.Now().Add(time.Hour)},
				}
				tokenJSON, _ := json.Marshal(tokenResponse)
				httpmock.RegisterResponder("POST", "https://github.company.com/api/v3/app/installations/12345/access_tokens",
					httpmock.NewStringResponder(201, string(tokenJSON)))
			},
			expectedError: "",
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			httpmock.Reset()
			tt.setupMocks()

			ctx := tt.setupTest()
			err := Generate(ctx)

			if tt.expectedError != "" {
				assert.Error(t, err)
				assert.Contains(t, err.Error(), tt.expectedError)
			} else {
				assert.NoError(t, err)
			}
		})
	}
}

// TestGenerateWithOutputFormats tests different output formats
func TestGenerateWithOutputFormats(t *testing.T) {
	httpmock.Activate()
	defer httpmock.DeactivateAndReset()

	tokenResponse := &github.InstallationToken{
		Token:     github.String("ghs_test_token_123"),
		ExpiresAt: &github.Timestamp{Time: time.Now().Add(time.Hour)},
	}
	tokenJSON, _ := json.Marshal(tokenResponse)

	tests := []struct {
		name       string
		flags      map[string]interface{}
		setupMocks func()
	}{
		{
			name: "json_output_format",
			flags: map[string]interface{}{
				"app-id":          "123456",
				"installation-id": "12345",
				"key":             "fixtures/test-private-key.test.pem",
				"hostname":        "api.github.com",
				"jwt-expiry":      10,
				"silent":          false, // To test JSON output
			},
			setupMocks: func() {
				httpmock.RegisterResponder("POST", "https://api.github.com/app/installations/12345/access_tokens",
					httpmock.NewStringResponder(201, string(tokenJSON)))
			},
		},
		{
			name: "token_only_output_format",
			flags: map[string]interface{}{
				"app-id":          "123456",
				"installation-id": "12345",
				"key":             "fixtures/test-private-key.test.pem",
				"hostname":        "api.github.com",
				"jwt-expiry":      10,
				"token-only":      true,
				"silent":          false, // To test token-only output
			},
			setupMocks: func() {
				httpmock.RegisterResponder("POST", "https://api.github.com/app/installations/12345/access_tokens",
					httpmock.NewStringResponder(201, string(tokenJSON)))
			},
		},
		{
			name: "jwt_output_format",
			flags: map[string]interface{}{
				"app-id":     "123456",
				"key":        "fixtures/test-private-key.test.pem",
				"jwt":        true,
				"jwt-expiry": 10,
				"silent":     false, // To test JWT output
			},
			setupMocks: func() {},
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			httpmock.Reset()
			tt.setupMocks()

			ctx := createTestContext(tt.flags)
			err := Generate(ctx)

			assert.NoError(t, err)

			// Verify HTTP calls were made as expected (except for JWT-only)
			if !getBoolFlag(tt.flags, "jwt") {
				info := httpmock.GetCallCountInfo()
				assert.Greater(t, len(info), 0, "Expected HTTP calls to be made")
			}
		})
	}
}


================================================
FILE: internal/installations.go
================================================
package internal

import (
	"crypto/rsa"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"strings"
	"time"

	"github.com/google/go-github/v55/github"
	"github.com/urfave/cli/v2"
)

// Installations is the entrypoint for the installations command
func Installations(c *cli.Context) error {
	appID := c.String("app-id")
	keyPath := c.String("key")
	keyBase64 := c.String("base64-key")
	hostname := strings.ToLower(c.String("hostname"))

	if keyPath == "" && keyBase64 == "" {
		return fmt.Errorf("either --key or --base64-key must be specified")
	}

	if keyPath != "" && keyBase64 != "" {
		return fmt.Errorf("only one of --key or --base64-key may be specified")
	}

	if hostname != "api.github.com" && !strings.Contains(hostname, "/api/v3") {
		endpoint := fmt.Sprintf("%s/api/v3", hostname)
		hostname = strings.TrimSuffix(endpoint, "/")
	}

	var err error
	var privateKey *rsa.PrivateKey
	if keyPath != "" {
		privateKey, err = readKey(keyPath)
		if err != nil {
			return err
		}
	} else {
		privateKey, err = readKeyBase64(keyBase64)
		if err != nil {
			return err
		}
	}

	jsonWebToken, err := generateJWT(appID, 1, privateKey)
	if err != nil {
		return fmt.Errorf("failed generating JWT: %w", err)
	}

	installations, err := listInstallations(hostname, jsonWebToken)
	if err != nil {
		return fmt.Errorf("failed listing installations: %w", err)
	}

	bytes, err := json.MarshalIndent(installations, "", "  ")
	if err != nil {
		return fmt.Errorf("failed marshalling installations to JSON: %w", err)
	}

	if len(*installations) < 1 {
		fmt.Println("[]")
		return nil
	}

	fmt.Println(string(bytes))

	return nil
}

func listInstallations(hostname, jwt string) (*[]github.Installation, error) {
	page := 0
	var responses []github.Installation
	for {
		endpoint := fmt.Sprintf("https://%s/app/installations?per_page=100&page=%d", hostname, page)
		req, err := http.NewRequest("GET", endpoint, nil)
		if err != nil {
			return nil, fmt.Errorf("unable to create GET request to %s: %w", endpoint, err)
		}
		req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", jwt))
		req.Header.Add("Accept", "application/vnd.github+json")
		req.Header.Add("X-GitHub-Api-Version", "2022-11-28")
		req.Header.Add("User-Agent", "Link-/gh-token")

		client := &http.Client{}
		resp, err := client.Do(req)
		if err != nil {
			return nil, fmt.Errorf("unable to POST to %s: %w", endpoint, err)
		}
		defer func() {
			_ = resp.Body.Close()
		}()

		if resp.StatusCode != 200 {
			return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
		}

		var response *[]github.Installation
		bytes, err := io.ReadAll(resp.Body)
		if err != nil {
			return nil, fmt.Errorf("unable to read response body: %w", err)
		}

		err = json.Unmarshal(bytes, &response)
		if err != nil {
			return nil, fmt.Errorf("unable to unmarshal response body: %w", err)
		}
		responses = append(responses, *response...)

		if len(*response) < 100 {
			break
		}
		page++

		time.Sleep(1 * time.Second)
	}

	return &responses, nil
}


================================================
FILE: internal/installations_flags.go
================================================
package internal

import "github.com/urfave/cli/v2"

// InstallationsFlags returns the CLI flags for the generate command
func InstallationsFlags() []cli.Flag {
	return []cli.Flag{
		&cli.StringFlag{
			Name:     "app-id",
			Usage:    "GitHub App ID",
			Required: true,
			Aliases:  []string{"i", "app_id"},
		},
		&cli.StringFlag{
			Name:     "key",
			Usage:    "Path to private key",
			Required: false,
			Aliases:  []string{"k"},
		},
		&cli.StringFlag{
			Name:     "base64-key",
			Usage:    "A base64 encoded private key",
			Required: false,
			Aliases:  []string{"b", "base64_key"},
		},
		&cli.StringFlag{
			Name:     "hostname",
			Usage:    "GitHub Enterprise Server API endpoint, example: github.example.com",
			Required: false,
			Aliases:  []string{"o"},
			Value:    "api.github.com",
		},
	}
}


================================================
FILE: internal/installations_test.go
================================================
package internal

import (
	"encoding/base64"
	"encoding/json"
	"flag"
	"fmt"
	"net/http"
	"os"
	"testing"

	"github.com/google/go-github/v55/github"
	"github.com/jarcoal/httpmock"
	"github.com/stretchr/testify/assert"
	"github.com/urfave/cli/v2"
)

// createTestContextForInstallations creates a test CLI context with the given flags for installations command
func createTestContextForInstallations(flags map[string]interface{}) *cli.Context {
	app := &cli.App{}
	set := flag.NewFlagSet("test", flag.ContinueOnError)

	// Set default values
	defaults := map[string]interface{}{
		"app-id":     "",
		"key":        "",
		"base64-key": "",
		"hostname":   "api.github.com",
	}

	// Override with test-specific flags
	for k, v := range flags {
		defaults[k] = v
	}

	// Set up flags based on type
	for key, value := range defaults {
		switch v := value.(type) {
		case string:
			set.String(key, v, "")
		case bool:
			set.Bool(key, v, "")
		case int:
			set.Int(key, v, "")
		}
	}

	return cli.NewContext(app, set, nil)
}

func TestInstallations(t *testing.T) {
	// Setup
	httpmock.Activate()
	defer httpmock.DeactivateAndReset()

	// Read test key for base64 encoding
	keyBytes, err := os.ReadFile("fixtures/test-private-key.test.pem")
	assert.NoError(t, err)
	keyBase64 := base64.StdEncoding.EncodeToString(keyBytes)

	// Sample installation responses
	singleInstallationResponse := []github.Installation{
		{
			ID:      github.Int64(12345),
			Account: &github.User{Login: github.String("testuser")},
		},
	}
	singleInstallationJSON, _ := json.Marshal(singleInstallationResponse)

	multipleInstallationsResponse := []github.Installation{
		{
			ID:      github.Int64(12345),
			Account: &github.User{Login: github.String("testuser1")},
		},
		{
			ID:      github.Int64(67890),
			Account: &github.User{Login: github.String("testuser2")},
		},
	}
	multipleInstallationsJSON, _ := json.Marshal(multipleInstallationsResponse)

	emptyInstallationsResponse := []github.Installation{}
	emptyInstallationsJSON, _ := json.Marshal(emptyInstallationsResponse)

	tests := []struct {
		name          string
		flags         map[string]interface{}
		setupMocks    func()
		expectedError string
	}{
		{
			name: "successful_list_installations_with_key_file",
			flags: map[string]interface{}{
				"app-id":   "123456",
				"key":      "fixtures/test-private-key.test.pem",
				"hostname": "api.github.com",
			},
			setupMocks: func() {
				httpmock.RegisterResponder("GET", "https://api.github.com/app/installations?per_page=100&page=0",
					httpmock.NewStringResponder(200, string(singleInstallationJSON)))
			},
			expectedError: "",
		},
		{
			name: "successful_list_installations_with_base64_key",
			flags: map[string]interface{}{
				"app-id":     "123456",
				"base64-key": keyBase64,
				"hostname":   "api.github.com",
			},
			setupMocks: func() {
				httpmock.RegisterResponder("GET", "https://api.github.com/app/installations?per_page=100&page=0",
					httpmock.NewStringResponder(200, string(singleInstallationJSON)))
			},
			expectedError: "",
		},
		{
			name: "successful_list_multiple_installations",
			flags: map[string]interface{}{
				"app-id":   "123456",
				"key":      "fixtures/test-private-key.test.pem",
				"hostname": "api.github.com",
			},
			setupMocks: func() {
				httpmock.RegisterResponder("GET", "https://api.github.com/app/installations?per_page=100&page=0",
					httpmock.NewStringResponder(200, string(multipleInstallationsJSON)))
			},
			expectedError: "",
		},
		{
			name: "successful_empty_installations_list",
			flags: map[string]interface{}{
				"app-id":   "123456",
				"key":      "fixtures/test-private-key.test.pem",
				"hostname": "api.github.com",
			},
			setupMocks: func() {
				httpmock.RegisterResponder("GET", "https://api.github.com/app/installations?per_page=100&page=0",
					httpmock.NewStringResponder(200, string(emptyInstallationsJSON)))
			},
			expectedError: "",
		},
		{
			name: "successful_with_custom_hostname_without_api_path",
			flags: map[string]interface{}{
				"app-id":   "123456",
				"key":      "fixtures/test-private-key.test.pem",
				"hostname": "github.company.com",
			},
			setupMocks: func() {
				httpmock.RegisterResponder("GET", "https://github.company.com/api/v3/app/installations?per_page=100&page=0",
					httpmock.NewStringResponder(200, string(singleInstallationJSON)))
			},
			expectedError: "",
		},
		{
			name: "successful_with_custom_hostname_with_api_path",
			flags: map[string]interface{}{
				"app-id":   "123456",
				"key":      "fixtures/test-private-key.test.pem",
				"hostname": "github.company.com/api/v3",
			},
			setupMocks: func() {
				httpmock.RegisterResponder("GET", "https://github.company.com/api/v3/app/installations?per_page=100&page=0",
					httpmock.NewStringResponder(200, string(singleInstallationJSON)))
			},
			expectedError: "",
		},
		{
			name: "successful_with_mixed_case_hostname",
			flags: map[string]interface{}{
				"app-id":   "123456",
				"key":      "fixtures/test-private-key.test.pem",
				"hostname": "GitHub.Company.COM",
			},
			setupMocks: func() {
				httpmock.RegisterResponder("GET", "https://github.company.com/api/v3/app/installations?per_page=100&page=0",
					httpmock.NewStringResponder(200, string(singleInstallationJSON)))
			},
			expectedError: "",
		},
		{
			name: "error_no_key_specified",
			flags: map[string]interface{}{
				"app-id": "123456",
			},
			setupMocks:    func() {},
			expectedError: "either --key or --base64-key must be specified",
		},
		{
			name: "error_both_keys_specified",
			flags: map[string]interface{}{
				"app-id":     "123456",
				"key":        "fixtures/test-private-key.test.pem",
				"base64-key": keyBase64,
			},
			setupMocks:    func() {},
			expectedError: "only one of --key or --base64-key may be specified",
		},
		{
			name: "error_invalid_key_file",
			flags: map[string]interface{}{
				"app-id": "123456",
				"key":    "fixtures/nonexistent.pem",
			},
			setupMocks:    func() {},
			expectedError: "unable to read key file",
		},
		{
			name: "error_invalid_base64_key",
			flags: map[string]interface{}{
				"app-id":     "123456",
				"base64-key": "invalid-base64-string",
			},
			setupMocks:    func() {},
			expectedError: "unable to decode key from base64",
		},
		{
			name: "error_http_request_fails",
			flags: map[string]interface{}{
				"app-id":   "123456",
				"key":      "fixtures/test-private-key.test.pem",
				"hostname": "api.github.com",
			},
			setupMocks: func() {
				httpmock.RegisterResponder("GET", "https://api.github.com/app/installations?per_page=100&page=0",
					httpmock.NewErrorResponder(fmt.Errorf("network error")))
			},
			expectedError: "failed listing installations",
		},
		{
			name: "error_http_status_not_200",
			flags: map[string]interface{}{
				"app-id":   "123456",
				"key":      "fixtures/test-private-key.test.pem",
				"hostname": "api.github.com",
			},
			setupMocks: func() {
				httpmock.RegisterResponder("GET", "https://api.github.com/app/installations?per_page=100&page=0",
					httpmock.NewStringResponder(404, `{"message": "Not Found"}`))
			},
			expectedError: "failed listing installations: unexpected status code: 404",
		},
		{
			name: "error_invalid_json_response",
			flags: map[string]interface{}{
				"app-id":   "123456",
				"key":      "fixtures/test-private-key.test.pem",
				"hostname": "api.github.com",
			},
			setupMocks: func() {
				httpmock.RegisterResponder("GET", "https://api.github.com/app/installations?per_page=100&page=0",
					httpmock.NewStringResponder(200, "invalid json"))
			},
			expectedError: "failed listing installations: unable to unmarshal response body",
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			// Reset mocks for each test
			httpmock.Reset()
			tt.setupMocks()

			// Create CLI context
			ctx := createTestContextForInstallations(tt.flags)

			// Execute the function
			err := Installations(ctx)

			// Assert results
			if tt.expectedError != "" {
				assert.Error(t, err)
				assert.Contains(t, err.Error(), tt.expectedError)
			} else {
				assert.NoError(t, err)

				// Verify HTTP calls were made as expected
				info := httpmock.GetCallCountInfo()
				assert.Greater(t, len(info), 0, "Expected HTTP calls to be made")
			}
		})
	}
}

func TestListInstallations(t *testing.T) {
	httpmock.Activate()
	defer httpmock.DeactivateAndReset()

	// Sample installation responses
	firstPageResponse := []github.Installation{
		{ID: github.Int64(12345), Account: &github.User{Login: github.String("user1")}},
		{ID: github.Int64(67890), Account: &github.User{Login: github.String("user2")}},
	}
	firstPageJSON, _ := json.Marshal(firstPageResponse)

	secondPageResponse := []github.Installation{
		{ID: github.Int64(11111), Account: &github.User{Login: github.String("user3")}},
	}
	secondPageJSON, _ := json.Marshal(secondPageResponse)

	emptyResponse := []github.Installation{}
	emptyResponseJSON, _ := json.Marshal(emptyResponse)

	// Create a response with 100 items to test pagination
	fullPageResponse := make([]github.Installation, 100)
	for i := 0; i < 100; i++ {
		fullPageResponse[i] = github.Installation{
			ID:      github.Int64(int64(i + 1000)),
			Account: &github.User{Login: github.String(fmt.Sprintf("user%d", i))},
		}
	}
	fullPageJSON, _ := json.Marshal(fullPageResponse)

	tests := []struct {
		name          string
		hostname      string
		jwt           string
		setupMocks    func()
		expectedCount int
		expectedError string
	}{
		{
			name:     "successful_single_page",
			hostname: "api.github.com",
			jwt:      "test.jwt.token",
			setupMocks: func() {
				httpmock.RegisterResponder("GET", "https://api.github.com/app/installations?per_page=100&page=0",
					httpmock.NewStringResponder(200, string(firstPageJSON)))
			},
			expectedCount: 2,
			expectedError: "",
		},
		{
			name:     "successful_multiple_pages",
			hostname: "api.github.com",
			jwt:      "test.jwt.token",
			setupMocks: func() {
				httpmock.RegisterResponder("GET", "https://api.github.com/app/installations?per_page=100&page=0",
					httpmock.NewStringResponder(200, string(fullPageJSON)))
				httpmock.RegisterResponder("GET", "https://api.github.com/app/installations?per_page=100&page=1",
					httpmock.NewStringResponder(200, string(secondPageJSON)))
			},
			expectedCount: 101,
			expectedError: "",
		},
		{
			name:     "successful_empty_response",
			hostname: "api.github.com",
			jwt:      "test.jwt.token",
			setupMocks: func() {
				httpmock.RegisterResponder("GET", "https://api.github.com/app/installations?per_page=100&page=0",
					httpmock.NewStringResponder(200, string(emptyResponseJSON)))
			},
			expectedCount: 0,
			expectedError: "",
		},
		{
			name:     "successful_with_custom_hostname",
			hostname: "github.company.com/api/v3",
			jwt:      "test.jwt.token",
			setupMocks: func() {
				httpmock.RegisterResponder("GET", "https://github.company.com/api/v3/app/installations?per_page=100&page=0",
					httpmock.NewStringResponder(200, string(firstPageJSON)))
			},
			expectedCount: 2,
			expectedError: "",
		},
		{
			name:     "error_http_request_fails",
			hostname: "api.github.com",
			jwt:      "test.jwt.token",
			setupMocks: func() {
				httpmock.RegisterResponder("GET", "https://api.github.com/app/installations?per_page=100&page=0",
					httpmock.NewErrorResponder(fmt.Errorf("network error")))
			},
			expectedCount: 0,
			expectedError: "unable to POST to",
		},
		{
			name:     "error_status_not_200",
			hostname: "api.github.com",
			jwt:      "test.jwt.token",
			setupMocks: func() {
				httpmock.RegisterResponder("GET", "https://api.github.com/app/installations?per_page=100&page=0",
					httpmock.NewStringResponder(401, `{"message": "Unauthorized"}`))
			},
			expectedCount: 0,
			expectedError: "unexpected status code: 401",
		},
		{
			name:     "error_invalid_json_response",
			hostname: "api.github.com",
			jwt:      "test.jwt.token",
			setupMocks: func() {
				httpmock.RegisterResponder("GET", "https://api.github.com/app/installations?per_page=100&page=0",
					httpmock.NewStringResponder(200, "invalid json"))
			},
			expectedCount: 0,
			expectedError: "unable to unmarshal response body",
		},
		{
			name:     "error_on_second_page",
			hostname: "api.github.com",
			jwt:      "test.jwt.token",
			setupMocks: func() {
				httpmock.RegisterResponder("GET", "https://api.github.com/app/installations?per_page=100&page=0",
					httpmock.NewStringResponder(200, string(fullPageJSON)))
				httpmock.RegisterResponder("GET", "https://api.github.com/app/installations?per_page=100&page=1",
					httpmock.NewStringResponder(500, `{"message": "Internal Server Error"}`))
			},
			expectedCount: 0,
			expectedError: "unexpected status code: 500",
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			// Reset mocks for each test
			httpmock.Reset()
			tt.setupMocks()

			// Execute the function
			result, err := listInstallations(tt.hostname, tt.jwt)

			// Assert results
			if tt.expectedError != "" {
				assert.Error(t, err)
				assert.Contains(t, err.Error(), tt.expectedError)
				assert.Nil(t, result)
			} else {
				assert.NoError(t, err)
				assert.NotNil(t, result)
				assert.Equal(t, tt.expectedCount, len(*result))

				// Verify HTTP calls were made as expected
				info := httpmock.GetCallCountInfo()
				assert.Greater(t, len(info), 0, "Expected HTTP calls to be made")

				// Verify request headers for the first request
				if tt.expectedCount > 0 {
					// Check that the Authorization header was set correctly
					endpoint := fmt.Sprintf("https://%s/app/installations?per_page=100&page=0", tt.hostname)
					assert.Equal(t, 1, info[fmt.Sprintf("GET %s", endpoint)], "Expected exactly one call to first page")
				}
			}
		})
	}
}

func TestInstallationsPaginationBehavior(t *testing.T) {
	httpmock.Activate()
	defer httpmock.DeactivateAndReset()

	// Test that pagination stops when we get less than 100 results
	// Create responses for multiple pages
	page0Response := make([]github.Installation, 100)
	for i := 0; i < 100; i++ {
		page0Response[i] = github.Installation{
			ID:      github.Int64(int64(i)),
			Account: &github.User{Login: github.String(fmt.Sprintf("user%d", i))},
		}
	}
	page0JSON, _ := json.Marshal(page0Response)

	page1Response := make([]github.Installation, 100)
	for i := 0; i < 100; i++ {
		page1Response[i] = github.Installation{
			ID:      github.Int64(int64(i + 100)),
			Account: &github.User{Login: github.String(fmt.Sprintf("user%d", i+100))},
		}
	}
	page1JSON, _ := json.Marshal(page1Response)

	// Page 2 has less than 100 results, should stop pagination
	page2Response := make([]github.Installation, 50)
	for i := 0; i < 50; i++ {
		page2Response[i] = github.Installation{
			ID:      github.Int64(int64(i + 200)),
			Account: &github.User{Login: github.String(fmt.Sprintf("user%d", i+200))},
		}
	}
	page2JSON, _ := json.Marshal(page2Response)

	t.Run("pagination_stops_when_less_than_100_results", func(t *testing.T) {
		httpmock.Reset()

		httpmock.RegisterResponder("GET", "https://api.github.com/app/installations?per_page=100&page=0",
			httpmock.NewStringResponder(200, string(page0JSON)))
		httpmock.RegisterResponder("GET", "https://api.github.com/app/installations?per_page=100&page=1",
			httpmock.NewStringResponder(200, string(page1JSON)))
		httpmock.RegisterResponder("GET", "https://api.github.com/app/installations?per_page=100&page=2",
			httpmock.NewStringResponder(200, string(page2JSON)))

		result, err := listInstallations("api.github.com", "test.jwt.token")

		assert.NoError(t, err)
		assert.NotNil(t, result)
		assert.Equal(t, 250, len(*result)) // 100 + 100 + 50

		// Verify all three pages were called
		info := httpmock.GetCallCountInfo()
		assert.Equal(t, 1, info["GET https://api.github.com/app/installations?per_page=100&page=0"])
		assert.Equal(t, 1, info["GET https://api.github.com/app/installations?per_page=100&page=1"])
		assert.Equal(t, 1, info["GET https://api.github.com/app/installations?per_page=100&page=2"])
		// Page 3 should not be called
		assert.Equal(t, 0, info["GET https://api.github.com/app/installations?per_page=100&page=3"])
	})
}

func TestInstallationsRequestHeaders(t *testing.T) {
	httpmock.Activate()
	defer httpmock.DeactivateAndReset()

	installationResponse := []github.Installation{
		{ID: github.Int64(12345)},
	}
	installationJSON, _ := json.Marshal(installationResponse)

	t.Run("correct_headers_are_set", func(t *testing.T) {
		httpmock.Reset()

		// Use a custom responder to check headers
		httpmock.RegisterResponder("GET", "https://api.github.com/app/installations?per_page=100&page=0",
			func(req *http.Request) (*http.Response, error) {
				// Verify headers
				assert.Equal(t, "Bearer test.jwt.token", req.Header.Get("Authorization"))
				assert.Equal(t, "application/vnd.github+json", req.Header.Get("Accept"))
				assert.Equal(t, "2022-11-28", req.Header.Get("X-GitHub-Api-Version"))
				assert.Equal(t, "Link-/gh-token", req.Header.Get("User-Agent"))

				return httpmock.NewStringResponse(200, string(installationJSON)), nil
			})

		result, err := listInstallations("api.github.com", "test.jwt.token")

		assert.NoError(t, err)
		assert.NotNil(t, result)
		assert.Equal(t, 1, len(*result))
	})
}


================================================
FILE: internal/key.go
================================================
package internal

import (
	"crypto/rsa"
	"encoding/base64"
	"fmt"
	"os"
	"time"

	"github.com/golang-jwt/jwt/v5"
)

func readKey(path string) (*rsa.PrivateKey, error) {
	keyBytes, err := os.ReadFile(path)
	if err != nil {
		return nil, fmt.Errorf("unable to read key file: %w", err)
	}
	key, err := jwt.ParseRSAPrivateKeyFromPEM(keyBytes)
	if err != nil {
		return nil, fmt.Errorf("unable to parse key from PEM to RSA format: %w", err)
	}

	return key, nil
}

func readKeyBase64(keyBase64 string) (*rsa.PrivateKey, error) {
	keyBytes, err := base64.StdEncoding.DecodeString(keyBase64)
	if err != nil {
		return nil, fmt.Errorf("unable to decode key from base64: %w", err)
	}
	key, err := jwt.ParseRSAPrivateKeyFromPEM(keyBytes)
	if err != nil {
		return nil, fmt.Errorf("unable to parse key from PEM to RSA format: %w", err)
	}

	return key, nil
}

func generateJWT(appID string, expiry int, key *rsa.PrivateKey) (string, error) {
	iat := jwt.NewNumericDate(time.Now().Add(-60 * time.Second))
	exp := jwt.NewNumericDate(time.Now().Add(time.Duration(expiry) * 60 * time.Second))
	token := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{
		"iat": iat,
		"exp": exp,
		"iss": appID,
	})
	signedToken, err := token.SignedString(key)
	if err != nil {
		return "", fmt.Errorf("unable to sign JWT: %w", err)
	}

	return signedToken, nil
}


================================================
FILE: internal/revoke.go
================================================
package internal

import (
	"fmt"
	"net/http"
	"strings"

	"github.com/urfave/cli/v2"
)

// Revoke is the entrypoint for the revoke command
func Revoke(c *cli.Context) error {
	token := c.String("token")
	hostname := strings.ToLower(c.String("hostname"))
	silent := c.Bool("silent")

	if hostname != "api.github.com" && !strings.Contains(hostname, "/api/v3") {
		endpoint := fmt.Sprintf("%s/api/v3", hostname)
		hostname = strings.TrimSuffix(endpoint, "/")
	}

	err := revokeToken(hostname, token)
	if err != nil {
		return fmt.Errorf("failed revoking installation token: %w", err)
	}
	if !silent {
		fmt.Println("Successfully revoked installation token")
	}

	return nil
}

func revokeToken(hostname, token string) error {
	endpoint := fmt.Sprintf("https://%s/installation/token", hostname)
	req, err := http.NewRequest("DELETE", endpoint, nil)
	if err != nil {
		return fmt.Errorf("unable to create DELETE request to %s: %w", endpoint, err)
	}
	req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token))
	req.Header.Add("Accept", "application/vnd.github+json")
	req.Header.Add("X-GitHub-Api-Version", "2022-11-28")
	req.Header.Add("User-Agent", "Link-/gh-token")

	client := &http.Client{}
	resp, err := client.Do(req)
	if err != nil {
		return fmt.Errorf("unable to DELETE to %s: %w", endpoint, err)
	}
	defer func() {
		_ = resp.Body.Close()
	}()

	if resp.StatusCode != 204 {
		return fmt.Errorf("token might be invalid or not properly formatted. Unexpected status code: %d", resp.StatusCode)
	}

	return nil
}


================================================
FILE: internal/revoke_flags.go
================================================
package internal

import "github.com/urfave/cli/v2"

// RevokeFlags returns the CLI flags for the revoke command
func RevokeFlags() []cli.Flag {
	return []cli.Flag{
		&cli.StringFlag{
			Name:     "token",
			Usage:    "GitHub App installation Token",
			Required: true,
			Aliases:  []string{"t"},
		},
		&cli.StringFlag{
			Name:     "hostname",
			Usage:    "GitHub Enterprise Server API endpoint, example: github.example.com",
			Required: false,
			Aliases:  []string{"o"},
			Value:    "api.github.com",
		},
		&cli.BoolFlag{
			Name:    "silent",
			Usage:   "Do not print to stdout",
			Aliases: []string{"s"},
			Value:   false,
		},
	}
}


================================================
FILE: internal/revoke_test.go
================================================
package internal

import (
	"flag"
	"fmt"
	"net/http"
	"testing"

	"github.com/jarcoal/httpmock"
	"github.com/stretchr/testify/assert"
	"github.com/urfave/cli/v2"
)

// createTestContextForRevoke creates a test CLI context with the given flags for revoke command
func createTestContextForRevoke(flags map[string]interface{}) *cli.Context {
	app := &cli.App{}
	set := flag.NewFlagSet("test", flag.ContinueOnError)

	// Set default values
	defaults := map[string]interface{}{
		"token":    "",
		"hostname": "api.github.com",
		"silent":   false,
	}

	// Override with test-specific flags
	for k, v := range flags {
		defaults[k] = v
	}

	// Set up flags based on type
	for key, value := range defaults {
		switch v := value.(type) {
		case string:
			set.String(key, v, "")
		case bool:
			set.Bool(key, v, "")
		}
	}

	return cli.NewContext(app, set, nil)
}

func TestRevoke(t *testing.T) {
	// Setup
	httpmock.Activate()
	defer httpmock.DeactivateAndReset()

	tests := []struct {
		name          string
		flags         map[string]interface{}
		setupMocks    func()
		expectedError string
	}{
		{
			name: "successful_token_revocation_default_hostname",
			flags: map[string]interface{}{
				"token":    "ghs_test_token_123",
				"hostname": "api.github.com",
				"silent":   true,
			},
			setupMocks: func() {
				httpmock.RegisterResponder("DELETE", "https://api.github.com/installation/token",
					httpmock.NewStringResponder(204, ""))
			},
			expectedError: "",
		},
		{
			name: "successful_token_revocation_custom_hostname_without_api_path",
			flags: map[string]interface{}{
				"token":    "ghs_test_token_456",
				"hostname": "github.company.com",
				"silent":   true,
			},
			setupMocks: func() {
				httpmock.RegisterResponder("DELETE", "https://github.company.com/api/v3/installation/token",
					httpmock.NewStringResponder(204, ""))
			},
			expectedError: "",
		},
		{
			name: "successful_token_revocation_custom_hostname_with_api_path",
			flags: map[string]interface{}{
				"token":    "ghs_test_token_789",
				"hostname": "github.company.com/api/v3",
				"silent":   true,
			},
			setupMocks: func() {
				httpmock.RegisterResponder("DELETE", "https://github.company.com/api/v3/installation/token",
					httpmock.NewStringResponder(204, ""))
			},
			expectedError: "",
		},
		{
			name: "successful_token_revocation_verbose_output",
			flags: map[string]interface{}{
				"token":    "ghs_test_token_verbose",
				"hostname": "api.github.com",
				"silent":   false,
			},
			setupMocks: func() {
				httpmock.RegisterResponder("DELETE", "https://api.github.com/installation/token",
					httpmock.NewStringResponder(204, ""))
			},
			expectedError: "",
		},
		{
			name: "error_invalid_token_401",
			flags: map[string]interface{}{
				"token":    "invalid_token",
				"hostname": "api.github.com",
				"silent":   true,
			},
			setupMocks: func() {
				httpmock.RegisterResponder("DELETE", "https://api.github.com/installation/token",
					httpmock.NewStringResponder(401, `{"message": "Bad credentials"}`))
			},
			expectedError: "failed revoking installation token: token might be invalid or not properly formatted. Unexpected status code: 401",
		},
		{
			name: "error_forbidden_403",
			flags: map[string]interface{}{
				"token":    "forbidden_token",
				"hostname": "api.github.com",
				"silent":   true,
			},
			setupMocks: func() {
				httpmock.RegisterResponder("DELETE", "https://api.github.com/installation/token",
					httpmock.NewStringResponder(403, `{"message": "Forbidden"}`))
			},
			expectedError: "failed revoking installation token: token might be invalid or not properly formatted. Unexpected status code: 403",
		},
		{
			name: "error_not_found_404",
			flags: map[string]interface{}{
				"token":    "not_found_token",
				"hostname": "api.github.com",
				"silent":   true,
			},
			setupMocks: func() {
				httpmock.RegisterResponder("DELETE", "https://api.github.com/installation/token",
					httpmock.NewStringResponder(404, `{"message": "Not Found"}`))
			},
			expectedError: "failed revoking installation token: token might be invalid or not properly formatted. Unexpected status code: 404",
		},
		{
			name: "error_server_error_500",
			flags: map[string]interface{}{
				"token":    "server_error_token",
				"hostname": "api.github.com",
				"silent":   true,
			},
			setupMocks: func() {
				httpmock.RegisterResponder("DELETE", "https://api.github.com/installation/token",
					httpmock.NewStringResponder(500, `{"message": "Internal Server Error"}`))
			},
			expectedError: "failed revoking installation token: token might be invalid or not properly formatted. Unexpected status code: 500",
		},
		{
			name: "hostname_case_insensitive",
			flags: map[string]interface{}{
				"token":    "ghs_case_test_token",
				"hostname": "API.GITHUB.COM",
				"silent":   true,
			},
			setupMocks: func() {
				httpmock.RegisterResponder("DELETE", "https://api.github.com/installation/token",
					httpmock.NewStringResponder(204, ""))
			},
			expectedError: "",
		},
		{
			name: "custom_hostname_with_trailing_slash",
			flags: map[string]interface{}{
				"token":    "ghs_trailing_slash_token",
				"hostname": "github.company.com/api/v3/",
				"silent":   true,
			},
			setupMocks: func() {
				// The hostname processing logic preserves the trailing slash when /api/v3 is already present,
				// resulting in a double slash in the final URL
				httpmock.RegisterResponder("DELETE", "https://github.company.com/api/v3//installation/token",
					httpmock.NewStringResponder(204, ""))
			},
			expectedError: "",
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			// Reset mocks for each test
			httpmock.Reset()
			tt.setupMocks()

			// Create CLI context
			ctx := createTestContextForRevoke(tt.flags)

			// Execute the function
			err := Revoke(ctx)

			// Assert results
			if tt.expectedError != "" {
				assert.Error(t, err)
				assert.Equal(t, tt.expectedError, err.Error())
			} else {
				assert.NoError(t, err)
			}

			// Verify HTTP calls were made as expected
			info := httpmock.GetCallCountInfo()
			assert.Greater(t, len(info), 0, "Expected HTTP calls to be made")
		})
	}
}

func TestRevokeToken(t *testing.T) {
	httpmock.Activate()
	defer httpmock.DeactivateAndReset()

	tests := []struct {
		name          string
		hostname      string
		token         string
		responseCode  int
		responseBody  string
		expectedError string
	}{
		{
			name:          "successful_revocation_github_com",
			hostname:      "api.github.com",
			token:         "ghs_test_token_123",
			responseCode:  204,
			responseBody:  "",
			expectedError: "",
		},
		{
			name:          "successful_revocation_custom_hostname",
			hostname:      "github.company.com/api/v3",
			token:         "ghs_test_token_456",
			responseCode:  204,
			responseBody:  "",
			expectedError: "",
		},
		{
			name:          "error_bad_credentials_401",
			hostname:      "api.github.com",
			token:         "invalid_token",
			responseCode:  401,
			responseBody:  `{"message": "Bad credentials"}`,
			expectedError: "token might be invalid or not properly formatted. Unexpected status code: 401",
		},
		{
			name:          "error_forbidden_403",
			hostname:      "api.github.com",
			token:         "forbidden_token",
			responseCode:  403,
			responseBody:  `{"message": "Forbidden"}`,
			expectedError: "token might be invalid or not properly formatted. Unexpected status code: 403",
		},
		{
			name:          "error_not_found_404",
			hostname:      "api.github.com",
			token:         "not_found_token",
			responseCode:  404,
			responseBody:  `{"message": "Not Found"}`,
			expectedError: "token might be invalid or not properly formatted. Unexpected status code: 404",
		},
		{
			name:          "error_unprocessable_entity_422",
			hostname:      "api.github.com",
			token:         "malformed_token",
			responseCode:  422,
			responseBody:  `{"message": "Unprocessable Entity"}`,
			expectedError: "token might be invalid or not properly formatted. Unexpected status code: 422",
		},
		{
			name:          "error_internal_server_error_500",
			hostname:      "api.github.com",
			token:         "server_error_token",
			responseCode:  500,
			responseBody:  `{"message": "Internal Server Error"}`,
			expectedError: "token might be invalid or not properly formatted. Unexpected status code: 500",
		},
		{
			name:          "error_service_unavailable_503",
			hostname:      "api.github.com",
			token:         "service_unavailable_token",
			responseCode:  503,
			responseBody:  `{"message": "Service Unavailable"}`,
			expectedError: "token might be invalid or not properly formatted. Unexpected status code: 503",
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			httpmock.Reset()

			endpoint := fmt.Sprintf("https://%s/installation/token", tt.hostname)
			httpmock.RegisterResponder("DELETE", endpoint,
				httpmock.NewStringResponder(tt.responseCode, tt.responseBody))

			err := revokeToken(tt.hostname, tt.token)

			if tt.expectedError != "" {
				assert.Error(t, err)
				assert.Equal(t, tt.expectedError, err.Error())
			} else {
				assert.NoError(t, err)
			}

			// Verify the request was made with correct method and endpoint
			info := httpmock.GetCallCountInfo()
			assert.Equal(t, 1, info[fmt.Sprintf("DELETE %s", endpoint)], "Expected exactly one DELETE request to be made")
		})
	}
}

func TestRevokeTokenNetworkErrors(t *testing.T) {
	httpmock.Activate()
	defer httpmock.DeactivateAndReset()

	tests := []struct {
		name          string
		hostname      string
		token         string
		setupMock     func()
		expectedError string
		errorContains string
	}{
		{
			name:     "network_connection_error",
			hostname: "api.github.com",
			token:    "ghs_network_error_token",
			setupMock: func() {
				httpmock.RegisterResponder("DELETE", "https://api.github.com/installation/token",
					httpmock.NewErrorResponder(fmt.Errorf("connection refused")))
			},
			errorContains: "unable to DELETE to https://api.github.com/installation/token",
		},
		{
			name:     "timeout_error",
			hostname: "api.github.com",
			token:    "ghs_timeout_token",
			setupMock: func() {
				httpmock.RegisterResponder("DELETE", "https://api.github.com/installation/token",
					httpmock.NewErrorResponder(fmt.Errorf("request timeout")))
			},
			errorContains: "unable to DELETE to https://api.github.com/installation/token",
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			httpmock.Reset()
			tt.setupMock()

			err := revokeToken(tt.hostname, tt.token)

			assert.Error(t, err)
			if tt.expectedError != "" {
				assert.Equal(t, tt.expectedError, err.Error())
			} else if tt.errorContains != "" {
				assert.Contains(t, err.Error(), tt.errorContains)
			}

			// Verify the request was attempted
			info := httpmock.GetCallCountInfo()
			endpoint := fmt.Sprintf("https://%s/installation/token", tt.hostname)
			assert.Equal(t, 1, info[fmt.Sprintf("DELETE %s", endpoint)], "Expected exactly one DELETE request to be attempted")
		})
	}
}

func TestRevokeAdvancedCases(t *testing.T) {
	httpmock.Activate()
	defer httpmock.DeactivateAndReset()

	tests := []struct {
		name       string
		setupTest  func() *cli.Context
		setupMocks func()
		verifyFunc func(t *testing.T)
	}{
		{
			name: "verify_request_headers",
			setupTest: func() *cli.Context {
				return createTestContextForRevoke(map[string]interface{}{
					"token":    "ghs_header_test_token",
					"hostname": "api.github.com",
					"silent":   true,
				})
			},
			setupMocks: func() {
				httpmock.RegisterResponder("DELETE", "https://api.github.com/installation/token",
					func(req *http.Request) (*http.Response, error) {
						// Verify required headers are present
						assert.Equal(t, "Bearer ghs_header_test_token", req.Header.Get("Authorization"))
						assert.Equal(t, "application/vnd.github+json", req.Header.Get("Accept"))
						assert.Equal(t, "2022-11-28", req.Header.Get("X-GitHub-Api-Version"))
						assert.Equal(t, "Link-/gh-token", req.Header.Get("User-Agent"))
						return httpmock.NewStringResponse(204, ""), nil
					})
			},
			verifyFunc: func(t *testing.T) {
				// Headers are verified in the mock responder
			},
		},
		{
			name: "hostname_normalization_mixed_case",
			setupTest: func() *cli.Context {
				return createTestContextForRevoke(map[string]interface{}{
					"token":    "ghs_mixed_case_token",
					"hostname": "GitHub.Company.Com",
					"silent":   true,
				})
			},
			setupMocks: func() {
				// The hostname should be normalized to lowercase and have /api/v3 appended
				httpmock.RegisterResponder("DELETE", "https://github.company.com/api/v3/installation/token",
					httpmock.NewStringResponder(204, ""))
			},
			verifyFunc: func(t *testing.T) {
				info := httpmock.GetCallCountInfo()
				assert.Equal(t, 1, info["DELETE https://github.company.com/api/v3/installation/token"])
			},
		},
		{
			name: "empty_token_string",
			setupTest: func() *cli.Context {
				return createTestContextForRevoke(map[string]interface{}{
					"token":    "",
					"hostname": "api.github.com",
					"silent":   true,
				})
			},
			setupMocks: func() {
				httpmock.RegisterResponder("DELETE", "https://api.github.com/installation/token",
					httpmock.NewStringResponder(401, `{"message": "Bad credentials"}`))
			},
			verifyFunc: func(t *testing.T) {
				// Should still attempt the request even with empty token
				info := httpmock.GetCallCountInfo()
				assert.Equal(t, 1, info["DELETE https://api.github.com/installation/token"])
			},
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			httpmock.Reset()
			tt.setupMocks()

			ctx := tt.setupTest()
			_ = Revoke(ctx) // We don't check error here as we're testing specific behaviors

			tt.verifyFunc(t)
		})
	}
}


================================================
FILE: main.go
================================================
// Package main is the entrypoint for the gh-token CLI
package main

import (
	"fmt"
	"os"

	"github.com/Link-/gh-token/internal"
	"github.com/urfave/cli/v2"
)

func main() {
	app := &cli.App{
		Name:                 "gh-token",
		Usage:                "Manage GitHub App installation tokens",
		Version:              Version,
		EnableBashCompletion: true,
		Suggest:              true,
		Commands: []*cli.Command{
			{
				Name:   "generate",
				Usage:  "Generate a new GitHub App installation token",
				Flags:  internal.GenerateFlags(),
				Action: internal.Generate,
			},
			{
				Name:   "revoke",
				Usage:  "Revoke a GitHub App installation token",
				Flags:  internal.RevokeFlags(),
				Action: internal.Revoke,
			},
			{
				Name:   "installations",
				Usage:  "List GitHub App installations",
				Flags:  internal.InstallationsFlags(),
				Action: internal.Installations,
			},
		},
	}

	err := app.Run(os.Args)
	if err != nil {
		fmt.Fprintf(os.Stderr, "Error: %v\n", err)
		os.Exit(1)
	}
}


================================================
FILE: version.go
================================================
package main

// Version is the current version of the gh-token CLI.
const Version = "2.0.10"
Download .txt
gitextract_vc2g9202/

├── .github/
│   ├── copilot-instructions.md
│   ├── dependabot.yml
│   └── workflows/
│       ├── codeql.yml
│       ├── linter.yml
│       ├── release.yml
│       └── test.yml
├── .gitignore
├── .vscode/
│   └── launch.json
├── LICENSE
├── Makefile
├── README.md
├── SECURITY.md
├── go.mod
├── go.sum
├── internal/
│   ├── fixtures/
│   │   └── test-private-key.test.pem
│   ├── generate.go
│   ├── generate_flags.go
│   ├── generate_test.go
│   ├── installations.go
│   ├── installations_flags.go
│   ├── installations_test.go
│   ├── key.go
│   ├── revoke.go
│   ├── revoke_flags.go
│   └── revoke_test.go
├── main.go
└── version.go
Download .txt
SYMBOL INDEX (32 symbols across 12 files)

FILE: internal/generate.go
  function Generate (line 17) | func Generate(c *cli.Context) error {
  function retrieveDefaultInstallationID (line 100) | func retrieveDefaultInstallationID(hostname, jwt string) (string, error) {
  function generateToken (line 138) | func generateToken(hostname, jwt, installationID string) (*github.Instal...

FILE: internal/generate_flags.go
  function GenerateFlags (line 6) | func GenerateFlags() []cli.Flag {

FILE: internal/generate_test.go
  function createTestContext (line 19) | func createTestContext(flags map[string]interface{}) *cli.Context {
  function getBoolFlag (line 57) | func getBoolFlag(flags map[string]interface{}, key string) bool {
  function TestGenerate (line 64) | func TestGenerate(t *testing.T) {
  function TestRetrieveDefaultInstallationID (line 244) | func TestRetrieveDefaultInstallationID(t *testing.T) {
  function TestGenerateToken (line 312) | func TestGenerateToken(t *testing.T) {
  function TestGenerateAdvancedCases (line 387) | func TestGenerateAdvancedCases(t *testing.T) {
  function TestGenerateWithOutputFormats (line 490) | func TestGenerateWithOutputFormats(t *testing.T) {

FILE: internal/installations.go
  function Installations (line 17) | func Installations(c *cli.Context) error {
  function listInstallations (line 75) | func listInstallations(hostname, jwt string) (*[]github.Installation, er...

FILE: internal/installations_flags.go
  function InstallationsFlags (line 6) | func InstallationsFlags() []cli.Flag {

FILE: internal/installations_test.go
  function createTestContextForInstallations (line 19) | func createTestContextForInstallations(flags map[string]interface{}) *cl...
  function TestInstallations (line 51) | func TestInstallations(t *testing.T) {
  function TestListInstallations (line 286) | func TestListInstallations(t *testing.T) {
  function TestInstallationsPaginationBehavior (line 451) | func TestInstallationsPaginationBehavior(t *testing.T) {
  function TestInstallationsRequestHeaders (line 511) | func TestInstallationsRequestHeaders(t *testing.T) {

FILE: internal/key.go
  function readKey (line 13) | func readKey(path string) (*rsa.PrivateKey, error) {
  function readKeyBase64 (line 26) | func readKeyBase64(keyBase64 string) (*rsa.PrivateKey, error) {
  function generateJWT (line 39) | func generateJWT(appID string, expiry int, key *rsa.PrivateKey) (string,...

FILE: internal/revoke.go
  function Revoke (line 12) | func Revoke(c *cli.Context) error {
  function revokeToken (line 33) | func revokeToken(hostname, token string) error {

FILE: internal/revoke_flags.go
  function RevokeFlags (line 6) | func RevokeFlags() []cli.Flag {

FILE: internal/revoke_test.go
  function createTestContextForRevoke (line 15) | func createTestContextForRevoke(flags map[string]interface{}) *cli.Conte...
  function TestRevoke (line 44) | func TestRevoke(t *testing.T) {
  function TestRevokeToken (line 216) | func TestRevokeToken(t *testing.T) {
  function TestRevokeTokenNetworkErrors (line 318) | func TestRevokeTokenNetworkErrors(t *testing.T) {
  function TestRevokeAdvancedCases (line 374) | func TestRevokeAdvancedCases(t *testing.T) {

FILE: main.go
  function main (line 12) | func main() {

FILE: version.go
  constant Version (line 4) | Version = "2.0.10"
Condensed preview — 27 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (107K chars).
[
  {
    "path": ".github/copilot-instructions.md",
    "chars": 2029,
    "preview": "## Project structure\n\n[Creates an installation access token](https://docs.github.com/en/rest/reference/apps#create-an-in"
  },
  {
    "path": ".github/dependabot.yml",
    "chars": 258,
    "preview": "version: 2\nupdates:\n  - package-ecosystem: gomod\n    directory: \"/\"\n    schedule:\n      interval: weekly\n\n  - package-ec"
  },
  {
    "path": ".github/workflows/codeql.yml",
    "chars": 803,
    "preview": "name: \"CodeQL\"\n\non:\n  push:\n    branches:\n      - main\n  pull_request:\n    branches:\n      - main\n  schedule:\n    - cron"
  },
  {
    "path": ".github/workflows/linter.yml",
    "chars": 421,
    "preview": "name: Lint\n\non:\n  pull_request:\n    branches:\n      - main\n\nconcurrency:\n  group: ${{ github.ref }}-${{ github.workflow "
  },
  {
    "path": ".github/workflows/release.yml",
    "chars": 621,
    "preview": "name: Release\non:\n  push:\n    tags:\n      - \"v*\"\npermissions:\n  contents: write\n\njobs:\n  release:\n    runs-on: ubuntu-la"
  },
  {
    "path": ".github/workflows/test.yml",
    "chars": 3069,
    "preview": "name: Test\n\non:\n  push:\n    branches:\n      - main\n  pull_request:\n    branches:\n      - main\n  workflow_dispatch:\n    i"
  },
  {
    "path": ".gitignore",
    "chars": 984,
    "preview": "### 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"
  },
  {
    "path": ".vscode/launch.json",
    "chars": 534,
    "preview": "{\n    \"version\": \"0.2.0\",\n    \"configurations\": [\n        {\n            \"name\": \"Debug gh token\",\n            \"type\": \"g"
  },
  {
    "path": "LICENSE",
    "chars": 1045,
    "preview": "Copyright 2024 Link-\n\nPermission is hereby granted, free of charge, to any person obtaining a copy of this software and "
  },
  {
    "path": "Makefile",
    "chars": 2581,
    "preview": "# Makefile with the following targets:\n#   all: build the project\n#   clean: remove all build artifacts\n#   build: build"
  },
  {
    "path": "README.md",
    "chars": 9620,
    "preview": "# GH Token\n\n```shell\n* _____ _   *_   _______ *  _      *  *    **   *\n / ____| |* | | |__   __|  | |  *       *        "
  },
  {
    "path": "SECURITY.md",
    "chars": 183,
    "preview": "# Security Policy\n\n## Reporting a Vulnerability\n\nUse the [private vulnerability reporting feature](https://github.com/Li"
  },
  {
    "path": "go.mod",
    "chars": 1036,
    "preview": "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"
  },
  {
    "path": "go.sum",
    "chars": 8780,
    "preview": "github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371 h1:kkhsdkhsCvIsutKu5zLMgWtgh9YxGCNAw8Ad8hjwfYg=\ngithu"
  },
  {
    "path": "internal/fixtures/test-private-key.test.pem",
    "chars": 1704,
    "preview": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCL02nUZNMcAaOs\nJ+L5qKErTjABtmS6CBgCbDbUN1u"
  },
  {
    "path": "internal/generate.go",
    "chars": 4486,
    "preview": "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/g"
  },
  {
    "path": "internal/generate_flags.go",
    "chars": 1845,
    "preview": "package internal\n\nimport \"github.com/urfave/cli/v2\"\n\n// GenerateFlags returns the CLI flags for the generate command\nfun"
  },
  {
    "path": "internal/generate_test.go",
    "chars": 15877,
    "preview": "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/goo"
  },
  {
    "path": "internal/installations.go",
    "chars": 2992,
    "preview": "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/goog"
  },
  {
    "path": "internal/installations_flags.go",
    "chars": 817,
    "preview": "package internal\n\nimport \"github.com/urfave/cli/v2\"\n\n// InstallationsFlags returns the CLI flags for the generate comman"
  },
  {
    "path": "internal/installations_test.go",
    "chars": 17301,
    "preview": "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"
  },
  {
    "path": "internal/key.go",
    "chars": 1342,
    "preview": "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\nfun"
  },
  {
    "path": "internal/revoke.go",
    "chars": 1522,
    "preview": "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"
  },
  {
    "path": "internal/revoke_flags.go",
    "chars": 648,
    "preview": "package internal\n\nimport \"github.com/urfave/cli/v2\"\n\n// RevokeFlags returns the CLI flags for the revoke command\nfunc Re"
  },
  {
    "path": "internal/revoke_test.go",
    "chars": 13843,
    "preview": "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/t"
  },
  {
    "path": "main.go",
    "chars": 1009,
    "preview": "// 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/"
  },
  {
    "path": "version.go",
    "chars": 94,
    "preview": "package main\n\n// Version is the current version of the gh-token CLI.\nconst Version = \"2.0.10\"\n"
  }
]

About this extraction

This page contains the full source code of the Link-/gh-token GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 27 files (93.2 KB), approximately 29.6k tokens, and a symbol index with 32 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!