Terminal.php 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509
  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 Throwable;
  13. use think\Response;
  14. use think\facade\Config;
  15. use app\admin\library\Auth;
  16. use app\admin\library\module\Manage;
  17. use think\exception\HttpResponseException;
  18. use app\common\library\token\TokenExpirationException;
  19. class Terminal
  20. {
  21. /**
  22. * @var ?Terminal 对象实例
  23. */
  24. protected static ?Terminal $instance = null;
  25. /**
  26. * @var string 当前执行的命令 $command 的 key
  27. */
  28. protected string $commandKey = '';
  29. /**
  30. * @var array proc_open 的参数
  31. */
  32. protected array $descriptorsPec = [];
  33. /**
  34. * @var resource|bool proc_open 返回的 resource
  35. */
  36. protected $process = false;
  37. /**
  38. * @var array proc_open 的管道
  39. */
  40. protected array $pipes = [];
  41. /**
  42. * @var int proc执行状态:0=未执行,1=执行中,2=执行完毕
  43. */
  44. protected int $procStatusMark = 0;
  45. /**
  46. * @var array proc执行状态数据
  47. */
  48. protected array $procStatusData = [];
  49. /**
  50. * @var string 命令在前台的uuid
  51. */
  52. protected string $uuid = '';
  53. /**
  54. * @var string 扩展信息
  55. */
  56. protected string $extend = '';
  57. /**
  58. * @var string 命令执行输出文件
  59. */
  60. protected string $outputFile = '';
  61. /**
  62. * @var string 命令执行实时输出内容
  63. */
  64. protected string $outputContent = '';
  65. /**
  66. * @var string 自动构建的前端文件的 outDir(相对于根目录)
  67. */
  68. protected static string $distDir = 'web' . DIRECTORY_SEPARATOR . 'dist';
  69. /**
  70. * @var array 状态标识
  71. */
  72. protected array $flag = [
  73. // 连接成功
  74. 'link-success' => 'command-link-success',
  75. // 执行成功
  76. 'exec-success' => 'command-exec-success',
  77. // 执行完成
  78. 'exec-completed' => 'command-exec-completed',
  79. // 执行出错
  80. 'exec-error' => 'command-exec-error',
  81. ];
  82. /**
  83. * 初始化
  84. */
  85. public static function instance(): Terminal
  86. {
  87. if (is_null(self::$instance)) {
  88. self::$instance = new static();
  89. }
  90. return self::$instance;
  91. }
  92. /**
  93. * 构造函数
  94. */
  95. public function __construct()
  96. {
  97. $this->uuid = request()->param('uuid', '');
  98. $this->extend = request()->param('extend', '');
  99. // 初始化日志文件
  100. $outputDir = root_path() . 'runtime' . DIRECTORY_SEPARATOR . 'terminal';
  101. $this->outputFile = $outputDir . DIRECTORY_SEPARATOR . 'exec.log';
  102. if (!is_dir($outputDir)) {
  103. mkdir($outputDir, 0755, true);
  104. }
  105. file_put_contents($this->outputFile, '');
  106. /**
  107. * 命令执行结果输出到文件而不是管道
  108. * 因为输出到管道时有延迟,而文件虽然需要频繁读取和对比内容,但是输出实时的
  109. */
  110. $this->descriptorsPec = [0 => ['pipe', 'r'], 1 => ['file', $this->outputFile, 'w'], 2 => ['file', $this->outputFile, 'w']];
  111. }
  112. /**
  113. * 获取命令
  114. * @param string $key 命令key
  115. * @return array|bool
  116. */
  117. public static function getCommand(string $key): bool|array
  118. {
  119. if (!$key) {
  120. return false;
  121. }
  122. $commands = Config::get('terminal.commands');
  123. if (stripos($key, '.')) {
  124. $key = explode('.', $key);
  125. if (!array_key_exists($key[0], $commands) || !is_array($commands[$key[0]]) || !array_key_exists($key[1], $commands[$key[0]])) {
  126. return false;
  127. }
  128. $command = $commands[$key[0]][$key[1]];
  129. } else {
  130. if (!array_key_exists($key, $commands)) {
  131. return false;
  132. }
  133. $command = $commands[$key];
  134. }
  135. if (!is_array($command)) {
  136. $command = [
  137. 'cwd' => root_path(),
  138. 'command' => $command,
  139. ];
  140. } else {
  141. $command['cwd'] = root_path() . $command['cwd'];
  142. }
  143. if (str_contains($command['command'], '%')) {
  144. $args = request()->param('extend', '');
  145. $args = explode('~~', $args);
  146. array_unshift($args, $command['command']);
  147. $command['command'] = call_user_func_array('sprintf', $args);
  148. }
  149. $command['cwd'] = str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $command['cwd']);
  150. return $command;
  151. }
  152. /**
  153. * 执行命令
  154. * @param bool $authentication 是否鉴权
  155. * @throws Throwable
  156. */
  157. public function exec(bool $authentication = true): void
  158. {
  159. $this->sendHeader();
  160. while (ob_get_level()) {
  161. ob_end_clean();
  162. }
  163. if (!ob_get_level()) ob_start();
  164. $this->commandKey = request()->param('command');
  165. $command = self::getCommand($this->commandKey);
  166. if (!$command) {
  167. $this->execError('The command was not allowed to be executed', true);
  168. }
  169. if ($authentication) {
  170. try {
  171. $token = get_auth_token();
  172. $auth = Auth::instance();
  173. $auth->init($token);
  174. if (!$auth->isLogin() || !$auth->isSuperAdmin()) {
  175. $this->execError("You are not super administrator or not logged in", true);
  176. }
  177. } catch (TokenExpirationException) {
  178. $this->execError(__('Token expiration'));
  179. }
  180. }
  181. $this->beforeExecution();
  182. $this->outputFlag('link-success');
  183. if (!empty($command['notes'])) {
  184. $this->output('> ' . __($command['notes']), false);
  185. }
  186. $this->output('> ' . $command['command'], false);
  187. $this->process = proc_open($command['command'], $this->descriptorsPec, $this->pipes, $command['cwd']);
  188. if (!is_resource($this->process)) {
  189. $this->execError('Failed to execute', true);
  190. }
  191. while ($this->getProcStatus()) {
  192. $contents = file_get_contents($this->outputFile);
  193. if (strlen($contents) && $this->outputContent != $contents) {
  194. $newOutput = str_replace($this->outputContent, '', $contents);
  195. $this->checkOutput($contents, $newOutput);
  196. if (preg_match('/\r\n|\r|\n/', $newOutput)) {
  197. $this->output($newOutput);
  198. $this->outputContent = $contents;
  199. }
  200. }
  201. // 输出执行状态信息
  202. if ($this->procStatusMark === 2) {
  203. $this->output('exitCode: ' . $this->procStatusData['exitcode']);
  204. if ($this->procStatusData['exitcode'] === 0) {
  205. if ($this->successCallback()) {
  206. $this->outputFlag('exec-success');
  207. } else {
  208. $this->output('Error: Command execution succeeded, but callback execution failed');
  209. $this->outputFlag('exec-error');
  210. }
  211. } else {
  212. $this->outputFlag('exec-error');
  213. }
  214. }
  215. usleep(500000);
  216. }
  217. foreach ($this->pipes as $pipe) {
  218. fclose($pipe);
  219. }
  220. proc_close($this->process);
  221. $this->outputFlag('exec-completed');
  222. }
  223. /**
  224. * 获取执行状态
  225. * @throws Throwable
  226. */
  227. public function getProcStatus(): bool
  228. {
  229. $this->procStatusData = proc_get_status($this->process);
  230. if ($this->procStatusData['running']) {
  231. $this->procStatusMark = 1;
  232. return true;
  233. } elseif ($this->procStatusMark === 1) {
  234. $this->procStatusMark = 2;
  235. return true;
  236. } else {
  237. return false;
  238. }
  239. }
  240. /**
  241. * 输出 EventSource 数据
  242. * @param string $data
  243. * @param bool $callback
  244. */
  245. public function output(string $data, bool $callback = true): void
  246. {
  247. $data = self::outputFilter($data);
  248. $data = [
  249. 'data' => $data,
  250. 'uuid' => $this->uuid,
  251. 'extend' => $this->extend,
  252. 'key' => $this->commandKey,
  253. ];
  254. $data = json_encode($data, JSON_UNESCAPED_UNICODE);
  255. if ($data) {
  256. $this->finalOutput($data);
  257. if ($callback) $this->outputCallback($data);
  258. @ob_flush();// 刷新浏览器缓冲区
  259. }
  260. }
  261. /**
  262. * 检查输出
  263. * @param string $outputs 全部输出内容
  264. * @param string $rowOutput 当前输出内容(行)
  265. */
  266. public function checkOutput(string $outputs, string $rowOutput): void
  267. {
  268. if (str_contains($rowOutput, '(Y/n)')) {
  269. $this->execError('Interactive output detected, please manually execute the command to confirm the situation.', true);
  270. }
  271. }
  272. /**
  273. * 输出状态标记
  274. * @param string $flag
  275. */
  276. public function outputFlag(string $flag): void
  277. {
  278. $this->output($this->flag[$flag], false);
  279. }
  280. /**
  281. * 输出后回调
  282. */
  283. public function outputCallback($data): void
  284. {
  285. }
  286. /**
  287. * 成功后回调
  288. * @return bool
  289. * @throws Throwable
  290. */
  291. public function successCallback(): bool
  292. {
  293. if (stripos($this->commandKey, '.')) {
  294. $commandKeyArr = explode('.', $this->commandKey);
  295. $commandPKey = $commandKeyArr[0] ?? '';
  296. } else {
  297. $commandPKey = $this->commandKey;
  298. }
  299. if ($commandPKey == 'web-build') {
  300. if (!self::mvDist()) {
  301. $this->output('Build succeeded, but move file failed. Please operate manually.');
  302. return false;
  303. }
  304. } elseif ($commandPKey == 'web-install' && $this->extend) {
  305. [$type, $value] = explode(':', $this->extend);
  306. if ($type == 'module-install' && $value) {
  307. Manage::instance($value)->dependentInstallComplete('npm');
  308. }
  309. } elseif ($commandPKey == 'composer' && $this->extend) {
  310. [$type, $value] = explode(':', $this->extend);
  311. if ($type == 'module-install' && $value) {
  312. Manage::instance($value)->dependentInstallComplete('composer');
  313. }
  314. } elseif ($commandPKey == 'nuxt-install' && $this->extend) {
  315. [$type, $value] = explode(':', $this->extend);
  316. if ($type == 'module-install' && $value) {
  317. Manage::instance($value)->dependentInstallComplete('nuxt_npm');
  318. }
  319. }
  320. return true;
  321. }
  322. /**
  323. * 执行前埋点
  324. */
  325. public function beforeExecution(): void
  326. {
  327. if ($this->commandKey == 'test.pnpm') {
  328. @unlink(root_path() . 'public' . DIRECTORY_SEPARATOR . 'npm-install-test' . DIRECTORY_SEPARATOR . 'pnpm-lock.yaml');
  329. } elseif ($this->commandKey == 'web-install.pnpm') {
  330. @unlink(root_path() . 'web' . DIRECTORY_SEPARATOR . 'pnpm-lock.yaml');
  331. }
  332. }
  333. /**
  334. * 输出过滤
  335. */
  336. public static function outputFilter($str): string
  337. {
  338. $str = trim($str);
  339. $preg = '/\[(.*?)m/i';
  340. $str = preg_replace($preg, '', $str);
  341. $str = str_replace(["\r\n", "\r", "\n"], "\n", $str);
  342. return mb_convert_encoding($str, 'UTF-8', 'UTF-8,GBK,GB2312,BIG5');
  343. }
  344. /**
  345. * 执行错误
  346. */
  347. public function execError($error, $break = false): void
  348. {
  349. $this->output('Error:' . $error);
  350. $this->outputFlag('exec-error');
  351. if ($break) $this->break();
  352. }
  353. /**
  354. * 退出执行
  355. */
  356. public function break(): void
  357. {
  358. throw new HttpResponseException(Response::create()->contentType('text/event-stream'));
  359. }
  360. /**
  361. * 执行一个命令并以字符串的方式返回执行输出
  362. * 代替 exec 使用,这样就只需要解除 proc_open 的函数禁用了
  363. * @param $commandKey
  364. * @return string|bool
  365. */
  366. public static function getOutputFromProc($commandKey): bool|string
  367. {
  368. if (!function_exists('proc_open') || !function_exists('proc_close')) {
  369. return false;
  370. }
  371. $command = self::getCommand($commandKey);
  372. if (!$command) {
  373. return false;
  374. }
  375. $descriptorsPec = [1 => ['pipe', 'w'], 2 => ['pipe', 'w']];
  376. $process = proc_open($command['command'], $descriptorsPec, $pipes, null, null);
  377. if (is_resource($process)) {
  378. $info = stream_get_contents($pipes[1]);
  379. $info .= stream_get_contents($pipes[2]);
  380. fclose($pipes[1]);
  381. fclose($pipes[2]);
  382. proc_close($process);
  383. return self::outputFilter($info);
  384. }
  385. return '';
  386. }
  387. public static function mvDist(): bool
  388. {
  389. $distPath = root_path() . self::$distDir . DIRECTORY_SEPARATOR;
  390. $indexHtmlPath = $distPath . 'index.html';
  391. $assetsPath = $distPath . 'assets';
  392. if (!file_exists($indexHtmlPath) || !file_exists($assetsPath)) {
  393. return false;
  394. }
  395. $toIndexHtmlPath = root_path() . 'public' . DIRECTORY_SEPARATOR . 'index.html';
  396. $toAssetsPath = root_path() . 'public' . DIRECTORY_SEPARATOR . 'assets';
  397. @unlink($toIndexHtmlPath);
  398. Filesystem::delDir($toAssetsPath);
  399. if (rename($indexHtmlPath, $toIndexHtmlPath) && rename($assetsPath, $toAssetsPath)) {
  400. Filesystem::delDir($distPath);
  401. return true;
  402. } else {
  403. return false;
  404. }
  405. }
  406. public static function changeTerminalConfig($config = []): bool
  407. {
  408. // 不保存在数据库中,因为切换包管理器时,数据库资料可能还未配置
  409. $oldPackageManager = Config::get('terminal.npm_package_manager');
  410. $newPackageManager = request()->post('manager', $config['manager'] ?? $oldPackageManager);
  411. if ($oldPackageManager == $newPackageManager) {
  412. return true;
  413. }
  414. $buildConfigFile = config_path() . 'terminal.php';
  415. $buildConfigContent = @file_get_contents($buildConfigFile);
  416. $buildConfigContent = preg_replace("/'npm_package_manager'(\s+)=>(\s+)'$oldPackageManager'/", "'npm_package_manager'\$1=>\$2'$newPackageManager'", $buildConfigContent);
  417. $result = @file_put_contents($buildConfigFile, $buildConfigContent);
  418. return (bool)$result;
  419. }
  420. /**
  421. * 最终输出
  422. */
  423. public function finalOutput(string $data): void
  424. {
  425. $app = app();
  426. if (!empty($app->worker) && !empty($app->connection)) {
  427. $app->connection->send(new \Workerman\Protocols\Http\ServerSentEvents(['event' => 'message', 'data' => $data]));
  428. } else {
  429. echo 'data: ' . $data . "\n\n";
  430. }
  431. }
  432. /**
  433. * 发送响应头
  434. */
  435. public function sendHeader(): void
  436. {
  437. $headers = array_merge(request()->allowCrossDomainHeaders ?? [], [
  438. 'X-Accel-Buffering' => 'no',
  439. 'Content-Type' => 'text/event-stream',
  440. 'Cache-Control' => 'no-cache',
  441. ]);
  442. $app = app();
  443. if (!empty($app->worker) && !empty($app->connection)) {
  444. $app->connection->send(new \Workerman\Protocols\Http\Response(200, $headers, "\r\n"));
  445. } else {
  446. foreach ($headers as $name => $val) {
  447. header($name . (!is_null($val) ? ':' . $val : ''));
  448. }
  449. }
  450. }
  451. }