Terminal.php 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418
  1. <?php
  2. // +----------------------------------------------------------------------
  3. // | BuildAdmin [ Quickly create commercial-grade management system using popular technology stack ]
  4. // +----------------------------------------------------------------------
  5. // | Copyright (c) 2022~2022 http://buildadmin.com All rights reserved.
  6. // +----------------------------------------------------------------------
  7. // | Licensed ( http://www.apache.org/licenses/LICENSE-2.0 )
  8. // +----------------------------------------------------------------------
  9. // | Author: 妙码生花 <hi@buildadmin.com>
  10. // +----------------------------------------------------------------------
  11. namespace ba;
  12. use think\Response;
  13. use ba\module\Manage;
  14. use think\facade\Config;
  15. use think\facade\Cookie;
  16. use app\admin\library\Auth;
  17. use think\exception\HttpResponseException;
  18. class Terminal
  19. {
  20. /**
  21. * @var object 对象实例
  22. */
  23. protected static $instance;
  24. /**
  25. * 当前执行的命令,$command 的 key
  26. */
  27. protected $commandKey = null;
  28. /**
  29. * proc_open 的参数
  30. */
  31. protected $descriptorsPec = [];
  32. protected $process = null;
  33. protected $pipes = null;
  34. protected $procStatus = null;
  35. /**
  36. * 命令在前台的uuid
  37. */
  38. protected $uuid = null;
  39. /**
  40. * 扩展信息
  41. */
  42. protected $extend = null;
  43. /**
  44. * 命令执行输出文件
  45. */
  46. protected $outputFile = null;
  47. /**
  48. * 命令执行实时输出内容
  49. */
  50. protected $outputContent = '';
  51. /**
  52. * 自动构建的前端文件的 outDir(相对于根目录)
  53. */
  54. protected static $distDir = 'web' . DIRECTORY_SEPARATOR . 'dist';
  55. /**
  56. * 状态标识
  57. */
  58. protected $flag = [
  59. // 连接成功
  60. 'link-success' => 'command-link-success',
  61. // 执行成功
  62. 'exec-success' => 'command-exec-success',
  63. // 执行完成
  64. 'exec-completed' => 'command-exec-completed',
  65. // 执行出错
  66. 'exec-error' => 'command-exec-error',
  67. ];
  68. /**
  69. * 初始化
  70. */
  71. public static function instance()
  72. {
  73. if (is_null(self::$instance)) {
  74. self::$instance = new static();
  75. }
  76. return self::$instance;
  77. }
  78. /**
  79. * 构造函数
  80. */
  81. public function __construct()
  82. {
  83. $this->uuid = request()->param('uuid');
  84. $this->extend = request()->param('extend');
  85. // 初始化日志文件
  86. $outputDir = root_path() . 'runtime' . DIRECTORY_SEPARATOR . 'terminal';
  87. $this->outputFile = $outputDir . DIRECTORY_SEPARATOR . 'exec.log';
  88. if (!is_dir($outputDir)) {
  89. mkdir($outputDir, 0755, true);
  90. }
  91. file_put_contents($this->outputFile, '');
  92. /**
  93. * 命令执行结果输出到文件而不是管道
  94. * 因为输出到管道时有延迟,而文件虽然需要频繁读取和对比内容,但是输出实时的
  95. */
  96. $this->descriptorsPec = [0 => ['pipe', 'r'], 1 => ['file', $this->outputFile, 'w'], 2 => ['file', $this->outputFile, 'w']];
  97. }
  98. /**
  99. * 获取命令
  100. * @param string $key 命令key
  101. * @return array|false
  102. */
  103. public static function getCommand(string $key)
  104. {
  105. if (!$key) {
  106. return false;
  107. }
  108. $commands = Config::get('terminal.commands');
  109. if (stripos($key, '.')) {
  110. $key = explode('.', $key);
  111. if (!array_key_exists($key[0], $commands) || !is_array($commands[$key[0]]) || !array_key_exists($key[1], $commands[$key[0]])) {
  112. return false;
  113. }
  114. $command = $commands[$key[0]][$key[1]];
  115. } else {
  116. if (!array_key_exists($key, $commands)) {
  117. return false;
  118. }
  119. $command = $commands[$key];
  120. }
  121. if (!is_array($command)) {
  122. $command = [
  123. 'cwd' => root_path(),
  124. 'command' => $command,
  125. ];
  126. } else {
  127. $command = [
  128. 'cwd' => root_path() . $command['cwd'],
  129. 'command' => $command['command'],
  130. ];
  131. }
  132. $command['cwd'] = str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $command['cwd']);
  133. return $command;
  134. }
  135. public function exec(bool $authentication = true)
  136. {
  137. header('Content-Type: text/event-stream');
  138. header('Cache-Control: no-cache');
  139. $this->commandKey = request()->param('command');
  140. if (ob_get_level()) ob_end_clean();
  141. if (!ob_get_level()) ob_start();
  142. $command = self::getCommand($this->commandKey);
  143. if (!$command) {
  144. $this->execError('The command was not allowed to be executed', true);
  145. }
  146. if ($authentication) {
  147. $token = request()->server('HTTP_BATOKEN', request()->request('batoken', Cookie::get('batoken') ?: false));
  148. $auth = Auth::instance();
  149. $auth->init($token);
  150. if (!$auth->isLogin()) {
  151. $this->execError('Please login first', true);
  152. }
  153. }
  154. $this->beforeExecution();
  155. $this->outputFlag('link-success');
  156. $this->output('> ' . $command['command'], false);
  157. $this->process = proc_open($command['command'], $this->descriptorsPec, $this->pipes, $command['cwd']);
  158. if (!is_resource($this->process)) {
  159. $this->execError('Failed to execute', true);
  160. }
  161. while ($this->getProcStatus()) {
  162. $contents = file_get_contents($this->outputFile);
  163. if (strlen($contents) && $this->outputContent != $contents) {
  164. $newOutput = str_replace($this->outputContent, '', $contents);
  165. if (preg_match('/\r\n|\r|\n/', $newOutput)) {
  166. $this->output($newOutput);
  167. $this->outputContent = $contents;
  168. }
  169. }
  170. usleep(500000);
  171. }
  172. foreach ($this->pipes as $pipe) {
  173. fclose($pipe);
  174. }
  175. proc_close($this->process);
  176. $this->outputFlag('exec-completed');
  177. }
  178. public function getProcStatus(): bool
  179. {
  180. $status = proc_get_status($this->process);
  181. if ($status['running']) {
  182. $this->procStatus = 1;
  183. return true;
  184. } elseif ($this->procStatus === 1) {
  185. $this->procStatus = 0;
  186. $this->output('exitcode: ' . $status['exitcode']);
  187. if ($status['exitcode'] === 0) {
  188. if ($this->successCallback()) {
  189. $this->outputFlag('exec-success');
  190. } else {
  191. $this->output('Error: Command execution succeeded, but callback execution failed');
  192. $this->outputFlag('exec-error');
  193. }
  194. } else {
  195. $this->outputFlag('exec-error');
  196. }
  197. return true;
  198. } else {
  199. return false;
  200. }
  201. }
  202. /**
  203. * 输出 EventSource 数据
  204. * @param string $data
  205. * @param bool $callback
  206. */
  207. public function output(string $data, bool $callback = true)
  208. {
  209. $data = self::outputFilter($data);
  210. $data = [
  211. 'data' => $data,
  212. 'uuid' => $this->uuid,
  213. 'extend' => $this->extend,
  214. 'key' => $this->commandKey,
  215. ];
  216. $data = json_encode($data, JSON_UNESCAPED_UNICODE);
  217. if ($data) {
  218. echo 'data: ' . $data . "\n\n";
  219. if ($callback) $this->outputCallback($data);
  220. @ob_flush();// 刷新浏览器缓冲区
  221. }
  222. }
  223. /**
  224. * 输出状态标记
  225. * @param string $flag
  226. */
  227. public function outputFlag(string $flag)
  228. {
  229. $this->output($this->flag[$flag], false);
  230. }
  231. /**
  232. * 输出后回调
  233. */
  234. public function outputCallback($data)
  235. {
  236. }
  237. /**
  238. * 成功后回调
  239. * @return bool
  240. */
  241. public function successCallback(): bool
  242. {
  243. if (stripos($this->commandKey, '.')) {
  244. $commandKeyArr = explode('.', $this->commandKey);
  245. $commandPKey = $commandKeyArr[0] ?? '';
  246. } else {
  247. $commandPKey = $this->commandKey;
  248. }
  249. if ($commandPKey == 'web-build') {
  250. if (!self::mvDist()) {
  251. $this->output('Build succeeded, but move file failed. Please operate manually.');
  252. return false;
  253. }
  254. } elseif ($commandPKey == 'web-install' && $this->extend) {
  255. [$type, $value] = explode(':', $this->extend);
  256. if ($type == 'module-install' && $value) {
  257. Manage::instance($value)->dependentInstallComplete('npm');
  258. }
  259. } elseif ($commandPKey == 'composer' && $this->extend) {
  260. [$type, $value] = explode(':', $this->extend);
  261. if ($type == 'module-install' && $value) {
  262. Manage::instance($value)->dependentInstallComplete('composer');
  263. }
  264. } elseif ($commandPKey == 'nuxt-install' && $this->extend) {
  265. [$type, $value] = explode(':', $this->extend);
  266. if ($type == 'module-install' && $value) {
  267. Manage::instance($value)->dependentInstallComplete('nuxt_npm');
  268. }
  269. }
  270. return true;
  271. }
  272. /**
  273. * 执行前埋点
  274. */
  275. public function beforeExecution()
  276. {
  277. if ($this->commandKey == 'test.pnpm') {
  278. @unlink(root_path() . 'public' . DIRECTORY_SEPARATOR . 'npm-install-test' . DIRECTORY_SEPARATOR . 'pnpm-lock.yaml');
  279. } elseif ($this->commandKey == 'web-install.pnpm') {
  280. @unlink(root_path() . 'web' . DIRECTORY_SEPARATOR . 'pnpm-lock.yaml');
  281. }
  282. }
  283. /**
  284. * 输出过滤
  285. */
  286. public static function outputFilter($str)
  287. {
  288. $str = trim($str);
  289. $preg = '/\[(.*?)m/i';
  290. $str = preg_replace($preg, '', $str);
  291. $str = str_replace(["\r\n", "\r", "\n"], "", $str);
  292. return mb_convert_encoding($str, 'UTF-8', 'UTF-8,GBK,GB2312,BIG5');
  293. }
  294. /**
  295. * 执行错误
  296. */
  297. public function execError($error, $break = false)
  298. {
  299. $this->output('Error:' . $error);
  300. $this->outputFlag('exec-error');
  301. if ($break) $this->break();
  302. }
  303. /**
  304. * 退出执行
  305. */
  306. public function break()
  307. {
  308. throw new HttpResponseException(Response::create()->contentType('text/event-stream'));
  309. }
  310. /**
  311. * 执行一个命令并以字符串的方式返回执行输出
  312. * 代替 exec 使用,这样就只需要解除 proc_open 的函数禁用了
  313. * @param $commandKey
  314. * @return string | bool
  315. */
  316. public static function getOutputFromProc($commandKey)
  317. {
  318. if (!function_exists('proc_open') || !function_exists('proc_close')) {
  319. return false;
  320. }
  321. $command = self::getCommand($commandKey);
  322. if (!$command) {
  323. return false;
  324. }
  325. $descriptorsPec = [1 => ['pipe', 'w'], 2 => ['pipe', 'w']];
  326. $process = proc_open($command['command'], $descriptorsPec, $pipes, null, null);
  327. if (is_resource($process)) {
  328. $info = stream_get_contents($pipes[1]);
  329. $info .= stream_get_contents($pipes[2]);
  330. fclose($pipes[1]);
  331. fclose($pipes[2]);
  332. proc_close($process);
  333. return self::outputFilter($info);
  334. }
  335. return '';
  336. }
  337. public static function mvDist(): bool
  338. {
  339. $distPath = root_path() . self::$distDir . DIRECTORY_SEPARATOR;
  340. $indexHtmlPath = $distPath . 'index.html';
  341. $assetsPath = $distPath . 'assets';
  342. if (!file_exists($indexHtmlPath) || !file_exists($assetsPath)) {
  343. return false;
  344. }
  345. $toIndexHtmlPath = root_path() . 'public' . DIRECTORY_SEPARATOR . 'index.html';
  346. $toAssetsPath = root_path() . 'public' . DIRECTORY_SEPARATOR . 'assets';
  347. @unlink($toIndexHtmlPath);
  348. deldir($toAssetsPath);
  349. if (rename($indexHtmlPath, $toIndexHtmlPath) && rename($assetsPath, $toAssetsPath)) {
  350. deldir($distPath);
  351. return true;
  352. } else {
  353. return false;
  354. }
  355. }
  356. public static function changeTerminalConfig($config = []): bool
  357. {
  358. // 不保存在数据库中,因为切换包管理器时,数据库资料可能还未配置
  359. $oldPort = Config::get('terminal.install_service_port');
  360. $oldPackageManager = Config::get('terminal.npm_package_manager');
  361. $newPort = request()->post('port', $config['port'] ?? $oldPort);
  362. $newPackageManager = request()->post('manager', $config['manager'] ?? $oldPackageManager);
  363. if ($oldPort == $newPort && $oldPackageManager == $newPackageManager) {
  364. return true;
  365. }
  366. $buildConfigFile = config_path() . 'terminal.php';
  367. $buildConfigContent = @file_get_contents($buildConfigFile);
  368. $buildConfigContent = preg_replace("/'install_service_port'(\s+)=>(\s+)'$oldPort'/", "'install_service_port'\$1=>\$2'$newPort'", $buildConfigContent);
  369. $buildConfigContent = preg_replace("/'npm_package_manager'(\s+)=>(\s+)'$oldPackageManager'/", "'npm_package_manager'\$1=>\$2'$newPackageManager'", $buildConfigContent);
  370. $result = @file_put_contents($buildConfigFile, $buildConfigContent);
  371. return (bool)$result;
  372. }
  373. }