233 lines
7.2 KiB
JavaScript
233 lines
7.2 KiB
JavaScript
/**
|
|
* GMP 模块卸载器
|
|
* 工单编号: GH-GMP-004
|
|
* 开发者: 培园A04 (5TH-LE-HK-A04)
|
|
* 职责: 安全停止模块 · 备份数据 · 移除模块文件 · 注销注册
|
|
*
|
|
* 卸载流程:
|
|
* 1. 检查模块是否存在
|
|
* 2. 停止模块进程 (如果有)
|
|
* 3. 备份模块数据到归档目录
|
|
* 4. 移除模块文件
|
|
* 5. 从installedModules注销
|
|
* 6. 清理残留
|
|
*/
|
|
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
const { execFileSync } = require('child_process');
|
|
const { createLogger } = require('./lib/logger');
|
|
const { sanitizeModuleName, assertWithinBase } = require('./lib/path-guard');
|
|
|
|
const logger = createLogger('uninstaller');
|
|
|
|
class ModuleUninstaller {
|
|
constructor(config, installedModules) {
|
|
this.config = config;
|
|
this.installedModules = installedModules;
|
|
}
|
|
|
|
/**
|
|
* 卸载模块
|
|
* @param {Object} opts
|
|
* @param {string} opts.moduleName - 模块名称
|
|
* @param {boolean} opts.keepBackup - 是否保留备份 (默认true)
|
|
* @param {boolean} opts.force - 是否强制卸载 (跳过停止步骤)
|
|
* @returns {Object} 卸载结果
|
|
*/
|
|
async uninstall({ moduleName, keepBackup, force }) {
|
|
// 安全防线 1: 模块名白名单 + path.basename 净化 (CodeQL 公认 sanitizer)
|
|
const safeName = sanitizeModuleName(moduleName);
|
|
|
|
const startTime = Date.now();
|
|
const shouldBackup = keepBackup !== false; // 默认保留备份
|
|
|
|
logger.info('[Uninstaller] 开始卸载模块: ' + safeName + ' backup=' + shouldBackup + ' force=' + !!force);
|
|
|
|
// Step 1: 检查模块是否存在
|
|
const moduleInfo = this.installedModules.get(safeName);
|
|
if (!moduleInfo) {
|
|
throw new Error('模块未注册: ' + safeName);
|
|
}
|
|
|
|
const moduleDir = moduleInfo.path;
|
|
// 安全防线 2: resolve 后校验模块路径在 modulesDir 之内 (防路径穿越)
|
|
assertWithinBase(this.config.modulesDir, moduleDir);
|
|
|
|
if (!fs.existsSync(moduleDir)) {
|
|
// 文件不存在但注册表有记录 → 清理注册
|
|
this.installedModules.delete(safeName);
|
|
logger.warn('[Uninstaller] 模块目录不存在, 已清理注册: ' + safeName);
|
|
return { status: 'cleaned', module: safeName, note: '目录不存在·仅清理注册' };
|
|
}
|
|
|
|
try {
|
|
// Step 2: 停止模块进程
|
|
if (!force) {
|
|
logger.info('[Uninstaller] Step 1: 停止模块进程...');
|
|
await this._stopModule(safeName, moduleDir);
|
|
}
|
|
|
|
// Step 3: 备份
|
|
let backupPath = null;
|
|
if (shouldBackup) {
|
|
logger.info('[Uninstaller] Step 2: 备份模块...');
|
|
backupPath = this._backupModule(safeName, moduleDir);
|
|
}
|
|
|
|
// Step 4: 移除模块文件
|
|
logger.info('[Uninstaller] Step 3: 移除模块文件...');
|
|
this._removeModule(moduleDir);
|
|
|
|
// Step 5: 注销
|
|
this.installedModules.delete(safeName);
|
|
logger.info('[Uninstaller] Step 4: 模块已注销');
|
|
|
|
const duration = Date.now() - startTime;
|
|
logger.info('[Uninstaller] 模块卸载成功: ' + safeName + ' 耗时=' + duration + 'ms');
|
|
|
|
return {
|
|
status: 'uninstalled',
|
|
module: safeName,
|
|
duration: duration,
|
|
backupPath: backupPath,
|
|
backupKept: shouldBackup
|
|
};
|
|
|
|
} catch (err) {
|
|
const duration = Date.now() - startTime;
|
|
logger.error('[Uninstaller] 卸载失败: ' + safeName + ' -> ' + err.message + ' 耗时=' + duration + 'ms');
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 停止模块进程
|
|
* 约定: 模块如果有运行中的进程, 通过pidfile或pm2管理
|
|
*/
|
|
async _stopModule(moduleName, moduleDir) {
|
|
// 方式1: 检查pidfile
|
|
const pidFile = path.join(moduleDir, '.pid');
|
|
if (fs.existsSync(pidFile)) {
|
|
try {
|
|
const pid = parseInt(fs.readFileSync(pidFile, 'utf-8').trim(), 10);
|
|
if (pid && !isNaN(pid)) {
|
|
process.kill(pid, 'SIGTERM');
|
|
logger.info('[Uninstaller] 已发送SIGTERM到PID: ' + pid);
|
|
// 等待进程退出
|
|
await this._waitForExit(pid, 5000);
|
|
}
|
|
} catch (err) {
|
|
if (err.code === 'ESRCH') {
|
|
logger.info('[Uninstaller] 进程已不存在 (PID文件过期)');
|
|
} else {
|
|
logger.warn('[Uninstaller] 停止进程警告: ' + err.message);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 方式2: 尝试pm2 stop (如果使用PM2管理)
|
|
// moduleName 已在 uninstall() 入口经过白名单校验, 此处用 execFileSync 数组形式传参
|
|
try {
|
|
execFileSync('pm2', ['stop', moduleName], {
|
|
stdio: 'pipe',
|
|
timeout: 10000
|
|
});
|
|
} catch (err) {
|
|
// PM2不可用或模块不在PM2中, 忽略
|
|
}
|
|
|
|
// 方式3: 检查模块自带的stop脚本
|
|
const stopScript = path.join(moduleDir, 'stop.sh');
|
|
if (fs.existsSync(stopScript)) {
|
|
try {
|
|
execFileSync('bash', ['stop.sh'], {
|
|
cwd: moduleDir,
|
|
stdio: 'pipe',
|
|
timeout: 15000
|
|
});
|
|
logger.info('[Uninstaller] 已执行模块stop脚本');
|
|
} catch (err) {
|
|
logger.warn('[Uninstaller] stop脚本执行警告 (exit ' + (err.status || 'unknown') + ')');
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 等待进程退出
|
|
*/
|
|
_waitForExit(pid, timeout) {
|
|
return new Promise((resolve) => {
|
|
const start = Date.now();
|
|
const check = () => {
|
|
try {
|
|
process.kill(pid, 0); // 检查进程是否存在
|
|
if (Date.now() - start > timeout) {
|
|
logger.warn('[Uninstaller] 进程未在超时内退出, PID=' + pid);
|
|
resolve();
|
|
} else {
|
|
setTimeout(check, 500);
|
|
}
|
|
} catch (err) {
|
|
// 进程已退出
|
|
logger.info('[Uninstaller] 进程已退出, PID=' + pid);
|
|
resolve();
|
|
}
|
|
};
|
|
check();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 备份模块到归档目录
|
|
*/
|
|
_backupModule(moduleName, moduleDir) {
|
|
// moduleName 已在 uninstall() 入口经过白名单校验
|
|
const archiveDir = path.join(this.config.modulesDir, '.archive');
|
|
if (!fs.existsSync(archiveDir)) {
|
|
fs.mkdirSync(archiveDir, { recursive: true });
|
|
}
|
|
|
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
const backupDir = path.join(archiveDir, moduleName + '_' + timestamp);
|
|
// 路径校验: 备份目录必须在 archiveDir 之内
|
|
assertWithinBase(archiveDir, backupDir);
|
|
|
|
this._copyDir(moduleDir, backupDir);
|
|
logger.info('[Uninstaller] 模块已备份: ' + backupDir);
|
|
return backupDir;
|
|
}
|
|
|
|
/**
|
|
* 移除模块目录
|
|
*/
|
|
_removeModule(moduleDir) {
|
|
try {
|
|
fs.rmSync(moduleDir, { recursive: true, force: true });
|
|
logger.info('[Uninstaller] 模块目录已移除: ' + moduleDir);
|
|
} catch (err) {
|
|
throw new Error('移除模块目录失败: ' + err.message);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 递归复制目录 (用于备份)
|
|
*/
|
|
_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);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
module.exports = { ModuleUninstaller };
|