#Requires -Version 5.1 # configure.ps1 — PowerShell Automation Center # The only file a user needs to interact with; everything else lives in internal\. param( [switch]$AutoRegisterRunner, [switch]$AutoRemoveRunner ) $ErrorActionPreference = 'Continue' $script:InternalRoot = Join-Path $PSScriptRoot 'internal' $script:TaskName = 'PSAutomation-Runner' $script:TaskPath = '\' $script:RunnerIntervalMinutes = 2 $script:ConfigureScriptPath = if ($PSCommandPath) { $PSCommandPath } else { $MyInvocation.MyCommand.Path } $script:RegisterResultPath = Join-Path $script:InternalRoot 'data\logs\last-register-result.json' $script:RemoveResultPath = Join-Path $script:InternalRoot 'data\logs\last-remove-result.json' $script:ElevatedDiagPath = Join-Path $script:InternalRoot 'data\logs\elevated-process.log' function Write-ElevatedDiag { param([string]$Message) try { $dir = Split-Path $script:ElevatedDiagPath -Parent if (-not (Test-Path $dir)) { New-Item -Path $dir -ItemType Directory -Force | Out-Null } $line = "[{0}] {1}" -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $Message Add-Content -Path $script:ElevatedDiagPath -Value $line -Encoding UTF8 } catch { # Never fail caller due to diagnostics write issues } } function Write-OperationResultEarly { param( [Parameter(Mandatory)][string]$Path, [Parameter(Mandatory)][bool]$Success, [Parameter(Mandatory)][string]$Message ) try { $dir = Split-Path $Path -Parent if (-not (Test-Path $dir)) { New-Item -Path $dir -ItemType Directory -Force | Out-Null } @{ timestamp = (Get-Date).ToString('o') success = $Success message = $Message } | ConvertTo-Json -Depth 5 | Set-Content -Path $Path -Encoding UTF8 } catch { # Best-effort fallback only } } # ── Validate installation ───────────────────────────────────────────────────── foreach ($required in @('lib\Logging.ps1', 'lib\Config.ps1')) { $p = Join-Path $script:InternalRoot $required if (-not (Test-Path $p)) { Write-Host "ERROR: Cannot find 'internal\$required'. Installation may be incomplete." -ForegroundColor Red exit 1 } } . (Join-Path $script:InternalRoot 'lib\Logging.ps1') . (Join-Path $script:InternalRoot 'lib\Config.ps1') . (Join-Path $script:InternalRoot 'lib\Elevation.ps1') # Detect whether raw keyboard input is available (console host) $script:CanUseRawInput = ($Host.Name -eq 'ConsoleHost' -and $null -ne $Host.UI.RawUI) function Test-ConsoleReadKeyAvailable { try { $null = [Console]::KeyAvailable return $true } catch { return $false } } # ═════════════════════════════════════════════════════════════════════════════ # Utility # ═════════════════════════════════════════════════════════════════════════════ function Read-SingleKey { if ($script:CanUseRawInput) { return $Host.UI.RawUI.ReadKey('NoEcho,IncludeKeyDown') } # Secondary path: direct console API (works in some non-ConsoleHost scenarios) if (Test-ConsoleReadKeyAvailable) { $k = [Console]::ReadKey($true) return [PSCustomObject]@{ Character = $k.KeyChar VirtualKeyCode = [int]$k.Key } } # Final fallback: line input (requires Enter) $line = Read-Host -Prompt ' ' return [PSCustomObject]@{ Character = if ($line.Length -gt 0) { $line[0] } else { [char]0 } VirtualKeyCode = 0 } } function Write-Header { param([string]$Title) $bar = '═' * 52 Clear-Host Write-Host $bar -ForegroundColor Cyan Write-Host " $Title" -ForegroundColor Cyan Write-Host $bar -ForegroundColor Cyan Write-Host '' } function Write-Hint { param([string]$Text) Write-Host " $Text" -ForegroundColor DarkGray } function Pause-ForKey { param([string]$Prompt = '') if ([string]::IsNullOrWhiteSpace($Prompt)) { if ($script:CanUseRawInput -or (Test-ConsoleReadKeyAvailable)) { $Prompt = 'Press any key to return...' } else { $Prompt = 'Press Enter to return...' } } Write-Host '' Write-Hint $Prompt $null = Read-SingleKey } function Get-RunnerTask { # Prefer configured task path; fall back to root path for legacy tasks. $task = Get-ScheduledTask -TaskName $script:TaskName -TaskPath $script:TaskPath -ErrorAction SilentlyContinue if ($task) { return $task } return Get-ScheduledTask -TaskName $script:TaskName -ErrorAction SilentlyContinue } function Write-OperationResult { param( [Parameter(Mandatory)][string]$Path, [Parameter(Mandatory)][bool]$Success, [Parameter(Mandatory)][string]$Message ) $dir = Split-Path $Path -Parent if (-not (Test-Path $dir)) { New-Item -Path $dir -ItemType Directory -Force | Out-Null } @{ timestamp = (Get-Date).ToString('o') success = $Success message = $Message } | ConvertTo-Json -Depth 5 | Set-Content -Path $Path -Encoding UTF8 } function Read-OperationResult { param([Parameter(Mandatory)][string]$Path) if (-not (Test-Path $Path)) { return $null } try { return (Get-Content -Path $Path -Raw -Encoding UTF8 | ConvertFrom-Json) } catch { return $null } } # ═════════════════════════════════════════════════════════════════════════════ # Feature discovery # ═════════════════════════════════════════════════════════════════════════════ function Get-Features { <# .SYNOPSIS Dot-sources every .ps1 in internal\features\ and collects their $FeatureMeta. Returns an ordered list of [PSCustomObject]@{ File; Meta }. #> $featuresDir = Join-Path $script:InternalRoot 'features' $featureFiles = Get-ChildItem -Path $featuresDir -Filter '*.ps1' -ErrorAction SilentlyContinue | Sort-Object Name $features = [System.Collections.Generic.List[PSCustomObject]]::new() foreach ($file in $featureFiles) { try { . $file.FullName # defines $FeatureMeta + Invoke-Feature $meta = $FeatureMeta # copy before the next iteration overwrites it $features.Add([PSCustomObject]@{ File = $file.FullName Meta = $meta }) } catch { Write-Log -Level Warn ` -Message "Failed to load feature '$($file.Name)': $_" ` -Feature 'Configure' } } return $features } # ═════════════════════════════════════════════════════════════════════════════ # Option 1 — Check registration state # ═════════════════════════════════════════════════════════════════════════════ function Show-RegistrationState { Write-Header 'Registration State' Write-Log -Level Info -Message 'Checking runner registration state.' -Feature 'Configure' $task = Get-RunnerTask if (-not $task) { Write-Host " Task '$($script:TaskName)' is NOT registered." -ForegroundColor Yellow Write-Hint " Run option 2 to register it." Write-Log -Level Warn -Message "Task '$($script:TaskName)' not found." -Feature 'Configure' Pause-ForKey return } $info = Get-ScheduledTaskInfo -TaskName $task.TaskName -TaskPath $task.TaskPath -ErrorAction SilentlyContinue $stateColor = switch ($task.State) { 'Ready' { 'Green' } 'Running' { 'Cyan' } default { 'Yellow' } } Write-Host " Task: " -NoNewline; Write-Host $task.TaskName -ForegroundColor Green Write-Host " Task Path: $($task.TaskPath)" Write-Host " State: " -NoNewline; Write-Host $task.State -ForegroundColor $stateColor if ($info) { $resultColor = if ($info.LastTaskResult -eq 0) { 'Green' } else { 'Yellow' } Write-Host " Last Run: $($info.LastRunTime)" Write-Host " Result: " -NoNewline Write-Host ("0x{0:X}" -f $info.LastTaskResult) -ForegroundColor $resultColor Write-Host " Next Run: $($info.NextRunTime)" } $runnerPath = Join-Path $script:InternalRoot 'runner.ps1' Write-Host '' Write-Host " Runner: $runnerPath" -ForegroundColor DarkGray Write-Log -Level Info -Message ("Task found at path '{0}' with state '{1}'." -f $task.TaskPath, $task.State) -Feature 'Configure' Pause-ForKey } # ═════════════════════════════════════════════════════════════════════════════ # Option 2 — Register runner (scheduled task) # ═════════════════════════════════════════════════════════════════════════════ function Register-Runner { param( [switch]$NoPause, [switch]$ForceReRegister ) Write-Header 'Register Runner' Write-Log -Level Info -Message 'Register-Runner invoked.' -Feature 'Configure' $runnerPath = Join-Path $script:InternalRoot 'runner.ps1' # Request elevation only for this operation, when needed. if (-not (Test-Administrator)) { Remove-Item -Path $script:RegisterResultPath -Force -ErrorAction SilentlyContinue $elevationResult = Request-Elevation ` -ScriptPath $script:ConfigureScriptPath ` -ScriptArguments @('-AutoRegisterRunner') ` -BootstrapLogPath $script:ElevatedDiagPath ` -Wait ` -TimeoutSeconds 120 Write-Log -Level Info -Message ("Elevation requested for Register-Runner. Completed={0}; ExitCode={1}" -f $elevationResult['Completed'], $elevationResult['ExitCode']) -Feature 'Configure' if ($elevationResult['Requested']) { if (-not $elevationResult['Completed']) { Write-Host ' Elevated registration did not complete within 120 seconds.' -ForegroundColor Yellow Write-Hint ' You can check status manually with option 1 in a moment.' Write-Log -Level Warn -Message 'Elevated registration timed out after 120 seconds.' -Feature 'Configure' if (-not $NoPause) { Pause-ForKey } return } $result = Read-OperationResult -Path $script:RegisterResultPath if ($result) { if (-not $result.success) { Write-Host " Elevated registration failed: $($result.message)" -ForegroundColor Red Write-Log -Level Error -Message ("Elevated registration failure reported: {0}" -f $result.message) -Feature 'Configure' } else { Write-Log -Level Info -Message ("Elevated registration success reported: {0}" -f $result.message) -Feature 'Configure' } } else { $exitCode = $elevationResult['ExitCode'] Write-Host " Elevated registration exited with code $exitCode and did not return a result file." -ForegroundColor Red Write-Hint " Check internal\\data\\logs\\elevated-process.log for the bootstrap error trace." Write-Log -Level Warn -Message ("No elevated registration result file found. ExitCode={0}" -f $exitCode) -Feature 'Configure' } Write-Host ' Elevated registration finished. Verifying registration state...' -ForegroundColor Green Start-Sleep -Milliseconds 400 Show-RegistrationState return } Write-Host ' ERROR: This operation requires administrator privileges.' -ForegroundColor Red Write-Log -Level Error -Message 'Elevation request was denied or failed.' -Feature 'Configure' if (-not $NoPause) { Pause-ForKey } return } if (-not (Test-Path $runnerPath)) { Write-Host " ERROR: runner.ps1 not found at: $runnerPath" -ForegroundColor Red Write-Log -Level Error -Message "runner.ps1 missing at '$runnerPath'." -Feature 'Configure' Write-OperationResult -Path $script:RegisterResultPath -Success $false -Message "runner.ps1 missing at '$runnerPath'." if (-not $NoPause) { Pause-ForKey } return } $existing = Get-RunnerTask if ($existing) { Write-Host " Task '$($script:TaskName)' already exists (state: $($existing.State))." -ForegroundColor Yellow Write-Log -Level Info -Message ("Existing task found at '{0}'." -f $existing.TaskPath) -Feature 'Configure' if (-not $ForceReRegister) { Write-Host '' Write-Host ' Re-register? (Y / N) ' -ForegroundColor White -NoNewline $key = Read-SingleKey Write-Host '' if ([string]$key.Character -notin 'y', 'Y') { Write-OperationResult -Path $script:RegisterResultPath -Success $false -Message 'Registration canceled by user.' return } } try { Unregister-ScheduledTask -TaskName $existing.TaskName -TaskPath $existing.TaskPath -Confirm:$false -ErrorAction Stop Write-Log -Level Info -Message 'Existing task removed before re-registration.' -Feature 'Configure' } catch { Write-Host " ERROR: Could not remove existing task: $_" -ForegroundColor Red Write-Log -Level Error -Message "Failed to remove existing task: $_" -Feature 'Configure' Write-OperationResult -Path $script:RegisterResultPath -Success $false -Message "Failed to remove existing task: $_" if (-not $NoPause) { Pause-ForKey } return } } try { $currentUser = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name $action = New-ScheduledTaskAction ` -Execute 'powershell.exe' ` -Argument "-WindowStyle Hidden -NonInteractive -ExecutionPolicy Bypass -File `"$runnerPath`"" # Logon trigger — copy Repetition from a -Once trigger (reliable workaround) $logonTrigger = New-ScheduledTaskTrigger -AtLogOn $repeatTrigger = New-ScheduledTaskTrigger -Once -At '00:00' ` -RepetitionInterval (New-TimeSpan -Minutes $script:RunnerIntervalMinutes) $logonTrigger.Repetition = $repeatTrigger.Repetition $settings = New-ScheduledTaskSettingsSet ` -ExecutionTimeLimit (New-TimeSpan -Hours 1) ` -MultipleInstances IgnoreNew ` -StartWhenAvailable $principal = New-ScheduledTaskPrincipal ` -UserId $currentUser ` -LogonType Interactive ` -RunLevel Limited Register-ScheduledTask ` -TaskName $script:TaskName ` -TaskPath $script:TaskPath ` -Action $action ` -Trigger $logonTrigger ` -Settings $settings ` -Principal $principal ` -Force ` -ErrorAction Stop | Out-Null # Verify creation before reporting success $createdTask = Get-RunnerTask if (-not $createdTask) { throw "Register-ScheduledTask returned without creating '$($script:TaskName)'." } Write-Host " Task '$($script:TaskName)' registered successfully." -ForegroundColor Green Write-Host '' Write-Hint (" Triggers: at logon + every {0} minutes" -f $script:RunnerIntervalMinutes) Write-Hint " Runs: hidden PowerShell, current user, no elevation" Write-Log -Level Info ` -Message ("Scheduled task '{0}' registered at path '{1}' for user '{2}'." -f $script:TaskName, $createdTask.TaskPath, $currentUser) ` -Feature 'Configure' Write-OperationResult -Path $script:RegisterResultPath -Success $true -Message 'Runner registration completed successfully.' } catch { Write-Host " ERROR: Failed to register task: $_" -ForegroundColor Red Write-Log -Level Error -Message "Failed to register scheduled task: $_" -Feature 'Configure' Write-OperationResult -Path $script:RegisterResultPath -Success $false -Message ("Failed to register scheduled task: $_") Write-Host '' Write-Hint " Tip: if this persists, check internal\data\logs\automation-.log for details." } if (-not $NoPause) { Pause-ForKey } } # ═════════════════════════════════════════════════════════════════════════════ # Option 3 — Remove runner # ═════════════════════════════════════════════════════════════════════════════ function Remove-Runner { param([switch]$NoPause) Write-Header 'Remove Runner' Write-Log -Level Info -Message 'Remove-Runner invoked.' -Feature 'Configure' if (-not (Test-Administrator)) { Remove-Item -Path $script:RemoveResultPath -Force -ErrorAction SilentlyContinue $elevationResult = Request-Elevation ` -ScriptPath $script:ConfigureScriptPath ` -ScriptArguments @('-AutoRemoveRunner') ` -BootstrapLogPath $script:ElevatedDiagPath ` -Wait ` -TimeoutSeconds 120 if ($elevationResult['Requested']) { if (-not $elevationResult['Completed']) { Write-Host ' Elevated removal did not complete within 120 seconds.' -ForegroundColor Yellow if (-not $NoPause) { Pause-ForKey } return } $result = Read-OperationResult -Path $script:RemoveResultPath if ($result -and -not $result.success) { Write-Host " Elevated removal failed: $($result.message)" -ForegroundColor Red } Write-Host ' Elevated removal finished. Verifying registration state...' -ForegroundColor Green Start-Sleep -Milliseconds 400 Show-RegistrationState return } Write-Host ' ERROR: This operation requires administrator privileges.' -ForegroundColor Red if (-not $NoPause) { Pause-ForKey } return } $task = Get-RunnerTask if (-not $task) { Write-Host " Task '$($script:TaskName)' is not registered." -ForegroundColor Yellow Write-OperationResult -Path $script:RemoveResultPath -Success $true -Message 'Task already absent.' if (-not $NoPause) { Pause-ForKey } return } try { Unregister-ScheduledTask -TaskName $task.TaskName -TaskPath $task.TaskPath -Confirm:$false -ErrorAction Stop Write-Host " Task '$($script:TaskName)' removed successfully." -ForegroundColor Green Write-Log -Level Info -Message ("Task '{0}' removed from path '{1}'." -f $task.TaskName, $task.TaskPath) -Feature 'Configure' Write-OperationResult -Path $script:RemoveResultPath -Success $true -Message 'Runner removed successfully.' } catch { Write-Host " ERROR: Failed to remove task: $_" -ForegroundColor Red Write-Log -Level Error -Message "Failed to remove task: $_" -Feature 'Configure' Write-OperationResult -Path $script:RemoveResultPath -Success $false -Message ("Failed to remove task: $_") } if (-not $NoPause) { Pause-ForKey } } # ═════════════════════════════════════════════════════════════════════════════ # Option 4 — Feature config sub-menu (called when user presses C) # ═════════════════════════════════════════════════════════════════════════════ function Show-FeatureConfigMenu { param( [PSCustomObject]$Feature, [hashtable]$Config ) $name = $Feature.Meta.Name Write-Header "Configure: $name" Write-Hint $Feature.Meta.Description Write-Host '' if ($Feature.Meta.Settings.Count -eq 0) { Write-Hint "This feature has no configurable settings." Pause-ForKey return } foreach ($setting in $Feature.Meta.Settings) { $currentValue = $Config.features[$name][$setting.Key] Write-Host " $($setting.Label)" -ForegroundColor White Write-Hint " $($setting.Description)" Write-Host " Current: " -NoNewline Write-Host $currentValue -ForegroundColor Yellow Write-Host '' $input = Read-Host " New value (Enter to keep)" if ($input -ne '') { try { $newValue = switch ($setting.Type) { 'int' { [int]$input } 'bool' { [System.Convert]::ToBoolean($input) } default { $input } } $Config.features[$name][$setting.Key] = $newValue } catch { Write-Host " Invalid value for type '$($setting.Type)' — keeping current." -ForegroundColor Red } } Write-Host '' } Save-Config $Config Write-Host ' Settings saved.' -ForegroundColor Green Start-Sleep -Milliseconds 600 } # ═════════════════════════════════════════════════════════════════════════════ # Option 4 — Feature configuration list # ═════════════════════════════════════════════════════════════════════════════ function Show-FeatureMenu { $features = Get-Features if ($features.Count -eq 0) { Write-Header 'Configure Features' Write-Host ' No feature files found in internal\features\.' -ForegroundColor Yellow Pause-ForKey return } # Seed any missing config entries from feature metadata $config = Get-Config foreach ($feature in $features) { $config = Ensure-FeatureConfig -Config $config -FeatureMeta $feature.Meta } Save-Config $config while ($true) { Write-Header 'Configure Features' Write-Hint 'Type feature number to toggle, C to configure, Q to go back' Write-Host '' for ($i = 0; $i -lt $features.Count; $i++) { $num = $i + 1 $name = $features[$i].Meta.Name $desc = $features[$i].Meta.Description $enabled = $config.features[$name]['enabled'] $chk = if ($enabled) { '[X]' } else { '[ ]' } $shortDesc = if ($desc.Length -gt 50) { $desc.Substring(0, 47) + '...' } else { $desc } Write-Host " $num $chk $name" -ForegroundColor White -NoNewline Write-Host " $shortDesc" -ForegroundColor DarkGray } Write-Host '' $input = Read-Host 'Enter command' $input = $input.Trim() if ($input -eq '' -or $input -eq 'q' -or $input -eq 'Q') { return } elseif ($input -match '^c(\d+)$' -or $input -match '^C(\d+)$') { # C — configure $idx = [int]$Matches[1] - 1 if ($idx -ge 0 -and $idx -lt $features.Count) { Show-FeatureConfigMenu -Feature $features[$idx] -Config $config $config = Get-Config } } elseif ($input -match '^\d+$') { # Just a number — toggle $idx = [int]$input - 1 if ($idx -ge 0 -and $idx -lt $features.Count) { $name = $features[$idx].Meta.Name $config.features[$name]['enabled'] = -not $config.features[$name]['enabled'] Save-Config $config $newState = if ($config.features[$name]['enabled']) { 'enabled' } else { 'disabled' } Write-Log -Level Info ` -Message "Feature '$name' $newState via configure.ps1." ` -Feature 'Configure' } } } } # ═════════════════════════════════════════════════════════════════════════════ # Main menu # ═════════════════════════════════════════════════════════════════════════════ function Show-MainMenu { while ($true) { Write-Header 'PowerShell Automation Center' Write-Host ' 1 Check registration state' Write-Host ' 2 Register runner' Write-Host ' 3 Remove registered runner' Write-Host ' 4 Configure features' Write-Host '' Write-Hint ' Q Exit' Write-Host '' $key = Read-SingleKey $ch = [string]$key.Character switch ($ch) { '1' { Show-RegistrationState } '2' { Register-Runner } '3' { Remove-Runner } '4' { Show-FeatureMenu } { $_ -in 'q', 'Q' } { Clear-Host return } } } } if ($AutoRegisterRunner) { try { Write-ElevatedDiag 'AutoRegisterRunner started.' Register-Runner -NoPause -ForceReRegister if (-not (Test-Path $script:RegisterResultPath)) { Write-OperationResultEarly -Path $script:RegisterResultPath -Success $true -Message 'Runner registration completed (fallback success result).' } Write-ElevatedDiag 'AutoRegisterRunner finished without unhandled exceptions.' Pause-ForKey -Prompt 'Press any key to close elevated window...' exit 0 } catch { $msg = "Unhandled error in AutoRegisterRunner: $($_.Exception.Message)" Write-ElevatedDiag $msg Write-ElevatedDiag ("Stack: {0}" -f $_.ScriptStackTrace) Write-OperationResultEarly -Path $script:RegisterResultPath -Success $false -Message $msg Pause-ForKey -Prompt 'Press any key to close elevated window...' exit 1 } } if ($AutoRemoveRunner) { try { Write-ElevatedDiag 'AutoRemoveRunner started.' Remove-Runner -NoPause if (-not (Test-Path $script:RemoveResultPath)) { Write-OperationResultEarly -Path $script:RemoveResultPath -Success $true -Message 'Runner removal completed (fallback success result).' } Write-ElevatedDiag 'AutoRemoveRunner finished without unhandled exceptions.' Pause-ForKey -Prompt 'Press any key to close elevated window...' exit 0 } catch { $msg = "Unhandled error in AutoRemoveRunner: $($_.Exception.Message)" Write-ElevatedDiag $msg Write-ElevatedDiag ("Stack: {0}" -f $_.ScriptStackTrace) Write-OperationResultEarly -Path $script:RemoveResultPath -Success $false -Message $msg Pause-ForKey -Prompt 'Press any key to close elevated window...' exit 1 } } Show-MainMenu