feat(self-update): resolve latest via Forgejo/gitea releases API
This commit is contained in:
@@ -4,6 +4,7 @@ namespace Toolbox\Commands;
|
||||
|
||||
use Toolbox\Config;
|
||||
use Toolbox\SelfUpdater;
|
||||
use Toolbox\Semver;
|
||||
use Toolbox\ToolboxException;
|
||||
|
||||
final class SelfUpdateCommand extends Command
|
||||
@@ -19,14 +20,24 @@ final class SelfUpdateCommand extends Command
|
||||
$from = $this->option($parsed['options'], 'from', Config::updateUrl());
|
||||
$target = $this->option($parsed['options'], 'target', Config::binPath());
|
||||
$temp = $this->option($parsed['options'], 'temp', sys_get_temp_dir());
|
||||
$force = $this->flag($parsed['options'], 'force');
|
||||
|
||||
if ($from === null || $target === null) {
|
||||
$this->console->error('Faltan --from o --target.');
|
||||
return 1;
|
||||
}
|
||||
|
||||
$updater = new SelfUpdater($this->console);
|
||||
|
||||
try {
|
||||
(new SelfUpdater($this->console))->update($from, $target, $temp);
|
||||
if ($this->flag($parsed['options'], 'check')) {
|
||||
$tag = $updater->available($from);
|
||||
$this->console->out(' actual: v' . Config::VERSION);
|
||||
$this->console->out(' disponible: ' . ($tag !== null ? 'v' . ltrim($tag, 'vV') : '(desconocido)'));
|
||||
return $tag === null || Semver::compare(Config::VERSION, ltrim($tag, 'vV')) >= 0 ? 0 : 1;
|
||||
}
|
||||
|
||||
$updater->update($from, $target, $temp, $force);
|
||||
} catch (ToolboxException $e) {
|
||||
$this->console->error($e->getMessage());
|
||||
return 1;
|
||||
|
||||
@@ -49,7 +49,7 @@ final class Config
|
||||
public static function updateUrl(): string
|
||||
{
|
||||
return self::env('DUCKBRAIN_UPDATE_URL')
|
||||
?: 'https://git.kj2.me/kj/duckbrain-toolbox/releases/download/latest';
|
||||
?: 'https://git.kj2.me/api/v1/repos/kj/duckbrain-toolbox/releases';
|
||||
}
|
||||
|
||||
public static function binPath(): string
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
namespace Toolbox;
|
||||
|
||||
/**
|
||||
* Autoactualización del instalador global: descarga el phar, verifica su
|
||||
* sha256 contra el publicado y reemplaza el binario de forma atómica.
|
||||
* 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
|
||||
{
|
||||
@@ -12,10 +14,17 @@ final class SelfUpdater
|
||||
{
|
||||
}
|
||||
|
||||
public function update(string $from, string $target, string $tempDir): void
|
||||
public function update(string $from, string $target, string $tempDir, bool $force = false): void
|
||||
{
|
||||
$source = rtrim($from, '/') . '/duckbrain.phar';
|
||||
$sumUrl = $source . '.sha256';
|
||||
[$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);
|
||||
@@ -46,6 +55,99 @@ final class SelfUpdater
|
||||
throw new ToolboxException("No se pudo reemplazar el instalador: {$target}");
|
||||
}
|
||||
|
||||
$this->console->ok('instalador actualizado (' . $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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user