diff --git a/CHANGELOG.md b/CHANGELOG.md index e15ef8f..96d750d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 --- +## [0.1.4] — 2026-05-23 + +### Added + +- **`update --prerelease` and `update check --prerelease`.** Opt into prerelease tags for a single command invocation without touching the DI-registered `SelfUpdaterOptions.IncludePrereleases` default. Useful for downstream apps testing RC builds. Help text: `Consider GitHub prereleases when looking for the latest version (off by default).` +- **`bool? includePrereleasesOverride` parameter** on `IUpdateSource.GetLatestAsync`, `IUpdateChecker.CheckAsync`, and `ISelfUpdater.GetLatestReleaseAsync`, added as default interface methods that delegate to the existing overload. External `IUpdateSource` implementers continue to compile unchanged; they only need to override the new overload if they want to honour `--prerelease`. `null` defers to the source's captured default; `true`/`false` force inclusion or exclusion. + +### Changed + +- The update-check cache now keys on `(channel, includePrereleases)` so a `--prerelease` answer doesn't pollute the next default `update check`, and vice versa. Cache files written by v0.1.3 are read as non-prerelease (matches their actual provenance — prereleases were always opt-in at DI registration). No migration needed; the new field is nullable. + +--- + ## [0.1.3] — 2026-05-03 ### Added @@ -72,6 +85,7 @@ Initial commit. Never published to nuget.org — superseded by 0.1.1 before the - Full XML documentation on the public surface, `TreatWarningsAsErrors=true`, `AnalysisLevel=latest`. - SourceLink, deterministic builds, published symbol packages. +[0.1.4]: https://github.com/StuartMeeks/NextIteration.SpectreConsole.SelfUpdate/releases/tag/v0.1.4 [0.1.3]: https://github.com/StuartMeeks/NextIteration.SpectreConsole.SelfUpdate/releases/tag/v0.1.3 [0.1.2]: https://github.com/StuartMeeks/NextIteration.SpectreConsole.SelfUpdate/releases/tag/v0.1.2 [0.1.1]: https://github.com/StuartMeeks/NextIteration.SpectreConsole.SelfUpdate/releases/tag/v0.1.1 diff --git a/src/NextIteration.SpectreConsole.SelfUpdate/Commands/UpdateCheckCommand.cs b/src/NextIteration.SpectreConsole.SelfUpdate/Commands/UpdateCheckCommand.cs index cdba4ef..a929351 100644 --- a/src/NextIteration.SpectreConsole.SelfUpdate/Commands/UpdateCheckCommand.cs +++ b/src/NextIteration.SpectreConsole.SelfUpdate/Commands/UpdateCheckCommand.cs @@ -1,3 +1,4 @@ +using System.ComponentModel; using System.Globalization; using Spectre.Console; @@ -16,8 +17,22 @@ namespace NextIteration.SpectreConsole.SelfUpdate.Commands /// 2 — a newer release is available. /// /// - public sealed class UpdateCheckCommand : AsyncCommand + public sealed class UpdateCheckCommand : AsyncCommand { + /// Settings for update check. + public sealed class Settings : CommandSettings + { + /// + /// Opt into prerelease tags for this invocation only. Equivalent + /// to flipping SelfUpdaterOptions.IncludePrereleases to + /// true for the lifetime of this command without mutating + /// shared options. + /// + [CommandOption("--prerelease")] + [Description("Consider GitHub prereleases when looking for the latest version (off by default).")] + public bool Prerelease { get; init; } + } + private readonly IUpdateChecker _checker; private readonly IAnsiConsole _console; @@ -32,15 +47,18 @@ public UpdateCheckCommand(IUpdateChecker checker, IAnsiConsole console) } /// - protected override async Task ExecuteAsync(CommandContext context, CancellationToken cancellationToken) + protected override async Task ExecuteAsync(CommandContext context, Settings settings, CancellationToken cancellationToken) { + ArgumentNullException.ThrowIfNull(settings); + var current = _checker.GetCurrentVersion() ?? "dev"; _console.MarkupLineInterpolated(CultureInfo.InvariantCulture, $"Current version: [bold]{current}[/]"); + bool? prereleaseOverride = settings.Prerelease ? true : null; UpdateInfo? info; try { - info = await _checker.CheckAsync(cancellationToken).ConfigureAwait(false); + info = await _checker.CheckAsync(prereleaseOverride, cancellationToken).ConfigureAwait(false); } catch (Exception ex) { diff --git a/src/NextIteration.SpectreConsole.SelfUpdate/Commands/UpdateCommand.cs b/src/NextIteration.SpectreConsole.SelfUpdate/Commands/UpdateCommand.cs index ebafe23..bdea55c 100644 --- a/src/NextIteration.SpectreConsole.SelfUpdate/Commands/UpdateCommand.cs +++ b/src/NextIteration.SpectreConsole.SelfUpdate/Commands/UpdateCommand.cs @@ -42,6 +42,16 @@ public sealed class Settings : CommandSettings [CommandOption("--strategy")] [Description("Conflict strategy when a release ships a preserved path: ask | keep | new.")] public string? Strategy { get; init; } + + /// + /// Opt into prerelease tags for this invocation only. Equivalent + /// to flipping SelfUpdaterOptions.IncludePrereleases to + /// true for the lifetime of this command without mutating + /// shared options. + /// + [CommandOption("--prerelease")] + [Description("Consider GitHub prereleases when looking for the latest version (off by default).")] + public bool Prerelease { get; init; } } private enum ConflictStrategy { Ask, Keep, New } @@ -80,10 +90,11 @@ protected override async Task ExecuteAsync(CommandContext context, Settings // Fetch the release once and use it for both display and install, // so the user confirms exactly the release that gets installed // (no TOCTOU window between "what's latest?" and "install latest"). + bool? prereleaseOverride = settings.Prerelease ? true : null; RemoteRelease? release; try { - release = await _selfUpdater.GetLatestReleaseAsync(cancellationToken).ConfigureAwait(false); + release = await _selfUpdater.GetLatestReleaseAsync(prereleaseOverride, cancellationToken).ConfigureAwait(false); } catch (Exception ex) when (ex is not OperationCanceledException) { diff --git a/src/NextIteration.SpectreConsole.SelfUpdate/ISelfUpdater.cs b/src/NextIteration.SpectreConsole.SelfUpdate/ISelfUpdater.cs index 5140bb3..6d8eaac 100644 --- a/src/NextIteration.SpectreConsole.SelfUpdate/ISelfUpdater.cs +++ b/src/NextIteration.SpectreConsole.SelfUpdate/ISelfUpdater.cs @@ -10,7 +10,7 @@ namespace NextIteration.SpectreConsole.SelfUpdate public interface ISelfUpdater { /// - /// Non-blocking probe — see . + /// Non-blocking probe — see . /// Task CheckAsync(CancellationToken ct = default); @@ -26,6 +26,20 @@ public interface ISelfUpdater /// Task GetLatestReleaseAsync(CancellationToken ct = default); + /// + /// Per-invocation variant of + /// that lets the + /// caller override + /// for one call (used by the update --prerelease CLI flag). + /// defers to the configured option; + /// / force inclusion or + /// exclusion. The default-interface implementation drops the override + /// and delegates to the base overload so existing custom updaters + /// continue to compile. + /// + Task GetLatestReleaseAsync(bool? includePrereleasesOverride, CancellationToken ct = default) => + GetLatestReleaseAsync(ct); + /// /// Install the supplied release: download, run the verifier /// pipeline, extract the archive, and swap the new files into the @@ -54,8 +68,8 @@ Task InstallAsync( /// when no release is available or /// when any pipeline stage fails. Has a TOCTOU window: the /// release returned by the source here may differ from the one a - /// prior reported. For interactive UIs, - /// prefer the + + /// prior reported. For interactive UIs, + /// prefer the + /// /// pair so the user confirms exactly the release that gets /// installed. diff --git a/src/NextIteration.SpectreConsole.SelfUpdate/IUpdateChecker.cs b/src/NextIteration.SpectreConsole.SelfUpdate/IUpdateChecker.cs index 32b6acb..68c2c47 100644 --- a/src/NextIteration.SpectreConsole.SelfUpdate/IUpdateChecker.cs +++ b/src/NextIteration.SpectreConsole.SelfUpdate/IUpdateChecker.cs @@ -18,6 +18,20 @@ public interface IUpdateChecker /// Task CheckAsync(CancellationToken ct = default); + /// + /// Per-invocation variant of + /// that lets the caller override + /// for one call + /// (used by the update check --prerelease CLI flag). + /// defers to the configured option; + /// / force inclusion or + /// exclusion. The default-interface implementation drops the override + /// and delegates to the base overload so existing custom checkers + /// continue to compile. + /// + Task CheckAsync(bool? includePrereleasesOverride, CancellationToken ct = default) => + CheckAsync(ct); + /// /// The running CLI's version, read from /// @@ -27,7 +41,7 @@ public interface IUpdateChecker /// is /// not consulted here — predicate-skipped versions are still /// reported so commands can display them; the predicate suppresses - /// the check inside , not the + /// the check inside , not the /// displayed version. /// string? GetCurrentVersion(); diff --git a/src/NextIteration.SpectreConsole.SelfUpdate/IUpdateSource.cs b/src/NextIteration.SpectreConsole.SelfUpdate/IUpdateSource.cs index c7187c3..3b5d5bf 100644 --- a/src/NextIteration.SpectreConsole.SelfUpdate/IUpdateSource.cs +++ b/src/NextIteration.SpectreConsole.SelfUpdate/IUpdateSource.cs @@ -28,6 +28,27 @@ public interface IUpdateSource /// Cancellation token honoured for both DNS and stream reads. Task GetLatestAsync(string? channel, CancellationToken ct); + /// + /// Per-invocation variant of + /// that lets the caller override + /// without + /// mutating shared options. Used by the update --prerelease + /// and update check --prerelease CLI flags. + /// + /// Channel filter — see the base overload. + /// + /// defers to the source's captured + /// ; + /// forces prerelease inclusion for this call; + /// forces exclusion. The default interface implementation drops the + /// override and delegates to the base overload so existing third-party + /// sources continue to compile — implementers wanting to honour the + /// CLI flag should override this method explicitly. + /// + /// Cancellation token. + Task GetLatestAsync(string? channel, bool? includePrereleasesOverride, CancellationToken ct) => + GetLatestAsync(channel, ct); + /// /// Stream a single release asset to . /// Should write nothing on failure and propagate the underlying diff --git a/src/NextIteration.SpectreConsole.SelfUpdate/NextIteration.SpectreConsole.SelfUpdate.csproj b/src/NextIteration.SpectreConsole.SelfUpdate/NextIteration.SpectreConsole.SelfUpdate.csproj index 60967fa..3ce9b83 100644 --- a/src/NextIteration.SpectreConsole.SelfUpdate/NextIteration.SpectreConsole.SelfUpdate.csproj +++ b/src/NextIteration.SpectreConsole.SelfUpdate/NextIteration.SpectreConsole.SelfUpdate.csproj @@ -11,7 +11,7 @@ NextIteration.SpectreConsole.SelfUpdate - 0.1.3 + 0.1.4 Stuart Meeks Self-update for Spectre.Console CLIs: pluggable update sources (GitHub Releases over HTTP, GitHub Releases via gh CLI for private repos, generic HTTPS manifest, custom), SHA-256 verification, atomic file swap, and a drop-in `update` command. true diff --git a/src/NextIteration.SpectreConsole.SelfUpdate/Pipeline/SelfUpdater.cs b/src/NextIteration.SpectreConsole.SelfUpdate/Pipeline/SelfUpdater.cs index f4aa2c3..cfcb59c 100644 --- a/src/NextIteration.SpectreConsole.SelfUpdate/Pipeline/SelfUpdater.cs +++ b/src/NextIteration.SpectreConsole.SelfUpdate/Pipeline/SelfUpdater.cs @@ -34,7 +34,10 @@ public SelfUpdater( _checker.CheckAsync(ct); public Task GetLatestReleaseAsync(CancellationToken ct = default) => - _source.GetLatestAsync(_options.Channel, ct); + GetLatestReleaseAsync(includePrereleasesOverride: null, ct); + + public Task GetLatestReleaseAsync(bool? includePrereleasesOverride, CancellationToken ct = default) => + _source.GetLatestAsync(_options.Channel, includePrereleasesOverride, ct); public Task InstallAsync( RemoteRelease release, diff --git a/src/NextIteration.SpectreConsole.SelfUpdate/Pipeline/UpdateCacheFile.cs b/src/NextIteration.SpectreConsole.SelfUpdate/Pipeline/UpdateCacheFile.cs index b0deaaf..209e7d4 100644 --- a/src/NextIteration.SpectreConsole.SelfUpdate/Pipeline/UpdateCacheFile.cs +++ b/src/NextIteration.SpectreConsole.SelfUpdate/Pipeline/UpdateCacheFile.cs @@ -51,5 +51,6 @@ internal sealed record UpdateCacheEntry( [property: JsonPropertyName("checkedAt")] DateTimeOffset CheckedAt, [property: JsonPropertyName("latestTag")] string LatestTag, [property: JsonPropertyName("releaseUrl")] string? ReleaseUrl, - [property: JsonPropertyName("channel")] string? Channel); + [property: JsonPropertyName("channel")] string? Channel, + [property: JsonPropertyName("includePrereleases")] bool? IncludePrereleases = null); } diff --git a/src/NextIteration.SpectreConsole.SelfUpdate/Pipeline/UpdateChecker.cs b/src/NextIteration.SpectreConsole.SelfUpdate/Pipeline/UpdateChecker.cs index 77bcde4..ae04d08 100644 --- a/src/NextIteration.SpectreConsole.SelfUpdate/Pipeline/UpdateChecker.cs +++ b/src/NextIteration.SpectreConsole.SelfUpdate/Pipeline/UpdateChecker.cs @@ -45,7 +45,10 @@ internal UpdateChecker( _utcNow = utcNow; } - public async Task CheckAsync(CancellationToken ct = default) + public Task CheckAsync(CancellationToken ct = default) => + CheckAsync(includePrereleasesOverride: null, ct); + + public async Task CheckAsync(bool? includePrereleasesOverride, CancellationToken ct = default) { if (IsOptOutSet()) return null; @@ -53,9 +56,10 @@ internal UpdateChecker( if (current is null) return null; if (IsVersionSkipped(current)) return null; + var effectivePrerelease = includePrereleasesOverride ?? _options.IncludePrereleases; var cachePath = ResolveCacheFilePath(); var cached = UpdateCacheFile.TryRead(cachePath); - if (IsCacheFresh(cached)) + if (IsCacheFresh(cached, effectivePrerelease)) { return Compare(current, cached!.LatestTag, ParseUri(cached.ReleaseUrl)); } @@ -65,14 +69,15 @@ internal UpdateChecker( using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct); linked.CancelAfter(_options.CheckTimeout); - var release = await _source.GetLatestAsync(_options.Channel, linked.Token).ConfigureAwait(false); + var release = await _source.GetLatestAsync(_options.Channel, includePrereleasesOverride, linked.Token).ConfigureAwait(false); if (release is null) return null; UpdateCacheFile.TryWrite(cachePath, new UpdateCacheEntry( CheckedAt: _utcNow(), LatestTag: release.Tag, ReleaseUrl: release.ReleaseNotesUrl?.ToString(), - Channel: _options.Channel)); + Channel: _options.Channel, + IncludePrereleases: effectivePrerelease)); return Compare(current, release.Tag, release.ReleaseNotesUrl); } @@ -122,10 +127,17 @@ internal string ResolveCacheFilePath() return Path.Combine(dir, ".update-check.json"); } - internal bool IsCacheFresh(UpdateCacheEntry? entry) + internal bool IsCacheFresh(UpdateCacheEntry? entry) => + IsCacheFresh(entry, _options.IncludePrereleases); + + internal bool IsCacheFresh(UpdateCacheEntry? entry, bool effectivePrerelease) { if (entry is null) return false; if (!string.Equals(entry.Channel, _options.Channel, StringComparison.Ordinal)) return false; + // Pre-0.1.4 cache entries have no IncludePrereleases field — they + // were written before the override existed, so they always + // reflect a non-prerelease answer. + if ((entry.IncludePrereleases ?? false) != effectivePrerelease) return false; return (_utcNow() - entry.CheckedAt) < _options.CacheTtl; } diff --git a/src/NextIteration.SpectreConsole.SelfUpdate/SelfUpdaterOptions.cs b/src/NextIteration.SpectreConsole.SelfUpdate/SelfUpdaterOptions.cs index d451adf..423a3cf 100644 --- a/src/NextIteration.SpectreConsole.SelfUpdate/SelfUpdaterOptions.cs +++ b/src/NextIteration.SpectreConsole.SelfUpdate/SelfUpdaterOptions.cs @@ -19,7 +19,7 @@ public sealed class SelfUpdaterOptions /// /// Optional release channel filter, e.g. "stable", - /// "beta". Forwarded to ; + /// "beta". Forwarded to ; /// the source decides what the value means. /// uses the source's default channel. /// diff --git a/src/NextIteration.SpectreConsole.SelfUpdate/Sources/GhCliReleaseSource.cs b/src/NextIteration.SpectreConsole.SelfUpdate/Sources/GhCliReleaseSource.cs index b39b3f7..500f768 100644 --- a/src/NextIteration.SpectreConsole.SelfUpdate/Sources/GhCliReleaseSource.cs +++ b/src/NextIteration.SpectreConsole.SelfUpdate/Sources/GhCliReleaseSource.cs @@ -27,7 +27,7 @@ namespace NextIteration.SpectreConsole.SelfUpdate.Sources /// --pattern <name> --output <tempfile> followed by streaming /// the temp file into the caller's destination. The tag is recovered /// from (key "tag") which - /// the source populates during . Arguments + /// the source populates during . Arguments /// are passed via /// so values from a remote source never need to be quoted or escaped. /// @@ -74,11 +74,16 @@ internal GhCliReleaseSource( } /// - public async Task GetLatestAsync(string? channel, CancellationToken ct) + public Task GetLatestAsync(string? channel, CancellationToken ct) => + GetLatestAsync(channel, includePrereleasesOverride: null, ct); + + /// + public async Task GetLatestAsync(string? channel, bool? includePrereleasesOverride, CancellationToken ct) { + var includePrereleases = includePrereleasesOverride ?? _includePrereleases; try { - if (channel is null && !_includePrereleases) + if (channel is null && !includePrereleases) { var stdout = await _runner( new[] @@ -106,7 +111,7 @@ internal GhCliReleaseSource( var releases = JsonSerializer.Deserialize(listJson, JsonOpts) ?? Array.Empty(); var match = releases .Where(r => !r.IsDraft) - .Where(r => _includePrereleases || !r.IsPrerelease) + .Where(r => includePrereleases || !r.IsPrerelease) .Where(r => channel is null || (r.TagName ?? string.Empty).Contains($"-{channel}", StringComparison.OrdinalIgnoreCase)) .OrderByDescending(r => r.PublishedAt) diff --git a/src/NextIteration.SpectreConsole.SelfUpdate/Sources/HttpGitHubReleaseSource.cs b/src/NextIteration.SpectreConsole.SelfUpdate/Sources/HttpGitHubReleaseSource.cs index a20f8d3..2d81fc1 100644 --- a/src/NextIteration.SpectreConsole.SelfUpdate/Sources/HttpGitHubReleaseSource.cs +++ b/src/NextIteration.SpectreConsole.SelfUpdate/Sources/HttpGitHubReleaseSource.cs @@ -83,14 +83,19 @@ internal HttpGitHubReleaseSource( } /// - public async Task GetLatestAsync(string? channel, CancellationToken ct) + public Task GetLatestAsync(string? channel, CancellationToken ct) => + GetLatestAsync(channel, includePrereleasesOverride: null, ct); + + /// + public async Task GetLatestAsync(string? channel, bool? includePrereleasesOverride, CancellationToken ct) { + var includePrereleases = includePrereleasesOverride ?? _includePrereleases; try { using var http = _httpClientFactory.CreateClient(); ConfigureRequestHeaders(http); - if (channel is null && !_includePrereleases) + if (channel is null && !includePrereleases) { var dto = await GetJsonAsync(http, $"repos/{_repository}/releases/latest", ct).ConfigureAwait(false); return dto is null ? null : Convert(dto, channel); @@ -101,7 +106,7 @@ internal HttpGitHubReleaseSource( var match = releases .Where(r => !r.Draft) - .Where(r => _includePrereleases || !r.Prerelease) + .Where(r => includePrereleases || !r.Prerelease) .Where(r => channel is null || (r.TagName ?? string.Empty).Contains($"-{channel}", StringComparison.OrdinalIgnoreCase)) .OrderByDescending(r => r.PublishedAt) diff --git a/src/NextIteration.SpectreConsole.SelfUpdate/Sources/HttpManifestSource.cs b/src/NextIteration.SpectreConsole.SelfUpdate/Sources/HttpManifestSource.cs index dcbdfad..5a19a8e 100644 --- a/src/NextIteration.SpectreConsole.SelfUpdate/Sources/HttpManifestSource.cs +++ b/src/NextIteration.SpectreConsole.SelfUpdate/Sources/HttpManifestSource.cs @@ -95,6 +95,15 @@ public HttpManifestSource(IHttpClientFactory httpClientFactory, Uri manifestUrl, private static bool IsHttps(Uri uri) => string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase); + /// + /// + /// The single-release manifest does not expose a prerelease flag, so the + /// includePrereleasesOverride overload simply delegates here — the + /// manifest's own tag is always returned regardless of override. + /// + public Task GetLatestAsync(string? channel, bool? includePrereleasesOverride, CancellationToken ct) => + GetLatestAsync(channel, ct); + /// public async Task GetLatestAsync(string? channel, CancellationToken ct) { diff --git a/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Commands/UpdateCheckCommandTests.cs b/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Commands/UpdateCheckCommandTests.cs index 677fac2..72793d4 100644 --- a/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Commands/UpdateCheckCommandTests.cs +++ b/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Commands/UpdateCheckCommandTests.cs @@ -13,10 +13,42 @@ namespace NextIteration.SpectreConsole.SelfUpdate.Tests.Commands { public sealed class UpdateCheckCommandTests { + private delegate Task Runner(params string[] args); + + [Fact] + public async Task Execute_with_prerelease_flag_passes_true_override_to_checker() + { + var (run, _, checker) = BuildHarness(c => + { + c.CurrentVersion = "1.0.0"; + c.CheckImpl = _ => Task.FromResult( + new UpdateInfo("1.0.0", "v1.4.2", IsUpdateAvailable: false, ReleaseUrl: null)); + }); + + await run("--prerelease"); + + Assert.True(checker.LastIncludePrereleasesOverride); + } + + [Fact] + public async Task Execute_without_prerelease_flag_passes_null_override_to_checker() + { + var (run, _, checker) = BuildHarness(c => + { + c.CurrentVersion = "1.0.0"; + c.CheckImpl = _ => Task.FromResult( + new UpdateInfo("1.0.0", "v1.4.2", IsUpdateAvailable: false, ReleaseUrl: null)); + }); + + await run(); + + Assert.Null(checker.LastIncludePrereleasesOverride); + } + [Fact] public async Task Execute_when_up_to_date_returns_zero() { - var (run, console) = BuildHarness(checker => + var (run, console, _) = BuildHarness(checker => { checker.CurrentVersion = "1.4.2"; checker.CheckImpl = _ => Task.FromResult( @@ -32,7 +64,7 @@ public async Task Execute_when_up_to_date_returns_zero() [Fact] public async Task Execute_when_update_available_returns_two() { - var (run, console) = BuildHarness(checker => + var (run, console, _) = BuildHarness(checker => { checker.CurrentVersion = "1.0.0"; checker.CheckImpl = _ => Task.FromResult( @@ -51,7 +83,7 @@ public async Task Execute_when_update_available_returns_two() [Fact] public async Task Execute_when_check_returns_null_returns_one() { - var (run, console) = BuildHarness(checker => + var (run, console, _) = BuildHarness(checker => checker.CheckImpl = _ => Task.FromResult(null)); var exit = await run(); @@ -63,7 +95,7 @@ public async Task Execute_when_check_returns_null_returns_one() [Fact] public async Task Execute_when_check_throws_returns_one() { - var (run, console) = BuildHarness(checker => + var (run, console, _) = BuildHarness(checker => checker.CheckImpl = _ => throw new InvalidOperationException("offline")); var exit = await run(); @@ -76,7 +108,7 @@ public async Task Execute_when_check_throws_returns_one() [Fact] public async Task Execute_when_no_release_url_omits_release_notes_line() { - var (run, console) = BuildHarness(checker => + var (run, console, _) = BuildHarness(checker => { checker.CurrentVersion = "1.0.0"; checker.CheckImpl = _ => Task.FromResult( @@ -91,7 +123,7 @@ public async Task Execute_when_no_release_url_omits_release_notes_line() // ---------- helpers ---------- - private static (Func> Run, TestConsole Console) BuildHarness(Action configChecker) + private static (Runner Run, TestConsole Console, StubUpdateChecker Checker) BuildHarness(Action configChecker) { var checker = new StubUpdateChecker(); configChecker(checker); @@ -107,8 +139,8 @@ private static (Func> Run, TestConsole Console) BuildHarness(Action(registrar); app.Configure(c => c.PropagateExceptions()); - Task Run() => app.RunAsync(Array.Empty()); - return (Run, console); + Runner run = args => app.RunAsync(args); + return (run, console, checker); } } } diff --git a/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Commands/UpdateCommandTests.cs b/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Commands/UpdateCommandTests.cs index db86a45..c256761 100644 --- a/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Commands/UpdateCommandTests.cs +++ b/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Commands/UpdateCommandTests.cs @@ -196,6 +196,39 @@ public async Task Execute_when_force_and_up_to_date_proceeds_to_install() Assert.Equal(1, installCalls); } + [Fact] + public async Task Execute_with_prerelease_flag_passes_true_override_to_updater() + { + var (run, _, updater) = BuildHarness( + configChecker: c => c.CurrentVersion = "1.0.0", + configUpdater: u => + { + u.GetLatestImpl = _ => Task.FromResult(ReleaseV142); + u.InstallReleaseImpl = (_, _, _, _) => Task.CompletedTask; + }); + + var exit = await run("--yes", "--prerelease"); + + Assert.Equal(0, exit); + Assert.True(updater.LastIncludePrereleasesOverride); + } + + [Fact] + public async Task Execute_without_prerelease_flag_passes_null_override_to_updater() + { + var (run, _, updater) = BuildHarness( + configChecker: c => c.CurrentVersion = "1.0.0", + configUpdater: u => + { + u.GetLatestImpl = _ => Task.FromResult(ReleaseV142); + u.InstallReleaseImpl = (_, _, _, _) => Task.CompletedTask; + }); + + await run("--yes"); + + Assert.Null(updater.LastIncludePrereleasesOverride); + } + [Fact] public async Task Execute_prints_current_and_latest_versions() { diff --git a/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Infrastructure/FakeUpdateSource.cs b/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Infrastructure/FakeUpdateSource.cs index 2f90c27..7276760 100644 --- a/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Infrastructure/FakeUpdateSource.cs +++ b/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Infrastructure/FakeUpdateSource.cs @@ -11,11 +11,16 @@ internal sealed class FakeUpdateSource : IUpdateSource public Func? AssetBytes { get; set; } public int GetLatestCallCount { get; private set; } public string? LastChannelRequested { get; private set; } + public bool? LastIncludePrereleasesOverride { get; private set; } - public Task GetLatestAsync(string? channel, CancellationToken ct) + public Task GetLatestAsync(string? channel, CancellationToken ct) => + GetLatestAsync(channel, includePrereleasesOverride: null, ct); + + public Task GetLatestAsync(string? channel, bool? includePrereleasesOverride, CancellationToken ct) { GetLatestCallCount++; LastChannelRequested = channel; + LastIncludePrereleasesOverride = includePrereleasesOverride; var result = LatestSelector?.Invoke(channel) ?? LatestForChannel; return Task.FromResult(result); } diff --git a/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Infrastructure/Stubs.cs b/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Infrastructure/Stubs.cs index dbbe6db..ce13dd1 100644 --- a/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Infrastructure/Stubs.cs +++ b/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Infrastructure/Stubs.cs @@ -7,12 +7,19 @@ internal sealed class StubSelfUpdater : ISelfUpdater public Func>? GetLatestImpl { get; set; } public Func?, CancellationToken, Task>? InstallImpl { get; set; } public Func?, Func>?, CancellationToken, Task>? InstallReleaseImpl { get; set; } + public bool? LastIncludePrereleasesOverride { get; private set; } public Task CheckAsync(CancellationToken ct = default) => CheckImpl?.Invoke(ct) ?? Task.FromResult(null); public Task GetLatestReleaseAsync(CancellationToken ct = default) => - GetLatestImpl?.Invoke(ct) ?? Task.FromResult(null); + GetLatestReleaseAsync(includePrereleasesOverride: null, ct); + + public Task GetLatestReleaseAsync(bool? includePrereleasesOverride, CancellationToken ct = default) + { + LastIncludePrereleasesOverride = includePrereleasesOverride; + return GetLatestImpl?.Invoke(ct) ?? Task.FromResult(null); + } public Task InstallAsync( RemoteRelease release, @@ -30,9 +37,16 @@ internal sealed class StubUpdateChecker : IUpdateChecker { public string? CurrentVersion { get; set; } = "1.0.0"; public Func>? CheckImpl { get; set; } + public bool? LastIncludePrereleasesOverride { get; private set; } public Task CheckAsync(CancellationToken ct = default) => - CheckImpl?.Invoke(ct) ?? Task.FromResult(null); + CheckAsync(includePrereleasesOverride: null, ct); + + public Task CheckAsync(bool? includePrereleasesOverride, CancellationToken ct = default) + { + LastIncludePrereleasesOverride = includePrereleasesOverride; + return CheckImpl?.Invoke(ct) ?? Task.FromResult(null); + } public string? GetCurrentVersion() => CurrentVersion; } diff --git a/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Pipeline/UpdateCheckerTests.cs b/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Pipeline/UpdateCheckerTests.cs index bb7db7c..3113f4d 100644 --- a/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Pipeline/UpdateCheckerTests.cs +++ b/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Pipeline/UpdateCheckerTests.cs @@ -116,6 +116,27 @@ public async Task CheckAsync_returns_update_available_when_remote_is_newer() Assert.Equal("v1.0.5", info.LatestTag); } + [Fact] + public async Task CheckAsync_override_true_does_not_reuse_default_cache_entry() + { + using var dir = new TempDir(); + var opts = new SelfUpdaterOptions { AppName = "myapp", CacheDirectory = dir.Path, SkipVersionPredicate = null }; + var source = new FakeUpdateSource + { + LatestSelector = _ => TestRelease("v1.5.0"), + }; + var checker = NewChecker(opts, source, "1.0.0"); + + // First call without override populates the default-prerelease cache. + await checker.CheckAsync(); + Assert.Equal(1, source.GetLatestCallCount); + + // Second call with override=true must not reuse the default cache. + await checker.CheckAsync(includePrereleasesOverride: true); + Assert.Equal(2, source.GetLatestCallCount); + Assert.True(source.LastIncludePrereleasesOverride); + } + [Fact] public async Task CheckAsync_returns_no_update_when_remote_is_same() { diff --git a/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Sources/HttpGitHubReleaseSourceTests.cs b/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Sources/HttpGitHubReleaseSourceTests.cs index 0adf361..0db2f35 100644 --- a/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Sources/HttpGitHubReleaseSourceTests.cs +++ b/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Sources/HttpGitHubReleaseSourceTests.cs @@ -90,6 +90,31 @@ public async Task GetLatestAsync_with_includePrereleases_filters_drafts_only() Assert.Equal("v2.0.0-rc.1", release?.Tag); } + [Fact] + public async Task GetLatestAsync_with_override_true_hits_list_endpoint_when_constructor_says_false() + { + // Constructor was passed includePrereleases:false, but the + // per-invocation override should take over and force the source + // onto the listing endpoint that returns prereleases. + const string json = """ + [ + { "tag_name": "v2.0.0-rc.1", "draft": false, "prerelease": true, "published_at": "2026-05-02T12:00:00Z", "assets": [] }, + { "tag_name": "v1.4.2", "draft": false, "prerelease": false, "published_at": "2026-04-30T12:00:00Z", "assets": [] } + ] + """; + HttpRequestMessage? captured = null; + var handler = new FakeHttpHandler + { + Responder = req => { captured = req; return FakeHttpHandler.Json(json); }, + }; + var source = NewSource(handler, includePrereleases: false); + + var release = await source.GetLatestAsync(channel: null, includePrereleasesOverride: true, CancellationToken.None); + + Assert.Equal("v2.0.0-rc.1", release?.Tag); + Assert.DoesNotContain("/latest", captured?.RequestUri?.AbsolutePath ?? string.Empty, StringComparison.Ordinal); + } + [Fact] public async Task GetLatestAsync_when_request_fails_returns_null() {