远程模板支持(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');
  }
}

📝 总结

核心功能

  1. ✅ GitHub 仓库模板下载
  2. ✅ npm 包模板支持
  3. ✅ zip 包模板处理
  4. ✅ 模板缓存机制
  5. ✅ 模板更新检查

下一步


脚手架 前端工程化 远程模板 GitHub npm