Skip to content

Add aspire --info to enumerate the running CLI's install + discovered installs + hives#17461

Open
radical wants to merge 18 commits into
microsoft:mainfrom
radical:radical/installs-discovery
Open

Add aspire --info to enumerate the running CLI's install + discovered installs + hives#17461
radical wants to merge 18 commits into
microsoft:mainfrom
radical:radical/installs-discovery

Conversation

@radical
Copy link
Copy Markdown
Member

@radical radical commented May 25, 2026

What this does

aspire doctor isn't really the right home for the install / hive list. Move it to a new top-level aspire --info.

New: aspire --info

  • aspire --info — text: Version, Channel, then the per-row install block
  • aspire --info --format json{ version, channel, installs[] }
  • aspire --info --self — text, running CLI only
  • aspire --info --self --format json — bare InstallationInfo array; this is the cross-version peer-probe contract consumed by PeerInstallProbe

Gone from aspire doctor

  • installations field in doctor --format json
  • The hidden doctor --self / doctor --self --format json peer-probe variant
  • PeerInstallProbe now spawns peers with --info --self --format json, falling back to --version for binaries that predate --info

Also in this PR (along for the ride)

  • routesource terminology rename across CLI code, scripts, and docs. On-disk wire strings are unchangedscript / pr / winget / brew / dotnet-tool / localhive all stay literal; the sidecar JSON field name was already source.
  • InstallSource.BrewInstallSource.Homebrew (C# only; wire string "brew" is preserved in serializer and parser).
  • Windows on-disk casing fix in PathLookupHelper: aspire.exe no longer renders as aspire.EXE just because PATHEXT contains .EXE. Linux / macOS paths are unaffected.

Why this approach

  • --info is root-only / non-recursive. aspire run --info runs run and ignores --info; only aspire --info fires install enumeration. This avoids the recursive-option / subcommand-local --format shadowing trap, where aspire run --info --format json would otherwise silently emit install text because run's own --format swallows the token.

  • Channel-read and discovery-walk failures exit 0, not non-zero. --info is the surface a user reaches for to find out their binary is broken; crashing there would be self-defeating. Discovery failure emits a kind: "discovery-failed" row carrying the IO error as statusReason.

Call-outs

  • Unified --info JSON shape. Both --info --format json (each installs[] element) and --info --self --format json (bare array element) use the same InstallationInfo row: id, kind, path, canonicalPath, version, channel, source, hive, pathStatus, status, statusReason, managedBy. Nullable fields are omitted via WhenWritingNull. status (lifecycle: ok / failed / notProbed / no install found) and pathStatus (PATH-axis: active / shadowed / notOnPath) are orthogonal — consumers switch on each axis independently. Orphan-hive rows have path: null + kind: "orphan-hive" and carry the hive directory in hive. Full spec in docs/specs/cli-output-formats.md.

  • --self / --format without --info produce a parse error rather than silently falling through to grouped help. aspire doctor --format json still routes correctly.

  • --format table is rejected by the value parser; the enum stays { List, Json }. list is the explicit spelling of the default text rendering.

Checklist

  • Is this feature complete?
    • Yes. Ready to ship.
    • No. Follow-up changes expected.
  • Are you including unit tests for the changes and scenario tests if relevant?
    • Yes
    • No
  • Did you add public API?
    • Yes
    • No
  • Does the change make any security assumptions or guarantees?
    • Yes
    • No

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 25, 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 -- 17461

Or

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

@radical

This comment was marked as outdated.

@radical radical force-pushed the radical/installs-discovery branch from 53d1e36 to 102af0d Compare May 25, 2026 22:29
@radical radical marked this pull request as ready for review May 25, 2026 22:41
Copilot AI review requested due to automatic review settings May 25, 2026 22:41
@radical radical requested a review from joperezr May 25, 2026 22:42
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR splits Aspire CLI installation enumeration out of aspire doctor into a dedicated aspire installs command, leaving doctor focused on environment checks only. It also completes a broad terminology rename from “install route” to “install source” across CLI code, scripts, tests, and docs.

Changes:

  • Add new aspire installs command (including hidden --self --format json peer-probe surface) and supporting hive enumeration.
  • Remove installation-table output and --self support from aspire doctor, updating JSON contracts and resource strings accordingly.
  • Hoist WinGet first-run sidecar stamping into CLI startup (CliExecutionContext factory) and fix Windows PATH casing preservation in PathLookupHelper.

Reviewed changes

Copilot reviewed 74 out of 75 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
tests/Aspire.Hosting.Tests/PathLookupHelperTests.cs Adds Windows-only regression test for preserving filesystem casing when resolving PATHEXT hits.
tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs Registers InstallsCommand and HiveEnumerator in the CLI test DI container.
tests/Aspire.Cli.Tests/Utils/CliPathHelperTests.cs Renames route→source helpers/tests for Aspire home directory derivation.
tests/Aspire.Cli.Tests/Packaging/TemporaryNuGetConfigTests.cs Updates cross-reference to renamed macOS firmlink test name.
tests/Aspire.Cli.Tests/Configuration/DotNetBasedAppHostServerChannelResolutionTests.cs Updates terminology in test doc comment (route→source).
tests/Aspire.Cli.Tests/Commands/SetupCommandTests.cs Renames route→source in test names/comments for setup default path behavior.
tests/Aspire.Cli.Tests/Commands/InstallsCommandTests.cs New unit tests covering aspire installs (list output and hidden --self JSON).
tests/Aspire.Cli.Tests/Commands/DoctorCommandTests.cs Updates doctor tests to assert installations are no longer included and --self is rejected.
tests/Aspire.Cli.Tests/BundleServiceComputeDefaultExtractDirTests.cs Updates comments/terminology for bundle extraction layout selection (route→source).
tests/Aspire.Cli.Tests/Bundles/BundleServiceCrossSourceExtractionTests.cs Renames cross-route extraction matrix test to cross-source and updates homebrew naming.
tests/Aspire.Cli.Tests/Acquisition/WingetStartupProbeTests.cs New tests pinning startup-time WinGet sidecar probe behavior and failure swallowing.
tests/Aspire.Cli.Tests/Acquisition/WindowsRegistryReaderTests.cs New Windows-only round-trip tests against HKCU Uninstall for WinGet detection.
tests/Aspire.Cli.Tests/Acquisition/PeerInstallProbeTests.cs Updates peer probe to call installs --self --format json and accept bare-array JSON shape.
tests/Aspire.Cli.Tests/Acquisition/InstallSidecarReaderTests.cs Updates spec reference and enum expectations (brewHomebrew) plus “future-source” wording.
tests/Aspire.Cli.Tests/Acquisition/InstallationDiscoveryDiscoverAllTests.cs Updates expectations/messages and property names (route→source) for discovery behavior.
tests/Aspire.Acquisition.Tests/Scripts/VerifyCliArchivePowerShellTests.cs Updates user-facing verifier assertions to “install-source sidecar” wording.
tests/Aspire.Acquisition.Tests/Scripts/ReleaseScriptShellTests.cs Updates dry-run sidecar messaging assertions (route→source).
tests/Aspire.Acquisition.Tests/Scripts/ReleaseScriptPowerShellTests.cs Updates -WhatIf sidecar messaging assertions (route→source).
tests/Aspire.Acquisition.Tests/Scripts/PRScriptToolModeTests.cs Updates tool-mode assertions to ensure no “source sidecar” messaging appears.
tests/Aspire.Acquisition.Tests/Scripts/PRScriptShellTests.cs Updates PR-script dry-run tests and sidecar messaging (route→source).
tests/Aspire.Acquisition.Tests/Scripts/PRScriptPowerShellTests.cs Updates PR-script WhatIf tests and sidecar messaging (route→source).
tests/Aspire.Acquisition.Tests/Scripts/PRScriptInstallerModeTests.cs Updates installer-mode expectations and wording (“Homebrew” command naming).
tests/Aspire.Acquisition.Tests/Scripts/PRScriptInstallE2ETests.cs Updates E2E assertion wording for the sidecar presence (route→source).
tests/Aspire.Acquisition.Tests/Scripts/LocalHiveScriptFunctionTests.cs Adds guard test ensuring localhive scripts don’t reference removed aspire info.
tests/Aspire.Acquisition.Tests/Scripts/Common/FakeArchiveHelper.cs Updates spec reference in archive helper docs (install-routes→install-sources).
src/Shared/PathLookupHelper.cs Returns filesystem-cased executable paths on Windows instead of inheriting PATHEXT casing.
src/Aspire.Cli/Utils/EnvironmentChecker/EnvironmentCheckResult.cs Removes installations from doctor JSON response model.
src/Aspire.Cli/Utils/CliPathHelper.cs Renames route→source for Aspire home directory selection helper.
src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.cs.xlf Removes installation-table-related localized resources from doctor XLF.
src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.de.xlf Removes installation-table-related localized resources from doctor XLF.
src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.es.xlf Removes installation-table-related localized resources from doctor XLF.
src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.fr.xlf Removes installation-table-related localized resources from doctor XLF.
src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.it.xlf Removes installation-table-related localized resources from doctor XLF.
src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.ja.xlf Removes installation-table-related localized resources from doctor XLF.
src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.ko.xlf Removes installation-table-related localized resources from doctor XLF.
src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.pl.xlf Removes installation-table-related localized resources from doctor XLF.
src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.pt-BR.xlf Removes installation-table-related localized resources from doctor XLF.
src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.ru.xlf Removes installation-table-related localized resources from doctor XLF.
src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.tr.xlf Removes installation-table-related localized resources from doctor XLF.
src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.zh-Hans.xlf Removes installation-table-related localized resources from doctor XLF.
src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.zh-Hant.xlf Removes installation-table-related localized resources from doctor XLF.
src/Aspire.Cli/Resources/DoctorCommandStrings.resx Removes installation-table string resources now that doctor no longer renders installs.
src/Aspire.Cli/Resources/DoctorCommandStrings.Designer.cs Removes generated resource accessors for deleted installation-table strings.
src/Aspire.Cli/Program.cs Runs WinGet first-run probe during CLI startup and registers InstallsCommand/HiveEnumerator.
src/Aspire.Cli/JsonSourceGenerationContext.cs Adds source-gen support for InstallationInfo[] and List<InstallListItem>.
src/Aspire.Cli/Commands/SetupCommand.cs Updates comments for source-aware vs source-independent extract/install path behavior.
src/Aspire.Cli/Commands/RootCommand.cs Wires InstallsCommand into the CLI root command.
src/Aspire.Cli/Commands/InstallsCommand.cs Implements new aspire installs command and JSON/list outputs including orphan hives.
src/Aspire.Cli/Commands/InstallationInfoOutput.cs Removes doctor-oriented discovery output; keeps safe self-description helper for installs self-probe.
src/Aspire.Cli/Commands/DoctorCommand.cs Removes install enumeration/self-probe and limits output to environment checks.
src/Aspire.Cli/CliExecutionContext.cs Updates docs/comments to reflect source-specific Aspire home selection behavior.
src/Aspire.Cli/Bundles/BundleService.cs Removes WinGet probe call from bundle extraction path and updates spec reference.
src/Aspire.Cli/Acquisition/WingetFirstRunProbe.cs Updates documentation wording to “install-source sidecar”.
src/Aspire.Cli/Acquisition/PeerInstallProbe.cs Switches peer self-describe call to installs --self --format json and supports new JSON shape.
src/Aspire.Cli/Acquisition/IPeerInstallProbe.cs Updates contract docs for the new peer-probe invocation surface.
src/Aspire.Cli/Acquisition/InstallSource.cs Renames enum member to Homebrew while keeping the wire string as brew.
src/Aspire.Cli/Acquisition/InstallSidecarReader.cs Updates comments/spec reference to install-sources.
src/Aspire.Cli/Acquisition/InstallationInfo.cs Renames JSON field routesource and updates contract docs to installs surfaces.
src/Aspire.Cli/Acquisition/InstallationDiscovery.cs Renames/threads Source through discovery and updates messaging to install-source terminology.
src/Aspire.Cli/Acquisition/InstallationCandidateSources.cs Updates comment wording for installs discovery.
src/Aspire.Cli/Acquisition/IInstallSidecarReader.cs Updates sidecar reader docs to install-source terminology and removes obsolete command references.
src/Aspire.Cli/Acquisition/IInstallationDiscovery.cs Updates discovery interface docs for aspire installs and new peer-probe surface.
src/Aspire.Cli/Acquisition/HiveEnumerator.cs Adds enumeration of hives used by aspire installs list to show orphan hives.
localhive.sh Updates sidecar stamping comments/spec reference to install-sources terminology.
localhive.ps1 Updates sidecar stamping and archive validation messaging to install-sources terminology.
eng/scripts/verify-cli-tool-nupkg.ps1 Updates comment wording around sidecar source expectations.
eng/scripts/verify-cli-archive.ps1 Updates archive sidecar validation messaging to install-sources terminology.
eng/scripts/get-aspire-cli.sh Updates installer sidecar messaging to “source sidecar” and spec reference.
eng/scripts/get-aspire-cli.ps1 Updates installer sidecar messaging to “source sidecar” and spec reference.
eng/scripts/get-aspire-cli-pr.sh Renames PR sidecar writer function and updates comments/messages to install-sources terminology.
eng/scripts/get-aspire-cli-pr.ps1 Renames PR sidecar writer function and updates WhatIf messaging to “source sidecar”.
eng/clipack/Common.projitems Updates build-time guard/error text to “install sources” terminology and new spec reference.
docs/specs/install-sources.md Renames/updates the sidecar specification from install-routes to install-sources.
docs/specs/cli-output-formats.md Documents aspire installs list --format json and clarifies hidden installs --self contract.
docs/dogfooding-pull-requests.md Updates wording around Homebrew uninstall behavior and shared prefixes (route→source).
Files not reviewed (1)
  • src/Aspire.Cli/Resources/DoctorCommandStrings.Designer.cs: Language not supported

Comment thread src/Aspire.Cli/Acquisition/HiveEnumerator.cs Outdated
@radical radical added this to the 13.4 milestone May 26, 2026
Copy link
Copy Markdown
Member

@mitchdenny mitchdenny left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the install-discovery split. Three related findings, all on InstallsCommand.cs — the status field in the new installs list --format json contract is currently overloaded for non-Ok rows, which both complicates programmatic consumption and forces a couple of magic-string duplications.

Comment thread src/Aspire.Cli/Commands/InstallsCommand.cs Outdated
Comment thread src/Aspire.Cli/Commands/InstallsCommand.cs Outdated
Comment thread src/Aspire.Cli/Commands/InstallsCommand.cs Outdated
@mitchdenny

This comment has been minimized.

@radical
Copy link
Copy Markdown
Member Author

radical commented May 26, 2026

Thanks for the thorough PR-test pass @mitchdenny. All three inline comments addressed in aa0d308:

  • GetInstallStatus no longer concatenates failed: <reason>status stays enum-shaped, message rides on statusReason alone. Spec updated, failed-row example added, new test guards both human and JSON surfaces against regression.
  • "no install found" extracted to OrphanHiveStatus const, shared between the row constructor and the sort-rank switch.
  • Human duplication auto-resolved once status stops embedding the reason.

On Scenario 6 (bare aspire installs returning exit 1 when displaying help): I checked and that's the codebase convention — CommandResult.DisplayHelp() is hard-wired to CliExitCodes.InvalidCommand (src/Aspire.Cli/Commands/CommandResult.cs:40), and aspire config (also a ParentCommand-style subcommand-with-no-action) does the same. Changing it just for installs would diverge from config. Happy to take it up as a separate, repo-wide change if you think the convention itself should flip.

CI green, ready for re-review.

@radical

This comment has been minimized.

@radical

This comment has been minimized.

@radical radical added the NO-MERGE The PR is not ready for merge yet (see discussion for detailed reasons) label May 26, 2026
@radical radical changed the title Add aspire installs command; drop install table from aspire doctor Add aspire --info to enumerate the running CLI's install + discovered installs + hives May 26, 2026
Copy link
Copy Markdown
Member

@JamesNK JamesNK left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One finding: BuildRowsAsync lacks the error handling described in the PR description for discovery-walk failures. Either the code needs a try-catch (matching what DiscoverAllSafelyAsync used to do), or the PR description should be updated to reflect that this invariant was intentionally dropped.

Comment thread src/Aspire.Cli/Commands/InstallsCommand.cs Outdated
radical and others added 3 commits May 26, 2026 19:09
Rename the C# identifiers, prose, file names, and xmldoc references that
spoke of an "install route" to "install source" so the codebase matches
the on-disk sidecar field name (`{"source":"..."}`) which has always
been the authoritative spelling.

Mechanical rename only. No behavior change. Wire strings are unchanged:
`script`, `pr`, `winget`, `brew`, `dotnet-tool`, `localhive` all stay
literal; the sidecar JSON field name was already `source`.

Notable identifier renames:

- `InstallSource.Brew` enum value → `InstallSource.Homebrew` (the wire
  string stays `"brew"` via the preserved `InstallSourceExtensions.BrewWire`
  constant — only the C# enum name and the friendlier display label change).
- `InstallationInfo.Route` property → `InstallationInfo.Source` (and the
  associated `[JsonPropertyName("route")]` → `"source"`; this surface
  was added earlier in the branch and has not shipped).
- `CliPathHelper.TryGetAspireHomeDirectoryFromInstallRoute` →
  `TryGetAspireHomeDirectoryFromInstallSource`.
- `docs/specs/install-routes.md` → `docs/specs/install-sources.md`.
- `tests/.../BundleServiceCrossRouteExtractionTests.cs` →
  `BundleServiceCrossSourceExtractionTests.cs` and the contained
  theory method's name.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…indows

`PathLookupHelper.FindAllFullPathsFromPath` used to concatenate the
candidate command name with each `PATHEXT` entry and return that
synthesized path verbatim when the file existed. Because Windows
`PATHEXT` is typically registered uppercase (`.EXE`, `.CMD`, ...), an
on-disk `aspire.exe` would surface to user-facing output as
`aspire.EXE`, which looks like a different binary and breaks string-
compare dedup checks elsewhere in the install-discovery walk.

After matching the file by case-insensitive existence check, look up the
actual on-disk name via `Directory.EnumerateFileSystemEntries(directory,
fileName)` and return that spelling instead of the `PATHEXT`-derived
form. Linux and macOS are untouched.

The probe is guarded against `Directory.EnumerateFileSystemEntries`
throwing — if the lookup fails (permissions, path too long, etc.) the
helper falls back to the original PATHEXT-derived spelling, preserving
the prior behavior on the unhappy path.

Regression test added at
`tests/Aspire.Hosting.Tests/PathLookupHelperTests.cs` exercises the
Windows-only casing-preservation path.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Aspire CLI install enumeration was bolted onto `aspire doctor` alongside
the environment-prerequisite checks. That mixed two unrelated concerns
into one command, and there was no clean root-level surface for
"tell me what CLI I'm running and what other CLIs / hives this machine
has."

Add a root-level `--info` flag that returns the running CLI's identity
plus the install/orphan-hive table in one payload, and pull the install
table out of `aspire doctor`.

  - `aspire --info` — text: `Version`, `Channel`, then the per-row
    install block.
  - `aspire --info --format json` —
    `{ "version": ..., "channel": ..., "installs": [...] }`.
  - `aspire --info --self` — text: just the running CLI's row.
  - `aspire --info --self --format json` — single-element
    `InstallationInfo` array; this is the cross-version peer-probe
    contract consumed by `PeerInstallProbe.TryParseRichProbeResult`.

`--info` is wired as an `AsynchronousCommandLineAction` on a root-only
option, so `aspire --info` short-circuits routing the same way `--help`
and `--version` do — but unlike `--help`/`--version`, `--info` is
intentionally non-recursive: `aspire run --info` is a separate operation
(the `--info` token does not bind at subcommand scope, so `run` runs and
`--info` is ignored). This keeps install enumeration a deliberate
top-level concept and avoids the recursive-option / subcommand-local
`--format` shadowing trap, where `aspire run --info --format json` would
otherwise silently emit install text because `run`'s own `--format`
swallows the token.

`--self` and `--format` are hidden root-only options with validators
that fail parse when set without `--info`, so `aspire --self` and
`aspire --format json` produce a parse error instead of silently falling
through to grouped help (and `aspire doctor --format json` still routes
correctly to the doctor subcommand). `--info` is added to
`CommonOptionNames.InformationalOptionNames` so its text form does not
consume the first-run sentinel or start a tracked telemetry activity.

`aspire doctor` is now scoped to environment checks. The `installations`
field is removed from `aspire doctor --format json`; the `doctor --self`
hidden peer-probe variant is removed; the install-table rendering and
the install-related `DoctorCommandStrings` resource entries (and
matching xlf entries across 13 languages) are deleted.

`PeerInstallProbe` now spawns peers with
`["--info", "--self", "--format", "json"]`. An older peer that predates
`--info` exits non-zero on the unknown option; the probe falls through
to a second spawn of `["--version"]` and re-derives the peer's channel
from its InformationalVersion string for PR builds (the existing
`--version` fallback path in the probe). No explicit shim for the old
`doctor --self` argv is needed because no live consumer of that argv
remains.

Why this approach
-----------------

`CliExecutionContext`'s DI factory used to eagerly call
`IIdentityChannelReader.ReadChannel()`, which throws when a CLI binary
has missing or invalid `AspireCliChannel` assembly metadata. That made
the channel-failure path undeliverable for any diagnostic surface: the
factory would crash before any subcommand or root-option action ran.
The factory now wraps the read in `Lazy<string>` and
`CliExecutionContext.IdentityChannel` returns
`_lazy?.Value ?? identityChannel`, preserving the original throwing
semantics on first access for consumers that actually need the channel
(packaging, scaffolding, update / new / init), while letting
`InfoOptionAction` inject `IIdentityChannelReader` directly and catch
the exception itself — exit 0, channel field dropped from JSON (via
`DefaultIgnoreCondition.WhenWritingNull`), Channel row omitted from text.

`InfoOption`, `InfoSelfOption`, and `InfoFormatOption` are per-
`RootCommand` instance fields rather than `static`. Each `RootCommand`
construction sets `InfoOption.Action` and adds option validators; with
static fields, concurrent tests building their own `RootCommand` would
race on the shared `Action` setter (last write wins, the test that
built first sees another test's service provider) and `Validators.Add`
would accumulate across instances.

`WingetFirstRunProbe.Run` moves from `BundleService` + the old install
table call site into the `CliExecutionContext` DI factory in
`Program.cs`. Same behavior, single call site, fires before the
install-source sidecar is read by any downstream consumer;
`BundleService` is now install-source-agnostic.

Surprises and call-outs
-----------------------

- `--info` is intentionally root-only. `aspire --info` fires install
  enumeration; `aspire run --info` is a separate operation that runs
  the subcommand and ignores the unmatched `--info` token. Two
  regression tests (`Info_OnSubcommand_DoesNotFireInfoAction`,
  `Info_OnSubcommand_WithFormat_BindsFormatToSubcommand`) pin this so
  a future Recursive flip can't silently resurrect the subcommand-local
  `--format` shadowing trap.

- Discovery-walk failure exits 0, not non-zero. `BuildRowsSafelyAsync`
  catches a throwing `IInstallationDiscovery.DiscoverAllAsync`
  (filesystem ACL, IO on `~/.aspire/hives`, ...) and emits a single
  failure row (`kind: "discovery-failed"`, `status: "failed"`,
  `statusReason: <message>`) so `aspire --info` stays useful for
  diagnosing a flaky environment. Same posture as the channel-read and
  self-version tolerant paths. The empty-installs case still emits
  `"installs": []` rather than dropping the field.

- Channel-read failure also deliberately does not exit non-zero. The
  `--info` surface is exactly the one a user reaches for to find out
  their binary is broken; it would be self-defeating to crash.

- `--format list` is accepted as the explicit spelling of the default
  text rendering. The enum stays `{ List, Json }` so the
  System.CommandLine value parser rejects unknown values like `table`.

- The `--info --self --format json` row uses the `InstallationInfo`
  field set (`path`, `canonicalPath`, `version`, `channel`, `source`,
  `pathStatus`, `status`, `statusReason`), which is *distinct from* the
  aggregate `installs[*]` row shape (`id`, `kind`, `channel`, `path`,
  `hive`, `status`, `statusReason`, `managedBy`). Both shapes are
  documented in `docs/specs/cli-output-formats.md`.

Tests
-----

`InfoOptionTests.cs` covers: peer-probe contract roundtrip, channel-read
failure path (asserts `CliExecutionContext` still resolves and the
command exits 0), `channel: "local"` is not treated as failure,
empty-installs `installs: []`, discovery-walk failure exits 0 with a
`discovery-failed` row, `--format list` synonym + `--format table`
rejection, `aspire run --info` is non-recursive and does not fire the
info action, parse errors for `--self`/`--format` without `--info`, the
`aspire doctor --format json` regression guard, and an end-to-end
peer-probe roundtrip that feeds `--info --self --format json` output
through `InstallationInfoParser.Parse`.

`PeerInstallProbeTests` argv assertion, shell/batch fake scripts
(`EmitExit`, `StderrRepeatScript`, `DoctorOrVersionScript`), and inline
comments updated to dispatch on `--info` instead of `doctor --self`.
New tests: `HiveEnumeratorTests` (channel-shape allow-list + path
returns), `WindowsRegistryReaderTests` (Windows-only HKCU round-trip
with prefixed entries swept on each run), `WingetStartupProbeTests`
(pins the DI-factory invocation contract and the swallow-on-failure
behavior).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@radical radical force-pushed the radical/installs-discovery branch from 8bf4093 to 402388b Compare May 26, 2026 23:11
The previous gate scanned raw args with
`args.Any(a => InformationalOptionNames.Contains(a))`. Because `--info`
is wired as a root-only non-recursive option on `RootCommand`,
`aspire run --info` is a real `run` invocation — the `--info` token
does not bind to `RootCommand.InfoOption`. But the position-blind scan
still matched the literal token, so `aspire run --info` silently
suppressed telemetry, suppressed the first-run banner, and skipped
creating the one-shot first-run sentinel for that real `run`
invocation.

Replace `InformationalOptionNames` with `IsInformationalInvocation`, a
position-aware helper:

  - `--info` counts as informational only before the first non-option
    token (matching the System.CommandLine binding).
  - `--version`, `--help`, `-h`, `-?` stay position-blind because
    System.CommandLine treats them as recursive — they really are
    informational at any depth, e.g. `aspire run --help`.

`TelemetryManager` and `Program.DisplayFirstTimeUseNoticeIfNeededAsync`
both switch to the helper. Test coverage pins both sides of the new
contract:

  - `Info_OnSubcommand_IsNotInformationalInvocation` /
    `RecursiveInformationalFlags_OnSubcommand_RemainInformational` —
    helper-level.
  - `AzureMonitor_Disabled_WhenInfoFlagProvided` /
    `AzureMonitor_Enabled_WhenInfoFlagOnSubcommand` /
    `AzureMonitor_Disabled_WhenRecursiveInformationalFlagOnSubcommand`
    — telemetry-gate behavior.
  - `InfoFlagOnSubcommand_CreatesSentinelAndShowsBanner_OnFirstRun` /
    `RecursiveInformationalFlag_OnSubcommand_DoesNotCreateSentinel`
    — first-run sentinel / banner behavior.
  - `--info` added to the existing
    `InformationalFlag_SuppressesBannerAndDoesNotCreateSentinel`
    theory for symmetry with `--version`/`--help`.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@radical

This comment has been minimized.

@radical radical removed the NO-MERGE The PR is not ready for merge yet (see discussion for detailed reasons) label May 27, 2026
@radical radical requested review from JamesNK, Copilot and mitchdenny May 27, 2026 01:19
@radical
Copy link
Copy Markdown
Member Author

radical commented May 27, 2026

New version of the changes are in now. Instead of aspire doctor or aspire installs we have aspire --info.

cc @davidfowl @maddymontaquila @joperezr

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 93 out of 95 changed files in this pull request and generated 2 comments.

Files not reviewed (2)
  • src/Aspire.Cli/Resources/DoctorCommandStrings.Designer.cs: Language not supported
  • src/Aspire.Cli/Resources/RootCommandStrings.Designer.cs: Language not supported

Comment thread src/Aspire.Cli/CommonOptionNames.cs
Comment thread src/Aspire.Cli/Commands/RootCommand.cs
radical and others added 8 commits May 26, 2026 22:30
Before this change `aspire --info --format json` and
`aspire --info --self --format json` returned two structurally different
record types:

  - aggregate `installs[*]` was an `InstallListItem` with
    {id, kind, channel, path, hive, status, statusReason, managedBy}
  - `--self` bare-array element was an `InstallationInfo` with
    {path, canonicalPath, version, channel, source, pathStatus, status,
     statusReason}

Only 4 of ~10 fields overlapped. Neither shape has shipped (the
introducing PR microsoft#17105 commit is not in any release tag or release branch),
so there is no backward-compat constraint here.

Unify both surfaces onto `InstallationInfo`:

  - `Path` becomes nullable (orphan-hive rows describe a directory on disk
    with no binary).
  - Add `Id`, `Kind`, `Hive`, `ManagedBy` to `InstallationInfo`. Aggregate
    rows populate them; the `--self` surface leaves them null (and
    `WhenWritingNull` keeps them out of the JSON).
  - Delete the `InstallListItem` record.
  - `InfoOutput.Installs` is now `IReadOnlyList<InstallationInfo>`.
  - Drop the `GetInstallStatus` projection that previously folded
    `PathStatus` into `Status` for OK aggregate rows. `Status` is now
    purely lifecycle (`ok`/`failed`/`notProbed`/`no install found`) and
    `PathStatus` is the PATH-axis on both surfaces. The two axes are
    orthogonal on the wire.
  - Text rendering now emits `Status   <lifecycle>` plus
    `On PATH  <pathStatus>` for the aggregate, mirroring what `--self`
    already did.

Contract + behavior tests:

  - `Info_UnifiedShape_AggregateRowAndSelfRowHaveSameKeys` pins that the
    aggregate and self surfaces use the same record shape.
  - `Info_Json_StatusAndPathStatusAreOrthogonalForOkInstalls` catches
    accidental re-introduction of the status/pathStatus projection.
  - `Info_Json_OrphanHiveRow_HasNullPathAndPopulatedHive` pins the
    orphan-hive structural shape.
  - `Info_Json_SortOrder_ActiveThenShadowedThenNotOnPathThenFailedThenOrphanHive`
    pins the deterministic row order under the new orthogonal axes.
  - `Info_TextRendering_DisplaysStatusAndOnPathSeparately` pins the
    human-visible two-line rendering.

The existing `Info_Self_Json_IsParseableByInstallationInfoParser_PeerProbeContract`
roundtrip test continues to pin the peer-probe wire contract end-to-end.

Spec doc `docs/specs/cli-output-formats.md` is updated to document the
single unified shape with orthogonal status/pathStatus axes and the
orphan-hive special case.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
`aspire run`, `aspire add`, every CLI command on Windows currently walks
both HKCU and HKLM `Uninstall` hives looking for the winget portable
manifest entry, on every single startup, for any install path that
didn't ship the `.aspire-install.json` sidecar (notably dev builds and
legacy pre-sidecar installs). The probe has a positive fast path —
sidecar exists, skip — but no negative-result cache. The old
`{"source":"winget"}` byte-array writer only fires when the registry
confirms a winget install, so non-winget Windows users re-walk the
registry forever. The factory comment in `Program.cs` claimed "the
worst case here is a no-op", which is true about side effects but
misleading about cost.

Rework the probe as a sidecar back-fill that always writes after one
registry read, either the positive `{"source":"winget"}` payload (when
the running binary IS a winget portable install) or a
`{"backfilled":true}` sentinel otherwise. The sentinel has no `source`
field, so `InstallSidecarReader.TryRead` parses it as Unknown and
downstream consumers see the row identically to a missing sidecar. The
existing `File.Exists` fast path at the top of `EnsureSidecar` then
short-circuits every subsequent startup — `HasWingetAspireUninstallEntry`
runs at most once per install location, ever.

Rename for clarity:

- `WingetFirstRunProbe` → `WingetSidecarBackfill`. "FirstRun" implied
  run-once-and-never-again; the reality is "every run until a sidecar
  exists". "Probe" implied read-only detection; the type writes.
- `WingetFirstRunProbe.Run(string)` → `WingetSidecarBackfill.EnsureSidecar(string)`.
  The new name matches the post-call invariant: a sidecar exists at the
  binary directory (real or backfilled sentinel) unless the write itself
  failed.
- `Program.TryRunWingetFirstRunProbe` → `Program.TryEnsureWingetSidecar`.
- Test files `WingetRegistryProbeTests` / `WingetStartupProbeTests` →
  `WingetSidecarBackfillTests` / `WingetSidecarBackfillStartupTests`.

Surprises worth calling out:

- One small behavior change for non-winget Windows installs: discovery
  used to mark these rows as `notProbed` (no sidecar = "we won't execute
  this binary"). Post-change the sidecar exists (sentinel form), so
  discovery promotes the row to `ok` and spawns the peer probe. The
  spawn target is the running binary itself — same binary that just
  wrote the sentinel — so self-vouching is logically consistent; the
  `notProbed` outcome was a side effect of the missing-sidecar gate,
  not a deliberate categorization. End user effect: the running CLI's
  row in `aspire --info` shows a real version instead of "not probed".
- A user who later installs via winget on top of a previously
  probed-negative location pays one row of `source: null` until they
  reinstall or delete the sentinel. `EnsureSidecar_DoesNotPromoteBackfilledSentinel_OnLaterWingetRun`
  pins this trade-off explicitly. In practice winget portable installs
  land in their own directory under `%LOCALAPPDATA%\Microsoft\WinGet\Packages\`,
  so the collision is rare.
- The startup-contract tests (`WingetSidecarBackfillStartupTests`) now
  clean up the sentinel written next to the test runner binary; without
  cleanup, the second test would skip via the existing-sidecar fast path
  and pollute the build output across runs.

Tests:

- `WingetSidecarBackfillTests`:
  - `EnsureSidecar_WritesWingetSidecar_WhenRegistryClaimsWinget` — positive path.
  - `EnsureSidecar_WritesBackfilledSentinel_WhenRegistryDoesNotClaimWinget` — sentinel content + asserts `source` field intentionally absent.
  - `EnsureSidecar_SkipsRegistryWalk_WhenSidecarExists` — counting fake registry reader, asserts `CallCount == 0` (the cost-reduction guarantee).
  - `EnsureSidecar_DoesNotOverwriteExistingSidecar` — idempotency.
  - `EnsureSidecar_DoesNotPromoteBackfilledSentinel_OnLaterWingetRun` — trade-off pinned.
  - `EnsureSidecar_ConcurrentInvocations_ProduceSingleValidSidecar` `[Theory]` — both positive and negative concurrent paths.
- `WingetSidecarBackfillStartupTests`: renamed + cleanup of negative-result sentinel.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ateCommand ctors

`aspire --info` is supposed to be the surface a user reaches for to
find out their binary is broken — including the case where the CLI
binary ships missing or invalid `[AssemblyMetadata("AspireCliChannel", ...)]`.
The `CliExecutionContext` DI factory in `Program.cs` already wraps
`IIdentityChannelReader.ReadChannel()` in a `Lazy<string>` precisely so
the context resolves cleanly when channel metadata is broken, and
`InfoOptionAction` injects the reader directly with its own try/catch.
Both pieces work in isolation. But the rest of the DI graph defeats
them: `NewCommand` and `UpdateCommand` are both pulled in as
constructor parameters of `RootCommand`, and both constructors read
`ExecutionContext.IdentityChannel` eagerly to pick a help-text variant
for `--channel`. On a broken-channel binary the Lazy throws on that
first access, `NewCommand`/`UpdateCommand` construction fails, and
`provider.GetRequiredService<RootCommand>()` throws — taking
`aspire --info` and `aspire --help` down with it.

Wrap the channel reads in tolerant helpers that mirror
`InfoOptionAction.TryReadChannel`: catch any non-cancellation
exception, fall back to the non-staging description, and let the
constructor complete. The fallback is benign — only the help text for
`--channel` loses its staging-channel mention; the option itself still
works, and the command's normal runtime reads of
`ExecutionContext.IdentityChannel` (which legitimately need to throw
on a broken binary) are unchanged.

Why the existing `Info_ChannelReadFailure_*` tests missed this:
`TestExecutionContextHelper.CreateExecutionContext` builds
`CliExecutionContext` with a literal `identityChannel: "local"` string
and leaves `IdentityChannelLazy` null, so
`CliExecutionContext.IdentityChannel` returns the literal string and
never invokes the throwing reader. Those tests exercise only the
direct-injection path inside `InfoOptionAction`, not the lazy property
path that production wiring goes through. Adding a sibling test that
sets `IdentityChannelLazy` to a throwing factory delegate exercises the
real production code path and pins both new tolerant helpers.

Tests:

- `InfoOptionTests.Info_ChannelLazyThrows_RootCommandStillResolves_AndInfoExitsZero`
  builds the service provider with a `CliExecutionContext` whose
  `IdentityChannelLazy.Value` throws on first access, resolves
  `RootCommand` (forcing every subcommand ctor through the throwing
  path), parses `--info --format json`, and asserts exit 0 with the
  channel field omitted from the JSON payload.
- Verified the test correctly fails under the pre-fix code by
  stashing the `NewCommand.cs` / `UpdateCommand.cs` changes and
  re-running — the resolution throws with
  `InvalidOperationException: AspireCliChannel assembly metadata is
  missing.` exactly as production would.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… value-taking options and after `--`

`CommonOptionNames.IsInformationalInvocation` is the pre-parse gate
that decides whether an invocation is purely informational, so the CLI
can opt out of telemetry, suppress the first-run banner, and skip
consuming the one-shot first-run sentinel. It walks `args[]` once,
treating the first non-option token as the subcommand boundary; before
that boundary `--info` counts as informational, after it does not
(mirroring `--info`'s root-only / non-recursive binding on
RootCommand). Two correctness gaps in that scan:

1. **Value tokens for root value-taking options were being mistaken
   for subcommands.** Root options like `--log-level`, `-l`,
   `--format`, `--capture-profile-output`, `--capture-profile-delay`
   take a value in the next token, and value tokens don't start with
   `-`. The scan flipped `sawSubcommand=true` on the value, then
   refused to count the trailing `--info`. Real-world consequence:
   `aspire --log-level Debug --info`, `aspire --format json --info`
   (the exact example called out in the doc comment as "safe because
   the validator rejects it" — which is wrong, the validator only
   fires when --format is supplied WITHOUT --info), `aspire --locale
   fr-FR --info`, etc. all sent telemetry and showed the first-run
   banner — and on a fresh-install machine the banner could
   interleave with `--info --format json` output, breaking
   `PeerInstallProbe.TryParseRichProbeResult`'s JSON parse on the
   consumer side.

2. **The POSIX `--` end-of-options marker was ignored.** Tokens after
   `--` are positional / forwarded args (`aspire run -- --info`
   forwards `--info` to the AppHost), but the scan kept reading and
   would return true on the literal `--info` token. Result:
   `aspire -- --info` was misclassified as informational even though
   `--info` after `--` cannot bind to the root option in
   System.CommandLine.

The fix is the same shape as the original gate, with two added rules:

- Stop scanning at `arg == "--"`. The marker terminates option parsing
  by POSIX convention, so anything after — including `--info`,
  `--help`, `--version` — is a forwarded arg, not a CLI flag.
- Maintain a `s_rootOptionsTakingAValue` set listing the non-bool
  root options. When the scan sees one of these and we're still before
  any subcommand, skip the next token so the option's value isn't
  treated as a subcommand boundary. `=`-form (`--log-level=Debug`) is
  a single token that starts with `-` and needs no special handling.

The set is kept in sync by hand with the non-bool options declared on
RootCommand. A bool option (`--debug`, `--non-interactive`, ...)
doesn't take a separate value token and doesn't need to be listed.

Also corrected the outdated XML doc comment that claimed
`ValidateRequiresInfo` mitigates the `--format json --info` case — it
doesn't, because `--info` IS present in that invocation so the
validator passes.

Tests:

34 new test cases in `InfoOptionTests`, all named `IsInformationalInvocation_*`:

- `_ReturnsTrue_ForInfoAfterRootValueTakingOption` `[Theory]`, 9
  inlines — pins the value-skip across `--log-level`, `-l`, `--format`,
  `--capture-profile-output`, `--capture-profile-delay`, mixed, and
  `=`-form.
- `_ReturnsTrue_ForInfoAfterRootBoolOption` `[Theory]`, 6 inlines —
  sanity coverage for bool root options that don't need the skip, so a
  future accidental promotion into the value-taking set regresses one
  of these.
- `_ReturnsTrue_ForInfoBeforeSubcommandToken` — `["--info", "run"]`.
- `_ReturnsFalse_AfterEndOfOptionsMarker` `[Theory]`, 8 inlines —
  pins the `--` short-circuit across `aspire --`, `aspire run --`,
  `aspire run apphost.csproj --`, `aspire publish --` shapes, with
  every informational flag (`--info`, `--help`, `-h`, `--version`).
- `_ReturnsTrue_WhenInfoIsBeforeEndOfOptionsMarker` — `--info` before
  `--` still binds at root.
- `_ReturnsFalse_WhenInfoFollowsSubcommandEvenAfterRootValueTakingOption`
  `[Theory]`, 3 inlines — combined case from the bug-report shape.
- `_ReturnsFalse_ForEmptyArgs`, `_ReturnsFalse_ForNullArgs`,
  `_ReturnsFalse_ForBareSubcommand`,
  `_ReturnsFalse_ForValueTakingOptionAtEndOfArgsWithNoInfo` (`aspire
  --log-level`, malformed parse handled cleanly).
- `_ReturnsFalse_WhenValueLooksLikeInfo` — pathological `aspire
  --log-level --info` where `--info` is the VALUE of `--log-level`
  rather than a real flag.
- `_ReturnsTrue_ForVersionAtAnyPosition` — recursive-flag sanity.

Verified the new tests catch the bug: stashed
`src/Aspire.Cli/CommonOptionNames.cs` and re-ran the suite — 13 of
the 44 IsInformationalInvocation tests failed under the old code (7
for the value-taking-option case, 5 for the `--` case, 1 for the
pathological-value case). With the fix in place all 1301 Commands
tests pass.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
WingetSidecarBackfill.EnsureSidecar exists to amortize a HKCU + HKLM
Uninstall hive walk: on a winget portable install it stamps
{"source":"winget"}; on any other Windows install it writes a
{"backfilled":true} sentinel so the next startup hits the File.Exists
fast path and never re-walks the registry.

It was being invoked unconditionally from the CliExecutionContext DI
factory (Program.TryEnsureWingetSidecar). On Linux/macOS the
NullWindowsRegistryReader stub always returns false, so the helper
dropped the negative-result sentinel into the directory containing
Environment.ProcessPath — e.g. the dotnet global-tools store at
~/.dotnet/tools/.store/aspire.cli/<ver>/.../tools/<tfm>/<rid>/ — with
no amortization benefit, because there's no registry there to walk in
the first place. The write was harmless in isolation, but a stray
sidecar in the dotnet-tool layout silently re-classifies the install's
Source to a now-permanent sentinel-backed Unknown.

Gate EnsureSidecar with OperatingSystem.IsWindows() at the top, so the
method's contract (the docstring already says "on any other Windows
install ...") matches its observable behavior. The existing tests that
asserted the write happened unconditionally were also wrong on this
axis; gate them with Assert.SkipUnless(OperatingSystem.IsWindows())
mirroring WindowsRegistryReaderTests, and pin the new no-op behavior
with EnsureSidecar_IsNoOp_OnNonWindows.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
IsDisplayableInstall is the filter that decides whether a sidecar-less
discovery row gets surfaced in `aspire --info`. It used a case-sensitive
pattern match — `fileName is "aspire" or "aspire.exe"`.

PathLookupHelper deliberately preserves whatever spelling NTFS recorded
for the binary (that's the point of the recent on-disk-casing fix);
Windows command resolution is case-insensitive, so a real install with
on-disk filename `Aspire.exe` or `ASPIRE.EXE` and no sidecar would be
silently filtered out as "not an Aspire CLI binary" and never appear in
`aspire --info`.

Switch to string.Equals with OrdinalIgnoreCase on Windows and Ordinal
elsewhere, so POSIX paths stay case-sensitive (a deliberately-named
`Aspire` script on Linux is still excluded). Cover the three plausible
on-disk spellings with a Windows-only Theory in InfoOptionTests.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The unique-id minter in `InstallationInfoOutput.GetUniqueIdCore`
counted collisions on the base id only and never reserved the suffixed
name it returned. Two rows could therefore share an `id` when one row's
natural base id happened to match another row's disambiguation suffix:

    1st  "stable"                       → "stable"
    2nd  "stable"                       → "stable-2"  (suffix NOT recorded)
    orphan hive literally "stable-2"    → "stable-2"  (silent collision)

The orphan-hive base id is the directory name under `~/.aspire/hives/`,
which is not constrained to the `IdentityChannelReader` allowlist on
the enumeration path, so user-created (or installer-quirk) hive names
can naturally land on a `<channel>-N` shape. JSON consumers that key on
`id` (the documented purpose of the field) silently merge or overwrite
the colliding rows.

Loop the suffix counter until the candidate is unused, then record both
the bumped per-base counter and the candidate itself so future natural
ids can't recreate it.

Added a regression test exercising two `winget` installs sharing
channel `stable` plus an orphan-hive directory named `stable-2`; the
test fails on the pre-fix code (orphan ends up sharing `stable-2`) and
passes with the fix (orphan gets `stable-2-2`).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ages

`ValidateRequiresInfo` formats the offending option using `option.Name`.
System.CommandLine 2.0.8 returns the full token including the leading
`--` (e.g. `--self`), so the rendered message reads:

    Option `--self` is only valid when `--info` is also specified.

A future SCL upgrade that changed `Name` semantics to strip the dashes
would silently degrade the message to the ungrammatical "Option `self`
is only valid when..." with no test failure today, because the existing
parse-error tests only check that `result.Errors` is non-empty.

Strengthen `Info_SelfWithoutInfo_FailsParse` and
`Info_FormatWithoutInfo_FailsParse` to assert the error text contains
``--self`` / ``--format`` (the token form, backticks included). This is
the same shape other consumers in the repo already rely on
(`BaseCommand.cs` checks `option.Name == "--format"`,
`GroupedHelpWriter.cs` calls `option.Name.StartsWith("--", ...)`), so
an SCL change in this area would break multiple call sites — the new
assertion just surfaces it on the user-visible path.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 99 out of 101 changed files in this pull request and generated 3 comments.

Files not reviewed (2)
  • src/Aspire.Cli/Resources/DoctorCommandStrings.Designer.cs: Language not supported
  • src/Aspire.Cli/Resources/RootCommandStrings.Designer.cs: Language not supported

Comment thread tests/Aspire.Cli.Tests/Acquisition/WingetSidecarBackfillStartupTests.cs Outdated
Comment thread eng/scripts/verify-cli-tool-nupkg.ps1
… lifecycle

`AzureMonitor_Enabled_WhenInfoFlagOnSubcommand` was constructing
`TelemetryManager` directly:

    var configuration = new ConfigurationBuilder().Build();
    var manager = new TelemetryManager(configuration, args);
    Assert.True(manager.HasAzureMonitor);

Because the args (`"run", "--info"` etc.) do not trip the informational
opt-out, this builds a real Azure Monitor `TracerProvider` and registers
a process-wide `ActivityListener` on `Aspire.Cli.Reported`. The manager
is never disposed, and even with `using` would not be — `TelemetryManager.Dispose`
only calls `Shutdown(0)` on the providers (see comment on
`TelemetryManager.cs:208`), which does not unregister the listener.

Across parallel test classes the leaked listener intercepts reported
activities from unrelated tests (`AddCommand`, `NewCommand`,
`RunCommand`, ...), and concurrent invocations race on
`ActivityCreationOptions.SamplingTags.Add("microsoft.sample_rate", ...)`
(Azure Monitor's `RateLimitedSampler` uses `Add`, not `TryAdd`),
throwing the same exception the test process tries to surface as the
failing test:

    ❌ An unexpected error occurred: "The collection already contains
       item with same key 'microsoft.sample_rate'"

This is the same race tracked by microsoft#17450. The fix in
e126a1a (process-wide telemetry opt-out + opted-in tests routing
through `BuildHostAsync`) keeps existing Azure-Monitor-enabled tests on
`main` clean — they all go through DI disposal. The new tests on this
branch bypassed that lifecycle and resurrected the race.

Route the new test through `BuildHostAsync` like the surrounding
`AzureMonitor_Enabled_ByDefault` test. `BuildHostAsync` now accepts an
optional `args` parameter that is threaded into
`Program.BuildApplicationAsync`, so the position-aware
`IsInformationalInvocation` gate still gets exercised with the
non-informational subcommand-plus-`--info` shapes. `WithTelemetryOptIn`
undoes the process-wide opt-out (`TestTelemetryDefaults`) so Azure
Monitor is actually built — same lifecycle pattern as
`AzureMonitor_Enabled_ByDefault`.

The two new opted-OUT tests (`AzureMonitor_Disabled_WhenInfoFlagProvided`,
`AzureMonitor_Disabled_WhenRecursiveInformationalFlagOnSubcommand`)
stay on direct construction: they short-circuit before building a
TracerProvider, so there is no listener to leak.

The underlying bug in `TelemetryManager.Dispose` is pre-existing and
out of scope for this PR — filed separately.

Verified locally: three consecutive full Aspire.Cli.Tests runs all pass
3864/3864 with zero `sample_rate` failures. Before this change the same
runs surfaced 20-60 `sample_rate`-flavored failures each.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@github-actions
Copy link
Copy Markdown
Contributor

Re-running the failed jobs in the CI workflow for this pull request because 1 job was identified as retry-safe transient failures in the CI run attempt.
GitHub was asked to rerun all failed jobs for that attempt, and the rerun is being tracked in the rerun attempt.
The job links below point to the failed attempt jobs that matched the retry-safe transient failure rules.

Three findings from the latest Copilot review pass on PR microsoft#17461:

1. `WingetSidecarBackfillStartupTests.cs` — `CapturingWindowsRegistryReader`
   XML doc claimed returning `false` from the reader meant "the probe does
   not write a sidecar", which is the opposite of what the code does:
   returning `false` is exactly what drives `WingetSidecarBackfill` down
   the negative-result arm that writes the `{"backfilled":true}` sentinel.
   Rewrite the doc to describe the actual contract.

2. `WingetSidecarBackfillStartupTests.cs` — the test's
   `AssertBackfillReachableFromTestRunner` skip path silently dropped
   coverage on Windows.

   Root cause: `Program.TryEnsureWingetSidecar` derives `binaryDir` from
   `Environment.ProcessPath`, and the `CliExecutionContext` DI factory
   invokes it on every resolution. Tests that build a real host via
   `Program.BuildApplicationAsync` and resolve `CliExecutionContext`
   (e.g. `CliBootstrapTests`) trigger the back-fill on Windows, writing
   `.aspire-install.json` into the testhost binary directory. After that
   write, `WingetSidecarBackfillStartupTests`'s `File.Exists` precondition
   fires and the assertion path is skipped — silently, with no failure.
   The sentinel also persists across `dotnet test --no-build` invocations
   because build-output `bin/...` is not purged between runs.

   Fix: thread the running binary's process path through as an explicit
   parameter. Add an `internal static TryEnsureWingetSidecar(sp, processPath)`
   overload on `Program` and an `internal EnsureSidecar(binaryDir, processPath)`
   overload on `WingetSidecarBackfill`; the existing public/no-arg overloads
   delegate with `Environment.ProcessPath`. `EnsureSidecar` no longer reads
   `Environment.ProcessPath` mid-method, so `binaryDir` and the registry
   probe argument are guaranteed consistent for any caller. Production
   call site at `Program.cs:377` is unchanged.

   Rewrite `WingetSidecarBackfillStartupTests` to use a per-test temp
   directory via `TestTempDirectory` and a synthesized fake binary path,
   drop `AssertBackfillReachableFromTestRunner` and the silent-skip path
   entirely, and assert the registry probe sees the same fake path. The
   "DI factory binds binaryDir to `Environment.ProcessPath`" claim becomes
   a one-line delegating expression at the no-arg overload, below the
   threshold of needing dedicated coverage.

   Leave a breadcrumb in `CliBootstrapTests.BuildHostAsync` so the next
   person debugging a stray `.aspire-install.json` in the testhost binary
   directory on Windows finds the cause.

3. `eng/scripts/verify-cli-tool-nupkg.ps1` — wording fix: "so the CLI can
   source `aspire update --self`" implied the CLI is sourcing a script.
   Should be "route" — the sidecar's `source` field is what lets the CLI
   route the self-update command to the dotnet-tool delegate.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@radical
Copy link
Copy Markdown
Member Author

radical commented May 27, 2026

PR Testing Report — #17461

PR Information

  • PR Number: #17461
  • Title: Add aspire --info to enumerate the running CLI's install + discovered installs + hives
  • Head Commit: dafaf4f5f8dada4614a24ca220ca88e9ee1dcd73 (short dafaf4f5)
  • Tested At: 2026-05-27 03:54 -04:00
  • Host: macOS arm64 (local-mode install via get-aspire-cli-pr.sh --install-path ... --skip-path --skip-extension)

CLI Version Verification

  • Expected (PR head, short SHA): dafaf4f5
  • Installed CLI reports: 13.5.0-pr.17461.gdafaf4f5
  • Status: ✅ Verified — installed binary matches PR head

Scope Tested

This PR's user-facing surface is the new top-level aspire --info option (text + JSON, --self + aggregate), plus parse-time validation that gates --self / --format on --info, plus the IsInformationalInvocation -- boundary. The Windows-only WingetSidecarBackfill test seam from commit dafaf4f5f8 is not covered here (no Windows host); the tests/Cli/Cli (windows-latest) CI leg covers it.

Test Scenarios Executed

Scenario 1: aspire --info (text)

Objective: New top-level --info prints the aggregate text shape: Version, Channel, then a per-install row block.
Coverage Type: Happy path
Status: ✅ Passed

Command: aspire --info
Exit code: 0
Observations:

  • Output begins with Version 13.5.0-pr.17461.gdafaf4f5 and Channel pr-17461
  • Discovered two installs:
    • The PR install (pr-17461, Status ok, on the temp dogfood path, On PATH notOnPath)
    • An existing /Users/ankj/.aspire/bin/aspire install (Status notProbed, with Reason No install-source sidecar found at .../bin/.aspire-install.json; peer was not probed.)
  • The Reason line on the notProbed row is the structured statusReason surface added in the mitchdenny review thread (commit aa0d308e9e).

Scenario 2: aspire --info --format json (aggregate JSON)

Objective: JSON aggregate matches the { version, channel, installs[] } contract in docs/specs/cli-output-formats.md.
Coverage Type: Happy path
Status: ✅ Passed

Command: aspire --info --format json
Exit code: 0
Validated shape:

{
  "version_type": "string",
  "channel_type": "string",
  "installs_is_array": true,
  "install_count": 2,
  "install_keys": ["canonicalPath","channel","hive","id","kind","path","pathStatus","source","status","version"],
  "first_status": "ok"
}

Observations:

  • Root is an object with version, channel, and installs[] — matches spec.
  • Install items carry the enum-shaped status field (validates mitchdenny's structured-status request).

Scenario 3: aspire --info --self (text, running CLI only)

Objective: --self narrows the aggregate to just the running CLI.
Coverage Type: Happy path
Status: ✅ Passed

Command: aspire --info --self
Exit code: 0
Output:

self
  Status   ok
  Channel  pr-17461
  Source   pr
  Version  13.5.0-pr.17461.gdafaf4f5
  Path     /var/folders/.../dogfood/pr-17461/bin/aspire
  On PATH  notOnPath

Observations: No Version: / Channel: aggregate header — just the single install block, as expected.


Scenario 4: aspire --info --self --format json (peer-probe contract)

Objective: Output is a bare InstallationInfo array, not the wrapped { version, channel, installs[] } aggregate. This is the cross-version peer-probe contract documented in docs/specs/cli-output-formats.md.
Coverage Type: Happy path (cross-version stability contract)
Status: ✅ Passed

Command: aspire --info --self --format json
Exit code: 0
Validated shape:

  • Root is an array (not an object)
  • Array length: 1
  • Element keys: canonicalPath, channel, path, pathStatus, source, status, version
  • status: "ok", source: "pr", version matches gdafaf4f5

Scenario 5: aspire --self (without --info) — parse-time validation

Objective: --self outside of --info context is rejected with a clear parse error that mentions both option tokens.
Coverage Type: Unhappy path
Status: ✅ Passed

Command: aspire --self
Exit code: 1
Expected: non-zero, error message citing `--self` and `--info` (with backticks intact)
Got: Option `--self` is only valid when `--info` is also specified.
Observations: Validates the rendered-message claim from review thread 3307870278Option.Name returns the full --self token (including dashes), so the backticked text in the resource string renders correctly. The thread had been closed without a code change, but commit c21a614ad7 added test pins; this scenario is a runtime end-to-end check of the same invariant.


Scenario 6: aspire --format json (without --info) — parse-time validation

Objective: Same gating, but for --format.
Coverage Type: Unhappy path
Status: ✅ Passed

Command: aspire --format json
Exit code: 1
Got: Option `--format` is only valid when `--info` is also specified.


Scenario 7: aspire run -- --info-- boundary

Objective: --info appearing after -- must not be treated as informational; the run subcommand must dispatch normally and forward the trailing argv as-is.
Coverage Type: Unhappy path (boundary)
Status: ✅ Passed

Command: aspire run -- --info
Exit code: non-zero (terminated on non-interactive selection prompt — expected because the worktree has many AppHosts to choose from)
Behavior observed:

  • run was dispatched: output shows Finding AppHosts... followed by ~140 enumerated AppHost paths from the worktree's playground/ and tests/ trees, ending with ❌ An unexpected error occurred: Cannot show selection prompt since the current terminal isn't interactive.
  • --info did not leak past the -- boundary: the aggregate Version 13.5.0-pr.17461 ... header is absent from the output.
    Observations: Validates the CommonOptionNames.IsInformationalInvocation fix from commit abd887c2a1. Pre-fix, this invocation would have short-circuited to print the install table and suppressed telemetry/banner.

Summary

# Scenario Status
1 aspire --info (text) ✅ Passed
2 aspire --info --format json (aggregate) ✅ Passed
3 aspire --info --self (text) ✅ Passed
4 aspire --info --self --format json (peer contract) ✅ Passed
5 aspire --self without --info (parse error) ✅ Passed
6 aspire --format json without --info (parse error) ✅ Passed
7 aspire run -- --info (-- boundary) ✅ Passed

Overall Result

✅ PR VERIFIED on macOS arm64

All seven scenarios behaved as specified by the PR description and docs/specs/cli-output-formats.md. The peer-probe contract (scenario 4) returns the documented bare-array shape, the parse-time validation (5+6) renders option tokens with their leading -- intact, and the -- end-of-options boundary (7) correctly suppresses the informational scan so run dispatches as a real subcommand.

Not Covered Here

  • WingetSidecarBackfill seam (Windows-only). The most recent commit (dafaf4f5f8) addresses the silent-skip in WingetSidecarBackfillStartupTests by adding an internal void EnsureSidecar(string binaryDir, string? processPath) overload on WingetSidecarBackfill and a matching internal static TryEnsureWingetSidecar(IServiceProvider sp, string? processPath) overload on Program. The non-Windows tests in Aspire.Cli.Tests pass locally; the assertion path is Windows-only and is validated by the Tests / Cli / Cli (windows-latest) CI leg.

Recommendations

  • No issues found. Windows CI on dafaf4f5f8 is the remaining signal needed before this PR is ready to ship.

@IEvangelist
Copy link
Copy Markdown
Member

PR Testing Report

PR Information

  • PR Number: Add aspire --info to enumerate the running CLI's install + discovered installs + hives #17461
  • Title: Add aspire --info to enumerate the running CLI's install + discovered installs + hives
  • Head Commit: dafaf4f
  • Tested At: 2026-05-27T13:25:35.6727545-05:00
  • Execution Target: Local Windows temp directory
  • Temp Directory: $(@{prNumber=17461; expectedCommit=dafaf4f5f8dada4614a24ca220ca88e9ee1dcd73; testDir=C:\Users\dapine\AppData\Local\Temp\aspire-pr-test-1b1c57e994d1478ab09520a0f8786a0c; evidenceDir=C:\Users\dapine\AppData\Local\Temp\aspire-pr-test-1b1c57e994d1478ab09520a0f8786a0c\evidence; installOutput=C:\Users\dapine\AppData\Local\Temp\aspire-pr-test-1b1c57e994d1478ab09520a0f8786a0c\evidence\install-output.txt; installExit=0; cliPath=C:\Users\dapine\AppData\Local\Temp\aspire-pr-test-1b1c57e994d1478ab09520a0f8786a0c\dogfood\pr-17461\bin\aspire.exe; hivePath=C:\Users\dapine\AppData\Local\Temp\aspire-pr-test-1b1c57e994d1478ab09520a0f8786a0c\hives\pr-17461\packages; reportPath=C:\Users\dapine\AppData\Local\Temp\aspire-pr-test-1b1c57e994d1478ab09520a0f8786a0c\pr-17461-testing-report.md}.testDir)

CLI Version Verification

  • Expected Commit: dafaf4f
  • Installed Version: 13.5.0-pr.17461.gdafaf4f5
  • Status: Verified

Changes Analyzed

Files Changed

  • docs/dogfooding-pull-requests.md
  • docs/specs/cli-output-formats.md
  • docs/specs/install-sources.md
  • eng/clipack/Common.projitems
  • eng/scripts/get-aspire-cli-pr.ps1
  • eng/scripts/get-aspire-cli-pr.sh
  • eng/scripts/get-aspire-cli.ps1
  • eng/scripts/get-aspire-cli.sh
  • eng/scripts/verify-cli-archive.ps1
  • localhive.ps1
  • localhive.sh
  • src/Aspire.Cli/Acquisition/HiveEnumerator.cs
  • src/Aspire.Cli/Acquisition/IInstallSidecarReader.cs
  • src/Aspire.Cli/Acquisition/IInstallationDiscovery.cs
  • src/Aspire.Cli/Acquisition/IPeerInstallProbe.cs
  • src/Aspire.Cli/Acquisition/IWindowsRegistryReader.cs
  • src/Aspire.Cli/Acquisition/InstallSidecarReader.cs
  • src/Aspire.Cli/Acquisition/InstallSource.cs
  • src/Aspire.Cli/Acquisition/InstallationCandidateSources.cs
  • src/Aspire.Cli/Acquisition/InstallationDiscovery.cs
  • src/Aspire.Cli/Acquisition/InstallationInfo.cs
  • src/Aspire.Cli/Acquisition/PeerInstallProbe.cs
  • src/Aspire.Cli/Acquisition/WingetFirstRunProbe.cs
  • src/Aspire.Cli/Acquisition/WingetSidecarBackfill.cs
  • src/Aspire.Cli/Bundles/BundleService.cs
  • src/Aspire.Cli/CliExecutionContext.cs
  • src/Aspire.Cli/Commands/DoctorCommand.cs
  • src/Aspire.Cli/Commands/InfoOptionAction.cs
  • src/Aspire.Cli/Commands/InstallationInfoOutput.cs
  • src/Aspire.Cli/Commands/NewCommand.cs
  • src/Aspire.Cli/Commands/RootCommand.cs
  • src/Aspire.Cli/Commands/SetupCommand.cs
  • src/Aspire.Cli/Commands/UpdateCommand.cs
  • src/Aspire.Cli/CommonOptionNames.cs
  • src/Aspire.Cli/JsonSourceGenerationContext.cs
  • src/Aspire.Cli/Program.cs
  • src/Aspire.Cli/Resources/DoctorCommandStrings.Designer.cs
  • src/Aspire.Cli/Resources/DoctorCommandStrings.resx
  • src/Aspire.Cli/Resources/RootCommandStrings.Designer.cs
  • src/Aspire.Cli/Resources/RootCommandStrings.resx
  • src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.cs.xlf
  • src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.de.xlf
  • src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.es.xlf
  • src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.fr.xlf
  • src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.it.xlf
  • src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.ja.xlf
  • src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.ko.xlf
  • src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.pl.xlf
  • src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.pt-BR.xlf
  • src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.ru.xlf
  • src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.tr.xlf
  • src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.zh-Hans.xlf
  • src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.zh-Hant.xlf
  • src/Aspire.Cli/Resources/xlf/RootCommandStrings.cs.xlf
  • src/Aspire.Cli/Resources/xlf/RootCommandStrings.de.xlf
  • src/Aspire.Cli/Resources/xlf/RootCommandStrings.es.xlf
  • src/Aspire.Cli/Resources/xlf/RootCommandStrings.fr.xlf
  • src/Aspire.Cli/Resources/xlf/RootCommandStrings.it.xlf
  • src/Aspire.Cli/Resources/xlf/RootCommandStrings.ja.xlf
  • src/Aspire.Cli/Resources/xlf/RootCommandStrings.ko.xlf
  • src/Aspire.Cli/Resources/xlf/RootCommandStrings.pl.xlf
  • src/Aspire.Cli/Resources/xlf/RootCommandStrings.pt-BR.xlf
  • src/Aspire.Cli/Resources/xlf/RootCommandStrings.ru.xlf
  • src/Aspire.Cli/Resources/xlf/RootCommandStrings.tr.xlf
  • src/Aspire.Cli/Resources/xlf/RootCommandStrings.zh-Hans.xlf
  • src/Aspire.Cli/Resources/xlf/RootCommandStrings.zh-Hant.xlf
  • src/Aspire.Cli/Telemetry/TelemetryManager.cs
  • src/Aspire.Cli/Utils/CliPathHelper.cs
  • src/Aspire.Cli/Utils/EnvironmentChecker/EnvironmentCheckResult.cs
  • src/Shared/PathLookupHelper.cs
  • tests/Aspire.Acquisition.Tests/Scripts/Common/FakeArchiveHelper.cs
  • tests/Aspire.Acquisition.Tests/Scripts/LocalHiveScriptFunctionTests.cs
  • tests/Aspire.Acquisition.Tests/Scripts/PRScriptInstallE2ETests.cs
  • tests/Aspire.Acquisition.Tests/Scripts/PRScriptInstallerModeTests.cs
  • tests/Aspire.Acquisition.Tests/Scripts/PRScriptPowerShellTests.cs
  • tests/Aspire.Acquisition.Tests/Scripts/PRScriptShellTests.cs
  • tests/Aspire.Acquisition.Tests/Scripts/PRScriptToolModeTests.cs
  • tests/Aspire.Acquisition.Tests/Scripts/ReleaseScriptPowerShellTests.cs
  • tests/Aspire.Acquisition.Tests/Scripts/ReleaseScriptShellTests.cs
  • tests/Aspire.Acquisition.Tests/Scripts/VerifyCliArchivePowerShellTests.cs
  • tests/Aspire.Cli.Tests/Acquisition/HiveEnumeratorTests.cs
  • tests/Aspire.Cli.Tests/Acquisition/InstallSidecarReaderTests.cs
  • tests/Aspire.Cli.Tests/Acquisition/InstallationDiscoveryDiscoverAllTests.cs
  • tests/Aspire.Cli.Tests/Acquisition/PeerInstallProbeTests.cs
  • tests/Aspire.Cli.Tests/Acquisition/WindowsRegistryReaderTests.cs
  • tests/Aspire.Cli.Tests/Acquisition/WingetRegistryProbeTests.cs
  • tests/Aspire.Cli.Tests/Acquisition/WingetSidecarBackfillStartupTests.cs
  • tests/Aspire.Cli.Tests/Acquisition/WingetSidecarBackfillTests.cs
  • tests/Aspire.Cli.Tests/BundleServiceComputeDefaultExtractDirTests.cs
  • tests/Aspire.Cli.Tests/Bundles/BundleServiceCrossSourceExtractionTests.cs
  • tests/Aspire.Cli.Tests/CliBootstrapTests.cs
  • tests/Aspire.Cli.Tests/Commands/DoctorCommandTests.cs
  • tests/Aspire.Cli.Tests/Commands/InfoOptionTests.cs
  • tests/Aspire.Cli.Tests/Commands/RootCommandTests.cs
  • tests/Aspire.Cli.Tests/Commands/SetupCommandTests.cs
  • tests/Aspire.Cli.Tests/Configuration/DotNetBasedAppHostServerChannelResolutionTests.cs
  • tests/Aspire.Cli.Tests/Packaging/TemporaryNuGetConfigTests.cs
  • tests/Aspire.Cli.Tests/Telemetry/TelemetryConfigurationTests.cs
  • tests/Aspire.Cli.Tests/Utils/CliPathHelperTests.cs
  • tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs

Change Categories

  • CLI changes detected
  • Hosting integration changes
  • Dashboard changes
  • Client/component changes
  • Template changes
  • Acquisition/install script changes
  • Test changes
  • Documentation changes

Test Scenarios Executed

Scenario 1: PR CLI install + version verification

Objective: Install the dogfood CLI into an isolated temp directory and verify the installed binary matches the PR head commit.
Coverage Type: Happy path
Status: Passed

Steps:

  1. Installed the PR CLI with the PowerShell dogfood script using -InstallPath, -SkipExtension, and -SkipPath.
  2. Ran the installed binary with --version.
  3. Checked the PR head short SHA and PR install sidecar metadata.

Evidence:

  • $e\install-summary.txt
  • $e\version.txt

Observations:

  • CLI path: $(@{prNumber=17461; expectedCommit=dafaf4f5f8dada4614a24ca220ca88e9ee1dcd73; testDir=C:\Users\dapine\AppData\Local\Temp\aspire-pr-test-1b1c57e994d1478ab09520a0f8786a0c; evidenceDir=C:\Users\dapine\AppData\Local\Temp\aspire-pr-test-1b1c57e994d1478ab09520a0f8786a0c\evidence; installOutput=C:\Users\dapine\AppData\Local\Temp\aspire-pr-test-1b1c57e994d1478ab09520a0f8786a0c\evidence\install-output.txt; installExit=0; cliPath=C:\Users\dapine\AppData\Local\Temp\aspire-pr-test-1b1c57e994d1478ab09520a0f8786a0c\dogfood\pr-17461\bin\aspire.exe; hivePath=C:\Users\dapine\AppData\Local\Temp\aspire-pr-test-1b1c57e994d1478ab09520a0f8786a0c\hives\pr-17461\packages; reportPath=C:\Users\dapine\AppData\Local\Temp\aspire-pr-test-1b1c57e994d1478ab09520a0f8786a0c\pr-17461-testing-report.md}.cliPath)
  • Hive path: $(@{prNumber=17461; expectedCommit=dafaf4f5f8dada4614a24ca220ca88e9ee1dcd73; testDir=C:\Users\dapine\AppData\Local\Temp\aspire-pr-test-1b1c57e994d1478ab09520a0f8786a0c; evidenceDir=C:\Users\dapine\AppData\Local\Temp\aspire-pr-test-1b1c57e994d1478ab09520a0f8786a0c\evidence; installOutput=C:\Users\dapine\AppData\Local\Temp\aspire-pr-test-1b1c57e994d1478ab09520a0f8786a0c\evidence\install-output.txt; installExit=0; cliPath=C:\Users\dapine\AppData\Local\Temp\aspire-pr-test-1b1c57e994d1478ab09520a0f8786a0c\dogfood\pr-17461\bin\aspire.exe; hivePath=C:\Users\dapine\AppData\Local\Temp\aspire-pr-test-1b1c57e994d1478ab09520a0f8786a0c\hives\pr-17461\packages; reportPath=C:\Users\dapine\AppData\Local\Temp\aspire-pr-test-1b1c57e994d1478ab09520a0f8786a0c\pr-17461-testing-report.md}.hivePath)
  • Installed version: $cliVersion
  • Sidecar content: { "source": "pr" }

Scenario 2: Human �spire --info output

Objective: Verify the human output reports version, channel, install/source status, path, and hive information.
Coverage Type: Happy path
Status: Failed

Steps:

  1. Ran the installed PR CLI with --info.
  2. Checked successful exit and key diagnostic fields.
  3. Compared the displayed Windows path row with the expected active install path.

Evidence:

  • $e\info-text.txt

Observations:

  • Command exited 0 and reported the PR install row with pr-17461, source/kind pr, version, path, hive, and
    otOnPath status.
  • The active sidecar-less install row heading rendered as $actualMalformedPath instead of $expectedUserAspirePath.
  • JSON output for the same row preserved the correct path, so this appears isolated to human/Markdown rendering of a Windows path used as the row ID.

Scenario 3: JSON �spire --info --format json contract

Objective: Verify the machine-readable aggregate output shape and renamed source/kind fields.
Coverage Type: Happy path
Status: Passed

Steps:

  1. Ran �spire --info --format json.
  2. Parsed the JSON payload.
  3. Checked top-level �ersion, channel, and installs[].
  4. Checked the PR row includes source: pr, kind: pr, hive, pathStatus, and no legacy
    oute field.

Evidence:

  • $e\info-json.json

Observations:

  • Top-level channel was pr-17461.
  • Installed version was $cliVersion.
  • The PR install row was present and status was ok.
  • The legacy
    oute field was absent.

Scenario 4: Peer-probe �spire --info --self --format json contract

Objective: Verify the hidden self-probe emits a single InstallationInfo row for peer discovery.
Coverage Type: Happy path
Status: Passed

Steps:

  1. Ran �spire --info --self --format json.
  2. Parsed the JSON payload.
  3. Checked the payload is a single-element array describing the running PR binary.

Evidence:

  • $e\info-self-json.json

Observations:

  • The row included path, canonicalPath, �ersion, channel: pr-17461, source: pr, pathStatus: notOnPath, and status: ok.

Scenario 5: Root option validation / scoping

Objective: Verify hidden root modifiers require --info and subcommand --format remains command-specific.
Coverage Type: Unhappy path / Regression
Status: Passed

Steps:

  1. Ran �spire --self without --info.
  2. Ran �spire --format json without --info.
  3. Ran �spire doctor --format json --non-interactive --nologo.
  4. Checked doctor output did not include install-discovery rows.

Evidence:

  • $e\self-without-info.txt
  • $e\format-without-info.txt
  • $e\doctor-json.json

Observations:

  • �spire --self exited 1 with Option --self is only valid when --info is also specified.
  • �spire --format json exited 1 with Option --format is only valid when --info is also specified.
  • �spire doctor --format json exited 0 and returned doctor checks/summary without an installations field.

Expected Unhappy-Path Outcome: Root-only --self and --format modifiers fail clearly without --info; doctor keeps its own --format behavior.


Scenario 6: Orphan hive discovery boundary

Objective: Verify an isolated hive with no matching install is reported as a safe diagnostic row.
Coverage Type: Boundary / Unhappy path
Status: Passed

Steps:

  1. Created $(@{prNumber=17461; expectedCommit=dafaf4f5f8dada4614a24ca220ca88e9ee1dcd73; testDir=C:\Users\dapine\AppData\Local\Temp\aspire-pr-test-1b1c57e994d1478ab09520a0f8786a0c; evidenceDir=C:\Users\dapine\AppData\Local\Temp\aspire-pr-test-1b1c57e994d1478ab09520a0f8786a0c\evidence; installOutput=C:\Users\dapine\AppData\Local\Temp\aspire-pr-test-1b1c57e994d1478ab09520a0f8786a0c\evidence\install-output.txt; installExit=0; cliPath=C:\Users\dapine\AppData\Local\Temp\aspire-pr-test-1b1c57e994d1478ab09520a0f8786a0c\dogfood\pr-17461\bin\aspire.exe; hivePath=C:\Users\dapine\AppData\Local\Temp\aspire-pr-test-1b1c57e994d1478ab09520a0f8786a0c\hives\pr-17461\packages; reportPath=C:\Users\dapine\AppData\Local\Temp\aspire-pr-test-1b1c57e994d1478ab09520a0f8786a0c\pr-17461-testing-report.md}.testDir)\hives\pr-99999\packages without adding a matching binary.
  2. Ran �spire --info --format json.
  3. Checked the pr-99999 row is reported as orphan-hive /
    o install found.

Evidence:

  • $e\info-json-orphan-hive.json

Observations:

  • The orphan row had kind: orphan-hive, channel: pr-99999, pathStatus: notOnPath, and status: no install found.

Expected Unhappy-Path Outcome: A hive directory without a matching binary is surfaced as an orphan-hive row and exits successfully.


Scenario 7: Basic CLI regression smoke

Objective: Verify the installed PR hive/templates remain usable for project creation.
Coverage Type: Regression smoke
Status: Passed

Steps:

  1. Created a fresh temp output directory path.
  2. Ran �spire new aspire-empty with explicit --source and --version pointing at the PR hive.
  3. Checked the generated file-based AppHost project exists.

Evidence:

  • $e\new-aspire-empty-output.txt
  • $e\new-aspire-empty-check.txt

Observations:

  • �spire new aspire-empty exited 0.
  • Generated �pphost.cs, �pphost.run.json, �spire.config.json,
    uget.config, and .vscode\extensions.json.

Summary

Scenario Status Notes
PR CLI install + version verification Passed Installed binary matched PR head commit.
Human �spire --info output Failed Windows path row heading lost the backslash before .aspire.
JSON �spire --info --format json contract Passed JSON shape and source/kind fields were correct.
Peer-probe �spire --info --self --format json contract Passed Returned a single self row.
Root option validation / scoping Passed Invalid root modifiers failed; doctor format remained scoped.
Orphan hive discovery boundary Passed Orphan hive surfaced safely.
Basic CLI regression smoke Passed �spire-empty project creation succeeded.

Overall Result

ISSUES FOUND

Recommendations

  • Fix human �spire --info row rendering for Windows paths used as row IDs. The JSON output is correct, but the text heading appears to pass a raw path through Markdown rendering, causing .aspire to render as .aspire.

Artifacts

  • Evidence directory: $e
  • Report path: $reportPath
  • Screenshots: None captured; this PR only changed CLI/acquisition behavior.

Copy link
Copy Markdown
Member

@IEvangelist IEvangelist left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review: found 1 CLI output correctness issue.

Comment thread src/Aspire.Cli/Commands/InfoOptionAction.cs Outdated
radical and others added 2 commits May 27, 2026 15:03
`aspire --info` renders one heading line per discovered install via
`DisplayMarkdown($"**{row.Id}**  {row.Kind}")`. For sidecar-less installs
`row.Id` falls back to `install.Path` (see
`InstallationInfoOutput.GetInstallId`), which on Windows looks like
`C:\Users\<user>\.aspire\bin\aspire.exe`. CommonMark treats any `\`
followed by ASCII punctuation as a backslash-escape and drops the
backslash, so the heading rendered as
`C:\Users\<user>.aspire\bin\aspire.exe` — silently corrupting the
diagnostic surface a user reaches for to figure out where their CLI
actually lives.

Escape ASCII punctuation in `row.Id` and `row.Kind` before they reach
the Markdown renderer. The escape covers the full CommonMark ASCII
punctuation range so other punctuation in unexpected paths, hive names,
or future install kinds doesn't reintroduce the same class of bug.

Round-trip test pins the contract: each test input is escaped, fed
through `MarkdownToSpectreConverter.ConvertToPlainText` (the same
renderer used for redirected/non-interactive `--info` output), and the
result must equal the original literal.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
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.

5 participants