Removing Fields From a SharePoint .wsp Package Without Visual Studio
The problem
A SharePoint site template (.wsp) sometimes ships with field definitions you never asked for. Disabling a feature doesn't clean up after itself — the fields it provisioned stay behind on every site where they were added. They just sit there, inert, and get carried along the next time you save that site as a .wsp template.
That's exactly what happened with PerformancePoint. It isn't available in SharePoint Subscription Edition, so if your on-prem farm ever had the feature enabled, the PerformancePoint field definitions ride along in the .wsp even after you turn the feature off. Try to create a new site from that template on SE, and it fails — the feature that owns those fields doesn't exist there.
The fix is to strip those field definitions out of the .wsp before migrating. There's no "Remove Field" button for this — a .wsp is a CAB archive, and the field definitions live as plain <Field ... /> elements scattered across whatever XML files the solution ships. Doing this by hand in Visual Studio, or worse, manually with expand.exe and makecab.exe, gets old fast if you have to repeat it across several templates.
So I wrote a script to do the whole round trip: extract, strip, repackage.
What the script does
Remove-WspFields.ps1 takes a .wsp path and a GroupName, and does four things:
- Extracts the CAB with
expand.exe, preserving the internal folder structure exactly as SharePoint expects it. - Scans every
.xmlfile in the extracted tree for lines containingGroup="<GroupName>", and removes those lines. - Rebuilds the CAB with
makecab.exe, using a generated.ddffile so every extracted file goes back to its original internal path. - Writes the result, backing up the original
.wspfirst (unless you pass-NoBackup).
If any matching line isn't a self-contained, single-line <Field ... /> element, the script stops and throws instead of guessing — it won't handle multi-line field definitions automatically, but it also won't quietly corrupt one.
The CAB rebuild goes through a generated .ddf file listing every extracted file next to its original internal path, handed to makecab.exe. Keeping that path mapping exact — same relative paths as the original extraction — is what keeps the rebuilt .wsp's internal structure identical to what SharePoint expects.
.\Remove-WspFields.ps1 -WspPath .\Solution.wsp
By default it targets the PerformancePoint group and overwrites the input file in place, leaving a .bak copy next to it. Both are overridable:
.\Remove-WspFields.ps1 `
-WspPath .\template.wsp `
-GroupName "PerformancePoint" `
-OutputPath .\template-clean.wsp `
-KeepExtracted
-KeepExtracted skips the cleanup step so you can inspect the extracted XML afterward — useful the first time you run it against a new template, before you trust it.
The script
<#
.SYNOPSIS
Removes Field definitions matching a given Group attribute from a SharePoint
.wsp (CAB) site template and rebuilds the package.
.DESCRIPTION
1. Extracts the .wsp with expand.exe, preserving the internal folder structure.
2. Removes every single-line <Field ... Group="<GroupName>" ... /> entry from
any XML file that contains it.
3. Rebuilds the CAB with makecab.exe using the same internal paths.
4. Writes the result to -OutputPath (default: overwrites the input .wsp;
a .bak copy of the original is kept next to it unless -NoBackup).
.EXAMPLE
.\Remove-WspFields.ps1 -WspPath .\Solution.wsp
.EXAMPLE
.\Remove-WspFields.ps1 -WspPath .\template.wsp -GroupName "PerformancePoint" -OutputPath .\template-clean.wsp -KeepExtracted
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$WspPath,
[string]$GroupName = 'PerformancePoint',
[string]$OutputPath,
[switch]$NoBackup,
# Keep the extracted working folder next to the wsp instead of deleting it
[switch]$KeepExtracted
)
$ErrorActionPreference = 'Stop'
$WspPath = (Resolve-Path $WspPath).Path
if (-not $OutputPath) { $OutputPath = $WspPath }
$wspName = [IO.Path]::GetFileNameWithoutExtension($WspPath)
# --- 1. Extract -------------------------------------------------------------
$extractDir = Join-Path ([IO.Path]::GetDirectoryName($WspPath)) ("_extract_" + $wspName)
if (Test-Path $extractDir) { Remove-Item $extractDir -Recurse -Force }
New-Item -ItemType Directory -Path $extractDir | Out-Null
Write-Host "Extracting '$WspPath' -> '$extractDir'"
& "$env:SystemRoot\System32\expand.exe" -F:* $WspPath $extractDir | Out-Null
if ($LASTEXITCODE -ne 0) { throw "expand.exe failed with exit code $LASTEXITCODE" }
# --- 2. Remove matching Field lines ----------------------------------------
$pattern = "Group=`"$GroupName`""
$totalRemoved = 0
Get-ChildItem $extractDir -Recurse -Filter *.xml | ForEach-Object {
$lines = [IO.File]::ReadAllLines($_.FullName)
$hits = @($lines | Where-Object { $_ -match [regex]::Escape($pattern) })
if ($hits.Count -eq 0) { return }
# Safety: only handle self-contained single-line <Field ... /> entries
$bad = @($hits | Where-Object { $_ -notmatch '<Field\b.*/>\s*$' })
if ($bad.Count -gt 0) {
throw "File '$($_.FullName)' contains $pattern on a line that is not a single-line <Field ... /> - manual review needed."
}
$kept = $lines | Where-Object { $_ -notmatch [regex]::Escape($pattern) }
# Preserve UTF-8 BOM if the original had one
$origBytes = [IO.File]::ReadAllBytes($_.FullName)
$hasBom = $origBytes.Length -ge 3 -and $origBytes[0] -eq 0xEF -and $origBytes[1] -eq 0xBB -and $origBytes[2] -eq 0xBF
$enc = New-Object System.Text.UTF8Encoding($hasBom)
[IO.File]::WriteAllLines($_.FullName, $kept, $enc)
Write-Host (" {0}: removed {1} field(s)" -f $_.FullName.Substring($extractDir.Length + 1), $hits.Count)
$script:totalRemoved += $hits.Count
}
Write-Host "Total fields removed: $totalRemoved"
# --- 3. Rebuild CAB ---------------------------------------------------------
$cabName = "$wspName.cab"
$buildDir = Join-Path $extractDir '_cabout'
New-Item -ItemType Directory -Path $buildDir | Out-Null
$ddf = New-Object System.Collections.Generic.List[string]
$ddf.Add('.OPTION EXPLICIT')
$ddf.Add(".Set CabinetNameTemplate=$cabName")
$ddf.Add(".Set DiskDirectory1=$buildDir")
$ddf.Add('.Set CompressionType=MSZIP')
$ddf.Add('.Set Cabinet=ON')
$ddf.Add('.Set Compress=ON')
$ddf.Add('.Set UniqueFiles=OFF')
$ddf.Add('.Set MaxDiskSize=0')
$ddf.Add('.Set MaxCabinetSize=0')
$ddf.Add('.Set FolderSizeThreshold=0')
Get-ChildItem $extractDir -Recurse -File |
Where-Object { $_.FullName -notlike "$buildDir*" } |
ForEach-Object {
$internal = $_.FullName.Substring($extractDir.Length + 1)
$ddf.Add("`"$($_.FullName)`" `"$internal`"")
}
$ddfPath = Join-Path $extractDir 'build.ddf'
[IO.File]::WriteAllLines($ddfPath, $ddf, (New-Object System.Text.UTF8Encoding($false)))
Write-Host "Building CAB..."
Push-Location $extractDir
try {
& "$env:SystemRoot\System32\makecab.exe" /F $ddfPath | Out-Null
if ($LASTEXITCODE -ne 0) { throw "makecab.exe failed with exit code $LASTEXITCODE" }
}
finally { Pop-Location }
$builtCab = Join-Path $buildDir $cabName
if (-not (Test-Path $builtCab)) { throw "Expected CAB not found: $builtCab" }
# --- 4. Deliver output ------------------------------------------------------
if ((Test-Path $OutputPath) -and -not $NoBackup) {
Copy-Item $OutputPath "$OutputPath.bak" -Force
Write-Host "Backup written: $OutputPath.bak"
}
Move-Item $builtCab $OutputPath -Force
Write-Host "Output written: $OutputPath"
if (-not $KeepExtracted) {
Remove-Item $extractDir -Recurse -Force
} else {
Write-Host "Extracted files kept in: $extractDir"
}
GroupName isn't hardcoded to PerformancePoint — pass any group name and the script strips that instead, so it doubles as a general-purpose tool for retiring any other deprecated feature's field leftovers before a migration.
Small tool, but it turned a "reopen Visual Studio, find the fields, delete them, redeploy the solution" chore into a one-line command.