diff --git a/Source/Cli.Specs/for_LatestVersion/when_normalizing_a_tag/and_it_carries_no_prefix.cs b/Source/Cli.Specs/for_LatestVersion/when_normalizing_a_tag/and_it_carries_no_prefix.cs new file mode 100644 index 0000000..f719516 --- /dev/null +++ b/Source/Cli.Specs/for_LatestVersion/when_normalizing_a_tag/and_it_carries_no_prefix.cs @@ -0,0 +1,13 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Cli.for_LatestVersion.when_normalizing_a_tag; + +public class and_it_carries_no_prefix : Specification +{ + string? _result; + + void Because() => _result = LatestVersion.NormalizeTag("2.3.6"); + + [Fact] void should_keep_the_version() => _result.ShouldEqual("2.3.6"); +} diff --git a/Source/Cli.Specs/for_LatestVersion/when_normalizing_a_tag/and_it_carries_the_release_prefix.cs b/Source/Cli.Specs/for_LatestVersion/when_normalizing_a_tag/and_it_carries_the_release_prefix.cs new file mode 100644 index 0000000..7f031ea --- /dev/null +++ b/Source/Cli.Specs/for_LatestVersion/when_normalizing_a_tag/and_it_carries_the_release_prefix.cs @@ -0,0 +1,13 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Cli.for_LatestVersion.when_normalizing_a_tag; + +public class and_it_carries_the_release_prefix : Specification +{ + string? _result; + + void Because() => _result = LatestVersion.NormalizeTag("v2.3.6"); + + [Fact] void should_drop_the_prefix() => _result.ShouldEqual("2.3.6"); +} diff --git a/Source/Cli.Specs/for_LatestVersion/when_normalizing_a_tag/and_it_is_empty.cs b/Source/Cli.Specs/for_LatestVersion/when_normalizing_a_tag/and_it_is_empty.cs new file mode 100644 index 0000000..c77b725 --- /dev/null +++ b/Source/Cli.Specs/for_LatestVersion/when_normalizing_a_tag/and_it_is_empty.cs @@ -0,0 +1,13 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Cli.for_LatestVersion.when_normalizing_a_tag; + +public class and_it_is_empty : Specification +{ + string? _result; + + void Because() => _result = LatestVersion.NormalizeTag(" "); + + [Fact] void should_have_nothing_to_report() => _result.ShouldBeNull(); +} diff --git a/Source/Cli.Specs/for_LatestVersion/when_resolving_the_source/and_installed_as_a_dotnet_tool.cs b/Source/Cli.Specs/for_LatestVersion/when_resolving_the_source/and_installed_as_a_dotnet_tool.cs new file mode 100644 index 0000000..165ac68 --- /dev/null +++ b/Source/Cli.Specs/for_LatestVersion/when_resolving_the_source/and_installed_as_a_dotnet_tool.cs @@ -0,0 +1,13 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Cli.for_LatestVersion.when_resolving_the_source; + +public class and_installed_as_a_dotnet_tool : Specification +{ + LatestVersionSource _result; + + void Because() => _result = LatestVersion.SourceFor(CliUpdateStrategy.DotNetTool); + + [Fact] void should_read_from_nuget() => _result.ShouldEqual(LatestVersionSource.NuGet); +} diff --git a/Source/Cli.Specs/for_LatestVersion/when_resolving_the_source/and_installed_as_a_linux_binary.cs b/Source/Cli.Specs/for_LatestVersion/when_resolving_the_source/and_installed_as_a_linux_binary.cs new file mode 100644 index 0000000..ce98d3f --- /dev/null +++ b/Source/Cli.Specs/for_LatestVersion/when_resolving_the_source/and_installed_as_a_linux_binary.cs @@ -0,0 +1,13 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Cli.for_LatestVersion.when_resolving_the_source; + +public class and_installed_as_a_linux_binary : Specification +{ + LatestVersionSource _result; + + void Because() => _result = LatestVersion.SourceFor(CliUpdateStrategy.ManualLinux); + + [Fact] void should_read_from_the_github_release() => _result.ShouldEqual(LatestVersionSource.GitHubRelease); +} diff --git a/Source/Cli.Specs/for_LatestVersion/when_resolving_the_source/and_installed_by_hand.cs b/Source/Cli.Specs/for_LatestVersion/when_resolving_the_source/and_installed_by_hand.cs new file mode 100644 index 0000000..0ce16b0 --- /dev/null +++ b/Source/Cli.Specs/for_LatestVersion/when_resolving_the_source/and_installed_by_hand.cs @@ -0,0 +1,13 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Cli.for_LatestVersion.when_resolving_the_source; + +public class and_installed_by_hand : Specification +{ + LatestVersionSource _result; + + void Because() => _result = LatestVersion.SourceFor(CliUpdateStrategy.Manual); + + [Fact] void should_read_from_the_github_release() => _result.ShouldEqual(LatestVersionSource.GitHubRelease); +} diff --git a/Source/Cli.Specs/for_LatestVersion/when_resolving_the_source/and_installed_with_homebrew.cs b/Source/Cli.Specs/for_LatestVersion/when_resolving_the_source/and_installed_with_homebrew.cs new file mode 100644 index 0000000..019c46e --- /dev/null +++ b/Source/Cli.Specs/for_LatestVersion/when_resolving_the_source/and_installed_with_homebrew.cs @@ -0,0 +1,16 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Cli.for_LatestVersion.when_resolving_the_source; + +/// +/// Homebrew upgrades from the tap, which the release workflow writes from a GitHub release - never NuGet. +/// +public class and_installed_with_homebrew : Specification +{ + LatestVersionSource _result; + + void Because() => _result = LatestVersion.SourceFor(CliUpdateStrategy.Homebrew); + + [Fact] void should_read_from_the_github_release() => _result.ShouldEqual(LatestVersionSource.GitHubRelease); +} diff --git a/Source/Cli.Specs/for_UpdateChecker/when_checking_freshness/and_an_update_has_been_waiting_over_a_day.cs b/Source/Cli.Specs/for_UpdateChecker/when_checking_freshness/and_an_update_has_been_waiting_over_a_day.cs new file mode 100644 index 0000000..88317a6 --- /dev/null +++ b/Source/Cli.Specs/for_UpdateChecker/when_checking_freshness/and_an_update_has_been_waiting_over_a_day.cs @@ -0,0 +1,15 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Cli.for_UpdateChecker.when_checking_freshness; + +public class and_an_update_has_been_waiting_over_a_day : Specification +{ + static readonly DateTime _now = new(2026, 7, 30, 20, 0, 0, DateTimeKind.Utc); + + bool _result; + + void Because() => _result = UpdateChecker.IsFresh("2.3.6", _now.AddHours(-25), "2.3.4", _now); + + [Fact] void should_no_longer_be_trusted() => _result.ShouldBeFalse(); +} diff --git a/Source/Cli.Specs/for_UpdateChecker/when_checking_freshness/and_an_update_is_waiting.cs b/Source/Cli.Specs/for_UpdateChecker/when_checking_freshness/and_an_update_is_waiting.cs new file mode 100644 index 0000000..0e7b837 --- /dev/null +++ b/Source/Cli.Specs/for_UpdateChecker/when_checking_freshness/and_an_update_is_waiting.cs @@ -0,0 +1,18 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Cli.for_UpdateChecker.when_checking_freshness; + +/// +/// An update stays available until the user installs it, so this answer survives far longer. +/// +public class and_an_update_is_waiting : Specification +{ + static readonly DateTime _now = new(2026, 7, 30, 20, 0, 0, DateTimeKind.Utc); + + bool _result; + + void Because() => _result = UpdateChecker.IsFresh("2.3.6", _now.AddHours(-12), "2.3.4", _now); + + [Fact] void should_still_be_trusted() => _result.ShouldBeTrue(); +} diff --git a/Source/Cli.Specs/for_UpdateChecker/when_checking_freshness/and_the_up_to_date_answer_has_aged.cs b/Source/Cli.Specs/for_UpdateChecker/when_checking_freshness/and_the_up_to_date_answer_has_aged.cs new file mode 100644 index 0000000..d746eca --- /dev/null +++ b/Source/Cli.Specs/for_UpdateChecker/when_checking_freshness/and_the_up_to_date_answer_has_aged.cs @@ -0,0 +1,20 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Cli.for_UpdateChecker.when_checking_freshness; + +/// +/// A check that lands in the window between a release being tagged and the package becoming visible records +/// the previous version. Trusting that for a day means every release published after it goes unannounced for +/// a day, which is exactly what happened between 2.3.4 and 2.3.6. +/// +public class and_the_up_to_date_answer_has_aged : Specification +{ + static readonly DateTime _now = new(2026, 7, 30, 20, 0, 0, DateTimeKind.Utc); + + bool _result; + + void Because() => _result = UpdateChecker.IsFresh("2.3.4", _now.AddHours(-2), "2.3.4", _now); + + [Fact] void should_no_longer_be_trusted() => _result.ShouldBeFalse(); +} diff --git a/Source/Cli.Specs/for_UpdateChecker/when_checking_freshness/and_the_up_to_date_answer_is_recent.cs b/Source/Cli.Specs/for_UpdateChecker/when_checking_freshness/and_the_up_to_date_answer_is_recent.cs new file mode 100644 index 0000000..10ff0fc --- /dev/null +++ b/Source/Cli.Specs/for_UpdateChecker/when_checking_freshness/and_the_up_to_date_answer_is_recent.cs @@ -0,0 +1,15 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Cli.for_UpdateChecker.when_checking_freshness; + +public class and_the_up_to_date_answer_is_recent : Specification +{ + static readonly DateTime _now = new(2026, 7, 30, 20, 0, 0, DateTimeKind.Utc); + + bool _result; + + void Because() => _result = UpdateChecker.IsFresh("2.3.4", _now.AddMinutes(-30), "2.3.4", _now); + + [Fact] void should_still_be_trusted() => _result.ShouldBeTrue(); +} diff --git a/Source/Cli.Specs/for_UpdateChecker/when_checking_freshness/and_the_user_has_updated_past_the_cached_answer.cs b/Source/Cli.Specs/for_UpdateChecker/when_checking_freshness/and_the_user_has_updated_past_the_cached_answer.cs new file mode 100644 index 0000000..93c1d86 --- /dev/null +++ b/Source/Cli.Specs/for_UpdateChecker/when_checking_freshness/and_the_user_has_updated_past_the_cached_answer.cs @@ -0,0 +1,18 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Cli.for_UpdateChecker.when_checking_freshness; + +/// +/// The cached answer is now behind what is installed, so it says nothing about what comes next. +/// +public class and_the_user_has_updated_past_the_cached_answer : Specification +{ + static readonly DateTime _now = new(2026, 7, 30, 20, 0, 0, DateTimeKind.Utc); + + bool _result; + + void Because() => _result = UpdateChecker.IsFresh("2.3.4", _now.AddHours(-2), "2.3.6", _now); + + [Fact] void should_no_longer_be_trusted() => _result.ShouldBeFalse(); +} diff --git a/Source/Cli.Specs/for_UpdateChecker/when_keying_the_cache/and_the_source_is_nuget.cs b/Source/Cli.Specs/for_UpdateChecker/when_keying_the_cache/and_the_source_is_nuget.cs new file mode 100644 index 0000000..f563da0 --- /dev/null +++ b/Source/Cli.Specs/for_UpdateChecker/when_keying_the_cache/and_the_source_is_nuget.cs @@ -0,0 +1,13 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Cli.for_UpdateChecker.when_keying_the_cache; + +public class and_the_source_is_nuget : Specification +{ + string _result = null!; + + void Because() => _result = UpdateChecker.CacheKeyFor(LatestVersionSource.NuGet, UpdateChecker.CliPackageId); + + [Fact] void should_key_by_the_package() => _result.ShouldEqual(UpdateChecker.CliPackageId); +} diff --git a/Source/Cli.Specs/for_UpdateChecker/when_keying_the_cache/and_the_source_is_the_github_release.cs b/Source/Cli.Specs/for_UpdateChecker/when_keying_the_cache/and_the_source_is_the_github_release.cs new file mode 100644 index 0000000..b11e020 --- /dev/null +++ b/Source/Cli.Specs/for_UpdateChecker/when_keying_the_cache/and_the_source_is_the_github_release.cs @@ -0,0 +1,16 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Cli.for_UpdateChecker.when_keying_the_cache; + +/// +/// Keyed apart from the package so an answer read from NuGet is never served to a native installation. +/// +public class and_the_source_is_the_github_release : Specification +{ + string _result = null!; + + void Because() => _result = UpdateChecker.CacheKeyFor(LatestVersionSource.GitHubRelease, UpdateChecker.CliPackageId); + + [Fact] void should_not_collide_with_the_package_key() => _result.ShouldNotEqual(UpdateChecker.CliPackageId); +} diff --git a/Source/Cli/Commands/Version/SelfUpdateCommand.cs b/Source/Cli/Commands/Version/SelfUpdateCommand.cs index 3088d41..52753b8 100644 --- a/Source/Cli/Commands/Version/SelfUpdateCommand.cs +++ b/Source/Cli/Commands/Version/SelfUpdateCommand.cs @@ -34,9 +34,11 @@ protected override async Task ExecuteAsync(CommandContext context, SelfUpda using var updateCheckCts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); try { + // Asking for an update is the user saying the cached answer is not good enough, so this one + // always goes to the source. expectedNewVersion = await RunWithStatus( "Checking for updates...", - () => UpdateChecker.CheckForUpdate(UpdateChecker.CliPackageId, currentVersion, updateCheckCts.Token)); + () => UpdateChecker.CheckForCliUpdate(currentVersion, true, updateCheckCts.Token)); } catch { diff --git a/Source/Cli/Commands/Version/VersionCommand.cs b/Source/Cli/Commands/Version/VersionCommand.cs index 1243c15..3de2e2a 100644 --- a/Source/Cli/Commands/Version/VersionCommand.cs +++ b/Source/Cli/Commands/Version/VersionCommand.cs @@ -49,9 +49,10 @@ protected override async Task ExecuteAsync(CommandContext context, Chronicl // Server unavailable, misconfigured, or doesn't support GetVersionInfo — all fine. } - // Check NuGet for newer versions — both fire in parallel and never block on failure. + // Check for newer versions — both fire in parallel and never block on failure. The CLI is checked + // against wherever this installation updates from, the server against its NuGet package. using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); - var cliUpdateTask = UpdateChecker.CheckForUpdate(UpdateChecker.CliPackageId, cliVersion, cts.Token); + var cliUpdateTask = UpdateChecker.CheckForUpdate(cliVersion, cts.Token); var serverUpdateTask = serverInfo is not null ? UpdateChecker.CheckForUpdate(UpdateChecker.ServerPackageId, serverInfo.Version, cts.Token) : Task.FromResult(null); diff --git a/Source/Cli/LatestVersion.cs b/Source/Cli/LatestVersion.cs new file mode 100644 index 0000000..19cf1bb --- /dev/null +++ b/Source/Cli/LatestVersion.cs @@ -0,0 +1,121 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Cli; + +/// +/// Reads the latest published version from the places a CLI installation can come from. +/// +public static class LatestVersion +{ + /// + /// The GitHub repository the native downloads and the Homebrew formula are released from. + /// + public const string GitHubRepository = "Cratis/cli"; + + static readonly TimeSpan _timeout = TimeSpan.FromSeconds(5); + + /// + /// Resolves the place an installation updating through the given strategy should be compared against. + /// + /// The detected update strategy. + /// The to read from. + /// + /// Only the dotnet tool installs from NuGet. Every native installation - Homebrew, the Linux tarball, or a + /// binary put somewhere by hand - is built from a GitHub release, so that is the version those have to be + /// told about. The Homebrew formula is written by the same workflow after the release exists, which means + /// the release can never be behind the tap. + /// + public static LatestVersionSource SourceFor(CliUpdateStrategy strategy) => + strategy == CliUpdateStrategy.DotNetTool + ? LatestVersionSource.NuGet + : LatestVersionSource.GitHubRelease; + + /// + /// Reads the latest stable version of a package from NuGet. + /// + /// The NuGet package identifier. + /// A cancellation token. + /// The latest stable version, or null when it could not be read. + public static async Task FromNuGet(string packageId, CancellationToken cancellationToken = default) + { + using var http = new HttpClient { Timeout = _timeout }; + var url = $"https://api.nuget.org/v3-flatcontainer/{packageId.ToLowerInvariant()}/index.json"; + var response = await http.GetAsync(url, cancellationToken); + + if (!response.IsSuccessStatusCode) + { + return null; + } + + var json = await response.Content.ReadAsStringAsync(cancellationToken); + using var document = JsonDocument.Parse(json); + + if (!document.RootElement.TryGetProperty("versions", out var versions) || + versions.ValueKind != JsonValueKind.Array) + { + return null; + } + + // NuGet returns versions in ascending order; the last stable version is what we want. + string? latest = null; + + foreach (var v in versions.EnumerateArray()) + { + var versionString = v.GetString(); + if (versionString?.Contains('-') == false) + { + latest = versionString; + } + } + + return latest; + } + + /// + /// Reads the version of the latest GitHub release. + /// + /// A cancellation token. + /// The latest released version, or null when it could not be read. + public static async Task FromGitHubRelease(CancellationToken cancellationToken = default) + { + using var http = new HttpClient { Timeout = _timeout }; + + // GitHub rejects requests without a user agent, and serves the release metadata under its own media type. + http.DefaultRequestHeaders.UserAgent.ParseAdd("Cratis.Cli"); + http.DefaultRequestHeaders.Accept.ParseAdd("application/vnd.github+json"); + + var url = $"https://api.github.com/repos/{GitHubRepository}/releases/latest"; + var response = await http.GetAsync(url, cancellationToken); + + if (!response.IsSuccessStatusCode) + { + return null; + } + + var json = await response.Content.ReadAsStringAsync(cancellationToken); + using var document = JsonDocument.Parse(json); + + return document.RootElement.TryGetProperty("tag_name", out var tag) + ? NormalizeTag(tag.GetString()) + : null; + } + + /// + /// Turns a release tag into a comparable version. + /// + /// The tag name, which the release workflow writes as v{version}. + /// The version without the tag prefix, or null when there was nothing to read. + internal static string? NormalizeTag(string? tagName) + { + if (string.IsNullOrWhiteSpace(tagName)) + { + return null; + } + + var trimmed = tagName.Trim(); + return trimmed.StartsWith('v') || trimmed.StartsWith('V') + ? trimmed[1..] + : trimmed; + } +} diff --git a/Source/Cli/LatestVersionSource.cs b/Source/Cli/LatestVersionSource.cs new file mode 100644 index 0000000..6818694 --- /dev/null +++ b/Source/Cli/LatestVersionSource.cs @@ -0,0 +1,25 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Cli; + +/// +/// Represents where the latest available version of the CLI should be read from. +/// +/// +/// An installation has to be compared against the place it actually updates from. A tool installed with +/// dotnet tool install updates from NuGet, while the native downloads - Homebrew included - update from +/// the GitHub releases. Those are published by separate jobs and do not become visible at the same moment. +/// +public enum LatestVersionSource +{ + /// + /// Read the latest version from the NuGet package feed. + /// + NuGet, + + /// + /// Read the latest version from the GitHub releases. + /// + GitHubRelease +} diff --git a/Source/Cli/Program.cs b/Source/Cli/Program.cs index 7a00808..294991c 100644 --- a/Source/Cli/Program.cs +++ b/Source/Cli/Program.cs @@ -4,9 +4,12 @@ using Cratis.Cli; using Cratis.Cli.Commands.Version; -using var updateCts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); var currentVersion = VersionCommand.GetCliVersion(); -var updateCheckTask = UpdateChecker.CheckForUpdate(currentVersion, updateCts.Token); + +// The request carries its own five second timeout. A deadline measured from here would instead be spent while +// the command runs, so anything slower than that - the workbench, a run, generating a screenplay - would cancel +// the check before it ever finished, leaving both the hint and the cached answer permanently out of reach. +var updateCheckTask = UpdateChecker.CheckForUpdate(currentVersion); if (args.Length == 0 && !Console.IsOutputRedirected && !GlobalSettings.IsAiAgentEnvironment()) { diff --git a/Source/Cli/UpdateChecker.cs b/Source/Cli/UpdateChecker.cs index 44a13e5..ec5b8ab 100644 --- a/Source/Cli/UpdateChecker.cs +++ b/Source/Cli/UpdateChecker.cs @@ -4,9 +4,13 @@ namespace Cratis.Cli; /// -/// Checks NuGet for the latest available version of a package and caches the result. -/// The check runs at most once per configured interval to avoid slowing down every command. +/// Checks whether a newer version is available and caches the result, so that the check runs at most once per +/// interval rather than on every command. /// +/// +/// The CLI's own version is read from wherever the running installation updates from - NuGet for a dotnet tool, +/// the GitHub releases for the native downloads - while other packages are read from NuGet. +/// public static class UpdateChecker { /// @@ -25,7 +29,8 @@ public static class UpdateChecker /// public const string DisableEnvVar = "CRATIS_NO_UPDATE_CHECK"; - static readonly TimeSpan _checkInterval = TimeSpan.FromHours(24); + static readonly TimeSpan _updateAvailableInterval = TimeSpan.FromHours(24); + static readonly TimeSpan _upToDateInterval = TimeSpan.FromHours(1); static readonly JsonSerializerOptions _cacheJsonOptions = new() { WriteIndented = true }; /// @@ -43,13 +48,38 @@ public static string GetCachePath() => Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".cratis", "version-check.json"); /// - /// Checks whether a newer version of the CLI is available. + /// Checks whether a newer version of the CLI is available, from the place this installation updates from. /// /// The current CLI version. /// A cancellation token for timeout control. /// The latest version string if newer, otherwise null. public static Task CheckForUpdate(string currentVersion, CancellationToken cancellationToken = default) - => CheckForUpdate(CliPackageId, currentVersion, cancellationToken); + => CheckForCliUpdate(currentVersion, false, cancellationToken); + + /// + /// Checks whether a newer version of the CLI is available, from the place this installation updates from. + /// + /// The current CLI version. + /// Whether to ask the source directly rather than trusting the cached answer. + /// A cancellation token for timeout control. + /// The latest version string if newer, otherwise null. + /// + /// A dotnet tool is compared against NuGet and a native installation against the GitHub releases, because + /// those are published separately and comparing against the wrong one reports an update that the user's own + /// update command cannot yet install, or none when one is waiting. + /// + public static async Task CheckForCliUpdate(string currentVersion, bool bypassCache, CancellationToken cancellationToken = default) + { + var source = LatestVersion.SourceFor(CliUpdate.DetectStrategy()); + return await Check( + CacheKeyFor(source, CliPackageId), + currentVersion, + bypassCache, + token => source == LatestVersionSource.NuGet + ? LatestVersion.FromNuGet(CliPackageId, token) + : LatestVersion.FromGitHubRelease(token), + cancellationToken); + } /// /// Checks whether a newer version of the specified NuGet package is available. @@ -61,39 +91,38 @@ public static string GetCachePath() => /// The current version. /// A cancellation token for timeout control. /// The latest version string if newer, otherwise null. - public static async Task CheckForUpdate(string packageId, string currentVersion, CancellationToken cancellationToken = default) - { - if (IsDisabled()) - { - return null; - } + public static Task CheckForUpdate(string packageId, string currentVersion, CancellationToken cancellationToken = default) => + Check(packageId, currentVersion, false, token => LatestVersion.FromNuGet(packageId, token), cancellationToken); - var cache = ReadCache(); - if (cache?.Packages.TryGetValue(packageId, out var entry) == true && - DateTime.UtcNow - entry.CheckedAt < _checkInterval) - { - return IsNewer(entry.LatestVersion, currentVersion) ? entry.LatestVersion : null; - } - - try - { - var latestVersion = await FetchLatestVersion(packageId, cancellationToken); - if (latestVersion is null) - { - return null; - } - - cache ??= new VersionCache(); - cache.Packages[packageId] = new PackageVersionEntry(latestVersion, DateTime.UtcNow); - WriteCache(cache); + /// + /// Gets the cache key an answer read from the given source is stored under. + /// + /// The source the answer came from. + /// The package identifier, used when reading from NuGet. + /// The cache key. + /// + /// The sources are keyed apart so an answer read from one is never served for the other - the same + /// installation can change how it updates, and the two do not publish at the same moment. + /// + internal static string CacheKeyFor(LatestVersionSource source, string packageId) => + source == LatestVersionSource.NuGet ? packageId : $"github:{LatestVersion.GitHubRepository}"; - return IsNewer(latestVersion, currentVersion) ? latestVersion : null; - } - catch - { - return null; - } - } + /// + /// Determines whether a cached answer can still be trusted. + /// + /// The latest version recorded when the check ran. + /// When the check ran. + /// The version currently installed. + /// The current time, in UTC. + /// True when the cached answer is still fresh enough to serve. + /// + /// The two answers do not go stale at the same rate. "An update is available" stays true until the user + /// updates, so it is held for a day. "You are up to date" stops being true the moment a release happens, + /// and a check landing in the window between the release and the package becoming visible records the + /// previous version - holding that for a day means the release goes unannounced for a day. + /// + internal static bool IsFresh(string cachedLatestVersion, DateTime checkedAt, string currentVersion, DateTime utcNow) => + utcNow - checkedAt < (IsNewer(cachedLatestVersion, currentVersion) ? _updateAvailableInterval : _upToDateInterval); /// /// Determines whether the latest version is newer than the current version. @@ -111,39 +140,44 @@ internal static bool IsNewer(string latest, string current) latestVer > currentVer; } - static async Task FetchLatestVersion(string packageId, CancellationToken cancellationToken) + static async Task Check( + string cacheKey, + string currentVersion, + bool bypassCache, + Func> fetch, + CancellationToken cancellationToken) { - using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(5) }; - var url = $"https://api.nuget.org/v3-flatcontainer/{packageId.ToLowerInvariant()}/index.json"; - var response = await http.GetAsync(url, cancellationToken); - - if (!response.IsSuccessStatusCode) + if (IsDisabled()) { return null; } - var json = await response.Content.ReadAsStringAsync(cancellationToken); - using var document = JsonDocument.Parse(json); - - if (!document.RootElement.TryGetProperty("versions", out var versions) || - versions.ValueKind != JsonValueKind.Array) + var cache = ReadCache(); + if (!bypassCache && + cache?.Packages.TryGetValue(cacheKey, out var entry) == true && + IsFresh(entry.LatestVersion, entry.CheckedAt, currentVersion, DateTime.UtcNow)) { - return null; + return IsNewer(entry.LatestVersion, currentVersion) ? entry.LatestVersion : null; } - // NuGet returns versions in ascending order; the last stable version is what we want. - string? latest = null; - - foreach (var v in versions.EnumerateArray()) + try { - var versionString = v.GetString(); - if (versionString?.Contains('-') == false) + var latestVersion = await fetch(cancellationToken); + if (latestVersion is null) { - latest = versionString; + return null; } - } - return latest; + cache ??= new VersionCache(); + cache.Packages[cacheKey] = new PackageVersionEntry(latestVersion, DateTime.UtcNow); + WriteCache(cache); + + return IsNewer(latestVersion, currentVersion) ? latestVersion : null; + } + catch + { + return null; + } } static VersionCache? ReadCache()