From cf66a1ef10525ae282edf7d9aa931e1f17804ca9 Mon Sep 17 00:00:00 2001 From: Zachary Teutsch Date: Mon, 27 Apr 2026 13:14:38 -0400 Subject: [PATCH 01/10] add upgrade notice --- .../UpdateNotificationServiceTests.cs | 215 +++++++++++++++++ .../Helpers/HostBuilderExtensions.cs | 1 + src/winapp-CLI/WinApp.Cli/Program.cs | 55 +++-- .../Services/IUpdateNotificationService.cs | 13 + .../Services/UpdateNotificationService.cs | 225 ++++++++++++++++++ 5 files changed, 493 insertions(+), 16 deletions(-) create mode 100644 src/winapp-CLI/WinApp.Cli.Tests/UpdateNotificationServiceTests.cs create mode 100644 src/winapp-CLI/WinApp.Cli/Services/IUpdateNotificationService.cs create mode 100644 src/winapp-CLI/WinApp.Cli/Services/UpdateNotificationService.cs diff --git a/src/winapp-CLI/WinApp.Cli.Tests/UpdateNotificationServiceTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/UpdateNotificationServiceTests.cs new file mode 100644 index 00000000..5852398f --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli.Tests/UpdateNotificationServiceTests.cs @@ -0,0 +1,215 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Extensions.DependencyInjection; +using System.Globalization; +using WinApp.Cli.Helpers; +using WinApp.Cli.Services; + +namespace WinApp.Cli.Tests; + +[TestClass] +[DoNotParallelize] // Tests modify environment variables +public class UpdateNotificationServiceTests : BaseCommandTests +{ + private IUpdateNotificationService _updateNotificationService = null!; + private string? _originalCaller; + private string? _originalLatestVersion; + + [TestInitialize] + public void Setup() + { + _updateNotificationService = GetRequiredService(); + // Save and clear env vars to avoid interference + _originalCaller = Environment.GetEnvironmentVariable("WINAPP_CLI_CALLER"); + _originalLatestVersion = Environment.GetEnvironmentVariable("WINAPP_CLI_LATEST_VERSION"); + Environment.SetEnvironmentVariable("WINAPP_CLI_CALLER", null); + Environment.SetEnvironmentVariable("WINAPP_CLI_LATEST_VERSION", "0.0.0"); + } + + [TestCleanup] + public void Cleanup() + { + Environment.SetEnvironmentVariable("WINAPP_CLI_CALLER", _originalCaller); + Environment.SetEnvironmentVariable("WINAPP_CLI_LATEST_VERSION", _originalLatestVersion); + } + + [TestMethod] + public async Task CheckAndNotifyAsync_FirstCall_WritesUpdateCheckCacheFile() + { + await _updateNotificationService.CheckAndNotifyAsync(TestContext.CancellationToken); + + var cacheFile = new FileInfo(Path.Combine(_testCacheDirectory.FullName, ".update-check")); + cacheFile.Refresh(); + Assert.IsTrue(cacheFile.Exists, "Update check cache file should be created"); + } + + [TestMethod] + public async Task CheckAndNotifyAsync_SecondCallWithinThreshold_SkipsCheck() + { + // First call writes cache + await _updateNotificationService.CheckAndNotifyAsync(TestContext.CancellationToken); + + var cacheFile = new FileInfo(Path.Combine(_testCacheDirectory.FullName, ".update-check")); + cacheFile.Refresh(); + var firstWriteTime = cacheFile.LastWriteTimeUtc; + + // Small delay to detect write time difference + await Task.Delay(50); + + // Second call should skip (cache is fresh) + await _updateNotificationService.CheckAndNotifyAsync(TestContext.CancellationToken); + cacheFile.Refresh(); + Assert.AreEqual(firstWriteTime, cacheFile.LastWriteTimeUtc, "Cache file should not be rewritten within threshold"); + } + + [TestMethod] + public async Task CheckAndNotifyAsync_ExpiredCache_RechecksAndWritesCache() + { + // Write an expired cache entry + var cacheDir = _testCacheDirectory.FullName; + var cacheFile = Path.Combine(cacheDir, ".update-check"); + var expiredTime = DateTime.UtcNow.AddHours(-25).ToString("O"); + File.WriteAllText(cacheFile, $"{expiredTime}\n"); + + await _updateNotificationService.CheckAndNotifyAsync(TestContext.CancellationToken); + + // Cache should be refreshed with a new timestamp + var lines = await File.ReadAllLinesAsync(cacheFile, TestContext.CancellationToken); + Assert.IsTrue(lines.Length >= 1); + Assert.IsTrue(DateTimeOffset.TryParse(lines[0], CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var newTimestamp)); + Assert.IsTrue((DateTimeOffset.UtcNow - newTimestamp).TotalMinutes < 1, "Cache timestamp should be recent"); + } + + [TestMethod] + public async Task CheckAndNotifyAsync_NewerVersionAvailable_DisplaysNotification() + { + // Set a version that is newer than the current CLI version + Environment.SetEnvironmentVariable("WINAPP_CLI_LATEST_VERSION", "999.0.0"); + + await _updateNotificationService.CheckAndNotifyAsync(TestContext.CancellationToken); + + var output = TestAnsiConsole.Output; + Assert.IsTrue(output.Contains("999.0.0"), $"Expected notification with version, got: {output}"); + Assert.IsTrue(output.Contains("available"), $"Expected 'available' in notification, got: {output}"); + } + + [TestMethod] + public async Task CheckAndNotifyAsync_SameVersion_NoNotification() + { + var currentVersion = VersionHelper.GetVersionString(); + Environment.SetEnvironmentVariable("WINAPP_CLI_LATEST_VERSION", currentVersion); + + await _updateNotificationService.CheckAndNotifyAsync(TestContext.CancellationToken); + + var output = TestAnsiConsole.Output; + Assert.IsFalse(output.Contains("available"), $"Should not notify for same version, got: {output}"); + } + + [TestMethod] + public async Task CheckAndNotifyAsync_OlderVersion_NoNotification() + { + Environment.SetEnvironmentVariable("WINAPP_CLI_LATEST_VERSION", "0.0.1"); + + await _updateNotificationService.CheckAndNotifyAsync(TestContext.CancellationToken); + + var output = TestAnsiConsole.Output; + Assert.IsFalse(output.Contains("available"), $"Should not notify for older version, got: {output}"); + } + + [TestMethod] + public async Task CheckAndNotifyAsync_NpmCaller_ShowsNpmUpgradeHint() + { + Environment.SetEnvironmentVariable("WINAPP_CLI_LATEST_VERSION", "999.0.0"); + Environment.SetEnvironmentVariable("WINAPP_CLI_CALLER", "npm"); + + await _updateNotificationService.CheckAndNotifyAsync(TestContext.CancellationToken); + + var output = TestAnsiConsole.Output; + Assert.IsTrue(output.Contains("npm update -g @microsoft/winappcli"), $"Expected npm hint, got: {output}"); + } + + [TestMethod] + public async Task CheckAndNotifyAsync_NodejsPackageCaller_ShowsNpmUpgradeHint() + { + Environment.SetEnvironmentVariable("WINAPP_CLI_LATEST_VERSION", "999.0.0"); + Environment.SetEnvironmentVariable("WINAPP_CLI_CALLER", "nodejs-package"); + + await _updateNotificationService.CheckAndNotifyAsync(TestContext.CancellationToken); + + var output = TestAnsiConsole.Output; + Assert.IsTrue(output.Contains("npm update -g @microsoft/winappcli"), $"Expected npm hint, got: {output}"); + } + + [TestMethod] + public async Task CheckAndNotifyAsync_NuGetCaller_ShowsNuGetUpgradeHint() + { + Environment.SetEnvironmentVariable("WINAPP_CLI_LATEST_VERSION", "999.0.0"); + Environment.SetEnvironmentVariable("WINAPP_CLI_CALLER", "nuget-package"); + + await _updateNotificationService.CheckAndNotifyAsync(TestContext.CancellationToken); + + var output = TestAnsiConsole.Output; + Assert.IsTrue(output.Contains("Microsoft.Windows.SDK.BuildTools.WinApp"), $"Expected NuGet hint, got: {output}"); + } + + [TestMethod] + public async Task CheckAndNotifyAsync_StandaloneExe_ShowsReleasesPageHint() + { + Environment.SetEnvironmentVariable("WINAPP_CLI_LATEST_VERSION", "999.0.0"); + // No WINAPP_CLI_CALLER set, defaults to standalone exe + + await _updateNotificationService.CheckAndNotifyAsync(TestContext.CancellationToken); + + var output = TestAnsiConsole.Output; + Assert.IsTrue(output.Contains("github.com/microsoft/winappcli/releases"), $"Expected releases page hint, got: {output}"); + } + + [TestMethod] + public void IsNewerVersion_NewerVersion_ReturnsTrue() + { + Assert.IsTrue(UpdateNotificationService.IsNewerVersion("2.0.0", "1.0.0")); + } + + [TestMethod] + public void IsNewerVersion_SameVersion_ReturnsFalse() + { + Assert.IsFalse(UpdateNotificationService.IsNewerVersion("1.0.0", "1.0.0")); + } + + [TestMethod] + public void IsNewerVersion_OlderVersion_ReturnsFalse() + { + Assert.IsFalse(UpdateNotificationService.IsNewerVersion("0.9.0", "1.0.0")); + } + + [TestMethod] + public void IsNewerVersion_PreReleaseToStable_ReturnsTrue() + { + Assert.IsTrue(UpdateNotificationService.IsNewerVersion("1.0.0", "1.0.0-beta.1")); + } + + [TestMethod] + public void IsNewerVersion_StableToPreRelease_ReturnsFalse() + { + Assert.IsFalse(UpdateNotificationService.IsNewerVersion("1.0.0-beta.1", "1.0.0")); + } + + [TestMethod] + public void IsNewerVersion_WithBuildMetadata_StripsAndCompares() + { + Assert.IsTrue(UpdateNotificationService.IsNewerVersion("2.0.0+build123", "1.0.0+abc456")); + } + + [TestMethod] + public void IsNewerVersion_InvalidLatest_ReturnsFalse() + { + Assert.IsFalse(UpdateNotificationService.IsNewerVersion("not-a-version", "1.0.0")); + } + + [TestMethod] + public void IsNewerVersion_InvalidCurrent_ReturnsFalse() + { + Assert.IsFalse(UpdateNotificationService.IsNewerVersion("2.0.0", "not-a-version")); + } +} diff --git a/src/winapp-CLI/WinApp.Cli/Helpers/HostBuilderExtensions.cs b/src/winapp-CLI/WinApp.Cli/Helpers/HostBuilderExtensions.cs index b0f4b3a3..5db8f5b0 100644 --- a/src/winapp-CLI/WinApp.Cli/Helpers/HostBuilderExtensions.cs +++ b/src/winapp-CLI/WinApp.Cli/Helpers/HostBuilderExtensions.cs @@ -45,6 +45,7 @@ public static IServiceCollection ConfigureServices(this IServiceCollection servi .AddSingleton(AnsiConsole.Console) .AddSingleton() .AddSingleton() + .AddSingleton() // UI Automation services .AddSingleton() .AddSingleton() diff --git a/src/winapp-CLI/WinApp.Cli/Program.cs b/src/winapp-CLI/WinApp.Cli/Program.cs index 82eace46..5dd40512 100644 --- a/src/winapp-CLI/WinApp.Cli/Program.cs +++ b/src/winapp-CLI/WinApp.Cli/Program.cs @@ -84,15 +84,45 @@ static async Task Main(string[] args) using var serviceProvider = services.BuildServiceProvider(); + var rootCommand = serviceProvider.GetRequiredService(); + System.CommandLine.ParseResult? parseResult = null; + + if (args.Length > 0) + { + parseResult = rootCommand.Parse(args, WinAppParserConfiguration.Default); + + // Set WINAPP_CLI_CALLER env var from --caller option so telemetry and update checks can use it + var caller = parseResult.GetValue(WinAppRootCommand.CallerOption); + if (!string.IsNullOrWhiteSpace(caller)) + { + Environment.SetEnvironmentVariable("WINAPP_CLI_CALLER", caller); + } + } + // Skip first-run notice for machine-readable output modes and completions var didShowFirstRunNotice = false; if (!isCliSchemaMode && !isCompleteMode && !json) { var firstRunService = serviceProvider.GetRequiredService(); didShowFirstRunNotice = firstRunService.CheckAndDisplayFirstRunNotice(); - } - var rootCommand = serviceProvider.GetRequiredService(); + // Check for CLI updates (at most once per day, silent on failure) + if (!quiet) + { + var updateNotificationService = serviceProvider.GetRequiredService(); + + using var cancellationTokenSource = new CancellationTokenSource(TimeSpan.FromSeconds(2)); + + try + { + await updateNotificationService.CheckAndNotifyAsync(cancellationTokenSource.Token); + } + catch (OperationCanceledException) + { + // Keep startup responsive if the update check is slow/unreachable or canceled. + } + } + } // If no arguments provided, display banner and show help if (args.Length == 0) @@ -107,16 +137,16 @@ static async Task Main(string[] args) return 0; } - var parseResult = rootCommand.Parse(args, WinAppParserConfiguration.Default); + var parsedArgs = parseResult!; // Catch single-dash typos like "-app" before invocation so the user gets a clear // "Did you mean --app?" message instead of System.CommandLine's confusing // "Unrecognized command or argument" pointing at the wrong token (issue #467). // Only run when parsing already failed — otherwise a command that legitimately // accepts a "-foo"-shaped positional value would get a false-positive typo error. - if (parseResult.Errors.Count > 0) + if (parsedArgs.Errors.Count > 0) { - var typo = OptionTypoValidator.FindLikelyLongOptionTypo(args, parseResult); + var typo = OptionTypoValidator.FindLikelyLongOptionTypo(args, parsedArgs); if (typo is not null) { var suggested = "-" + typo; @@ -127,32 +157,25 @@ static async Task Main(string[] args) } } - // Set WINAPP_CLI_CALLER env var from --caller option so telemetry picks it up - var caller = parseResult.GetValue(WinAppRootCommand.CallerOption); - if (!string.IsNullOrWhiteSpace(caller)) - { - Environment.SetEnvironmentVariable("WINAPP_CLI_CALLER", caller); - } - try { if (!isCompleteMode) { - CommandInvokedEvent.Log(parseResult.CommandResult); + CommandInvokedEvent.Log(parsedArgs.CommandResult); } - var returnCode = await parseResult.InvokeAsync(); + var returnCode = await parsedArgs.InvokeAsync(); if (!isCompleteMode) { - CommandCompletedEvent.Log(parseResult.CommandResult, returnCode); + CommandCompletedEvent.Log(parsedArgs.CommandResult, returnCode); } return returnCode; } catch (Exception ex) { - TelemetryFactory.Get().LogException(parseResult.CommandResult.Command.Name, ex); + TelemetryFactory.Get().LogException(parsedArgs.CommandResult.Command.Name, ex); Console.Error.WriteLine($"An unexpected error occurred: {ex.Message}"); return 1; } diff --git a/src/winapp-CLI/WinApp.Cli/Services/IUpdateNotificationService.cs b/src/winapp-CLI/WinApp.Cli/Services/IUpdateNotificationService.cs new file mode 100644 index 00000000..2095e153 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Services/IUpdateNotificationService.cs @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +namespace WinApp.Cli.Services; + +internal interface IUpdateNotificationService +{ + /// + /// Checks if an update is available (at most once per day) and prints a notification. + /// Failures are silently ignored; cancellation is allowed to propagate. + /// + Task CheckAndNotifyAsync(CancellationToken cancellationToken = default); +} diff --git a/src/winapp-CLI/WinApp.Cli/Services/UpdateNotificationService.cs b/src/winapp-CLI/WinApp.Cli/Services/UpdateNotificationService.cs new file mode 100644 index 00000000..bdb00c47 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Services/UpdateNotificationService.cs @@ -0,0 +1,225 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Extensions.Logging; +using Spectre.Console; +using System.Globalization; +using System.Runtime.InteropServices; +using System.Text.Json; +using WinApp.Cli.Helpers; + +namespace WinApp.Cli.Services; + +internal class UpdateNotificationService( + IWinappDirectoryService winappDirectoryService, + IAnsiConsole ansiConsole, + ILogger logger) : IUpdateNotificationService +{ + private static readonly HttpClient Http = new() { Timeout = TimeSpan.FromSeconds(10) }; + private const string GitHubApiLatestRelease = "https://api.github.com/repos/microsoft/winappcli/releases/latest"; + private const string UpdateCheckFileName = ".update-check"; + private const int CheckIntervalHours = 24; + + public async Task CheckAndNotifyAsync(CancellationToken cancellationToken = default) + { + try + { + var cacheFile = GetUpdateCheckFile(); + + // Read cache to see if we already checked recently + if (cacheFile.Exists) + { + var lines = await File.ReadAllLinesAsync(cacheFile.FullName, cancellationToken); + if (lines.Length >= 1 + && DateTimeOffset.TryParse(lines[0], CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var lastCheck) + && (DateTimeOffset.UtcNow - lastCheck).TotalHours < CheckIntervalHours) + { + // Already checked and notified within the last 24 hours — skip + return; + } + } + + var latestVersion = await GetLatestVersionAsync(cancellationToken); + var currentVersion = VersionHelper.GetVersionString(); + string? newVersion = null; + + if (latestVersion != null && IsNewerVersion(latestVersion, currentVersion)) + { + newVersion = latestVersion; + DisplayUpdateNotification(newVersion); + } + + // Write cache with timestamp so we don't check again for 24 hours + WriteCacheFile(cacheFile, newVersion); + } + catch (OperationCanceledException) + { + throw; + } + catch + { + // Silent failure — never disrupt the user's command + } + } + + internal async Task GetLatestVersionAsync(CancellationToken cancellationToken = default) + { + // Allow overriding the latest version for testing (skips GitHub API call) + var overrideVersion = Environment.GetEnvironmentVariable("WINAPP_CLI_LATEST_VERSION"); + if (!string.IsNullOrEmpty(overrideVersion)) + { + return overrideVersion; + } + + try + { + using var request = new HttpRequestMessage(HttpMethod.Get, GitHubApiLatestRelease); + request.Headers.Add("Accept", "application/vnd.github+json"); + request.Headers.UserAgent.ParseAdd("WinAppCLI"); + + using var response = await Http.SendAsync(request, cancellationToken); + response.EnsureSuccessStatusCode(); + + using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); + using var doc = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken); + + var tagName = doc.RootElement.GetProperty("tag_name").GetString(); + if (string.IsNullOrEmpty(tagName)) + { + return null; + } + + // Strip leading "v" prefix (e.g. "v0.3.0" → "0.3.0") + return tagName.StartsWith('v') ? tagName[1..] : tagName; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + logger.LogDebug("Failed to check for CLI updates: {Error}", ex.Message); + return null; + } + } + + private void DisplayUpdateNotification(string newVersion) + { + var upgradeHint = DetectInstallChannel() switch + { + InstallChannel.Npm => "npm update -g @microsoft/winappcli", + InstallChannel.NuGet => "dotnet add package Microsoft.Windows.SDK.BuildTools.WinApp", + _ => "visit https://github.com/microsoft/winappcli/releases" + }; + + ansiConsole.MarkupLine($"[yellow]v{newVersion} is available. To update, {Markup.Escape(upgradeHint)}.[/]"); + } + + internal static bool IsNewerVersion(string latest, string current) + { + static bool TryParseSemVer(string value, out Version coreVersion, out string? prerelease) + { + coreVersion = new Version(0, 0); + prerelease = null; + + var plusIdx = value.IndexOf('+'); + if (plusIdx >= 0) + { + value = value[..plusIdx]; + } + + var dashIdx = value.IndexOf('-'); + if (dashIdx >= 0) + { + prerelease = value[(dashIdx + 1)..]; + value = value[..dashIdx]; + } + + return Version.TryParse(value, out coreVersion!); + } + + if (!TryParseSemVer(latest, out var latestCore, out var latestPre)) + { + return false; + } + + if (!TryParseSemVer(current, out var currentCore, out var currentPre)) + { + return false; + } + + var coreCompare = latestCore.CompareTo(currentCore); + if (coreCompare != 0) + { + return coreCompare > 0; + } + + // Same core version: a stable release (no pre-release) is newer than a pre-release + if (currentPre != null && latestPre == null) + { + return true; + } + + return false; + } + + private static InstallChannel DetectInstallChannel() + { + // Check caller env var (set by wrapper scripts via --caller option) + var caller = Environment.GetEnvironmentVariable("WINAPP_CLI_CALLER"); + if (string.Equals(caller, "npm", StringComparison.OrdinalIgnoreCase) + || string.Equals(caller, "nodejs-package", StringComparison.OrdinalIgnoreCase)) + { + return InstallChannel.Npm; + } + if (string.Equals(caller, "nuget-package", StringComparison.OrdinalIgnoreCase)) + { + return InstallChannel.NuGet; + } + + // Check exe path heuristics + var exePath = Environment.ProcessPath; + if (!string.IsNullOrEmpty(exePath)) + { + if (exePath.Contains("node_modules", StringComparison.OrdinalIgnoreCase)) + { + return InstallChannel.Npm; + } + if (exePath.Contains(".nuget", StringComparison.OrdinalIgnoreCase)) + { + return InstallChannel.NuGet; + } + } + + return InstallChannel.StandaloneExe; + } + + private FileInfo GetUpdateCheckFile() + { + var globalDir = winappDirectoryService.GetGlobalWinappDirectory(); + return new FileInfo(Path.Combine(globalDir.FullName, UpdateCheckFileName)); + } + + private void WriteCacheFile(FileInfo cacheFile, string? newVersion) + { + try + { + cacheFile.Directory?.Create(); + File.WriteAllText(cacheFile.FullName, $"{DateTime.UtcNow:O}\n{newVersion ?? ""}"); + cacheFile.Refresh(); + cacheFile.Attributes |= FileAttributes.Hidden; + } + catch (Exception ex) + { + logger.LogDebug("Failed to write update check cache: {Error}", ex.Message); + } + } +} + +internal enum InstallChannel +{ + Msix, + StandaloneExe, + Npm, + NuGet +} From a39bf63de2fa506ffeb98c209dc81faed3c43c64 Mon Sep 17 00:00:00 2001 From: Zach Teutsch <88554871+zateutsch@users.noreply.github.com> Date: Mon, 27 Apr 2026 16:11:13 -0400 Subject: [PATCH 02/10] Update src/winapp-CLI/WinApp.Cli/Services/UpdateNotificationService.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/winapp-CLI/WinApp.Cli/Services/UpdateNotificationService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/winapp-CLI/WinApp.Cli/Services/UpdateNotificationService.cs b/src/winapp-CLI/WinApp.Cli/Services/UpdateNotificationService.cs index bdb00c47..30c70365 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/UpdateNotificationService.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/UpdateNotificationService.cs @@ -108,7 +108,7 @@ private void DisplayUpdateNotification(string newVersion) var upgradeHint = DetectInstallChannel() switch { InstallChannel.Npm => "npm update -g @microsoft/winappcli", - InstallChannel.NuGet => "dotnet add package Microsoft.Windows.SDK.BuildTools.WinApp", + InstallChannel.NuGet => "visit https://github.com/microsoft/winappcli/releases", _ => "visit https://github.com/microsoft/winappcli/releases" }; From a166c522b4e3dd5e60ee4217e5c80dc9c4778ab4 Mon Sep 17 00:00:00 2001 From: Zach Teutsch <88554871+zateutsch@users.noreply.github.com> Date: Mon, 27 Apr 2026 17:16:02 -0400 Subject: [PATCH 03/10] Update src/winapp-CLI/WinApp.Cli/Services/UpdateNotificationService.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/winapp-CLI/WinApp.Cli/Services/UpdateNotificationService.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/winapp-CLI/WinApp.Cli/Services/UpdateNotificationService.cs b/src/winapp-CLI/WinApp.Cli/Services/UpdateNotificationService.cs index 30c70365..a8a2783e 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/UpdateNotificationService.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/UpdateNotificationService.cs @@ -4,7 +4,6 @@ using Microsoft.Extensions.Logging; using Spectre.Console; using System.Globalization; -using System.Runtime.InteropServices; using System.Text.Json; using WinApp.Cli.Helpers; From 314b83cd3f72ebd3d41bdee47bc2e1b201337322 Mon Sep 17 00:00:00 2001 From: Zach Teutsch <88554871+zateutsch@users.noreply.github.com> Date: Mon, 27 Apr 2026 17:25:16 -0400 Subject: [PATCH 04/10] Update src/winapp-CLI/WinApp.Cli/Services/UpdateNotificationService.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/winapp-CLI/WinApp.Cli/Services/UpdateNotificationService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/winapp-CLI/WinApp.Cli/Services/UpdateNotificationService.cs b/src/winapp-CLI/WinApp.Cli/Services/UpdateNotificationService.cs index a8a2783e..ae32c92d 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/UpdateNotificationService.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/UpdateNotificationService.cs @@ -97,7 +97,7 @@ public async Task CheckAndNotifyAsync(CancellationToken cancellationToken = defa } catch (Exception ex) { - logger.LogDebug("Failed to check for CLI updates: {Error}", ex.Message); + logger.LogDebug(ex, "Failed to check for CLI updates."); return null; } } From 073aadc0c374e23df714edb5dfccd45d017f6570 Mon Sep 17 00:00:00 2001 From: Zach Teutsch <88554871+zateutsch@users.noreply.github.com> Date: Mon, 27 Apr 2026 17:25:25 -0400 Subject: [PATCH 05/10] Update src/winapp-CLI/WinApp.Cli/Services/UpdateNotificationService.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/winapp-CLI/WinApp.Cli/Services/UpdateNotificationService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/winapp-CLI/WinApp.Cli/Services/UpdateNotificationService.cs b/src/winapp-CLI/WinApp.Cli/Services/UpdateNotificationService.cs index ae32c92d..aaf6b52b 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/UpdateNotificationService.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/UpdateNotificationService.cs @@ -210,7 +210,7 @@ private void WriteCacheFile(FileInfo cacheFile, string? newVersion) } catch (Exception ex) { - logger.LogDebug("Failed to write update check cache: {Error}", ex.Message); + logger.LogDebug(ex, "Failed to write update check cache."); } } } From 79064fd3213c46c60227d9904634c8789656b20a Mon Sep 17 00:00:00 2001 From: Zach Teutsch <88554871+zateutsch@users.noreply.github.com> Date: Mon, 27 Apr 2026 17:25:43 -0400 Subject: [PATCH 06/10] Update src/winapp-CLI/WinApp.Cli.Tests/UpdateNotificationServiceTests.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../WinApp.Cli.Tests/UpdateNotificationServiceTests.cs | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/winapp-CLI/WinApp.Cli.Tests/UpdateNotificationServiceTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/UpdateNotificationServiceTests.cs index 5852398f..59c9a033 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/UpdateNotificationServiceTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/UpdateNotificationServiceTests.cs @@ -52,15 +52,13 @@ public async Task CheckAndNotifyAsync_SecondCallWithinThreshold_SkipsCheck() var cacheFile = new FileInfo(Path.Combine(_testCacheDirectory.FullName, ".update-check")); cacheFile.Refresh(); - var firstWriteTime = cacheFile.LastWriteTimeUtc; - - // Small delay to detect write time difference - await Task.Delay(50); + Assert.IsTrue(cacheFile.Exists, "Update check cache file should be created"); + var initialCacheContents = await File.ReadAllTextAsync(cacheFile.FullName, TestContext.CancellationToken); // Second call should skip (cache is fresh) await _updateNotificationService.CheckAndNotifyAsync(TestContext.CancellationToken); - cacheFile.Refresh(); - Assert.AreEqual(firstWriteTime, cacheFile.LastWriteTimeUtc, "Cache file should not be rewritten within threshold"); + var subsequentCacheContents = await File.ReadAllTextAsync(cacheFile.FullName, TestContext.CancellationToken); + Assert.AreEqual(initialCacheContents, subsequentCacheContents, "Cache file should not be rewritten within threshold"); } [TestMethod] From f879a5b4e1bfe73b4b759bd4d264699ca6fe26c9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 27 Apr 2026 21:33:56 +0000 Subject: [PATCH 07/10] fix: implement SemVer pre-release identifier comparison in IsNewerVersion Agent-Logs-Url: https://github.com/microsoft/winappCli/sessions/30586b2a-a92a-468d-8e95-e527acc67aea Co-authored-by: zateutsch <88554871+zateutsch@users.noreply.github.com> --- .../UpdateNotificationServiceTests.cs | 41 ++++++++++++++++ .../Services/UpdateNotificationService.cs | 47 +++++++++++++++++-- 2 files changed, 84 insertions(+), 4 deletions(-) diff --git a/src/winapp-CLI/WinApp.Cli.Tests/UpdateNotificationServiceTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/UpdateNotificationServiceTests.cs index 59c9a033..39e385c9 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/UpdateNotificationServiceTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/UpdateNotificationServiceTests.cs @@ -210,4 +210,45 @@ public void IsNewerVersion_InvalidCurrent_ReturnsFalse() { Assert.IsFalse(UpdateNotificationService.IsNewerVersion("2.0.0", "not-a-version")); } + + [TestMethod] + public void IsNewerVersion_NewerPreReleaseNumericIdentifier_ReturnsTrue() + { + // beta.2 > beta.1 because the numeric identifier 2 > 1 + Assert.IsTrue(UpdateNotificationService.IsNewerVersion("1.0.0-beta.2", "1.0.0-beta.1")); + } + + [TestMethod] + public void IsNewerVersion_OlderPreReleaseNumericIdentifier_ReturnsFalse() + { + Assert.IsFalse(UpdateNotificationService.IsNewerVersion("1.0.0-beta.1", "1.0.0-beta.2")); + } + + [TestMethod] + public void IsNewerVersion_SamePreRelease_ReturnsFalse() + { + Assert.IsFalse(UpdateNotificationService.IsNewerVersion("1.0.0-beta.1", "1.0.0-beta.1")); + } + + [TestMethod] + public void IsNewerVersion_LaterAlphaPreRelease_ReturnsTrue() + { + // "rc" > "beta" lexically + Assert.IsTrue(UpdateNotificationService.IsNewerVersion("1.0.0-rc.1", "1.0.0-beta.1")); + } + + [TestMethod] + public void IsNewerVersion_NumericVsAlphanumericPreRelease_ReturnsCorrectOrder() + { + // Per SemVer: numeric identifiers have lower precedence than alphanumeric ones + Assert.IsFalse(UpdateNotificationService.IsNewerVersion("1.0.0-1", "1.0.0-alpha")); + Assert.IsTrue(UpdateNotificationService.IsNewerVersion("1.0.0-alpha", "1.0.0-1")); + } + + [TestMethod] + public void IsNewerVersion_LongerPreReleaseWithMatchingPrefix_ReturnsTrue() + { + // "beta.1.2" > "beta.1" because more fields when prefix matches + Assert.IsTrue(UpdateNotificationService.IsNewerVersion("1.0.0-beta.1.2", "1.0.0-beta.1")); + } } diff --git a/src/winapp-CLI/WinApp.Cli/Services/UpdateNotificationService.cs b/src/winapp-CLI/WinApp.Cli/Services/UpdateNotificationService.cs index aaf6b52b..76715fc7 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/UpdateNotificationService.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/UpdateNotificationService.cs @@ -153,13 +153,52 @@ static bool TryParseSemVer(string value, out Version coreVersion, out string? pr return coreCompare > 0; } - // Same core version: a stable release (no pre-release) is newer than a pre-release - if (currentPre != null && latestPre == null) + // Same core version: compare pre-release identifiers per SemVer 2.0.0 + return ComparePreRelease(latestPre, currentPre) > 0; + } + + // Compares two SemVer pre-release strings identifier-by-identifier. + // Returns positive if a > b, negative if a < b, zero if equal. + // A null value (stable release) is always greater than any pre-release string. + private static int ComparePreRelease(string? a, string? b) + { + if (a == null && b == null) { return 0; } + if (a == null) { return 1; } // stable > pre-release + if (b == null) { return -1; } // pre-release < stable + + var aIds = a.Split('.'); + var bIds = b.Split('.'); + var len = Math.Min(aIds.Length, bIds.Length); + + for (var i = 0; i < len; i++) { - return true; + var aIsNum = int.TryParse(aIds[i], out var aNum); + var bIsNum = int.TryParse(bIds[i], out var bNum); + + int cmp; + if (aIsNum && bIsNum) + { + cmp = aNum.CompareTo(bNum); + } + else if (aIsNum) + { + // Per SemVer: numeric identifiers have lower precedence than alphanumeric + cmp = -1; + } + else if (bIsNum) + { + cmp = 1; + } + else + { + cmp = string.Compare(aIds[i], bIds[i], StringComparison.Ordinal); + } + + if (cmp != 0) { return cmp; } } - return false; + // All compared identifiers are equal; a longer pre-release has higher precedence + return aIds.Length.CompareTo(bIds.Length); } private static InstallChannel DetectInstallChannel() From af89a7da42a110a219580ba04d8f0ef4d55ea639 Mon Sep 17 00:00:00 2001 From: Zachary Teutsch Date: Tue, 28 Apr 2026 20:46:29 -0400 Subject: [PATCH 08/10] address Nikola feedback: cache check to prevent latency, add env variable for disabling and check for CI environment --- docs/usage.md | 24 ++ .../UpdateNotificationServiceTests.cs | 211 ++++++++++++++---- src/winapp-CLI/WinApp.Cli/Program.cs | 15 +- .../Services/IUpdateNotificationService.cs | 7 +- .../Services/UpdateNotificationService.cs | 123 +++++++--- 5 files changed, 290 insertions(+), 90 deletions(-) diff --git a/docs/usage.md b/docs/usage.md index c14413ae..d49d84d6 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -933,6 +933,30 @@ $env:WINAPP_CLI_CACHE_DIRECTORY=d:\temp\.winapp ``` Winapp will create this directory automatically when you run commands like `init` or `restore`. + +### Update Checks + +The winapp CLI periodically checks for new versions and displays a one-line notice when an update is available. This check runs in the background and adds no latency to commands. + +Update checks are automatically disabled in CI environments (GitHub Actions, Azure Pipelines, etc.). + +To manually disable update checks, set the `WINAPP_CLI_UPDATE_CHECK` environment variable to `0`. + +In **cmd**: +```cmd +set WINAPP_CLI_UPDATE_CHECK=0 +``` + +In **PowerShell** and **pwsh**: +```pwsh +$env:WINAPP_CLI_UPDATE_CHECK = "0" +``` + +To make this permanent: +```powershell +[System.Environment]::SetEnvironmentVariable('WINAPP_CLI_UPDATE_CHECK', '0', 'User') +``` + ### ui Inspect and interact with running Windows app UIs using UI Automation (UIA). diff --git a/src/winapp-CLI/WinApp.Cli.Tests/UpdateNotificationServiceTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/UpdateNotificationServiceTests.cs index 39e385c9..2d873372 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/UpdateNotificationServiceTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/UpdateNotificationServiceTests.cs @@ -13,18 +13,26 @@ namespace WinApp.Cli.Tests; public class UpdateNotificationServiceTests : BaseCommandTests { private IUpdateNotificationService _updateNotificationService = null!; + private UpdateNotificationService _concreteService = null!; private string? _originalCaller; private string? _originalLatestVersion; + private string? _originalUpdateCheck; + private string? _originalCI; [TestInitialize] public void Setup() { _updateNotificationService = GetRequiredService(); + _concreteService = (UpdateNotificationService)_updateNotificationService; // Save and clear env vars to avoid interference _originalCaller = Environment.GetEnvironmentVariable("WINAPP_CLI_CALLER"); _originalLatestVersion = Environment.GetEnvironmentVariable("WINAPP_CLI_LATEST_VERSION"); + _originalUpdateCheck = Environment.GetEnvironmentVariable("WINAPP_CLI_UPDATE_CHECK"); + _originalCI = Environment.GetEnvironmentVariable("CI"); Environment.SetEnvironmentVariable("WINAPP_CLI_CALLER", null); Environment.SetEnvironmentVariable("WINAPP_CLI_LATEST_VERSION", "0.0.0"); + Environment.SetEnvironmentVariable("WINAPP_CLI_UPDATE_CHECK", null); + Environment.SetEnvironmentVariable("CI", null); } [TestCleanup] @@ -32,137 +40,244 @@ public void Cleanup() { Environment.SetEnvironmentVariable("WINAPP_CLI_CALLER", _originalCaller); Environment.SetEnvironmentVariable("WINAPP_CLI_LATEST_VERSION", _originalLatestVersion); + Environment.SetEnvironmentVariable("WINAPP_CLI_UPDATE_CHECK", _originalUpdateCheck); + Environment.SetEnvironmentVariable("CI", _originalCI); } [TestMethod] - public async Task CheckAndNotifyAsync_FirstCall_WritesUpdateCheckCacheFile() + public void CheckAndNotify_NoCacheFile_NoNotificationAndStartsBackgroundRefresh() { - await _updateNotificationService.CheckAndNotifyAsync(TestContext.CancellationToken); + // First run with no cache — nothing to show, background refresh should start + _updateNotificationService.CheckAndNotify(); + var output = TestAnsiConsole.Output; + Assert.IsFalse(output.Contains("available"), $"Should not notify on first run (no cache), got: {output}"); + } + + [TestMethod] + public async Task RefreshCacheAsync_WritesUpdateCheckCacheFile() + { var cacheFile = new FileInfo(Path.Combine(_testCacheDirectory.FullName, ".update-check")); + + await _concreteService.RefreshCacheAsync(cacheFile); + cacheFile.Refresh(); Assert.IsTrue(cacheFile.Exists, "Update check cache file should be created"); } [TestMethod] - public async Task CheckAndNotifyAsync_SecondCallWithinThreshold_SkipsCheck() + public async Task RefreshCacheAsync_PreservesLastShownDate() { - // First call writes cache - await _updateNotificationService.CheckAndNotifyAsync(TestContext.CancellationToken); - var cacheFile = new FileInfo(Path.Combine(_testCacheDirectory.FullName, ".update-check")); - cacheFile.Refresh(); - Assert.IsTrue(cacheFile.Exists, "Update check cache file should be created"); - var initialCacheContents = await File.ReadAllTextAsync(cacheFile.FullName, TestContext.CancellationToken); + // Write an existing cache with a lastShownDate + cacheFile.Directory?.Create(); + File.WriteAllText(cacheFile.FullName, $"{DateTime.UtcNow.AddHours(-25):O}\n999.0.0\n2026-01-15"); + + await _concreteService.RefreshCacheAsync(cacheFile); - // Second call should skip (cache is fresh) - await _updateNotificationService.CheckAndNotifyAsync(TestContext.CancellationToken); - var subsequentCacheContents = await File.ReadAllTextAsync(cacheFile.FullName, TestContext.CancellationToken); - Assert.AreEqual(initialCacheContents, subsequentCacheContents, "Cache file should not be rewritten within threshold"); + var cache = UpdateNotificationService.ReadCache(cacheFile); + Assert.AreEqual("2026-01-15", cache.LastShownDate, "LastShownDate should be preserved after refresh"); } [TestMethod] - public async Task CheckAndNotifyAsync_ExpiredCache_RechecksAndWritesCache() + public void CheckAndNotify_CachedNewerVersion_DisplaysNotification() { - // Write an expired cache entry + // Pre-populate cache with a newer version and stale "shown" date var cacheDir = _testCacheDirectory.FullName; var cacheFile = Path.Combine(cacheDir, ".update-check"); - var expiredTime = DateTime.UtcNow.AddHours(-25).ToString("O"); - File.WriteAllText(cacheFile, $"{expiredTime}\n"); + Directory.CreateDirectory(cacheDir); + File.WriteAllText(cacheFile, $"{DateTime.UtcNow:O}\n999.0.0\n2020-01-01"); - await _updateNotificationService.CheckAndNotifyAsync(TestContext.CancellationToken); + _updateNotificationService.CheckAndNotify(); - // Cache should be refreshed with a new timestamp - var lines = await File.ReadAllLinesAsync(cacheFile, TestContext.CancellationToken); - Assert.IsTrue(lines.Length >= 1); - Assert.IsTrue(DateTimeOffset.TryParse(lines[0], CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var newTimestamp)); - Assert.IsTrue((DateTimeOffset.UtcNow - newTimestamp).TotalMinutes < 1, "Cache timestamp should be recent"); + var output = TestAnsiConsole.Output; + Assert.IsTrue(output.Contains("999.0.0"), $"Expected notification with version, got: {output}"); + Assert.IsTrue(output.Contains("available"), $"Expected 'available' in notification, got: {output}"); } [TestMethod] - public async Task CheckAndNotifyAsync_NewerVersionAvailable_DisplaysNotification() + public void CheckAndNotify_CachedNewerVersion_AlreadyShownToday_NoNotification() { - // Set a version that is newer than the current CLI version - Environment.SetEnvironmentVariable("WINAPP_CLI_LATEST_VERSION", "999.0.0"); + var today = DateTime.UtcNow.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); + var cacheDir = _testCacheDirectory.FullName; + var cacheFile = Path.Combine(cacheDir, ".update-check"); + Directory.CreateDirectory(cacheDir); + File.WriteAllText(cacheFile, $"{DateTime.UtcNow:O}\n999.0.0\n{today}"); - await _updateNotificationService.CheckAndNotifyAsync(TestContext.CancellationToken); + _updateNotificationService.CheckAndNotify(); var output = TestAnsiConsole.Output; - Assert.IsTrue(output.Contains("999.0.0"), $"Expected notification with version, got: {output}"); - Assert.IsTrue(output.Contains("available"), $"Expected 'available' in notification, got: {output}"); + Assert.IsFalse(output.Contains("available"), $"Should not notify when already shown today, got: {output}"); } [TestMethod] - public async Task CheckAndNotifyAsync_SameVersion_NoNotification() + public void CheckAndNotify_CachedSameVersion_NoNotification() { var currentVersion = VersionHelper.GetVersionString(); - Environment.SetEnvironmentVariable("WINAPP_CLI_LATEST_VERSION", currentVersion); + var cacheDir = _testCacheDirectory.FullName; + var cacheFile = Path.Combine(cacheDir, ".update-check"); + Directory.CreateDirectory(cacheDir); + File.WriteAllText(cacheFile, $"{DateTime.UtcNow:O}\n{currentVersion}\n"); - await _updateNotificationService.CheckAndNotifyAsync(TestContext.CancellationToken); + _updateNotificationService.CheckAndNotify(); var output = TestAnsiConsole.Output; Assert.IsFalse(output.Contains("available"), $"Should not notify for same version, got: {output}"); } [TestMethod] - public async Task CheckAndNotifyAsync_OlderVersion_NoNotification() + public void CheckAndNotify_CachedOlderVersion_NoNotification() { - Environment.SetEnvironmentVariable("WINAPP_CLI_LATEST_VERSION", "0.0.1"); + var cacheDir = _testCacheDirectory.FullName; + var cacheFile = Path.Combine(cacheDir, ".update-check"); + Directory.CreateDirectory(cacheDir); + File.WriteAllText(cacheFile, $"{DateTime.UtcNow:O}\n0.0.1\n"); - await _updateNotificationService.CheckAndNotifyAsync(TestContext.CancellationToken); + _updateNotificationService.CheckAndNotify(); var output = TestAnsiConsole.Output; Assert.IsFalse(output.Contains("available"), $"Should not notify for older version, got: {output}"); } [TestMethod] - public async Task CheckAndNotifyAsync_NpmCaller_ShowsNpmUpgradeHint() + public void CheckAndNotify_ShowsNotice_UpdatesLastShownDate() + { + var cacheDir = _testCacheDirectory.FullName; + var cacheFilePath = Path.Combine(cacheDir, ".update-check"); + Directory.CreateDirectory(cacheDir); + File.WriteAllText(cacheFilePath, $"{DateTime.UtcNow:O}\n999.0.0\n2020-01-01"); + + _updateNotificationService.CheckAndNotify(); + + var cache = UpdateNotificationService.ReadCache(new FileInfo(cacheFilePath)); + var today = DateTime.UtcNow.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); + Assert.AreEqual(today, cache.LastShownDate, "LastShownDate should be updated to today after showing notice"); + } + + [TestMethod] + public void CheckAndNotify_StaleCache_DoesNotBlockOnNetwork() + { + // Write an expired cache entry — CheckAndNotify should return instantly + // (the background refresh is fire-and-forget) + var cacheDir = _testCacheDirectory.FullName; + var cacheFile = Path.Combine(cacheDir, ".update-check"); + Directory.CreateDirectory(cacheDir); + var expiredTime = DateTime.UtcNow.AddHours(-25).ToString("O"); + File.WriteAllText(cacheFile, $"{expiredTime}\n0.0.0\n"); + + var sw = System.Diagnostics.Stopwatch.StartNew(); + _updateNotificationService.CheckAndNotify(); + sw.Stop(); + + // Should complete nearly instantly (no network call in the foreground) + Assert.IsTrue(sw.ElapsedMilliseconds < 1000, $"CheckAndNotify took {sw.ElapsedMilliseconds}ms — should be instant (no blocking network call)"); + } + + [TestMethod] + public void CheckAndNotify_NpmCaller_ShowsNpmUpgradeHint() { - Environment.SetEnvironmentVariable("WINAPP_CLI_LATEST_VERSION", "999.0.0"); Environment.SetEnvironmentVariable("WINAPP_CLI_CALLER", "npm"); + var cacheDir = _testCacheDirectory.FullName; + var cacheFile = Path.Combine(cacheDir, ".update-check"); + Directory.CreateDirectory(cacheDir); + File.WriteAllText(cacheFile, $"{DateTime.UtcNow:O}\n999.0.0\n2020-01-01"); - await _updateNotificationService.CheckAndNotifyAsync(TestContext.CancellationToken); + _updateNotificationService.CheckAndNotify(); var output = TestAnsiConsole.Output; Assert.IsTrue(output.Contains("npm update -g @microsoft/winappcli"), $"Expected npm hint, got: {output}"); } [TestMethod] - public async Task CheckAndNotifyAsync_NodejsPackageCaller_ShowsNpmUpgradeHint() + public void CheckAndNotify_NodejsPackageCaller_ShowsNpmUpgradeHint() { - Environment.SetEnvironmentVariable("WINAPP_CLI_LATEST_VERSION", "999.0.0"); Environment.SetEnvironmentVariable("WINAPP_CLI_CALLER", "nodejs-package"); + var cacheDir = _testCacheDirectory.FullName; + var cacheFile = Path.Combine(cacheDir, ".update-check"); + Directory.CreateDirectory(cacheDir); + File.WriteAllText(cacheFile, $"{DateTime.UtcNow:O}\n999.0.0\n2020-01-01"); - await _updateNotificationService.CheckAndNotifyAsync(TestContext.CancellationToken); + _updateNotificationService.CheckAndNotify(); var output = TestAnsiConsole.Output; Assert.IsTrue(output.Contains("npm update -g @microsoft/winappcli"), $"Expected npm hint, got: {output}"); } [TestMethod] - public async Task CheckAndNotifyAsync_NuGetCaller_ShowsNuGetUpgradeHint() + public void CheckAndNotify_NuGetCaller_ShowsNuGetUpgradeHint() { - Environment.SetEnvironmentVariable("WINAPP_CLI_LATEST_VERSION", "999.0.0"); Environment.SetEnvironmentVariable("WINAPP_CLI_CALLER", "nuget-package"); + var cacheDir = _testCacheDirectory.FullName; + var cacheFile = Path.Combine(cacheDir, ".update-check"); + Directory.CreateDirectory(cacheDir); + File.WriteAllText(cacheFile, $"{DateTime.UtcNow:O}\n999.0.0\n2020-01-01"); - await _updateNotificationService.CheckAndNotifyAsync(TestContext.CancellationToken); + _updateNotificationService.CheckAndNotify(); var output = TestAnsiConsole.Output; - Assert.IsTrue(output.Contains("Microsoft.Windows.SDK.BuildTools.WinApp"), $"Expected NuGet hint, got: {output}"); + Assert.IsTrue(output.Contains("github.com/microsoft/winappcli/releases"), $"Expected NuGet releases page hint, got: {output}"); } [TestMethod] - public async Task CheckAndNotifyAsync_StandaloneExe_ShowsReleasesPageHint() + public void CheckAndNotify_StandaloneExe_ShowsReleasesPageHint() { - Environment.SetEnvironmentVariable("WINAPP_CLI_LATEST_VERSION", "999.0.0"); // No WINAPP_CLI_CALLER set, defaults to standalone exe + var cacheDir = _testCacheDirectory.FullName; + var cacheFile = Path.Combine(cacheDir, ".update-check"); + Directory.CreateDirectory(cacheDir); + File.WriteAllText(cacheFile, $"{DateTime.UtcNow:O}\n999.0.0\n2020-01-01"); - await _updateNotificationService.CheckAndNotifyAsync(TestContext.CancellationToken); + _updateNotificationService.CheckAndNotify(); var output = TestAnsiConsole.Output; Assert.IsTrue(output.Contains("github.com/microsoft/winappcli/releases"), $"Expected releases page hint, got: {output}"); } + [TestMethod] + public void CheckAndNotify_OptOutEnvVar_NoNotification() + { + Environment.SetEnvironmentVariable("WINAPP_CLI_UPDATE_CHECK", "0"); + var cacheDir = _testCacheDirectory.FullName; + var cacheFile = Path.Combine(cacheDir, ".update-check"); + Directory.CreateDirectory(cacheDir); + File.WriteAllText(cacheFile, $"{DateTime.UtcNow:O}\n999.0.0\n2020-01-01"); + + _updateNotificationService.CheckAndNotify(); + + var output = TestAnsiConsole.Output; + Assert.IsFalse(output.Contains("available"), $"Should not notify when opted out, got: {output}"); + } + + [TestMethod] + public void CheckAndNotify_CIEnvironment_NoNotification() + { + Environment.SetEnvironmentVariable("CI", "true"); + var cacheDir = _testCacheDirectory.FullName; + var cacheFile = Path.Combine(cacheDir, ".update-check"); + Directory.CreateDirectory(cacheDir); + File.WriteAllText(cacheFile, $"{DateTime.UtcNow:O}\n999.0.0\n2020-01-01"); + + _updateNotificationService.CheckAndNotify(); + + var output = TestAnsiConsole.Output; + Assert.IsFalse(output.Contains("available"), $"Should not notify in CI, got: {output}"); + } + + [TestMethod] + public void ReadCache_BackwardCompatible_TwoLineFormat() + { + // Old cache format (2 lines, no lastShownDate) + var cacheDir = _testCacheDirectory.FullName; + var cacheFilePath = Path.Combine(cacheDir, ".update-check"); + Directory.CreateDirectory(cacheDir); + File.WriteAllText(cacheFilePath, $"{DateTime.UtcNow:O}\n999.0.0"); + + var cache = UpdateNotificationService.ReadCache(new FileInfo(cacheFilePath)); + + Assert.AreEqual("999.0.0", cache.LatestVersion); + Assert.AreEqual("", cache.LastShownDate, "Missing lastShownDate should default to empty (never shown)"); + } + [TestMethod] public void IsNewerVersion_NewerVersion_ReturnsTrue() { diff --git a/src/winapp-CLI/WinApp.Cli/Program.cs b/src/winapp-CLI/WinApp.Cli/Program.cs index 5dd40512..8c9816f3 100644 --- a/src/winapp-CLI/WinApp.Cli/Program.cs +++ b/src/winapp-CLI/WinApp.Cli/Program.cs @@ -106,21 +106,12 @@ static async Task Main(string[] args) var firstRunService = serviceProvider.GetRequiredService(); didShowFirstRunNotice = firstRunService.CheckAndDisplayFirstRunNotice(); - // Check for CLI updates (at most once per day, silent on failure) + // Check for CLI updates — shows cached notice instantly (no network), + // and starts a background refresh if the cache is stale (fire-and-forget). if (!quiet) { var updateNotificationService = serviceProvider.GetRequiredService(); - - using var cancellationTokenSource = new CancellationTokenSource(TimeSpan.FromSeconds(2)); - - try - { - await updateNotificationService.CheckAndNotifyAsync(cancellationTokenSource.Token); - } - catch (OperationCanceledException) - { - // Keep startup responsive if the update check is slow/unreachable or canceled. - } + updateNotificationService.CheckAndNotify(); } } diff --git a/src/winapp-CLI/WinApp.Cli/Services/IUpdateNotificationService.cs b/src/winapp-CLI/WinApp.Cli/Services/IUpdateNotificationService.cs index 2095e153..83bf5bea 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/IUpdateNotificationService.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/IUpdateNotificationService.cs @@ -6,8 +6,9 @@ namespace WinApp.Cli.Services; internal interface IUpdateNotificationService { /// - /// Checks if an update is available (at most once per day) and prints a notification. - /// Failures are silently ignored; cancellation is allowed to propagate. + /// Shows a cached update notice (if available and not yet shown today) with zero network I/O. + /// If the cache is stale (>24 h), a background refresh is started (fire-and-forget). + /// This method is synchronous and never blocks on the network. /// - Task CheckAndNotifyAsync(CancellationToken cancellationToken = default); + void CheckAndNotify(); } diff --git a/src/winapp-CLI/WinApp.Cli/Services/UpdateNotificationService.cs b/src/winapp-CLI/WinApp.Cli/Services/UpdateNotificationService.cs index 76715fc7..9b020ba3 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/UpdateNotificationService.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/UpdateNotificationService.cs @@ -6,6 +6,7 @@ using System.Globalization; using System.Text.Json; using WinApp.Cli.Helpers; +using WinApp.Cli.Telemetry; namespace WinApp.Cli.Services; @@ -19,41 +20,51 @@ internal class UpdateNotificationService( private const string UpdateCheckFileName = ".update-check"; private const int CheckIntervalHours = 24; - public async Task CheckAndNotifyAsync(CancellationToken cancellationToken = default) + // Cache file format (one value per line): + // Line 0: last-check timestamp (round-trip "O" format, UTC) + // Line 1: latest version found (or empty) + // Line 2: date when notice was last shown (yyyy-MM-dd, or empty) + + public void CheckAndNotify() { try { - var cacheFile = GetUpdateCheckFile(); - - // Read cache to see if we already checked recently - if (cacheFile.Exists) + // Opt-out: user explicitly disabled update checks, or running in CI + if (Environment.GetEnvironmentVariable("WINAPP_CLI_UPDATE_CHECK") == "0" + || CIEnvironmentDetectorForTelemetry.IsCIEnvironment()) { - var lines = await File.ReadAllLinesAsync(cacheFile.FullName, cancellationToken); - if (lines.Length >= 1 - && DateTimeOffset.TryParse(lines[0], CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var lastCheck) - && (DateTimeOffset.UtcNow - lastCheck).TotalHours < CheckIntervalHours) - { - // Already checked and notified within the last 24 hours — skip - return; - } + return; } - var latestVersion = await GetLatestVersionAsync(cancellationToken); - var currentVersion = VersionHelper.GetVersionString(); - string? newVersion = null; + var cacheFile = GetUpdateCheckFile(); + var cache = ReadCache(cacheFile); - if (latestVersion != null && IsNewerVersion(latestVersion, currentVersion)) + // Show notice if a newer version is cached and not yet shown today + if (!string.IsNullOrEmpty(cache.LatestVersion) + && IsNewerVersion(cache.LatestVersion, VersionHelper.GetVersionString()) + && cache.LastShownDate != DateTime.UtcNow.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)) { - newVersion = latestVersion; - DisplayUpdateNotification(newVersion); + DisplayUpdateNotification(cache.LatestVersion); + cache = cache with { LastShownDate = DateTime.UtcNow.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) }; + WriteCacheFile(cacheFile, cache); } - // Write cache with timestamp so we don't check again for 24 hours - WriteCacheFile(cacheFile, newVersion); - } - catch (OperationCanceledException) - { - throw; + // If cache is stale (or missing), refresh in the background — fire and forget + if (!cache.LastCheck.HasValue + || (DateTimeOffset.UtcNow - cache.LastCheck.Value).TotalHours >= CheckIntervalHours) + { + _ = Task.Run(async () => + { + try + { + await RefreshCacheAsync(cacheFile); + } + catch + { + // Best-effort — never crash the process + } + }); + } } catch { @@ -61,6 +72,21 @@ public async Task CheckAndNotifyAsync(CancellationToken cancellationToken = defa } } + internal async Task RefreshCacheAsync(FileInfo? cacheFileOverride = null) + { + var cacheFile = cacheFileOverride ?? GetUpdateCheckFile(); + var latestVersion = await GetLatestVersionAsync(); + + // Preserve lastShownDate from existing cache + var existingCache = ReadCache(cacheFile); + var newCache = new UpdateCheckCache( + LastCheck: DateTimeOffset.UtcNow, + LatestVersion: latestVersion ?? "", + LastShownDate: existingCache.LastShownDate); + + WriteCacheFile(cacheFile, newCache); + } + internal async Task GetLatestVersionAsync(CancellationToken cancellationToken = default) { // Allow overriding the latest version for testing (skips GitHub API call) @@ -238,12 +264,47 @@ private FileInfo GetUpdateCheckFile() return new FileInfo(Path.Combine(globalDir.FullName, UpdateCheckFileName)); } - private void WriteCacheFile(FileInfo cacheFile, string? newVersion) + internal static UpdateCheckCache ReadCache(FileInfo cacheFile) + { + if (!cacheFile.Exists) + { + return UpdateCheckCache.Empty; + } + + try + { + var lines = File.ReadAllLines(cacheFile.FullName); + + DateTimeOffset? lastCheck = null; + if (lines.Length >= 1 + && DateTimeOffset.TryParse(lines[0], CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var parsed)) + { + lastCheck = parsed; + } + + var latestVersion = lines.Length >= 2 ? lines[1] : ""; + var lastShownDate = lines.Length >= 3 ? lines[2] : ""; + + return new UpdateCheckCache(lastCheck, latestVersion, lastShownDate); + } + catch + { + return UpdateCheckCache.Empty; + } + } + + private void WriteCacheFile(FileInfo cacheFile, UpdateCheckCache cache) { try { cacheFile.Directory?.Create(); - File.WriteAllText(cacheFile.FullName, $"{DateTime.UtcNow:O}\n{newVersion ?? ""}"); + + // Write to a temp file then move for atomic replacement + var tempPath = cacheFile.FullName + ".tmp"; + var content = $"{cache.LastCheck?.ToString("O") ?? ""}\n{cache.LatestVersion ?? ""}\n{cache.LastShownDate ?? ""}"; + File.WriteAllText(tempPath, content); + File.Move(tempPath, cacheFile.FullName, overwrite: true); + cacheFile.Refresh(); cacheFile.Attributes |= FileAttributes.Hidden; } @@ -252,6 +313,14 @@ private void WriteCacheFile(FileInfo cacheFile, string? newVersion) logger.LogDebug(ex, "Failed to write update check cache."); } } + + internal record UpdateCheckCache( + DateTimeOffset? LastCheck, + string LatestVersion, + string LastShownDate) + { + public static readonly UpdateCheckCache Empty = new(null, "", ""); + } } internal enum InstallChannel From 0dcbeee0060ccc9fe1e97cb2d9662566fc2cd908 Mon Sep 17 00:00:00 2001 From: Zachary Teutsch Date: Tue, 28 Apr 2026 20:48:16 -0400 Subject: [PATCH 09/10] remove override version check --- .../WinApp.Cli/Services/UpdateNotificationService.cs | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/winapp-CLI/WinApp.Cli/Services/UpdateNotificationService.cs b/src/winapp-CLI/WinApp.Cli/Services/UpdateNotificationService.cs index 9b020ba3..e37a63bf 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/UpdateNotificationService.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/UpdateNotificationService.cs @@ -89,13 +89,6 @@ internal async Task RefreshCacheAsync(FileInfo? cacheFileOverride = null) internal async Task GetLatestVersionAsync(CancellationToken cancellationToken = default) { - // Allow overriding the latest version for testing (skips GitHub API call) - var overrideVersion = Environment.GetEnvironmentVariable("WINAPP_CLI_LATEST_VERSION"); - if (!string.IsNullOrEmpty(overrideVersion)) - { - return overrideVersion; - } - try { using var request = new HttpRequestMessage(HttpMethod.Get, GitHubApiLatestRelease); From 8e664e8085cd33433a94e505568447924e515c99 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 29 Apr 2026 01:13:29 +0000 Subject: [PATCH 10/10] fix: address second review round - CI env vars, markup escape, concurrent guard, InvariantCulture Agent-Logs-Url: https://github.com/microsoft/winappCli/sessions/d7e88a21-1475-46c7-8881-fe9619103108 Co-authored-by: zateutsch <88554871+zateutsch@users.noreply.github.com> --- .../UpdateNotificationServiceTests.cs | 30 ++++++++++++++----- .../Services/UpdateNotificationService.cs | 20 ++++++++++--- 2 files changed, 38 insertions(+), 12 deletions(-) diff --git a/src/winapp-CLI/WinApp.Cli.Tests/UpdateNotificationServiceTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/UpdateNotificationServiceTests.cs index 2d873372..7a346623 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/UpdateNotificationServiceTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/UpdateNotificationServiceTests.cs @@ -15,33 +15,47 @@ public class UpdateNotificationServiceTests : BaseCommandTests private IUpdateNotificationService _updateNotificationService = null!; private UpdateNotificationService _concreteService = null!; private string? _originalCaller; - private string? _originalLatestVersion; private string? _originalUpdateCheck; - private string? _originalCI; + + // All environment variable names checked by CIEnvironmentDetectorForTelemetry + private static readonly string[] CiVarNames = + [ + "CI", "GITHUB_ACTIONS", "TF_BUILD", "APPVEYOR", "TRAVIS", "CIRCLECI", + "TEAMCITY_VERSION", "JB_SPACE_API_URL", + "CODEBUILD_BUILD_ID", "AWS_REGION", "BUILD_ID", "BUILD_URL", "PROJECT_ID" + ]; + private Dictionary _savedCiVars = []; [TestInitialize] public void Setup() { _updateNotificationService = GetRequiredService(); _concreteService = (UpdateNotificationService)_updateNotificationService; + // Prevent background HTTP calls during unit tests + _concreteService.SkipBackgroundRefreshForTesting = true; + // Save and clear env vars to avoid interference _originalCaller = Environment.GetEnvironmentVariable("WINAPP_CLI_CALLER"); - _originalLatestVersion = Environment.GetEnvironmentVariable("WINAPP_CLI_LATEST_VERSION"); _originalUpdateCheck = Environment.GetEnvironmentVariable("WINAPP_CLI_UPDATE_CHECK"); - _originalCI = Environment.GetEnvironmentVariable("CI"); + _savedCiVars = CiVarNames.ToDictionary(name => name, name => Environment.GetEnvironmentVariable(name)); + Environment.SetEnvironmentVariable("WINAPP_CLI_CALLER", null); - Environment.SetEnvironmentVariable("WINAPP_CLI_LATEST_VERSION", "0.0.0"); Environment.SetEnvironmentVariable("WINAPP_CLI_UPDATE_CHECK", null); - Environment.SetEnvironmentVariable("CI", null); + foreach (var name in CiVarNames) + { + Environment.SetEnvironmentVariable(name, null); + } } [TestCleanup] public void Cleanup() { Environment.SetEnvironmentVariable("WINAPP_CLI_CALLER", _originalCaller); - Environment.SetEnvironmentVariable("WINAPP_CLI_LATEST_VERSION", _originalLatestVersion); Environment.SetEnvironmentVariable("WINAPP_CLI_UPDATE_CHECK", _originalUpdateCheck); - Environment.SetEnvironmentVariable("CI", _originalCI); + foreach (var (name, value) in _savedCiVars) + { + Environment.SetEnvironmentVariable(name, value); + } } [TestMethod] diff --git a/src/winapp-CLI/WinApp.Cli/Services/UpdateNotificationService.cs b/src/winapp-CLI/WinApp.Cli/Services/UpdateNotificationService.cs index e37a63bf..69898469 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/UpdateNotificationService.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/UpdateNotificationService.cs @@ -16,10 +16,16 @@ internal class UpdateNotificationService( ILogger logger) : IUpdateNotificationService { private static readonly HttpClient Http = new() { Timeout = TimeSpan.FromSeconds(10) }; + private static int _refreshScheduled; // guarded by Interlocked; see NotScheduled/Scheduled constants + private const int NotScheduled = 0; + private const int Scheduled = 1; private const string GitHubApiLatestRelease = "https://api.github.com/repos/microsoft/winappcli/releases/latest"; private const string UpdateCheckFileName = ".update-check"; private const int CheckIntervalHours = 24; + // For testing only — when true, skips the fire-and-forget background network refresh + internal bool SkipBackgroundRefreshForTesting; + // Cache file format (one value per line): // Line 0: last-check timestamp (round-trip "O" format, UTC) // Line 1: latest version found (or empty) @@ -50,8 +56,10 @@ public void CheckAndNotify() } // If cache is stale (or missing), refresh in the background — fire and forget - if (!cache.LastCheck.HasValue - || (DateTimeOffset.UtcNow - cache.LastCheck.Value).TotalHours >= CheckIntervalHours) + if (!SkipBackgroundRefreshForTesting + && (!cache.LastCheck.HasValue + || (DateTimeOffset.UtcNow - cache.LastCheck.Value).TotalHours >= CheckIntervalHours) + && Interlocked.CompareExchange(ref _refreshScheduled, Scheduled, NotScheduled) == NotScheduled) { _ = Task.Run(async () => { @@ -63,6 +71,10 @@ public void CheckAndNotify() { // Best-effort — never crash the process } + finally + { + Interlocked.Exchange(ref _refreshScheduled, NotScheduled); + } }); } } @@ -130,7 +142,7 @@ private void DisplayUpdateNotification(string newVersion) _ => "visit https://github.com/microsoft/winappcli/releases" }; - ansiConsole.MarkupLine($"[yellow]v{newVersion} is available. To update, {Markup.Escape(upgradeHint)}.[/]"); + ansiConsole.MarkupLine($"[yellow]v{Markup.Escape(newVersion)} is available. To update, {Markup.Escape(upgradeHint)}.[/]"); } internal static bool IsNewerVersion(string latest, string current) @@ -294,7 +306,7 @@ private void WriteCacheFile(FileInfo cacheFile, UpdateCheckCache cache) // Write to a temp file then move for atomic replacement var tempPath = cacheFile.FullName + ".tmp"; - var content = $"{cache.LastCheck?.ToString("O") ?? ""}\n{cache.LatestVersion ?? ""}\n{cache.LastShownDate ?? ""}"; + var content = $"{cache.LastCheck?.ToString("O", CultureInfo.InvariantCulture) ?? ""}\n{cache.LatestVersion ?? ""}\n{cache.LastShownDate ?? ""}"; File.WriteAllText(tempPath, content); File.Move(tempPath, cacheFile.FullName, overwrite: true);