From ee4ff1236c1a52a9274afc5c635bcfe917229489 Mon Sep 17 00:00:00 2001 From: adamint Date: Thu, 28 May 2026 18:10:16 -0400 Subject: [PATCH 1/6] =?UTF-8?q?Fix=20five=20`aspire=20ls`=20bugs=20from=20?= =?UTF-8?q?#17620=20(L1=E2=80=93L5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #17615, #17620, #17621, #17624, #17626. - L1 (#17615): Remove the eager-migration block in ConfigurationHelper.RegisterSettingsFiles. Read commands like `aspire ls` no longer silently materialize an aspire.config.json next to a user's legacy .aspire/settings.json. Migration now happens lazily/explicitly via the existing write paths. - L2 (#17620): Drop the `silent` parameter from ProjectLocator.GetAppHostProjectFileFromSettingsAsync so the legacy branch unconditionally surfaces the migration warning, and surface the actual user-authored `.aspire/settings.json` path in the warning text rather than the auto-created `aspire.config.json` path. - L3 (#17621): Remove the dead post-emission `appHosts.Sort()` in LsCommand.FindAppHostsWithJsonStreamAsync (--stream emits candidates as they are discovered, so the sort had no effect on already-emitted output). Update the --stream option description and docs/specs/cli-output-formats.md to declare the arrival-ordered contract. - L4 (#17624): Add an IsValidConfiguredAppHostPath helper in ProjectLocator that rejects `\0` and Path.GetInvalidPathChars() before the path is passed to Path.IsPathRooted / Path.Combine. Wired into both the modern `aspire.config.json` (`appHost.path`) branch and the legacy `.aspire/settings.json` (`appHostPath`) branch. Validation is intentionally at the consumption point rather than in AspireConfigFile.Load, which has 12+ unrelated callers. Adds a new ConfiguredAppHostPathHasInvalidCharacters resource string and refreshes the xlf set via UpdateXlf. - L5 (#17626): Add PathNormalizer.ResolveSymlinks in src/Shared, a recursive segment-walker that canonicalizes intermediate symlinks (Directory.ResolveLinkTarget only reads exactly the path it is given, and returns the link target as stored on disk — so a single call on /tmp/x/y.cs does not unwrap /tmp -> /private/tmp, and following a link whose stored target is /var/.../app keeps the un-canonical /var prefix). The recursion has a hard depth limit of 40 and falls back to the un-resolved input on broken or circular links. Use it in AddSettingsAppHostCandidateAsync as a comparison key only — the surfaced AppHostProjectCandidate keeps its original FileInfo so the displayed path matches what the user authored in settings. Tests: 158 of 160 targeted tests pass (2 Windows-only skipped on macOS). New tests cover L1 (no migration on read), L2 (legacy warning references settings.json), L3 (arrival-order under --stream), L4 (NUL byte in modern and legacy branches), L5 (symlink dedupe via a node_modules-hosted link the discovery walk excludes), plus 5 unit tests on PathNormalizer.ResolveSymlinks itself. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/specs/cli-output-formats.md | 2 + src/Aspire.Cli/Commands/LsCommand.cs | 9 +- src/Aspire.Cli/Program.cs | 2 +- src/Aspire.Cli/Projects/ProjectLocator.cs | 106 ++++++++- .../Resources/ErrorStrings.Designer.cs | 9 + src/Aspire.Cli/Resources/ErrorStrings.resx | 4 + .../Resources/SharedCommandStrings.resx | 2 +- .../Resources/xlf/ErrorStrings.cs.xlf | 5 + .../Resources/xlf/ErrorStrings.de.xlf | 5 + .../Resources/xlf/ErrorStrings.es.xlf | 5 + .../Resources/xlf/ErrorStrings.fr.xlf | 5 + .../Resources/xlf/ErrorStrings.it.xlf | 5 + .../Resources/xlf/ErrorStrings.ja.xlf | 5 + .../Resources/xlf/ErrorStrings.ko.xlf | 5 + .../Resources/xlf/ErrorStrings.pl.xlf | 5 + .../Resources/xlf/ErrorStrings.pt-BR.xlf | 5 + .../Resources/xlf/ErrorStrings.ru.xlf | 5 + .../Resources/xlf/ErrorStrings.tr.xlf | 5 + .../Resources/xlf/ErrorStrings.zh-Hans.xlf | 5 + .../Resources/xlf/ErrorStrings.zh-Hant.xlf | 5 + .../Resources/xlf/SharedCommandStrings.cs.xlf | 4 +- .../Resources/xlf/SharedCommandStrings.de.xlf | 4 +- .../Resources/xlf/SharedCommandStrings.es.xlf | 4 +- .../Resources/xlf/SharedCommandStrings.fr.xlf | 4 +- .../Resources/xlf/SharedCommandStrings.it.xlf | 4 +- .../Resources/xlf/SharedCommandStrings.ja.xlf | 4 +- .../Resources/xlf/SharedCommandStrings.ko.xlf | 4 +- .../Resources/xlf/SharedCommandStrings.pl.xlf | 4 +- .../xlf/SharedCommandStrings.pt-BR.xlf | 4 +- .../Resources/xlf/SharedCommandStrings.ru.xlf | 4 +- .../Resources/xlf/SharedCommandStrings.tr.xlf | 4 +- .../xlf/SharedCommandStrings.zh-Hans.xlf | 4 +- .../xlf/SharedCommandStrings.zh-Hant.xlf | 4 +- src/Aspire.Cli/Utils/ConfigurationHelper.cs | 99 ++------ src/Shared/PathNormalizer.cs | 114 +++++++++ .../Commands/LsCommandTests.cs | 52 +++++ .../Configuration/ConfigurationHelperTests.cs | 102 +++----- .../Projects/ProjectLocatorTests.cs | 219 ++++++++++++++++++ tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs | 2 +- .../Utils/PathNormalizerTests.cs | 121 ++++++++++ 40 files changed, 769 insertions(+), 191 deletions(-) create mode 100644 tests/Aspire.Cli.Tests/Utils/PathNormalizerTests.cs diff --git a/docs/specs/cli-output-formats.md b/docs/specs/cli-output-formats.md index 1872557e34c..3260effc14e 100644 --- a/docs/specs/cli-output-formats.md +++ b/docs/specs/cli-output-formats.md @@ -44,6 +44,8 @@ Use `--format json --stream` to receive discovery results as NDJSON, with one co {"path":"/path/to/ts-app/apphost.ts","language":"TypeScript","status":"possibly-unbuildable"} ``` +Stream output is emitted in arrival order from parallel discovery; lines are not sorted. The non-streaming `--format json` snapshot above is sorted by `path`. If you need a deterministic order for streamed output, pipe through your own sort step (for example `jq -s 'sort_by(.path)'`). + If discovery finds no AppHost candidates, the stream emits no lines. The stream does not emit `started`, `complete`, or `canceled` control records; use the command's exit code and end-of-file to detect stream completion. #### AppHost candidate fields diff --git a/src/Aspire.Cli/Commands/LsCommand.cs b/src/Aspire.Cli/Commands/LsCommand.cs index 542913fca9c..b044eb75649 100644 --- a/src/Aspire.Cli/Commands/LsCommand.cs +++ b/src/Aspire.Cli/Commands/LsCommand.cs @@ -153,6 +153,13 @@ private async Task> FindAppHostsWithJsonStreamAsyn { var appHosts = new List(); + // `aspire ls --format json --stream` emits each candidate as soon as discovery surfaces + // it (arrival order from parallel discovery). The contract is documented in + // docs/specs/cli-output-formats.md and in LsStreamOptionDescription. Do NOT sort here: + // candidates have already been written to stdout via WriteJsonStreamCandidate above, so + // any post-loop sort would only reorder this in-memory list — which the caller does not + // use for stream output. Pipe to `sort` / `jq -s 'sort_by(.path)'` for ordered output. + // See https://github.com/microsoft/aspire/issues/17621. await foreach (var candidate in _projectLocator.FindAppHostProjectsStreamAsync(_executionContext.WorkingDirectory, scope, cancellationToken: cancellationToken).ConfigureAwait(false)) { cancellationToken.ThrowIfCancellationRequested(); @@ -160,8 +167,6 @@ private async Task> FindAppHostsWithJsonStreamAsyn WriteJsonStreamCandidate(CreateDisplayInfo(candidate)); } - appHosts.Sort((x, y) => string.Compare(x.AppHostFile.FullName, y.AppHostFile.FullName, StringComparison.Ordinal)); - return appHosts; } diff --git a/src/Aspire.Cli/Program.cs b/src/Aspire.Cli/Program.cs index 3a78e399b7b..740f283684c 100644 --- a/src/Aspire.Cli/Program.cs +++ b/src/Aspire.Cli/Program.cs @@ -304,7 +304,7 @@ internal static async Task BuildApplicationAsync(string[] args, CliStartu var globalSettingsFilePath = GetGlobalSettingsPath(startupContext.Logger); var globalSettingsFile = new FileInfo(globalSettingsFilePath); var workingDirectory = new DirectoryInfo(Environment.CurrentDirectory); - ConfigurationHelper.RegisterSettingsFiles(builder.Configuration, workingDirectory, globalSettingsFile, startupContext.Logger); + ConfigurationHelper.RegisterSettingsFiles(builder.Configuration, workingDirectory, globalSettingsFile); TrySetLocaleOverride(LocaleHelpers.GetLocaleOverride(builder.Configuration), startupContext.Logger, startupContext.ErrorWriter); WarnIfGlobalSettingsContainAppHostPath(globalSettingsFile, startupContext.ErrorWriter); diff --git a/src/Aspire.Cli/Projects/ProjectLocator.cs b/src/Aspire.Cli/Projects/ProjectLocator.cs index 095a1f6363d..1bb65b7ca2e 100644 --- a/src/Aspire.Cli/Projects/ProjectLocator.cs +++ b/src/Aspire.Cli/Projects/ProjectLocator.cs @@ -444,7 +444,7 @@ await Parallel.ForEachAsync(candidatesWithHandlers, parallelOptions, async (cand async Task AddSettingsAppHostCandidateAsync() { - var settingsAppHost = await GetAppHostProjectFileFromSettingsAsync(searchDirectory, searchParentDirectories: true, silent: true, cancellationToken).ConfigureAwait(false); + var settingsAppHost = await GetAppHostProjectFileFromSettingsAsync(searchDirectory, searchParentDirectories: true, silent: false, cancellationToken).ConfigureAwait(false); if (settingsAppHost is null) { return; @@ -453,8 +453,33 @@ async Task AddSettingsAppHostCandidateAsync() var pathComparison = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; - if (appHostProjects.Any(candidate => string.Equals(candidate.AppHostFile.FullName, settingsAppHost.FullName, pathComparison)) - || unbuildableSuspectedAppHostProjects.Any(candidate => string.Equals(candidate.AppHostFile.FullName, settingsAppHost.FullName, pathComparison))) + + // Canonicalize symlinks before comparing so a settings-derived candidate + // like /tmp/L5/x.cs does not produce a duplicate entry next to the + // discovery-walked /private/tmp/L5/x.cs on macOS, where /tmp is a symlink + // to /private/tmp. See https://github.com/microsoft/aspire/issues/17626. + // Resolved paths are used as comparison keys only — the surfaced + // AppHostProjectCandidate keeps the original FileInfo so display paths are + // unchanged from what the user-authored settings file pointed at. + // + // Symlink resolution does ~one syscall per path segment, so we keep it + // off the hot path: the exact-string compare below short-circuits before + // the per-candidate resolve runs at all in the common case (no symlinks + // involved). Pre-materializing canonical paths for every candidate would + // force the resolve even when the cheap compare would have matched. + var settingsCanonicalPath = PathNormalizer.ResolveSymlinks(settingsAppHost.FullName); + bool IsDuplicate(AppHostProjectCandidate candidate) + { + if (string.Equals(candidate.AppHostFile.FullName, settingsAppHost.FullName, pathComparison)) + { + return true; + } + + var candidateCanonicalPath = PathNormalizer.ResolveSymlinks(candidate.AppHostFile.FullName); + return string.Equals(candidateCanonicalPath, settingsCanonicalPath, pathComparison); + } + + if (appHostProjects.Any(IsDuplicate) || unbuildableSuspectedAppHostProjects.Any(IsDuplicate)) { return; } @@ -528,6 +553,10 @@ async Task AddSettingsAppHostCandidateAsync() public async Task GetAppHostFromSettingsAsync(DirectoryInfo searchDirectory, bool searchParentDirectories, CancellationToken cancellationToken = default) { // Intentionally does not call ValidateAppHostAsync. See interface XML docs for rationale. + // Probe-style callers (DotNetSdkCheck, AspireVersionCheck, TypeScriptAppHostToolingCheck, + // UpdateCommand, IntegrationPackageSearchService) drive this path and expect a + // non-interactive answer; the user-facing legacy-migration warning is emitted from the + // discovery walk (AddSettingsAppHostCandidateAsync) instead. var settingsAppHost = await GetAppHostProjectFileFromSettingsAsync(searchDirectory, searchParentDirectories, silent: true, cancellationToken); if (settingsAppHost is null) { @@ -544,9 +573,13 @@ async Task AddSettingsAppHostCandidateAsync() return settingsAppHost; } - private async Task GetValidatedAppHostProjectFileFromSettingsAsync(DirectoryInfo searchDirectory, bool searchParentDirectories, bool silent, CancellationToken cancellationToken) + private async Task GetValidatedAppHostProjectFileFromSettingsAsync(DirectoryInfo searchDirectory, bool searchParentDirectories, CancellationToken cancellationToken) { - var settingsAppHost = await GetAppHostProjectFileFromSettingsAsync(searchDirectory, searchParentDirectories, silent, cancellationToken); + // This is reached from UseOrFindAppHostProjectFileAsync. When the configured + // legacy settings point at a missing file we still want the warning to surface, + // but the discovery walk that runs afterwards (AddSettingsAppHostCandidateAsync) + // will emit the same warning. Stay silent here to avoid a duplicate. + var settingsAppHost = await GetAppHostProjectFileFromSettingsAsync(searchDirectory, searchParentDirectories, silent: true, cancellationToken); if (settingsAppHost is null) { return null; @@ -594,11 +627,26 @@ async Task AddSettingsAppHostCandidateAsync() } catch (JsonException ex) { - interactionService.DisplayError(ex.Message); + if (!silent) + { + interactionService.DisplayError(ex.Message); + } return null; } if (aspireConfig?.AppHost?.Path is { } configAppHostPath) { + var configFilePath = Path.Combine(searchDirectory.FullName, AspireConfigFile.FileName); + + // Validate before Path.Combine / new FileInfo, which throw ArgumentException + // ("Null character in path." / "Illegal characters in path.") on NUL bytes and + // other invalid characters that survive JSON parsing. Without this we surface + // as a generic "An unexpected error occurred" — see + // https://github.com/microsoft/aspire/issues/17624. + if (!IsValidConfiguredAppHostPath(configAppHostPath, configFilePath, "appHost.path", silent)) + { + return null; + } + var qualifiedPath = Path.IsPathRooted(configAppHostPath) ? configAppHostPath : Path.Combine(searchDirectory.FullName, configAppHostPath); @@ -612,8 +660,10 @@ async Task AddSettingsAppHostCandidateAsync() } else { - var configFilePath = Path.Combine(searchDirectory.FullName, AspireConfigFile.FileName); - interactionService.DisplayMessage(KnownEmojis.Warning, string.Format(CultureInfo.CurrentCulture, ErrorStrings.AppHostWasSpecifiedButDoesntExist, configFilePath, qualifiedPath)); + if (!silent) + { + interactionService.DisplayMessage(KnownEmojis.Warning, string.Format(CultureInfo.CurrentCulture, ErrorStrings.AppHostWasSpecifiedButDoesntExist, configFilePath, qualifiedPath)); + } return null; } } @@ -630,6 +680,14 @@ async Task AddSettingsAppHostCandidateAsync() if (json.RootElement.TryGetProperty("appHostPath", out var appHostPathProperty) && appHostPathProperty.GetString() is { } appHostPath) { + // Mirror the validation on the modern path above so the legacy branch also + // cannot reach Path.Combine with a NUL byte or other Path.GetInvalidPathChars + // value (https://github.com/microsoft/aspire/issues/17624). + if (!IsValidConfiguredAppHostPath(appHostPath, settingsFile.FullName, "appHostPath", silent)) + { + return null; + } + var qualifiedAppHostPath = Path.IsPathRooted(appHostPath) ? appHostPath : Path.Combine(settingsFile.Directory!.FullName, appHostPath); qualifiedAppHostPath = PathNormalizer.NormalizePathForCurrentPlatform(qualifiedAppHostPath); var appHostFile = new FileInfo(qualifiedAppHostPath); @@ -640,9 +698,14 @@ async Task AddSettingsAppHostCandidateAsync() } else { - // AppHost file was specified but doesn't exist, return null to trigger fallback logic if (!silent) { + // Warn against the user-authored file (.aspire/settings.json), not the + // never-authored aspire.config.json. Earlier versions reported + // aspire.config.json because startup eagerly migrated the legacy + // settings (PR #17234); see https://github.com/microsoft/aspire/issues/17620 + // for the user-facing impact of pointing users at a file they did + // not create. interactionService.DisplayMessage(KnownEmojis.Warning, string.Format(CultureInfo.CurrentCulture, ErrorStrings.AppHostWasSpecifiedButDoesntExist, settingsFile.FullName, qualifiedAppHostPath)); } return null; @@ -661,6 +724,29 @@ async Task AddSettingsAppHostCandidateAsync() } } + // Reject empty paths (Path.Combine("", base) collapses to the base directory and surfaces + // a misleading "directory doesn't exist" warning downstream) and paths that contain + // characters that would crash System.IO APIs. Path.GetInvalidPathChars() includes NUL on + // every platform plus the platform-specific set of disallowed characters (e.g. < > | on + // Windows). Plain Contains('\0') is included explicitly for readability even though it is + // redundant with the IndexOfAny check. + private bool IsValidConfiguredAppHostPath(string path, string configFilePath, string fieldName, bool silent) + { + if (path.Length == 0 || path.Contains('\0') || path.IndexOfAny(Path.GetInvalidPathChars()) >= 0) + { + if (!silent) + { + interactionService.DisplayError(string.Format(CultureInfo.CurrentCulture, ErrorStrings.ConfiguredAppHostPathHasInvalidCharacters, configFilePath, fieldName)); + } + // Log even in silent mode so non-interactive probes (DotNetSdkCheck etc.) leave a + // diagnostic trail when they reject a malformed path. + logger.LogWarning("Ignoring configured AppHost path in '{ConfigFilePath}' ('{FieldName}') because it is empty or contains invalid characters.", configFilePath, fieldName); + return false; + } + + return true; + } + public async Task UseOrFindAppHostProjectFileAsync(FileInfo? projectFile, MultipleAppHostProjectsFoundBehavior multipleAppHostProjectsFoundBehavior, bool createSettingsFile, CancellationToken cancellationToken = default) { logger.LogDebug("Finding project file in {CurrentDirectory}", executionContext.WorkingDirectory); @@ -782,7 +868,7 @@ public async Task UseOrFindAppHostProjectFileAsync(F } } - var settingsAppHost = await GetValidatedAppHostProjectFileFromSettingsAsync(executionContext.WorkingDirectory, searchParentDirectories: true, silent: true, cancellationToken); + var settingsAppHost = await GetValidatedAppHostProjectFileFromSettingsAsync(executionContext.WorkingDirectory, searchParentDirectories: true, cancellationToken); if (settingsAppHost is not null && multipleAppHostProjectsFoundBehavior is not MultipleAppHostProjectsFoundBehavior.None) { diff --git a/src/Aspire.Cli/Resources/ErrorStrings.Designer.cs b/src/Aspire.Cli/Resources/ErrorStrings.Designer.cs index 0a1e6964118..fe8da929cf4 100644 --- a/src/Aspire.Cli/Resources/ErrorStrings.Designer.cs +++ b/src/Aspire.Cli/Resources/ErrorStrings.Designer.cs @@ -452,5 +452,14 @@ public static string CodegenDebugHeader { return ResourceManager.GetString("CodegenDebugHeader", resourceCulture); } } + + /// + /// Looks up a localized string similar to The configured AppHost path in '{0}' ('{1}') is empty or contains characters that are not allowed in a file path.. + /// + public static string ConfiguredAppHostPathHasInvalidCharacters { + get { + return ResourceManager.GetString("ConfiguredAppHostPathHasInvalidCharacters", resourceCulture); + } + } } } diff --git a/src/Aspire.Cli/Resources/ErrorStrings.resx b/src/Aspire.Cli/Resources/ErrorStrings.resx index aba3b239f19..45a2c97df98 100644 --- a/src/Aspire.Cli/Resources/ErrorStrings.resx +++ b/src/Aspire.Cli/Resources/ErrorStrings.resx @@ -276,4 +276,8 @@ Diagnostic details: + + The configured AppHost path in '{0}' ('{1}') is empty or contains characters that are not allowed in a file path. + {0} is the configuration file path that the user authored, {1} is the field/setting name within that file (for example "appHost.path" or "appHostPath") + diff --git a/src/Aspire.Cli/Resources/SharedCommandStrings.resx b/src/Aspire.Cli/Resources/SharedCommandStrings.resx index db64d25f87f..e9f85fae74c 100644 --- a/src/Aspire.Cli/Resources/SharedCommandStrings.resx +++ b/src/Aspire.Cli/Resources/SharedCommandStrings.resx @@ -152,7 +152,7 @@ Include all candidate AppHosts, ignoring .gitignore and built-in directory filters - Stream newline-delimited JSON discovery events. Requires --format json + Stream newline-delimited JSON discovery events in arrival order from parallel discovery (not sorted). Requires --format json The --stream option requires --format json. diff --git a/src/Aspire.Cli/Resources/xlf/ErrorStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/ErrorStrings.cs.xlf index 7c2ddc27688..a44e5d5eb58 100644 --- a/src/Aspire.Cli/Resources/xlf/ErrorStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/ErrorStrings.cs.xlf @@ -97,6 +97,11 @@ Hodnota konfigurace je povinná. + + The configured AppHost path in '{0}' ('{1}') is empty or contains characters that are not allowed in a file path. + The configured AppHost path in '{0}' ('{1}') is empty or contains characters that are not allowed in a file path. + {0} is the configuration file path that the user authored, {1} is the field/setting name within that file (for example "appHost.path" or "appHostPath") + Could not parse Aspire.Hosting package version. Nepodařilo se analyzovat verzi balíčku Aspire.Hosting. diff --git a/src/Aspire.Cli/Resources/xlf/ErrorStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/ErrorStrings.de.xlf index 7ee217e468a..79a72ace4d5 100644 --- a/src/Aspire.Cli/Resources/xlf/ErrorStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/ErrorStrings.de.xlf @@ -97,6 +97,11 @@ Der Konfigurationswert ist erforderlich. + + The configured AppHost path in '{0}' ('{1}') is empty or contains characters that are not allowed in a file path. + The configured AppHost path in '{0}' ('{1}') is empty or contains characters that are not allowed in a file path. + {0} is the configuration file path that the user authored, {1} is the field/setting name within that file (for example "appHost.path" or "appHostPath") + Could not parse Aspire.Hosting package version. Die Aspire.Hosting-Paketversion konnte nicht analysiert werden. diff --git a/src/Aspire.Cli/Resources/xlf/ErrorStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/ErrorStrings.es.xlf index 00257b66305..c85fe3facb9 100644 --- a/src/Aspire.Cli/Resources/xlf/ErrorStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/ErrorStrings.es.xlf @@ -97,6 +97,11 @@ El valor de configuración es obligatorio. + + The configured AppHost path in '{0}' ('{1}') is empty or contains characters that are not allowed in a file path. + The configured AppHost path in '{0}' ('{1}') is empty or contains characters that are not allowed in a file path. + {0} is the configuration file path that the user authored, {1} is the field/setting name within that file (for example "appHost.path" or "appHostPath") + Could not parse Aspire.Hosting package version. No se pudo analizar la versión del paquete Aspire.Hosting. diff --git a/src/Aspire.Cli/Resources/xlf/ErrorStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/ErrorStrings.fr.xlf index 280e4d050b7..5c8666d2ae5 100644 --- a/src/Aspire.Cli/Resources/xlf/ErrorStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/ErrorStrings.fr.xlf @@ -97,6 +97,11 @@ La valeur de la configuration est requise. + + The configured AppHost path in '{0}' ('{1}') is empty or contains characters that are not allowed in a file path. + The configured AppHost path in '{0}' ('{1}') is empty or contains characters that are not allowed in a file path. + {0} is the configuration file path that the user authored, {1} is the field/setting name within that file (for example "appHost.path" or "appHostPath") + Could not parse Aspire.Hosting package version. Impossible d’analyser la version du package Aspire.Hosting. diff --git a/src/Aspire.Cli/Resources/xlf/ErrorStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/ErrorStrings.it.xlf index 3f9864622c6..5f5daf78cc6 100644 --- a/src/Aspire.Cli/Resources/xlf/ErrorStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/ErrorStrings.it.xlf @@ -97,6 +97,11 @@ Il valore di configurazione è obbligatorio. + + The configured AppHost path in '{0}' ('{1}') is empty or contains characters that are not allowed in a file path. + The configured AppHost path in '{0}' ('{1}') is empty or contains characters that are not allowed in a file path. + {0} is the configuration file path that the user authored, {1} is the field/setting name within that file (for example "appHost.path" or "appHostPath") + Could not parse Aspire.Hosting package version. Non è possibile analizzare la versione del pacchetto Aspire.Hosting. diff --git a/src/Aspire.Cli/Resources/xlf/ErrorStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/ErrorStrings.ja.xlf index 84cb684a628..0308c4997d5 100644 --- a/src/Aspire.Cli/Resources/xlf/ErrorStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/ErrorStrings.ja.xlf @@ -97,6 +97,11 @@ 構成値が必要です。 + + The configured AppHost path in '{0}' ('{1}') is empty or contains characters that are not allowed in a file path. + The configured AppHost path in '{0}' ('{1}') is empty or contains characters that are not allowed in a file path. + {0} is the configuration file path that the user authored, {1} is the field/setting name within that file (for example "appHost.path" or "appHostPath") + Could not parse Aspire.Hosting package version. Aspire.Hosting パッケージのバージョンを解析できませんでした。 diff --git a/src/Aspire.Cli/Resources/xlf/ErrorStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/ErrorStrings.ko.xlf index 1cdf0d2b9d3..1a11f863fa3 100644 --- a/src/Aspire.Cli/Resources/xlf/ErrorStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/ErrorStrings.ko.xlf @@ -97,6 +97,11 @@ 구성 값이 필요합니다. + + The configured AppHost path in '{0}' ('{1}') is empty or contains characters that are not allowed in a file path. + The configured AppHost path in '{0}' ('{1}') is empty or contains characters that are not allowed in a file path. + {0} is the configuration file path that the user authored, {1} is the field/setting name within that file (for example "appHost.path" or "appHostPath") + Could not parse Aspire.Hosting package version. Aspire.Hosting 패키지 버전을 구문 분석할 수 없습니다. diff --git a/src/Aspire.Cli/Resources/xlf/ErrorStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/ErrorStrings.pl.xlf index 791f4f7644c..deae91b7786 100644 --- a/src/Aspire.Cli/Resources/xlf/ErrorStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/ErrorStrings.pl.xlf @@ -97,6 +97,11 @@ Wartość konfiguracji jest wymagana. + + The configured AppHost path in '{0}' ('{1}') is empty or contains characters that are not allowed in a file path. + The configured AppHost path in '{0}' ('{1}') is empty or contains characters that are not allowed in a file path. + {0} is the configuration file path that the user authored, {1} is the field/setting name within that file (for example "appHost.path" or "appHostPath") + Could not parse Aspire.Hosting package version. Nie można przeanalizować wersji pakietu Aspire.Hosting. diff --git a/src/Aspire.Cli/Resources/xlf/ErrorStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/ErrorStrings.pt-BR.xlf index 8c867c5648d..84b014c1972 100644 --- a/src/Aspire.Cli/Resources/xlf/ErrorStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/ErrorStrings.pt-BR.xlf @@ -97,6 +97,11 @@ O valor de configuração é obrigatório. + + The configured AppHost path in '{0}' ('{1}') is empty or contains characters that are not allowed in a file path. + The configured AppHost path in '{0}' ('{1}') is empty or contains characters that are not allowed in a file path. + {0} is the configuration file path that the user authored, {1} is the field/setting name within that file (for example "appHost.path" or "appHostPath") + Could not parse Aspire.Hosting package version. Não foi possível analisar a versão do pacote Aspire.Hosting. diff --git a/src/Aspire.Cli/Resources/xlf/ErrorStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/ErrorStrings.ru.xlf index 77cc5494af3..9f8988a0b69 100644 --- a/src/Aspire.Cli/Resources/xlf/ErrorStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/ErrorStrings.ru.xlf @@ -97,6 +97,11 @@ Значение конфигурации обязательно. + + The configured AppHost path in '{0}' ('{1}') is empty or contains characters that are not allowed in a file path. + The configured AppHost path in '{0}' ('{1}') is empty or contains characters that are not allowed in a file path. + {0} is the configuration file path that the user authored, {1} is the field/setting name within that file (for example "appHost.path" or "appHostPath") + Could not parse Aspire.Hosting package version. Не удалось выполнить разбор версии пакета Aspire.Hosting. diff --git a/src/Aspire.Cli/Resources/xlf/ErrorStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/ErrorStrings.tr.xlf index 2fb689d9b5a..bd9146a8960 100644 --- a/src/Aspire.Cli/Resources/xlf/ErrorStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/ErrorStrings.tr.xlf @@ -97,6 +97,11 @@ Yapılandırma değeri gereklidir. + + The configured AppHost path in '{0}' ('{1}') is empty or contains characters that are not allowed in a file path. + The configured AppHost path in '{0}' ('{1}') is empty or contains characters that are not allowed in a file path. + {0} is the configuration file path that the user authored, {1} is the field/setting name within that file (for example "appHost.path" or "appHostPath") + Could not parse Aspire.Hosting package version. Aspire.Hosting paket sürümü ayrıştırılamadı. diff --git a/src/Aspire.Cli/Resources/xlf/ErrorStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/ErrorStrings.zh-Hans.xlf index f46af90c416..15f56b90fa5 100644 --- a/src/Aspire.Cli/Resources/xlf/ErrorStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/ErrorStrings.zh-Hans.xlf @@ -97,6 +97,11 @@ 配置值是必需的。 + + The configured AppHost path in '{0}' ('{1}') is empty or contains characters that are not allowed in a file path. + The configured AppHost path in '{0}' ('{1}') is empty or contains characters that are not allowed in a file path. + {0} is the configuration file path that the user authored, {1} is the field/setting name within that file (for example "appHost.path" or "appHostPath") + Could not parse Aspire.Hosting package version. 无法分析 Aspire.Hosting 包版本。 diff --git a/src/Aspire.Cli/Resources/xlf/ErrorStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/ErrorStrings.zh-Hant.xlf index be6279bf20b..1b7f395991a 100644 --- a/src/Aspire.Cli/Resources/xlf/ErrorStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/ErrorStrings.zh-Hant.xlf @@ -97,6 +97,11 @@ 設定值為必須。 + + The configured AppHost path in '{0}' ('{1}') is empty or contains characters that are not allowed in a file path. + The configured AppHost path in '{0}' ('{1}') is empty or contains characters that are not allowed in a file path. + {0} is the configuration file path that the user authored, {1} is the field/setting name within that file (for example "appHost.path" or "appHostPath") + Could not parse Aspire.Hosting package version. 無法剖析 Aspire.Hosting 套件版本。 diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.cs.xlf index d80cb544940..0f90e4dc884 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.cs.xlf @@ -73,8 +73,8 @@ {0} is the number of directories searched so far. {1} is the number of AppHost candidates found so far. - Stream newline-delimited JSON discovery events. Requires --format json - Stream newline-delimited JSON discovery events. Requires --format json + Stream newline-delimited JSON discovery events in arrival order from parallel discovery (not sorted). Requires --format json + Stream newline-delimited JSON discovery events in arrival order from parallel discovery (not sorted). Requires --format json diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.de.xlf index 31d42f7488b..51caeb5d0ed 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.de.xlf @@ -73,8 +73,8 @@ {0} is the number of directories searched so far. {1} is the number of AppHost candidates found so far. - Stream newline-delimited JSON discovery events. Requires --format json - Stream newline-delimited JSON discovery events. Requires --format json + Stream newline-delimited JSON discovery events in arrival order from parallel discovery (not sorted). Requires --format json + Stream newline-delimited JSON discovery events in arrival order from parallel discovery (not sorted). Requires --format json diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.es.xlf index c730a706dfb..23f1175dc7c 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.es.xlf @@ -73,8 +73,8 @@ {0} is the number of directories searched so far. {1} is the number of AppHost candidates found so far. - Stream newline-delimited JSON discovery events. Requires --format json - Stream newline-delimited JSON discovery events. Requires --format json + Stream newline-delimited JSON discovery events in arrival order from parallel discovery (not sorted). Requires --format json + Stream newline-delimited JSON discovery events in arrival order from parallel discovery (not sorted). Requires --format json diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.fr.xlf index 49d5496493a..e8685651298 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.fr.xlf @@ -73,8 +73,8 @@ {0} is the number of directories searched so far. {1} is the number of AppHost candidates found so far. - Stream newline-delimited JSON discovery events. Requires --format json - Stream newline-delimited JSON discovery events. Requires --format json + Stream newline-delimited JSON discovery events in arrival order from parallel discovery (not sorted). Requires --format json + Stream newline-delimited JSON discovery events in arrival order from parallel discovery (not sorted). Requires --format json diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.it.xlf index 6767b9fd2c5..8b1912c116e 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.it.xlf @@ -73,8 +73,8 @@ {0} is the number of directories searched so far. {1} is the number of AppHost candidates found so far. - Stream newline-delimited JSON discovery events. Requires --format json - Stream newline-delimited JSON discovery events. Requires --format json + Stream newline-delimited JSON discovery events in arrival order from parallel discovery (not sorted). Requires --format json + Stream newline-delimited JSON discovery events in arrival order from parallel discovery (not sorted). Requires --format json diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ja.xlf index 548d2b428d7..f14065bcd71 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ja.xlf @@ -73,8 +73,8 @@ {0} is the number of directories searched so far. {1} is the number of AppHost candidates found so far. - Stream newline-delimited JSON discovery events. Requires --format json - Stream newline-delimited JSON discovery events. Requires --format json + Stream newline-delimited JSON discovery events in arrival order from parallel discovery (not sorted). Requires --format json + Stream newline-delimited JSON discovery events in arrival order from parallel discovery (not sorted). Requires --format json diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ko.xlf index 0b5ae46ed6f..7143f2b6f40 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ko.xlf @@ -73,8 +73,8 @@ {0} is the number of directories searched so far. {1} is the number of AppHost candidates found so far. - Stream newline-delimited JSON discovery events. Requires --format json - Stream newline-delimited JSON discovery events. Requires --format json + Stream newline-delimited JSON discovery events in arrival order from parallel discovery (not sorted). Requires --format json + Stream newline-delimited JSON discovery events in arrival order from parallel discovery (not sorted). Requires --format json diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.pl.xlf index 8774eb35ce6..a50398d0192 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.pl.xlf @@ -73,8 +73,8 @@ {0} is the number of directories searched so far. {1} is the number of AppHost candidates found so far. - Stream newline-delimited JSON discovery events. Requires --format json - Stream newline-delimited JSON discovery events. Requires --format json + Stream newline-delimited JSON discovery events in arrival order from parallel discovery (not sorted). Requires --format json + Stream newline-delimited JSON discovery events in arrival order from parallel discovery (not sorted). Requires --format json diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.pt-BR.xlf index 60c709da5f5..b70845a512a 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.pt-BR.xlf @@ -73,8 +73,8 @@ {0} is the number of directories searched so far. {1} is the number of AppHost candidates found so far. - Stream newline-delimited JSON discovery events. Requires --format json - Stream newline-delimited JSON discovery events. Requires --format json + Stream newline-delimited JSON discovery events in arrival order from parallel discovery (not sorted). Requires --format json + Stream newline-delimited JSON discovery events in arrival order from parallel discovery (not sorted). Requires --format json diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ru.xlf index 9d7383418f3..4030b196979 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ru.xlf @@ -73,8 +73,8 @@ {0} is the number of directories searched so far. {1} is the number of AppHost candidates found so far. - Stream newline-delimited JSON discovery events. Requires --format json - Stream newline-delimited JSON discovery events. Requires --format json + Stream newline-delimited JSON discovery events in arrival order from parallel discovery (not sorted). Requires --format json + Stream newline-delimited JSON discovery events in arrival order from parallel discovery (not sorted). Requires --format json diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.tr.xlf index 567191b78ac..d8cfbdfb345 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.tr.xlf @@ -73,8 +73,8 @@ {0} is the number of directories searched so far. {1} is the number of AppHost candidates found so far. - Stream newline-delimited JSON discovery events. Requires --format json - Stream newline-delimited JSON discovery events. Requires --format json + Stream newline-delimited JSON discovery events in arrival order from parallel discovery (not sorted). Requires --format json + Stream newline-delimited JSON discovery events in arrival order from parallel discovery (not sorted). Requires --format json diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.zh-Hans.xlf index 1f16cae6bc4..3fdc38ca175 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.zh-Hans.xlf @@ -73,8 +73,8 @@ {0} is the number of directories searched so far. {1} is the number of AppHost candidates found so far. - Stream newline-delimited JSON discovery events. Requires --format json - Stream newline-delimited JSON discovery events. Requires --format json + Stream newline-delimited JSON discovery events in arrival order from parallel discovery (not sorted). Requires --format json + Stream newline-delimited JSON discovery events in arrival order from parallel discovery (not sorted). Requires --format json diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.zh-Hant.xlf index 16bdb12f075..db879e98a78 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.zh-Hant.xlf @@ -73,8 +73,8 @@ {0} is the number of directories searched so far. {1} is the number of AppHost candidates found so far. - Stream newline-delimited JSON discovery events. Requires --format json - Stream newline-delimited JSON discovery events. Requires --format json + Stream newline-delimited JSON discovery events in arrival order from parallel discovery (not sorted). Requires --format json + Stream newline-delimited JSON discovery events in arrival order from parallel discovery (not sorted). Requires --format json diff --git a/src/Aspire.Cli/Utils/ConfigurationHelper.cs b/src/Aspire.Cli/Utils/ConfigurationHelper.cs index 042d20c694e..b83f5273393 100644 --- a/src/Aspire.Cli/Utils/ConfigurationHelper.cs +++ b/src/Aspire.Cli/Utils/ConfigurationHelper.cs @@ -7,7 +7,6 @@ using Aspire.Cli.Configuration; using Aspire.Cli.Resources; using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Logging; namespace Aspire.Cli.Utils; @@ -24,7 +23,7 @@ internal static class ConfigurationHelper AllowTrailingCommas = true }; - internal static void RegisterSettingsFiles(IConfigurationBuilder configuration, DirectoryInfo workingDirectory, FileInfo globalSettingsFile, ILogger logger) + internal static void RegisterSettingsFiles(IConfigurationBuilder configuration, DirectoryInfo workingDirectory, FileInfo globalSettingsFile) { var currentDirectory = workingDirectory; @@ -43,46 +42,27 @@ internal static void RegisterSettingsFiles(IConfigurationBuilder configuration, // TODO: Remove legacy .aspire/settings.json fallback once confident most users have migrated. // Tracked by https://github.com/microsoft/aspire/issues/15239 - // Fall back to .aspire/settings.json (legacy format) + // Fall back to .aspire/settings.json (legacy format). + // + // Startup is shared by every command — including read-only ones (aspire ls, ps, + // doctor, describe, --version). Earlier versions eagerly migrated the legacy file + // to aspire.config.json here so the workspace would move forward on the user's + // first run of a newer CLI (https://github.com/microsoft/aspire/issues/15488), + // but that broke the "read commands don't mutate the working tree" contract: + // running aspire ls in a workspace that only had .aspire/settings.json was + // silently writing aspire.config.json, polluting git status and tripping CI + // dirty-tree checks (https://github.com/microsoft/aspire/issues/17615). + // + // Migration is now deferred to commands that already mutate the workspace + // (aspire run/add/init/update/etc. via ProjectLocator.CreateSettingsFileAsync -> + // AspireConfigFile.LoadOrCreate). Read commands continue to work against the + // legacy file directly: AppHostPathConfigurationPolicy.TryFindAppHostPathKey + // accepts both the legacy flat "appHostPath" key and the modern "appHost:path" + // hierarchical key, and ProjectLocator's settings-file reader has its own legacy + // fallback that does not write. var legacySettingsPath = BuildPathToSettingsJsonFile(currentDirectory.FullName); if (File.Exists(legacySettingsPath)) { - // Eagerly migrate the legacy layout to aspire.config.json on startup so that - // even read-only commands (aspire doctor, aspire ls, aspire --version, etc.) - // move the workspace forward on the user's first run of a newer CLI. Without - // this, migration only happens when a command actively writes settings - // (aspire run/add/update/pipeline), leaving workspaces stuck on the legacy - // layout indefinitely for users who never invoke those commands. - // See https://github.com/microsoft/aspire/issues/15488. - if (LegacySettingsFileHasMigratableData(legacySettingsPath)) - { - try - { - _ = AspireConfigFile.LoadOrCreate(currentDirectory.FullName); - var migratedPath = Path.Combine(currentDirectory.FullName, AspireConfigFile.FileName); - if (File.Exists(migratedPath)) - { - logger.LogInformation( - "Migrated legacy {LegacyPath} to {MigratedPath} on CLI startup.", - legacySettingsPath, - migratedPath); - localSettingsFile = new FileInfo(migratedPath); - break; - } - } - catch (Exception ex) - { - // Migration is best-effort during startup. If it fails (read-only - // directory, IO error, malformed legacy JSON), fall back to using the - // legacy file directly so the CLI still works. The next command that - // writes settings will retry the migration through its normal path. - logger.LogWarning( - ex, - "Failed to migrate legacy {LegacyPath} to aspire.config.json on startup. Falling back to the legacy file.", - legacySettingsPath); - } - } - localSettingsFile = new FileInfo(legacySettingsPath); break; } @@ -108,47 +88,6 @@ internal static string BuildPathToSettingsJsonFile(string workingDirectory) return Path.Combine(workingDirectory, ".aspire", "settings.json"); } - /// - /// Returns true when a legacy .aspire/settings.json file contains an - /// appHostPath entry — the signal that this is a real AppHost workspace worth - /// migrating to aspire.config.json. Files that exist purely as directory-walking - /// stop markers (empty JSON object, whitespace, comment-only, or files that only carry - /// flat colon-keys awaiting in-place normalization) are not migrated because doing so - /// would needlessly materialize an aspire.config.json alongside them with no - /// meaningful content. - /// - private static bool LegacySettingsFileHasMigratableData(string legacySettingsPath) - { - try - { - var content = File.ReadAllText(legacySettingsPath); - if (string.IsNullOrWhiteSpace(content)) - { - return false; - } - - // Read appHostPath directly with JsonDocument to avoid coupling this check to the - // strict-typed AspireJsonConfiguration deserializer, which would fail for files - // that contain loosely-typed values (e.g. string "false" in a bool dictionary). - using var doc = JsonDocument.Parse(content, ParseOptions); - if (doc.RootElement.ValueKind != JsonValueKind.Object) - { - return false; - } - - return doc.RootElement.TryGetProperty("appHostPath", out var appHostPathElement) - && appHostPathElement.ValueKind == JsonValueKind.String - && !string.IsNullOrEmpty(appHostPathElement.GetString()); - } - catch - { - // Treat unreadable or malformed legacy files as not-migratable so startup does - // not throw. The user will see the same JSON parse error from the regular - // configuration registration path below if the file is genuinely broken. - return false; - } - } - internal static DirectoryInfo? GetLegacySettingsRootDirectory(FileInfo settingsFile) { if (!string.Equals(settingsFile.Name, AspireJsonConfiguration.FileName, StringComparison.OrdinalIgnoreCase)) diff --git a/src/Shared/PathNormalizer.cs b/src/Shared/PathNormalizer.cs index 19beaf4a0d0..7b5ea212bd3 100644 --- a/src/Shared/PathNormalizer.cs +++ b/src/Shared/PathNormalizer.cs @@ -84,4 +84,118 @@ public static string ResolveToFilesystemPath(string path) return path; } + + /// + /// Resolves symbolic links along every segment of and returns + /// the filesystem-canonical absolute path. Useful for comparing two user-supplied paths + /// that may differ only because one of them traverses a symlinked directory + /// (for example /tmp/x vs /private/tmp/x on macOS, where /tmp is a + /// symlink to /private/tmp). + /// + /// + /// Walks each segment so that an intermediate directory symlink resolves + /// correctly — only reads the + /// symlink at exactly the path it is given, so a single call on a path like + /// /tmp/x/y.cs would not unwrap /tmp. + /// On any IO failure (broken link, permission denied, missing intermediate + /// segment, circular link), returns the path with as many segments resolved as + /// possible. This is a best-effort canonicalization for comparison — callers should + /// not rely on it for security boundaries. + /// + public static string ResolveSymlinks(string path) + { + return ResolveSymlinksCore(path, depth: 0); + } + + // Hard depth limit on recursive canonicalization to defend against pathological + // symlink chains; well-formed real-world paths resolve in a handful of levels. + private const int MaxResolveSymlinksDepth = 40; + + private static string ResolveSymlinksCore(string path, int depth) + { + if (string.IsNullOrEmpty(path)) + { + return path; + } + + if (depth > MaxResolveSymlinksDepth) + { + // Give up rather than risk a stack overflow on circular/pathological links. + return path; + } + + try + { + var fullPath = Path.GetFullPath(path); + var root = Path.GetPathRoot(fullPath); + if (string.IsNullOrEmpty(root)) + { + return fullPath; + } + + // Walk only the part after the root so segment splitting cannot eat a drive + // letter ("C:") or UNC prefix. + var relative = fullPath[root.Length..]; + var segments = relative.Split( + [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], + StringSplitOptions.RemoveEmptyEntries); + + var current = root; + for (var i = 0; i < segments.Length; i++) + { + current = Path.Combine(current, segments[i]); + + FileSystemInfo? linkTarget = null; + try + { + // For intermediate segments we know they must be directories — files + // cannot have child segments. For the final segment, try file first + // then directory, since either is plausible. + linkTarget = i < segments.Length - 1 + ? Directory.ResolveLinkTarget(current, returnFinalTarget: true) + : File.ResolveLinkTarget(current, returnFinalTarget: true) + ?? Directory.ResolveLinkTarget(current, returnFinalTarget: true); + } + catch (IOException) + { + // Broken or circular symlink. Stop unwrapping and return what we have + // resolved so far combined with the remaining unresolved segments — + // matches the behaviour callers get from FileInfo when the link is bad. + return CombineRemaining(current, segments, i + 1); + } + catch (UnauthorizedAccessException) + { + return CombineRemaining(current, segments, i + 1); + } + + if (linkTarget?.FullName is { Length: > 0 } resolved) + { + // ResolveLinkTarget returns the symlink target exactly as stored on disk, + // which may itself contain unresolved symlinks in intermediate segments + // (for example on macOS a link target "/var/.../app" still has + // "/var -> /private/var" unresolved). Recurse so the canonical form does + // not depend on which side of the comparison reached the file first. + current = ResolveSymlinksCore(resolved, depth + 1); + } + } + + return current; + } + catch (Exception) + { + // Defensive: any unexpected normalization failure preserves caller-visible + // behaviour by falling back to the input path. + return path; + } + + static string CombineRemaining(string current, string[] segments, int startIndex) + { + for (var j = startIndex; j < segments.Length; j++) + { + current = Path.Combine(current, segments[j]); + } + + return current; + } + } } diff --git a/tests/Aspire.Cli.Tests/Commands/LsCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/LsCommandTests.cs index 59a2655e32b..660c40f978a 100644 --- a/tests/Aspire.Cli.Tests/Commands/LsCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/LsCommandTests.cs @@ -356,6 +356,58 @@ public async Task LsCommand_JsonFormat_Stream_WhenNoCandidates_DoesNotWriteStder Assert.Equal(string.Empty, errorWriter.ToString()); } + [Fact] + public async Task LsCommand_JsonFormat_Stream_EmitsCandidatesInArrivalOrder() + { + // Regression test for https://github.com/microsoft/aspire/issues/17621. + // `aspire ls --format json --stream` must emit each candidate in the order it + // arrives from parallel discovery — it must NOT sort the stream output. Without + // this contract, the streaming option offers no latency benefit over the buffered + // snapshot, and consumers that rely on prompt arrival are silently broken. + // + // Use names in non-alphabetical arrival order (Z, A, M) so any incidental + // alphabetical sort would fail this assertion. + using var workspace = TemporaryWorkspace.Create(outputHelper); + var textWriter = new TestOutputTextWriter(outputHelper); + var errorWriter = new StringWriter(); + var appHostPathZ = Path.Combine(workspace.WorkspaceRoot.FullName, "ZApp", "Z.AppHost.csproj"); + var appHostPathA = Path.Combine(workspace.WorkspaceRoot.FullName, "AApp", "A.AppHost.csproj"); + var appHostPathM = Path.Combine(workspace.WorkspaceRoot.FullName, "MApp", "M.AppHost.csproj"); + var appHostZ = new AppHostProjectCandidate(new FileInfo(appHostPathZ), KnownLanguageId.CSharp); + var appHostA = new AppHostProjectCandidate(new FileInfo(appHostPathA), KnownLanguageId.CSharp); + var appHostM = new AppHostProjectCandidate(new FileInfo(appHostPathM), KnownLanguageId.CSharp); + var projectLocator = new TestProjectLocator + { + FindAppHostProjectsStreamAsyncCallback = (_, _, _, _) => ToAsyncEnumerable(appHostZ, appHostA, appHostM) + }; + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.OutputTextWriter = textWriter; + options.ErrorTextWriter = errorWriter; + options.ProjectLocatorFactory = _ => projectLocator; + }); + using var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("ls --format json --stream"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(CliExitCodes.Success, exitCode); + + var lines = textWriter.Logs.ToArray(); + Assert.Equal(3, lines.Length); + + using var first = JsonDocument.Parse(lines[0]); + using var second = JsonDocument.Parse(lines[1]); + using var third = JsonDocument.Parse(lines[2]); + Assert.Equal(appHostPathZ, first.RootElement.GetProperty("path").GetString()); + Assert.Equal(appHostPathA, second.RootElement.GetProperty("path").GetString()); + Assert.Equal(appHostPathM, third.RootElement.GetProperty("path").GetString()); + Assert.Equal(string.Empty, errorWriter.ToString()); + } + [Fact] public async Task LsCommand_JsonFormat_Stream_FlushesCandidateBeforeDiscoveryCompletes() { diff --git a/tests/Aspire.Cli.Tests/Configuration/ConfigurationHelperTests.cs b/tests/Aspire.Cli.Tests/Configuration/ConfigurationHelperTests.cs index 0f6a189c035..796a2b3bd04 100644 --- a/tests/Aspire.Cli.Tests/Configuration/ConfigurationHelperTests.cs +++ b/tests/Aspire.Cli.Tests/Configuration/ConfigurationHelperTests.cs @@ -7,7 +7,6 @@ using Aspire.Cli.Tests.Utils; using Aspire.Cli.Utils; using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Logging.Abstractions; namespace Aspire.Cli.Tests.Configuration; @@ -23,7 +22,7 @@ private static IConfiguration BuildConfigurationFromSettingsFile( var globalSettingsFile = new FileInfo(Path.Combine(globalDir.FullName, AspireConfigFile.FileName)); var builder = new ConfigurationBuilder(); - ConfigurationHelper.RegisterSettingsFiles(builder, workspace.WorkspaceRoot, globalSettingsFile, NullLogger.Instance); + ConfigurationHelper.RegisterSettingsFiles(builder, workspace.WorkspaceRoot, globalSettingsFile); return builder.Build(); } @@ -174,13 +173,19 @@ public void GetWorkspaceAspireDirectory_UsesLegacySettingsParentDirectory() } [Fact] - public void RegisterSettingsFiles_MigratesLegacySettingsToAspireConfigJsonOnStartup() + public void RegisterSettingsFiles_DoesNotMigrateLegacySettingsOnStartup() { - // Reproduces https://github.com/microsoft/aspire/issues/15488: a user upgrades the - // CLI and runs it against an existing AppHost workspace that only has the legacy - // .aspire/settings.json. The CLI must eagerly migrate the workspace to - // aspire.config.json on startup, regardless of which command the user runs (even - // read-only commands that never pass createSettingsFile: true to ProjectLocator). + // Read commands (aspire ls, ps, doctor, --version) must not silently write + // aspire.config.json to disk when a workspace only has the legacy + // .aspire/settings.json. Earlier versions migrated eagerly here + // (https://github.com/microsoft/aspire/issues/15488), but that violated the + // "read commands don't mutate the working tree" contract reported in + // https://github.com/microsoft/aspire/issues/17615. + // + // Migration is now deferred to commands that already mutate the workspace + // (aspire run/add/init/update/etc.). Startup must register the legacy file + // directly so legacy settings remain readable from IConfiguration without + // materializing aspire.config.json. using var workspace = TemporaryWorkspace.Create(outputHelper); var legacyDir = workspace.CreateDirectory(AspireJsonConfiguration.SettingsFolder); @@ -199,11 +204,18 @@ public void RegisterSettingsFiles_MigratesLegacySettingsToAspireConfigJsonOnStar var globalSettingsFile = new FileInfo(Path.Combine(globalDir.FullName, AspireConfigFile.FileName)); var builder = new ConfigurationBuilder(); - ConfigurationHelper.RegisterSettingsFiles(builder, workspace.WorkspaceRoot, globalSettingsFile, NullLogger.Instance); + ConfigurationHelper.RegisterSettingsFiles(builder, workspace.WorkspaceRoot, globalSettingsFile); - Assert.True( + Assert.False( File.Exists(aspireConfigPath), - "aspire.config.json should have been created by eager migration during RegisterSettingsFiles."); + "Startup must not materialize aspire.config.json — that would be a silent write from a read command path."); + + // Legacy values must remain readable via their flat key. Consumers reading the + // hierarchical key go through AppHostPathConfigurationPolicy.TryFindAppHostPathKey, + // which falls back to the legacy "appHostPath" key. + var config = builder.Build(); + Assert.Equal("MyApp.csproj", config["appHostPath"]); + Assert.Equal("stable", config["channel"]); } [Fact] @@ -213,8 +225,8 @@ public void RegisterSettingsFiles_DoesNotOverwriteExistingAspireConfigJson() // Both files present: the workspace was already migrated but the legacy file was // retained (this is the documented transition state — see AspireConfigFile.LoadOrCreate - // and https://github.com/microsoft/aspire/issues/15239). Startup migration must not - // clobber the existing aspire.config.json or re-migrate from stale legacy data. + // and https://github.com/microsoft/aspire/issues/15239). Startup must continue to + // prefer the new file and must not touch either file from a read-command path. var aspireConfigPath = Path.Combine(workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName); var existingContent = """ { @@ -234,7 +246,7 @@ public void RegisterSettingsFiles_DoesNotOverwriteExistingAspireConfigJson() var globalSettingsFile = new FileInfo(Path.Combine(globalDir.FullName, AspireConfigFile.FileName)); var builder = new ConfigurationBuilder(); - ConfigurationHelper.RegisterSettingsFiles(builder, workspace.WorkspaceRoot, globalSettingsFile, NullLogger.Instance); + ConfigurationHelper.RegisterSettingsFiles(builder, workspace.WorkspaceRoot, globalSettingsFile); Assert.Equal(existingContent, File.ReadAllText(aspireConfigPath)); @@ -244,76 +256,26 @@ public void RegisterSettingsFiles_DoesNotOverwriteExistingAspireConfigJson() } [Fact] - public void RegisterSettingsFiles_GuardRejectsUnparseableLegacyFile() + public void RegisterSettingsFiles_UnparseableLegacyFileDoesNotCreateAspireConfigJson() { using var workspace = TemporaryWorkspace.Create(outputHelper); var legacyDir = workspace.CreateDirectory(AspireJsonConfiguration.SettingsFolder); var legacySettingsPath = Path.Combine(legacyDir.FullName, AspireJsonConfiguration.FileName); - // Unparseable JSON fails JsonDocument.Parse inside LegacySettingsFileHasMigratableData, - // so the guard short-circuits and returns false before migration is attempted. The - // downstream JSON registration via AddSettingsFile is what surfaces the parse error - // to the user, which is the pre-existing "your settings.json is broken" signal. The - // migration step itself must not introduce a new crash path on top of that. File.WriteAllText(legacySettingsPath, "{ this is not valid json"); var globalDir = workspace.CreateDirectory("global-aspire"); var globalSettingsFile = new FileInfo(Path.Combine(globalDir.FullName, AspireConfigFile.FileName)); var builder = new ConfigurationBuilder(); + // AddSettingsFile (JSON configuration provider) may throw on malformed JSON when the + // configuration is built, but RegisterSettingsFiles itself must not produce a + // partially-written aspire.config.json on the way through. var ex = Record.Exception(() => - ConfigurationHelper.RegisterSettingsFiles(builder, workspace.WorkspaceRoot, globalSettingsFile, NullLogger.Instance)); + ConfigurationHelper.RegisterSettingsFiles(builder, workspace.WorkspaceRoot, globalSettingsFile)); - // The guard rejected the file before migration ran, so aspire.config.json must not have - // been materialized at the workspace root. var migratedPath = Path.Combine(workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName); - Assert.False(File.Exists(migratedPath), "Unparseable legacy file should not produce a partial aspire.config.json."); - // Either no exception (graceful), or the same InvalidOperationException previously - // thrown by AddSettingsFile for malformed JSON. Both are acceptable; what we're - // proving is that the guard prevented us from crashing inside the new migration step. + Assert.False(File.Exists(migratedPath), "Unparseable legacy file must not produce an aspire.config.json on disk."); Assert.True(ex is null or InvalidOperationException, $"Unexpected exception type: {ex?.GetType().FullName}"); } - - [Fact] - public void RegisterSettingsFiles_FallsBackToLegacyWhenMigrationLoadThrows() - { - using var workspace = TemporaryWorkspace.Create(outputHelper); - - var legacyDir = workspace.CreateDirectory(AspireJsonConfiguration.SettingsFolder); - var legacySettingsPath = Path.Combine(legacyDir.FullName, AspireJsonConfiguration.FileName); - // This file is *parseable* JSON with a valid string appHostPath, so - // LegacySettingsFileHasMigratableData returns true and we enter the migration try - // block. However, "features" is typed Dictionary with a strict - // FlexibleBooleanDictionaryConverter, so passing a string for it causes - // AspireJsonConfiguration.Load (invoked by AspireConfigFile.LoadOrCreate) to throw a - // JsonException. That exception must be caught and we must fall back to registering - // the legacy file directly. - File.WriteAllText(legacySettingsPath, """ - { - "appHostPath": "MyApp.csproj", - "features": "not-an-object" - } - """); - - var globalDir = workspace.CreateDirectory("global-aspire"); - var globalSettingsFile = new FileInfo(Path.Combine(globalDir.FullName, AspireConfigFile.FileName)); - - var builder = new ConfigurationBuilder(); - var ex = Record.Exception(() => - ConfigurationHelper.RegisterSettingsFiles(builder, workspace.WorkspaceRoot, globalSettingsFile, NullLogger.Instance)); - - Assert.Null(ex); - - // Migration failed inside LoadOrCreate, so aspire.config.json must not exist at the - // workspace root. Its absence proves we hit the catch block and continued past the - // failed migration rather than half-writing a new config file. - var migratedPath = Path.Combine(workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName); - Assert.False(File.Exists(migratedPath), "Failed migration should not produce a partial aspire.config.json."); - - // The fallback registered the legacy file directly, so appHostPath remains readable - // from configuration via its flat key (the JSON source flattens nested objects with ':', - // but appHostPath is a root-level scalar so its key is unchanged). - var config = builder.Build(); - Assert.Equal("MyApp.csproj", config["appHostPath"]); - } } diff --git a/tests/Aspire.Cli.Tests/Projects/ProjectLocatorTests.cs b/tests/Aspire.Cli.Tests/Projects/ProjectLocatorTests.cs index 8fc9e2bdb46..6f01c36461e 100644 --- a/tests/Aspire.Cli.Tests/Projects/ProjectLocatorTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/ProjectLocatorTests.cs @@ -2594,6 +2594,225 @@ await projectLocator.UseOrFindAppHostProjectFileAsync( }); } + [Fact] + public async Task GetAppHostFromSettings_LegacyFile_WarningNamesSettingsJsonNotAspireConfigJson() + { + // Regression test for https://github.com/microsoft/aspire/issues/17620. + // When the only user-authored config is the legacy .aspire/settings.json and its + // appHostPath points at a file that no longer exists, the warning must reference + // .aspire/settings.json (the file the user wrote) and must NOT reference + // aspire.config.json (which the user never authored). + // + // The discovery walk (FindAppHostProjectsAsync, the path used by `aspire ls`) is + // the user-facing surface that emits this warning via AddSettingsAppHostCandidateAsync. + // The probe-style GetAppHostFromSettingsAsync API is intentionally silent so background + // feature-detection callers (DotNetSdkCheck, AspireVersionCheck, etc.) don't leak + // user-facing output. + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var workspaceSettingsDirectory = workspace.CreateDirectory(".aspire"); + var aspireSettingsFile = new FileInfo(Path.Combine(workspaceSettingsDirectory.FullName, "settings.json")); + + using (var writer = aspireSettingsFile.OpenWrite()) + { + await JsonSerializer.SerializeAsync(writer, new + { + appHostPath = "DoesNotExist/AppHost.csproj" + }); + } + + var interactionService = new TestInteractionService(); + var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); + var projectLocator = CreateProjectLocator(executionContext, interactionService: interactionService); + + await projectLocator.FindAppHostProjectsAsync(workspace.WorkspaceRoot, AppHostDiscoveryScope.DefaultFiltered, CancellationToken.None).DefaultTimeout(); + + var warning = Assert.Single(interactionService.DisplayedMessages); + Assert.Equal(KnownEmojis.Warning, warning.Emoji); + Assert.Contains(aspireSettingsFile.FullName, warning.Message); + Assert.DoesNotContain(AspireConfigFile.FileName, warning.Message); + } + + [Fact] + public async Task GetAppHostFromSettings_AspireConfigJson_WithNulByteInPath_ReportsValidationErrorAndDoesNotCrash() + { + // Regression test for https://github.com/microsoft/aspire/issues/17624. + // A NUL byte in appHost.path survives JSON deserialization, then would crash + // inside Path.Combine / new FileInfo with "Null character in path." surfaced as + // a generic "unexpected error". Discovery must instead return null and display + // a clear validation error naming the user-authored config file. + // + // Driven through FindAppHostProjectsAsync (the user-facing `aspire ls` path) so + // the validation error surfaces via AddSettingsAppHostCandidateAsync. + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var configPath = Path.Combine(workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName); + // \u0000 is the escaped NUL byte; the JSON parser preserves it as a literal \0 + // inside the deserialized string. Writing a raw NUL would test JSON parsing, + // not the post-deserialization validation we want to exercise. + await File.WriteAllTextAsync(configPath, """ + { "appHost": { "path": "a\u0000b.csproj" } } + """); + + var interactionService = new TestInteractionService(); + var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); + var projectLocator = CreateProjectLocator(executionContext, interactionService: interactionService); + + await projectLocator.FindAppHostProjectsAsync(workspace.WorkspaceRoot, AppHostDiscoveryScope.DefaultFiltered, CancellationToken.None).DefaultTimeout(); + + var error = Assert.Single(interactionService.DisplayedErrors); + Assert.Contains(configPath, error); + Assert.Contains("appHost.path", error); + } + + [Fact] + public async Task GetAppHostFromSettings_LegacySettings_WithNulByteInPath_ReportsValidationErrorAndDoesNotCrash() + { + // Same scenario as the modern-config case above, but for the legacy + // .aspire/settings.json reader. Both code paths consume a user-supplied path + // string and must validate it before reaching System.IO APIs. + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var workspaceSettingsDirectory = workspace.CreateDirectory(".aspire"); + var aspireSettingsFile = new FileInfo(Path.Combine(workspaceSettingsDirectory.FullName, "settings.json")); + await File.WriteAllTextAsync(aspireSettingsFile.FullName, """ + { "appHostPath": "a\u0000b.csproj" } + """); + + var interactionService = new TestInteractionService(); + var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); + var projectLocator = CreateProjectLocator(executionContext, interactionService: interactionService); + + await projectLocator.FindAppHostProjectsAsync(workspace.WorkspaceRoot, AppHostDiscoveryScope.DefaultFiltered, CancellationToken.None).DefaultTimeout(); + + var error = Assert.Single(interactionService.DisplayedErrors); + Assert.Contains(aspireSettingsFile.FullName, error); + Assert.Contains("appHostPath", error); + } + + [Fact] + public async Task GetAppHostFromSettings_AspireConfigJson_WithEmptyPath_ReportsValidationErrorAndDoesNotCrash() + { + // Adversarial regression: an empty string for appHost.path is technically valid + // JSON but used to fall through the L4 validation with the misleading + // "contains characters that are not allowed" message even though it has no + // characters at all. Path.Combine(directory, "") collapses to the directory, + // which would then surface a "directory is not a file" warning downstream. + // The validator now rejects empty paths up front with the corrected message. + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var configPath = Path.Combine(workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName); + await File.WriteAllTextAsync(configPath, """ + { "appHost": { "path": "" } } + """); + + var interactionService = new TestInteractionService(); + var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); + var projectLocator = CreateProjectLocator(executionContext, interactionService: interactionService); + + await projectLocator.FindAppHostProjectsAsync(workspace.WorkspaceRoot, AppHostDiscoveryScope.DefaultFiltered, CancellationToken.None).DefaultTimeout(); + + var error = Assert.Single(interactionService.DisplayedErrors); + Assert.Contains(configPath, error); + Assert.Contains("appHost.path", error); + Assert.Contains("is empty or contains", error); + } + + [Fact] + public async Task GetAppHostFromSettings_LegacySettings_WithEmptyPath_ReportsValidationErrorAndDoesNotCrash() + { + // Companion to the modern-config empty-path case above; the legacy reader has + // the same gap and must surface the same corrected validation error. + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var workspaceSettingsDirectory = workspace.CreateDirectory(".aspire"); + var aspireSettingsFile = new FileInfo(Path.Combine(workspaceSettingsDirectory.FullName, "settings.json")); + await File.WriteAllTextAsync(aspireSettingsFile.FullName, """ + { "appHostPath": "" } + """); + + var interactionService = new TestInteractionService(); + var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); + var projectLocator = CreateProjectLocator(executionContext, interactionService: interactionService); + + await projectLocator.FindAppHostProjectsAsync(workspace.WorkspaceRoot, AppHostDiscoveryScope.DefaultFiltered, CancellationToken.None).DefaultTimeout(); + + var error = Assert.Single(interactionService.DisplayedErrors); + Assert.Contains(aspireSettingsFile.FullName, error); + Assert.Contains("appHostPath", error); + Assert.Contains("is empty or contains", error); + } + + [Fact] + public async Task FindAppHostProjectsAsync_DeduplicatesSettingsCandidateAcrossSymlink() + { + // Regression test for https://github.com/microsoft/aspire/issues/17626. + // When the user's settings file points at an apphost via a symlinked path + // (for example /tmp/L5/x.cs on macOS while the canonical path is + // /private/tmp/L5/x.cs), the directory walk surfaces the canonical path and the + // settings reader surfaces the user-written symbolic path. Plain string + // comparison between the two used to produce a duplicate entry; dedupe must now + // canonicalize symlinks before comparing. + // + // To isolate the settings-vs-walk dedupe from the walk's own behaviour, the + // symlink target lives in a directory the default discovery filter skips + // (node_modules), so the walk only surfaces the real path. + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var realAppDirectory = workspace.WorkspaceRoot.CreateSubdirectory("real-app"); + var realAppHost = new FileInfo(Path.Combine(realAppDirectory.FullName, "AppHost.csproj")); + await File.WriteAllTextAsync(realAppHost.FullName, "Not a real project file."); + + // node_modules is excluded from DefaultFiltered discovery, so a symlink placed + // there is invisible to the walk. The same symlink path used in settings still + // resolves through the link to the real apphost file. + var nodeModulesDir = workspace.WorkspaceRoot.CreateSubdirectory("node_modules"); + var linkDirectory = Path.Combine(nodeModulesDir.FullName, "link"); + try + { + Directory.CreateSymbolicLink(linkDirectory, realAppDirectory.FullName); + } + catch (UnauthorizedAccessException ex) + { + Assert.Skip($"Cannot create symbolic links in this environment: {ex.Message}"); + } + catch (IOException ex) + { + Assert.Skip($"Symbolic link creation failed in this environment: {ex.Message}"); + } + + var pathThroughLink = Path.Combine(linkDirectory, "AppHost.csproj"); + + // Sanity-check the test setup: the symlink path must resolve to the real file, + // and the two strings must differ — otherwise the test would pass trivially. + Assert.True(File.Exists(pathThroughLink), "Symlinked path should resolve to the real apphost file."); + Assert.NotEqual(realAppHost.FullName, pathThroughLink); + + var workspaceSettingsDirectory = workspace.CreateDirectory(".aspire"); + var aspireSettingsFile = new FileInfo(Path.Combine(workspaceSettingsDirectory.FullName, "settings.json")); + using (var writer = aspireSettingsFile.OpenWrite()) + { + await JsonSerializer.SerializeAsync(writer, new + { + appHostPath = pathThroughLink + }); + } + + var projectFactory = new TestAppHostProjectFactory + { + ValidateAppHostCallback = _ => new AppHostValidationResult(IsValid: true) + }; + + var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); + var projectLocator = CreateProjectLocator(executionContext, projectFactory: projectFactory); + + var found = await projectLocator.FindAppHostProjectsAsync(workspace.WorkspaceRoot, AppHostDiscoveryScope.DefaultFiltered, CancellationToken.None).DefaultTimeout(); + + // Exactly one candidate: the settings-derived path through the symlink must be + // recognized as the same file as the walk-discovered real path. + Assert.Single(found); + } + private static ProjectLocator CreateProjectLocator( CliExecutionContext executionContext, IInteractionService? interactionService = null, diff --git a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs index 628238e9f0e..e1081821c39 100644 --- a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs +++ b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs @@ -83,7 +83,7 @@ public static IServiceCollection CreateServiceCollection(TemporaryWorkspace work var globalSettingsFilePath = Path.Combine(options.WorkingDirectory.FullName, ".aspire", "settings.global.json"); var globalSettingsFile = new FileInfo(globalSettingsFilePath); - ConfigurationHelper.RegisterSettingsFiles(configBuilder, options.WorkingDirectory, globalSettingsFile, NullLogger.Instance); + ConfigurationHelper.RegisterSettingsFiles(configBuilder, options.WorkingDirectory, globalSettingsFile); var configuration = configBuilder.Build(); services.AddSingleton(configuration); diff --git a/tests/Aspire.Cli.Tests/Utils/PathNormalizerTests.cs b/tests/Aspire.Cli.Tests/Utils/PathNormalizerTests.cs new file mode 100644 index 00000000000..728f8a3878f --- /dev/null +++ b/tests/Aspire.Cli.Tests/Utils/PathNormalizerTests.cs @@ -0,0 +1,121 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.Utils; + +namespace Aspire.Cli.Tests.Utils; + +public class PathNormalizerTests(ITestOutputHelper outputHelper) +{ + [Fact] + public void ResolveSymlinks_IsIdempotent_WhenPathHasNoSymlinks() + { + // The input itself may sit under a symlinked root (for example /var -> /private/var + // on macOS), so we cannot assert the result equals the input. We can assert + // idempotence: a path with no remaining symlinks must resolve to itself. + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var subdir = workspace.WorkspaceRoot.CreateSubdirectory("App"); + var file = new FileInfo(Path.Combine(subdir.FullName, "app.csproj")); + File.WriteAllText(file.FullName, ""); + + var firstPass = PathNormalizer.ResolveSymlinks(file.FullName); + var secondPass = PathNormalizer.ResolveSymlinks(firstPass); + + Assert.Equal(firstPass, secondPass); + } + + [Fact] + public void ResolveSymlinks_ReturnsInputUnchanged_WhenEmpty() + { + Assert.Equal(string.Empty, PathNormalizer.ResolveSymlinks(string.Empty)); + } + + [Fact] + public void ResolveSymlinks_ResolvesFinalFileSymlink() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var target = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "target.csproj")); + File.WriteAllText(target.FullName, ""); + + var linkPath = Path.Combine(workspace.WorkspaceRoot.FullName, "link.csproj"); + TryCreateSymlink(linkPath, target.FullName, isDirectory: false); + + var resolved = PathNormalizer.ResolveSymlinks(linkPath); + + // The link's final target should be canonical-equal to the real file. We use + // ResolveSymlinks on the target as well to account for the temp directory itself + // sitting under a symlinked root (for example /tmp -> /private/tmp on macOS). + Assert.Equal(PathNormalizer.ResolveSymlinks(target.FullName), resolved); + } + + [Fact] + public void ResolveSymlinks_ResolvesIntermediateDirectorySymlink() + { + // The L5 repro relies on a symlink that is NOT the final segment: on macOS, + // /tmp -> /private/tmp, and the apphost lives at /tmp/L5/x.cs. A single call to + // Directory.ResolveLinkTarget on the full path would not unwrap /tmp, so the + // implementation must walk segments. + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var realDirectory = workspace.WorkspaceRoot.CreateSubdirectory("real"); + var nested = realDirectory.CreateSubdirectory("nested"); + var file = new FileInfo(Path.Combine(nested.FullName, "app.csproj")); + File.WriteAllText(file.FullName, ""); + + var linkDirectory = Path.Combine(workspace.WorkspaceRoot.FullName, "link"); + TryCreateSymlink(linkDirectory, realDirectory.FullName, isDirectory: true); + + // Path through the link should resolve to the same canonical path as the path + // through the real directory. + var pathThroughLink = Path.Combine(linkDirectory, "nested", "app.csproj"); + + var resolvedThroughLink = PathNormalizer.ResolveSymlinks(pathThroughLink); + var resolvedThroughReal = PathNormalizer.ResolveSymlinks(file.FullName); + + Assert.Equal(resolvedThroughReal, resolvedThroughLink); + } + + [Fact] + public void ResolveSymlinks_PreservesPath_WhenLinkIsBroken() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var missingTarget = Path.Combine(workspace.WorkspaceRoot.FullName, "missing.csproj"); + var linkPath = Path.Combine(workspace.WorkspaceRoot.FullName, "broken-link.csproj"); + TryCreateSymlink(linkPath, missingTarget, isDirectory: false); + + // A broken link should not throw — the method must fall back to returning the + // path so callers can still surface a useful "file not found" error. + var resolved = PathNormalizer.ResolveSymlinks(linkPath); + + Assert.False(string.IsNullOrEmpty(resolved)); + } + + private static void TryCreateSymlink(string linkPath, string targetPath, bool isDirectory) + { + try + { + if (isDirectory) + { + Directory.CreateSymbolicLink(linkPath, targetPath); + } + else + { + File.CreateSymbolicLink(linkPath, targetPath); + } + } + catch (UnauthorizedAccessException ex) + { + // Creating symlinks on Windows requires either administrator rights or + // Developer Mode. Skip cleanly on environments that don't allow it rather + // than failing the test for an environment reason. + Assert.Skip($"Cannot create symbolic links in this environment: {ex.Message}"); + } + catch (IOException ex) + { + Assert.Skip($"Symbolic link creation failed in this environment: {ex.Message}"); + } + } +} From efae1b110f88fc23aa4d7f5f854c3c82ed6ede8d Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Fri, 29 May 2026 11:56:15 -0400 Subject: [PATCH 2/6] Address Aspire CLI PR feedback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Commands/LsCommand.cs | 2 +- src/Aspire.Cli/Projects/ProjectLocator.cs | 127 +++++++++++++----- .../Resources/ErrorStrings.Designer.cs | 18 +++ src/Aspire.Cli/Resources/ErrorStrings.resx | 8 ++ .../Resources/SharedCommandStrings.resx | 2 +- .../Resources/xlf/ErrorStrings.cs.xlf | 10 ++ .../Resources/xlf/ErrorStrings.de.xlf | 10 ++ .../Resources/xlf/ErrorStrings.es.xlf | 10 ++ .../Resources/xlf/ErrorStrings.fr.xlf | 10 ++ .../Resources/xlf/ErrorStrings.it.xlf | 10 ++ .../Resources/xlf/ErrorStrings.ja.xlf | 10 ++ .../Resources/xlf/ErrorStrings.ko.xlf | 10 ++ .../Resources/xlf/ErrorStrings.pl.xlf | 10 ++ .../Resources/xlf/ErrorStrings.pt-BR.xlf | 10 ++ .../Resources/xlf/ErrorStrings.ru.xlf | 10 ++ .../Resources/xlf/ErrorStrings.tr.xlf | 10 ++ .../Resources/xlf/ErrorStrings.zh-Hans.xlf | 10 ++ .../Resources/xlf/ErrorStrings.zh-Hant.xlf | 10 ++ .../Resources/xlf/SharedCommandStrings.cs.xlf | 4 +- .../Resources/xlf/SharedCommandStrings.de.xlf | 4 +- .../Resources/xlf/SharedCommandStrings.es.xlf | 4 +- .../Resources/xlf/SharedCommandStrings.fr.xlf | 4 +- .../Resources/xlf/SharedCommandStrings.it.xlf | 4 +- .../Resources/xlf/SharedCommandStrings.ja.xlf | 4 +- .../Resources/xlf/SharedCommandStrings.ko.xlf | 4 +- .../Resources/xlf/SharedCommandStrings.pl.xlf | 4 +- .../xlf/SharedCommandStrings.pt-BR.xlf | 4 +- .../Resources/xlf/SharedCommandStrings.ru.xlf | 4 +- .../Resources/xlf/SharedCommandStrings.tr.xlf | 4 +- .../xlf/SharedCommandStrings.zh-Hans.xlf | 4 +- .../xlf/SharedCommandStrings.zh-Hant.xlf | 4 +- .../Projects/ProjectLocatorTests.cs | 126 ++++++++++++++++- 32 files changed, 400 insertions(+), 65 deletions(-) diff --git a/src/Aspire.Cli/Commands/LsCommand.cs b/src/Aspire.Cli/Commands/LsCommand.cs index b044eb75649..26c35e431c1 100644 --- a/src/Aspire.Cli/Commands/LsCommand.cs +++ b/src/Aspire.Cli/Commands/LsCommand.cs @@ -155,7 +155,7 @@ private async Task> FindAppHostsWithJsonStreamAsyn // `aspire ls --format json --stream` emits each candidate as soon as discovery surfaces // it (arrival order from parallel discovery). The contract is documented in - // docs/specs/cli-output-formats.md and in LsStreamOptionDescription. Do NOT sort here: + // docs/specs/cli-output-formats.md. Do NOT sort here: // candidates have already been written to stdout via WriteJsonStreamCandidate above, so // any post-loop sort would only reorder this in-memory list — which the caller does not // use for stream output. Pipe to `sort` / `jq -s 'sort_by(.path)'` for ordered output. diff --git a/src/Aspire.Cli/Projects/ProjectLocator.cs b/src/Aspire.Cli/Projects/ProjectLocator.cs index 1bb65b7ca2e..96397a092e2 100644 --- a/src/Aspire.Cli/Projects/ProjectLocator.cs +++ b/src/Aspire.Cli/Projects/ProjectLocator.cs @@ -627,12 +627,10 @@ bool IsDuplicate(AppHostProjectCandidate candidate) } catch (JsonException ex) { - if (!silent) - { - interactionService.DisplayError(ex.Message); - } + ReportInvalidConfigurationFile(ex, ex.Message, silent); return null; } + if (aspireConfig?.AppHost?.Path is { } configAppHostPath) { var configFilePath = Path.Combine(searchDirectory.FullName, AspireConfigFile.FileName); @@ -675,42 +673,66 @@ bool IsDuplicate(AppHostProjectCandidate candidate) if (settingsFile.Exists) { - using var stream = settingsFile.OpenRead(); - var json = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken); - - if (json.RootElement.TryGetProperty("appHostPath", out var appHostPathProperty) && appHostPathProperty.GetString() is { } appHostPath) + try { - // Mirror the validation on the modern path above so the legacy branch also - // cannot reach Path.Combine with a NUL byte or other Path.GetInvalidPathChars - // value (https://github.com/microsoft/aspire/issues/17624). - if (!IsValidConfiguredAppHostPath(appHostPath, settingsFile.FullName, "appHostPath", silent)) + using var stream = settingsFile.OpenRead(); + using var json = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false); + + if (json.RootElement.ValueKind is not JsonValueKind.Object) { + ReportInvalidConfigurationFileShape(settingsFile.FullName, silent); return null; } - var qualifiedAppHostPath = Path.IsPathRooted(appHostPath) ? appHostPath : Path.Combine(settingsFile.Directory!.FullName, appHostPath); - qualifiedAppHostPath = PathNormalizer.NormalizePathForCurrentPlatform(qualifiedAppHostPath); - var appHostFile = new FileInfo(qualifiedAppHostPath); - - if (appHostFile.Exists) + if (json.RootElement.TryGetProperty("appHostPath", out var appHostPathProperty)) { - return appHostFile; - } - else - { - if (!silent) + if (appHostPathProperty.ValueKind is not JsonValueKind.Null and not JsonValueKind.String) { - // Warn against the user-authored file (.aspire/settings.json), not the - // never-authored aspire.config.json. Earlier versions reported - // aspire.config.json because startup eagerly migrated the legacy - // settings (PR #17234); see https://github.com/microsoft/aspire/issues/17620 - // for the user-facing impact of pointing users at a file they did - // not create. - interactionService.DisplayMessage(KnownEmojis.Warning, string.Format(CultureInfo.CurrentCulture, ErrorStrings.AppHostWasSpecifiedButDoesntExist, settingsFile.FullName, qualifiedAppHostPath)); + ReportInvalidConfiguredAppHostPathType(settingsFile.FullName, "appHostPath", silent); + return null; + } + + if (appHostPathProperty.GetString() is { } appHostPath) + { + // Mirror the validation on the modern path above so the legacy branch also + // cannot reach Path.Combine with a NUL byte or other Path.GetInvalidPathChars + // value (https://github.com/microsoft/aspire/issues/17624). + if (!IsValidConfiguredAppHostPath(appHostPath, settingsFile.FullName, "appHostPath", silent)) + { + return null; + } + + var qualifiedAppHostPath = Path.IsPathRooted(appHostPath) ? appHostPath : Path.Combine(settingsFile.Directory!.FullName, appHostPath); + qualifiedAppHostPath = PathNormalizer.NormalizePathForCurrentPlatform(qualifiedAppHostPath); + var appHostFile = new FileInfo(qualifiedAppHostPath); + + if (appHostFile.Exists) + { + return appHostFile; + } + else + { + if (!silent) + { + // Warn against the user-authored file (.aspire/settings.json), not the + // never-authored aspire.config.json. Earlier versions reported + // aspire.config.json because startup eagerly migrated the legacy + // settings (PR #17234); see https://github.com/microsoft/aspire/issues/17620 + // for the user-facing impact of pointing users at a file they did + // not create. + interactionService.DisplayMessage(KnownEmojis.Warning, string.Format(CultureInfo.CurrentCulture, ErrorStrings.AppHostWasSpecifiedButDoesntExist, settingsFile.FullName, qualifiedAppHostPath)); + } + return null; + } } - return null; } } + catch (JsonException ex) + { + var message = string.Format(CultureInfo.CurrentCulture, ErrorStrings.InvalidJsonInConfigFile, settingsFile.FullName, ex.Message); + ReportInvalidConfigurationFile(ex, message, silent); + return null; + } } if (searchParentDirectories && searchDirectory.Parent is not null) @@ -724,6 +746,44 @@ bool IsDuplicate(AppHostProjectCandidate candidate) } } + private void ReportInvalidConfigurationFileShape(string configFilePath, bool silent) + { + var message = string.Format(CultureInfo.CurrentCulture, ErrorStrings.ConfigurationFileMustBeJsonObject, configFilePath); + if (!silent) + { + interactionService.DisplayError(message); + } + else + { + logger.LogWarning("Ignoring AppHost settings in '{ConfigFilePath}' because the configuration root is not a JSON object.", configFilePath); + } + } + + private void ReportInvalidConfiguredAppHostPathType(string configFilePath, string fieldName, bool silent) + { + var message = string.Format(CultureInfo.CurrentCulture, ErrorStrings.ConfiguredAppHostPathMustBeString, configFilePath, fieldName); + if (!silent) + { + interactionService.DisplayError(message); + } + else + { + logger.LogWarning("Ignoring configured AppHost path in '{ConfigFilePath}' ('{FieldName}') because it is not a JSON string.", configFilePath, fieldName); + } + } + + private void ReportInvalidConfigurationFile(JsonException ex, string message, bool silent) + { + if (!silent) + { + interactionService.DisplayError(message); + } + else + { + logger.LogWarning(ex, "Unable to load AppHost settings: {Message}", message); + } + } + // Reject empty paths (Path.Combine("", base) collapses to the base directory and surfaces // a misleading "directory doesn't exist" warning downstream) and paths that contain // characters that would crash System.IO APIs. Path.GetInvalidPathChars() includes NUL on @@ -738,9 +798,10 @@ private bool IsValidConfiguredAppHostPath(string path, string configFilePath, st { interactionService.DisplayError(string.Format(CultureInfo.CurrentCulture, ErrorStrings.ConfiguredAppHostPathHasInvalidCharacters, configFilePath, fieldName)); } - // Log even in silent mode so non-interactive probes (DotNetSdkCheck etc.) leave a - // diagnostic trail when they reject a malformed path. - logger.LogWarning("Ignoring configured AppHost path in '{ConfigFilePath}' ('{FieldName}') because it is empty or contains invalid characters.", configFilePath, fieldName); + else + { + logger.LogWarning("Ignoring configured AppHost path in '{ConfigFilePath}' ('{FieldName}') because it is empty or contains invalid characters.", configFilePath, fieldName); + } return false; } diff --git a/src/Aspire.Cli/Resources/ErrorStrings.Designer.cs b/src/Aspire.Cli/Resources/ErrorStrings.Designer.cs index fe8da929cf4..f01fa5c5095 100644 --- a/src/Aspire.Cli/Resources/ErrorStrings.Designer.cs +++ b/src/Aspire.Cli/Resources/ErrorStrings.Designer.cs @@ -461,5 +461,23 @@ public static string ConfiguredAppHostPathHasInvalidCharacters { return ResourceManager.GetString("ConfiguredAppHostPathHasInvalidCharacters", resourceCulture); } } + + /// + /// Looks up a localized string similar to The configured AppHost path in '{0}' ('{1}') must be a JSON string.. + /// + public static string ConfiguredAppHostPathMustBeString { + get { + return ResourceManager.GetString("ConfiguredAppHostPathMustBeString", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The configuration file '{0}' must contain a JSON object.. + /// + public static string ConfigurationFileMustBeJsonObject { + get { + return ResourceManager.GetString("ConfigurationFileMustBeJsonObject", resourceCulture); + } + } } } diff --git a/src/Aspire.Cli/Resources/ErrorStrings.resx b/src/Aspire.Cli/Resources/ErrorStrings.resx index 45a2c97df98..15cd900dd7f 100644 --- a/src/Aspire.Cli/Resources/ErrorStrings.resx +++ b/src/Aspire.Cli/Resources/ErrorStrings.resx @@ -280,4 +280,12 @@ The configured AppHost path in '{0}' ('{1}') is empty or contains characters that are not allowed in a file path. {0} is the configuration file path that the user authored, {1} is the field/setting name within that file (for example "appHost.path" or "appHostPath") + + The configured AppHost path in '{0}' ('{1}') must be a JSON string. + {0} is the configuration file path that the user authored, {1} is the field/setting name within that file (for example "appHost.path" or "appHostPath") + + + The configuration file '{0}' must contain a JSON object. + {0} is the configuration file path that the user authored. + diff --git a/src/Aspire.Cli/Resources/SharedCommandStrings.resx b/src/Aspire.Cli/Resources/SharedCommandStrings.resx index e9f85fae74c..ab8f423998e 100644 --- a/src/Aspire.Cli/Resources/SharedCommandStrings.resx +++ b/src/Aspire.Cli/Resources/SharedCommandStrings.resx @@ -152,7 +152,7 @@ Include all candidate AppHosts, ignoring .gitignore and built-in directory filters - Stream newline-delimited JSON discovery events in arrival order from parallel discovery (not sorted). Requires --format json + Output discovered AppHosts as newline-delimited JSON. Requires --format json The --stream option requires --format json. diff --git a/src/Aspire.Cli/Resources/xlf/ErrorStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/ErrorStrings.cs.xlf index a44e5d5eb58..652575c0303 100644 --- a/src/Aspire.Cli/Resources/xlf/ErrorStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/ErrorStrings.cs.xlf @@ -82,6 +82,11 @@ Tento příkaz zatím není pro funkci AppHost pro jednosouborové scénáře podporován. + + The configuration file '{0}' must contain a JSON object. + The configuration file '{0}' must contain a JSON object. + {0} is the configuration file path that the user authored. + Configuration key {0} not found. Konfigurační klíč {0} se nenašel. @@ -102,6 +107,11 @@ The configured AppHost path in '{0}' ('{1}') is empty or contains characters that are not allowed in a file path. {0} is the configuration file path that the user authored, {1} is the field/setting name within that file (for example "appHost.path" or "appHostPath") + + The configured AppHost path in '{0}' ('{1}') must be a JSON string. + The configured AppHost path in '{0}' ('{1}') must be a JSON string. + {0} is the configuration file path that the user authored, {1} is the field/setting name within that file (for example "appHost.path" or "appHostPath") + Could not parse Aspire.Hosting package version. Nepodařilo se analyzovat verzi balíčku Aspire.Hosting. diff --git a/src/Aspire.Cli/Resources/xlf/ErrorStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/ErrorStrings.de.xlf index 79a72ace4d5..3cd1ff40c3a 100644 --- a/src/Aspire.Cli/Resources/xlf/ErrorStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/ErrorStrings.de.xlf @@ -82,6 +82,11 @@ Dieser Befehl wird für AppHosts mit einer einzelnen Datei noch nicht unterstützt. + + The configuration file '{0}' must contain a JSON object. + The configuration file '{0}' must contain a JSON object. + {0} is the configuration file path that the user authored. + Configuration key {0} not found. Der Konfigurationsschlüssel „{0}“ wurde nicht gefunden. @@ -102,6 +107,11 @@ The configured AppHost path in '{0}' ('{1}') is empty or contains characters that are not allowed in a file path. {0} is the configuration file path that the user authored, {1} is the field/setting name within that file (for example "appHost.path" or "appHostPath") + + The configured AppHost path in '{0}' ('{1}') must be a JSON string. + The configured AppHost path in '{0}' ('{1}') must be a JSON string. + {0} is the configuration file path that the user authored, {1} is the field/setting name within that file (for example "appHost.path" or "appHostPath") + Could not parse Aspire.Hosting package version. Die Aspire.Hosting-Paketversion konnte nicht analysiert werden. diff --git a/src/Aspire.Cli/Resources/xlf/ErrorStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/ErrorStrings.es.xlf index c85fe3facb9..cd2a73d1481 100644 --- a/src/Aspire.Cli/Resources/xlf/ErrorStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/ErrorStrings.es.xlf @@ -82,6 +82,11 @@ Este comando aún no es compatible con AppHosts de un solo archivo. + + The configuration file '{0}' must contain a JSON object. + The configuration file '{0}' must contain a JSON object. + {0} is the configuration file path that the user authored. + Configuration key {0} not found. No se encuentra la clave de configuración {0}. @@ -102,6 +107,11 @@ The configured AppHost path in '{0}' ('{1}') is empty or contains characters that are not allowed in a file path. {0} is the configuration file path that the user authored, {1} is the field/setting name within that file (for example "appHost.path" or "appHostPath") + + The configured AppHost path in '{0}' ('{1}') must be a JSON string. + The configured AppHost path in '{0}' ('{1}') must be a JSON string. + {0} is the configuration file path that the user authored, {1} is the field/setting name within that file (for example "appHost.path" or "appHostPath") + Could not parse Aspire.Hosting package version. No se pudo analizar la versión del paquete Aspire.Hosting. diff --git a/src/Aspire.Cli/Resources/xlf/ErrorStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/ErrorStrings.fr.xlf index 5c8666d2ae5..66ce3faedae 100644 --- a/src/Aspire.Cli/Resources/xlf/ErrorStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/ErrorStrings.fr.xlf @@ -82,6 +82,11 @@ Cette commande n’est pas encore prise en charge avec les hôtes d’application à fichier unique. + + The configuration file '{0}' must contain a JSON object. + The configuration file '{0}' must contain a JSON object. + {0} is the configuration file path that the user authored. + Configuration key {0} not found. Clé de configuration {0} introuvable. @@ -102,6 +107,11 @@ The configured AppHost path in '{0}' ('{1}') is empty or contains characters that are not allowed in a file path. {0} is the configuration file path that the user authored, {1} is the field/setting name within that file (for example "appHost.path" or "appHostPath") + + The configured AppHost path in '{0}' ('{1}') must be a JSON string. + The configured AppHost path in '{0}' ('{1}') must be a JSON string. + {0} is the configuration file path that the user authored, {1} is the field/setting name within that file (for example "appHost.path" or "appHostPath") + Could not parse Aspire.Hosting package version. Impossible d’analyser la version du package Aspire.Hosting. diff --git a/src/Aspire.Cli/Resources/xlf/ErrorStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/ErrorStrings.it.xlf index 5f5daf78cc6..82ec8b8bc0a 100644 --- a/src/Aspire.Cli/Resources/xlf/ErrorStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/ErrorStrings.it.xlf @@ -82,6 +82,11 @@ Questo comando non è ancora supportato con AppHost a file singolo. + + The configuration file '{0}' must contain a JSON object. + The configuration file '{0}' must contain a JSON object. + {0} is the configuration file path that the user authored. + Configuration key {0} not found. Chiave di configurazione {0} non trovata. @@ -102,6 +107,11 @@ The configured AppHost path in '{0}' ('{1}') is empty or contains characters that are not allowed in a file path. {0} is the configuration file path that the user authored, {1} is the field/setting name within that file (for example "appHost.path" or "appHostPath") + + The configured AppHost path in '{0}' ('{1}') must be a JSON string. + The configured AppHost path in '{0}' ('{1}') must be a JSON string. + {0} is the configuration file path that the user authored, {1} is the field/setting name within that file (for example "appHost.path" or "appHostPath") + Could not parse Aspire.Hosting package version. Non è possibile analizzare la versione del pacchetto Aspire.Hosting. diff --git a/src/Aspire.Cli/Resources/xlf/ErrorStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/ErrorStrings.ja.xlf index 0308c4997d5..95977bda072 100644 --- a/src/Aspire.Cli/Resources/xlf/ErrorStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/ErrorStrings.ja.xlf @@ -82,6 +82,11 @@ このコマンドは、単一ファイルの AppHost ではまだサポートされていません。 + + The configuration file '{0}' must contain a JSON object. + The configuration file '{0}' must contain a JSON object. + {0} is the configuration file path that the user authored. + Configuration key {0} not found. 構成キー {0} が見つかりません。 @@ -102,6 +107,11 @@ The configured AppHost path in '{0}' ('{1}') is empty or contains characters that are not allowed in a file path. {0} is the configuration file path that the user authored, {1} is the field/setting name within that file (for example "appHost.path" or "appHostPath") + + The configured AppHost path in '{0}' ('{1}') must be a JSON string. + The configured AppHost path in '{0}' ('{1}') must be a JSON string. + {0} is the configuration file path that the user authored, {1} is the field/setting name within that file (for example "appHost.path" or "appHostPath") + Could not parse Aspire.Hosting package version. Aspire.Hosting パッケージのバージョンを解析できませんでした。 diff --git a/src/Aspire.Cli/Resources/xlf/ErrorStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/ErrorStrings.ko.xlf index 1a11f863fa3..de17b21cb8a 100644 --- a/src/Aspire.Cli/Resources/xlf/ErrorStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/ErrorStrings.ko.xlf @@ -82,6 +82,11 @@ 이 명령은 단일 파일 AppHosts에서 아직 지원되지 않습니다. + + The configuration file '{0}' must contain a JSON object. + The configuration file '{0}' must contain a JSON object. + {0} is the configuration file path that the user authored. + Configuration key {0} not found. 구성 키 {0}을(를) 찾을 수 없습니다. @@ -102,6 +107,11 @@ The configured AppHost path in '{0}' ('{1}') is empty or contains characters that are not allowed in a file path. {0} is the configuration file path that the user authored, {1} is the field/setting name within that file (for example "appHost.path" or "appHostPath") + + The configured AppHost path in '{0}' ('{1}') must be a JSON string. + The configured AppHost path in '{0}' ('{1}') must be a JSON string. + {0} is the configuration file path that the user authored, {1} is the field/setting name within that file (for example "appHost.path" or "appHostPath") + Could not parse Aspire.Hosting package version. Aspire.Hosting 패키지 버전을 구문 분석할 수 없습니다. diff --git a/src/Aspire.Cli/Resources/xlf/ErrorStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/ErrorStrings.pl.xlf index deae91b7786..073565cbffb 100644 --- a/src/Aspire.Cli/Resources/xlf/ErrorStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/ErrorStrings.pl.xlf @@ -82,6 +82,11 @@ To polecenie nie jest jeszcze obsługiwane w przypadku hostów AppHost z jednym plikiem. + + The configuration file '{0}' must contain a JSON object. + The configuration file '{0}' must contain a JSON object. + {0} is the configuration file path that the user authored. + Configuration key {0} not found. Nie znaleziono klucza konfiguracji {0}. @@ -102,6 +107,11 @@ The configured AppHost path in '{0}' ('{1}') is empty or contains characters that are not allowed in a file path. {0} is the configuration file path that the user authored, {1} is the field/setting name within that file (for example "appHost.path" or "appHostPath") + + The configured AppHost path in '{0}' ('{1}') must be a JSON string. + The configured AppHost path in '{0}' ('{1}') must be a JSON string. + {0} is the configuration file path that the user authored, {1} is the field/setting name within that file (for example "appHost.path" or "appHostPath") + Could not parse Aspire.Hosting package version. Nie można przeanalizować wersji pakietu Aspire.Hosting. diff --git a/src/Aspire.Cli/Resources/xlf/ErrorStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/ErrorStrings.pt-BR.xlf index 84b014c1972..504677a5398 100644 --- a/src/Aspire.Cli/Resources/xlf/ErrorStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/ErrorStrings.pt-BR.xlf @@ -82,6 +82,11 @@ Este comando ainda não tem suporte para AppHosts de arquivo único. + + The configuration file '{0}' must contain a JSON object. + The configuration file '{0}' must contain a JSON object. + {0} is the configuration file path that the user authored. + Configuration key {0} not found. Chave de configuração {0} não encontrada. @@ -102,6 +107,11 @@ The configured AppHost path in '{0}' ('{1}') is empty or contains characters that are not allowed in a file path. {0} is the configuration file path that the user authored, {1} is the field/setting name within that file (for example "appHost.path" or "appHostPath") + + The configured AppHost path in '{0}' ('{1}') must be a JSON string. + The configured AppHost path in '{0}' ('{1}') must be a JSON string. + {0} is the configuration file path that the user authored, {1} is the field/setting name within that file (for example "appHost.path" or "appHostPath") + Could not parse Aspire.Hosting package version. Não foi possível analisar a versão do pacote Aspire.Hosting. diff --git a/src/Aspire.Cli/Resources/xlf/ErrorStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/ErrorStrings.ru.xlf index 9f8988a0b69..9048d03de96 100644 --- a/src/Aspire.Cli/Resources/xlf/ErrorStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/ErrorStrings.ru.xlf @@ -82,6 +82,11 @@ Эта команда пока не поддерживается для одиночных файлов AppHost. + + The configuration file '{0}' must contain a JSON object. + The configuration file '{0}' must contain a JSON object. + {0} is the configuration file path that the user authored. + Configuration key {0} not found. Ключ конфигурации {0} не найден. @@ -102,6 +107,11 @@ The configured AppHost path in '{0}' ('{1}') is empty or contains characters that are not allowed in a file path. {0} is the configuration file path that the user authored, {1} is the field/setting name within that file (for example "appHost.path" or "appHostPath") + + The configured AppHost path in '{0}' ('{1}') must be a JSON string. + The configured AppHost path in '{0}' ('{1}') must be a JSON string. + {0} is the configuration file path that the user authored, {1} is the field/setting name within that file (for example "appHost.path" or "appHostPath") + Could not parse Aspire.Hosting package version. Не удалось выполнить разбор версии пакета Aspire.Hosting. diff --git a/src/Aspire.Cli/Resources/xlf/ErrorStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/ErrorStrings.tr.xlf index bd9146a8960..a00112fa538 100644 --- a/src/Aspire.Cli/Resources/xlf/ErrorStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/ErrorStrings.tr.xlf @@ -82,6 +82,11 @@ Bu komut, tek dosya Uygulama Ana İşlemlerinde henüz desteklenmemektedir. + + The configuration file '{0}' must contain a JSON object. + The configuration file '{0}' must contain a JSON object. + {0} is the configuration file path that the user authored. + Configuration key {0} not found. {0} yapılandırma anahtarı bulunamadı. @@ -102,6 +107,11 @@ The configured AppHost path in '{0}' ('{1}') is empty or contains characters that are not allowed in a file path. {0} is the configuration file path that the user authored, {1} is the field/setting name within that file (for example "appHost.path" or "appHostPath") + + The configured AppHost path in '{0}' ('{1}') must be a JSON string. + The configured AppHost path in '{0}' ('{1}') must be a JSON string. + {0} is the configuration file path that the user authored, {1} is the field/setting name within that file (for example "appHost.path" or "appHostPath") + Could not parse Aspire.Hosting package version. Aspire.Hosting paket sürümü ayrıştırılamadı. diff --git a/src/Aspire.Cli/Resources/xlf/ErrorStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/ErrorStrings.zh-Hans.xlf index 15f56b90fa5..1b1034c7424 100644 --- a/src/Aspire.Cli/Resources/xlf/ErrorStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/ErrorStrings.zh-Hans.xlf @@ -82,6 +82,11 @@ 单文件应用主机尚不支持此命令。 + + The configuration file '{0}' must contain a JSON object. + The configuration file '{0}' must contain a JSON object. + {0} is the configuration file path that the user authored. + Configuration key {0} not found. 未找到配置键 {0}。 @@ -102,6 +107,11 @@ The configured AppHost path in '{0}' ('{1}') is empty or contains characters that are not allowed in a file path. {0} is the configuration file path that the user authored, {1} is the field/setting name within that file (for example "appHost.path" or "appHostPath") + + The configured AppHost path in '{0}' ('{1}') must be a JSON string. + The configured AppHost path in '{0}' ('{1}') must be a JSON string. + {0} is the configuration file path that the user authored, {1} is the field/setting name within that file (for example "appHost.path" or "appHostPath") + Could not parse Aspire.Hosting package version. 无法分析 Aspire.Hosting 包版本。 diff --git a/src/Aspire.Cli/Resources/xlf/ErrorStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/ErrorStrings.zh-Hant.xlf index 1b7f395991a..a4c742cd1d8 100644 --- a/src/Aspire.Cli/Resources/xlf/ErrorStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/ErrorStrings.zh-Hant.xlf @@ -82,6 +82,11 @@ 單一檔案 AppHost 尚不支援此命令。 + + The configuration file '{0}' must contain a JSON object. + The configuration file '{0}' must contain a JSON object. + {0} is the configuration file path that the user authored. + Configuration key {0} not found. 找不到設定金鑰 {0}。 @@ -102,6 +107,11 @@ The configured AppHost path in '{0}' ('{1}') is empty or contains characters that are not allowed in a file path. {0} is the configuration file path that the user authored, {1} is the field/setting name within that file (for example "appHost.path" or "appHostPath") + + The configured AppHost path in '{0}' ('{1}') must be a JSON string. + The configured AppHost path in '{0}' ('{1}') must be a JSON string. + {0} is the configuration file path that the user authored, {1} is the field/setting name within that file (for example "appHost.path" or "appHostPath") + Could not parse Aspire.Hosting package version. 無法剖析 Aspire.Hosting 套件版本。 diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.cs.xlf index 0f90e4dc884..c8a32b43bd6 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.cs.xlf @@ -73,8 +73,8 @@ {0} is the number of directories searched so far. {1} is the number of AppHost candidates found so far. - Stream newline-delimited JSON discovery events in arrival order from parallel discovery (not sorted). Requires --format json - Stream newline-delimited JSON discovery events in arrival order from parallel discovery (not sorted). Requires --format json + Output discovered AppHosts as newline-delimited JSON. Requires --format json + Output discovered AppHosts as newline-delimited JSON. Requires --format json diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.de.xlf index 51caeb5d0ed..5e434376353 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.de.xlf @@ -73,8 +73,8 @@ {0} is the number of directories searched so far. {1} is the number of AppHost candidates found so far. - Stream newline-delimited JSON discovery events in arrival order from parallel discovery (not sorted). Requires --format json - Stream newline-delimited JSON discovery events in arrival order from parallel discovery (not sorted). Requires --format json + Output discovered AppHosts as newline-delimited JSON. Requires --format json + Output discovered AppHosts as newline-delimited JSON. Requires --format json diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.es.xlf index 23f1175dc7c..bf202fc6715 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.es.xlf @@ -73,8 +73,8 @@ {0} is the number of directories searched so far. {1} is the number of AppHost candidates found so far. - Stream newline-delimited JSON discovery events in arrival order from parallel discovery (not sorted). Requires --format json - Stream newline-delimited JSON discovery events in arrival order from parallel discovery (not sorted). Requires --format json + Output discovered AppHosts as newline-delimited JSON. Requires --format json + Output discovered AppHosts as newline-delimited JSON. Requires --format json diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.fr.xlf index e8685651298..6ab0951b25a 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.fr.xlf @@ -73,8 +73,8 @@ {0} is the number of directories searched so far. {1} is the number of AppHost candidates found so far. - Stream newline-delimited JSON discovery events in arrival order from parallel discovery (not sorted). Requires --format json - Stream newline-delimited JSON discovery events in arrival order from parallel discovery (not sorted). Requires --format json + Output discovered AppHosts as newline-delimited JSON. Requires --format json + Output discovered AppHosts as newline-delimited JSON. Requires --format json diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.it.xlf index 8b1912c116e..9269f235e7e 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.it.xlf @@ -73,8 +73,8 @@ {0} is the number of directories searched so far. {1} is the number of AppHost candidates found so far. - Stream newline-delimited JSON discovery events in arrival order from parallel discovery (not sorted). Requires --format json - Stream newline-delimited JSON discovery events in arrival order from parallel discovery (not sorted). Requires --format json + Output discovered AppHosts as newline-delimited JSON. Requires --format json + Output discovered AppHosts as newline-delimited JSON. Requires --format json diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ja.xlf index f14065bcd71..1d67deacc53 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ja.xlf @@ -73,8 +73,8 @@ {0} is the number of directories searched so far. {1} is the number of AppHost candidates found so far. - Stream newline-delimited JSON discovery events in arrival order from parallel discovery (not sorted). Requires --format json - Stream newline-delimited JSON discovery events in arrival order from parallel discovery (not sorted). Requires --format json + Output discovered AppHosts as newline-delimited JSON. Requires --format json + Output discovered AppHosts as newline-delimited JSON. Requires --format json diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ko.xlf index 7143f2b6f40..e602e9cfa91 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ko.xlf @@ -73,8 +73,8 @@ {0} is the number of directories searched so far. {1} is the number of AppHost candidates found so far. - Stream newline-delimited JSON discovery events in arrival order from parallel discovery (not sorted). Requires --format json - Stream newline-delimited JSON discovery events in arrival order from parallel discovery (not sorted). Requires --format json + Output discovered AppHosts as newline-delimited JSON. Requires --format json + Output discovered AppHosts as newline-delimited JSON. Requires --format json diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.pl.xlf index a50398d0192..c7ce26d905e 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.pl.xlf @@ -73,8 +73,8 @@ {0} is the number of directories searched so far. {1} is the number of AppHost candidates found so far. - Stream newline-delimited JSON discovery events in arrival order from parallel discovery (not sorted). Requires --format json - Stream newline-delimited JSON discovery events in arrival order from parallel discovery (not sorted). Requires --format json + Output discovered AppHosts as newline-delimited JSON. Requires --format json + Output discovered AppHosts as newline-delimited JSON. Requires --format json diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.pt-BR.xlf index b70845a512a..a4077bf74bd 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.pt-BR.xlf @@ -73,8 +73,8 @@ {0} is the number of directories searched so far. {1} is the number of AppHost candidates found so far. - Stream newline-delimited JSON discovery events in arrival order from parallel discovery (not sorted). Requires --format json - Stream newline-delimited JSON discovery events in arrival order from parallel discovery (not sorted). Requires --format json + Output discovered AppHosts as newline-delimited JSON. Requires --format json + Output discovered AppHosts as newline-delimited JSON. Requires --format json diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ru.xlf index 4030b196979..092d0677494 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ru.xlf @@ -73,8 +73,8 @@ {0} is the number of directories searched so far. {1} is the number of AppHost candidates found so far. - Stream newline-delimited JSON discovery events in arrival order from parallel discovery (not sorted). Requires --format json - Stream newline-delimited JSON discovery events in arrival order from parallel discovery (not sorted). Requires --format json + Output discovered AppHosts as newline-delimited JSON. Requires --format json + Output discovered AppHosts as newline-delimited JSON. Requires --format json diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.tr.xlf index d8cfbdfb345..a5e5b3b9052 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.tr.xlf @@ -73,8 +73,8 @@ {0} is the number of directories searched so far. {1} is the number of AppHost candidates found so far. - Stream newline-delimited JSON discovery events in arrival order from parallel discovery (not sorted). Requires --format json - Stream newline-delimited JSON discovery events in arrival order from parallel discovery (not sorted). Requires --format json + Output discovered AppHosts as newline-delimited JSON. Requires --format json + Output discovered AppHosts as newline-delimited JSON. Requires --format json diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.zh-Hans.xlf index 3fdc38ca175..6ed4ab62cb5 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.zh-Hans.xlf @@ -73,8 +73,8 @@ {0} is the number of directories searched so far. {1} is the number of AppHost candidates found so far. - Stream newline-delimited JSON discovery events in arrival order from parallel discovery (not sorted). Requires --format json - Stream newline-delimited JSON discovery events in arrival order from parallel discovery (not sorted). Requires --format json + Output discovered AppHosts as newline-delimited JSON. Requires --format json + Output discovered AppHosts as newline-delimited JSON. Requires --format json diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.zh-Hant.xlf index db879e98a78..0d9c9855ef2 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.zh-Hant.xlf @@ -73,8 +73,8 @@ {0} is the number of directories searched so far. {1} is the number of AppHost candidates found so far. - Stream newline-delimited JSON discovery events in arrival order from parallel discovery (not sorted). Requires --format json - Stream newline-delimited JSON discovery events in arrival order from parallel discovery (not sorted). Requires --format json + Output discovered AppHosts as newline-delimited JSON. Requires --format json + Output discovered AppHosts as newline-delimited JSON. Requires --format json diff --git a/tests/Aspire.Cli.Tests/Projects/ProjectLocatorTests.cs b/tests/Aspire.Cli.Tests/Projects/ProjectLocatorTests.cs index 6f01c36461e..bb82e36f9a2 100644 --- a/tests/Aspire.Cli.Tests/Projects/ProjectLocatorTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/ProjectLocatorTests.cs @@ -15,7 +15,9 @@ using Aspire.Cli.Tests.TestServices; using Aspire.Cli.Tests.Utils; using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Logging.Testing; namespace Aspire.Cli.Tests.Projects; @@ -2654,15 +2656,18 @@ await File.WriteAllTextAsync(configPath, """ { "appHost": { "path": "a\u0000b.csproj" } } """); + var sink = new TestSink(); + var logger = new TestLogger(new TestLoggerFactory(sink, enabled: true)); var interactionService = new TestInteractionService(); var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); - var projectLocator = CreateProjectLocator(executionContext, interactionService: interactionService); + var projectLocator = CreateProjectLocator(executionContext, interactionService: interactionService, logger: logger); await projectLocator.FindAppHostProjectsAsync(workspace.WorkspaceRoot, AppHostDiscoveryScope.DefaultFiltered, CancellationToken.None).DefaultTimeout(); var error = Assert.Single(interactionService.DisplayedErrors); Assert.Contains(configPath, error); Assert.Contains("appHost.path", error); + Assert.DoesNotContain(sink.Writes, w => w.LogLevel == LogLevel.Warning && w.Message?.Contains("Ignoring configured AppHost path", StringComparison.Ordinal) == true); } [Fact] @@ -2743,6 +2748,119 @@ await File.WriteAllTextAsync(aspireSettingsFile.FullName, """ Assert.Contains("is empty or contains", error); } + [Fact] + public async Task GetAppHostFromSettings_MalformedConfig_SilentProbeLogsWarning() + { + // Probe-style callers intentionally don't write to the user-facing interaction + // service, but malformed config still needs diagnostics so background checks are + // debuggable. + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var configPath = Path.Combine(workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName); + await File.WriteAllTextAsync(configPath, """ + { "appHost": { + """); + + var sink = new TestSink(); + var logger = new TestLogger(new TestLoggerFactory(sink, enabled: true)); + var interactionService = new TestInteractionService(); + var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); + var projectLocator = CreateProjectLocator(executionContext, interactionService: interactionService, logger: logger); + + var foundAppHost = await projectLocator.GetAppHostFromSettingsAsync(CancellationToken.None).DefaultTimeout(); + + Assert.Null(foundAppHost); + Assert.Empty(interactionService.DisplayedErrors); + + var warning = Assert.Single(sink.Writes, w => w.LogLevel == LogLevel.Warning); + Assert.Contains(configPath, warning.Message); + Assert.IsAssignableFrom(warning.Exception); + } + + [Fact] + public async Task GetAppHostFromSettings_MalformedLegacySettings_SilentProbeLogsWarning() + { + // The legacy settings reader uses JsonDocument directly, so it needs the same + // silent-mode diagnostics as aspire.config.json instead of letting JsonException + // escape from background probes. + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var workspaceSettingsDirectory = workspace.CreateDirectory(".aspire"); + var settingsPath = Path.Combine(workspaceSettingsDirectory.FullName, "settings.json"); + await File.WriteAllTextAsync(settingsPath, """ + { "appHostPath": + """); + + var sink = new TestSink(); + var logger = new TestLogger(new TestLoggerFactory(sink, enabled: true)); + var interactionService = new TestInteractionService(); + var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); + var projectLocator = CreateProjectLocator(executionContext, interactionService: interactionService, logger: logger); + + var foundAppHost = await projectLocator.GetAppHostFromSettingsAsync(CancellationToken.None).DefaultTimeout(); + + Assert.Null(foundAppHost); + Assert.Empty(interactionService.DisplayedErrors); + + var warning = Assert.Single(sink.Writes, w => w.LogLevel == LogLevel.Warning); + Assert.Contains(settingsPath, warning.Message); + Assert.IsAssignableFrom(warning.Exception); + } + + [Fact] + public async Task GetAppHostFromSettings_LegacySettingsWithNonStringPath_ReportsValidationErrorAndDoesNotCrash() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var workspaceSettingsDirectory = workspace.CreateDirectory(".aspire"); + var settingsPath = Path.Combine(workspaceSettingsDirectory.FullName, "settings.json"); + await File.WriteAllTextAsync(settingsPath, """ + { "appHostPath": 123 } + """); + + var sink = new TestSink(); + var logger = new TestLogger(new TestLoggerFactory(sink, enabled: true)); + var interactionService = new TestInteractionService(); + var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); + var projectLocator = CreateProjectLocator(executionContext, interactionService: interactionService, logger: logger); + + var foundAppHost = await projectLocator.GetAppHostFromSettingsAsync(CancellationToken.None).DefaultTimeout(); + + Assert.Null(foundAppHost); + Assert.Empty(interactionService.DisplayedErrors); + + var warning = Assert.Single(sink.Writes, w => w.LogLevel == LogLevel.Warning); + Assert.Contains(settingsPath, warning.Message); + Assert.Contains("not a JSON string", warning.Message); + } + + [Fact] + public async Task GetAppHostFromSettings_LegacySettingsWithNonObjectRoot_ReportsValidationErrorAndDoesNotCrash() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var workspaceSettingsDirectory = workspace.CreateDirectory(".aspire"); + var settingsPath = Path.Combine(workspaceSettingsDirectory.FullName, "settings.json"); + await File.WriteAllTextAsync(settingsPath, """ + [] + """); + + var sink = new TestSink(); + var logger = new TestLogger(new TestLoggerFactory(sink, enabled: true)); + var interactionService = new TestInteractionService(); + var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); + var projectLocator = CreateProjectLocator(executionContext, interactionService: interactionService, logger: logger); + + var foundAppHost = await projectLocator.GetAppHostFromSettingsAsync(CancellationToken.None).DefaultTimeout(); + + Assert.Null(foundAppHost); + Assert.Empty(interactionService.DisplayedErrors); + + var warning = Assert.Single(sink.Writes, w => w.LogLevel == LogLevel.Warning); + Assert.Contains(settingsPath, warning.Message); + Assert.Contains("not a JSON object", warning.Message); + } + [Fact] public async Task FindAppHostProjectsAsync_DeduplicatesSettingsCandidateAcrossSymlink() { @@ -2821,16 +2939,16 @@ private static ProjectLocator CreateProjectLocator( ILanguageDiscovery? languageDiscovery = null, IDotNetSdkInstaller? sdkInstaller = null, IGitRepository? gitRepository = null, - AspireCliTelemetry? telemetry = null) + AspireCliTelemetry? telemetry = null, + ILogger? logger = null) { - var logger = NullLogger.Instance; var appHostCandidateFinder = new AppHostCandidateFinder( gitRepository ?? new TestGitRepository(), new ProfilingTelemetry(new ConfigurationBuilder().Build()), NullLogger.Instance); return new ProjectLocator( - logger, + logger ?? NullLogger.Instance, executionContext, interactionService ?? new TestInteractionService(), configurationService ?? new TestConfigurationService(executionContext), From f7f8dc1ea82adb83301bbcfb7a24bcd8cf0c72ff Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Fri, 29 May 2026 11:57:16 -0400 Subject: [PATCH 3/6] Use constants for AppHost config keys Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Projects/ProjectLocator.cs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/Aspire.Cli/Projects/ProjectLocator.cs b/src/Aspire.Cli/Projects/ProjectLocator.cs index 96397a092e2..f91ca79541e 100644 --- a/src/Aspire.Cli/Projects/ProjectLocator.cs +++ b/src/Aspire.Cli/Projects/ProjectLocator.cs @@ -138,6 +138,9 @@ internal sealed class ProjectLocator( IAppHostCandidateFinder appHostCandidateFinder, AspireCliTelemetry telemetry) : IProjectLocator { + private const string AspireConfigAppHostPathKey = "appHost.path"; + private const string LegacySettingsAppHostPathKey = "appHostPath"; + /// /// Finds all candidate AppHost projects in the specified search directory with language metadata. /// @@ -640,7 +643,7 @@ bool IsDuplicate(AppHostProjectCandidate candidate) // other invalid characters that survive JSON parsing. Without this we surface // as a generic "An unexpected error occurred" — see // https://github.com/microsoft/aspire/issues/17624. - if (!IsValidConfiguredAppHostPath(configAppHostPath, configFilePath, "appHost.path", silent)) + if (!IsValidConfiguredAppHostPath(configAppHostPath, configFilePath, fieldName: AspireConfigAppHostPathKey, silent: silent)) { return null; } @@ -684,11 +687,11 @@ bool IsDuplicate(AppHostProjectCandidate candidate) return null; } - if (json.RootElement.TryGetProperty("appHostPath", out var appHostPathProperty)) + if (json.RootElement.TryGetProperty(LegacySettingsAppHostPathKey, out var appHostPathProperty)) { if (appHostPathProperty.ValueKind is not JsonValueKind.Null and not JsonValueKind.String) { - ReportInvalidConfiguredAppHostPathType(settingsFile.FullName, "appHostPath", silent); + ReportInvalidConfiguredAppHostPathType(settingsFile.FullName, LegacySettingsAppHostPathKey, silent); return null; } @@ -697,7 +700,7 @@ bool IsDuplicate(AppHostProjectCandidate candidate) // Mirror the validation on the modern path above so the legacy branch also // cannot reach Path.Combine with a NUL byte or other Path.GetInvalidPathChars // value (https://github.com/microsoft/aspire/issues/17624). - if (!IsValidConfiguredAppHostPath(appHostPath, settingsFile.FullName, "appHostPath", silent)) + if (!IsValidConfiguredAppHostPath(appHostPath, settingsFile.FullName, fieldName: LegacySettingsAppHostPathKey, silent: silent)) { return null; } @@ -1107,7 +1110,7 @@ private async Task CreateSettingsFileAsync(FileInfo projectFile, CancellationTok var relativePathToProjectFile = Path.GetRelativePath(settingsFile.Directory!.FullName, projectFile.FullName).Replace(Path.DirectorySeparatorChar, '/'); // Use the configuration writer to set the AppHost path, which will merge with any existing settings. - await ConfigurationService.SetConfigurationInFileAsync(settingsFile.FullName, "appHost.path", relativePathToProjectFile, cancellationToken); + await ConfigurationService.SetConfigurationInFileAsync(settingsFile.FullName, AspireConfigAppHostPathKey, relativePathToProjectFile, cancellationToken); // For polyglot projects, also set language and inherit SDK version from parent/global config. var language = languageDiscovery.GetLanguageByFile(projectFile); From 3d9c813a4858630c0b2f7a9cfdf6d62aa6737d84 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Fri, 29 May 2026 12:34:18 -0400 Subject: [PATCH 4/6] Deduplicate AppHost config casing on macOS Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Projects/ProjectLocator.cs | 5 ++- .../Projects/ProjectLocatorTests.cs | 38 +++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/src/Aspire.Cli/Projects/ProjectLocator.cs b/src/Aspire.Cli/Projects/ProjectLocator.cs index f91ca79541e..09976dd40b1 100644 --- a/src/Aspire.Cli/Projects/ProjectLocator.cs +++ b/src/Aspire.Cli/Projects/ProjectLocator.cs @@ -453,7 +453,10 @@ async Task AddSettingsAppHostCandidateAsync() return; } - var pathComparison = OperatingSystem.IsWindows() + // Windows and default macOS APFS volumes are case-insensitive, so a + // differently-cased settings path can still refer to the same file found + // by the discovery walk. See https://github.com/microsoft/aspire/issues/17635. + var pathComparison = OperatingSystem.IsWindows() || OperatingSystem.IsMacOS() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; diff --git a/tests/Aspire.Cli.Tests/Projects/ProjectLocatorTests.cs b/tests/Aspire.Cli.Tests/Projects/ProjectLocatorTests.cs index bb82e36f9a2..0283dfda9d1 100644 --- a/tests/Aspire.Cli.Tests/Projects/ProjectLocatorTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/ProjectLocatorTests.cs @@ -2931,6 +2931,44 @@ public async Task FindAppHostProjectsAsync_DeduplicatesSettingsCandidateAcrossSy Assert.Single(found); } + [Fact] + public async Task FindAppHostProjectsAsync_DeduplicatesAspireConfigCandidateWithDifferentCasing() + { + // Regression test for https://github.com/microsoft/aspire/issues/17635. + // Case-insensitive file systems can resolve a user-authored config path like + // APPHOST.csproj to the same file that the discovery walk reports as + // AppHost.csproj. The two strings differ, but the paths should still dedupe. + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var appHostProjectFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj")); + await File.WriteAllTextAsync(appHostProjectFile.FullName, "Not a real project file."); + + var differentlyCasedPath = Path.Combine(workspace.WorkspaceRoot.FullName, "APPHOST.csproj"); + if (!File.Exists(differentlyCasedPath)) + { + Assert.Skip("The current file system is case-sensitive."); + } + + Assert.NotEqual(appHostProjectFile.FullName, differentlyCasedPath); + + var configPath = Path.Combine(workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName); + await File.WriteAllTextAsync(configPath, $$""" + { "appHost": { "path": {{JsonSerializer.Serialize(differentlyCasedPath)}} } } + """); + + var projectFactory = new TestAppHostProjectFactory + { + ValidateAppHostCallback = _ => new AppHostValidationResult(IsValid: true) + }; + + var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); + var projectLocator = CreateProjectLocator(executionContext, projectFactory: projectFactory); + + var found = await projectLocator.FindAppHostProjectsAsync(workspace.WorkspaceRoot, AppHostDiscoveryScope.DefaultFiltered, CancellationToken.None).DefaultTimeout(); + + Assert.Single(found); + } + private static ProjectLocator CreateProjectLocator( CliExecutionContext executionContext, IInteractionService? interactionService = null, From 12b7b9c499c12c65488b502fb89627774284e0e5 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Fri, 29 May 2026 13:19:38 -0400 Subject: [PATCH 5/6] Handle duplicate ATS capabilities Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../api/Aspire.Hosting.Foundry.ats.txt | 1 - .../TypeScriptApiCompatTests.cs | 14 +++++++++++++ tools/TypeScriptApiCompat/AtsSurfaceParser.cs | 21 ++++++++++++++++++- 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/Aspire.Hosting.Foundry/api/Aspire.Hosting.Foundry.ats.txt b/src/Aspire.Hosting.Foundry/api/Aspire.Hosting.Foundry.ats.txt index a5500e67343..42b0fa4aa4e 100644 --- a/src/Aspire.Hosting.Foundry/api/Aspire.Hosting.Foundry.ats.txt +++ b/src/Aspire.Hosting.Foundry/api/Aspire.Hosting.Foundry.ats.txt @@ -299,7 +299,6 @@ Aspire.Hosting.Foundry/runAsFoundryLocal() -> Aspire.Hosting.Foundry/Aspire.Host Aspire.Hosting.Foundry/withAppInsights(appInsights: Aspire.Hosting.Azure.ApplicationInsights/Aspire.Hosting.Azure.AzureApplicationInsightsResource) -> Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.AzureCognitiveServicesProjectResource Aspire.Hosting.Foundry/withBingReference(bingReference: Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.BingGroundingConnectionResource|string|Aspire.Hosting/Aspire.Hosting.ApplicationModel.ParameterResource) -> Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.BingGroundingToolResource Aspire.Hosting.Foundry/withCapabilityHost(resource: Aspire.Hosting.Azure.CosmosDB/Aspire.Hosting.AzureCosmosDBResource|Aspire.Hosting.Azure.Storage/Aspire.Hosting.Azure.AzureStorageResource|Aspire.Hosting.Azure.Search/Aspire.Hosting.Azure.AzureSearchResource|Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.FoundryResource) -> Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.AzureCognitiveServicesProjectResource -Aspire.Hosting.Foundry/withComputeEnvironmentExecutable(project?: Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.AzureCognitiveServicesProjectResource, configure?: callback) -> Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithEndpoints Aspire.Hosting.Foundry/withFoundryDeploymentProperties(configure: callback) -> Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.FoundryDeploymentResource Aspire.Hosting.Foundry/withFoundryRoleAssignments(target: Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.FoundryResource, roles: enum:Aspire.Hosting.FoundryRole[]) -> Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResource Aspire.Hosting.Foundry/withKeyVault(keyVault: Aspire.Hosting.Azure.KeyVault/Aspire.Hosting.Azure.AzureKeyVaultResource) -> Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.AzureCognitiveServicesProjectResource diff --git a/tests/Infrastructure.Tests/TypeScriptApiCompat/TypeScriptApiCompatTests.cs b/tests/Infrastructure.Tests/TypeScriptApiCompat/TypeScriptApiCompatTests.cs index 1ebe8bd36fd..8bbc584576b 100644 --- a/tests/Infrastructure.Tests/TypeScriptApiCompat/TypeScriptApiCompatTests.cs +++ b/tests/Infrastructure.Tests/TypeScriptApiCompat/TypeScriptApiCompatTests.cs @@ -119,6 +119,20 @@ public void ParserUsesMostSpecificKnownPackageForDottedSymbols() Assert.Equal(["Aspire.Hosting.Mode"], surface.EnumTypes.Keys.Select(k => k["enum:".Length..])); } + [Fact] + public void ParserIgnoresDuplicateEquivalentCapabilities() + { + var surface = AtsSurfaceParser.Parse("Pkg", """ + # Capabilities + Pkg/addThing(name: string, port?: number) -> Pkg/Thing + Pkg/addThing(name: string, port?: number) -> Pkg/Thing + """); + + var capability = Assert.Single(surface.Capabilities.Values); + Assert.Equal("Pkg/addThing", capability.CapabilityId); + Assert.Equal("Pkg/Thing", capability.ReturnTypeId); + } + [Fact] public void ComparerClassifiesBreakingAndAdditiveChanges() { diff --git a/tools/TypeScriptApiCompat/AtsSurfaceParser.cs b/tools/TypeScriptApiCompat/AtsSurfaceParser.cs index 9f04fbfb0bf..fbaa095b1bc 100644 --- a/tools/TypeScriptApiCompat/AtsSurfaceParser.cs +++ b/tools/TypeScriptApiCompat/AtsSurfaceParser.cs @@ -101,7 +101,7 @@ public static AtsSurface Parse(string packageName, string content, IReadOnlyColl var capability = ParseCapability(trimmed); if (IsOwnedByPackage(capability.CapabilityId, packageName, packageNames)) { - capabilities.Add(capability.CapabilityId, capability); + AddCapability(capabilities, capability); } break; } @@ -210,6 +210,25 @@ private static AtsCapability ParseCapability(string line) return new AtsCapability(capabilityId, parameters, returnTypeId); } + private static void AddCapability(Dictionary capabilities, AtsCapability capability) + { + if (capabilities.TryGetValue(capability.CapabilityId, out var existingCapability)) + { + if (CapabilitiesAreEquivalent(existingCapability, capability)) + { + return; + } + + throw new InvalidDataException($"Duplicate capability '{capability.CapabilityId}' has conflicting signatures."); + } + + capabilities.Add(capability.CapabilityId, capability); + } + + private static bool CapabilitiesAreEquivalent(AtsCapability left, AtsCapability right) => + string.Equals(left.ReturnTypeId, right.ReturnTypeId, StringComparison.Ordinal) && + left.Parameters.SequenceEqual(right.Parameters); + private static AtsParameter ParseParameter(string parameterText) { var separatorIndex = parameterText.IndexOf(": ", StringComparison.Ordinal); From 18dbb264cc38e030dc0fc88a33b30e8bbcb8f97d Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Fri, 29 May 2026 13:43:51 -0400 Subject: [PATCH 6/6] Revert duplicate ATS compatibility fix PR #17671 owns the Foundry ATS baseline update; this branch should only contain the AppHost and CLI fixes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../api/Aspire.Hosting.Foundry.ats.txt | 1 + .../TypeScriptApiCompatTests.cs | 14 ------------- tools/TypeScriptApiCompat/AtsSurfaceParser.cs | 21 +------------------ 3 files changed, 2 insertions(+), 34 deletions(-) diff --git a/src/Aspire.Hosting.Foundry/api/Aspire.Hosting.Foundry.ats.txt b/src/Aspire.Hosting.Foundry/api/Aspire.Hosting.Foundry.ats.txt index 42b0fa4aa4e..a5500e67343 100644 --- a/src/Aspire.Hosting.Foundry/api/Aspire.Hosting.Foundry.ats.txt +++ b/src/Aspire.Hosting.Foundry/api/Aspire.Hosting.Foundry.ats.txt @@ -299,6 +299,7 @@ Aspire.Hosting.Foundry/runAsFoundryLocal() -> Aspire.Hosting.Foundry/Aspire.Host Aspire.Hosting.Foundry/withAppInsights(appInsights: Aspire.Hosting.Azure.ApplicationInsights/Aspire.Hosting.Azure.AzureApplicationInsightsResource) -> Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.AzureCognitiveServicesProjectResource Aspire.Hosting.Foundry/withBingReference(bingReference: Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.BingGroundingConnectionResource|string|Aspire.Hosting/Aspire.Hosting.ApplicationModel.ParameterResource) -> Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.BingGroundingToolResource Aspire.Hosting.Foundry/withCapabilityHost(resource: Aspire.Hosting.Azure.CosmosDB/Aspire.Hosting.AzureCosmosDBResource|Aspire.Hosting.Azure.Storage/Aspire.Hosting.Azure.AzureStorageResource|Aspire.Hosting.Azure.Search/Aspire.Hosting.Azure.AzureSearchResource|Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.FoundryResource) -> Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.AzureCognitiveServicesProjectResource +Aspire.Hosting.Foundry/withComputeEnvironmentExecutable(project?: Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.AzureCognitiveServicesProjectResource, configure?: callback) -> Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithEndpoints Aspire.Hosting.Foundry/withFoundryDeploymentProperties(configure: callback) -> Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.FoundryDeploymentResource Aspire.Hosting.Foundry/withFoundryRoleAssignments(target: Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.FoundryResource, roles: enum:Aspire.Hosting.FoundryRole[]) -> Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResource Aspire.Hosting.Foundry/withKeyVault(keyVault: Aspire.Hosting.Azure.KeyVault/Aspire.Hosting.Azure.AzureKeyVaultResource) -> Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.AzureCognitiveServicesProjectResource diff --git a/tests/Infrastructure.Tests/TypeScriptApiCompat/TypeScriptApiCompatTests.cs b/tests/Infrastructure.Tests/TypeScriptApiCompat/TypeScriptApiCompatTests.cs index 8bbc584576b..1ebe8bd36fd 100644 --- a/tests/Infrastructure.Tests/TypeScriptApiCompat/TypeScriptApiCompatTests.cs +++ b/tests/Infrastructure.Tests/TypeScriptApiCompat/TypeScriptApiCompatTests.cs @@ -119,20 +119,6 @@ public void ParserUsesMostSpecificKnownPackageForDottedSymbols() Assert.Equal(["Aspire.Hosting.Mode"], surface.EnumTypes.Keys.Select(k => k["enum:".Length..])); } - [Fact] - public void ParserIgnoresDuplicateEquivalentCapabilities() - { - var surface = AtsSurfaceParser.Parse("Pkg", """ - # Capabilities - Pkg/addThing(name: string, port?: number) -> Pkg/Thing - Pkg/addThing(name: string, port?: number) -> Pkg/Thing - """); - - var capability = Assert.Single(surface.Capabilities.Values); - Assert.Equal("Pkg/addThing", capability.CapabilityId); - Assert.Equal("Pkg/Thing", capability.ReturnTypeId); - } - [Fact] public void ComparerClassifiesBreakingAndAdditiveChanges() { diff --git a/tools/TypeScriptApiCompat/AtsSurfaceParser.cs b/tools/TypeScriptApiCompat/AtsSurfaceParser.cs index fbaa095b1bc..9f04fbfb0bf 100644 --- a/tools/TypeScriptApiCompat/AtsSurfaceParser.cs +++ b/tools/TypeScriptApiCompat/AtsSurfaceParser.cs @@ -101,7 +101,7 @@ public static AtsSurface Parse(string packageName, string content, IReadOnlyColl var capability = ParseCapability(trimmed); if (IsOwnedByPackage(capability.CapabilityId, packageName, packageNames)) { - AddCapability(capabilities, capability); + capabilities.Add(capability.CapabilityId, capability); } break; } @@ -210,25 +210,6 @@ private static AtsCapability ParseCapability(string line) return new AtsCapability(capabilityId, parameters, returnTypeId); } - private static void AddCapability(Dictionary capabilities, AtsCapability capability) - { - if (capabilities.TryGetValue(capability.CapabilityId, out var existingCapability)) - { - if (CapabilitiesAreEquivalent(existingCapability, capability)) - { - return; - } - - throw new InvalidDataException($"Duplicate capability '{capability.CapabilityId}' has conflicting signatures."); - } - - capabilities.Add(capability.CapabilityId, capability); - } - - private static bool CapabilitiesAreEquivalent(AtsCapability left, AtsCapability right) => - string.Equals(left.ReturnTypeId, right.ReturnTypeId, StringComparison.Ordinal) && - left.Parameters.SequenceEqual(right.Parameters); - private static AtsParameter ParseParameter(string parameterText) { var separatorIndex = parameterText.IndexOf(": ", StringComparison.Ordinal);