diff --git a/src/Glyph/Commands/UpdateCommand.cs b/src/Glyph/Commands/UpdateCommand.cs new file mode 100644 index 0000000..9e27366 --- /dev/null +++ b/src/Glyph/Commands/UpdateCommand.cs @@ -0,0 +1,65 @@ +using System.CommandLine; +using Glyph.Services; +using Spectre.Console; + +namespace Glyph.Commands; + +public static class UpdateCommand +{ + public static Command Create() + { + var command = new Command("update") { Description = "Update Glyph to the latest version" }; + + command.SetAction(async (ParseResult parseResult, CancellationToken ct) => + { + var current = UpdateChecker.GetCurrentVersion(); + + await AnsiConsole.Status() + .Spinner(Spinner.Known.Dots) + .StartAsync("Checking for updates...", async ctx => + { + string? latest; + try + { + latest = await UpdateChecker.GetLatestVersionAsync(); + } + catch + { + AnsiConsole.MarkupLine("[red]Failed to check for updates. Please check your internet connection.[/]"); + return; + } + + if (latest == null) + { + AnsiConsole.MarkupLine("[yellow]Could not determine the latest version.[/]"); + return; + } + + if (!UpdateChecker.IsNewerVersion(latest, current)) + { + AnsiConsole.MarkupLine($"[green]Glyph is already up to date ({current}).[/]"); + return; + } + + AnsiConsole.MarkupLine($"Updating Glyph from {current} to {latest}..."); + ctx.Status("Updating..."); + + var (exitCode, output, error) = await ProcessRunner.RunAsync( + "dotnet", "tool update --global Glyph"); + + if (exitCode == 0) + { + AnsiConsole.MarkupLine($"[green]Successfully updated Glyph to {latest}![/]"); + } + else + { + AnsiConsole.MarkupLine("[red]Update failed.[/]"); + if (!string.IsNullOrEmpty(error)) + AnsiConsole.WriteLine(error); + } + }); + }); + + return command; + } +} diff --git a/src/Glyph/Program.cs b/src/Glyph/Program.cs index f1a205b..d0735ef 100644 --- a/src/Glyph/Program.cs +++ b/src/Glyph/Program.cs @@ -3,12 +3,18 @@ using Glyph.Services; using Spectre.Console; -if (!GitService.IsGitRepository()) +// Allow 'update' to run outside a git repository +var isUpdateCommand = args.Length > 0 && args[0] == "update"; + +if (!isUpdateCommand && !GitService.IsGitRepository()) { AnsiConsole.MarkupLine("[red]Error:[/] Not a git repository (or any parent up to mount point)."); return 1; } +// Start the update check in the background (non-blocking) +var updateCheckTask = UpdateChecker.CheckForUpdateAsync(); + var rootCommand = new RootCommand("Glyph - A Git TUI for trunk-based development workflows"); rootCommand.Subcommands.Add(TreeCommand.Create()); rootCommand.Subcommands.Add(ParentCommand.Create()); @@ -20,6 +26,7 @@ rootCommand.Subcommands.Add(CommitCommand.Create()); rootCommand.Subcommands.Add(EditCommand.Create()); rootCommand.Subcommands.Add(ShipCommand.Create()); +rootCommand.Subcommands.Add(UpdateCommand.Create()); // Default to tree view when no subcommand is given rootCommand.SetAction(parseResult => @@ -27,4 +34,14 @@ TreeCommand.Create().Parse(Array.Empty()).Invoke(); }); -return rootCommand.Parse(args).Invoke(); +var result = rootCommand.Parse(args).Invoke(); + +// Show update notification if available (don't delay if check hasn't finished) +if (updateCheckTask.IsCompleted) +{ + var updateMessage = await updateCheckTask; + if (updateMessage != null) + AnsiConsole.MarkupLine(updateMessage); +} + +return result; diff --git a/src/Glyph/Services/UpdateChecker.cs b/src/Glyph/Services/UpdateChecker.cs new file mode 100644 index 0000000..1aba5c0 --- /dev/null +++ b/src/Glyph/Services/UpdateChecker.cs @@ -0,0 +1,128 @@ +using System.Net.Http.Json; +using System.Reflection; +using System.Text.Json.Serialization; + +namespace Glyph.Services; + +[JsonSerializable(typeof(UpdateChecker.NuGetVersionIndex))] +internal partial class UpdateCheckerJsonContext : JsonSerializerContext; + +public static class UpdateChecker +{ + private const string NuGetIndexUrl = "https://api.nuget.org/v3-flatcontainer/glyph/index.json"; + private static readonly string CacheDir = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".glyph"); + private static readonly string CacheFile = Path.Combine(CacheDir, "update-check"); + + public static string GetCurrentVersion() + { + var version = typeof(UpdateChecker).Assembly + .GetCustomAttribute()?.InformationalVersion; + + // Strip any +metadata suffix (e.g. "0.1.0+abc123") + if (version != null) + { + var plusIndex = version.IndexOf('+'); + if (plusIndex >= 0) + version = version[..plusIndex]; + } + + return version ?? "0.0.0"; + } + + public static async Task GetLatestVersionAsync() + { + using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(5) }; + http.DefaultRequestHeaders.UserAgent.ParseAdd("Glyph-UpdateChecker/1.0"); + + var response = await http.GetFromJsonAsync(NuGetIndexUrl, UpdateCheckerJsonContext.Default.NuGetVersionIndex); + if (response?.Versions is not { Count: > 0 }) + return null; + + // Versions are listed oldest to newest; take the last non-prerelease version + for (var i = response.Versions.Count - 1; i >= 0; i--) + { + var v = response.Versions[i]; + if (!v.Contains('-')) + return v; + } + + return response.Versions[^1]; + } + + public static bool IsNewerVersion(string latest, string current) + { + return Version.TryParse(NormalizeVersion(latest), out var latestVer) + && Version.TryParse(NormalizeVersion(current), out var currentVer) + && latestVer > currentVer; + } + + /// + /// Checks for updates at most once per day. Returns a message if an update is + /// available, or null if the version is current (or the check was skipped/failed). + /// + public static async Task CheckForUpdateAsync() + { + try + { + if (!ShouldCheck()) + return null; + + var latest = await GetLatestVersionAsync(); + WriteCacheTimestamp(); + + if (latest == null) + return null; + + var current = GetCurrentVersion(); + if (IsNewerVersion(latest, current)) + return $"[yellow]A new version of Glyph is available: {latest} (current: {current}). Run [bold]glyph update[/] to update.[/]"; + + return null; + } + catch + { + // Never let update checks break the main flow + return null; + } + } + + private static bool ShouldCheck() + { + if (!File.Exists(CacheFile)) + return true; + + var lastCheck = File.GetLastWriteTimeUtc(CacheFile); + return DateTime.UtcNow - lastCheck > TimeSpan.FromHours(24); + } + + private static void WriteCacheTimestamp() + { + try + { + Directory.CreateDirectory(CacheDir); + File.WriteAllText(CacheFile, DateTime.UtcNow.ToString("O")); + } + catch + { + // Ignore cache write failures + } + } + + private static string NormalizeVersion(string version) + { + // Ensure we have at least major.minor for Version.TryParse + var parts = version.Split('.'); + return parts.Length switch + { + 1 => $"{parts[0]}.0", + _ => version + }; + } + + internal sealed class NuGetVersionIndex + { + [JsonPropertyName("versions")] + public List Versions { get; set; } = []; + } +}