2026-05-10 13:12:44 +08:00

299 lines
9.5 KiB
JavaScript

/**
* GMP 模块安装器
* 工单编号: GH-GMP-004
* 开发者: 培园A04 (5TH-LE-HK-A04)
* 职责: 从GitHub拉取模块代码 · 验证manifest · 安装依赖 · 注册模块
*
* 安装流程:
* 1. 克隆/拉取指定模块目录到临时目录
* 2. 验证manifest.yaml存在且合法
* 3. 安装依赖 (npm install / pip install)
* 4. 运行模块自带的selfcheck (如有)
* 5. 移动到modules/目录 · 注册到installedModules
* 6. 清理临时文件
*/
const path = require('path');
const fs = require('fs');
const { execFileSync } = require('child_process');
const { createLogger } = require('./lib/logger');
const {
sanitizeModuleName,
assertWithinBase,
assertNotOption,
assertSafeGitUrl
} = require('./lib/path-guard');
const logger = createLogger('installer');
class ModuleInstaller {
constructor(config, installedModules) {
this.config = config;
this.installedModules = installedModules;
}
/**
* 安装模块
* @param {Object} opts
* @param {string} opts.repoUrl - 仓库克隆URL (可选, 默认用config中的)
* @param {string} opts.moduleName - 模块名称
* @param {string} opts.branch - 分支名 (默认main)
* @param {boolean} opts.autoTriggered - 是否由webhook自动触发
* @returns {Object} 安装结果
*/
async install({ repoUrl, moduleName, branch, autoTriggered }) {
// 安全防线 1: 模块名白名单 + path.basename 净化 (CodeQL 公认 sanitizer)
const safeName = sanitizeModuleName(moduleName);
const startTime = Date.now();
// 分支名同样做防选项注入校验 (防 git --upload-pack=... 二阶注入)
const branchName = branch || 'main';
assertNotOption(branchName, '分支名');
if (!/^[a-zA-Z0-9_./-]{1,128}$/.test(branchName)) {
throw new Error('非法分支名: 仅允许字母数字 . _ - / 长度1~128');
}
const cloneUrl = repoUrl || this.config.defaultRepoUrl;
assertSafeGitUrl(cloneUrl);
logger.info('[Installer] 开始安装模块: ' + safeName + ' 分支=' + branchName);
const tempDir = path.join(this.config.tempDir, 'install-' + safeName + '-' + Date.now());
const targetDir = path.join(this.config.modulesDir, safeName);
// 安全防线 2: resolve 后校验目标目录在 modulesDir 之内 (防路径穿越)
assertWithinBase(this.config.modulesDir, targetDir);
assertWithinBase(this.config.tempDir, tempDir);
try {
// Step 1: 克隆仓库到临时目录
logger.info('[Installer] Step 1: 克隆仓库...');
this._cloneRepo(cloneUrl, branchName, tempDir);
// Step 2: 检查模块目录是否存在
const moduleSrcDir = path.join(tempDir, 'guanghu-self-hosted', safeName);
if (!fs.existsSync(moduleSrcDir)) {
throw new Error('模块目录不存在: guanghu-self-hosted/' + safeName);
}
// Step 3: 验证manifest
logger.info('[Installer] Step 2: 验证manifest...');
const manifestResult = this._validateManifest(moduleSrcDir, safeName);
// Step 4: 安装依赖
logger.info('[Installer] Step 3: 安装依赖...');
this._installDependencies(moduleSrcDir);
// Step 5: 运行自检 (如果有)
logger.info('[Installer] Step 4: 运行自检...');
const selfCheckResult = this._runSelfCheck(moduleSrcDir);
// Step 6: 部署到目标目录
logger.info('[Installer] Step 5: 部署到模块目录...');
this._deployModule(moduleSrcDir, targetDir);
// Step 7: 注册模块
const moduleInfo = {
name: safeName,
path: targetDir,
manifestPath: path.join(targetDir, 'manifest.yaml'),
installedAt: new Date().toISOString(),
branch: branchName,
status: 'installed',
autoTriggered: !!autoTriggered,
selfCheckPassed: selfCheckResult.passed
};
this.installedModules.set(safeName, moduleInfo);
const duration = Date.now() - startTime;
logger.info('[Installer] 模块安装成功: ' + safeName + ' 耗时=' + duration + 'ms');
return {
status: 'installed',
module: safeName,
branch: branchName,
duration: duration,
selfCheck: selfCheckResult,
manifest: manifestResult
};
} catch (err) {
const duration = Date.now() - startTime;
logger.error('[Installer] 安装失败: ' + safeName + ' -> ' + err.message + ' 耗时=' + duration + 'ms');
throw err;
} finally {
// 清理临时目录
this._cleanup(tempDir);
}
}
/**
* 克隆仓库
*/
_cloneRepo(url, branch, dest) {
// 二次断言: 防选项注入 (CodeQL js/second-order-command-line-injection)
assertSafeGitUrl(url);
assertNotOption(branch, '分支名');
assertNotOption(dest, '目标目录');
try {
// 使用 execFileSync 数组形式传参; 同时使用 -c 关闭危险协议防 second-order injection
execFileSync(
'git',
[
'-c', 'protocol.ext.allow=never',
'-c', 'protocol.file.allow=user',
'clone',
'--depth', '1',
'--single-branch',
'--branch', branch,
'--',
url,
dest
],
{ stdio: 'pipe', timeout: 120000 }
);
logger.info('[Installer] 仓库克隆完成');
} catch (err) {
// 错误信息不暴露 stderr 原文 (可能含 token / 内部路径)
throw new Error('仓库克隆失败 (exit code ' + (err.status || 'unknown') + ')');
}
}
/**
* 验证manifest.yaml
*/
_validateManifest(moduleDir, moduleName) {
const manifestPath = path.join(moduleDir, 'manifest.yaml');
const hasManifest = fs.existsSync(manifestPath);
if (!hasManifest) {
// manifest不是强制要求(兼容旧模块), 但记录警告
logger.warn('[Installer] 模块缺少manifest.yaml: ' + moduleName);
return { valid: false, warning: '缺少manifest.yaml' };
}
// 简单校验: 文件非空
const content = fs.readFileSync(manifestPath, 'utf-8').trim();
if (content.length === 0) {
logger.warn('[Installer] manifest.yaml为空: ' + moduleName);
return { valid: false, warning: 'manifest.yaml为空' };
}
logger.info('[Installer] manifest验证通过');
return { valid: true, size: content.length };
}
/**
* 安装依赖
*/
_installDependencies(moduleDir) {
// Node.js 依赖
const packageJson = path.join(moduleDir, 'package.json');
if (fs.existsSync(packageJson)) {
try {
execFileSync('npm', ['install', '--production'], {
cwd: moduleDir,
stdio: 'pipe',
timeout: 180000
});
logger.info('[Installer] npm依赖安装完成');
} catch (err) {
logger.warn('[Installer] npm install警告 (exit ' + (err.status || 'unknown') + ')');
}
}
// Python 依赖
const requirementsTxt = path.join(moduleDir, 'requirements.txt');
if (fs.existsSync(requirementsTxt)) {
try {
execFileSync('pip', ['install', '-r', 'requirements.txt', '--quiet'], {
cwd: moduleDir,
stdio: 'pipe',
timeout: 180000
});
logger.info('[Installer] pip依赖安装完成');
} catch (err) {
logger.warn('[Installer] pip install警告 (exit ' + (err.status || 'unknown') + ')');
}
}
}
/**
* 运行模块自检脚本
*/
_runSelfCheck(moduleDir) {
// 约定: test/ 目录下有自检脚本, 或 manifest 中指定
const testDir = path.join(moduleDir, 'test');
const selfCheckScript = path.join(moduleDir, 'selfcheck.js');
if (fs.existsSync(selfCheckScript)) {
try {
execFileSync('node', ['selfcheck.js'], {
cwd: moduleDir,
stdio: 'pipe',
timeout: 60000
});
logger.info('[Installer] 自检通过 (selfcheck.js)');
return { passed: true, method: 'selfcheck.js' };
} catch (err) {
logger.warn('[Installer] 自检失败 (exit ' + (err.status || 'unknown') + ')');
return { passed: false, method: 'selfcheck.js', error: 'exit code ' + (err.status || 'unknown') };
}
}
// 无自检脚本
logger.info('[Installer] 模块无自检脚本, 跳过');
return { passed: true, method: 'none', note: '无自检脚本' };
}
/**
* 部署模块到目标目录
*/
_deployModule(srcDir, targetDir) {
// 如果目标目录已存在, 先备份
if (fs.existsSync(targetDir)) {
const backupDir = targetDir + '.bak.' + Date.now();
fs.renameSync(targetDir, backupDir);
logger.info('[Installer] 已备份旧版本: ' + backupDir);
}
// 递归复制
this._copyDir(srcDir, targetDir);
logger.info('[Installer] 模块已部署到: ' + targetDir);
}
/**
* 递归复制目录
*/
_copyDir(src, dest) {
fs.mkdirSync(dest, { recursive: true });
const entries = fs.readdirSync(src, { withFileTypes: true });
for (const entry of entries) {
const srcPath = path.join(src, entry.name);
const destPath = path.join(dest, entry.name);
if (entry.isDirectory()) {
if (entry.name === 'node_modules' || entry.name === '.git') continue;
this._copyDir(srcPath, destPath);
} else {
fs.copyFileSync(srcPath, destPath);
}
}
}
/**
* 清理临时目录
*/
_cleanup(tempDir) {
try {
if (fs.existsSync(tempDir)) {
fs.rmSync(tempDir, { recursive: true, force: true });
logger.info('[Installer] 临时目录已清理: ' + tempDir);
}
} catch (err) {
logger.warn('[Installer] 清理临时目录失败: ' + err.message);
}
}
}
module.exports = { ModuleInstaller };