feat(cli): aspire update --self refuses non-script install routes; route-aware notifications; #15600 fix (PR α)#17110
Draft
radical wants to merge 8 commits into
Conversation
Resolves microsoft#15600 partial scope (PR γ stack — does not close issue): - Adds SelfUpdateRouter (pure policy lookup mapping InstallSource to in-process vs delegate action). Script + Unknown stay in-process for legacy compatibility; Pr/Winget/Brew/DotnetTool/LocalHive delegate. - Adds IUpgradeInstructionProvider + UpgradeInstructionProvider returning the route-appropriate update command. Reuses DotNetToolDetection.GetDotNetToolUpdateCommand for the dotnet-tool case (correctly handles -g vs --tool-path installs). PR-route command substitutes the PR number from CliExecutionContext.IdentityChannel. - UpdateCommand --self resolves the running install via IInstallationDiscovery.DescribeSelf (single source of truth — no new symlink-resolution duplicated). Falls back to no-arg DotNetToolDetection.IsRunningAsDotNetTool so the AsyncLocal test override continues to work and legacy dotnet-tool installs without sidecars are still recognized. - Adds 5 RESX strings for per-route refusal messages; xlf for 14 locales. Closes the silent-PR-demotion and package-manager binary-clobber regressions on `aspire update --self` (the silent overwrite path now gates on InstallSource and only the script route runs in-process). Tests: 23 new (Theory-flattened where applicable). All 48 existing UpdateCommand tests and 177 acquisition-area tests still pass. Co-authored-by: Copilot <[email protected]>
The 'version X is available' notification now surfaces the command that actually updates the binary the user is running. Before: hardcoded 'aspire update' fallback (which only runs the project-update flow, not the CLI self-update) or 'dotnet tool update' special-case. After: the notifier resolves install source via IInstallationDiscovery.DescribeSelf and delegates to IUpgradeInstructionProvider, falling back to 'aspire update --self' for Script and Unknown (legacy compat). Closes the part of S9.1–S9.4 (route-aware notifications) addressable from the notifier path. Pkg-mgr users see 'brew upgrade --cask aspire' / 'winget upgrade Microsoft.Aspire'; PR-route users see the 'get-aspire-cli-pr.sh <N>' hint with their PR number substituted; dotnet-tool users get the path-aware -g vs --tool-path command. Tests: new UpdateNotificationRouteTests Theory-flattens the 5 routes. One pre-existing test renamed (was asserting the literal 'aspire update' recommendation for archive-path installs; updated to 'aspire update --self' which is the route-correct command for the script route). Co-authored-by: Copilot <[email protected]>
…rompt (microsoft#15600) Pre-fix: when hive directories exist under `~/.aspire/hives/`, `aspire update --non-interactive` reached `PromptForSelectionAsync` to ask the user to pick a channel — and crashed with "Interactive input is not supported in this environment". Fix: gate the channel-selection prompt on `ICliHostEnvironment.SupportsInteractiveInput`. In non-interactive hosts (explicit `--non-interactive`, CI environment variables, missing console handles) the implicit/default channel is picked silently — matching the branch already taken when no hives exist. Also adds a regression test for microsoft#15601 that verifies the pre-existing `AddNonInteractiveRequiresYesValidator` short-circuits `--non-interactive` without `--yes` before any `ProjectUpdater.PromptConfirmAsync` site is reached. Also: tidy stale 'preview' channel reference in the Channel property description on `AspireConfigFile` / `AspireJsonConfiguration` (the real non-stable channels are stable / staging / daily / pr-<N> / local). Tests: 3 new (Theory-flattened), 2 pre-existing tests updated to opt in to interactive host environment so the channel-prompt content contract is still exercised. All 89 acquisition+update tests pass. Closes microsoft#15600. Co-authored-by: Copilot <[email protected]>
…ating Adds UpdateCommandRouteRegressionTests — a Theory-flattened end-to-end guard that drives 'aspire update --self' for each newly-gated route (pr / winget / brew / localhive) via RootCommand + Parse + InvokeAsync, and asserts: (a) the binary is NOT touched (the in-process update flow is not reached) (b) the route-appropriate refusal command is printed verbatim to stdout (c) exit code is 0 (matches the existing dotnet-tool refusal contract) This complements the unit-level SelfUpdateRouterTests + UpgradeInstructionProviderTests by exercising the full UpdateCommand flow with a FakeInstallationDiscovery surfacing each route, locking in the regression net for silent-PR-demotion + pkg-mgr binary-clobber bugs. Co-authored-by: Copilot <[email protected]>
…nknown sidecar source Three new non-happy-path test rows close the remaining coverage gaps for PR α's route gating: 1. SelfUpdate_NoSidecar_LegacyDotnetToolPathShape_RefusesWithDotnetToolHint — when discovery returns no route AND path-shape inspection identifies the binary as a dotnet-tool, the fallback in ResolveRunningInstall fixes up Unknown → DotnetTool and refuses with 'dotnet tool update -g Aspire.Cli'. Locks in the legacy pre-sidecar dotnet-tool compat path. 2. SelfUpdate_NoSidecar_NotDotnetTool_FallsThroughToInProcessFlow — when discovery returns no route AND path is unrecognized, ResolveRunningInstall yields Unknown which SelfUpdateRouter maps to InProcess (legacy script-route compat). Asserts none of the route-specific refusal strings appear, verifying the gated branch was not taken. 3. UpdateNotificationRouteTests Theory gains a 'future-route-name' row — when the sidecar source is a string this CLI doesn't recognize yet (a new route added by a future build), ParseInstallSource yields Unknown and the notification falls back to 'aspire update --self' rather than printing the raw unknown name. Future-tolerance contract. 194 / 194 acquisition+update tests pass. Co-authored-by: Copilot <[email protected]>
Contributor
|
🚀 Dogfood this PR with:
curl -fsSL https://raw.githubusercontent.com/microsoft/aspire/main/eng/scripts/get-aspire-cli-pr.sh | bash -s -- 17110Or
iex "& { $(irm https://raw.githubusercontent.com/microsoft/aspire/main/eng/scripts/get-aspire-cli-pr.ps1) } 17110" |
…te resolution Before this fix, a fresh WinGet install whose very first command was `aspire update --self` would have no install-route sidecar yet (the sidecar is stamped lazily by WingetFirstRunProbe from BundleService.EnsureExtractedAsync). The resolver classified the binary as Unknown, the router mapped Unknown to InProcess for legacy script-route compat, and the in-process updater would silently overwrite the WinGet-owned binary — the exact regression PR α was meant to prevent. Caught by GPT-5.5 code review. Fix: ResolveRunningInstall (UpdateCommand) and GetRouteAwareUpdateCommand (CliUpdateNotifier) now run WingetFirstRunProbe.Run before reading the sidecar, on any platform when the initial DescribeSelf reports no route. The probe self-gates via IWindowsRegistryReader (NullWindowsRegistryReader on non-Windows makes it a cheap no-op there), so the call is safe in every environment. After the probe runs, DescribeSelf is called again so the freshly-stamped sidecar is picked up. The probe's binaryDir is now derived from IInstallationDiscovery.DescribeSelf().CanonicalPath rather than Environment.ProcessPath. This both removes the OS-conditional guard that lived at the call site (the probe self-gates) and makes the integration testable end-to-end via FakeInstallationDiscovery. Also fixes the SelfUpdateAction.Delegate XML doc that still claimed non-zero exit (caught by Opus code review). The implementation deliberately returns exit 0 to match the existing dotnet-tool refusal contract; the doc is now correct. Tests: - WingetFirstRunSelfUpdateGuardTests covers (a) probe stamps sidecar when registry confirms WinGet portable, (b) probe is a no-op when registry says no, and (c) the end-to-end aspire update --self path with a fake registry returning true → sidecar stamped + refusal prints 'winget upgrade Microsoft.Aspire'. The integration test is platform-agnostic via SidecarBackedDiscovery + a controllable temp binary directory. - 197 / 197 acquisition+update tests pass. Two CliUpdateNotifier constructor-surface ripples: the test override class CliUpdateNotifierWithPackageVersionOverride gained a 4th forwarded dep, and 8 call sites in CliUpdateNotificationServiceTests / 1 in CliTestHelper bulk-updated. Co-authored-by: Copilot <[email protected]>
CI on Windows (run 25895814470) failed UpgradeInstructionProviderTests.GetUpdateCommand_DotnetTool_ToolPath_* because the tests built the input path with forward slashes but DotNetToolDetection.NormalizeDirectorySeparators converts every separator to Path.DirectorySeparatorChar before walking up to the .store directory. On Windows the extracted toolPath therefore comes back with backslashes (\opt\my-aspire) while the assertion expected forward slashes (/opt/my-aspire). Fix: construct both the input processPath and the expected toolPath via Path.DirectorySeparatorChar so the test passes on both Unix (`/`) and Windows (`\`). Co-authored-by: Copilot <[email protected]>
… hatch Unknown install routes now fail closed instead of entering the in-process binary swap. The new --force option preserves an explicit escape hatch for users who have investigated their install and want to attempt the in-process update anyway, while logging the detected route for follow-up diagnostics. Route refusal hints now include the force override, the unknown-route hint explains missing or unreadable sidecars, dotnet-tool hints honor the supplied process path, and local hive guidance includes both shell and PowerShell scripts. Co-authored-by: Copilot <[email protected]>
3 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
aspire update --selfnow refuses to perform an in-process binary swap for installs it doesn't own (PR / WinGet / Homebrew /dotnet tool/ locally-built), and the "version X is available" notification surfaces the command that actually updates your binary. Also fixes theaspire update --non-interactivechannel-prompt crash (#15600).Part of the acquisition coherence ladder tracked in #16737. Stacked on PR γ (#17105) — consumes its
InstallSourceenum +IInstallationDiscovery.DescribeSelf(). Once #17105 lands, this PR retargets tomain.Closes #15600.
What ships
Route-gated
aspire update --self(UpdateCommand.cs):IInstallationDiscovery.DescribeSelf()(no new symlink-resolve helper added — reuses PR γ's resolver).ScriptandUnknown(legacy script installs without sidecars) → in-process update, unchanged behavior.Pr/Winget/Brew/DotnetTool/LocalHive→ refuse with route-appropriate command viaIUpgradeInstructionProvider, exit 0 (matches existing dotnet-tool refusal contract).DotNetToolDetection.IsRunningAsDotNetTool()returns true, the route is fixed up toDotnetToolso pre-sidecar dotnet-tool installs are recognized.SelfUpdateRouter+IUpgradeInstructionProvider+UpgradeInstructionProvider:InstallSource→SelfUpdateAction(InProcess|Delegate).-gglobal vs--tool-path "<dir>") reusesDotNetToolDetection.GetDotNetToolUpdateCommandno-arg overload — already AOT-safe and honors test overrides via its existingAsyncLocal.CliExecutionContext.IdentityChannel(pr-<N>→get-aspire-cli-pr.sh <N>) with deterministic fallback to the parameterised form when the identity channel isn't a PR build.Route-aware "update available" notification (
CliUpdateNotifier.cs):"aspire update"(which only runs the project-update flow, not the CLI self-update) and dotnet-tool special-case with a single call toIUpgradeInstructionProvider."aspire update --self"forScript/Unknown/ unrecognized future routes.brew upgrade --cask aspire; WinGet users seewinget upgrade Microsoft.Aspire; etc.aspire update --non-interactivechannel-prompt crash fix (#15600):~/.aspire/hives/, the command calledPromptForSelectionAsyncregardless of host environment, crashing withInvalidOperationException: Interactive input is not supported.ICliHostEnvironment.SupportsInteractiveInput. In non-interactive hosts (explicit--non-interactive, CI env vars, missing console handles) the implicit/default channel is selected silently — matching the no-hives branch.#15601 regression test added — the existing
AddNonInteractiveRequiresYesValidatoralready short-circuits--non-interactivewithout--yesbefore anyProjectUpdater.PromptConfirmAsyncsite is reached. The new test hooksConfirmCallbackto throw if reached and asserts non-zero exit.Localization:
WingetSelfUpdateMessage,BrewSelfUpdateMessage,PrSelfUpdateMessage,LocalHiveSelfUpdateMessage,SelfUpdateUnknownSourceMessage.dotnet build /t:UpdateXlfregenerated 14 locale.xlffiles.Description text fix (small): the
Channelproperty description onAspireConfigFileandAspireJsonConfigurationreferenced the stale"preview"channel. Replaced with"pr-<N>", the actual non-stable channel form.What's intentionally NOT in this PR
Channelproperty deletion (originally planned as Scenario E) — re-evaluated and dropped from scope. The property is still actively read for project-localaspire.config.jsonfiles (DotNetBasedAppHostServerProject, PrebuiltAppHostServer, IntegrationPackageSearchService) and the legacyAspireJsonConfigurationfallback. PR 1 already handled the only problematic case (global file residue → nulled on migration inProgram.cs:176).Closes / impacts
Supersedes / coordinates with #15602 (Copilot bot draft addressing the same
#15600/#15601crashes with a narrower scope — validator + channel-prompt guard only, no route gating, no notifier work).aspire update --selfUpdateCommand --selfroute gatingaspire update --selfUpdateCommand --selfroute gatingaspire update --non-interactivecrash at channel promptICliHostEnvironment.SupportsInteractiveInputaspire update --non-interactivecrash at ConfirmAsyncAddNonInteractiveRequiresYesValidator; regression test pins it downdocs/specs/acquisition/user-scenarios.md—--selfper route)CliUpdateNotifierconsumesIUpgradeInstructionProviderValidation
Manual matrix executed against this build on macOS arm64 (full results saved as a session artifact). All 9 scenarios pass:
--self --yes --channel stable→ in-process flow runs end-to-end (downloaded + installed v13.3.2)--self→ refuses withget-aspire-cli-pr.sh <N>hint (parameterised fallback because Debug build's identity channel islocal; PR-route CI builds bake the matching channel)--self→ refuses withbrew upgrade --cask aspire--self→ refuses withwinget upgrade Microsoft.Aspire--self→ refuses with./localhive.shrebuild hint--self→ refuses withdotnet tool update -g Aspire.Cli--tool-path--self→ refuses with path-aware variantupdate --non-interactive --yesagainst config with hive dirs → no crash, default channel chosen silentlyaspire.config.jsonwith"channel": "preview"and an unknown future field → CLI doesn't crash; ignores both fields per System.Text.Json default toleranceTests
194 / 194 acquisition + update tests pass. New test classes (Theory-flattened where applicable):
SelfUpdateRouterTests— everyInstallSourcevalue → action mapping (7 rows).UpgradeInstructionProviderTests— static-hint routes + no-hint routes + PR-number substitution + 5 non-PR identity-channel fallback rows + dotnet-tool-g/--tool-path/ whitespace-quote / path-shape-unrecognized fallback (13 Theory rows + 4 Facts).UpdateNotificationRouteTests— 5 routes × notification + Unknown (null) + unrecognized future source name (forward-compat).UpdateCommandRouteRegressionTests— 4 newly-gated routes end-to-end + legacy dotnet-tool path-shape fallback + legacy script no-sidecar in-process fallback.UpdateCommandNonInteractiveTests— 2 hive-scenario rows foraspire update --non-interactivecrashes at PromptForSelectionAsync when hive directories exist #15600 + 1 Fact foraspire update --non-interactivecrashes at ConfirmAsync when version downgrade is detected #15601 validator.Two pre-existing
UpdateCommandTestscases were updated to opt in to interactive host environment so the channel-prompt content contract is still exercised. One pre-existingCliUpdateNotificationServiceTeststest was renamed to assert the now-correctaspire update --selfrecommendation for archive-path installs.Code reuse highlights
Per the
Watch for utility code that might already existdirective:IInstallationDiscovery.DescribeSelf()— reused PR γ's canonical resolver instead of adding a 6th symlink-resolve helper (the codebase already had 5:InstallationDiscovery.ResolveCanonicalPath,InstallationUninstaller.ResolveSymlinkOrSelf,InfoCommand.TryResolveSymlink,BundleService.ResolveSymlinks,InstallationUninstaller's standaloneFile.ResolveLinkTargetblock).DotNetToolDetection.GetDotNetToolUpdateCommand()andIsRunningAsDotNetTool()no-arg overloads — already honor theAsyncLocaltest override; reused for both production behavior and test compatibility.InstallSourceExtensions.ParseInstallSource— wire-string parser from PR γ.FakeInstallationDiscoverytest helper from PR γ — used byUpdateNotificationRouteTestsandUpdateCommandRouteRegressionTests.CliUpdateNotifierWithPackageVersionOverridetest helper — extended (3 new constructor params, 8 call sites bulk-updated).AddNonInteractiveRequiresYesValidator+PromptBinding.Create— existing patterns kept; new prompt only adds anICliHostEnvironmentcheck.Notes for reviewers
aspire-commands(PR γ feat(cli):aspire doctorlists every Aspire CLI install on the machine #17105). The diff againstaspire-commandsis what's actually new (5 commits, ~1,300 lines including locale xlf and test churn). Once PR γ merges, the base will be retargeted tomainand the diff will be reviewable directly against main.dotnet tool updaterefusal contract — the CLI succeeds in telling the user what to run. Detecting "did this update?" via exit code was not previously possible for the dotnet-tool case either; users should compare versions before/after.Checklist
aspire doctorlists every Aspire CLI install on the machine #17105.internal)aspire update --self, preventing the silent-PR-demotion and pkg-mgr-binary-clobber regressions. The classification source-of-truth is the install-route sidecar contract documented indocs/specs/install-routes.md(single-field JSON next to the binary). Compromise of the sidecar would let an attacker change the refusal message but not weaken the refusal — the worst case is still a printedwinget upgrade Microsoft.Aspireinstead of an in-process swap.Co-authored-by: Copilot [email protected]
PR test results
Generated by
pr-testingskill — 2026-05-15T00:26:33-04:00CLI build under test:
13.4.0-pr.17110.ge9f61898(osx-arm64, GitHub Actions run 25897523105, run #20678)Platform: macOS 25.4 / arm64 (Darwin)
Runner: host (installed under isolated
HOMEperpr-testingSKILL local-mode flow)Head commit verified:
e9f6189863…⇄ CLI version suffixge9f61898✅aspire --version13.4.0-pr.17110.ge9f61898; version suffix matches PR heade9f6189863…aspire info --non-interactive --nologopr, channel=pr-17110; host dotnet-tool install also detected separatelyaspire update --self --non-interactive --yeswith sidecardotnet-tooldotnet tool update -g Aspire.Clihintaspire update --self --non-interactive --yeswith sidecarbrewbrew upgrade --cask aspirehintaspire update --self --non-interactive --yeswith sidecarwingetwinget upgrade Microsoft.Aspirehintaspire update --self --non-interactive --yeswith sidecarprget-aspire-cli-pr.sh 17110/ PowerShell PR reinstall hintaspire update --self --non-interactive --yeswith sidecarlocalhive./localhive.shrebuild-from-checkout hintaspire update --non-interactive --yes --apphost <single-file apphost>with PR hive present and no explicit targetaspire update --non-interactive --channel stable --apphost <single-file apphost>without--yesrequires --yes when the --non-interactive option is specified; did not reach/crash at confirmation prompt (#15601 regression)aspire --helpunderpr/brew/winget/dotnet-tool/localhivesidecarsupdate availablehint was emitted in this build, so no stale/wrong route hint was observedScenarios passed: 10 / 10.
Notes for reviewers
aspire update --selfpath was run. Only delegated/refusal routes were exercised. The CLI version remained13.4.0-pr.17110.ge9f61898after all--selfruns.0in this PR build. That matches the current in-code contract for “successfully told the user what updater owns this install,” even though the important safety property is refusal-before-mutation.aspire update --non-interactivecrashes at PromptForSelectionAsync when hive directories exist #15600 coverage: generated a disposable single-file AppHost from the PR hive, left~/.aspire/hives/pr-17110present, omitted--channel, and used--non-interactive --yes; the command selected the implicit update path without prompting/crashing.aspire update --non-interactivecrashes at ConfirmAsync when version downgrade is detected #15601 coverage: used the same AppHost with an explicitstabletarget and--non-interactivebut no--yes; validation failed before any downgrade confirmation prompt could run..aspire-install.jsonnext to the isolated PR binary) were the safest way to exercise all package-manager/local routes without invoking real package managers or mutating the host CLI.