Skip to content

Add on-demand orphaned installer patch cleanup with safety quarantine#44

Open
AlrightLad wants to merge 5 commits intoDTC-Inc:mainfrom
AlrightLad:patch-8
Open

Add on-demand orphaned installer patch cleanup with safety quarantine#44
AlrightLad wants to merge 5 commits intoDTC-Inc:mainfrom
AlrightLad:patch-8

Conversation

@AlrightLad
Copy link

@AlrightLad AlrightLad commented Feb 19, 2026

Adds Cleanup-InstallerPatches.ps1 — an on-demand NinjaRMM remediation script that removes orphaned .msi/.msp files from C:\Windows\Installer identified by the same registry-based orphan detection used in the monitor script. Designed to be triggered manually by a technician or automatically via NinjaRMM condition when the monitor reports Warning or Critical status.

Cleanup operates in five phases:

Phase 0 — Pre-flight: Checks C: drive free space. If disk is below 10% free, automatically switches from quarantine mode to direct deletion since quarantining would consume additional disk space on an already-full drive. Phase 1 — DISM Component Cleanup: Runs DISM /Online /Cleanup-Image /StartComponentCleanup to reclaim space from superseded Windows components. Deliberately avoids /ResetBase which would prevent future update uninstalls — too destructive for unattended automation. Skippable with -SkipDISM. Phase 2 — Orphaned Installer Cleanup: Runs the shared orphan detection function against the Windows Installer registry database. In default mode, orphaned files are moved to C:\DTC\InstallerCleanup\Quarantine<date>\ for safe recovery. With -Force, files are deleted directly. If orphan detection fails for any reason, the entire cleanup aborts with exit code 1 — the script will never delete files it cannot confirm are orphaned. Phase 3 — $PatchCache$ Cleanup: Clears the $PatchCache$ subfolder contents when larger than 1 GB. This folder contains redundant copies of patch data that Windows can re-download if needed. Phase 4 — Quarantine Maintenance: Purges quarantine folders older than -QuarantineDays (default 30 days), reclaiming the space once the safety retention window has passed. Operating modes:

Default (quarantine): Moves orphaned files to C:\DTC\InstallerCleanup\Quarantine\ — fully reversible by moving files back -Force: Deletes orphaned files directly — use when disk is critically full or after confirming quarantine contents are safe -WhatIf: Dry run that reports exactly what would be cleaned with zero filesystem changes — recommended as a first pass on any new machine Auto-Force: Engages automatically when C: drive free space drops below 10%, logged prominently as a warning Post-cleanup actions:

Re-runs orphan detection to update NinjaRMM custom fields with current state Writes cleanup summary to Windows Application Event Log (Event ID 1001, source DTC-InstallerMonitor) Detailed per-file log written to C:\DTC\InstallerCleanup\Logs\ with timestamps, action taken, file sizes, and success/failure status Safety guarantees:

Orphan detection failure aborts the entire cleanup — no blind deletion Locked files are caught and skipped gracefully (logged, continues to next file) All thresholds defined as variables at script top for easy adjustment Self-contained with no external dependencies (PowerShell 5.1 only) Designed to run as SYSTEM on Windows 10, 11, Server 2019, and Server 2022

Summary by CodeRabbit

  • New Features

    • Added a comprehensive installer cleanup utility with configurable dry-run (WhatIf), quarantine, and direct deletion modes
    • Configurable thresholds for disk-space warnings/critical, auto-force behavior, and quarantine retention (default 30 days)
    • Multi-phase execution with optional system image cleanup, PatchCache$ management, quarantine lifecycle, and automatic expired-quarantine purging
    • Detailed console reporting, persistent logs, Windows Event Log entry, and status updates via the management agent
  • Bug Fixes / Reliability

    • Improved error handling and safety checks to abort on detection failures and log per-item errors

Adds Cleanup-InstallerPatches.ps1 — an on-demand NinjaRMM remediation script that removes orphaned .msi/.msp files from C:\Windows\Installer identified by the same registry-based orphan detection used in the monitor script. Designed to be triggered manually by a technician or automatically via NinjaRMM condition when the monitor reports Warning or Critical status.

Cleanup operates in five phases:

Phase 0 — Pre-flight: Checks C: drive free space. If disk is below 10% free, automatically switches from quarantine mode to direct deletion since quarantining would consume additional disk space on an already-full drive.
Phase 1 — DISM Component Cleanup: Runs DISM /Online /Cleanup-Image /StartComponentCleanup to reclaim space from superseded Windows components. Deliberately avoids /ResetBase which would prevent future update uninstalls — too destructive for unattended automation. Skippable with -SkipDISM.
Phase 2 — Orphaned Installer Cleanup: Runs the shared orphan detection function against the Windows Installer registry database. In default mode, orphaned files are moved to C:\DTC\InstallerCleanup\Quarantine\<date>\ for safe recovery. With -Force, files are deleted directly. If orphan detection fails for any reason, the entire cleanup aborts with exit code 1 — the script will never delete files it cannot confirm are orphaned.
Phase 3 — $PatchCache$ Cleanup: Clears the $PatchCache$ subfolder contents when larger than 1 GB. This folder contains redundant copies of patch data that Windows can re-download if needed.
Phase 4 — Quarantine Maintenance: Purges quarantine folders older than -QuarantineDays (default 30 days), reclaiming the space once the safety retention window has passed.
Operating modes:

Default (quarantine): Moves orphaned files to C:\DTC\InstallerCleanup\Quarantine\ — fully reversible by moving files back
-Force: Deletes orphaned files directly — use when disk is critically full or after confirming quarantine contents are safe
-WhatIf: Dry run that reports exactly what would be cleaned with zero filesystem changes — recommended as a first pass on any new machine
Auto-Force: Engages automatically when C: drive free space drops below 10%, logged prominently as a warning
Post-cleanup actions:

Re-runs orphan detection to update NinjaRMM custom fields with current state
Writes cleanup summary to Windows Application Event Log (Event ID 1001, source DTC-InstallerMonitor)
Detailed per-file log written to C:\DTC\InstallerCleanup\Logs\ with timestamps, action taken, file sizes, and success/failure status
Safety guarantees:

Orphan detection failure aborts the entire cleanup — no blind deletion
Locked files are caught and skipped gracefully (logged, continues to next file)
All thresholds defined as variables at script top for easy adjustment
Self-contained with no external dependencies (PowerShell 5.1 only)
Designed to run as SYSTEM on Windows 10, 11, Server 2019, and Server 2022
@AlrightLad AlrightLad requested a review from Gumbees as a code owner February 19, 2026 07:54
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 19, 2026

Warning

Rate limit exceeded

@AlrightLad has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 7 minutes and 14 seconds before requesting another review.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

📝 Walkthrough

Walkthrough

Adds a standalone PowerShell script that identifies orphaned .msi/.msp files in C:\Windows\Installer via registry queries, then quarantines or deletes them with phased execution, optional DISM/PatchCache$ cleanup, quarantine lifecycle maintenance, detailed logging, and NinjaRMM/Event Log reporting. (50 words)

Changes

Cohort / File(s) Summary
DTC Installer Patch Cleanup Script
rmm-ninja/Cleanup-InstallerPatches/DTC-Installer-Patch-Cleanup.ps1
New PowerShell script (~+495 lines) implementing registry-based orphan detection, parameterized execution (WhatIf, SkipDISM, Force, QuarantineDays), disk-space thresholds, optional DISM run, PatchCache$ cleanup, quarantine directories and automated purge, per-file action logging, error handling, NinjaRMM custom-field updates, and Event Log integration.

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant Script as DTC_Cleanup_Script
    participant Registry as Windows_Registry
    participant FileSystem as File_System
    participant Quarantine as Quarantine_Folder
    participant NinjaRMM as NinjaRMM_Service
    participant EventLog as Windows_EventLog

    User->>Script: Execute (WhatIf/Force/SkipDISM/QuarantineDays)
    Script->>Script: Pre-flight checks (disk thresholds, params)
    Script->>FileSystem: Enumerate C:\Windows\Installer files
    Script->>Registry: Query product & patch references
    Registry-->>Script: Referenced file list
    Script->>Script: Classify files (referenced vs orphaned)
    alt WhatIf
        Script-->>User: Report planned actions
    else Quarantine mode
        Script->>Quarantine: Move orphaned files to dated folder
        Quarantine-->>Script: Confirm move / log entry
    else Delete mode
        Script->>FileSystem: Delete orphaned files
        FileSystem-->>Script: Confirm deletion / log entry
    end
    Script->>FileSystem: Optional PatchCache$ cleanup
    Script->>Script: Purge quarantines older than QuarantineDays
    Script->>NinjaRMM: Update custom fields / request scan refresh
    Script->>EventLog: Write summary event
    Script-->>User: Print final summary (counts, recovered space, logfile)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰 I hopped through Installer halls and found a lonesome file,

I nudged it to a safety den, then sat and grinned a while.
With logs and bins and tidy bins, I kept the system neat,
A gentle quarantine tango — tidy bytes beneath my feet.

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main change: adding a PowerShell script for on-demand cleanup of orphaned installer patches with a safety quarantine workflow. It is concise, specific, and clearly summarizes the primary purpose of the changeset.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (2)
rmm-ninja/Cleanup-InstallerPatches (2)

416-421: Dead elseif ($WhatIf) branch — $WhatIf is always $false inside if (-not $WhatIf).

The entire Phase 5 block (line 376) is guarded by if (-not $WhatIf), so the elseif ($WhatIf) in the event message here-string on line 418 is unreachable. The mode string will always evaluate to either "Direct Delete" or "Quarantine".

♻️ Proposed cleanup
-Mode: $(if ($Force) {"Direct Delete"} elseif ($WhatIf) {"WhatIf (no changes)"} else {"Quarantine"})
+Mode: $(if ($Force) {"Direct Delete"} else {"Quarantine"})
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@rmm-ninja/Cleanup-InstallerPatches` around lines 416 - 421, The here-string
assigned to $eventMessage contains an unreachable elseif ($WhatIf) branch
because the Phase 5 block is already guarded by if (-not $WhatIf); remove the
dead elseif and simplify the mode expression to only check $Force vs fallback
(e.g., if ($Force) {"Direct Delete"} else {"Quarantine"}) or compute a $mode
variable before the here-string and use that; update references to $Force,
$WhatIf, and $eventMessage/Phase 5 accordingly.

232-234: DISM exit code is not checked — failures are only visible in raw [DISM] output lines.

A non-zero $LASTEXITCODE (e.g., 0x800F081F, 87) after DISM is silently ignored. The cleanup summary and NinjaRMM fields never reflect a DISM failure.

♻️ Proposed fix — check $LASTEXITCODE
 $dismResult = & DISM /Online /Cleanup-Image /StartComponentCleanup 2>&1
 $dismResult | ForEach-Object { Write-Output "[DISM] $_" }
+if ($LASTEXITCODE -ne 0) {
+    Write-Warning "[PHASE 1] DISM exited with code $LASTEXITCODE — component cleanup may be incomplete."
+}
 Write-Output ""
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@rmm-ninja/Cleanup-InstallerPatches` around lines 232 - 234, The DISM
invocation stores output in $dismResult but never checks $LASTEXITCODE, so
failures (non-zero exit codes like 0x800F081F or 87) are only visible in the raw
"[DISM] ..." lines; update the logic after running DISM (the block using
$dismResult and the ForEach-Object/Write-Output lines) to inspect $LASTEXITCODE,
treat any non-zero value as a failure, and propagate that state into the cleanup
summary and NinjaRMM fields (e.g., set failure status/message variables or call
the existing report/update function used for other steps) so the overall result
reflects DISM errors instead of silently succeeding.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@rmm-ninja/Cleanup-InstallerPatches`:
- Line 37: The script allows zero or negative values for $QuarantineDays which
causes (Get-Date).AddDays(-$QuarantineDays) to delete everything; add input
validation: attach a [ValidateRange(1,9999)] (or appropriate max) to the
parameter declaration for [int]$QuarantineDays or add an explicit guard early
(e.g., if ($QuarantineDays -lt 1) { throw "QuarantineDays must be >= 1" })
before computing the cutoff, and apply the same fix to the other occurrences of
the cutoff logic referenced near the second instance (occurrences around the
code that uses (Get-Date).AddDays(-$QuarantineDays)).
- Line 260: The quarantine path currently uses only date (variable
$quarantinePath) causing multiple runs the same day to share one folder and
letting Move-Item -Force silently overwrite files; change the path construction
to append a time component (e.g., Get-Date -Format 'yyyy-MM-dd_HHmmss' to match
$logFile) or, alternately, detect destination name collisions before calling
Move-Item and rename files (e.g., append a unique suffix or incrementing
counter) so Move-Item is never allowed to overwrite an existing quarantined
file; update all places that build $quarantinePath (including the other
occurrences noted around the Move-Item calls) to use the new uniquifying scheme.
- Around line 203-212: Replace the deprecated Get-WmiObject call with
Get-CimInstance to query Win32_LogicalDisk for DeviceID 'C:' and add a
null-guard and error handling before dereferencing $cDrive: wrap the query in
try/catch (or check for $null) and if $cDrive is $null,
Write-Warning/Write-Output a clear message and set safe defaults for
$freePercent and $freeSizeGB (e.g. 0 or -1) or abort gracefully so the script
doesn't call methods on a null; then continue using the existing
$AutoForceThresholdPct and $Force logic (only toggle $Force when $freePercent is
a valid number) to avoid the null-valued expression crash.
- Around line 1-31: The file is missing the .ps1 extension and uses PascalCase
which breaks NinjaRMM detection and repo naming conventions; rename the script
file from Cleanup-InstallerPatches to cleanup-installer-patches.ps1 and update
any references (task definitions, docs, or automation entries) that point to the
old filename so the condition-triggered runner and on-demand invocation can find
and execute the script.

---

Nitpick comments:
In `@rmm-ninja/Cleanup-InstallerPatches`:
- Around line 416-421: The here-string assigned to $eventMessage contains an
unreachable elseif ($WhatIf) branch because the Phase 5 block is already guarded
by if (-not $WhatIf); remove the dead elseif and simplify the mode expression to
only check $Force vs fallback (e.g., if ($Force) {"Direct Delete"} else
{"Quarantine"}) or compute a $mode variable before the here-string and use that;
update references to $Force, $WhatIf, and $eventMessage/Phase 5 accordingly.
- Around line 232-234: The DISM invocation stores output in $dismResult but
never checks $LASTEXITCODE, so failures (non-zero exit codes like 0x800F081F or
87) are only visible in the raw "[DISM] ..." lines; update the logic after
running DISM (the block using $dismResult and the ForEach-Object/Write-Output
lines) to inspect $LASTEXITCODE, treat any non-zero value as a failure, and
propagate that state into the cleanup summary and NinjaRMM fields (e.g., set
failure status/message variables or call the existing report/update function
used for other steps) so the overall result reflects DISM errors instead of
silently succeeding.

Comment on lines +1 to +31
<#
.SYNOPSIS
DTC Installer Patch Cleanup — On-demand removal of orphaned .msi/.msp files
from C:\Windows\Installer with safety quarantine.
.DESCRIPTION
Detects and removes orphaned installer cache files from C:\Windows\Installer.
Supports quarantine mode (default), direct deletion (-Force), and dry-run (-WhatIf).
Includes DISM component cleanup, $PatchCache$ cleanup, and quarantine expiry management.

Safety: If orphan detection fails for ANY reason, the entire cleanup is aborted.

Deployment: NinjaRMM on-demand script (tech triggers manually) or condition-triggered.
Runs as: SYSTEM

Reference: HALO Ticket 1125653 — 128 GB orphaned patches, 96.1% disk utilization.
.PARAMETER WhatIf
Report what WOULD be cleaned — make ZERO filesystem changes.
.PARAMETER SkipDISM
Skip the DISM component cleanup phase.
.PARAMETER Force
Delete orphaned files directly instead of quarantining. Auto-enabled when disk < 10% free.
.PARAMETER QuarantineDays
Number of days to keep quarantined files before auto-purge. Default: 30.
.NOTES
Author: DTC Engineering
Version: 1.0.0
Requires: PowerShell 5.1, Windows 10/11/Server 2019/2022
Dependencies: None (self-contained)
#>

#Requires -Version 5.1
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check for the file without .ps1 extension and list all scripts in rmm-ninja/
echo "=== Files matching 'Cleanup-InstallerPatches' (any extension) ==="
fd "Cleanup-InstallerPatches" rmm-ninja/ 2>/dev/null || echo "No matches found"

echo ""
echo "=== All .ps1 files in rmm-ninja/ ==="
fd --type f --extension ps1 rmm-ninja/ 2>/dev/null || echo "No .ps1 files found"

echo ""
echo "=== All files in rmm-ninja/ directory (top level and first subdirs) ==="
find rmm-ninja/ -maxdepth 2 -type f 2>/dev/null | head -30

Repository: DTC-Inc/msp-script-library

Length of output: 402


Rename file to cleanup-installer-patches.ps1 — missing extension and incorrect naming convention.

The file is stored as Cleanup-InstallerPatches without .ps1 extension and uses PascalCase naming. PowerShell script filenames must use lowercase-with-hyphens format (e.g., veeam-add-backup-repo.ps1). NinjaRMM's automation engine identifies scripts by extension; without it, the script will not be detected or executed by the condition-triggered runner or on-demand technician invocation. Additionally, the PascalCase naming violates the repository's PowerShell script naming convention.

Rename to cleanup-installer-patches.ps1.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@rmm-ninja/Cleanup-InstallerPatches` around lines 1 - 31, The file is missing
the .ps1 extension and uses PascalCase which breaks NinjaRMM detection and repo
naming conventions; rename the script file from Cleanup-InstallerPatches to
cleanup-installer-patches.ps1 and update any references (task definitions, docs,
or automation entries) that point to the old filename so the condition-triggered
runner and on-demand invocation can find and execute the script.

Addresses all 6 findings from the CodeRabbit review on PR DTC-Inc#44, plus proactive fixes for patterns shared with the monitor script that would trigger findings on a second pass.

Deprecated Get-WmiObject replaced with Get-CimInstance (crash fix):
Get-WmiObject has been deprecated since PowerShell 3.0 in favor of Get-CimInstance and is absent entirely on PowerShell 7+. More critically, if the WMI/CIM query returns $null (possible on non-standard disk configurations, WMI corruption, or running inside certain container environments), the very next line calls .FreeSpace on a null object — a terminating error that aborts the script before any cleanup work begins. Added a null guard: if the CIM query fails, free-space check and auto-Force logic are skipped with a warning, defaulting to safe values (100% free, -1 GB) so the script continues into the cleanup phases rather than dying on pre-flight.

QuarantineDays zero/negative purges entire quarantine (data loss fix):
(Get-Date).AddDays(-$QuarantineDays) with a zero or negative value sets the cutoff date to the present or future, which matches every existing quarantine folder and silently destroys the entire recovery safety net in Phase 4. Added [ValidateRange(1, 365)] to the parameter declaration so PowerShell rejects invalid values at invocation time with a clear error message rather than allowing silent destructive behavior. The 365-day upper bound prevents accidental permanent retention that would defeat the purpose of quarantine expiry.

Same-day quarantine path collision (data loss fix):
The quarantine folder used date-only format (yyyy-MM-dd), so multiple cleanup runs on the same day shared one folder. Move-Item -Force silently overwrites a previously quarantined file with the same name — meaning a second run would destroy the first run's quarantined files with no error logged, eliminating the recovery path. Changed the format to yyyy-MM-dd_HHmmss so each run gets its own unique folder. This also makes forensic review easier since each quarantine folder maps to a specific cleanup execution.

DISM exit code not checked (silent failure fix):
The script captured DISM's stdout/stderr output and logged it line-by-line, but never checked $LASTEXITCODE. Non-zero DISM exit codes (e.g., 0x800F081F missing source, 87 invalid parameter, or timeout errors on slow disks) were silently swallowed — the cleanup would report success despite DISM component cleanup failing. Added an explicit check: if $LASTEXITCODE is non-zero, a warning is emitted with the exit code so technicians reviewing the NinjaRMM script log can see that DISM didn't complete cleanly.

Dead elseif ($WhatIf) branch removed (dead code cleanup):
The Phase 5 event log message included elseif ($WhatIf) {"WhatIf (no changes)"} in the Mode string, but the entire Phase 5 block is guarded by if (-not $WhatIf). The WhatIf branch was unreachable — the Mode string could only ever evaluate to "Direct Delete" or "Quarantine". Removed the dead branch to avoid confusion during future maintenance where someone might assume WhatIf event logging is implemented when it isn't.

Unprotected EventLog SourceExists() call (crash fix):
Same issue as the monitor script — [System.Diagnostics.EventLog]::SourceExists() throws a terminating SecurityException if the caller lacks permission to enumerate registered event-log sources. Wrapped in try/catch so the cleanup completes and reports results even if event source registration fails. This is especially important in the cleanup script since the actual disk recovery work has already been done by this point — crashing during reporting would leave the technician with no summary of what was cleaned.

Post-cleanup threshold comparisons fixed to -ge (consistency fix):
The Phase 5 post-cleanup status determination used -gt while the spec defines thresholds as >= values. This created an inconsistency where the cleanup script's post-run status could differ from the monitor script's status for the same folder size, causing confusing NinjaRMM field values. Changed to -ge to match both the spec and the monitor script.

Null coercion on Measure-Object .Sum throughout (defensive arithmetic):
Applied -as [long] coercion with explicit zero fallback to every Measure-Object .Sum call outside the shared function: $baselineSize in Phase 0, $patchCacheSize in Phase 3, and quarantine folder size measurement in Phase 4. Also applied the same fix to the shared Get-OrphanedInstallerFiles function (identical to the monitor script fix) so the function is safe on machines with an empty C:\Windows\Installer folder.
@AlrightLad
Copy link
Author

@coderabbitai review

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 19, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (4)
rmm-ninja/Cleanup-InstallerPatches (4)

148-165: Array += in loop is O(n²) — use [System.Collections.Generic.List[PSCustomObject]].

$referenced += ... and $orphaned += ... inside the foreach loop re-allocates the entire array on every iteration. On systems with thousands of installer files (the target use case per the ticket — 128 GB of patches), this becomes a noticeable bottleneck.

Suggested fix
     # --- STEP 3: Compare and classify ---
-    $referenced = @()
-    $orphaned = @()
+    $referenced = [System.Collections.Generic.List[PSCustomObject]]::new()
+    $orphaned   = [System.Collections.Generic.List[PSCustomObject]]::new()
 
     foreach ($file in $allFiles) {
         if ($referencedFiles.Contains($file.FullName)) {
-            $referenced += [PSCustomObject]@{
+            $referenced.Add([PSCustomObject]@{
                 FullPath  = $file.FullName
                 SizeBytes = $file.Length
-            }
+            })
         } else {
-            $orphaned += [PSCustomObject]@{
+            $orphaned.Add([PSCustomObject]@{
                 FullPath      = $file.FullName
                 SizeBytes     = $file.Length
                 LastWriteTime = $file.LastWriteTime
-            }
+            })
         }
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@rmm-ninja/Cleanup-InstallerPatches` around lines 148 - 165, The loop is
repeatedly reallocating arrays by using $referenced += ... and $orphaned += ...,
causing O(n^2) behavior; replace the array accumulators with strongly-typed
lists (e.g. [System.Collections.Generic.List[PSCustomObject]]), initialize them
before the foreach, use the .Add(...) method inside the foreach that iterates
$allFiles, and if an array is required later convert the lists back with
.ToArray() or cast to [PSCustomObject[]]; update references to $referenced and
$orphaned accordingly (e.g. when building the PSCustomObject entries and when
consuming the collections).

44-47: Configuration variables use PascalCase instead of camelCase.

Per repository conventions, PowerShell variables should use camelCase.

Suggested rename
-$WarningThresholdGB     = 20
-$CriticalThresholdGB    = 50
-$AutoForceThresholdPct  = 10
-$PatchCacheThresholdGB  = 1
+$warningThresholdGB     = 20
+$criticalThresholdGB    = 50
+$autoForceThresholdPct  = 10
+$patchCacheThresholdGB  = 1

Based on learnings: "Use camelCase for PowerShell variables and functions."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@rmm-ninja/Cleanup-InstallerPatches` around lines 44 - 47, The variables use
PascalCase ($WarningThresholdGB, $CriticalThresholdGB, $AutoForceThresholdPct,
$PatchCacheThresholdGB); rename them to camelCase (e.g., $warningThresholdGB,
$criticalThresholdGB, $autoForceThresholdPct, $patchCacheThresholdGB) and update
every reference (assignments, checks, functions, exported settings) to these
symbols to preserve behavior and repo conventions; ensure any documentation,
help text, or tests that reference the old names are updated too.

338-346: $totalRecoveredBytes overstated if $PatchCache$ files are locked or fail to delete.

Line 342 uses -ErrorAction SilentlyContinue on Remove-Item, silently skipping locked files. Line 343 then unconditionally adds the full pre-deletion $patchCacheSize to $totalRecoveredBytes. The same pattern applies to Phase 2 quarantine failures — the catch block doesn't decrement the counter, but at least it logs the failure. Here, there's no visibility into partial deletion at all.

Consider measuring the folder again after deletion, or accumulating only successful deletes:

             Get-ChildItem $patchCachePath -Recurse -Force -ErrorAction SilentlyContinue |
                 Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
-            $totalRecoveredBytes += $patchCacheSize
+            $postCacheSize = (Get-ChildItem $patchCachePath -Recurse -Force -ErrorAction SilentlyContinue |
+                Measure-Object Length -Sum).Sum -as [long]
+            if (-not $postCacheSize) { $postCacheSize = [long]0 }
+            $totalRecoveredBytes += ($patchCacheSize - $postCacheSize)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@rmm-ninja/Cleanup-InstallerPatches` around lines 338 - 346, The script
currently adds the full $patchCacheSize to $totalRecoveredBytes even if
Remove-Item skipped locked files; change this to compute actual reclaimed bytes
after the delete attempt: capture the pre-delete size ($patchCacheSize or
Measure-Object on $patchCachePath), perform deletion (prefer iterating files
under $patchCachePath and removing each so failures are visible), then
re-measure remaining size with Get-ChildItem $patchCachePath -Recurse |
Measure-Object -Sum Length and compute recovered = preDeleteSize -
postDeleteSize; add recovered to $totalRecoveredBytes and log the actual
recovered bytes to $logFile (use the same log entry location instead of
unconditionally logging $patchCacheSize).

1-39: Missing RMM three-part structure and variable declaration block.

This script is deployed via NinjaRMM but does not follow the required three-part structure: (1) RMM variable declaration comment block, (2) input handling with $RMM detection, (3) script logic. There is no ## PLEASE COMMENT YOUR VARIABLES DIRECTLY BELOW HERE IF YOU'RE RUNNING FROM A RMM header, and no $RMM detection to differentiate interactive vs. RMM execution paths. The $Force, $SkipDISM, $QuarantineDays parameters would need to be settable as RMM variables for condition-triggered runs.

Suggested structure addition after line 31
 `#Requires` -Version 5.1
 
+## PLEASE COMMENT YOUR VARIABLES DIRECTLY BELOW HERE IF YOU'RE RUNNING FROM A RMM
+## $WhatIf
+## $SkipDISM
+## $Force
+## $QuarantineDays
+
 param(
     [switch]$WhatIf,
     [switch]$SkipDISM,
     [switch]$Force,
     [ValidateRange(1, 365)]
     [int]$QuarantineDays = 30
 )

Based on learnings: "All PowerShell scripts must follow the three-part structure from script-template-powershell.ps1 (RMM variable declaration, input handling, script logic)" and "Top-of-file RMM variable declaration: include a comment block headed with '## PLEASE COMMENT YOUR VARIABLES DIRECTLY BELOW HERE IF YOU'RE RUNNING FROM A RMM' and list each required variable as comments."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@rmm-ninja/Cleanup-InstallerPatches` around lines 1 - 39, The script lacks the
required NinjaRMM three-part structure: add a top-of-file RMM variable
declaration comment block headed with "## PLEASE COMMENT YOUR VARIABLES DIRECTLY
BELOW HERE IF YOU'RE RUNNING FROM A RMM" listing the RMM-mappable variables
(WhatIf, SkipDISM, Force, QuarantineDays), then implement an input-handling
section that detects the $RMM sentinel and, when present, reads/assigns RMM
inputs into the script variables (populate $Force, $SkipDISM, $QuarantineDays,
and optionally $WhatIf) before executing the existing script logic; ensure this
detection uses a clear $RMM check and sets types/ValidateRange for
QuarantineDays to match the param block so both interactive and RMM-triggered
runs behave the same.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@rmm-ninja/Cleanup-InstallerPatches`:
- Around line 289-321: The loop currently increments $totalRecoveredBytes when
Move-Item in the non-Force (quarantine) branch succeeds even though files remain
on the same volume; update the logic so $totalRecoveredBytes only reflects
actual disk-space-recovered (i.e., when files are deleted), and introduce a
separate counter (e.g., $installerFolderFreedBytes) for space freed from the
Installer folder by Move-Item/quarantine; adjust the branches around Move-Item
and Remove-Item (referencing Move-Item, Remove-Item, $totalRecoveredBytes,
$quarantinePath, $Force, $WhatIf) to increment the correct counter and ensure
the final summary uses the appropriate field names and messaging (Disk space
recovered vs. Installer-folder space freed).
- Around line 196-222: The initial "Mode:" Write-Output is printed before the
auto-Force logic, so when $freePercent falls below $AutoForceThresholdPct the
script may switch $Force to $true but the startup log still shows "Quarantine";
update the script so the mode line reflects the final decision by moving the
mode announcement (the Write-Output that checks $WhatIf/$Force) to after the
auto-Force block that sets $Force based on $freePercent, or alternatively keep
the original line but add a corrected Write-Output immediately after the
auto-Force check that logs the updated mode and notes that auto-Force was
applied when $Force is changed by the $AutoForceThresholdPct check.

---

Nitpick comments:
In `@rmm-ninja/Cleanup-InstallerPatches`:
- Around line 148-165: The loop is repeatedly reallocating arrays by using
$referenced += ... and $orphaned += ..., causing O(n^2) behavior; replace the
array accumulators with strongly-typed lists (e.g.
[System.Collections.Generic.List[PSCustomObject]]), initialize them before the
foreach, use the .Add(...) method inside the foreach that iterates $allFiles,
and if an array is required later convert the lists back with .ToArray() or cast
to [PSCustomObject[]]; update references to $referenced and $orphaned
accordingly (e.g. when building the PSCustomObject entries and when consuming
the collections).
- Around line 44-47: The variables use PascalCase ($WarningThresholdGB,
$CriticalThresholdGB, $AutoForceThresholdPct, $PatchCacheThresholdGB); rename
them to camelCase (e.g., $warningThresholdGB, $criticalThresholdGB,
$autoForceThresholdPct, $patchCacheThresholdGB) and update every reference
(assignments, checks, functions, exported settings) to these symbols to preserve
behavior and repo conventions; ensure any documentation, help text, or tests
that reference the old names are updated too.
- Around line 338-346: The script currently adds the full $patchCacheSize to
$totalRecoveredBytes even if Remove-Item skipped locked files; change this to
compute actual reclaimed bytes after the delete attempt: capture the pre-delete
size ($patchCacheSize or Measure-Object on $patchCachePath), perform deletion
(prefer iterating files under $patchCachePath and removing each so failures are
visible), then re-measure remaining size with Get-ChildItem $patchCachePath
-Recurse | Measure-Object -Sum Length and compute recovered = preDeleteSize -
postDeleteSize; add recovered to $totalRecoveredBytes and log the actual
recovered bytes to $logFile (use the same log entry location instead of
unconditionally logging $patchCacheSize).
- Around line 1-39: The script lacks the required NinjaRMM three-part structure:
add a top-of-file RMM variable declaration comment block headed with "## PLEASE
COMMENT YOUR VARIABLES DIRECTLY BELOW HERE IF YOU'RE RUNNING FROM A RMM" listing
the RMM-mappable variables (WhatIf, SkipDISM, Force, QuarantineDays), then
implement an input-handling section that detects the $RMM sentinel and, when
present, reads/assigns RMM inputs into the script variables (populate $Force,
$SkipDISM, $QuarantineDays, and optionally $WhatIf) before executing the
existing script logic; ensure this detection uses a clear $RMM check and sets
types/ValidateRange for QuarantineDays to match the param block so both
interactive and RMM-triggered runs behave the same.

Cleanup script changes:

Mode announcement moved after auto-Force decision (misleading log fix):
The mode line was printed before the free-space check that can auto-enable Force mode. If disk was below 10% free and -Force wasn't explicitly set, the startup log would say "Quarantine" but the script would actually operate in Force mode. Operators reviewing NinjaRMM script activity would see contradictory information. Moved the Write-Output to after the auto-Force block.

Recovery metrics split into two counters (misleading metric fix):
$totalRecoveredBytes conflated "installer folder freed" with "disk space recovered." In quarantine mode, Move-Item moves files from C:\Windows\Installer to C:\DTC\InstallerCleanup\Quarantine — both on C:. No disk space is freed (data stays on the same volume, only the directory entry changes). Yet the summary reported this as "Space recovered." Now tracks:

$installerFolderFreedBytes: bytes removed from C:\Windows\Installer (Move or Delete — what shrinks the folder for monitor threshold purposes)
$diskSpaceRecoveredBytes: bytes actually freed from disk (Delete only + Phase 4 quarantine purge)
NinjaRMM installerCleanupRecoveredGB now reports actual disk recovery. Console summary shows both metrics with a note explaining that quarantined files will free disk space when quarantine expires.

PatchCache$ recovery re-measured after deletion (inflated metric fix):
Previously added the full pre-deletion $patchCacheSize to recovered bytes even though Remove-Item -ErrorAction SilentlyContinue silently skips locked files. Now re-measures the folder after deletion and computes $actualCacheRecovered = $preSize - $postSize. Log entry shows "X of Y bytes" so technicians can see if locked files prevented full cleanup.

Quarantine purge recovery verified after deletion (inflated metric fix):
Same pattern as PatchCache$ — Phase 4 now checks Test-Path after Remove-Item. If the folder is fully deleted, counts the full size. If partially remaining (e.g., locked files), re-measures and counts only the actual difference.

PatchCache threshold -gt → -ge (consistency fix):
All other threshold comparisons in both scripts use -ge (greater-or-equal). PatchCache$ used -gt (strictly greater), meaning a folder exactly at the 1 GB threshold would be skipped. Changed to -ge for consistent boundary behavior.

Post-cleanup threshold uses unfiltered folder total:
Phase 5 post-cleanup re-scan now uses InstallerFolderTotalBytes for the threshold comparison, matching the monitor script's behavior so the post-cleanup status in NinjaRMM is consistent with what the weekly monitor would report.
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (3)
rmm-ninja/Cleanup-InstallerPatches (3)

44-47: Configuration variable names use PascalCase; repo convention requires camelCase.

$WarningThresholdGB, $CriticalThresholdGB, $AutoForceThresholdPct, and $PatchCacheThresholdGB should be $warningThresholdGB, $criticalThresholdGB, $autoForceThresholdPct, and $patchCacheThresholdGB. Cascading renames also apply to every downstream reference.

Based on learnings: "Use camelCase for PowerShell variables and functions."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@rmm-ninja/Cleanup-InstallerPatches` around lines 44 - 47, The variables use
PascalCase but repo convention requires camelCase; rename $WarningThresholdGB,
$CriticalThresholdGB, $AutoForceThresholdPct, and $PatchCacheThresholdGB to
$warningThresholdGB, $criticalThresholdGB, $autoForceThresholdPct, and
$patchCacheThresholdGB respectively, and update every downstream reference
(functions, conditionals, assignments, and any exported/config usages) to the
new names so all callers (e.g., any code that reads these thresholds) compile
and behave the same; ensure you update comments and tests that reference the old
symbols and run a quick grep/IDE rename to catch all occurrences.

383-384: Quarantine expiry relies on CreationTime, which can be reset on copy/restore.

$_.CreationTime is an NTFS metadata attribute that can be silently reset to the current time if the C:\DTC tree is ever copied, restored from backup, or moved across volumes. Folder names already encode yyyy-MM-dd_HHmmss; parsing the name for the cutoff comparison is more reliable and self-documenting.

♻️ Proposed alternative — parse date from folder name
-    $expiredFolders = Get-ChildItem $quarantineRoot -Directory |
-        Where-Object { $_.CreationTime -lt $cutoffDate }
+    $expiredFolders = Get-ChildItem $quarantineRoot -Directory | Where-Object {
+        $folderDate = $null
+        if ([datetime]::TryParseExact(
+                ($_.Name -replace '_\d{6}$', ''),   # strip HHmmss suffix
+                'yyyy-MM-dd',
+                [System.Globalization.CultureInfo]::InvariantCulture,
+                [System.Globalization.DateTimeStyles]::None,
+                [ref]$folderDate)) {
+            $folderDate -lt $cutoffDate
+        } else {
+            $_.CreationTime -lt $cutoffDate   # fallback for non-standard names
+        }
+    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@rmm-ninja/Cleanup-InstallerPatches` around lines 383 - 384, The current
quarantine expiry filter uses filesystem CreationTime which can be reset; update
the logic that builds $expiredFolders (the pipeline starting with Get-ChildItem
$quarantineRoot | Where-Object ...) to instead parse the timestamp from each
folder's name (folders follow yyyy-MM-dd_HHmmss), validate the name with a
regex, convert using [DateTime]::ParseExact or TryParseExact, and compare that
parsed DateTime to $cutoffDate so only folders with a parsed timestamp older
than $cutoffDate are selected; keep the filter limited to directories and skip
names that don’t match the pattern to avoid false positives.

33-50: Add the required RMM variable declaration comment block.

The repo template mandates a top-of-file block headed ## PLEASE COMMENT YOUR VARIABLES DIRECTLY BELOW HERE IF YOU'RE RUNNING FROM A RMM listing each configurable variable as a comment. This serves as in-platform documentation for technicians configuring the script in NinjaRMM's script library. The block and the $RMM detection pattern are both absent.

♻️ Suggested addition (top of file, before `param`)
+## PLEASE COMMENT YOUR VARIABLES DIRECTLY BELOW HERE IF YOU'RE RUNNING FROM A RMM
+## $Force          # Set to $true to delete orphaned files directly instead of quarantining
+## $WhatIf         # Set to $true for a dry-run (no filesystem changes)
+## $SkipDISM       # Set to $true to skip Phase 1 DISM component cleanup
+## $QuarantineDays # Days to retain quarantined files before auto-purge (default: 30, range: 1-365)
+
 `#Requires` -Version 5.1

Based on learnings: "Top-of-file RMM variable declaration: include a comment block headed with '## PLEASE COMMENT YOUR VARIABLES DIRECTLY BELOW HERE IF YOU'RE RUNNING FROM A RMM' and list each required variable as comments."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@rmm-ninja/Cleanup-InstallerPatches` around lines 33 - 50, Add the missing
top-of-file RMM variable declaration comment block before the existing
param(...) by inserting a header comment "## PLEASE COMMENT YOUR VARIABLES
DIRECTLY BELOW HERE IF YOU'RE RUNNING FROM A RMM" followed by commented lines
for each configurable variable present in the script (e.g., $WarningThresholdGB,
$CriticalThresholdGB, $AutoForceThresholdPct, $PatchCacheThresholdGB,
$QuarantineDays, $WhatIf, $SkipDISM, $Force) and add the $RMM detection
pattern/variable check (referencing $RMM) so the script documents and
conditionally supports running from NinjaRMM; ensure the comments mirror the
variable names exactly and appear immediately above the param block.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@rmm-ninja/Cleanup-InstallerPatches`:
- Around line 249-256: The synchronous call to DISM via "& DISM /Online
/Cleanup-Image /StartComponentCleanup" (captured in $dismResult and checked with
$LASTEXITCODE) can exceed the RMM execution window; replace this direct call
with launching DISM as a background job (e.g., Start-Job) and use a bounded wait
(Wait-Job -Timeout) so the script continues to Phases 2–4 if the timeout is
reached, then capture/pipe the job output into the same logging path, check/kill
the job on timeout, log a clear warning including the job timeout and any
partial results, and set appropriate status fields; if you cannot implement a
background job, instead add a .NOTES runtime requirement (e.g., >=120 minutes)
and emit a prominent runtime warning before invoking DISM so callers can
increase the NinjaRMM script timeout.
- Around line 289-294: The quarantine directory creation (New-Item for
$quarantinePath) is not verified and errors are suppressed, so subsequent
Move-Item calls will fail and produce only per-file "[QUARANTINE-FAILED]" logs;
update the block that runs when -not $WhatIf to check the result/error from
New-Item for $quarantinePath (or use New-Item with -ErrorAction Stop inside a
try/catch) and abort the script (or set a visible error state) if creation fails
when -not $Force, ensuring the code paths that later call Move-Item detect and
do not proceed without a valid quarantine directory; reference $quarantinePath,
New-Item, Move-Item, WhatIf and Force to locate the changes.

---

Duplicate comments:
In `@rmm-ninja/Cleanup-InstallerPatches`:
- Around line 1-31: The script filename must be renamed so NinjaRMM can detect
it; change the repository file named "Cleanup-InstallerPatches" to
"cleanup-installer-patches.ps1" (kebab-case with the .ps1 extension) and ensure
any external references or deployment manifests that call this script use the
new name; do not modify the script body (e.g., the existing .SYNOPSIS/.PARAMETER
blocks or the `#Requires` -Version 5.1 line).

---

Nitpick comments:
In `@rmm-ninja/Cleanup-InstallerPatches`:
- Around line 44-47: The variables use PascalCase but repo convention requires
camelCase; rename $WarningThresholdGB, $CriticalThresholdGB,
$AutoForceThresholdPct, and $PatchCacheThresholdGB to $warningThresholdGB,
$criticalThresholdGB, $autoForceThresholdPct, and $patchCacheThresholdGB
respectively, and update every downstream reference (functions, conditionals,
assignments, and any exported/config usages) to the new names so all callers
(e.g., any code that reads these thresholds) compile and behave the same; ensure
you update comments and tests that reference the old symbols and run a quick
grep/IDE rename to catch all occurrences.
- Around line 383-384: The current quarantine expiry filter uses filesystem
CreationTime which can be reset; update the logic that builds $expiredFolders
(the pipeline starting with Get-ChildItem $quarantineRoot | Where-Object ...) to
instead parse the timestamp from each folder's name (folders follow
yyyy-MM-dd_HHmmss), validate the name with a regex, convert using
[DateTime]::ParseExact or TryParseExact, and compare that parsed DateTime to
$cutoffDate so only folders with a parsed timestamp older than $cutoffDate are
selected; keep the filter limited to directories and skip names that don’t match
the pattern to avoid false positives.
- Around line 33-50: Add the missing top-of-file RMM variable declaration
comment block before the existing param(...) by inserting a header comment "##
PLEASE COMMENT YOUR VARIABLES DIRECTLY BELOW HERE IF YOU'RE RUNNING FROM A RMM"
followed by commented lines for each configurable variable present in the script
(e.g., $WarningThresholdGB, $CriticalThresholdGB, $AutoForceThresholdPct,
$PatchCacheThresholdGB, $QuarantineDays, $WhatIf, $SkipDISM, $Force) and add the
$RMM detection pattern/variable check (referencing $RMM) so the script documents
and conditionally supports running from NinjaRMM; ensure the comments mirror the
variable names exactly and appear immediately above the param block.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
rmm-ninja/Cleanup-InstallerPatches (2)

44-48: Configuration variable names use PascalCase instead of camelCase.

$WarningThresholdGB, $CriticalThresholdGB, $AutoForceThresholdPct, $PatchCacheThresholdGB, and $DismTimeoutMinutes should be camelCase ($warningThresholdGB, $criticalThresholdGB, etc.). The same applies to Get-OrphanedInstallerFiles on line 53, though that name follows the standard PowerShell Verb-Noun convention which directly conflicts with the camelCase guideline — worth a team decision on which takes precedence for function names.

Based on learnings: "Use camelCase for PowerShell variables and functions."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@rmm-ninja/Cleanup-InstallerPatches` around lines 44 - 48, Rename the
configuration variables from PascalCase to camelCase (e.g., $WarningThresholdGB
-> $warningThresholdGB, $CriticalThresholdGB -> $criticalThresholdGB,
$AutoForceThresholdPct -> $autoForceThresholdPct, $PatchCacheThresholdGB ->
$patchCacheThresholdGB, $DismTimeoutMinutes -> $dismTimeoutMinutes) and update
every reference/usages across the file so names remain consistent; for the
function Get-OrphanedInstallerFiles, decide the team convention (keep PowerShell
Verb-Noun or rename to camelCase like getOrphanedInstallerFiles) and then rename
the function and all its callers accordingly to avoid mismatches.

301-302: $logPath is hardcoded; it should follow the $LogPath convention set in the RMM/interactive input-handling section.

C:\DTC\InstallerCleanup\Logs is not %WINDIR%\logs, which is where technicians are expected to look after execution and where the repo's RMM simulation guidance directs verification. Once the three-part structure (above) is added, $logPath should derive from the $LogPath variable established during input handling:

-$logPath = "C:\DTC\InstallerCleanup\Logs"
+$logPath = $LogPath   # set to $env:WINDIR\logs or $RMMScriptPath\logs during input handling

Based on learnings: "set $LogPath to %WINDIR%\logs in interactive, and $RMMScriptPath\logs (fallback to %WINDIR%\logs) in RMM" and "Verify transcripts/logs in %WINDIR%\logs after execution."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@rmm-ninja/Cleanup-InstallerPatches` around lines 301 - 302, Replace the
hardcoded $logPath with a path derived from the existing $LogPath variable
(respecting the three-part structure introduced in input handling): if $LogPath
is set use it, otherwise fall back to "$Env:Windir\logs"; then append the
InstallerCleanup/Logs subfolders (so $logPath is Join-Path $LogPath
"InstallerCleanup" "Logs" or the fallback) and ensure the directory is created
before computing $logFile (which should keep the existing timestamped filename
logic). Use the variables $logPath, $LogPath, and $logFile to locate and
implement this change.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@rmm-ninja/Cleanup-InstallerPatches`:
- Around line 33-48: The script is missing the required RMM three-part scaffold;
add a top-of-file RMM variable declaration comment block (the "## PLEASE COMMENT
YOUR VARIABLES..." section) declaring defaults for $RMM, $RMMScriptPath,
$LogPath, $Description and exposing the existing params ($WhatIf, $SkipDISM,
$Force, $QuarantineDays) for RMM override, then implement an if ($RMM -eq 1)
branch that reads inputs from those RMM variables (setting $LogPath = Join-Path
$RMMScriptPath 'logs' with a fallback to $env:windir\logs) and an else
interactive path that prompts with Read-Host/validation loops to populate
$QuarantineDays, $Force, $SkipDISM and capture $Description, ensuring the same
variable names used by the rest of the script (e.g., $WarningThresholdGB,
$PatchCacheThresholdGB, $DismTimeoutMinutes) remain untouched; finally ensure
$Description is always set and present before proceeding.

---

Duplicate comments:
In `@rmm-ninja/Cleanup-InstallerPatches`:
- Around line 304-310: The quarantine directory creation is unguarded: New-Item
-Path $quarantinePath is piped to Out-Null with no -ErrorAction Stop or
existence check, so later Move-Item calls silently fail; update the block that
creates quarantinePath (check the WhatIf/Force branch where New-Item is called)
to explicitly test for existence (Test-Path $quarantinePath) and attempt
creation with New-Item -ErrorAction Stop, capture/handle any exceptions, and if
creation fails log an explicit error and set a failure flag or throw so Phase 2
does not silently no-op when Move-Item operations would later fail.
- Around line 1-31: The file is misnamed and missing the .ps1 extension; rename
the script from "Cleanup-InstallerPatches" to the recommended lowercase,
hyphenated "cleanup-installer-patches.ps1" and update any external
references/callers to that new name (search for usages of
Cleanup-InstallerPatches); ensure internal metadata/comments (like .SYNOPSIS or
.PARAMETER descriptions) remain unchanged and that any packaging/deployment
manifests or NinjaRMM job definitions referencing the old name are updated
accordingly.

---

Nitpick comments:
In `@rmm-ninja/Cleanup-InstallerPatches`:
- Around line 44-48: Rename the configuration variables from PascalCase to
camelCase (e.g., $WarningThresholdGB -> $warningThresholdGB,
$CriticalThresholdGB -> $criticalThresholdGB, $AutoForceThresholdPct ->
$autoForceThresholdPct, $PatchCacheThresholdGB -> $patchCacheThresholdGB,
$DismTimeoutMinutes -> $dismTimeoutMinutes) and update every reference/usages
across the file so names remain consistent; for the function
Get-OrphanedInstallerFiles, decide the team convention (keep PowerShell
Verb-Noun or rename to camelCase like getOrphanedInstallerFiles) and then rename
the function and all its callers accordingly to avoid mismatches.
- Around line 301-302: Replace the hardcoded $logPath with a path derived from
the existing $LogPath variable (respecting the three-part structure introduced
in input handling): if $LogPath is set use it, otherwise fall back to
"$Env:Windir\logs"; then append the InstallerCleanup/Logs subfolders (so
$logPath is Join-Path $LogPath "InstallerCleanup" "Logs" or the fallback) and
ensure the directory is created before computing $logFile (which should keep the
existing timestamped filename logic). Use the variables $logPath, $LogPath, and
$logFile to locate and implement this change.

Comment on lines +33 to +48
param(
[switch]$WhatIf,
[switch]$SkipDISM,
[switch]$Force,
[ValidateRange(1, 365)]
[int]$QuarantineDays = 30
)

# ============================================================================
# CONFIGURATION — Adjust thresholds here
# ============================================================================
$WarningThresholdGB = 20 # Total Installer folder size >= this = "Warning"
$CriticalThresholdGB = 50 # Total Installer folder size >= this = "Critical"
$AutoForceThresholdPct = 10 # Free disk % below which Force mode auto-enables
$PatchCacheThresholdGB = 1 # $PatchCache$ size above which cleanup is triggered
$DismTimeoutMinutes = 60 # Max minutes to wait for DISM before continuing
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Missing required RMM three-part structure: variable declaration block, $RMM detection, interactive path, and $Description.

The script omits the three required structural sections mandated by the repo template:

  1. No top-of-file RMM variable block — the ## PLEASE COMMENT YOUR VARIABLES DIRECTLY BELOW HERE IF YOU'RE RUNNING FROM A RMM comment block is absent.
  2. No $RMM detection — there is no if ($RMM -eq 1) branch to distinguish interactive from RMM execution.
  3. No interactive input path — no Read-Host/validation loop for technician-driven runs.
  4. No $Description capture — always required per the template.
♻️ Suggested structural skeleton
+## PLEASE COMMENT YOUR VARIABLES DIRECTLY BELOW HERE IF YOU'RE RUNNING FROM A RMM
+## $SkipDISM     — set to 1 to skip DISM phase
+## $Force        — set to 1 to delete directly instead of quarantining
+## $QuarantineDays — days to retain quarantined files (default: 30)
+
 param(
     [switch]$WhatIf,
     [switch]$SkipDISM,
     [switch]$Force,
     [ValidateRange(1, 365)]
     [int]$QuarantineDays = 30
 )
+
+# ============================================================================
+# INPUT HANDLING
+# ============================================================================
+if ($RMM -eq 1) {
+    # RMM: variables pre-set above
+    $Description = "RMM-triggered installer patch cleanup"
+    $LogPath = if ($RMMScriptPath) { Join-Path $RMMScriptPath "logs" } else { "$env:WINDIR\logs" }
+} else {
+    # Interactive: prompt technician
+    $Description = Read-Host "Enter a description for this cleanup run"
+    $LogPath = "$env:WINDIR\logs"
+    # Optionally prompt for $Force / $SkipDISM here
+}

Based on learnings: "All PowerShell scripts must follow the three-part structure from script-template-powershell.ps1 (RMM variable declaration, input handling, script logic)" and "Input handling must detect $RMM (1 for RMM, undefined for interactive); interactive uses Read-Host with validation loop; RMM uses pre-set variables; always capture $Description; set $LogPath to %WINDIR%\logs in interactive, and $RMMScriptPath\logs (fallback to %WINDIR%\logs) in RMM."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@rmm-ninja/Cleanup-InstallerPatches` around lines 33 - 48, The script is
missing the required RMM three-part scaffold; add a top-of-file RMM variable
declaration comment block (the "## PLEASE COMMENT YOUR VARIABLES..." section)
declaring defaults for $RMM, $RMMScriptPath, $LogPath, $Description and exposing
the existing params ($WhatIf, $SkipDISM, $Force, $QuarantineDays) for RMM
override, then implement an if ($RMM -eq 1) branch that reads inputs from those
RMM variables (setting $LogPath = Join-Path $RMMScriptPath 'logs' with a
fallback to $env:windir\logs) and an else interactive path that prompts with
Read-Host/validation loops to populate $QuarantineDays, $Force, $SkipDISM and
capture $Description, ensuring the same variable names used by the rest of the
script (e.g., $WarningThresholdGB, $PatchCacheThresholdGB, $DismTimeoutMinutes)
remain untouched; finally ensure $Description is always set and present before
proceeding.

Refactor registry error handling and improve comments for clarity.
@Gumbees
Copy link
Contributor

Gumbees commented Mar 23, 2026

@

Adds Cleanup-InstallerPatches.ps1 — an on-demand NinjaRMM remediation script that removes orphaned .msi/.msp files from C:\Windows\Installer identified by the same registry-based orphan detection used in the monitor script. Designed to be triggered manually by a technician or automatically via NinjaRMM condition when the monitor reports Warning or Critical status.

Cleanup operates in five phases:

Phase 0 — Pre-flight: Checks C: drive free space. If disk is below 10% free, automatically switches from quarantine mode to direct deletion since quarantining would consume additional disk space on an already-full drive. Phase 1 — DISM Component Cleanup: Runs DISM /Online /Cleanup-Image /StartComponentCleanup to reclaim space from superseded Windows components. Deliberately avoids /ResetBase which would prevent future update uninstalls — too destructive for unattended automation. Skippable with -SkipDISM. Phase 2 — Orphaned Installer Cleanup: Runs the shared orphan detection function against the Windows Installer registry database. In default mode, orphaned files are moved to C:\DTC\InstallerCleanup\Quarantine\ for safe recovery. With -Force, files are deleted directly. If orphan detection fails for any reason, the entire cleanup aborts with exit code 1 — the script will never delete files it cannot confirm are orphaned. Phase 3 — P a t c h C a c h e Cleanup: Clears the P a t c h C a c h e subfolder contents when larger than 1 GB. This folder contains redundant copies of patch data that Windows can re-download if needed. Phase 4 — Quarantine Maintenance: Purges quarantine folders older than -QuarantineDays (default 30 days), reclaiming the space once the safety retention window has passed. Operating modes:

Default (quarantine): Moves orphaned files to C:\DTC\InstallerCleanup\Quarantine\ — fully reversible by moving files back -Force: Deletes orphaned files directly — use when disk is critically full or after confirming quarantine contents are safe -WhatIf: Dry run that reports exactly what would be cleaned with zero filesystem changes — recommended as a first pass on any new machine Auto-Force: Engages automatically when C: drive free space drops below 10%, logged prominently as a warning Post-cleanup actions:

Re-runs orphan detection to update NinjaRMM custom fields with current state Writes cleanup summary to Windows Application Event Log (Event ID 1001, source DTC-InstallerMonitor) Detailed per-file log written to C:\DTC\InstallerCleanup\Logs\ with timestamps, action taken, file sizes, and success/failure status Safety guarantees:

Orphan detection failure aborts the entire cleanup — no blind deletion Locked files are caught and skipped gracefully (logged, continues to next file) All thresholds defined as variables at script top for easy adjustment Self-contained with no external dependencies (PowerShell 5.1 only) Designed to run as SYSTEM on Windows 10, 11, Server 2019, and Server 2022

Summary by CodeRabbit

* **New Features**
  
  * Added a comprehensive installer cleanup utility with configurable dry-run (WhatIf), quarantine, and direct deletion modes
  * Configurable thresholds for disk-space warnings/critical, auto-force behavior, and quarantine retention (default 30 days)
  * Multi-phase execution with optional system image cleanup, PatchCache$ management, quarantine lifecycle, and automatic expired-quarantine purging
  * Detailed console reporting, persistent logs, Windows Event Log entry, and status updates via the management agent

* **Bug Fixes / Reliability**
  
  * Improved error handling and safety checks to abort on detection failures and log per-item errors

I do not understand why this script exists or was written still. Please elaborate. What is the demand for this exactly?

@Gumbees Gumbees added the feature New feature or request label Mar 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants