diff --git a/CHANGELOG.md b/CHANGELOG.md index a533675..ed99091 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ All notable changes to this project are documented in this file. +## [0.2.2] + +### Added +- Passive update notification: when run interactively, the CLI performs a best-effort, once-per-day check against the GitHub Releases API and prints a one-line notice on stderr if a newer version is available. It never writes to stdout, is skipped when stderr is redirected (scripts/CI), fails silently offline, and can be disabled with `ADOMD_NO_UPDATE_CHECK=1`. + ## [0.2.1] ### Added diff --git a/README.md b/README.md index 0590846..1b691d5 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,15 @@ dotnet publish .\src\Adomd.Cli\Adomd.Cli.csproj --configuration Release --runtim .\artifacts\publish\win-x64\adomd.exe --help ``` +### Staying up to date + +The tool does not update itself. When it runs interactively, it performs a best-effort check against the GitHub Releases API and prints a one-line notice on **stderr** if a newer version is available; download the new release and replace `adomd.exe` to upgrade. The check: + +- never writes to stdout, so JSON output is unaffected; +- runs at most once per day (the result is cached under `%LocalAppData%\adomd-cli`) and uses a short timeout; +- is skipped automatically when stderr is redirected (scripts/CI) and fails silently when offline; +- can be disabled entirely by setting `ADOMD_NO_UPDATE_CHECK=1`. + ## Usage ```powershell diff --git a/src/Adomd.Cli.Tests/UpdateNotifierTests.cs b/src/Adomd.Cli.Tests/UpdateNotifierTests.cs new file mode 100644 index 0000000..22dbfc1 --- /dev/null +++ b/src/Adomd.Cli.Tests/UpdateNotifierTests.cs @@ -0,0 +1,64 @@ +public class UpdateNotifierTests +{ + [Theory] + [InlineData("v0.2.1", "0.2.1")] + [InlineData("0.2.1", "0.2.1")] + [InlineData("V1.0.0", "1.0.0")] + [InlineData("v1.2.3-rc1", "1.2.3")] + [InlineData("0.2.1+abc123", "0.2.1")] + [InlineData("v2.0.0-beta+sha.9f8", "2.0.0")] + [InlineData(" v3.4.5 ", "3.4.5")] + public void TryParseVersion_ParsesTagsAndStripsMetadata(string raw, string expected) + { + Assert.True(UpdateNotifier.TryParseVersion(raw, out var version)); + Assert.Equal(Version.Parse(expected), version); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("not-a-version")] + [InlineData("v")] + public void TryParseVersion_ReturnsFalse_ForInvalidInput(string? raw) + { + Assert.False(UpdateNotifier.TryParseVersion(raw, out var version)); + Assert.Null(version); + } + + [Fact] + public void IsNewer_True_WhenLatestGreater() + { + Assert.True(UpdateNotifier.IsNewer(Version.Parse("0.2.1"), Version.Parse("0.2.2"))); + Assert.True(UpdateNotifier.IsNewer(Version.Parse("0.2.1"), Version.Parse("1.0.0"))); + } + + [Fact] + public void IsNewer_False_WhenLatestEqualOrOlder() + { + Assert.False(UpdateNotifier.IsNewer(Version.Parse("0.2.1"), Version.Parse("0.2.1"))); + Assert.False(UpdateNotifier.IsNewer(Version.Parse("0.2.2"), Version.Parse("0.2.1"))); + } + + [Theory] + [InlineData("1", false, true)] + [InlineData("true", false, true)] + [InlineData("yes", false, true)] + [InlineData(null, true, true)] + [InlineData("", true, true)] + public void IsDisabled_True_WhenOptedOutOrStderrRedirected(string? optOut, bool redirected, bool expected) + { + Assert.Equal(expected, UpdateNotifier.IsDisabled(optOut, redirected)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("0")] + [InlineData("false")] + [InlineData("False")] + public void IsDisabled_False_WhenNotOptedOutAndStderrInteractive(string? optOut) + { + Assert.False(UpdateNotifier.IsDisabled(optOut, stderrRedirected: false)); + } +} diff --git a/src/Adomd.Cli/Adomd.Cli.csproj b/src/Adomd.Cli/Adomd.Cli.csproj index 00a6a06..c3405b5 100644 --- a/src/Adomd.Cli/Adomd.Cli.csproj +++ b/src/Adomd.Cli/Adomd.Cli.csproj @@ -4,7 +4,7 @@ Exe net10.0 adomd - 0.2.1 + 0.2.2 sbroenne JSON-first command-line wrapper for querying Analysis Services through ADOMD.NET. https://github.com/sbroenne/adomd-cli diff --git a/src/Adomd.Cli/Program.cs b/src/Adomd.Cli/Program.cs index d1a403d..4ff10d3 100644 --- a/src/Adomd.Cli/Program.cs +++ b/src/Adomd.Cli/Program.cs @@ -2,6 +2,7 @@ using System.Data; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; +using System.Net.Http; using System.Reflection; using System.Runtime.InteropServices; using System.Text; @@ -39,7 +40,9 @@ .WithDescription("Alias for query."); }); -return app.Run(args); +var exitCode = app.Run(args); +UpdateNotifier.Notify(); +return exitCode; /// Exit codes returned by JSON commands, exposed for tests and callers scripting against the CLI. public static class ExitCodes @@ -603,3 +606,204 @@ internal static RowsetResult DataTableToRows(DataTable table, int limit) return new RowsetResult { Rows = rows, Truncated = table.Rows.Count > limit }; } } + +/// Best-effort, fail-silent nudge that tells the user (on stderr only) when a newer release exists. +/// It never touches stdout, is throttled to at most one network call per day, uses a tight HTTP timeout, and +/// is easy to silence for automation. Any failure is swallowed so it can never affect the command outcome. +public static class UpdateNotifier +{ + private const string Repository = "sbroenne/adomd-cli"; + private static readonly TimeSpan CheckInterval = TimeSpan.FromHours(24); + private static readonly TimeSpan HttpTimeout = TimeSpan.FromSeconds(2); + + public static void Notify() => Notify(Console.Error); + + internal static void Notify(TextWriter stderr) + { + try + { + if (IsDisabled()) + { + return; + } + + if (!TryParseVersion(GetCurrentInformationalVersion(), out var current)) + { + return; + } + + var latest = GetLatestKnownRelease(); + if (latest is { } release && IsNewer(current, release.Version)) + { + stderr.WriteLine( + $"adomd: version {release.Version} is available (you have {current}). {release.Url}"); + stderr.WriteLine("adomd: set ADOMD_NO_UPDATE_CHECK=1 to silence this check."); + } + } + catch + { + // Update notification is strictly best-effort; never let it affect the command result. + } + } + + /// True when the check should be skipped: explicitly opted out, or stderr is being captured + /// (typical of scripts/CI) where an unsolicited message would be noise. + private static bool IsDisabled() => IsDisabled( + Environment.GetEnvironmentVariable("ADOMD_NO_UPDATE_CHECK"), + Console.IsErrorRedirected); + + internal static bool IsDisabled(string? optOutValue, bool stderrRedirected) + { + var explicitlyDisabled = !string.IsNullOrEmpty(optOutValue) + && optOutValue != "0" + && !string.Equals(optOutValue, "false", StringComparison.OrdinalIgnoreCase); + + return explicitlyDisabled || stderrRedirected; + } + + /// Returns the latest release we know about, refreshing from GitHub at most once per + /// and otherwise serving the cached value so invocations stay fast. + private static (Version Version, string Url)? GetLatestKnownRelease() + { + var cachePath = GetCachePath(); + var cache = ReadCache(cachePath); + + var due = cache is null || DateTimeOffset.UtcNow - cache.Value.LastCheckUtc >= CheckInterval; + if (due) + { + var fetched = FetchLatestRelease(); + // Record the attempt time regardless of success so an offline machine isn't slowed on every run, + // while keeping any previously cached version if this refresh failed. + var version = fetched?.Version.ToString() ?? cache?.Version; + var url = fetched?.Url ?? cache?.Url; + WriteCache(cachePath, new CacheEntry(DateTimeOffset.UtcNow, version, url)); + cache = new CacheEntry(DateTimeOffset.UtcNow, version, url); + } + + if (cache?.Version is { } cachedVersion + && cache.Value.Url is { } cachedUrl + && TryParseVersion(cachedVersion, out var parsed)) + { + return (parsed, cachedUrl); + } + + return null; + } + + private static (Version Version, string Url)? FetchLatestRelease() + { + using var http = new HttpClient { Timeout = HttpTimeout }; + http.DefaultRequestHeaders.UserAgent.ParseAdd("adomd-cli"); + http.DefaultRequestHeaders.Accept.ParseAdd("application/vnd.github+json"); + + using var response = http.GetAsync($"https://api.github.com/repos/{Repository}/releases/latest") + .GetAwaiter().GetResult(); + if (!response.IsSuccessStatusCode) + { + return null; + } + + var json = response.Content.ReadAsStringAsync().GetAwaiter().GetResult(); + using var document = JsonDocument.Parse(json); + var root = document.RootElement; + + if (!root.TryGetProperty("tag_name", out var tagElement) + || !TryParseVersion(tagElement.GetString(), out var version)) + { + return null; + } + + var url = root.TryGetProperty("html_url", out var urlElement) + ? urlElement.GetString() ?? $"https://github.com/{Repository}/releases/latest" + : $"https://github.com/{Repository}/releases/latest"; + + return (version, url); + } + + private static string GetCurrentInformationalVersion() => + Assembly.GetExecutingAssembly() + .GetCustomAttribute()?.InformationalVersion + ?? Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) + ?? "0.0.0"; + + /// Parses a release tag or version string into a comparable , tolerating a + /// leading 'v' and stripping any pre-release/build metadata (e.g. "v1.2.3-rc1+sha" -> 1.2.3). + internal static bool TryParseVersion(string? raw, [NotNullWhen(true)] out Version? version) + { + version = null; + if (string.IsNullOrWhiteSpace(raw)) + { + return false; + } + + var trimmed = raw.Trim(); + if (trimmed.StartsWith('v') || trimmed.StartsWith('V')) + { + trimmed = trimmed[1..]; + } + + var metadataStart = trimmed.IndexOfAny(['-', '+']); + if (metadataStart >= 0) + { + trimmed = trimmed[..metadataStart]; + } + + return Version.TryParse(trimmed, out version); + } + + internal static bool IsNewer(Version current, Version latest) => latest > current; + + private static string GetCachePath() => + Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "adomd-cli", + "update-check.json"); + + private static CacheEntry? ReadCache(string path) + { + try + { + if (!File.Exists(path)) + { + return null; + } + + using var document = JsonDocument.Parse(File.ReadAllText(path)); + var root = document.RootElement; + if (!root.TryGetProperty("lastCheckUtc", out var lastCheckElement) + || !lastCheckElement.TryGetDateTimeOffset(out var lastCheck)) + { + return null; + } + + var version = root.TryGetProperty("latestVersion", out var v) ? v.GetString() : null; + var url = root.TryGetProperty("latestUrl", out var u) ? u.GetString() : null; + return new CacheEntry(lastCheck, version, url); + } + catch + { + return null; + } + } + + private static void WriteCache(string path, CacheEntry entry) + { + try + { + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + var payload = JsonSerializer.Serialize(new + { + lastCheckUtc = entry.LastCheckUtc, + latestVersion = entry.Version, + latestUrl = entry.Url + }); + File.WriteAllText(path, payload); + } + catch + { + // A non-writable cache just means we re-check next time; not worth surfacing. + } + } + + private readonly record struct CacheEntry(DateTimeOffset LastCheckUtc, string? Version, string? Url); +}