Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
020942b
feat: implement deterministic exit codes and structured machine outpu…
KhushviB Jul 11, 2026
7b82638
fix: harden machine output contract against edge cases
KhushviB Jul 11, 2026
5cca375
fix/copilot-fix-suggestion
KhushviB Jul 13, 2026
c8cd2c8
Merge branch 'main' into feature/a1-deterministic-output
KhushviB Jul 13, 2026
2c6a7c0
Potential fix for pull request finding
KhushviB Jul 13, 2026
96c7c30
Apply suggestions from code review
KhushviB Jul 13, 2026
6afcabe
Apply suggestions from code review
KhushviB Jul 13, 2026
9addb34
fix: emit mutually-exclusive options error to STDERR as JSON in machi…
KhushviB Jul 13, 2026
6155fde
Potential fix for pull request finding
KhushviB Jul 13, 2026
9adee04
Apply suggestions from code review
KhushviB Jul 13, 2026
5c71995
Apply suggestions from code review
KhushviB Jul 13, 2026
d0289b0
Potential fix for pull request finding
KhushviB Jul 13, 2026
562e1f3
Apply suggestions from code review
KhushviB Jul 13, 2026
971f4dd
fix: 2 changes suggested by copilot
KhushviB Jul 13, 2026
ecf0177
fix: skip stderr JSON payload if command already reported diagnostic
KhushviB Jul 14, 2026
96b7f9f
Apply suggestions from code review
KhushviB Jul 14, 2026
5dddf31
Apply suggestions from code review
KhushviB Jul 14, 2026
d51071b
Apply suggestions from code review
KhushviB Jul 14, 2026
ab8de85
Apply suggestions from code review
KhushviB Jul 14, 2026
765b194
Potential fix for pull request finding
KhushviB Jul 14, 2026
70498e5
Potential fix for pull request finding
KhushviB Jul 14, 2026
dc5b027
fix: normalize --output to lowercase and fix connectionMode/currentLo…
KhushviB Jul 14, 2026
7f9287d
test: avoid forced stdin redirection in process harness
KhushviB Jul 14, 2026
222bc56
Merge branch 'main' into feature/a1-deterministic-output
KhushviB Jul 14, 2026
162f635
fix: keep machine-mode errors off stdout
KhushviB Jul 14, 2026
564666c
fix: silence AnsiConsole in structured output modes
KhushviB Jul 14, 2026
ae681f0
fix: treat quiet startup as machine mode
KhushviB Jul 14, 2026
4fbbabf
fix: harden machine-mode output end-to-end
KhushviB Jul 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 92 additions & 11 deletions CosmosDBShell.Tests/Integration/ShellProcessTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ namespace CosmosShell.Tests.Integration;

using System.Diagnostics;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using Azure.Data.Cosmos.Shell.Util;

Expand Down Expand Up @@ -157,7 +158,7 @@ public async Task ConnectOption_WithExecuteAndQuit_RunsCommandAgainstEmulator()

var result = await RunShellAsync(
stdinScript: null,
extraArgs: ["--connect", connectionString, "-c", "connect"],
extraArgs: ["--connect", connectionString, "-c", "connect", "--output", "table"],
cancellationToken: TestContext.Current.CancellationToken);

Assert.Equal(0, result.ExitCode);
Expand All @@ -166,6 +167,81 @@ public async Task ConnectOption_WithExecuteAndQuit_RunsCommandAgainstEmulator()
Assert.Contains("Current Location", result.StdOut, StringComparison.OrdinalIgnoreCase);
}

[Fact]
public async Task BadArguments_WithOutputJson_ReturnsExitCode2_AndJsonOnStdErr()
{
var result = await RunShellAsync(
stdinScript: null,
extraArgs: ["--output", "json", "-c", "not a real command"],
cancellationToken: TestContext.Current.CancellationToken);
Comment thread
KhushviB marked this conversation as resolved.

Assert.Equal(2, result.ExitCode);
Assert.Empty(result.StdOut.Trim());
Assert.Contains("\"status\":\"error\"", result.StdErr, StringComparison.OrdinalIgnoreCase);
Assert.Contains("not recognized as an internal or external command", result.StdErr, StringComparison.OrdinalIgnoreCase);
}
Comment thread
KhushviB marked this conversation as resolved.

[Fact]
public async Task ConnectionFailure_WithOutputJson_ReturnsExitCode3_AndJsonOnStdErr()
{
var result = await RunShellAsync(
stdinScript: null,
extraArgs: ["--connect", "https://127.0.0.1:1", "--output", "json", "-c", "version"],
cancellationToken: TestContext.Current.CancellationToken);

Assert.Equal(3, result.ExitCode);
Assert.Empty(result.StdOut.Trim());
Assert.Contains("\"status\":\"error\"", result.StdErr, StringComparison.OrdinalIgnoreCase);
}

[Fact]
public async Task ExecuteAndQuit_WithoutOutput_EmitsParseableNdjsonOnStdOut()
{
var result = await RunShellAsync(
stdinScript: null,
extraArgs: ["-c", "version"],
cancellationToken: TestContext.Current.CancellationToken);

Assert.Equal(0, result.ExitCode);
Assert.Empty(result.StdErr.Trim());

var lines = result.StdOut
.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
Assert.NotEmpty(lines);

foreach (var line in lines)
{
using var doc = JsonDocument.Parse(line);
Assert.True(doc.RootElement.ValueKind == JsonValueKind.Object || doc.RootElement.ValueKind == JsonValueKind.Array);
}
}

[Fact]
public async Task InvalidMcpPort_WithOutputJson_ReturnsExitCode2_AndJsonOnStdErr()
{
var result = await RunShellAsync(
stdinScript: null,
extraArgs: ["--output", "json", "--mcp", "0"],
cancellationToken: TestContext.Current.CancellationToken);

Assert.Equal(2, result.ExitCode);
Assert.Empty(result.StdOut.Trim());
Assert.Contains("\"status\":\"error\"", result.StdErr, StringComparison.OrdinalIgnoreCase);
Assert.Contains("port", result.StdErr, StringComparison.OrdinalIgnoreCase);
}

[Fact]
public async Task ClearHistory_WithOutputJson_DoesNotWriteInformationalStdOut()
{
var result = await RunShellAsync(
stdinScript: null,
extraArgs: ["--output", "json", "--clear-history"],
cancellationToken: TestContext.Current.CancellationToken);

Assert.Equal(0, result.ExitCode);
Assert.Empty(result.StdOut.Trim());
}

private static async Task<ShellProcessResult> RunShellAsync(
string stdinScript,
CancellationToken cancellationToken)
Expand All @@ -178,6 +254,11 @@ private static async Task<ShellProcessResult> RunShellAsync(
IEnumerable<string>? extraArgs,
CancellationToken cancellationToken)
{
var argsList = extraArgs?.ToList();
var requiresOwnedStdin = stdinScript != null
|| (argsList?.Contains("-c") == true)
|| (argsList?.Contains("-k") == true);

var shellDll = Path.Combine(AppContext.BaseDirectory, "CosmosDBShell.dll");
if (!File.Exists(shellDll))
{
Expand All @@ -191,7 +272,7 @@ private static async Task<ShellProcessResult> RunShellAsync(
FileName = dotnet,
WorkingDirectory = AppContext.BaseDirectory,
UseShellExecute = false,
RedirectStandardInput = true,
RedirectStandardInput = requiresOwnedStdin,
RedirectStandardOutput = true,
RedirectStandardError = true,
StandardOutputEncoding = Encoding.UTF8,
Expand All @@ -200,9 +281,9 @@ private static async Task<ShellProcessResult> RunShellAsync(
};

startInfo.ArgumentList.Add(shellDll);
if (extraArgs != null)
if (argsList != null)
{
foreach (var a in extraArgs)
foreach (var a in argsList)
{
startInfo.ArgumentList.Add(a);
}
Expand Down Expand Up @@ -239,14 +320,14 @@ private static async Task<ShellProcessResult> RunShellAsync(
process.BeginOutputReadLine();
process.BeginErrorReadLine();

if (stdinScript != null)
{
await process.StandardInput.WriteAsync(stdinScript);
await process.StandardInput.WriteLineAsync();
process.StandardInput.Close();
}
else
if (requiresOwnedStdin)
{
if (stdinScript != null)
{
await process.StandardInput.WriteAsync(stdinScript);
await process.StandardInput.WriteLineAsync();
}

process.StandardInput.Close();
}

Expand Down
16 changes: 16 additions & 0 deletions CosmosDBShell.Tests/UtilTest/OutputFormatTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,22 @@ void TestJSon()
Assert.Equal(StripWS(input), StripWS(output));
}

[Fact]
void TestNdjson()
{
var input = "[{ \"id\": 12 }, { \"id\": 13 }]";
var element = JsonSerializer.Deserialize<JsonElement>(input);

var commandState = new CommandState();
commandState.Result = new ShellJson(element);
commandState.OutputFormat = OutputFormat.Ndjson;

var output = commandState.GenerateOutputText();
var expected = "{\"id\":12}" + Environment.NewLine + "{\"id\":13}" + Environment.NewLine;

Assert.Equal(expected, output);
}

[Fact]
void TestCSV()
{
Expand Down
69 changes: 40 additions & 29 deletions CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ConnectCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,12 @@ internal partial class ConnectCommand : CosmosCommand

public async override Task<CommandState> ExecuteAsync(ShellInterpreter shell, CommandState commandState, string commandText, CancellationToken token)
{
var isQuiet = shell.Options?.Quiet == true;

// If no connection string provided, show current connection info
if (this.ConnectionString is null)
{
return await PrintConnectionInfoAsync(shell, commandState, token);
return await PrintConnectionInfoAsync(shell, commandState, token, isQuiet);
}

// If already connected, inform user about switching accounts.
Expand All @@ -67,7 +69,10 @@ public async override Task<CommandState> ExecuteAsync(ShellInterpreter shell, Co
if (shell.State is ConnectedState cs)
{
var previousEndpoint = cs.Client.Endpoint.Host;
AnsiConsole.MarkupLine(MessageService.GetArgsString("command-connect-switching", "endpoint", previousEndpoint));
if (!isQuiet)
{
AnsiConsole.MarkupLine(MessageService.GetArgsString("command-connect-switching", "endpoint", previousEndpoint));
}
}

ConnectionMode? connectionMode = null;
Expand Down Expand Up @@ -95,7 +100,7 @@ public async override Task<CommandState> ExecuteAsync(ShellInterpreter shell, Co
await shell.ConnectAsync(this.ConnectionString, this.LoginHint, connectionMode, tenantId: this.TenantId, authorityHost: this.AuthorityHost, managedIdentityClientId: this.ManagedIdentityClientId, useVSCodeCredential: this.UseVSCodeCredential, subscriptionId: this.SubscriptionId, resourceGroupName: this.ResourceGroupName, token: token);
var returnState = new CommandState
{
IsPrinted = true,
IsPrinted = !isQuiet,
};
Comment thread
KhushviB marked this conversation as resolved.
var endpoint = ParsedDocDBConnectionString.ExtractEndpoint(this.ConnectionString);
var resultElement = JsonSerializer.SerializeToElement(new Dictionary<string, string?>
Expand All @@ -111,7 +116,7 @@ public async override Task<CommandState> ExecuteAsync(ShellInterpreter shell, Co
}
catch (Exception e)
{
if (TryGetPrincipalIdFromRbacException(e, out var id, out var permission))
if (!isQuiet && TryGetPrincipalIdFromRbacException(e, out var id, out var permission))
{
AskForRBacPermissions(id ?? string.Empty, permission ?? string.Empty);
return commandState;
Expand Down Expand Up @@ -183,13 +188,16 @@ internal static void PrintConnectUsageHint(ShellInterpreter shell)
AnsiConsole.MarkupLine(Markup.Escape(MessageService.GetString("command-connect-not_connected-usage-footer")));
}

private static async Task<CommandState> PrintConnectionInfoAsync(ShellInterpreter shell, CommandState commandState, CancellationToken token)
private static async Task<CommandState> PrintConnectionInfoAsync(ShellInterpreter shell, CommandState commandState, CancellationToken token, bool isQuiet)
{
if (shell.State is not ConnectedState connectedState)
{
AnsiConsole.MarkupLine(MessageService.GetString("command-connect-not_connected"));
PrintConnectUsageHint(shell);
commandState.IsPrinted = true;
if (!isQuiet)
{
AnsiConsole.MarkupLine(MessageService.GetString("command-connect-not_connected"));
PrintConnectUsageHint(shell);
}
commandState.IsPrinted = !isQuiet;
var notConnectedJson = new Dictionary<string, object?>
{
["connected"] = false,
Expand All @@ -202,36 +210,39 @@ private static async Task<CommandState> PrintConnectionInfoAsync(ShellInterprete

token.ThrowIfCancellationRequested();
var acc = await client.ReadAccountAsync().WaitAsync(token);
AnsiConsole.MarkupLine(Theme.FormatSectionHeader(MessageService.GetString("command-connect-info-title")));
var connectionMode = client.ClientOptions.ConnectionMode;
string currentLocation = ShellLocation.GetCurrentLocation(shell.State) ?? ShellLocation.NotConnectedText;

var table = new Table();
table.AddColumns(string.Empty, string.Empty);
table.HideHeaders();
if (!isQuiet)
{
AnsiConsole.MarkupLine(Theme.FormatSectionHeader(MessageService.GetString("command-connect-info-title")));

table.AddRow(MessageService.GetString("command-connect-info-account"), Theme.FormatTableValue(acc.Id));
table.AddRow(MessageService.GetString("command-connect-info-endpoint"), Theme.FormatTableValue(client.Endpoint.ToString()));
var table = new Table();
table.AddColumns(string.Empty, string.Empty);
table.HideHeaders();

if (connectedState.ArmContext != null)
{
table.AddRow(MessageService.GetString("command-connect-info-arm-account"), Theme.FormatTableValue(connectedState.ArmContext.AccountResourceId.ToString()));
}
table.AddRow(MessageService.GetString("command-connect-info-account"), Theme.FormatTableValue(acc.Id));
table.AddRow(MessageService.GetString("command-connect-info-endpoint"), Theme.FormatTableValue(client.Endpoint.ToString()));

// Display the connection mode
var connectionMode = client.ClientOptions.ConnectionMode;
table.AddRow(MessageService.GetString("command-connect-info-mode"), Theme.FormatTableValue(connectionMode.ToString()));
if (connectedState.ArmContext != null)
{
table.AddRow(MessageService.GetString("command-connect-info-arm-account"), Theme.FormatTableValue(connectedState.ArmContext.AccountResourceId.ToString()));
}

// Display the readable/writable regions
table.AddRow(MessageService.GetString("command-connect-info-read-regions"), Theme.FormatTableValue(string.Join(", ", acc.ReadableRegions.Select(r => r.Name))));
table.AddRow(MessageService.GetString("command-connect-info-write-regions"), Theme.FormatTableValue(string.Join(", ", acc.WritableRegions.Select(r => r.Name))));
// Display the connection mode
table.AddRow(MessageService.GetString("command-connect-info-mode"), Theme.FormatTableValue(connectionMode.ToString()));

// Show current navigation state
string currentLocation = ShellLocation.GetCurrentLocation(shell.State) ?? ShellLocation.NotConnectedText;
// Display the readable/writable regions
table.AddRow(MessageService.GetString("command-connect-info-read-regions"), Theme.FormatTableValue(string.Join(", ", acc.ReadableRegions.Select(r => r.Name))));
table.AddRow(MessageService.GetString("command-connect-info-write-regions"), Theme.FormatTableValue(string.Join(", ", acc.WritableRegions.Select(r => r.Name))));

table.AddRow(MessageService.GetString("command-connect-info-location"), Theme.ConnectedStatePromt(currentLocation));
// Show current navigation state
table.AddRow(MessageService.GetString("command-connect-info-location"), Theme.ConnectedStatePromt(currentLocation));

AnsiConsole.Write(table);
AnsiConsole.Write(table);
}

commandState.IsPrinted = true;
commandState.IsPrinted = !isQuiet;
var jsonResult = new Dictionary<string, object?>
{
["connected"] = true,
Comment thread
KhushviB marked this conversation as resolved.
Expand Down
44 changes: 43 additions & 1 deletion CosmosDBShell/Azure.Data.Cosmos.Shell.Core/CommandState.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ public partial class CommandState
/// </summary>
public virtual bool IsError => false;

/// <summary>
/// Gets the exit code for the command state. Default is 0.
/// </summary>
public virtual int ExitCode => 0;

/// <summary>
/// Gets or sets the output format for the command result.
/// </summary>
Expand Down Expand Up @@ -55,9 +60,13 @@ internal void SetFormat(string? outputFormat)
{
this.OutputFormat = OutputFormat.Table;
}
else if (string.Equals(outputFormat, "ndjson", StringComparison.OrdinalIgnoreCase))
{
this.OutputFormat = OutputFormat.Ndjson;
}
Comment thread
KhushviB marked this conversation as resolved.
else
{
throw new ShellException(MessageService.GetString("error-invalid_output_format", new Dictionary<string, object> { { "format", outputFormat } }));
throw new ArgumentException(MessageService.GetString("error-invalid_output_format", new Dictionary<string, object> { { "format", outputFormat } }));
}
}

Expand Down Expand Up @@ -123,6 +132,39 @@ internal string GenerateOutputText()
}

return ResultToTable(json.EnumerateArray().ToArray()).ToGridString();
case OutputFormat.Ndjson:
{
var array = json;
if (json.ValueKind == JsonValueKind.Object)
{
if (json.TryGetProperty("documents", out var documents))
{
array = documents;
}
else if (json.TryGetProperty("items", out var items))
{
array = items;
}
else
{
return JsonSerializer.Serialize(json) + Environment.NewLine;
}
}

if (array.ValueKind == JsonValueKind.Array)
{
var sb = new StringBuilder();
foreach (var element in array.EnumerateArray())
{
sb.AppendLine(JsonSerializer.Serialize(element));
}

return sb.ToString();
}

return JsonSerializer.Serialize(array) + Environment.NewLine;
}

default:
throw new InvalidOperationException("OutputFormat invalid " + this.OutputFormat);
}
Expand Down
Loading