Skip to content

Commit 3256a69

Browse files
randyquayeclaude
andcommitted
Restructure CLI as gh-app with subcommands
Replace individual binaries with a single `gh-app` entry point supporting: token, pull, clone, check-token, list-repos. Adds shared auth helper and --help flag on all commands. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 741494c commit 3256a69

10 files changed

Lines changed: 258 additions & 56 deletions

File tree

README.md

Lines changed: 35 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
# github-app-util
1+
# gh-app
22

3-
Generate GitHub App installation tokens for API access.
3+
A CLI for GitHub App authentication and git operations.
44

55
## Prerequisites
66

@@ -20,7 +20,7 @@ Or clone and install locally:
2020
```bash
2121
git clone https://github.com/AztecProtocol/github-app-util.git
2222
cd github-app-util
23-
npm install
23+
npm install && npm link
2424
```
2525

2626
## Setup
@@ -43,40 +43,53 @@ GITHUB_PRIVATE_KEY_PATH=./private-key.pem
4343

4444
## Usage
4545

46-
If installed globally:
46+
```
47+
gh-app <command> [options]
48+
49+
Commands:
50+
token Generate an installation token
51+
pull Pull a repo using a fresh token
52+
clone Clone a repo using a fresh token
53+
check-token Show token validity and expiry
54+
list-repos List accessible repositories
55+
```
56+
57+
Run `gh-app <command> --help` for command-specific help.
58+
59+
### Generate a token
4760

4861
```bash
49-
github-app-util
62+
gh-app token
5063
```
5164

52-
If cloned locally:
65+
To capture as an environment variable:
5366

5467
```bash
55-
node bin/get-token.js
68+
export GITHUB_TOKEN=$(gh-app token 2>/dev/null)
5669
```
5770

58-
The tool outputs an installation token you can use for API calls.
59-
60-
To capture the token as an environment variable:
71+
### Pull a repo
6172

6273
```bash
63-
export GITHUB_TOKEN=$(github-app-util 2>/dev/null)
74+
gh-app pull owner/repo
75+
gh-app pull owner/repo feature-branch
6476
```
6577

66-
The `2>/dev/null` suppresses the TTY warning so only the token is captured.
67-
68-
Then you can use the git api normally with the token:
78+
### Clone a repo
6979

7080
```bash
71-
git pull https://<GITHUB_TOKEN>@github.com/OWNER/REPO.git main
81+
gh-app clone owner/repo
82+
gh-app clone owner/repo my-directory
7283
```
7384

74-
Or set it more persistently:
75-
```bash
76-
# Set the remote URL with the token
77-
git remote set-url origin https://<GITHUB_TOKEN>@github.com/OWNER/REPO.git
78-
79-
# Then pull normally
80-
git pull
85+
### Check token validity
86+
87+
```bash
88+
gh-app check-token
8189
```
8290

91+
### List accessible repos
92+
93+
```bash
94+
gh-app list-repos
95+
```

bin/commands/check-token.js

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
if (process.argv.includes("--help") || process.argv.includes("-h")) {
2+
console.log(`Usage: gh-app check-token
3+
4+
Show the current installation token's validity and expiry time.
5+
6+
Options:
7+
-h, --help Show this help message`);
8+
process.exit(0);
9+
}
10+
11+
import { getInstallationAuth } from "../lib/auth.js";
12+
13+
const { octokit, token, expiresAt } = await getInstallationAuth();
14+
15+
const { data: app } = await octokit.rest.apps.getAuthenticated();
16+
console.log(`App: ${app.name}`);
17+
18+
const expires = new Date(expiresAt);
19+
const now = new Date();
20+
const minutesLeft = Math.round((expires - now) / 60000);
21+
22+
console.log(`Token prefix: ${token.slice(0, 8)}...`);
23+
console.log(`Expires at: ${expires.toISOString()}`);
24+
25+
if (minutesLeft > 0) {
26+
console.log(`Status: valid (${minutesLeft} min remaining)`);
27+
} else {
28+
console.log(`Status: expired`);
29+
}

bin/commands/clone.js

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
if (process.argv.includes("--help") || process.argv.includes("-h")) {
2+
console.log(`Usage: gh-app clone <owner/repo> [directory]
3+
4+
Clone a repo using a fresh GitHub App installation token.
5+
6+
Options:
7+
-h, --help Show this help message
8+
9+
Examples:
10+
gh-app clone myorg/myrepo
11+
gh-app clone myorg/myrepo ./my-directory`);
12+
process.exit(0);
13+
}
14+
15+
import { execSync } from "child_process";
16+
import { getInstallationAuth } from "../lib/auth.js";
17+
18+
const [repoSlug, directory] = process.argv.slice(2);
19+
20+
if (!repoSlug) {
21+
console.error("Usage: gh-app clone <owner/repo> [directory]");
22+
console.error("Run gh-app clone --help for more information.");
23+
process.exit(1);
24+
}
25+
26+
const { token } = await getInstallationAuth();
27+
const remote = `https://x-access-token:${token}@github.com/${repoSlug}.git`;
28+
29+
const cmd = directory ? `git clone ${remote} ${directory}` : `git clone ${remote}`;
30+
31+
try {
32+
execSync(cmd, { stdio: "inherit" });
33+
} catch {
34+
process.exit(1);
35+
}

bin/commands/list-repos.js

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
if (process.argv.includes("--help") || process.argv.includes("-h")) {
2+
console.log(`Usage: gh-app list-repos
3+
4+
List all repositories accessible to the GitHub App installation.
5+
6+
Options:
7+
-h, --help Show this help message`);
8+
process.exit(0);
9+
}
10+
11+
import { getInstallationAuth } from "../lib/auth.js";
12+
13+
const { octokit } = await getInstallationAuth();
14+
15+
const repos = await octokit.paginate(
16+
octokit.rest.apps.listReposAccessibleToInstallation,
17+
{ per_page: 100 }
18+
);
19+
20+
if (repos.length === 0) {
21+
console.log("No repositories accessible to this installation.");
22+
process.exit(0);
23+
}
24+
25+
console.log(`Accessible repositories (${repos.length}):\n`);
26+
for (const repo of repos) {
27+
const visibility = repo.private ? "private" : "public";
28+
console.log(` ${repo.full_name} (${visibility})`);
29+
}

bin/commands/pull.js

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
if (process.argv.includes("--help") || process.argv.includes("-h")) {
2+
console.log(`Usage: gh-app pull <owner/repo> [branch]
3+
4+
Pull latest changes using a fresh GitHub App installation token.
5+
If a branch is specified, only that branch is pulled.
6+
7+
Options:
8+
-h, --help Show this help message
9+
10+
Examples:
11+
gh-app pull myorg/myrepo
12+
gh-app pull myorg/myrepo feature-branch`);
13+
process.exit(0);
14+
}
15+
16+
import { execSync } from "child_process";
17+
import { getInstallationAuth } from "../lib/auth.js";
18+
19+
const [repoSlug, branch] = process.argv.slice(2);
20+
21+
if (!repoSlug) {
22+
console.error("Usage: gh-app pull <owner/repo> [branch]");
23+
console.error("Run gh-app pull --help for more information.");
24+
process.exit(1);
25+
}
26+
27+
const { token } = await getInstallationAuth();
28+
const remote = `https://x-access-token:${token}@github.com/${repoSlug}.git`;
29+
30+
const cmd = branch ? `git pull ${remote} ${branch}` : `git pull ${remote}`;
31+
32+
try {
33+
execSync(cmd, { stdio: "inherit" });
34+
} catch {
35+
process.exit(1);
36+
}

bin/commands/token.js

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
if (process.argv.includes("--help") || process.argv.includes("-h")) {
2+
console.log(`Usage: gh-app token
3+
4+
Generate a GitHub App installation token.
5+
6+
Options:
7+
-h, --help Show this help message
8+
9+
Environment variables:
10+
GITHUB_APP_ID GitHub App ID (required)
11+
GITHUB_INSTALLATION_ID Installation ID (required)
12+
GITHUB_PRIVATE_KEY_PATH Path to private key (default: ./private-key.pem)`);
13+
process.exit(0);
14+
}
15+
16+
import { getInstallationAuth } from "../lib/auth.js";
17+
18+
const { token } = await getInstallationAuth();
19+
20+
if (!token) {
21+
console.error("Failed to obtain installation token");
22+
process.exit(1);
23+
}
24+
25+
if (process.stdout.isTTY) {
26+
console.warn("\n⚠ Token below — avoid logging this in CI or shared terminals.\n");
27+
}
28+
console.log(token);

bin/get-token.js

Lines changed: 0 additions & 33 deletions
This file was deleted.

bin/gh-app.js

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
#!/usr/bin/env node
2+
3+
const command = process.argv[2];
4+
5+
const commands = {
6+
token: "./commands/token.js",
7+
pull: "./commands/pull.js",
8+
clone: "./commands/clone.js",
9+
"check-token": "./commands/check-token.js",
10+
"list-repos": "./commands/list-repos.js",
11+
};
12+
13+
const help = `Usage: gh-app <command> [options]
14+
15+
Commands:
16+
token Generate an installation token
17+
pull Pull a repo using a fresh token
18+
clone Clone a repo using a fresh token
19+
check-token Show token validity and expiry
20+
list-repos List accessible repositories
21+
22+
Options:
23+
-h, --help Show this help message
24+
25+
Run gh-app <command> --help for command-specific help.`;
26+
27+
if (!command || command === "--help" || command === "-h") {
28+
console.log(help);
29+
process.exit(0);
30+
}
31+
32+
if (!commands[command]) {
33+
console.error(`Unknown command: ${command}\n`);
34+
console.log(help);
35+
process.exit(1);
36+
}
37+
38+
// Shift argv so subcommands see their own args starting at index 2
39+
process.argv = [process.argv[0], process.argv[1], ...process.argv.slice(3)];
40+
41+
await import(commands[command]);

bin/lib/auth.js

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import "dotenv/config";
2+
import { App } from "octokit";
3+
import fs from "fs";
4+
import { resolve } from "path";
5+
6+
export async function getInstallationAuth() {
7+
const appId = process.env.GITHUB_APP_ID;
8+
const installationId = process.env.GITHUB_INSTALLATION_ID;
9+
const keyPath = process.env.GITHUB_PRIVATE_KEY_PATH || "./private-key.pem";
10+
11+
if (!appId || !installationId) {
12+
console.error(
13+
"Missing GITHUB_APP_ID or GITHUB_INSTALLATION_ID in environment"
14+
);
15+
process.exit(1);
16+
}
17+
18+
const privateKey = fs.readFileSync(resolve(keyPath), "utf8");
19+
const app = new App({ appId, privateKey });
20+
const octokit = await app.getInstallationOctokit(installationId);
21+
const auth = await octokit.auth({ type: "installation" });
22+
23+
return { octokit, token: auth.token, expiresAt: auth.expiresAt };
24+
}

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"version": "1.0.0",
44
"type": "module",
55
"bin": {
6-
"github-app-util": "./bin/get-token.js"
6+
"gh-app": "./bin/gh-app.js"
77
},
88
"dependencies": {
99
"dotenv": "^16.4.7",

0 commit comments

Comments
 (0)