diff --git a/src/AppInstallerCLICore/Argument.cpp b/src/AppInstallerCLICore/Argument.cpp index d56ecab462..17753d338c 100644 --- a/src/AppInstallerCLICore/Argument.cpp +++ b/src/AppInstallerCLICore/Argument.cpp @@ -92,7 +92,9 @@ namespace AppInstaller::CLI case Execution::Args::Type::NoUpgrade: return { type, "no-upgrade"_liv, ArgTypeCategory::CopyFlagToSubContext }; case Execution::Args::Type::SkipDependencies: - return { type, "skip-dependencies"_liv, ArgTypeCategory::InstallerBehavior | ArgTypeCategory::CopyFlagToSubContext }; + return { type, "skip-dependencies"_liv, ArgTypeCategory::InstallerBehavior | ArgTypeCategory::CopyFlagToSubContext, ArgTypeExclusiveSet::DependenciesConflict }; + case Execution::Args::Type::DependenciesOnly: + return { type, "dependencies-only"_liv, ArgTypeCategory::InstallerBehavior, ArgTypeExclusiveSet::DependenciesConflict }; case Execution::Args::Type::AllowReboot: return { type, "allow-reboot"_liv, ArgTypeCategory::InstallerBehavior | ArgTypeCategory::CopyFlagToSubContext }; @@ -408,6 +410,8 @@ namespace AppInstaller::CLI return Argument{ type, Resource::String::HelpArgumentDescription, ArgumentType::Flag }; case Args::Type::SkipDependencies: return Argument{ type, Resource::String::SkipDependenciesArgumentDescription, ArgumentType::Flag, false }; + case Args::Type::DependenciesOnly: + return Argument{ type, Resource::String::DependenciesOnlyArgumentDescription, ArgumentType::Flag, false }; case Args::Type::IgnoreLocalArchiveMalwareScan: return Argument{ type, Resource::String::IgnoreLocalArchiveMalwareScanArgumentDescription, ArgumentType::Flag, Settings::TogglePolicy::Policy::LocalArchiveMalwareScanOverride, Settings::BoolAdminSetting::LocalArchiveMalwareScanOverride }; case Args::Type::SourceName: diff --git a/src/AppInstallerCLICore/Argument.h b/src/AppInstallerCLICore/Argument.h index 426568ba55..e7b438f1a7 100644 --- a/src/AppInstallerCLICore/Argument.h +++ b/src/AppInstallerCLICore/Argument.h @@ -91,6 +91,7 @@ namespace AppInstaller::CLI AllAndTargetVersion = 0x40, ConfigurationSetChoice = 0x80, DscResourceFunction = 0x100, + DependenciesConflict = 0x200, // This must always be at the end Max diff --git a/src/AppInstallerCLICore/Commands/InstallCommand.cpp b/src/AppInstallerCLICore/Commands/InstallCommand.cpp index 20eb99393a..15aeaec7ad 100644 --- a/src/AppInstallerCLICore/Commands/InstallCommand.cpp +++ b/src/AppInstallerCLICore/Commands/InstallCommand.cpp @@ -45,6 +45,7 @@ namespace AppInstaller::CLI Argument::ForType(Args::Type::HashOverride), Argument::ForType(Args::Type::AllowReboot), Argument::ForType(Args::Type::SkipDependencies), + Argument::ForType(Args::Type::DependenciesOnly), Argument::ForType(Args::Type::IgnoreLocalArchiveMalwareScan), Argument::ForType(Args::Type::DependencySource), Argument::ForType(Args::Type::AcceptPackageAgreements), @@ -150,6 +151,10 @@ namespace AppInstaller::CLI { flags = ProcessMultiplePackages::Flags::IgnoreDependencies; } + else if (context.Args.Contains(Execution::Args::Type::DependenciesOnly)) + { + flags = ProcessMultiplePackages::Flags::DependenciesOnly; + } context << GetMultiSearchRequests << diff --git a/src/AppInstallerCLICore/ExecutionArgs.h b/src/AppInstallerCLICore/ExecutionArgs.h index eaa4eb775a..fa7e9c6358 100644 --- a/src/AppInstallerCLICore/ExecutionArgs.h +++ b/src/AppInstallerCLICore/ExecutionArgs.h @@ -45,6 +45,7 @@ namespace AppInstaller::CLI::Execution InstallerType, HashOverride, // Ignore hash mismatches SkipDependencies, // Skip dependencies + DependenciesOnly, // Install only dependencies, not the target package IgnoreLocalArchiveMalwareScan, // Ignore the local malware scan on archive files AcceptPackageAgreements, // Accept all license agreements for packages Rename, // Renames the file of the executable. Only applies to the portable installerType diff --git a/src/AppInstallerCLICore/Resources.h b/src/AppInstallerCLICore/Resources.h index 3fa69a1a0c..ef3db8b395 100644 --- a/src/AppInstallerCLICore/Resources.h +++ b/src/AppInstallerCLICore/Resources.h @@ -686,6 +686,8 @@ namespace AppInstaller::CLI::Resource WINGET_DEFINE_RESOURCE_STRINGID(SilentArgumentDescription); WINGET_DEFINE_RESOURCE_STRINGID(SingleCharAfterDashError); WINGET_DEFINE_RESOURCE_STRINGID(SkipDependenciesArgumentDescription); + WINGET_DEFINE_RESOURCE_STRINGID(DependenciesOnlyArgumentDescription); + WINGET_DEFINE_RESOURCE_STRINGID(DependenciesOnlyMessage); WINGET_DEFINE_RESOURCE_STRINGID(SkipMicrosoftStorePackageLicenseArgumentDescription); WINGET_DEFINE_RESOURCE_STRINGID(SourceAddAlreadyExistsDifferentArg); WINGET_DEFINE_RESOURCE_STRINGID(SourceAddAlreadyExistsDifferentName); diff --git a/src/AppInstallerCLICore/Workflows/InstallFlow.cpp b/src/AppInstallerCLICore/Workflows/InstallFlow.cpp index 130cddb2b2..f702629c20 100644 --- a/src/AppInstallerCLICore/Workflows/InstallFlow.cpp +++ b/src/AppInstallerCLICore/Workflows/InstallFlow.cpp @@ -157,6 +157,16 @@ namespace AppInstaller::CLI::Workflow HRESULT HResult; Resource::StringId Message; }; + + void CheckForOnlyDependencies(Execution::Context& context) + { + if (context.Args.Contains(Execution::Args::Type::DependenciesOnly)) + { + context.Reporter.Info() << Resource::String::DependenciesOnlyMessage << std::endl; + // We want the context to terminate, but successfully. + context.SetTerminationHR(S_OK); + } + } } namespace details @@ -640,6 +650,7 @@ namespace AppInstaller::CLI::Workflow Workflow::ShowPromptsForSinglePackage(/* ensureAcceptance */ true) << Workflow::CreateDependencySubContexts(Resource::String::PackageRequiresDependencies) << Workflow::InstallDependencies << + CheckForOnlyDependencies << Workflow::DownloadInstaller << Workflow::InstallPackageInstaller << Workflow::RegisterStartupAfterReboot(); @@ -712,6 +723,7 @@ namespace AppInstaller::CLI::Workflow m_stopOnFailure = WI_IsFlagSet(flags, Flags::StopOnFailure); m_refreshPathVariable = WI_IsFlagSet(flags, Flags::RefreshPathVariable); m_downloadOnly = WI_IsFlagSet(flags, Flags::DownloadOnly); + m_dependenciesOnly = WI_IsFlagSet(flags, Flags::DependenciesOnly); } void ProcessMultiplePackages::operator()(Execution::Context& context) const @@ -763,6 +775,11 @@ namespace AppInstaller::CLI::Workflow size_t packagesCount = packageSubContexts.size(); size_t packagesProgress = 0; + if (m_dependenciesOnly) + { + context.Reporter.Info() << Resource::String::DependenciesOnlyMessage << std::endl; + } + for (auto& packageContext : packageSubContexts) { packagesProgress++; @@ -786,11 +803,14 @@ namespace AppInstaller::CLI::Workflow Workflow::ProcessMultiplePackages(m_dependenciesReportMessage, APPINSTALLER_CLI_ERROR_INSTALL_DEPENDENCIES, Flags::IgnoreDependencies | Flags::StopOnFailure | Flags::RefreshPathVariable); } - currentContext << Workflow::DownloadInstaller; - - if (!downloadInstallerOnly) + if (!m_dependenciesOnly) { - currentContext << Workflow::InstallPackageInstaller; + currentContext << Workflow::DownloadInstaller; + + if (!downloadInstallerOnly) + { + currentContext << Workflow::InstallPackageInstaller; + } } } catch (...) diff --git a/src/AppInstallerCLICore/Workflows/InstallFlow.h b/src/AppInstallerCLICore/Workflows/InstallFlow.h index b80c5d6654..2f2c056ab8 100644 --- a/src/AppInstallerCLICore/Workflows/InstallFlow.h +++ b/src/AppInstallerCLICore/Workflows/InstallFlow.h @@ -182,6 +182,7 @@ namespace AppInstaller::CLI::Workflow StopOnFailure = 0x04, RefreshPathVariable = 0x08, DownloadOnly = 0x10, + DependenciesOnly = 0x20, }; ProcessMultiplePackages( @@ -201,6 +202,7 @@ namespace AppInstaller::CLI::Workflow bool m_stopOnFailure; bool m_refreshPathVariable; bool m_downloadOnly; + bool m_dependenciesOnly; }; DEFINE_ENUM_FLAG_OPERATORS(ProcessMultiplePackages::Flags); diff --git a/src/AppInstallerCLIE2ETests/InstallCommand.cs b/src/AppInstallerCLIE2ETests/InstallCommand.cs index e5de1868e1..53921ec817 100644 --- a/src/AppInstallerCLIE2ETests/InstallCommand.cs +++ b/src/AppInstallerCLIE2ETests/InstallCommand.cs @@ -709,6 +709,30 @@ public void InstallWithPackageDependency_RefreshPathVariable() Assert.True(TestCommon.VerifyTestExeInstalledAndCleanup(testDir)); } + /// + /// Test install a package with a package dependency and specify dependencies only. + /// + [Test] + public void InstallWithPackageDependency_DependenciesOnly() + { + var testDir = TestCommon.GetRandomTestDir(); + string installDir = TestCommon.GetPortablePackagesDirectory(); + var installResult = TestCommon.RunAICLICommand("install", $"-q AppInstallerTest.PackageDependencyRequiresPathRefresh -q AppInstallerTest.TestExeInstaller -l {testDir} --dependencies-only"); + Assert.AreEqual(Constants.ErrorCode.S_OK, installResult.ExitCode); + Assert.True(installResult.StdOut.Contains("Installing dependencies only. The package itself will not be installed.")); + Assert.True(installResult.StdOut.Contains("Successfully installed")); + + // Portable package is used as a dependency. Ensure that it is installed and cleaned up successfully. + string portablePackageId, commandAlias, fileName, packageDirName, productCode; + portablePackageId = "AppInstallerTest.TestPortableExeWithCommand"; + packageDirName = productCode = portablePackageId + "_" + Constants.TestSourceIdentifier; + fileName = "AppInstallerTestExeInstaller.exe"; + commandAlias = "testCommand.exe"; + + TestCommon.VerifyPortablePackage(Path.Combine(installDir, packageDirName), commandAlias, fileName, productCode, true); + Assert.False(TestCommon.VerifyTestExeInstalledAndCleanup(testDir)); + } + /// /// Test install a package using a specific installer type. /// diff --git a/src/AppInstallerCLIPackage/Shared/Strings/en-us/winget.resw b/src/AppInstallerCLIPackage/Shared/Strings/en-us/winget.resw index 7ee024e54d..6cfee598b7 100644 --- a/src/AppInstallerCLIPackage/Shared/Strings/en-us/winget.resw +++ b/src/AppInstallerCLIPackage/Shared/Strings/en-us/winget.resw @@ -2024,6 +2024,12 @@ Please specify one of them using the --source option to proceed. Skips processing package dependencies and Windows features + + Installs only the dependencies of the package + + + Installing dependencies only. The package itself will not be installed. + Dependencies skipped. diff --git a/src/AppInstallerCLITests/InstallDependenciesFlow.cpp b/src/AppInstallerCLITests/InstallDependenciesFlow.cpp index c3f81912de..d566bb649d 100644 --- a/src/AppInstallerCLITests/InstallDependenciesFlow.cpp +++ b/src/AppInstallerCLITests/InstallDependenciesFlow.cpp @@ -232,6 +232,28 @@ TEST_CASE("InstallerWithDependencies_IgnoreDependenciesSetting", "[dependencies] REQUIRE_FALSE(installOutput.str().find("PreviewIIS") != std::string::npos); } +TEST_CASE("InstallerWithDependencies_DependenciesOnly", "[dependencies]") +{ + std::ostringstream installOutput; + TestContext context{ installOutput, std::cin }; + auto previousThreadGlobals = context.SetForCurrentThread(); + OverrideOpenDependencySource(context); + OverrideEnableWindowsFeaturesDependencies(context); + + context.Args.AddArg(Execution::Args::Type::Manifest, TestDataFile("Installer_Exe_Dependencies.yaml").GetPath().u8string()); + context.Args.AddArg(Execution::Args::Type::DependenciesOnly); + + InstallCommand install({}); + install.Execute(context); + INFO(installOutput.str()); + + // Dependencies should be reported and installed + REQUIRE(installOutput.str().find(Resource::LocString(Resource::String::PackageRequiresDependencies).get()) != std::string::npos); + REQUIRE(installOutput.str().find("PreviewIIS") != std::string::npos); + // DependenciesOnly message should be shown + REQUIRE(installOutput.str().find(Resource::LocString(Resource::String::DependenciesOnlyMessage).get()) != std::string::npos); +} + TEST_CASE("DependenciesMultideclaration_InstallerDependenciesPreference", "[dependencies]") { std::ostringstream installOutput;