diff --git a/playground/polyglot/TypeScript/Aspire.Hosting.Azure.Storage/ValidationAppHost/.aspire/settings.json b/playground/polyglot/TypeScript/Aspire.Hosting.Azure.Storage/ValidationAppHost/.aspire/settings.json index 6f25239fe75..13a7ec9205e 100644 --- a/playground/polyglot/TypeScript/Aspire.Hosting.Azure.Storage/ValidationAppHost/.aspire/settings.json +++ b/playground/polyglot/TypeScript/Aspire.Hosting.Azure.Storage/ValidationAppHost/.aspire/settings.json @@ -1,9 +1,7 @@ { "appHostPath": "../apphost.ts", "language": "typescript/nodejs", - "channel": "pr-13970", - "sdkVersion": "13.2.0-pr.13970.g9fb24263", "packages": { - "Aspire.Hosting.Azure.Storage": "13.2.0-pr.13970.g9fb24263" + "Aspire.Hosting.Azure.Storage": "" } -} \ No newline at end of file +} diff --git a/playground/polyglot/TypeScript/Aspire.Hosting.RabbitMQ/ValidationAppHost/.aspire/settings.json b/playground/polyglot/TypeScript/Aspire.Hosting.RabbitMQ/ValidationAppHost/.aspire/settings.json index 90e901beee5..780d67e6150 100644 --- a/playground/polyglot/TypeScript/Aspire.Hosting.RabbitMQ/ValidationAppHost/.aspire/settings.json +++ b/playground/polyglot/TypeScript/Aspire.Hosting.RabbitMQ/ValidationAppHost/.aspire/settings.json @@ -1,9 +1,7 @@ { "appHostPath": "../apphost.ts", "language": "typescript/nodejs", - "channel": "local", - "sdkVersion": "13.2.0-preview.1.26081.1", "packages": { - "Aspire.Hosting.RabbitMQ": "13.2.0-preview.1.26081.1" + "Aspire.Hosting.RabbitMQ": "" } -} \ No newline at end of file +} diff --git a/playground/polyglot/TypeScript/Aspire.Hosting.SqlServer/.aspire/settings.json b/playground/polyglot/TypeScript/Aspire.Hosting.SqlServer/.aspire/settings.json index 32bf312c1cb..640997ec58b 100644 --- a/playground/polyglot/TypeScript/Aspire.Hosting.SqlServer/.aspire/settings.json +++ b/playground/polyglot/TypeScript/Aspire.Hosting.SqlServer/.aspire/settings.json @@ -1,9 +1,7 @@ { "appHostPath": "../apphost.ts", "language": "typescript/nodejs", - "channel": "pr-13970", - "sdkVersion": "13.1.0", "packages": { - "Aspire.Hosting.SqlServer": "13.2.0-pr.13970.g0575147c" + "Aspire.Hosting.SqlServer": "" } -} \ No newline at end of file +} diff --git a/src/Aspire.Cli/Commands/AddCommand.cs b/src/Aspire.Cli/Commands/AddCommand.cs index a27bdebac36..7a7e1288f69 100644 --- a/src/Aspire.Cli/Commands/AddCommand.cs +++ b/src/Aspire.Cli/Commands/AddCommand.cs @@ -100,8 +100,13 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell string? configuredChannel = null; if (project.LanguageId != KnownLanguageId.CSharp) { - var settings = AspireJsonConfiguration.Load(effectiveAppHostProjectFile.Directory!.FullName); - configuredChannel = settings?.Channel; + var appHostDirectory = effectiveAppHostProjectFile.Directory!.FullName; + var isProjectReferenceMode = AspireRepositoryDetector.DetectRepositoryRoot(appHostDirectory) is not null; + if (!isProjectReferenceMode) + { + var settings = AspireJsonConfiguration.Load(appHostDirectory); + configuredChannel = settings?.Channel; + } } var packagesWithChannels = await InteractionService.ShowStatusAsync( diff --git a/src/Aspire.Cli/Commands/UpdateCommand.cs b/src/Aspire.Cli/Commands/UpdateCommand.cs index 7427721146e..c75e943e38b 100644 --- a/src/Aspire.Cli/Commands/UpdateCommand.cs +++ b/src/Aspire.Cli/Commands/UpdateCommand.cs @@ -146,18 +146,26 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell return ExitCodeConstants.FailedToFindProject; } - var allChannels = await _packagingService.GetChannelsAsync(cancellationToken); + var project = _projectFactory.GetProject(projectFile); + var isProjectReferenceMode = project.IsUsingProjectReferences(projectFile); // Check if channel or quality option was provided (channel takes precedence) var channelName = parseResult.GetValue(_channelOption) ?? parseResult.GetValue(_qualityOption); PackageChannel channel; + var allChannels = await _packagingService.GetChannelsAsync(cancellationToken); + if (!string.IsNullOrEmpty(channelName)) { // Try to find a channel matching the provided channel/quality channel = allChannels.FirstOrDefault(c => string.Equals(c.Name, channelName, StringComparison.OrdinalIgnoreCase)) ?? throw new ChannelNotFoundException($"No channel found matching '{channelName}'. Valid options are: {string.Join(", ", allChannels.Select(c => c.Name))}"); } + else if (isProjectReferenceMode) + { + channel = allChannels.FirstOrDefault(c => c.Type is PackageChannelType.Implicit) + ?? allChannels.First(); + } else { // If there are hives (PR build directories), prompt for channel selection. @@ -181,8 +189,7 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell } } - // Get the appropriate project handler and update packages - var project = _projectFactory.GetProject(projectFile); + // Update packages using the appropriate project handler var updateContext = new UpdatePackagesContext { AppHostFile = projectFile, diff --git a/src/Aspire.Cli/Configuration/AspireJsonConfiguration.cs b/src/Aspire.Cli/Configuration/AspireJsonConfiguration.cs index 3afeaca8846..3b25cc8785d 100644 --- a/src/Aspire.Cli/Configuration/AspireJsonConfiguration.cs +++ b/src/Aspire.Cli/Configuration/AspireJsonConfiguration.cs @@ -163,32 +163,56 @@ public bool RemovePackage(string packageId) } /// - /// Gets all package references including the base Aspire.Hosting packages. - /// Uses the SdkVersion for base packages. - /// Note: Aspire.Hosting.AppHost is an SDK package (not a runtime DLL) and is excluded. + /// Gets the effective SDK version for package-based AppHost preparation. + /// Falls back to when no SDK version is configured. + /// + public string GetEffectiveSdkVersion(string defaultSdkVersion) + { + return string.IsNullOrWhiteSpace(SdkVersion) ? defaultSdkVersion : SdkVersion; + } + + /// + /// Gets all package references including the base Aspire.Hosting package. + /// Empty package versions in settings are resolved to the effective SDK version. /// + /// Default SDK version to use when not configured. /// Enumerable of (PackageName, Version) tuples. - public IEnumerable<(string Name, string Version)> GetAllPackages() + public IEnumerable<(string Name, string Version)> GetAllPackages(string defaultSdkVersion) { - var sdkVersion = SdkVersion ?? throw new InvalidOperationException("SdkVersion must be set before calling GetAllPackages. Use LoadOrCreate to ensure it's set."); + var sdkVersion = GetEffectiveSdkVersion(defaultSdkVersion); - // Base packages always included (Aspire.Hosting.AppHost is an SDK, not a runtime DLL) + // Base package always included (Aspire.Hosting.AppHost is an SDK, not a runtime DLL) yield return ("Aspire.Hosting", sdkVersion); - // Additional packages from settings - if (Packages is not null) + if (Packages is null) + { + yield break; + } + + foreach (var (packageName, version) in Packages) { - foreach (var (packageName, version) in Packages) + // Skip base packages and SDK-only packages + if (string.Equals(packageName, "Aspire.Hosting", StringComparison.OrdinalIgnoreCase) || + string.Equals(packageName, "Aspire.Hosting.AppHost", StringComparison.OrdinalIgnoreCase)) { - // Skip base packages and SDK-only packages - if (string.Equals(packageName, "Aspire.Hosting", StringComparison.OrdinalIgnoreCase) || - string.Equals(packageName, "Aspire.Hosting.AppHost", StringComparison.OrdinalIgnoreCase)) - { - continue; - } - - yield return (packageName, version); + continue; } + + yield return (packageName, string.IsNullOrWhiteSpace(version) ? sdkVersion : version); } } + + /// + /// Gets all package references including the base Aspire.Hosting packages. + /// Uses the SdkVersion for base packages. + /// Note: Aspire.Hosting.AppHost is an SDK package (not a runtime DLL) and is excluded. + /// + /// Enumerable of (PackageName, Version) tuples. + public IEnumerable<(string Name, string Version)> GetAllPackages() + { + var sdkVersion = !string.IsNullOrWhiteSpace(SdkVersion) + ? SdkVersion + : throw new InvalidOperationException("SdkVersion must be set to a non-empty value before calling GetAllPackages. Use LoadOrCreate to ensure it's set."); + return GetAllPackages(sdkVersion); + } } diff --git a/src/Aspire.Cli/Projects/AppHostServerProject.cs b/src/Aspire.Cli/Projects/AppHostServerProject.cs index df3784ea968..c864bfb0835 100644 --- a/src/Aspire.Cli/Projects/AppHostServerProject.cs +++ b/src/Aspire.Cli/Projects/AppHostServerProject.cs @@ -8,6 +8,7 @@ using Aspire.Cli.DotNet; using Aspire.Cli.NuGet; using Aspire.Cli.Packaging; +using Aspire.Cli.Utils; using Microsoft.Extensions.Logging; namespace Aspire.Cli.Projects; @@ -58,7 +59,7 @@ public async Task CreateAsync(string appPath, Cancellatio } // Priority 1: Check for dev mode (ASPIRE_REPO_ROOT or running from Aspire source repo) - var repoRoot = DetectAspireRepoRoot(); + var repoRoot = AspireRepositoryDetector.DetectRepositoryRoot(appPath); if (repoRoot is not null) { return new DotNetBasedAppHostServerProject( @@ -91,40 +92,4 @@ public async Task CreateAsync(string appPath, Cancellatio "No Aspire AppHost server is available. Ensure the Aspire CLI is installed " + "with a valid bundle layout, or reinstall using 'aspire setup --force'."); } - - /// - /// Detects the Aspire repository root for dev mode. - /// Checks ASPIRE_REPO_ROOT env var first, then walks up from the CLI executable - /// looking for a git repo containing Aspire.slnx. - /// - private static string? DetectAspireRepoRoot() - { - // Check explicit environment variable - var envRoot = Environment.GetEnvironmentVariable("ASPIRE_REPO_ROOT"); - if (!string.IsNullOrEmpty(envRoot) && Directory.Exists(envRoot)) - { - return envRoot; - } - - // Auto-detect: walk up from the CLI executable looking for .git + Aspire.slnx - var cliPath = Environment.ProcessPath; - if (string.IsNullOrEmpty(cliPath)) - { - return null; - } - - var dir = Path.GetDirectoryName(cliPath); - while (dir is not null) - { - if (Directory.Exists(Path.Combine(dir, ".git")) && - File.Exists(Path.Combine(dir, "Aspire.slnx"))) - { - return dir; - } - - dir = Path.GetDirectoryName(dir); - } - - return null; - } } diff --git a/src/Aspire.Cli/Projects/DotNetAppHostProject.cs b/src/Aspire.Cli/Projects/DotNetAppHostProject.cs index 7ee32718103..ea56782b2b0 100644 --- a/src/Aspire.Cli/Projects/DotNetAppHostProject.cs +++ b/src/Aspire.Cli/Projects/DotNetAppHostProject.cs @@ -139,6 +139,12 @@ private static bool IsValidSingleFileAppHost(FileInfo candidateFile) /// public string? AppHostFileName => "apphost.cs"; + /// + public bool IsUsingProjectReferences(FileInfo appHostFile) + { + return false; + } + // ═══════════════════════════════════════════════════════════════ // EXECUTION // ═══════════════════════════════════════════════════════════════ diff --git a/src/Aspire.Cli/Projects/GuestAppHostProject.cs b/src/Aspire.Cli/Projects/GuestAppHostProject.cs index 1ce14fd6e12..4fa1af18f8f 100644 --- a/src/Aspire.Cli/Projects/GuestAppHostProject.cs +++ b/src/Aspire.Cli/Projects/GuestAppHostProject.cs @@ -128,6 +128,12 @@ public bool CanHandle(FileInfo appHostFile) /// public string? AppHostFileName => _resolvedLanguage.DetectionPatterns.FirstOrDefault(); + /// + public bool IsUsingProjectReferences(FileInfo appHostFile) + { + return AspireRepositoryDetector.DetectRepositoryRoot(appHostFile.Directory?.FullName) is not null; + } + /// /// Gets all packages including the code generation package for the current language. /// @@ -135,15 +141,28 @@ public bool CanHandle(FileInfo appHostFile) AspireJsonConfiguration config, CancellationToken cancellationToken) { - var packages = config.GetAllPackages().ToList(); + var defaultSdkVersion = GetEffectiveSdkVersion(); + var packages = config.GetAllPackages(defaultSdkVersion).ToList(); var codeGenPackage = await _languageDiscovery.GetPackageForLanguageAsync(_resolvedLanguage.LanguageId, cancellationToken); if (codeGenPackage is not null) { - packages.Add((codeGenPackage, config.SdkVersion!)); + var codeGenVersion = config.GetEffectiveSdkVersion(defaultSdkVersion); + packages.Add((codeGenPackage, codeGenVersion)); } return packages; } + private AspireJsonConfiguration LoadConfiguration(DirectoryInfo directory) + { + var effectiveSdkVersion = GetEffectiveSdkVersion(); + return AspireJsonConfiguration.LoadOrCreate(directory.FullName, effectiveSdkVersion); + } + + private string GetPrepareSdkVersion(AspireJsonConfiguration config) + { + return config.GetEffectiveSdkVersion(GetEffectiveSdkVersion()); + } + /// /// Prepares the AppHost server (creates files and builds for dev mode, restores packages for prebuilt mode). /// @@ -162,14 +181,14 @@ public bool CanHandle(FileInfo appHostFile) /// private async Task BuildAndGenerateSdkAsync(DirectoryInfo directory, CancellationToken cancellationToken) { + var appHostServerProject = await _appHostServerProjectFactory.CreateAsync(directory.FullName, cancellationToken); + // Step 1: Load config - source of truth for SDK version and packages - var effectiveSdkVersion = GetEffectiveSdkVersion(); - var config = AspireJsonConfiguration.LoadOrCreate(directory.FullName, effectiveSdkVersion); + var config = LoadConfiguration(directory); var packages = await GetAllPackagesAsync(config, cancellationToken); + var sdkVersion = GetPrepareSdkVersion(config); - var appHostServerProject = await _appHostServerProjectFactory.CreateAsync(directory.FullName, cancellationToken); - - var (buildSuccess, buildOutput, _, _) = await PrepareAppHostServerAsync(appHostServerProject, config.SdkVersion!, packages, cancellationToken); + var (buildSuccess, buildOutput, _, _) = await PrepareAppHostServerAsync(appHostServerProject, sdkVersion, packages, cancellationToken); if (!buildSuccess) { if (buildOutput is not null) @@ -269,19 +288,19 @@ public async Task RunAsync(AppHostProjectContext context, CancellationToken } // Build phase: build AppHost server (dependency install happens after server starts) + var appHostServerProject = await _appHostServerProjectFactory.CreateAsync(directory.FullName, cancellationToken); + // Load config - source of truth for SDK version and packages - var effectiveSdkVersion = GetEffectiveSdkVersion(); - var config = AspireJsonConfiguration.LoadOrCreate(directory.FullName, effectiveSdkVersion); + var config = LoadConfiguration(directory); var packages = await GetAllPackagesAsync(config, cancellationToken); - - var appHostServerProject = await _appHostServerProjectFactory.CreateAsync(directory.FullName, cancellationToken); + var sdkVersion = GetPrepareSdkVersion(config); var buildResult = await _interactionService.ShowStatusAsync( ":gear: Preparing Aspire server...", async () => { // Prepare the AppHost server (build for dev mode, restore for prebuilt) - var (prepareSuccess, prepareOutput, channelName, needsCodeGen) = await PrepareAppHostServerAsync(appHostServerProject, config.SdkVersion!, packages, cancellationToken); + var (prepareSuccess, prepareOutput, channelName, needsCodeGen) = await PrepareAppHostServerAsync(appHostServerProject, sdkVersion, packages, cancellationToken); if (!prepareSuccess) { return (Success: false, Output: prepareOutput, Error: "Failed to prepare app host.", ChannelName: (string?)null, NeedsCodeGen: false); @@ -557,14 +576,13 @@ public async Task PublishAsync(PublishContext context, CancellationToken ca try { // Step 1: Load config - source of truth for SDK version and packages - var effectiveSdkVersion = GetEffectiveSdkVersion(); - var config = AspireJsonConfiguration.LoadOrCreate(directory.FullName, effectiveSdkVersion); - var packages = await GetAllPackagesAsync(config, cancellationToken); - var appHostServerProject = await _appHostServerProjectFactory.CreateAsync(directory.FullName, cancellationToken); + var config = LoadConfiguration(directory); + var packages = await GetAllPackagesAsync(config, cancellationToken); + var sdkVersion = GetPrepareSdkVersion(config); // Prepare the AppHost server (build for dev mode, restore for prebuilt) - var (prepareSuccess, prepareOutput, _, needsCodeGen) = await PrepareAppHostServerAsync(appHostServerProject, config.SdkVersion!, packages, cancellationToken); + var (prepareSuccess, prepareOutput, _, needsCodeGen) = await PrepareAppHostServerAsync(appHostServerProject, sdkVersion, packages, cancellationToken); if (!prepareSuccess) { // Set OutputCollector so PipelineCommandBase can display errors @@ -802,8 +820,7 @@ public async Task AddPackageAsync(AddPackageContext context, CancellationT } // Load config - source of truth for SDK version and packages - var effectiveSdkVersion = GetEffectiveSdkVersion(); - var config = AspireJsonConfiguration.LoadOrCreate(directory.FullName, effectiveSdkVersion); + var config = LoadConfiguration(directory); // Update .aspire/settings.json with the new package config.AddOrUpdatePackage(context.PackageId, context.PackageVersion); @@ -825,8 +842,7 @@ public async Task UpdatePackagesAsync(UpdatePackagesContex } // Load config - source of truth for SDK version and packages - var effectiveSdkVersion = GetEffectiveSdkVersion(); - var config = AspireJsonConfiguration.LoadOrCreate(directory.FullName, effectiveSdkVersion); + var config = LoadConfiguration(directory); // Find updates for SDK version and packages string? newSdkVersion = null; diff --git a/src/Aspire.Cli/Projects/IAppHostProject.cs b/src/Aspire.Cli/Projects/IAppHostProject.cs index 5a747b65b4a..f2d7b59a1f8 100644 --- a/src/Aspire.Cli/Projects/IAppHostProject.cs +++ b/src/Aspire.Cli/Projects/IAppHostProject.cs @@ -166,6 +166,13 @@ internal interface IAppHostProject /// string? AppHostFileName { get; } + /// + /// Determines whether this AppHost should use project references instead of package references. + /// + /// The AppHost file being operated on. + /// when project-reference mode should be used; otherwise . + bool IsUsingProjectReferences(FileInfo appHostFile); + /// /// Runs the AppHost project. /// diff --git a/src/Aspire.Cli/Projects/ProjectLocator.cs b/src/Aspire.Cli/Projects/ProjectLocator.cs index 6d68a96a1a7..43d4f455a8e 100644 --- a/src/Aspire.Cli/Projects/ProjectLocator.cs +++ b/src/Aspire.Cli/Projects/ProjectLocator.cs @@ -314,7 +314,7 @@ private async Task CreateSettingsFileAsync(FileInfo projectFile, CancellationTok if (language is not null && !language.LanguageId.Value.Equals(KnownLanguageId.CSharp, StringComparison.OrdinalIgnoreCase)) { await configurationService.SetConfigurationAsync("language", language.LanguageId.Value, isGlobal: false, cancellationToken); - + // Inherit SDK version from parent/global config if available var inheritedSdkVersion = await configurationService.GetConfigurationAsync("sdkVersion", cancellationToken); if (!string.IsNullOrEmpty(inheritedSdkVersion)) diff --git a/src/Aspire.Cli/Scaffolding/ScaffoldingService.cs b/src/Aspire.Cli/Scaffolding/ScaffoldingService.cs index 4dfcb1fe116..35c05a11d96 100644 --- a/src/Aspire.Cli/Scaffolding/ScaffoldingService.cs +++ b/src/Aspire.Cli/Scaffolding/ScaffoldingService.cs @@ -55,25 +55,25 @@ private async Task ScaffoldGuestLanguageAsync(ScaffoldContext context, Cancellat var directory = context.TargetDirectory; var language = context.Language; - // Step 1: Resolve SDK version from channel (if configured) or use default + // Step 1: Resolve SDK and package strategy var sdkVersion = await ResolveSdkVersionAsync(cancellationToken); - - // Load or create config with resolved SDK version var config = AspireJsonConfiguration.LoadOrCreate(directory.FullName, sdkVersion); // Include the code generation package for scaffolding and code gen var codeGenPackage = await _languageDiscovery.GetPackageForLanguageAsync(language.LanguageId, cancellationToken); - var packages = config.GetAllPackages().ToList(); + var packages = config.GetAllPackages(sdkVersion).ToList(); if (codeGenPackage is not null) { - packages.Add((codeGenPackage, config.SdkVersion!)); + var codeGenVersion = config.GetEffectiveSdkVersion(sdkVersion); + packages.Add((codeGenPackage, codeGenVersion)); } var appHostServerProject = await _appHostServerProjectFactory.CreateAsync(directory.FullName, cancellationToken); + var prepareSdkVersion = config.GetEffectiveSdkVersion(sdkVersion); var prepareResult = await _interactionService.ShowStatusAsync( ":gear: Preparing Aspire server...", - () => appHostServerProject.PrepareAsync(config.SdkVersion!, packages, cancellationToken)); + () => appHostServerProject.PrepareAsync(prepareSdkVersion, packages, cancellationToken)); if (!prepareResult.Success) { if (prepareResult.Output is not null) @@ -134,6 +134,7 @@ await GenerateCodeViaRpcAsync( { config.Channel = prepareResult.ChannelName; } + config.Language = language.LanguageId; config.Save(directory.FullName); } diff --git a/src/Aspire.Cli/Utils/AspireRepositoryDetector.cs b/src/Aspire.Cli/Utils/AspireRepositoryDetector.cs new file mode 100644 index 00000000000..5f27b54d333 --- /dev/null +++ b/src/Aspire.Cli/Utils/AspireRepositoryDetector.cs @@ -0,0 +1,114 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Shared; + +namespace Aspire.Cli.Utils; + +internal static class AspireRepositoryDetector +{ +#if DEBUG + private const string AspireSolutionFileName = "Aspire.slnx"; + + private static string? s_cachedRepoRoot; + private static bool s_cacheInitialized; +#endif + + public static string? DetectRepositoryRoot(string? startPath = null) + { +#if !DEBUG + // In release builds, only check the environment variable to avoid + // filesystem walking on every call in production scenarios. + var envRoot = Environment.GetEnvironmentVariable(BundleDiscovery.RepoRootEnvVar); + if (!string.IsNullOrEmpty(envRoot) && Directory.Exists(envRoot)) + { + return Path.GetFullPath(envRoot); + } + + return null; +#else + if (s_cacheInitialized) + { + return s_cachedRepoRoot; + } + + s_cachedRepoRoot = DetectRepositoryRootCore(startPath); + s_cacheInitialized = true; + return s_cachedRepoRoot; +#endif + } + +#if DEBUG + internal static void ResetCache() + { + s_cachedRepoRoot = null; + s_cacheInitialized = false; + } + + private static string? DetectRepositoryRootCore(string? startPath) + { + var repoRoot = FindRepositoryRoot(startPath); + if (!string.IsNullOrEmpty(repoRoot)) + { + return repoRoot; + } + + var envRoot = Environment.GetEnvironmentVariable(BundleDiscovery.RepoRootEnvVar); + if (!string.IsNullOrEmpty(envRoot) && Directory.Exists(envRoot)) + { + return Path.GetFullPath(envRoot); + } + + var processPath = Environment.ProcessPath; + if (!string.IsNullOrEmpty(processPath)) + { + repoRoot = FindRepositoryRoot(Path.GetDirectoryName(processPath)); + if (!string.IsNullOrEmpty(repoRoot)) + { + return repoRoot; + } + } + + return null; + } + + private static string? FindRepositoryRoot(string? startPath) + { + if (string.IsNullOrEmpty(startPath)) + { + return null; + } + + var currentDirectory = ResolveSearchDirectory(startPath); + while (!string.IsNullOrEmpty(currentDirectory)) + { + if (File.Exists(Path.Combine(currentDirectory, AspireSolutionFileName))) + { + return currentDirectory; + } + + currentDirectory = Directory.GetParent(currentDirectory)?.FullName; + } + + return null; + } + + private static string ResolveSearchDirectory(string path) + { + var fullPath = Path.GetFullPath(path); + + if (Directory.Exists(fullPath)) + { + return fullPath; + } + + if (File.Exists(fullPath)) + { + return Path.GetDirectoryName(fullPath)!; + } + + var parentDirectory = Path.GetDirectoryName(fullPath); + return string.IsNullOrEmpty(parentDirectory) ? fullPath : parentDirectory; + } +#endif +} diff --git a/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectTests.cs b/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectTests.cs index 36db392a5ba..876be8d1451 100644 --- a/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectTests.cs @@ -165,6 +165,64 @@ public void AspireJsonConfiguration_GetAllPackages_WithNoExplicitPackages_Return Assert.Contains(packages, p => p.Name == "Aspire.Hosting" && p.Version == "13.1.0"); } + [Fact] + public void AspireJsonConfiguration_GetAllPackages_WithWhitespaceSdkVersion_Throws() + { + var config = new AspireJsonConfiguration + { + SdkVersion = " ", + Language = "typescript" + }; + + var exception = Assert.Throws(() => config.GetAllPackages().ToList()); + + Assert.Contains("non-empty", exception.Message); + } + + [Fact] + public void AspireJsonConfiguration_GetAllPackages_WithDefaultSdkVersion_UsesFallbackVersion() + { + // Arrange + var config = new AspireJsonConfiguration + { + Language = "typescript", + Packages = new Dictionary + { + ["Aspire.Hosting.Redis"] = string.Empty + } + }; + + // Act + var packages = config.GetAllPackages("13.1.0").ToList(); + + // Assert + Assert.Contains(packages, p => p.Name == "Aspire.Hosting" && p.Version == "13.1.0"); + Assert.Contains(packages, p => p.Name == "Aspire.Hosting.Redis" && p.Version == "13.1.0"); + } + + [Fact] + public void AspireJsonConfiguration_GetAllPackages_WithConfiguredSdkVersion_ReturnsConfiguredVersions() + { + // Arrange + var config = new AspireJsonConfiguration + { + SdkVersion = "13.1.0", + Language = "typescript", + Channel = "daily", + Packages = new Dictionary + { + ["Aspire.Hosting.Redis"] = "13.1.0" + } + }; + + // Act + var packages = config.GetAllPackages("13.1.0").ToList(); + + // Assert + Assert.Contains(packages, p => p.Name == "Aspire.Hosting" && p.Version == "13.1.0"); + Assert.Contains(packages, p => p.Name == "Aspire.Hosting.Redis" && p.Version == "13.1.0"); + } + [Fact] public void AspireJsonConfiguration_Save_PreservesExtensionData() { diff --git a/tests/Aspire.Cli.Tests/TestServices/TestAppHostProjectFactory.cs b/tests/Aspire.Cli.Tests/TestServices/TestAppHostProjectFactory.cs index bc0b7d20918..3c99f10bc7c 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestAppHostProjectFactory.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestAppHostProjectFactory.cs @@ -117,6 +117,11 @@ public TestAppHostProject(TestAppHostProjectFactory factory) public string DisplayName => "C# (.NET)"; public string? AppHostFileName => "AppHost.csproj"; + public bool IsUsingProjectReferences(FileInfo appHostFile) + { + return false; + } + public Task GetDetectionPatternsAsync(CancellationToken cancellationToken) => Task.FromResult(s_detectionPatterns); diff --git a/tests/Aspire.Cli.Tests/Utils/AspireRepositoryDetectorTests.cs b/tests/Aspire.Cli.Tests/Utils/AspireRepositoryDetectorTests.cs new file mode 100644 index 00000000000..ed8e40806cc --- /dev/null +++ b/tests/Aspire.Cli.Tests/Utils/AspireRepositoryDetectorTests.cs @@ -0,0 +1,85 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#if DEBUG + +using Aspire.Cli.Utils; + +namespace Aspire.Cli.Tests.Utils; + +public class AspireRepositoryDetectorTests : IDisposable +{ + private const string RepoRootEnvironmentVariableName = "ASPIRE_REPO_ROOT"; + private readonly List _directoriesToDelete = []; + private readonly string? _originalRepoRoot = Environment.GetEnvironmentVariable(RepoRootEnvironmentVariableName); + + public AspireRepositoryDetectorTests() + { + AspireRepositoryDetector.ResetCache(); + } + + public void Dispose() + { + Environment.SetEnvironmentVariable(RepoRootEnvironmentVariableName, _originalRepoRoot); + AspireRepositoryDetector.ResetCache(); + + foreach (var directory in _directoriesToDelete) + { + if (Directory.Exists(directory)) + { + Directory.Delete(directory, recursive: true); + } + } + } + + [Fact] + public void DetectRepositoryRoot_ReturnsDirectoryContainingAspireSolution() + { + var repoRoot = CreateTempDirectory(); + File.WriteAllText(Path.Combine(repoRoot, "Aspire.slnx"), string.Empty); + + var nestedDirectory = Directory.CreateDirectory(Path.Combine(repoRoot, "src", "Project")).FullName; + + var detectedRoot = AspireRepositoryDetector.DetectRepositoryRoot(nestedDirectory); + + Assert.Equal(repoRoot, detectedRoot); + } + + [Fact] + public void DetectRepositoryRoot_UsesEnvironmentVariable_WhenNoSolutionFound() + { + var repoRoot = CreateTempDirectory(); + var workingDirectory = CreateTempDirectory(); + + Environment.SetEnvironmentVariable(RepoRootEnvironmentVariableName, repoRoot); + + var detectedRoot = AspireRepositoryDetector.DetectRepositoryRoot(workingDirectory); + + Assert.Equal(repoRoot, detectedRoot); + } + + [Fact] + public void DetectRepositoryRoot_PrefersSolutionSearchOverEnvironmentVariable() + { + var repoRoot = CreateTempDirectory(); + File.WriteAllText(Path.Combine(repoRoot, "Aspire.slnx"), string.Empty); + + var envRoot = CreateTempDirectory(); + Environment.SetEnvironmentVariable(RepoRootEnvironmentVariableName, envRoot); + + var nestedDirectory = Directory.CreateDirectory(Path.Combine(repoRoot, "playground", "polyglot")).FullName; + + var detectedRoot = AspireRepositoryDetector.DetectRepositoryRoot(nestedDirectory); + + Assert.Equal(repoRoot, detectedRoot); + } + + private string CreateTempDirectory() + { + var directory = Directory.CreateTempSubdirectory("aspire-repo-detector-tests-").FullName; + _directoriesToDelete.Add(directory); + return directory; + } +} + +#endif diff --git a/tests/Shared/TemporaryRepo.cs b/tests/Shared/TemporaryRepo.cs index ffe7c0d9352..e61ff2d4c48 100644 --- a/tests/Shared/TemporaryRepo.cs +++ b/tests/Shared/TemporaryRepo.cs @@ -62,6 +62,12 @@ internal static TemporaryWorkspace Create(ITestOutputHelper outputHelper) var repoDirectory = Directory.CreateDirectory(path); outputHelper.WriteLine($"Temporary workspace created at: {repoDirectory.FullName}"); + // Create an empty settings file so directory-walking searches + // (ConfigurationHelper, ConfigurationService) stop here instead + // of finding the user's actual ~/.aspire/settings.json. + var aspireDir = Directory.CreateDirectory(Path.Combine(path, ".aspire")); + File.WriteAllText(Path.Combine(aspireDir.FullName, "settings.json"), "{}"); + return new TemporaryWorkspace(outputHelper, repoDirectory); } }