diff --git a/eng/Bundle.proj b/eng/Bundle.proj
index 626184870d4..a3e4a3bd00f 100644
--- a/eng/Bundle.proj
+++ b/eng/Bundle.proj
@@ -89,9 +89,10 @@
<_CliBinlog Condition="'$(ContinuousIntegrationBuild)' == 'true'">-bl:$(ArtifactsLogDir)PublishCli.binlog
<_BundleArchivePath>$(ArtifactsDir)bundle\aspire-$(BundleVersion)-$(TargetRid).tar.gz
+ <_CliChannelArg Condition="'$(CliChannel)' != ''">/p:CliChannel=$(CliChannel)
-
+
@@ -118,6 +119,7 @@
<_ArtifactsDirArg>$(ArtifactsDir.TrimEnd('\').TrimEnd('/'))
<_CreateLayoutArgs>--output "$(_BundleOutputDirArg)" --artifacts "$(_ArtifactsDirArg)" --rid $(TargetRid) --bundle-version $(BundleVersion) --verbose --archive
+ <_CreateLayoutArgs Condition="'$(UpdateInstructions)' != ''">$(_CreateLayoutArgs) --update-instructions "$(UpdateInstructions)"
diff --git a/eng/clipack/Common.projitems b/eng/clipack/Common.projitems
index 5e67c75bbc5..64038f636e5 100644
--- a/eng/clipack/Common.projitems
+++ b/eng/clipack/Common.projitems
@@ -6,6 +6,16 @@
zip
tar.gz
+
+ winget upgrade Microsoft.Aspire
+ brew upgrade --cask aspire
+
+
+ stable
+
true
@@ -37,6 +47,23 @@
+
+
+
+ <_AspireUpdateJsonContent>{"selfUpdateDisabled":true,"updateInstructions":"$(CliUpdateInstructions)"}
+
+
+
+
+
+
diff --git a/eng/homebrew/aspire.rb.template b/eng/homebrew/aspire.rb.template
index dbe07554fe3..b0383698a3c 100644
--- a/eng/homebrew/aspire.rb.template
+++ b/eng/homebrew/aspire.rb.template
@@ -13,5 +13,12 @@ cask "aspire" do
binary "aspire"
+ postflight do
+ # Write .aspire-update.json next to the binary to disable self-update.
+ # Users should update via 'brew upgrade --cask aspire' instead.
+ config_file = staged_path.join(".aspire-update.json")
+ config_file.write('{"selfUpdateDisabled":true,"updateInstructions":"brew upgrade --cask aspire"}')
+ end
+
zap trash: "~/.aspire"
end
diff --git a/eng/scripts/get-aspire-cli.ps1 b/eng/scripts/get-aspire-cli.ps1
index a16f6e1f102..7309952f8a1 100755
--- a/eng/scripts/get-aspire-cli.ps1
+++ b/eng/scripts/get-aspire-cli.ps1
@@ -764,6 +764,15 @@ function Expand-AspireCliArchive {
Remove-OldCliBackupFiles -TargetExePath $targetExePath
}
+ # Remove .aspire-update.json if present. This file disables self-update for
+ # package-manager installations (WinGet, Homebrew) but install-script users
+ # should retain self-update capability.
+ $aspireUpdateJson = Join-Path $DestinationPath ".aspire-update.json"
+ if (Test-Path $aspireUpdateJson) {
+ Remove-Item -Path $aspireUpdateJson -Force -ErrorAction SilentlyContinue
+ Write-Message "Removed .aspire-update.json (self-update remains enabled for script installs)" -Level Verbose
+ }
+
Write-Message "Successfully unpacked archive" -Level Verbose
}
catch {
diff --git a/eng/scripts/get-aspire-cli.sh b/eng/scripts/get-aspire-cli.sh
index cc4a04d59b4..6ec7de2b96f 100755
--- a/eng/scripts/get-aspire-cli.sh
+++ b/eng/scripts/get-aspire-cli.sh
@@ -478,6 +478,11 @@ install_archive() {
fi
fi
+ # Remove .aspire-update.json if present. This file disables self-update for
+ # package-manager installations (WinGet, Homebrew) but install-script users
+ # should retain self-update capability.
+ rm -f "${destination_path}/.aspire-update.json"
+
say_verbose "Successfully installed archive"
}
diff --git a/localhive.ps1 b/localhive.ps1
index b5f9693280a..b7f7e329a1a 100644
--- a/localhive.ps1
+++ b/localhive.ps1
@@ -270,7 +270,7 @@ if (-not $SkipBundle) {
$skipNativeArg = if ($NativeAot) { '' } else { '/p:SkipNativeBuild=true' }
Write-Log "Building bundle (aspire-managed + DCP$(if ($NativeAot) { ' + native AOT CLI' }))..."
- $buildArgs = @($bundleProjPath, '-c', $effectiveConfig, "/p:VersionSuffix=$VersionSuffix")
+ $buildArgs = @($bundleProjPath, '-c', $effectiveConfig, "/p:VersionSuffix=$VersionSuffix", "/p:CliChannel=pr")
if (-not $NativeAot) {
$buildArgs += '/p:SkipNativeBuild=true'
}
@@ -376,10 +376,7 @@ if (-not $SkipCli) {
$installedCliPath = Join-Path $cliBinDir $cliExeName
Write-Log "Aspire CLI installed to: $installedCliPath"
-
- # Set the channel to the local hive so templates and packages resolve from it
- & $installedCliPath config set channel $Name -g 2>$null
- Write-Log "Set global channel to '$Name'"
+ Write-Log "CLI has embedded channel 'pr' - hive packages at $aspireRoot\packages\"
# Check if the bin directory is in PATH
$pathSeparator = [System.IO.Path]::PathSeparator
diff --git a/localhive.sh b/localhive.sh
index 859865339ac..c1ec857a4b1 100755
--- a/localhive.sh
+++ b/localhive.sh
@@ -246,10 +246,10 @@ if [[ $SKIP_BUNDLE -eq 0 ]]; then
if [[ $NATIVE_AOT -eq 1 ]]; then
log "Building bundle (aspire-managed + DCP + native AOT CLI)..."
- dotnet build "$BUNDLE_PROJ" -c "$EFFECTIVE_CONFIG" "/p:VersionSuffix=$VERSION_SUFFIX"
+ dotnet build "$BUNDLE_PROJ" -c "$EFFECTIVE_CONFIG" "/p:VersionSuffix=$VERSION_SUFFIX" "/p:CliChannel=pr"
else
log "Building bundle (aspire-managed + DCP)..."
- dotnet build "$BUNDLE_PROJ" -c "$EFFECTIVE_CONFIG" /p:SkipNativeBuild=true "/p:VersionSuffix=$VERSION_SUFFIX"
+ dotnet build "$BUNDLE_PROJ" -c "$EFFECTIVE_CONFIG" /p:SkipNativeBuild=true "/p:VersionSuffix=$VERSION_SUFFIX" "/p:CliChannel=pr"
fi
if [[ $? -ne 0 ]]; then
error "Bundle build failed."
@@ -318,12 +318,7 @@ if [[ $SKIP_CLI -eq 0 ]]; then
chmod +x "$CLI_BIN_DIR/aspire"
log "Aspire CLI installed to: $CLI_BIN_DIR/aspire"
-
- if "$CLI_BIN_DIR/aspire" config set channel "$HIVE_NAME" -g >/dev/null 2>&1; then
- log "Set global channel to '$HIVE_NAME'"
- else
- warn "Failed to set global channel to '$HIVE_NAME'. Run: aspire config set channel '$HIVE_NAME' -g"
- fi
+ log "CLI has embedded channel 'pr' — hive packages at $ASPIRE_ROOT/packages/"
# Check if the bin directory is in PATH
if [[ ":$PATH:" != *":$CLI_BIN_DIR:"* ]]; then
diff --git a/src/Aspire.Cli/Aspire.Cli.csproj b/src/Aspire.Cli/Aspire.Cli.csproj
index a3633f025f6..062798994a3 100644
--- a/src/Aspire.Cli/Aspire.Cli.csproj
+++ b/src/Aspire.Cli/Aspire.Cli.csproj
@@ -24,8 +24,20 @@
$(DefineConstants);CLI
true
false
+
+
+
+
+
+ <_Parameter1>CliChannel
+ <_Parameter2>$(CliChannel)
+
+
+
true
diff --git a/src/Aspire.Cli/Commands/AddCommand.cs b/src/Aspire.Cli/Commands/AddCommand.cs
index fc6f1db8282..0a2e15fc422 100644
--- a/src/Aspire.Cli/Commands/AddCommand.cs
+++ b/src/Aspire.Cli/Commands/AddCommand.cs
@@ -28,6 +28,7 @@ internal sealed class AddCommand : BaseCommand
private readonly IDotNetSdkInstaller _sdkInstaller;
private readonly ICliHostEnvironment _hostEnvironment;
private readonly IAppHostProjectFactory _projectFactory;
+ private readonly IConfigurationService _configurationService;
private static readonly Argument s_integrationArgument = new("integration")
{
@@ -44,7 +45,7 @@ internal sealed class AddCommand : BaseCommand
Description = AddCommandStrings.SourceArgumentDescription
};
- public AddCommand(IPackagingService packagingService, IInteractionService interactionService, IProjectLocator projectLocator, IAddCommandPrompter prompter, AspireCliTelemetry telemetry, IDotNetSdkInstaller sdkInstaller, IFeatures features, ICliUpdateNotifier updateNotifier, CliExecutionContext executionContext, ICliHostEnvironment hostEnvironment, IAppHostProjectFactory projectFactory)
+ public AddCommand(IPackagingService packagingService, IInteractionService interactionService, IProjectLocator projectLocator, IAddCommandPrompter prompter, AspireCliTelemetry telemetry, IDotNetSdkInstaller sdkInstaller, IFeatures features, ICliUpdateNotifier updateNotifier, CliExecutionContext executionContext, ICliHostEnvironment hostEnvironment, IAppHostProjectFactory projectFactory, IConfigurationService configurationService)
: base("add", AddCommandStrings.Description, features, updateNotifier, executionContext, interactionService, telemetry)
{
_packagingService = packagingService;
@@ -53,6 +54,7 @@ public AddCommand(IPackagingService packagingService, IInteractionService intera
_sdkInstaller = sdkInstaller;
_hostEnvironment = hostEnvironment;
_projectFactory = projectFactory;
+ _configurationService = configurationService;
Arguments.Add(s_integrationArgument);
Options.Add(s_appHostOption);
@@ -93,9 +95,9 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell
var source = parseResult.GetValue(s_sourceOption);
- // For non-.NET projects, read the channel from the local Aspire configuration if available.
- // Unlike .NET projects which have a nuget.config, polyglot apphosts persist the channel
- // in aspire.config.json (or the legacy settings.json during migration).
+ // For non-.NET projects, read the channel from the configuration service,
+ // which supports both the global config (aspire config set) and legacy settings.
+ // Falls back to the embedded channel baked into the binary.
string? configuredChannel = null;
if (project.LanguageId != KnownLanguageId.CSharp)
{
@@ -103,12 +105,10 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell
var isProjectReferenceMode = AspireRepositoryDetector.DetectRepositoryRoot(appHostDirectory) is not null;
if (!isProjectReferenceMode)
{
- // TODO: Remove legacy AspireJsonConfiguration fallback once confident most users
- // have migrated. Tracked by https://github.com/microsoft/aspire/issues/15239
try
{
- configuredChannel = AspireConfigFile.Load(appHostDirectory)?.Channel
- ?? AspireJsonConfiguration.Load(appHostDirectory)?.Channel;
+ configuredChannel = await _configurationService.GetConfigurationAsync("channel", cancellationToken)
+ ?? PackagingService.GetEmbeddedChannel();
}
catch (JsonException ex)
{
diff --git a/src/Aspire.Cli/Commands/InitCommand.cs b/src/Aspire.Cli/Commands/InitCommand.cs
index 0eb3f96154e..e711c8843ee 100644
--- a/src/Aspire.Cli/Commands/InitCommand.cs
+++ b/src/Aspire.Cli/Commands/InitCommand.cs
@@ -736,10 +736,11 @@ private static bool IsSupportedTfm(string tfm)
// Check if --channel option was provided (highest priority)
var channelName = parseResult.GetValue(_channelOption);
- // If no --channel option, check for global channel setting
+ // If no --channel option, check for global channel setting, then embedded channel
if (string.IsNullOrEmpty(channelName))
{
- channelName = await _configurationService.GetConfigurationAsync("channel", cancellationToken);
+ channelName = await _configurationService.GetConfigurationAsync("channel", cancellationToken)
+ ?? PackagingService.GetEmbeddedChannel();
}
IEnumerable channels;
diff --git a/src/Aspire.Cli/Commands/NewCommand.cs b/src/Aspire.Cli/Commands/NewCommand.cs
index e0987fb5610..abdc4a1e85c 100644
--- a/src/Aspire.Cli/Commands/NewCommand.cs
+++ b/src/Aspire.Cli/Commands/NewCommand.cs
@@ -302,7 +302,8 @@ private async Task ResolveCliTemplateVersionAsync(
var configuredChannelName = parseResult.GetValue(_channelOption);
if (string.IsNullOrWhiteSpace(configuredChannelName))
{
- configuredChannelName = await _configurationService.GetConfigurationAsync("channel", cancellationToken);
+ configuredChannelName = await _configurationService.GetConfigurationAsync("channel", cancellationToken)
+ ?? PackagingService.GetEmbeddedChannel();
}
var selectedChannel = string.IsNullOrWhiteSpace(configuredChannelName)
diff --git a/src/Aspire.Cli/Commands/UpdateCommand.cs b/src/Aspire.Cli/Commands/UpdateCommand.cs
index 665bd0f9208..4ab6ac269db 100644
--- a/src/Aspire.Cli/Commands/UpdateCommand.cs
+++ b/src/Aspire.Cli/Commands/UpdateCommand.cs
@@ -32,6 +32,7 @@ internal sealed class UpdateCommand : BaseCommand
private readonly IFeatures _features;
private readonly IConfigurationService _configurationService;
private readonly IConfiguration _configuration;
+ private readonly IInstallationDetector _installationDetector;
private static readonly OptionWithLegacy s_appHostOption = new("--apphost", "--project", UpdateCommandStrings.ProjectArgumentDescription);
private static readonly Option s_selfOption = new("--self")
@@ -53,7 +54,8 @@ public UpdateCommand(
CliExecutionContext executionContext,
IConfigurationService configurationService,
AspireCliTelemetry telemetry,
- IConfiguration configuration)
+ IConfiguration configuration,
+ IInstallationDetector installationDetector)
: base("update", UpdateCommandStrings.Description, features, updateNotifier, executionContext, interactionService, telemetry)
{
_projectLocator = projectLocator;
@@ -65,6 +67,7 @@ public UpdateCommand(
_features = features;
_configurationService = configurationService;
_configuration = configuration;
+ _installationDetector = installationDetector;
Options.Add(s_appHostOption);
Options.Add(s_selfOption);
@@ -93,20 +96,6 @@ public UpdateCommand(
protected override bool UpdateNotificationsEnabled => false;
- private static bool IsRunningAsDotNetTool()
- {
- // When running as a dotnet tool, the process path points to "dotnet" or "dotnet.exe"
- // When running as a native binary, it points to "aspire" or "aspire.exe"
- var processPath = Environment.ProcessPath;
- if (string.IsNullOrEmpty(processPath))
- {
- return false;
- }
-
- var fileName = Path.GetFileNameWithoutExtension(processPath);
- return string.Equals(fileName, "dotnet", StringComparison.OrdinalIgnoreCase);
- }
-
protected override async Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken)
{
var isSelfUpdate = parseResult.GetValue(s_selfOption);
@@ -114,14 +103,27 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell
// If --self is specified, handle CLI self-update
if (isSelfUpdate)
{
+ var installInfo = _installationDetector.GetInstallationInfo();
+
// When running as a dotnet tool, print the update command instead of executing
- if (IsRunningAsDotNetTool())
+ if (installInfo.IsDotNetTool)
{
InteractionService.DisplayMessage(KnownEmojis.Information, UpdateCommandStrings.DotNetToolSelfUpdateMessage);
InteractionService.DisplayPlainText(" dotnet tool update -g Aspire.Cli");
return 0;
}
+ // When self-update is disabled (e.g., installed via WinGet/Homebrew), show instructions
+ if (installInfo.SelfUpdateDisabled)
+ {
+ InteractionService.DisplayMessage(KnownEmojis.Information, UpdateCommandStrings.SelfUpdateDisabledMessage);
+ if (!string.IsNullOrEmpty(installInfo.UpdateInstructions))
+ {
+ InteractionService.DisplayPlainText($" {installInfo.UpdateInstructions}");
+ }
+ return 0;
+ }
+
if (_cliDownloader is null)
{
InteractionService.DisplayError("CLI self-update is not available in this environment.");
@@ -208,15 +210,35 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell
_updateNotifier.IsUpdateAvailable() &&
!string.IsNullOrEmpty(channel.CliDownloadBaseUrl))
{
- var shouldUpdateCli = await InteractionService.ConfirmAsync(
- UpdateCommandStrings.UpdateCliAfterProjectUpdatePrompt,
- defaultValue: true,
- cancellationToken);
-
- if (shouldUpdateCli)
+ var installInfo = _installationDetector.GetInstallationInfo();
+
+ if (installInfo.IsDotNetTool)
+ {
+ // Show dotnet tool update message instead of prompting
+ InteractionService.DisplayMessage(KnownEmojis.Information, UpdateCommandStrings.DotNetToolSelfUpdateMessage);
+ InteractionService.DisplayPlainText(" dotnet tool update -g Aspire.Cli");
+ }
+ else if (installInfo.SelfUpdateDisabled)
+ {
+ // Show package manager instructions instead of prompting
+ InteractionService.DisplayMessage(KnownEmojis.Information, UpdateCommandStrings.SelfUpdateDisabledMessage);
+ if (!string.IsNullOrEmpty(installInfo.UpdateInstructions))
+ {
+ InteractionService.DisplayPlainText($" {installInfo.UpdateInstructions}");
+ }
+ }
+ else
{
- // Use the same channel that was selected for the project update
- return await ExecuteSelfUpdateAsync(parseResult, cancellationToken, channel.Name);
+ var shouldUpdateCli = await InteractionService.ConfirmAsync(
+ UpdateCommandStrings.UpdateCliAfterProjectUpdatePrompt,
+ defaultValue: true,
+ cancellationToken);
+
+ if (shouldUpdateCli)
+ {
+ // Use the same channel that was selected for the project update
+ return await ExecuteSelfUpdateAsync(parseResult, cancellationToken, channel.Name);
+ }
}
}
}
@@ -239,8 +261,23 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell
// Check if this is a "no project found" error and prompt for self-update
if (string.Equals(ex.Message, ErrorStrings.NoProjectFileFound, StringComparisons.CliInputOrOutput))
{
- // Only prompt for self-update if not running as dotnet tool and downloader is available
- if (_cliDownloader is not null)
+ var installInfo = _installationDetector.GetInstallationInfo();
+
+ // Only prompt for self-update if not running as dotnet tool, not disabled, and downloader is available
+ if (installInfo.IsDotNetTool)
+ {
+ InteractionService.DisplayMessage(KnownEmojis.Information, UpdateCommandStrings.DotNetToolSelfUpdateMessage);
+ InteractionService.DisplayPlainText(" dotnet tool update -g Aspire.Cli");
+ }
+ else if (installInfo.SelfUpdateDisabled)
+ {
+ InteractionService.DisplayMessage(KnownEmojis.Information, UpdateCommandStrings.SelfUpdateDisabledMessage);
+ if (!string.IsNullOrEmpty(installInfo.UpdateInstructions))
+ {
+ InteractionService.DisplayPlainText($" {installInfo.UpdateInstructions}");
+ }
+ }
+ else if (_cliDownloader is not null)
{
var shouldUpdateCli = await InteractionService.ConfirmAsync(
UpdateCommandStrings.NoAppHostFoundUpdateCliPrompt,
@@ -269,9 +306,20 @@ private async Task ExecuteSelfUpdateAsync(ParseResult parseResult, Cancella
{
var channel = selectedChannel ?? parseResult.GetValue(_channelOption) ?? parseResult.GetValue(_qualityOption);
- // If channel is not specified, always prompt the user to select one.
- // This ensures they consciously choose a channel that will be saved to global settings
- // for future 'aspire new' and 'aspire init' commands.
+ // If channel is not specified via argument, use the embedded channel baked into the binary at build time.
+ // This allows stable-channel binaries to self-update from the stable channel automatically,
+ // and daily-channel binaries to update from daily, without user interaction.
+ if (string.IsNullOrEmpty(channel))
+ {
+ var embeddedChannel = PackagingService.GetEmbeddedChannel();
+ if (!string.IsNullOrEmpty(embeddedChannel) &&
+ !string.Equals(embeddedChannel, "pr", StringComparison.OrdinalIgnoreCase))
+ {
+ channel = embeddedChannel;
+ }
+ }
+
+ // If still not specified, prompt the user to select one.
if (string.IsNullOrEmpty(channel))
{
var isStagingEnabled = KnownFeatures.IsStagingChannelEnabled(_features, _configuration);
@@ -304,20 +352,6 @@ private async Task ExecuteSelfUpdateAsync(ParseResult parseResult, Cancella
// Extract and update to $HOME/.aspire/bin
await ExtractAndUpdateAsync(archivePath, cancellationToken);
- // Save the selected channel to global settings for future use with 'aspire new' and 'aspire init'
- // For stable channel, clear the setting to leave it blank (like the install scripts do)
- // For other channels (staging, daily), save the channel name
- if (string.Equals(channel, PackageChannelNames.Stable, StringComparison.OrdinalIgnoreCase))
- {
- await _configurationService.DeleteConfigurationAsync("channel", isGlobal: true, cancellationToken);
- _logger.LogDebug("Cleared global channel setting for stable channel");
- }
- else
- {
- await _configurationService.SetConfigurationAsync("channel", channel, isGlobal: true, cancellationToken);
- _logger.LogDebug("Saved global channel setting: {Channel}", channel);
- }
-
return 0;
}
catch (OperationCanceledException)
diff --git a/src/Aspire.Cli/Configuration/AspireConfigFile.cs b/src/Aspire.Cli/Configuration/AspireConfigFile.cs
index fb909e01437..51af5e0e442 100644
--- a/src/Aspire.Cli/Configuration/AspireConfigFile.cs
+++ b/src/Aspire.Cli/Configuration/AspireConfigFile.cs
@@ -24,7 +24,6 @@ namespace Aspire.Cli.Configuration;
/// {
/// "appHost": { "path": "app.ts", "language": "typescript/nodejs" },
/// "sdk": { "version": "9.2.0" },
-/// "channel": "stable",
/// "features": { "polyglotSupportEnabled": true },
/// "profiles": {
/// "default": {
@@ -81,13 +80,6 @@ public string? SdkVersion
set => (Sdk ??= new AspireConfigSdk()).Version = value;
}
- ///
- /// Aspire channel for package resolution.
- ///
- [JsonPropertyName("channel")]
- [Description("The Aspire channel to use for package resolution (e.g., \"stable\", \"preview\", \"staging\", \"daily\"). Used by aspire add to determine which NuGet feed to use.")]
- public string? Channel { get; set; }
-
///
/// Feature flags.
///
@@ -109,6 +101,14 @@ public string? SdkVersion
[Description("Package references for non-first-class languages. Key is package name, value is version. A value ending in \".csproj\" is treated as a project reference.")]
public Dictionary? Packages { get; set; }
+ ///
+ /// Captures any additional JSON properties not mapped to a typed member.
+ /// Ensures forward/backward compatibility when the file contains properties
+ /// from older or newer CLI versions (e.g., the former "channel" property).
+ ///
+ [JsonExtensionData]
+ public Dictionary? ExtensionData { get; set; }
+
///
/// Loads aspire.config.json from the specified directory.
///
@@ -400,7 +400,16 @@ public static AspireConfigFile FromLegacy(AspireJsonConfiguration? settings, Dic
config.Sdk = new AspireConfigSdk { Version = settings.SdkVersion };
}
- config.Channel = settings.Channel;
+ // Preserve channel in extension data for backward compatibility.
+ // The typed Channel property was removed (channel is now embedded in the binary),
+ // but users who previously set a channel should keep it in their config file.
+ if (!string.IsNullOrEmpty(settings.Channel))
+ {
+ config.ExtensionData ??= new Dictionary();
+ using var doc = JsonDocument.Parse($"\"{settings.Channel}\"");
+ config.ExtensionData["channel"] = doc.RootElement.Clone();
+ }
+
config.Features = settings.Features;
config.Packages = settings.Packages;
}
diff --git a/src/Aspire.Cli/JsonSourceGenerationContext.cs b/src/Aspire.Cli/JsonSourceGenerationContext.cs
index b0fadbcf717..47b17ce5422 100644
--- a/src/Aspire.Cli/JsonSourceGenerationContext.cs
+++ b/src/Aspire.Cli/JsonSourceGenerationContext.cs
@@ -10,6 +10,7 @@
using Aspire.Cli.Configuration;
using Aspire.Cli.Mcp.Docs;
using Aspire.Cli.Mcp.Tools;
+using Aspire.Cli.Utils;
using Aspire.Cli.Utils.EnvironmentChecker;
namespace Aspire.Cli;
@@ -40,6 +41,7 @@ namespace Aspire.Cli;
[JsonSerializable(typeof(DocsListItem[]))]
[JsonSerializable(typeof(SearchResult[]))]
[JsonSerializable(typeof(DocsContent))]
+[JsonSerializable(typeof(AspireUpdateConfig))]
internal partial class JsonSourceGenerationContext : JsonSerializerContext
{
private static JsonSourceGenerationContext? s_relaxedEscaping;
diff --git a/src/Aspire.Cli/Packaging/PackagingService.cs b/src/Aspire.Cli/Packaging/PackagingService.cs
index 646661bd52e..00bca067c78 100644
--- a/src/Aspire.Cli/Packaging/PackagingService.cs
+++ b/src/Aspire.Cli/Packaging/PackagingService.cs
@@ -16,6 +16,20 @@ internal interface IPackagingService
internal class PackagingService(CliExecutionContext executionContext, INuGetPackageCache nuGetPackageCache, IFeatures features, IConfiguration configuration) : IPackagingService
{
+ ///
+ /// Reads the distribution channel baked into the binary at build time via
+ /// [assembly: AssemblyMetadata("CliChannel", "...")].
+ /// Returns null for dev/inner-loop builds that have no embedded channel.
+ ///
+ internal static string? GetEmbeddedChannel()
+ {
+ var value = Assembly.GetExecutingAssembly()
+ .GetCustomAttributes()
+ .FirstOrDefault(a => a.Key == "CliChannel")?.Value;
+
+ return string.IsNullOrEmpty(value) ? null : value;
+ }
+
public Task> GetChannelsAsync(CancellationToken cancellationToken = default)
{
var defaultChannel = PackageChannel.CreateImplicitChannel(nuGetPackageCache);
diff --git a/src/Aspire.Cli/Program.cs b/src/Aspire.Cli/Program.cs
index 2bcdfbf53e6..1cd5a368726 100644
--- a/src/Aspire.Cli/Program.cs
+++ b/src/Aspire.Cli/Program.cs
@@ -365,6 +365,7 @@ internal static async Task BuildApplicationAsync(string[] args, CliStartu
builder.Services.AddSingleton();
builder.Services.AddSingleton(sp => sp.GetRequiredService());
builder.Services.AddHostedService(sp => sp.GetRequiredService());
+ builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services.AddSingleton();
diff --git a/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs b/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs
index 36290523aea..034a9db439d 100644
--- a/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs
+++ b/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs
@@ -329,7 +329,8 @@ private XDocument CreateProjectFile(IEnumerable integratio
if (string.IsNullOrEmpty(configuredChannelName))
{
- configuredChannelName = await _configurationService.GetConfigurationAsync("channel", cancellationToken);
+ configuredChannelName = await _configurationService.GetConfigurationAsync("channel", cancellationToken)
+ ?? PackagingService.GetEmbeddedChannel();
}
// Resolve channel sources and add them via RestoreAdditionalProjectSources
diff --git a/src/Aspire.Cli/Projects/GuestAppHostProject.cs b/src/Aspire.Cli/Projects/GuestAppHostProject.cs
index 2fc509f3743..b340868a27c 100644
--- a/src/Aspire.Cli/Projects/GuestAppHostProject.cs
+++ b/src/Aspire.Cli/Projects/GuestAppHostProject.cs
@@ -361,12 +361,7 @@ public async Task RunAsync(AppHostProjectContext context, CancellationToken
return (Success: true, Output: prepareOutput, Error: (string?)null, ChannelName: channelName, NeedsCodeGen: needsCodeGen);
}, emoji: KnownEmojis.Gear);
- // Save the channel to settings if available (config already has SdkVersion)
- if (buildResult.ChannelName is not null)
- {
- config.Channel = buildResult.ChannelName;
- SaveConfiguration(config, directory);
- }
+ // Channel is now embedded in the binary via assembly metadata — no need to save.
if (!buildResult.Success)
{
@@ -1124,11 +1119,7 @@ public async Task UpdatePackagesAsync(UpdatePackagesContex
{
config.SdkVersion = newSdkVersion;
}
- // Update channel if it's an explicit channel (not the implicit/default one)
- if (context.Channel.Type == Packaging.PackageChannelType.Explicit)
- {
- config.Channel = context.Channel.Name;
- }
+ // Update channel is now embedded in the binary — don't persist to config.
foreach (var (packageId, _, newVersion) in updates)
{
config.AddOrUpdatePackage(packageId, newVersion);
diff --git a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs
index 39e25281e21..dae778fcd7f 100644
--- a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs
+++ b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs
@@ -330,7 +330,7 @@ internal static string GenerateIntegrationProjectFile(
}
///
- /// Resolves the configured channel name from local settings.json or global config.
+ /// Resolves the configured channel name from local settings.json, global config, or embedded channel.
///
private async Task ResolveChannelNameAsync(CancellationToken cancellationToken)
{
@@ -338,10 +338,11 @@ internal static string GenerateIntegrationProjectFile(
var localConfig = AspireJsonConfiguration.Load(_appDirectoryPath);
var channelName = localConfig?.Channel;
- // Fall back to global config
+ // Fall back to global config, then embedded channel
if (string.IsNullOrEmpty(channelName))
{
- channelName = await _configurationService.GetConfigurationAsync("channel", cancellationToken);
+ channelName = await _configurationService.GetConfigurationAsync("channel", cancellationToken)
+ ?? PackagingService.GetEmbeddedChannel();
}
if (!string.IsNullOrEmpty(channelName))
diff --git a/src/Aspire.Cli/Resources/UpdateCommandStrings.Designer.cs b/src/Aspire.Cli/Resources/UpdateCommandStrings.Designer.cs
index fd4c0c24794..60e8d7ecd2e 100644
--- a/src/Aspire.Cli/Resources/UpdateCommandStrings.Designer.cs
+++ b/src/Aspire.Cli/Resources/UpdateCommandStrings.Designer.cs
@@ -116,5 +116,6 @@ internal static string ProjectArgumentDescription {
internal static string RegeneratingSdkCode => ResourceManager.GetString("RegeneratingSdkCode", resourceCulture);
internal static string RegeneratedSdkCode => ResourceManager.GetString("RegeneratedSdkCode", resourceCulture);
internal static string SelfOptionDescription => ResourceManager.GetString("SelfOptionDescription", resourceCulture);
+ internal static string SelfUpdateDisabledMessage => ResourceManager.GetString("SelfUpdateDisabledMessage", resourceCulture);
}
}
diff --git a/src/Aspire.Cli/Resources/UpdateCommandStrings.resx b/src/Aspire.Cli/Resources/UpdateCommandStrings.resx
index 28c75afa616..667a192fd1e 100644
--- a/src/Aspire.Cli/Resources/UpdateCommandStrings.resx
+++ b/src/Aspire.Cli/Resources/UpdateCommandStrings.resx
@@ -165,4 +165,7 @@
Update the Aspire CLI itself to the latest version
+
+ Self-update is disabled for this installation.
+
diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.cs.xlf
index 1f5f8620746..ffc4a646e9e 100644
--- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.cs.xlf
+++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.cs.xlf
@@ -232,6 +232,11 @@
Update the Aspire CLI itself to the latest version
+
+ Self-update is disabled for this installation.
+ Self-update is disabled for this installation.
+
+
Unexpected code path.
Neočekávaná cesta kódu.
diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.de.xlf
index 306ceba6c2e..23ece6283e8 100644
--- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.de.xlf
+++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.de.xlf
@@ -232,6 +232,11 @@
Update the Aspire CLI itself to the latest version
+
+ Self-update is disabled for this installation.
+ Self-update is disabled for this installation.
+
+
Unexpected code path.
Unerwarteter Codepfad
diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.es.xlf
index 01572c31716..90ecd2ed51c 100644
--- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.es.xlf
+++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.es.xlf
@@ -232,6 +232,11 @@
Update the Aspire CLI itself to the latest version
+
+ Self-update is disabled for this installation.
+ Self-update is disabled for this installation.
+
+
Unexpected code path.
Ruta de acceso al código inesperada.
diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.fr.xlf
index d49b7cd1224..fbbbb50fe8f 100644
--- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.fr.xlf
+++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.fr.xlf
@@ -232,6 +232,11 @@
Update the Aspire CLI itself to the latest version
+
+ Self-update is disabled for this installation.
+ Self-update is disabled for this installation.
+
+
Unexpected code path.
Chemin de code inattendu.
diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.it.xlf
index e2977671f34..6017ef0c7b1 100644
--- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.it.xlf
+++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.it.xlf
@@ -232,6 +232,11 @@
Update the Aspire CLI itself to the latest version
+
+ Self-update is disabled for this installation.
+ Self-update is disabled for this installation.
+
+
Unexpected code path.
Percorso del codice imprevisto.
diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ja.xlf
index 9a38b494f8a..25511287d39 100644
--- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ja.xlf
+++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ja.xlf
@@ -232,6 +232,11 @@
Update the Aspire CLI itself to the latest version
+
+ Self-update is disabled for this installation.
+ Self-update is disabled for this installation.
+
+
Unexpected code path.
予期しないコード パスです。
diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ko.xlf
index 559dc98324d..d583e70d971 100644
--- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ko.xlf
+++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ko.xlf
@@ -232,6 +232,11 @@
Update the Aspire CLI itself to the latest version
+
+ Self-update is disabled for this installation.
+ Self-update is disabled for this installation.
+
+
Unexpected code path.
예기치 않은 코드 경로입니다.
@@ -259,4 +264,4 @@