Files
duckbrain-toolbox/packages/commands/src/Toolbox/SelfUpdater.php
T

154 lines
5.1 KiB
PHP

<?php
namespace Toolbox;
/**
* Autoactualización del instalador global: detecta la última release vía la
* API de Forgejo/Gitea eligiendo el mayor tag semver de la lista (igual que
* el core con sus tags), o un directorio plano; descarga el phar, verifica
* su sha256 contra el publicado y reemplaza el binario de forma atómica.
*/
final class SelfUpdater
{
public function __construct(private readonly Console $console)
{
}
public function update(string $from, string $target, string $tempDir, bool $force = false): void
{
[$source, $sumUrl, $tag] = $this->resolve($from);
if ($tag !== null) {
$remote = ltrim($tag, 'vV');
if (!$force && Semver::compare(Config::VERSION, $remote) >= 0) {
$this->console->out(' ya está actualizado (v' . Config::VERSION . ')');
return;
}
}
$this->console->out(' descargando ' . $source);
$data = @file_get_contents($source);
if ($data === false) {
throw new ToolboxException("No se pudo descargar el instalador: {$source}");
}
$expected = trim((string) @file_get_contents($sumUrl));
if ($expected === '') {
throw new ToolboxException("No se pudo leer el sha256 publicado: {$sumUrl}");
}
$actual = hash('sha256', $data);
if (!hash_equals($expected, $actual)) {
throw new ToolboxException('sha256 inválido; actualización abortada sin tocar el binario');
}
if (!is_dir($tempDir)) {
mkdir($tempDir, 0777, true);
}
$temp = rtrim($tempDir, '/') . '/duckbrain.phar.' . bin2hex(random_bytes(4));
file_put_contents($temp, $data);
chmod($temp, 0755);
if (!rename($temp, $target)) {
@unlink($temp);
throw new ToolboxException("No se pudo reemplazar el instalador: {$target}");
}
$this->console->ok('instalador actualizado' . ($tag !== null ? ' a v' . ltrim($tag, 'vV') : '') . ' (' . $target . ')');
}
public function available(string $from): ?string
{
return $this->resolve($from)[2];
}
/**
* @return array{0: string, 1: string, 2: ?string} [URL del phar, URL del sha256, tag o null]
*/
private function resolve(string $from): array
{
if (!preg_match('#/releases(\?|$|/)|\.json$#', $from)) {
$source = rtrim($from, '/') . '/duckbrain.phar';
return [$source, $source . '.sha256', null];
}
$json = @file_get_contents($from);
if ($json === false) {
throw new ToolboxException("No se pudo leer la release: {$from}");
}
$data = json_decode($json, true);
if (!is_array($data)) {
throw new ToolboxException("Respuesta de release inválida: {$from}");
}
$candidates = [];
foreach (array_is_list($data) ? $data : [$data] as $release) {
$tag = is_array($release) ? (string) ($release['tag_name'] ?? '') : '';
if ($tag === '' || (is_array($release) && ($release['draft'] ?? false)) || !is_array($release)) {
continue;
}
try {
$version = Semver::normalize($tag);
Semver::parse($version);
} catch (ToolboxException) {
continue;
}
$assets = $this->assets((array) $release, $from);
if ($assets === null) {
continue;
}
$candidates[] = ['version' => $version, 'tag' => $tag, 'phar' => $assets[0], 'sum' => $assets[1]];
}
if ($candidates === []) {
throw new ToolboxException("Ninguna release publicada en '{$from}' incluye duckbrain.phar con su sha256");
}
usort($candidates, static fn (array $a, array $b): int => Semver::compare($b['version'], $a['version']));
$best = $candidates[0];
return [$best['phar'], $best['sum'], $best['tag']];
}
/**
* @return array{0: string, 1: string}|null
*/
private function assets(array $release, string $from): ?array
{
$phar = $sum = null;
foreach ($release['assets'] ?? [] as $asset) {
$name = (string) ($asset['name'] ?? '');
if ($name === 'duckbrain.phar' || $name === 'duckbrain.phar.sha256') {
$url = $this->absolute((string) ($asset['browser_download_url'] ?? $asset['download_url'] ?? ''), $from);
if ($url === '') {
return null;
}
if ($name === 'duckbrain.phar') {
$phar = $url;
} else {
$sum = $url;
}
}
}
return $phar !== null && $sum !== null ? [$phar, $sum] : null;
}
private function absolute(string $url, string $base): string
{
if ($url === '' || preg_match('#^(https?|file)://#', $url)) {
return $url;
}
$parts = parse_url($base);
return ($parts['scheme'] ?? 'https') . '://' . ($parts['host'] ?? '') . $url;
}
}