feat(cli): prioritize GitHub release download with fallback to bundled assets (#81)

- Add GitHubRateLimitError and GitHubDownloadError for better error handling
- Detect rate limits (403 with remaining=0, 429)
- Try downloading from GitHub releases first
- Fall back to bundled assets on network error, rate limit, or download failure
- Add --offline flag to skip GitHub download
- Use GitHub auto-generated archive URL as fallback when no ZIP asset exists
- Update CLI to v1.9.0

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Viet Tran
2026-01-16 08:02:35 +07:00
committed by GitHub
parent 9a9704125b
commit 408df90766
7 changed files with 216 additions and 13 deletions

View File

@@ -1,7 +1,8 @@
import { mkdir, rm, access, cp } from 'node:fs/promises';
import { mkdir, rm, access, cp, mkdtemp, readdir } from 'node:fs/promises';
import { join, basename } from 'node:path';
import { exec } from 'node:child_process';
import { promisify } from 'node:util';
import { tmpdir } from 'node:os';
import type { AIType } from '../types/index.js';
import { AI_FOLDERS } from '../types/index.js';
@@ -93,3 +94,56 @@ export async function cleanup(tempDir: string): Promise<void> {
// Ignore cleanup errors
}
}
/**
* Create a temporary directory for extracting ZIP files
*/
export async function createTempDir(): Promise<string> {
return mkdtemp(join(tmpdir(), 'uipro-'));
}
/**
* Find the extracted folder inside temp directory
* GitHub release ZIPs often contain a single root folder
*/
async function findExtractedRoot(tempDir: string): Promise<string> {
const entries = await readdir(tempDir, { withFileTypes: true });
const dirs = entries.filter(e => e.isDirectory());
// If there's exactly one directory, it's likely the extracted root
if (dirs.length === 1) {
return join(tempDir, dirs[0].name);
}
// Otherwise, assume tempDir itself is the root
return tempDir;
}
/**
* Install from a downloaded and extracted ZIP file
*/
export async function installFromZip(
zipPath: string,
targetDir: string,
aiType: AIType
): Promise<{ copiedFolders: string[]; tempDir: string }> {
// Create temp directory
const tempDir = await createTempDir();
try {
// Extract ZIP to temp directory
await extractZip(zipPath, tempDir);
// Find the actual root of the extracted content
const extractedRoot = await findExtractedRoot(tempDir);
// Copy folders from extracted content to target
const copiedFolders = await copyFolders(extractedRoot, targetDir, aiType);
return { copiedFolders, tempDir };
} catch (error) {
// Cleanup on error
await cleanup(tempDir);
throw error;
}
}

View File

@@ -5,6 +5,32 @@ 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';
}
}
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}`);
}
if (response.status === 429) {
throw new GitHubRateLimitError('GitHub API rate limit exceeded (429 Too Many Requests)');
}
}
export async function fetchReleases(): Promise<Release[]> {
const url = `${API_BASE}/repos/${REPO_OWNER}/${REPO_NAME}/releases`;
@@ -15,8 +41,10 @@ export async function fetchReleases(): Promise<Release[]> {
},
});
checkRateLimit(response);
if (!response.ok) {
throw new Error(`Failed to fetch releases: ${response.statusText}`);
throw new GitHubDownloadError(`Failed to fetch releases: ${response.status} ${response.statusText}`);
}
return response.json();
@@ -32,8 +60,10 @@ export async function getLatestRelease(): Promise<Release> {
},
});
checkRateLimit(response);
if (!response.ok) {
throw new Error(`Failed to fetch latest release: ${response.statusText}`);
throw new GitHubDownloadError(`Failed to fetch latest release: ${response.status} ${response.statusText}`);
}
return response.json();
@@ -43,11 +73,14 @@ export async function downloadRelease(url: string, dest: string): Promise<void>
const response = await fetch(url, {
headers: {
'User-Agent': 'uipro-cli',
'Accept': 'application/octet-stream',
},
});
checkRateLimit(response);
if (!response.ok) {
throw new Error(`Failed to download: ${response.statusText}`);
throw new GitHubDownloadError(`Failed to download: ${response.status} ${response.statusText}`);
}
const buffer = await response.arrayBuffer();
@@ -55,6 +88,17 @@ export async function downloadRelease(url: string, dest: string): Promise<void>
}
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'));
return asset?.browser_download_url ?? null;
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;
}