refactor(scripts): optimize system remediation and M365 export scripts

- teams: harden Classic Teams removal, protect New Teams, and clean HKU run keys
- dotnet: add bootstrapper EXE uninstall support and remove orphaned 3.1.* folders
- teams: add MSIX package targeting and version parsing for vulnerable New Teams builds
- m365: improve Graph SDK export speed, reuse sessions, and add null-safe handling
This commit is contained in:
mattspeer committed 2026-10-04 10:58:05 -05:00
1 parent 4fd95b76d6
commit 5ef82842f2
19 files changed
+1651 -1268

No files matched your search

+296
View File
@@ -0,0 +1,296 @@
<#
.SYNOPSIS
Generates interactive HTML and plain-text directory tree reports.
.DESCRIPTION
Recursively scans a target directory and produces two report files:
1. A plain-text ASCII directory tree file (.txt).
2. An interactive HTML tree file (.html) with search filtering and collapse/expand controls.
.PARAMETER RootPath
The target root folder path to scan.
.PARAMETER OutputDirectory
The destination directory where reports will be saved. Defaults to the current working directory.
.PARAMETER ReportNamePrefix
Optional custom prefix for generated filenames. Defaults to the sanitized root folder name.
.PARAMETER OpenInBrowser
Switch parameter to automatically open the generated HTML report upon completion.
.EXAMPLE
.\Export-DirectoryTree.ps1 -RootPath "E:\Sync Method\Corporate\Documents" -OutputDirectory "C:\Reports" -OpenInBrowser
.NOTES
Created: 2025-03-02
Modified: 2026-10-04
#>
[CmdletBinding()]
param (
[Parameter(Mandatory = $true, Position = 0, ValueFromPipeline =$true)]
[ValidateNotNullOrEmpty()]
[string]$RootPath,
[Parameter(Mandatory = $false, Position = 1)] [string]$OutputDirectory = (Get-Location).Path,
[Parameter(Mandatory = $false)]
[string]$ReportNamePrefix,
[switch]$OpenInBrowser
)
# 1. Validate & Resolve Paths
if (-not (Test-Path -LiteralPath $RootPath -PathType Container)) {
Write-Error "Target root directory not found or unreadable: '$RootPath'"
return
}
$ResolvedRoot = (Get-Item -LiteralPath$RootPath).FullName
$RootLeafName = Split-Path -Path$ResolvedRoot -Leaf
# Auto-generate report prefix if not explicitly provided
if ([string]::IsNullOrWhiteSpace($ReportNamePrefix)) {
$ReportNamePrefix =$RootLeafName -replace '[\/\\:\*\?"<>\|]', '-'
}
# Ensure output directory exists
if (-not (Test-Path -LiteralPath $OutputDirectory)) {
New-Item -ItemType Directory -Path $OutputDirectory -Force | Out-Null
}
$TextFile = Join-Path -Path $OutputDirectory -ChildPath "$ReportNamePrefix-tree.txt"
$HtmlFile = Join-Path -Path $OutputDirectory -ChildPath "$ReportNamePrefix-tree.html"
# 2. State Tracking Setup
$script:textLines = [System.Collections.Generic.List[string]]::new()
$script:html = [System.Text.StringBuilder]::new()$script:scanErrors = 0
$script:totalFiles = 0$script:totalFolders = 0
function ConvertTo-HtmlEncoded {
param([string]$Text)
return [System.Net.WebUtility]::HtmlEncode($Text)
}
function Add-TreeItems {
param(
[string]$Path,
[string]$Prefix
)
try {
$items = @(
Get-ChildItem -LiteralPath $Path -Force -ErrorAction Stop |
Sort-Object @{ Expression = { if ($_.PSIsContainer) { 0 } else { 1 } } }, Name
)
}
catch {
$script:scanErrors++
[void]$script:textLines.Add("$Prefix[Unable to read: $($_.Exception.Message)]")
[void]$script:html.AppendLine(
"<li class='error'>Unable to read: $(ConvertTo-HtmlEncoded$_.Exception.Message)</li>"
)
return
}
for ($index = 0; $index -lt $items.Count; $index++) {
$item =$items[$index]$isLast = $index -eq ($items.Count - 1)
$connector = if ($isLast) { '\-- ' } else { '|-- ' }
$nextPrefix =$Prefix + $(if ($isLast) { ' ' } else { '| ' })
$nameHtml = ConvertTo-HtmlEncoded$item.Name
$pathHtml = ConvertTo-HtmlEncoded$item.FullName
$isLink = -not [string]::IsNullOrWhiteSpace([string]$item.LinkType)
if ($item.PSIsContainer -and -not $isLink) {$script:totalFolders++
[void]$script:textLines.Add("$Prefix$connector$($item.Name)")
[void]$script:html.AppendLine(
"<li><details data-path='$pathHtml'><summary>📁 $nameHtml</summary><ul>"
)
# Recurse subdirectories
Add-TreeItems -Path $item.FullName -Prefix$nextPrefix
[void]$script:html.AppendLine('</ul></details></li>')
}
elseif ($item.PSIsContainer -and $isLink) {$script:totalFolders++
$target = @($item.Target) -join ', '
[void]$script:textLines.Add("$Prefix$connector$($item.Name) [Link → $target]")
[void]$script:html.AppendLine(
"<li class='link' data-path='$pathHtml'>🔗 $nameHtml <small>→ $(ConvertTo-HtmlEncoded$target)</small></li>"
)
}
else {
$script:totalFiles++
[void]$script:textLines.Add("$Prefix$connector$($item.Name)")
[void]$script:html.AppendLine(
"<li class='file' data-path='$pathHtml'>📄 $nameHtml</li>"
)
}
}
}
# 3. Document Building
Write-Host "Scanning directory structure: $ResolvedRoot" -ForegroundColor Cyan
$rootNameHtml = ConvertTo-HtmlEncoded $RootLeafName$rootPathHtml = ConvertTo-HtmlEncoded $ResolvedRoot$generated = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
[void]$script:textLines.Add($ResolvedRoot)
[void]$script:html.AppendLine(@"
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Directory Tree - $rootNameHtml</title>
<style>
:root {
--bg-color: #ffffff;
--text-color: #1f2328;
--border-color: #d0d7de;
--link-color: #8250df;
--error-color: #cf222e;
--subtext-color: #57606a;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
margin: 24px;
color: var(--text-color);
background: var(--bg-color);
}
h1 { margin-bottom: 6px; font-size: 1.5rem; }
.meta { color: var(--subtext-color); margin-bottom: 18px; font-size: 0.9rem; line-height: 1.4; }
.controls {
position: sticky;
top: 0;
background: var(--bg-color);
padding: 10px 0;
border-bottom: 1px solid var(--border-color);
z-index: 10;
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
input[type="search"] {
width: min(500px, 60vw);
padding: 6px 10px;
font-size: 14px;
border: 1px solid var(--border-color);
border-radius: 6px;
}
button {
padding: 6px 12px;
font-size: 14px;
background-color: #f6f8fa;
border: 1px solid var(--border-color);
border-radius: 6px;
cursor: pointer;
}
button:hover { background-color: #f3f4f6; }
ul {
list-style: none;
margin: 4px 0 4px 16px;
padding-left: 12px;
border-left: 1px solid #d8dee4;
}
.tree { border-left: 0; margin-left: 0; padding-left: 0; }
summary { cursor: pointer; padding: 3px; font-weight: 500; }
summary:hover { background-color: #f6f8fa; border-radius: 4px; }
.file, .link { padding: 3px; }
.link { color: var(--link-color); }
.error { color: var(--error-color); }
small { color: var(--subtext-color); }
.hidden { display: none !important; }
code { background: #f6f8fa; padding: 2px 6px; border-radius: 4px; font-size: 0.85em; }
</style>
</head>
<body>
<h1>Directory Tree Report</h1>
<div class="meta">
<strong>Root:</strong> <code>$rootPathHtml</code><br>
<strong>Generated:</strong> $generated
</div>
<div class="controls">
<input id="search" type="search" placeholder="Search files and folders...">
<button type="button" onclick="setAll(true)">Expand All</button>
<button type="button" onclick="setAll(false)">Collapse All</button>
</div>
<ul class="tree">
<li>
<details open data-path="$rootPathHtml">
<summary>📁 $rootNameHtml</summary>
<ul>
"@)
# Run recursive scan
Add-TreeItems -Path $ResolvedRoot -Prefix ''
[void]$script:html.AppendLine(@"
</ul>
</details>
</li>
</ul>
<script>
function setAll(state) {
document.querySelectorAll('details').forEach(function (item) {
item.open = state;
});
}
document.getElementById('search').addEventListener('input', function () {
const term = this.value.trim().toLowerCase();
const nodes = Array.from(document.querySelectorAll('[data-path]'));
nodes.forEach(function (node) {
node.classList.remove('hidden');
});
if (!term) return;
nodes.forEach(function (node) {
if (!node.dataset.path.toLowerCase().includes(term)) {
node.classList.add('hidden');
}
});
nodes.filter(function (node) {
return node.dataset.path.toLowerCase().includes(term);
}).forEach(function (node) {
node.classList.remove('hidden');
let parent = node.parentElement;
while (parent) {
if (parent.tagName === 'DETAILS') {
parent.open = true;
parent.classList.remove('hidden');
}
if (parent.tagName === 'LI') {
parent.classList.remove('hidden');
}
parent = parent.parentElement;
}
});
});
</script>
</body>
</html>
"@)
# 4. Save Output Files
$script:textLines \vert{} Set-Content -LiteralPath$TextFile -Encoding UTF8
$script:html.ToString() \vert{} Set-Content -LiteralPath$HtmlFile -Encoding UTF8
# Output Summary
Write-Host "`nScan Completed Successfully:" -ForegroundColor Green
Write-Host " Folders Scanned : $script:totalFolders"
Write-Host " Files Cataloged : $script:totalFiles"
Write-Host " Unreadable Errors: $script:scanErrors"
Write-Host " Text Report : $TextFile"
Write-Host " HTML Report : $HtmlFile`n"
if ($OpenInBrowser) {
Start-Process $HtmlFile
}