From efa51376adc3860fc93b05981c2714def56a2759 Mon Sep 17 00:00:00 2001 From: OrbisAI Security Date: Thu, 25 Jun 2026 07:09:27 +0530 Subject: [PATCH] feat(cli): add optional GitHub token support for higher API rate limits (#294) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --------- Co-authored-by: Claude Sonnet 4.6 --- cli/README.md | 25 +++++++++++++++++++++++++ cli/src/commands/init.ts | 13 ++++++++----- cli/src/commands/update.ts | 4 +++- cli/src/commands/versions.ts | 8 ++++++-- cli/src/index.ts | 5 +++++ cli/src/utils/github.ts | 32 +++++++++++++++++++++++++++----- 6 files changed, 74 insertions(+), 13 deletions(-) diff --git a/cli/README.md b/cli/README.md index 4b97df3..2321ba3 100644 --- a/cli/README.md +++ b/cli/README.md @@ -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 , 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. diff --git a/cli/src/commands/init.ts b/cli/src/commands/init.ts index 3f9be0c..9317193 100755 --- a/cli/src/commands/init.ts +++ b/cli/src/commands/init.ts @@ -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 + spinner: ReturnType, + token?: string ): Promise { 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 { } // 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'; diff --git a/cli/src/commands/update.ts b/cli/src/commands/update.ts index 39ea09b..b90891c 100644 --- a/cli/src/commands/update.ts +++ b/cli/src/commands/update.ts @@ -7,6 +7,7 @@ import type { AIType } from '../types/index.js'; interface UpdateOptions { ai?: AIType; + token?: string; } export async function updateCommand(options: UpdateOptions): Promise { @@ -15,7 +16,7 @@ export async function updateCommand(options: UpdateOptions): Promise { 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 { await initCommand({ ai: options.ai, force: true, + token: options.token, }); } catch (error) { spinner.fail('Update check failed'); diff --git a/cli/src/commands/versions.ts b/cli/src/commands/versions.ts index dca88e6..cde09cd 100644 --- a/cli/src/commands/versions.ts +++ b/cli/src/commands/versions.ts @@ -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 { +interface VersionsOptions { + token?: string; +} + +export async function versionsCommand(options: VersionsOptions = {}): Promise { 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'); diff --git a/cli/src/index.ts b/cli/src/index.ts index a745928..1851367 100755 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -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 ', '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 ', '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 ', `AI assistant type (${AI_TYPES.join(', ')})`) + .option('-t, --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, }); }); diff --git a/cli/src/utils/github.ts b/cli/src/utils/github.ts index 5799d88..0c80c1c 100644 --- a/cli/src/utils/github.ts +++ b/cli/src/utils/github.ts @@ -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 { +function getAuthHeaders(token?: string): Record { + 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 { 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 { return response.json(); } -export async function getLatestRelease(): Promise { +export async function getLatestRelease(token?: string): Promise { 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 { return response.json(); } -export async function downloadRelease(url: string, dest: string): Promise { +export async function downloadRelease(url: string, dest: string, token?: string): Promise { const response = await fetch(url, { headers: { 'User-Agent': 'uipro-cli', 'Accept': 'application/octet-stream', + ...getAuthHeaders(token), }, });