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);
+}