diff --git a/src/winapp-CLI/WinApp.Cli.Tests/ProgramTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/ProgramTests.cs index 68af6dae7..a6a4d9ebd 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/ProgramTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/ProgramTests.cs @@ -208,6 +208,20 @@ public async Task Main_UnknownCommand_FallsThroughToInvocation_WithoutTypoSugges 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] diff --git a/src/winapp-CLI/WinApp.Cli/Program.cs b/src/winapp-CLI/WinApp.Cli/Program.cs index 12fdd5afa..2316738fd 100644 --- a/src/winapp-CLI/WinApp.Cli/Program.cs +++ b/src/winapp-CLI/WinApp.Cli/Program.cs @@ -340,13 +340,17 @@ private static async Task RunCoreAsync(string[] args, bool framedStdout, Ac } } +<<<<<<< HEAD + return await RunWithTelemetryAsync(parsedArgs, isCompleteMode, async () => +======= return await RunWithTelemetryAsync(parsedArgs, isCompleteMode || framedStdout, () => +>>>>>>> upstream/main { // --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(); + return await parsedArgs.InvokeAsync(); } // Target selection is settled before anything else, and settled for every command. @@ -355,12 +359,17 @@ private static async Task RunCoreAsync(string[] args, bool framedStdout, Ac // exists to prevent. if (ExecutionTargetSelection.Validate(parsedArgs) is { } selectionError) { +<<<<<<< HEAD + return TargetOutput.RejectSelection( + serviceProvider.GetRequiredService(), effectiveJson, selectionError); +======= if (effectiveJson && TryEmitCommandJsonError(parsedArgs, selectionError.Message)) { return Task.FromResult(TargetOutput.InvalidCommandLineExitCode); } return Task.FromResult(TargetOutput.RejectSelection( serviceProvider.GetRequiredService(), effectiveJson, selectionError)); +>>>>>>> upstream/main } // System.CommandLine binds an unrecognised option to a nearby optional positional rather @@ -371,7 +380,7 @@ private static async Task RunCoreAsync(string[] args, bool framedStdout, Ac if (parsedArgs.CommandResult.Command is not RunCommand && WindowsCommandLine.FindOptionLikePositionals(parsedArgs) is { Count: > 0 } stray) { - return Task.FromResult(RejectOptionLikePositionals(parsedArgs, stray, effectiveJson)); + return RejectOptionLikePositionals(parsedArgs, stray, effectiveJson); } // One pre-dispatch interception, before any local UI service runs. A command the user @@ -398,7 +407,7 @@ private static async Task RunCoreAsync(string[] args, bool framedStdout, Ac if (parsedArgs.Errors.Count == 0 && ExecutionTargetUiRouter.ShouldRoute(parsedArgs)) { var router = serviceProvider.GetRequiredService(); - return router.RouteAsync( + return await router.RouteAsync( args, TargetUiRequirements.For(parsedArgs), effectiveJson, @@ -413,9 +422,23 @@ private static async Task RunCoreAsync(string[] args, bool framedStdout, Ac // 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); } +<<<<<<< HEAD + // 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 && !RequestedDescriptiveAction(parsedArgs)) + { + return 1; + } + return invokeExitCode; +======= return InvokeCommandAsync(); async Task InvokeCommandAsync() @@ -434,6 +457,7 @@ async Task InvokeCommandAsync() return serviceProvider.GetRequiredService() .ClassifyWriterResult(result); } +>>>>>>> upstream/main }); } @@ -442,9 +466,44 @@ 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.Help.HelpOption || + option.Option == WinAppRootCommand.CliSchemaOption) + { + return true; + } + } + } + return false; + } + 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)