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; } }