From d43f8302ca3fb5f7c95e141ae163e751ecaa79ea Mon Sep 17 00:00:00 2001 From: Akhil Gupta Date: Wed, 7 Oct 2026 13:56:16 -0700 Subject: [PATCH 01/21] fix: return exit code 1 on parse errors --- src/winapp-CLI/WinApp.Cli/winapp-Program.cs | 686 ++++++++++++++++++++ 1 file changed, 686 insertions(+) create mode 100644 src/winapp-CLI/WinApp.Cli/winapp-Program.cs diff --git a/src/winapp-CLI/WinApp.Cli/winapp-Program.cs b/src/winapp-CLI/WinApp.Cli/winapp-Program.cs new file mode 100644 index 000000000..6e46646c5 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/winapp-Program.cs @@ -0,0 +1,686 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Spectre.Console; +using System.Text; +using WinApp.Cli.Commands; +using WinApp.Cli.Helpers; +using WinApp.Cli.Models; +using WinApp.Cli.Services; +using WinApp.Cli.Telemetry; +using WinApp.Cli.Telemetry.Events; +using LogLevel = Microsoft.Extensions.Logging.LogLevel; + +namespace WinApp.Cli; + +internal static class Program +{ + internal static async Task Main(string[] args) + { + // Hidden internal verb: the WinUI DbgEng triage pass runs in this isolated child process so + // its modern dbgeng.dll is not poisoned by the system32 dbghelp.dll the parent already loaded. + // Intercept before any host/service setup to keep the loader state clean and output noise-free. + if (args.Length > 0 && args[0] == Services.XamlTriageRunner.InternalVerb) + { + return Services.XamlTriageRunner.Run(args); + } + + // Ensure UTF-8 I/O for emoji-capable terminals; fall back silently if not supported + ConfigureConsoleEncoding(static () => + { + Console.OutputEncoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); + Console.InputEncoding = Encoding.UTF8; + }); + + var loggingMode = ResolveLoggingMode(args); + if (loggingMode.ConflictError is not null) + { + Console.Error.WriteLine(loggingMode.ConflictError); + return 1; + } + + var minimumLogLevel = loggingMode.MinimumLevel; + bool quiet = loggingMode.Quiet; + bool json = loggingMode.Json; + + // Check if --cli-schema is specified - this outputs machine-readable JSON + // and should not display any interactive messages like first-run notices + bool isCliSchemaMode = GlobalOptionPreScan.IsFlagPresent( + args, WinAppRootCommand.CliSchemaOption.Name, []); + + // Check if this is a completion request - completions must be fast and silent + bool isCompleteMode = args.Length > 0 && args[0] == "complete"; + + var services = new ServiceCollection() + .ConfigureServices() + .ConfigureCommands() + .AddLogging(b => + { + b.ClearProviders(); + b.AddTextWriterLogger(Console.Out, Console.Error); + b.SetMinimumLevel(minimumLogLevel); + }); + + using var serviceProvider = services.BuildServiceProvider(); + + var rootCommand = serviceProvider.GetRequiredService(); + System.CommandLine.ParseResult? parseResult = null; + + if (args.Length > 0) + { + parseResult = rootCommand.Parse(args, WinAppParserConfiguration.Default); + + // Infrastructure processes are not separate user invocations. Set this before any + // telemetry instance is created; descendants inherit the winapp-only opt-out. + if (parseResult.CommandResult.Command is GuestAgentCommand) + { + Environment.SetEnvironmentVariable(Telemetry.Telemetry.OptOutEnvironmentVariable, "1"); + } + + // Set WINAPP_CLI_CALLER env var from --caller option so telemetry and update checks can use it + var caller = parseResult.GetValue(WinAppRootCommand.CallerOption); + if (!string.IsNullOrWhiteSpace(caller)) + { + Environment.SetEnvironmentVariable("WINAPP_CLI_CALLER", caller); + } + + // Reject an invalid '='-attached value on ANY boolean option reachable by the selected + // command — a global flag like --json=bogus or a command flag like --eraser=bogus. + // System.CommandLine silently coerces such a value to TRUE with no parse error, which + // would (a) silently enable a flag the user never set (e.g. the pen eraser) and (b) for + // --json leave logging un-suppressed, so the human ❌ line and the JSON envelope BOTH hit + // stderr and corrupt machine-readable output. Fail fast HERE — before the first-run / + // update notice can print anything (M1) — with a single clean invalid_arguments error, + // mirroring how the parser already rejects other malformed values (e.g. --pressure nope). + if (TryFindInvalidBooleanOption(parseResult, args, out var invalidBoolOption, out var invalidBoolValue)) + { + var message = + $"Cannot parse argument '{invalidBoolValue}' for option '{invalidBoolOption}' as expected type 'System.Boolean'."; + if (IsNewCommandJson(parseResult)) + { + // `new` owns a bespoke NewCommandResult envelope; emit it (and use its + // invalid-argument exit code) so machine callers get structured output here too. + NewCommand.EmitParseErrorJson(message); + return NewCommand.ExitInvalidArgs; + } + if (ResolveEffectiveJson(parseResult) && IsUiDescendant(parseResult)) + { + UiJsonError.Emit(true, UiJsonError.CodeInvalidArguments, message); + } + else if (ResolveEffectiveJson(parseResult) && IsFlatJsonErrorCommand(parseResult)) + { + EmitFlatJsonError(message); + } + else if (ResolveEffectiveJson(parseResult) && IsTargetDescendant(parseResult)) + { + TargetOutput.RejectCommandLine(message); + } + else + { + Console.Error.WriteLine(message); + } + + return 1; + } + } + + // Skip first-run notice for machine-readable output modes and completions + var didShowFirstRunNotice = false; + if (!isCliSchemaMode && !isCompleteMode && !json) + { + var firstRunService = serviceProvider.GetRequiredService(); + didShowFirstRunNotice = firstRunService.CheckAndDisplayFirstRunNotice(); + + // Check for CLI updates — shows cached notice instantly (no network), + // and starts a background refresh if the cache is stale (fire-and-forget). + if (!quiet) + { + var updateNotificationService = serviceProvider.GetRequiredService(); + updateNotificationService.CheckAndNotify(); + } + } + + // If no arguments provided, display banner and show help + if (args.Length == 0) + { + if (!didShowFirstRunNotice) + { + BannerHelper.DisplayBanner(); + } + + // Show help by invoking with --help + await rootCommand.Parse(["--help"], WinAppParserConfiguration.Default).InvokeAsync(); + return 0; + } + + var parsedArgs = parseResult!; + + // Derive the effective JSON mode from the SELECTED command's PARSED --json value (M1). + // The pre-scan (json) is value-aware but still a heuristic; the parsed value is the truth. + // Reading the parsed bool works even when a different option (e.g. --pressure) failed parse. + bool effectiveJson = ResolveEffectiveJson(parsedArgs); + + // Catch single-dash typos like "-app" before invocation so the user gets a clear + // "Did you mean --app?" message instead of System.CommandLine's confusing + // "Unrecognized command or argument" pointing at the wrong token (issue #467). + // Only run when parsing already failed — otherwise a command that legitimately + // accepts a "-foo"-shaped positional value would get a false-positive typo error. + if (parsedArgs.Errors.Count > 0) + { + var typo = OptionTypoValidator.FindLikelyLongOptionTypo(args, parsedArgs); + if (typo is not null) + { + var suggested = "-" + typo; + var typoMessage = $"Unknown option '{typo}'. Did you mean '{suggested}'?"; + if (IsNewCommandJson(parsedArgs)) + { + // Route `new --json` typo failures through its structured envelope with paired + // telemetry, mirroring the ui branch below and the main parse-error bridge. + if (!isCompleteMode) + { + CommandInvokedEvent.Log(parsedArgs.CommandResult); + } + NewCommand.EmitParseErrorJson(typoMessage); + if (!isCompleteMode) + { + CommandCompletedEvent.Log(parsedArgs.CommandResult, NewCommand.ExitInvalidArgs); + } + return NewCommand.ExitInvalidArgs; + } + if (effectiveJson && IsUiDescendant(parsedArgs)) + { + // Gate on IsUiDescendant so that non-ui commands (e.g. cert info) fall through + // to default error handling instead of receiving the nested UI schema (M2). + if (!isCompleteMode) + { + CommandInvokedEvent.Log(parsedArgs.CommandResult); + } + UiJsonError.Emit(true, UiJsonError.CodeInvalidArguments, typoMessage); + if (!isCompleteMode) + { + CommandCompletedEvent.Log(parsedArgs.CommandResult, 1); + } + } + else if (effectiveJson && IsFlatJsonErrorCommand(parsedArgs)) + { + // find-ui/find-api emit all their JSON (results and errors) on stdout as a flat + // {"error":"..."} object — keep parser-level failures on that same contract. + if (!isCompleteMode) + { + CommandInvokedEvent.Log(parsedArgs.CommandResult); + } + EmitFlatJsonError($"Unknown option '{typo}'. Did you mean '{suggested}'?"); + if (!isCompleteMode) + { + CommandCompletedEvent.Log(parsedArgs.CommandResult, 1); + } + } + else if (effectiveJson && IsTargetDescendant(parsedArgs)) + { + if (!isCompleteMode) + { + CommandInvokedEvent.Log(parsedArgs.CommandResult); + } + TargetOutput.RejectCommandLine(typoMessage); + if (!isCompleteMode) + { + CommandCompletedEvent.Log(parsedArgs.CommandResult, 1); + } + } + else + { + Console.Error.WriteLine(typoMessage); + Console.Error.WriteLine( + "(Single-dash flags are reserved for short aliases like '-a'. Long options use a double dash.)"); + } + return 1; + } + } + + return await RunWithTelemetryAsync(parsedArgs, isCompleteMode, () => + { + // Target selection is settled before anything else, and settled for every command. + // A command that cannot honour --on says so, and a selector that names nothing usable + // fails here — never silently on this desktop, which is the one outcome the option + // exists to prevent. + if (ExecutionTargetSelection.Validate(parsedArgs) is { } selectionError) + { + return Task.FromResult(TargetOutput.RejectSelection( + serviceProvider.GetRequiredService(), effectiveJson, selectionError)); + } + + // System.CommandLine binds an unrecognised option to a nearby optional positional rather + // than failing, so `--onn=sandbox` would become a UI element selector and the command + // would run here and report success. Rejected before dispatch for every command except + // `run`, whose own handler already reports the same mistake with the passthrough advice + // its `--` separator needs and in its own --json shape. + if (parsedArgs.CommandResult.Command is not RunCommand && + WindowsCommandLine.FindOptionLikePositionals(parsedArgs) is { Count: > 0 } stray) + { + return Task.FromResult(RejectOptionLikePositionals(parsedArgs, stray, effectiveJson)); + } + + // One pre-dispatch interception, before any local UI service runs. A command the user + // sent to another target must not perform UI Automation, window discovery, capture, or + // input injection on this desktop, and the only way to guarantee that for every verb is + // to divert before the handler exists. + // + // Only for a command line that actually parsed. Routing a broken one first means + // `winapp ui inspect --on sandbox --depth notanumber` boots a Sandbox, spends minutes + // preparing it, and then reports a transport failure that never mentions `--depth` — + // and leaves the Sandbox running. The ordinary parse-error path is both faster and + // truthful, and it runs on this machine only because it runs nothing at all. + if (parsedArgs.Errors.Count == 0 && ExecutionTargetUiRouter.ShouldRoute(parsedArgs)) + { + var router = serviceProvider.GetRequiredService(); + return router.RouteAsync( + args, + TargetUiRequirements.For(parsedArgs), + effectiveJson, + CancellationToken.None); + } + + // Parse errors must exit non-zero (issue #1015). InvokeAsync displays + // the error and help text, but return 1 explicitly so scripts and + // agents don't treat a rejected command line as success. + var invokeExitCode = await parsedArgs.InvokeAsync(); + if (parsedArgs.Errors.Count > 0 && invokeExitCode == 0) + { + return 1; + } + return invokeExitCode; + }); + } + + /// + /// Reports positional values that were really misspelt options, and returns the exit code. + /// + /// + /// Rendered in whichever error shape the invoked command promises, so a --json caller + /// still gets a parseable document rather than a bare line on stderr. + /// + private static int RejectOptionLikePositionals( + System.CommandLine.ParseResult parsedArgs, + IReadOnlyList stray, + bool effectiveJson) + { + var message = stray.Count == 1 + ? $"Unrecognized option '{stray[0]}'." + : $"Unrecognized options: {string.Join(", ", stray.Select(token => $"'{token}'"))}."; + + var advice = "Check the spelling, or put the value after '--' if it really is an argument."; + + if (effectiveJson && IsUiDescendant(parsedArgs)) + { + UiJsonError.Emit(true, UiJsonError.CodeInvalidArguments, $"{message} {advice}"); + } + else if (effectiveJson && IsFlatJsonErrorCommand(parsedArgs)) + { + EmitFlatJsonError($"{message} {advice}"); + } + else if (effectiveJson && IsTargetDescendant(parsedArgs)) + { + TargetOutput.RejectCommandLine($"{message} {advice}"); + } + else + { + Console.Error.WriteLine(message); + Console.Error.WriteLine(advice); + } + + // The same code every other malformed winapp command line returns. This is a parse mistake, + // not a failure to reach a target, and conflating the two would make an unknown option look + // like an unavailable machine. + return TargetOutput.InvalidCommandLineExitCode; + } + + /// + /// Applies the given console-encoding mutation, swallowing the platform exception that occurs + /// when the standard stream encoding cannot be changed (e.g. redirected or unsupported handles). + /// UTF-8 I/O is a best-effort nicety, so a failure here must never abort startup. + /// + internal static void ConfigureConsoleEncoding(Action apply) + { + try + { + apply(); + } + catch + { + // ignore + } + } + + /// + /// Runs the parsed command via , emitting command-invoked/-completed + /// telemetry (unless in completion mode) and converting any unhandled exception into a logged + /// error plus exit code 1. The seam lets tests exercise both the + /// success and top-level-failure paths without needing a real, throwing command. + /// + internal static Task RunWithTelemetryAsync( + System.CommandLine.ParseResult parsedArgs, bool isCompleteMode, Func> invoke) => + RunWithTelemetryAsync(parsedArgs, isCompleteMode, invoke, CommandInvokedEvent.Log, CommandCompletedEvent.Log); + + /// + /// Core of + /// with the telemetry sinks injected. and + /// default (via the public overload) to the production + /// / events; the seam + /// lets tests assert that the invoked/completed events fire around the invocation — in order, with + /// the parsed command result and real exit code, and only when not in completion mode. + /// + internal static async Task RunWithTelemetryAsync( + System.CommandLine.ParseResult parsedArgs, bool isCompleteMode, Func> invoke, + Action logCommandInvoked, + Action logCommandCompleted) + { + try + { + // Start a correlation scope so the CommandInvoked/CommandCompleted events and any + // command-specific event a handler emits (e.g. NewCommand_Event) share one + // relatedActivityId for this invocation. + TelemetryCorrelation.Begin(); + + // Open the coordination scope in the same async flow that reads it below. A `winapp ui` + // command sets its cooperative-turn summary from deep inside invoke(), and + // CommandCompletedEvent picks it up here (issue #764). + Services.InteractiveDesktop.UiCoordinationTelemetryScope.Begin(); + + if (!isCompleteMode) + { + logCommandInvoked(parsedArgs.CommandResult); + } + + bool effectiveJson = ResolveEffectiveJson(parsedArgs); + + // Parse-error → JSON bridge: activated only when the SELECTED command exposes --json, + // its parsed value is true (effectiveJson), AND the command is a ui descendant (M3). + // Non-ui commands (e.g. cert info) use a flat {"error":"..."} schema — do not impose + // the UI nested contract on them; let SCL's default parse-error handling run instead. + // M3: emit CommandCompletedEvent before the early return to keep telemetry paired. + if (effectiveJson && parsedArgs.Errors.Count > 0 && IsUiDescendant(parsedArgs)) + { + var errorMsg = string.Join("; ", parsedArgs.Errors.Select(e => e.Message)); + UiJsonError.Emit(true, UiJsonError.CodeInvalidArguments, errorMsg); + if (!isCompleteMode) + { + logCommandCompleted(parsedArgs.CommandResult, 1); + } + return 1; + } + + // Parse-error → JSON bridge for `winapp new`: it owns a bespoke NewCommandResult schema + // (not the ui error envelope), so a parse failure like `--template bogus --json` must be + // reported in that shape rather than falling through to SCL's help/error text on stdout. + // Return ExitInvalidArgs (2) — the same code the handler uses for invalid names/versions — + // so agents can branch on the invalid-argument stage regardless of where it was caught. + if (effectiveJson && parsedArgs.Errors.Count > 0 + && parsedArgs.CommandResult.Command.Name == "new") + { + var errorMsg = string.Join("; ", parsedArgs.Errors.Select(e => e.Message)); + NewCommand.EmitParseErrorJson(errorMsg); + if (!isCompleteMode) + { + logCommandCompleted(parsedArgs.CommandResult, NewCommand.ExitInvalidArgs); + } + return NewCommand.ExitInvalidArgs; + } + + // Discovery parse-error → JSON bridge. find-ui and find-api both document a --json + // contract (results and in-handler validation errors all emit JSON on stdout), but a + // type/parser error such as `--max abc` fails before the handler runs, so + // System.CommandLine would otherwise print human help text. Emit the same flat + // {"error":"..."} object here so every --json path stays machine-readable (#719). + if (effectiveJson && parsedArgs.Errors.Count > 0 && IsFlatJsonErrorCommand(parsedArgs)) + { + var errorMsg = string.Join("; ", parsedArgs.Errors.Select(e => e.Message)); + EmitFlatJsonError(errorMsg); + if (!isCompleteMode) + { + logCommandCompleted(parsedArgs.CommandResult, 1); + } + return 1; + } + + // Parse-error → JSON bridge for the `target` verbs, which own their own error envelope + // ({"error":{"code":...}} on stderr). Same reasoning as the ui bridge: a caller that + // passed --json must never have to read human help text off stdout to find out that its + // command line was wrong. + if (effectiveJson && parsedArgs.Errors.Count > 0 && IsTargetDescendant(parsedArgs)) + { + var errorMsg = string.Join("; ", parsedArgs.Errors.Select(e => e.Message)); + var exitCode = TargetOutput.RejectCommandLine(errorMsg); + if (!isCompleteMode) + { + logCommandCompleted(parsedArgs.CommandResult, exitCode); + } + return exitCode; + } + + var returnCode = await invoke(); + + if (!isCompleteMode) + { + logCommandCompleted(parsedArgs.CommandResult, returnCode); + } + + return returnCode; + } + catch (Exception ex) + { + TelemetryFactory.Get().LogException(parsedArgs.CommandResult.Command.Name, ex); + Console.Error.WriteLine($"An unexpected error occurred: {ex.Message}"); + return 1; + } + } + + /// + /// Derives the effective JSON mode from the selected command's PARSED --json option. + /// Reading the parsed value is reliable even when a different option caused a parse error, + /// because System.CommandLine parses each option independently (M1). + /// + private static bool ResolveEffectiveJson(System.CommandLine.ParseResult parsedArgs) + { + // Only engage when the selected (innermost) command actually owns --json. + var selectedCmd = parsedArgs.CommandResult.Command; + if (!selectedCmd.Options.Contains(WinAppRootCommand.JsonOption)) + { + return false; + } + try + { + return parsedArgs.GetValue(WinAppRootCommand.JsonOption); + } + catch + { + // If reading the parsed value fails for any reason, do not fire the bridge. + return false; + } + } + + /// + /// Returns when the selected command is new with --json in + /// effect. Callers use this to route an early parse failure (invalid boolean value, single-dash + /// typo) through and + /// return , so new --json yields a machine-readable + /// NewCommandResult — with the same invalid-argument exit code the handler uses — for + /// EVERY parse error, not only those reaching the main bridge in . + /// + private static bool IsNewCommandJson(System.CommandLine.ParseResult parseResult) => + ResolveEffectiveJson(parseResult) && parseResult.CommandResult.Command.Name == "new"; + + /// + /// Returns when the selected command is the ui command + /// or any of its descendants. Used to scope the parse-error JSON bridge to ui commands + /// only, leaving non-ui commands (e.g. cert info) with their own flat error schema (M3). + /// + private static bool IsUiDescendant(System.CommandLine.ParseResult parseResult) + { + var cmd = parseResult.CommandResult.Command; + while (cmd is not null) + { + if (cmd.Name == "ui") + { + return true; + } + cmd = cmd.Parents.OfType().FirstOrDefault(); + } + return false; + } + + /// + /// Returns when the selected command is one of the discovery commands + /// that document a flat {"error":"..."} JSON contract — find-ui, or find-api + /// and any of its verbs. Used to route parser-level failures to that contract (#719) instead of + /// System.CommandLine's human help text, mirroring the ui bridge above. + /// + private static bool IsFlatJsonErrorCommand(System.CommandLine.ParseResult parseResult) + { + var cmd = parseResult.CommandResult.Command; + while (cmd is not null) + { + if (cmd.Name is "find-ui" or "find-api") + { + return true; + } + cmd = cmd.Parents.OfType().FirstOrDefault(); + } + return false; + } + + /// + /// Returns when the selected command is target or one of its + /// descendants, so a parse failure is reported in the target error envelope rather than as + /// help text. + /// + private static bool IsTargetDescendant(System.CommandLine.ParseResult parseResult) + { + var cmd = parseResult.CommandResult.Command; + while (cmd is not null) + { + if (cmd.Name == "target") + { + return true; + } + cmd = cmd.Parents.OfType().FirstOrDefault(); + } + return false; + } + + /// + /// Writes a flat {"error":"..."} object to stdout — the same schema and sink + /// find-ui and find-api use for in-handler errors — so a parse failure under + /// --json stays machine-readable. stdout (not stderr) matches where those commands emit + /// all their other JSON. + /// + private static void EmitFlatJsonError(string message) + { + var payload = System.Text.Json.JsonSerializer.Serialize( + new JsonErrorOutput { Error = message }, + WinAppJsonContext.Default.JsonErrorOutput); + Console.Out.WriteLine(payload); + } + + /// + /// Scans argv for an invalid =-attached value on any boolean option reachable by the + /// selected command — the command's own boolean + /// options plus inherited/global ones on ancestor commands (e.g. --json/--verbose/ + /// --quiet on the root, or --eraser on ui pen). System.CommandLine silently + /// coerces a non-boolean attached value (e.g. --eraser=bogus) to ; + /// the caller uses this to reject it with a clean error instead (#600 H1/M1/M2). + /// + private static bool TryFindInvalidBooleanOption( + System.CommandLine.ParseResult parseResult, + string[] args, + out string optionName, + out string invalidValue) + { + optionName = string.Empty; + invalidValue = string.Empty; + + // Walk the selected command up through its ancestors so both command-level bool options + // and inherited/global ones are covered. Dedupe by name in case an option appears twice. + var seen = new HashSet(StringComparer.Ordinal); + var cmd = parseResult.CommandResult.Command; + while (cmd is not null) + { + foreach (var option in cmd.Options) + { + if (option is not System.CommandLine.Option) + { + continue; + } + if (!seen.Add(option.Name)) + { + continue; + } + if (GlobalOptionPreScan.TryFindInvalidBooleanValue(args, option.Name, option.Aliases, out var badValue)) + { + optionName = option.Name; + invalidValue = badValue; + return true; + } + } + + cmd = cmd.Parents.OfType().FirstOrDefault(); + } + + return false; + } + + /// + /// The global logging mode resolved from argv: the minimum log level plus the individual + /// --quiet/--verbose/--json flags, and a non-null + /// message when a mutually-exclusive combination was requested. + /// + internal readonly record struct LoggingMode(LogLevel MinimumLevel, bool Quiet, bool Verbose, bool Json, string? ConflictError); + + /// + /// Pre-scans argv for the global logging-mode flags and resolves the effective + /// . The scan (via ) stops at the first + /// standalone -- separator so passthrough payload (e.g. winapp run . -- --json) is + /// not misread as a winapp global flag. Extracted from so the flag precedence + /// and mutual-exclusion rules are unit testable without invoking the full entrypoint. + /// + internal static LoggingMode ResolveLoggingMode(string[] args) + { + var minimumLogLevel = LogLevel.Information; + bool quiet = false; + bool verbose = false; + bool json = false; + + verbose = GlobalOptionPreScan.GetBooleanFlagValue(args, WinAppRootCommand.VerboseOption.Name, WinAppRootCommand.VerboseOption.Aliases); + if (verbose) + { + minimumLogLevel = LogLevel.Debug; + } + quiet = GlobalOptionPreScan.GetBooleanFlagValue(args, WinAppRootCommand.QuietOption.Name, WinAppRootCommand.QuietOption.Aliases); + if (quiet) + { + minimumLogLevel = LogLevel.Warning; + } + json = GlobalOptionPreScan.GetBooleanFlagValue(args, WinAppRootCommand.JsonOption.Name, WinAppRootCommand.JsonOption.Aliases); + if (json) + { + minimumLogLevel = LogLevel.None; + } + + string? conflictError = null; + if (quiet && verbose) + { + conflictError = "Cannot specify both --quiet and --verbose options together."; + } + else if (quiet && json) + { + conflictError = "Cannot specify both --quiet and --json options together."; + } + else if (verbose && json) + { + conflictError = "Cannot specify both --verbose and --json options together."; + } + + return new LoggingMode(minimumLogLevel, quiet, verbose, json, conflictError); + } +} From 9bdba15691a8858a1fb7bd865df799e4cb04fe88 Mon Sep 17 00:00:00 2001 From: Akhil Gupta Date: Wed, 7 Oct 2026 13:57:36 -0700 Subject: [PATCH 02/21] Delete src/winapp-CLI/WinApp.Cli/Program.cs --- src/winapp-CLI/WinApp.Cli/Program.cs | 678 --------------------------- 1 file changed, 678 deletions(-) delete mode 100644 src/winapp-CLI/WinApp.Cli/Program.cs diff --git a/src/winapp-CLI/WinApp.Cli/Program.cs b/src/winapp-CLI/WinApp.Cli/Program.cs deleted file mode 100644 index 9cbffb8e1..000000000 --- a/src/winapp-CLI/WinApp.Cli/Program.cs +++ /dev/null @@ -1,678 +0,0 @@ -// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. -// Licensed under the MIT License. - -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Spectre.Console; -using System.Text; -using WinApp.Cli.Commands; -using WinApp.Cli.Helpers; -using WinApp.Cli.Models; -using WinApp.Cli.Services; -using WinApp.Cli.Telemetry; -using WinApp.Cli.Telemetry.Events; -using LogLevel = Microsoft.Extensions.Logging.LogLevel; - -namespace WinApp.Cli; - -internal static class Program -{ - internal static async Task Main(string[] args) - { - // Hidden internal verb: the WinUI DbgEng triage pass runs in this isolated child process so - // its modern dbgeng.dll is not poisoned by the system32 dbghelp.dll the parent already loaded. - // Intercept before any host/service setup to keep the loader state clean and output noise-free. - if (args.Length > 0 && args[0] == Services.XamlTriageRunner.InternalVerb) - { - return Services.XamlTriageRunner.Run(args); - } - - // Ensure UTF-8 I/O for emoji-capable terminals; fall back silently if not supported - ConfigureConsoleEncoding(static () => - { - Console.OutputEncoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); - Console.InputEncoding = Encoding.UTF8; - }); - - var loggingMode = ResolveLoggingMode(args); - if (loggingMode.ConflictError is not null) - { - Console.Error.WriteLine(loggingMode.ConflictError); - return 1; - } - - var minimumLogLevel = loggingMode.MinimumLevel; - bool quiet = loggingMode.Quiet; - bool json = loggingMode.Json; - - // Check if --cli-schema is specified - this outputs machine-readable JSON - // and should not display any interactive messages like first-run notices - bool isCliSchemaMode = GlobalOptionPreScan.IsFlagPresent( - args, WinAppRootCommand.CliSchemaOption.Name, []); - - // Check if this is a completion request - completions must be fast and silent - bool isCompleteMode = args.Length > 0 && args[0] == "complete"; - - var services = new ServiceCollection() - .ConfigureServices() - .ConfigureCommands() - .AddLogging(b => - { - b.ClearProviders(); - b.AddTextWriterLogger(Console.Out, Console.Error); - b.SetMinimumLevel(minimumLogLevel); - }); - - using var serviceProvider = services.BuildServiceProvider(); - - var rootCommand = serviceProvider.GetRequiredService(); - System.CommandLine.ParseResult? parseResult = null; - - if (args.Length > 0) - { - parseResult = rootCommand.Parse(args, WinAppParserConfiguration.Default); - - // Infrastructure processes are not separate user invocations. Set this before any - // telemetry instance is created; descendants inherit the winapp-only opt-out. - if (parseResult.CommandResult.Command is GuestAgentCommand) - { - Environment.SetEnvironmentVariable(Telemetry.Telemetry.OptOutEnvironmentVariable, "1"); - } - - // Set WINAPP_CLI_CALLER env var from --caller option so telemetry and update checks can use it - var caller = parseResult.GetValue(WinAppRootCommand.CallerOption); - if (!string.IsNullOrWhiteSpace(caller)) - { - Environment.SetEnvironmentVariable("WINAPP_CLI_CALLER", caller); - } - - // Reject an invalid '='-attached value on ANY boolean option reachable by the selected - // command — a global flag like --json=bogus or a command flag like --eraser=bogus. - // System.CommandLine silently coerces such a value to TRUE with no parse error, which - // would (a) silently enable a flag the user never set (e.g. the pen eraser) and (b) for - // --json leave logging un-suppressed, so the human ❌ line and the JSON envelope BOTH hit - // stderr and corrupt machine-readable output. Fail fast HERE — before the first-run / - // update notice can print anything (M1) — with a single clean invalid_arguments error, - // mirroring how the parser already rejects other malformed values (e.g. --pressure nope). - if (TryFindInvalidBooleanOption(parseResult, args, out var invalidBoolOption, out var invalidBoolValue)) - { - var message = - $"Cannot parse argument '{invalidBoolValue}' for option '{invalidBoolOption}' as expected type 'System.Boolean'."; - if (IsNewCommandJson(parseResult)) - { - // `new` owns a bespoke NewCommandResult envelope; emit it (and use its - // invalid-argument exit code) so machine callers get structured output here too. - NewCommand.EmitParseErrorJson(message); - return NewCommand.ExitInvalidArgs; - } - if (ResolveEffectiveJson(parseResult) && IsUiDescendant(parseResult)) - { - UiJsonError.Emit(true, UiJsonError.CodeInvalidArguments, message); - } - else if (ResolveEffectiveJson(parseResult) && IsFlatJsonErrorCommand(parseResult)) - { - EmitFlatJsonError(message); - } - else if (ResolveEffectiveJson(parseResult) && IsTargetDescendant(parseResult)) - { - TargetOutput.RejectCommandLine(message); - } - else - { - Console.Error.WriteLine(message); - } - - return 1; - } - } - - // Skip first-run notice for machine-readable output modes and completions - var didShowFirstRunNotice = false; - if (!isCliSchemaMode && !isCompleteMode && !json) - { - var firstRunService = serviceProvider.GetRequiredService(); - didShowFirstRunNotice = firstRunService.CheckAndDisplayFirstRunNotice(); - - // Check for CLI updates — shows cached notice instantly (no network), - // and starts a background refresh if the cache is stale (fire-and-forget). - if (!quiet) - { - var updateNotificationService = serviceProvider.GetRequiredService(); - updateNotificationService.CheckAndNotify(); - } - } - - // If no arguments provided, display banner and show help - if (args.Length == 0) - { - if (!didShowFirstRunNotice) - { - BannerHelper.DisplayBanner(); - } - - // Show help by invoking with --help - await rootCommand.Parse(["--help"], WinAppParserConfiguration.Default).InvokeAsync(); - return 0; - } - - var parsedArgs = parseResult!; - - // Derive the effective JSON mode from the SELECTED command's PARSED --json value (M1). - // The pre-scan (json) is value-aware but still a heuristic; the parsed value is the truth. - // Reading the parsed bool works even when a different option (e.g. --pressure) failed parse. - bool effectiveJson = ResolveEffectiveJson(parsedArgs); - - // Catch single-dash typos like "-app" before invocation so the user gets a clear - // "Did you mean --app?" message instead of System.CommandLine's confusing - // "Unrecognized command or argument" pointing at the wrong token (issue #467). - // Only run when parsing already failed — otherwise a command that legitimately - // accepts a "-foo"-shaped positional value would get a false-positive typo error. - if (parsedArgs.Errors.Count > 0) - { - var typo = OptionTypoValidator.FindLikelyLongOptionTypo(args, parsedArgs); - if (typo is not null) - { - var suggested = "-" + typo; - var typoMessage = $"Unknown option '{typo}'. Did you mean '{suggested}'?"; - if (IsNewCommandJson(parsedArgs)) - { - // Route `new --json` typo failures through its structured envelope with paired - // telemetry, mirroring the ui branch below and the main parse-error bridge. - if (!isCompleteMode) - { - CommandInvokedEvent.Log(parsedArgs.CommandResult); - } - NewCommand.EmitParseErrorJson(typoMessage); - if (!isCompleteMode) - { - CommandCompletedEvent.Log(parsedArgs.CommandResult, NewCommand.ExitInvalidArgs); - } - return NewCommand.ExitInvalidArgs; - } - if (effectiveJson && IsUiDescendant(parsedArgs)) - { - // Gate on IsUiDescendant so that non-ui commands (e.g. cert info) fall through - // to default error handling instead of receiving the nested UI schema (M2). - if (!isCompleteMode) - { - CommandInvokedEvent.Log(parsedArgs.CommandResult); - } - UiJsonError.Emit(true, UiJsonError.CodeInvalidArguments, typoMessage); - if (!isCompleteMode) - { - CommandCompletedEvent.Log(parsedArgs.CommandResult, 1); - } - } - else if (effectiveJson && IsFlatJsonErrorCommand(parsedArgs)) - { - // find-ui/find-api emit all their JSON (results and errors) on stdout as a flat - // {"error":"..."} object — keep parser-level failures on that same contract. - if (!isCompleteMode) - { - CommandInvokedEvent.Log(parsedArgs.CommandResult); - } - EmitFlatJsonError($"Unknown option '{typo}'. Did you mean '{suggested}'?"); - if (!isCompleteMode) - { - CommandCompletedEvent.Log(parsedArgs.CommandResult, 1); - } - } - else if (effectiveJson && IsTargetDescendant(parsedArgs)) - { - if (!isCompleteMode) - { - CommandInvokedEvent.Log(parsedArgs.CommandResult); - } - TargetOutput.RejectCommandLine(typoMessage); - if (!isCompleteMode) - { - CommandCompletedEvent.Log(parsedArgs.CommandResult, 1); - } - } - else - { - Console.Error.WriteLine(typoMessage); - Console.Error.WriteLine( - "(Single-dash flags are reserved for short aliases like '-a'. Long options use a double dash.)"); - } - return 1; - } - } - - return await RunWithTelemetryAsync(parsedArgs, isCompleteMode, () => - { - // Target selection is settled before anything else, and settled for every command. - // A command that cannot honour --on says so, and a selector that names nothing usable - // fails here — never silently on this desktop, which is the one outcome the option - // exists to prevent. - if (ExecutionTargetSelection.Validate(parsedArgs) is { } selectionError) - { - return Task.FromResult(TargetOutput.RejectSelection( - serviceProvider.GetRequiredService(), effectiveJson, selectionError)); - } - - // System.CommandLine binds an unrecognised option to a nearby optional positional rather - // than failing, so `--onn=sandbox` would become a UI element selector and the command - // would run here and report success. Rejected before dispatch for every command except - // `run`, whose own handler already reports the same mistake with the passthrough advice - // its `--` separator needs and in its own --json shape. - if (parsedArgs.CommandResult.Command is not RunCommand && - WindowsCommandLine.FindOptionLikePositionals(parsedArgs) is { Count: > 0 } stray) - { - return Task.FromResult(RejectOptionLikePositionals(parsedArgs, stray, effectiveJson)); - } - - // One pre-dispatch interception, before any local UI service runs. A command the user - // sent to another target must not perform UI Automation, window discovery, capture, or - // input injection on this desktop, and the only way to guarantee that for every verb is - // to divert before the handler exists. - // - // Only for a command line that actually parsed. Routing a broken one first means - // `winapp ui inspect --on sandbox --depth notanumber` boots a Sandbox, spends minutes - // preparing it, and then reports a transport failure that never mentions `--depth` — - // and leaves the Sandbox running. The ordinary parse-error path is both faster and - // truthful, and it runs on this machine only because it runs nothing at all. - if (parsedArgs.Errors.Count == 0 && ExecutionTargetUiRouter.ShouldRoute(parsedArgs)) - { - var router = serviceProvider.GetRequiredService(); - return router.RouteAsync( - args, - TargetUiRequirements.For(parsedArgs), - effectiveJson, - CancellationToken.None); - } - - return parsedArgs.InvokeAsync(); - }); - } - - /// - /// Reports positional values that were really misspelt options, and returns the exit code. - /// - /// - /// Rendered in whichever error shape the invoked command promises, so a --json caller - /// still gets a parseable document rather than a bare line on stderr. - /// - private static int RejectOptionLikePositionals( - System.CommandLine.ParseResult parsedArgs, - IReadOnlyList stray, - bool effectiveJson) - { - var message = stray.Count == 1 - ? $"Unrecognized option '{stray[0]}'." - : $"Unrecognized options: {string.Join(", ", stray.Select(token => $"'{token}'"))}."; - - var advice = "Check the spelling, or put the value after '--' if it really is an argument."; - - if (effectiveJson && IsUiDescendant(parsedArgs)) - { - UiJsonError.Emit(true, UiJsonError.CodeInvalidArguments, $"{message} {advice}"); - } - else if (effectiveJson && IsFlatJsonErrorCommand(parsedArgs)) - { - EmitFlatJsonError($"{message} {advice}"); - } - else if (effectiveJson && IsTargetDescendant(parsedArgs)) - { - TargetOutput.RejectCommandLine($"{message} {advice}"); - } - else - { - Console.Error.WriteLine(message); - Console.Error.WriteLine(advice); - } - - // The same code every other malformed winapp command line returns. This is a parse mistake, - // not a failure to reach a target, and conflating the two would make an unknown option look - // like an unavailable machine. - return TargetOutput.InvalidCommandLineExitCode; - } - - /// - /// Applies the given console-encoding mutation, swallowing the platform exception that occurs - /// when the standard stream encoding cannot be changed (e.g. redirected or unsupported handles). - /// UTF-8 I/O is a best-effort nicety, so a failure here must never abort startup. - /// - internal static void ConfigureConsoleEncoding(Action apply) - { - try - { - apply(); - } - catch - { - // ignore - } - } - - /// - /// Runs the parsed command via , emitting command-invoked/-completed - /// telemetry (unless in completion mode) and converting any unhandled exception into a logged - /// error plus exit code 1. The seam lets tests exercise both the - /// success and top-level-failure paths without needing a real, throwing command. - /// - internal static Task RunWithTelemetryAsync( - System.CommandLine.ParseResult parsedArgs, bool isCompleteMode, Func> invoke) => - RunWithTelemetryAsync(parsedArgs, isCompleteMode, invoke, CommandInvokedEvent.Log, CommandCompletedEvent.Log); - - /// - /// Core of - /// with the telemetry sinks injected. and - /// default (via the public overload) to the production - /// / events; the seam - /// lets tests assert that the invoked/completed events fire around the invocation — in order, with - /// the parsed command result and real exit code, and only when not in completion mode. - /// - internal static async Task RunWithTelemetryAsync( - System.CommandLine.ParseResult parsedArgs, bool isCompleteMode, Func> invoke, - Action logCommandInvoked, - Action logCommandCompleted) - { - try - { - // Start a correlation scope so the CommandInvoked/CommandCompleted events and any - // command-specific event a handler emits (e.g. NewCommand_Event) share one - // relatedActivityId for this invocation. - TelemetryCorrelation.Begin(); - - // Open the coordination scope in the same async flow that reads it below. A `winapp ui` - // command sets its cooperative-turn summary from deep inside invoke(), and - // CommandCompletedEvent picks it up here (issue #764). - Services.InteractiveDesktop.UiCoordinationTelemetryScope.Begin(); - - if (!isCompleteMode) - { - logCommandInvoked(parsedArgs.CommandResult); - } - - bool effectiveJson = ResolveEffectiveJson(parsedArgs); - - // Parse-error → JSON bridge: activated only when the SELECTED command exposes --json, - // its parsed value is true (effectiveJson), AND the command is a ui descendant (M3). - // Non-ui commands (e.g. cert info) use a flat {"error":"..."} schema — do not impose - // the UI nested contract on them; let SCL's default parse-error handling run instead. - // M3: emit CommandCompletedEvent before the early return to keep telemetry paired. - if (effectiveJson && parsedArgs.Errors.Count > 0 && IsUiDescendant(parsedArgs)) - { - var errorMsg = string.Join("; ", parsedArgs.Errors.Select(e => e.Message)); - UiJsonError.Emit(true, UiJsonError.CodeInvalidArguments, errorMsg); - if (!isCompleteMode) - { - logCommandCompleted(parsedArgs.CommandResult, 1); - } - return 1; - } - - // Parse-error → JSON bridge for `winapp new`: it owns a bespoke NewCommandResult schema - // (not the ui error envelope), so a parse failure like `--template bogus --json` must be - // reported in that shape rather than falling through to SCL's help/error text on stdout. - // Return ExitInvalidArgs (2) — the same code the handler uses for invalid names/versions — - // so agents can branch on the invalid-argument stage regardless of where it was caught. - if (effectiveJson && parsedArgs.Errors.Count > 0 - && parsedArgs.CommandResult.Command.Name == "new") - { - var errorMsg = string.Join("; ", parsedArgs.Errors.Select(e => e.Message)); - NewCommand.EmitParseErrorJson(errorMsg); - if (!isCompleteMode) - { - logCommandCompleted(parsedArgs.CommandResult, NewCommand.ExitInvalidArgs); - } - return NewCommand.ExitInvalidArgs; - } - - // Discovery parse-error → JSON bridge. find-ui and find-api both document a --json - // contract (results and in-handler validation errors all emit JSON on stdout), but a - // type/parser error such as `--max abc` fails before the handler runs, so - // System.CommandLine would otherwise print human help text. Emit the same flat - // {"error":"..."} object here so every --json path stays machine-readable (#719). - if (effectiveJson && parsedArgs.Errors.Count > 0 && IsFlatJsonErrorCommand(parsedArgs)) - { - var errorMsg = string.Join("; ", parsedArgs.Errors.Select(e => e.Message)); - EmitFlatJsonError(errorMsg); - if (!isCompleteMode) - { - logCommandCompleted(parsedArgs.CommandResult, 1); - } - return 1; - } - - // Parse-error → JSON bridge for the `target` verbs, which own their own error envelope - // ({"error":{"code":...}} on stderr). Same reasoning as the ui bridge: a caller that - // passed --json must never have to read human help text off stdout to find out that its - // command line was wrong. - if (effectiveJson && parsedArgs.Errors.Count > 0 && IsTargetDescendant(parsedArgs)) - { - var errorMsg = string.Join("; ", parsedArgs.Errors.Select(e => e.Message)); - var exitCode = TargetOutput.RejectCommandLine(errorMsg); - if (!isCompleteMode) - { - logCommandCompleted(parsedArgs.CommandResult, exitCode); - } - return exitCode; - } - - var returnCode = await invoke(); - - if (!isCompleteMode) - { - logCommandCompleted(parsedArgs.CommandResult, returnCode); - } - - return returnCode; - } - catch (Exception ex) - { - TelemetryFactory.Get().LogException(parsedArgs.CommandResult.Command.Name, ex); - Console.Error.WriteLine($"An unexpected error occurred: {ex.Message}"); - return 1; - } - } - - /// - /// Derives the effective JSON mode from the selected command's PARSED --json option. - /// Reading the parsed value is reliable even when a different option caused a parse error, - /// because System.CommandLine parses each option independently (M1). - /// - private static bool ResolveEffectiveJson(System.CommandLine.ParseResult parsedArgs) - { - // Only engage when the selected (innermost) command actually owns --json. - var selectedCmd = parsedArgs.CommandResult.Command; - if (!selectedCmd.Options.Contains(WinAppRootCommand.JsonOption)) - { - return false; - } - try - { - return parsedArgs.GetValue(WinAppRootCommand.JsonOption); - } - catch - { - // If reading the parsed value fails for any reason, do not fire the bridge. - return false; - } - } - - /// - /// Returns when the selected command is new with --json in - /// effect. Callers use this to route an early parse failure (invalid boolean value, single-dash - /// typo) through and - /// return , so new --json yields a machine-readable - /// NewCommandResult — with the same invalid-argument exit code the handler uses — for - /// EVERY parse error, not only those reaching the main bridge in . - /// - private static bool IsNewCommandJson(System.CommandLine.ParseResult parseResult) => - ResolveEffectiveJson(parseResult) && parseResult.CommandResult.Command.Name == "new"; - - /// - /// Returns when the selected command is the ui command - /// or any of its descendants. Used to scope the parse-error JSON bridge to ui commands - /// only, leaving non-ui commands (e.g. cert info) with their own flat error schema (M3). - /// - private static bool IsUiDescendant(System.CommandLine.ParseResult parseResult) - { - var cmd = parseResult.CommandResult.Command; - while (cmd is not null) - { - if (cmd.Name == "ui") - { - return true; - } - cmd = cmd.Parents.OfType().FirstOrDefault(); - } - return false; - } - - /// - /// Returns when the selected command is one of the discovery commands - /// that document a flat {"error":"..."} JSON contract — find-ui, or find-api - /// and any of its verbs. Used to route parser-level failures to that contract (#719) instead of - /// System.CommandLine's human help text, mirroring the ui bridge above. - /// - private static bool IsFlatJsonErrorCommand(System.CommandLine.ParseResult parseResult) - { - var cmd = parseResult.CommandResult.Command; - while (cmd is not null) - { - if (cmd.Name is "find-ui" or "find-api") - { - return true; - } - cmd = cmd.Parents.OfType().FirstOrDefault(); - } - return false; - } - - /// - /// Returns when the selected command is target or one of its - /// descendants, so a parse failure is reported in the target error envelope rather than as - /// help text. - /// - private static bool IsTargetDescendant(System.CommandLine.ParseResult parseResult) - { - var cmd = parseResult.CommandResult.Command; - while (cmd is not null) - { - if (cmd.Name == "target") - { - return true; - } - cmd = cmd.Parents.OfType().FirstOrDefault(); - } - return false; - } - - /// - /// Writes a flat {"error":"..."} object to stdout — the same schema and sink - /// find-ui and find-api use for in-handler errors — so a parse failure under - /// --json stays machine-readable. stdout (not stderr) matches where those commands emit - /// all their other JSON. - /// - private static void EmitFlatJsonError(string message) - { - var payload = System.Text.Json.JsonSerializer.Serialize( - new JsonErrorOutput { Error = message }, - WinAppJsonContext.Default.JsonErrorOutput); - Console.Out.WriteLine(payload); - } - - /// - /// Scans argv for an invalid =-attached value on any boolean option reachable by the - /// selected command — the command's own boolean - /// options plus inherited/global ones on ancestor commands (e.g. --json/--verbose/ - /// --quiet on the root, or --eraser on ui pen). System.CommandLine silently - /// coerces a non-boolean attached value (e.g. --eraser=bogus) to ; - /// the caller uses this to reject it with a clean error instead (#600 H1/M1/M2). - /// - private static bool TryFindInvalidBooleanOption( - System.CommandLine.ParseResult parseResult, - string[] args, - out string optionName, - out string invalidValue) - { - optionName = string.Empty; - invalidValue = string.Empty; - - // Walk the selected command up through its ancestors so both command-level bool options - // and inherited/global ones are covered. Dedupe by name in case an option appears twice. - var seen = new HashSet(StringComparer.Ordinal); - var cmd = parseResult.CommandResult.Command; - while (cmd is not null) - { - foreach (var option in cmd.Options) - { - if (option is not System.CommandLine.Option) - { - continue; - } - if (!seen.Add(option.Name)) - { - continue; - } - if (GlobalOptionPreScan.TryFindInvalidBooleanValue(args, option.Name, option.Aliases, out var badValue)) - { - optionName = option.Name; - invalidValue = badValue; - return true; - } - } - - cmd = cmd.Parents.OfType().FirstOrDefault(); - } - - return false; - } - - /// - /// The global logging mode resolved from argv: the minimum log level plus the individual - /// --quiet/--verbose/--json flags, and a non-null - /// message when a mutually-exclusive combination was requested. - /// - internal readonly record struct LoggingMode(LogLevel MinimumLevel, bool Quiet, bool Verbose, bool Json, string? ConflictError); - - /// - /// Pre-scans argv for the global logging-mode flags and resolves the effective - /// . The scan (via ) stops at the first - /// standalone -- separator so passthrough payload (e.g. winapp run . -- --json) is - /// not misread as a winapp global flag. Extracted from so the flag precedence - /// and mutual-exclusion rules are unit testable without invoking the full entrypoint. - /// - internal static LoggingMode ResolveLoggingMode(string[] args) - { - var minimumLogLevel = LogLevel.Information; - bool quiet = false; - bool verbose = false; - bool json = false; - - verbose = GlobalOptionPreScan.GetBooleanFlagValue(args, WinAppRootCommand.VerboseOption.Name, WinAppRootCommand.VerboseOption.Aliases); - if (verbose) - { - minimumLogLevel = LogLevel.Debug; - } - quiet = GlobalOptionPreScan.GetBooleanFlagValue(args, WinAppRootCommand.QuietOption.Name, WinAppRootCommand.QuietOption.Aliases); - if (quiet) - { - minimumLogLevel = LogLevel.Warning; - } - json = GlobalOptionPreScan.GetBooleanFlagValue(args, WinAppRootCommand.JsonOption.Name, WinAppRootCommand.JsonOption.Aliases); - if (json) - { - minimumLogLevel = LogLevel.None; - } - - string? conflictError = null; - if (quiet && verbose) - { - conflictError = "Cannot specify both --quiet and --verbose options together."; - } - else if (quiet && json) - { - conflictError = "Cannot specify both --quiet and --json options together."; - } - else if (verbose && json) - { - conflictError = "Cannot specify both --verbose and --json options together."; - } - - return new LoggingMode(minimumLogLevel, quiet, verbose, json, conflictError); - } -} From f76e4811a16ad8a894831aea8980dbf7489687c3 Mon Sep 17 00:00:00 2001 From: Akhil Gupta Date: Wed, 7 Oct 2026 13:58:24 -0700 Subject: [PATCH 03/21] fix: return exit code 1 on parse errors --- src/winapp-CLI/WinApp.Cli/{winapp-Program.cs => Program.cs} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/winapp-CLI/WinApp.Cli/{winapp-Program.cs => Program.cs} (100%) diff --git a/src/winapp-CLI/WinApp.Cli/winapp-Program.cs b/src/winapp-CLI/WinApp.Cli/Program.cs similarity index 100% rename from src/winapp-CLI/WinApp.Cli/winapp-Program.cs rename to src/winapp-CLI/WinApp.Cli/Program.cs From d3b7f1c4aafea91b5dfac37648424712e988f644 Mon Sep 17 00:00:00 2001 From: Akhil Gupta Date: Wed, 7 Oct 2026 21:04:40 -0700 Subject: [PATCH 04/21] fix: make parse-error callback async (compilation fix) --- .../WinApp.Cli/winapp-Program-fix.cs | 686 ++++++++++++++++++ 1 file changed, 686 insertions(+) create mode 100644 src/winapp-CLI/WinApp.Cli/winapp-Program-fix.cs diff --git a/src/winapp-CLI/WinApp.Cli/winapp-Program-fix.cs b/src/winapp-CLI/WinApp.Cli/winapp-Program-fix.cs new file mode 100644 index 000000000..a0579dba9 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/winapp-Program-fix.cs @@ -0,0 +1,686 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Spectre.Console; +using System.Text; +using WinApp.Cli.Commands; +using WinApp.Cli.Helpers; +using WinApp.Cli.Models; +using WinApp.Cli.Services; +using WinApp.Cli.Telemetry; +using WinApp.Cli.Telemetry.Events; +using LogLevel = Microsoft.Extensions.Logging.LogLevel; + +namespace WinApp.Cli; + +internal static class Program +{ + internal static async Task Main(string[] args) + { + // Hidden internal verb: the WinUI DbgEng triage pass runs in this isolated child process so + // its modern dbgeng.dll is not poisoned by the system32 dbghelp.dll the parent already loaded. + // Intercept before any host/service setup to keep the loader state clean and output noise-free. + if (args.Length > 0 && args[0] == Services.XamlTriageRunner.InternalVerb) + { + return Services.XamlTriageRunner.Run(args); + } + + // Ensure UTF-8 I/O for emoji-capable terminals; fall back silently if not supported + ConfigureConsoleEncoding(static () => + { + Console.OutputEncoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); + Console.InputEncoding = Encoding.UTF8; + }); + + var loggingMode = ResolveLoggingMode(args); + if (loggingMode.ConflictError is not null) + { + Console.Error.WriteLine(loggingMode.ConflictError); + return 1; + } + + var minimumLogLevel = loggingMode.MinimumLevel; + bool quiet = loggingMode.Quiet; + bool json = loggingMode.Json; + + // Check if --cli-schema is specified - this outputs machine-readable JSON + // and should not display any interactive messages like first-run notices + bool isCliSchemaMode = GlobalOptionPreScan.IsFlagPresent( + args, WinAppRootCommand.CliSchemaOption.Name, []); + + // Check if this is a completion request - completions must be fast and silent + bool isCompleteMode = args.Length > 0 && args[0] == "complete"; + + var services = new ServiceCollection() + .ConfigureServices() + .ConfigureCommands() + .AddLogging(b => + { + b.ClearProviders(); + b.AddTextWriterLogger(Console.Out, Console.Error); + b.SetMinimumLevel(minimumLogLevel); + }); + + using var serviceProvider = services.BuildServiceProvider(); + + var rootCommand = serviceProvider.GetRequiredService(); + System.CommandLine.ParseResult? parseResult = null; + + if (args.Length > 0) + { + parseResult = rootCommand.Parse(args, WinAppParserConfiguration.Default); + + // Infrastructure processes are not separate user invocations. Set this before any + // telemetry instance is created; descendants inherit the winapp-only opt-out. + if (parseResult.CommandResult.Command is GuestAgentCommand) + { + Environment.SetEnvironmentVariable(Telemetry.Telemetry.OptOutEnvironmentVariable, "1"); + } + + // Set WINAPP_CLI_CALLER env var from --caller option so telemetry and update checks can use it + var caller = parseResult.GetValue(WinAppRootCommand.CallerOption); + if (!string.IsNullOrWhiteSpace(caller)) + { + Environment.SetEnvironmentVariable("WINAPP_CLI_CALLER", caller); + } + + // Reject an invalid '='-attached value on ANY boolean option reachable by the selected + // command — a global flag like --json=bogus or a command flag like --eraser=bogus. + // System.CommandLine silently coerces such a value to TRUE with no parse error, which + // would (a) silently enable a flag the user never set (e.g. the pen eraser) and (b) for + // --json leave logging un-suppressed, so the human ❌ line and the JSON envelope BOTH hit + // stderr and corrupt machine-readable output. Fail fast HERE — before the first-run / + // update notice can print anything (M1) — with a single clean invalid_arguments error, + // mirroring how the parser already rejects other malformed values (e.g. --pressure nope). + if (TryFindInvalidBooleanOption(parseResult, args, out var invalidBoolOption, out var invalidBoolValue)) + { + var message = + $"Cannot parse argument '{invalidBoolValue}' for option '{invalidBoolOption}' as expected type 'System.Boolean'."; + if (IsNewCommandJson(parseResult)) + { + // `new` owns a bespoke NewCommandResult envelope; emit it (and use its + // invalid-argument exit code) so machine callers get structured output here too. + NewCommand.EmitParseErrorJson(message); + return NewCommand.ExitInvalidArgs; + } + if (ResolveEffectiveJson(parseResult) && IsUiDescendant(parseResult)) + { + UiJsonError.Emit(true, UiJsonError.CodeInvalidArguments, message); + } + else if (ResolveEffectiveJson(parseResult) && IsFlatJsonErrorCommand(parseResult)) + { + EmitFlatJsonError(message); + } + else if (ResolveEffectiveJson(parseResult) && IsTargetDescendant(parseResult)) + { + TargetOutput.RejectCommandLine(message); + } + else + { + Console.Error.WriteLine(message); + } + + return 1; + } + } + + // Skip first-run notice for machine-readable output modes and completions + var didShowFirstRunNotice = false; + if (!isCliSchemaMode && !isCompleteMode && !json) + { + var firstRunService = serviceProvider.GetRequiredService(); + didShowFirstRunNotice = firstRunService.CheckAndDisplayFirstRunNotice(); + + // Check for CLI updates — shows cached notice instantly (no network), + // and starts a background refresh if the cache is stale (fire-and-forget). + if (!quiet) + { + var updateNotificationService = serviceProvider.GetRequiredService(); + updateNotificationService.CheckAndNotify(); + } + } + + // If no arguments provided, display banner and show help + if (args.Length == 0) + { + if (!didShowFirstRunNotice) + { + BannerHelper.DisplayBanner(); + } + + // Show help by invoking with --help + await rootCommand.Parse(["--help"], WinAppParserConfiguration.Default).InvokeAsync(); + return 0; + } + + var parsedArgs = parseResult!; + + // Derive the effective JSON mode from the SELECTED command's PARSED --json value (M1). + // The pre-scan (json) is value-aware but still a heuristic; the parsed value is the truth. + // Reading the parsed bool works even when a different option (e.g. --pressure) failed parse. + bool effectiveJson = ResolveEffectiveJson(parsedArgs); + + // Catch single-dash typos like "-app" before invocation so the user gets a clear + // "Did you mean --app?" message instead of System.CommandLine's confusing + // "Unrecognized command or argument" pointing at the wrong token (issue #467). + // Only run when parsing already failed — otherwise a command that legitimately + // accepts a "-foo"-shaped positional value would get a false-positive typo error. + if (parsedArgs.Errors.Count > 0) + { + var typo = OptionTypoValidator.FindLikelyLongOptionTypo(args, parsedArgs); + if (typo is not null) + { + var suggested = "-" + typo; + var typoMessage = $"Unknown option '{typo}'. Did you mean '{suggested}'?"; + if (IsNewCommandJson(parsedArgs)) + { + // Route `new --json` typo failures through its structured envelope with paired + // telemetry, mirroring the ui branch below and the main parse-error bridge. + if (!isCompleteMode) + { + CommandInvokedEvent.Log(parsedArgs.CommandResult); + } + NewCommand.EmitParseErrorJson(typoMessage); + if (!isCompleteMode) + { + CommandCompletedEvent.Log(parsedArgs.CommandResult, NewCommand.ExitInvalidArgs); + } + return NewCommand.ExitInvalidArgs; + } + if (effectiveJson && IsUiDescendant(parsedArgs)) + { + // Gate on IsUiDescendant so that non-ui commands (e.g. cert info) fall through + // to default error handling instead of receiving the nested UI schema (M2). + if (!isCompleteMode) + { + CommandInvokedEvent.Log(parsedArgs.CommandResult); + } + UiJsonError.Emit(true, UiJsonError.CodeInvalidArguments, typoMessage); + if (!isCompleteMode) + { + CommandCompletedEvent.Log(parsedArgs.CommandResult, 1); + } + } + else if (effectiveJson && IsFlatJsonErrorCommand(parsedArgs)) + { + // find-ui/find-api emit all their JSON (results and errors) on stdout as a flat + // {"error":"..."} object — keep parser-level failures on that same contract. + if (!isCompleteMode) + { + CommandInvokedEvent.Log(parsedArgs.CommandResult); + } + EmitFlatJsonError($"Unknown option '{typo}'. Did you mean '{suggested}'?"); + if (!isCompleteMode) + { + CommandCompletedEvent.Log(parsedArgs.CommandResult, 1); + } + } + else if (effectiveJson && IsTargetDescendant(parsedArgs)) + { + if (!isCompleteMode) + { + CommandInvokedEvent.Log(parsedArgs.CommandResult); + } + TargetOutput.RejectCommandLine(typoMessage); + if (!isCompleteMode) + { + CommandCompletedEvent.Log(parsedArgs.CommandResult, 1); + } + } + else + { + Console.Error.WriteLine(typoMessage); + Console.Error.WriteLine( + "(Single-dash flags are reserved for short aliases like '-a'. Long options use a double dash.)"); + } + return 1; + } + } + + return await RunWithTelemetryAsync(parsedArgs, isCompleteMode, async () => + { + // Target selection is settled before anything else, and settled for every command. + // A command that cannot honour --on says so, and a selector that names nothing usable + // fails here — never silently on this desktop, which is the one outcome the option + // exists to prevent. + if (ExecutionTargetSelection.Validate(parsedArgs) is { } selectionError) + { + return TargetOutput.RejectSelection( + serviceProvider.GetRequiredService(), effectiveJson, selectionError); + } + + // System.CommandLine binds an unrecognised option to a nearby optional positional rather + // than failing, so `--onn=sandbox` would become a UI element selector and the command + // would run here and report success. Rejected before dispatch for every command except + // `run`, whose own handler already reports the same mistake with the passthrough advice + // its `--` separator needs and in its own --json shape. + if (parsedArgs.CommandResult.Command is not RunCommand && + WindowsCommandLine.FindOptionLikePositionals(parsedArgs) is { Count: > 0 } stray) + { + return RejectOptionLikePositionals(parsedArgs, stray, effectiveJson); + } + + // One pre-dispatch interception, before any local UI service runs. A command the user + // sent to another target must not perform UI Automation, window discovery, capture, or + // input injection on this desktop, and the only way to guarantee that for every verb is + // to divert before the handler exists. + // + // Only for a command line that actually parsed. Routing a broken one first means + // `winapp ui inspect --on sandbox --depth notanumber` boots a Sandbox, spends minutes + // preparing it, and then reports a transport failure that never mentions `--depth` — + // and leaves the Sandbox running. The ordinary parse-error path is both faster and + // truthful, and it runs on this machine only because it runs nothing at all. + if (parsedArgs.Errors.Count == 0 && ExecutionTargetUiRouter.ShouldRoute(parsedArgs)) + { + var router = serviceProvider.GetRequiredService(); + return await router.RouteAsync( + args, + TargetUiRequirements.For(parsedArgs), + effectiveJson, + CancellationToken.None); + } + + // Parse errors must exit non-zero (issue #1015). InvokeAsync displays + // the error and help text, but return 1 explicitly so scripts and + // agents don't treat a rejected command line as success. + var invokeExitCode = await parsedArgs.InvokeAsync(); + if (parsedArgs.Errors.Count > 0 && invokeExitCode == 0) + { + return 1; + } + return invokeExitCode; + }); + } + + /// + /// Reports positional values that were really misspelt options, and returns the exit code. + /// + /// + /// Rendered in whichever error shape the invoked command promises, so a --json caller + /// still gets a parseable document rather than a bare line on stderr. + /// + private static int RejectOptionLikePositionals( + System.CommandLine.ParseResult parsedArgs, + IReadOnlyList stray, + bool effectiveJson) + { + var message = stray.Count == 1 + ? $"Unrecognized option '{stray[0]}'." + : $"Unrecognized options: {string.Join(", ", stray.Select(token => $"'{token}'"))}."; + + var advice = "Check the spelling, or put the value after '--' if it really is an argument."; + + if (effectiveJson && IsUiDescendant(parsedArgs)) + { + UiJsonError.Emit(true, UiJsonError.CodeInvalidArguments, $"{message} {advice}"); + } + else if (effectiveJson && IsFlatJsonErrorCommand(parsedArgs)) + { + EmitFlatJsonError($"{message} {advice}"); + } + else if (effectiveJson && IsTargetDescendant(parsedArgs)) + { + TargetOutput.RejectCommandLine($"{message} {advice}"); + } + else + { + Console.Error.WriteLine(message); + Console.Error.WriteLine(advice); + } + + // The same code every other malformed winapp command line returns. This is a parse mistake, + // not a failure to reach a target, and conflating the two would make an unknown option look + // like an unavailable machine. + return TargetOutput.InvalidCommandLineExitCode; + } + + /// + /// Applies the given console-encoding mutation, swallowing the platform exception that occurs + /// when the standard stream encoding cannot be changed (e.g. redirected or unsupported handles). + /// UTF-8 I/O is a best-effort nicety, so a failure here must never abort startup. + /// + internal static void ConfigureConsoleEncoding(Action apply) + { + try + { + apply(); + } + catch + { + // ignore + } + } + + /// + /// Runs the parsed command via , emitting command-invoked/-completed + /// telemetry (unless in completion mode) and converting any unhandled exception into a logged + /// error plus exit code 1. The seam lets tests exercise both the + /// success and top-level-failure paths without needing a real, throwing command. + /// + internal static Task RunWithTelemetryAsync( + System.CommandLine.ParseResult parsedArgs, bool isCompleteMode, Func> invoke) => + RunWithTelemetryAsync(parsedArgs, isCompleteMode, invoke, CommandInvokedEvent.Log, CommandCompletedEvent.Log); + + /// + /// Core of + /// with the telemetry sinks injected. and + /// default (via the public overload) to the production + /// / events; the seam + /// lets tests assert that the invoked/completed events fire around the invocation — in order, with + /// the parsed command result and real exit code, and only when not in completion mode. + /// + internal static async Task RunWithTelemetryAsync( + System.CommandLine.ParseResult parsedArgs, bool isCompleteMode, Func> invoke, + Action logCommandInvoked, + Action logCommandCompleted) + { + try + { + // Start a correlation scope so the CommandInvoked/CommandCompleted events and any + // command-specific event a handler emits (e.g. NewCommand_Event) share one + // relatedActivityId for this invocation. + TelemetryCorrelation.Begin(); + + // Open the coordination scope in the same async flow that reads it below. A `winapp ui` + // command sets its cooperative-turn summary from deep inside invoke(), and + // CommandCompletedEvent picks it up here (issue #764). + Services.InteractiveDesktop.UiCoordinationTelemetryScope.Begin(); + + if (!isCompleteMode) + { + logCommandInvoked(parsedArgs.CommandResult); + } + + bool effectiveJson = ResolveEffectiveJson(parsedArgs); + + // Parse-error → JSON bridge: activated only when the SELECTED command exposes --json, + // its parsed value is true (effectiveJson), AND the command is a ui descendant (M3). + // Non-ui commands (e.g. cert info) use a flat {"error":"..."} schema — do not impose + // the UI nested contract on them; let SCL's default parse-error handling run instead. + // M3: emit CommandCompletedEvent before the early return to keep telemetry paired. + if (effectiveJson && parsedArgs.Errors.Count > 0 && IsUiDescendant(parsedArgs)) + { + var errorMsg = string.Join("; ", parsedArgs.Errors.Select(e => e.Message)); + UiJsonError.Emit(true, UiJsonError.CodeInvalidArguments, errorMsg); + if (!isCompleteMode) + { + logCommandCompleted(parsedArgs.CommandResult, 1); + } + return 1; + } + + // Parse-error → JSON bridge for `winapp new`: it owns a bespoke NewCommandResult schema + // (not the ui error envelope), so a parse failure like `--template bogus --json` must be + // reported in that shape rather than falling through to SCL's help/error text on stdout. + // Return ExitInvalidArgs (2) — the same code the handler uses for invalid names/versions — + // so agents can branch on the invalid-argument stage regardless of where it was caught. + if (effectiveJson && parsedArgs.Errors.Count > 0 + && parsedArgs.CommandResult.Command.Name == "new") + { + var errorMsg = string.Join("; ", parsedArgs.Errors.Select(e => e.Message)); + NewCommand.EmitParseErrorJson(errorMsg); + if (!isCompleteMode) + { + logCommandCompleted(parsedArgs.CommandResult, NewCommand.ExitInvalidArgs); + } + return NewCommand.ExitInvalidArgs; + } + + // Discovery parse-error → JSON bridge. find-ui and find-api both document a --json + // contract (results and in-handler validation errors all emit JSON on stdout), but a + // type/parser error such as `--max abc` fails before the handler runs, so + // System.CommandLine would otherwise print human help text. Emit the same flat + // {"error":"..."} object here so every --json path stays machine-readable (#719). + if (effectiveJson && parsedArgs.Errors.Count > 0 && IsFlatJsonErrorCommand(parsedArgs)) + { + var errorMsg = string.Join("; ", parsedArgs.Errors.Select(e => e.Message)); + EmitFlatJsonError(errorMsg); + if (!isCompleteMode) + { + logCommandCompleted(parsedArgs.CommandResult, 1); + } + return 1; + } + + // Parse-error → JSON bridge for the `target` verbs, which own their own error envelope + // ({"error":{"code":...}} on stderr). Same reasoning as the ui bridge: a caller that + // passed --json must never have to read human help text off stdout to find out that its + // command line was wrong. + if (effectiveJson && parsedArgs.Errors.Count > 0 && IsTargetDescendant(parsedArgs)) + { + var errorMsg = string.Join("; ", parsedArgs.Errors.Select(e => e.Message)); + var exitCode = TargetOutput.RejectCommandLine(errorMsg); + if (!isCompleteMode) + { + logCommandCompleted(parsedArgs.CommandResult, exitCode); + } + return exitCode; + } + + var returnCode = await invoke(); + + if (!isCompleteMode) + { + logCommandCompleted(parsedArgs.CommandResult, returnCode); + } + + return returnCode; + } + catch (Exception ex) + { + TelemetryFactory.Get().LogException(parsedArgs.CommandResult.Command.Name, ex); + Console.Error.WriteLine($"An unexpected error occurred: {ex.Message}"); + return 1; + } + } + + /// + /// Derives the effective JSON mode from the selected command's PARSED --json option. + /// Reading the parsed value is reliable even when a different option caused a parse error, + /// because System.CommandLine parses each option independently (M1). + /// + private static bool ResolveEffectiveJson(System.CommandLine.ParseResult parsedArgs) + { + // Only engage when the selected (innermost) command actually owns --json. + var selectedCmd = parsedArgs.CommandResult.Command; + if (!selectedCmd.Options.Contains(WinAppRootCommand.JsonOption)) + { + return false; + } + try + { + return parsedArgs.GetValue(WinAppRootCommand.JsonOption); + } + catch + { + // If reading the parsed value fails for any reason, do not fire the bridge. + return false; + } + } + + /// + /// Returns when the selected command is new with --json in + /// effect. Callers use this to route an early parse failure (invalid boolean value, single-dash + /// typo) through and + /// return , so new --json yields a machine-readable + /// NewCommandResult — with the same invalid-argument exit code the handler uses — for + /// EVERY parse error, not only those reaching the main bridge in . + /// + private static bool IsNewCommandJson(System.CommandLine.ParseResult parseResult) => + ResolveEffectiveJson(parseResult) && parseResult.CommandResult.Command.Name == "new"; + + /// + /// Returns when the selected command is the ui command + /// or any of its descendants. Used to scope the parse-error JSON bridge to ui commands + /// only, leaving non-ui commands (e.g. cert info) with their own flat error schema (M3). + /// + private static bool IsUiDescendant(System.CommandLine.ParseResult parseResult) + { + var cmd = parseResult.CommandResult.Command; + while (cmd is not null) + { + if (cmd.Name == "ui") + { + return true; + } + cmd = cmd.Parents.OfType().FirstOrDefault(); + } + return false; + } + + /// + /// Returns when the selected command is one of the discovery commands + /// that document a flat {"error":"..."} JSON contract — find-ui, or find-api + /// and any of its verbs. Used to route parser-level failures to that contract (#719) instead of + /// System.CommandLine's human help text, mirroring the ui bridge above. + /// + private static bool IsFlatJsonErrorCommand(System.CommandLine.ParseResult parseResult) + { + var cmd = parseResult.CommandResult.Command; + while (cmd is not null) + { + if (cmd.Name is "find-ui" or "find-api") + { + return true; + } + cmd = cmd.Parents.OfType().FirstOrDefault(); + } + return false; + } + + /// + /// Returns when the selected command is target or one of its + /// descendants, so a parse failure is reported in the target error envelope rather than as + /// help text. + /// + private static bool IsTargetDescendant(System.CommandLine.ParseResult parseResult) + { + var cmd = parseResult.CommandResult.Command; + while (cmd is not null) + { + if (cmd.Name == "target") + { + return true; + } + cmd = cmd.Parents.OfType().FirstOrDefault(); + } + return false; + } + + /// + /// Writes a flat {"error":"..."} object to stdout — the same schema and sink + /// find-ui and find-api use for in-handler errors — so a parse failure under + /// --json stays machine-readable. stdout (not stderr) matches where those commands emit + /// all their other JSON. + /// + private static void EmitFlatJsonError(string message) + { + var payload = System.Text.Json.JsonSerializer.Serialize( + new JsonErrorOutput { Error = message }, + WinAppJsonContext.Default.JsonErrorOutput); + Console.Out.WriteLine(payload); + } + + /// + /// Scans argv for an invalid =-attached value on any boolean option reachable by the + /// selected command — the command's own boolean + /// options plus inherited/global ones on ancestor commands (e.g. --json/--verbose/ + /// --quiet on the root, or --eraser on ui pen). System.CommandLine silently + /// coerces a non-boolean attached value (e.g. --eraser=bogus) to ; + /// the caller uses this to reject it with a clean error instead (#600 H1/M1/M2). + /// + private static bool TryFindInvalidBooleanOption( + System.CommandLine.ParseResult parseResult, + string[] args, + out string optionName, + out string invalidValue) + { + optionName = string.Empty; + invalidValue = string.Empty; + + // Walk the selected command up through its ancestors so both command-level bool options + // and inherited/global ones are covered. Dedupe by name in case an option appears twice. + var seen = new HashSet(StringComparer.Ordinal); + var cmd = parseResult.CommandResult.Command; + while (cmd is not null) + { + foreach (var option in cmd.Options) + { + if (option is not System.CommandLine.Option) + { + continue; + } + if (!seen.Add(option.Name)) + { + continue; + } + if (GlobalOptionPreScan.TryFindInvalidBooleanValue(args, option.Name, option.Aliases, out var badValue)) + { + optionName = option.Name; + invalidValue = badValue; + return true; + } + } + + cmd = cmd.Parents.OfType().FirstOrDefault(); + } + + return false; + } + + /// + /// The global logging mode resolved from argv: the minimum log level plus the individual + /// --quiet/--verbose/--json flags, and a non-null + /// message when a mutually-exclusive combination was requested. + /// + internal readonly record struct LoggingMode(LogLevel MinimumLevel, bool Quiet, bool Verbose, bool Json, string? ConflictError); + + /// + /// Pre-scans argv for the global logging-mode flags and resolves the effective + /// . The scan (via ) stops at the first + /// standalone -- separator so passthrough payload (e.g. winapp run . -- --json) is + /// not misread as a winapp global flag. Extracted from so the flag precedence + /// and mutual-exclusion rules are unit testable without invoking the full entrypoint. + /// + internal static LoggingMode ResolveLoggingMode(string[] args) + { + var minimumLogLevel = LogLevel.Information; + bool quiet = false; + bool verbose = false; + bool json = false; + + verbose = GlobalOptionPreScan.GetBooleanFlagValue(args, WinAppRootCommand.VerboseOption.Name, WinAppRootCommand.VerboseOption.Aliases); + if (verbose) + { + minimumLogLevel = LogLevel.Debug; + } + quiet = GlobalOptionPreScan.GetBooleanFlagValue(args, WinAppRootCommand.QuietOption.Name, WinAppRootCommand.QuietOption.Aliases); + if (quiet) + { + minimumLogLevel = LogLevel.Warning; + } + json = GlobalOptionPreScan.GetBooleanFlagValue(args, WinAppRootCommand.JsonOption.Name, WinAppRootCommand.JsonOption.Aliases); + if (json) + { + minimumLogLevel = LogLevel.None; + } + + string? conflictError = null; + if (quiet && verbose) + { + conflictError = "Cannot specify both --quiet and --verbose options together."; + } + else if (quiet && json) + { + conflictError = "Cannot specify both --quiet and --json options together."; + } + else if (verbose && json) + { + conflictError = "Cannot specify both --verbose and --json options together."; + } + + return new LoggingMode(minimumLogLevel, quiet, verbose, json, conflictError); + } +} From 994ad2dbb1cab9340b318085be5505add091e60a Mon Sep 17 00:00:00 2001 From: Akhil Gupta Date: Wed, 7 Oct 2026 21:05:59 -0700 Subject: [PATCH 05/21] Delete src/winapp-CLI/WinApp.Cli/Program.cs --- src/winapp-CLI/WinApp.Cli/Program.cs | 686 --------------------------- 1 file changed, 686 deletions(-) delete mode 100644 src/winapp-CLI/WinApp.Cli/Program.cs diff --git a/src/winapp-CLI/WinApp.Cli/Program.cs b/src/winapp-CLI/WinApp.Cli/Program.cs deleted file mode 100644 index 6e46646c5..000000000 --- a/src/winapp-CLI/WinApp.Cli/Program.cs +++ /dev/null @@ -1,686 +0,0 @@ -// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. -// Licensed under the MIT License. - -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Spectre.Console; -using System.Text; -using WinApp.Cli.Commands; -using WinApp.Cli.Helpers; -using WinApp.Cli.Models; -using WinApp.Cli.Services; -using WinApp.Cli.Telemetry; -using WinApp.Cli.Telemetry.Events; -using LogLevel = Microsoft.Extensions.Logging.LogLevel; - -namespace WinApp.Cli; - -internal static class Program -{ - internal static async Task Main(string[] args) - { - // Hidden internal verb: the WinUI DbgEng triage pass runs in this isolated child process so - // its modern dbgeng.dll is not poisoned by the system32 dbghelp.dll the parent already loaded. - // Intercept before any host/service setup to keep the loader state clean and output noise-free. - if (args.Length > 0 && args[0] == Services.XamlTriageRunner.InternalVerb) - { - return Services.XamlTriageRunner.Run(args); - } - - // Ensure UTF-8 I/O for emoji-capable terminals; fall back silently if not supported - ConfigureConsoleEncoding(static () => - { - Console.OutputEncoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); - Console.InputEncoding = Encoding.UTF8; - }); - - var loggingMode = ResolveLoggingMode(args); - if (loggingMode.ConflictError is not null) - { - Console.Error.WriteLine(loggingMode.ConflictError); - return 1; - } - - var minimumLogLevel = loggingMode.MinimumLevel; - bool quiet = loggingMode.Quiet; - bool json = loggingMode.Json; - - // Check if --cli-schema is specified - this outputs machine-readable JSON - // and should not display any interactive messages like first-run notices - bool isCliSchemaMode = GlobalOptionPreScan.IsFlagPresent( - args, WinAppRootCommand.CliSchemaOption.Name, []); - - // Check if this is a completion request - completions must be fast and silent - bool isCompleteMode = args.Length > 0 && args[0] == "complete"; - - var services = new ServiceCollection() - .ConfigureServices() - .ConfigureCommands() - .AddLogging(b => - { - b.ClearProviders(); - b.AddTextWriterLogger(Console.Out, Console.Error); - b.SetMinimumLevel(minimumLogLevel); - }); - - using var serviceProvider = services.BuildServiceProvider(); - - var rootCommand = serviceProvider.GetRequiredService(); - System.CommandLine.ParseResult? parseResult = null; - - if (args.Length > 0) - { - parseResult = rootCommand.Parse(args, WinAppParserConfiguration.Default); - - // Infrastructure processes are not separate user invocations. Set this before any - // telemetry instance is created; descendants inherit the winapp-only opt-out. - if (parseResult.CommandResult.Command is GuestAgentCommand) - { - Environment.SetEnvironmentVariable(Telemetry.Telemetry.OptOutEnvironmentVariable, "1"); - } - - // Set WINAPP_CLI_CALLER env var from --caller option so telemetry and update checks can use it - var caller = parseResult.GetValue(WinAppRootCommand.CallerOption); - if (!string.IsNullOrWhiteSpace(caller)) - { - Environment.SetEnvironmentVariable("WINAPP_CLI_CALLER", caller); - } - - // Reject an invalid '='-attached value on ANY boolean option reachable by the selected - // command — a global flag like --json=bogus or a command flag like --eraser=bogus. - // System.CommandLine silently coerces such a value to TRUE with no parse error, which - // would (a) silently enable a flag the user never set (e.g. the pen eraser) and (b) for - // --json leave logging un-suppressed, so the human ❌ line and the JSON envelope BOTH hit - // stderr and corrupt machine-readable output. Fail fast HERE — before the first-run / - // update notice can print anything (M1) — with a single clean invalid_arguments error, - // mirroring how the parser already rejects other malformed values (e.g. --pressure nope). - if (TryFindInvalidBooleanOption(parseResult, args, out var invalidBoolOption, out var invalidBoolValue)) - { - var message = - $"Cannot parse argument '{invalidBoolValue}' for option '{invalidBoolOption}' as expected type 'System.Boolean'."; - if (IsNewCommandJson(parseResult)) - { - // `new` owns a bespoke NewCommandResult envelope; emit it (and use its - // invalid-argument exit code) so machine callers get structured output here too. - NewCommand.EmitParseErrorJson(message); - return NewCommand.ExitInvalidArgs; - } - if (ResolveEffectiveJson(parseResult) && IsUiDescendant(parseResult)) - { - UiJsonError.Emit(true, UiJsonError.CodeInvalidArguments, message); - } - else if (ResolveEffectiveJson(parseResult) && IsFlatJsonErrorCommand(parseResult)) - { - EmitFlatJsonError(message); - } - else if (ResolveEffectiveJson(parseResult) && IsTargetDescendant(parseResult)) - { - TargetOutput.RejectCommandLine(message); - } - else - { - Console.Error.WriteLine(message); - } - - return 1; - } - } - - // Skip first-run notice for machine-readable output modes and completions - var didShowFirstRunNotice = false; - if (!isCliSchemaMode && !isCompleteMode && !json) - { - var firstRunService = serviceProvider.GetRequiredService(); - didShowFirstRunNotice = firstRunService.CheckAndDisplayFirstRunNotice(); - - // Check for CLI updates — shows cached notice instantly (no network), - // and starts a background refresh if the cache is stale (fire-and-forget). - if (!quiet) - { - var updateNotificationService = serviceProvider.GetRequiredService(); - updateNotificationService.CheckAndNotify(); - } - } - - // If no arguments provided, display banner and show help - if (args.Length == 0) - { - if (!didShowFirstRunNotice) - { - BannerHelper.DisplayBanner(); - } - - // Show help by invoking with --help - await rootCommand.Parse(["--help"], WinAppParserConfiguration.Default).InvokeAsync(); - return 0; - } - - var parsedArgs = parseResult!; - - // Derive the effective JSON mode from the SELECTED command's PARSED --json value (M1). - // The pre-scan (json) is value-aware but still a heuristic; the parsed value is the truth. - // Reading the parsed bool works even when a different option (e.g. --pressure) failed parse. - bool effectiveJson = ResolveEffectiveJson(parsedArgs); - - // Catch single-dash typos like "-app" before invocation so the user gets a clear - // "Did you mean --app?" message instead of System.CommandLine's confusing - // "Unrecognized command or argument" pointing at the wrong token (issue #467). - // Only run when parsing already failed — otherwise a command that legitimately - // accepts a "-foo"-shaped positional value would get a false-positive typo error. - if (parsedArgs.Errors.Count > 0) - { - var typo = OptionTypoValidator.FindLikelyLongOptionTypo(args, parsedArgs); - if (typo is not null) - { - var suggested = "-" + typo; - var typoMessage = $"Unknown option '{typo}'. Did you mean '{suggested}'?"; - if (IsNewCommandJson(parsedArgs)) - { - // Route `new --json` typo failures through its structured envelope with paired - // telemetry, mirroring the ui branch below and the main parse-error bridge. - if (!isCompleteMode) - { - CommandInvokedEvent.Log(parsedArgs.CommandResult); - } - NewCommand.EmitParseErrorJson(typoMessage); - if (!isCompleteMode) - { - CommandCompletedEvent.Log(parsedArgs.CommandResult, NewCommand.ExitInvalidArgs); - } - return NewCommand.ExitInvalidArgs; - } - if (effectiveJson && IsUiDescendant(parsedArgs)) - { - // Gate on IsUiDescendant so that non-ui commands (e.g. cert info) fall through - // to default error handling instead of receiving the nested UI schema (M2). - if (!isCompleteMode) - { - CommandInvokedEvent.Log(parsedArgs.CommandResult); - } - UiJsonError.Emit(true, UiJsonError.CodeInvalidArguments, typoMessage); - if (!isCompleteMode) - { - CommandCompletedEvent.Log(parsedArgs.CommandResult, 1); - } - } - else if (effectiveJson && IsFlatJsonErrorCommand(parsedArgs)) - { - // find-ui/find-api emit all their JSON (results and errors) on stdout as a flat - // {"error":"..."} object — keep parser-level failures on that same contract. - if (!isCompleteMode) - { - CommandInvokedEvent.Log(parsedArgs.CommandResult); - } - EmitFlatJsonError($"Unknown option '{typo}'. Did you mean '{suggested}'?"); - if (!isCompleteMode) - { - CommandCompletedEvent.Log(parsedArgs.CommandResult, 1); - } - } - else if (effectiveJson && IsTargetDescendant(parsedArgs)) - { - if (!isCompleteMode) - { - CommandInvokedEvent.Log(parsedArgs.CommandResult); - } - TargetOutput.RejectCommandLine(typoMessage); - if (!isCompleteMode) - { - CommandCompletedEvent.Log(parsedArgs.CommandResult, 1); - } - } - else - { - Console.Error.WriteLine(typoMessage); - Console.Error.WriteLine( - "(Single-dash flags are reserved for short aliases like '-a'. Long options use a double dash.)"); - } - return 1; - } - } - - return await RunWithTelemetryAsync(parsedArgs, isCompleteMode, () => - { - // Target selection is settled before anything else, and settled for every command. - // A command that cannot honour --on says so, and a selector that names nothing usable - // fails here — never silently on this desktop, which is the one outcome the option - // exists to prevent. - if (ExecutionTargetSelection.Validate(parsedArgs) is { } selectionError) - { - return Task.FromResult(TargetOutput.RejectSelection( - serviceProvider.GetRequiredService(), effectiveJson, selectionError)); - } - - // System.CommandLine binds an unrecognised option to a nearby optional positional rather - // than failing, so `--onn=sandbox` would become a UI element selector and the command - // would run here and report success. Rejected before dispatch for every command except - // `run`, whose own handler already reports the same mistake with the passthrough advice - // its `--` separator needs and in its own --json shape. - if (parsedArgs.CommandResult.Command is not RunCommand && - WindowsCommandLine.FindOptionLikePositionals(parsedArgs) is { Count: > 0 } stray) - { - return Task.FromResult(RejectOptionLikePositionals(parsedArgs, stray, effectiveJson)); - } - - // One pre-dispatch interception, before any local UI service runs. A command the user - // sent to another target must not perform UI Automation, window discovery, capture, or - // input injection on this desktop, and the only way to guarantee that for every verb is - // to divert before the handler exists. - // - // Only for a command line that actually parsed. Routing a broken one first means - // `winapp ui inspect --on sandbox --depth notanumber` boots a Sandbox, spends minutes - // preparing it, and then reports a transport failure that never mentions `--depth` — - // and leaves the Sandbox running. The ordinary parse-error path is both faster and - // truthful, and it runs on this machine only because it runs nothing at all. - if (parsedArgs.Errors.Count == 0 && ExecutionTargetUiRouter.ShouldRoute(parsedArgs)) - { - var router = serviceProvider.GetRequiredService(); - return router.RouteAsync( - args, - TargetUiRequirements.For(parsedArgs), - effectiveJson, - CancellationToken.None); - } - - // Parse errors must exit non-zero (issue #1015). InvokeAsync displays - // the error and help text, but return 1 explicitly so scripts and - // agents don't treat a rejected command line as success. - var invokeExitCode = await parsedArgs.InvokeAsync(); - if (parsedArgs.Errors.Count > 0 && invokeExitCode == 0) - { - return 1; - } - return invokeExitCode; - }); - } - - /// - /// Reports positional values that were really misspelt options, and returns the exit code. - /// - /// - /// Rendered in whichever error shape the invoked command promises, so a --json caller - /// still gets a parseable document rather than a bare line on stderr. - /// - private static int RejectOptionLikePositionals( - System.CommandLine.ParseResult parsedArgs, - IReadOnlyList stray, - bool effectiveJson) - { - var message = stray.Count == 1 - ? $"Unrecognized option '{stray[0]}'." - : $"Unrecognized options: {string.Join(", ", stray.Select(token => $"'{token}'"))}."; - - var advice = "Check the spelling, or put the value after '--' if it really is an argument."; - - if (effectiveJson && IsUiDescendant(parsedArgs)) - { - UiJsonError.Emit(true, UiJsonError.CodeInvalidArguments, $"{message} {advice}"); - } - else if (effectiveJson && IsFlatJsonErrorCommand(parsedArgs)) - { - EmitFlatJsonError($"{message} {advice}"); - } - else if (effectiveJson && IsTargetDescendant(parsedArgs)) - { - TargetOutput.RejectCommandLine($"{message} {advice}"); - } - else - { - Console.Error.WriteLine(message); - Console.Error.WriteLine(advice); - } - - // The same code every other malformed winapp command line returns. This is a parse mistake, - // not a failure to reach a target, and conflating the two would make an unknown option look - // like an unavailable machine. - return TargetOutput.InvalidCommandLineExitCode; - } - - /// - /// Applies the given console-encoding mutation, swallowing the platform exception that occurs - /// when the standard stream encoding cannot be changed (e.g. redirected or unsupported handles). - /// UTF-8 I/O is a best-effort nicety, so a failure here must never abort startup. - /// - internal static void ConfigureConsoleEncoding(Action apply) - { - try - { - apply(); - } - catch - { - // ignore - } - } - - /// - /// Runs the parsed command via , emitting command-invoked/-completed - /// telemetry (unless in completion mode) and converting any unhandled exception into a logged - /// error plus exit code 1. The seam lets tests exercise both the - /// success and top-level-failure paths without needing a real, throwing command. - /// - internal static Task RunWithTelemetryAsync( - System.CommandLine.ParseResult parsedArgs, bool isCompleteMode, Func> invoke) => - RunWithTelemetryAsync(parsedArgs, isCompleteMode, invoke, CommandInvokedEvent.Log, CommandCompletedEvent.Log); - - /// - /// Core of - /// with the telemetry sinks injected. and - /// default (via the public overload) to the production - /// / events; the seam - /// lets tests assert that the invoked/completed events fire around the invocation — in order, with - /// the parsed command result and real exit code, and only when not in completion mode. - /// - internal static async Task RunWithTelemetryAsync( - System.CommandLine.ParseResult parsedArgs, bool isCompleteMode, Func> invoke, - Action logCommandInvoked, - Action logCommandCompleted) - { - try - { - // Start a correlation scope so the CommandInvoked/CommandCompleted events and any - // command-specific event a handler emits (e.g. NewCommand_Event) share one - // relatedActivityId for this invocation. - TelemetryCorrelation.Begin(); - - // Open the coordination scope in the same async flow that reads it below. A `winapp ui` - // command sets its cooperative-turn summary from deep inside invoke(), and - // CommandCompletedEvent picks it up here (issue #764). - Services.InteractiveDesktop.UiCoordinationTelemetryScope.Begin(); - - if (!isCompleteMode) - { - logCommandInvoked(parsedArgs.CommandResult); - } - - bool effectiveJson = ResolveEffectiveJson(parsedArgs); - - // Parse-error → JSON bridge: activated only when the SELECTED command exposes --json, - // its parsed value is true (effectiveJson), AND the command is a ui descendant (M3). - // Non-ui commands (e.g. cert info) use a flat {"error":"..."} schema — do not impose - // the UI nested contract on them; let SCL's default parse-error handling run instead. - // M3: emit CommandCompletedEvent before the early return to keep telemetry paired. - if (effectiveJson && parsedArgs.Errors.Count > 0 && IsUiDescendant(parsedArgs)) - { - var errorMsg = string.Join("; ", parsedArgs.Errors.Select(e => e.Message)); - UiJsonError.Emit(true, UiJsonError.CodeInvalidArguments, errorMsg); - if (!isCompleteMode) - { - logCommandCompleted(parsedArgs.CommandResult, 1); - } - return 1; - } - - // Parse-error → JSON bridge for `winapp new`: it owns a bespoke NewCommandResult schema - // (not the ui error envelope), so a parse failure like `--template bogus --json` must be - // reported in that shape rather than falling through to SCL's help/error text on stdout. - // Return ExitInvalidArgs (2) — the same code the handler uses for invalid names/versions — - // so agents can branch on the invalid-argument stage regardless of where it was caught. - if (effectiveJson && parsedArgs.Errors.Count > 0 - && parsedArgs.CommandResult.Command.Name == "new") - { - var errorMsg = string.Join("; ", parsedArgs.Errors.Select(e => e.Message)); - NewCommand.EmitParseErrorJson(errorMsg); - if (!isCompleteMode) - { - logCommandCompleted(parsedArgs.CommandResult, NewCommand.ExitInvalidArgs); - } - return NewCommand.ExitInvalidArgs; - } - - // Discovery parse-error → JSON bridge. find-ui and find-api both document a --json - // contract (results and in-handler validation errors all emit JSON on stdout), but a - // type/parser error such as `--max abc` fails before the handler runs, so - // System.CommandLine would otherwise print human help text. Emit the same flat - // {"error":"..."} object here so every --json path stays machine-readable (#719). - if (effectiveJson && parsedArgs.Errors.Count > 0 && IsFlatJsonErrorCommand(parsedArgs)) - { - var errorMsg = string.Join("; ", parsedArgs.Errors.Select(e => e.Message)); - EmitFlatJsonError(errorMsg); - if (!isCompleteMode) - { - logCommandCompleted(parsedArgs.CommandResult, 1); - } - return 1; - } - - // Parse-error → JSON bridge for the `target` verbs, which own their own error envelope - // ({"error":{"code":...}} on stderr). Same reasoning as the ui bridge: a caller that - // passed --json must never have to read human help text off stdout to find out that its - // command line was wrong. - if (effectiveJson && parsedArgs.Errors.Count > 0 && IsTargetDescendant(parsedArgs)) - { - var errorMsg = string.Join("; ", parsedArgs.Errors.Select(e => e.Message)); - var exitCode = TargetOutput.RejectCommandLine(errorMsg); - if (!isCompleteMode) - { - logCommandCompleted(parsedArgs.CommandResult, exitCode); - } - return exitCode; - } - - var returnCode = await invoke(); - - if (!isCompleteMode) - { - logCommandCompleted(parsedArgs.CommandResult, returnCode); - } - - return returnCode; - } - catch (Exception ex) - { - TelemetryFactory.Get().LogException(parsedArgs.CommandResult.Command.Name, ex); - Console.Error.WriteLine($"An unexpected error occurred: {ex.Message}"); - return 1; - } - } - - /// - /// Derives the effective JSON mode from the selected command's PARSED --json option. - /// Reading the parsed value is reliable even when a different option caused a parse error, - /// because System.CommandLine parses each option independently (M1). - /// - private static bool ResolveEffectiveJson(System.CommandLine.ParseResult parsedArgs) - { - // Only engage when the selected (innermost) command actually owns --json. - var selectedCmd = parsedArgs.CommandResult.Command; - if (!selectedCmd.Options.Contains(WinAppRootCommand.JsonOption)) - { - return false; - } - try - { - return parsedArgs.GetValue(WinAppRootCommand.JsonOption); - } - catch - { - // If reading the parsed value fails for any reason, do not fire the bridge. - return false; - } - } - - /// - /// Returns when the selected command is new with --json in - /// effect. Callers use this to route an early parse failure (invalid boolean value, single-dash - /// typo) through and - /// return , so new --json yields a machine-readable - /// NewCommandResult — with the same invalid-argument exit code the handler uses — for - /// EVERY parse error, not only those reaching the main bridge in . - /// - private static bool IsNewCommandJson(System.CommandLine.ParseResult parseResult) => - ResolveEffectiveJson(parseResult) && parseResult.CommandResult.Command.Name == "new"; - - /// - /// Returns when the selected command is the ui command - /// or any of its descendants. Used to scope the parse-error JSON bridge to ui commands - /// only, leaving non-ui commands (e.g. cert info) with their own flat error schema (M3). - /// - private static bool IsUiDescendant(System.CommandLine.ParseResult parseResult) - { - var cmd = parseResult.CommandResult.Command; - while (cmd is not null) - { - if (cmd.Name == "ui") - { - return true; - } - cmd = cmd.Parents.OfType().FirstOrDefault(); - } - return false; - } - - /// - /// Returns when the selected command is one of the discovery commands - /// that document a flat {"error":"..."} JSON contract — find-ui, or find-api - /// and any of its verbs. Used to route parser-level failures to that contract (#719) instead of - /// System.CommandLine's human help text, mirroring the ui bridge above. - /// - private static bool IsFlatJsonErrorCommand(System.CommandLine.ParseResult parseResult) - { - var cmd = parseResult.CommandResult.Command; - while (cmd is not null) - { - if (cmd.Name is "find-ui" or "find-api") - { - return true; - } - cmd = cmd.Parents.OfType().FirstOrDefault(); - } - return false; - } - - /// - /// Returns when the selected command is target or one of its - /// descendants, so a parse failure is reported in the target error envelope rather than as - /// help text. - /// - private static bool IsTargetDescendant(System.CommandLine.ParseResult parseResult) - { - var cmd = parseResult.CommandResult.Command; - while (cmd is not null) - { - if (cmd.Name == "target") - { - return true; - } - cmd = cmd.Parents.OfType().FirstOrDefault(); - } - return false; - } - - /// - /// Writes a flat {"error":"..."} object to stdout — the same schema and sink - /// find-ui and find-api use for in-handler errors — so a parse failure under - /// --json stays machine-readable. stdout (not stderr) matches where those commands emit - /// all their other JSON. - /// - private static void EmitFlatJsonError(string message) - { - var payload = System.Text.Json.JsonSerializer.Serialize( - new JsonErrorOutput { Error = message }, - WinAppJsonContext.Default.JsonErrorOutput); - Console.Out.WriteLine(payload); - } - - /// - /// Scans argv for an invalid =-attached value on any boolean option reachable by the - /// selected command — the command's own boolean - /// options plus inherited/global ones on ancestor commands (e.g. --json/--verbose/ - /// --quiet on the root, or --eraser on ui pen). System.CommandLine silently - /// coerces a non-boolean attached value (e.g. --eraser=bogus) to ; - /// the caller uses this to reject it with a clean error instead (#600 H1/M1/M2). - /// - private static bool TryFindInvalidBooleanOption( - System.CommandLine.ParseResult parseResult, - string[] args, - out string optionName, - out string invalidValue) - { - optionName = string.Empty; - invalidValue = string.Empty; - - // Walk the selected command up through its ancestors so both command-level bool options - // and inherited/global ones are covered. Dedupe by name in case an option appears twice. - var seen = new HashSet(StringComparer.Ordinal); - var cmd = parseResult.CommandResult.Command; - while (cmd is not null) - { - foreach (var option in cmd.Options) - { - if (option is not System.CommandLine.Option) - { - continue; - } - if (!seen.Add(option.Name)) - { - continue; - } - if (GlobalOptionPreScan.TryFindInvalidBooleanValue(args, option.Name, option.Aliases, out var badValue)) - { - optionName = option.Name; - invalidValue = badValue; - return true; - } - } - - cmd = cmd.Parents.OfType().FirstOrDefault(); - } - - return false; - } - - /// - /// The global logging mode resolved from argv: the minimum log level plus the individual - /// --quiet/--verbose/--json flags, and a non-null - /// message when a mutually-exclusive combination was requested. - /// - internal readonly record struct LoggingMode(LogLevel MinimumLevel, bool Quiet, bool Verbose, bool Json, string? ConflictError); - - /// - /// Pre-scans argv for the global logging-mode flags and resolves the effective - /// . The scan (via ) stops at the first - /// standalone -- separator so passthrough payload (e.g. winapp run . -- --json) is - /// not misread as a winapp global flag. Extracted from so the flag precedence - /// and mutual-exclusion rules are unit testable without invoking the full entrypoint. - /// - internal static LoggingMode ResolveLoggingMode(string[] args) - { - var minimumLogLevel = LogLevel.Information; - bool quiet = false; - bool verbose = false; - bool json = false; - - verbose = GlobalOptionPreScan.GetBooleanFlagValue(args, WinAppRootCommand.VerboseOption.Name, WinAppRootCommand.VerboseOption.Aliases); - if (verbose) - { - minimumLogLevel = LogLevel.Debug; - } - quiet = GlobalOptionPreScan.GetBooleanFlagValue(args, WinAppRootCommand.QuietOption.Name, WinAppRootCommand.QuietOption.Aliases); - if (quiet) - { - minimumLogLevel = LogLevel.Warning; - } - json = GlobalOptionPreScan.GetBooleanFlagValue(args, WinAppRootCommand.JsonOption.Name, WinAppRootCommand.JsonOption.Aliases); - if (json) - { - minimumLogLevel = LogLevel.None; - } - - string? conflictError = null; - if (quiet && verbose) - { - conflictError = "Cannot specify both --quiet and --verbose options together."; - } - else if (quiet && json) - { - conflictError = "Cannot specify both --quiet and --json options together."; - } - else if (verbose && json) - { - conflictError = "Cannot specify both --verbose and --json options together."; - } - - return new LoggingMode(minimumLogLevel, quiet, verbose, json, conflictError); - } -} From 21a417007c5a51f7022d14252e71a8729b4289e2 Mon Sep 17 00:00:00 2001 From: Akhil Gupta Date: Wed, 7 Oct 2026 21:06:36 -0700 Subject: [PATCH 06/21] Rename winapp-Program-fix.cs to Program.cs --- src/winapp-CLI/WinApp.Cli/{winapp-Program-fix.cs => Program.cs} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/winapp-CLI/WinApp.Cli/{winapp-Program-fix.cs => Program.cs} (100%) diff --git a/src/winapp-CLI/WinApp.Cli/winapp-Program-fix.cs b/src/winapp-CLI/WinApp.Cli/Program.cs similarity index 100% rename from src/winapp-CLI/WinApp.Cli/winapp-Program-fix.cs rename to src/winapp-CLI/WinApp.Cli/Program.cs From 772a4bb360c27cee38daa42f2f62f7d19e6e54bc Mon Sep 17 00:00:00 2001 From: Akhil Gupta Date: Wed, 7 Oct 2026 21:09:16 -0700 Subject: [PATCH 07/21] test: regression for parse-error exit code 1 --- .../WinApp.Cli.Tests/winapp-ProgramTests.cs | 346 ++++++++++++++++++ 1 file changed, 346 insertions(+) create mode 100644 src/winapp-CLI/WinApp.Cli.Tests/winapp-ProgramTests.cs diff --git a/src/winapp-CLI/WinApp.Cli.Tests/winapp-ProgramTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/winapp-ProgramTests.cs new file mode 100644 index 000000000..a6a4d9ebd --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli.Tests/winapp-ProgramTests.cs @@ -0,0 +1,346 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Extensions.Logging; +using WinApp.Cli; +using WinApp.Cli.Commands; +using WinApp.Cli.ExecutionTargets.Abstractions; + +namespace WinApp.Cli.Tests; + +[TestClass] +public class ProgramResolveLoggingModeTests +{ + [TestMethod] + public void ResolveLoggingMode_NoFlags_DefaultsToInformation() + { + var mode = Program.ResolveLoggingMode(["init"]); + + Assert.AreEqual(LogLevel.Information, mode.MinimumLevel); + Assert.IsFalse(mode.Quiet); + Assert.IsFalse(mode.Verbose); + Assert.IsFalse(mode.Json); + Assert.IsNull(mode.ConflictError); + } + + [TestMethod] + [DataRow("--verbose")] + [DataRow("-v")] + public void ResolveLoggingMode_Verbose_SetsDebug(string flag) + { + var mode = Program.ResolveLoggingMode([flag]); + + Assert.AreEqual(LogLevel.Debug, mode.MinimumLevel); + Assert.IsTrue(mode.Verbose); + Assert.IsNull(mode.ConflictError); + } + + [TestMethod] + [DataRow("--quiet")] + [DataRow("-q")] + public void ResolveLoggingMode_Quiet_SetsWarning(string flag) + { + var mode = Program.ResolveLoggingMode([flag]); + + Assert.AreEqual(LogLevel.Warning, mode.MinimumLevel); + Assert.IsTrue(mode.Quiet); + Assert.IsNull(mode.ConflictError); + } + + [TestMethod] + public void ResolveLoggingMode_Json_SetsNone() + { + var mode = Program.ResolveLoggingMode(["--json"]); + + Assert.AreEqual(LogLevel.None, mode.MinimumLevel); + Assert.IsTrue(mode.Json); + Assert.IsNull(mode.ConflictError); + } + + [TestMethod] + public void ResolveLoggingMode_QuietAndVerbose_ReportsConflict() + { + var mode = Program.ResolveLoggingMode(["--quiet", "--verbose"]); + + Assert.AreEqual("Cannot specify both --quiet and --verbose options together.", mode.ConflictError); + } + + [TestMethod] + public void ResolveLoggingMode_QuietAndJson_ReportsConflict() + { + var mode = Program.ResolveLoggingMode(["--quiet", "--json"]); + + Assert.AreEqual("Cannot specify both --quiet and --json options together.", mode.ConflictError); + } + + [TestMethod] + public void ResolveLoggingMode_VerboseAndJson_ReportsConflict() + { + var mode = Program.ResolveLoggingMode(["--verbose", "--json"]); + + Assert.AreEqual("Cannot specify both --verbose and --json options together.", mode.ConflictError); + } + + [TestMethod] + public void ResolveLoggingMode_FlagAfterDoubleDash_IsIgnored() + { + // Tokens after a standalone "--" are passthrough payload, not winapp global flags. + var mode = Program.ResolveLoggingMode(["run", ".", "--", "--json"]); + + Assert.IsFalse(mode.Json, "--json after -- must be treated as passthrough, not a global flag."); + Assert.AreEqual(LogLevel.Information, mode.MinimumLevel); + } +} + +[TestClass] +[DoNotParallelize] // Invokes Program.Main, which mutates static Console streams and env vars. +public class ProgramMainTests +{ + private string _tempCacheDir = null!; + private string? _savedCacheDir; + private string? _savedUpdateCheck; + private string? _savedCaller; + + private Dictionary _savedCiVars = []; + + [TestInitialize] + public void Setup() + { + _tempCacheDir = Path.Combine(Path.GetTempPath(), $"winapp_program_test_{Guid.NewGuid():N}"); + Directory.CreateDirectory(_tempCacheDir); + // Mark first-run complete and disable update checks so Main runs deterministically offline. + File.Create(Path.Combine(_tempCacheDir, ".first-run-complete")).Dispose(); + + _savedCacheDir = Environment.GetEnvironmentVariable("WINAPP_CLI_CACHE_DIRECTORY"); + _savedUpdateCheck = Environment.GetEnvironmentVariable("WINAPP_CLI_UPDATE_CHECK"); + _savedCaller = Environment.GetEnvironmentVariable("WINAPP_CLI_CALLER"); + _savedCiVars = ProgramMainTestHarness.CiVarNames.ToDictionary(name => name, name => Environment.GetEnvironmentVariable(name)); + + Environment.SetEnvironmentVariable("WINAPP_CLI_CACHE_DIRECTORY", _tempCacheDir); + Environment.SetEnvironmentVariable("WINAPP_CLI_UPDATE_CHECK", "0"); + Environment.SetEnvironmentVariable("WINAPP_CLI_CALLER", null); + foreach (var name in ProgramMainTestHarness.CiVarNames) + { + Environment.SetEnvironmentVariable(name, null); + } + } + + [TestCleanup] + public void Cleanup() + { + Environment.SetEnvironmentVariable("WINAPP_CLI_CACHE_DIRECTORY", _savedCacheDir); + Environment.SetEnvironmentVariable("WINAPP_CLI_UPDATE_CHECK", _savedUpdateCheck); + Environment.SetEnvironmentVariable("WINAPP_CLI_CALLER", _savedCaller); + foreach (var (name, value) in _savedCiVars) + { + Environment.SetEnvironmentVariable(name, value); + } + + try { Directory.Delete(_tempCacheDir, recursive: true); } catch { /* best effort */ } + } + + [TestMethod] + public async Task Main_NoArguments_ShowsBannerAndHelp_ReturnsZero() + { + var (stdout, _, exitCode) = await ProgramMainTestHarness.InvokeProgramAsync([]); + + Assert.AreEqual(0, exitCode); + Assert.IsTrue(stdout.Contains("Windows App Development CLI", StringComparison.Ordinal), + $"No-args invocation must print the banner. Got stdout: {stdout}"); + } + + [TestMethod] + public async Task Main_SingleDashLongOptionTypo_PrintsSuggestion_ReturnsOne() + { + var (_, stderr, exitCode) = await ProgramMainTestHarness.InvokeProgramAsync(["ui", "inspect", "-app", "some-app"]); + + Assert.AreEqual(1, exitCode); + Assert.IsTrue(stderr.Contains("Did you mean", StringComparison.Ordinal), + $"A single-dash long-option typo must suggest the double-dash form. Got stderr: {stderr}"); + Assert.IsTrue(stderr.Contains("--app", StringComparison.Ordinal)); + } + + [TestMethod] + public async Task Main_TargetOptionLikePositional_WithJson_EmitsTargetEnvelope() + { + var (stdout, stderr, exitCode) = await ProgramMainTestHarness.InvokeProgramAsync( + ["target", "push", "sandbox", "--sorce", @".\Setup", "--json"]); + + Assert.AreEqual(TargetOutput.InvalidCommandLineExitCode, exitCode); + Assert.AreEqual(string.Empty, stdout); + + using var document = System.Text.Json.JsonDocument.Parse(stderr); + Assert.AreEqual( + ExecutionTargetErrorCodes.TargetInvalidArguments, + document.RootElement.GetProperty("error").GetProperty("code").GetString()); + } + + [TestMethod] + public async Task Main_ConflictingLoggingFlags_PrintsError_ReturnsOne() + { + var (_, stderr, exitCode) = await ProgramMainTestHarness.InvokeProgramAsync(["--quiet", "--verbose"]); + + Assert.AreEqual(1, exitCode); + Assert.IsTrue(stderr.Contains("Cannot specify both --quiet and --verbose options together.", StringComparison.Ordinal), + $"Conflicting logging flags must be rejected. Got stderr: {stderr}"); + } + + [TestMethod] + public async Task Main_CliSchemaWithCaller_SetsCallerEnvVarAndReturnsZero() + { + var (stdout, _, exitCode) = await ProgramMainTestHarness.InvokeProgramAsync(["--cli-schema", "--caller", "test-caller"]); + + Assert.AreEqual(0, exitCode); + Assert.AreEqual("test-caller", Environment.GetEnvironmentVariable("WINAPP_CLI_CALLER"), + "The --caller value must be promoted to the WINAPP_CLI_CALLER environment variable."); + Assert.IsTrue(stdout.Contains('{', StringComparison.Ordinal), + $"--cli-schema must emit machine-readable JSON. Got: {stdout}"); + } + + [TestMethod] + public async Task Main_UnknownCommand_FallsThroughToInvocation_WithoutTypoSuggestion() + { + // A bare unknown command produces parse errors but is not a single-dash typo, so the typo + // shortcut is skipped and the parsed args flow into the normal invocation path. + var (_, stderr, exitCode) = await ProgramMainTestHarness.InvokeProgramAsync(["this-is-not-a-real-command"]); + + Assert.AreNotEqual(0, exitCode, "An unknown command must not succeed."); + Assert.IsFalse(stderr.Contains("Did you mean", StringComparison.Ordinal), + $"A non-dash unknown command must not trigger the single-dash typo hint. Got stderr: {stderr}"); + } + + [TestMethod] + [DataRow(new[] { "cert", "bogus" })] + [DataRow(new[] { "init", "a", "b", "c" })] + [DataRow(new[] { "--verbose", "bogus" })] + public async Task Main_ParseError_ExitsOne(string[] args) + { + // Issue #1015: parse errors that print help must exit 1, not 0, so scripts + // and agents don't treat a rejected command line as success. + var (_, _, exitCode) = await ProgramMainTestHarness.InvokeProgramAsync(args); + + Assert.AreEqual(1, exitCode, + $"Parse error for '{string.Join(" ", args)}' must exit with code 1."); + } +} + +[TestClass] +[DoNotParallelize] // The RunWithTelemetryAsync failure test redirects the static Console.Error stream. +public class ProgramSeamTests +{ + private static System.CommandLine.ParseResult ParseEmptyRoot() => + new System.CommandLine.RootCommand("test").Parse([], WinApp.Cli.Helpers.WinAppParserConfiguration.Default); + + // Expected telemetry ordering: CommandInvoked fires, the command runs, then CommandCompleted fires. + private static readonly string[] ExpectedTelemetrySequence = ["invoked", "invoke", "completed"]; + + [TestMethod] + public void ConfigureConsoleEncoding_NonThrowingAction_IsApplied() + { + var applied = false; + + Program.ConfigureConsoleEncoding(() => applied = true); + + Assert.IsTrue(applied, "The encoding mutation must run when it does not throw."); + } + + [TestMethod] + public void ConfigureConsoleEncoding_ThrowingAction_IsSwallowed() + { + // Setting console encoding throws on redirected/unsupported handles; startup must survive it. + Program.ConfigureConsoleEncoding(() => throw new IOException("encoding not supported")); + } + + [TestMethod] + public async Task RunWithTelemetryAsync_CompleteMode_ReturnsInvokeResult() + { + var invoked = false; + var invokedLogged = false; + var completedLogged = false; + + var result = await Program.RunWithTelemetryAsync(ParseEmptyRoot(), isCompleteMode: true, + invoke: () => + { + invoked = true; + return Task.FromResult(3); + }, + logCommandInvoked: _ => invokedLogged = true, + logCommandCompleted: (_, _) => completedLogged = true); + + Assert.AreEqual(3, result); + Assert.IsTrue(invoked, "The command must still be invoked in completion mode."); + Assert.IsFalse(invokedLogged, "Completion mode must not emit CommandInvoked telemetry."); + Assert.IsFalse(completedLogged, "Completion mode must not emit CommandCompleted telemetry."); + } + + [TestMethod] + public async Task RunWithTelemetryAsync_NonCompleteMode_LogsTelemetryAndReturnsResult() + { + var parseResult = ParseEmptyRoot(); + var expectedCommandResult = parseResult.CommandResult; + var sequence = new List(); + System.CommandLine.Parsing.CommandResult? invokedWith = null; + System.CommandLine.Parsing.CommandResult? completedWith = null; + var completedExitCode = int.MinValue; + + var result = await Program.RunWithTelemetryAsync(parseResult, isCompleteMode: false, + invoke: () => + { + sequence.Add("invoke"); + return Task.FromResult(7); + }, + logCommandInvoked: cr => + { + sequence.Add("invoked"); + invokedWith = cr; + }, + logCommandCompleted: (cr, code) => + { + sequence.Add("completed"); + completedWith = cr; + completedExitCode = code; + }); + + Assert.AreEqual(7, result); + + // Telemetry must bracket the invocation: CommandInvoked before running, CommandCompleted after. + CollectionAssert.AreEqual(ExpectedTelemetrySequence, sequence, + "Invoked telemetry fires before the command runs and completed telemetry fires after it returns."); + Assert.AreSame(expectedCommandResult, invokedWith, + "CommandInvoked telemetry must carry the parsed command result."); + Assert.AreSame(expectedCommandResult, completedWith, + "CommandCompleted telemetry must carry the parsed command result."); + Assert.AreEqual(7, completedExitCode, + "CommandCompleted telemetry must report the real exit code returned by the command."); + } + + [TestMethod] + public async Task RunWithTelemetryAsync_InvokeThrows_ReturnsOneAndWritesError() + { + var originalErr = Console.Error; + var stderr = new StringWriter(); + var invokedLogged = false; + var completedLogged = false; + try + { + Console.SetError(stderr); + + var result = await Program.RunWithTelemetryAsync(ParseEmptyRoot(), isCompleteMode: false, + invoke: () => throw new InvalidOperationException("boom"), + logCommandInvoked: _ => invokedLogged = true, + logCommandCompleted: (_, _) => completedLogged = true); + + Assert.AreEqual(1, result); + Assert.IsTrue(stderr.ToString().Contains("An unexpected error occurred: boom", StringComparison.Ordinal), + $"The top-level handler must surface the failure message. Got: {stderr}"); + } + finally + { + Console.SetError(originalErr); + } + + // Invoked telemetry fires before the throwing invocation; completed telemetry must be skipped. + Assert.IsTrue(invokedLogged, "CommandInvoked telemetry fires before the command that throws."); + Assert.IsFalse(completedLogged, "CommandCompleted telemetry must not fire when the invocation throws."); + } +} From 30ef112427fdc8c04935e686dac179b6a1eee54d Mon Sep 17 00:00:00 2001 From: Akhil Gupta Date: Wed, 7 Oct 2026 21:09:28 -0700 Subject: [PATCH 08/21] Delete src/winapp-CLI/WinApp.Cli.Tests/ProgramTests.cs --- .../WinApp.Cli.Tests/ProgramTests.cs | 332 ------------------ 1 file changed, 332 deletions(-) delete mode 100644 src/winapp-CLI/WinApp.Cli.Tests/ProgramTests.cs diff --git a/src/winapp-CLI/WinApp.Cli.Tests/ProgramTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/ProgramTests.cs deleted file mode 100644 index 68af6dae7..000000000 --- a/src/winapp-CLI/WinApp.Cli.Tests/ProgramTests.cs +++ /dev/null @@ -1,332 +0,0 @@ -// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. -// Licensed under the MIT License. - -using Microsoft.Extensions.Logging; -using WinApp.Cli; -using WinApp.Cli.Commands; -using WinApp.Cli.ExecutionTargets.Abstractions; - -namespace WinApp.Cli.Tests; - -[TestClass] -public class ProgramResolveLoggingModeTests -{ - [TestMethod] - public void ResolveLoggingMode_NoFlags_DefaultsToInformation() - { - var mode = Program.ResolveLoggingMode(["init"]); - - Assert.AreEqual(LogLevel.Information, mode.MinimumLevel); - Assert.IsFalse(mode.Quiet); - Assert.IsFalse(mode.Verbose); - Assert.IsFalse(mode.Json); - Assert.IsNull(mode.ConflictError); - } - - [TestMethod] - [DataRow("--verbose")] - [DataRow("-v")] - public void ResolveLoggingMode_Verbose_SetsDebug(string flag) - { - var mode = Program.ResolveLoggingMode([flag]); - - Assert.AreEqual(LogLevel.Debug, mode.MinimumLevel); - Assert.IsTrue(mode.Verbose); - Assert.IsNull(mode.ConflictError); - } - - [TestMethod] - [DataRow("--quiet")] - [DataRow("-q")] - public void ResolveLoggingMode_Quiet_SetsWarning(string flag) - { - var mode = Program.ResolveLoggingMode([flag]); - - Assert.AreEqual(LogLevel.Warning, mode.MinimumLevel); - Assert.IsTrue(mode.Quiet); - Assert.IsNull(mode.ConflictError); - } - - [TestMethod] - public void ResolveLoggingMode_Json_SetsNone() - { - var mode = Program.ResolveLoggingMode(["--json"]); - - Assert.AreEqual(LogLevel.None, mode.MinimumLevel); - Assert.IsTrue(mode.Json); - Assert.IsNull(mode.ConflictError); - } - - [TestMethod] - public void ResolveLoggingMode_QuietAndVerbose_ReportsConflict() - { - var mode = Program.ResolveLoggingMode(["--quiet", "--verbose"]); - - Assert.AreEqual("Cannot specify both --quiet and --verbose options together.", mode.ConflictError); - } - - [TestMethod] - public void ResolveLoggingMode_QuietAndJson_ReportsConflict() - { - var mode = Program.ResolveLoggingMode(["--quiet", "--json"]); - - Assert.AreEqual("Cannot specify both --quiet and --json options together.", mode.ConflictError); - } - - [TestMethod] - public void ResolveLoggingMode_VerboseAndJson_ReportsConflict() - { - var mode = Program.ResolveLoggingMode(["--verbose", "--json"]); - - Assert.AreEqual("Cannot specify both --verbose and --json options together.", mode.ConflictError); - } - - [TestMethod] - public void ResolveLoggingMode_FlagAfterDoubleDash_IsIgnored() - { - // Tokens after a standalone "--" are passthrough payload, not winapp global flags. - var mode = Program.ResolveLoggingMode(["run", ".", "--", "--json"]); - - Assert.IsFalse(mode.Json, "--json after -- must be treated as passthrough, not a global flag."); - Assert.AreEqual(LogLevel.Information, mode.MinimumLevel); - } -} - -[TestClass] -[DoNotParallelize] // Invokes Program.Main, which mutates static Console streams and env vars. -public class ProgramMainTests -{ - private string _tempCacheDir = null!; - private string? _savedCacheDir; - private string? _savedUpdateCheck; - private string? _savedCaller; - - private Dictionary _savedCiVars = []; - - [TestInitialize] - public void Setup() - { - _tempCacheDir = Path.Combine(Path.GetTempPath(), $"winapp_program_test_{Guid.NewGuid():N}"); - Directory.CreateDirectory(_tempCacheDir); - // Mark first-run complete and disable update checks so Main runs deterministically offline. - File.Create(Path.Combine(_tempCacheDir, ".first-run-complete")).Dispose(); - - _savedCacheDir = Environment.GetEnvironmentVariable("WINAPP_CLI_CACHE_DIRECTORY"); - _savedUpdateCheck = Environment.GetEnvironmentVariable("WINAPP_CLI_UPDATE_CHECK"); - _savedCaller = Environment.GetEnvironmentVariable("WINAPP_CLI_CALLER"); - _savedCiVars = ProgramMainTestHarness.CiVarNames.ToDictionary(name => name, name => Environment.GetEnvironmentVariable(name)); - - Environment.SetEnvironmentVariable("WINAPP_CLI_CACHE_DIRECTORY", _tempCacheDir); - Environment.SetEnvironmentVariable("WINAPP_CLI_UPDATE_CHECK", "0"); - Environment.SetEnvironmentVariable("WINAPP_CLI_CALLER", null); - foreach (var name in ProgramMainTestHarness.CiVarNames) - { - Environment.SetEnvironmentVariable(name, null); - } - } - - [TestCleanup] - public void Cleanup() - { - Environment.SetEnvironmentVariable("WINAPP_CLI_CACHE_DIRECTORY", _savedCacheDir); - Environment.SetEnvironmentVariable("WINAPP_CLI_UPDATE_CHECK", _savedUpdateCheck); - Environment.SetEnvironmentVariable("WINAPP_CLI_CALLER", _savedCaller); - foreach (var (name, value) in _savedCiVars) - { - Environment.SetEnvironmentVariable(name, value); - } - - try { Directory.Delete(_tempCacheDir, recursive: true); } catch { /* best effort */ } - } - - [TestMethod] - public async Task Main_NoArguments_ShowsBannerAndHelp_ReturnsZero() - { - var (stdout, _, exitCode) = await ProgramMainTestHarness.InvokeProgramAsync([]); - - Assert.AreEqual(0, exitCode); - Assert.IsTrue(stdout.Contains("Windows App Development CLI", StringComparison.Ordinal), - $"No-args invocation must print the banner. Got stdout: {stdout}"); - } - - [TestMethod] - public async Task Main_SingleDashLongOptionTypo_PrintsSuggestion_ReturnsOne() - { - var (_, stderr, exitCode) = await ProgramMainTestHarness.InvokeProgramAsync(["ui", "inspect", "-app", "some-app"]); - - Assert.AreEqual(1, exitCode); - Assert.IsTrue(stderr.Contains("Did you mean", StringComparison.Ordinal), - $"A single-dash long-option typo must suggest the double-dash form. Got stderr: {stderr}"); - Assert.IsTrue(stderr.Contains("--app", StringComparison.Ordinal)); - } - - [TestMethod] - public async Task Main_TargetOptionLikePositional_WithJson_EmitsTargetEnvelope() - { - var (stdout, stderr, exitCode) = await ProgramMainTestHarness.InvokeProgramAsync( - ["target", "push", "sandbox", "--sorce", @".\Setup", "--json"]); - - Assert.AreEqual(TargetOutput.InvalidCommandLineExitCode, exitCode); - Assert.AreEqual(string.Empty, stdout); - - using var document = System.Text.Json.JsonDocument.Parse(stderr); - Assert.AreEqual( - ExecutionTargetErrorCodes.TargetInvalidArguments, - document.RootElement.GetProperty("error").GetProperty("code").GetString()); - } - - [TestMethod] - public async Task Main_ConflictingLoggingFlags_PrintsError_ReturnsOne() - { - var (_, stderr, exitCode) = await ProgramMainTestHarness.InvokeProgramAsync(["--quiet", "--verbose"]); - - Assert.AreEqual(1, exitCode); - Assert.IsTrue(stderr.Contains("Cannot specify both --quiet and --verbose options together.", StringComparison.Ordinal), - $"Conflicting logging flags must be rejected. Got stderr: {stderr}"); - } - - [TestMethod] - public async Task Main_CliSchemaWithCaller_SetsCallerEnvVarAndReturnsZero() - { - var (stdout, _, exitCode) = await ProgramMainTestHarness.InvokeProgramAsync(["--cli-schema", "--caller", "test-caller"]); - - Assert.AreEqual(0, exitCode); - Assert.AreEqual("test-caller", Environment.GetEnvironmentVariable("WINAPP_CLI_CALLER"), - "The --caller value must be promoted to the WINAPP_CLI_CALLER environment variable."); - Assert.IsTrue(stdout.Contains('{', StringComparison.Ordinal), - $"--cli-schema must emit machine-readable JSON. Got: {stdout}"); - } - - [TestMethod] - public async Task Main_UnknownCommand_FallsThroughToInvocation_WithoutTypoSuggestion() - { - // A bare unknown command produces parse errors but is not a single-dash typo, so the typo - // shortcut is skipped and the parsed args flow into the normal invocation path. - var (_, stderr, exitCode) = await ProgramMainTestHarness.InvokeProgramAsync(["this-is-not-a-real-command"]); - - Assert.AreNotEqual(0, exitCode, "An unknown command must not succeed."); - Assert.IsFalse(stderr.Contains("Did you mean", StringComparison.Ordinal), - $"A non-dash unknown command must not trigger the single-dash typo hint. Got stderr: {stderr}"); - } -} - -[TestClass] -[DoNotParallelize] // The RunWithTelemetryAsync failure test redirects the static Console.Error stream. -public class ProgramSeamTests -{ - private static System.CommandLine.ParseResult ParseEmptyRoot() => - new System.CommandLine.RootCommand("test").Parse([], WinApp.Cli.Helpers.WinAppParserConfiguration.Default); - - // Expected telemetry ordering: CommandInvoked fires, the command runs, then CommandCompleted fires. - private static readonly string[] ExpectedTelemetrySequence = ["invoked", "invoke", "completed"]; - - [TestMethod] - public void ConfigureConsoleEncoding_NonThrowingAction_IsApplied() - { - var applied = false; - - Program.ConfigureConsoleEncoding(() => applied = true); - - Assert.IsTrue(applied, "The encoding mutation must run when it does not throw."); - } - - [TestMethod] - public void ConfigureConsoleEncoding_ThrowingAction_IsSwallowed() - { - // Setting console encoding throws on redirected/unsupported handles; startup must survive it. - Program.ConfigureConsoleEncoding(() => throw new IOException("encoding not supported")); - } - - [TestMethod] - public async Task RunWithTelemetryAsync_CompleteMode_ReturnsInvokeResult() - { - var invoked = false; - var invokedLogged = false; - var completedLogged = false; - - var result = await Program.RunWithTelemetryAsync(ParseEmptyRoot(), isCompleteMode: true, - invoke: () => - { - invoked = true; - return Task.FromResult(3); - }, - logCommandInvoked: _ => invokedLogged = true, - logCommandCompleted: (_, _) => completedLogged = true); - - Assert.AreEqual(3, result); - Assert.IsTrue(invoked, "The command must still be invoked in completion mode."); - Assert.IsFalse(invokedLogged, "Completion mode must not emit CommandInvoked telemetry."); - Assert.IsFalse(completedLogged, "Completion mode must not emit CommandCompleted telemetry."); - } - - [TestMethod] - public async Task RunWithTelemetryAsync_NonCompleteMode_LogsTelemetryAndReturnsResult() - { - var parseResult = ParseEmptyRoot(); - var expectedCommandResult = parseResult.CommandResult; - var sequence = new List(); - System.CommandLine.Parsing.CommandResult? invokedWith = null; - System.CommandLine.Parsing.CommandResult? completedWith = null; - var completedExitCode = int.MinValue; - - var result = await Program.RunWithTelemetryAsync(parseResult, isCompleteMode: false, - invoke: () => - { - sequence.Add("invoke"); - return Task.FromResult(7); - }, - logCommandInvoked: cr => - { - sequence.Add("invoked"); - invokedWith = cr; - }, - logCommandCompleted: (cr, code) => - { - sequence.Add("completed"); - completedWith = cr; - completedExitCode = code; - }); - - Assert.AreEqual(7, result); - - // Telemetry must bracket the invocation: CommandInvoked before running, CommandCompleted after. - CollectionAssert.AreEqual(ExpectedTelemetrySequence, sequence, - "Invoked telemetry fires before the command runs and completed telemetry fires after it returns."); - Assert.AreSame(expectedCommandResult, invokedWith, - "CommandInvoked telemetry must carry the parsed command result."); - Assert.AreSame(expectedCommandResult, completedWith, - "CommandCompleted telemetry must carry the parsed command result."); - Assert.AreEqual(7, completedExitCode, - "CommandCompleted telemetry must report the real exit code returned by the command."); - } - - [TestMethod] - public async Task RunWithTelemetryAsync_InvokeThrows_ReturnsOneAndWritesError() - { - var originalErr = Console.Error; - var stderr = new StringWriter(); - var invokedLogged = false; - var completedLogged = false; - try - { - Console.SetError(stderr); - - var result = await Program.RunWithTelemetryAsync(ParseEmptyRoot(), isCompleteMode: false, - invoke: () => throw new InvalidOperationException("boom"), - logCommandInvoked: _ => invokedLogged = true, - logCommandCompleted: (_, _) => completedLogged = true); - - Assert.AreEqual(1, result); - Assert.IsTrue(stderr.ToString().Contains("An unexpected error occurred: boom", StringComparison.Ordinal), - $"The top-level handler must surface the failure message. Got: {stderr}"); - } - finally - { - Console.SetError(originalErr); - } - - // Invoked telemetry fires before the throwing invocation; completed telemetry must be skipped. - Assert.IsTrue(invokedLogged, "CommandInvoked telemetry fires before the command that throws."); - Assert.IsFalse(completedLogged, "CommandCompleted telemetry must not fire when the invocation throws."); - } -} From e88cba2b1e2acd5ae3206f050c7266f14573d8fb Mon Sep 17 00:00:00 2001 From: Akhil Gupta Date: Wed, 7 Oct 2026 21:09:50 -0700 Subject: [PATCH 09/21] Rename winapp-ProgramTests.cs to ProgramTests.cs --- .../WinApp.Cli.Tests/{winapp-ProgramTests.cs => ProgramTests.cs} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/winapp-CLI/WinApp.Cli.Tests/{winapp-ProgramTests.cs => ProgramTests.cs} (100%) diff --git a/src/winapp-CLI/WinApp.Cli.Tests/winapp-ProgramTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/ProgramTests.cs similarity index 100% rename from src/winapp-CLI/WinApp.Cli.Tests/winapp-ProgramTests.cs rename to src/winapp-CLI/WinApp.Cli.Tests/ProgramTests.cs From 8cb5472f42e75b9f8f654cbc640871d8d333f255 Mon Sep 17 00:00:00 2001 From: Akhil Gupta Date: Wed, 7 Oct 2026 21:11:51 -0700 Subject: [PATCH 10/21] Upload winapp-Program-rebased.cs (intermediate step) --- .../WinApp.Cli/winapp-Program-rebased.cs | 729 ++++++++++++++++++ 1 file changed, 729 insertions(+) create mode 100644 src/winapp-CLI/WinApp.Cli/winapp-Program-rebased.cs diff --git a/src/winapp-CLI/WinApp.Cli/winapp-Program-rebased.cs b/src/winapp-CLI/WinApp.Cli/winapp-Program-rebased.cs new file mode 100644 index 000000000..7523ffcae --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/winapp-Program-rebased.cs @@ -0,0 +1,729 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Spectre.Console; +using System.Text; +using WinApp.Cli.Commands; +using WinApp.Cli.Helpers; +using WinApp.Cli.Models; +using WinApp.Cli.Services; +using WinApp.Cli.Telemetry; +using WinApp.Cli.Telemetry.Events; +using LogLevel = Microsoft.Extensions.Logging.LogLevel; + +namespace WinApp.Cli; + +internal static class Program +{ + internal static async Task Main(string[] args) + { + // Hidden internal verb: the WinUI DbgEng triage pass runs in this isolated child process so + // its modern dbgeng.dll is not poisoned by the system32 dbghelp.dll the parent already loaded. + // Intercept before any host/service setup to keep the loader state clean and output noise-free. + if (args.Length > 0 && args[0] == Services.XamlTriageRunner.InternalVerb) + { + return Services.XamlTriageRunner.Run(args); + } + + // Ensure UTF-8 I/O for emoji-capable terminals; fall back silently if not supported + ConfigureConsoleEncoding(static () => + { + Console.OutputEncoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); + Console.InputEncoding = Encoding.UTF8; + }); + + var loggingMode = ResolveLoggingMode(args); + if (loggingMode.ConflictError is not null) + { + Console.Error.WriteLine(loggingMode.ConflictError); + return 1; + } + + var minimumLogLevel = loggingMode.MinimumLevel; + bool quiet = loggingMode.Quiet; + bool json = loggingMode.Json; + + // Check if --cli-schema is specified - this outputs machine-readable JSON + // and should not display any interactive messages like first-run notices + bool isCliSchemaMode = GlobalOptionPreScan.IsFlagPresent( + args, WinAppRootCommand.CliSchemaOption.Name, []); + + // Check if this is a completion request - completions must be fast and silent + bool isCompleteMode = args.Length > 0 && args[0] == "complete"; + + var services = new ServiceCollection() + .ConfigureServices() + .ConfigureCommands() + .AddLogging(b => + { + b.ClearProviders(); + b.AddTextWriterLogger(Console.Out, Console.Error); + b.SetMinimumLevel(minimumLogLevel); + }); + + using var serviceProvider = services.BuildServiceProvider(); + + var rootCommand = serviceProvider.GetRequiredService(); + System.CommandLine.ParseResult? parseResult = null; + + if (args.Length > 0) + { + parseResult = rootCommand.Parse(args, WinAppParserConfiguration.Default); + + // Infrastructure processes are not separate user invocations. Set this before any + // telemetry instance is created; descendants inherit the winapp-only opt-out. + if (parseResult.CommandResult.Command is GuestAgentCommand) + { + Environment.SetEnvironmentVariable(Telemetry.Telemetry.OptOutEnvironmentVariable, "1"); + } + + // Set WINAPP_CLI_CALLER env var from --caller option so telemetry and update checks can use it + var caller = parseResult.GetValue(WinAppRootCommand.CallerOption); + if (!string.IsNullOrWhiteSpace(caller)) + { + Environment.SetEnvironmentVariable("WINAPP_CLI_CALLER", caller); + } + + // Reject an invalid '='-attached value on ANY boolean option reachable by the selected + // command — a global flag like --json=bogus or a command flag like --eraser=bogus. + // System.CommandLine silently coerces such a value to TRUE with no parse error, which + // would (a) silently enable a flag the user never set (e.g. the pen eraser) and (b) for + // --json leave logging un-suppressed, so the human ❌ line and the JSON envelope BOTH hit + // stderr and corrupt machine-readable output. Fail fast HERE — before the first-run / + // update notice can print anything (M1) — with a single clean invalid_arguments error, + // mirroring how the parser already rejects other malformed values (e.g. --pressure nope). + if (TryFindInvalidBooleanOption(parseResult, args, out var invalidBoolOption, out var invalidBoolValue)) + { + var message = + $"Cannot parse argument '{invalidBoolValue}' for option '{invalidBoolOption}' as expected type 'System.Boolean'."; + if (IsNewCommandJson(parseResult)) + { + // `new` owns a bespoke NewCommandResult envelope; emit it (and use its + // invalid-argument exit code) so machine callers get structured output here too. + NewCommand.EmitParseErrorJson(message); + return NewCommand.ExitInvalidArgs; + } + if (ResolveEffectiveJson(parseResult) && IsUiDescendant(parseResult)) + { + UiJsonError.Emit(true, UiJsonError.CodeInvalidArguments, message); + } + else if (ResolveEffectiveJson(parseResult) && IsFlatJsonErrorCommand(parseResult)) + { + EmitFlatJsonError(message); + } + else if (ResolveEffectiveJson(parseResult) && IsTargetDescendant(parseResult)) + { + TargetOutput.RejectCommandLine(message); + } + else + { + Console.Error.WriteLine(message); + } + + return 1; + } + + // `winapp ui ` fails before anything else, even with --help or --on: help is a + // terminating action, so the command line parses cleanly and would otherwise print the + // group help with exit 0, or route a command that does not exist to another target. + if (UiUnknownCommand.Find(parseResult) is { } unknownUiCommand) + { + var suggestions = UiUnknownCommand.Suggest(unknownUiCommand, parseResult.CommandResult.Command.Subcommands); + if (ResolveEffectiveJson(parseResult)) + { + UiJsonError.Emit(true, UiJsonError.CodeInvalidArguments, UiUnknownCommand.Message(unknownUiCommand), + recoveryHint: UiUnknownCommand.RecoveryHint, suggestions: suggestions); + } + else + { + UiUnknownCommand.WriteText(Console.Error, unknownUiCommand, suggestions); + } + + return 1; + } + } + + // Skip first-run notice for machine-readable output modes and completions + var didShowFirstRunNotice = false; + if (!isCliSchemaMode && !isCompleteMode && !json) + { + var firstRunService = serviceProvider.GetRequiredService(); + didShowFirstRunNotice = firstRunService.CheckAndDisplayFirstRunNotice(); + + // Check for CLI updates — shows cached notice instantly (no network), + // and starts a background refresh if the cache is stale (fire-and-forget). + if (!quiet) + { + var updateNotificationService = serviceProvider.GetRequiredService(); + updateNotificationService.CheckAndNotify(); + } + } + + // If no arguments provided, display banner and show help + if (args.Length == 0) + { + if (!didShowFirstRunNotice) + { + BannerHelper.DisplayBanner(); + } + + // Show help by invoking with --help + await rootCommand.Parse(["--help"], WinAppParserConfiguration.Default).InvokeAsync(); + return 0; + } + + var parsedArgs = parseResult!; + + // Derive the effective JSON mode from the SELECTED command's PARSED --json value (M1). + // The pre-scan (json) is value-aware but still a heuristic; the parsed value is the truth. + // Reading the parsed bool works even when a different option (e.g. --pressure) failed parse. + bool effectiveJson = ResolveEffectiveJson(parsedArgs); + + // Catch single-dash typos like "-app" before invocation so the user gets a clear + // "Did you mean --app?" message instead of System.CommandLine's confusing + // "Unrecognized command or argument" pointing at the wrong token (issue #467). + // Only run when parsing already failed — otherwise a command that legitimately + // accepts a "-foo"-shaped positional value would get a false-positive typo error. + if (parsedArgs.Errors.Count > 0) + { + var typo = OptionTypoValidator.FindLikelyLongOptionTypo(args, parsedArgs); + if (typo is not null) + { + var suggested = "-" + typo; + var typoMessage = $"Unknown option '{typo}'. Did you mean '{suggested}'?"; + if (IsNewCommandJson(parsedArgs)) + { + // Route `new --json` typo failures through its structured envelope with paired + // telemetry, mirroring the ui branch below and the main parse-error bridge. + if (!isCompleteMode) + { + CommandInvokedEvent.Log(parsedArgs.CommandResult); + } + NewCommand.EmitParseErrorJson(typoMessage); + if (!isCompleteMode) + { + CommandCompletedEvent.Log(parsedArgs.CommandResult, NewCommand.ExitInvalidArgs); + } + return NewCommand.ExitInvalidArgs; + } + if (effectiveJson && IsUiDescendant(parsedArgs)) + { + // Gate on IsUiDescendant so that non-ui commands (e.g. cert info) fall through + // to default error handling instead of receiving the nested UI schema (M2). + if (!isCompleteMode) + { + CommandInvokedEvent.Log(parsedArgs.CommandResult); + } + UiJsonError.Emit(true, UiJsonError.CodeInvalidArguments, typoMessage); + if (!isCompleteMode) + { + CommandCompletedEvent.Log(parsedArgs.CommandResult, 1); + } + } + else if (effectiveJson && IsFlatJsonErrorCommand(parsedArgs)) + { + // find-ui/find-api emit all their JSON (results and errors) on stdout as a flat + // {"error":"..."} object — keep parser-level failures on that same contract. + if (!isCompleteMode) + { + CommandInvokedEvent.Log(parsedArgs.CommandResult); + } + EmitFlatJsonError($"Unknown option '{typo}'. Did you mean '{suggested}'?"); + if (!isCompleteMode) + { + CommandCompletedEvent.Log(parsedArgs.CommandResult, 1); + } + } + else if (effectiveJson && IsTargetDescendant(parsedArgs)) + { + if (!isCompleteMode) + { + CommandInvokedEvent.Log(parsedArgs.CommandResult); + } + TargetOutput.RejectCommandLine(typoMessage); + if (!isCompleteMode) + { + CommandCompletedEvent.Log(parsedArgs.CommandResult, 1); + } + } + else + { + Console.Error.WriteLine(typoMessage); + Console.Error.WriteLine( + "(Single-dash flags are reserved for short aliases like '-a'. Long options use a double dash.)"); + } + return 1; + } + } + + return await RunWithTelemetryAsync(parsedArgs, isCompleteMode, async () => + { + // Target selection is settled before anything else, and settled for every command. + // A command that cannot honour --on says so, and a selector that names nothing usable + // fails here — never silently on this desktop, which is the one outcome the option + // exists to prevent. + if (ExecutionTargetSelection.Validate(parsedArgs) is { } selectionError) + { + return TargetOutput.RejectSelection( + serviceProvider.GetRequiredService(), effectiveJson, selectionError); + } + + // System.CommandLine binds an unrecognised option to a nearby optional positional rather + // than failing, so `--onn=sandbox` would become a UI element selector and the command + // would run here and report success. Rejected before dispatch for every command except + // `run`, whose own handler already reports the same mistake with the passthrough advice + // its `--` separator needs and in its own --json shape. + if (parsedArgs.CommandResult.Command is not RunCommand && + WindowsCommandLine.FindOptionLikePositionals(parsedArgs) is { Count: > 0 } stray) + { + return RejectOptionLikePositionals(parsedArgs, stray, effectiveJson); + } + + // One pre-dispatch interception, before any local UI service runs. A command the user + // sent to another target must not perform UI Automation, window discovery, capture, or + // input injection on this desktop, and the only way to guarantee that for every verb is + // to divert before the handler exists. + // + // Only for a command line that actually parsed. Routing a broken one first means + // `winapp ui inspect --on sandbox --depth notanumber` boots a Sandbox, spends minutes + // preparing it, and then reports a transport failure that never mentions `--depth` — + // and leaves the Sandbox running. The ordinary parse-error path is both faster and + // truthful, and it runs on this machine only because it runs nothing at all. + if (parsedArgs.Errors.Count == 0 && ExecutionTargetUiRouter.ShouldRoute(parsedArgs)) + { + var router = serviceProvider.GetRequiredService(); + return await router.RouteAsync( + args, + TargetUiRequirements.For(parsedArgs), + effectiveJson, + CancellationToken.None); + } + + if (parsedArgs.Action is System.CommandLine.Invocation.ParseErrorAction parseError + && IsUiDescendant(parsedArgs) + && parsedArgs.CommandResult.Command is not UiCommand) + { + // The error names the mistake; the full ui command help after it would bury that + // error, so point at --help instead. A bare "winapp ui" keeps its help: that is + // how people discover the commands. + parseError.ShowHelp = false; + return InvokeWithHelpPointerAsync(parsedArgs); + } + + // Parse errors must exit non-zero (issue #1015). InvokeAsync displays + // the error and help text, but return 1 explicitly so scripts and + // agents don't treat a rejected command line as success. + var invokeExitCode = await parsedArgs.InvokeAsync(); + if (parsedArgs.Errors.Count > 0 && invokeExitCode == 0) + { + return 1; + } + return invokeExitCode; + }); + } + + private static async Task InvokeWithHelpPointerAsync(System.CommandLine.ParseResult parsedArgs) + { + var exitCode = await parsedArgs.InvokeAsync(); + var path = string.Join(" ", parsedArgs.CommandResult.Command.Parents + .OfType() + .Where(c => c is not System.CommandLine.RootCommand) + .Select(c => c.Name) + .Prepend(parsedArgs.CommandResult.Command.Name) + .Reverse()); + parsedArgs.InvocationConfiguration.Error.WriteLine($"Run 'winapp {path} --help' for usage."); + return exitCode; + } + + /// + /// Reports positional values that were really misspelt options, and returns the exit code. + /// + /// + /// Rendered in whichever error shape the invoked command promises, so a --json caller + /// still gets a parseable document rather than a bare line on stderr. + /// + private static int RejectOptionLikePositionals( + System.CommandLine.ParseResult parsedArgs, + IReadOnlyList stray, + bool effectiveJson) + { + var message = stray.Count == 1 + ? $"Unrecognized option '{stray[0]}'." + : $"Unrecognized options: {string.Join(", ", stray.Select(token => $"'{token}'"))}."; + + var advice = "Check the spelling, or put the value after '--' if it really is an argument."; + + if (effectiveJson && IsUiDescendant(parsedArgs)) + { + UiJsonError.Emit(true, UiJsonError.CodeInvalidArguments, $"{message} {advice}"); + } + else if (effectiveJson && IsFlatJsonErrorCommand(parsedArgs)) + { + EmitFlatJsonError($"{message} {advice}"); + } + else if (effectiveJson && IsTargetDescendant(parsedArgs)) + { + TargetOutput.RejectCommandLine($"{message} {advice}"); + } + else + { + Console.Error.WriteLine(message); + Console.Error.WriteLine(advice); + } + + // The same code every other malformed winapp command line returns. This is a parse mistake, + // not a failure to reach a target, and conflating the two would make an unknown option look + // like an unavailable machine. + return TargetOutput.InvalidCommandLineExitCode; + } + + /// + /// Applies the given console-encoding mutation, swallowing the platform exception that occurs + /// when the standard stream encoding cannot be changed (e.g. redirected or unsupported handles). + /// UTF-8 I/O is a best-effort nicety, so a failure here must never abort startup. + /// + internal static void ConfigureConsoleEncoding(Action apply) + { + try + { + apply(); + } + catch + { + // ignore + } + } + + /// + /// Runs the parsed command via , emitting command-invoked/-completed + /// telemetry (unless in completion mode) and converting any unhandled exception into a logged + /// error plus exit code 1. The seam lets tests exercise both the + /// success and top-level-failure paths without needing a real, throwing command. + /// + internal static Task RunWithTelemetryAsync( + System.CommandLine.ParseResult parsedArgs, bool isCompleteMode, Func> invoke) => + RunWithTelemetryAsync(parsedArgs, isCompleteMode, invoke, CommandInvokedEvent.Log, CommandCompletedEvent.Log); + + /// + /// Core of + /// with the telemetry sinks injected. and + /// default (via the public overload) to the production + /// / events; the seam + /// lets tests assert that the invoked/completed events fire around the invocation — in order, with + /// the parsed command result and real exit code, and only when not in completion mode. + /// + internal static async Task RunWithTelemetryAsync( + System.CommandLine.ParseResult parsedArgs, bool isCompleteMode, Func> invoke, + Action logCommandInvoked, + Action logCommandCompleted) + { + try + { + // Start a correlation scope so the CommandInvoked/CommandCompleted events and any + // command-specific event a handler emits (e.g. NewCommand_Event) share one + // relatedActivityId for this invocation. + TelemetryCorrelation.Begin(); + + // Open the coordination scope in the same async flow that reads it below. A `winapp ui` + // command sets its cooperative-turn summary from deep inside invoke(), and + // CommandCompletedEvent picks it up here (issue #764). + Services.InteractiveDesktop.UiCoordinationTelemetryScope.Begin(); + + if (!isCompleteMode) + { + logCommandInvoked(parsedArgs.CommandResult); + } + + bool effectiveJson = ResolveEffectiveJson(parsedArgs); + + // Parse-error → JSON bridge: activated only when the SELECTED command exposes --json, + // its parsed value is true (effectiveJson), AND the command is a ui descendant (M3). + // Non-ui commands (e.g. cert info) use a flat {"error":"..."} schema — do not impose + // the UI nested contract on them; let SCL's default parse-error handling run instead. + // M3: emit CommandCompletedEvent before the early return to keep telemetry paired. + if (effectiveJson && parsedArgs.Errors.Count > 0 && IsUiDescendant(parsedArgs)) + { + var errorMsg = string.Join("; ", parsedArgs.Errors.Select(e => e.Message)); + UiJsonError.Emit(true, UiJsonError.CodeInvalidArguments, errorMsg); + if (!isCompleteMode) + { + logCommandCompleted(parsedArgs.CommandResult, 1); + } + return 1; + } + + // Parse-error → JSON bridge for `winapp new`: it owns a bespoke NewCommandResult schema + // (not the ui error envelope), so a parse failure like `--template bogus --json` must be + // reported in that shape rather than falling through to SCL's help/error text on stdout. + // Return ExitInvalidArgs (2) — the same code the handler uses for invalid names/versions — + // so agents can branch on the invalid-argument stage regardless of where it was caught. + if (effectiveJson && parsedArgs.Errors.Count > 0 + && parsedArgs.CommandResult.Command.Name == "new") + { + var errorMsg = string.Join("; ", parsedArgs.Errors.Select(e => e.Message)); + NewCommand.EmitParseErrorJson(errorMsg); + if (!isCompleteMode) + { + logCommandCompleted(parsedArgs.CommandResult, NewCommand.ExitInvalidArgs); + } + return NewCommand.ExitInvalidArgs; + } + + // Discovery parse-error → JSON bridge. find-ui and find-api both document a --json + // contract (results and in-handler validation errors all emit JSON on stdout), but a + // type/parser error such as `--max abc` fails before the handler runs, so + // System.CommandLine would otherwise print human help text. Emit the same flat + // {"error":"..."} object here so every --json path stays machine-readable (#719). + if (effectiveJson && parsedArgs.Errors.Count > 0 && IsFlatJsonErrorCommand(parsedArgs)) + { + var errorMsg = string.Join("; ", parsedArgs.Errors.Select(e => e.Message)); + EmitFlatJsonError(errorMsg); + if (!isCompleteMode) + { + logCommandCompleted(parsedArgs.CommandResult, 1); + } + return 1; + } + + // Parse-error → JSON bridge for the `target` verbs, which own their own error envelope + // ({"error":{"code":...}} on stderr). Same reasoning as the ui bridge: a caller that + // passed --json must never have to read human help text off stdout to find out that its + // command line was wrong. + if (effectiveJson && parsedArgs.Errors.Count > 0 && IsTargetDescendant(parsedArgs)) + { + var errorMsg = string.Join("; ", parsedArgs.Errors.Select(e => e.Message)); + var exitCode = TargetOutput.RejectCommandLine(errorMsg); + if (!isCompleteMode) + { + logCommandCompleted(parsedArgs.CommandResult, exitCode); + } + return exitCode; + } + + var returnCode = await invoke(); + + if (!isCompleteMode) + { + logCommandCompleted(parsedArgs.CommandResult, returnCode); + } + + return returnCode; + } + catch (Exception ex) + { + TelemetryFactory.Get().LogException(parsedArgs.CommandResult.Command.Name, ex); + Console.Error.WriteLine($"An unexpected error occurred: {ex.Message}"); + return 1; + } + } + + /// + /// Derives the effective JSON mode from the selected command's PARSED --json option. + /// Reading the parsed value is reliable even when a different option caused a parse error, + /// because System.CommandLine parses each option independently (M1). + /// + private static bool ResolveEffectiveJson(System.CommandLine.ParseResult parsedArgs) + { + // Only engage when the selected (innermost) command actually owns --json. + var selectedCmd = parsedArgs.CommandResult.Command; + if (!selectedCmd.Options.Contains(WinAppRootCommand.JsonOption)) + { + return false; + } + try + { + return parsedArgs.GetValue(WinAppRootCommand.JsonOption); + } + catch + { + // If reading the parsed value fails for any reason, do not fire the bridge. + return false; + } + } + + /// + /// Returns when the selected command is new with --json in + /// effect. Callers use this to route an early parse failure (invalid boolean value, single-dash + /// typo) through and + /// return , so new --json yields a machine-readable + /// NewCommandResult — with the same invalid-argument exit code the handler uses — for + /// EVERY parse error, not only those reaching the main bridge in . + /// + private static bool IsNewCommandJson(System.CommandLine.ParseResult parseResult) => + ResolveEffectiveJson(parseResult) && parseResult.CommandResult.Command.Name == "new"; + + /// + /// Returns when the selected command is the ui command + /// or any of its descendants. Used to scope the parse-error JSON bridge to ui commands + /// only, leaving non-ui commands (e.g. cert info) with their own flat error schema (M3). + /// + private static bool IsUiDescendant(System.CommandLine.ParseResult parseResult) + { + var cmd = parseResult.CommandResult.Command; + while (cmd is not null) + { + if (cmd.Name == "ui") + { + return true; + } + cmd = cmd.Parents.OfType().FirstOrDefault(); + } + return false; + } + + /// + /// Returns when the selected command is one of the discovery commands + /// that document a flat {"error":"..."} JSON contract — find-ui, or find-api + /// and any of its verbs. Used to route parser-level failures to that contract (#719) instead of + /// System.CommandLine's human help text, mirroring the ui bridge above. + /// + private static bool IsFlatJsonErrorCommand(System.CommandLine.ParseResult parseResult) + { + var cmd = parseResult.CommandResult.Command; + while (cmd is not null) + { + if (cmd.Name is "find-ui" or "find-api") + { + return true; + } + cmd = cmd.Parents.OfType().FirstOrDefault(); + } + return false; + } + + /// + /// Returns when the selected command is target or one of its + /// descendants, so a parse failure is reported in the target error envelope rather than as + /// help text. + /// + private static bool IsTargetDescendant(System.CommandLine.ParseResult parseResult) + { + var cmd = parseResult.CommandResult.Command; + while (cmd is not null) + { + if (cmd.Name == "target") + { + return true; + } + cmd = cmd.Parents.OfType().FirstOrDefault(); + } + return false; + } + + /// + /// Writes a flat {"error":"..."} object to stdout — the same schema and sink + /// find-ui and find-api use for in-handler errors — so a parse failure under + /// --json stays machine-readable. stdout (not stderr) matches where those commands emit + /// all their other JSON. + /// + private static void EmitFlatJsonError(string message) + { + var payload = System.Text.Json.JsonSerializer.Serialize( + new JsonErrorOutput { Error = message }, + WinAppJsonContext.Default.JsonErrorOutput); + Console.Out.WriteLine(payload); + } + + /// + /// Scans argv for an invalid =-attached value on any boolean option reachable by the + /// selected command — the command's own boolean + /// options plus inherited/global ones on ancestor commands (e.g. --json/--verbose/ + /// --quiet on the root, or --eraser on ui pen). System.CommandLine silently + /// coerces a non-boolean attached value (e.g. --eraser=bogus) to ; + /// the caller uses this to reject it with a clean error instead (#600 H1/M1/M2). + /// + private static bool TryFindInvalidBooleanOption( + System.CommandLine.ParseResult parseResult, + string[] args, + out string optionName, + out string invalidValue) + { + optionName = string.Empty; + invalidValue = string.Empty; + + // Walk the selected command up through its ancestors so both command-level bool options + // and inherited/global ones are covered. Dedupe by name in case an option appears twice. + var seen = new HashSet(StringComparer.Ordinal); + var cmd = parseResult.CommandResult.Command; + while (cmd is not null) + { + foreach (var option in cmd.Options) + { + if (option is not System.CommandLine.Option) + { + continue; + } + if (!seen.Add(option.Name)) + { + continue; + } + if (GlobalOptionPreScan.TryFindInvalidBooleanValue(args, option.Name, option.Aliases, out var badValue)) + { + optionName = option.Name; + invalidValue = badValue; + return true; + } + } + + cmd = cmd.Parents.OfType().FirstOrDefault(); + } + + return false; + } + + /// + /// The global logging mode resolved from argv: the minimum log level plus the individual + /// --quiet/--verbose/--json flags, and a non-null + /// message when a mutually-exclusive combination was requested. + /// + internal readonly record struct LoggingMode(LogLevel MinimumLevel, bool Quiet, bool Verbose, bool Json, string? ConflictError); + + /// + /// Pre-scans argv for the global logging-mode flags and resolves the effective + /// . The scan (via ) stops at the first + /// standalone -- separator so passthrough payload (e.g. winapp run . -- --json) is + /// not misread as a winapp global flag. Extracted from so the flag precedence + /// and mutual-exclusion rules are unit testable without invoking the full entrypoint. + /// + internal static LoggingMode ResolveLoggingMode(string[] args) + { + var minimumLogLevel = LogLevel.Information; + bool quiet = false; + bool verbose = false; + bool json = false; + + verbose = GlobalOptionPreScan.GetBooleanFlagValue(args, WinAppRootCommand.VerboseOption.Name, WinAppRootCommand.VerboseOption.Aliases); + if (verbose) + { + minimumLogLevel = LogLevel.Debug; + } + quiet = GlobalOptionPreScan.GetBooleanFlagValue(args, WinAppRootCommand.QuietOption.Name, WinAppRootCommand.QuietOption.Aliases); + if (quiet) + { + minimumLogLevel = LogLevel.Warning; + } + json = GlobalOptionPreScan.GetBooleanFlagValue(args, WinAppRootCommand.JsonOption.Name, WinAppRootCommand.JsonOption.Aliases); + if (json) + { + minimumLogLevel = LogLevel.None; + } + + string? conflictError = null; + if (quiet && verbose) + { + conflictError = "Cannot specify both --quiet and --verbose options together."; + } + else if (quiet && json) + { + conflictError = "Cannot specify both --quiet and --json options together."; + } + else if (verbose && json) + { + conflictError = "Cannot specify both --verbose and --json options together."; + } + + return new LoggingMode(minimumLogLevel, quiet, verbose, json, conflictError); + } +} From 383952be31645561956a2054ee7abad9c99a97a8 Mon Sep 17 00:00:00 2001 From: Akhil Gupta Date: Wed, 7 Oct 2026 21:12:22 -0700 Subject: [PATCH 11/21] Delete src/winapp-CLI/WinApp.Cli/Program.cs --- src/winapp-CLI/WinApp.Cli/Program.cs | 686 --------------------------- 1 file changed, 686 deletions(-) delete mode 100644 src/winapp-CLI/WinApp.Cli/Program.cs diff --git a/src/winapp-CLI/WinApp.Cli/Program.cs b/src/winapp-CLI/WinApp.Cli/Program.cs deleted file mode 100644 index a0579dba9..000000000 --- a/src/winapp-CLI/WinApp.Cli/Program.cs +++ /dev/null @@ -1,686 +0,0 @@ -// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. -// Licensed under the MIT License. - -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Spectre.Console; -using System.Text; -using WinApp.Cli.Commands; -using WinApp.Cli.Helpers; -using WinApp.Cli.Models; -using WinApp.Cli.Services; -using WinApp.Cli.Telemetry; -using WinApp.Cli.Telemetry.Events; -using LogLevel = Microsoft.Extensions.Logging.LogLevel; - -namespace WinApp.Cli; - -internal static class Program -{ - internal static async Task Main(string[] args) - { - // Hidden internal verb: the WinUI DbgEng triage pass runs in this isolated child process so - // its modern dbgeng.dll is not poisoned by the system32 dbghelp.dll the parent already loaded. - // Intercept before any host/service setup to keep the loader state clean and output noise-free. - if (args.Length > 0 && args[0] == Services.XamlTriageRunner.InternalVerb) - { - return Services.XamlTriageRunner.Run(args); - } - - // Ensure UTF-8 I/O for emoji-capable terminals; fall back silently if not supported - ConfigureConsoleEncoding(static () => - { - Console.OutputEncoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); - Console.InputEncoding = Encoding.UTF8; - }); - - var loggingMode = ResolveLoggingMode(args); - if (loggingMode.ConflictError is not null) - { - Console.Error.WriteLine(loggingMode.ConflictError); - return 1; - } - - var minimumLogLevel = loggingMode.MinimumLevel; - bool quiet = loggingMode.Quiet; - bool json = loggingMode.Json; - - // Check if --cli-schema is specified - this outputs machine-readable JSON - // and should not display any interactive messages like first-run notices - bool isCliSchemaMode = GlobalOptionPreScan.IsFlagPresent( - args, WinAppRootCommand.CliSchemaOption.Name, []); - - // Check if this is a completion request - completions must be fast and silent - bool isCompleteMode = args.Length > 0 && args[0] == "complete"; - - var services = new ServiceCollection() - .ConfigureServices() - .ConfigureCommands() - .AddLogging(b => - { - b.ClearProviders(); - b.AddTextWriterLogger(Console.Out, Console.Error); - b.SetMinimumLevel(minimumLogLevel); - }); - - using var serviceProvider = services.BuildServiceProvider(); - - var rootCommand = serviceProvider.GetRequiredService(); - System.CommandLine.ParseResult? parseResult = null; - - if (args.Length > 0) - { - parseResult = rootCommand.Parse(args, WinAppParserConfiguration.Default); - - // Infrastructure processes are not separate user invocations. Set this before any - // telemetry instance is created; descendants inherit the winapp-only opt-out. - if (parseResult.CommandResult.Command is GuestAgentCommand) - { - Environment.SetEnvironmentVariable(Telemetry.Telemetry.OptOutEnvironmentVariable, "1"); - } - - // Set WINAPP_CLI_CALLER env var from --caller option so telemetry and update checks can use it - var caller = parseResult.GetValue(WinAppRootCommand.CallerOption); - if (!string.IsNullOrWhiteSpace(caller)) - { - Environment.SetEnvironmentVariable("WINAPP_CLI_CALLER", caller); - } - - // Reject an invalid '='-attached value on ANY boolean option reachable by the selected - // command — a global flag like --json=bogus or a command flag like --eraser=bogus. - // System.CommandLine silently coerces such a value to TRUE with no parse error, which - // would (a) silently enable a flag the user never set (e.g. the pen eraser) and (b) for - // --json leave logging un-suppressed, so the human ❌ line and the JSON envelope BOTH hit - // stderr and corrupt machine-readable output. Fail fast HERE — before the first-run / - // update notice can print anything (M1) — with a single clean invalid_arguments error, - // mirroring how the parser already rejects other malformed values (e.g. --pressure nope). - if (TryFindInvalidBooleanOption(parseResult, args, out var invalidBoolOption, out var invalidBoolValue)) - { - var message = - $"Cannot parse argument '{invalidBoolValue}' for option '{invalidBoolOption}' as expected type 'System.Boolean'."; - if (IsNewCommandJson(parseResult)) - { - // `new` owns a bespoke NewCommandResult envelope; emit it (and use its - // invalid-argument exit code) so machine callers get structured output here too. - NewCommand.EmitParseErrorJson(message); - return NewCommand.ExitInvalidArgs; - } - if (ResolveEffectiveJson(parseResult) && IsUiDescendant(parseResult)) - { - UiJsonError.Emit(true, UiJsonError.CodeInvalidArguments, message); - } - else if (ResolveEffectiveJson(parseResult) && IsFlatJsonErrorCommand(parseResult)) - { - EmitFlatJsonError(message); - } - else if (ResolveEffectiveJson(parseResult) && IsTargetDescendant(parseResult)) - { - TargetOutput.RejectCommandLine(message); - } - else - { - Console.Error.WriteLine(message); - } - - return 1; - } - } - - // Skip first-run notice for machine-readable output modes and completions - var didShowFirstRunNotice = false; - if (!isCliSchemaMode && !isCompleteMode && !json) - { - var firstRunService = serviceProvider.GetRequiredService(); - didShowFirstRunNotice = firstRunService.CheckAndDisplayFirstRunNotice(); - - // Check for CLI updates — shows cached notice instantly (no network), - // and starts a background refresh if the cache is stale (fire-and-forget). - if (!quiet) - { - var updateNotificationService = serviceProvider.GetRequiredService(); - updateNotificationService.CheckAndNotify(); - } - } - - // If no arguments provided, display banner and show help - if (args.Length == 0) - { - if (!didShowFirstRunNotice) - { - BannerHelper.DisplayBanner(); - } - - // Show help by invoking with --help - await rootCommand.Parse(["--help"], WinAppParserConfiguration.Default).InvokeAsync(); - return 0; - } - - var parsedArgs = parseResult!; - - // Derive the effective JSON mode from the SELECTED command's PARSED --json value (M1). - // The pre-scan (json) is value-aware but still a heuristic; the parsed value is the truth. - // Reading the parsed bool works even when a different option (e.g. --pressure) failed parse. - bool effectiveJson = ResolveEffectiveJson(parsedArgs); - - // Catch single-dash typos like "-app" before invocation so the user gets a clear - // "Did you mean --app?" message instead of System.CommandLine's confusing - // "Unrecognized command or argument" pointing at the wrong token (issue #467). - // Only run when parsing already failed — otherwise a command that legitimately - // accepts a "-foo"-shaped positional value would get a false-positive typo error. - if (parsedArgs.Errors.Count > 0) - { - var typo = OptionTypoValidator.FindLikelyLongOptionTypo(args, parsedArgs); - if (typo is not null) - { - var suggested = "-" + typo; - var typoMessage = $"Unknown option '{typo}'. Did you mean '{suggested}'?"; - if (IsNewCommandJson(parsedArgs)) - { - // Route `new --json` typo failures through its structured envelope with paired - // telemetry, mirroring the ui branch below and the main parse-error bridge. - if (!isCompleteMode) - { - CommandInvokedEvent.Log(parsedArgs.CommandResult); - } - NewCommand.EmitParseErrorJson(typoMessage); - if (!isCompleteMode) - { - CommandCompletedEvent.Log(parsedArgs.CommandResult, NewCommand.ExitInvalidArgs); - } - return NewCommand.ExitInvalidArgs; - } - if (effectiveJson && IsUiDescendant(parsedArgs)) - { - // Gate on IsUiDescendant so that non-ui commands (e.g. cert info) fall through - // to default error handling instead of receiving the nested UI schema (M2). - if (!isCompleteMode) - { - CommandInvokedEvent.Log(parsedArgs.CommandResult); - } - UiJsonError.Emit(true, UiJsonError.CodeInvalidArguments, typoMessage); - if (!isCompleteMode) - { - CommandCompletedEvent.Log(parsedArgs.CommandResult, 1); - } - } - else if (effectiveJson && IsFlatJsonErrorCommand(parsedArgs)) - { - // find-ui/find-api emit all their JSON (results and errors) on stdout as a flat - // {"error":"..."} object — keep parser-level failures on that same contract. - if (!isCompleteMode) - { - CommandInvokedEvent.Log(parsedArgs.CommandResult); - } - EmitFlatJsonError($"Unknown option '{typo}'. Did you mean '{suggested}'?"); - if (!isCompleteMode) - { - CommandCompletedEvent.Log(parsedArgs.CommandResult, 1); - } - } - else if (effectiveJson && IsTargetDescendant(parsedArgs)) - { - if (!isCompleteMode) - { - CommandInvokedEvent.Log(parsedArgs.CommandResult); - } - TargetOutput.RejectCommandLine(typoMessage); - if (!isCompleteMode) - { - CommandCompletedEvent.Log(parsedArgs.CommandResult, 1); - } - } - else - { - Console.Error.WriteLine(typoMessage); - Console.Error.WriteLine( - "(Single-dash flags are reserved for short aliases like '-a'. Long options use a double dash.)"); - } - return 1; - } - } - - return await RunWithTelemetryAsync(parsedArgs, isCompleteMode, async () => - { - // Target selection is settled before anything else, and settled for every command. - // A command that cannot honour --on says so, and a selector that names nothing usable - // fails here — never silently on this desktop, which is the one outcome the option - // exists to prevent. - if (ExecutionTargetSelection.Validate(parsedArgs) is { } selectionError) - { - return TargetOutput.RejectSelection( - serviceProvider.GetRequiredService(), effectiveJson, selectionError); - } - - // System.CommandLine binds an unrecognised option to a nearby optional positional rather - // than failing, so `--onn=sandbox` would become a UI element selector and the command - // would run here and report success. Rejected before dispatch for every command except - // `run`, whose own handler already reports the same mistake with the passthrough advice - // its `--` separator needs and in its own --json shape. - if (parsedArgs.CommandResult.Command is not RunCommand && - WindowsCommandLine.FindOptionLikePositionals(parsedArgs) is { Count: > 0 } stray) - { - return RejectOptionLikePositionals(parsedArgs, stray, effectiveJson); - } - - // One pre-dispatch interception, before any local UI service runs. A command the user - // sent to another target must not perform UI Automation, window discovery, capture, or - // input injection on this desktop, and the only way to guarantee that for every verb is - // to divert before the handler exists. - // - // Only for a command line that actually parsed. Routing a broken one first means - // `winapp ui inspect --on sandbox --depth notanumber` boots a Sandbox, spends minutes - // preparing it, and then reports a transport failure that never mentions `--depth` — - // and leaves the Sandbox running. The ordinary parse-error path is both faster and - // truthful, and it runs on this machine only because it runs nothing at all. - if (parsedArgs.Errors.Count == 0 && ExecutionTargetUiRouter.ShouldRoute(parsedArgs)) - { - var router = serviceProvider.GetRequiredService(); - return await router.RouteAsync( - args, - TargetUiRequirements.For(parsedArgs), - effectiveJson, - CancellationToken.None); - } - - // Parse errors must exit non-zero (issue #1015). InvokeAsync displays - // the error and help text, but return 1 explicitly so scripts and - // agents don't treat a rejected command line as success. - var invokeExitCode = await parsedArgs.InvokeAsync(); - if (parsedArgs.Errors.Count > 0 && invokeExitCode == 0) - { - return 1; - } - return invokeExitCode; - }); - } - - /// - /// Reports positional values that were really misspelt options, and returns the exit code. - /// - /// - /// Rendered in whichever error shape the invoked command promises, so a --json caller - /// still gets a parseable document rather than a bare line on stderr. - /// - private static int RejectOptionLikePositionals( - System.CommandLine.ParseResult parsedArgs, - IReadOnlyList stray, - bool effectiveJson) - { - var message = stray.Count == 1 - ? $"Unrecognized option '{stray[0]}'." - : $"Unrecognized options: {string.Join(", ", stray.Select(token => $"'{token}'"))}."; - - var advice = "Check the spelling, or put the value after '--' if it really is an argument."; - - if (effectiveJson && IsUiDescendant(parsedArgs)) - { - UiJsonError.Emit(true, UiJsonError.CodeInvalidArguments, $"{message} {advice}"); - } - else if (effectiveJson && IsFlatJsonErrorCommand(parsedArgs)) - { - EmitFlatJsonError($"{message} {advice}"); - } - else if (effectiveJson && IsTargetDescendant(parsedArgs)) - { - TargetOutput.RejectCommandLine($"{message} {advice}"); - } - else - { - Console.Error.WriteLine(message); - Console.Error.WriteLine(advice); - } - - // The same code every other malformed winapp command line returns. This is a parse mistake, - // not a failure to reach a target, and conflating the two would make an unknown option look - // like an unavailable machine. - return TargetOutput.InvalidCommandLineExitCode; - } - - /// - /// Applies the given console-encoding mutation, swallowing the platform exception that occurs - /// when the standard stream encoding cannot be changed (e.g. redirected or unsupported handles). - /// UTF-8 I/O is a best-effort nicety, so a failure here must never abort startup. - /// - internal static void ConfigureConsoleEncoding(Action apply) - { - try - { - apply(); - } - catch - { - // ignore - } - } - - /// - /// Runs the parsed command via , emitting command-invoked/-completed - /// telemetry (unless in completion mode) and converting any unhandled exception into a logged - /// error plus exit code 1. The seam lets tests exercise both the - /// success and top-level-failure paths without needing a real, throwing command. - /// - internal static Task RunWithTelemetryAsync( - System.CommandLine.ParseResult parsedArgs, bool isCompleteMode, Func> invoke) => - RunWithTelemetryAsync(parsedArgs, isCompleteMode, invoke, CommandInvokedEvent.Log, CommandCompletedEvent.Log); - - /// - /// Core of - /// with the telemetry sinks injected. and - /// default (via the public overload) to the production - /// / events; the seam - /// lets tests assert that the invoked/completed events fire around the invocation — in order, with - /// the parsed command result and real exit code, and only when not in completion mode. - /// - internal static async Task RunWithTelemetryAsync( - System.CommandLine.ParseResult parsedArgs, bool isCompleteMode, Func> invoke, - Action logCommandInvoked, - Action logCommandCompleted) - { - try - { - // Start a correlation scope so the CommandInvoked/CommandCompleted events and any - // command-specific event a handler emits (e.g. NewCommand_Event) share one - // relatedActivityId for this invocation. - TelemetryCorrelation.Begin(); - - // Open the coordination scope in the same async flow that reads it below. A `winapp ui` - // command sets its cooperative-turn summary from deep inside invoke(), and - // CommandCompletedEvent picks it up here (issue #764). - Services.InteractiveDesktop.UiCoordinationTelemetryScope.Begin(); - - if (!isCompleteMode) - { - logCommandInvoked(parsedArgs.CommandResult); - } - - bool effectiveJson = ResolveEffectiveJson(parsedArgs); - - // Parse-error → JSON bridge: activated only when the SELECTED command exposes --json, - // its parsed value is true (effectiveJson), AND the command is a ui descendant (M3). - // Non-ui commands (e.g. cert info) use a flat {"error":"..."} schema — do not impose - // the UI nested contract on them; let SCL's default parse-error handling run instead. - // M3: emit CommandCompletedEvent before the early return to keep telemetry paired. - if (effectiveJson && parsedArgs.Errors.Count > 0 && IsUiDescendant(parsedArgs)) - { - var errorMsg = string.Join("; ", parsedArgs.Errors.Select(e => e.Message)); - UiJsonError.Emit(true, UiJsonError.CodeInvalidArguments, errorMsg); - if (!isCompleteMode) - { - logCommandCompleted(parsedArgs.CommandResult, 1); - } - return 1; - } - - // Parse-error → JSON bridge for `winapp new`: it owns a bespoke NewCommandResult schema - // (not the ui error envelope), so a parse failure like `--template bogus --json` must be - // reported in that shape rather than falling through to SCL's help/error text on stdout. - // Return ExitInvalidArgs (2) — the same code the handler uses for invalid names/versions — - // so agents can branch on the invalid-argument stage regardless of where it was caught. - if (effectiveJson && parsedArgs.Errors.Count > 0 - && parsedArgs.CommandResult.Command.Name == "new") - { - var errorMsg = string.Join("; ", parsedArgs.Errors.Select(e => e.Message)); - NewCommand.EmitParseErrorJson(errorMsg); - if (!isCompleteMode) - { - logCommandCompleted(parsedArgs.CommandResult, NewCommand.ExitInvalidArgs); - } - return NewCommand.ExitInvalidArgs; - } - - // Discovery parse-error → JSON bridge. find-ui and find-api both document a --json - // contract (results and in-handler validation errors all emit JSON on stdout), but a - // type/parser error such as `--max abc` fails before the handler runs, so - // System.CommandLine would otherwise print human help text. Emit the same flat - // {"error":"..."} object here so every --json path stays machine-readable (#719). - if (effectiveJson && parsedArgs.Errors.Count > 0 && IsFlatJsonErrorCommand(parsedArgs)) - { - var errorMsg = string.Join("; ", parsedArgs.Errors.Select(e => e.Message)); - EmitFlatJsonError(errorMsg); - if (!isCompleteMode) - { - logCommandCompleted(parsedArgs.CommandResult, 1); - } - return 1; - } - - // Parse-error → JSON bridge for the `target` verbs, which own their own error envelope - // ({"error":{"code":...}} on stderr). Same reasoning as the ui bridge: a caller that - // passed --json must never have to read human help text off stdout to find out that its - // command line was wrong. - if (effectiveJson && parsedArgs.Errors.Count > 0 && IsTargetDescendant(parsedArgs)) - { - var errorMsg = string.Join("; ", parsedArgs.Errors.Select(e => e.Message)); - var exitCode = TargetOutput.RejectCommandLine(errorMsg); - if (!isCompleteMode) - { - logCommandCompleted(parsedArgs.CommandResult, exitCode); - } - return exitCode; - } - - var returnCode = await invoke(); - - if (!isCompleteMode) - { - logCommandCompleted(parsedArgs.CommandResult, returnCode); - } - - return returnCode; - } - catch (Exception ex) - { - TelemetryFactory.Get().LogException(parsedArgs.CommandResult.Command.Name, ex); - Console.Error.WriteLine($"An unexpected error occurred: {ex.Message}"); - return 1; - } - } - - /// - /// Derives the effective JSON mode from the selected command's PARSED --json option. - /// Reading the parsed value is reliable even when a different option caused a parse error, - /// because System.CommandLine parses each option independently (M1). - /// - private static bool ResolveEffectiveJson(System.CommandLine.ParseResult parsedArgs) - { - // Only engage when the selected (innermost) command actually owns --json. - var selectedCmd = parsedArgs.CommandResult.Command; - if (!selectedCmd.Options.Contains(WinAppRootCommand.JsonOption)) - { - return false; - } - try - { - return parsedArgs.GetValue(WinAppRootCommand.JsonOption); - } - catch - { - // If reading the parsed value fails for any reason, do not fire the bridge. - return false; - } - } - - /// - /// Returns when the selected command is new with --json in - /// effect. Callers use this to route an early parse failure (invalid boolean value, single-dash - /// typo) through and - /// return , so new --json yields a machine-readable - /// NewCommandResult — with the same invalid-argument exit code the handler uses — for - /// EVERY parse error, not only those reaching the main bridge in . - /// - private static bool IsNewCommandJson(System.CommandLine.ParseResult parseResult) => - ResolveEffectiveJson(parseResult) && parseResult.CommandResult.Command.Name == "new"; - - /// - /// Returns when the selected command is the ui command - /// or any of its descendants. Used to scope the parse-error JSON bridge to ui commands - /// only, leaving non-ui commands (e.g. cert info) with their own flat error schema (M3). - /// - private static bool IsUiDescendant(System.CommandLine.ParseResult parseResult) - { - var cmd = parseResult.CommandResult.Command; - while (cmd is not null) - { - if (cmd.Name == "ui") - { - return true; - } - cmd = cmd.Parents.OfType().FirstOrDefault(); - } - return false; - } - - /// - /// Returns when the selected command is one of the discovery commands - /// that document a flat {"error":"..."} JSON contract — find-ui, or find-api - /// and any of its verbs. Used to route parser-level failures to that contract (#719) instead of - /// System.CommandLine's human help text, mirroring the ui bridge above. - /// - private static bool IsFlatJsonErrorCommand(System.CommandLine.ParseResult parseResult) - { - var cmd = parseResult.CommandResult.Command; - while (cmd is not null) - { - if (cmd.Name is "find-ui" or "find-api") - { - return true; - } - cmd = cmd.Parents.OfType().FirstOrDefault(); - } - return false; - } - - /// - /// Returns when the selected command is target or one of its - /// descendants, so a parse failure is reported in the target error envelope rather than as - /// help text. - /// - private static bool IsTargetDescendant(System.CommandLine.ParseResult parseResult) - { - var cmd = parseResult.CommandResult.Command; - while (cmd is not null) - { - if (cmd.Name == "target") - { - return true; - } - cmd = cmd.Parents.OfType().FirstOrDefault(); - } - return false; - } - - /// - /// Writes a flat {"error":"..."} object to stdout — the same schema and sink - /// find-ui and find-api use for in-handler errors — so a parse failure under - /// --json stays machine-readable. stdout (not stderr) matches where those commands emit - /// all their other JSON. - /// - private static void EmitFlatJsonError(string message) - { - var payload = System.Text.Json.JsonSerializer.Serialize( - new JsonErrorOutput { Error = message }, - WinAppJsonContext.Default.JsonErrorOutput); - Console.Out.WriteLine(payload); - } - - /// - /// Scans argv for an invalid =-attached value on any boolean option reachable by the - /// selected command — the command's own boolean - /// options plus inherited/global ones on ancestor commands (e.g. --json/--verbose/ - /// --quiet on the root, or --eraser on ui pen). System.CommandLine silently - /// coerces a non-boolean attached value (e.g. --eraser=bogus) to ; - /// the caller uses this to reject it with a clean error instead (#600 H1/M1/M2). - /// - private static bool TryFindInvalidBooleanOption( - System.CommandLine.ParseResult parseResult, - string[] args, - out string optionName, - out string invalidValue) - { - optionName = string.Empty; - invalidValue = string.Empty; - - // Walk the selected command up through its ancestors so both command-level bool options - // and inherited/global ones are covered. Dedupe by name in case an option appears twice. - var seen = new HashSet(StringComparer.Ordinal); - var cmd = parseResult.CommandResult.Command; - while (cmd is not null) - { - foreach (var option in cmd.Options) - { - if (option is not System.CommandLine.Option) - { - continue; - } - if (!seen.Add(option.Name)) - { - continue; - } - if (GlobalOptionPreScan.TryFindInvalidBooleanValue(args, option.Name, option.Aliases, out var badValue)) - { - optionName = option.Name; - invalidValue = badValue; - return true; - } - } - - cmd = cmd.Parents.OfType().FirstOrDefault(); - } - - return false; - } - - /// - /// The global logging mode resolved from argv: the minimum log level plus the individual - /// --quiet/--verbose/--json flags, and a non-null - /// message when a mutually-exclusive combination was requested. - /// - internal readonly record struct LoggingMode(LogLevel MinimumLevel, bool Quiet, bool Verbose, bool Json, string? ConflictError); - - /// - /// Pre-scans argv for the global logging-mode flags and resolves the effective - /// . The scan (via ) stops at the first - /// standalone -- separator so passthrough payload (e.g. winapp run . -- --json) is - /// not misread as a winapp global flag. Extracted from so the flag precedence - /// and mutual-exclusion rules are unit testable without invoking the full entrypoint. - /// - internal static LoggingMode ResolveLoggingMode(string[] args) - { - var minimumLogLevel = LogLevel.Information; - bool quiet = false; - bool verbose = false; - bool json = false; - - verbose = GlobalOptionPreScan.GetBooleanFlagValue(args, WinAppRootCommand.VerboseOption.Name, WinAppRootCommand.VerboseOption.Aliases); - if (verbose) - { - minimumLogLevel = LogLevel.Debug; - } - quiet = GlobalOptionPreScan.GetBooleanFlagValue(args, WinAppRootCommand.QuietOption.Name, WinAppRootCommand.QuietOption.Aliases); - if (quiet) - { - minimumLogLevel = LogLevel.Warning; - } - json = GlobalOptionPreScan.GetBooleanFlagValue(args, WinAppRootCommand.JsonOption.Name, WinAppRootCommand.JsonOption.Aliases); - if (json) - { - minimumLogLevel = LogLevel.None; - } - - string? conflictError = null; - if (quiet && verbose) - { - conflictError = "Cannot specify both --quiet and --verbose options together."; - } - else if (quiet && json) - { - conflictError = "Cannot specify both --quiet and --json options together."; - } - else if (verbose && json) - { - conflictError = "Cannot specify both --verbose and --json options together."; - } - - return new LoggingMode(minimumLogLevel, quiet, verbose, json, conflictError); - } -} From b884eeb379e73ee90f6bfa0f7714cc78ed779d38 Mon Sep 17 00:00:00 2001 From: Akhil Gupta Date: Wed, 7 Oct 2026 21:13:51 -0700 Subject: [PATCH 12/21] fix: rebase exit-code fix onto current main (resolve conflict) --- .../WinApp.Cli/{winapp-Program-rebased.cs => Program.cs} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/winapp-CLI/WinApp.Cli/{winapp-Program-rebased.cs => Program.cs} (100%) diff --git a/src/winapp-CLI/WinApp.Cli/winapp-Program-rebased.cs b/src/winapp-CLI/WinApp.Cli/Program.cs similarity index 100% rename from src/winapp-CLI/WinApp.Cli/winapp-Program-rebased.cs rename to src/winapp-CLI/WinApp.Cli/Program.cs From 915f370b23c534d29cccb037a953e79786537f96 Mon Sep 17 00:00:00 2001 From: Akhil Gupta Date: Wed, 7 Oct 2026 21:15:29 -0700 Subject: [PATCH 13/21] Upload winapp-Program-proper.cs (intermediate step) --- .../WinApp.Cli/winapp-Program-proper.cs | 734 ++++++++++++++++++ 1 file changed, 734 insertions(+) create mode 100644 src/winapp-CLI/WinApp.Cli/winapp-Program-proper.cs diff --git a/src/winapp-CLI/WinApp.Cli/winapp-Program-proper.cs b/src/winapp-CLI/WinApp.Cli/winapp-Program-proper.cs new file mode 100644 index 000000000..547226d61 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/winapp-Program-proper.cs @@ -0,0 +1,734 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Spectre.Console; +using System.Text; +using WinApp.Cli.Commands; +using WinApp.Cli.Helpers; +using WinApp.Cli.Models; +using WinApp.Cli.Services; +using WinApp.Cli.Telemetry; +using WinApp.Cli.Telemetry.Events; +using LogLevel = Microsoft.Extensions.Logging.LogLevel; + +namespace WinApp.Cli; + +internal static class Program +{ + internal static async Task Main(string[] args) + { + // Hidden internal verb: the WinUI DbgEng triage pass runs in this isolated child process so + // its modern dbgeng.dll is not poisoned by the system32 dbghelp.dll the parent already loaded. + // Intercept before any host/service setup to keep the loader state clean and output noise-free. + if (args.Length > 0 && args[0] == Services.XamlTriageRunner.InternalVerb) + { + return Services.XamlTriageRunner.Run(args); + } + + // Ensure UTF-8 I/O for emoji-capable terminals; fall back silently if not supported + ConfigureConsoleEncoding(static () => + { + Console.OutputEncoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); + Console.InputEncoding = Encoding.UTF8; + }); + + var loggingMode = ResolveLoggingMode(args); + if (loggingMode.ConflictError is not null) + { + Console.Error.WriteLine(loggingMode.ConflictError); + return 1; + } + + var minimumLogLevel = loggingMode.MinimumLevel; + bool quiet = loggingMode.Quiet; + bool json = loggingMode.Json; + + // Check if --cli-schema is specified - this outputs machine-readable JSON + // and should not display any interactive messages like first-run notices + bool isCliSchemaMode = GlobalOptionPreScan.IsFlagPresent( + args, WinAppRootCommand.CliSchemaOption.Name, []); + + // Check if this is a completion request - completions must be fast and silent + bool isCompleteMode = args.Length > 0 && args[0] == "complete"; + + var services = new ServiceCollection() + .ConfigureServices() + .ConfigureCommands() + .AddLogging(b => + { + b.ClearProviders(); + b.AddTextWriterLogger(Console.Out, Console.Error); + b.SetMinimumLevel(minimumLogLevel); + }); + + using var serviceProvider = services.BuildServiceProvider(); + + var rootCommand = serviceProvider.GetRequiredService(); + System.CommandLine.ParseResult? parseResult = null; + + if (args.Length > 0) + { + parseResult = rootCommand.Parse(args, WinAppParserConfiguration.Default); + + // Infrastructure processes are not separate user invocations. Set this before any + // telemetry instance is created; descendants inherit the winapp-only opt-out. + if (parseResult.CommandResult.Command is GuestAgentCommand) + { + Environment.SetEnvironmentVariable(Telemetry.Telemetry.OptOutEnvironmentVariable, "1"); + } + + // Set WINAPP_CLI_CALLER env var from --caller option so telemetry and update checks can use it + var caller = parseResult.GetValue(WinAppRootCommand.CallerOption); + if (!string.IsNullOrWhiteSpace(caller)) + { + Environment.SetEnvironmentVariable("WINAPP_CLI_CALLER", caller); + } + + // Reject an invalid '='-attached value on ANY boolean option reachable by the selected + // command — a global flag like --json=bogus or a command flag like --eraser=bogus. + // System.CommandLine silently coerces such a value to TRUE with no parse error, which + // would (a) silently enable a flag the user never set (e.g. the pen eraser) and (b) for + // --json leave logging un-suppressed, so the human ❌ line and the JSON envelope BOTH hit + // stderr and corrupt machine-readable output. Fail fast HERE — before the first-run / + // update notice can print anything (M1) — with a single clean invalid_arguments error, + // mirroring how the parser already rejects other malformed values (e.g. --pressure nope). + if (TryFindInvalidBooleanOption(parseResult, args, out var invalidBoolOption, out var invalidBoolValue)) + { + var message = + $"Cannot parse argument '{invalidBoolValue}' for option '{invalidBoolOption}' as expected type 'System.Boolean'."; + if (IsNewCommandJson(parseResult)) + { + // `new` owns a bespoke NewCommandResult envelope; emit it (and use its + // invalid-argument exit code) so machine callers get structured output here too. + NewCommand.EmitParseErrorJson(message); + return NewCommand.ExitInvalidArgs; + } + if (ResolveEffectiveJson(parseResult) && IsUiDescendant(parseResult)) + { + UiJsonError.Emit(true, UiJsonError.CodeInvalidArguments, message); + } + else if (ResolveEffectiveJson(parseResult) && IsFlatJsonErrorCommand(parseResult)) + { + EmitFlatJsonError(message); + } + else if (ResolveEffectiveJson(parseResult) && IsTargetDescendant(parseResult)) + { + TargetOutput.RejectCommandLine(message); + } + else + { + Console.Error.WriteLine(message); + } + + return 1; + } + + // `winapp ui ` fails before anything else, even with --help or --on: help is a + // terminating action, so the command line parses cleanly and would otherwise print the + // group help with exit 0, or route a command that does not exist to another target. + if (UiUnknownCommand.Find(parseResult) is { } unknownUiCommand) + { + var suggestions = UiUnknownCommand.Suggest(unknownUiCommand, parseResult.CommandResult.Command.Subcommands); + if (ResolveEffectiveJson(parseResult)) + { + UiJsonError.Emit(true, UiJsonError.CodeInvalidArguments, UiUnknownCommand.Message(unknownUiCommand), + recoveryHint: UiUnknownCommand.RecoveryHint, suggestions: suggestions); + } + else + { + UiUnknownCommand.WriteText(Console.Error, unknownUiCommand, suggestions); + } + + return 1; + } + } + + // Skip first-run notice for machine-readable output modes and completions + var didShowFirstRunNotice = false; + if (!isCliSchemaMode && !isCompleteMode && !json) + { + var firstRunService = serviceProvider.GetRequiredService(); + didShowFirstRunNotice = firstRunService.CheckAndDisplayFirstRunNotice(); + + // Check for CLI updates — shows cached notice instantly (no network), + // and starts a background refresh if the cache is stale (fire-and-forget). + if (!quiet) + { + var updateNotificationService = serviceProvider.GetRequiredService(); + updateNotificationService.CheckAndNotify(); + } + } + + // If no arguments provided, display banner and show help + if (args.Length == 0) + { + if (!didShowFirstRunNotice) + { + BannerHelper.DisplayBanner(); + } + + // Show help by invoking with --help + await rootCommand.Parse(["--help"], WinAppParserConfiguration.Default).InvokeAsync(); + return 0; + } + + var parsedArgs = parseResult!; + + // Derive the effective JSON mode from the SELECTED command's PARSED --json value (M1). + // The pre-scan (json) is value-aware but still a heuristic; the parsed value is the truth. + // Reading the parsed bool works even when a different option (e.g. --pressure) failed parse. + bool effectiveJson = ResolveEffectiveJson(parsedArgs); + + // Catch single-dash typos like "-app" before invocation so the user gets a clear + // "Did you mean --app?" message instead of System.CommandLine's confusing + // "Unrecognized command or argument" pointing at the wrong token (issue #467). + // Only run when parsing already failed — otherwise a command that legitimately + // accepts a "-foo"-shaped positional value would get a false-positive typo error. + if (parsedArgs.Errors.Count > 0) + { + var typo = OptionTypoValidator.FindLikelyLongOptionTypo(args, parsedArgs); + if (typo is not null) + { + var suggested = "-" + typo; + var typoMessage = $"Unknown option '{typo}'. Did you mean '{suggested}'?"; + if (IsNewCommandJson(parsedArgs)) + { + // Route `new --json` typo failures through its structured envelope with paired + // telemetry, mirroring the ui branch below and the main parse-error bridge. + if (!isCompleteMode) + { + CommandInvokedEvent.Log(parsedArgs.CommandResult); + } + NewCommand.EmitParseErrorJson(typoMessage); + if (!isCompleteMode) + { + CommandCompletedEvent.Log(parsedArgs.CommandResult, NewCommand.ExitInvalidArgs); + } + return NewCommand.ExitInvalidArgs; + } + if (effectiveJson && IsUiDescendant(parsedArgs)) + { + // Gate on IsUiDescendant so that non-ui commands (e.g. cert info) fall through + // to default error handling instead of receiving the nested UI schema (M2). + if (!isCompleteMode) + { + CommandInvokedEvent.Log(parsedArgs.CommandResult); + } + UiJsonError.Emit(true, UiJsonError.CodeInvalidArguments, typoMessage); + if (!isCompleteMode) + { + CommandCompletedEvent.Log(parsedArgs.CommandResult, 1); + } + } + else if (effectiveJson && IsFlatJsonErrorCommand(parsedArgs)) + { + // find-ui/find-api emit all their JSON (results and errors) on stdout as a flat + // {"error":"..."} object — keep parser-level failures on that same contract. + if (!isCompleteMode) + { + CommandInvokedEvent.Log(parsedArgs.CommandResult); + } + EmitFlatJsonError($"Unknown option '{typo}'. Did you mean '{suggested}'?"); + if (!isCompleteMode) + { + CommandCompletedEvent.Log(parsedArgs.CommandResult, 1); + } + } + else if (effectiveJson && IsTargetDescendant(parsedArgs)) + { + if (!isCompleteMode) + { + CommandInvokedEvent.Log(parsedArgs.CommandResult); + } + TargetOutput.RejectCommandLine(typoMessage); + if (!isCompleteMode) + { + CommandCompletedEvent.Log(parsedArgs.CommandResult, 1); + } + } + else + { + Console.Error.WriteLine(typoMessage); + Console.Error.WriteLine( + "(Single-dash flags are reserved for short aliases like '-a'. Long options use a double dash.)"); + } + return 1; + } + } + + return await RunWithTelemetryAsync(parsedArgs, isCompleteMode, async () => + { + // Target selection is settled before anything else, and settled for every command. + // A command that cannot honour --on says so, and a selector that names nothing usable + // fails here — never silently on this desktop, which is the one outcome the option + // exists to prevent. + if (ExecutionTargetSelection.Validate(parsedArgs) is { } selectionError) + { + return TargetOutput.RejectSelection( + serviceProvider.GetRequiredService(), effectiveJson, selectionError); + } + + // System.CommandLine binds an unrecognised option to a nearby optional positional rather + // than failing, so `--onn=sandbox` would become a UI element selector and the command + // would run here and report success. Rejected before dispatch for every command except + // `run`, whose own handler already reports the same mistake with the passthrough advice + // its `--` separator needs and in its own --json shape. + if (parsedArgs.CommandResult.Command is not RunCommand && + WindowsCommandLine.FindOptionLikePositionals(parsedArgs) is { Count: > 0 } stray) + { + return RejectOptionLikePositionals(parsedArgs, stray, effectiveJson); + } + + // One pre-dispatch interception, before any local UI service runs. A command the user + // sent to another target must not perform UI Automation, window discovery, capture, or + // input injection on this desktop, and the only way to guarantee that for every verb is + // to divert before the handler exists. + // + // Only for a command line that actually parsed. Routing a broken one first means + // `winapp ui inspect --on sandbox --depth notanumber` boots a Sandbox, spends minutes + // preparing it, and then reports a transport failure that never mentions `--depth` — + // and leaves the Sandbox running. The ordinary parse-error path is both faster and + // truthful, and it runs on this machine only because it runs nothing at all. + if (parsedArgs.Errors.Count == 0 && ExecutionTargetUiRouter.ShouldRoute(parsedArgs)) + { + var router = serviceProvider.GetRequiredService(); + return await router.RouteAsync( + args, + TargetUiRequirements.For(parsedArgs), + effectiveJson, + CancellationToken.None); + } + + if (parsedArgs.Action is System.CommandLine.Invocation.ParseErrorAction parseError + && IsUiDescendant(parsedArgs) + && parsedArgs.CommandResult.Command is not UiCommand) + { + // The error names the mistake; the full ui command help after it would bury that + // error, so point at --help instead. A bare "winapp ui" keeps its help: that is + // how people discover the commands. + parseError.ShowHelp = false; + return InvokeWithHelpPointerAsync(parsedArgs); + } + + // Parse errors must exit non-zero (issue #1015). InvokeAsync displays + // the error and help text, but return 1 explicitly so scripts and + // agents don't treat a rejected command line as success. + var invokeExitCode = await parsedArgs.InvokeAsync(); + if (parsedArgs.Errors.Count > 0 && invokeExitCode == 0) + { + return 1; + } + return invokeExitCode; + }); + } + + private static async Task InvokeWithHelpPointerAsync(System.CommandLine.ParseResult parsedArgs) + { + var exitCode = await parsedArgs.InvokeAsync(); + // Parse errors must exit non-zero (issue #1015). + if (parsedArgs.Errors.Count > 0 && exitCode == 0) + { + return 1; + } + var path = string.Join(" ", parsedArgs.CommandResult.Command.Parents + .OfType() + .Where(c => c is not System.CommandLine.RootCommand) + .Select(c => c.Name) + .Prepend(parsedArgs.CommandResult.Command.Name) + .Reverse()); + parsedArgs.InvocationConfiguration.Error.WriteLine($"Run 'winapp {path} --help' for usage."); + return exitCode; + } + + /// + /// Reports positional values that were really misspelt options, and returns the exit code. + /// + /// + /// Rendered in whichever error shape the invoked command promises, so a --json caller + /// still gets a parseable document rather than a bare line on stderr. + /// + private static int RejectOptionLikePositionals( + System.CommandLine.ParseResult parsedArgs, + IReadOnlyList stray, + bool effectiveJson) + { + var message = stray.Count == 1 + ? $"Unrecognized option '{stray[0]}'." + : $"Unrecognized options: {string.Join(", ", stray.Select(token => $"'{token}'"))}."; + + var advice = "Check the spelling, or put the value after '--' if it really is an argument."; + + if (effectiveJson && IsUiDescendant(parsedArgs)) + { + UiJsonError.Emit(true, UiJsonError.CodeInvalidArguments, $"{message} {advice}"); + } + else if (effectiveJson && IsFlatJsonErrorCommand(parsedArgs)) + { + EmitFlatJsonError($"{message} {advice}"); + } + else if (effectiveJson && IsTargetDescendant(parsedArgs)) + { + TargetOutput.RejectCommandLine($"{message} {advice}"); + } + else + { + Console.Error.WriteLine(message); + Console.Error.WriteLine(advice); + } + + // The same code every other malformed winapp command line returns. This is a parse mistake, + // not a failure to reach a target, and conflating the two would make an unknown option look + // like an unavailable machine. + return TargetOutput.InvalidCommandLineExitCode; + } + + /// + /// Applies the given console-encoding mutation, swallowing the platform exception that occurs + /// when the standard stream encoding cannot be changed (e.g. redirected or unsupported handles). + /// UTF-8 I/O is a best-effort nicety, so a failure here must never abort startup. + /// + internal static void ConfigureConsoleEncoding(Action apply) + { + try + { + apply(); + } + catch + { + // ignore + } + } + + /// + /// Runs the parsed command via , emitting command-invoked/-completed + /// telemetry (unless in completion mode) and converting any unhandled exception into a logged + /// error plus exit code 1. The seam lets tests exercise both the + /// success and top-level-failure paths without needing a real, throwing command. + /// + internal static Task RunWithTelemetryAsync( + System.CommandLine.ParseResult parsedArgs, bool isCompleteMode, Func> invoke) => + RunWithTelemetryAsync(parsedArgs, isCompleteMode, invoke, CommandInvokedEvent.Log, CommandCompletedEvent.Log); + + /// + /// Core of + /// with the telemetry sinks injected. and + /// default (via the public overload) to the production + /// / events; the seam + /// lets tests assert that the invoked/completed events fire around the invocation — in order, with + /// the parsed command result and real exit code, and only when not in completion mode. + /// + internal static async Task RunWithTelemetryAsync( + System.CommandLine.ParseResult parsedArgs, bool isCompleteMode, Func> invoke, + Action logCommandInvoked, + Action logCommandCompleted) + { + try + { + // Start a correlation scope so the CommandInvoked/CommandCompleted events and any + // command-specific event a handler emits (e.g. NewCommand_Event) share one + // relatedActivityId for this invocation. + TelemetryCorrelation.Begin(); + + // Open the coordination scope in the same async flow that reads it below. A `winapp ui` + // command sets its cooperative-turn summary from deep inside invoke(), and + // CommandCompletedEvent picks it up here (issue #764). + Services.InteractiveDesktop.UiCoordinationTelemetryScope.Begin(); + + if (!isCompleteMode) + { + logCommandInvoked(parsedArgs.CommandResult); + } + + bool effectiveJson = ResolveEffectiveJson(parsedArgs); + + // Parse-error → JSON bridge: activated only when the SELECTED command exposes --json, + // its parsed value is true (effectiveJson), AND the command is a ui descendant (M3). + // Non-ui commands (e.g. cert info) use a flat {"error":"..."} schema — do not impose + // the UI nested contract on them; let SCL's default parse-error handling run instead. + // M3: emit CommandCompletedEvent before the early return to keep telemetry paired. + if (effectiveJson && parsedArgs.Errors.Count > 0 && IsUiDescendant(parsedArgs)) + { + var errorMsg = string.Join("; ", parsedArgs.Errors.Select(e => e.Message)); + UiJsonError.Emit(true, UiJsonError.CodeInvalidArguments, errorMsg); + if (!isCompleteMode) + { + logCommandCompleted(parsedArgs.CommandResult, 1); + } + return 1; + } + + // Parse-error → JSON bridge for `winapp new`: it owns a bespoke NewCommandResult schema + // (not the ui error envelope), so a parse failure like `--template bogus --json` must be + // reported in that shape rather than falling through to SCL's help/error text on stdout. + // Return ExitInvalidArgs (2) — the same code the handler uses for invalid names/versions — + // so agents can branch on the invalid-argument stage regardless of where it was caught. + if (effectiveJson && parsedArgs.Errors.Count > 0 + && parsedArgs.CommandResult.Command.Name == "new") + { + var errorMsg = string.Join("; ", parsedArgs.Errors.Select(e => e.Message)); + NewCommand.EmitParseErrorJson(errorMsg); + if (!isCompleteMode) + { + logCommandCompleted(parsedArgs.CommandResult, NewCommand.ExitInvalidArgs); + } + return NewCommand.ExitInvalidArgs; + } + + // Discovery parse-error → JSON bridge. find-ui and find-api both document a --json + // contract (results and in-handler validation errors all emit JSON on stdout), but a + // type/parser error such as `--max abc` fails before the handler runs, so + // System.CommandLine would otherwise print human help text. Emit the same flat + // {"error":"..."} object here so every --json path stays machine-readable (#719). + if (effectiveJson && parsedArgs.Errors.Count > 0 && IsFlatJsonErrorCommand(parsedArgs)) + { + var errorMsg = string.Join("; ", parsedArgs.Errors.Select(e => e.Message)); + EmitFlatJsonError(errorMsg); + if (!isCompleteMode) + { + logCommandCompleted(parsedArgs.CommandResult, 1); + } + return 1; + } + + // Parse-error → JSON bridge for the `target` verbs, which own their own error envelope + // ({"error":{"code":...}} on stderr). Same reasoning as the ui bridge: a caller that + // passed --json must never have to read human help text off stdout to find out that its + // command line was wrong. + if (effectiveJson && parsedArgs.Errors.Count > 0 && IsTargetDescendant(parsedArgs)) + { + var errorMsg = string.Join("; ", parsedArgs.Errors.Select(e => e.Message)); + var exitCode = TargetOutput.RejectCommandLine(errorMsg); + if (!isCompleteMode) + { + logCommandCompleted(parsedArgs.CommandResult, exitCode); + } + return exitCode; + } + + var returnCode = await invoke(); + + if (!isCompleteMode) + { + logCommandCompleted(parsedArgs.CommandResult, returnCode); + } + + return returnCode; + } + catch (Exception ex) + { + TelemetryFactory.Get().LogException(parsedArgs.CommandResult.Command.Name, ex); + Console.Error.WriteLine($"An unexpected error occurred: {ex.Message}"); + return 1; + } + } + + /// + /// Derives the effective JSON mode from the selected command's PARSED --json option. + /// Reading the parsed value is reliable even when a different option caused a parse error, + /// because System.CommandLine parses each option independently (M1). + /// + private static bool ResolveEffectiveJson(System.CommandLine.ParseResult parsedArgs) + { + // Only engage when the selected (innermost) command actually owns --json. + var selectedCmd = parsedArgs.CommandResult.Command; + if (!selectedCmd.Options.Contains(WinAppRootCommand.JsonOption)) + { + return false; + } + try + { + return parsedArgs.GetValue(WinAppRootCommand.JsonOption); + } + catch + { + // If reading the parsed value fails for any reason, do not fire the bridge. + return false; + } + } + + /// + /// Returns when the selected command is new with --json in + /// effect. Callers use this to route an early parse failure (invalid boolean value, single-dash + /// typo) through and + /// return , so new --json yields a machine-readable + /// NewCommandResult — with the same invalid-argument exit code the handler uses — for + /// EVERY parse error, not only those reaching the main bridge in . + /// + private static bool IsNewCommandJson(System.CommandLine.ParseResult parseResult) => + ResolveEffectiveJson(parseResult) && parseResult.CommandResult.Command.Name == "new"; + + /// + /// Returns when the selected command is the ui command + /// or any of its descendants. Used to scope the parse-error JSON bridge to ui commands + /// only, leaving non-ui commands (e.g. cert info) with their own flat error schema (M3). + /// + private static bool IsUiDescendant(System.CommandLine.ParseResult parseResult) + { + var cmd = parseResult.CommandResult.Command; + while (cmd is not null) + { + if (cmd.Name == "ui") + { + return true; + } + cmd = cmd.Parents.OfType().FirstOrDefault(); + } + return false; + } + + /// + /// Returns when the selected command is one of the discovery commands + /// that document a flat {"error":"..."} JSON contract — find-ui, or find-api + /// and any of its verbs. Used to route parser-level failures to that contract (#719) instead of + /// System.CommandLine's human help text, mirroring the ui bridge above. + /// + private static bool IsFlatJsonErrorCommand(System.CommandLine.ParseResult parseResult) + { + var cmd = parseResult.CommandResult.Command; + while (cmd is not null) + { + if (cmd.Name is "find-ui" or "find-api") + { + return true; + } + cmd = cmd.Parents.OfType().FirstOrDefault(); + } + return false; + } + + /// + /// Returns when the selected command is target or one of its + /// descendants, so a parse failure is reported in the target error envelope rather than as + /// help text. + /// + private static bool IsTargetDescendant(System.CommandLine.ParseResult parseResult) + { + var cmd = parseResult.CommandResult.Command; + while (cmd is not null) + { + if (cmd.Name == "target") + { + return true; + } + cmd = cmd.Parents.OfType().FirstOrDefault(); + } + return false; + } + + /// + /// Writes a flat {"error":"..."} object to stdout — the same schema and sink + /// find-ui and find-api use for in-handler errors — so a parse failure under + /// --json stays machine-readable. stdout (not stderr) matches where those commands emit + /// all their other JSON. + /// + private static void EmitFlatJsonError(string message) + { + var payload = System.Text.Json.JsonSerializer.Serialize( + new JsonErrorOutput { Error = message }, + WinAppJsonContext.Default.JsonErrorOutput); + Console.Out.WriteLine(payload); + } + + /// + /// Scans argv for an invalid =-attached value on any boolean option reachable by the + /// selected command — the command's own boolean + /// options plus inherited/global ones on ancestor commands (e.g. --json/--verbose/ + /// --quiet on the root, or --eraser on ui pen). System.CommandLine silently + /// coerces a non-boolean attached value (e.g. --eraser=bogus) to ; + /// the caller uses this to reject it with a clean error instead (#600 H1/M1/M2). + /// + private static bool TryFindInvalidBooleanOption( + System.CommandLine.ParseResult parseResult, + string[] args, + out string optionName, + out string invalidValue) + { + optionName = string.Empty; + invalidValue = string.Empty; + + // Walk the selected command up through its ancestors so both command-level bool options + // and inherited/global ones are covered. Dedupe by name in case an option appears twice. + var seen = new HashSet(StringComparer.Ordinal); + var cmd = parseResult.CommandResult.Command; + while (cmd is not null) + { + foreach (var option in cmd.Options) + { + if (option is not System.CommandLine.Option) + { + continue; + } + if (!seen.Add(option.Name)) + { + continue; + } + if (GlobalOptionPreScan.TryFindInvalidBooleanValue(args, option.Name, option.Aliases, out var badValue)) + { + optionName = option.Name; + invalidValue = badValue; + return true; + } + } + + cmd = cmd.Parents.OfType().FirstOrDefault(); + } + + return false; + } + + /// + /// The global logging mode resolved from argv: the minimum log level plus the individual + /// --quiet/--verbose/--json flags, and a non-null + /// message when a mutually-exclusive combination was requested. + /// + internal readonly record struct LoggingMode(LogLevel MinimumLevel, bool Quiet, bool Verbose, bool Json, string? ConflictError); + + /// + /// Pre-scans argv for the global logging-mode flags and resolves the effective + /// . The scan (via ) stops at the first + /// standalone -- separator so passthrough payload (e.g. winapp run . -- --json) is + /// not misread as a winapp global flag. Extracted from so the flag precedence + /// and mutual-exclusion rules are unit testable without invoking the full entrypoint. + /// + internal static LoggingMode ResolveLoggingMode(string[] args) + { + var minimumLogLevel = LogLevel.Information; + bool quiet = false; + bool verbose = false; + bool json = false; + + verbose = GlobalOptionPreScan.GetBooleanFlagValue(args, WinAppRootCommand.VerboseOption.Name, WinAppRootCommand.VerboseOption.Aliases); + if (verbose) + { + minimumLogLevel = LogLevel.Debug; + } + quiet = GlobalOptionPreScan.GetBooleanFlagValue(args, WinAppRootCommand.QuietOption.Name, WinAppRootCommand.QuietOption.Aliases); + if (quiet) + { + minimumLogLevel = LogLevel.Warning; + } + json = GlobalOptionPreScan.GetBooleanFlagValue(args, WinAppRootCommand.JsonOption.Name, WinAppRootCommand.JsonOption.Aliases); + if (json) + { + minimumLogLevel = LogLevel.None; + } + + string? conflictError = null; + if (quiet && verbose) + { + conflictError = "Cannot specify both --quiet and --verbose options together."; + } + else if (quiet && json) + { + conflictError = "Cannot specify both --quiet and --json options together."; + } + else if (verbose && json) + { + conflictError = "Cannot specify both --verbose and --json options together."; + } + + return new LoggingMode(minimumLogLevel, quiet, verbose, json, conflictError); + } +} From 6856c285b1708e9657e02e83a54a3238b6be957c Mon Sep 17 00:00:00 2001 From: Akhil Gupta Date: Wed, 7 Oct 2026 21:15:51 -0700 Subject: [PATCH 14/21] Delete src/winapp-CLI/WinApp.Cli/Program.cs --- src/winapp-CLI/WinApp.Cli/Program.cs | 729 --------------------------- 1 file changed, 729 deletions(-) delete mode 100644 src/winapp-CLI/WinApp.Cli/Program.cs diff --git a/src/winapp-CLI/WinApp.Cli/Program.cs b/src/winapp-CLI/WinApp.Cli/Program.cs deleted file mode 100644 index 7523ffcae..000000000 --- a/src/winapp-CLI/WinApp.Cli/Program.cs +++ /dev/null @@ -1,729 +0,0 @@ -// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. -// Licensed under the MIT License. - -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Spectre.Console; -using System.Text; -using WinApp.Cli.Commands; -using WinApp.Cli.Helpers; -using WinApp.Cli.Models; -using WinApp.Cli.Services; -using WinApp.Cli.Telemetry; -using WinApp.Cli.Telemetry.Events; -using LogLevel = Microsoft.Extensions.Logging.LogLevel; - -namespace WinApp.Cli; - -internal static class Program -{ - internal static async Task Main(string[] args) - { - // Hidden internal verb: the WinUI DbgEng triage pass runs in this isolated child process so - // its modern dbgeng.dll is not poisoned by the system32 dbghelp.dll the parent already loaded. - // Intercept before any host/service setup to keep the loader state clean and output noise-free. - if (args.Length > 0 && args[0] == Services.XamlTriageRunner.InternalVerb) - { - return Services.XamlTriageRunner.Run(args); - } - - // Ensure UTF-8 I/O for emoji-capable terminals; fall back silently if not supported - ConfigureConsoleEncoding(static () => - { - Console.OutputEncoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); - Console.InputEncoding = Encoding.UTF8; - }); - - var loggingMode = ResolveLoggingMode(args); - if (loggingMode.ConflictError is not null) - { - Console.Error.WriteLine(loggingMode.ConflictError); - return 1; - } - - var minimumLogLevel = loggingMode.MinimumLevel; - bool quiet = loggingMode.Quiet; - bool json = loggingMode.Json; - - // Check if --cli-schema is specified - this outputs machine-readable JSON - // and should not display any interactive messages like first-run notices - bool isCliSchemaMode = GlobalOptionPreScan.IsFlagPresent( - args, WinAppRootCommand.CliSchemaOption.Name, []); - - // Check if this is a completion request - completions must be fast and silent - bool isCompleteMode = args.Length > 0 && args[0] == "complete"; - - var services = new ServiceCollection() - .ConfigureServices() - .ConfigureCommands() - .AddLogging(b => - { - b.ClearProviders(); - b.AddTextWriterLogger(Console.Out, Console.Error); - b.SetMinimumLevel(minimumLogLevel); - }); - - using var serviceProvider = services.BuildServiceProvider(); - - var rootCommand = serviceProvider.GetRequiredService(); - System.CommandLine.ParseResult? parseResult = null; - - if (args.Length > 0) - { - parseResult = rootCommand.Parse(args, WinAppParserConfiguration.Default); - - // Infrastructure processes are not separate user invocations. Set this before any - // telemetry instance is created; descendants inherit the winapp-only opt-out. - if (parseResult.CommandResult.Command is GuestAgentCommand) - { - Environment.SetEnvironmentVariable(Telemetry.Telemetry.OptOutEnvironmentVariable, "1"); - } - - // Set WINAPP_CLI_CALLER env var from --caller option so telemetry and update checks can use it - var caller = parseResult.GetValue(WinAppRootCommand.CallerOption); - if (!string.IsNullOrWhiteSpace(caller)) - { - Environment.SetEnvironmentVariable("WINAPP_CLI_CALLER", caller); - } - - // Reject an invalid '='-attached value on ANY boolean option reachable by the selected - // command — a global flag like --json=bogus or a command flag like --eraser=bogus. - // System.CommandLine silently coerces such a value to TRUE with no parse error, which - // would (a) silently enable a flag the user never set (e.g. the pen eraser) and (b) for - // --json leave logging un-suppressed, so the human ❌ line and the JSON envelope BOTH hit - // stderr and corrupt machine-readable output. Fail fast HERE — before the first-run / - // update notice can print anything (M1) — with a single clean invalid_arguments error, - // mirroring how the parser already rejects other malformed values (e.g. --pressure nope). - if (TryFindInvalidBooleanOption(parseResult, args, out var invalidBoolOption, out var invalidBoolValue)) - { - var message = - $"Cannot parse argument '{invalidBoolValue}' for option '{invalidBoolOption}' as expected type 'System.Boolean'."; - if (IsNewCommandJson(parseResult)) - { - // `new` owns a bespoke NewCommandResult envelope; emit it (and use its - // invalid-argument exit code) so machine callers get structured output here too. - NewCommand.EmitParseErrorJson(message); - return NewCommand.ExitInvalidArgs; - } - if (ResolveEffectiveJson(parseResult) && IsUiDescendant(parseResult)) - { - UiJsonError.Emit(true, UiJsonError.CodeInvalidArguments, message); - } - else if (ResolveEffectiveJson(parseResult) && IsFlatJsonErrorCommand(parseResult)) - { - EmitFlatJsonError(message); - } - else if (ResolveEffectiveJson(parseResult) && IsTargetDescendant(parseResult)) - { - TargetOutput.RejectCommandLine(message); - } - else - { - Console.Error.WriteLine(message); - } - - return 1; - } - - // `winapp ui ` fails before anything else, even with --help or --on: help is a - // terminating action, so the command line parses cleanly and would otherwise print the - // group help with exit 0, or route a command that does not exist to another target. - if (UiUnknownCommand.Find(parseResult) is { } unknownUiCommand) - { - var suggestions = UiUnknownCommand.Suggest(unknownUiCommand, parseResult.CommandResult.Command.Subcommands); - if (ResolveEffectiveJson(parseResult)) - { - UiJsonError.Emit(true, UiJsonError.CodeInvalidArguments, UiUnknownCommand.Message(unknownUiCommand), - recoveryHint: UiUnknownCommand.RecoveryHint, suggestions: suggestions); - } - else - { - UiUnknownCommand.WriteText(Console.Error, unknownUiCommand, suggestions); - } - - return 1; - } - } - - // Skip first-run notice for machine-readable output modes and completions - var didShowFirstRunNotice = false; - if (!isCliSchemaMode && !isCompleteMode && !json) - { - var firstRunService = serviceProvider.GetRequiredService(); - didShowFirstRunNotice = firstRunService.CheckAndDisplayFirstRunNotice(); - - // Check for CLI updates — shows cached notice instantly (no network), - // and starts a background refresh if the cache is stale (fire-and-forget). - if (!quiet) - { - var updateNotificationService = serviceProvider.GetRequiredService(); - updateNotificationService.CheckAndNotify(); - } - } - - // If no arguments provided, display banner and show help - if (args.Length == 0) - { - if (!didShowFirstRunNotice) - { - BannerHelper.DisplayBanner(); - } - - // Show help by invoking with --help - await rootCommand.Parse(["--help"], WinAppParserConfiguration.Default).InvokeAsync(); - return 0; - } - - var parsedArgs = parseResult!; - - // Derive the effective JSON mode from the SELECTED command's PARSED --json value (M1). - // The pre-scan (json) is value-aware but still a heuristic; the parsed value is the truth. - // Reading the parsed bool works even when a different option (e.g. --pressure) failed parse. - bool effectiveJson = ResolveEffectiveJson(parsedArgs); - - // Catch single-dash typos like "-app" before invocation so the user gets a clear - // "Did you mean --app?" message instead of System.CommandLine's confusing - // "Unrecognized command or argument" pointing at the wrong token (issue #467). - // Only run when parsing already failed — otherwise a command that legitimately - // accepts a "-foo"-shaped positional value would get a false-positive typo error. - if (parsedArgs.Errors.Count > 0) - { - var typo = OptionTypoValidator.FindLikelyLongOptionTypo(args, parsedArgs); - if (typo is not null) - { - var suggested = "-" + typo; - var typoMessage = $"Unknown option '{typo}'. Did you mean '{suggested}'?"; - if (IsNewCommandJson(parsedArgs)) - { - // Route `new --json` typo failures through its structured envelope with paired - // telemetry, mirroring the ui branch below and the main parse-error bridge. - if (!isCompleteMode) - { - CommandInvokedEvent.Log(parsedArgs.CommandResult); - } - NewCommand.EmitParseErrorJson(typoMessage); - if (!isCompleteMode) - { - CommandCompletedEvent.Log(parsedArgs.CommandResult, NewCommand.ExitInvalidArgs); - } - return NewCommand.ExitInvalidArgs; - } - if (effectiveJson && IsUiDescendant(parsedArgs)) - { - // Gate on IsUiDescendant so that non-ui commands (e.g. cert info) fall through - // to default error handling instead of receiving the nested UI schema (M2). - if (!isCompleteMode) - { - CommandInvokedEvent.Log(parsedArgs.CommandResult); - } - UiJsonError.Emit(true, UiJsonError.CodeInvalidArguments, typoMessage); - if (!isCompleteMode) - { - CommandCompletedEvent.Log(parsedArgs.CommandResult, 1); - } - } - else if (effectiveJson && IsFlatJsonErrorCommand(parsedArgs)) - { - // find-ui/find-api emit all their JSON (results and errors) on stdout as a flat - // {"error":"..."} object — keep parser-level failures on that same contract. - if (!isCompleteMode) - { - CommandInvokedEvent.Log(parsedArgs.CommandResult); - } - EmitFlatJsonError($"Unknown option '{typo}'. Did you mean '{suggested}'?"); - if (!isCompleteMode) - { - CommandCompletedEvent.Log(parsedArgs.CommandResult, 1); - } - } - else if (effectiveJson && IsTargetDescendant(parsedArgs)) - { - if (!isCompleteMode) - { - CommandInvokedEvent.Log(parsedArgs.CommandResult); - } - TargetOutput.RejectCommandLine(typoMessage); - if (!isCompleteMode) - { - CommandCompletedEvent.Log(parsedArgs.CommandResult, 1); - } - } - else - { - Console.Error.WriteLine(typoMessage); - Console.Error.WriteLine( - "(Single-dash flags are reserved for short aliases like '-a'. Long options use a double dash.)"); - } - return 1; - } - } - - return await RunWithTelemetryAsync(parsedArgs, isCompleteMode, async () => - { - // Target selection is settled before anything else, and settled for every command. - // A command that cannot honour --on says so, and a selector that names nothing usable - // fails here — never silently on this desktop, which is the one outcome the option - // exists to prevent. - if (ExecutionTargetSelection.Validate(parsedArgs) is { } selectionError) - { - return TargetOutput.RejectSelection( - serviceProvider.GetRequiredService(), effectiveJson, selectionError); - } - - // System.CommandLine binds an unrecognised option to a nearby optional positional rather - // than failing, so `--onn=sandbox` would become a UI element selector and the command - // would run here and report success. Rejected before dispatch for every command except - // `run`, whose own handler already reports the same mistake with the passthrough advice - // its `--` separator needs and in its own --json shape. - if (parsedArgs.CommandResult.Command is not RunCommand && - WindowsCommandLine.FindOptionLikePositionals(parsedArgs) is { Count: > 0 } stray) - { - return RejectOptionLikePositionals(parsedArgs, stray, effectiveJson); - } - - // One pre-dispatch interception, before any local UI service runs. A command the user - // sent to another target must not perform UI Automation, window discovery, capture, or - // input injection on this desktop, and the only way to guarantee that for every verb is - // to divert before the handler exists. - // - // Only for a command line that actually parsed. Routing a broken one first means - // `winapp ui inspect --on sandbox --depth notanumber` boots a Sandbox, spends minutes - // preparing it, and then reports a transport failure that never mentions `--depth` — - // and leaves the Sandbox running. The ordinary parse-error path is both faster and - // truthful, and it runs on this machine only because it runs nothing at all. - if (parsedArgs.Errors.Count == 0 && ExecutionTargetUiRouter.ShouldRoute(parsedArgs)) - { - var router = serviceProvider.GetRequiredService(); - return await router.RouteAsync( - args, - TargetUiRequirements.For(parsedArgs), - effectiveJson, - CancellationToken.None); - } - - if (parsedArgs.Action is System.CommandLine.Invocation.ParseErrorAction parseError - && IsUiDescendant(parsedArgs) - && parsedArgs.CommandResult.Command is not UiCommand) - { - // The error names the mistake; the full ui command help after it would bury that - // error, so point at --help instead. A bare "winapp ui" keeps its help: that is - // how people discover the commands. - parseError.ShowHelp = false; - return InvokeWithHelpPointerAsync(parsedArgs); - } - - // Parse errors must exit non-zero (issue #1015). InvokeAsync displays - // the error and help text, but return 1 explicitly so scripts and - // agents don't treat a rejected command line as success. - var invokeExitCode = await parsedArgs.InvokeAsync(); - if (parsedArgs.Errors.Count > 0 && invokeExitCode == 0) - { - return 1; - } - return invokeExitCode; - }); - } - - private static async Task InvokeWithHelpPointerAsync(System.CommandLine.ParseResult parsedArgs) - { - var exitCode = await parsedArgs.InvokeAsync(); - var path = string.Join(" ", parsedArgs.CommandResult.Command.Parents - .OfType() - .Where(c => c is not System.CommandLine.RootCommand) - .Select(c => c.Name) - .Prepend(parsedArgs.CommandResult.Command.Name) - .Reverse()); - parsedArgs.InvocationConfiguration.Error.WriteLine($"Run 'winapp {path} --help' for usage."); - return exitCode; - } - - /// - /// Reports positional values that were really misspelt options, and returns the exit code. - /// - /// - /// Rendered in whichever error shape the invoked command promises, so a --json caller - /// still gets a parseable document rather than a bare line on stderr. - /// - private static int RejectOptionLikePositionals( - System.CommandLine.ParseResult parsedArgs, - IReadOnlyList stray, - bool effectiveJson) - { - var message = stray.Count == 1 - ? $"Unrecognized option '{stray[0]}'." - : $"Unrecognized options: {string.Join(", ", stray.Select(token => $"'{token}'"))}."; - - var advice = "Check the spelling, or put the value after '--' if it really is an argument."; - - if (effectiveJson && IsUiDescendant(parsedArgs)) - { - UiJsonError.Emit(true, UiJsonError.CodeInvalidArguments, $"{message} {advice}"); - } - else if (effectiveJson && IsFlatJsonErrorCommand(parsedArgs)) - { - EmitFlatJsonError($"{message} {advice}"); - } - else if (effectiveJson && IsTargetDescendant(parsedArgs)) - { - TargetOutput.RejectCommandLine($"{message} {advice}"); - } - else - { - Console.Error.WriteLine(message); - Console.Error.WriteLine(advice); - } - - // The same code every other malformed winapp command line returns. This is a parse mistake, - // not a failure to reach a target, and conflating the two would make an unknown option look - // like an unavailable machine. - return TargetOutput.InvalidCommandLineExitCode; - } - - /// - /// Applies the given console-encoding mutation, swallowing the platform exception that occurs - /// when the standard stream encoding cannot be changed (e.g. redirected or unsupported handles). - /// UTF-8 I/O is a best-effort nicety, so a failure here must never abort startup. - /// - internal static void ConfigureConsoleEncoding(Action apply) - { - try - { - apply(); - } - catch - { - // ignore - } - } - - /// - /// Runs the parsed command via , emitting command-invoked/-completed - /// telemetry (unless in completion mode) and converting any unhandled exception into a logged - /// error plus exit code 1. The seam lets tests exercise both the - /// success and top-level-failure paths without needing a real, throwing command. - /// - internal static Task RunWithTelemetryAsync( - System.CommandLine.ParseResult parsedArgs, bool isCompleteMode, Func> invoke) => - RunWithTelemetryAsync(parsedArgs, isCompleteMode, invoke, CommandInvokedEvent.Log, CommandCompletedEvent.Log); - - /// - /// Core of - /// with the telemetry sinks injected. and - /// default (via the public overload) to the production - /// / events; the seam - /// lets tests assert that the invoked/completed events fire around the invocation — in order, with - /// the parsed command result and real exit code, and only when not in completion mode. - /// - internal static async Task RunWithTelemetryAsync( - System.CommandLine.ParseResult parsedArgs, bool isCompleteMode, Func> invoke, - Action logCommandInvoked, - Action logCommandCompleted) - { - try - { - // Start a correlation scope so the CommandInvoked/CommandCompleted events and any - // command-specific event a handler emits (e.g. NewCommand_Event) share one - // relatedActivityId for this invocation. - TelemetryCorrelation.Begin(); - - // Open the coordination scope in the same async flow that reads it below. A `winapp ui` - // command sets its cooperative-turn summary from deep inside invoke(), and - // CommandCompletedEvent picks it up here (issue #764). - Services.InteractiveDesktop.UiCoordinationTelemetryScope.Begin(); - - if (!isCompleteMode) - { - logCommandInvoked(parsedArgs.CommandResult); - } - - bool effectiveJson = ResolveEffectiveJson(parsedArgs); - - // Parse-error → JSON bridge: activated only when the SELECTED command exposes --json, - // its parsed value is true (effectiveJson), AND the command is a ui descendant (M3). - // Non-ui commands (e.g. cert info) use a flat {"error":"..."} schema — do not impose - // the UI nested contract on them; let SCL's default parse-error handling run instead. - // M3: emit CommandCompletedEvent before the early return to keep telemetry paired. - if (effectiveJson && parsedArgs.Errors.Count > 0 && IsUiDescendant(parsedArgs)) - { - var errorMsg = string.Join("; ", parsedArgs.Errors.Select(e => e.Message)); - UiJsonError.Emit(true, UiJsonError.CodeInvalidArguments, errorMsg); - if (!isCompleteMode) - { - logCommandCompleted(parsedArgs.CommandResult, 1); - } - return 1; - } - - // Parse-error → JSON bridge for `winapp new`: it owns a bespoke NewCommandResult schema - // (not the ui error envelope), so a parse failure like `--template bogus --json` must be - // reported in that shape rather than falling through to SCL's help/error text on stdout. - // Return ExitInvalidArgs (2) — the same code the handler uses for invalid names/versions — - // so agents can branch on the invalid-argument stage regardless of where it was caught. - if (effectiveJson && parsedArgs.Errors.Count > 0 - && parsedArgs.CommandResult.Command.Name == "new") - { - var errorMsg = string.Join("; ", parsedArgs.Errors.Select(e => e.Message)); - NewCommand.EmitParseErrorJson(errorMsg); - if (!isCompleteMode) - { - logCommandCompleted(parsedArgs.CommandResult, NewCommand.ExitInvalidArgs); - } - return NewCommand.ExitInvalidArgs; - } - - // Discovery parse-error → JSON bridge. find-ui and find-api both document a --json - // contract (results and in-handler validation errors all emit JSON on stdout), but a - // type/parser error such as `--max abc` fails before the handler runs, so - // System.CommandLine would otherwise print human help text. Emit the same flat - // {"error":"..."} object here so every --json path stays machine-readable (#719). - if (effectiveJson && parsedArgs.Errors.Count > 0 && IsFlatJsonErrorCommand(parsedArgs)) - { - var errorMsg = string.Join("; ", parsedArgs.Errors.Select(e => e.Message)); - EmitFlatJsonError(errorMsg); - if (!isCompleteMode) - { - logCommandCompleted(parsedArgs.CommandResult, 1); - } - return 1; - } - - // Parse-error → JSON bridge for the `target` verbs, which own their own error envelope - // ({"error":{"code":...}} on stderr). Same reasoning as the ui bridge: a caller that - // passed --json must never have to read human help text off stdout to find out that its - // command line was wrong. - if (effectiveJson && parsedArgs.Errors.Count > 0 && IsTargetDescendant(parsedArgs)) - { - var errorMsg = string.Join("; ", parsedArgs.Errors.Select(e => e.Message)); - var exitCode = TargetOutput.RejectCommandLine(errorMsg); - if (!isCompleteMode) - { - logCommandCompleted(parsedArgs.CommandResult, exitCode); - } - return exitCode; - } - - var returnCode = await invoke(); - - if (!isCompleteMode) - { - logCommandCompleted(parsedArgs.CommandResult, returnCode); - } - - return returnCode; - } - catch (Exception ex) - { - TelemetryFactory.Get().LogException(parsedArgs.CommandResult.Command.Name, ex); - Console.Error.WriteLine($"An unexpected error occurred: {ex.Message}"); - return 1; - } - } - - /// - /// Derives the effective JSON mode from the selected command's PARSED --json option. - /// Reading the parsed value is reliable even when a different option caused a parse error, - /// because System.CommandLine parses each option independently (M1). - /// - private static bool ResolveEffectiveJson(System.CommandLine.ParseResult parsedArgs) - { - // Only engage when the selected (innermost) command actually owns --json. - var selectedCmd = parsedArgs.CommandResult.Command; - if (!selectedCmd.Options.Contains(WinAppRootCommand.JsonOption)) - { - return false; - } - try - { - return parsedArgs.GetValue(WinAppRootCommand.JsonOption); - } - catch - { - // If reading the parsed value fails for any reason, do not fire the bridge. - return false; - } - } - - /// - /// Returns when the selected command is new with --json in - /// effect. Callers use this to route an early parse failure (invalid boolean value, single-dash - /// typo) through and - /// return , so new --json yields a machine-readable - /// NewCommandResult — with the same invalid-argument exit code the handler uses — for - /// EVERY parse error, not only those reaching the main bridge in . - /// - private static bool IsNewCommandJson(System.CommandLine.ParseResult parseResult) => - ResolveEffectiveJson(parseResult) && parseResult.CommandResult.Command.Name == "new"; - - /// - /// Returns when the selected command is the ui command - /// or any of its descendants. Used to scope the parse-error JSON bridge to ui commands - /// only, leaving non-ui commands (e.g. cert info) with their own flat error schema (M3). - /// - private static bool IsUiDescendant(System.CommandLine.ParseResult parseResult) - { - var cmd = parseResult.CommandResult.Command; - while (cmd is not null) - { - if (cmd.Name == "ui") - { - return true; - } - cmd = cmd.Parents.OfType().FirstOrDefault(); - } - return false; - } - - /// - /// Returns when the selected command is one of the discovery commands - /// that document a flat {"error":"..."} JSON contract — find-ui, or find-api - /// and any of its verbs. Used to route parser-level failures to that contract (#719) instead of - /// System.CommandLine's human help text, mirroring the ui bridge above. - /// - private static bool IsFlatJsonErrorCommand(System.CommandLine.ParseResult parseResult) - { - var cmd = parseResult.CommandResult.Command; - while (cmd is not null) - { - if (cmd.Name is "find-ui" or "find-api") - { - return true; - } - cmd = cmd.Parents.OfType().FirstOrDefault(); - } - return false; - } - - /// - /// Returns when the selected command is target or one of its - /// descendants, so a parse failure is reported in the target error envelope rather than as - /// help text. - /// - private static bool IsTargetDescendant(System.CommandLine.ParseResult parseResult) - { - var cmd = parseResult.CommandResult.Command; - while (cmd is not null) - { - if (cmd.Name == "target") - { - return true; - } - cmd = cmd.Parents.OfType().FirstOrDefault(); - } - return false; - } - - /// - /// Writes a flat {"error":"..."} object to stdout — the same schema and sink - /// find-ui and find-api use for in-handler errors — so a parse failure under - /// --json stays machine-readable. stdout (not stderr) matches where those commands emit - /// all their other JSON. - /// - private static void EmitFlatJsonError(string message) - { - var payload = System.Text.Json.JsonSerializer.Serialize( - new JsonErrorOutput { Error = message }, - WinAppJsonContext.Default.JsonErrorOutput); - Console.Out.WriteLine(payload); - } - - /// - /// Scans argv for an invalid =-attached value on any boolean option reachable by the - /// selected command — the command's own boolean - /// options plus inherited/global ones on ancestor commands (e.g. --json/--verbose/ - /// --quiet on the root, or --eraser on ui pen). System.CommandLine silently - /// coerces a non-boolean attached value (e.g. --eraser=bogus) to ; - /// the caller uses this to reject it with a clean error instead (#600 H1/M1/M2). - /// - private static bool TryFindInvalidBooleanOption( - System.CommandLine.ParseResult parseResult, - string[] args, - out string optionName, - out string invalidValue) - { - optionName = string.Empty; - invalidValue = string.Empty; - - // Walk the selected command up through its ancestors so both command-level bool options - // and inherited/global ones are covered. Dedupe by name in case an option appears twice. - var seen = new HashSet(StringComparer.Ordinal); - var cmd = parseResult.CommandResult.Command; - while (cmd is not null) - { - foreach (var option in cmd.Options) - { - if (option is not System.CommandLine.Option) - { - continue; - } - if (!seen.Add(option.Name)) - { - continue; - } - if (GlobalOptionPreScan.TryFindInvalidBooleanValue(args, option.Name, option.Aliases, out var badValue)) - { - optionName = option.Name; - invalidValue = badValue; - return true; - } - } - - cmd = cmd.Parents.OfType().FirstOrDefault(); - } - - return false; - } - - /// - /// The global logging mode resolved from argv: the minimum log level plus the individual - /// --quiet/--verbose/--json flags, and a non-null - /// message when a mutually-exclusive combination was requested. - /// - internal readonly record struct LoggingMode(LogLevel MinimumLevel, bool Quiet, bool Verbose, bool Json, string? ConflictError); - - /// - /// Pre-scans argv for the global logging-mode flags and resolves the effective - /// . The scan (via ) stops at the first - /// standalone -- separator so passthrough payload (e.g. winapp run . -- --json) is - /// not misread as a winapp global flag. Extracted from so the flag precedence - /// and mutual-exclusion rules are unit testable without invoking the full entrypoint. - /// - internal static LoggingMode ResolveLoggingMode(string[] args) - { - var minimumLogLevel = LogLevel.Information; - bool quiet = false; - bool verbose = false; - bool json = false; - - verbose = GlobalOptionPreScan.GetBooleanFlagValue(args, WinAppRootCommand.VerboseOption.Name, WinAppRootCommand.VerboseOption.Aliases); - if (verbose) - { - minimumLogLevel = LogLevel.Debug; - } - quiet = GlobalOptionPreScan.GetBooleanFlagValue(args, WinAppRootCommand.QuietOption.Name, WinAppRootCommand.QuietOption.Aliases); - if (quiet) - { - minimumLogLevel = LogLevel.Warning; - } - json = GlobalOptionPreScan.GetBooleanFlagValue(args, WinAppRootCommand.JsonOption.Name, WinAppRootCommand.JsonOption.Aliases); - if (json) - { - minimumLogLevel = LogLevel.None; - } - - string? conflictError = null; - if (quiet && verbose) - { - conflictError = "Cannot specify both --quiet and --verbose options together."; - } - else if (quiet && json) - { - conflictError = "Cannot specify both --quiet and --json options together."; - } - else if (verbose && json) - { - conflictError = "Cannot specify both --verbose and --json options together."; - } - - return new LoggingMode(minimumLogLevel, quiet, verbose, json, conflictError); - } -} From d6fb7957c67d6de0f60558571e32bfaf35635343 Mon Sep 17 00:00:00 2001 From: Akhil Gupta Date: Wed, 7 Oct 2026 21:16:39 -0700 Subject: [PATCH 15/21] fix: rebase exit-code fix onto current main with help-pointer flow --- .../WinApp.Cli/{winapp-Program-proper.cs => Program.cs} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/winapp-CLI/WinApp.Cli/{winapp-Program-proper.cs => Program.cs} (100%) diff --git a/src/winapp-CLI/WinApp.Cli/winapp-Program-proper.cs b/src/winapp-CLI/WinApp.Cli/Program.cs similarity index 100% rename from src/winapp-CLI/WinApp.Cli/winapp-Program-proper.cs rename to src/winapp-CLI/WinApp.Cli/Program.cs From 79018fd4582d4a75066fadbe38744cfbef985f49 Mon Sep 17 00:00:00 2001 From: Akhil Gupta Date: Thu, 8 Oct 2026 21:14:58 -0700 Subject: [PATCH 16/21] fix: add missing await in async lambda --- src/winapp-CLI/WinApp.Cli/Program.cs | 746 --------------------------- 1 file changed, 746 deletions(-) delete mode 100644 src/winapp-CLI/WinApp.Cli/Program.cs diff --git a/src/winapp-CLI/WinApp.Cli/Program.cs b/src/winapp-CLI/WinApp.Cli/Program.cs deleted file mode 100644 index d45f5d994..000000000 --- a/src/winapp-CLI/WinApp.Cli/Program.cs +++ /dev/null @@ -1,746 +0,0 @@ -// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. -// Licensed under the MIT License. - -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Spectre.Console; -using System.Text; -using WinApp.Cli.Commands; -using WinApp.Cli.Helpers; -using WinApp.Cli.Models; -using WinApp.Cli.Services; -using WinApp.Cli.Telemetry; -using WinApp.Cli.Telemetry.Events; -using LogLevel = Microsoft.Extensions.Logging.LogLevel; - -namespace WinApp.Cli; - -internal static class Program -{ - internal static async Task Main(string[] args) - { - // Hidden internal verb: the WinUI DbgEng triage pass runs in this isolated child process so - // its modern dbgeng.dll is not poisoned by the system32 dbghelp.dll the parent already loaded. - // Intercept before any host/service setup to keep the loader state clean and output noise-free. - if (args.Length > 0 && args[0] == Services.XamlTriageRunner.InternalVerb) - { - return Services.XamlTriageRunner.Run(args); - } - - // Ensure UTF-8 I/O for emoji-capable terminals; fall back silently if not supported - ConfigureConsoleEncoding(static () => - { - Console.OutputEncoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); - Console.InputEncoding = Encoding.UTF8; - }); - - var loggingMode = ResolveLoggingMode(args); - if (loggingMode.ConflictError is not null) - { - Console.Error.WriteLine(loggingMode.ConflictError); - return 1; - } - - var minimumLogLevel = loggingMode.MinimumLevel; - bool quiet = loggingMode.Quiet; - bool json = loggingMode.Json; - - // Check if --cli-schema is specified - this outputs machine-readable JSON - // and should not display any interactive messages like first-run notices - bool isCliSchemaMode = GlobalOptionPreScan.IsFlagPresent( - args, WinAppRootCommand.CliSchemaOption.Name, []); - - // Check if this is a completion request - completions must be fast and silent - bool isCompleteMode = args.Length > 0 && args[0] == "complete"; - - var services = new ServiceCollection() - .ConfigureServices() - .ConfigureCommands() - .AddLogging(b => - { - b.ClearProviders(); - b.AddTextWriterLogger(Console.Out, Console.Error); - b.SetMinimumLevel(minimumLogLevel); - }); - - using var serviceProvider = services.BuildServiceProvider(); - - var rootCommand = serviceProvider.GetRequiredService(); - System.CommandLine.ParseResult? parseResult = null; - - if (args.Length > 0) - { - parseResult = rootCommand.Parse(args, WinAppParserConfiguration.Default); - - // Infrastructure processes are not separate user invocations. Set this before any - // telemetry instance is created; descendants inherit the winapp-only opt-out. - if (parseResult.CommandResult.Command is GuestAgentCommand) - { - Environment.SetEnvironmentVariable(Telemetry.Telemetry.OptOutEnvironmentVariable, "1"); - } - - // Set WINAPP_CLI_CALLER env var from --caller option so telemetry and update checks can use it - var caller = parseResult.GetValue(WinAppRootCommand.CallerOption); - if (!string.IsNullOrWhiteSpace(caller)) - { - Environment.SetEnvironmentVariable("WINAPP_CLI_CALLER", caller); - } - - // Reject an invalid '='-attached value on ANY boolean option reachable by the selected - // command — a global flag like --json=bogus or a command flag like --eraser=bogus. - // System.CommandLine silently coerces such a value to TRUE with no parse error, which - // would (a) silently enable a flag the user never set (e.g. the pen eraser) and (b) for - // --json leave logging un-suppressed, so the human ❌ line and the JSON envelope BOTH hit - // stderr and corrupt machine-readable output. Fail fast HERE — before the first-run / - // update notice can print anything (M1) — with a single clean invalid_arguments error, - // mirroring how the parser already rejects other malformed values (e.g. --pressure nope). - if (TryFindInvalidBooleanOption(parseResult, args, out var invalidBoolOption, out var invalidBoolValue)) - { - var message = - $"Cannot parse argument '{invalidBoolValue}' for option '{invalidBoolOption}' as expected type 'System.Boolean'."; - if (IsNewCommandJson(parseResult)) - { - // `new` owns a bespoke NewCommandResult envelope; emit it (and use its - // invalid-argument exit code) so machine callers get structured output here too. - NewCommand.EmitParseErrorJson(message); - return NewCommand.ExitInvalidArgs; - } - if (ResolveEffectiveJson(parseResult) && IsUiDescendant(parseResult)) - { - UiJsonError.Emit(true, UiJsonError.CodeInvalidArguments, message); - } - else if (ResolveEffectiveJson(parseResult) && IsFlatJsonErrorCommand(parseResult)) - { - EmitFlatJsonError(message); - } - else if (ResolveEffectiveJson(parseResult) && IsTargetDescendant(parseResult)) - { - TargetOutput.RejectCommandLine(message); - } - else - { - Console.Error.WriteLine(message); - } - - return 1; - } - - // `winapp ui ` fails before anything else, even with --help or --on: help is a - // terminating action, so the command line parses cleanly and would otherwise print the - // group help with exit 0, or route a command that does not exist to another target. - if (UiUnknownCommand.Find(parseResult) is { } unknownUiCommand) - { - var suggestions = UiUnknownCommand.Suggest(unknownUiCommand, parseResult.CommandResult.Command.Subcommands); - if (ResolveEffectiveJson(parseResult)) - { - UiJsonError.Emit(true, UiJsonError.CodeInvalidArguments, UiUnknownCommand.Message(unknownUiCommand), - recoveryHint: UiUnknownCommand.RecoveryHint, suggestions: suggestions); - } - else - { - UiUnknownCommand.WriteText(Console.Error, unknownUiCommand, suggestions); - } - - return 1; - } - } - - // Skip first-run notice for machine-readable output modes and completions - var didShowFirstRunNotice = false; - if (!isCliSchemaMode && !isCompleteMode && !json) - { - var firstRunService = serviceProvider.GetRequiredService(); - didShowFirstRunNotice = firstRunService.CheckAndDisplayFirstRunNotice(); - - // Check for CLI updates — shows cached notice instantly (no network), - // and starts a background refresh if the cache is stale (fire-and-forget). - if (!quiet) - { - var updateNotificationService = serviceProvider.GetRequiredService(); - updateNotificationService.CheckAndNotify(); - } - } - - // If no arguments provided, display banner and show help - if (args.Length == 0) - { - if (!didShowFirstRunNotice) - { - BannerHelper.DisplayBanner(); - } - - // Show help by invoking with --help - await rootCommand.Parse(["--help"], WinAppParserConfiguration.Default).InvokeAsync(); - return 0; - } - - var parsedArgs = parseResult!; - - // Derive the effective JSON mode from the SELECTED command's PARSED --json value (M1). - // The pre-scan (json) is value-aware but still a heuristic; the parsed value is the truth. - // Reading the parsed bool works even when a different option (e.g. --pressure) failed parse. - bool effectiveJson = ResolveEffectiveJson(parsedArgs); - - // Catch single-dash typos like "-app" before invocation so the user gets a clear - // "Did you mean --app?" message instead of System.CommandLine's confusing - // "Unrecognized command or argument" pointing at the wrong token (issue #467). - // Only run when parsing already failed — otherwise a command that legitimately - // accepts a "-foo"-shaped positional value would get a false-positive typo error. - if (parsedArgs.Errors.Count > 0) - { - var typo = OptionTypoValidator.FindLikelyLongOptionTypo(args, parsedArgs); - if (typo is not null) - { - var suggested = "-" + typo; - var typoMessage = $"Unknown option '{typo}'. Did you mean '{suggested}'?"; - if (IsNewCommandJson(parsedArgs)) - { - // Route `new --json` typo failures through its structured envelope with paired - // telemetry, mirroring the ui branch below and the main parse-error bridge. - if (!isCompleteMode) - { - CommandInvokedEvent.Log(parsedArgs.CommandResult); - } - NewCommand.EmitParseErrorJson(typoMessage); - if (!isCompleteMode) - { - CommandCompletedEvent.Log(parsedArgs.CommandResult, NewCommand.ExitInvalidArgs); - } - return NewCommand.ExitInvalidArgs; - } - if (effectiveJson && IsUiDescendant(parsedArgs)) - { - // Gate on IsUiDescendant so that non-ui commands (e.g. cert info) fall through - // to default error handling instead of receiving the nested UI schema (M2). - if (!isCompleteMode) - { - CommandInvokedEvent.Log(parsedArgs.CommandResult); - } - UiJsonError.Emit(true, UiJsonError.CodeInvalidArguments, typoMessage); - if (!isCompleteMode) - { - CommandCompletedEvent.Log(parsedArgs.CommandResult, 1); - } - } - else if (effectiveJson && IsFlatJsonErrorCommand(parsedArgs)) - { - // find-ui/find-api emit all their JSON (results and errors) on stdout as a flat - // {"error":"..."} object — keep parser-level failures on that same contract. - if (!isCompleteMode) - { - CommandInvokedEvent.Log(parsedArgs.CommandResult); - } - EmitFlatJsonError($"Unknown option '{typo}'. Did you mean '{suggested}'?"); - if (!isCompleteMode) - { - CommandCompletedEvent.Log(parsedArgs.CommandResult, 1); - } - } - else if (effectiveJson && IsTargetDescendant(parsedArgs)) - { - if (!isCompleteMode) - { - CommandInvokedEvent.Log(parsedArgs.CommandResult); - } - TargetOutput.RejectCommandLine(typoMessage); - if (!isCompleteMode) - { - CommandCompletedEvent.Log(parsedArgs.CommandResult, 1); - } - } - else - { - Console.Error.WriteLine(typoMessage); - Console.Error.WriteLine( - "(Single-dash flags are reserved for short aliases like '-a'. Long options use a double dash.)"); - } - return 1; - } - } - - return await RunWithTelemetryAsync(parsedArgs, isCompleteMode, async () => - { - // --help and --cli-schema describe the command; they never execute it, so they run here - // regardless of --on rather than validating or preparing a target just to print text. - if (IsDescriptiveAction(parsedArgs)) - { - return parsedArgs.InvokeAsync(); - } - - // Target selection is settled before anything else, and settled for every command. - // A command that cannot honour --on says so, and a selector that names nothing usable - // fails here — never silently on this desktop, which is the one outcome the option - // exists to prevent. - if (ExecutionTargetSelection.Validate(parsedArgs) is { } selectionError) - { - return TargetOutput.RejectSelection( - serviceProvider.GetRequiredService(), effectiveJson, selectionError); - } - - // System.CommandLine binds an unrecognised option to a nearby optional positional rather - // than failing, so `--onn=sandbox` would become a UI element selector and the command - // would run here and report success. Rejected before dispatch for every command except - // `run`, whose own handler already reports the same mistake with the passthrough advice - // its `--` separator needs and in its own --json shape. - if (parsedArgs.CommandResult.Command is not RunCommand && - WindowsCommandLine.FindOptionLikePositionals(parsedArgs) is { Count: > 0 } stray) - { - return RejectOptionLikePositionals(parsedArgs, stray, effectiveJson); - } - - // One pre-dispatch interception, before any local UI service runs. A command the user - // sent to another target must not perform UI Automation, window discovery, capture, or - // input injection on this desktop, and the only way to guarantee that for every verb is - // to divert before the handler exists. - // - // Only for a command line that actually parsed. Routing a broken one first means - // `winapp ui inspect --on sandbox --depth notanumber` boots a Sandbox, spends minutes - // preparing it, and then reports a transport failure that never mentions `--depth` — - // and leaves the Sandbox running. The ordinary parse-error path is both faster and - // truthful, and it runs on this machine only because it runs nothing at all. - if (parsedArgs.Errors.Count == 0 && ExecutionTargetUiRouter.ShouldRoute(parsedArgs)) - { - var router = serviceProvider.GetRequiredService(); - return await router.RouteAsync( - args, - TargetUiRequirements.For(parsedArgs), - effectiveJson, - CancellationToken.None); - } - - if (parsedArgs.Action is System.CommandLine.Invocation.ParseErrorAction parseError - && IsUiDescendant(parsedArgs) - && parsedArgs.CommandResult.Command is not UiCommand) - { - // The error names the mistake; the full ui command help after it would bury that - // error, so point at --help instead. A bare "winapp ui" keeps its help: that is - // how people discover the commands. - parseError.ShowHelp = false; - return InvokeWithHelpPointerAsync(parsedArgs); - } - - // Parse errors must exit non-zero (issue #1015). InvokeAsync displays - // the error and help text, but return 1 explicitly so scripts and - // agents don't treat a rejected command line as success. - var invokeExitCode = await parsedArgs.InvokeAsync(); - if (parsedArgs.Errors.Count > 0 && invokeExitCode == 0) - { - return 1; - } - return invokeExitCode; - }); - } - - internal static bool IsDescriptiveAction(System.CommandLine.ParseResult parsedArgs) => - parsedArgs.Errors.Count == 0 && - parsedArgs.Action is { Terminating: true } action && - action != parsedArgs.CommandResult.Command.Action; - - private static async Task InvokeWithHelpPointerAsync(System.CommandLine.ParseResult parsedArgs) - { - var exitCode = await parsedArgs.InvokeAsync(); - // Parse errors must exit non-zero (issue #1015). - if (parsedArgs.Errors.Count > 0 && exitCode == 0) - { - return 1; - } - var path = string.Join(" ", parsedArgs.CommandResult.Command.Parents - .OfType() - .Where(c => c is not System.CommandLine.RootCommand) - .Select(c => c.Name) - .Prepend(parsedArgs.CommandResult.Command.Name) - .Reverse()); - parsedArgs.InvocationConfiguration.Error.WriteLine($"Run 'winapp {path} --help' for usage."); - return exitCode; - } - - /// - /// Reports positional values that were really misspelt options, and returns the exit code. - /// - /// - /// Rendered in whichever error shape the invoked command promises, so a --json caller - /// still gets a parseable document rather than a bare line on stderr. - /// - private static int RejectOptionLikePositionals( - System.CommandLine.ParseResult parsedArgs, - IReadOnlyList stray, - bool effectiveJson) - { - var message = stray.Count == 1 - ? $"Unrecognized option '{stray[0]}'." - : $"Unrecognized options: {string.Join(", ", stray.Select(token => $"'{token}'"))}."; - - var advice = "Check the spelling, or put the value after '--' if it really is an argument."; - - if (effectiveJson && IsUiDescendant(parsedArgs)) - { - UiJsonError.Emit(true, UiJsonError.CodeInvalidArguments, $"{message} {advice}"); - } - else if (effectiveJson && IsFlatJsonErrorCommand(parsedArgs)) - { - EmitFlatJsonError($"{message} {advice}"); - } - else if (effectiveJson && IsTargetDescendant(parsedArgs)) - { - TargetOutput.RejectCommandLine($"{message} {advice}"); - } - else - { - Console.Error.WriteLine(message); - Console.Error.WriteLine(advice); - } - - // The same code every other malformed winapp command line returns. This is a parse mistake, - // not a failure to reach a target, and conflating the two would make an unknown option look - // like an unavailable machine. - return TargetOutput.InvalidCommandLineExitCode; - } - - /// - /// Applies the given console-encoding mutation, swallowing the platform exception that occurs - /// when the standard stream encoding cannot be changed (e.g. redirected or unsupported handles). - /// UTF-8 I/O is a best-effort nicety, so a failure here must never abort startup. - /// - internal static void ConfigureConsoleEncoding(Action apply) - { - try - { - apply(); - } - catch - { - // ignore - } - } - - /// - /// Runs the parsed command via , emitting command-invoked/-completed - /// telemetry (unless in completion mode) and converting any unhandled exception into a logged - /// error plus exit code 1. The seam lets tests exercise both the - /// success and top-level-failure paths without needing a real, throwing command. - /// - internal static Task RunWithTelemetryAsync( - System.CommandLine.ParseResult parsedArgs, bool isCompleteMode, Func> invoke) => - RunWithTelemetryAsync(parsedArgs, isCompleteMode, invoke, CommandInvokedEvent.Log, CommandCompletedEvent.Log); - - /// - /// Core of - /// with the telemetry sinks injected. and - /// default (via the public overload) to the production - /// / events; the seam - /// lets tests assert that the invoked/completed events fire around the invocation — in order, with - /// the parsed command result and real exit code, and only when not in completion mode. - /// - internal static async Task RunWithTelemetryAsync( - System.CommandLine.ParseResult parsedArgs, bool isCompleteMode, Func> invoke, - Action logCommandInvoked, - Action logCommandCompleted) - { - try - { - // Start a correlation scope so the CommandInvoked/CommandCompleted events and any - // command-specific event a handler emits (e.g. NewCommand_Event) share one - // relatedActivityId for this invocation. - TelemetryCorrelation.Begin(); - - // Open the coordination scope in the same async flow that reads it below. A `winapp ui` - // command sets its cooperative-turn summary from deep inside invoke(), and - // CommandCompletedEvent picks it up here (issue #764). - Services.InteractiveDesktop.UiCoordinationTelemetryScope.Begin(); - - if (!isCompleteMode) - { - logCommandInvoked(parsedArgs.CommandResult); - } - - bool effectiveJson = ResolveEffectiveJson(parsedArgs); - - // Parse-error → JSON bridge: activated only when the SELECTED command exposes --json, - // its parsed value is true (effectiveJson), AND the command is a ui descendant (M3). - // Non-ui commands (e.g. cert info) use a flat {"error":"..."} schema — do not impose - // the UI nested contract on them; let SCL's default parse-error handling run instead. - // M3: emit CommandCompletedEvent before the early return to keep telemetry paired. - if (effectiveJson && parsedArgs.Errors.Count > 0 && IsUiDescendant(parsedArgs)) - { - var errorMsg = string.Join("; ", parsedArgs.Errors.Select(e => e.Message)); - UiJsonError.Emit(true, UiJsonError.CodeInvalidArguments, errorMsg); - if (!isCompleteMode) - { - logCommandCompleted(parsedArgs.CommandResult, 1); - } - return 1; - } - - // Parse-error → JSON bridge for `winapp new`: it owns a bespoke NewCommandResult schema - // (not the ui error envelope), so a parse failure like `--template bogus --json` must be - // reported in that shape rather than falling through to SCL's help/error text on stdout. - // Return ExitInvalidArgs (2) — the same code the handler uses for invalid names/versions — - // so agents can branch on the invalid-argument stage regardless of where it was caught. - if (effectiveJson && parsedArgs.Errors.Count > 0 - && parsedArgs.CommandResult.Command.Name == "new") - { - var errorMsg = string.Join("; ", parsedArgs.Errors.Select(e => e.Message)); - NewCommand.EmitParseErrorJson(errorMsg); - if (!isCompleteMode) - { - logCommandCompleted(parsedArgs.CommandResult, NewCommand.ExitInvalidArgs); - } - return NewCommand.ExitInvalidArgs; - } - - // Discovery parse-error → JSON bridge. find-ui and find-api both document a --json - // contract (results and in-handler validation errors all emit JSON on stdout), but a - // type/parser error such as `--max abc` fails before the handler runs, so - // System.CommandLine would otherwise print human help text. Emit the same flat - // {"error":"..."} object here so every --json path stays machine-readable (#719). - if (effectiveJson && parsedArgs.Errors.Count > 0 && IsFlatJsonErrorCommand(parsedArgs)) - { - var errorMsg = string.Join("; ", parsedArgs.Errors.Select(e => e.Message)); - EmitFlatJsonError(errorMsg); - if (!isCompleteMode) - { - logCommandCompleted(parsedArgs.CommandResult, 1); - } - return 1; - } - - // Parse-error → JSON bridge for the `target` verbs, which own their own error envelope - // ({"error":{"code":...}} on stderr). Same reasoning as the ui bridge: a caller that - // passed --json must never have to read human help text off stdout to find out that its - // command line was wrong. - if (effectiveJson && parsedArgs.Errors.Count > 0 && IsTargetDescendant(parsedArgs)) - { - var errorMsg = string.Join("; ", parsedArgs.Errors.Select(e => e.Message)); - var exitCode = TargetOutput.RejectCommandLine(errorMsg); - if (!isCompleteMode) - { - logCommandCompleted(parsedArgs.CommandResult, exitCode); - } - return exitCode; - } - - var returnCode = await invoke(); - - if (!isCompleteMode) - { - logCommandCompleted(parsedArgs.CommandResult, returnCode); - } - - return returnCode; - } - catch (Exception ex) - { - TelemetryFactory.Get().LogException(parsedArgs.CommandResult.Command.Name, ex); - Console.Error.WriteLine($"An unexpected error occurred: {ex.Message}"); - return 1; - } - } - - /// - /// Derives the effective JSON mode from the selected command's PARSED --json option. - /// Reading the parsed value is reliable even when a different option caused a parse error, - /// because System.CommandLine parses each option independently (M1). - /// - private static bool ResolveEffectiveJson(System.CommandLine.ParseResult parsedArgs) - { - // Only engage when the selected (innermost) command actually owns --json. - var selectedCmd = parsedArgs.CommandResult.Command; - if (!selectedCmd.Options.Contains(WinAppRootCommand.JsonOption)) - { - return false; - } - try - { - return parsedArgs.GetValue(WinAppRootCommand.JsonOption); - } - catch - { - // If reading the parsed value fails for any reason, do not fire the bridge. - return false; - } - } - - /// - /// Returns when the selected command is new with --json in - /// effect. Callers use this to route an early parse failure (invalid boolean value, single-dash - /// typo) through and - /// return , so new --json yields a machine-readable - /// NewCommandResult — with the same invalid-argument exit code the handler uses — for - /// EVERY parse error, not only those reaching the main bridge in . - /// - private static bool IsNewCommandJson(System.CommandLine.ParseResult parseResult) => - ResolveEffectiveJson(parseResult) && parseResult.CommandResult.Command.Name == "new"; - - /// - /// Returns when the selected command is the ui command - /// or any of its descendants. Used to scope the parse-error JSON bridge to ui commands - /// only, leaving non-ui commands (e.g. cert info) with their own flat error schema (M3). - /// - private static bool IsUiDescendant(System.CommandLine.ParseResult parseResult) - { - var cmd = parseResult.CommandResult.Command; - while (cmd is not null) - { - if (cmd.Name == "ui") - { - return true; - } - cmd = cmd.Parents.OfType().FirstOrDefault(); - } - return false; - } - - /// - /// Returns when the selected command is one of the discovery commands - /// that document a flat {"error":"..."} JSON contract — find-ui, or find-api - /// and any of its verbs. Used to route parser-level failures to that contract (#719) instead of - /// System.CommandLine's human help text, mirroring the ui bridge above. - /// - private static bool IsFlatJsonErrorCommand(System.CommandLine.ParseResult parseResult) - { - var cmd = parseResult.CommandResult.Command; - while (cmd is not null) - { - if (cmd.Name is "find-ui" or "find-api") - { - return true; - } - cmd = cmd.Parents.OfType().FirstOrDefault(); - } - return false; - } - - /// - /// Returns when the selected command is target or one of its - /// descendants, so a parse failure is reported in the target error envelope rather than as - /// help text. - /// - private static bool IsTargetDescendant(System.CommandLine.ParseResult parseResult) - { - var cmd = parseResult.CommandResult.Command; - while (cmd is not null) - { - if (cmd.Name == "target") - { - return true; - } - cmd = cmd.Parents.OfType().FirstOrDefault(); - } - return false; - } - - /// - /// Writes a flat {"error":"..."} object to stdout — the same schema and sink - /// find-ui and find-api use for in-handler errors — so a parse failure under - /// --json stays machine-readable. stdout (not stderr) matches where those commands emit - /// all their other JSON. - /// - private static void EmitFlatJsonError(string message) - { - var payload = System.Text.Json.JsonSerializer.Serialize( - new JsonErrorOutput { Error = message }, - WinAppJsonContext.Default.JsonErrorOutput); - Console.Out.WriteLine(payload); - } - - /// - /// Scans argv for an invalid =-attached value on any boolean option reachable by the - /// selected command — the command's own boolean - /// options plus inherited/global ones on ancestor commands (e.g. --json/--verbose/ - /// --quiet on the root, or --eraser on ui pen). System.CommandLine silently - /// coerces a non-boolean attached value (e.g. --eraser=bogus) to ; - /// the caller uses this to reject it with a clean error instead (#600 H1/M1/M2). - /// - private static bool TryFindInvalidBooleanOption( - System.CommandLine.ParseResult parseResult, - string[] args, - out string optionName, - out string invalidValue) - { - optionName = string.Empty; - invalidValue = string.Empty; - - // Walk the selected command up through its ancestors so both command-level bool options - // and inherited/global ones are covered. Dedupe by name in case an option appears twice. - var seen = new HashSet(StringComparer.Ordinal); - var cmd = parseResult.CommandResult.Command; - while (cmd is not null) - { - foreach (var option in cmd.Options) - { - if (option is not System.CommandLine.Option) - { - continue; - } - if (!seen.Add(option.Name)) - { - continue; - } - if (GlobalOptionPreScan.TryFindInvalidBooleanValue(args, option.Name, option.Aliases, out var badValue)) - { - optionName = option.Name; - invalidValue = badValue; - return true; - } - } - - cmd = cmd.Parents.OfType().FirstOrDefault(); - } - - return false; - } - - /// - /// The global logging mode resolved from argv: the minimum log level plus the individual - /// --quiet/--verbose/--json flags, and a non-null - /// message when a mutually-exclusive combination was requested. - /// - internal readonly record struct LoggingMode(LogLevel MinimumLevel, bool Quiet, bool Verbose, bool Json, string? ConflictError); - - /// - /// Pre-scans argv for the global logging-mode flags and resolves the effective - /// . The scan (via ) stops at the first - /// standalone -- separator so passthrough payload (e.g. winapp run . -- --json) is - /// not misread as a winapp global flag. Extracted from so the flag precedence - /// and mutual-exclusion rules are unit testable without invoking the full entrypoint. - /// - internal static LoggingMode ResolveLoggingMode(string[] args) - { - var minimumLogLevel = LogLevel.Information; - bool quiet = false; - bool verbose = false; - bool json = false; - - verbose = GlobalOptionPreScan.GetBooleanFlagValue(args, WinAppRootCommand.VerboseOption.Name, WinAppRootCommand.VerboseOption.Aliases); - if (verbose) - { - minimumLogLevel = LogLevel.Debug; - } - quiet = GlobalOptionPreScan.GetBooleanFlagValue(args, WinAppRootCommand.QuietOption.Name, WinAppRootCommand.QuietOption.Aliases); - if (quiet) - { - minimumLogLevel = LogLevel.Warning; - } - json = GlobalOptionPreScan.GetBooleanFlagValue(args, WinAppRootCommand.JsonOption.Name, WinAppRootCommand.JsonOption.Aliases); - if (json) - { - minimumLogLevel = LogLevel.None; - } - - string? conflictError = null; - if (quiet && verbose) - { - conflictError = "Cannot specify both --quiet and --verbose options together."; - } - else if (quiet && json) - { - conflictError = "Cannot specify both --quiet and --json options together."; - } - else if (verbose && json) - { - conflictError = "Cannot specify both --verbose and --json options together."; - } - - return new LoggingMode(minimumLogLevel, quiet, verbose, json, conflictError); - } -} From 69043b7f217d17b566faef071c60bd2e71a42c0e Mon Sep 17 00:00:00 2001 From: Akhil Gupta Date: Thu, 8 Oct 2026 21:15:24 -0700 Subject: [PATCH 17/21] fix: add missing await in async lambda --- .../WinApp.Cli/winapp-Program-await.cs | 746 ++++++++++++++++++ 1 file changed, 746 insertions(+) create mode 100644 src/winapp-CLI/WinApp.Cli/winapp-Program-await.cs diff --git a/src/winapp-CLI/WinApp.Cli/winapp-Program-await.cs b/src/winapp-CLI/WinApp.Cli/winapp-Program-await.cs new file mode 100644 index 000000000..a79e587e7 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/winapp-Program-await.cs @@ -0,0 +1,746 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Spectre.Console; +using System.Text; +using WinApp.Cli.Commands; +using WinApp.Cli.Helpers; +using WinApp.Cli.Models; +using WinApp.Cli.Services; +using WinApp.Cli.Telemetry; +using WinApp.Cli.Telemetry.Events; +using LogLevel = Microsoft.Extensions.Logging.LogLevel; + +namespace WinApp.Cli; + +internal static class Program +{ + internal static async Task Main(string[] args) + { + // Hidden internal verb: the WinUI DbgEng triage pass runs in this isolated child process so + // its modern dbgeng.dll is not poisoned by the system32 dbghelp.dll the parent already loaded. + // Intercept before any host/service setup to keep the loader state clean and output noise-free. + if (args.Length > 0 && args[0] == Services.XamlTriageRunner.InternalVerb) + { + return Services.XamlTriageRunner.Run(args); + } + + // Ensure UTF-8 I/O for emoji-capable terminals; fall back silently if not supported + ConfigureConsoleEncoding(static () => + { + Console.OutputEncoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); + Console.InputEncoding = Encoding.UTF8; + }); + + var loggingMode = ResolveLoggingMode(args); + if (loggingMode.ConflictError is not null) + { + Console.Error.WriteLine(loggingMode.ConflictError); + return 1; + } + + var minimumLogLevel = loggingMode.MinimumLevel; + bool quiet = loggingMode.Quiet; + bool json = loggingMode.Json; + + // Check if --cli-schema is specified - this outputs machine-readable JSON + // and should not display any interactive messages like first-run notices + bool isCliSchemaMode = GlobalOptionPreScan.IsFlagPresent( + args, WinAppRootCommand.CliSchemaOption.Name, []); + + // Check if this is a completion request - completions must be fast and silent + bool isCompleteMode = args.Length > 0 && args[0] == "complete"; + + var services = new ServiceCollection() + .ConfigureServices() + .ConfigureCommands() + .AddLogging(b => + { + b.ClearProviders(); + b.AddTextWriterLogger(Console.Out, Console.Error); + b.SetMinimumLevel(minimumLogLevel); + }); + + using var serviceProvider = services.BuildServiceProvider(); + + var rootCommand = serviceProvider.GetRequiredService(); + System.CommandLine.ParseResult? parseResult = null; + + if (args.Length > 0) + { + parseResult = rootCommand.Parse(args, WinAppParserConfiguration.Default); + + // Infrastructure processes are not separate user invocations. Set this before any + // telemetry instance is created; descendants inherit the winapp-only opt-out. + if (parseResult.CommandResult.Command is GuestAgentCommand) + { + Environment.SetEnvironmentVariable(Telemetry.Telemetry.OptOutEnvironmentVariable, "1"); + } + + // Set WINAPP_CLI_CALLER env var from --caller option so telemetry and update checks can use it + var caller = parseResult.GetValue(WinAppRootCommand.CallerOption); + if (!string.IsNullOrWhiteSpace(caller)) + { + Environment.SetEnvironmentVariable("WINAPP_CLI_CALLER", caller); + } + + // Reject an invalid '='-attached value on ANY boolean option reachable by the selected + // command — a global flag like --json=bogus or a command flag like --eraser=bogus. + // System.CommandLine silently coerces such a value to TRUE with no parse error, which + // would (a) silently enable a flag the user never set (e.g. the pen eraser) and (b) for + // --json leave logging un-suppressed, so the human ❌ line and the JSON envelope BOTH hit + // stderr and corrupt machine-readable output. Fail fast HERE — before the first-run / + // update notice can print anything (M1) — with a single clean invalid_arguments error, + // mirroring how the parser already rejects other malformed values (e.g. --pressure nope). + if (TryFindInvalidBooleanOption(parseResult, args, out var invalidBoolOption, out var invalidBoolValue)) + { + var message = + $"Cannot parse argument '{invalidBoolValue}' for option '{invalidBoolOption}' as expected type 'System.Boolean'."; + if (IsNewCommandJson(parseResult)) + { + // `new` owns a bespoke NewCommandResult envelope; emit it (and use its + // invalid-argument exit code) so machine callers get structured output here too. + NewCommand.EmitParseErrorJson(message); + return NewCommand.ExitInvalidArgs; + } + if (ResolveEffectiveJson(parseResult) && IsUiDescendant(parseResult)) + { + UiJsonError.Emit(true, UiJsonError.CodeInvalidArguments, message); + } + else if (ResolveEffectiveJson(parseResult) && IsFlatJsonErrorCommand(parseResult)) + { + EmitFlatJsonError(message); + } + else if (ResolveEffectiveJson(parseResult) && IsTargetDescendant(parseResult)) + { + TargetOutput.RejectCommandLine(message); + } + else + { + Console.Error.WriteLine(message); + } + + return 1; + } + + // `winapp ui ` fails before anything else, even with --help or --on: help is a + // terminating action, so the command line parses cleanly and would otherwise print the + // group help with exit 0, or route a command that does not exist to another target. + if (UiUnknownCommand.Find(parseResult) is { } unknownUiCommand) + { + var suggestions = UiUnknownCommand.Suggest(unknownUiCommand, parseResult.CommandResult.Command.Subcommands); + if (ResolveEffectiveJson(parseResult)) + { + UiJsonError.Emit(true, UiJsonError.CodeInvalidArguments, UiUnknownCommand.Message(unknownUiCommand), + recoveryHint: UiUnknownCommand.RecoveryHint, suggestions: suggestions); + } + else + { + UiUnknownCommand.WriteText(Console.Error, unknownUiCommand, suggestions); + } + + return 1; + } + } + + // Skip first-run notice for machine-readable output modes and completions + var didShowFirstRunNotice = false; + if (!isCliSchemaMode && !isCompleteMode && !json) + { + var firstRunService = serviceProvider.GetRequiredService(); + didShowFirstRunNotice = firstRunService.CheckAndDisplayFirstRunNotice(); + + // Check for CLI updates — shows cached notice instantly (no network), + // and starts a background refresh if the cache is stale (fire-and-forget). + if (!quiet) + { + var updateNotificationService = serviceProvider.GetRequiredService(); + updateNotificationService.CheckAndNotify(); + } + } + + // If no arguments provided, display banner and show help + if (args.Length == 0) + { + if (!didShowFirstRunNotice) + { + BannerHelper.DisplayBanner(); + } + + // Show help by invoking with --help + await rootCommand.Parse(["--help"], WinAppParserConfiguration.Default).InvokeAsync(); + return 0; + } + + var parsedArgs = parseResult!; + + // Derive the effective JSON mode from the SELECTED command's PARSED --json value (M1). + // The pre-scan (json) is value-aware but still a heuristic; the parsed value is the truth. + // Reading the parsed bool works even when a different option (e.g. --pressure) failed parse. + bool effectiveJson = ResolveEffectiveJson(parsedArgs); + + // Catch single-dash typos like "-app" before invocation so the user gets a clear + // "Did you mean --app?" message instead of System.CommandLine's confusing + // "Unrecognized command or argument" pointing at the wrong token (issue #467). + // Only run when parsing already failed — otherwise a command that legitimately + // accepts a "-foo"-shaped positional value would get a false-positive typo error. + if (parsedArgs.Errors.Count > 0) + { + var typo = OptionTypoValidator.FindLikelyLongOptionTypo(args, parsedArgs); + if (typo is not null) + { + var suggested = "-" + typo; + var typoMessage = $"Unknown option '{typo}'. Did you mean '{suggested}'?"; + if (IsNewCommandJson(parsedArgs)) + { + // Route `new --json` typo failures through its structured envelope with paired + // telemetry, mirroring the ui branch below and the main parse-error bridge. + if (!isCompleteMode) + { + CommandInvokedEvent.Log(parsedArgs.CommandResult); + } + NewCommand.EmitParseErrorJson(typoMessage); + if (!isCompleteMode) + { + CommandCompletedEvent.Log(parsedArgs.CommandResult, NewCommand.ExitInvalidArgs); + } + return NewCommand.ExitInvalidArgs; + } + if (effectiveJson && IsUiDescendant(parsedArgs)) + { + // Gate on IsUiDescendant so that non-ui commands (e.g. cert info) fall through + // to default error handling instead of receiving the nested UI schema (M2). + if (!isCompleteMode) + { + CommandInvokedEvent.Log(parsedArgs.CommandResult); + } + UiJsonError.Emit(true, UiJsonError.CodeInvalidArguments, typoMessage); + if (!isCompleteMode) + { + CommandCompletedEvent.Log(parsedArgs.CommandResult, 1); + } + } + else if (effectiveJson && IsFlatJsonErrorCommand(parsedArgs)) + { + // find-ui/find-api emit all their JSON (results and errors) on stdout as a flat + // {"error":"..."} object — keep parser-level failures on that same contract. + if (!isCompleteMode) + { + CommandInvokedEvent.Log(parsedArgs.CommandResult); + } + EmitFlatJsonError($"Unknown option '{typo}'. Did you mean '{suggested}'?"); + if (!isCompleteMode) + { + CommandCompletedEvent.Log(parsedArgs.CommandResult, 1); + } + } + else if (effectiveJson && IsTargetDescendant(parsedArgs)) + { + if (!isCompleteMode) + { + CommandInvokedEvent.Log(parsedArgs.CommandResult); + } + TargetOutput.RejectCommandLine(typoMessage); + if (!isCompleteMode) + { + CommandCompletedEvent.Log(parsedArgs.CommandResult, 1); + } + } + else + { + Console.Error.WriteLine(typoMessage); + Console.Error.WriteLine( + "(Single-dash flags are reserved for short aliases like '-a'. Long options use a double dash.)"); + } + return 1; + } + } + + return await RunWithTelemetryAsync(parsedArgs, isCompleteMode, async () => + { + // --help and --cli-schema describe the command; they never execute it, so they run here + // regardless of --on rather than validating or preparing a target just to print text. + if (IsDescriptiveAction(parsedArgs)) + { + return await parsedArgs.InvokeAsync(); + } + + // Target selection is settled before anything else, and settled for every command. + // A command that cannot honour --on says so, and a selector that names nothing usable + // fails here — never silently on this desktop, which is the one outcome the option + // exists to prevent. + if (ExecutionTargetSelection.Validate(parsedArgs) is { } selectionError) + { + return TargetOutput.RejectSelection( + serviceProvider.GetRequiredService(), effectiveJson, selectionError); + } + + // System.CommandLine binds an unrecognised option to a nearby optional positional rather + // than failing, so `--onn=sandbox` would become a UI element selector and the command + // would run here and report success. Rejected before dispatch for every command except + // `run`, whose own handler already reports the same mistake with the passthrough advice + // its `--` separator needs and in its own --json shape. + if (parsedArgs.CommandResult.Command is not RunCommand && + WindowsCommandLine.FindOptionLikePositionals(parsedArgs) is { Count: > 0 } stray) + { + return RejectOptionLikePositionals(parsedArgs, stray, effectiveJson); + } + + // One pre-dispatch interception, before any local UI service runs. A command the user + // sent to another target must not perform UI Automation, window discovery, capture, or + // input injection on this desktop, and the only way to guarantee that for every verb is + // to divert before the handler exists. + // + // Only for a command line that actually parsed. Routing a broken one first means + // `winapp ui inspect --on sandbox --depth notanumber` boots a Sandbox, spends minutes + // preparing it, and then reports a transport failure that never mentions `--depth` — + // and leaves the Sandbox running. The ordinary parse-error path is both faster and + // truthful, and it runs on this machine only because it runs nothing at all. + if (parsedArgs.Errors.Count == 0 && ExecutionTargetUiRouter.ShouldRoute(parsedArgs)) + { + var router = serviceProvider.GetRequiredService(); + return await router.RouteAsync( + args, + TargetUiRequirements.For(parsedArgs), + effectiveJson, + CancellationToken.None); + } + + if (parsedArgs.Action is System.CommandLine.Invocation.ParseErrorAction parseError + && IsUiDescendant(parsedArgs) + && parsedArgs.CommandResult.Command is not UiCommand) + { + // The error names the mistake; the full ui command help after it would bury that + // error, so point at --help instead. A bare "winapp ui" keeps its help: that is + // how people discover the commands. + parseError.ShowHelp = false; + return InvokeWithHelpPointerAsync(parsedArgs); + } + + // Parse errors must exit non-zero (issue #1015). InvokeAsync displays + // the error and help text, but return 1 explicitly so scripts and + // agents don't treat a rejected command line as success. + var invokeExitCode = await parsedArgs.InvokeAsync(); + if (parsedArgs.Errors.Count > 0 && invokeExitCode == 0) + { + return 1; + } + return invokeExitCode; + }); + } + + internal static bool IsDescriptiveAction(System.CommandLine.ParseResult parsedArgs) => + parsedArgs.Errors.Count == 0 && + parsedArgs.Action is { Terminating: true } action && + action != parsedArgs.CommandResult.Command.Action; + + private static async Task InvokeWithHelpPointerAsync(System.CommandLine.ParseResult parsedArgs) + { + var exitCode = await parsedArgs.InvokeAsync(); + // Parse errors must exit non-zero (issue #1015). + if (parsedArgs.Errors.Count > 0 && exitCode == 0) + { + return 1; + } + var path = string.Join(" ", parsedArgs.CommandResult.Command.Parents + .OfType() + .Where(c => c is not System.CommandLine.RootCommand) + .Select(c => c.Name) + .Prepend(parsedArgs.CommandResult.Command.Name) + .Reverse()); + parsedArgs.InvocationConfiguration.Error.WriteLine($"Run 'winapp {path} --help' for usage."); + return exitCode; + } + + /// + /// Reports positional values that were really misspelt options, and returns the exit code. + /// + /// + /// Rendered in whichever error shape the invoked command promises, so a --json caller + /// still gets a parseable document rather than a bare line on stderr. + /// + private static int RejectOptionLikePositionals( + System.CommandLine.ParseResult parsedArgs, + IReadOnlyList stray, + bool effectiveJson) + { + var message = stray.Count == 1 + ? $"Unrecognized option '{stray[0]}'." + : $"Unrecognized options: {string.Join(", ", stray.Select(token => $"'{token}'"))}."; + + var advice = "Check the spelling, or put the value after '--' if it really is an argument."; + + if (effectiveJson && IsUiDescendant(parsedArgs)) + { + UiJsonError.Emit(true, UiJsonError.CodeInvalidArguments, $"{message} {advice}"); + } + else if (effectiveJson && IsFlatJsonErrorCommand(parsedArgs)) + { + EmitFlatJsonError($"{message} {advice}"); + } + else if (effectiveJson && IsTargetDescendant(parsedArgs)) + { + TargetOutput.RejectCommandLine($"{message} {advice}"); + } + else + { + Console.Error.WriteLine(message); + Console.Error.WriteLine(advice); + } + + // The same code every other malformed winapp command line returns. This is a parse mistake, + // not a failure to reach a target, and conflating the two would make an unknown option look + // like an unavailable machine. + return TargetOutput.InvalidCommandLineExitCode; + } + + /// + /// Applies the given console-encoding mutation, swallowing the platform exception that occurs + /// when the standard stream encoding cannot be changed (e.g. redirected or unsupported handles). + /// UTF-8 I/O is a best-effort nicety, so a failure here must never abort startup. + /// + internal static void ConfigureConsoleEncoding(Action apply) + { + try + { + apply(); + } + catch + { + // ignore + } + } + + /// + /// Runs the parsed command via , emitting command-invoked/-completed + /// telemetry (unless in completion mode) and converting any unhandled exception into a logged + /// error plus exit code 1. The seam lets tests exercise both the + /// success and top-level-failure paths without needing a real, throwing command. + /// + internal static Task RunWithTelemetryAsync( + System.CommandLine.ParseResult parsedArgs, bool isCompleteMode, Func> invoke) => + RunWithTelemetryAsync(parsedArgs, isCompleteMode, invoke, CommandInvokedEvent.Log, CommandCompletedEvent.Log); + + /// + /// Core of + /// with the telemetry sinks injected. and + /// default (via the public overload) to the production + /// / events; the seam + /// lets tests assert that the invoked/completed events fire around the invocation — in order, with + /// the parsed command result and real exit code, and only when not in completion mode. + /// + internal static async Task RunWithTelemetryAsync( + System.CommandLine.ParseResult parsedArgs, bool isCompleteMode, Func> invoke, + Action logCommandInvoked, + Action logCommandCompleted) + { + try + { + // Start a correlation scope so the CommandInvoked/CommandCompleted events and any + // command-specific event a handler emits (e.g. NewCommand_Event) share one + // relatedActivityId for this invocation. + TelemetryCorrelation.Begin(); + + // Open the coordination scope in the same async flow that reads it below. A `winapp ui` + // command sets its cooperative-turn summary from deep inside invoke(), and + // CommandCompletedEvent picks it up here (issue #764). + Services.InteractiveDesktop.UiCoordinationTelemetryScope.Begin(); + + if (!isCompleteMode) + { + logCommandInvoked(parsedArgs.CommandResult); + } + + bool effectiveJson = ResolveEffectiveJson(parsedArgs); + + // Parse-error → JSON bridge: activated only when the SELECTED command exposes --json, + // its parsed value is true (effectiveJson), AND the command is a ui descendant (M3). + // Non-ui commands (e.g. cert info) use a flat {"error":"..."} schema — do not impose + // the UI nested contract on them; let SCL's default parse-error handling run instead. + // M3: emit CommandCompletedEvent before the early return to keep telemetry paired. + if (effectiveJson && parsedArgs.Errors.Count > 0 && IsUiDescendant(parsedArgs)) + { + var errorMsg = string.Join("; ", parsedArgs.Errors.Select(e => e.Message)); + UiJsonError.Emit(true, UiJsonError.CodeInvalidArguments, errorMsg); + if (!isCompleteMode) + { + logCommandCompleted(parsedArgs.CommandResult, 1); + } + return 1; + } + + // Parse-error → JSON bridge for `winapp new`: it owns a bespoke NewCommandResult schema + // (not the ui error envelope), so a parse failure like `--template bogus --json` must be + // reported in that shape rather than falling through to SCL's help/error text on stdout. + // Return ExitInvalidArgs (2) — the same code the handler uses for invalid names/versions — + // so agents can branch on the invalid-argument stage regardless of where it was caught. + if (effectiveJson && parsedArgs.Errors.Count > 0 + && parsedArgs.CommandResult.Command.Name == "new") + { + var errorMsg = string.Join("; ", parsedArgs.Errors.Select(e => e.Message)); + NewCommand.EmitParseErrorJson(errorMsg); + if (!isCompleteMode) + { + logCommandCompleted(parsedArgs.CommandResult, NewCommand.ExitInvalidArgs); + } + return NewCommand.ExitInvalidArgs; + } + + // Discovery parse-error → JSON bridge. find-ui and find-api both document a --json + // contract (results and in-handler validation errors all emit JSON on stdout), but a + // type/parser error such as `--max abc` fails before the handler runs, so + // System.CommandLine would otherwise print human help text. Emit the same flat + // {"error":"..."} object here so every --json path stays machine-readable (#719). + if (effectiveJson && parsedArgs.Errors.Count > 0 && IsFlatJsonErrorCommand(parsedArgs)) + { + var errorMsg = string.Join("; ", parsedArgs.Errors.Select(e => e.Message)); + EmitFlatJsonError(errorMsg); + if (!isCompleteMode) + { + logCommandCompleted(parsedArgs.CommandResult, 1); + } + return 1; + } + + // Parse-error → JSON bridge for the `target` verbs, which own their own error envelope + // ({"error":{"code":...}} on stderr). Same reasoning as the ui bridge: a caller that + // passed --json must never have to read human help text off stdout to find out that its + // command line was wrong. + if (effectiveJson && parsedArgs.Errors.Count > 0 && IsTargetDescendant(parsedArgs)) + { + var errorMsg = string.Join("; ", parsedArgs.Errors.Select(e => e.Message)); + var exitCode = TargetOutput.RejectCommandLine(errorMsg); + if (!isCompleteMode) + { + logCommandCompleted(parsedArgs.CommandResult, exitCode); + } + return exitCode; + } + + var returnCode = await invoke(); + + if (!isCompleteMode) + { + logCommandCompleted(parsedArgs.CommandResult, returnCode); + } + + return returnCode; + } + catch (Exception ex) + { + TelemetryFactory.Get().LogException(parsedArgs.CommandResult.Command.Name, ex); + Console.Error.WriteLine($"An unexpected error occurred: {ex.Message}"); + return 1; + } + } + + /// + /// Derives the effective JSON mode from the selected command's PARSED --json option. + /// Reading the parsed value is reliable even when a different option caused a parse error, + /// because System.CommandLine parses each option independently (M1). + /// + private static bool ResolveEffectiveJson(System.CommandLine.ParseResult parsedArgs) + { + // Only engage when the selected (innermost) command actually owns --json. + var selectedCmd = parsedArgs.CommandResult.Command; + if (!selectedCmd.Options.Contains(WinAppRootCommand.JsonOption)) + { + return false; + } + try + { + return parsedArgs.GetValue(WinAppRootCommand.JsonOption); + } + catch + { + // If reading the parsed value fails for any reason, do not fire the bridge. + return false; + } + } + + /// + /// Returns when the selected command is new with --json in + /// effect. Callers use this to route an early parse failure (invalid boolean value, single-dash + /// typo) through and + /// return , so new --json yields a machine-readable + /// NewCommandResult — with the same invalid-argument exit code the handler uses — for + /// EVERY parse error, not only those reaching the main bridge in . + /// + private static bool IsNewCommandJson(System.CommandLine.ParseResult parseResult) => + ResolveEffectiveJson(parseResult) && parseResult.CommandResult.Command.Name == "new"; + + /// + /// Returns when the selected command is the ui command + /// or any of its descendants. Used to scope the parse-error JSON bridge to ui commands + /// only, leaving non-ui commands (e.g. cert info) with their own flat error schema (M3). + /// + private static bool IsUiDescendant(System.CommandLine.ParseResult parseResult) + { + var cmd = parseResult.CommandResult.Command; + while (cmd is not null) + { + if (cmd.Name == "ui") + { + return true; + } + cmd = cmd.Parents.OfType().FirstOrDefault(); + } + return false; + } + + /// + /// Returns when the selected command is one of the discovery commands + /// that document a flat {"error":"..."} JSON contract — find-ui, or find-api + /// and any of its verbs. Used to route parser-level failures to that contract (#719) instead of + /// System.CommandLine's human help text, mirroring the ui bridge above. + /// + private static bool IsFlatJsonErrorCommand(System.CommandLine.ParseResult parseResult) + { + var cmd = parseResult.CommandResult.Command; + while (cmd is not null) + { + if (cmd.Name is "find-ui" or "find-api") + { + return true; + } + cmd = cmd.Parents.OfType().FirstOrDefault(); + } + return false; + } + + /// + /// Returns when the selected command is target or one of its + /// descendants, so a parse failure is reported in the target error envelope rather than as + /// help text. + /// + private static bool IsTargetDescendant(System.CommandLine.ParseResult parseResult) + { + var cmd = parseResult.CommandResult.Command; + while (cmd is not null) + { + if (cmd.Name == "target") + { + return true; + } + cmd = cmd.Parents.OfType().FirstOrDefault(); + } + return false; + } + + /// + /// Writes a flat {"error":"..."} object to stdout — the same schema and sink + /// find-ui and find-api use for in-handler errors — so a parse failure under + /// --json stays machine-readable. stdout (not stderr) matches where those commands emit + /// all their other JSON. + /// + private static void EmitFlatJsonError(string message) + { + var payload = System.Text.Json.JsonSerializer.Serialize( + new JsonErrorOutput { Error = message }, + WinAppJsonContext.Default.JsonErrorOutput); + Console.Out.WriteLine(payload); + } + + /// + /// Scans argv for an invalid =-attached value on any boolean option reachable by the + /// selected command — the command's own boolean + /// options plus inherited/global ones on ancestor commands (e.g. --json/--verbose/ + /// --quiet on the root, or --eraser on ui pen). System.CommandLine silently + /// coerces a non-boolean attached value (e.g. --eraser=bogus) to ; + /// the caller uses this to reject it with a clean error instead (#600 H1/M1/M2). + /// + private static bool TryFindInvalidBooleanOption( + System.CommandLine.ParseResult parseResult, + string[] args, + out string optionName, + out string invalidValue) + { + optionName = string.Empty; + invalidValue = string.Empty; + + // Walk the selected command up through its ancestors so both command-level bool options + // and inherited/global ones are covered. Dedupe by name in case an option appears twice. + var seen = new HashSet(StringComparer.Ordinal); + var cmd = parseResult.CommandResult.Command; + while (cmd is not null) + { + foreach (var option in cmd.Options) + { + if (option is not System.CommandLine.Option) + { + continue; + } + if (!seen.Add(option.Name)) + { + continue; + } + if (GlobalOptionPreScan.TryFindInvalidBooleanValue(args, option.Name, option.Aliases, out var badValue)) + { + optionName = option.Name; + invalidValue = badValue; + return true; + } + } + + cmd = cmd.Parents.OfType().FirstOrDefault(); + } + + return false; + } + + /// + /// The global logging mode resolved from argv: the minimum log level plus the individual + /// --quiet/--verbose/--json flags, and a non-null + /// message when a mutually-exclusive combination was requested. + /// + internal readonly record struct LoggingMode(LogLevel MinimumLevel, bool Quiet, bool Verbose, bool Json, string? ConflictError); + + /// + /// Pre-scans argv for the global logging-mode flags and resolves the effective + /// . The scan (via ) stops at the first + /// standalone -- separator so passthrough payload (e.g. winapp run . -- --json) is + /// not misread as a winapp global flag. Extracted from so the flag precedence + /// and mutual-exclusion rules are unit testable without invoking the full entrypoint. + /// + internal static LoggingMode ResolveLoggingMode(string[] args) + { + var minimumLogLevel = LogLevel.Information; + bool quiet = false; + bool verbose = false; + bool json = false; + + verbose = GlobalOptionPreScan.GetBooleanFlagValue(args, WinAppRootCommand.VerboseOption.Name, WinAppRootCommand.VerboseOption.Aliases); + if (verbose) + { + minimumLogLevel = LogLevel.Debug; + } + quiet = GlobalOptionPreScan.GetBooleanFlagValue(args, WinAppRootCommand.QuietOption.Name, WinAppRootCommand.QuietOption.Aliases); + if (quiet) + { + minimumLogLevel = LogLevel.Warning; + } + json = GlobalOptionPreScan.GetBooleanFlagValue(args, WinAppRootCommand.JsonOption.Name, WinAppRootCommand.JsonOption.Aliases); + if (json) + { + minimumLogLevel = LogLevel.None; + } + + string? conflictError = null; + if (quiet && verbose) + { + conflictError = "Cannot specify both --quiet and --verbose options together."; + } + else if (quiet && json) + { + conflictError = "Cannot specify both --quiet and --json options together."; + } + else if (verbose && json) + { + conflictError = "Cannot specify both --verbose and --json options together."; + } + + return new LoggingMode(minimumLogLevel, quiet, verbose, json, conflictError); + } +} From 49b3e1ed312eee6b7082524f22d7a8a07a0b9e05 Mon Sep 17 00:00:00 2001 From: Akhil Gupta Date: Thu, 8 Oct 2026 21:15:46 -0700 Subject: [PATCH 18/21] fix: add missing await in async lambda --- src/winapp-CLI/WinApp.Cli/{winapp-Program-await.cs => Program.cs} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/winapp-CLI/WinApp.Cli/{winapp-Program-await.cs => Program.cs} (100%) diff --git a/src/winapp-CLI/WinApp.Cli/winapp-Program-await.cs b/src/winapp-CLI/WinApp.Cli/Program.cs similarity index 100% rename from src/winapp-CLI/WinApp.Cli/winapp-Program-await.cs rename to src/winapp-CLI/WinApp.Cli/Program.cs From c32e12c3182df40e7f692f83888b3bb50edeac96 Mon Sep 17 00:00:00 2001 From: Akhil Gupta Date: Fri, 9 Oct 2026 13:41:33 -0700 Subject: [PATCH 19/21] fix: await InvokeWithHelpPointerAsync to fix CS4016 --- src/winapp-CLI/WinApp.Cli/Program.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/winapp-CLI/WinApp.Cli/Program.cs b/src/winapp-CLI/WinApp.Cli/Program.cs index a79e587e7..fca977f79 100644 --- a/src/winapp-CLI/WinApp.Cli/Program.cs +++ b/src/winapp-CLI/WinApp.Cli/Program.cs @@ -316,7 +316,7 @@ internal static async Task Main(string[] args) // error, so point at --help instead. A bare "winapp ui" keeps its help: that is // how people discover the commands. parseError.ShowHelp = false; - return InvokeWithHelpPointerAsync(parsedArgs); + return await InvokeWithHelpPointerAsync(parsedArgs); } // Parse errors must exit non-zero (issue #1015). InvokeAsync displays From 9b662092ad5d5e28566c26f8996b406666153aa3 Mon Sep 17 00:00:00 2001 From: Akhil Gupta Date: Tue, 13 Oct 2026 12:15:45 -0700 Subject: [PATCH 20/21] fix: keep help/schema requests at exit 0 when parse has errors --- src/winapp-CLI/WinApp.Cli/Program.cs | 35 +++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/src/winapp-CLI/WinApp.Cli/Program.cs b/src/winapp-CLI/WinApp.Cli/Program.cs index fca977f79..6613556da 100644 --- a/src/winapp-CLI/WinApp.Cli/Program.cs +++ b/src/winapp-CLI/WinApp.Cli/Program.cs @@ -322,8 +322,11 @@ internal static async Task Main(string[] args) // Parse errors must exit non-zero (issue #1015). InvokeAsync displays // the error and help text, but return 1 explicitly so scripts and // agents don't treat a rejected command line as success. + // Exception: a help / CLI-schema request succeeds by displaying text, + // even when the parser also recorded errors for other tokens + // (e.g. `run --help --args guest-agent` must still exit 0). var invokeExitCode = await parsedArgs.InvokeAsync(); - if (parsedArgs.Errors.Count > 0 && invokeExitCode == 0) + if (parsedArgs.Errors.Count > 0 && invokeExitCode == 0 && !RequestedDescriptiveAction(parsedArgs)) { return 1; } @@ -336,6 +339,36 @@ internal static bool IsDescriptiveAction(System.CommandLine.ParseResult parsedAr parsedArgs.Action is { Terminating: true } action && action != parsedArgs.CommandResult.Command.Action; + /// + /// True when the user asked for a describe-the-command action (help / CLI schema) on any + /// command in this parse, even when the parser also recorded errors for other tokens. + /// Unlike , this does not require a clean parse: the + /// request itself is what matters, because a help/schema request succeeds by displaying + /// text. Detected from the matched options rather than the selected action, so it holds + /// whether the parser selected the help action or a parse-error action for the request. + /// + private static bool RequestedDescriptiveAction(System.CommandLine.ParseResult parsedArgs) + { + for (System.CommandLine.Parsing.CommandResult? command = parsedArgs.CommandResult; + command is not null; + command = command.Parent as System.CommandLine.Parsing.CommandResult) + { + foreach (var option in command.Children.OfType()) + { + if (option.Implicit) + { + continue; + } + if (option.Option is System.CommandLine.HelpOption || + option.Option == WinAppRootCommand.CliSchemaOption) + { + return true; + } + } + } + return false; + } + private static async Task InvokeWithHelpPointerAsync(System.CommandLine.ParseResult parsedArgs) { var exitCode = await parsedArgs.InvokeAsync(); From 75662e09af0da2ad4a5ceef744316aed72bbae81 Mon Sep 17 00:00:00 2001 From: Akhil Gupta Date: Wed, 14 Oct 2026 08:04:00 -0700 Subject: [PATCH 21/21] Fix HelpOption namespace for System.CommandLine v2.0.5 --- src/winapp-CLI/WinApp.Cli/Program.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/winapp-CLI/WinApp.Cli/Program.cs b/src/winapp-CLI/WinApp.Cli/Program.cs index 5f8079b48..66b3e21e6 100644 --- a/src/winapp-CLI/WinApp.Cli/Program.cs +++ b/src/winapp-CLI/WinApp.Cli/Program.cs @@ -377,7 +377,7 @@ private static bool RequestedDescriptiveAction(System.CommandLine.ParseResult pa { continue; } - if (option.Option is System.CommandLine.HelpOption || + if (option.Option is System.CommandLine.Help.HelpOption || option.Option == WinAppRootCommand.CliSchemaOption) { return true;