From d11afa2394feb20b3b25a7d77ae9d52f99e9bd43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Thu, 2 Jul 2026 21:56:27 +0200 Subject: [PATCH 1/9] Add JSON format for list outputs --- doc/ReleaseNotes.md | 2 +- src/AppInstallerCLICore/Argument.cpp | 4 + src/AppInstallerCLICore/Command.cpp | 11 +++ .../Commands/ListCommand.cpp | 6 ++ .../Commands/UpgradeCommand.cpp | 12 +++ src/AppInstallerCLICore/ExecutionArgs.h | 1 + src/AppInstallerCLICore/Resources.h | 1 + .../Workflows/WorkflowBase.cpp | 81 +++++++++++++++++++ .../Workflows/WorkflowBase.h | 3 + .../Shared/Strings/en-us/winget.resw | 6 +- src/AppInstallerCLITests/UpdateFlow.cpp | 56 +++++++++++++ 11 files changed, 181 insertions(+), 2 deletions(-) diff --git a/doc/ReleaseNotes.md b/doc/ReleaseNotes.md index 28f90d9b5d..ae53cf804b 100644 --- a/doc/ReleaseNotes.md +++ b/doc/ReleaseNotes.md @@ -1,6 +1,6 @@ ## New in v1.29 -Nothing yet. +* Added `--format json` support for `winget list` and `winget upgrade` when listing available upgrades. ## Bug Fixes diff --git a/src/AppInstallerCLICore/Argument.cpp b/src/AppInstallerCLICore/Argument.cpp index 2de195e6ff..ab014be457 100644 --- a/src/AppInstallerCLICore/Argument.cpp +++ b/src/AppInstallerCLICore/Argument.cpp @@ -195,6 +195,8 @@ namespace AppInstaller::CLI return { type, "ascending"_liv, "asc"_liv, ArgTypeCategory::None, ArgTypeExclusiveSet::SortDirection }; case Execution::Args::Type::SortDescending: return { type, "descending"_liv, "desc"_liv, ArgTypeCategory::None, ArgTypeExclusiveSet::SortDirection }; + case Execution::Args::Type::OutputFormat: + return { type, "format"_liv }; // Pin command case Execution::Args::Type::GatedVersion: @@ -508,6 +510,8 @@ namespace AppInstaller::CLI return Argument{ type, Resource::String::CorrelationArgumentDescription, ArgumentType::Standard, Argument::Visibility::Hidden }; case Args::Type::ListDetails: return Argument{ type, Resource::String::ListDetailsArgumentDescription, ArgumentType::Flag, Argument::Visibility::Help }; + case Args::Type::OutputFormat: + return Argument{ type, Resource::String::OutputFormatArgumentDescription, ArgumentType::Standard, Argument::Visibility::Help }; default: THROW_HR(E_UNEXPECTED); } diff --git a/src/AppInstallerCLICore/Command.cpp b/src/AppInstallerCLICore/Command.cpp index 591f110473..770435b399 100644 --- a/src/AppInstallerCLICore/Command.cpp +++ b/src/AppInstallerCLICore/Command.cpp @@ -819,6 +819,17 @@ namespace AppInstaller::CLI } } + if (execArgs.Contains(Execution::Args::Type::OutputFormat)) + { + auto format = execArgs.GetArg(Execution::Args::Type::OutputFormat); + if (!Utility::CaseInsensitiveEquals(format, "table"sv) && + !Utility::CaseInsensitiveEquals(format, "json"sv)) + { + auto validOptions = Utility::Join(", "_liv, std::vector{ "table"_lis, "json"_lis }); + throw CommandException(Resource::String::InvalidArgumentValueError(ArgumentCommon::ForType(Execution::Args::Type::OutputFormat).Name, validOptions)); + } + } + Argument::ValidateExclusiveArguments(execArgs); ValidateArgumentsInternal(execArgs); diff --git a/src/AppInstallerCLICore/Commands/ListCommand.cpp b/src/AppInstallerCLICore/Commands/ListCommand.cpp index fc57f03e3a..3fc26767d9 100644 --- a/src/AppInstallerCLICore/Commands/ListCommand.cpp +++ b/src/AppInstallerCLICore/Commands/ListCommand.cpp @@ -35,6 +35,7 @@ namespace AppInstaller::CLI Argument{ Execution::Args::Type::IncludeUnknown, Resource::String::IncludeUnknownInListArgumentDescription, ArgumentType::Flag }, Argument{ Execution::Args::Type::IncludePinned, Resource::String::IncludePinnedInListArgumentDescription, ArgumentType::Flag}, Argument::ForType(Execution::Args::Type::ListDetails), + Argument::ForType(Execution::Args::Type::OutputFormat), Argument{ Execution::Args::Type::Sort, Resource::String::SortArgumentDescription, ArgumentType::Standard }.SetCountLimit(s_sortFieldCount), Argument{ Execution::Args::Type::SortAscending, Resource::String::SortAscendingArgumentDescription, ArgumentType::Flag }, Argument{ Execution::Args::Type::SortDescending, Resource::String::SortDescendingArgumentDescription, ArgumentType::Flag }, @@ -90,6 +91,11 @@ namespace AppInstaller::CLI void ListCommand::ExecuteInternal(Execution::Context& context) const { + if (Workflow::IsJsonOutputFormat(context.Args)) + { + context.Reporter.SetChannel(Execution::Reporter::Channel::Json); + } + context.SetFlags(Execution::ContextFlag::TreatSourceFailuresAsWarning); context << diff --git a/src/AppInstallerCLICore/Commands/UpgradeCommand.cpp b/src/AppInstallerCLICore/Commands/UpgradeCommand.cpp index bf3292b9ef..bc6ad7335a 100644 --- a/src/AppInstallerCLICore/Commands/UpgradeCommand.cpp +++ b/src/AppInstallerCLICore/Commands/UpgradeCommand.cpp @@ -69,6 +69,7 @@ namespace AppInstaller::CLI Argument::ForType(Args::Type::CustomHeader), Argument::ForType(Args::Type::AuthenticationMode), Argument::ForType(Args::Type::AuthenticationAccount), + Argument::ForType(Args::Type::OutputFormat), Argument{ Args::Type::All, Resource::String::UpdateAllArgumentDescription, ArgumentType::Flag }, Argument{ Args::Type::IncludeUnknown, Resource::String::IncludeUnknownArgumentDescription, ArgumentType::Flag }, Argument{ Args::Type::IncludePinned, Resource::String::IncludePinnedArgumentDescription, ArgumentType::Flag}, @@ -138,6 +139,12 @@ namespace AppInstaller::CLI { const auto argCategories = Argument::GetCategoriesAndValidateCommonArguments(execArgs, /* requirePackageSelectionArg */ false); + if (Workflow::IsJsonOutputFormat(execArgs) && !ShouldListUpgrade(execArgs, argCategories)) + { + auto validOptions = Utility::Join(", "_liv, std::vector{ "table"_lis }); + throw CommandException(Resource::String::InvalidArgumentValueError(ArgumentCommon::ForType(Execution::Args::Type::OutputFormat).Name, validOptions)); + } + if (!ShouldListUpgrade(execArgs, argCategories) && WI_IsFlagClear(argCategories, ArgTypeCategory::PackageQuery) && WI_IsFlagSet(argCategories, ArgTypeCategory::SingleInstallerBehavior)) @@ -154,6 +161,11 @@ namespace AppInstaller::CLI // We have to set it now to allow for source open failures to also just warn. if (ShouldListUpgrade(context.Args)) { + if (Workflow::IsJsonOutputFormat(context.Args)) + { + context.Reporter.SetChannel(Execution::Reporter::Channel::Json); + } + context.SetFlags(Execution::ContextFlag::TreatSourceFailuresAsWarning); } diff --git a/src/AppInstallerCLICore/ExecutionArgs.h b/src/AppInstallerCLICore/ExecutionArgs.h index e1e6355cb9..623e7e434c 100644 --- a/src/AppInstallerCLICore/ExecutionArgs.h +++ b/src/AppInstallerCLICore/ExecutionArgs.h @@ -117,6 +117,7 @@ namespace AppInstaller::CLI::Execution Sort, // Sort output by field (repeatable: --sort name --sort id) SortAscending, // Sort output in ascending order SortDescending, // Sort output in descending order + OutputFormat, // Select the output format // Pin command GatedVersion, // Differs from Version in that this supports wildcards diff --git a/src/AppInstallerCLICore/Resources.h b/src/AppInstallerCLICore/Resources.h index b35f9b3dc9..6a9a826861 100644 --- a/src/AppInstallerCLICore/Resources.h +++ b/src/AppInstallerCLICore/Resources.h @@ -521,6 +521,7 @@ namespace AppInstaller::CLI::Resource WINGET_DEFINE_RESOURCE_STRINGID(Options); WINGET_DEFINE_RESOURCE_STRINGID(OSVersionDescription); WINGET_DEFINE_RESOURCE_STRINGID(OutputDirectoryArgumentDescription); + WINGET_DEFINE_RESOURCE_STRINGID(OutputFormatArgumentDescription); WINGET_DEFINE_RESOURCE_STRINGID(OutputFileArgumentDescription); WINGET_DEFINE_RESOURCE_STRINGID(OverrideArgumentDescription); WINGET_DEFINE_RESOURCE_STRINGID(OverwritingExistingFileAtMessage); diff --git a/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp b/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp index 4641d99359..f959d3444d 100644 --- a/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp +++ b/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp @@ -18,6 +18,7 @@ #include #include #include +#include #include EXTERN_C IMAGE_DOS_HEADER __ImageBase; @@ -341,6 +342,8 @@ namespace AppInstaller::CLI::Workflow Utility::LocIndString Source; }; + void SortInstalledPackagesTableLines(Execution::Context& context, std::vector& lines); + void OutputInstalledPackagesTable(Execution::Context& context, std::vector& lines) { Execution::TableOutput<5> table(context.Reporter, @@ -366,6 +369,63 @@ namespace AppInstaller::CLI::Workflow table.Complete(); } + Json::Value InstalledPackageLineToJson(const InstalledPackagesTableLine& line) + { + Json::Value package{ Json::ValueType::objectValue }; + package["name"] = line.Name.get(); + package["id"] = line.Id.get(); + package["installedVersion"] = line.InstalledVersion.get(); + package["availableVersion"] = line.AvailableVersion.get(); + package["source"] = line.Source.get(); + return package; + } + + Json::Value InstalledPackageLinesToJson(const std::vector& lines) + { + Json::Value packages{ Json::ValueType::arrayValue }; + for (const auto& line : lines) + { + packages.append(InstalledPackageLineToJson(line)); + } + + return packages; + } + + void OutputInstalledPackagesJson( + Execution::Context& context, + std::vector& lines, + std::vector& linesForExplicitUpgrade, + std::vector& linesForPins, + bool truncated, + bool onlyShowUpgrades, + int availableUpgradesCount, + int packagesWithUnknownVersionSkipped, + int packagesWithUserPinsSkipped) + { + SortInstalledPackagesTableLines(context, lines); + SortInstalledPackagesTableLines(context, linesForExplicitUpgrade); + SortInstalledPackagesTableLines(context, linesForPins); + + Json::Value result{ Json::ValueType::objectValue }; + result["packages"] = InstalledPackageLinesToJson(lines); + result["truncated"] = truncated; + + if (onlyShowUpgrades) + { + result["availableUpgrades"] = availableUpgradesCount; + result["packagesWithAvailableUpgradesForPins"] = InstalledPackageLinesToJson(linesForExplicitUpgrade); + result["packagesBlockedByPins"] = InstalledPackageLinesToJson(linesForPins); + result["skippedUnknownVersions"] = packagesWithUnknownVersionSkipped; + result["skippedPinned"] = packagesWithUserPinsSkipped; + } + + Json::StreamWriterBuilder writerBuilder; + writerBuilder.settings_["indentation"] = ""; + writerBuilder.settings_["commentStyle"] = "None"; + writerBuilder.settings_["emitUTF8"] = true; + context.Reporter.Json() << Json::writeString(writerBuilder, result) << std::endl; + } + void ShowMetadataField( Execution::OutputStream& outputStream, StringResource::StringId label, @@ -584,6 +644,12 @@ namespace AppInstaller::CLI::Workflow return authArgs; } + bool IsJsonOutputFormat(const Execution::Args& args) + { + return args.Contains(Execution::Args::Type::OutputFormat) && + Utility::CaseInsensitiveEquals(args.GetArg(Execution::Args::Type::OutputFormat), "json"sv); + } + HRESULT HandleException(Execution::Context* context, std::exception_ptr exception) { try @@ -1263,6 +1329,21 @@ namespace AppInstaller::CLI::Workflow } } + if (IsJsonOutputFormat(context.Args)) + { + OutputInstalledPackagesJson( + context, + lines, + linesForExplicitUpgrade, + linesForPins, + searchResult.Truncated, + m_onlyShowUpgrades, + availableUpgradesCount, + packagesWithUnknownVersionSkipped, + packagesWithUserPinsSkipped); + return; + } + OutputInstalledPackages(context, lines); if (lines.empty()) diff --git a/src/AppInstallerCLICore/Workflows/WorkflowBase.h b/src/AppInstallerCLICore/Workflows/WorkflowBase.h index 7d9cba0ae3..4b6343f3f5 100644 --- a/src/AppInstallerCLICore/Workflows/WorkflowBase.h +++ b/src/AppInstallerCLICore/Workflows/WorkflowBase.h @@ -85,6 +85,9 @@ namespace AppInstaller::CLI::Workflow // Helper to create authentication arguments from context input. Authentication::AuthenticationArguments GetAuthenticationArguments(const Execution::Context& context); + // Helper to determine whether the requested output format is JSON. + bool IsJsonOutputFormat(const Execution::Args& args); + // Helper to report exceptions and return the HRESULT. // If context is null, no output will be attempted. HRESULT HandleException(Execution::Context* context, std::exception_ptr exception); diff --git a/src/AppInstallerCLIPackage/Shared/Strings/en-us/winget.resw b/src/AppInstallerCLIPackage/Shared/Strings/en-us/winget.resw index b562e3022a..363c03b33b 100644 --- a/src/AppInstallerCLIPackage/Shared/Strings/en-us/winget.resw +++ b/src/AppInstallerCLIPackage/Shared/Strings/en-us/winget.resw @@ -859,6 +859,10 @@ They can be configured through the settings file 'winget settings'. File where the result is to be written + + Output format + Description for the --format argument used to select command output format. + File describing the packages to install @@ -3649,4 +3653,4 @@ An unlocalized JSON fragment will follow on another line. The DSC processor hash provided does not match hash of the target file. - \ No newline at end of file + diff --git a/src/AppInstallerCLITests/UpdateFlow.cpp b/src/AppInstallerCLITests/UpdateFlow.cpp index 9d70283c78..6fcd690839 100644 --- a/src/AppInstallerCLITests/UpdateFlow.cpp +++ b/src/AppInstallerCLITests/UpdateFlow.cpp @@ -4,6 +4,7 @@ #include "WorkflowCommon.h" #include "TestHooks.h" #include +#include #include #include #include @@ -306,6 +307,61 @@ TEST_CASE("UpdateFlow_NoArgs_UnknownVersion", "[UpdateFlow][workflow]") REQUIRE(updateOutput.str().find(Resource::String::UpgradeUnknownVersionCount(1)) != std::string::npos); } +TEST_CASE("ListFlow_JsonOutput", "[ListFlow][workflow]") +{ + std::ostringstream listOutput; + TestContext context{ listOutput, std::cin }; + auto previousThreadGlobals = context.SetForCurrentThread(); + OverrideForCompositeInstalledSource(context, CreateTestSource({ TSR::TestInstaller_Exe })); + context.Args.AddArg(Execution::Args::Type::Query, TSR::TestInstaller_Exe.Query); + context.Args.AddArg(Execution::Args::Type::OutputFormat, "json"sv); + + ListCommand list({}); + context.SetExecutingCommand(&list); + list.Execute(context); + INFO(listOutput.str()); + + Json::Value json = ConvertToJson(listOutput.str()); + REQUIRE(json["packages"].isArray()); + REQUIRE(json["packages"].size() == 1); + REQUIRE(json["packages"][0]["id"].asString() == "AppInstallerCliTest.TestExeInstaller"); + REQUIRE(json["packages"][0]["installedVersion"].asString() == "1.0.0.0"); + REQUIRE(json["truncated"].asBool() == false); +} + +TEST_CASE("UpdateFlow_ListJsonOutput", "[UpdateFlow][workflow]") +{ + std::ostringstream updateOutput; + TestContext context{ updateOutput, std::cin }; + auto previousThreadGlobals = context.SetForCurrentThread(); + OverrideForCompositeInstalledSource(context, CreateTestSource({ TSR::TestInstaller_Exe })); + context.Args.AddArg(Execution::Args::Type::OutputFormat, "json"sv); + + UpgradeCommand update({}); + context.SetExecutingCommand(&update); + update.Execute(context); + INFO(updateOutput.str()); + + Json::Value json = ConvertToJson(updateOutput.str()); + REQUIRE(json["packages"].isArray()); + REQUIRE(json["packages"].size() == 1); + REQUIRE(json["packages"][0]["id"].asString() == "AppInstallerCliTest.TestExeInstaller"); + REQUIRE(json["packages"][0]["availableVersion"].asString() != ""); + REQUIRE(json["availableUpgrades"].asInt() == 1); + REQUIRE(json["packagesBlockedByPins"].isArray()); + REQUIRE(json["packagesWithAvailableUpgradesForPins"].isArray()); +} + +TEST_CASE("UpdateFlow_JsonOutputRequiresListMode", "[UpdateFlow][workflow]") +{ + Execution::Args args; + args.AddArg(Execution::Args::Type::All); + args.AddArg(Execution::Args::Type::OutputFormat, "json"sv); + + UpgradeCommand update({}); + REQUIRE_THROWS_AS(update.ValidateArguments(args), CommandException); +} + TEST_CASE("UpdateFlow_IncludeUnknown", "[UpdateFlow][workflow]") { std::ostringstream updateOutput; From 4cc5f9ee8195368ef184e20272b1c7486f3515d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Thu, 2 Jul 2026 22:46:13 +0200 Subject: [PATCH 2/9] Address JSON list output review feedback --- .../Commands/ListCommand.cpp | 12 +++++----- .../Commands/UpgradeCommand.cpp | 6 +---- src/AppInstallerCLICore/ExecutionArgs.h | 4 +++- .../Workflows/WorkflowBase.cpp | 22 +++++++++++++++++++ .../Workflows/WorkflowBase.h | 3 +++ src/AppInstallerCLITests/UpdateFlow.cpp | 14 ++++++++++++ 6 files changed, 50 insertions(+), 11 deletions(-) diff --git a/src/AppInstallerCLICore/Commands/ListCommand.cpp b/src/AppInstallerCLICore/Commands/ListCommand.cpp index 3fc26767d9..6734e7d571 100644 --- a/src/AppInstallerCLICore/Commands/ListCommand.cpp +++ b/src/AppInstallerCLICore/Commands/ListCommand.cpp @@ -87,19 +87,21 @@ namespace AppInstaller::CLI { Argument::ValidateArgumentDependency(execArgs, Execution::Args::Type::IncludeUnknown, Execution::Args::Type::Upgrade); Argument::ValidateArgumentDependency(execArgs, Execution::Args::Type::IncludePinned, Execution::Args::Type::Upgrade); - } - void ListCommand::ExecuteInternal(Execution::Context& context) const - { - if (Workflow::IsJsonOutputFormat(context.Args)) + if (Workflow::IsJsonOutputFormat(execArgs) && execArgs.Contains(Execution::Args::Type::ListDetails)) { - context.Reporter.SetChannel(Execution::Reporter::Channel::Json); + auto validOptions = Utility::Join(", "_liv, std::vector{ "table"_lis }); + throw CommandException(Resource::String::InvalidArgumentValueError(ArgumentCommon::ForType(Execution::Args::Type::OutputFormat).Name, validOptions)); } + } + void ListCommand::ExecuteInternal(Execution::Context& context) const + { context.SetFlags(Execution::ContextFlag::TreatSourceFailuresAsWarning); context << Workflow::OpenSource() << + Workflow::SetJsonOutputChannel << Workflow::OpenCompositeSource(Workflow::DetermineInstalledSource(context)) << Workflow::SearchSourceForMany << Workflow::HandleSearchResultFailures << diff --git a/src/AppInstallerCLICore/Commands/UpgradeCommand.cpp b/src/AppInstallerCLICore/Commands/UpgradeCommand.cpp index bc6ad7335a..06127de3ad 100644 --- a/src/AppInstallerCLICore/Commands/UpgradeCommand.cpp +++ b/src/AppInstallerCLICore/Commands/UpgradeCommand.cpp @@ -161,11 +161,6 @@ namespace AppInstaller::CLI // We have to set it now to allow for source open failures to also just warn. if (ShouldListUpgrade(context.Args)) { - if (Workflow::IsJsonOutputFormat(context.Args)) - { - context.Reporter.SetChannel(Execution::Reporter::Channel::Json); - } - context.SetFlags(Execution::ContextFlag::TreatSourceFailuresAsWarning); } @@ -173,6 +168,7 @@ namespace AppInstaller::CLI InitializeInstallerDownloadAuthenticatorsMap << ReportExecutionStage(ExecutionStage::Discovery) << OpenSource() << + SetJsonOutputChannel << OpenCompositeSource(DetermineInstalledSource(context)); if (ShouldListUpgrade(context.Args)) diff --git a/src/AppInstallerCLICore/ExecutionArgs.h b/src/AppInstallerCLICore/ExecutionArgs.h index 623e7e434c..ede30de694 100644 --- a/src/AppInstallerCLICore/ExecutionArgs.h +++ b/src/AppInstallerCLICore/ExecutionArgs.h @@ -117,7 +117,6 @@ namespace AppInstaller::CLI::Execution Sort, // Sort output by field (repeatable: --sort name --sort id) SortAscending, // Sort output in ascending order SortDescending, // Sort output in descending order - OutputFormat, // Select the output format // Pin command GatedVersion, // Differs from Version in that this supports wildcards @@ -197,6 +196,9 @@ namespace AppInstaller::CLI::Execution // Used for demonstration purposes ExperimentalArg, + // Output format selector. Keep new arguments appended to preserve serialized argument ordinals. + OutputFormat, + // This should always be at the end Max }; diff --git a/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp b/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp index f959d3444d..a1148be8f0 100644 --- a/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp +++ b/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp @@ -391,6 +391,19 @@ namespace AppInstaller::CLI::Workflow return packages; } + Json::Value SourceFailuresToJson(const std::vector& failures) + { + Json::Value result{ Json::ValueType::arrayValue }; + for (const auto& failure : failures) + { + Json::Value sourceFailure{ Json::ValueType::objectValue }; + sourceFailure["source"] = failure.SourceName; + result.append(std::move(sourceFailure)); + } + + return result; + } + void OutputInstalledPackagesJson( Execution::Context& context, std::vector& lines, @@ -409,6 +422,7 @@ namespace AppInstaller::CLI::Workflow Json::Value result{ Json::ValueType::objectValue }; result["packages"] = InstalledPackageLinesToJson(lines); result["truncated"] = truncated; + result["sourceFailures"] = SourceFailuresToJson(context.Get().Failures); if (onlyShowUpgrades) { @@ -650,6 +664,14 @@ namespace AppInstaller::CLI::Workflow Utility::CaseInsensitiveEquals(args.GetArg(Execution::Args::Type::OutputFormat), "json"sv); } + void SetJsonOutputChannel(Execution::Context& context) + { + if (IsJsonOutputFormat(context.Args)) + { + context.Reporter.SetChannel(Execution::Reporter::Channel::Json); + } + } + HRESULT HandleException(Execution::Context* context, std::exception_ptr exception) { try diff --git a/src/AppInstallerCLICore/Workflows/WorkflowBase.h b/src/AppInstallerCLICore/Workflows/WorkflowBase.h index 4b6343f3f5..6599509eab 100644 --- a/src/AppInstallerCLICore/Workflows/WorkflowBase.h +++ b/src/AppInstallerCLICore/Workflows/WorkflowBase.h @@ -88,6 +88,9 @@ namespace AppInstaller::CLI::Workflow // Helper to determine whether the requested output format is JSON. bool IsJsonOutputFormat(const Execution::Args& args); + // Helper to set the reporter to JSON when requested. + void SetJsonOutputChannel(Execution::Context& context); + // Helper to report exceptions and return the HRESULT. // If context is null, no output will be attempted. HRESULT HandleException(Execution::Context* context, std::exception_ptr exception); diff --git a/src/AppInstallerCLITests/UpdateFlow.cpp b/src/AppInstallerCLITests/UpdateFlow.cpp index 6fcd690839..5849195eee 100644 --- a/src/AppInstallerCLITests/UpdateFlow.cpp +++ b/src/AppInstallerCLITests/UpdateFlow.cpp @@ -327,6 +327,8 @@ TEST_CASE("ListFlow_JsonOutput", "[ListFlow][workflow]") REQUIRE(json["packages"][0]["id"].asString() == "AppInstallerCliTest.TestExeInstaller"); REQUIRE(json["packages"][0]["installedVersion"].asString() == "1.0.0.0"); REQUIRE(json["truncated"].asBool() == false); + REQUIRE(json["sourceFailures"].isArray()); + REQUIRE(json["sourceFailures"].empty()); } TEST_CASE("UpdateFlow_ListJsonOutput", "[UpdateFlow][workflow]") @@ -350,6 +352,8 @@ TEST_CASE("UpdateFlow_ListJsonOutput", "[UpdateFlow][workflow]") REQUIRE(json["availableUpgrades"].asInt() == 1); REQUIRE(json["packagesBlockedByPins"].isArray()); REQUIRE(json["packagesWithAvailableUpgradesForPins"].isArray()); + REQUIRE(json["sourceFailures"].isArray()); + REQUIRE(json["sourceFailures"].empty()); } TEST_CASE("UpdateFlow_JsonOutputRequiresListMode", "[UpdateFlow][workflow]") @@ -362,6 +366,16 @@ TEST_CASE("UpdateFlow_JsonOutputRequiresListMode", "[UpdateFlow][workflow]") REQUIRE_THROWS_AS(update.ValidateArguments(args), CommandException); } +TEST_CASE("ListFlow_JsonOutputRejectsDetails", "[ListFlow][workflow]") +{ + Execution::Args args; + args.AddArg(Execution::Args::Type::ListDetails); + args.AddArg(Execution::Args::Type::OutputFormat, "json"sv); + + ListCommand list({}); + REQUIRE_THROWS_AS(list.ValidateArguments(args), CommandException); +} + TEST_CASE("UpdateFlow_IncludeUnknown", "[UpdateFlow][workflow]") { std::ostringstream updateOutput; From fb7429a10039cffa401a113bed9d927e11a2290a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Thu, 2 Jul 2026 23:56:52 +0200 Subject: [PATCH 3/9] Keep list JSON output parseable --- .../Commands/ListCommand.cpp | 3 ++- .../Commands/UpgradeCommand.cpp | 2 +- .../Workflows/WorkflowBase.cpp | 6 +++++ src/AppInstallerCLITests/UpdateFlow.cpp | 23 +++++++++++++++++++ 4 files changed, 32 insertions(+), 2 deletions(-) diff --git a/src/AppInstallerCLICore/Commands/ListCommand.cpp b/src/AppInstallerCLICore/Commands/ListCommand.cpp index 6734e7d571..eb0190b1f2 100644 --- a/src/AppInstallerCLICore/Commands/ListCommand.cpp +++ b/src/AppInstallerCLICore/Commands/ListCommand.cpp @@ -9,6 +9,7 @@ namespace AppInstaller::CLI { using namespace AppInstaller::CLI::Workflow; + using namespace AppInstaller::Utility::literals; using namespace std::string_view_literals; std::vector ListCommand::GetArguments() const @@ -100,8 +101,8 @@ namespace AppInstaller::CLI context.SetFlags(Execution::ContextFlag::TreatSourceFailuresAsWarning); context << - Workflow::OpenSource() << Workflow::SetJsonOutputChannel << + Workflow::OpenSource() << Workflow::OpenCompositeSource(Workflow::DetermineInstalledSource(context)) << Workflow::SearchSourceForMany << Workflow::HandleSearchResultFailures << diff --git a/src/AppInstallerCLICore/Commands/UpgradeCommand.cpp b/src/AppInstallerCLICore/Commands/UpgradeCommand.cpp index 06127de3ad..519497cbae 100644 --- a/src/AppInstallerCLICore/Commands/UpgradeCommand.cpp +++ b/src/AppInstallerCLICore/Commands/UpgradeCommand.cpp @@ -167,8 +167,8 @@ namespace AppInstaller::CLI context << InitializeInstallerDownloadAuthenticatorsMap << ReportExecutionStage(ExecutionStage::Discovery) << - OpenSource() << SetJsonOutputChannel << + OpenSource() << OpenCompositeSource(DetermineInstalledSource(context)); if (ShouldListUpgrade(context.Args)) diff --git a/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp b/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp index a1148be8f0..f5436d693a 100644 --- a/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp +++ b/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp @@ -1422,6 +1422,12 @@ namespace AppInstaller::CLI::Workflow if (searchResult.Matches.size() == 0) { Logging::Telemetry().LogNoAppMatch(); + + if (IsJsonOutputFormat(context.Args) && (m_operationType == OperationType::List || m_operationType == OperationType::Upgrade)) + { + ReportListResult(m_operationType == OperationType::Upgrade)(context); + AICLI_TERMINATE_CONTEXT(APPINSTALLER_CLI_ERROR_NO_APPLICATIONS_FOUND); + } switch (m_operationType) { diff --git a/src/AppInstallerCLITests/UpdateFlow.cpp b/src/AppInstallerCLITests/UpdateFlow.cpp index 5849195eee..e07e3906e7 100644 --- a/src/AppInstallerCLITests/UpdateFlow.cpp +++ b/src/AppInstallerCLITests/UpdateFlow.cpp @@ -331,6 +331,29 @@ TEST_CASE("ListFlow_JsonOutput", "[ListFlow][workflow]") REQUIRE(json["sourceFailures"].empty()); } +TEST_CASE("ListFlow_JsonOutputNoMatches", "[ListFlow][workflow]") +{ + std::ostringstream listOutput; + TestContext context{ listOutput, std::cin }; + auto previousThreadGlobals = context.SetForCurrentThread(); + OverrideForCompositeInstalledSource(context, CreateTestSource({})); + context.Args.AddArg(Execution::Args::Type::Query, "NoSuchPackage"sv); + context.Args.AddArg(Execution::Args::Type::OutputFormat, "json"sv); + + ListCommand list({}); + context.SetExecutingCommand(&list); + list.Execute(context); + INFO(listOutput.str()); + + Json::Value json = ConvertToJson(listOutput.str()); + REQUIRE(json["packages"].isArray()); + REQUIRE(json["packages"].empty()); + REQUIRE(json["truncated"].asBool() == false); + REQUIRE(json["sourceFailures"].isArray()); + REQUIRE(json["sourceFailures"].empty()); + REQUIRE(context.GetTerminationHR() == APPINSTALLER_CLI_ERROR_NO_APPLICATIONS_FOUND); +} + TEST_CASE("UpdateFlow_ListJsonOutput", "[UpdateFlow][workflow]") { std::ostringstream updateOutput; From ae4dc99fb025e3098f0b6e3f21eeb6bda98db2ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Fri, 3 Jul 2026 00:27:23 +0200 Subject: [PATCH 4/9] Report JSON source-open errors --- .../Workflows/WorkflowBase.cpp | 84 ++++++++++++++++--- src/AppInstallerCLITests/UpdateFlow.cpp | 34 ++++++++ 2 files changed, 108 insertions(+), 10 deletions(-) diff --git a/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp b/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp index f5436d693a..dbbdc1c133 100644 --- a/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp +++ b/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. #include "pch.h" +#include "Command.h" #include "WorkflowBase.h" #include "ExecutionContext.h" #include "PackageTableSortHelper.h" @@ -182,6 +183,8 @@ namespace AppInstaller::CLI::Workflow } CATCH_LOG(); + void OutputInstalledPackagesJsonError(Execution::Context& context, HRESULT resultCode, Resource::LocString message, Utility::LocIndView sourceName = {}); + Repository::Source OpenNamedSource(Execution::Context& context, Utility::LocIndView sourceName) { Repository::Source source; @@ -197,11 +200,18 @@ namespace AppInstaller::CLI::Workflow if (!sourceName.empty() && !sources.empty()) { // A bad name was given, try to help. - context.Reporter.Error() << Resource::String::OpenSourceFailedNoMatch(sourceName) << std::endl; - context.Reporter.Info() << Resource::String::OpenSourceFailedNoMatchHelp << std::endl; - for (const auto& details : sources) + if (IsJsonOutputFormat(context.Args)) + { + OutputInstalledPackagesJsonError(context, APPINSTALLER_CLI_ERROR_SOURCE_NAME_DOES_NOT_EXIST, Resource::String::OpenSourceFailedNoMatch(sourceName), sourceName); + } + else { - context.Reporter.Info() << " "_liv << details.Name << std::endl; + context.Reporter.Error() << Resource::String::OpenSourceFailedNoMatch(sourceName) << std::endl; + context.Reporter.Info() << Resource::String::OpenSourceFailedNoMatchHelp << std::endl; + for (const auto& details : sources) + { + context.Reporter.Info() << " "_liv << details.Name << std::endl; + } } AICLI_TERMINATE_CONTEXT_RETURN(APPINSTALLER_CLI_ERROR_SOURCE_NAME_DOES_NOT_EXIST, {}); @@ -209,7 +219,15 @@ namespace AppInstaller::CLI::Workflow else { // Even if a name was given, there are no sources - context.Reporter.Error() << Resource::String::OpenSourceFailedNoSourceDefined << std::endl; + if (IsJsonOutputFormat(context.Args)) + { + OutputInstalledPackagesJsonError(context, APPINSTALLER_CLI_ERROR_NO_SOURCES_DEFINED, Resource::String::OpenSourceFailedNoSourceDefined, sourceName); + } + else + { + context.Reporter.Error() << Resource::String::OpenSourceFailedNoSourceDefined << std::endl; + } + AICLI_TERMINATE_CONTEXT_RETURN(APPINSTALLER_CLI_ERROR_NO_SOURCES_DEFINED, {}); } } @@ -404,6 +422,56 @@ namespace AppInstaller::CLI::Workflow return result; } + Json::Value EmptyInstalledPackagesJson(bool onlyShowUpgrades) + { + Json::Value result{ Json::ValueType::objectValue }; + result["packages"] = Json::Value{ Json::ValueType::arrayValue }; + result["truncated"] = false; + result["sourceFailures"] = Json::Value{ Json::ValueType::arrayValue }; + + if (onlyShowUpgrades) + { + result["availableUpgrades"] = 0; + result["packagesWithAvailableUpgradesForPins"] = Json::Value{ Json::ValueType::arrayValue }; + result["packagesBlockedByPins"] = Json::Value{ Json::ValueType::arrayValue }; + result["skippedUnknownVersions"] = 0; + result["skippedPinned"] = 0; + } + + return result; + } + + void WriteJsonOutput(Execution::Context& context, const Json::Value& result) + { + Json::StreamWriterBuilder writerBuilder; + writerBuilder.settings_["indentation"] = ""; + writerBuilder.settings_["commentStyle"] = "None"; + writerBuilder.settings_["emitUTF8"] = true; + context.Reporter.Json() << Json::writeString(writerBuilder, result) << std::endl; + } + + void OutputInstalledPackagesJsonError(Execution::Context& context, HRESULT resultCode, Resource::LocString message, Utility::LocIndView sourceName) + { + Json::Value result = EmptyInstalledPackagesJson(context.GetExecutingCommand() && context.GetExecutingCommand()->Name() == "upgrade"); + + if (!sourceName.empty()) + { + Json::Value sourceFailure{ Json::ValueType::objectValue }; + sourceFailure["source"] = std::string{ sourceName }; + result["sourceFailures"].append(std::move(sourceFailure)); + } + + std::ostringstream errorCode; + errorCode << WINGET_OSTREAM_FORMAT_HRESULT(resultCode); + + Json::Value error{ Json::ValueType::objectValue }; + error["code"] = errorCode.str(); + error["message"] = message.get(); + result["error"] = std::move(error); + + WriteJsonOutput(context, result); + } + void OutputInstalledPackagesJson( Execution::Context& context, std::vector& lines, @@ -433,11 +501,7 @@ namespace AppInstaller::CLI::Workflow result["skippedPinned"] = packagesWithUserPinsSkipped; } - Json::StreamWriterBuilder writerBuilder; - writerBuilder.settings_["indentation"] = ""; - writerBuilder.settings_["commentStyle"] = "None"; - writerBuilder.settings_["emitUTF8"] = true; - context.Reporter.Json() << Json::writeString(writerBuilder, result) << std::endl; + WriteJsonOutput(context, result); } void ShowMetadataField( diff --git a/src/AppInstallerCLITests/UpdateFlow.cpp b/src/AppInstallerCLITests/UpdateFlow.cpp index e07e3906e7..38397d7e32 100644 --- a/src/AppInstallerCLITests/UpdateFlow.cpp +++ b/src/AppInstallerCLITests/UpdateFlow.cpp @@ -3,6 +3,7 @@ #include "pch.h" #include "WorkflowCommon.h" #include "TestHooks.h" +#include "TestSettings.h" #include #include #include @@ -354,6 +355,39 @@ TEST_CASE("ListFlow_JsonOutputNoMatches", "[ListFlow][workflow]") REQUIRE(context.GetTerminationHR() == APPINSTALLER_CLI_ERROR_NO_APPLICATIONS_FOUND); } +TEST_CASE("ListFlow_JsonOutputBadSource", "[ListFlow][workflow]") +{ + SetSetting(Stream::UserSources, R"( +Sources: + - Name: TestSource + Type: Microsoft.Test + Arg: TestArg + Data: TestData + IsTombstone: false +)"sv); + + std::ostringstream listOutput; + TestContext context{ listOutput, std::cin }; + auto previousThreadGlobals = context.SetForCurrentThread(); + context.Args.AddArg(Execution::Args::Type::Source, "MissingSource"sv); + context.Args.AddArg(Execution::Args::Type::OutputFormat, "json"sv); + + ListCommand list({}); + context.SetExecutingCommand(&list); + list.Execute(context); + INFO(listOutput.str()); + + Json::Value json = ConvertToJson(listOutput.str()); + REQUIRE(json["packages"].isArray()); + REQUIRE(json["packages"].empty()); + REQUIRE(json["sourceFailures"].isArray()); + REQUIRE(json["sourceFailures"].size() == 1); + REQUIRE(json["sourceFailures"][0]["source"].asString() == "MissingSource"); + REQUIRE(json["error"]["code"].asString() == "0x8a150012"); + REQUIRE(json["error"]["message"].asString().empty() == false); + REQUIRE(context.GetTerminationHR() == APPINSTALLER_CLI_ERROR_SOURCE_NAME_DOES_NOT_EXIST); +} + TEST_CASE("UpdateFlow_ListJsonOutput", "[UpdateFlow][workflow]") { std::ostringstream updateOutput; From a7044783e07240c3734716d6a8892fce6689cbeb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Fri, 3 Jul 2026 00:57:12 +0200 Subject: [PATCH 5/9] Keep list JSON output complete --- src/AppInstallerCLICore/Command.cpp | 3 ++ .../ExecutionContextData.h | 7 +++ .../Workflows/WorkflowBase.cpp | 44 +++++++++++++++++- src/AppInstallerCLITests/UpdateFlow.cpp | 46 +++++++++++++++++++ 4 files changed, 98 insertions(+), 2 deletions(-) diff --git a/src/AppInstallerCLICore/Command.cpp b/src/AppInstallerCLICore/Command.cpp index 770435b399..c3ab2b6a45 100644 --- a/src/AppInstallerCLICore/Command.cpp +++ b/src/AppInstallerCLICore/Command.cpp @@ -4,6 +4,7 @@ #include "Command.h" #include "Resources.h" #include "Sixel.h" +#include "Workflows/WorkflowBase.h" #include #include #include @@ -1071,6 +1072,8 @@ namespace AppInstaller::CLI { try { + Workflow::SetJsonOutputChannel(context); + if (!Settings::User().GetWarnings().empty() && !WI_IsFlagSet(command->GetOutputFlags(), CommandOutputFlags::IgnoreSettingsWarnings)) { diff --git a/src/AppInstallerCLICore/ExecutionContextData.h b/src/AppInstallerCLICore/ExecutionContextData.h index 4609893dc2..f93a1ab21e 100644 --- a/src/AppInstallerCLICore/ExecutionContextData.h +++ b/src/AppInstallerCLICore/ExecutionContextData.h @@ -69,6 +69,7 @@ namespace AppInstaller::CLI::Execution RepairString, MsixDigests, InstallerDownloadAuthenticators, + SourceOpenUpdateFailures, Max }; @@ -299,5 +300,11 @@ namespace AppInstaller::CLI::Execution // The authenticator map shared with sub contexts using value_t = std::shared_ptr>; }; + + template<> + struct DataMapping + { + using value_t = std::vector; + }; } } diff --git a/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp b/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp index dbbdc1c133..547f4ecbd2 100644 --- a/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp +++ b/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp @@ -256,6 +256,17 @@ namespace AppInstaller::CLI::Workflow context.Reporter.Warn() << Resource::String::SourceOpenWithFailedUpdate(Utility::LocIndView{ s.Name }) << std::endl; } + if (!updateFailures.empty()) + { + if (!context.Contains(Execution::Data::SourceOpenUpdateFailures)) + { + context.Add({}); + } + + auto& sourceOpenUpdateFailures = context.Get(); + std::move(updateFailures.begin(), updateFailures.end(), std::back_inserter(sourceOpenUpdateFailures)); + } + // Report sources that may need authentication if (source.IsComposite()) { @@ -274,6 +285,12 @@ namespace AppInstaller::CLI::Workflow } catch (const wil::ResultException& re) { + if (IsJsonOutputFormat(context.Args)) + { + OutputInstalledPackagesJsonError(context, re.GetErrorCode(), Resource::String::SourceOpenFailedSuggestion, sourceName); + AICLI_TERMINATE_CONTEXT_RETURN(re.GetErrorCode(), {}); + } + context.Reporter.Error() << Resource::String::SourceOpenFailedSuggestion << std::endl; if (re.GetErrorCode() == APPINSTALLER_CLI_ERROR_FAILED_TO_OPEN_ALL_SOURCES) { @@ -288,6 +305,12 @@ namespace AppInstaller::CLI::Workflow } catch (...) { + if (IsJsonOutputFormat(context.Args)) + { + OutputInstalledPackagesJsonError(context, E_FAIL, Resource::String::SourceOpenFailedSuggestion, sourceName); + AICLI_TERMINATE_CONTEXT_RETURN(E_FAIL, {}); + } + context.Reporter.Error() << Resource::String::SourceOpenFailedSuggestion << std::endl; throw; } @@ -422,6 +445,16 @@ namespace AppInstaller::CLI::Workflow return result; } + void AppendSourceFailures(Json::Value& sourceFailures, const std::vector& failures) + { + for (const auto& failure : failures) + { + Json::Value sourceFailure{ Json::ValueType::objectValue }; + sourceFailure["source"] = failure.Name; + sourceFailures.append(std::move(sourceFailure)); + } + } + Json::Value EmptyInstalledPackagesJson(bool onlyShowUpgrades) { Json::Value result{ Json::ValueType::objectValue }; @@ -452,7 +485,9 @@ namespace AppInstaller::CLI::Workflow void OutputInstalledPackagesJsonError(Execution::Context& context, HRESULT resultCode, Resource::LocString message, Utility::LocIndView sourceName) { - Json::Value result = EmptyInstalledPackagesJson(context.GetExecutingCommand() && context.GetExecutingCommand()->Name() == "upgrade"); + Json::Value result = EmptyInstalledPackagesJson( + (context.GetExecutingCommand() && context.GetExecutingCommand()->Name() == "upgrade") || + context.Args.Contains(Execution::Args::Type::Upgrade)); if (!sourceName.empty()) { @@ -492,6 +527,11 @@ namespace AppInstaller::CLI::Workflow result["truncated"] = truncated; result["sourceFailures"] = SourceFailuresToJson(context.Get().Failures); + if (context.Contains(Execution::Data::SourceOpenUpdateFailures)) + { + AppendSourceFailures(result["sourceFailures"], context.Get()); + } + if (onlyShowUpgrades) { result["availableUpgrades"] = availableUpgradesCount; @@ -1489,7 +1529,7 @@ namespace AppInstaller::CLI::Workflow if (IsJsonOutputFormat(context.Args) && (m_operationType == OperationType::List || m_operationType == OperationType::Upgrade)) { - ReportListResult(m_operationType == OperationType::Upgrade)(context); + ReportListResult(m_operationType == OperationType::Upgrade || context.Args.Contains(Execution::Args::Type::Upgrade))(context); AICLI_TERMINATE_CONTEXT(APPINSTALLER_CLI_ERROR_NO_APPLICATIONS_FOUND); } diff --git a/src/AppInstallerCLITests/UpdateFlow.cpp b/src/AppInstallerCLITests/UpdateFlow.cpp index 38397d7e32..70a830db8a 100644 --- a/src/AppInstallerCLITests/UpdateFlow.cpp +++ b/src/AppInstallerCLITests/UpdateFlow.cpp @@ -355,6 +355,28 @@ TEST_CASE("ListFlow_JsonOutputNoMatches", "[ListFlow][workflow]") REQUIRE(context.GetTerminationHR() == APPINSTALLER_CLI_ERROR_NO_APPLICATIONS_FOUND); } +TEST_CASE("ListFlow_JsonOutputWithSettingsWarnings", "[ListFlow][workflow]") +{ + auto settingsGuard = DeleteUserSettingsFiles(); + SetSetting(Stream::PrimaryUserSettings, "{"sv); + + std::ostringstream listOutput; + TestContext context{ listOutput, std::cin }; + auto previousThreadGlobals = context.SetForCurrentThread(); + OverrideForCompositeInstalledSource(context, CreateTestSource({ TSR::TestInstaller_Exe })); + context.Args.AddArg(Execution::Args::Type::Query, TSR::TestInstaller_Exe.Query); + context.Args.AddArg(Execution::Args::Type::OutputFormat, "json"sv); + + ListCommand list({}); + context.SetExecutingCommand(&list); + ExecuteWithoutLoggingSuccess(context, &list); + INFO(listOutput.str()); + + Json::Value json = ConvertToJson(listOutput.str()); + REQUIRE(json["packages"].isArray()); + REQUIRE(json["packages"].size() == 1); +} + TEST_CASE("ListFlow_JsonOutputBadSource", "[ListFlow][workflow]") { SetSetting(Stream::UserSources, R"( @@ -413,6 +435,30 @@ TEST_CASE("UpdateFlow_ListJsonOutput", "[UpdateFlow][workflow]") REQUIRE(json["sourceFailures"].empty()); } +TEST_CASE("UpdateFlow_ListJsonOutputNoMatches", "[UpdateFlow][workflow]") +{ + std::ostringstream updateOutput; + TestContext context{ updateOutput, std::cin }; + auto previousThreadGlobals = context.SetForCurrentThread(); + OverrideForCompositeInstalledSource(context, CreateTestSource({})); + context.Args.AddArg(Execution::Args::Type::OutputFormat, "json"sv); + + UpgradeCommand update({}); + context.SetExecutingCommand(&update); + update.Execute(context); + INFO(updateOutput.str()); + + Json::Value json = ConvertToJson(updateOutput.str()); + REQUIRE(json["packages"].isArray()); + REQUIRE(json["packages"].empty()); + REQUIRE(json["availableUpgrades"].asInt() == 0); + REQUIRE(json["packagesBlockedByPins"].isArray()); + REQUIRE(json["packagesWithAvailableUpgradesForPins"].isArray()); + REQUIRE(json["skippedUnknownVersions"].asInt() == 0); + REQUIRE(json["skippedPinned"].asInt() == 0); + REQUIRE(context.GetTerminationHR() == APPINSTALLER_CLI_ERROR_NO_APPLICATIONS_FOUND); +} + TEST_CASE("UpdateFlow_JsonOutputRequiresListMode", "[UpdateFlow][workflow]") { Execution::Args args; From 0eb19f8c46bee66dfa34e77744717e89599019bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Fri, 3 Jul 2026 01:20:43 +0200 Subject: [PATCH 6/9] Report predefined source JSON errors --- .../Workflows/WorkflowBase.cpp | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp b/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp index 547f4ecbd2..e9874b0738 100644 --- a/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp +++ b/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp @@ -979,8 +979,25 @@ namespace AppInstaller::CLI::Workflow }; context.Reporter.ExecuteWithProgress(openFunction, true); } + catch (const wil::ResultException& re) + { + if (IsJsonOutputFormat(context.Args)) + { + OutputInstalledPackagesJsonError(context, re.GetErrorCode(), Resource::String::SourceOpenPredefinedFailedSuggestion); + AICLI_TERMINATE_CONTEXT(re.GetErrorCode()); + } + + context.Reporter.Error() << Resource::String::SourceOpenPredefinedFailedSuggestion << std::endl; + throw; + } catch (...) { + if (IsJsonOutputFormat(context.Args)) + { + OutputInstalledPackagesJsonError(context, E_FAIL, Resource::String::SourceOpenPredefinedFailedSuggestion); + AICLI_TERMINATE_CONTEXT(E_FAIL); + } + context.Reporter.Error() << Resource::String::SourceOpenPredefinedFailedSuggestion << std::endl; throw; } From 8ad1a29293fd0358136eca87a900856530524b39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Fri, 3 Jul 2026 01:48:53 +0200 Subject: [PATCH 7/9] Keep policy errors parseable in list JSON --- .../Workflows/WorkflowBase.cpp | 12 ++++++++- src/AppInstallerCLITests/UpdateFlow.cpp | 25 +++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp b/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp index e9874b0738..30a4b9bf00 100644 --- a/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp +++ b/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp @@ -25,6 +25,7 @@ EXTERN_C IMAGE_DOS_HEADER __ImageBase; using namespace std::string_literals; +using namespace std::string_view_literals; using namespace AppInstaller::Utility::literals; using namespace AppInstaller::Pinning; using namespace AppInstaller::Repository; @@ -813,7 +814,16 @@ namespace AppInstaller::CLI::Workflow { auto policy = Settings::TogglePolicy::GetPolicy(e.Policy()); auto policyNameId = policy.PolicyName(); - context->Reporter.Error() << Resource::String::DisabledByGroupPolicy(policyNameId) << std::endl; + auto message = Resource::String::DisabledByGroupPolicy(policyNameId); + + if (IsJsonOutputFormat(context->Args)) + { + OutputInstalledPackagesJsonError(*context, APPINSTALLER_CLI_ERROR_BLOCKED_BY_POLICY, message); + } + else + { + context->Reporter.Error() << message << std::endl; + } } return APPINSTALLER_CLI_ERROR_BLOCKED_BY_POLICY; } diff --git a/src/AppInstallerCLITests/UpdateFlow.cpp b/src/AppInstallerCLITests/UpdateFlow.cpp index 70a830db8a..bafcedf17d 100644 --- a/src/AppInstallerCLITests/UpdateFlow.cpp +++ b/src/AppInstallerCLITests/UpdateFlow.cpp @@ -377,6 +377,31 @@ TEST_CASE("ListFlow_JsonOutputWithSettingsWarnings", "[ListFlow][workflow]") REQUIRE(json["packages"].size() == 1); } +TEST_CASE("ListFlow_JsonOutputWinGetPolicyDisabled", "[ListFlow][workflow]") +{ + GroupPolicyTestOverride policies; + policies.SetState(TogglePolicy::Policy::WinGet, PolicyState::Disabled); + + std::ostringstream listOutput; + TestContext context{ listOutput, std::cin }; + auto previousThreadGlobals = context.SetForCurrentThread(); + context.Args.AddArg(Execution::Args::Type::OutputFormat, "json"sv); + + ListCommand list({}); + context.SetExecutingCommand(&list); + ExecuteWithoutLoggingSuccess(context, &list); + INFO(listOutput.str()); + + Json::Value json = ConvertToJson(listOutput.str()); + REQUIRE(json["packages"].isArray()); + REQUIRE(json["packages"].empty()); + REQUIRE(json["sourceFailures"].isArray()); + REQUIRE(json["sourceFailures"].empty()); + REQUIRE(json["error"]["code"].asString() == "0x8a15003a"); + REQUIRE(json["error"]["message"].asString().empty() == false); + REQUIRE(context.GetTerminationHR() == APPINSTALLER_CLI_ERROR_BLOCKED_BY_POLICY); +} + TEST_CASE("ListFlow_JsonOutputBadSource", "[ListFlow][workflow]") { SetSetting(Stream::UserSources, R"( From 3e9efade69b744705a3be6dd9d5000b17e46ea01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Fri, 3 Jul 2026 02:10:06 +0200 Subject: [PATCH 8/9] Stop composite source after JSON open failures --- .../Workflows/WorkflowBase.cpp | 4 +++ src/AppInstallerCLITests/UpdateFlow.cpp | 27 +++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp b/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp index 30a4b9bf00..a968ba9121 100644 --- a/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp +++ b/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp @@ -1037,6 +1037,10 @@ namespace AppInstaller::CLI::Workflow // Open the predefined source. context << OpenPredefinedSource(m_predefinedSource, m_forDependencies); + if (context.IsTerminated()) + { + return; + } // Create the composite source from the two. Repository::Source source; diff --git a/src/AppInstallerCLITests/UpdateFlow.cpp b/src/AppInstallerCLITests/UpdateFlow.cpp index bafcedf17d..3b63dc6e7d 100644 --- a/src/AppInstallerCLITests/UpdateFlow.cpp +++ b/src/AppInstallerCLITests/UpdateFlow.cpp @@ -435,6 +435,33 @@ TEST_CASE("ListFlow_JsonOutputBadSource", "[ListFlow][workflow]") REQUIRE(context.GetTerminationHR() == APPINSTALLER_CLI_ERROR_SOURCE_NAME_DOES_NOT_EXIST); } +TEST_CASE("ListFlow_JsonOutputStopsCompositeAfterInstalledSourceFailure", "[ListFlow][workflow]") +{ + std::ostringstream listOutput; + TestContext context{ listOutput, std::cin }; + auto previousThreadGlobals = context.SetForCurrentThread(); + context.Args.AddArg(Execution::Args::Type::OutputFormat, "json"sv); + Workflow::SetJsonOutputChannel(context); + + auto availableSource = AppInstaller::Repository::Source{ CreateTestSource({ TSR::TestInstaller_Exe }) }; + context.Add(availableSource); + context.Override({ "OpenPredefinedSource", [](TestContext& context) + { + context.Reporter.Json() << R"({"packages":[],"truncated":false,"sourceFailures":[],"error":{"code":"0x8a15000e","message":"Failed to open source."}})" << std::endl; + context.SetTerminationHR(APPINSTALLER_CLI_ERROR_SOURCE_OPEN_FAILED); + } }); + + context << Workflow::OpenCompositeSource(AppInstaller::Repository::PredefinedSource::Installed); + INFO(listOutput.str()); + + Json::Value json = ConvertToJson(listOutput.str()); + REQUIRE(json["packages"].isArray()); + REQUIRE(json["packages"].empty()); + REQUIRE(json["error"]["code"].asString() == "0x8a15000e"); + REQUIRE(context.GetTerminationHR() == APPINSTALLER_CLI_ERROR_SOURCE_OPEN_FAILED); + REQUIRE_FALSE(context.Get().IsComposite()); +} + TEST_CASE("UpdateFlow_ListJsonOutput", "[UpdateFlow][workflow]") { std::ostringstream updateOutput; From 3de5ecf4c8b58e96c14aa771004d16c76fb07a4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Fri, 3 Jul 2026 02:32:29 +0200 Subject: [PATCH 9/9] Keep JSON failures and help visible --- src/AppInstallerCLICore/Command.cpp | 5 +- .../Workflows/WorkflowBase.cpp | 65 +++++++++++++++---- src/AppInstallerCLITests/UpdateFlow.cpp | 43 ++++++++++++ 3 files changed, 101 insertions(+), 12 deletions(-) diff --git a/src/AppInstallerCLICore/Command.cpp b/src/AppInstallerCLICore/Command.cpp index c3ab2b6a45..3fc8f3f7a9 100644 --- a/src/AppInstallerCLICore/Command.cpp +++ b/src/AppInstallerCLICore/Command.cpp @@ -1072,7 +1072,10 @@ namespace AppInstaller::CLI { try { - Workflow::SetJsonOutputChannel(context); + if (!context.Args.Contains(Execution::Args::Type::Help)) + { + Workflow::SetJsonOutputChannel(context); + } if (!Settings::User().GetWarnings().empty() && !WI_IsFlagSet(command->GetOutputFlags(), CommandOutputFlags::IgnoreSettingsWarnings)) diff --git a/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp b/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp index a968ba9121..dfdb59e8d7 100644 --- a/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp +++ b/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp @@ -508,6 +508,19 @@ namespace AppInstaller::CLI::Workflow WriteJsonOutput(context, result); } + Resource::LocString GetUnexpectedErrorMessage(std::string_view message) + { + std::string result = Resource::LocString{ Resource::String::UnexpectedErrorExecutingCommand }.get(); + + if (!message.empty()) + { + result += ' '; + result += message; + } + + return Resource::LocString{ Utility::LocIndString{ std::move(result) } }; + } + void OutputInstalledPackagesJson( Execution::Context& context, std::vector& lines, @@ -790,9 +803,17 @@ namespace AppInstaller::CLI::Workflow Logging::Telemetry().LogException(Logging::FailureTypeEnum::ResultException, re.what()); if (context) { - context->Reporter.Error() << - Resource::String::UnexpectedErrorExecutingCommand << ' ' << std::endl << - GetUserPresentableMessage(re) << std::endl; + auto message = GetUserPresentableMessage(re); + if (IsJsonOutputFormat(context->Args)) + { + OutputInstalledPackagesJsonError(*context, re.GetErrorCode(), GetUnexpectedErrorMessage(message)); + } + else + { + context->Reporter.Error() << + Resource::String::UnexpectedErrorExecutingCommand << ' ' << std::endl << + message << std::endl; + } } return re.GetErrorCode(); } @@ -802,9 +823,16 @@ namespace AppInstaller::CLI::Workflow Logging::Telemetry().LogException(Logging::FailureTypeEnum::WinrtHResultError, message); if (context) { - context->Reporter.Error() << - Resource::String::UnexpectedErrorExecutingCommand << ' ' << std::endl << - message << std::endl; + if (IsJsonOutputFormat(context->Args)) + { + OutputInstalledPackagesJsonError(*context, hre.code(), GetUnexpectedErrorMessage(message)); + } + else + { + context->Reporter.Error() << + Resource::String::UnexpectedErrorExecutingCommand << ' ' << std::endl << + message << std::endl; + } } return hre.code(); } @@ -832,9 +860,17 @@ namespace AppInstaller::CLI::Workflow Logging::Telemetry().LogException(Logging::FailureTypeEnum::StdException, e.what()); if (context) { - context->Reporter.Error() << - Resource::String::UnexpectedErrorExecutingCommand << ' ' << std::endl << - GetUserPresentableMessage(e) << std::endl; + auto message = GetUserPresentableMessage(e); + if (IsJsonOutputFormat(context->Args)) + { + OutputInstalledPackagesJsonError(*context, APPINSTALLER_CLI_ERROR_COMMAND_FAILED, GetUnexpectedErrorMessage(message)); + } + else + { + context->Reporter.Error() << + Resource::String::UnexpectedErrorExecutingCommand << ' ' << std::endl << + message << std::endl; + } } return APPINSTALLER_CLI_ERROR_COMMAND_FAILED; } @@ -844,8 +880,15 @@ namespace AppInstaller::CLI::Workflow Logging::Telemetry().LogException(Logging::FailureTypeEnum::Unknown, {}); if (context) { - context->Reporter.Error() << - Resource::String::UnexpectedErrorExecutingCommand << " ???"_liv << std::endl; + if (IsJsonOutputFormat(context->Args)) + { + OutputInstalledPackagesJsonError(*context, APPINSTALLER_CLI_ERROR_COMMAND_FAILED, GetUnexpectedErrorMessage("???"sv)); + } + else + { + context->Reporter.Error() << + Resource::String::UnexpectedErrorExecutingCommand << " ???"_liv << std::endl; + } } return APPINSTALLER_CLI_ERROR_COMMAND_FAILED; } diff --git a/src/AppInstallerCLITests/UpdateFlow.cpp b/src/AppInstallerCLITests/UpdateFlow.cpp index 3b63dc6e7d..1b87dcbbd9 100644 --- a/src/AppInstallerCLITests/UpdateFlow.cpp +++ b/src/AppInstallerCLITests/UpdateFlow.cpp @@ -377,6 +377,23 @@ TEST_CASE("ListFlow_JsonOutputWithSettingsWarnings", "[ListFlow][workflow]") REQUIRE(json["packages"].size() == 1); } +TEST_CASE("ListFlow_JsonOutputHelpUsesTextOutput", "[ListFlow][workflow]") +{ + std::ostringstream listOutput; + TestContext context{ listOutput, std::cin }; + auto previousThreadGlobals = context.SetForCurrentThread(); + context.Args.AddArg(Execution::Args::Type::Help); + context.Args.AddArg(Execution::Args::Type::OutputFormat, "json"sv); + + ListCommand list({}); + context.SetExecutingCommand(&list); + ExecuteWithoutLoggingSuccess(context, &list); + INFO(listOutput.str()); + + REQUIRE(listOutput.str().find(Resource::String::Usage("winget"_liv, "list"_liv).get()) != std::string::npos); + REQUIRE_FALSE(context.IsTerminated()); +} + TEST_CASE("ListFlow_JsonOutputWinGetPolicyDisabled", "[ListFlow][workflow]") { GroupPolicyTestOverride policies; @@ -402,6 +419,32 @@ TEST_CASE("ListFlow_JsonOutputWinGetPolicyDisabled", "[ListFlow][workflow]") REQUIRE(context.GetTerminationHR() == APPINSTALLER_CLI_ERROR_BLOCKED_BY_POLICY); } +TEST_CASE("ListFlow_JsonOutputGenericExecutionFailure", "[ListFlow][workflow]") +{ + std::ostringstream listOutput; + TestContext context{ listOutput, std::cin }; + auto previousThreadGlobals = context.SetForCurrentThread(); + context.Args.AddArg(Execution::Args::Type::OutputFormat, "json"sv); + context.Override({ "OpenSource", [](TestContext&) + { + THROW_HR(APPINSTALLER_CLI_ERROR_SOURCE_OPEN_FAILED); + } }); + + ListCommand list({}); + context.SetExecutingCommand(&list); + ExecuteWithoutLoggingSuccess(context, &list); + INFO(listOutput.str()); + + Json::Value json = ConvertToJson(listOutput.str()); + REQUIRE(json["packages"].isArray()); + REQUIRE(json["packages"].empty()); + REQUIRE(json["sourceFailures"].isArray()); + REQUIRE(json["sourceFailures"].empty()); + REQUIRE(json["error"]["code"].asString() == "0x8a150045"); + REQUIRE(json["error"]["message"].asString().empty() == false); + REQUIRE(context.GetTerminationHR() == APPINSTALLER_CLI_ERROR_SOURCE_OPEN_FAILED); +} + TEST_CASE("ListFlow_JsonOutputBadSource", "[ListFlow][workflow]") { SetSetting(Stream::UserSources, R"(