const fs = require('fs'); const os = require('os'); const path = require('path'); const spawn = require('cross-spawn'); const manifestPath = path.resolve(__dirname, '../../../wecom-cli-runtime.json'); const runtimeManifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); const INSTALL_LOCK_STALE_MS = 10 * 60 * 1000; const DEFAULT_INSTALL_TIMEOUT_MS = 5 * 60 * 1000; const DEFAULT_CALL_TIMEOUT_MS = 120000; const MAX_CAPTURE_BYTES = 10 * 1024 * 1024; function expandHome(value) { const text = String(value || ''); if (text === '~') return os.homedir(); if (text.startsWith(`~${path.sep}`) || text.startsWith('~/') || text.startsWith('~\\')) { return path.join(os.homedir(), text.slice(2)); } return text; } function getPlatformKey() { return `${process.platform}-${process.arch}`; } function isSupportedPlatform() { return runtimeManifest.supportedPlatforms.includes(getPlatformKey()); } function getRuntimeRoot() { if (process.env.QIWE_OFFICIAL_CLI_RUNTIME_DIR) { return path.resolve(expandHome(process.env.QIWE_OFFICIAL_CLI_RUNTIME_DIR)); } if (process.platform === 'win32') { const base = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'); return path.join(base, 'Fmode', 'qiwe-assistant', 'wecom-cli'); } if (process.platform === 'darwin') { return path.join(os.homedir(), 'Library', 'Caches', 'fmode', 'qiwe-assistant', 'wecom-cli'); } const base = process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache'); return path.join(base, 'fmode', 'qiwe-assistant', 'wecom-cli'); } function getRuntimeDir() { return path.join(getRuntimeRoot(), runtimeManifest.version); } function getOfficialConfigDir() { const configured = process.env.WECOM_CLI_CONFIG_DIR; return configured ? path.resolve(expandHome(configured)) : path.join(os.homedir(), '.config', 'wecom'); } function getCliPackagePath(runtimeDir = getRuntimeDir()) { return path.join(runtimeDir, 'node_modules', '@wecom', 'cli', 'package.json'); } function getCliEntrypoint(runtimeDir = getRuntimeDir()) { return path.join(runtimeDir, 'node_modules', '@wecom', 'cli', 'bin', 'wecom.js'); } function inspectInstalledRuntime(runtimeDir = getRuntimeDir()) { const packagePath = getCliPackagePath(runtimeDir); const entrypoint = getCliEntrypoint(runtimeDir); if (!fs.existsSync(packagePath) || !fs.existsSync(entrypoint)) { return { installed: false, valid: false, expectedVersion: runtimeManifest.version }; } try { const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8')); return { installed: true, valid: packageJson.name === runtimeManifest.package && packageJson.version === runtimeManifest.version, packageName: packageJson.name, version: packageJson.version, expectedVersion: runtimeManifest.version }; } catch { return { installed: true, valid: false, expectedVersion: runtimeManifest.version }; } } function sanitizeCliText(value) { return String(value || '') .replace(/Bearer\s+[^"'\s,}]+/gi, 'Bearer [REDACTED]') .replace( /("(?:[^"]*secret|access[_-]?token|authorization|credentials?|provider(?:name|host)?|vendor(?:name|host)?|upstream(?:name|host)?)"\s*:\s*")([^"]+)(")/gi, '$1[REDACTED]$3' ) .replace(/(Bot\s+Secret\s*[:=]\s*)\S+/gi, '$1[REDACTED]') .replace(/((?:provider|vendor|upstream)(?:Name|Host)?\s*[:=]\s*)[^\s,}]+/gi, '$1[REDACTED]'); } function sanitizeOfficialPayload(value) { if (Array.isArray(value)) return value.map(sanitizeOfficialPayload); if (value && typeof value === 'object') { const output = {}; for (const [key, item] of Object.entries(value)) { if ( /^(?:secret|bot_?secret|client_?secret|access_?token|authorization|credentials?|provider(?:Name|Host)?|vendor(?:Name|Host)?|upstream(?:Name|Host)?)$/i.test( key ) ) { continue; } output[key] = sanitizeOfficialPayload(item); } return output; } return typeof value === 'string' ? sanitizeCliText(value) : value; } function runProcess(command, args, options = {}) { const timeoutMs = options.timeoutMs || DEFAULT_CALL_TIMEOUT_MS; const inherited = options.inherited === true; const maxCaptureBytes = options.maxCaptureBytes || MAX_CAPTURE_BYTES; return new Promise((resolve, reject) => { let stdout = ''; let stderr = ''; let capturedBytes = 0; let settled = false; let timedOut = false; const child = spawn(command, args, { cwd: options.cwd || os.homedir(), env: options.env || process.env, shell: false, windowsHide: !inherited, stdio: inherited ? 'inherit' : ['ignore', 'pipe', 'pipe'] }); const timer = setTimeout(() => { timedOut = true; child.kill(); }, timeoutMs); function capture(kind, chunk) { capturedBytes += chunk.length; if (capturedBytes > maxCaptureBytes) { child.kill(); return; } if (kind === 'stdout') stdout += chunk.toString('utf8'); else stderr += chunk.toString('utf8'); } if (!inherited) { child.stdout.on('data', chunk => capture('stdout', chunk)); child.stderr.on('data', chunk => capture('stderr', chunk)); } child.on('error', error => { if (settled) return; settled = true; clearTimeout(timer); reject(error); }); child.on('close', (code, signal) => { if (settled) return; settled = true; clearTimeout(timer); if (timedOut) { const error = new Error(`process timed out after ${timeoutMs}ms`); error.kind = 'timeout'; reject(error); return; } if (capturedBytes > maxCaptureBytes) { const error = new Error(`process output exceeded ${maxCaptureBytes} bytes`); error.kind = 'output_limit'; reject(error); return; } resolve({ exitCode: Number.isInteger(code) ? code : 1, signal, stdout, stderr }); }); }); } async function acquireInstallLock(lockPath, waitTimeoutMs = DEFAULT_INSTALL_TIMEOUT_MS) { const startedAt = Date.now(); while (Date.now() - startedAt < waitTimeoutMs) { try { const handle = await fs.promises.open(lockPath, 'wx'); await handle.writeFile(`${process.pid}\n${new Date().toISOString()}\n`); return async () => { await handle.close().catch(() => {}); await fs.promises.rm(lockPath, { force: true }).catch(() => {}); }; } catch (error) { if (error.code !== 'EEXIST') throw error; try { const stat = await fs.promises.stat(lockPath); if (Date.now() - stat.mtimeMs > INSTALL_LOCK_STALE_MS) { await fs.promises.rm(lockPath, { force: true }); continue; } } catch (statError) { if (statError.code !== 'ENOENT') throw statError; continue; } await new Promise(resolve => setTimeout(resolve, 250)); } } throw new Error('等待企业微信官方 CLI 安装锁超时'); } function assertRuntimeRequirements() { const nodeMajor = Number(process.versions.node.split('.')[0]); if (!Number.isFinite(nodeMajor) || nodeMajor < 18) { throw new Error(`企业微信官方 CLI 需要 Node.js >= 18,当前版本为 ${process.versions.node}`); } if (!isSupportedPlatform()) { throw new Error( `企业微信官方 CLI 不支持当前平台 ${getPlatformKey()};支持 ${runtimeManifest.supportedPlatforms.join(', ')}` ); } } async function prepareOfficialCli(options = {}) { assertRuntimeRequirements(); const runtimeRoot = getRuntimeRoot(); const runtimeDir = getRuntimeDir(); const force = options.force === true; const installTimeoutMs = options.timeoutMs || DEFAULT_INSTALL_TIMEOUT_MS; const current = inspectInstalledRuntime(runtimeDir); if (current.valid && !force) return { ...current, runtimeDir, cached: true }; await fs.promises.mkdir(runtimeRoot, { recursive: true }); const lockPath = path.join(runtimeRoot, `.${runtimeManifest.version}.install.lock`); const releaseLock = await acquireInstallLock(lockPath, installTimeoutMs); let tempDir; try { const afterLock = inspectInstalledRuntime(runtimeDir); if (afterLock.valid && !force) return { ...afterLock, runtimeDir, cached: true }; tempDir = path.join(runtimeRoot, `.${runtimeManifest.version}.tmp-${process.pid}-${Date.now()}`); await fs.promises.rm(tempDir, { recursive: true, force: true }); await fs.promises.mkdir(tempDir, { recursive: true }); const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm'; const installResult = await runProcess( npmCommand, [ 'install', '--prefix', tempDir, `${runtimeManifest.package}@${runtimeManifest.version}`, '--omit=dev', '--no-audit', '--no-fund', '--save-exact' ], { timeoutMs: installTimeoutMs, cwd: runtimeRoot } ); if (installResult.exitCode !== 0) { throw new Error( `下载企业微信官方 CLI 失败(exit ${installResult.exitCode}):${sanitizeCliText( installResult.stderr || installResult.stdout ).trim()}` ); } const installed = inspectInstalledRuntime(tempDir); if (!installed.valid) { throw new Error( `企业微信官方 CLI 安装校验失败:期望 ${runtimeManifest.package}@${runtimeManifest.version}` ); } await fs.promises.rm(runtimeDir, { recursive: true, force: true }); await fs.promises.rename(tempDir, runtimeDir); tempDir = undefined; return { ...installed, runtimeDir, cached: false }; } finally { if (tempDir) await fs.promises.rm(tempDir, { recursive: true, force: true }).catch(() => {}); await releaseLock(); } } function parseCliOutput(stdout) { const text = String(stdout || '').trim(); if (!text) return undefined; try { return sanitizeOfficialPayload(JSON.parse(text)); } catch { return undefined; } } async function runOfficialCli(args, options = {}) { if (!Array.isArray(args) || args.some(item => typeof item !== 'string')) { throw new Error('CLI arguments must be an array of strings'); } if (options.ensure !== false) await prepareOfficialCli(); const runtime = inspectInstalledRuntime(); if (!runtime.valid) throw new Error('企业微信官方 CLI 尚未安装或版本校验失败'); const result = await runProcess( process.execPath, [getCliEntrypoint(), ...args], { timeoutMs: options.timeoutMs || DEFAULT_CALL_TIMEOUT_MS, inherited: options.inherited === true, cwd: options.cwd || os.homedir(), env: { ...process.env, ...(options.env || {}) } } ); return { ...result, stdout: sanitizeCliText(result.stdout), stderr: sanitizeCliText(result.stderr), parsed: parseCliOutput(result.stdout) }; } async function getOfficialCliStatus() { const runtime = inspectInstalledRuntime(); const configDir = getOfficialConfigDir(); const botConfigExists = fs.existsSync(path.join(configDir, 'bot.enc')); const mcpConfigExists = fs.existsSync(path.join(configDir, 'mcp_config.enc')); let authorized = false; if (runtime.valid) { try { const authResult = await runOfficialCli(['auth', 'show', '--auth-status'], { ensure: false, timeoutMs: 10000 }); authorized = authResult.exitCode === 0 && authResult.stdout.trim() === 'authorized'; } catch { authorized = false; } } return { supported: isSupportedPlatform(), platform: getPlatformKey(), packageName: runtimeManifest.package, expectedVersion: runtimeManifest.version, installed: runtime.installed === true, valid: runtime.valid === true, installedVersion: runtime.version, authorized, mcpConfigExists, ready: runtime.valid === true && authorized && botConfigExists && mcpConfigExists }; } function getInitCommand() { const wrapperPath = path.resolve(__dirname, '../../../bin/qiwe-official-cli.js'); return `"${process.execPath}" "${wrapperPath}" init`; } module.exports = { runtimeManifest, getPlatformKey, getRuntimeRoot, getRuntimeDir, getOfficialConfigDir, getCliEntrypoint, inspectInstalledRuntime, sanitizeCliText, sanitizeOfficialPayload, prepareOfficialCli, runOfficialCli, getOfficialCliStatus, getInitCommand };