Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.ComponentModel;
using System.Globalization;

using Spectre.Console;
Expand All @@ -16,8 +17,22 @@ namespace NextIteration.SpectreConsole.SelfUpdate.Commands
/// <item><description><c>2</c> — a newer release is available.</description></item>
/// </list>
/// </summary>
public sealed class UpdateCheckCommand : AsyncCommand
public sealed class UpdateCheckCommand : AsyncCommand<UpdateCheckCommand.Settings>
{
/// <summary>Settings for <c>update check</c>.</summary>
public sealed class Settings : CommandSettings
{
/// <summary>
/// Opt into prerelease tags for this invocation only. Equivalent
/// to flipping <c>SelfUpdaterOptions.IncludePrereleases</c> to
/// <c>true</c> for the lifetime of this command without mutating
/// shared options.
/// </summary>
[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;

Expand All @@ -32,15 +47,18 @@ public UpdateCheckCommand(IUpdateChecker checker, IAnsiConsole console)
}

/// <inheritdoc />
protected override async Task<int> ExecuteAsync(CommandContext context, CancellationToken cancellationToken)
protected override async Task<int> 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)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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; }

/// <summary>
/// Opt into prerelease tags for this invocation only. Equivalent
/// to flipping <c>SelfUpdaterOptions.IncludePrereleases</c> to
/// <c>true</c> for the lifetime of this command without mutating
/// shared options.
/// </summary>
[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 }
Expand Down Expand Up @@ -80,10 +90,11 @@ protected override async Task<int> 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)
{
Expand Down
20 changes: 17 additions & 3 deletions src/NextIteration.SpectreConsole.SelfUpdate/ISelfUpdater.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ namespace NextIteration.SpectreConsole.SelfUpdate
public interface ISelfUpdater
{
/// <summary>
/// Non-blocking probe — see <see cref="IUpdateChecker.CheckAsync"/>.
/// Non-blocking probe — see <see cref="IUpdateChecker.CheckAsync(CancellationToken)"/>.
/// </summary>
Task<UpdateInfo?> CheckAsync(CancellationToken ct = default);

Expand All @@ -26,6 +26,20 @@ public interface ISelfUpdater
/// </summary>
Task<RemoteRelease?> GetLatestReleaseAsync(CancellationToken ct = default);

/// <summary>
/// Per-invocation variant of
/// <see cref="GetLatestReleaseAsync(CancellationToken)"/> that lets the
/// caller override <see cref="SelfUpdaterOptions.IncludePrereleases"/>
/// for one call (used by the <c>update --prerelease</c> CLI flag).
/// <see langword="null"/> defers to the configured option;
/// <see langword="true"/>/<see langword="false"/> force inclusion or
/// exclusion. The default-interface implementation drops the override
/// and delegates to the base overload so existing custom updaters
/// continue to compile.
/// </summary>
Task<RemoteRelease?> GetLatestReleaseAsync(bool? includePrereleasesOverride, CancellationToken ct = default) =>
GetLatestReleaseAsync(ct);

/// <summary>
/// Install the supplied release: download, run the verifier
/// pipeline, extract the archive, and swap the new files into the
Expand Down Expand Up @@ -54,8 +68,8 @@ Task InstallAsync(
/// <see cref="UpdateException"/> when no release is available or
/// when any pipeline stage fails. <b>Has a TOCTOU window</b>: the
/// release returned by the source here may differ from the one a
/// prior <see cref="CheckAsync"/> reported. For interactive UIs,
/// prefer the <see cref="GetLatestReleaseAsync"/> +
/// prior <see cref="CheckAsync(CancellationToken)"/> reported. For interactive UIs,
/// prefer the <see cref="GetLatestReleaseAsync(CancellationToken)"/> +
/// <see cref="InstallAsync(RemoteRelease, IProgress{UpdateProgressEvent}, Func{UpdateConflict, CancellationToken, Task{UpdateConflictResolution}}, CancellationToken)"/>
/// pair so the user confirms exactly the release that gets
/// installed.
Expand Down
16 changes: 15 additions & 1 deletion src/NextIteration.SpectreConsole.SelfUpdate/IUpdateChecker.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,20 @@ public interface IUpdateChecker
/// </summary>
Task<UpdateInfo?> CheckAsync(CancellationToken ct = default);

/// <summary>
/// Per-invocation variant of <see cref="CheckAsync(CancellationToken)"/>
/// that lets the caller override
/// <see cref="SelfUpdaterOptions.IncludePrereleases"/> for one call
/// (used by the <c>update check --prerelease</c> CLI flag).
/// <see langword="null"/> defers to the configured option;
/// <see langword="true"/>/<see langword="false"/> force inclusion or
/// exclusion. The default-interface implementation drops the override
/// and delegates to the base overload so existing custom checkers
/// continue to compile.
/// </summary>
Task<UpdateInfo?> CheckAsync(bool? includePrereleasesOverride, CancellationToken ct = default) =>
CheckAsync(ct);

/// <summary>
/// The running CLI's version, read from
/// <see cref="System.Reflection.AssemblyInformationalVersionAttribute"/>
Expand All @@ -27,7 +41,7 @@ public interface IUpdateChecker
/// <see cref="SelfUpdaterOptions.SkipVersionPredicate"/> is
/// <b>not</b> consulted here — predicate-skipped versions are still
/// reported so commands can display them; the predicate suppresses
/// the <i>check</i> inside <see cref="CheckAsync"/>, not the
/// the <i>check</i> inside <see cref="CheckAsync(CancellationToken)"/>, not the
/// displayed version.
/// </summary>
string? GetCurrentVersion();
Expand Down
21 changes: 21 additions & 0 deletions src/NextIteration.SpectreConsole.SelfUpdate/IUpdateSource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,27 @@ public interface IUpdateSource
/// <param name="ct">Cancellation token honoured for both DNS and stream reads.</param>
Task<RemoteRelease?> GetLatestAsync(string? channel, CancellationToken ct);

/// <summary>
/// Per-invocation variant of <see cref="GetLatestAsync(string?, CancellationToken)"/>
/// that lets the caller override
/// <see cref="SelfUpdaterOptions.IncludePrereleases"/> without
/// mutating shared options. Used by the <c>update --prerelease</c>
/// and <c>update check --prerelease</c> CLI flags.
/// </summary>
/// <param name="channel">Channel filter — see the base overload.</param>
/// <param name="includePrereleasesOverride">
/// <see langword="null"/> defers to the source's captured
/// <see cref="SelfUpdaterOptions.IncludePrereleases"/>; <see langword="true"/>
/// forces prerelease inclusion for this call; <see langword="false"/>
/// 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.
/// </param>
/// <param name="ct">Cancellation token.</param>
Task<RemoteRelease?> GetLatestAsync(string? channel, bool? includePrereleasesOverride, CancellationToken ct) =>
GetLatestAsync(channel, ct);

/// <summary>
/// Stream a single release asset to <paramref name="destination"/>.
/// Should write nothing on failure and propagate the underlying
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

<PropertyGroup>
<PackageId>NextIteration.SpectreConsole.SelfUpdate</PackageId>
<Version>0.1.3</Version>
<Version>0.1.4</Version>
<Authors>Stuart Meeks</Authors>
<Description>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.</Description>
<GeneratePackageOnBuild Condition="'$(Configuration)' == 'Release'">true</GeneratePackageOnBuild>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,10 @@ public SelfUpdater(
_checker.CheckAsync(ct);

public Task<RemoteRelease?> GetLatestReleaseAsync(CancellationToken ct = default) =>
_source.GetLatestAsync(_options.Channel, ct);
GetLatestReleaseAsync(includePrereleasesOverride: null, ct);

public Task<RemoteRelease?> GetLatestReleaseAsync(bool? includePrereleasesOverride, CancellationToken ct = default) =>
_source.GetLatestAsync(_options.Channel, includePrereleasesOverride, ct);

public Task InstallAsync(
RemoteRelease release,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -45,17 +45,21 @@ internal UpdateChecker(
_utcNow = utcNow;
}

public async Task<UpdateInfo?> CheckAsync(CancellationToken ct = default)
public Task<UpdateInfo?> CheckAsync(CancellationToken ct = default) =>
CheckAsync(includePrereleasesOverride: null, ct);

public async Task<UpdateInfo?> CheckAsync(bool? includePrereleasesOverride, CancellationToken ct = default)
{
if (IsOptOutSet()) return null;

var current = GetCurrentVersion();
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));
}
Expand All @@ -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);
}
Expand Down Expand Up @@ -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;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ public sealed class SelfUpdaterOptions

/// <summary>
/// Optional release channel filter, e.g. <c>"stable"</c>,
/// <c>"beta"</c>. Forwarded to <see cref="IUpdateSource.GetLatestAsync"/>;
/// <c>"beta"</c>. Forwarded to <see cref="IUpdateSource.GetLatestAsync(string?, CancellationToken)"/>;
/// the source decides what the value means.
/// <see langword="null"/> uses the source's default channel.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ namespace NextIteration.SpectreConsole.SelfUpdate.Sources
/// --pattern &lt;name&gt; --output &lt;tempfile&gt;</c> followed by streaming
/// the temp file into the caller's destination. The tag is recovered
/// from <see cref="ReleaseAsset.Metadata"/> (key <c>"tag"</c>) which
/// the source populates during <see cref="GetLatestAsync"/>. Arguments
/// the source populates during <see cref="GetLatestAsync(string?, CancellationToken)"/>. Arguments
/// are passed via <see cref="System.Diagnostics.ProcessStartInfo.ArgumentList"/>
/// so values from a remote source never need to be quoted or escaped.
/// </para>
Expand Down Expand Up @@ -74,11 +74,16 @@ internal GhCliReleaseSource(
}

/// <inheritdoc />
public async Task<RemoteRelease?> GetLatestAsync(string? channel, CancellationToken ct)
public Task<RemoteRelease?> GetLatestAsync(string? channel, CancellationToken ct) =>
GetLatestAsync(channel, includePrereleasesOverride: null, ct);

/// <inheritdoc />
public async Task<RemoteRelease?> 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[]
Expand Down Expand Up @@ -106,7 +111,7 @@ internal GhCliReleaseSource(
var releases = JsonSerializer.Deserialize<GhReleaseDto[]>(listJson, JsonOpts) ?? Array.Empty<GhReleaseDto>();
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)
Expand Down
Loading
Loading