项目初始化

This commit is contained in:
2026-03-18 15:54:43 +08:00
commit dfcd762e23
601 changed files with 57883 additions and 0 deletions

421
extend/ba/Terminal.php Normal file
View File

@@ -0,0 +1,421 @@
<?php
namespace ba;
use Throwable;
use support\Response;
use app\admin\library\Auth;
use app\common\library\token\TokenExpirationException;
use Workerman\Protocols\Http\ServerSentEvents;
use Workerman\Protocols\Http\Response as WorkermanResponse;
/**
* WEB 终端SSE 命令执行)
* 适配 Webman使用 request()->connection、config()
*/
class Terminal
{
protected static ?Terminal $instance = null;
protected string $commandKey = '';
protected array $descriptorsPec = [];
protected $process = false;
protected array $pipes = [];
protected int $procStatusMark = 0;
protected array $procStatusData = [];
protected string $uuid = '';
protected string $extend = '';
protected string $outputFile = '';
protected string $outputContent = '';
protected static string $distDir = 'web' . DIRECTORY_SEPARATOR . 'dist';
protected array $flag = [
'link-success' => 'command-link-success',
'exec-success' => 'command-exec-success',
'exec-completed' => 'command-exec-completed',
'exec-error' => 'command-exec-error',
];
public static function instance(): Terminal
{
if (is_null(self::$instance)) {
self::$instance = new static();
}
return self::$instance;
}
public function __construct()
{
$request = request();
$this->uuid = $request ? $request->param('uuid', '') : '';
$this->extend = $request ? $request->param('extend', '') : '';
$outputDir = root_path() . 'runtime' . DIRECTORY_SEPARATOR . 'terminal';
$this->outputFile = $outputDir . DIRECTORY_SEPARATOR . 'exec.log';
if (!is_dir($outputDir)) {
mkdir($outputDir, 0755, true);
}
file_put_contents($this->outputFile, '');
$this->descriptorsPec = [0 => ['pipe', 'r'], 1 => ['file', $this->outputFile, 'w'], 2 => ['file', $this->outputFile, 'w']];
}
public static function getCommand(string $key): bool|array
{
if (!$key) {
return false;
}
$commands = config('terminal.commands', []);
if (stripos($key, '.')) {
$key = explode('.', $key);
if (!array_key_exists($key[0], $commands) || !is_array($commands[$key[0]]) || !array_key_exists($key[1], $commands[$key[0]])) {
return false;
}
$command = $commands[$key[0]][$key[1]];
} else {
if (!array_key_exists($key, $commands)) {
return false;
}
$command = $commands[$key];
}
if (!is_array($command)) {
$command = [
'cwd' => root_path(),
'command' => $command,
];
} else {
$cwd = $command['cwd'] ?? '';
$root = rtrim(root_path(), DIRECTORY_SEPARATOR . '/');
$command['cwd'] = $root . DIRECTORY_SEPARATOR . ltrim(str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $cwd), DIRECTORY_SEPARATOR . '/');
}
$request = request();
if ($request && str_contains($command['command'], '%')) {
$args = $request->param('extend', '');
$args = explode('~~', $args);
$args = array_map('escapeshellarg', $args);
array_unshift($args, $command['command']);
$command['command'] = call_user_func_array('sprintf', $args);
}
$command['cwd'] = str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $command['cwd']);
if (DIRECTORY_SEPARATOR === '\\') {
$command['command'] = 'cmd /c "cd /d ' . str_replace('"', '""', $command['cwd']) . ' && ' . $command['command'] . '"';
$command['cwd'] = root_path();
}
return $command;
}
public function exec(bool $authentication = true): void
{
$this->sendHeader();
while (ob_get_level()) {
ob_end_clean();
}
if (!ob_get_level()) ob_start();
$request = request();
$this->commandKey = $request ? $request->param('command', '') : '';
$command = self::getCommand($this->commandKey);
if (!$command) {
$this->execError('The command was not allowed to be executed', true);
}
if ($authentication) {
try {
$token = get_auth_token();
$auth = Auth::instance();
$auth->init($token);
if (!$auth->isLogin() || !$auth->isSuperAdmin()) {
$this->execError("You are not super administrator or not logged in", true);
}
} catch (TokenExpirationException) {
$this->execError(__('Token expiration'));
}
}
$this->beforeExecution();
$this->outputFlag('link-success');
if (!empty($command['notes'])) {
$this->output('> ' . __($command['notes']), false);
}
$this->output('> ' . $command['command'], false);
$procEnv = $this->getProcEnv();
$this->process = proc_open($command['command'], $this->descriptorsPec, $this->pipes, $command['cwd'], $procEnv);
if (!is_resource($this->process)) {
$this->execError('Failed to execute', true);
}
if (isset($this->pipes[0])) {
fclose($this->pipes[0]);
unset($this->pipes[0]);
}
$lastHeartbeat = time();
$heartbeatInterval = 15;
while ($this->getProcStatus()) {
$contents = file_get_contents($this->outputFile);
if (strlen($contents) && $this->outputContent != $contents) {
$newOutput = str_replace($this->outputContent, '', $contents);
$this->checkOutput($contents, $newOutput);
if (strlen(trim($newOutput)) > 0) {
$this->output($newOutput);
$this->outputContent = $contents;
$lastHeartbeat = time();
}
}
if ($this->procStatusMark === 1 && (time() - $lastHeartbeat) >= $heartbeatInterval) {
$this->sendHeartbeat();
$lastHeartbeat = time();
}
if ($this->procStatusMark === 2) {
$this->output('exitCode: ' . $this->procStatusData['exitcode']);
if ($this->procStatusData['exitcode'] === 0) {
if ($this->successCallback()) {
$this->outputFlag('exec-success');
} else {
$this->output('Error: Command execution succeeded, but callback execution failed');
$this->outputFlag('exec-error');
}
} else {
$this->outputFlag('exec-error');
}
}
usleep(500000);
}
foreach ($this->pipes as $pipe) {
fclose($pipe);
}
proc_close($this->process);
$this->outputFlag('exec-completed');
}
protected function getProcEnv(): ?array
{
$env = getenv();
if (!is_array($env)) {
return null;
}
$env['CI'] = '1';
$env['FORCE_COLOR'] = '0';
return $env;
}
public function getProcStatus(): bool
{
$this->procStatusData = proc_get_status($this->process);
if ($this->procStatusData['running']) {
$this->procStatusMark = 1;
return true;
} elseif ($this->procStatusMark === 1) {
$this->procStatusMark = 2;
return true;
} else {
return false;
}
}
public function output(string $data, bool $callback = true): void
{
$data = self::outputFilter($data);
$data = [
'data' => $data,
'uuid' => $this->uuid,
'extend' => $this->extend,
'key' => $this->commandKey,
];
$data = json_encode($data, JSON_UNESCAPED_UNICODE);
if ($data) {
$this->finalOutput($data);
if ($callback) $this->outputCallback($data);
@ob_flush();
}
}
public function checkOutput(string $outputs, string $rowOutput): void
{
if (str_contains($rowOutput, '(Y/n)')) {
$this->execError('Interactive output detected, please manually execute the command to confirm the situation.', true);
}
}
public function outputFlag(string $flag): void
{
$this->output($this->flag[$flag], false);
}
protected function sendHeartbeat(): void
{
$request = request();
$connection = $request && isset($request->connection) ? $request->connection : null;
if ($connection) {
$connection->send(new ServerSentEvents(['' => 'keepalive']));
}
@ob_flush();
}
public function outputCallback($data): void
{
}
public function successCallback(): bool
{
if (stripos($this->commandKey, '.')) {
$commandKeyArr = explode('.', $this->commandKey);
$commandPKey = $commandKeyArr[0] ?? '';
} else {
$commandPKey = $this->commandKey;
}
if ($commandPKey == 'web-build') {
if (!self::mvDist()) {
$this->output('Build succeeded, but move file failed. Please operate manually.');
return false;
}
} elseif ($commandPKey == 'web-install' && $this->extend && class_exists(\app\admin\library\module\Manage::class)) {
[$type, $value] = explode(':', $this->extend);
if ($type == 'module-install' && $value) {
\app\admin\library\module\Manage::instance($value)->dependentInstallComplete('npm');
}
} elseif ($commandPKey == 'composer' && $this->extend && class_exists(\app\admin\library\module\Manage::class)) {
[$type, $value] = explode(':', $this->extend);
if ($type == 'module-install' && $value) {
\app\admin\library\module\Manage::instance($value)->dependentInstallComplete('composer');
}
} elseif ($commandPKey == 'nuxt-install' && $this->extend && class_exists(\app\admin\library\module\Manage::class)) {
[$type, $value] = explode(':', $this->extend);
if ($type == 'module-install' && $value) {
\app\admin\library\module\Manage::instance($value)->dependentInstallComplete('nuxt_npm');
}
}
return true;
}
public function beforeExecution(): void
{
if ($this->commandKey == 'test.pnpm') {
@unlink(root_path() . 'public' . DIRECTORY_SEPARATOR . 'npm-install-test' . DIRECTORY_SEPARATOR . 'pnpm-lock.yaml');
} elseif ($this->commandKey == 'web-install.pnpm') {
@unlink(root_path() . 'web' . DIRECTORY_SEPARATOR . 'pnpm-lock.yaml');
}
}
public static function outputFilter($str): string
{
$str = trim($str);
$preg = '/\x1b\[(.*?)m/i';
$str = preg_replace($preg, '', $str);
$str = str_replace(["\r\n", "\r", "\n"], "\n", $str);
return mb_convert_encoding($str, 'UTF-8', 'UTF-8,GBK,GB2312,BIG5');
}
public function execError($error, $break = false): void
{
$this->output('Error:' . $error);
$this->outputFlag('exec-error');
if ($break) $this->break();
}
public function break(): void
{
throw new TerminalBreakException();
}
public static function getOutputFromProc($commandKey): bool|string
{
if (!function_exists('proc_open') || !function_exists('proc_close')) {
return false;
}
$command = self::getCommand($commandKey);
if (!$command) {
return false;
}
$descriptorsPec = [1 => ['pipe', 'w'], 2 => ['pipe', 'w']];
$process = proc_open($command['command'], $descriptorsPec, $pipes, null, null);
if (is_resource($process)) {
$info = stream_get_contents($pipes[1]);
$info .= stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
proc_close($process);
return self::outputFilter($info);
}
return '';
}
public static function mvDist(): bool
{
$distPath = root_path() . self::$distDir . DIRECTORY_SEPARATOR;
$indexHtmlPath = $distPath . 'index.html';
$assetsPath = $distPath . 'assets';
if (!file_exists($indexHtmlPath) || !file_exists($assetsPath)) {
return false;
}
$toIndexHtmlPath = root_path() . 'public' . DIRECTORY_SEPARATOR . 'index.html';
$toAssetsPath = root_path() . 'public' . DIRECTORY_SEPARATOR . 'assets';
@unlink($toIndexHtmlPath);
Filesystem::delDir($toAssetsPath);
if (rename($indexHtmlPath, $toIndexHtmlPath) && rename($assetsPath, $toAssetsPath)) {
Filesystem::delDir($distPath);
return true;
} else {
return false;
}
}
public static function changeTerminalConfig($config = []): bool
{
$request = request();
$oldPackageManager = config('terminal.npm_package_manager');
$newPackageManager = $request ? $request->post('manager', $config['manager'] ?? $oldPackageManager) : $oldPackageManager;
if ($oldPackageManager == $newPackageManager) {
return true;
}
$buildConfigFile = config_path('terminal.php');
$buildConfigContent = @file_get_contents($buildConfigFile);
$buildConfigContent = preg_replace("/'npm_package_manager'(\s+)=>(\s+)'$oldPackageManager'/", "'npm_package_manager'\$1=>\$2'$newPackageManager'", $buildConfigContent);
$result = @file_put_contents($buildConfigFile, $buildConfigContent);
return (bool)$result;
}
public function finalOutput(string $data): void
{
$request = request();
$connection = $request && isset($request->connection) ? $request->connection : null;
if ($connection) {
$connection->send(new ServerSentEvents(['event' => 'message', 'data' => $data]));
} else {
echo 'data: ' . $data . "\n\n";
}
}
public function sendHeader(): void
{
$request = request();
$headers = array_merge($request->allowCrossDomainHeaders ?? [], [
'X-Accel-Buffering' => 'no',
'Content-Type' => 'text/event-stream',
'Cache-Control' => 'no-cache',
]);
$connection = $request && isset($request->connection) ? $request->connection : null;
if ($connection) {
$connection->send(new WorkermanResponse(200, $headers, "\r\n"));
} else {
foreach ($headers as $name => $val) {
header($name . (!is_null($val) ? ':' . $val : ''));
}
}
}
}