Let human-readable output be routed to stdout - #174
Let human-readable output be routed to stdout#174Alexandre Zollinger Chohfi (azchohfi) wants to merge 10 commits into
Conversation
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
There was a problem hiding this comment.
🟡 Changes recommended
Static console writes bypass the selected stream, and raw argument parsing has validation and delimiter inconsistencies.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Adds opt-in routing of human-readable CLI output to stdout for Azure DevOps while preserving machine-readable stdout payloads.
Changes:
- Adds
--output-streamandMSSTORE_OUTPUT_STREAMresolution. - Configures console routing and stream-aware interactivity.
- Documents behavior and adds resolver/parser tests.
File summaries
| File | Description |
|---|---|
README.md |
Documents stream behavior and Azure DevOps usage. |
MSStore.CLI/StoreHostBuilderExtensions.cs |
Registers the option across commands. |
MSStore.CLI/Services/EnvironmentInfo.cs |
Defines the environment variable. |
MSStore.CLI/Program.cs |
Selects and configures the human-output stream. |
MSStore.CLI/MicrosoftStoreCLI.cs |
Defines the global option. |
MSStore.CLI/Helpers/StandardOutput.cs |
Clarifies payload routing. |
MSStore.CLI/Helpers/OutputStreamResolver.cs |
Resolves option and environment values. |
MSStore.CLI/Helpers/OutputStream.cs |
Defines supported streams. |
MSStore.CLI.UnitTests/OutputStreamUnitTests.cs |
Tests resolution and parser acceptance. |
Review details
- Files reviewed: 9/9 changed files
- Comments generated: 4
- Review effort level: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
- OutputStreamResolver.TryParse matched with Enum.TryParse, which also accepts the underlying numbers, so MSSTORE_OUTPUT_STREAM=1 was silently treated as Stdout instead of warning. Match the two names explicitly. - The raw argument scan ignored System.CommandLine's `--` end-of-options marker, so `msstore package -- --output-stream=stdout` redirected output even though the parser treats that token as a literal path. Stop scanning at `--`. - Nine call sites still reached for the static AnsiConsole (the apps/flights list and info tables, the browser launcher prompt, and every ConsoleReader prompt), which writes to stdout. Human-readable output therefore did not all go to stderr by default, and --output-stream did not control those paths. Point the static console at the configured instance. This moves `apps list`, `flights list` and `info` tables, and interactive prompts, from stdout to stderr, which is what the documented contract already claimed. Their machine-readable counterparts (`apps get`, `flights get`) are unaffected and still emit JSON on stdout. The test harness mirrored the old split, so it is updated alongside the four assertions that depended on it. - README stated the stream separation unconditionally, which the new option contradicts. Qualify it as the default. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b1d98dd1-7d74-456f-8392-bf210b4400c0
There was a problem hiding this comment.
🟡 Changes recommended
System.CommandLine help and diagnostics bypass the configured Spectre stream, contradicting the advertised authoritative routing.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 13/13 changed files
- Comments generated: 1
- Review effort level: Balanced
System.CommandLine writes help through InvocationConfiguration.Output and parse diagnostics through .Error, neither of which is affected by --output-stream. The README claimed stdout carried only machine-readable payloads, which help text contradicts. Rather than redirect them, keep the conventional behaviour and describe it: help belongs on stdout so `msstore --help | more` works, and parse errors belong on stderr because they accompany a non-zero exit code. Document both as deliberately outside the option's scope, and note the exclusion at the InvokeAsync call so it does not read as an oversight. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b1d98dd1-7d74-456f-8392-bf210b4400c0
There was a problem hiding this comment.
🔵 Needs a closer look
Numeric enum option values are accepted by the command parser but interpreted differently by the raw stream resolver.
Review details
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
MSStore.CLI/MicrosoftStoreCLI.cs:37
- The default enum converter accepts numeric values, but the raw resolver intentionally rejects them. As a result,
--output-stream 1is accepted by System.CommandLine asStdoutwhileOutputStreamResolverfalls back toStderr, so the command silently writes to the opposite stream. Use the sameTryParselogic as a custom option parser (and cover0/1as option values) so parsing and pre-host resolution share one contract.
- Files reviewed: 13/13 changed files
- Comments generated: 0 new
- Review effort level: Balanced
Tightening OutputStreamResolver.TryParse to the two names left the option itself on System.CommandLine's built-in enum converter, which still accepts the underlying numbers. `--output-stream 1` therefore parsed as Stdout while the resolver, which is what actually selects the stream before the host is built, fell back to Stderr - so the command wrote to the opposite stream of what it accepted. Give the option a CustomParser backed by the same TryParse, following the existing idiom in PublishCommand, so both sides reject anything that is not one of the two names. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b1d98dd1-7d74-456f-8392-bf210b4400c0
There was a problem hiding this comment.
🟡 Changes recommended
The central end-to-end stream-routing behavior lacks automated coverage.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 13/13 changed files
- Comments generated: 1
- Review effort level: Balanced
The routing configured in Program.Main had no automated coverage. OutputStreamUnitTests exercises the resolver and the parser, but BaseCommandLineTest.ParseAndInvokeAsync builds its own consoles and never runs Main, so pinning the console back to Console.Error left the suite green. Add OutputStreamProcessTests, which runs the built executable and reads stdout and stderr separately, following the existing ExternalCommandExecutorTests precedent. It covers the default, the option in all three spellings, the environment variable, the option overriding the environment variable, the invalid-value warning, and help staying on stdout. Verified the coverage is real: reverting the Out selector to Console.Error fails four of them. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b1d98dd1-7d74-456f-8392-bf210b4400c0
There was a problem hiding this comment.
🟡 Changes recommended
Response-file arguments can make parsing and actual stream routing disagree, and process tests can modify real user configuration.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (2)
Previously missed (1) — in code that hasn't changed since the last review.
MSStore.CLI/Helpers/OutputStreamResolver.cs:64
- System.CommandLine 2.0.10 expands
@response-filetokens before parsing, but this pre-host scan only sees the literal@fileargument. A response file containing--output-stream stdoutis therefore accepted by the command parser while the console remains on stderr. Expand response-file arguments with the same parser configuration before resolving, or explicitly prevent this option from being supplied that way so parsing and routing cannot disagree.
MSStore.CLI/Program.cs:66
- The process tests only use the verbose logger marker, which writes through the injected console; the in-process harness assigns
AnsiConsole.Consoleindependently. Consequently, removing this assignment leaves the suite green even though tables and prompts stop honoring--output-stream. Add coverage that executes a staticAnsiConsolewriter through the real startup configuration for both default and stdout routing.
AnsiConsole.Console = ansiConsole;
- Files reviewed: 14/14 changed files
- Comments generated: 1
- Review effort level: Balanced
Two gaps from review: - Removing `AnsiConsole.Console = ansiConsole` left the suite green. The process tests only assert on the verbose logger, which writes through the injected console, and the in-process harness assigns the static console itself. Extract the console construction into ConsoleFactory so it can be exercised directly, and add ConsoleFactoryUnitTests covering both streams through the injected and the static console. Deleting the assignment now fails three of them. - OutputStreamProcessTests spawned the CLI, which loads and can rewrite telemetrySettings.json before it parses anything, so running the suite mutated real user configuration. Environment.GetFolderPath ignores LOCALAPPDATA/HOME on Windows, so the child cannot be pointed at a temporary profile; snapshot the file and restore it around the class instead, covering the macOS Application Support location as well. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b1d98dd1-7d74-456f-8392-bf210b4400c0
|
Picking up the two suppressed comments from the last review, since they have no threads to reply in. Static
|
| Invocation | Exit | Parse error |
|---|---|---|
msstore --output-stream bogus |
1 | Cannot parse argument 'bogus' for option '--output-stream'. |
msstore @bad.rsp --help |
0 | none |
If the file were expanded, the second row would have produced the same parse error. And with a valid --output-stream stdout inside a response file, output stayed on stderr — the parser did not pick it up either, so parsing and routing agree.
Happy to revisit if response file support gets enabled later; the fix would be to expand tokens with the same configuration before resolving. As it stands there is no divergence to fix, so I have not added speculative handling.
Full suite is 237/237 on net10.0-windows10.0.17763.0.
- The backup/restore of the real telemetrySettings.json was not crash-safe: a killed test host, a hang past the timeout, or a concurrent run could leave a developer's config deleted or stale. Gate the class behind MSSTORE_RUN_PROCESS_TESTS instead and drop the backup entirely. CI runners are disposable, so the mutation is harmless where the variable is set, and the tests skip everywhere else. Both CI definitions set it on every test step, so the coverage of the real Program.cs stream wiring still runs. - A missing executable used Assert.Inconclusive, so the whole class could silently skip and still report green - dropping the only coverage of the real stream wiring if the build layout ever changed. It is now Assert.Fail naming the probed path, the configuration and the target framework. Deliberate skips (the opt-in gate) and genuine failures (a missing build) are now distinct. - ConsoleFactory.Create also assigned the static AnsiConsole.Console, which the name did not hint at, making the side effect invisible at the call site. Renamed to CreateAndInstall. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b1d98dd1-7d74-456f-8392-bf210b4400c0
There was a problem hiding this comment.
🟡 Changes recommended
The process tests still modify the real telemetry configuration without the promised snapshot and restoration.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 18/18 changed files
- Comments generated: 1
- Review effort level: Balanced
There was a problem hiding this comment.
🟡 Changes recommended
Process tests can mutate real telemetry settings and do not fully validate parser acceptance.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (1)
MSStore.CLI.UnitTests/OutputStreamProcessTests.cs:21
- The PR description says these tests snapshot and restore
telemetrySettings.json, but this implementation only gates them behind an environment variable. Once enabled, each process runsLoadAsync(true)/CreateTelemetryClientAsyncagainst the real profile and can permanently replace the user's telemetry settings; this also affects persistent CI agents. Please restore the file snapshot (or delete a newly created file) in class cleanup as described.
/// <c>CreateTelemetryClientAsync</c>, which rewrites <c>telemetrySettings.json</c> whenever the telemetry
/// GUID is missing or older than 24 hours, and <c>ConfigurationManager</c> resolves that path through
/// <see cref="Environment.GetFolderPath(Environment.SpecialFolder)"/>, which ignores <c>LOCALAPPDATA</c>
/// and <c>HOME</c> on Windows. There is no way to redirect it at a temporary profile, so rather than
/// mutate a developer's real configuration these only run where that is harmless.
- Files reviewed: 18/18 changed files
- Comments generated: 1
- Review effort level: Balanced
The process tests asserted only on the "Command is" marker, which Program logs before InvokeAsync, so a run that emitted the marker on the right stream and then failed would still have passed. Assert the exit code in every one, and add a negative case for an invalid option value. The colon spelling was also only covered at the resolver level, never end to end, so add it to the routing cases. Note that the exit code alone does not prove a spelling parsed: --help takes precedence over parse errors, so an unrecognized token still exits 0 with no error text on either stream. Parser acceptance of the inline forms is therefore asserted in-process, where no help action is involved and ParseAndInvokeAsync already requires an exit code of 0. Verified by pointing one case at a separator System.CommandLine does not accept, which fails only there. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b1d98dd1-7d74-456f-8392-bf210b4400c0
apps list, flights list and info mixed both consoles in one command: the spinner and status messages went through the injected IAnsiConsole while the table itself went through the static AnsiConsole. apps list also split its "no Managed apps" message that way, and flights list was internally inconsistent, using the static console for the table but the injected one for its "no Flights" message. Installing the configured console as the static one made these behave correctly, but only as a side effect - the call sites still read as though they write somewhere else. Use the injected console directly, and inject one into InfoCommand.Handler, which did not take one. No behaviour change, since both already resolve to the same instance. It does remove the dependency on the static install: before this, `info --output-stream stdout` put the table on stdout only because of the assignment; now it does so on its own. The remaining static call sites are the ConsoleReader prompts and the BrowserLauncher confirmation. Those are interactive prompt paths that the static install still covers. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b1d98dd1-7d74-456f-8392-bf210b4400c0
There was a problem hiding this comment.
🟡 Changes recommended
The resolver mishandles an end-of-options marker used where the option value is missing, diverging from parser behavior.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (2)
Previously missed (1) — in code that hasn't changed since the last review.
MSStore.CLI/Helpers/OutputStreamResolver.cs:151
- When
--immediately follows this option (for example,--output-stream -- --output-stream=stdout), the resolver consumes the marker as the option value and skips over it, then treats the trailing literal as another option. System.CommandLine instead keeps--as the end-of-options marker and reports the first option's missing value, so the pre-parse console can be routed differently from the parser contract. Stop scanning when the prospective value is the marker.
MSStore.CLI.UnitTests/ConsoleFactoryUnitTests.cs:83
- This test comment still names the apps/flights/info tables as static-console consumers, but those call sites are migrated to
_ansiConsolein this PR. The static assertion remains useful for the browser launcher and prompts; update the rationale accordingly.
// The apps/flights/info tables, the browser launcher and every ConsoleReader prompt write through
// the static console, so it has to honour the selected stream too.
- Files reviewed: 21/21 changed files
- Comments generated: 2
- Review effort level: Balanced
`--output-stream -- --output-stream=stdout` diverged from the parser. The raw scan took `--` as the value for the first occurrence, skipped past it, and then matched the trailing literal, so the console was routed to stdout. System. CommandLine instead keeps `--` as the end-of-options marker and reports the first option's value as missing, failing the command. Stop scanning when the prospective value is the marker, so a rejected command line can no longer redirect output. Also correct two comments left stale by the previous commit: the apps, flights and info tables no longer go through the static console, so only the ConsoleReader prompts and the BrowserLauncher confirmation justify installing it. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b1d98dd1-7d74-456f-8392-bf210b4400c0
|
Picking up the suppressed comment about the end-of-options marker, since it has no thread to reply in.
|
| Before | After | |
|---|---|---|
| Exit code | 1 | 1 |
| Parser | Required argument missing for option: '--output-stream'Unrecognized command or argument '--output-stream=stdout' |
same |
| Human output routed to | stdout ❌ | stderr ✅ |
So a command line the parser rejected outright was still steering the console — exactly the parser/resolver divergence this option is supposed to avoid.
FindOptionValue now stops when the prospective value is the marker, matching System.CommandLine's treatment of -- as end-of-options rather than a value.
Two regression tests added, and I confirmed they bite by neutralising the guard:
failed ResolveDoesNotConsumeTheEndOfOptionsMarkerAsTheOptionValue
failed ResolveKeepsAnEarlierValueWhenTheOptionLaterPrecedesTheEndOfOptionsMarker
Checked the neighbouring behaviour is unaffected: --output-stream stdout --help still routes to stdout and exits 0, and a value supplied before a later -- still wins.
A methodology note
My first mutation attempt replaced the guard with if (false), which tripped CS0162 under TreatWarningsAsErrors. The build failed, the test run used the stale binary, and everything came back green — a false negative. Re-ran it with a sentinel comparison that keeps the branch reachable, which is where the two failures above came from. Worth knowing if anyone repeats this on a Release build.
Suite is 245/245 on net10.0-windows10.0.17763.0.
There was a problem hiding this comment.
🟢 Approval recommended
Stream precedence, payload isolation, static-console behavior, documentation, and cross-platform CI coverage are consistent and adequately tested.
Review details
- Files reviewed: 21/21 changed files
- Comments generated: 0 new
- Review effort level: Balanced
| /// Scanning stops at a standalone <c>--</c>, because System.CommandLine treats everything after it as | ||
| /// literal arguments rather than options. | ||
| /// </remarks> | ||
| private static string? FindOptionValue(IReadOnlyList<string> args) |
There was a problem hiding this comment.
This hand-rolls a chunk of System.CommandLine's tokenizer — inline =/: separators, last-occurrence-wins, -- end-of-options, and the "don't consume -- as the value" case. That's ~90 lines plus ~15 tests to read one flag, and two of the commits on this branch (c6c2cf8, f671704) were spent reconciling it with the real parser. Every future parser nuance is a divergence that only surfaces as the option and the resolver disagreeing.
I don't think the circular dependency this works around actually exists. MicrosoftStoreCLI.OutputStreamOption is a static built in a static ctor with no DI, so it can be parsed against a bare RootCommand before the host is built:
private static OutputStream? FromArgs(IReadOnlyList<string> args)
{
var probe = new RootCommand { TreatUnmatchedTokensAsErrors = false };
probe.Options.Add(MicrosoftStoreCLI.OutputStreamOption);
var result = probe.Parse([.. args]);
var optionResult = result.GetResult(MicrosoftStoreCLI.OutputStreamOption);
return optionResult is null || optionResult.Errors.Count > 0
? null
: result.GetValue(MicrosoftStoreCLI.OutputStreamOption);
}TreatUnmatchedTokensAsErrors = false makes subcommands and positionals harmless, and post--- tokens land in UnmatchedTokens for free — so the two end-of-options cases this method handles explicitly need no code at all. Scope the error check to optionResult.Errors rather than result.Errors, otherwise an unrelated parse error elsewhere on the line would silently drop a valid --output-stream.
I prototyped this locally and ran it against all 18 edge cases covered by OutputStreamUnitTests (inline separators, casing, repeats, --output-stream --, --output-streamer, --output, missing value, invalid value, -- in every position). All 18 resolve identically to the current implementation, so the existing tests can stay exactly as they are and serve as the regression net for the swap.
Worth noting the --verbose comparison, since this is the local precedent: Program.cs:63 gets away with args.Contains only because a boolean has no value to locate — and it's already quietly wrong in the same way (msstore package -- --verbose enables verbose logging even though the parser treats it as a literal). Nobody notices because the cost is a few extra log lines, whereas getting --output-stream wrong defeats the whole feature. So the extra rigor here is right; it just doesn't have to be hand-written. If you like the approach, the same probe could resolve both options and retire the Contains hack too — probably a follow-up rather than this PR.
Non-blocking either way — the current code is correct as far as I can tell, this is about the maintenance surface.
|
|
||
| `--output-stream stdout` deliberately breaks that separation: it moves the human-readable half onto stdout, where it is interleaved with any payload. | ||
|
|
||
| Two things sit outside the option's scope on purpose, matching the behavior of other CLIs: | ||
|
|
||
| * Machine-readable payloads are always written to stdout, so they are never affected by the option. | ||
| * `--help` is always written to stdout, so that `msstore --help | more` works, and command line parse errors are always written to stderr, because they accompany a non-zero exit code. |
There was a problem hiding this comment.
This section reads as if the stdout/stderr split had always been the design, but it's a behavior change. Installing the stderr console as the static AnsiConsole.Console in ConsoleFactory.CreateAndInstall, plus switching the three commands to the injected console, moves everything that previously went through the static console off stdout: the apps list and flights list tables, the info table, ConsoleReader prompts, and the BrowserLauncher confirmation. The test churn in AppsCommandUnitTests, FlightsCommandUnitTests and EmptyCommandUnitTests (result.Output → result.Error) is exactly this, and it's the part a user upgrading will feel — msstore apps list | grep ... and msstore info > file start returning nothing, with a zero exit code and no diagnostic.
Worth stating outright, along with the rollback. Suggested addition just before ### Azure DevOps:
> [!WARNING]
> **Changed in <version>.** Human-readable output previously went to stdout. It now
> defaults to stderr, including the `apps list`, `flights list` and `info` tables,
> prompts, and status messages. Scripts that piped or captured that output need one of:
>
> * `--output-stream stdout` (or `MSSTORE_OUTPUT_STREAM=stdout`) to restore the
> previous routing, or
> * `2>&1` to merge the streams.
>
> Machine-readable payloads (`submission get`, `apps get`, `package`) were already on
> stdout and are unaffected.The point I'd most want made is that --output-stream stdout is a complete one-flag rollback — that's what makes this a manageable change rather than an alarming one, and the section never says it today. There's no CHANGELOG in the repo, so the README and the GitHub release notes are the only places this can land.
Two related follow-ups:
- The option's
DescriptioninMicrosoftStoreCLI.csis 426 characters on one line. In the--helpoptions table that wraps into a paragraph next to--verbose's"Verbose output". Since the README now covers the Azure DevOps rationale and the override precedence properly, the help text can shrink to one sentence naming the two values and the environment variable. - The README links
aka.ms/msstoredevcli/docsas canonical for CI/CD setup, and its Azure DevOps guidance goes stale when this merges. Probably an issue against the docs rather than anything in this PR, but the two shouldn't disagree.
Fixes #161
Problem
Program.csbuilds the singleIAnsiConsoleoverConsole.Error, so every human-facing message — success ticks, status lines, tables, and everything fromCustomSpectreConsoleLogger— goes to stderr.Azure DevOps renders any stderr line as
##[error], so a fully successfulmsstore publishreports as a failed or partially failed release stage.Documenting
failOnStderr: falsedoes not fix this: Azure DevOps still prints##[error]for stderr lines even when the task is configured not to fail on them (azure-pipelines-tasks#16825, #13097).Why this is opt-in rather than a changed default
The stderr routing is deliberate (commit
ca1d6ab, PR #158 / issue #150): stdout is reserved for machine-readable payloads — the 19StandardOutput.WriteLineJSON sites plus thepackageoutput path. Flipping the default would breakmsstore submission get | ConvertFrom-Jsonand$(msstore package).Prior art agrees the default is right:
ghputs progress on stderr (gated onIsStdoutTTY()), andaz,terraform,aws,npm,pip,dockeranddotnetall do the same with no way to switch streams. GitHub Actions is unaffected either way — it fails on exit code only and never annotates stderr.Change
A global
--output-stream <stderr|stdout>option, backed by theMSSTORE_OUTPUT_STREAMenvironment variable so a pipeline can opt in once at job scope.Resolution is option > environment variable >
stderr. The option deliberately wins, because a job-wide environment variable is the one real hazard here — it would interleave human output with the payload on the commands that emit one. Those calls opt back out locally:StandardOutputis untouched, so payloads always go to stdout either way.Implementation notes:
OutputStreamResolvertherefore reads the raw args, the same way--verbosealready does — stopping at--so it agrees with System.CommandLine's end-of-options semantics.CustomParserbacked by the sameTryParseas the resolver, so both sides share one contract and neither accepts the enum's underlying numbers.ConsoleFactory.CreateAndInstallbuilds the console and installs it as the staticAnsiConsole.Console, so the call sites that still use the static console honour the selected stream.Console.IsOutputRedirectedvsConsole.IsErrorRedirected).Nine call sites reached for the static
AnsiConsole, which writes to stdout: theapps list/flights list/infotables, theBrowserLauncherprompt, and everyConsoleReaderprompt. Human-readable output therefore did not all go to stderr by default, and--output-streamdid not control those paths at all.apps list,flights list,infotables--output-stream stdout)apps get,flights get,submission get,packagepayloadsThis makes the documented contract actually true and
--output-streamauthoritative over all human-readable output.Scope
--helpstays on stdout somsstore --help | morekeeps working, and parse errors stay on stderr because they accompany a non-zero exit code. Both are written throughInvocationConfiguration, which is deliberately left at its defaults.Verification
Behaviour confirmed against the built binary, comparing separately-redirected stdout/stderr:
--output-stream stdout--output-stream=stdout/--output-stream:stdout--output-stream STDOUTMSSTORE_OUTPUT_STREAM=stdoutMSSTORE_OUTPUT_STREAM=stdout+--output-stream stderrMSSTORE_OUTPUT_STREAM=bogusor=1-- --output-stream=stdout(after end-of-options)44 new tests across three classes:
ConsoleFactoryUnitTestsfor the stream selection and the static-console installOutputStreamProcessTests, which runs the built executable and reads the two streams separatelyOutputStreamProcessTestsis opt-in throughMSSTORE_RUN_PROCESS_TESTS, which both CI definitions set on every test step. Running the real CLI also runsCreateTelemetryClientAsync, which can rewritetelemetrySettings.json, andConfigurationManagerresolves that path viaEnvironment.GetFolderPath, which ignoresLOCALAPPDATA/HOMEon Windows — so it cannot be redirected at a temp profile. Rather than snapshot and restore a developer's real config (which is not crash-safe if the host is killed mid-run), these only run where the mutation is harmless. All runners used here are ephemeral and hosted (windows-latest/ubuntu-latest/macos-latest, and 1ES hosted pools). Verified from the CI TRX artifacts that all 9 run and pass on Windows, Ubuntu and macOS.Both new test classes were mutation-checked: reverting the
Outselector toConsole.Errorfails 4 process tests, and deleting theAnsiConsole.Consoleassignment fails 3 factory tests. Removing the built executable fails all 9 process tests with the probed path, rather than skipping.Full suite is 237/237 on
net10.0-windows10.0.17763.0. Onnet10.0the only 2 failures (PublishCommandFor{WinUI,Maui}AppsShouldCallMSBuildIfWindows) reproduce identically on a stashed clean tree, so they are pre-existing and environmental — both pass in CI. Release build is warning-free underTreatWarningsAsErrors, and ILC reports no trim/AOT warnings.