From 9b3080883e6ddbb0597a3dd13927ad997b67486c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20Kr=C3=BCger?= Date: Tue, 29 Sep 2026 14:55:51 +0200 Subject: [PATCH 1/3] Handle non-ANSI shell fallback Cache line editor creation failures so prompt fallback can handle redirected input, and exit cleanly on fallback EOF. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Shell/NonAnsiFallbackTests.cs | 107 ++++++++++++++++++ .../ShellInterpreter.cs | 29 ++++- 2 files changed, 132 insertions(+), 4 deletions(-) create mode 100644 CosmosDBShell.Tests/Shell/NonAnsiFallbackTests.cs diff --git a/CosmosDBShell.Tests/Shell/NonAnsiFallbackTests.cs b/CosmosDBShell.Tests/Shell/NonAnsiFallbackTests.cs new file mode 100644 index 0000000..b8bcb98 --- /dev/null +++ b/CosmosDBShell.Tests/Shell/NonAnsiFallbackTests.cs @@ -0,0 +1,107 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------ + +namespace CosmosShell.Tests.Shell; + +using Azure.Data.Cosmos.Shell.Core; +using Spectre.Console; + +[Collection(ThemeStateTestCollection.Name)] +public class NonAnsiFallbackTests +{ + [Fact] + public async Task RunAsync_WhenLineEditorCannotBeCreated_UsesPromptFallbackAndExitsOnEof() + { + var configPath = CreateConfigPath(); + var savedConsole = AnsiConsole.Console; + var savedIn = Console.In; + var savedOut = Console.Out; + var savedError = Console.Error; + using var input = new StringReader("echo hi" + Environment.NewLine); + using var output = new StringWriter(); + using var error = new StringWriter(); + try + { + AnsiConsole.Console = AnsiConsole.Create(new AnsiConsoleSettings + { + Ansi = AnsiSupport.No, + ColorSystem = ColorSystemSupport.NoColors, + Out = new AnsiConsoleOutput(output), + }); + Console.SetIn(input); + Console.SetOut(output); + Console.SetError(error); + + using var shell = new ShellInterpreter(configPath) + { + IsInteractiveSession = static () => false, + }; + + await shell.RunAsync().WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken); + + Assert.Null(shell.Editor); + Assert.False(shell.IsRunning); + Assert.Contains("hi", output.ToString(), StringComparison.Ordinal); + } + finally + { + Console.SetIn(savedIn); + Console.SetOut(savedOut); + Console.SetError(savedError); + AnsiConsole.Console = savedConsole; + DeleteConfigPath(configPath); + } + } + + [Fact] + public void Editor_WhenLineEditorCannotBeCreated_CachesFailure() + { + var configPath = CreateConfigPath(); + var savedConsole = AnsiConsole.Console; + var savedError = Console.Error; + using var output = new StringWriter(); + using var error = new StringWriter(); + try + { + AnsiConsole.Console = AnsiConsole.Create(new AnsiConsoleSettings + { + Ansi = AnsiSupport.No, + ColorSystem = ColorSystemSupport.NoColors, + Out = new AnsiConsoleOutput(output), + }); + Console.SetError(error); + + using var shell = new ShellInterpreter(configPath); + + Assert.Null(shell.Editor); + var errorAfterFirstAccess = error.ToString(); + + Assert.Null(shell.Editor); + Assert.Equal(errorAfterFirstAccess, error.ToString()); + Assert.Contains("ANSI", errorAfterFirstAccess, StringComparison.OrdinalIgnoreCase); + Assert.Single(errorAfterFirstAccess.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries)); + } + finally + { + Console.SetError(savedError); + AnsiConsole.Console = savedConsole; + DeleteConfigPath(configPath); + } + } + + private static string CreateConfigPath() + { + var configPath = Path.Join(AppContext.BaseDirectory, "test-config", $"non-ansi-{Guid.NewGuid():N}"); + Directory.CreateDirectory(configPath); + return configPath; + } + + private static void DeleteConfigPath(string configPath) + { + if (Directory.Exists(configPath)) + { + Directory.Delete(configPath, recursive: true); + } + } +} diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ShellInterpreter.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ShellInterpreter.cs index 50e5632..53168f0 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ShellInterpreter.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ShellInterpreter.cs @@ -73,6 +73,8 @@ public partial class ShellInterpreter : IDisposable private LineEditor? lineEditor; + private bool lineEditorCreationAttempted; + private CosmosShellPrompt? cosmosShellPrompt; private System.Text.StringBuilder? pendingMultiLineBuffer; @@ -136,7 +138,19 @@ internal ShellInterpreter(string? configPath = null) /// /// Gets the line editor instance used by the shell, or null if not available. /// - public LineEditor? Editor { get => this.lineEditor ??= this.CreateLineEditor(); } + public LineEditor? Editor + { + get + { + if (!this.lineEditorCreationAttempted) + { + this.lineEditorCreationAttempted = true; + this.lineEditor = this.CreateLineEditor(); + } + + return this.lineEditor; + } + } /// /// Gets or sets a value indicating whether the shell is currently running. @@ -954,7 +968,14 @@ internal async Task RunAsync() try { this.ClearHighlightStatements(); - var input = this.Editor != null ? await this.Editor.ReadLine(this.editorCancelTokenSource.Token) : PromptFallback(); + var editor = this.Editor; + var input = editor != null ? await editor.ReadLine(this.editorCancelTokenSource.Token) : PromptFallback(); + if (editor == null && input == null) + { + this.IsRunning = false; + break; + } + var command = ProcessInteractiveLine( input, ref this.pendingMultiLineBuffer, @@ -2234,7 +2255,7 @@ internal static CosmosClientOptions CreateClientOptions(ConnectionMode requested return options; } - private LineEditor CreateLineEditor() + private LineEditor? CreateLineEditor() { try { @@ -2274,7 +2295,7 @@ private LineEditor CreateLineEditor() catch (Exception e) { Console.Error.WriteLine(e.Message); - return new LineEditor(); + return null; } } From ab7daf9557301f420f261d60b7d8f18696120317 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20Kr=C3=BCger?= Date: Tue, 29 Sep 2026 14:58:57 +0200 Subject: [PATCH 2/3] Handle invariant globalization for messages Fall back to the invariant Fluent locale while loading the bundled English catalog, and cover invariant-mode startup with process and unit tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Integration/ShellProcessTests.cs | 34 ++++++++++++++++++- .../MessageServiceCultureTests.cs | 25 ++++++++++++++ .../MessageService.cs | 19 ++++++++++- README.md | 2 ++ 4 files changed, 78 insertions(+), 2 deletions(-) diff --git a/CosmosDBShell.Tests/Integration/ShellProcessTests.cs b/CosmosDBShell.Tests/Integration/ShellProcessTests.cs index 26288ae..cd07f4f 100644 --- a/CosmosDBShell.Tests/Integration/ShellProcessTests.cs +++ b/CosmosDBShell.Tests/Integration/ShellProcessTests.cs @@ -93,6 +93,23 @@ public async Task StdinPipedScript_EchoCommand_WritesArgumentToStdOut() Assert.Contains("hello from process", result.StdOut); } + [Fact] + public async Task InvariantGlobalization_ExecuteCommand_LoadsEnglishCatalog() + { + var result = await RunShellAsync( + stdinScript: null, + extraArgs: ["--quiet", "-c", "echo hi"], + cancellationToken: TestContext.Current.CancellationToken, + environment: new Dictionary + { + ["DOTNET_SYSTEM_GLOBALIZATION_INVARIANT"] = "1", + }); + + Assert.Equal(0, result.ExitCode); + Assert.Equal("hi", result.StdOut.Trim()); + Assert.Empty(result.StdErr); + } + [Fact] public async Task StdinPipedScript_MultipleCommands_AllRunAndLastOutputVisible() { @@ -432,7 +449,8 @@ private static async Task RunShellAsync( private static async Task RunShellAsync( string? stdinScript, IEnumerable? extraArgs, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + IReadOnlyDictionary? environment = null) { var argsList = extraArgs?.ToList(); var requiresOwnedStdin = stdinScript != null @@ -481,6 +499,20 @@ private static async Task RunShellAsync( startInfo.Environment["DOTNET_CLI_UI_LANGUAGE"] = "en"; startInfo.Environment.Remove("COSMOSDB_SHELL_FORMAT"); startInfo.Environment["COSMOSDB_SHELL_CONFIG_DIR"] = isolatedConfigDir; + if (environment != null) + { + foreach (var (key, value) in environment) + { + if (value == null) + { + startInfo.Environment.Remove(key); + } + else + { + startInfo.Environment[key] = value; + } + } + } using var process = new Process { StartInfo = startInfo }; var stdOut = new StringBuilder(); diff --git a/CosmosDBShell.Tests/MessageServiceCultureTests.cs b/CosmosDBShell.Tests/MessageServiceCultureTests.cs index 420de51..5696864 100644 --- a/CosmosDBShell.Tests/MessageServiceCultureTests.cs +++ b/CosmosDBShell.Tests/MessageServiceCultureTests.cs @@ -6,6 +6,7 @@ namespace CosmosShell.Tests; using System.Globalization; using Azure.Data.Cosmos.Shell.Util; +using Fluent.Net; public class MessageServiceCultureTests { @@ -27,4 +28,28 @@ public void GetCultureFallbacks_ReturnsSpecificToNeutralCultures(string cultureN { Assert.Equal(expected, MessageService.GetCultureFallbacks(CultureInfo.GetCultureInfo(cultureName))); } + + [Fact] + public void CreateMessageContext_FallsBackToInvariantCulture_WhenLocaleIsNotSupported() + { + var options = new MessageContextOptions { UseIsolating = false }; + var attempts = new List(); + + var context = MessageService.CreateMessageContext( + "en", + options, + locale => + { + attempts.Add(locale); + if (locale == "en") + { + throw new CultureNotFoundException("name", locale, "unsupported"); + } + + return new MessageContext(locale, options); + }); + + Assert.Equal(["en", CultureInfo.InvariantCulture.Name], attempts); + Assert.NotNull(context); + } } \ No newline at end of file diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Util/MessageService.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Util/MessageService.cs index 858d634..33b8da1 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Util/MessageService.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Util/MessageService.cs @@ -164,7 +164,7 @@ public static string GetArgsString(string id, params object[] args) } using var sr = new StreamReader(stream); - var mc = new MessageContext(resourceName, options); + var mc = CreateMessageContext(resourceName, options); var errors = mc.AddMessages(sr); if (errors.Any()) { @@ -181,4 +181,21 @@ public static string GetArgsString(string id, params object[] args) return null; } + + internal static MessageContext CreateMessageContext( + string locale, + MessageContextOptions options, + Func? factory = null) + { + factory ??= name => new MessageContext(name, options); + + try + { + return factory(locale); + } + catch (CultureNotFoundException) when (!string.IsNullOrEmpty(locale)) + { + return factory(CultureInfo.InvariantCulture.Name); + } + } } diff --git a/README.md b/README.md index 719a134..b219e54 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,8 @@ MCP command execution is serialized with the shell, and destructive confirmation The .NET runtime alone is not enough for the commands below. `dotnet run` and `dotnet tool install` are SDK commands. To verify the SDK is installed, run `dotnet --list-sdks`. +In Linux containers without ICU, the shell also supports .NET globalization-invariant mode and falls back to its bundled English messages. + ```bash dotnet run --project CosmosDBShell ``` From 51e5f2057a9bd4318074e670340013a42026cadf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20Kr=C3=BCger?= Date: Tue, 29 Sep 2026 18:03:38 +0200 Subject: [PATCH 3/3] Make non-ANSI fallback tests independent of CI terminal detection RadLine detects terminal capabilities itself when no console is supplied, and Spectre.Console's default profile enrichers force ANSI on under GitHub Actions. Let the interpreter take an explicit line editor console, and give the tests a non-ANSI console with default enrichers disabled. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Shell/NonAnsiFallbackTests.cs | 35 +++++++++++-------- .../ShellInterpreter.cs | 5 ++- 2 files changed, 24 insertions(+), 16 deletions(-) diff --git a/CosmosDBShell.Tests/Shell/NonAnsiFallbackTests.cs b/CosmosDBShell.Tests/Shell/NonAnsiFallbackTests.cs index b8bcb98..26ce300 100644 --- a/CosmosDBShell.Tests/Shell/NonAnsiFallbackTests.cs +++ b/CosmosDBShell.Tests/Shell/NonAnsiFallbackTests.cs @@ -23,12 +23,8 @@ public async Task RunAsync_WhenLineEditorCannotBeCreated_UsesPromptFallbackAndEx using var error = new StringWriter(); try { - AnsiConsole.Console = AnsiConsole.Create(new AnsiConsoleSettings - { - Ansi = AnsiSupport.No, - ColorSystem = ColorSystemSupport.NoColors, - Out = new AnsiConsoleOutput(output), - }); + var nonAnsiConsole = CreateNonAnsiConsole(output); + AnsiConsole.Console = nonAnsiConsole; Console.SetIn(input); Console.SetOut(output); Console.SetError(error); @@ -36,6 +32,7 @@ public async Task RunAsync_WhenLineEditorCannotBeCreated_UsesPromptFallbackAndEx using var shell = new ShellInterpreter(configPath) { IsInteractiveSession = static () => false, + LineEditorTerminal = nonAnsiConsole, }; await shell.RunAsync().WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken); @@ -58,21 +55,17 @@ public async Task RunAsync_WhenLineEditorCannotBeCreated_UsesPromptFallbackAndEx public void Editor_WhenLineEditorCannotBeCreated_CachesFailure() { var configPath = CreateConfigPath(); - var savedConsole = AnsiConsole.Console; var savedError = Console.Error; using var output = new StringWriter(); using var error = new StringWriter(); try { - AnsiConsole.Console = AnsiConsole.Create(new AnsiConsoleSettings - { - Ansi = AnsiSupport.No, - ColorSystem = ColorSystemSupport.NoColors, - Out = new AnsiConsoleOutput(output), - }); Console.SetError(error); - using var shell = new ShellInterpreter(configPath); + using var shell = new ShellInterpreter(configPath) + { + LineEditorTerminal = CreateNonAnsiConsole(output), + }; Assert.Null(shell.Editor); var errorAfterFirstAccess = error.ToString(); @@ -85,11 +78,23 @@ public void Editor_WhenLineEditorCannotBeCreated_CachesFailure() finally { Console.SetError(savedError); - AnsiConsole.Console = savedConsole; DeleteConfigPath(configPath); } } + private static IAnsiConsole CreateNonAnsiConsole(TextWriter output) + { + return AnsiConsole.Create(new AnsiConsoleSettings + { + Ansi = AnsiSupport.No, + ColorSystem = ColorSystemSupport.NoColors, + Out = new AnsiConsoleOutput(output), + + // Default enrichers force ANSI on in CI environments such as GitHub Actions. + Enrichment = new ProfileEnrichment { UseDefaultEnrichers = false }, + }); + } + private static string CreateConfigPath() { var configPath = Path.Join(AppContext.BaseDirectory, "test-config", $"non-ansi-{Guid.NewGuid():N}"); diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ShellInterpreter.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ShellInterpreter.cs index 53168f0..d078bb9 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ShellInterpreter.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ShellInterpreter.cs @@ -265,6 +265,9 @@ internal long SessionRequestChargeGeneration internal Func IsInteractiveSession { get; set; } = static () => !Console.IsInputRedirected && !Console.IsOutputRedirected; + // RadLine detects terminal capabilities itself when no console is supplied. + internal IAnsiConsole? LineEditorTerminal { get; set; } + internal IReadOnlyList History { get @@ -2260,7 +2263,7 @@ internal static CosmosClientOptions CreateClientOptions(ConnectionMode requested try { this.cosmosShellPrompt = new CosmosShellPrompt(this); - var lineEditor = new LineEditor() + var lineEditor = new LineEditor(this.LineEditorTerminal) { Prompt = this.cosmosShellPrompt, LineDecorationRenderer = new CosmosCompletionRenderer(this),