* 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>
127 lines
3.9 KiB
TypeScript
127 lines
3.9 KiB
TypeScript
import { writeFile } from 'node:fs/promises';
|
|
import type { Release } from '../types/index.js';
|
|
|
|
const REPO_OWNER = 'nextlevelbuilder';
|
|
const REPO_NAME = 'ui-ux-pro-max-skill';
|
|
const API_BASE = 'https://api.github.com';
|
|
|
|
export class GitHubRateLimitError extends Error {
|
|
constructor(message: string) {
|
|
super(message);
|
|
this.name = 'GitHubRateLimitError';
|
|
}
|
|
}
|
|
|
|
export class GitHubDownloadError extends Error {
|
|
constructor(message: string) {
|
|
super(message);
|
|
this.name = 'GitHubDownloadError';
|
|
}
|
|
}
|
|
|
|
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}.\n${getGitHubTokenGuidance()}`
|
|
);
|
|
}
|
|
if (response.status === 429) {
|
|
throw new GitHubRateLimitError(
|
|
`GitHub API rate limit exceeded (429 Too Many Requests).\n${getGitHubTokenGuidance()}`
|
|
);
|
|
}
|
|
}
|
|
|
|
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),
|
|
},
|
|
});
|
|
|
|
checkRateLimit(response);
|
|
|
|
if (!response.ok) {
|
|
throw new GitHubDownloadError(`Failed to fetch releases: ${response.status} ${response.statusText}`);
|
|
}
|
|
|
|
return response.json();
|
|
}
|
|
|
|
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),
|
|
},
|
|
});
|
|
|
|
checkRateLimit(response);
|
|
|
|
if (!response.ok) {
|
|
throw new GitHubDownloadError(`Failed to fetch latest release: ${response.status} ${response.statusText}`);
|
|
}
|
|
|
|
return response.json();
|
|
}
|
|
|
|
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),
|
|
},
|
|
});
|
|
|
|
checkRateLimit(response);
|
|
|
|
if (!response.ok) {
|
|
throw new GitHubDownloadError(`Failed to download: ${response.status} ${response.statusText}`);
|
|
}
|
|
|
|
const buffer = await response.arrayBuffer();
|
|
await writeFile(dest, Buffer.from(buffer));
|
|
}
|
|
|
|
export function getAssetUrl(release: Release): string | null {
|
|
// First try to find an uploaded ZIP asset
|
|
const asset = release.assets.find(a => a.name.endsWith('.zip'));
|
|
if (asset?.browser_download_url) {
|
|
return asset.browser_download_url;
|
|
}
|
|
|
|
// Fall back to GitHub's auto-generated archive
|
|
// Format: https://github.com/{owner}/{repo}/archive/refs/tags/{tag}.zip
|
|
if (release.tag_name) {
|
|
return `https://github.com/${REPO_OWNER}/${REPO_NAME}/archive/refs/tags/${release.tag_name}.zip`;
|
|
}
|
|
|
|
return null;
|
|
}
|