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:
@@ -5,9 +5,16 @@ import ora from 'ora';
|
||||
import prompts from 'prompts';
|
||||
import type { AIType } 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 { logger } from '../utils/logger.js';
|
||||
import {
|
||||
getLatestRelease,
|
||||
getAssetUrl,
|
||||
downloadRelease,
|
||||
GitHubRateLimitError,
|
||||
GitHubDownloadError,
|
||||
} from '../utils/github.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
// From dist/index.js -> ../assets (one level up to cli/, then assets/)
|
||||
@@ -16,6 +23,72 @@ const ASSETS_DIR = join(__dirname, '..', 'assets');
|
||||
interface InitOptions {
|
||||
ai?: AIType;
|
||||
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> {
|
||||
@@ -53,12 +126,27 @@ export async function initCommand(options: InitOptions): Promise<void> {
|
||||
logger.info(`Installing for: ${chalk.cyan(getAITypeDescription(aiType))}`);
|
||||
|
||||
const spinner = ora('Installing files...').start();
|
||||
const cwd = process.cwd();
|
||||
let copiedFolders: string[] = [];
|
||||
let usedGitHub = false;
|
||||
|
||||
try {
|
||||
const cwd = process.cwd();
|
||||
const copiedFolders = await copyFolders(ASSETS_DIR, cwd, aiType);
|
||||
// Try GitHub download first (unless offline mode)
|
||||
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
|
||||
console.log();
|
||||
|
||||
Reference in New Issue
Block a user