public/Invoke-OctaCategory.ps1
|
function Invoke-OctaCategory { <# .SYNOPSIS Dry-run preview and (optionally) apply of one or more Octa categories. FR-002 (dry-run preview), FR-003 (confirmation), FR-004/FR-005 (restore point), FR-006 (prior-state snapshot), FR-007 (irreversible actions surfaced), FR-024 (live activity panel). Returns a status object bin/octa.ps1 maps to an exit code. #> [CmdletBinding()] param( [Parameter(Mandatory)][string[]]$CategoryId, [switch]$Apply, [switch]$NoRestorePoint, [switch]$Yes, [switch]$Quiet, [switch]$ExcludeRisky ) $categories = @($CategoryId | ForEach-Object { Get-OctaCategory -Id $_ } | Where-Object { $_ }) $missing = $CategoryId | Where-Object { $_ -notin $categories.Id } if ($missing) { return [pscustomobject]@{ Status = 'UnknownCategory'; Message = "Unknown category: $($missing -join ', ')" } } $strings = Get-OctaStrings # Edge case: surface (never block on) a prior interrupted run before starting a new one. $lastRun = Get-OctaRunRecord -RunId 'latest' if ($lastRun -and $lastRun.Status -eq 'PartiallyApplied') { Write-Host "Warning: the previous run ($($lastRun.RunId)) was interrupted mid-apply. Run 'octa --undo $($lastRun.RunId)' to clean it up, or re-run the same category to finish/repair it." } # FR: elevation declared per category before any scan/apply. $needElevation = Assert-OctaElevation -Categories $categories if ($needElevation) { $names = ($needElevation | ForEach-Object { $_.DisplayName }) -join ', ' return [pscustomobject]@{ Status = 'ElevationRequired'; Message = ($strings.elevationRequired -f $names) } } # --- Scan phase: build the combined, de-duplicated action list ----------------------- Initialize-OctaActivityPanel $byCategory = @{} $seenKeys = @{} $combinedActions = @() foreach ($category in $categories) { Write-OctaActivity ($strings.activityScanning -f $category.DisplayName) $categoryActions = & $category.GetActionsFunction $byCategory[$category.Id] = $categoryActions foreach ($action in $categoryActions) { $dedupeKey = '{0}|{1}' -f $action.TargetType, $action.TargetIdentifier if ($seenKeys.ContainsKey($dedupeKey)) { continue } $seenKeys[$dedupeKey] = $true $combinedActions += [pscustomobject]@{ Category = $category; Action = $action } } } Complete-OctaActivityPanel # FR-028: bundles that opt in (e.g. Quick Clean) exclude Risky actions entirely, not just # gate them behind an extra prompt - filtered here, before preview, so the exclusion is # visible to the user rather than a silent behavior difference. $excludedRisky = @() if ($ExcludeRisky) { $excludedRisky = @($combinedActions | Where-Object { $_.Action.RiskLevel -eq 'Risky' }) $combinedActions = @($combinedActions | Where-Object { $_.Action.RiskLevel -ne 'Risky' }) } # --- Dry-run preview ------------------------------------------------------------------- if (-not $Quiet) { Write-Host '' Write-Host $strings.dryRunHeader foreach ($entry in $combinedActions) { $a = $entry.Action $flags = @() if ($a.HeuristicMatch) { $flags += 'heuristic' } if (-not $a.Reversible) { $flags += 'irreversible' } $flags += $a.RiskLevel $flagStr = ' [' + ($flags -join ', ') + ']' Write-Host (" [{0}] {1}: {2} -> {3}{4}" -f $a.TargetType, $a.TargetIdentifier, $a.CurrentValue, $a.PlannedValue, $flagStr) } foreach ($category in $categories) { if (@($byCategory[$category.Id]).Count -eq 0) { Write-Host (" {0}: {1}" -f $category.DisplayName, $strings.nothingToDo) } } if ($excludedRisky.Count -gt 0) { Write-Host '' Write-Host "Excluded (Risky, not eligible for this bundle - FR-028):" foreach ($entry in $excludedRisky) { Write-Host (" [{0}] {1}" -f $entry.Action.TargetType, $entry.Action.TargetIdentifier) } } } if ($combinedActions.Count -eq 0) { return [pscustomobject]@{ Status = 'NothingToDo'; Message = $strings.nothingToDo } } if (-not $Apply) { return [pscustomobject]@{ Status = 'DryRun'; Actions = $combinedActions } } # --- Confirmation (FR-003, FR-026) ------------------------------------------------------ $groups = Split-OctaActionsByConfirmationGroup -Entries $combinedActions $riskyActions = $groups.Risky $heuristicActions = $groups.Heuristic $normalActions = $groups.Normal if ($normalActions.Count -gt 0 -and -not $Yes) { $response = Read-Host $strings.confirmPrompt if ($response -notin @('y', 'Y', 's', 'S')) { return [pscustomobject]@{ Status = 'Cancelled'; Message = 'User declined confirmation.' } } } $applyActions = $normalActions if ($groups.Irreversible.Count -gt 0) { Write-Host '' Write-Host 'Irreversible actions (confirm separately - cannot be undone via --undo):' foreach ($entry in $groups.Irreversible) { Write-Host (" [{0}] {1}: {2}" -f $entry.Action.TargetType, $entry.Action.TargetIdentifier, $entry.Action.IrreversibleReason) } if ($Yes) { $applyActions += $groups.Irreversible } else { $irreversibleResponse = Read-Host $strings.confirmPrompt if ($irreversibleResponse -in @('y', 'Y', 's', 'S')) { $applyActions += $groups.Irreversible } } } if ($heuristicActions.Count -gt 0) { Write-Host '' Write-Host 'Heuristic OEM-suite matches (confirm separately - FR-017):' foreach ($entry in $heuristicActions) { Write-Host (" [{0}] {1}" -f $entry.Action.TargetType, $entry.Action.TargetIdentifier) } if ($Yes) { $applyActions += $heuristicActions } else { $heuristicResponse = Read-Host $strings.confirmPrompt if ($heuristicResponse -in @('y', 'Y', 's', 'S')) { $applyActions += $heuristicActions } } } if ($riskyActions.Count -gt 0) { Write-Host '' Write-Host 'Risky actions (confirm separately - FR-026):' foreach ($entry in $riskyActions) { Write-Host (" [{0}] {1}" -f $entry.Action.TargetType, $entry.Action.TargetIdentifier) } if ($Yes) { $applyActions += $riskyActions } else { $riskyResponse = Read-Host $strings.confirmPrompt if ($riskyResponse -in @('y', 'Y', 's', 'S')) { $applyActions += $riskyActions } } } if ($applyActions.Count -eq 0) { return [pscustomobject]@{ Status = 'Cancelled'; Message = 'User declined confirmation.' } } # --- Restore point (FR-004/FR-005) ------------------------------------------------------ $restorePoint = New-OctaRestorePoint -NoRestorePoint:$NoRestorePoint -Description 'Octa debloat run' # ponytail: $warningShown stops the same "System Protection is off" line (and its prompt) # from being printed twice in a row. Seen verbatim in a real terminal capture: the warning, # then "Enable System Protection...? (y/N): n", then the identical warning again, then a # second confirm prompt - because the offer-to-enable block below and the proceed-anyway # block after it both print the same string for the same status. $warningShown = $false if ($restorePoint.Status -eq 'SkippedProtectionDisabled') { Write-Host $strings.restorePointSkippedProtectionDisabled $warningShown = $true $shouldEnable = $Yes if (-not $Yes) { $enableResponse = Read-Host 'Enable System Protection and create a restore point now? (y/N)' $shouldEnable = $enableResponse -in @('y', 'Y', 's', 'S') } if ($shouldEnable) { try { Enable-ComputerRestore -Drive "$env:SystemDrive\" -ErrorAction Stop $restorePoint = New-OctaRestorePoint -NoRestorePoint:$NoRestorePoint -Description 'Octa debloat run' } catch { Write-Host "Could not enable System Protection automatically: $($_.Exception.Message)" } } } if ($restorePoint.Status -in @('SkippedThrottled', 'SkippedProtectionDisabled')) { if (-not $warningShown) { $warningKey = if ($restorePoint.Status -eq 'SkippedThrottled') { 'restorePointSkippedThrottled' } else { 'restorePointSkippedProtectionDisabled' } Write-Host $strings.$warningKey } if (-not $Yes) { $proceedResponse = Read-Host $strings.confirmPrompt if ($proceedResponse -notin @('y', 'Y', 's', 'S')) { return [pscustomobject]@{ Status = 'Cancelled'; Message = 'User declined to proceed without a restore point.' } } } } # --- Apply, snapshotting prior state first (FR-006) ------------------------------------- $run = New-OctaRunFolder $snapshots = @() $irreversibleRefs = @() $appliedCategoryIds = @() $runStatus = 'Completed' Initialize-OctaActivityPanel try { foreach ($entry in $applyActions) { $action = $entry.Action $snapshot = Save-OctaActionSnapshot -Action $action -RunFolder $run.Path if ($snapshot) { $snapshots += $snapshot Write-OctaActivity ($strings.activitySaved -f $action.TargetIdentifier) } else { $irreversibleRefs += $action.TargetIdentifier } Write-OctaActivity ($strings.activityApplying -f $action.TargetIdentifier) & $entry.Category.ApplyActionFunction -Action $action if ($entry.Category.Id -notin $appliedCategoryIds) { $appliedCategoryIds += $entry.Category.Id } } } catch { $runStatus = 'PartiallyApplied' Complete-OctaActivityPanel Save-OctaRunRecord -RunFolder $run.Path -RunRecord ([pscustomobject]@{ RunId = $run.RunId Timestamp = (Get-Date).ToString('o') CategoriesApplied = $appliedCategoryIds RestorePointStatus = $restorePoint.Status RestorePointSequenceNumber = $restorePoint.SequenceNumber ActionSnapshots = $snapshots IrreversibleActionRefs = $irreversibleRefs Status = $runStatus }) return [pscustomobject]@{ Status = 'Error'; Message = $_.Exception.Message; RunId = $run.RunId } } Complete-OctaActivityPanel Save-OctaRunRecord -RunFolder $run.Path -RunRecord ([pscustomobject]@{ RunId = $run.RunId Timestamp = (Get-Date).ToString('o') CategoriesApplied = $appliedCategoryIds RestorePointStatus = $restorePoint.Status RestorePointSequenceNumber = $restorePoint.SequenceNumber ActionSnapshots = $snapshots IrreversibleActionRefs = $irreversibleRefs Status = $runStatus }) return [pscustomobject]@{ Status = 'Success'; RunId = $run.RunId; RestorePointStatus = $restorePoint.Status } } |