Skip to content

Commit 477e670

Browse files
zateutschCopilotCopilotnmetulev
authored
App update available notice on first run of the day - no upgrade command (#503)
Once a day, on first command run, the CLI will check if a newer version of the CLI is available and show a notification at the top of the command output if so. --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Nikola Metulev <nmetulev@users.noreply.github.com>
1 parent 5b45da5 commit 477e670

7 files changed

Lines changed: 1016 additions & 15 deletions

File tree

docs/usage.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -937,6 +937,30 @@ $env:WINAPP_CLI_CACHE_DIRECTORY=d:\temp\.winapp
937937
```
938938

939939
Winapp will create this directory automatically when you run commands like `init` or `restore`.
940+
941+
### Update Checks
942+
943+
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.
944+
945+
Update checks are automatically disabled in CI environments (GitHub Actions, Azure Pipelines, etc.).
946+
947+
To manually disable update checks, set the `WINAPP_CLI_UPDATE_CHECK` environment variable to `0`.
948+
949+
In **cmd**:
950+
```cmd
951+
set WINAPP_CLI_UPDATE_CHECK=0
952+
```
953+
954+
In **PowerShell** and **pwsh**:
955+
```pwsh
956+
$env:WINAPP_CLI_UPDATE_CHECK = "0"
957+
```
958+
959+
To make this permanent:
960+
```powershell
961+
[System.Environment]::SetEnvironmentVariable('WINAPP_CLI_UPDATE_CHECK', '0', 'User')
962+
```
963+
940964
### ui
941965

942966
Inspect and interact with running Windows app UIs using UI Automation (UIA).
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
// Copyright (c) Microsoft Corporation and Contributors. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
using System.Globalization;
5+
6+
namespace WinApp.Cli.Tests;
7+
8+
/// <summary>
9+
/// Integration tests verifying that the Program-level gating logic correctly
10+
/// suppresses update notifications for --json, --quiet, and --cli-schema modes,
11+
/// and that --caller plumbs through to the notification hint text.
12+
/// Tests that exercise suppression invoke Program.Main directly.
13+
/// Tests that verify notification content use the service layer with env vars
14+
/// matching what Program.Main would set.
15+
/// </summary>
16+
[TestClass]
17+
[DoNotParallelize] // Modifies static Console streams and environment variables
18+
public class UpdateNotificationGatingTests
19+
{
20+
private string _tempCacheDir = null!;
21+
private string? _savedCacheDir;
22+
private string? _savedCaller;
23+
private string? _savedUpdateCheck;
24+
25+
[TestInitialize]
26+
public void Setup()
27+
{
28+
// Create temp cache directory and seed with a "newer" version
29+
_tempCacheDir = Path.Combine(Path.GetTempPath(), $"winapp_gating_test_{Guid.NewGuid():N}");
30+
Directory.CreateDirectory(_tempCacheDir);
31+
SeedUpdateCheckCache("99.0.0");
32+
33+
// Create first-run marker so FirstRunService doesn't trigger logging
34+
File.Create(Path.Combine(_tempCacheDir, ".first-run-complete")).Dispose();
35+
36+
// Save and override env vars
37+
_savedCacheDir = Environment.GetEnvironmentVariable("WINAPP_CLI_CACHE_DIRECTORY");
38+
_savedCaller = Environment.GetEnvironmentVariable("WINAPP_CLI_CALLER");
39+
_savedUpdateCheck = Environment.GetEnvironmentVariable("WINAPP_CLI_UPDATE_CHECK");
40+
41+
Environment.SetEnvironmentVariable("WINAPP_CLI_CACHE_DIRECTORY", _tempCacheDir);
42+
Environment.SetEnvironmentVariable("WINAPP_CLI_CALLER", null);
43+
Environment.SetEnvironmentVariable("WINAPP_CLI_UPDATE_CHECK", null);
44+
45+
// Clear CI vars to avoid suppression
46+
Environment.SetEnvironmentVariable("CI", null);
47+
Environment.SetEnvironmentVariable("GITHUB_ACTIONS", null);
48+
Environment.SetEnvironmentVariable("TF_BUILD", null);
49+
}
50+
51+
[TestCleanup]
52+
public void Cleanup()
53+
{
54+
Environment.SetEnvironmentVariable("WINAPP_CLI_CACHE_DIRECTORY", _savedCacheDir);
55+
Environment.SetEnvironmentVariable("WINAPP_CLI_CALLER", _savedCaller);
56+
Environment.SetEnvironmentVariable("WINAPP_CLI_UPDATE_CHECK", _savedUpdateCheck);
57+
58+
try { Directory.Delete(_tempCacheDir, recursive: true); } catch { /* best effort */ }
59+
}
60+
61+
[TestMethod]
62+
public async Task JsonMode_SuppressesUpdateNotice_StdoutHasNoNotice()
63+
{
64+
var (stdout, stderr, _) = await InvokeProgramAsync(["get-winapp-path", "--global", "--json"]);
65+
66+
Assert.IsFalse(stdout.Contains("available", StringComparison.OrdinalIgnoreCase),
67+
$"--json stdout must not contain update notice. Got stdout: {stdout}");
68+
Assert.IsFalse(stderr.Contains("available", StringComparison.OrdinalIgnoreCase),
69+
$"--json stderr must not contain update notice. Got stderr: {stderr}");
70+
}
71+
72+
[TestMethod]
73+
public async Task QuietMode_SuppressesUpdateNotice()
74+
{
75+
var (stdout, stderr, _) = await InvokeProgramAsync(["get-winapp-path", "--global", "--quiet"]);
76+
77+
Assert.IsFalse(stdout.Contains("available", StringComparison.OrdinalIgnoreCase),
78+
$"--quiet stdout must not contain update notice. Got stdout: {stdout}");
79+
Assert.IsFalse(stderr.Contains("available", StringComparison.OrdinalIgnoreCase),
80+
$"--quiet stderr must not contain update notice. Got stderr: {stderr}");
81+
}
82+
83+
[TestMethod]
84+
public async Task CliSchemaMode_SuppressesUpdateNotice()
85+
{
86+
var (stdout, stderr, _) = await InvokeProgramAsync(["--cli-schema"]);
87+
88+
Assert.IsFalse(stdout.Contains("available", StringComparison.OrdinalIgnoreCase),
89+
$"--cli-schema stdout must not contain update notice. Got stdout: {stdout}");
90+
Assert.IsFalse(stderr.Contains("available", StringComparison.OrdinalIgnoreCase),
91+
$"--cli-schema stderr must not contain update notice. Got stderr: {stderr}");
92+
}
93+
94+
[TestMethod]
95+
public async Task NormalMode_ShowsUpdateNotice_OnStderr()
96+
{
97+
// Invoke through the real entrypoint — the notification should appear on stderr,
98+
// never stdout. We capture stderr via Console.SetError.
99+
var (stdout, stderr, _) = await InvokeProgramAsync(["get-winapp-path", "--global"]);
100+
101+
Assert.IsFalse(stdout.Contains("available", StringComparison.OrdinalIgnoreCase),
102+
$"Update notice must not appear on stdout. Got stdout: {stdout}");
103+
Assert.IsTrue(stderr.Contains("available", StringComparison.OrdinalIgnoreCase),
104+
$"Update notice should appear on stderr in normal mode. Got stderr: {stderr}");
105+
}
106+
107+
[TestMethod]
108+
public async Task CallerNpm_ProducesNpmHint()
109+
{
110+
// --caller npm should set WINAPP_CLI_CALLER=npm which makes the update notice
111+
// include the npm update hint.
112+
var (_, stderr, _) = await InvokeProgramAsync(["get-winapp-path", "--global", "--caller", "npm"]);
113+
114+
Assert.IsTrue(stderr.Contains("npm update", StringComparison.OrdinalIgnoreCase),
115+
$"With --caller npm, notice should contain npm update hint. Got stderr: {stderr}");
116+
}
117+
118+
/// <summary>
119+
/// Seeds the .update-check file with a timestamp (now) and a specified "latest" version
120+
/// so the notification fires immediately without needing network access.
121+
/// </summary>
122+
private void SeedUpdateCheckCache(string version)
123+
{
124+
var content = $"{DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture)}\n{version}\n";
125+
File.WriteAllText(Path.Combine(_tempCacheDir, ".update-check"), content);
126+
}
127+
128+
/// <summary>
129+
/// Invokes Program.Main with captured stdout/stderr.
130+
/// Writers are intentionally not disposed to avoid ObjectDisposedException from
131+
/// Spectre.Console's static AnsiConsole.Console which may reference them after return.
132+
/// </summary>
133+
private static async Task<(string Stdout, string Stderr, int ExitCode)> InvokeProgramAsync(string[] args)
134+
{
135+
var originalOut = Console.Out;
136+
var originalErr = Console.Error;
137+
138+
var stdoutWriter = new StringWriter();
139+
var stderrWriter = new StringWriter();
140+
141+
try
142+
{
143+
Console.SetOut(stdoutWriter);
144+
Console.SetError(stderrWriter);
145+
146+
var exitCode = await Program.Main(args);
147+
148+
return (stdoutWriter.ToString(), stderrWriter.ToString(), exitCode);
149+
}
150+
finally
151+
{
152+
Console.SetOut(originalOut);
153+
Console.SetError(originalErr);
154+
}
155+
}
156+
}

0 commit comments

Comments
 (0)