using System.Net.Http.Headers; using System.Text.Json; using System.Text.RegularExpressions; namespace AgentWikiUi.Core; enum InstallMethod { Native, Npm, Homebrew, WinGet } /// Esito del controllo (sola lettura): versione installata (riga di `--version`), ultima pubblicata e se è più nuova. Available null = sconosciuto. record UpdateCheck(string? InstalledText, string? Latest, bool? Available); /// Esito di un aggiornamento: messaggio da mostrare e, se riuscito, le versioni prima e dopo. record UpdateOutcome(bool Ok, string Message, string? Before, string? After); // Aggiornamento di un CLI con il suo comando ufficiale (` update`). Vedi ADR-0004. static class EngineUpdater { public static readonly TimeSpan UpdateTimeout = TimeSpan.FromMinutes(10); static readonly TimeSpan VersionTimeout = TimeSpan.FromSeconds(30); static readonly TimeSpan CheckTimeout = TimeSpan.FromSeconds(10); static readonly HttpClient SharedHttp = NewHttp(); static HttpClient NewHttp() { var http = new HttpClient { Timeout = CheckTimeout }; http.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue(AppInfo.Name, "1.0")); // GitHub lo richiede return http; } /// Primo numero N.N.N del testo ("2.1.300 (Claude Code)", "codex-cli 0.162.1", "rust-v0.162.1"). public static Version? ParseVersion(string? text) { var m = text is null ? null : Regex.Match(text, @"\d+\.\d+\.\d+"); return m is { Success: true } && Version.TryParse(m.Value, out var v) ? v : null; } /// true se è più nuova di ; null se una delle due non si legge. public static bool? IsNewer(string? latest, string? installed) => ParseVersion(latest) is { } l && ParseVersion(installed) is { } i ? l > i : null; /// Controlla se c'è una versione più nuova senza installare nulla. Mai eccezioni: rete o formato inattesi ⇒ sconosciuto. public static async Task CheckAsync(Engine engine, HttpClient? http = null) { var installed = await GetVersionAsync(engine); string? latest = null; try { using var cts = new CancellationTokenSource(CheckTimeout); latest = await engine.FetchLatestVersionAsync(http ?? SharedHttp, cts.Token); } catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or OperationCanceledException or JsonException or KeyNotFoundException or InvalidOperationException) { } return new(installed, latest, IsNewer(latest, installed)); } /// Prima riga di `--version` ripulita; null se l'eseguibile manca o fallisce. public static async Task GetVersionAsync(Engine engine, TimeSpan? timeout = null) { try { var r = await EngineRunner.RunToolAsync(engine, ["--version"], timeout ?? VersionTimeout); if (r.TimedOut || r.Code != 0) return null; return r.Out.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).FirstOrDefault(); } catch (System.ComponentModel.Win32Exception) { return null; } } public static async Task UpdateAsync(Engine engine, TimeSpan? timeout = null) { var before = await GetVersionAsync(engine); (int Code, string Out, string Err, bool TimedOut) r; try { r = await EngineRunner.RunToolAsync(engine, ["update"], timeout ?? UpdateTimeout); } catch (System.ComponentModel.Win32Exception) { return new(false, $"'{engine.Executable}' non trovato nel PATH.", before, null); } var manual = ManualCommand(engine, DetectMethod(PathSearch.Find(engine.Executable))); if (r.TimedOut) return new(false, $"Aggiornamento interrotto: più di {Elapsed(timeout ?? UpdateTimeout)}.\nComando manuale: {manual}", before, null); if (r.Code != 0) return new(false, $"Aggiornamento non riuscito (codice {r.Code}).\n{Tail(r.Err, r.Out)}\nComando manuale: {manual}".Replace("\n\n", "\n"), before, null); var after = await GetVersionAsync(engine); if (before is not null && after is not null && before != after) return new(true, $"Aggiornato da {before} a {after}.", before, after); return new(true, $"Già aggiornato ({after ?? before ?? "versione sconosciuta"}).", before, after); } static string Elapsed(TimeSpan t) => t.TotalMinutes >= 1 ? $"{t.TotalMinutes:0} minuti" : $"{t.TotalSeconds:0} secondi"; // Ultime righe utili dell'output (stderr se c'è, altrimenti stdout). static string Tail(string err, string output) { var text = string.IsNullOrWhiteSpace(err) ? output : err; return string.Join('\n', text.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).TakeLast(4)); } /// Metodo di installazione dedotto dal percorso dell'eseguibile, anche dopo aver risolto i collegamenti simbolici. public static InstallMethod DetectMethod(string? path) { if (path is null) return InstallMethod.Native; foreach (var p in new[] { path, RealPath(path) }) { if (Has(p, "node_modules") || Has(p, ".npm")) return InstallMethod.Npm; if (Has(p, "Cellar") || Has(p, "homebrew") || Has(p, "linuxbrew")) return InstallMethod.Homebrew; if (Has(p, "WinGet") || Has(p, "WindowsApps")) return InstallMethod.WinGet; } return InstallMethod.Native; } static bool Has(string path, string part) => path.Contains(part, StringComparison.OrdinalIgnoreCase); static string RealPath(string path) { try { return new FileInfo(path).ResolveLinkTarget(returnFinalTarget: true)?.FullName ?? path; } catch (IOException) { return path; } } /// Comando da eseguire a mano quando l'aggiornamento automatico non basta (tabella dell'ADR-0004). public static string ManualCommand(Engine engine, InstallMethod method) => (engine.Id, method) switch { ("claude", InstallMethod.Npm) => "npm install -g @anthropic-ai/claude-code@latest", ("claude", InstallMethod.Homebrew) => "brew upgrade claude-code", ("claude", InstallMethod.WinGet) => "winget upgrade Anthropic.ClaudeCode", ("claude", _) => "claude update", ("codex", InstallMethod.Npm) => "npm install -g @openai/codex@latest", ("codex", InstallMethod.Homebrew) => "brew upgrade --cask codex", ("codex", _) => OperatingSystem.IsWindows() ? "powershell -ExecutionPolicy ByPass -c \"irm https://chatgpt.com/codex/install.ps1 | iex\"" : "curl -fsSL https://chatgpt.com/codex/install.sh | sh", ("agy", InstallMethod.Homebrew) => "brew upgrade agy", ("agy", _) => "agy update", _ => $"{engine.Executable} update", }; }