This commit is contained in:
crazywhalecc
2025-11-30 15:35:04 +08:00
parent f6c818d3c0
commit 14bfb4198a
179 changed files with 19502 additions and 655 deletions

View File

@@ -0,0 +1,191 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Runtime\Shell;
use StaticPHP\Exception\InterruptException;
use StaticPHP\Exception\SPCInternalException;
use StaticPHP\Util\FileSystem;
/**
* A default shell implementation that does not support custom commands.
* Used as a internal command caller when some place needs os-irrelevant shell.
*/
class DefaultShell extends Shell
{
/**
* @internal
*/
public function exec(string $cmd): static
{
throw new SPCInternalException('DefaultShell does not support custom command execution.');
}
/**
* Execute a cURL command to fetch data from a URL.
*/
public function executeCurl(string $url, string $method = 'GET', array $headers = [], array $hooks = [], int $retries = 0): false|string
{
foreach ($hooks as $hook) {
$hook($method, $url, $headers);
}
$url_arg = escapeshellarg($url);
$method_arg = match ($method) {
'GET' => '',
'HEAD' => '-I',
default => "-X {$method}",
};
$header_arg = implode(' ', array_map(fn ($v) => '"-H' . $v . '"', $headers));
$retry_arg = $retries > 0 ? "--retry {$retries}" : '';
$cmd = SPC_CURL_EXEC . " -sfSL {$retry_arg} {$method_arg} {$header_arg} {$url_arg}";
$this->logCommandInfo($cmd);
$result = $this->passthru($cmd, console_output: false, capture_output: true, throw_on_error: false);
$ret = $result['code'];
$output = $result['output'];
if ($ret !== 0) {
logger()->debug("[CURL ERROR] Command exited with code: {$ret}");
}
if ($ret === 2 || $ret === -1073741510) {
throw new InterruptException(sprintf('Canceled fetching "%s"', $url));
}
if ($ret !== 0) {
return false;
}
return trim($output);
}
/**
* Execute a cURL command to download a file from a URL.
*/
public function executeCurlDownload(string $url, string $path, array $headers = [], array $hooks = [], int $retries = 0): void
{
foreach ($hooks as $hook) {
$hook('GET', $url, $headers);
}
$url_arg = escapeshellarg($url);
$path_arg = escapeshellarg($path);
$header_arg = implode(' ', array_map(fn ($v) => '"-H' . $v . '"', $headers));
$retry_arg = $retries > 0 ? "--retry {$retries}" : '';
$check = $this->console_putput ? '#' : 's';
$cmd = clean_spaces(SPC_CURL_EXEC . " -{$check}fSL {$retry_arg} {$header_arg} -o {$path_arg} {$url_arg}");
$this->logCommandInfo($cmd);
logger()->debug('[CURL DOWNLOAD] ' . $cmd);
$this->passthru($cmd, $this->console_putput, capture_output: false, throw_on_error: true);
}
/**
* Execute a Git clone command to clone a repository.
*/
public function executeGitClone(string $url, string $branch, string $path, bool $shallow = true, ?array $submodules = null): void
{
$path = FileSystem::convertPath($path);
if (file_exists($path)) {
FileSystem::removeDir($path);
}
$git = SPC_GIT_EXEC;
$url_arg = escapeshellarg($url);
$branch_arg = escapeshellarg($branch);
$path_arg = escapeshellarg($path);
$shallow_arg = $shallow ? '--depth 1 --single-branch' : '';
$submodules_arg = ($submodules === null && $shallow) ? '--recursive --shallow-submodules' : ($submodules === null ? '--recursive' : '');
$cmd = clean_spaces("{$git} clone --config core.autocrlf=false --branch {$branch_arg} {$shallow_arg} {$submodules_arg} {$url_arg} {$path_arg}");
$this->logCommandInfo($cmd);
logger()->debug("[GIT CLONE] {$cmd}");
$this->passthru($cmd, $this->console_putput, capture_output: false, throw_on_error: true);
if ($submodules !== null) {
$depth_flag = $shallow ? '--depth 1' : '';
foreach ($submodules as $submodule) {
$submodule = escapeshellarg($submodule);
$submodule_cmd = clean_spaces("cd {$path_arg} && {$git} submodule update --init {$depth_flag} {$submodule}");
$this->logCommandInfo($submodule_cmd);
logger()->debug("[GIT SUBMODULE] {$submodule_cmd}");
$this->passthru($submodule_cmd, $this->console_putput, capture_output: false, throw_on_error: true);
}
}
}
/**
* Execute a tar command to extract an archive.
*
* @param string $archive_path Path to the archive file
* @param string $target_path Path to extract to
* @param string $compression Compression type: 'gz', 'bz2', 'xz', or 'none'
* @param int $strip Number of leading components to strip (default: 1)
*/
public function executeTarExtract(string $archive_path, string $target_path, string $compression, int $strip = 1): void
{
$archive_arg = escapeshellarg(FileSystem::convertPath($archive_path));
$target_arg = escapeshellarg(FileSystem::convertPath($target_path));
$compression_flag = match ($compression) {
'gz' => '-z',
'bz2' => '-j',
'xz' => '-J',
'none' => '',
default => throw new SPCInternalException("Unknown compression type: {$compression}"),
};
$mute = $this->console_putput ? '' : ' 2>/dev/null';
$cmd = "tar {$compression_flag}xf {$archive_arg} --strip-components {$strip} -C {$target_arg}{$mute}";
$this->logCommandInfo($cmd);
logger()->debug("[TAR EXTRACT] {$cmd}");
$this->passthru($cmd, $this->console_putput, capture_output: false, throw_on_error: true);
}
/**
* Execute an unzip command to extract a zip archive.
*
* @param string $zip_path Path to the zip file
* @param string $target_path Path to extract to
*/
public function executeUnzip(string $zip_path, string $target_path): void
{
$zip_arg = escapeshellarg(FileSystem::convertPath($zip_path));
$target_arg = escapeshellarg(FileSystem::convertPath($target_path));
$mute = $this->console_putput ? '' : ' > /dev/null';
$cmd = "unzip {$zip_arg} -d {$target_arg}{$mute}";
$this->logCommandInfo($cmd);
logger()->debug("[UNZIP] {$cmd}");
$this->passthru($cmd, $this->console_putput, capture_output: false, throw_on_error: true);
}
/**
* Execute a 7za command to extract an archive (Windows).
*
* @param string $archive_path Path to the archive file
* @param string $target_path Path to extract to
* @param bool $is_txz Whether this is a .txz/.tar.xz file that needs double extraction
*/
public function execute7zExtract(string $archive_path, string $target_path, bool $is_txz = false): void
{
$sdk_path = getenv('PHP_SDK_PATH');
if ($sdk_path === false) {
throw new SPCInternalException('PHP_SDK_PATH environment variable is not set');
}
$_7z = escapeshellarg(FileSystem::convertPath($sdk_path . '/bin/7za.exe'));
$archive_arg = escapeshellarg(FileSystem::convertPath($archive_path));
$target_arg = escapeshellarg(FileSystem::convertPath($target_path));
$mute = $this->console_putput ? '' : ' > NUL';
if ($is_txz) {
// txz/tar.xz contains a tar file inside, extract twice
$cmd = "{$_7z} x {$archive_arg} -so | {$_7z} x -si -ttar -o{$target_arg} -y{$mute}";
} else {
$cmd = "{$_7z} x {$archive_arg} -o{$target_arg} -y{$mute}";
}
$this->logCommandInfo($cmd);
logger()->debug("[7Z EXTRACT] {$cmd}");
$this->passthru($cmd, $this->console_putput, capture_output: false, throw_on_error: true);
}
}

View File

@@ -0,0 +1,259 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Runtime\Shell;
use StaticPHP\DI\ApplicationContext;
use StaticPHP\Exception\ExecutionException;
abstract class Shell
{
protected ?string $cd = null;
protected bool $console_putput;
protected array $env = [];
protected string $last_cmd = '';
protected readonly bool $enable_log_file;
protected static mixed $passthru_callback = null;
public function __construct(?bool $console_output = null, bool $enable_log_file = true)
{
$this->console_putput = $console_output ?? ApplicationContext::isDebug();
$this->enable_log_file = $enable_log_file;
}
public static function passthruCallback(?callable $callback): void
{
static::$passthru_callback = $callback;
}
/**
* Equivalent to `cd` command in shell.
*
* @param string $dir Directory to change to
*/
public function cd(string $dir): static
{
logger()->debug('Entering dir: ' . $dir);
$c = clone $this;
$c->cd = $dir;
return $c;
}
/**
* Set temporarily defined environment variables for current shell commands.
*
* @param array<string, string> $env Environment variables sets
*/
public function setEnv(array $env): static
{
foreach ($env as $k => $v) {
if (trim($v) === '') {
continue;
}
$this->env[$k] = trim($v);
}
return $this;
}
/**
* Append temporarily defined environment variables for current shell commands.
*
* @param array<string, string> $env Environment variables sets
*/
public function appendEnv(array $env): static
{
foreach ($env as $k => $v) {
if ($v === '') {
continue;
}
if (!isset($this->env[$k])) {
$this->env[$k] = $v;
} else {
$this->env[$k] = "{$v} {$this->env[$k]}";
}
}
return $this;
}
/**
* Executes a command in the shell.
*/
abstract public function exec(string $cmd): static;
/**
* Returns the last executed command.
*/
public function getLastCommand(): string
{
return $this->last_cmd;
}
/**
* Returns unix-style environment variable string.
*/
public function getEnvString(): string
{
$str = '';
foreach ($this->env as $k => $v) {
$str .= ' ' . $k . '="' . $v . '"';
}
return trim($str);
}
/**
* Logs the command information to a log file.
*/
protected function logCommandInfo(string $cmd): void
{
if (!$this->enable_log_file) {
return;
}
// write executed command to log file using fwrite
$log_file = fopen(SPC_SHELL_LOG, 'a');
fwrite($log_file, "\n>>>>>>>>>>>>>>>>>>>>>>>>>> [" . date('Y-m-d H:i:s') . "]\n");
fwrite($log_file, "> Executing command: {$cmd}\n");
// get the backtrace to find the file and line number
$backtrace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2);
if (isset($backtrace[1]['file'], $backtrace[1]['line'])) {
$file = $backtrace[1]['file'];
$line = $backtrace[1]['line'];
fwrite($log_file, "> Called from: {$file} at line {$line}\n");
}
fwrite($log_file, "> Environment variables: {$this->getEnvString()}\n");
if ($this->cd !== null) {
fwrite($log_file, "> Working dir: {$this->cd}\n");
}
fwrite($log_file, "\n");
}
/**
* Executes a command with console and log file output.
*
* @param string $cmd Full command to execute (including cd and env vars)
* @param bool $console_output If true, output will be printed to console
* @param null|string $original_command Original command string for logging
* @param bool $capture_output If true, capture and return output
* @param bool $throw_on_error If true, throw exception on non-zero exit code
*
* @return array{code: int, output: string} Returns exit code and captured output
*/
protected function passthru(
string $cmd,
bool $console_output = false,
?string $original_command = null,
bool $capture_output = false,
bool $throw_on_error = true
): array {
$file_res = null;
if ($this->enable_log_file) {
// write executed command to the log file using fwrite
$file_res = fopen(SPC_SHELL_LOG, 'a');
}
if ($console_output) {
$console_res = STDOUT;
}
$descriptors = [
0 => ['file', 'php://stdin', 'r'], // stdin
1 => ['pipe', 'w'], // stdout
2 => ['pipe', 'w'], // stderr
];
$process = proc_open($cmd, $descriptors, $pipes);
$output_value = '';
try {
if (!is_resource($process)) {
throw new ExecutionException(
cmd: $original_command ?? $cmd,
message: 'Failed to open process for command, proc_open() failed.',
code: -1,
cd: $this->cd,
env: $this->env
);
}
// fclose($pipes[0]);
stream_set_blocking($pipes[1], false);
stream_set_blocking($pipes[2], false);
while (true) {
$status = proc_get_status($process);
if (!$status['running']) {
foreach ([$pipes[1], $pipes[2]] as $pipe) {
while (($chunk = fread($pipe, 8192)) !== false && $chunk !== '') {
if ($console_output) {
fwrite($console_res, $chunk);
}
if ($file_res !== null) {
fwrite($file_res, $chunk);
}
if ($capture_output) {
$output_value .= $chunk;
}
}
}
// check exit code
if ($throw_on_error && $status['exitcode'] !== 0) {
if ($file_res !== null) {
fwrite($file_res, "Command exited with non-zero code: {$status['exitcode']}\n");
}
throw new ExecutionException(
cmd: $original_command ?? $cmd,
message: "Command exited with non-zero code: {$status['exitcode']}",
code: $status['exitcode'],
cd: $this->cd,
env: $this->env,
);
}
break;
}
if (static::$passthru_callback !== null) {
$callback = static::$passthru_callback;
$callback();
}
$read = [$pipes[1], $pipes[2]];
$write = null;
$except = null;
$ready = stream_select($read, $write, $except, 0, 100000);
if ($ready === false) {
continue;
}
if ($ready > 0) {
foreach ($read as $pipe) {
while (($chunk = fread($pipe, 8192)) !== false && $chunk !== '') {
if ($console_output) {
fwrite($console_res, $chunk);
}
if ($file_res !== null) {
fwrite($file_res, $chunk);
}
if ($capture_output) {
$output_value .= $chunk;
}
}
}
}
}
return [
'code' => $status['exitcode'],
'output' => $output_value,
];
} finally {
fclose($pipes[1]);
fclose($pipes[2]);
if ($file_res !== null) {
fclose($file_res);
}
proc_close($process);
}
}
}

View File

@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Runtime\Shell;
use StaticPHP\Exception\SPCInternalException;
use StaticPHP\Package\LibraryPackage;
use StaticPHP\Runtime\SystemTarget;
use ZM\Logger\ConsoleColor;
/**
* Unix-like OS shell command executor.
*
* This class provides methods to execute shell commands in a Unix-like environment.
* It supports setting environment variables and changing the working directory.
*/
class UnixShell extends Shell
{
public function __construct(?bool $console_output = null)
{
if (PHP_OS_FAMILY === 'Windows') {
throw new SPCInternalException('Windows cannot use UnixShell');
}
parent::__construct($console_output);
}
public function exec(string $cmd): static
{
$cmd = clean_spaces($cmd);
/* @phpstan-ignore-next-line */
logger()->info(ConsoleColor::yellow('[EXEC] ') . ConsoleColor::green($cmd));
$original_command = $cmd;
$this->logCommandInfo($original_command);
$this->last_cmd = $cmd = $this->getExecString($cmd);
$this->passthru($cmd, $this->console_putput, $original_command, capture_output: false, throw_on_error: true);
return $this;
}
/**
* Init the environment variable that common build will be used.
*
* @param LibraryPackage $library Library package
*/
public function initializeEnv(LibraryPackage $library): UnixShell
{
$this->setEnv([
'CFLAGS' => $library->getLibExtraCFlags(),
'CXXFLAGS' => $library->getLibExtraCXXFlags(),
'LDFLAGS' => $library->getLibExtraLdFlags(),
'LIBS' => $library->getLibExtraLibs() . SystemTarget::getRuntimeLibs(),
]);
return $this;
}
/**
* Execute a command and return the result.
*
* @param string $cmd Command to execute
* @param bool $with_log Whether to log the command
* @return array{0: int, 1: string[]} Return code and output lines
*/
public function execWithResult(string $cmd, bool $with_log = true): array
{
if ($with_log) {
/* @phpstan-ignore-next-line */
logger()->info(ConsoleColor::blue('[EXEC] ') . ConsoleColor::green($cmd));
} else {
/* @phpstan-ignore-next-line */
logger()->debug(ConsoleColor::blue('[EXEC] ') . ConsoleColor::gray($cmd));
}
$cmd = $this->getExecString($cmd);
$this->logCommandInfo($cmd);
$result = $this->passthru($cmd, $this->console_putput, $cmd, capture_output: true, throw_on_error: false);
$out = explode("\n", $result['output']);
return [$result['code'], $out];
}
private function getExecString(string $cmd): string
{
// logger()->debug('Executed at: ' . debug_backtrace()[0]['file'] . ':' . debug_backtrace()[0]['line']);
$env_str = $this->getEnvString();
if (!empty($env_str)) {
$cmd = "{$env_str} {$cmd}";
}
if ($this->cd !== null) {
$cmd = 'cd ' . escapeshellarg($this->cd) . ' && ' . $cmd;
}
return $cmd;
}
}

View File

@@ -0,0 +1,156 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Runtime\Shell;
use StaticPHP\Exception\ExecutionException;
use StaticPHP\Exception\SPCInternalException;
use ZM\Logger\ConsoleColor;
class WindowsCmd extends Shell
{
public function __construct(?bool $debug = null)
{
if (PHP_OS_FAMILY !== 'Windows') {
throw new SPCInternalException('Only windows can use WindowsCmd');
}
parent::__construct($debug);
}
public function exec(string $cmd): static
{
/* @phpstan-ignore-next-line */
logger()->info(ConsoleColor::yellow('[EXEC] ') . ConsoleColor::green($cmd));
$original_command = $cmd;
$this->logCommandInfo($original_command);
$this->last_cmd = $cmd = $this->getExecString($cmd);
// echo $cmd . PHP_EOL;
$this->passthru($cmd, $this->console_putput, $original_command, capture_output: false, throw_on_error: true);
return $this;
}
public function execWithWrapper(string $wrapper, string $args): WindowsCmd
{
return $this->exec($wrapper . ' "' . str_replace('"', '^"', $args) . '"');
}
public function execWithResult(string $cmd, bool $with_log = true): array
{
if ($with_log) {
/* @phpstan-ignore-next-line */
logger()->info(ConsoleColor::blue('[EXEC] ') . ConsoleColor::green($cmd));
} else {
logger()->debug('Running command with result: ' . $cmd);
}
$cmd = $this->getExecString($cmd);
$result = $this->passthru($cmd, $this->console_putput, $cmd, capture_output: true, throw_on_error: false);
$out = explode("\n", $result['output']);
return [$result['code'], $out];
}
public function setEnv(array $env): static
{
// windows currently does not support setting environment variables
throw new SPCInternalException('Windows does not support setting environment variables in shell commands.');
}
public function appendEnv(array $env): static
{
// windows currently does not support appending environment variables
throw new SPCInternalException('Windows does not support appending environment variables in shell commands.');
}
public function getLastCommand(): string
{
return $this->last_cmd;
}
/**
* Executes a command with console and log file output.
*
* @param string $cmd Full command to execute (including cd and env vars)
* @param bool $console_output If true, output will be printed to console
* @param null|string $original_command Original command string for logging
* @param bool $capture_output If true, capture and return output
* @param bool $throw_on_error If true, throw exception on non-zero exit code
*
* @return array{code: int, output: string} Returns exit code and captured output
*/
protected function passthru(
string $cmd,
bool $console_output = false,
?string $original_command = null,
bool $capture_output = false,
bool $throw_on_error = true
): array {
$file_res = null;
if ($this->enable_log_file) {
$file_res = fopen(SPC_SHELL_LOG, 'a');
}
$output_value = '';
try {
$process = popen($cmd . ' 2>&1', 'r');
if (!$process) {
throw new ExecutionException(
cmd: $original_command ?? $cmd,
message: 'Failed to open process for command, popen() failed.',
code: -1,
cd: $this->cd,
env: $this->env
);
}
while (($line = fgets($process)) !== false) {
if (static::$passthru_callback !== null) {
$callback = static::$passthru_callback;
$callback();
}
if ($console_output) {
echo $line;
}
if ($file_res !== null) {
fwrite($file_res, $line);
}
if ($capture_output) {
$output_value .= $line;
}
}
$result_code = pclose($process);
if ($throw_on_error && $result_code !== 0) {
if ($file_res !== null) {
fwrite($file_res, "Command exited with non-zero code: {$result_code}\n");
}
throw new ExecutionException(
cmd: $original_command ?? $cmd,
message: "Command exited with non-zero code: {$result_code}",
code: $result_code,
cd: $this->cd,
env: $this->env,
);
}
return [
'code' => $result_code,
'output' => $output_value,
];
} finally {
if ($file_res !== null) {
fclose($file_res);
}
}
}
private function getExecString(string $cmd): string
{
if ($this->cd !== null) {
$cmd = 'cd /d ' . escapeshellarg($this->cd) . ' && ' . $cmd;
}
return $cmd;
}
}