Skip to content

Commit e8bb007

Browse files
mitchdennyMitch DennyCopilot
authored
Fix config discovery to search from apphost directory and add aspire.config.json to .NET templates (#15423)
* Add E2E test: config discovery from apphost directory Adds a test that verifies aspire.config.json is discovered adjacent to the apphost file when running from a parent directory, rather than being recreated in the current working directory. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix config discovery to search from apphost directory When an apphost is found via recursive search, use its directory as the search root for aspire.config.json (walking upward) instead of defaulting to CWD. This prevents creating a duplicate config when the user runs 'aspire run' from a parent directory after 'aspire new'. Changes: - Add ConfigurationHelper.FindNearestConfigFilePath helper - ProjectLocator.CreateSettingsFileAsync: skip creation when config already exists near the apphost with valid appHost.path - GuestAppHostProject.GetConfigDirectory: search from apphost directory so launch profiles are read from the correct config Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix: only skip config creation when path matches discovered apphost The previous check skipped creation whenever any appHost.path existed, which broke config healing when the path was stale/invalid. Now we resolve the stored path and only skip if it points to the same apphost that was discovered via recursive search. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add aspire.config.json to .NET apphost templates Include aspire.config.json with appHost.path in the aspire-apphost (csproj) and aspire-apphost-singlefile templates so that aspire run from a parent directory finds the config adjacent to the apphost instead of creating a spurious one in the working directory. The csproj template uses sourceName 'Aspire.AppHost1' so the template engine substitutes the actual project name automatically. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add aspire.config.json to solution-level apphost templates Add aspire.config.json at the solution root for aspire-empty, aspire-starter, and aspire-ts-cs-starter templates. Each points to the AppHost csproj via a relative path. The template engine substitutes the sourceName so the path matches the actual project name chosen by the user. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add aspire.config.json to aspire-py-starter template Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address review feedback: fix legacy path handling, stale docs, dead code - Fix legacy .aspire/settings.json path handling in ProjectLocator: resolve config root to parent of .aspire/ directory - Update GetConfigDirectory XML doc to reflect new behavior - Remove unused _configurationService field and constructor parameter - Assert originalContent == currentContent in E2E test Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Mitch Denny <mitch@mitchdeny.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent aad1601 commit e8bb007

File tree

11 files changed

+266
-29
lines changed

11 files changed

+266
-29
lines changed

src/Aspire.Cli/Projects/GuestAppHostProject.cs

Lines changed: 16 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,6 @@ internal sealed class GuestAppHostProject : IAppHostProject, IGuestAppHostSdkGen
3737
private readonly IDotNetCliRunner _runner;
3838
private readonly IPackagingService _packagingService;
3939
private readonly IConfiguration _configuration;
40-
private readonly IConfigurationService _configurationService;
4140
private readonly IFeatures _features;
4241
private readonly ILanguageDiscovery _languageDiscovery;
4342
private readonly ILogger<GuestAppHostProject> _logger;
@@ -58,7 +57,6 @@ public GuestAppHostProject(
5857
IDotNetCliRunner runner,
5958
IPackagingService packagingService,
6059
IConfiguration configuration,
61-
IConfigurationService configurationService,
6260
IFeatures features,
6361
ILanguageDiscovery languageDiscovery,
6462
ILogger<GuestAppHostProject> logger,
@@ -73,7 +71,6 @@ public GuestAppHostProject(
7371
_runner = runner;
7472
_packagingService = packagingService;
7573
_configuration = configuration;
76-
_configurationService = configurationService;
7774
_features = features;
7875
_languageDiscovery = languageDiscovery;
7976
_logger = logger;
@@ -167,27 +164,27 @@ private async Task<List<IntegrationReference>> GetIntegrationReferencesAsync(
167164

168165
/// <summary>
169166
/// Resolves the directory containing the nearest aspire.config.json (or legacy settings file)
170-
/// by using the already-resolved path from <see cref="IConfigurationService"/>.
167+
/// by searching upward from <paramref name="appHostDirectory"/>.
171168
/// Falls back to <paramref name="appHostDirectory"/> when no config file is found.
172169
/// </summary>
173-
private DirectoryInfo GetConfigDirectory(DirectoryInfo appHostDirectory)
170+
private static DirectoryInfo GetConfigDirectory(DirectoryInfo appHostDirectory)
174171
{
175-
var settingsFilePath = _configurationService.GetSettingsFilePath(isGlobal: false);
176-
var settingsFile = new FileInfo(settingsFilePath);
177-
178-
// If the settings file exists and has a parent directory, use that
179-
if (settingsFile.Directory is { Exists: true })
172+
// Search from the apphost's directory upward to find the nearest config file.
173+
var nearAppHost = ConfigurationHelper.FindNearestConfigFilePath(appHostDirectory);
174+
if (nearAppHost is not null)
180175
{
181-
// For legacy .aspire/settings.json, the config directory is the parent of .aspire/
182-
// TODO: Remove legacy .aspire/ check once confident most users have migrated.
183-
// Tracked by https://github.com/dotnet/aspire/issues/15239
184-
if (string.Equals(settingsFile.Directory.Name, ".aspire", StringComparison.OrdinalIgnoreCase)
185-
&& settingsFile.Directory.Parent is not null)
176+
var configFile = new FileInfo(nearAppHost);
177+
if (configFile.Directory is { Exists: true })
186178
{
187-
return settingsFile.Directory.Parent;
188-
}
179+
// For legacy .aspire/settings.json, the config directory is the parent of .aspire/
180+
if (string.Equals(configFile.Directory.Name, ".aspire", StringComparison.OrdinalIgnoreCase)
181+
&& configFile.Directory.Parent is not null)
182+
{
183+
return configFile.Directory.Parent;
184+
}
189185

190-
return settingsFile.Directory;
186+
return configFile.Directory;
187+
}
191188
}
192189

193190
return appHostDirectory;
@@ -209,7 +206,7 @@ private AspireConfigFile LoadConfiguration(DirectoryInfo directory)
209206
}
210207
}
211208

212-
private void SaveConfiguration(AspireConfigFile config, DirectoryInfo directory)
209+
private static void SaveConfiguration(AspireConfigFile config, DirectoryInfo directory)
213210
{
214211
var configDir = GetConfigDirectory(directory);
215212
config.Save(configDir.FullName);

src/Aspire.Cli/Projects/ProjectLocator.cs

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -401,6 +401,47 @@ public async Task<AppHostProjectSearchResult> UseOrFindAppHostProjectFileAsync(F
401401

402402
private async Task CreateSettingsFileAsync(FileInfo projectFile, CancellationToken cancellationToken)
403403
{
404+
// Search from the apphost's directory upward for an existing config file.
405+
// This handles the case where "aspire new" created a project in a subdirectory
406+
// and the user runs "aspire run" from the parent without cd-ing first.
407+
if (projectFile.Directory is { } appHostDir)
408+
{
409+
var nearAppHost = ConfigurationHelper.FindNearestConfigFilePath(appHostDir);
410+
if (nearAppHost is not null)
411+
{
412+
var configDir = Path.GetDirectoryName(nearAppHost)!;
413+
414+
// For legacy .aspire/settings.json, the config root is the parent of .aspire/
415+
var trimmedConfigDir = configDir.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
416+
if (string.Equals(Path.GetFileName(trimmedConfigDir), ".aspire", StringComparison.OrdinalIgnoreCase))
417+
{
418+
var parentDir = Directory.GetParent(trimmedConfigDir);
419+
if (parentDir is not null)
420+
{
421+
configDir = parentDir.FullName;
422+
}
423+
}
424+
425+
var existingConfig = AspireConfigFile.Load(configDir);
426+
if (existingConfig?.AppHost?.Path is { } existingPath)
427+
{
428+
// Resolve the stored path relative to the config file's directory.
429+
var resolvedPath = Path.GetFullPath(
430+
Path.IsPathRooted(existingPath) ? existingPath : Path.Combine(configDir, existingPath));
431+
432+
// Only skip creation if the config already points to the discovered apphost.
433+
// If the path is stale/invalid, fall through so the config gets healed.
434+
if (string.Equals(resolvedPath, projectFile.FullName, StringComparison.OrdinalIgnoreCase))
435+
{
436+
logger.LogDebug(
437+
"Config at {Path} already references apphost {AppHost}, skipping creation",
438+
nearAppHost, projectFile.FullName);
439+
return;
440+
}
441+
}
442+
}
443+
}
444+
404445
var settingsFile = GetOrCreateLocalAspireConfigFile();
405446
var fileExisted = settingsFile.Exists;
406447

src/Aspire.Cli/Utils/ConfigurationHelper.cs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,34 @@ internal static string BuildPathToSettingsJsonFile(string workingDirectory)
6969
return Path.Combine(workingDirectory, ".aspire", "settings.json");
7070
}
7171

72+
/// <summary>
73+
/// Searches upward from <paramref name="startDirectory"/> for the nearest
74+
/// <c>aspire.config.json</c> or legacy <c>.aspire/settings.json</c>.
75+
/// </summary>
76+
/// <returns>The full path to the config file, or <c>null</c> if none is found.</returns>
77+
internal static string? FindNearestConfigFilePath(DirectoryInfo startDirectory)
78+
{
79+
var searchDir = startDirectory;
80+
while (searchDir is not null)
81+
{
82+
var configPath = Path.Combine(searchDir.FullName, AspireConfigFile.FileName);
83+
if (File.Exists(configPath))
84+
{
85+
return configPath;
86+
}
87+
88+
var legacyPath = BuildPathToSettingsJsonFile(searchDir.FullName);
89+
if (File.Exists(legacyPath))
90+
{
91+
return legacyPath;
92+
}
93+
94+
searchDir = searchDir.Parent;
95+
}
96+
97+
return null;
98+
}
99+
72100
/// <summary>
73101
/// Serializes a JsonObject and writes it to a settings file, creating the directory if needed.
74102
/// </summary>
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"appHost": {
3+
"path": "apphost.cs"
4+
}
5+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"appHost": {
3+
"path": "Aspire.AppHost1.csproj"
4+
}
5+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"appHost": {
3+
"path": "AspireApplication.1.AppHost/AspireApplication.1.AppHost.csproj"
4+
}
5+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"appHost": {
3+
"path": "apphost.cs"
4+
}
5+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"appHost": {
3+
"path": "Aspire-StarterApplication.1.AppHost/Aspire-StarterApplication.1.AppHost.csproj"
4+
}
5+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"appHost": {
3+
"path": "Aspire-StarterApplication.1.AppHost/Aspire-StarterApplication.1.AppHost.csproj"
4+
}
5+
}
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System.Text.Json;
5+
using Aspire.Cli.EndToEnd.Tests.Helpers;
6+
using Aspire.Cli.Tests.Utils;
7+
using Hex1b.Automation;
8+
using Hex1b.Input;
9+
using Xunit;
10+
11+
namespace Aspire.Cli.EndToEnd.Tests;
12+
13+
/// <summary>
14+
/// End-to-end tests verifying that <c>aspire.config.json</c> is discovered from the
15+
/// apphost's directory rather than being recreated in the current working directory.
16+
/// </summary>
17+
/// <remarks>
18+
/// Reproduces the bug where <c>aspire new myproject</c> creates the config inside
19+
/// <c>myproject/</c>, but running <c>aspire run</c> from the parent directory
20+
/// creates a spurious <c>aspire.config.json</c> in the parent instead of finding
21+
/// the one adjacent to <c>apphost.ts</c>.
22+
/// </remarks>
23+
public sealed class ConfigDiscoveryTests(ITestOutputHelper output)
24+
{
25+
/// <summary>
26+
/// Verifies that running <c>aspire run</c> from a parent directory discovers the
27+
/// existing <c>aspire.config.json</c> next to the apphost rather than creating a
28+
/// new one in the current working directory.
29+
/// </summary>
30+
[Fact]
31+
public async Task RunFromParentDirectory_UsesExistingConfigNearAppHost()
32+
{
33+
var repoRoot = CliE2ETestHelpers.GetRepoRoot();
34+
var installMode = CliE2ETestHelpers.DetectDockerInstallMode(repoRoot);
35+
var workspace = TemporaryWorkspace.Create(output);
36+
37+
using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(
38+
repoRoot, installMode, output,
39+
variant: CliE2ETestHelpers.DockerfileVariant.Polyglot,
40+
mountDockerSocket: true,
41+
workspace: workspace);
42+
43+
var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken);
44+
45+
var counter = new SequenceCounter();
46+
var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
47+
48+
await auto.PrepareDockerEnvironmentAsync(counter, workspace);
49+
await auto.InstallAspireCliInDockerAsync(installMode, counter);
50+
51+
const string projectName = "ConfigTest";
52+
53+
// Step 1: Create a TypeScript Empty AppHost project.
54+
// This creates a subdirectory with aspire.config.json inside it.
55+
await auto.AspireNewAsync(projectName, counter, template: AspireTemplate.TypeScriptEmptyAppHost);
56+
57+
// Capture the original config content before running from the parent directory.
58+
var projectConfigPath = Path.Combine(
59+
workspace.WorkspaceRoot.FullName, projectName, "aspire.config.json");
60+
var parentConfigPath = Path.Combine(
61+
workspace.WorkspaceRoot.FullName, "aspire.config.json");
62+
63+
// Verify the project config was created by aspire new
64+
Assert.True(File.Exists(projectConfigPath),
65+
$"aspire new should have created {projectConfigPath}");
66+
67+
var originalContent = File.ReadAllText(projectConfigPath);
68+
69+
// Step 2: Stay in the parent directory (do NOT cd into the project).
70+
// Run aspire run — this should find the apphost in the subdirectory
71+
// and use the adjacent aspire.config.json, not create a new one in CWD.
72+
// Run aspire run — this should find the apphost in the subdirectory
73+
// and use the adjacent aspire.config.json, not create a new one in CWD.
74+
await auto.TypeAsync($"aspire run --apphost {projectName}");
75+
await auto.EnterAsync();
76+
77+
// Wait for the run to start (or fail) — either way the config discovery has happened.
78+
await auto.WaitUntilAsync(s =>
79+
{
80+
// If a "Select an apphost" prompt appears, the bug may have caused multiple detection
81+
if (s.ContainsText("Select an apphost to use:"))
82+
{
83+
throw new InvalidOperationException("Multiple apphosts incorrectly detected");
84+
}
85+
86+
return s.ContainsText("Press CTRL+C to stop the apphost and exit.")
87+
|| s.ContainsText("ERR:");
88+
}, timeout: TimeSpan.FromMinutes(3), description: "aspire run started or errored");
89+
90+
// Stop the apphost
91+
await auto.Ctrl().KeyAsync(Hex1bKey.C);
92+
await auto.WaitForAnyPromptAsync(counter, timeout: TimeSpan.FromSeconds(30));
93+
94+
// Step 3: Assertions on file system state (host-side via bind mount).
95+
96+
// The parent directory should NOT have an aspire.config.json.
97+
Assert.False(File.Exists(parentConfigPath),
98+
$"aspire.config.json should NOT be created in the parent/CWD directory. " +
99+
$"Found: {parentConfigPath}");
100+
101+
// The project's aspire.config.json should still exist with its original rich content.
102+
Assert.True(File.Exists(projectConfigPath),
103+
$"aspire.config.json in project directory should still exist: {projectConfigPath}");
104+
105+
var currentContent = File.ReadAllText(projectConfigPath);
106+
107+
// Verify the config was not modified by the run.
108+
Assert.Equal(originalContent, currentContent);
109+
110+
using var doc = JsonDocument.Parse(currentContent);
111+
var root = doc.RootElement;
112+
113+
// Verify appHost.path is "apphost.ts"
114+
Assert.True(root.TryGetProperty("appHost", out var appHost),
115+
$"aspire.config.json missing 'appHost' property. Content:\n{currentContent}");
116+
Assert.True(appHost.TryGetProperty("path", out var pathProp),
117+
$"aspire.config.json missing 'appHost.path'. Content:\n{currentContent}");
118+
Assert.Equal("apphost.ts", pathProp.GetString());
119+
120+
// Verify language is typescript
121+
Assert.True(appHost.TryGetProperty("language", out var langProp),
122+
$"aspire.config.json missing 'appHost.language'. Content:\n{currentContent}");
123+
Assert.Contains("typescript", langProp.GetString(), StringComparison.OrdinalIgnoreCase);
124+
125+
// Verify profiles section exists with applicationUrl
126+
Assert.True(root.TryGetProperty("profiles", out var profiles),
127+
$"aspire.config.json missing 'profiles' section. Content:\n{currentContent}");
128+
Assert.True(profiles.EnumerateObject().Any(),
129+
$"aspire.config.json 'profiles' section is empty. Content:\n{currentContent}");
130+
131+
// At least one profile should have an applicationUrl
132+
var hasApplicationUrl = false;
133+
foreach (var profile in profiles.EnumerateObject())
134+
{
135+
if (profile.Value.TryGetProperty("applicationUrl", out _))
136+
{
137+
hasApplicationUrl = true;
138+
break;
139+
}
140+
}
141+
Assert.True(hasApplicationUrl,
142+
$"No profile has 'applicationUrl'. Content:\n{currentContent}");
143+
144+
await auto.TypeAsync("exit");
145+
await auto.EnterAsync();
146+
147+
await pendingRun;
148+
}
149+
}

0 commit comments

Comments
 (0)