folder-downloader
Version:
Download specific folders from GitHub repositories without cloning the entire repository
252 lines (211 loc) • 7.23 kB
text/typescript
import fs from 'fs-extra';
import path from 'path';
import os from 'os';
import fetch from 'node-fetch';
import unzipper from 'unzipper';
import chalk from 'chalk';
import ora from 'ora';
import { readFileSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Optional injected version
declare const __APP_VERSION__: string;
function getVersion() {
return typeof __APP_VERSION__ !== 'undefined'
? __APP_VERSION__
: JSON.parse(readFileSync(path.join(__dirname, '../package.json'), 'utf8')).version;
}
function printHelp() {
console.log(`
${chalk.bold('GitHub Folder Downloader')}
Usage:
${chalk.cyan('github-folder-downloader')} <repo> <subfolder> [target-folder]
Examples:
${chalk.cyan('github-folder-downloader')} metallicjs/templates saas-lite
${chalk.cyan('github-folder-downloader')} metallicjs/templates#dev src/components ./my-folder
${chalk.cyan('github-folder-downloader')} https://github.com/user/repo/tree/main/src/lib ./lib-copy
Options:
--help Show help
--version Show version
`);
}
function parseGitHubUrl(input: string): {
owner: string;
repo: string;
branch?: string;
subfolder?: string;
} {
const shorthandRegex = /^([\w.-]+)\/([\w.-]+)(?:#(.+))?$/;
const fullRepoRegex = /github\.com\/(.+?)\/(.+?)(?:\.git)?(?:#(.+))?$/;
const treeUrlRegex = /^https:\/\/github\.com\/([^/]+)\/([^/]+)\/tree\/([^/]+)\/(.+)$/;
const treeMatch = input.match(treeUrlRegex);
if (treeMatch) {
const [, owner, repo, branch, subfolder] = treeMatch;
return { owner, repo, branch, subfolder };
}
const shorthandMatch = input.match(shorthandRegex);
if (shorthandMatch) {
return {
owner: shorthandMatch[1],
repo: shorthandMatch[2],
branch: shorthandMatch[3]
};
}
const fullRepoMatch = input.match(fullRepoRegex);
if (fullRepoMatch) {
return {
owner: fullRepoMatch[1],
repo: fullRepoMatch[2],
branch: fullRepoMatch[3]
};
}
throw new Error('Invalid GitHub repo format. Use owner/repo[#branch] or full GitHub folder URL.');
}
async function getDefaultBranch(owner: string, repo: string): Promise<string> {
const res = await fetch(`https://api.github.com/repos/${owner}/${repo}`);
if (!res.ok) throw new Error(`Failed to fetch repo metadata: ${res.statusText}`);
const data = await res.json() as { default_branch: string };
return data.default_branch;
}
async function downloadFolder({
repoUrl,
subfolder,
target
}: {
repoUrl: string;
subfolder: string;
target?: string;
}) {
const spinner = ora('Preparing download...').start();
let tempDir: string | null = null;
try {
const { owner, repo, branch } = parseGitHubUrl(repoUrl);
const finalBranch = branch || await getDefaultBranch(owner, repo);
const zipUrl = `https://github.com/${owner}/${repo}/archive/refs/heads/${finalBranch}.zip`;
tempDir = path.join(os.tmpdir(), `gh-folder-${Date.now()}`);
const prefix = `${repo}-${finalBranch}/`;
spinner.text = `Fetching ZIP from ${chalk.cyan(branch ? `${branch}` : `default branch`)}`;
const res = await fetch(zipUrl);
if (!res.ok) throw new Error(`Failed to download ZIP: ${res.statusText}`);
if (!res.body) throw new Error("Error fetching data");
const defaultDirName = path.basename(subfolder.replace(/\/$/, '')) || 'downloaded-folder';
const outputDir = path.resolve(process.cwd(), target || defaultDirName);
try {
const stats = await fs.stat(outputDir);
if (stats.isFile()) {
spinner.fail(`Target ${chalk.red(outputDir)} exists as a file. Please remove it or choose another name.`);
process.exitCode=1;
return;
}
const files = await fs.readdir(outputDir);
if (files.length > 0) {
spinner.fail(`Directory ${chalk.red(outputDir)} already exists and is not empty.`);
process.exitCode=1;
return;
}
} catch (err: any) {
if (err.code !== 'ENOENT') {
spinner.fail(`Error checking target path: ${err.message}`);
process.exitCode=1;
return;
}
}
await fs.ensureDir(tempDir);
await fs.ensureDir(outputDir);
const stream = res.body.pipe(unzipper.Parse({ forceStream: true }));
let filesExtracted = 0;
let matchedSomething = false;
const normalizedSubfolder = subfolder.replace(/\/$/, '');
for await (const entry of stream) {
try {
const filePath = entry.path;
const relativePath = filePath.replace(prefix, '');
if (
relativePath === normalizedSubfolder ||
relativePath.startsWith(normalizedSubfolder + '/')
) {
matchedSomething = true;
const localPath = path.join(outputDir, relativePath.replace(normalizedSubfolder + '/', ''));
if (entry.type === 'Directory') {
await fs.ensureDir(localPath);
} else {
await fs.ensureDir(path.dirname(localPath));
await new Promise((resolve, reject) => {
entry.pipe(fs.createWriteStream(localPath))
.on('finish', resolve)
.on('error', reject);
});
filesExtracted++;
spinner.text = `Extracted ${filesExtracted} files...`;
}
} else {
entry.autodrain();
}
} catch (err: any) {
spinner.warn(`Failed to extract ${entry.path}: ${err.message}`);
}
}
if (!matchedSomething) {
spinner.fail(`Subfolder "${subfolder}" not found in repo.`);
await fs.remove(tempDir).catch(() => {});
process.exitCode=1;
return
}
spinner.succeed(`Successfully downloaded ${chalk.cyan(subfolder)} to ${chalk.green(outputDir)} (${filesExtracted} files)`);
} catch (error: any) {
spinner.fail(error.message);
if (tempDir) {
await fs.remove(tempDir).catch(() => {});
}
process.exitCode=1;
return
}
}
// --- CLI Entry ---
async function main() {
const [, , ...args] = process.argv;
if (args.includes('--version')) {
console.log(`github-folder-downloader v${getVersion()}`);
process.exit(0);
}
if (args.includes('--help') || args.length === 0) {
printHelp();
process.exit(0);
}
try {
const repoInput = args[0];
const parsed = parseGitHubUrl(repoInput);
let subfolder = parsed.subfolder;
let target: string | undefined;
if (!parsed.subfolder && args.length < 2) {
console.error(chalk.red('Subfolder path is required.'));
printHelp();
process.exitCode=1;
return;
}
if (!subfolder) {
subfolder = args[1];
target = args[2];
} else {
target = args[1];
}
if (!subfolder || typeof subfolder !== 'string') {
console.error(chalk.red('Subfolder path is required.'));
printHelp();
process.exitCode=1;
return;
}
await downloadFolder({
repoUrl: `${parsed.owner}/${parsed.repo}${parsed.branch ? `#${parsed.branch}` : ''}`,
subfolder,
target
});
} catch (err: any) {
console.error(chalk.red('Error:'), err.message);
process.exitCode=1;
return;
}
}
main();