Skip to content

Commit 32ff761

Browse files
azchohfiCopilot
andcommitted
Let human-readable output be routed to stdout
Program.cs builds the single IAnsiConsole over Console.Error, so every human-facing message - success ticks, status lines, tables, and everything from CustomSpectreConsoleLogger - goes to stderr. Azure DevOps renders any stderr line as ##[error], so a fully successful `msstore publish` reports as a failed or partially failed release stage. Setting `failOnStderr: false` stops the failure but not the error rendering (microsoft/azure-pipelines-tasks#16825), so this cannot be documented away. The stderr routing is deliberate: stdout is reserved for machine-readable payloads, the 19 StandardOutput.WriteLine JSON sites plus the `package` output path. Flipping the default would break `msstore submission get | ConvertFrom-Json` and `$(msstore package)`, so this is opt-in instead. Add a global `--output-stream <stderr|stdout>` option, backed by MSSTORE_OUTPUT_STREAM so a pipeline can opt in once at job scope. Resolution is option > environment variable > stderr; the option deliberately wins so that a job-wide environment variable can be overridden back to stderr on the individual commands that emit a payload. StandardOutput is untouched, so payloads always go to stdout either way. The console has to exist before the command line is parsed, because the host builder needs it in the service collection, so OutputStreamResolver reads the raw args the same way `--verbose` already does. The option is still registered on every command, otherwise the parser rejects the token. Interactivity now probes whichever stream is in use rather than always probing stderr. Fixes #161 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b1d98dd1-7d74-456f-8392-bf210b4400c0
1 parent 0ee6d63 commit 32ff761

9 files changed

Lines changed: 421 additions & 2 deletions

File tree

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
using MSStore.CLI.Helpers;
5+
using MSStore.CLI.Services;
6+
7+
namespace MSStore.CLI.UnitTests
8+
{
9+
[TestClass]
10+
public class OutputStreamUnitTests : BaseCommandLineTest
11+
{
12+
[TestInitialize]
13+
public void Init()
14+
{
15+
FakeLogin();
16+
AddDefaultFakeAccount();
17+
AddFakeApps();
18+
}
19+
20+
[TestCleanup]
21+
public void ResetOutputStreamEnvironmentVariable()
22+
{
23+
Environment.SetEnvironmentVariable(EnvironmentInfo.OutputStreamEnvironmentVariable, null);
24+
}
25+
26+
[TestMethod]
27+
public void ResolveDefaultsToStderr()
28+
{
29+
var (stream, warning) = OutputStreamResolver.Resolve([], null);
30+
31+
stream.Should().Be(OutputStream.Stderr);
32+
warning.Should().BeNull();
33+
}
34+
35+
[DataRow("stdout", nameof(OutputStream.Stdout))]
36+
[DataRow("stderr", nameof(OutputStream.Stderr))]
37+
[DataRow("STDOUT", nameof(OutputStream.Stdout))]
38+
[DataRow("StdErr", nameof(OutputStream.Stderr))]
39+
[TestMethod]
40+
public void ResolveReadsTheOptionValueCaseInsensitively(string value, string expected)
41+
{
42+
var (stream, warning) = OutputStreamResolver.Resolve(["publish", "--output-stream", value], null);
43+
44+
stream.Should().Be(Enum.Parse<OutputStream>(expected));
45+
warning.Should().BeNull();
46+
}
47+
48+
[DataRow("--output-stream=stdout")]
49+
[DataRow("--output-stream:stdout")]
50+
[TestMethod]
51+
public void ResolveSupportsInlineValueSeparators(string arg)
52+
{
53+
var (stream, warning) = OutputStreamResolver.Resolve(["publish", arg], null);
54+
55+
stream.Should().Be(OutputStream.Stdout);
56+
warning.Should().BeNull();
57+
}
58+
59+
[TestMethod]
60+
public void ResolveUsesTheLastOccurrenceWhenTheOptionIsRepeated()
61+
{
62+
var (stream, _) = OutputStreamResolver.Resolve(
63+
["publish", "--output-stream", "stdout", "--output-stream", "stderr"],
64+
null);
65+
66+
stream.Should().Be(OutputStream.Stderr);
67+
}
68+
69+
[TestMethod]
70+
public void ResolveIgnoresTheOptionWhenItHasNoValue()
71+
{
72+
var (stream, warning) = OutputStreamResolver.Resolve(["publish", "--output-stream"], null);
73+
74+
stream.Should().Be(OutputStream.Stderr);
75+
warning.Should().BeNull();
76+
}
77+
78+
[TestMethod]
79+
public void ResolveDoesNotMatchOptionsThatMerelyStartWithTheSameText()
80+
{
81+
var (stream, _) = OutputStreamResolver.Resolve(["package", "--output-streamer", "stdout"], null);
82+
83+
stream.Should().Be(OutputStream.Stderr);
84+
}
85+
86+
[TestMethod]
87+
public void ResolveDoesNotConfuseTheOptionWithTheOutputDirectoryOption()
88+
{
89+
var (stream, _) = OutputStreamResolver.Resolve(["package", "--output", "C:\\packages"], null);
90+
91+
stream.Should().Be(OutputStream.Stderr);
92+
}
93+
94+
[TestMethod]
95+
public void ResolveReadsTheEnvironmentVariableWhenTheOptionIsAbsent()
96+
{
97+
var (stream, warning) = OutputStreamResolver.Resolve(["publish"], "stdout");
98+
99+
stream.Should().Be(OutputStream.Stdout);
100+
warning.Should().BeNull();
101+
}
102+
103+
[DataRow("stdout", "stderr", nameof(OutputStream.Stderr))]
104+
[DataRow("stderr", "stdout", nameof(OutputStream.Stdout))]
105+
[TestMethod]
106+
public void ResolveLetsTheOptionOverrideTheEnvironmentVariable(string environmentValue, string optionValue, string expected)
107+
{
108+
var (stream, warning) = OutputStreamResolver.Resolve(
109+
["package", "--output-stream", optionValue],
110+
environmentValue);
111+
112+
stream.Should().Be(Enum.Parse<OutputStream>(expected));
113+
warning.Should().BeNull();
114+
}
115+
116+
[TestMethod]
117+
public void ResolveWarnsAndFallsBackWhenTheEnvironmentVariableIsInvalid()
118+
{
119+
var (stream, warning) = OutputStreamResolver.Resolve(["publish"], "console");
120+
121+
stream.Should().Be(OutputStream.Stderr);
122+
warning.Should().Contain("console");
123+
warning.Should().Contain(EnvironmentInfo.OutputStreamEnvironmentVariable);
124+
}
125+
126+
[DataRow("")]
127+
[DataRow(" ")]
128+
[TestMethod]
129+
public void ResolveTreatsABlankEnvironmentVariableAsUnset(string environmentValue)
130+
{
131+
var (stream, warning) = OutputStreamResolver.Resolve(["publish"], environmentValue);
132+
133+
stream.Should().Be(OutputStream.Stderr);
134+
warning.Should().BeNull();
135+
}
136+
137+
[TestMethod]
138+
public void ResolveDoesNotWarnForAnInvalidOptionValue()
139+
{
140+
// The parser reports invalid option values, so the resolver just falls through.
141+
var (stream, warning) = OutputStreamResolver.Resolve(["publish", "--output-stream", "console"], null);
142+
143+
stream.Should().Be(OutputStream.Stderr);
144+
warning.Should().BeNull();
145+
}
146+
147+
[TestMethod]
148+
public void ResolveReadsTheRealEnvironmentVariable()
149+
{
150+
Environment.SetEnvironmentVariable(EnvironmentInfo.OutputStreamEnvironmentVariable, "stdout");
151+
152+
var (stream, warning) = OutputStreamResolver.Resolve(["publish"]);
153+
154+
stream.Should().Be(OutputStream.Stdout);
155+
warning.Should().BeNull();
156+
}
157+
158+
[DataRow("stdout")]
159+
[DataRow("stderr")]
160+
[TestMethod]
161+
public async Task OutputStreamOptionIsAcceptedByCommands(string value)
162+
{
163+
var appId = FakeApps[2].Id!;
164+
165+
var result = await ParseAndInvokeAsync(
166+
[
167+
"apps",
168+
"get",
169+
appId,
170+
"--output-stream",
171+
value
172+
]);
173+
174+
// Machine-readable payloads always go to stdout, whichever stream the human-readable
175+
// output was routed to.
176+
result.Output.Should().Contain($"\"Id\": \"{appId}\",");
177+
}
178+
179+
[TestMethod]
180+
public async Task InvalidOutputStreamOptionValueIsRejectedByTheParser()
181+
{
182+
var result = await ParseAndInvokeAsync(
183+
[
184+
"apps",
185+
"list",
186+
"--output-stream",
187+
"console"
188+
],
189+
1);
190+
191+
result.Error.Should().Contain("--output-stream");
192+
}
193+
}
194+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
namespace MSStore.CLI.Helpers
5+
{
6+
/// <summary>
7+
/// The standard stream that human-readable console output is written to.
8+
/// </summary>
9+
/// <remarks>
10+
/// This never affects machine-readable payloads, which always go to stdout through
11+
/// <see cref="StandardOutput"/>.
12+
/// </remarks>
13+
internal enum OutputStream
14+
{
15+
/// <summary>
16+
/// Human-readable output goes to standard error. This is the default, and keeps stdout
17+
/// clean so payloads can be piped or captured.
18+
/// </summary>
19+
Stderr,
20+
21+
/// <summary>
22+
/// Human-readable output goes to standard output. Useful on Azure DevOps, which renders
23+
/// every stderr line as <c>##[error]</c>.
24+
/// </summary>
25+
Stdout
26+
}
27+
}
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
using System;
5+
using System.Collections.Generic;
6+
using MSStore.CLI.Services;
7+
8+
namespace MSStore.CLI.Helpers
9+
{
10+
/// <summary>
11+
/// Resolves which standard stream human-readable output should be written to.
12+
/// </summary>
13+
/// <remarks>
14+
/// <para>
15+
/// The resolution order is <c>--output-stream</c> &gt; <see cref="EnvironmentInfo.OutputStreamEnvironmentVariable"/>
16+
/// &gt; <see cref="OutputStream.Stderr"/>. The flag deliberately wins so that a pipeline-wide environment
17+
/// variable can be overridden on the individual commands that emit a machine-readable payload.
18+
/// </para>
19+
/// <para>
20+
/// <see cref="Program"/> has to build the <see cref="Spectre.Console.IAnsiConsole"/> before the command line is
21+
/// parsed, because the host builder needs it in the service collection. The raw arguments are therefore
22+
/// inspected here, the same way <c>--verbose</c> is handled.
23+
/// </para>
24+
/// </remarks>
25+
internal static class OutputStreamResolver
26+
{
27+
internal const string OptionName = "--output-stream";
28+
29+
private static readonly char[] InlineValueSeparators = [':', '='];
30+
31+
/// <summary>
32+
/// Resolves the stream from the raw command line arguments and the environment.
33+
/// </summary>
34+
/// <param name="args">The raw command line arguments.</param>
35+
/// <returns>The resolved stream, and a warning to surface when the environment variable is malformed.</returns>
36+
public static (OutputStream Stream, string? Warning) Resolve(IReadOnlyList<string> args)
37+
{
38+
string? environmentValue;
39+
try
40+
{
41+
environmentValue = Environment.GetEnvironmentVariable(EnvironmentInfo.OutputStreamEnvironmentVariable);
42+
}
43+
catch (Exception)
44+
{
45+
// Reading the environment can throw under restricted hosts. Fall back to the default.
46+
environmentValue = null;
47+
}
48+
49+
return Resolve(args, environmentValue);
50+
}
51+
52+
/// <summary>
53+
/// Resolves the stream from the raw command line arguments and an explicit environment variable value.
54+
/// </summary>
55+
/// <param name="args">The raw command line arguments.</param>
56+
/// <param name="environmentValue">The value of the environment variable, or null when it is not set.</param>
57+
/// <returns>The resolved stream, and a warning to surface when the environment variable is malformed.</returns>
58+
public static (OutputStream Stream, string? Warning) Resolve(IReadOnlyList<string> args, string? environmentValue)
59+
{
60+
ArgumentNullException.ThrowIfNull(args);
61+
62+
if (TryParse(FindOptionValue(args), out var fromArgs))
63+
{
64+
return (fromArgs, null);
65+
}
66+
67+
if (string.IsNullOrWhiteSpace(environmentValue))
68+
{
69+
return (OutputStream.Stderr, null);
70+
}
71+
72+
if (TryParse(environmentValue, out var fromEnvironment))
73+
{
74+
return (fromEnvironment, null);
75+
}
76+
77+
return (
78+
OutputStream.Stderr,
79+
$"'{environmentValue}' is not a valid {EnvironmentInfo.OutputStreamEnvironmentVariable} value. Expected '{nameof(OutputStream.Stdout)}' or '{nameof(OutputStream.Stderr)}'. Falling back to '{nameof(OutputStream.Stderr)}'.");
80+
}
81+
82+
/// <summary>
83+
/// Parses a stream name, accepting any casing.
84+
/// </summary>
85+
/// <param name="value">The value to parse.</param>
86+
/// <param name="outputStream">The parsed stream.</param>
87+
/// <returns>True when the value names a known stream.</returns>
88+
public static bool TryParse(string? value, out OutputStream outputStream)
89+
{
90+
outputStream = OutputStream.Stderr;
91+
92+
return !string.IsNullOrWhiteSpace(value)
93+
&& Enum.TryParse(value.Trim(), ignoreCase: true, out outputStream)
94+
&& Enum.IsDefined(outputStream);
95+
}
96+
97+
/// <summary>
98+
/// Finds the value of the last <c>--output-stream</c> occurrence, supporting both the
99+
/// <c>--output-stream value</c> and <c>--output-stream=value</c> forms.
100+
/// </summary>
101+
/// <param name="args">The raw command line arguments.</param>
102+
/// <returns>The value, or null when the option is absent.</returns>
103+
private static string? FindOptionValue(IReadOnlyList<string> args)
104+
{
105+
string? value = null;
106+
107+
for (var i = 0; i < args.Count; i++)
108+
{
109+
var arg = args[i];
110+
if (arg == null)
111+
{
112+
continue;
113+
}
114+
115+
if (arg.Length > OptionName.Length
116+
&& arg.StartsWith(OptionName, StringComparison.Ordinal)
117+
&& Array.IndexOf(InlineValueSeparators, arg[OptionName.Length]) >= 0)
118+
{
119+
value = arg[(OptionName.Length + 1)..];
120+
}
121+
else if (string.Equals(arg, OptionName, StringComparison.Ordinal) && i + 1 < args.Count)
122+
{
123+
value = args[i + 1];
124+
i++;
125+
}
126+
}
127+
128+
return value;
129+
}
130+
}
131+
}

MSStore.CLI/Helpers/StandardOutput.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,16 @@ internal static class StandardOutput
1212
/// </summary>
1313
/// <param name="value">The text to write.</param>
1414
/// <remarks>
15+
/// <para>
1516
/// This deliberately bypasses Spectre.Console's <see cref="Spectre.Console.AnsiConsole"/>: its renderer
1617
/// word-wraps at the console width (falling back to 80 columns when stdout is redirected), which injects
1718
/// raw newline characters inside JSON string values and produces invalid JSON.
19+
/// </para>
20+
/// <para>
21+
/// Machine-readable payloads always go to stdout, regardless of <c>--output-stream</c>. Pass
22+
/// <c>--output-stream stderr</c> on these commands when a pipeline-wide
23+
/// <c>MSSTORE_OUTPUT_STREAM=stdout</c> would otherwise interleave human-readable output with the payload.
24+
/// </para>
1825
/// </remarks>
1926
public static void WriteLine(string value)
2027
{

MSStore.CLI/MicrosoftStoreCLI.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,20 @@ internal class MicrosoftStoreCLI : RootCommand
2121
{
2222
internal static Option<bool> VerboseOption { get; }
2323

24+
internal static Option<OutputStream> OutputStreamOption { get; }
25+
2426
static MicrosoftStoreCLI()
2527
{
2628
VerboseOption = new Option<bool>("--verbose", "-v")
2729
{
2830
DefaultValueFactory = _ => false,
2931
Description = "Verbose output"
3032
};
33+
34+
OutputStreamOption = new Option<OutputStream>(OutputStreamResolver.OptionName)
35+
{
36+
Description = $"The stream that human-readable output is written to. Defaults to '{nameof(OutputStream.Stderr)}', which keeps stdout free for machine-readable payloads. Use '{nameof(OutputStream.Stdout)}' on Azure DevOps, which reports every stderr line as an error. Also settable through the {EnvironmentInfo.OutputStreamEnvironmentVariable} environment variable, which this option overrides."
37+
};
3138
}
3239

3340
internal static void WelcomeMessage(IAnsiConsole ansiConsole)

0 commit comments

Comments
 (0)