mirror of
https://github.com/nextlevelbuilder/ui-ux-pro-max-skill.git
synced 2026-07-26 07:58:10 +00:00
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:
parent
bdf1179bcf
commit
efa51376ad
@ -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.
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -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),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user