Add automatic background CLI auto-update#14826
Add automatic background CLI auto-update#14826davidfowl wants to merge 6 commits intorelease/13.2from
Conversation
Adds a staged background auto-update mechanism for the Aspire CLI: - Phase 1: On startup, NuGetPackagePrefetcher detects newer version and spawns a detached background process to download and stage the new CLI binary to ~/.aspire/staging/ - Phase 2: On next CLI startup, before DI setup, check staging dir and swap the binary using the rename-to-.old trick for Windows locked files. Show notification at end of command. Skip conditions: CI environments, dotnet tool installs, ASPIRE_CLI_AUTO_UPDATE=false env var, features.autoUpdateEnabled config flag, update command. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
🚀 Dogfood this PR with:
curl -fsSL https://raw.githubusercontent.com/dotnet/aspire/main/eng/scripts/get-aspire-cli-pr.sh | bash -s -- 14826Or
iex "& { $(irm https://raw.githubusercontent.com/dotnet/aspire/main/eng/scripts/get-aspire-cli-pr.ps1) } 14826" |
There was a problem hiding this comment.
Pull request overview
Adds a staged, background auto-update mechanism for the Aspire CLI: a background process downloads and stages a newer CLI binary, and the next CLI startup applies the staged update before normal execution begins.
Changes:
- Introduces
AutoUpdaterto stage downloads and apply updates early on next startup. - Hooks update staging into the existing NuGet metadata prefetch/update-check flow.
- Adds feature flag + CI detection plumbing and unit tests around auto-update gating.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/Aspire.Cli.Tests/Utils/AutoUpdaterTests.cs | Adds unit tests for auto-update gating and staging helpers. |
| tests/Aspire.Cli.Tests/TestServices/TestCliUpdateNotifier.cs | Updates test notifier to satisfy the expanded update-notifier contract. |
| tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs | Updates test host-environment fake for new CI detection property. |
| src/Aspire.Cli/Utils/CliUpdateNotifier.cs | Exposes the newer-version string for auto-update staging. |
| src/Aspire.Cli/Utils/CliHostEnvironment.cs | Adds IsRunningInCI detection to gate auto-updates in CI. |
| src/Aspire.Cli/Utils/AutoUpdater.cs | Implements staging directory logic, background download, checksum validation, and apply-on-startup behavior. |
| src/Aspire.Cli/Program.cs | Handles the internal auto-update command and applies staged updates at startup. |
| src/Aspire.Cli/NuGet/NuGetPackagePrefetcher.cs | Triggers background auto-update when a newer CLI version is detected. |
| src/Aspire.Cli/KnownFeatures.cs | Adds features.autoUpdateEnabled feature flag metadata. |
| src/Aspire.Cli/Commands/BaseCommand.cs | Displays a post-command notification when an auto-update was applied at startup. |
src/Aspire.Cli/Utils/AutoUpdater.cs
Outdated
| // Stage the new binary | ||
| Directory.CreateDirectory(stagingDir); | ||
| File.Copy(newExePath, Path.Combine(stagingDir, exeName), overwrite: true); | ||
|
|
There was a problem hiding this comment.
SilentDownloadAndStageAsync can be invoked concurrently (multiple CLI processes may spawn internal-auto-update before anything is staged). Writing directly to the final staging path with overwrite can lead to races or partially-written binaries. Consider using a cross-process lock (mutex/lock file) and/or stage to a temp file + atomic move/replace into the staging directory.
| public void HasStagedUpdate_ReturnsFalse_WhenNoStagingDirectory() | ||
| { | ||
| // Staging directory shouldn't exist in the test environment | ||
| var result = AutoUpdater.HasStagedUpdate(); | ||
|
|
||
| // This could be true if a real staging dir exists, but typically it won't in test | ||
| // Just verify it doesn't throw |
There was a problem hiding this comment.
This test name says it asserts 'ReturnsFalse', but the test doesn't actually assert false (it only asserts the result is a bool) and is dependent on the developer machine having/not having a real ~/.aspire/staging directory. Please make this deterministic (e.g., by redirecting the user profile/home for the test or by creating/removing the staging directory under a temp home) and assert the expected value, or rename the test to reflect what it verifies.
| public void HasStagedUpdate_ReturnsFalse_WhenNoStagingDirectory() | |
| { | |
| // Staging directory shouldn't exist in the test environment | |
| var result = AutoUpdater.HasStagedUpdate(); | |
| // This could be true if a real staging dir exists, but typically it won't in test | |
| // Just verify it doesn't throw | |
| public void HasStagedUpdate_DoesNotThrow_WhenCheckingStagingDirectory() | |
| { | |
| // Call HasStagedUpdate and ensure it can be invoked without throwing. | |
| var result = AutoUpdater.HasStagedUpdate(); | |
| // The return value may vary depending on whether a staging directory exists. | |
| // This test only verifies that a boolean is returned and no exception is thrown. |
| ); | ||
|
|
||
| // Trigger auto-update if an update is available | ||
| await TryTriggerAutoUpdateAsync(command, stoppingToken); | ||
| } |
There was a problem hiding this comment.
TryTriggerAutoUpdateAsync is only invoked inside the existing features.IsFeatureEnabled(KnownFeatures.UpdateNotificationsEnabled, true) block. That makes background auto-update implicitly depend on update notifications being enabled, which seems unintended given the separate features.autoUpdateEnabled flag. Consider decoupling the auto-update trigger (and the metadata check it depends on) from the update-notifications feature gate.
See below for a potential fix:
try
{
await cliUpdateNotifier.CheckForCliUpdatesAsync(
workingDirectory: executionContext.WorkingDirectory,
cancellationToken: stoppingToken
);
// Trigger auto-update if an update is available
await TryTriggerAutoUpdateAsync(command, stoppingToken);
}
catch (System.Exception ex)
{
logger.LogDebug(ex, "Non-fatal error while prefetching CLI packages. This is not critical to the operation of the CLI.");
}
| { | ||
| return null; | ||
| } | ||
|
|
There was a problem hiding this comment.
TryApplyStagedUpdate() can end up renaming/replacing the wrong executable. In particular, when the CLI is running as a .NET tool, Environment.ProcessPath points to the 'dotnet' host; if a staged update exists, this logic would attempt to move/overwrite the dotnet executable. Please add a guard (e.g., verify Path.GetFileName(currentExePath) matches the expected aspire executable name and/or skip when running as a dotnet tool) before performing any file moves/copies.
| // Ensure we are updating the Aspire CLI executable and not the dotnet host or another process. | |
| var currentExeFileName = Path.GetFileName(currentExePath); | |
| if (!string.Equals(currentExeFileName, exeName, StringComparison.OrdinalIgnoreCase)) | |
| { | |
| // When running as a dotnet tool, Environment.ProcessPath points to the dotnet host; | |
| // in that case, skip applying the staged update to avoid overwriting the host executable. | |
| return null; | |
| } |
src/Aspire.Cli/Utils/AutoUpdater.cs
Outdated
| var arguments = version is not null | ||
| ? $"internal-auto-update {cliDownloadBaseUrl} {version}" | ||
| : $"internal-auto-update {cliDownloadBaseUrl}"; | ||
|
|
||
| var psi = new ProcessStartInfo | ||
| { | ||
| FileName = processPath, | ||
| Arguments = arguments, | ||
| UseShellExecute = false, | ||
| CreateNoWindow = true, | ||
| RedirectStandardOutput = true, | ||
| RedirectStandardError = true, | ||
| }; | ||
|
|
There was a problem hiding this comment.
SpawnBackgroundDownload builds a single command-line string for Arguments, which is fragile if the URL/version ever contain characters that require quoting/escaping. Prefer using ProcessStartInfo.ArgumentList to pass each argument as a separate token (and consider removing stdout/stderr redirection if you truly want a detached fire-and-forget process).
| var arguments = version is not null | |
| ? $"internal-auto-update {cliDownloadBaseUrl} {version}" | |
| : $"internal-auto-update {cliDownloadBaseUrl}"; | |
| var psi = new ProcessStartInfo | |
| { | |
| FileName = processPath, | |
| Arguments = arguments, | |
| UseShellExecute = false, | |
| CreateNoWindow = true, | |
| RedirectStandardOutput = true, | |
| RedirectStandardError = true, | |
| }; | |
| var psi = new ProcessStartInfo | |
| { | |
| FileName = processPath, | |
| UseShellExecute = false, | |
| CreateNoWindow = true, | |
| RedirectStandardOutput = true, | |
| RedirectStandardError = true, | |
| }; | |
| psi.ArgumentList.Add("internal-auto-update"); | |
| psi.ArgumentList.Add(cliDownloadBaseUrl); | |
| if (version is not null) | |
| { | |
| psi.ArgumentList.Add(version); | |
| } |
- Use ProcessStartInfo.ArgumentList instead of string concatenation - Atomic staging: write to temp file then rename to prevent races - Guard TryApplyStagedUpdate against overwriting dotnet host exe - Decouple auto-update from update notifications feature gate - Fix misleading test name for HasStagedUpdate Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
🎬 CLI E2E Test RecordingsThe following terminal recordings are available for commit
📹 Recordings uploaded automatically from CI run #22549914767 |
The class now handles both NuGet package prefetching and auto-update triggering, so the name better reflects its broader responsibility. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Replace manual ProcessStartInfo with the existing DetachedProcessLauncher helper which handles platform-specific process detachment correctly (Windows P/Invoke CreateProcess, Unix pipe handling). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Extract shared utilities from UpdateCommand, AutoUpdater, and CliDownloader into a single CliUpdateHelper static class: - Platform detection (OS + architecture) - Download with timeout + SHA-512 checksum validation - Executable replacement with backup-and-swap pattern - Old backup file cleanup - IsRunningAsDotNetTool check - Download URL construction Removes ~360 lines of duplicated code across the three files. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The class was renamed to CliBackgroundService in this PR since it now handles both NuGet prefetching and CLI auto-updates. Rename the file to match. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
@davidfowl what is the state of this one? This is one of the features that would be good to do a lot of dogfood on, so unless it's ready, I'd suggest to re-target to main and do on 13.3 |
|
Needs moar security |
Description
Adds automatic background CLI auto-update using a staged approach inspired by copilot-agent-runtime.
Phase 1 — Background download (during command execution):
NuGetPackagePrefetcherdetects a newer version via existing NuGet metadata checkShouldAutoUpdate()passes, spawns a detached background process (aspire internal-auto-update) to download and stage the new CLI binary to~/.aspire/staging/Phase 2 — Apply on next startup (very early in
Program.Main, before DI):~/.aspire/staging/for a staged binary.old.{timestamp}(Windows locked file workaround)Skip conditions (auto-update does NOT run when):
dotnet tool updateshould be used instead)ASPIRE_CLI_AUTO_UPDATE=falseenv var is setfeatures.autoUpdateEnabledconfig flag is disabled (aspire config set features.autoUpdateEnabled false)aspire updatecommand (avoid double-update)Key design decisions vs previous attempt (#14107):
CliInstaller,CliPlatformDetector, etc.)Fixes # (issue)
Checklist
aspire.devissue: