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:
@@ -49,7 +49,7 @@ uipro init --ai all # All assistants
|
|||||||
```bash
|
```bash
|
||||||
uipro versions # List available versions
|
uipro versions # List available versions
|
||||||
uipro update # Update to latest version
|
uipro update # Update to latest version
|
||||||
uipro init --version v1.0.0 # Install specific version
|
uipro init --offline # Skip GitHub download, use bundled assets
|
||||||
```
|
```
|
||||||
|
|
||||||
### Manual Installation
|
### Manual Installation
|
||||||
|
|||||||
@@ -16,15 +16,30 @@ uipro init --ai claude # Claude Code
|
|||||||
uipro init --ai cursor # Cursor
|
uipro init --ai cursor # Cursor
|
||||||
uipro init --ai windsurf # Windsurf
|
uipro init --ai windsurf # Windsurf
|
||||||
uipro init --ai antigravity # Antigravity
|
uipro init --ai antigravity # Antigravity
|
||||||
|
uipro init --ai copilot # GitHub Copilot
|
||||||
|
uipro init --ai kiro # Kiro
|
||||||
uipro init --ai codex # Codex (Skills)
|
uipro init --ai codex # Codex (Skills)
|
||||||
|
uipro init --ai roocode # Roo Code
|
||||||
|
uipro init --ai qoder # Qoder
|
||||||
|
uipro init --ai gemini # Gemini CLI
|
||||||
|
uipro init --ai trae # Trae
|
||||||
uipro init --ai all # All assistants
|
uipro init --ai all # All assistants
|
||||||
|
|
||||||
|
# Options
|
||||||
|
uipro init --offline # Skip GitHub download, use bundled assets only
|
||||||
|
uipro init --force # Overwrite existing files
|
||||||
|
|
||||||
# Other commands
|
# Other commands
|
||||||
uipro versions # List available versions
|
uipro versions # List available versions
|
||||||
uipro update # Update to latest version
|
uipro update # Update to latest version
|
||||||
uipro init --version v1.0.0 # Install specific version
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
Use `--offline` to skip the GitHub download and use bundled assets directly.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "uipro-cli",
|
"name": "uipro-cli",
|
||||||
"version": "1.8.0",
|
"version": "1.9.0",
|
||||||
"description": "CLI to install UI/UX Pro Max skill for AI coding assistants",
|
"description": "CLI to install UI/UX Pro Max skill for AI coding assistants",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|||||||
@@ -5,9 +5,16 @@ import ora from 'ora';
|
|||||||
import prompts from 'prompts';
|
import prompts from 'prompts';
|
||||||
import type { AIType } from '../types/index.js';
|
import type { AIType } from '../types/index.js';
|
||||||
import { AI_TYPES } from '../types/index.js';
|
import { AI_TYPES } from '../types/index.js';
|
||||||
import { copyFolders } from '../utils/extract.js';
|
import { copyFolders, installFromZip, createTempDir, cleanup } from '../utils/extract.js';
|
||||||
import { detectAIType, getAITypeDescription } from '../utils/detect.js';
|
import { detectAIType, getAITypeDescription } from '../utils/detect.js';
|
||||||
import { logger } from '../utils/logger.js';
|
import { logger } from '../utils/logger.js';
|
||||||
|
import {
|
||||||
|
getLatestRelease,
|
||||||
|
getAssetUrl,
|
||||||
|
downloadRelease,
|
||||||
|
GitHubRateLimitError,
|
||||||
|
GitHubDownloadError,
|
||||||
|
} from '../utils/github.js';
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
// From dist/index.js -> ../assets (one level up to cli/, then assets/)
|
// From dist/index.js -> ../assets (one level up to cli/, then assets/)
|
||||||
@@ -16,6 +23,72 @@ const ASSETS_DIR = join(__dirname, '..', 'assets');
|
|||||||
interface InitOptions {
|
interface InitOptions {
|
||||||
ai?: AIType;
|
ai?: AIType;
|
||||||
force?: boolean;
|
force?: boolean;
|
||||||
|
offline?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try to install from GitHub release
|
||||||
|
* Returns the copied folders if successful, null if failed
|
||||||
|
*/
|
||||||
|
async function tryGitHubInstall(
|
||||||
|
targetDir: string,
|
||||||
|
aiType: AIType,
|
||||||
|
spinner: ReturnType<typeof ora>
|
||||||
|
): Promise<string[] | null> {
|
||||||
|
let tempDir: string | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
spinner.text = 'Fetching latest release from GitHub...';
|
||||||
|
const release = await getLatestRelease();
|
||||||
|
const assetUrl = getAssetUrl(release);
|
||||||
|
|
||||||
|
if (!assetUrl) {
|
||||||
|
throw new GitHubDownloadError('No ZIP asset found in latest release');
|
||||||
|
}
|
||||||
|
|
||||||
|
spinner.text = `Downloading ${release.tag_name}...`;
|
||||||
|
tempDir = await createTempDir();
|
||||||
|
const zipPath = join(tempDir, 'release.zip');
|
||||||
|
|
||||||
|
await downloadRelease(assetUrl, zipPath);
|
||||||
|
|
||||||
|
spinner.text = 'Extracting and installing files...';
|
||||||
|
const { copiedFolders, tempDir: extractedTempDir } = await installFromZip(
|
||||||
|
zipPath,
|
||||||
|
targetDir,
|
||||||
|
aiType
|
||||||
|
);
|
||||||
|
|
||||||
|
// Cleanup temp directory
|
||||||
|
await cleanup(extractedTempDir);
|
||||||
|
|
||||||
|
return copiedFolders;
|
||||||
|
} catch (error) {
|
||||||
|
// Cleanup temp directory on error
|
||||||
|
if (tempDir) {
|
||||||
|
await cleanup(tempDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof GitHubRateLimitError) {
|
||||||
|
spinner.warn('GitHub rate limit reached, using bundled assets...');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof GitHubDownloadError) {
|
||||||
|
spinner.warn('GitHub download failed, using bundled assets...');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Network errors or other fetch failures
|
||||||
|
if (error instanceof TypeError && error.message.includes('fetch')) {
|
||||||
|
spinner.warn('Network error, using bundled assets...');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unknown errors - still fall back to bundled assets
|
||||||
|
spinner.warn('Download failed, using bundled assets...');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function initCommand(options: InitOptions): Promise<void> {
|
export async function initCommand(options: InitOptions): Promise<void> {
|
||||||
@@ -53,12 +126,27 @@ export async function initCommand(options: InitOptions): Promise<void> {
|
|||||||
logger.info(`Installing for: ${chalk.cyan(getAITypeDescription(aiType))}`);
|
logger.info(`Installing for: ${chalk.cyan(getAITypeDescription(aiType))}`);
|
||||||
|
|
||||||
const spinner = ora('Installing files...').start();
|
const spinner = ora('Installing files...').start();
|
||||||
|
const cwd = process.cwd();
|
||||||
|
let copiedFolders: string[] = [];
|
||||||
|
let usedGitHub = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const cwd = process.cwd();
|
// Try GitHub download first (unless offline mode)
|
||||||
const copiedFolders = await copyFolders(ASSETS_DIR, cwd, aiType);
|
if (!options.offline) {
|
||||||
|
const githubResult = await tryGitHubInstall(cwd, aiType, spinner);
|
||||||
|
if (githubResult) {
|
||||||
|
copiedFolders = githubResult;
|
||||||
|
usedGitHub = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
spinner.succeed('Installation complete!');
|
// Fall back to bundled assets if GitHub failed or offline mode
|
||||||
|
if (!usedGitHub) {
|
||||||
|
spinner.text = 'Installing from bundled assets...';
|
||||||
|
copiedFolders = await copyFolders(ASSETS_DIR, cwd, aiType);
|
||||||
|
}
|
||||||
|
|
||||||
|
spinner.succeed(usedGitHub ? 'Installed from GitHub release!' : 'Installed from bundled assets!');
|
||||||
|
|
||||||
// Summary
|
// Summary
|
||||||
console.log();
|
console.log();
|
||||||
|
|||||||
@@ -12,13 +12,14 @@ const program = new Command();
|
|||||||
program
|
program
|
||||||
.name('uipro')
|
.name('uipro')
|
||||||
.description('CLI to install UI/UX Pro Max skill for AI coding assistants')
|
.description('CLI to install UI/UX Pro Max skill for AI coding assistants')
|
||||||
.version('1.5.0');
|
.version('1.9.0');
|
||||||
|
|
||||||
program
|
program
|
||||||
.command('init')
|
.command('init')
|
||||||
.description('Install UI/UX Pro Max skill to current project')
|
.description('Install UI/UX Pro Max skill to current project')
|
||||||
.option('-a, --ai <type>', `AI assistant type (${AI_TYPES.join(', ')})`)
|
.option('-a, --ai <type>', `AI assistant type (${AI_TYPES.join(', ')})`)
|
||||||
.option('-f, --force', 'Overwrite existing files')
|
.option('-f, --force', 'Overwrite existing files')
|
||||||
|
.option('-o, --offline', 'Skip GitHub download, use bundled assets only')
|
||||||
.action(async (options) => {
|
.action(async (options) => {
|
||||||
if (options.ai && !AI_TYPES.includes(options.ai)) {
|
if (options.ai && !AI_TYPES.includes(options.ai)) {
|
||||||
console.error(`Invalid AI type: ${options.ai}`);
|
console.error(`Invalid AI type: ${options.ai}`);
|
||||||
@@ -28,6 +29,7 @@ program
|
|||||||
await initCommand({
|
await initCommand({
|
||||||
ai: options.ai as AIType | undefined,
|
ai: options.ai as AIType | undefined,
|
||||||
force: options.force,
|
force: options.force,
|
||||||
|
offline: options.offline,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 { join, basename } from 'node:path';
|
||||||
import { exec } from 'node:child_process';
|
import { exec } from 'node:child_process';
|
||||||
import { promisify } from 'node:util';
|
import { promisify } from 'node:util';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
import type { AIType } from '../types/index.js';
|
import type { AIType } from '../types/index.js';
|
||||||
import { AI_FOLDERS } 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
|
// 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 REPO_NAME = 'ui-ux-pro-max-skill';
|
||||||
const API_BASE = 'https://api.github.com';
|
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[]> {
|
export async function fetchReleases(): Promise<Release[]> {
|
||||||
const url = `${API_BASE}/repos/${REPO_OWNER}/${REPO_NAME}/releases`;
|
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) {
|
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();
|
return response.json();
|
||||||
@@ -32,8 +60,10 @@ export async function getLatestRelease(): Promise<Release> {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
checkRateLimit(response);
|
||||||
|
|
||||||
if (!response.ok) {
|
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();
|
return response.json();
|
||||||
@@ -43,11 +73,14 @@ export async function downloadRelease(url: string, dest: string): Promise<void>
|
|||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
headers: {
|
headers: {
|
||||||
'User-Agent': 'uipro-cli',
|
'User-Agent': 'uipro-cli',
|
||||||
|
'Accept': 'application/octet-stream',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
checkRateLimit(response);
|
||||||
|
|
||||||
if (!response.ok) {
|
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();
|
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 {
|
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'));
|
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