feat(cli): add optional GitHub token support for higher API rate limits (#294)

* fix: V-001 security vulnerability

Automated security fix generated by Orbis Security AI

* feat(cli): add optional GitHub token support with proper UX and docs

- Rename env var from GITHUB_TOKEN to UI_PRO_MAX_GITHUB_TOKEN (primary),
  with GITHUB_TOKEN as fallback — avoids silently attaching CI workflow
  credentials that GitHub Actions injects automatically
- Add whitespace trim guard to prevent malformed Authorization headers
- Add getGitHubTokenGuidance() helper exported from github.ts so rate-limit
  errors and spinner warnings tell users exactly how to fix the problem
- Thread optional token param through fetchReleases, getLatestRelease,
  downloadRelease signatures
- Add --token flag to init, versions, update commands (Commander.js wiring)
- Update rate-limit catch in tryGitHubInstall to show token guidance
- Document token options (flag, env var, fallback) in cli/README.md with
  CI warning about GITHUB_TOKEN scope

Supersedes the narrower env-only approach in PR #294 and incorporates the
safeguards requested during review of closed PR #186.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
OrbisAI Security 2026-06-25 07:09:27 +05:30 committed by GitHub
parent bdf1179bcf
commit efa51376ad
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 74 additions and 13 deletions

View File

@ -36,6 +36,31 @@ uipro versions # List available versions
uipro update # Update to latest version
```
## GitHub Authentication
GitHub's unauthenticated API allows 60 requests/hour per IP. If you hit rate limits, you can provide a GitHub Personal Access Token (PAT) to raise the limit to 5,000 requests/hour.
**Options (in order of precedence):**
```bash
# 1. Pass directly as a flag (one-off use)
uipro init --token ghp_yourtoken
uipro versions --token ghp_yourtoken
uipro update --token ghp_yourtoken
# 2. Set as a project-scoped environment variable (recommended)
export UI_PRO_MAX_GITHUB_TOKEN=ghp_yourtoken
uipro init
# 3. Fallback: GITHUB_TOKEN is also read if UI_PRO_MAX_GITHUB_TOKEN is not set
export GITHUB_TOKEN=ghp_yourtoken
uipro init
```
**Creating a token:** Go to <https://github.com/settings/tokens>, click **Generate new token (classic)**, and select **no scopes** — public repo access requires no permissions. Copy the token and store it as an environment secret; never hardcode it in source files.
> **Warning:** `GITHUB_TOKEN` is automatically injected by GitHub Actions with broad repo permissions. Prefer `UI_PRO_MAX_GITHUB_TOKEN` in CI to avoid accidentally attaching workflow credentials to release download requests.
## How It Works
By default, `uipro init` tries to download the latest release from GitHub to ensure you get the most up-to-date version. If the download fails (network error, rate limit), it automatically falls back to the bundled assets included in the CLI package.

View File

@ -13,6 +13,7 @@ import {
getLatestRelease,
getAssetUrl,
downloadRelease,
getGitHubTokenGuidance,
GitHubRateLimitError,
GitHubDownloadError,
} from '../utils/github.js';
@ -27,6 +28,7 @@ interface InitOptions {
offline?: boolean;
legacy?: boolean; // Use old ZIP-based install
global?: boolean; // Install to home directory (global mode)
token?: string; // GitHub PAT for higher API rate limits
}
/**
@ -36,13 +38,14 @@ interface InitOptions {
async function tryGitHubInstall(
targetDir: string,
aiType: AIType,
spinner: ReturnType<typeof ora>
spinner: ReturnType<typeof ora>,
token?: string
): Promise<string[] | null> {
let tempDir: string | null = null;
try {
spinner.text = 'Fetching latest release from GitHub...';
const release = await getLatestRelease();
const release = await getLatestRelease(token);
const assetUrl = getAssetUrl(release);
if (!assetUrl) {
@ -53,7 +56,7 @@ async function tryGitHubInstall(
tempDir = await createTempDir();
const zipPath = join(tempDir, 'release.zip');
await downloadRelease(assetUrl, zipPath);
await downloadRelease(assetUrl, zipPath, token);
spinner.text = 'Extracting and installing files...';
const { copiedFolders, tempDir: extractedTempDir } = await installFromZip(
@ -73,7 +76,7 @@ async function tryGitHubInstall(
}
if (error instanceof GitHubRateLimitError) {
spinner.warn('GitHub rate limit reached, using template generation...');
spinner.warn(`GitHub rate limit reached, falling back to bundled assets.\n${getGitHubTokenGuidance()}`);
return null;
}
@ -164,7 +167,7 @@ export async function initCommand(options: InitOptions): Promise<void> {
}
// Try GitHub download first (unless offline mode)
if (!options.offline) {
const githubResult = await tryGitHubInstall(cwd, aiType, spinner);
const githubResult = await tryGitHubInstall(cwd, aiType, spinner, options.token);
if (githubResult) {
copiedFolders = githubResult;
installMethod = 'github';

View File

@ -7,6 +7,7 @@ import type { AIType } from '../types/index.js';
interface UpdateOptions {
ai?: AIType;
token?: string;
}
export async function updateCommand(options: UpdateOptions): Promise<void> {
@ -15,7 +16,7 @@ export async function updateCommand(options: UpdateOptions): Promise<void> {
const spinner = ora('Checking for updates...').start();
try {
const release = await getLatestRelease();
const release = await getLatestRelease(options.token);
spinner.succeed(`Latest version: ${chalk.cyan(release.tag_name)}`);
console.log();
@ -25,6 +26,7 @@ export async function updateCommand(options: UpdateOptions): Promise<void> {
await initCommand({
ai: options.ai,
force: true,
token: options.token,
});
} catch (error) {
spinner.fail('Update check failed');

View File

@ -3,11 +3,15 @@ import ora from 'ora';
import { fetchReleases } from '../utils/github.js';
import { logger } from '../utils/logger.js';
export async function versionsCommand(): Promise<void> {
interface VersionsOptions {
token?: string;
}
export async function versionsCommand(options: VersionsOptions = {}): Promise<void> {
const spinner = ora('Fetching available versions...').start();
try {
const releases = await fetchReleases();
const releases = await fetchReleases(options.token);
if (releases.length === 0) {
spinner.warn('No releases found');

View File

@ -29,6 +29,7 @@ program
.option('-f, --force', 'Overwrite existing files')
.option('-o, --offline', 'Skip GitHub download, use bundled assets only')
.option('-g, --global', 'Install globally to home directory (~/) instead of current project')
.option('-t, --token <token>', 'GitHub Personal Access Token for higher API rate limits')
.action(async (options) => {
if (options.ai && !AI_TYPES.includes(options.ai)) {
console.error(`Invalid AI type: ${options.ai}`);
@ -40,18 +41,21 @@ program
force: options.force,
offline: options.offline,
global: options.global,
token: options.token,
});
});
program
.command('versions')
.description('List available versions')
.option('-t, --token <token>', 'GitHub Personal Access Token for higher API rate limits')
.action(versionsCommand);
program
.command('update')
.description('Update UI/UX Pro Max to latest version')
.option('-a, --ai <type>', `AI assistant type (${AI_TYPES.join(', ')})`)
.option('-t, --token <token>', 'GitHub Personal Access Token for higher API rate limits')
.action(async (options) => {
if (options.ai && !AI_TYPES.includes(options.ai)) {
console.error(`Invalid AI type: ${options.ai}`);
@ -60,6 +64,7 @@ program
}
await updateCommand({
ai: options.ai as AIType | undefined,
token: options.token,
});
});

View File

@ -19,25 +19,45 @@ export class GitHubDownloadError extends Error {
}
}
export function getGitHubTokenGuidance(): string {
return (
'To increase your GitHub API rate limit, set the UI_PRO_MAX_GITHUB_TOKEN environment variable\n' +
'to a GitHub Personal Access Token (no scopes needed for public repos).\n' +
'Create one at: https://github.com/settings/tokens\n' +
'Example: UI_PRO_MAX_GITHUB_TOKEN=ghp_xxx uipro init\n' +
'Or pass it directly: uipro init --token ghp_xxx'
);
}
function checkRateLimit(response: Response): void {
const remaining = response.headers.get('x-ratelimit-remaining');
if (response.status === 403 && remaining === '0') {
const resetTime = response.headers.get('x-ratelimit-reset');
const resetDate = resetTime ? new Date(parseInt(resetTime) * 1000).toLocaleTimeString() : 'unknown';
throw new GitHubRateLimitError(`GitHub API rate limit exceeded. Resets at ${resetDate}`);
throw new GitHubRateLimitError(
`GitHub API rate limit exceeded. Resets at ${resetDate}.\n${getGitHubTokenGuidance()}`
);
}
if (response.status === 429) {
throw new GitHubRateLimitError('GitHub API rate limit exceeded (429 Too Many Requests)');
throw new GitHubRateLimitError(
`GitHub API rate limit exceeded (429 Too Many Requests).\n${getGitHubTokenGuidance()}`
);
}
}
export async function fetchReleases(): Promise<Release[]> {
function getAuthHeaders(token?: string): Record<string, string> {
const resolved = (token || process.env['UI_PRO_MAX_GITHUB_TOKEN'] || process.env['GITHUB_TOKEN'])?.trim();
return resolved ? { 'Authorization': `Bearer ${resolved}` } : {};
}
export async function fetchReleases(token?: string): Promise<Release[]> {
const url = `${API_BASE}/repos/${REPO_OWNER}/${REPO_NAME}/releases`;
const response = await fetch(url, {
headers: {
'Accept': 'application/vnd.github.v3+json',
'User-Agent': 'uipro-cli',
...getAuthHeaders(token),
},
});
@ -50,13 +70,14 @@ export async function fetchReleases(): Promise<Release[]> {
return response.json();
}
export async function getLatestRelease(): Promise<Release> {
export async function getLatestRelease(token?: string): Promise<Release> {
const url = `${API_BASE}/repos/${REPO_OWNER}/${REPO_NAME}/releases/latest`;
const response = await fetch(url, {
headers: {
'Accept': 'application/vnd.github.v3+json',
'User-Agent': 'uipro-cli',
...getAuthHeaders(token),
},
});
@ -69,11 +90,12 @@ export async function getLatestRelease(): Promise<Release> {
return response.json();
}
export async function downloadRelease(url: string, dest: string): Promise<void> {
export async function downloadRelease(url: string, dest: string, token?: string): Promise<void> {
const response = await fetch(url, {
headers: {
'User-Agent': 'uipro-cli',
'Accept': 'application/octet-stream',
...getAuthHeaders(token),
},
});