Skip to content

Commit 9821098

Browse files
aspire-repo-bot[bot]mitchdennyCopilot
authored
[release/13.3] Honor configured channel in 'aspire update' (#16808)
* Honor configured channel in 'aspire update' 'aspire update' previously consulted only the explicit --channel/--quality option and otherwise silently selected the implicit channel (or prompted only if PR hives were present). The local and global 'channel' configuration values were never read, so a user who ran 'aspire config set channel staging' (or had it saved by 'aspire update --self') would still get the implicit/NuGet-config based channel on subsequent 'aspire update' runs. UpdateCommand.ExecuteAsync now resolves the channel using the documented precedence: 1. explicit --channel / hidden --quality 2. local app config 'channel' (aspire.config.json / .aspire/settings.json) 3. global config 'channel' (~/.aspire/settings.global.json) 4. interactive channel prompt when PR hives are present 5. implicit/default channel as the documented fallback Local-vs-global precedence comes for free because RegisterSettingsFiles loads the global settings file before the local one, so IConfiguration (and IConfigurationService.GetConfigurationAsync) returns the local value when both exist. A configured channel that does not match any available channel now surfaces a ChannelNotFoundException instead of being silently ignored. The existing UpdateCommand_WithoutHives_UsesImplicitChannelWithoutPrompting test is preserved (no configured channel still falls back to the implicit channel) and joined by new regression coverage: * UpdateCommand_LocalConfiguredChannel_IsUsed * UpdateCommand_GlobalConfiguredChannel_IsUsed * UpdateCommand_ExplicitChannelOverridesConfiguredChannel * UpdateCommand_LocalConfiguredChannel_OverridesGlobalConfiguredChannel * UpdateCommand_WithoutHives_ConfiguredChannel_TakesPrecedenceOverImplicitFallback * UpdateCommand_ConfiguredChannelNotInChannelList_ThrowsChannelNotFound Fixes #16650 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Resolve `aspire update` channel config relative to AppHost project directory Addresses Copilot reviewer feedback on PR #16716. The previous fix moved channel resolution to read from `IConfiguration`, but `IConfiguration` is rooted at `Environment.CurrentDirectory` at startup via `ConfigurationHelper.RegisterSettingsFiles`. That means `aspire update --apphost <path-to-other-app>/AppHost.csproj` ignored the target app's local `aspire.config.json` and read config from the caller's cwd tree instead, so the documented "local app-config in the project tree" precedence was still broken for explicit `--apphost` updates. Add `IConfigurationService.GetConfigurationFromDirectoryAsync(key, startDirectory)` which walks up from a caller-supplied directory for the nearest `aspire.config.json`, then falls back to the global settings file. The process-wide IConfiguration is intentionally not consulted, so the lookup is never anchored to the launch cwd. `UpdateCommand` now passes `projectFile.Directory` to scope the channel lookup to the resolved AppHost project's tree. Three new tests cover: - Project in another directory uses its own local config - Project-local config wins over cwd config - Project-local config without `channel` falls back to global Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Mitch Denny <mitch@mitchdenny.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent cbbb043 commit 9821098

9 files changed

Lines changed: 459 additions & 2 deletions

File tree

src/Aspire.Cli/Commands/UpdateCommand.cs

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -155,19 +155,41 @@ protected override async Task<int> ExecuteAsync(ParseResult parseResult, Cancell
155155
var project = _projectFactory.GetProject(projectFile);
156156
var isProjectReferenceMode = project.IsUsingProjectReferences(projectFile);
157157

158-
// Check if channel or quality option was provided (channel takes precedence)
158+
// Resolve the channel using the documented precedence:
159+
// 1. explicit --channel / hidden --quality
160+
// 2. local app config "channel" (relative to the resolved AppHost project, NOT cwd)
161+
// 3. global config "channel"
162+
// 4. interactive channel prompt when appropriate (PR hives present)
163+
// 5. implicit/default channel as the documented fallback
164+
// The directory-scoped lookup is critical: `aspire update --apphost <elsewhere>`
165+
// must consult the project's directory tree, not the user's launch cwd. The
166+
// process-wide IConfiguration is rooted at the launch cwd at startup, so using
167+
// it here would silently read the wrong app's local config (issue #16650).
159168
var channelName = parseResult.GetValue(_channelOption) ?? parseResult.GetValue(_qualityOption);
169+
var channelFromConfig = false;
170+
if (string.IsNullOrWhiteSpace(channelName))
171+
{
172+
var configLookupDirectory = projectFile.Directory ?? ExecutionContext.WorkingDirectory;
173+
channelName = await _configurationService.GetConfigurationFromDirectoryAsync("channel", configLookupDirectory, cancellationToken);
174+
channelFromConfig = !string.IsNullOrWhiteSpace(channelName);
175+
}
176+
160177
PackageChannel channel;
161178

162179
var allChannels = await InteractionService.ShowStatusAsync(
163180
UpdateCommandStrings.CheckingForUpdates,
164181
async () => await _packagingService.GetChannelsAsync(cancellationToken));
165182

166-
if (!string.IsNullOrEmpty(channelName))
183+
if (!string.IsNullOrWhiteSpace(channelName))
167184
{
168185
// Try to find a channel matching the provided channel/quality
169186
channel = allChannels.FirstOrDefault(c => string.Equals(c.Name, channelName, StringComparison.OrdinalIgnoreCase))
170187
?? throw new ChannelNotFoundException($"No channel found matching '{channelName}'. Valid options are: {string.Join(", ", allChannels.Select(c => c.Name))}");
188+
189+
if (channelFromConfig)
190+
{
191+
_logger.LogDebug("Using channel '{ChannelName}' from configuration.", channel.Name);
192+
}
171193
}
172194
else if (isProjectReferenceMode)
173195
{

src/Aspire.Cli/Configuration/ConfigurationService.cs

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33

4+
using System.Globalization;
5+
using System.Text.Json;
46
using System.Text.Json.Nodes;
7+
using Aspire.Cli.Resources;
58
using Aspire.Cli.Utils;
69
using Microsoft.Extensions.Configuration;
710
using Microsoft.Extensions.Logging;
@@ -353,4 +356,91 @@ private static void FlattenJsonObject(JsonObject obj, Dictionary<string, string>
353356
var configKey = key.Replace('.', ':');
354357
return Task.FromResult(configuration[configKey]);
355358
}
359+
360+
public Task<string?> GetConfigurationFromDirectoryAsync(string key, DirectoryInfo startDirectory, CancellationToken cancellationToken = default)
361+
{
362+
ArgumentNullException.ThrowIfNull(startDirectory);
363+
364+
var configKey = key.Replace('.', ':');
365+
366+
// 1. Project-relative local settings: walk up from startDirectory.
367+
// Intentionally bypasses the process-wide IConfiguration (which is rooted at the
368+
// CLI's launch cwd via ConfigurationHelper.RegisterSettingsFiles) so that commands
369+
// that operate on a path other than cwd (e.g. `aspire update --apphost <elsewhere>`)
370+
// consult the project's own aspire.config.json instead of the caller's cwd.
371+
var localConfigPath = ConfigurationHelper.FindNearestConfigFilePath(startDirectory);
372+
if (localConfigPath is not null)
373+
{
374+
var localConfig = LoadSettingsFileForReading(localConfigPath);
375+
var localValue = localConfig[configKey];
376+
if (!string.IsNullOrWhiteSpace(localValue))
377+
{
378+
return Task.FromResult<string?>(localValue);
379+
}
380+
}
381+
382+
// 2. Global settings file fallback (lower precedence).
383+
if (File.Exists(globalSettingsFile.FullName))
384+
{
385+
var globalConfig = LoadSettingsFileForReading(globalSettingsFile.FullName);
386+
var globalValue = globalConfig[configKey];
387+
if (!string.IsNullOrWhiteSpace(globalValue))
388+
{
389+
return Task.FromResult<string?>(globalValue);
390+
}
391+
}
392+
393+
return Task.FromResult<string?>(null);
394+
}
395+
396+
/// <summary>
397+
/// Loads a single settings file into an isolated <see cref="IConfigurationRoot"/> for
398+
/// directory-scoped lookups, mirroring <c>ConfigurationHelper.AddSettingsFile</c>'s
399+
/// JSON-with-comments parsing and "throw on invalid JSON" behavior so directory-scoped
400+
/// reads fail loudly the same way startup-time loads do.
401+
/// </summary>
402+
private static IConfigurationRoot LoadSettingsFileForReading(string filePath)
403+
{
404+
string content;
405+
try
406+
{
407+
content = File.ReadAllText(filePath);
408+
}
409+
catch (IOException)
410+
{
411+
return new ConfigurationBuilder().Build();
412+
}
413+
catch (UnauthorizedAccessException)
414+
{
415+
return new ConfigurationBuilder().Build();
416+
}
417+
418+
if (string.IsNullOrWhiteSpace(content))
419+
{
420+
return new ConfigurationBuilder().Build();
421+
}
422+
423+
JsonNode? node;
424+
try
425+
{
426+
node = JsonNode.Parse(content, documentOptions: ConfigurationHelper.ParseOptions);
427+
}
428+
catch (JsonException ex)
429+
{
430+
throw new InvalidOperationException(
431+
string.Format(CultureInfo.CurrentCulture, ErrorStrings.InvalidJsonInConfigFile, filePath, ex.Message),
432+
ex);
433+
}
434+
435+
if (node is not JsonObject)
436+
{
437+
return new ConfigurationBuilder().Build();
438+
}
439+
440+
var cleanJson = node.ToJsonString();
441+
var bytes = System.Text.Encoding.UTF8.GetBytes(cleanJson);
442+
return new ConfigurationBuilder()
443+
.AddJsonStream(new MemoryStream(bytes))
444+
.Build();
445+
}
356446
}

src/Aspire.Cli/Configuration/IConfigurationService.cs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,22 @@ internal interface IConfigurationService
1111
Task<Dictionary<string, string>> GetLocalConfigurationAsync(CancellationToken cancellationToken = default);
1212
Task<Dictionary<string, string>> GetGlobalConfigurationAsync(CancellationToken cancellationToken = default);
1313
Task<string?> GetConfigurationAsync(string key, CancellationToken cancellationToken = default);
14+
15+
/// <summary>
16+
/// Reads a configuration value scoped to a specific directory rather than the
17+
/// process-wide working directory. The lookup walks upward from
18+
/// <paramref name="startDirectory"/> for the nearest <c>aspire.config.json</c>
19+
/// (or legacy <c>.aspire/settings.json</c>); if the key is not present in that file,
20+
/// falls back to the global settings file. The process-wide
21+
/// <see cref="Microsoft.Extensions.Configuration.IConfiguration"/> (which is rooted at
22+
/// the working directory the CLI was launched from) is intentionally NOT consulted,
23+
/// so commands like <c>aspire update --apphost &lt;path&gt;</c> can resolve config
24+
/// from the project's directory tree instead of the caller's cwd.
25+
/// </summary>
26+
/// <remarks>
27+
/// Throws <see cref="System.InvalidOperationException"/> if a settings file is found
28+
/// but cannot be parsed as JSON, matching the behavior of startup-time settings load.
29+
/// </remarks>
30+
Task<string?> GetConfigurationFromDirectoryAsync(string key, DirectoryInfo startDirectory, CancellationToken cancellationToken = default);
1431
string GetSettingsFilePath(bool isGlobal);
1532
}

tests/Aspire.Cli.Tests/Commands/ConfigCommandTests.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -934,6 +934,11 @@ public Task<Dictionary<string, string>> GetGlobalConfigurationAsync(Cancellation
934934
return Task.FromResult<string?>(key);
935935
}
936936

937+
public Task<string?> GetConfigurationFromDirectoryAsync(string key, DirectoryInfo startDirectory, CancellationToken cancellationToken = default)
938+
{
939+
return GetConfigurationAsync(key, cancellationToken);
940+
}
941+
937942
public string GetSettingsFilePath(bool isGlobal)
938943
{
939944
return string.Empty;

tests/Aspire.Cli.Tests/Commands/InitCommandTests.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -710,6 +710,9 @@ private sealed class FakeConfigurationServiceWithChannel(string channelValue) :
710710
public Task<string?> GetConfigurationAsync(string key, CancellationToken cancellationToken = default)
711711
=> Task.FromResult<string?>(string.Equals(key, "channel", StringComparison.Ordinal) ? channelValue : null);
712712

713+
public Task<string?> GetConfigurationFromDirectoryAsync(string key, DirectoryInfo startDirectory, CancellationToken cancellationToken = default)
714+
=> GetConfigurationAsync(key, cancellationToken);
715+
713716
public Task SetConfigurationAsync(string key, string value, bool isGlobal = false, CancellationToken cancellationToken = default)
714717
=> Task.CompletedTask;
715718

0 commit comments

Comments
 (0)