Skip to content

Commit 823dcfd

Browse files
authored
Preserve overrides with export and import (#6118)
When a package is installed with --override or --custom arguments, those values are now preserved through the winget export / winget import roundtrip, allowing packages to be reinstalled with the same customizations automatically.
1 parent 89a2830 commit 823dcfd

17 files changed

Lines changed: 483 additions & 3 deletions

doc/ReleaseNotes.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ match criteria that factor into the result ordering. This will prevent them from
2828

2929
## Minor Features
3030

31+
### Preserve installer arguments across export and import
32+
33+
`winget export` now captures the `--override` and `--custom` arguments that were used when a package was originally installed and saves them into the export file. When subsequently running `winget import`, those values are automatically re-applied during installation — `--override` replaces all installer arguments and `--custom` appends extra switches — so packages can be reinstalled with the same customizations without any manual intervention. Both fields are optional and independent of each other; packages without stored installer arguments are unaffected.
34+
3135
### --no-progress flag
3236

3337
Added a new `--no-progress` command-line flag that disables all progress reporting (progress bars and spinners). This flag is universally available on all commands and takes precedence over the `visual.progressBar` setting. Useful for automation scenarios or when running WinGet in environments where progress output is undesirable.

schemas/JSON/packages/packages.schema.2.0.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,16 @@
9696
"machine"
9797
],
9898
"default": "user"
99+
},
100+
101+
"InitialOverrideArguments": {
102+
"description": "Override arguments used when the package was initially installed; preserved on upgrade",
103+
"type": "string"
104+
},
105+
106+
"InitialCustomSwitches": {
107+
"description": "Additional custom switches used when the package was initially installed; preserved on upgrade",
108+
"type": "string"
99109
}
100110
}
101111
}

src/AppInstallerCLICore/PackageCollection.cpp

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ namespace AppInstaller::CLI
4141
const std::string PackagesJson_Package_Version = "Version";
4242
const std::string PackagesJson_Package_Channel = "Channel";
4343
const std::string PackagesJson_Package_Scope = "Scope";
44+
const std::string PackagesJson_Package_InitialOverrideArguments = "InitialOverrideArguments";
45+
const std::string PackagesJson_Package_InitialCustomSwitches = "InitialCustomSwitches";
4446

4547
static const StaticStrings& Instance()
4648
{
@@ -154,6 +156,16 @@ namespace AppInstaller::CLI
154156
PackageCollection::Package package{ Utility::LocIndString{ id }, Utility::Version{ version }, Utility::Channel{ channel } };
155157
package.Scope = Manifest::ConvertToScopeEnum(scope);
156158

159+
if (packageNode.isMember(ss.PackagesJson_Package_InitialOverrideArguments))
160+
{
161+
package.InitialOverrideArgs = packageNode[ss.PackagesJson_Package_InitialOverrideArguments].asString();
162+
}
163+
164+
if (packageNode.isMember(ss.PackagesJson_Package_InitialCustomSwitches))
165+
{
166+
package.InitialCustomSwitches = packageNode[ss.PackagesJson_Package_InitialCustomSwitches].asString();
167+
}
168+
157169
return package;
158170
}
159171
};
@@ -202,6 +214,16 @@ namespace AppInstaller::CLI
202214
packageNode[ss.PackagesJson_Package_Scope] = std::string{ Manifest::ScopeToString(package.Scope) };
203215
}
204216

217+
if (!package.InitialOverrideArgs.empty())
218+
{
219+
packageNode[ss.PackagesJson_Package_InitialOverrideArguments] = package.InitialOverrideArgs;
220+
}
221+
222+
if (!package.InitialCustomSwitches.empty())
223+
{
224+
packageNode[ss.PackagesJson_Package_InitialCustomSwitches] = package.InitialCustomSwitches;
225+
}
226+
205227
return sourceNode[ss.PackagesJson_Packages].append(std::move(packageNode));
206228
}
207229

src/AppInstallerCLICore/PackageCollection.h

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,10 @@ namespace AppInstaller::CLI
2828

2929
Utility::LocIndString Id;
3030
Utility::VersionAndChannel VersionAndChannel;
31-
Manifest::ScopeEnum Scope = Manifest::ScopeEnum::Unknown;
31+
Manifest::ScopeEnum Scope = Manifest::ScopeEnum::Unknown;
3232
std::filesystem::path InstalledLocation;
33+
std::string InitialOverrideArgs;
34+
std::string InitialCustomSwitches;
3335
};
3436

3537
// A source along with a set of packages available from it.

src/AppInstallerCLICore/Workflows/ImportExportFlow.cpp

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,27 @@ namespace AppInstaller::CLI::Workflow
140140
// but take the exported version from the installed package if needed.
141141
PackageCollection::Package exportPackage;
142142
exportPackage.Id = availablePackageVersion->GetProperty(PackageVersionProperty::Id);
143-
exportPackage.InstalledLocation = Utility::ConvertToUTF16(installedPackageVersion->GetMetadata()[PackageVersionMetadata::InstalledLocation]);
143+
144+
const auto& installedMetadata = installedPackageVersion->GetMetadata();
145+
146+
auto locationItr = installedMetadata.find(PackageVersionMetadata::InstalledLocation);
147+
if (locationItr != installedMetadata.end())
148+
{
149+
exportPackage.InstalledLocation = Utility::ConvertToUTF16(locationItr->second);
150+
}
151+
152+
auto overrideItr = installedMetadata.find(PackageVersionMetadata::InitialOverrideArguments);
153+
if (overrideItr != installedMetadata.end())
154+
{
155+
exportPackage.InitialOverrideArgs = overrideItr->second;
156+
}
157+
158+
auto customItr = installedMetadata.find(PackageVersionMetadata::InitialCustomSwitches);
159+
if (customItr != installedMetadata.end())
160+
{
161+
exportPackage.InitialCustomSwitches = customItr->second;
162+
}
163+
144164
if (includeVersions)
145165
{
146166
exportPackage.VersionAndChannel = { version.get(), channel.get() };
@@ -298,6 +318,16 @@ namespace AppInstaller::CLI::Workflow
298318
searchContext.Args.AddArg(Execution::Args::Type::Channel, channelString);
299319
}
300320

321+
if (!packageRequest.InitialOverrideArgs.empty())
322+
{
323+
searchContext.Args.AddArg(Execution::Args::Type::Override, packageRequest.InitialOverrideArgs);
324+
}
325+
326+
if (!packageRequest.InitialCustomSwitches.empty())
327+
{
328+
searchContext.Args.AddArg(Execution::Args::Type::CustomSwitches, packageRequest.InitialCustomSwitches);
329+
}
330+
301331
packageSubContexts.emplace_back(std::move(searchContextPtr));
302332
}
303333
}

src/AppInstallerCLICore/Workflows/InstallFlow.cpp

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -996,6 +996,8 @@ namespace AppInstaller::CLI::Workflow
996996
installedMetadata = context.Get<Data::InstalledPackageVersion>()->GetMetadata();
997997
}
998998

999+
bool isUpdate = WI_IsFlagSet(context.GetFlags(), ContextFlag::InstallerExecutionUseUpdate);
1000+
9991001
if (context.Args.Contains(Execution::Args::Type::InstallArchitecture))
10001002
{
10011003
version.SetMetadata(Repository::PackageVersionMetadata::UserIntentArchitecture, context.Args.GetArg(Execution::Args::Type::InstallArchitecture));
@@ -1021,5 +1023,34 @@ namespace AppInstaller::CLI::Workflow
10211023
version.SetMetadata(Repository::PackageVersionMetadata::UserIntentLocale, itr->second);
10221024
}
10231025
}
1026+
1027+
// InitialOverrideArguments and InitialCustomSwitches capture the args from the original install.
1028+
// They are set only on fresh install and preserved (not updated) on upgrade.
1029+
if (!isUpdate)
1030+
{
1031+
if (context.Args.Contains(Execution::Args::Type::Override))
1032+
{
1033+
version.SetMetadata(Repository::PackageVersionMetadata::InitialOverrideArguments, context.Args.GetArg(Execution::Args::Type::Override));
1034+
}
1035+
1036+
if (context.Args.Contains(Execution::Args::Type::CustomSwitches))
1037+
{
1038+
version.SetMetadata(Repository::PackageVersionMetadata::InitialCustomSwitches, context.Args.GetArg(Execution::Args::Type::CustomSwitches));
1039+
}
1040+
}
1041+
else
1042+
{
1043+
auto overrideItr = installedMetadata.find(Repository::PackageVersionMetadata::InitialOverrideArguments);
1044+
if (overrideItr != installedMetadata.end())
1045+
{
1046+
version.SetMetadata(Repository::PackageVersionMetadata::InitialOverrideArguments, overrideItr->second);
1047+
}
1048+
1049+
auto customItr = installedMetadata.find(Repository::PackageVersionMetadata::InitialCustomSwitches);
1050+
if (customItr != installedMetadata.end())
1051+
{
1052+
version.SetMetadata(Repository::PackageVersionMetadata::InitialCustomSwitches, customItr->second);
1053+
}
1054+
}
10241055
}
10251056
}

src/AppInstallerCLITests/AppInstallerCLITests.vcxproj

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -441,6 +441,12 @@
441441
<CopyFileToFolders Include="TestData\ImportFile-Good-WithLicenseAgreement.json">
442442
<DeploymentContent>true</DeploymentContent>
443443
</CopyFileToFolders>
444+
<CopyFileToFolders Include="TestData\ImportFile-Good-WithOverrideArgs.json">
445+
<DeploymentContent>true</DeploymentContent>
446+
</CopyFileToFolders>
447+
<CopyFileToFolders Include="TestData\ImportFile-Good-WithCustomSwitches.json">
448+
<DeploymentContent>true</DeploymentContent>
449+
</CopyFileToFolders>
444450
<None Include="packages.config" />
445451
<None Include="PropertySheet.props" />
446452
<CopyFileToFolders Include="TestData\InstallerArgTest_Inno_NoSwitches.yaml">

src/AppInstallerCLITests/AppInstallerCLITests.vcxproj.filters

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -798,6 +798,12 @@
798798
<CopyFileToFolders Include="TestData\ImportFile-Good-WithLicenseAgreement.json">
799799
<Filter>TestData</Filter>
800800
</CopyFileToFolders>
801+
<CopyFileToFolders Include="TestData\ImportFile-Good-WithOverrideArgs.json">
802+
<Filter>TestData</Filter>
803+
</CopyFileToFolders>
804+
<CopyFileToFolders Include="TestData\ImportFile-Good-WithCustomSwitches.json">
805+
<Filter>TestData</Filter>
806+
</CopyFileToFolders>
801807
<CopyFileToFolders Include="TestData\ImportFile-Good-Dependencies.json">
802808
<Filter>TestData</Filter>
803809
</CopyFileToFolders>

src/AppInstallerCLITests/CompositeSource.cpp

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1115,6 +1115,61 @@ TEST_CASE("CompositeSource_TrackingPackageFound_MetadataPopulatedFromTracking",
11151115
REQUIRE(metadata[Repository::PackageVersionMetadata::PinnedState] == "PinnedByManifest");
11161116
}
11171117

1118+
TEST_CASE("CompositeSource_TrackingPackageFound_UserInstallerArgsPopulatedFromTracking", "[CompositeSource]")
1119+
{
1120+
std::string availableID = "Available.ID";
1121+
std::string pfn = "sortof_apfn";
1122+
1123+
CompositeWithTrackingTestSetup setup;
1124+
auto installedPackage = setup.MakeInstalled().WithPFN(pfn);
1125+
auto availablePackage = setup.MakeAvailable().WithPFN(pfn).WithId(availableID).WithDefaultName(s_Everything_Query);
1126+
1127+
setup.Installed->Everything.Matches.emplace_back(installedPackage, Criteria());
1128+
setup.Installed->SearchFunction = [&](const SearchRequest& request)
1129+
{
1130+
RequireSearchRequestIncludes(request.Inclusions, PackageMatchField::PackageFamilyName, MatchType::Exact, pfn);
1131+
1132+
SearchResult result;
1133+
result.Matches.emplace_back(installedPackage, Criteria());
1134+
return result;
1135+
};
1136+
1137+
setup.Available->Everything.Matches.emplace_back(availablePackage, Criteria());
1138+
setup.Available->SearchFunction = [&](const SearchRequest& request)
1139+
{
1140+
if (request.Filters.empty())
1141+
{
1142+
RequireSearchRequestIncludes(request.Inclusions, PackageMatchField::PackageFamilyName, MatchType::Exact, pfn);
1143+
}
1144+
else
1145+
{
1146+
REQUIRE(request.Filters.size() == 1);
1147+
RequireSearchRequestIncludes(request.Filters, PackageMatchField::Id, MatchType::CaseInsensitive, availableID);
1148+
}
1149+
1150+
SearchResult result;
1151+
result.Matches.emplace_back(availablePackage, Criteria());
1152+
return result;
1153+
};
1154+
1155+
auto manifestId = setup.Tracking->GetIndex().AddManifest(availablePackage);
1156+
1157+
// InitialOverrideArguments and InitialCustomSwitches are only stored in the tracking catalog,
1158+
// so they must be merged from there into the composite installed version's metadata.
1159+
setup.Tracking->GetIndex().SetMetadataByManifestId(manifestId, Repository::PackageVersionMetadata::InitialOverrideArguments, "/silent /norestart");
1160+
setup.Tracking->GetIndex().SetMetadataByManifestId(manifestId, Repository::PackageVersionMetadata::InitialCustomSwitches, "--no-telemetry");
1161+
1162+
SearchResult result = setup.Search();
1163+
1164+
REQUIRE(result.Matches.size() == 1);
1165+
REQUIRE(result.Matches[0].Package);
1166+
REQUIRE(GetInstalledVersion(result.Matches[0].Package));
1167+
1168+
auto metadata = GetInstalledVersion(result.Matches[0].Package)->GetMetadata();
1169+
REQUIRE(metadata[Repository::PackageVersionMetadata::InitialOverrideArguments] == "/silent /norestart");
1170+
REQUIRE(metadata[Repository::PackageVersionMetadata::InitialCustomSwitches] == "--no-telemetry");
1171+
}
1172+
11181173
TEST_CASE("CompositeSource_TrackingFound_AvailableNot", "[CompositeSource]")
11191174
{
11201175
std::string availableID = "Available.ID";

src/AppInstallerCLITests/ExportFlow.cpp

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,15 @@
22
// Licensed under the MIT License.
33
#include "pch.h"
44
#include "WorkflowCommon.h"
5+
#include "TestSource.h"
56
#include <Commands/ExportCommand.h>
7+
#include <winget/ManifestYamlParser.h>
68

79
using namespace TestCommon;
810
using namespace AppInstaller::CLI;
11+
using namespace AppInstaller::Repository;
12+
using namespace AppInstaller::Manifest;
13+
using namespace AppInstaller::Manifest::YamlParser;
914

1015
TEST_CASE("ExportFlow_ExportAll", "[ExportFlow][workflow]")
1116
{
@@ -115,3 +120,72 @@ TEST_CASE("ExportFlow_ExportAll_WithVersions", "[ExportFlow][workflow]")
115120
return p.Id == "AppInstallerCliTest.TestExeUnknownVersion" && p.VersionAndChannel.GetVersion().ToString() == "unknown";
116121
}));
117122
}
123+
124+
TEST_CASE("ExportFlow_ExportAll_WithUserInstallerArgs", "[ExportFlow][workflow]")
125+
{
126+
TestCommon::TempFile exportResultPath("TestExport.json");
127+
128+
std::ostringstream exportOutput;
129+
TestContext context{ exportOutput, std::cin };
130+
auto previousThreadGlobals = context.SetForCurrentThread();
131+
132+
// Create a test source with packages that have InitialOverrideArguments and InitialCustomSwitches set
133+
auto testSource = CreateTestSource({});
134+
135+
TestSourceResult exeWithOverride(
136+
"AppInstallerCliTest.TestExeInstaller"sv,
137+
[](std::vector<ResultMatch>& matches, std::weak_ptr<const ISource> source)
138+
{
139+
auto manifest = YamlParser::CreateFromPath(TestDataFile("InstallFlowTest_Exe.yaml"));
140+
auto manifest2 = YamlParser::CreateFromPath(TestDataFile("UpdateFlowTest_Exe.yaml"));
141+
auto manifest3 = YamlParser::CreateFromPath(TestDataFile("UpdateFlowTest_Exe_2.yaml"));
142+
143+
auto testPackage = TestCompositePackage::Make(
144+
manifest,
145+
TestCompositePackage::MetadataMap
146+
{
147+
{ PackageVersionMetadata::InstalledType, "Exe" },
148+
{ PackageVersionMetadata::InitialOverrideArguments, "/silent /override" },
149+
{ PackageVersionMetadata::InitialCustomSwitches, "--custom-flag" },
150+
},
151+
std::vector<Manifest>{ manifest3, manifest2, manifest },
152+
source);
153+
for (auto& availablePackage : testPackage->Available)
154+
{
155+
availablePackage->IsSameOverride = [](const IPackage*, const IPackage*) { return true; };
156+
}
157+
matches.emplace_back(
158+
ResultMatch(
159+
testPackage,
160+
PackageMatchFilter(PackageMatchField::Id, MatchType::Exact, "AppInstallerCliTest.TestExeInstaller")));
161+
});
162+
163+
testSource->AddResult(exeWithOverride);
164+
165+
OverrideForCompositeInstalledSource(context, testSource);
166+
context.Args.AddArg(Execution::Args::Type::OutputFile, exportResultPath);
167+
168+
ExportCommand exportCommand({});
169+
exportCommand.Execute(context);
170+
INFO(exportOutput.str());
171+
172+
const auto& exportedCollection = context.Get<Execution::Data::PackageCollection>();
173+
REQUIRE(exportedCollection.Sources.size() == 1);
174+
175+
const auto& exportedPackages = exportedCollection.Sources[0].Packages;
176+
REQUIRE(exportedPackages.size() == 1);
177+
178+
const auto& pkg = exportedPackages[0];
179+
REQUIRE(pkg.Id == "AppInstallerCliTest.TestExeInstaller");
180+
REQUIRE(pkg.InitialOverrideArgs == "/silent /override");
181+
REQUIRE(pkg.InitialCustomSwitches == "--custom-flag");
182+
183+
// Verify the values are in the exported JSON file
184+
std::ifstream exportFile(exportResultPath.GetPath());
185+
Json::Value exportedJson;
186+
exportFile >> exportedJson;
187+
188+
const auto& jsonPackage = exportedJson["Sources"][0]["Packages"][0];
189+
REQUIRE(jsonPackage["InitialOverrideArguments"].asString() == "/silent /override");
190+
REQUIRE(jsonPackage["InitialCustomSwitches"].asString() == "--custom-flag");
191+
}

0 commit comments

Comments
 (0)