mira-app-server
Version:
Mira Server - standalone server application using mira-app-core
376 lines • 15.8 kB
JavaScript
;
/**
* 环境依赖自检(doctor)
*
* 检测 ffmpeg / ImageMagick / exiftool 是否可用;不可用时可尝试自动安装。
* 检测约定与 ThumbnailService / MetadataService 保持一致:
* 优先读环境变量(FFMPEG_PATH / IMAGEMAGICK_PATH / EXIFTOOL_PATH),否则用 which 在 PATH 中查找。
*/
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.registerDoctor = registerDoctor;
const util_1 = require("util");
const child_process_1 = require("child_process");
const which_1 = __importDefault(require("which"));
const format_1 = require("./format");
const execAsync = (0, util_1.promisify)(child_process_1.exec);
/** 平台标识:win32 / darwin / linux(其它平台归入 linux 处理) */
function platform() {
return process.platform === 'win32' ? 'win32'
: process.platform === 'darwin' ? 'darwin'
: 'linux';
}
const TOOLS = [
{
key: 'ffmpeg',
name: 'ffmpeg',
envVar: 'FFMPEG_PATH',
candidates: ['ffmpeg'],
versionArgs: ['-version'],
extractVersion: (s) => {
const m = s.match(/ffmpeg version\s+([^\s,]+)/i);
return m ? m[1] : null;
},
install: {
win32: 'winget install --id=Gyan.FFmpeg -e --accept-source-agreements --accept-package-agreements',
darwin: 'brew install ffmpeg',
linux: { apt: 'ffmpeg', dnf: 'ffmpeg', pacman: 'ffmpeg' },
},
},
{
key: 'imagemagick',
name: 'ImageMagick',
envVar: 'IMAGEMAGICK_PATH',
candidates: ['magick', 'convert'],
versionArgs: ['-version'],
extractVersion: (s) => {
const m = s.match(/version:\s*ImageMagick\s+([^\s,]+)/i);
return m ? m[1] : null;
},
install: {
win32: 'winget install --id=ImageMagick.ImageMagick -e --accept-source-agreements --accept-package-agreements',
darwin: 'brew install imagemagick',
linux: { apt: 'imagemagick', dnf: 'ImageMagick', pacman: 'imagemagick' },
},
},
{
key: 'exiftool',
name: 'exiftool',
envVar: 'EXIFTOOL_PATH',
candidates: ['exiftool'],
versionArgs: ['-ver'],
extractVersion: (s) => {
// exiftool -ver 输出形如 "12.50"
const m = s.match(/(\d+\.\d+(?:\.\d+)?)/);
return m ? m[1] : null;
},
install: {
win32: 'winget install --id=OliverBetz.Exiftool -e --accept-source-agreements --accept-package-agreements',
darwin: 'brew install exiftool',
linux: { apt: 'libimage-exiftool-perl', dnf: 'perl-Image-ExifTool', pacman: 'perl-image-exiftool' },
},
},
];
/** Linux 下探测可用的包管理器,返回家族与安装命令前缀 */
function detectLinuxInstaller() {
const list = [
{ test: 'apt-get', family: 'apt', prefix: 'sudo apt-get update && sudo apt-get' },
{ test: 'apt', family: 'apt', prefix: 'sudo apt update && sudo apt' },
{ test: 'dnf', family: 'dnf', prefix: 'sudo dnf' },
{ test: 'yum', family: 'yum', prefix: 'sudo yum' },
{ test: 'pacman', family: 'pacman', prefix: 'sudo pacman -S --noconfirm' }, // pacman 无 install 子命令
];
for (const { test, family, prefix } of list) {
try {
which_1.default.sync(test);
return { family, prefix };
}
catch {
// continue
}
}
return null;
}
/** 依据包管理器家族解析出实际安装命令 */
function resolveInstallCmd(spec) {
const det = detectLinuxInstaller();
if (!det)
return null;
const { family, prefix } = det;
let pkg;
switch (family) {
case 'dnf':
pkg = spec.dnf;
break;
case 'yum':
pkg = spec.yum ?? spec.dnf;
break;
case 'pacman':
pkg = spec.pacman ?? spec.apt;
break;
default:
pkg = spec.apt;
break;
}
// pacman 前缀已含 -S --noconfirm,直接接包名;其余需 install -y
return family === 'pacman' ? `${prefix} ${pkg}` : `${prefix} install -y ${pkg}`;
}
/** 执行一条 shell 命令;返回 { ok, error }。非 json 模式下透传 stdout/stderr。 */
async function runShell(cmd) {
try {
const { stdout, stderr } = await execAsync(cmd, {
windowsHide: true,
maxBuffer: 1024 * 1024 * 8,
// Windows 用 cmd, *nix 用默认 shell
shell: platform() === 'win32' ? process.env.ComSpec || 'cmd.exe' : undefined,
});
if (!(0, format_1.isJsonMode)()) {
if (stdout)
process.stdout.write(stdout);
if (stderr)
process.stderr.write(stderr);
}
return { ok: true, error: null };
}
catch (e) {
return { ok: false, error: e?.message || String(e) };
}
}
const MIRRORS = [
{ id: 'official', label: '官方源(默认,国内可能较慢)' },
{ id: 'tuna', label: '清华大学 TUNA', host: 'mirrors.tuna.tsinghua.edu.cn' },
{ id: 'ustc', label: '中国科学技术大学 USTC', host: 'mirrors.ustc.edu.cn' },
{ id: 'aliyun', label: '阿里云', host: 'mirrors.aliyun.com' },
];
/** 解析 --mirror 参数:预设 id 或自定义 host 域名 */
function resolveMirror(idOrHost) {
if (!idOrHost || idOrHost === 'official')
return MIRRORS[0];
const found = MIRRORS.find(m => m.id === idOrHost);
if (found)
return found;
// 非 preset id,按自定义 host 处理
return { id: 'custom', label: idOrHost, host: idOrHost };
}
/** 获取 EL 主版本号(8/9);失败回退 9 */
async function getElVersion() {
try {
const { stdout } = await execAsync('rpm -E %rhel', { windowsHide: true });
const v = stdout.trim();
if (/^\d+$/.test(v))
return v;
}
catch {
// 非 RPM 系或 rpm 缺失
}
return '9';
}
/**
* 把指定 repo 文件的 baseurl 改为镜像地址,并在 baseurl 生效后注释掉 metalink。
* 幂等可逆:首次会备份 *.bak;仅在 sed 成功写入 baseurl 时才动 metalink,
* 避免出现「metalink 被注释、baseurl 又没设上」的破坏性中间态。
*/
async function applyMirrorToRepo(file, newBase) {
// 备份(仅首次,cp -n 不覆盖已有 .bak)
await runShell(`sudo cp -n "${file}" "${file}.bak" 2>/dev/null || true`);
// 整行替换任意 baseurl 行(注释或非注释);单引号保护 $basearch 字面交给 dnf 展开
await runShell(`sudo sed -i 's|^[#[:space:]]*baseurl=.*|baseurl=${newBase}|' "${file}" 2>/dev/null || true`);
// 仅当存在生效 baseurl 时才注释 metalink(否则保持原状,仓库不破坏)
await runShell(`grep -q '^baseurl=' "${file}" 2>/dev/null && sudo sed -i 's|^[#[:space:]]*metalink=|#metalink=|' "${file}" 2>/dev/null || true`);
}
/**
* dnf/yum 系(RHEL/Fedora/CentOS)安装前确保第三方仓库可用:
* - dnf-plugins-core:提供 `dnf config-manager`(启用仓库需要)
* - EPEL:提供 ImageMagick / perl-Image-ExifTool
* - RPM Fusion free:提供 ffmpeg 本体
* - CRB(EL9)/powertools(EL8):提供 ladspa 等 ffmpeg 依赖链构建包;
* 不启用会报 "nothing provides ladspa needed by rubberband"
* mirror 非 official 时,还会把 EPEL/RPMFusion 的仓库 baseurl 替换为镜像并刷新元数据。
* 每条命令独立执行,单个失败不阻断(可能已装或离线)。
*/
async function prepareDnfRepos(mirror) {
if (!(0, format_1.isJsonMode)()) {
console.log(`📦 配置 RHEL 系仓库(镜像: ${mirror.label})...`);
}
const el = await getElVersion();
const host = mirror.host || 'mirrors.rpmfusion.org';
// 1) dnf-plugins-core(config-manager)+ EPEL(ImageMagick / perl-Image-ExifTool)
await runShell('sudo dnf install -y dnf-plugins-core epel-release');
// 2) RPM Fusion free release 包(镜像或官方 URL)
await runShell(`sudo dnf install -y --nogpgcheck https://${host}/rpmfusion/free/el/rpmfusion-free-release-${el}.noarch.rpm`);
// 3) 启用 CRB(EL9)/powertools(EL8):提供 ladspa 等 ffmpeg 依赖链构建包
// RHEL(订阅版)无此仓库,失败则忽略,用户需手动启用 codeready-builder
await runShell('sudo dnf config-manager --set-enabled crb || sudo dnf config-manager --set-enabled powertools || true');
// 4) 非官方镜像:替换 EPEL + RPMFusion 仓库 baseurl,刷新元数据使镜像生效
if (mirror.host) {
const epelBase = `https://${mirror.host}/epel/${el}/Everything/$basearch/`;
const epelTestingBase = `https://${mirror.host}/epel/testing/${el}/Everything/$basearch/`;
const rpmfBase = `https://${mirror.host}/rpmfusion/free/el/${el}/$basearch/`;
await applyMirrorToRepo('/etc/yum.repos.d/epel.repo', epelBase);
await applyMirrorToRepo('/etc/yum.repos.d/epel-testing.repo', epelTestingBase);
await applyMirrorToRepo('/etc/yum.repos.d/rpmfusion-free.repo', rpmfBase);
await applyMirrorToRepo('/etc/yum.repos.d/rpmfusion-free-updates.repo', rpmfBase);
await runShell('sudo dnf clean all && sudo dnf makecache');
}
}
/** 检测单个工具:先看环境变量,再 which 候选名;可用时尝试取版本 */
async function checkTool(def) {
let resolved = null;
const envVal = process.env[def.envVar];
if (envVal) {
try {
which_1.default.sync(envVal);
resolved = envVal;
}
catch {
// 环境变量指向的路径不可用,继续尝试候选名
}
}
if (!resolved) {
for (const name of def.candidates) {
try {
resolved = which_1.default.sync(name);
break;
}
catch {
// continue
}
}
}
if (!resolved) {
return {
key: def.key, name: def.name, available: false, path: null, version: null,
envVar: def.envVar, candidates: def.candidates,
installTried: false, installOk: false, installError: null,
};
}
let version = null;
try {
const { stdout } = await execAsync(`"${resolved}" ${def.versionArgs.join(' ')}`, {
windowsHide: true,
maxBuffer: 1024 * 1024 * 2,
});
version = def.extractVersion(stdout);
}
catch {
// 忽略版本获取失败
}
return {
key: def.key, name: def.name, available: true, path: resolved, version,
envVar: def.envVar, candidates: def.candidates,
installTried: false, installOk: false, installError: null,
};
}
/** 执行安装命令;返回 { ok, error } */
async function installTool(def) {
const pf = platform();
// win32 / darwin:直接取字符串命令
if (pf !== 'linux') {
const raw = def.install[pf];
if (!raw) {
return { ok: false, error: `暂不支持在 ${pf} 上自动安装 ${def.name}` };
}
return runShell(raw);
}
// linux:按检测到的包管理器家族选包名
const spec = def.install.linux;
if (!spec) {
return { ok: false, error: `暂不支持在 linux 上自动安装 ${def.name}` };
}
const cmd = resolveInstallCmd(spec);
if (!cmd) {
return { ok: false, error: `未检测到可用的包管理器(apt/dnf/yum/pacman)` };
}
return runShell(cmd);
}
/** 人类可读格式化 */
function formatReport(results) {
const lines = ['🔧 环境依赖检测', ''];
for (const r of results) {
const tag = r.available ? '✅' : '❌';
const ver = r.version ? `v${r.version}` : '版本未知';
const detail = r.available
? `${ver}\t${r.path}`
: `未找到\t(候选: ${r.candidates.join(', ')}, 环境变量: ${r.envVar})`;
lines.push(`${tag} ${r.name.padEnd(14)} ${detail}`);
if (r.installTried) {
lines.push(` ↳ 安装${r.installOk ? '成功' : '失败'}${r.installError ? ': ' + r.installError : ''}`);
}
}
const total = results.length;
const ok = results.filter(r => r.available).length;
const missing = results.filter(r => !r.available).map(r => r.name);
lines.push('');
lines.push(`汇总: ${ok}/${total} 可用${missing.length ? ';缺失: ' + missing.join(', ') : ''}`);
if (missing.length) {
lines.push('提示: 运行 `mira-app-server doctor --install` 尝试自动安装缺失工具');
}
return lines.join('\n');
}
function registerDoctor(program) {
program
.command('doctor')
.description('检测 ffmpeg / ImageMagick / exiftool 等外部依赖是否可用;缺失时可自动安装')
.option('--install', '检测到缺失项时自动尝试安装(按平台调用 winget/brew/apt/dnf 等;RHEL 系自动启用 EPEL/RPM Fusion)')
.option('--mirror <name|host>', 'RHEL 系下载镜像:official/tuna/ustc/aliyun 或自定义域名(默认 official)', 'official')
.action(async (options) => {
const results = [];
for (const def of TOOLS) {
results.push(await checkTool(def));
}
if (options.install) {
// dnf/yum 系(RHEL/Fedora/CentOS):安装前配置仓库(镜像 + EPEL + RPM Fusion + CRB)
if (platform() === 'linux') {
const det = detectLinuxInstaller();
if (det && (det.family === 'dnf' || det.family === 'yum')) {
await prepareDnfRepos(resolveMirror(options.mirror));
}
}
for (const def of TOOLS) {
const idx = results.findIndex(r => r.key === def.key);
if (idx < 0 || results[idx].available)
continue;
if (!(0, format_1.isJsonMode)()) {
console.log(`\n⬇️ 正在安装 ${def.name} ...`);
}
const res = await installTool(def);
results[idx] = {
...results[idx],
installTried: true,
installOk: res.ok,
installError: res.error,
};
// 安装后重新检测一次(PATH 可能未刷新,但部分包管理器会写入已存在路径)
if (res.ok) {
const recheck = await checkTool(def);
if (recheck.available) {
results[idx] = {
...results[idx],
available: true,
path: recheck.path,
version: recheck.version,
};
}
}
}
}
const total = results.length;
const okCount = results.filter(r => r.available).length;
const missing = results.filter(r => !r.available).map(r => r.name);
const summary = {
total,
available: okCount,
missing,
allOk: missing.length === 0,
platform: platform(),
};
(0, format_1.output)({ tools: results, summary }, () => formatReport(results));
// 缺失项存在时以非零码退出,便于脚本/CI 感知
if (missing.length > 0) {
process.exitCode = 2;
}
});
}
//# sourceMappingURL=doctor.js.map