| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390 |
- 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
- };
|