远程模板支持(Remote Template Support)
支持从远程仓库(GitHub、npm、zip 包等)下载模板,实现模板的共享和复用,包括模板下载、缓存机制、更新检查等功能。
📋 目录
1. GitHub 仓库模板下载
1.1 使用 GitHub API
// src/utils/github.ts
import https from 'https';
import { execSync } from 'child_process';
import path from 'path';
import fs from 'fs-extra';
export interface GitHubTemplate {
owner: string;
repo: string;
branch?: string;
path?: string; // 子目录路径
}
export async function downloadFromGitHub(
template: GitHubTemplate,
targetDir: string
): Promise<void> {
const { owner, repo, branch = 'main', path: subPath } = template;
// 使用 git clone 下载(推荐方式)
const repoUrl = `https://github.com/${owner}/${repo}.git`;
const tempDir = path.join(process.cwd(), '.temp', `${owner}-${repo}`);
try {
// 清理临时目录
if (await fs.pathExists(tempDir)) {
await fs.remove(tempDir);
}
// Clone 仓库
execSync(`git clone --depth 1 --branch ${branch} ${repoUrl} ${tempDir}`, {
stdio: 'inherit'
});
// 如果指定了子路径,只复制子路径内容
const sourcePath = subPath
? path.join(tempDir, subPath)
: tempDir;
if (!(await fs.pathExists(sourcePath))) {
throw new Error(`Template path not found: ${subPath}`);
}
// 复制到目标目录
await fs.copy(sourcePath, targetDir, {
filter: (src) => {
// 排除 .git 目录
return !src.includes('.git');
}
});
// 清理临时目录
await fs.remove(tempDir);
} catch (error) {
// 确保清理临时目录
if (await fs.pathExists(tempDir)) {
await fs.remove(tempDir).catch(() => {});
}
throw error;
}
}1.2 使用 GitHub API 下载(备选方案)
// 使用 GitHub API 下载 zip 包
export async function downloadFromGitHubAPI(
template: GitHubTemplate,
targetDir: string
): Promise<void> {
const { owner, repo, branch = 'main' } = template;
const zipUrl = `https://api.github.com/repos/${owner}/${repo}/zipball/${branch}`;
// 下载 zip 文件
const zipPath = path.join(process.cwd(), '.temp', `${owner}-${repo}.zip`);
await fs.ensureDir(path.dirname(zipPath));
await downloadFile(zipUrl, zipPath);
// 解压 zip 文件
await extractZip(zipPath, targetDir);
// 清理临时文件
await fs.remove(zipPath);
}
async function downloadFile(url: string, dest: string): Promise<void> {
return new Promise((resolve, reject) => {
const file = fs.createWriteStream(dest);
https.get(url, (response) => {
if (response.statusCode === 302 || response.statusCode === 301) {
// 处理重定向
https.get(response.headers.location!, (redirectResponse) => {
redirectResponse.pipe(file);
file.on('finish', () => {
file.close();
resolve();
});
}).on('error', reject);
} else {
response.pipe(file);
file.on('finish', () => {
file.close();
resolve();
});
}
}).on('error', reject);
});
}1.3 使用示例
// 使用 GitHub 模板
await downloadFromGitHub(
{
owner: 'user',
repo: 'my-template',
branch: 'main',
path: 'templates/react' // 可选:指定子目录
},
'./my-app'
);2. npm 包模板支持
2.1 npm 包模板结构
my-template-package/
├── package.json
├── templates/
│ ├── react/
│ │ ├── package.json.ejs
│ │ └── src/
│ └── vue/
│ ├── package.json.ejs
│ └── src/
└── template.config.json
2.2 下载 npm 包模板
// src/utils/npm.ts
import { execSync } from 'child_process';
import path from 'path';
import fs from 'fs-extra';
export async function downloadFromNpm(
packageName: string,
targetDir: string,
templateName?: string
): Promise<void> {
const tempDir = path.join(process.cwd(), '.temp', packageName);
try {
// 清理临时目录
if (await fs.pathExists(tempDir)) {
await fs.remove(tempDir);
}
await fs.ensureDir(tempDir);
// 安装 npm 包到临时目录
execSync(`npm install ${packageName} --prefix ${tempDir}`, {
stdio: 'inherit'
});
// 查找模板目录
const packageDir = path.join(tempDir, 'node_modules', packageName);
const templatesDir = path.join(packageDir, 'templates');
if (!(await fs.pathExists(templatesDir))) {
throw new Error(`Templates directory not found in ${packageName}`);
}
// 如果指定了模板名,使用指定模板
// 否则使用默认模板或第一个模板
let sourcePath: string;
if (templateName) {
sourcePath = path.join(templatesDir, templateName);
if (!(await fs.pathExists(sourcePath))) {
throw new Error(`Template ${templateName} not found`);
}
} else {
// 查找默认模板
const configPath = path.join(packageDir, 'template.config.json');
if (await fs.pathExists(configPath)) {
const config = await fs.readJson(configPath);
sourcePath = path.join(templatesDir, config.defaultTemplate || 'default');
} else {
// 使用第一个模板
const templates = await fs.readdir(templatesDir);
sourcePath = path.join(templatesDir, templates[0]);
}
}
// 复制模板到目标目录
await fs.copy(sourcePath, targetDir, {
filter: (src) => {
// 排除 node_modules
return !src.includes('node_modules');
}
});
// 清理临时目录
await fs.remove(tempDir);
} catch (error) {
// 确保清理临时目录
if (await fs.pathExists(tempDir)) {
await fs.remove(tempDir).catch(() => {});
}
throw error;
}
}2.3 使用示例
// 使用 npm 包模板
await downloadFromNpm('my-template-package', './my-app', 'react');3. zip 包模板处理
3.1 下载和解压 zip 包
// src/utils/zip.ts
import https from 'https';
import http from 'http';
import path from 'path';
import fs from 'fs-extra';
import { extract } from 'zip-lib'; // 需要安装 zip-lib 或使用其他解压库
export async function downloadFromZip(
zipUrl: string,
targetDir: string
): Promise<void> {
const zipPath = path.join(process.cwd(), '.temp', 'template.zip');
try {
// 下载 zip 文件
await downloadFile(zipUrl, zipPath);
// 解压 zip 文件
await extractZip(zipPath, targetDir);
// 清理临时文件
await fs.remove(zipPath);
} catch (error) {
// 确保清理临时文件
if (await fs.pathExists(zipPath)) {
await fs.remove(zipPath).catch(() => {});
}
throw error;
}
}
async function downloadFile(url: string, dest: string): Promise<void> {
return new Promise((resolve, reject) => {
const protocol = url.startsWith('https') ? https : http;
const file = fs.createWriteStream(dest);
protocol.get(url, (response) => {
if (response.statusCode === 302 || response.statusCode === 301) {
// 处理重定向
const redirectUrl = response.headers.location!;
const redirectProtocol = redirectUrl.startsWith('https') ? https : http;
redirectProtocol.get(redirectUrl, (redirectResponse) => {
redirectResponse.pipe(file);
file.on('finish', () => {
file.close();
resolve();
});
}).on('error', reject);
} else {
response.pipe(file);
file.on('finish', () => {
file.close();
resolve();
});
}
}).on('error', reject);
});
}
async function extractZip(zipPath: string, targetDir: string): Promise<void> {
await fs.ensureDir(targetDir);
await extract(zipPath, targetDir);
}4. 模板缓存机制
4.1 缓存管理器
// src/utils/cache.ts
import path from 'path';
import fs from 'fs-extra';
import crypto from 'crypto';
export class TemplateCache {
private cacheDir: string;
constructor() {
this.cacheDir = path.join(
require('os').homedir(),
'.my-cli',
'cache',
'templates'
);
this.ensureCacheDir();
}
private async ensureCacheDir(): Promise<void> {
await fs.ensureDir(this.cacheDir);
}
// 生成缓存键
private getCacheKey(source: string): string {
return crypto.createHash('md5').update(source).digest('hex');
}
// 获取缓存路径
private getCachePath(cacheKey: string): string {
return path.join(this.cacheDir, cacheKey);
}
// 检查缓存是否存在
async hasCache(source: string): Promise<boolean> {
const cacheKey = this.getCacheKey(source);
const cachePath = this.getCachePath(cacheKey);
return await fs.pathExists(cachePath);
}
// 获取缓存
async getCache(source: string): Promise<string | null> {
if (await this.hasCache(source)) {
const cacheKey = this.getCacheKey(source);
return this.getCachePath(cacheKey);
}
return null;
}
// 设置缓存
async setCache(source: string, templatePath: string): Promise<void> {
const cacheKey = this.getCacheKey(source);
const cachePath = this.getCachePath(cacheKey);
// 如果缓存已存在,先删除
if (await fs.pathExists(cachePath)) {
await fs.remove(cachePath);
}
// 复制到缓存目录
await fs.copy(templatePath, cachePath);
}
// 清除缓存
async clearCache(source?: string): Promise<void> {
if (source) {
const cacheKey = this.getCacheKey(source);
const cachePath = this.getCachePath(cacheKey);
if (await fs.pathExists(cachePath)) {
await fs.remove(cachePath);
}
} else {
// 清除所有缓存
await fs.remove(this.cacheDir);
await this.ensureCacheDir();
}
}
// 获取缓存信息
async getCacheInfo(): Promise<{ count: number; size: number }> {
const entries = await fs.readdir(this.cacheDir);
let totalSize = 0;
for (const entry of entries) {
const entryPath = path.join(this.cacheDir, entry);
const stat = await fs.stat(entryPath);
if (stat.isDirectory()) {
totalSize += await this.getDirSize(entryPath);
}
}
return {
count: entries.length,
size: totalSize
};
}
private async getDirSize(dirPath: string): Promise<number> {
let size = 0;
const entries = await fs.readdir(dirPath);
for (const entry of entries) {
const entryPath = path.join(dirPath, entry);
const stat = await fs.stat(entryPath);
if (stat.isDirectory()) {
size += await this.getDirSize(entryPath);
} else {
size += stat.size;
}
}
return size;
}
}4.2 使用缓存
// 在下载模板时使用缓存
const cache = new TemplateCache();
const cacheKey = `github:${owner}/${repo}:${branch}`;
// 检查缓存
if (await cache.hasCache(cacheKey)) {
const cachedPath = await cache.getCache(cacheKey);
await fs.copy(cachedPath!, targetDir);
} else {
// 下载模板
const tempDir = path.join(process.cwd(), '.temp', 'template');
await downloadFromGitHub(template, tempDir);
// 保存到缓存
await cache.setCache(cacheKey, tempDir);
// 复制到目标目录
await fs.copy(tempDir, targetDir);
}5. 模板更新检查
5.1 检查模板更新
// src/utils/update.ts
import { execSync } from 'child_process';
import path from 'path';
import fs from 'fs-extra';
export interface TemplateInfo {
source: string;
type: 'github' | 'npm' | 'zip';
version?: string;
lastUpdate?: Date;
}
export class TemplateUpdater {
private cache: TemplateCache;
constructor() {
this.cache = new TemplateCache();
}
// 检查 GitHub 模板更新
async checkGitHubUpdate(
owner: string,
repo: string,
branch: string = 'main'
): Promise<boolean> {
try {
// 获取最新 commit SHA
const apiUrl = `https://api.github.com/repos/${owner}/${repo}/commits/${branch}`;
const response = await fetch(apiUrl);
const data = await response.json();
const latestSha = data.sha;
// 检查缓存的 SHA
const cacheKey = `github:${owner}/${repo}:${branch}`;
const cachePath = await this.cache.getCache(cacheKey);
if (!cachePath) {
return true; // 没有缓存,需要下载
}
// 读取缓存的 SHA(如果存在)
const shaPath = path.join(cachePath, '.template-sha');
if (await fs.pathExists(shaPath)) {
const cachedSha = await fs.readFile(shaPath, 'utf-8');
return cachedSha !== latestSha;
}
return true; // 没有 SHA 记录,需要更新
} catch (error) {
console.warn('检查更新失败:', error);
return false;
}
}
// 检查 npm 包更新
async checkNpmUpdate(packageName: string): Promise<boolean> {
try {
// 获取最新版本
const latestVersion = execSync(
`npm view ${packageName} version`,
{ encoding: 'utf-8' }
).trim();
// 检查缓存的版本
const cacheKey = `npm:${packageName}`;
const cachePath = await this.cache.getCache(cacheKey);
if (!cachePath) {
return true; // 没有缓存,需要下载
}
// 读取缓存的版本
const versionPath = path.join(cachePath, '.template-version');
if (await fs.pathExists(versionPath)) {
const cachedVersion = await fs.readFile(versionPath, 'utf-8');
return cachedVersion !== latestVersion;
}
return true; // 没有版本记录,需要更新
} catch (error) {
console.warn('检查更新失败:', error);
return false;
}
}
// 更新模板
async updateTemplate(
source: string,
type: 'github' | 'npm' | 'zip'
): Promise<void> {
const cacheKey = `${type}:${source}`;
await this.cache.clearCache(cacheKey);
// 重新下载模板
// 这里需要根据类型调用相应的下载函数
}
}5.2 使用更新检查
// 在创建项目时检查更新
const updater = new TemplateUpdater();
if (options.template.startsWith('github:')) {
const [owner, repo, branch = 'main'] = options.template
.replace('github:', '')
.split('/');
const needsUpdate = await updater.checkGitHubUpdate(owner, repo, branch);
if (needsUpdate) {
logger.info('发现模板更新,正在更新...');
await updater.updateTemplate(`${owner}/${repo}`, 'github');
}
}📝 总结
核心功能
- ✅ GitHub 仓库模板下载
- ✅ npm 包模板支持
- ✅ zip 包模板处理
- ✅ 模板缓存机制
- ✅ 模板更新检查
下一步
- 发布与扩展 - 发布脚手架到 npm 并实现扩展能力