Skip to content

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
microsoft:aspire-commandsfrom
radical:ankj/v3-pr-alpha-identity-aware-cli
Draft

feat(cli): aspire update --self refuses non-script install routes; route-aware notifications; #15600 fix (PR α)#17110
radical wants to merge 8 commits into
microsoft:aspire-commandsfrom
radical:ankj/v3-pr-alpha-identity-aware-cli

Conversation

@radical
Copy link
Copy Markdown
Member

@radical radical commented May 15, 2026

Description

aspire update --self now 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 the aspire update --non-interactive channel-prompt crash (#15600).

Part of the acquisition coherence ladder tracked in #16737. Stacked on PR γ (#17105) — consumes its InstallSource enum + IInstallationDiscovery.DescribeSelf(). Once #17105 lands, this PR retargets to main.

Closes #15600.

What ships

Route-gated aspire update --self (UpdateCommand.cs):

  • Resolves the running install via IInstallationDiscovery.DescribeSelf() (no new symlink-resolve helper added — reuses PR γ's resolver).
  • Script and Unknown (legacy script installs without sidecars) → in-process update, unchanged behavior.
  • Pr / Winget / Brew / DotnetTool / LocalHive → refuse with route-appropriate command via IUpgradeInstructionProvider, exit 0 (matches existing dotnet-tool refusal contract).
  • Legacy fallback: when no sidecar is present AND DotNetToolDetection.IsRunningAsDotNetTool() returns true, the route is fixed up to DotnetTool so pre-sidecar dotnet-tool installs are recognized.

SelfUpdateRouter + IUpgradeInstructionProvider + UpgradeInstructionProvider:

  • Pure policy lookup mapping InstallSourceSelfUpdateAction (InProcess | Delegate).
  • Per-route command computation. Dotnet-tool runtime substitution (-g global vs --tool-path "<dir>") reuses DotNetToolDetection.GetDotNetToolUpdateCommand no-arg overload — already AOT-safe and honors test overrides via its existing AsyncLocal.
  • PR-route command substitutes the PR number from 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):

  • Replaces the hardcoded "aspire update" (which only runs the project-update flow, not the CLI self-update) and dotnet-tool special-case with a single call to IUpgradeInstructionProvider.
  • Falls back to "aspire update --self" for Script / Unknown / unrecognized future routes.
  • Brew users see brew upgrade --cask aspire; WinGet users see winget upgrade Microsoft.Aspire; etc.

aspire update --non-interactive channel-prompt crash fix (#15600):

  • Pre-fix: when hive directories existed under ~/.aspire/hives/, the command called PromptForSelectionAsync regardless of host environment, crashing with InvalidOperationException: Interactive input is not supported.
  • Post-fix: gate the prompt on 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 AddNonInteractiveRequiresYesValidator already short-circuits --non-interactive without --yes before any ProjectUpdater.PromptConfirmAsync site is reached. The new test hooks ConfirmCallback to throw if reached and asserts non-zero exit.

Localization:

  • 5 new RESX strings: WingetSelfUpdateMessage, BrewSelfUpdateMessage, PrSelfUpdateMessage, LocalHiveSelfUpdateMessage, SelfUpdateUnknownSourceMessage.
  • dotnet build /t:UpdateXlf regenerated 14 locale .xlf files.

Description text fix (small): the Channel property description on AspireConfigFile and AspireJsonConfiguration referenced the stale "preview" channel. Replaced with "pr-<N>", the actual non-stable channel form.

What's intentionally NOT in this PR

  • Channel property deletion (originally planned as Scenario E) — re-evaluated and dropped from scope. The property is still actively read for project-local aspire.config.json files (DotNetBasedAppHostServerProject, PrebuiltAppHostServer, IntegrationPackageSearchService) and the legacy AspireJsonConfiguration fallback. PR 1 already handled the only problematic case (global file residue → nulled on migration in Program.cs:176).
  • Concurrent-update safety (per-prefix locks, self-update lock, GC) — PR β scope.

Closes / impacts

Supersedes / coordinates with #15602 (Copilot bot draft addressing the same #15600 / #15601 crashes with a narrower scope — validator + channel-prompt guard only, no route gating, no notifier work).

Issue / scenario Closed by
★ Silent PR demotion on aspire update --self UpdateCommand --self route gating
★ Package-manager binary clobber on aspire update --self UpdateCommand --self route gating
#15600aspire update --non-interactive crash at channel prompt Channel prompt gated on ICliHostEnvironment.SupportsInteractiveInput
#15601aspire update --non-interactive crash at ConfirmAsync Already addressed by AddNonInteractiveRequiresYesValidator; regression test pins it down
S2.3 / S2.4 / S2.5 / S2.6 (docs/specs/acquisition/user-scenarios.md--self per route) Route gating + provider hints
S9.1–S9.4 (route-aware update notifications) CliUpdateNotifier consumes IUpgradeInstructionProvider
S4.2 / S4.3 (channel residue across routes) Already handled by PR 1's global-file cleanup

Validation

Manual matrix executed against this build on macOS arm64 (full results saved as a session artifact). All 9 scenarios pass:

  • Script --self --yes --channel stable → in-process flow runs end-to-end (downloaded + installed v13.3.2)
  • PR-route --self → refuses with get-aspire-cli-pr.sh <N> hint (parameterised fallback because Debug build's identity channel is local; PR-route CI builds bake the matching channel)
  • Brew --self → refuses with brew upgrade --cask aspire
  • WinGet --self → refuses with winget upgrade Microsoft.Aspire
  • LocalHive --self → refuses with ./localhive.sh rebuild hint
  • Dotnet-tool global --self → refuses with dotnet tool update -g Aspire.Cli
  • Dotnet-tool --tool-path --self → refuses with path-aware variant
  • update --non-interactive --yes against config with hive dirs → no crash, default channel chosen silently
  • Stale aspire.config.json with "channel": "preview" and an unknown future field → CLI doesn't crash; ignores both fields per System.Text.Json default tolerance

Tests

194 / 194 acquisition + update tests pass. New test classes (Theory-flattened where applicable):

Two pre-existing UpdateCommandTests cases were updated to opt in to interactive host environment so the channel-prompt content contract is still exercised. One pre-existing CliUpdateNotificationServiceTests test was renamed to assert the now-correct aspire update --self recommendation for archive-path installs.

Code reuse highlights

Per the Watch for utility code that might already exist directive:

  • 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 standalone File.ResolveLinkTarget block).
  • DotNetToolDetection.GetDotNetToolUpdateCommand() and IsRunningAsDotNetTool() no-arg overloads — already honor the AsyncLocal test override; reused for both production behavior and test compatibility.
  • InstallSourceExtensions.ParseInstallSource — wire-string parser from PR γ.
  • FakeInstallationDiscovery test helper from PR γ — used by UpdateNotificationRouteTests and UpdateCommandRouteRegressionTests.
  • CliUpdateNotifierWithPackageVersionOverride test helper — extended (3 new constructor params, 8 call sites bulk-updated).
  • AddNonInteractiveRequiresYesValidator + PromptBinding.Create — existing patterns kept; new prompt only adds an ICliHostEnvironment check.

Notes for reviewers

  • Stacked PR. This branch sits on top of aspire-commands (PR γ feat(cli): aspire doctor lists every Aspire CLI install on the machine #17105). The diff against aspire-commands is what's actually new (5 commits, ~1,300 lines including locale xlf and test churn). Once PR γ merges, the base will be retargeted to main and the diff will be reviewable directly against main.
  • Exit code 0 on refusal matches the existing dotnet tool update refusal 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.
  • Pre-PR-2 legacy installs (no sidecar) get InProcess by design — this preserves working behavior for users who installed before the sidecar contract shipped. The silent-PR-demotion / pkg-mgr-clobber regressions all involve binaries WITH sidecars (post-PR-2 installers always write them), so they correctly take the gated branch.

Checklist

  • Is this feature complete?
  • Are you including unit tests for the changes and scenario tests if relevant?
    • Yes
    • No
  • Did you add public API?
    • Yes
    • No (all new types are internal)
  • Does the change make any security assumptions or guarantees?
    • Yes
      • The route gating refuses to overwrite package-manager-owned binaries on 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 in docs/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 printed winget upgrade Microsoft.Aspire instead of an in-process swap.

Co-authored-by: Copilot [email protected]

PR test results

Generated by pr-testing skill — 2026-05-15T00:26:33-04:00

CLI 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 HOME per pr-testing SKILL local-mode flow)
Head commit verified: e9f6189863… ⇄ CLI version suffix ge9f61898

# Scenario Exit Result
1 aspire --version 0 13.4.0-pr.17110.ge9f61898; version suffix matches PR head e9f6189863…
2 aspire info --non-interactive --nologo 0 ✅ Install table renders; current CLI route=pr, channel=pr-17110; host dotnet-tool install also detected separately
3 aspire update --self --non-interactive --yes with sidecar dotnet-tool 0 ✅ Refuses before mutation with dotnet tool update -g Aspire.Cli hint
4 aspire update --self --non-interactive --yes with sidecar brew 0 ✅ Refuses before mutation with brew upgrade --cask aspire hint
5 aspire update --self --non-interactive --yes with sidecar winget 0 ✅ Refuses before mutation with winget upgrade Microsoft.Aspire hint
6 aspire update --self --non-interactive --yes with sidecar pr 0 ✅ Refuses before mutation with get-aspire-cli-pr.sh 17110 / PowerShell PR reinstall hint
7 aspire update --self --non-interactive --yes with sidecar localhive 0 ✅ Refuses before mutation with ./localhive.sh rebuild-from-checkout hint
8 aspire update --non-interactive --yes --apphost <single-file apphost> with PR hive present and no explicit target 0 ✅ Clean non-interactive update path; did not crash at channel prompt (#15600 regression)
9 aspire update --non-interactive --channel stable --apphost <single-file apphost> without --yes 1 ✅ Fails early with clear validator error: requires --yes when the --non-interactive option is specified; did not reach/crash at confirmation prompt (#15601 regression)
10 aspire --help under pr / brew / winget / dotnet-tool / localhive sidecars 0 each ✅ Command tree renders; no update available hint was emitted in this build, so no stale/wrong route hint was observed

Scenarios passed: 10 / 10.

Notes for reviewers

  • Safety: no script-route aspire update --self path was run. Only delegated/refusal routes were exercised. The CLI version remained 13.4.0-pr.17110.ge9f61898 after all --self runs.
  • Exit-code observation: route refusals exit 0 in 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-interactive crashes at PromptForSelectionAsync when hive directories exist #15600 coverage: generated a disposable single-file AppHost from the PR hive, left ~/.aspire/hives/pr-17110 present, omitted --channel, and used --non-interactive --yes; the command selected the implicit update path without prompting/crashing.
  • aspire update --non-interactive crashes at ConfirmAsync when version downgrade is detected #15601 coverage: used the same AppHost with an explicit stable target and --non-interactive but no --yes; validation failed before any downgrade confirmation prompt could run.
  • Route simulation: sidecar-only swaps (.aspire-install.json next 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.

radical and others added 5 commits May 14, 2026 19:31
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]>
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 15, 2026

🚀 Dogfood this PR with:

⚠️ WARNING: Do not do this without first carefully reviewing the code of this PR to satisfy yourself it is safe.

curl -fsSL https://raw.githubusercontent.com/microsoft/aspire/main/eng/scripts/get-aspire-cli-pr.sh | bash -s -- 17110

Or

  • Run remotely in PowerShell:
iex "& { $(irm https://raw.githubusercontent.com/microsoft/aspire/main/eng/scripts/get-aspire-cli-pr.ps1) } 17110"

radical and others added 2 commits May 14, 2026 21:48
…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]>
@radical radical changed the base branch from main to aspire-commands May 15, 2026 04:11
… 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]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

aspire update --non-interactive crashes at PromptForSelectionAsync when hive directories exist

1 participant