Skip to content
Draft
6 changes: 3 additions & 3 deletions src/Aspire.Cli/Acquisition/InstallSource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ internal enum InstallSource
{
/// <summary>
/// No sidecar was found, or the sidecar contained a value that does not
/// match any known route. Treated as legacy / pre-sidecar by callers.
/// match any known route. Callers fail closed unless an explicit override
/// applies.
/// </summary>
Unknown = 0,

Expand Down Expand Up @@ -55,8 +56,7 @@ internal static class InstallSourceExtensions
/// <summary>
/// Parses a sidecar <c>source</c> string into the strongly-typed enum.
/// Returns <see cref="InstallSource.Unknown"/> for null, empty, or
/// unrecognized values so callers can treat unknown sources as a
/// legacy / pre-sidecar install.
/// unrecognized values so callers can apply their unknown-route policy.
/// </summary>
public static InstallSource ParseInstallSource(string? raw)
{
Expand Down
55 changes: 55 additions & 0 deletions src/Aspire.Cli/Acquisition/SelfUpdateRouter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

namespace Aspire.Cli.Acquisition;

/// <summary>
/// Decides how <c>aspire update --self</c> should behave for an
/// <see cref="InstallSource"/>. The CLI can update itself in-process only
/// for installs it owns end-to-end (script). Every other route is owned by
/// a package manager or by a separate install path and would be corrupted
/// by an in-process binary swap, so we delegate by printing an
/// installer-appropriate command.
/// </summary>
internal enum SelfUpdateAction
{
/// <summary>
/// Perform the existing in-process self-update flow
/// (<c>CliDownloader</c>-driven download + binary swap).
/// </summary>
InProcess,

/// <summary>
/// Refuse to update in-process and instead print a route-appropriate
/// command via <see cref="IUpgradeInstructionProvider"/>. Returns
/// exit code 0 to match the existing dotnet-tool refusal contract;
/// callers that need to detect whether an update actually happened
/// should compare the binary version before and after the run rather
/// than relying on the exit code.
/// </summary>
Delegate,
}

/// <summary>
/// Pure policy lookup that maps <see cref="InstallSource"/> to the action
/// <c>aspire update --self</c> must take.
/// </summary>
internal static class SelfUpdateRouter
{
/// <summary>
/// Returns the action <c>aspire update --self</c> should perform for
/// the supplied <paramref name="source"/>.
/// </summary>
/// <remarks>
/// <see cref="InstallSource.Script"/> stays in-process — it's the route
/// the CLI owns end-to-end. Unknown routes are refused with a hint to
/// investigate the install or pass <c>--force</c>. The pre-PR-#16817
/// legacy script-install case is now covered by the <c>--force</c>
/// escape hatch.
/// </remarks>
public static SelfUpdateAction GetAction(InstallSource source) => source switch
{
InstallSource.Script => SelfUpdateAction.InProcess,
_ => SelfUpdateAction.Delegate,
};
}
107 changes: 107 additions & 0 deletions src/Aspire.Cli/Acquisition/UpgradeInstructionProvider.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Aspire.Cli.Resources;
using Aspire.Cli.Utils;

namespace Aspire.Cli.Acquisition;

/// <summary>
/// Returns the installer-appropriate command a user should run to update
/// the Aspire CLI, given the install route that produced their binary.
/// Consumed by <c>aspire update --self</c>'s refusal path and the
/// "update available" notifier so users always see the right command
/// for their install.
/// </summary>
internal interface IUpgradeInstructionProvider
{
/// <summary>
/// Returns the command string a user should run to update an
/// installation produced by <paramref name="source"/>.
/// </summary>
/// <param name="source">Install route the running binary was placed by.</param>
/// <param name="processPath">Absolute path of the running binary. Used
/// only for <see cref="InstallSource.DotnetTool"/>: a global-tool
/// install (under <c>~/.dotnet/tools/.store/</c>) gets
/// <c>dotnet tool update -g Aspire.Cli</c>, a <c>--tool-path</c>
/// install gets the path-aware variant.</param>
/// <param name="identityChannel">The CLI's identity channel
/// (<c>CliExecutionContext.IdentityChannel</c>). Used only for
/// <see cref="InstallSource.Pr"/> to substitute the PR number into the
/// <c>get-aspire-cli-pr</c> command.</param>
/// <returns>
/// The command to print verbatim, or <see langword="null"/> for
/// <see cref="InstallSource.Script"/> (which stays in-process and has
/// no separate update command to display).
/// </returns>
string? GetUpdateCommand(InstallSource source, string? processPath, string identityChannel);
}

/// <summary>
/// Default <see cref="IUpgradeInstructionProvider"/>. The mapping is a
/// pure function of <c>(source, processPath, identityChannel)</c>; no
/// I/O beyond the path-shape parsing already performed by
/// <see cref="DotNetToolDetection"/> for the <see cref="InstallSource.DotnetTool"/>
/// route.
/// </summary>
internal sealed class UpgradeInstructionProvider : IUpgradeInstructionProvider
{
/// <inheritdoc />
public string? GetUpdateCommand(InstallSource source, string? processPath, string identityChannel)
{
return source switch
{
// Script is the in-process update path; no separate command to display.
InstallSource.Script => null,

InstallSource.Pr => GetPrUpdateCommand(identityChannel),
InstallSource.Winget => "winget upgrade Microsoft.Aspire",
InstallSource.Brew => "brew upgrade --cask aspire",

// Prefer the supplied process path so tests and callers can
// classify synthesized paths without depending on Environment.ProcessPath.
// When no path is supplied, the no-arg overload preserves the
// existing production behavior and AsyncLocal test override.
InstallSource.DotnetTool => GetDotNetToolUpdateCommand(processPath),

// LocalHive installs are produced by re-running the dev script
// in the user's own checkout. There is no canonical update
// command — the user must rebuild from source.
InstallSource.LocalHive => "Run ./localhive.sh (Linux/macOS) or .\\localhive.ps1 (Windows) in the local hive directory.",
InstallSource.Unknown => UpdateCommandStrings.UnknownRouteRefusalHint,

_ => null,
};
}

private const string PrChannelPrefix = "pr-";

private static string GetDotNetToolUpdateCommand(string? processPath)
{
return (processPath is not null
? DotNetToolDetection.GetDotNetToolUpdateCommand(processPath)
: DotNetToolDetection.GetDotNetToolUpdateCommand())
?? "dotnet tool update -g Aspire.Cli";
}

private static string GetPrUpdateCommand(string identityChannel)
{
// The PR channel form is `pr-<N>` (parsed and validated by
// IdentityChannelReader); extract the digits if present so the
// hint shows the actual PR number.
if (identityChannel.StartsWith(PrChannelPrefix, StringComparison.Ordinal) &&
identityChannel.Length > PrChannelPrefix.Length)
{
var prNumber = identityChannel[PrChannelPrefix.Length..];
// Print both POSIX and Windows install lines so the docs are
// discoverable regardless of which shell the user is on.
return $"get-aspire-cli-pr.sh {prNumber} # or: get-aspire-cli-pr.ps1 -PRNumber {prNumber}";
}

// Defensive: if a PR-route sidecar coexists with a non-PR identity
// channel (theoretically impossible because PR archives bake the
// matching channel), emit the parameterised form so the user knows
// they need to supply the number.
return "get-aspire-cli-pr.sh <N> # or: get-aspire-cli-pr.ps1 -PRNumber <N>";
}
}
Loading