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

@@ -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();