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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user