From 981e1a96de538d0831d0330e1d39751e12d3d3dc Mon Sep 17 00:00:00 2001 From: Sinan KARAKAYA Date: Tue, 21 Apr 2026 22:00:08 +0200 Subject: [PATCH 1/2] fix: Auto-proceed on single result from working sources Treat source failures as warnings instead of errors when the install command finds exactly one result from working sources, avoiding the need for the user to manually specify --source. Signed-off-by: Sinan Karakaya --- src/AppInstallerCLICore/Workflows/WorkflowBase.cpp | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp b/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp index 9ee72f47db..62b4bbeee8 100644 --- a/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp +++ b/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp @@ -973,7 +973,13 @@ namespace AppInstaller::CLI::Workflow if (!searchResult.Failures.empty()) { - if (WI_IsFlagSet(context.GetFlags(), Execution::ContextFlag::TreatSourceFailuresAsWarning)) + // When the install command finds exactly one result from working sources, auto-proceed + // instead of requiring the user to specify --source. + bool singleResultOnPartialFailure = + WI_IsFlagSet(context.GetFlags(), Execution::ContextFlag::ShowSearchResultsOnPartialFailure) && + searchResult.Matches.size() == 1; + + if (WI_IsFlagSet(context.GetFlags(), Execution::ContextFlag::TreatSourceFailuresAsWarning) || singleResultOnPartialFailure) { auto warn = context.Reporter.Warn(); for (const auto& failure : searchResult.Failures) From 3014777122099f792b4087fd84ca752db92f809d Mon Sep 17 00:00:00 2001 From: Sinan KARAKAYA Date: Wed, 22 Apr 2026 11:45:05 +0200 Subject: [PATCH 2/2] fix: Add prompt to proceed on partial search failure with single result Refactors `IsInteractivityAllowed` out of an anonymous namespace to allow broader usage. Updates the partial search failure logic to prompt the user if exactly one match is found and interactivity is allowed, rather than auto-proceeding or failing. Signed-off-by: Sinan KARAKAYA --- src/AppInstallerCLICore/Resources.h | 1 + .../Workflows/PromptFlow.cpp | 47 +++++++------- .../Workflows/PromptFlow.h | 4 ++ .../Workflows/WorkflowBase.cpp | 40 +++++++++--- .../Shared/Strings/en-us/winget.resw | 4 ++ src/AppInstallerCLITests/WorkFlow.cpp | 62 +++++++++++++++++++ 6 files changed, 128 insertions(+), 30 deletions(-) diff --git a/src/AppInstallerCLICore/Resources.h b/src/AppInstallerCLICore/Resources.h index 11b4b8d1a7..1f0282e24c 100644 --- a/src/AppInstallerCLICore/Resources.h +++ b/src/AppInstallerCLICore/Resources.h @@ -610,6 +610,7 @@ namespace AppInstaller::CLI::Resource WINGET_DEFINE_RESOURCE_STRINGID(SearchFailureError); WINGET_DEFINE_RESOURCE_STRINGID(SearchFailureErrorListMatches); WINGET_DEFINE_RESOURCE_STRINGID(SearchFailureErrorNoMatches); + WINGET_DEFINE_RESOURCE_STRINGID(SearchFailureSingleResultPrompt); WINGET_DEFINE_RESOURCE_STRINGID(SearchFailureWarning); WINGET_DEFINE_RESOURCE_STRINGID(SearchId); WINGET_DEFINE_RESOURCE_STRINGID(SearchMatch); diff --git a/src/AppInstallerCLICore/Workflows/PromptFlow.cpp b/src/AppInstallerCLICore/Workflows/PromptFlow.cpp index 5e298f5c92..82c3b41f38 100644 --- a/src/AppInstallerCLICore/Workflows/PromptFlow.cpp +++ b/src/AppInstallerCLICore/Workflows/PromptFlow.cpp @@ -11,35 +11,36 @@ using namespace AppInstaller::Utility::literals; namespace AppInstaller::CLI::Workflow { - namespace + bool IsInteractivityAllowed(Execution::Context& context) { - bool IsInteractivityAllowed(Execution::Context& context) + // Interactivity can be disabled for several reasons: + // * We are running in a non-interactive context (e.g., COM call) + // * It is disabled in the settings + // * It was disabled from the command line + + if (WI_IsFlagSet(context.GetFlags(), Execution::ContextFlag::DisableInteractivity)) { - // Interactivity can be disabled for several reasons: - // * We are running in a non-interactive context (e.g., COM call) - // * It is disabled in the settings - // * It was disabled from the command line + AICLI_LOG(CLI, Verbose, << "Skipping prompt. Interactivity is disabled due to non-interactive context."); + return false; + } - if (WI_IsFlagSet(context.GetFlags(), Execution::ContextFlag::DisableInteractivity)) - { - AICLI_LOG(CLI, Verbose, << "Skipping prompt. Interactivity is disabled due to non-interactive context."); - return false; - } + if (context.Args.Contains(Execution::Args::Type::DisableInteractivity)) + { + AICLI_LOG(CLI, Verbose, << "Skipping prompt. Interactivity is disabled by command line argument."); + return false; + } - if (context.Args.Contains(Execution::Args::Type::DisableInteractivity)) - { - AICLI_LOG(CLI, Verbose, << "Skipping prompt. Interactivity is disabled by command line argument."); - return false; - } + if (Settings::User().Get()) + { + AICLI_LOG(CLI, Verbose, << "Skipping prompt. Interactivity is disabled in settings."); + return false; + } - if (Settings::User().Get()) - { - AICLI_LOG(CLI, Verbose, << "Skipping prompt. Interactivity is disabled in settings."); - return false; - } + return true; + } - return true; - } + namespace + { bool HandleSourceAgreementsForOneSource(Execution::Context& context, const Repository::Source& source) { diff --git a/src/AppInstallerCLICore/Workflows/PromptFlow.h b/src/AppInstallerCLICore/Workflows/PromptFlow.h index 7219cb1fff..692e6998df 100644 --- a/src/AppInstallerCLICore/Workflows/PromptFlow.h +++ b/src/AppInstallerCLICore/Workflows/PromptFlow.h @@ -5,6 +5,10 @@ namespace AppInstaller::CLI::Workflow { + // Returns true if interactivity is currently allowed in the given context. + bool IsInteractivityAllowed(Execution::Context& context); + + // Handles all opened source(s) agreements if needed. // Required Args: The source to be checked for agreements // Inputs: None diff --git a/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp b/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp index 62b4bbeee8..5c739a4b4a 100644 --- a/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp +++ b/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp @@ -973,13 +973,7 @@ namespace AppInstaller::CLI::Workflow if (!searchResult.Failures.empty()) { - // When the install command finds exactly one result from working sources, auto-proceed - // instead of requiring the user to specify --source. - bool singleResultOnPartialFailure = - WI_IsFlagSet(context.GetFlags(), Execution::ContextFlag::ShowSearchResultsOnPartialFailure) && - searchResult.Matches.size() == 1; - - if (WI_IsFlagSet(context.GetFlags(), Execution::ContextFlag::TreatSourceFailuresAsWarning) || singleResultOnPartialFailure) + if (WI_IsFlagSet(context.GetFlags(), Execution::ContextFlag::TreatSourceFailuresAsWarning)) { auto warn = context.Reporter.Warn(); for (const auto& failure : searchResult.Failures) @@ -987,6 +981,38 @@ namespace AppInstaller::CLI::Workflow warn << Resource::String::SearchFailureWarning(Utility::LocIndView{ failure.SourceName }) << std::endl; } } + // When ShowSearchResultsOnPartialFailure is set (e.g. install) and exactly one match + // was found, prompt the user interactively rather than failing outright. + // Falls back to the hard error path if interactivity is disabled. + else if (WI_IsFlagSet(context.GetFlags(), Execution::ContextFlag::ShowSearchResultsOnPartialFailure) && + searchResult.Matches.size() == 1 && + IsInteractivityAllowed(context)) + { + { + auto warn = context.Reporter.Warn(); + for (const auto& failure : searchResult.Failures) + { + warn << Resource::String::SearchFailureWarning(Utility::LocIndView{ failure.SourceName }) << std::endl; + } + } + + if (context.Reporter.PromptForBoolResponse(Resource::String::SearchFailureSingleResultPrompt, Reporter::Level::Warning, false)) + { + return; + } + + // User declined; terminate with the source failure HRESULT without re-showing errors + HRESULT overallHR = S_OK; + for (const auto& failure : searchResult.Failures) + { + HRESULT failureHR = HandleException(nullptr, failure.Exception); + if (overallHR == S_OK) + { + overallHR = failureHR; + } + } + context.SetTerminationHR(overallHR); + } else { HRESULT overallHR = S_OK; diff --git a/src/AppInstallerCLIPackage/Shared/Strings/en-us/winget.resw b/src/AppInstallerCLIPackage/Shared/Strings/en-us/winget.resw index 7293ac52a6..60f498e76e 100644 --- a/src/AppInstallerCLIPackage/Shared/Strings/en-us/winget.resw +++ b/src/AppInstallerCLIPackage/Shared/Strings/en-us/winget.resw @@ -1332,6 +1332,10 @@ Do you agree to the terms? Please specify one of them using the --source option to proceed. {Locked="--source"} "working sources" as in "sources that are working correctly" + + A package was found among the working sources. Would you like to proceed? + "working sources" as in "sources that are working correctly" + No packages were found among the working sources. "working sources" as in "sources that are working correctly" diff --git a/src/AppInstallerCLITests/WorkFlow.cpp b/src/AppInstallerCLITests/WorkFlow.cpp index ce69a4d12d..39ede15f82 100644 --- a/src/AppInstallerCLITests/WorkFlow.cpp +++ b/src/AppInstallerCLITests/WorkFlow.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -187,3 +188,64 @@ TEST_CASE("Export_Settings", "[Settings][workflow]") REQUIRE(userSettingsFileValue.find("settings.json") != std::string::npos); } } + +TEST_CASE("HandleSearchResultFailures_SingleMatchWithPartialFailure", "[HandleSearchResultFailures][workflow]") +{ + auto makeSearchResult = []() + { + SearchResult result; + result.Matches.push_back(ResultMatch{ + TestCompositePackage::Make(std::vector{ Manifest{} }), + PackageMatchFilter{ PackageMatchField::Id, MatchType::Exact } + }); + result.Failures.push_back({ "FailedSource", std::make_exception_ptr(std::runtime_error("source error")) }); + return result; + }; + + SECTION("Interactive_Accept") + { + std::istringstream input{ "y\n" }; + std::ostringstream output; + TestContext context{ output, input }; + auto previousThreadGlobals = context.SetForCurrentThread(); + context.SetFlags(ContextFlag::ShowSearchResultsOnPartialFailure); + context.Add(makeSearchResult()); + + context << HandleSearchResultFailures; + + INFO(output.str()); + REQUIRE_FALSE(context.IsTerminated()); + REQUIRE(output.str().find("FailedSource") != std::string::npos); + } + + SECTION("Interactive_Decline") + { + std::istringstream input{ "n\n" }; + std::ostringstream output; + TestContext context{ output, input }; + auto previousThreadGlobals = context.SetForCurrentThread(); + context.SetFlags(ContextFlag::ShowSearchResultsOnPartialFailure); + context.Add(makeSearchResult()); + + context << HandleSearchResultFailures; + + INFO(output.str()); + REQUIRE(context.IsTerminated()); + } + + SECTION("NonInteractive_HardError") + { + std::ostringstream output; + TestContext context{ output, std::cin }; + auto previousThreadGlobals = context.SetForCurrentThread(); + context.SetFlags(ContextFlag::ShowSearchResultsOnPartialFailure); + context.Args.AddArg(Args::Type::DisableInteractivity); + context.Add(makeSearchResult()); + + context << HandleSearchResultFailures; + + INFO(output.str()); + REQUIRE(context.IsTerminated()); + REQUIRE(output.str().find("FailedSource") != std::string::npos); + } +}