wecom-cli-runtime.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390
  1. const fs = require('fs');
  2. const os = require('os');
  3. const path = require('path');
  4. const spawn = require('cross-spawn');
  5. const manifestPath = path.resolve(__dirname, '../../../wecom-cli-runtime.json');
  6. const runtimeManifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
  7. const INSTALL_LOCK_STALE_MS = 10 * 60 * 1000;
  8. const DEFAULT_INSTALL_TIMEOUT_MS = 5 * 60 * 1000;
  9. const DEFAULT_CALL_TIMEOUT_MS = 120000;
  10. const MAX_CAPTURE_BYTES = 10 * 1024 * 1024;
  11. function expandHome(value) {
  12. const text = String(value || '');
  13. if (text === '~') return os.homedir();
  14. if (text.startsWith(`~${path.sep}`) || text.startsWith('~/') || text.startsWith('~\\')) {
  15. return path.join(os.homedir(), text.slice(2));
  16. }
  17. return text;
  18. }
  19. function getPlatformKey() {
  20. return `${process.platform}-${process.arch}`;
  21. }
  22. function isSupportedPlatform() {
  23. return runtimeManifest.supportedPlatforms.includes(getPlatformKey());
  24. }
  25. function getRuntimeRoot() {
  26. if (process.env.QIWE_OFFICIAL_CLI_RUNTIME_DIR) {
  27. return path.resolve(expandHome(process.env.QIWE_OFFICIAL_CLI_RUNTIME_DIR));
  28. }
  29. if (process.platform === 'win32') {
  30. const base = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
  31. return path.join(base, 'Fmode', 'qiwe-assistant', 'wecom-cli');
  32. }
  33. if (process.platform === 'darwin') {
  34. return path.join(os.homedir(), 'Library', 'Caches', 'fmode', 'qiwe-assistant', 'wecom-cli');
  35. }
  36. const base = process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache');
  37. return path.join(base, 'fmode', 'qiwe-assistant', 'wecom-cli');
  38. }
  39. function getRuntimeDir() {
  40. return path.join(getRuntimeRoot(), runtimeManifest.version);
  41. }
  42. function getOfficialConfigDir() {
  43. const configured = process.env.WECOM_CLI_CONFIG_DIR;
  44. return configured
  45. ? path.resolve(expandHome(configured))
  46. : path.join(os.homedir(), '.config', 'wecom');
  47. }
  48. function getCliPackagePath(runtimeDir = getRuntimeDir()) {
  49. return path.join(runtimeDir, 'node_modules', '@wecom', 'cli', 'package.json');
  50. }
  51. function getCliEntrypoint(runtimeDir = getRuntimeDir()) {
  52. return path.join(runtimeDir, 'node_modules', '@wecom', 'cli', 'bin', 'wecom.js');
  53. }
  54. function inspectInstalledRuntime(runtimeDir = getRuntimeDir()) {
  55. const packagePath = getCliPackagePath(runtimeDir);
  56. const entrypoint = getCliEntrypoint(runtimeDir);
  57. if (!fs.existsSync(packagePath) || !fs.existsSync(entrypoint)) {
  58. return {
  59. installed: false,
  60. valid: false,
  61. expectedVersion: runtimeManifest.version
  62. };
  63. }
  64. try {
  65. const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
  66. return {
  67. installed: true,
  68. valid: packageJson.name === runtimeManifest.package && packageJson.version === runtimeManifest.version,
  69. packageName: packageJson.name,
  70. version: packageJson.version,
  71. expectedVersion: runtimeManifest.version
  72. };
  73. } catch {
  74. return {
  75. installed: true,
  76. valid: false,
  77. expectedVersion: runtimeManifest.version
  78. };
  79. }
  80. }
  81. function sanitizeCliText(value) {
  82. return String(value || '')
  83. .replace(/Bearer\s+[^"'\s,}]+/gi, 'Bearer [REDACTED]')
  84. .replace(
  85. /("(?:[^"]*secret|access[_-]?token|authorization|credentials?|provider(?:name|host)?|vendor(?:name|host)?|upstream(?:name|host)?)"\s*:\s*")([^"]+)(")/gi,
  86. '$1[REDACTED]$3'
  87. )
  88. .replace(/(Bot\s+Secret\s*[:=]\s*)\S+/gi, '$1[REDACTED]')
  89. .replace(/((?:provider|vendor|upstream)(?:Name|Host)?\s*[:=]\s*)[^\s,}]+/gi, '$1[REDACTED]');
  90. }
  91. function sanitizeOfficialPayload(value) {
  92. if (Array.isArray(value)) return value.map(sanitizeOfficialPayload);
  93. if (value && typeof value === 'object') {
  94. const output = {};
  95. for (const [key, item] of Object.entries(value)) {
  96. if (
  97. /^(?:secret|bot_?secret|client_?secret|access_?token|authorization|credentials?|provider(?:Name|Host)?|vendor(?:Name|Host)?|upstream(?:Name|Host)?)$/i.test(
  98. key
  99. )
  100. ) {
  101. continue;
  102. }
  103. output[key] = sanitizeOfficialPayload(item);
  104. }
  105. return output;
  106. }
  107. return typeof value === 'string' ? sanitizeCliText(value) : value;
  108. }
  109. function runProcess(command, args, options = {}) {
  110. const timeoutMs = options.timeoutMs || DEFAULT_CALL_TIMEOUT_MS;
  111. const inherited = options.inherited === true;
  112. const maxCaptureBytes = options.maxCaptureBytes || MAX_CAPTURE_BYTES;
  113. return new Promise((resolve, reject) => {
  114. let stdout = '';
  115. let stderr = '';
  116. let capturedBytes = 0;
  117. let settled = false;
  118. let timedOut = false;
  119. const child = spawn(command, args, {
  120. cwd: options.cwd || os.homedir(),
  121. env: options.env || process.env,
  122. shell: false,
  123. windowsHide: !inherited,
  124. stdio: inherited ? 'inherit' : ['ignore', 'pipe', 'pipe']
  125. });
  126. const timer = setTimeout(() => {
  127. timedOut = true;
  128. child.kill();
  129. }, timeoutMs);
  130. function capture(kind, chunk) {
  131. capturedBytes += chunk.length;
  132. if (capturedBytes > maxCaptureBytes) {
  133. child.kill();
  134. return;
  135. }
  136. if (kind === 'stdout') stdout += chunk.toString('utf8');
  137. else stderr += chunk.toString('utf8');
  138. }
  139. if (!inherited) {
  140. child.stdout.on('data', chunk => capture('stdout', chunk));
  141. child.stderr.on('data', chunk => capture('stderr', chunk));
  142. }
  143. child.on('error', error => {
  144. if (settled) return;
  145. settled = true;
  146. clearTimeout(timer);
  147. reject(error);
  148. });
  149. child.on('close', (code, signal) => {
  150. if (settled) return;
  151. settled = true;
  152. clearTimeout(timer);
  153. if (timedOut) {
  154. const error = new Error(`process timed out after ${timeoutMs}ms`);
  155. error.kind = 'timeout';
  156. reject(error);
  157. return;
  158. }
  159. if (capturedBytes > maxCaptureBytes) {
  160. const error = new Error(`process output exceeded ${maxCaptureBytes} bytes`);
  161. error.kind = 'output_limit';
  162. reject(error);
  163. return;
  164. }
  165. resolve({
  166. exitCode: Number.isInteger(code) ? code : 1,
  167. signal,
  168. stdout,
  169. stderr
  170. });
  171. });
  172. });
  173. }
  174. async function acquireInstallLock(lockPath, waitTimeoutMs = DEFAULT_INSTALL_TIMEOUT_MS) {
  175. const startedAt = Date.now();
  176. while (Date.now() - startedAt < waitTimeoutMs) {
  177. try {
  178. const handle = await fs.promises.open(lockPath, 'wx');
  179. await handle.writeFile(`${process.pid}\n${new Date().toISOString()}\n`);
  180. return async () => {
  181. await handle.close().catch(() => {});
  182. await fs.promises.rm(lockPath, { force: true }).catch(() => {});
  183. };
  184. } catch (error) {
  185. if (error.code !== 'EEXIST') throw error;
  186. try {
  187. const stat = await fs.promises.stat(lockPath);
  188. if (Date.now() - stat.mtimeMs > INSTALL_LOCK_STALE_MS) {
  189. await fs.promises.rm(lockPath, { force: true });
  190. continue;
  191. }
  192. } catch (statError) {
  193. if (statError.code !== 'ENOENT') throw statError;
  194. continue;
  195. }
  196. await new Promise(resolve => setTimeout(resolve, 250));
  197. }
  198. }
  199. throw new Error('等待企业微信官方 CLI 安装锁超时');
  200. }
  201. function assertRuntimeRequirements() {
  202. const nodeMajor = Number(process.versions.node.split('.')[0]);
  203. if (!Number.isFinite(nodeMajor) || nodeMajor < 18) {
  204. throw new Error(`企业微信官方 CLI 需要 Node.js >= 18,当前版本为 ${process.versions.node}`);
  205. }
  206. if (!isSupportedPlatform()) {
  207. throw new Error(
  208. `企业微信官方 CLI 不支持当前平台 ${getPlatformKey()};支持 ${runtimeManifest.supportedPlatforms.join(', ')}`
  209. );
  210. }
  211. }
  212. async function prepareOfficialCli(options = {}) {
  213. assertRuntimeRequirements();
  214. const runtimeRoot = getRuntimeRoot();
  215. const runtimeDir = getRuntimeDir();
  216. const force = options.force === true;
  217. const installTimeoutMs = options.timeoutMs || DEFAULT_INSTALL_TIMEOUT_MS;
  218. const current = inspectInstalledRuntime(runtimeDir);
  219. if (current.valid && !force) return { ...current, runtimeDir, cached: true };
  220. await fs.promises.mkdir(runtimeRoot, { recursive: true });
  221. const lockPath = path.join(runtimeRoot, `.${runtimeManifest.version}.install.lock`);
  222. const releaseLock = await acquireInstallLock(lockPath, installTimeoutMs);
  223. let tempDir;
  224. try {
  225. const afterLock = inspectInstalledRuntime(runtimeDir);
  226. if (afterLock.valid && !force) return { ...afterLock, runtimeDir, cached: true };
  227. tempDir = path.join(runtimeRoot, `.${runtimeManifest.version}.tmp-${process.pid}-${Date.now()}`);
  228. await fs.promises.rm(tempDir, { recursive: true, force: true });
  229. await fs.promises.mkdir(tempDir, { recursive: true });
  230. const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
  231. const installResult = await runProcess(
  232. npmCommand,
  233. [
  234. 'install',
  235. '--prefix',
  236. tempDir,
  237. `${runtimeManifest.package}@${runtimeManifest.version}`,
  238. '--omit=dev',
  239. '--no-audit',
  240. '--no-fund',
  241. '--save-exact'
  242. ],
  243. { timeoutMs: installTimeoutMs, cwd: runtimeRoot }
  244. );
  245. if (installResult.exitCode !== 0) {
  246. throw new Error(
  247. `下载企业微信官方 CLI 失败(exit ${installResult.exitCode}):${sanitizeCliText(
  248. installResult.stderr || installResult.stdout
  249. ).trim()}`
  250. );
  251. }
  252. const installed = inspectInstalledRuntime(tempDir);
  253. if (!installed.valid) {
  254. throw new Error(
  255. `企业微信官方 CLI 安装校验失败:期望 ${runtimeManifest.package}@${runtimeManifest.version}`
  256. );
  257. }
  258. await fs.promises.rm(runtimeDir, { recursive: true, force: true });
  259. await fs.promises.rename(tempDir, runtimeDir);
  260. tempDir = undefined;
  261. return { ...installed, runtimeDir, cached: false };
  262. } finally {
  263. if (tempDir) await fs.promises.rm(tempDir, { recursive: true, force: true }).catch(() => {});
  264. await releaseLock();
  265. }
  266. }
  267. function parseCliOutput(stdout) {
  268. const text = String(stdout || '').trim();
  269. if (!text) return undefined;
  270. try {
  271. return sanitizeOfficialPayload(JSON.parse(text));
  272. } catch {
  273. return undefined;
  274. }
  275. }
  276. async function runOfficialCli(args, options = {}) {
  277. if (!Array.isArray(args) || args.some(item => typeof item !== 'string')) {
  278. throw new Error('CLI arguments must be an array of strings');
  279. }
  280. if (options.ensure !== false) await prepareOfficialCli();
  281. const runtime = inspectInstalledRuntime();
  282. if (!runtime.valid) throw new Error('企业微信官方 CLI 尚未安装或版本校验失败');
  283. const result = await runProcess(
  284. process.execPath,
  285. [getCliEntrypoint(), ...args],
  286. {
  287. timeoutMs: options.timeoutMs || DEFAULT_CALL_TIMEOUT_MS,
  288. inherited: options.inherited === true,
  289. cwd: options.cwd || os.homedir(),
  290. env: { ...process.env, ...(options.env || {}) }
  291. }
  292. );
  293. return {
  294. ...result,
  295. stdout: sanitizeCliText(result.stdout),
  296. stderr: sanitizeCliText(result.stderr),
  297. parsed: parseCliOutput(result.stdout)
  298. };
  299. }
  300. async function getOfficialCliStatus() {
  301. const runtime = inspectInstalledRuntime();
  302. const configDir = getOfficialConfigDir();
  303. const botConfigExists = fs.existsSync(path.join(configDir, 'bot.enc'));
  304. const mcpConfigExists = fs.existsSync(path.join(configDir, 'mcp_config.enc'));
  305. let authorized = false;
  306. if (runtime.valid) {
  307. try {
  308. const authResult = await runOfficialCli(['auth', 'show', '--auth-status'], {
  309. ensure: false,
  310. timeoutMs: 10000
  311. });
  312. authorized = authResult.exitCode === 0 && authResult.stdout.trim() === 'authorized';
  313. } catch {
  314. authorized = false;
  315. }
  316. }
  317. return {
  318. supported: isSupportedPlatform(),
  319. platform: getPlatformKey(),
  320. packageName: runtimeManifest.package,
  321. expectedVersion: runtimeManifest.version,
  322. installed: runtime.installed === true,
  323. valid: runtime.valid === true,
  324. installedVersion: runtime.version,
  325. authorized,
  326. mcpConfigExists,
  327. ready: runtime.valid === true && authorized && botConfigExists && mcpConfigExists
  328. };
  329. }
  330. function getInitCommand() {
  331. const wrapperPath = path.resolve(__dirname, '../../../bin/qiwe-official-cli.js');
  332. return `"${process.execPath}" "${wrapperPath}" init`;
  333. }
  334. module.exports = {
  335. runtimeManifest,
  336. getPlatformKey,
  337. getRuntimeRoot,
  338. getRuntimeDir,
  339. getOfficialConfigDir,
  340. getCliEntrypoint,
  341. inspectInstalledRuntime,
  342. sanitizeCliText,
  343. sanitizeOfficialPayload,
  344. prepareOfficialCli,
  345. runOfficialCli,
  346. getOfficialCliStatus,
  347. getInitCommand
  348. };