6c10d359d2
- Added new configuration options for direct checkout navigation, including `useDirectCheckoutNavigation`, `checkoutPath`, and `checkoutOpenDelayMs`. - Updated the auto-order flow to navigate directly to the checkout page, skipping the mini-cart and date/time steps. - Improved keyboard automation logic for item remark and order confirmation processes. - Removed the old SandwichAutoOrder.ps1 file as its functionality has been integrated into SandwichReminder-AutoOrder.ps1. - Introduced a new runner-launcher.vbs to start the runner.ps1 without a visible console window, ensuring WinForms dialogs can appear. - Implemented a mutex mechanism in runner.ps1 to prevent concurrent execution of the same feature.
902 lines
35 KiB
PowerShell
902 lines
35 KiB
PowerShell
#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 = '\PowerShell Automation\'
|
|
$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-FeatureFiles {
|
|
$featuresDir = Join-Path $script:InternalRoot 'features'
|
|
if (-not (Test-Path $featuresDir)) {
|
|
return @()
|
|
}
|
|
|
|
return @(Get-ChildItem -Path $featuresDir -Filter '*.ps1' -ErrorAction SilentlyContinue |
|
|
Sort-Object Name)
|
|
}
|
|
|
|
function Get-FeatureCacheSignature {
|
|
param([System.IO.FileInfo[]]$FeatureFiles)
|
|
|
|
return (($FeatureFiles | ForEach-Object {
|
|
'{0}:{1}' -f $_.FullName, $_.LastWriteTimeUtc.Ticks
|
|
}) -join '|')
|
|
}
|
|
|
|
function Load-FeaturesFromFiles {
|
|
param([System.IO.FileInfo[]]$FeatureFiles)
|
|
|
|
$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)
|
|
}
|
|
|
|
function Complete-FeaturePrewarm {
|
|
param([switch]$Wait)
|
|
|
|
if (-not $script:FeaturesPrewarmTask) {
|
|
return $false
|
|
}
|
|
|
|
$task = $script:FeaturesPrewarmTask
|
|
|
|
if ($Wait) {
|
|
$null = $task.Handle.AsyncWaitHandle.WaitOne()
|
|
}
|
|
|
|
if (-not $task.Handle.IsCompleted) {
|
|
return $false
|
|
}
|
|
|
|
try {
|
|
$result = $task.PowerShell.EndInvoke($task.Handle)
|
|
if ($result.Count -gt 0) {
|
|
$payload = $result[0]
|
|
$script:FeaturesCache = @($payload.Features)
|
|
$script:FeaturesCacheSignature = [string]$payload.Signature
|
|
|
|
foreach ($errMsg in @($payload.Errors)) {
|
|
Write-Log -Level Warn -Message $errMsg -Feature 'Configure'
|
|
}
|
|
}
|
|
}
|
|
catch {
|
|
Write-Log -Level Warn -Message "Feature prewarm completion failed: $_" -Feature 'Configure'
|
|
}
|
|
finally {
|
|
try { $task.PowerShell.Dispose() } catch {}
|
|
$script:FeaturesPrewarmTask = $null
|
|
}
|
|
|
|
return $true
|
|
}
|
|
|
|
function Start-FeaturePrewarmAsync {
|
|
if ($script:FeaturesPrewarmTask) {
|
|
return
|
|
}
|
|
|
|
$featureFiles = Get-FeatureFiles
|
|
if ($featureFiles.Count -eq 0) {
|
|
$script:FeaturesCache = @()
|
|
$script:FeaturesCacheSignature = ''
|
|
return
|
|
}
|
|
|
|
$ps = [powershell]::Create()
|
|
$scriptBlock = @'
|
|
param([string]$FeaturesDir)
|
|
|
|
$featureFiles = Get-ChildItem -Path $FeaturesDir -Filter '*.ps1' -ErrorAction SilentlyContinue |
|
|
Sort-Object Name
|
|
|
|
$signature = (($featureFiles | ForEach-Object {
|
|
'{0}:{1}' -f $_.FullName, $_.LastWriteTimeUtc.Ticks
|
|
}) -join '|')
|
|
|
|
$features = [System.Collections.Generic.List[PSCustomObject]]::new()
|
|
$errors = [System.Collections.Generic.List[string]]::new()
|
|
|
|
foreach ($file in $featureFiles) {
|
|
try {
|
|
. $file.FullName
|
|
$meta = $FeatureMeta
|
|
$features.Add([PSCustomObject]@{
|
|
File = $file.FullName
|
|
Meta = $meta
|
|
})
|
|
}
|
|
catch {
|
|
$errors.Add(("Failed to load feature '{0}' during prewarm: {1}" -f $file.Name, $_.Exception.Message))
|
|
}
|
|
}
|
|
|
|
[PSCustomObject]@{
|
|
Signature = $signature
|
|
Features = @($features)
|
|
Errors = @($errors)
|
|
}
|
|
'@
|
|
|
|
$null = $ps.AddScript($scriptBlock).AddArgument((Join-Path $script:InternalRoot 'features'))
|
|
$handle = $ps.BeginInvoke()
|
|
|
|
$script:FeaturesPrewarmTask = @{
|
|
PowerShell = $ps
|
|
Handle = $handle
|
|
}
|
|
}
|
|
|
|
function Get-Features {
|
|
<#
|
|
.SYNOPSIS
|
|
Dot-sources every .ps1 in internal\features\ and collects their $FeatureMeta.
|
|
Returns an ordered list of [PSCustomObject]@{ File; Meta }.
|
|
#>
|
|
param([switch]$Refresh)
|
|
|
|
$featureFiles = Get-FeatureFiles
|
|
$cacheSignature = Get-FeatureCacheSignature -FeatureFiles $featureFiles
|
|
|
|
if (-not $Refresh -and $script:FeaturesCache -and $script:FeaturesCacheSignature -eq $cacheSignature) {
|
|
return $script:FeaturesCache
|
|
}
|
|
|
|
if (-not $Refresh) {
|
|
[void](Complete-FeaturePrewarm)
|
|
|
|
if ($script:FeaturesCache -and $script:FeaturesCacheSignature -eq $cacheSignature) {
|
|
return $script:FeaturesCache
|
|
}
|
|
|
|
if ($script:FeaturesPrewarmTask) {
|
|
Write-Hint 'Loading feature metadata, please wait...'
|
|
[void](Complete-FeaturePrewarm -Wait)
|
|
|
|
if ($script:FeaturesCache -and $script:FeaturesCacheSignature -eq $cacheSignature) {
|
|
return $script:FeaturesCache
|
|
}
|
|
}
|
|
}
|
|
|
|
$script:FeaturesCache = Load-FeaturesFromFiles -FeatureFiles $featureFiles
|
|
$script:FeaturesCacheSignature = $cacheSignature
|
|
return $script:FeaturesCache
|
|
}
|
|
|
|
# ═════════════════════════════════════════════════════════════════════════════
|
|
# 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'
|
|
$runnerLauncherPath = Join-Path $script:InternalRoot 'runner-launcher.vbs'
|
|
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'
|
|
$runnerLauncherPath = Join-Path $script:InternalRoot 'runner-launcher.vbs'
|
|
|
|
# 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
|
|
}
|
|
|
|
if (-not (Test-Path $runnerLauncherPath)) {
|
|
Write-Host " ERROR: runner-launcher.vbs not found at: $runnerLauncherPath" -ForegroundColor Red
|
|
Write-Log -Level Error -Message "runner-launcher.vbs missing at '$runnerLauncherPath'." -Feature 'Configure'
|
|
Write-OperationResult -Path $script:RegisterResultPath -Success $false -Message "runner-launcher.vbs missing at '$runnerLauncherPath'."
|
|
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 'wscript.exe' `
|
|
-Argument "//B //Nologo `"$runnerLauncherPath`" `"$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)'."
|
|
}
|
|
|
|
# Start the task so runner begins executing
|
|
Start-ScheduledTask -TaskName $script:TaskName -TaskPath $script:TaskPath -ErrorAction Stop
|
|
|
|
Write-Host " Task '$($script:TaskName)' registered and started 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-<date>.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<number> 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<number> — 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'
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function Initialize-FeatureCache {
|
|
try {
|
|
Start-FeaturePrewarmAsync
|
|
Write-Log -Level Info -Message 'Feature cache prewarm started in background.' -Feature 'Configure'
|
|
}
|
|
catch {
|
|
Write-Log -Level Warn -Message "Feature cache prewarm failed: $_" -Feature 'Configure'
|
|
}
|
|
}
|
|
|
|
# ═════════════════════════════════════════════════════════════════════════════
|
|
# Main menu
|
|
# ═════════════════════════════════════════════════════════════════════════════
|
|
|
|
function Invoke-RunnerNow {
|
|
$runnerPath = Join-Path $script:InternalRoot 'runner.ps1'
|
|
$runnerLauncherPath = Join-Path $script:InternalRoot 'runner-launcher.vbs'
|
|
$task = Get-RunnerTask
|
|
|
|
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-Host ''
|
|
Pause-ForKey
|
|
return
|
|
}
|
|
|
|
if (-not (Test-Path $runnerLauncherPath)) {
|
|
Write-Host " ERROR: runner-launcher.vbs not found at: $runnerLauncherPath" -ForegroundColor Red
|
|
Write-Log -Level Error -Message "runner-launcher.vbs missing at '$runnerLauncherPath'." -Feature 'Configure'
|
|
Write-Host ''
|
|
Pause-ForKey
|
|
return
|
|
}
|
|
|
|
try {
|
|
Write-Host ''
|
|
if ($task) {
|
|
Write-Host " Executing registered runner task now..." -ForegroundColor Cyan
|
|
Start-ScheduledTask -TaskName $task.TaskName -TaskPath $task.TaskPath -ErrorAction Stop
|
|
Write-Host " Task execution started (runs async). Check logs in a moment." -ForegroundColor Green
|
|
Write-Hint " Note: The 2-minute repetition timer only starts on next logon/restart."
|
|
Write-Log -Level Info -Message "Runner task invoked manually from configure menu." -Feature 'Configure'
|
|
}
|
|
else {
|
|
Write-Host " Task not registered; executing runner directly (hidden)..." -ForegroundColor Yellow
|
|
Start-Process -FilePath 'wscript.exe' -ArgumentList "//B //Nologo `"$runnerLauncherPath`" `"$runnerPath`"" -WindowStyle Hidden
|
|
Write-Host " Runner launch started (runs async). Check logs in a moment." -ForegroundColor Green
|
|
Write-Log -Level Info -Message "Runner invoked manually from configure menu via hidden launcher (task absent)." -Feature 'Configure'
|
|
}
|
|
}
|
|
catch {
|
|
Write-Host " ERROR: Failed to start runner: $_" -ForegroundColor Red
|
|
Write-Log -Level Error -Message "Failed to start runner manually: $_" -Feature 'Configure'
|
|
}
|
|
|
|
Write-Host ''
|
|
Pause-ForKey
|
|
}
|
|
|
|
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 ' 5 Execute runner now'
|
|
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 }
|
|
'5' { Invoke-RunnerNow }
|
|
{ $_ -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
|
|
}
|
|
}
|
|
|
|
if (-not $AutoRegisterRunner -and -not $AutoRemoveRunner) {
|
|
Initialize-FeatureCache
|
|
}
|
|
Show-MainMenu
|
|
|