diff --git a/README.md b/README.md index 4f60c4c..8961503 100644 --- a/README.md +++ b/README.md @@ -4,14 +4,18 @@ A unified framework for managing Windows automation tasks with a modular, extens ## Quick Start +Clone or extract the repo to any location, then: + ```powershell -# Navigate to the root folder -cd C:\Tools\ArnePowershellAutomation +# Navigate to the repo folder (any path works) +cd # Run the main menu .\configure.ps1 ``` +All paths are relative to `configure.ps1`, so the repo is portable. + ## Architecture ### Entry Point: `configure.ps1` @@ -218,6 +222,7 @@ When you select a feature for configuration, you'll be prompted for each setting - **Windows 11 Only for Toasts**: Toast notifications require WinRT (Windows 10+), but full Windows 11 support is assumed. Win10 may work but is untested. - **Scheduled Task Security**: The runner task executes as the current user with Limited RunLevel (no elevation). Features cannot perform system-wide administrative tasks; they're limited to user-level operations. - **DNS Suffix Matching**: Network detection relies on `Get-DnsClient` adapter DNS suffix. Make sure your network adapter is configured with the correct suffix for accurate detection. +- **Manual Execution & Repetition Timer**: Executing the runner manually via menu option 5 ("Execute runner now") runs the task immediately but does **not** start the 2-minute repetition timer. The repetition timer only activates on logon/restart. For continuous testing, you may need to restart or wait for next logon. ## Extending with New Features diff --git a/configure.ps1 b/configure.ps1 index 97140fe..a71a46b 100644 --- a/configure.ps1 +++ b/configure.ps1 @@ -180,19 +180,30 @@ function Read-OperationResult { # 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 +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) { + foreach ($file in $FeatureFiles) { try { . $file.FullName # defines $FeatureMeta + Invoke-Feature $meta = $FeatureMeta # copy before the next iteration overwrites it @@ -208,7 +219,140 @@ function Get-Features { } } - return $features + 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 } # ═════════════════════════════════════════════════════════════════════════════ @@ -395,7 +539,10 @@ function Register-Runner { throw "Register-ScheduledTask returned without creating '$($script:TaskName)'." } - Write-Host " Task '$($script:TaskName)' registered successfully." -ForegroundColor Green + # 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" @@ -604,10 +751,46 @@ function Show-FeatureMenu { } } +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 { + $task = Get-RunnerTask + if (-not $task) { + Write-Host " ERROR: Task is not registered. Use option 2 to register first." -ForegroundColor Red + Write-Host '' + Pause-ForKey + return + } + + try { + Write-Host '' + Write-Host " Executing runner task now..." -ForegroundColor Cyan + Start-ScheduledTask -TaskName $script:TaskName -TaskPath $script: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' + } + catch { + Write-Host " ERROR: Failed to start task: $_" -ForegroundColor Red + Write-Log -Level Error -Message "Failed to start runner task manually: $_" -Feature 'Configure' + } + + Write-Host '' + Pause-ForKey +} + function Show-MainMenu { while ($true) { Write-Header 'PowerShell Automation Center' @@ -615,6 +798,7 @@ function Show-MainMenu { 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 '' @@ -627,6 +811,7 @@ function Show-MainMenu { '2' { Register-Runner } '3' { Remove-Runner } '4' { Show-FeatureMenu } + '5' { Invoke-RunnerNow } { $_ -in 'q', 'Q' } { Clear-Host return @@ -679,5 +864,8 @@ if ($AutoRemoveRunner) { } } +if (-not $AutoRegisterRunner -and -not $AutoRemoveRunner) { + Initialize-FeatureCache +} Show-MainMenu diff --git a/internal/features/DefaultBrowser.ps1 b/internal/features/DefaultBrowser.ps1 index c6fa929..a842922 100644 --- a/internal/features/DefaultBrowser.ps1 +++ b/internal/features/DefaultBrowser.ps1 @@ -1,4 +1,105 @@ -# ── Feature metadata ────────────────────────────────────────────────────────── +# ── Browser detection helper ────────────────────────────────────────────────── + +function Get-InstalledBrowsers { + $knownBrowsers = @( + 'ChromeHTML', + 'FirefoxURL', + 'OperaGXStable', + 'Opera GXStable', + 'SafariHTML', + 'MSEdgeHTM', + 'BraveHTML', + 'Vivaldi', + 'IEexplore' + ) + + $installed = @() + foreach ($progId in $knownBrowsers) { + if (Test-Path "HKLM:\SOFTWARE\Classes\$progId" -ErrorAction SilentlyContinue) { + $installed += $progId + } + } + + return @($installed | Sort-Object) +} + +function Get-BrowserSearchText { + param([string]$ProgId) + + if (-not $ProgId) { return 'browser' } + + switch -Regex ($ProgId) { + '^Opera\s?GXStable$' { return 'opera gx' } + '^ChromeHTML$' { return 'chrome' } + '^FirefoxURL' { return 'firefox' } + '^MSEdgeHTM$' { return 'edge' } + '^BraveHTML$' { return 'brave' } + '^Vivaldi$' { return 'vivaldi' } + default { return $ProgId } + } +} + +function Invoke-DefaultBrowserGuidedChange { + param([Parameter(Mandatory)][string]$TargetProgId) + + $regPath = 'HKCU:\Software\Microsoft\Windows\Shell\Associations\UrlAssociations\http\UserChoice' + + try { + $searchText = Get-BrowserSearchText -ProgId $TargetProgId + + Stop-Process -ErrorAction Ignore -Name SystemSettings + Start-Process 'ms-settings:defaultapps' + + $ps = Get-Process -ErrorAction Stop SystemSettings + do { + Start-Sleep -Milliseconds 100 + $ps.Refresh() + } while ([int]$ps.MainWindowHandle -eq 0) + + Start-Sleep -Milliseconds 200 + + $shell = New-Object -ComObject WScript.Shell + + foreach ($i in 1..5) { + $shell.SendKeys('{TAB}') + Start-Sleep -Milliseconds 100 + } + + $shell.SendKeys($searchText) + Start-Sleep -Seconds 1 + + $shell.SendKeys('{TAB}') + Start-Sleep -Milliseconds 100 + $shell.SendKeys('{ENTER}') + Start-Sleep -Milliseconds 200 + $shell.SendKeys('{ENTER}') + Start-Sleep -Milliseconds 200 + $shell.SendKeys('%{F4}') + Start-Sleep -Milliseconds 300 + + $browser = (Get-ItemProperty -Path $regPath -Name 'ProgId' -ErrorAction Stop).ProgId + if ($browser -eq $TargetProgId) { + Write-Log -Level Info -Message "Default browser successfully changed to '$browser'." -Feature 'DefaultBrowser' + return $true + } + + Write-Log -Level Warn -Message "Browser change attempted, but current ProgId is '$browser' (expected '$TargetProgId')." -Feature 'DefaultBrowser' + return $false + } + catch { + Write-Log -Level Error -Message "Failed during guided default-browser change: $_" -Feature 'DefaultBrowser' + return $false + } +} + +# ── Feature metadata ────────────────────────────────────────────────────────── + +$installedBrowsers = Get-InstalledBrowsers +$browserListText = if ($installedBrowsers.Count -gt 0) { + "Detected on this system: " + ($installedBrowsers -join ', ') +} else { + "No known browsers detected. Examples: ChromeHTML, FirefoxURL, OperaGXStable, SafariHTML" +} $FeatureMeta = @{ Name = 'DefaultBrowser' @@ -9,7 +110,7 @@ $FeatureMeta = @{ Label = 'Target ProgId' Type = 'string' Default = 'OperaGXStable' - Description = 'ProgId of the desired default browser (e.g. OperaGXStable, ChromeHTML, FirefoxURL-308046B0AF4A39CB)' + Description = $browserListText } ) } @@ -23,16 +124,10 @@ function Invoke-Feature { ) if (-not $State) { $State = @{} } - if (-not $State.ContainsKey('lastShownDate')) { $State['lastShownDate'] = $null } + if (-not $State.ContainsKey('ignoreUntilDate')) { $State['ignoreUntilDate'] = $null } $today = (Get-Date).ToString('yyyy-MM-dd') - # Only notify once per day - if ($State['lastShownDate'] -eq $today) { - Write-Log -Level Info -Message 'Already checked today, skipping.' -Feature 'DefaultBrowser' - return $State - } - $regPath = 'HKCU:\Software\Microsoft\Windows\Shell\Associations\UrlAssociations\http\UserChoice' try { @@ -49,7 +144,14 @@ function Invoke-Feature { Write-Log -Level Info ` -Message "Default browser OK: '$currentProgId'." ` -Feature 'DefaultBrowser' - $State['lastShownDate'] = $today + $State['ignoreUntilDate'] = $null + return $State + } + + if ($State['ignoreUntilDate'] -eq $today) { + Write-Log -Level Info ` + -Message "Mismatch ignored for today (current '$currentProgId', expected '$($Config['targetProgId'])')." ` + -Feature 'DefaultBrowser' return $State } @@ -57,17 +159,29 @@ function Invoke-Feature { -Message "Default browser mismatch — found '$currentProgId', expected '$($Config['targetProgId'])'." ` -Feature 'DefaultBrowser' - # Note: Windows 11 blocks programmatic default-browser changes via registry hash protection. - # We guide the user to the Settings page instead. Show-ToastNotification ` -Title 'Default Browser' ` - -Body ("Default browser is '$currentProgId'. Click below to set it to $($Config['targetProgId']).") ` + -Body ("Default browser is '$currentProgId'. Do you want to change it now?") ` -Buttons @( - @{ Label = 'Open Default Apps Settings'; Action = 'ms-settings:defaultapps' }, - @{ Label = 'Dismiss'; Action = 'dismiss' } + @{ Label = 'Yes'; Action = 'ms-settings:defaultapps' }, + @{ Label = 'No'; Action = 'dismiss' } ) - $State['lastShownDate'] = $today + $decision = Show-ConfirmationDialog ` + -Title 'Default Browser' ` + -Message ("Default browser is '$currentProgId', expected '$($Config['targetProgId'])'.`n`nDo you want to change it now?") ` + -Feature 'DefaultBrowser' ` + -Default 'No' + + if ($decision -eq 'Yes') { + [void](Invoke-DefaultBrowserGuidedChange -TargetProgId $Config['targetProgId']) + $State['ignoreUntilDate'] = $null + } + else { + $State['ignoreUntilDate'] = $today + Write-Log -Level Info -Message 'User selected No; mismatch notifications suppressed until tomorrow.' -Feature 'DefaultBrowser' + } + return $State } diff --git a/internal/features/SandwichReminder.ps1 b/internal/features/SandwichReminder.ps1 index 37c0432..f5396c9 100644 --- a/internal/features/SandwichReminder.ps1 +++ b/internal/features/SandwichReminder.ps1 @@ -45,6 +45,7 @@ function Invoke-Feature { if (-not $State) { $State = @{} } if (-not $State.ContainsKey('lastShownDate')) { $State['lastShownDate'] = $null } + if (-not $State.ContainsKey('snoozeUntil')) { $State['snoozeUntil'] = $null } $today = (Get-Date).ToString('yyyy-MM-dd') @@ -54,6 +55,21 @@ function Invoke-Feature { return $State } + if ($State['snoozeUntil']) { + try { + $snoozeUntil = [datetime]::Parse($State['snoozeUntil']) + if ((Get-Date) -lt $snoozeUntil) { + Write-Log -Level Info ` + -Message ("Snoozed until {0}, skipping." -f $snoozeUntil.ToString('HH:mm:ss')) ` + -Feature 'SandwichReminder' + return $State + } + } + catch { + Write-Log -Level Warn -Message "Invalid snoozeUntil value '$($State['snoozeUntil'])', ignoring." -Feature 'SandwichReminder' + } + } + # Parse reminder time try { $targetTime = [datetime]::ParseExact($Config['reminderTime'], 'HH:mm', $null) @@ -86,17 +102,48 @@ function Invoke-Feature { return $State } - Write-Log -Level Info -Message 'Showing sandwich reminder toast.' -Feature 'SandwichReminder' + Write-Log -Level Info -Message 'Showing sandwich reminder confirmation dialog.' -Feature 'SandwichReminder' - Show-ToastNotification ` - -Title 'Sandwich Order' ` - -Body 'Do you want to order a sandwich today?' ` - -Buttons @( - @{ Label = 'Yes, order now!'; Action = $Config['url'] }, - @{ Label = 'No thanks'; Action = 'dismiss' } + $decision = Show-MultiChoiceDialog ` + -Title 'Sandwich Order' ` + -Message 'Do you want to order a sandwich today?' ` + -Feature 'SandwichReminder' ` + -DefaultValue 'snooze15' ` + -Choices @( + @{ Label = 'Order now'; Value = 'order' }, + @{ Label = 'Snooze 15 min'; Value = 'snooze15' }, + @{ Label = 'Snooze 1h'; Value = 'snooze60' }, + @{ Label = 'No'; Value = 'no' } ) - $State['lastShownDate'] = $today + if ($decision -eq 'order') { + try { + Start-Process $Config['url'] + Write-Log -Level Info -Message "Opened sandwich order URL: $($Config['url'])" -Feature 'SandwichReminder' + } + catch { + Write-Log -Level Error -Message "Failed to open sandwich order URL '$($Config['url'])': $_" -Feature 'SandwichReminder' + } + + $State['lastShownDate'] = $today + $State['snoozeUntil'] = $null + } + elseif ($decision -eq 'snooze60') { + $until = (Get-Date).AddHours(1) + $State['snoozeUntil'] = $until.ToString('o') + Write-Log -Level Info -Message ("User snoozed sandwich reminder for 1 hour (until {0})." -f $until.ToString('HH:mm:ss')) -Feature 'SandwichReminder' + } + elseif ($decision -eq 'no') { + $State['lastShownDate'] = $today + $State['snoozeUntil'] = $null + Write-Log -Level Info -Message 'User selected No for sandwich reminder; skipping for rest of day.' -Feature 'SandwichReminder' + } + else { + $until = (Get-Date).AddMinutes(15) + $State['snoozeUntil'] = $until.ToString('o') + Write-Log -Level Info -Message ("User snoozed sandwich reminder for 15 minutes (until {0})." -f $until.ToString('HH:mm:ss')) -Feature 'SandwichReminder' + } + return $State } diff --git a/internal/lib/PromptHelper.ps1 b/internal/lib/PromptHelper.ps1 new file mode 100644 index 0000000..0dfcb39 --- /dev/null +++ b/internal/lib/PromptHelper.ps1 @@ -0,0 +1,105 @@ +# PromptHelper.ps1 — shared foreground/system-modal confirmation dialog helper. + +function Show-ConfirmationDialog { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$Title, + [Parameter(Mandatory)][string]$Message, + [string]$Feature = 'PromptHelper', + [string]$Default = 'No' + ) + + try { + $shell = New-Object -ComObject WScript.Shell + # 4=YesNo, 32=Question icon, 4096=System modal, 65536=Foreground + $result = $shell.Popup($Message, 0, $Title, 4 + 32 + 4096 + 65536) + + switch ($result) { + 6 { return 'Yes' } + 7 { return 'No' } + default { return $Default } + } + } + catch { + Write-Log -Level Error -Message "Failed to show confirmation dialog '$Title': $_" -Feature $Feature + return $Default + } +} + +function Show-MultiChoiceDialog { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$Title, + [Parameter(Mandatory)][string]$Message, + [Parameter(Mandatory)][object[]]$Choices, + [string]$Feature = 'PromptHelper', + [string]$DefaultValue = '' + ) + + try { + Add-Type -AssemblyName System.Windows.Forms -ErrorAction SilentlyContinue | Out-Null + Add-Type -AssemblyName System.Drawing -ErrorAction SilentlyContinue | Out-Null + + if ($Choices.Count -lt 1 -or $Choices.Count -gt 4) { + throw 'Show-MultiChoiceDialog expects 1 to 4 choices.' + } + + $form = New-Object System.Windows.Forms.Form + $form.Text = $Title + $form.StartPosition = [System.Windows.Forms.FormStartPosition]::CenterScreen + $form.TopMost = $true + $form.Width = 480 + $form.Height = 190 + $form.MinimizeBox = $false + $form.MaximizeBox = $false + $form.FormBorderStyle = [System.Windows.Forms.FormBorderStyle]::FixedDialog + + $label = New-Object System.Windows.Forms.Label + $label.AutoSize = $false + $label.Left = 12 + $label.Top = 12 + $label.Width = 440 + $label.Height = 80 + $label.Text = $Message + $form.Controls.Add($label) + + $form.Tag = $DefaultValue + $gap = 10 + $availableWidth = $form.ClientSize.Width - 24 - ($gap * ($Choices.Count - 1)) + $buttonWidth = [Math]::Min(130, [Math]::Floor($availableWidth / $Choices.Count)) + $totalWidth = ($buttonWidth * $Choices.Count) + ($gap * ($Choices.Count - 1)) + $startX = [Math]::Max(12, [int](($form.ClientSize.Width - $totalWidth) / 2)) + + for ($i = 0; $i -lt $Choices.Count; $i++) { + $choice = $Choices[$i] + $button = New-Object System.Windows.Forms.Button + $button.Left = $startX + ($i * ($buttonWidth + $gap)) + $button.Top = 105 + $button.Width = $buttonWidth + $button.Height = 30 + $button.Text = [string]$choice.Label + $button.Tag = [string]$choice.Value + $button.Add_Click({ + param($sender, $eventArgs) + $dlg = $sender.FindForm() + $dlg.Tag = [string]$sender.Tag + $dlg.DialogResult = [System.Windows.Forms.DialogResult]::OK + $dlg.Close() + }) + $form.Controls.Add($button) + if ($i -eq 0) { + $form.AcceptButton = $button + } + } + + [void]$form.ShowDialog() + $result = [string]$form.Tag + $form.Dispose() + + return $result + } + catch { + Write-Log -Level Error -Message "Failed to show multi-choice dialog '$Title': $_" -Feature $Feature + return $DefaultValue + } +} diff --git a/internal/runner.ps1 b/internal/runner.ps1 index 1f41211..efabf1b 100644 --- a/internal/runner.ps1 +++ b/internal/runner.ps1 @@ -10,6 +10,7 @@ $script:InternalRoot = $PSScriptRoot # runner.ps1 lives in internal\ . (Join-Path $InternalRoot 'lib\Logging.ps1') . (Join-Path $InternalRoot 'lib\NetworkUtils.ps1') . (Join-Path $InternalRoot 'lib\ToastHelper.ps1') +. (Join-Path $InternalRoot 'lib\PromptHelper.ps1') . (Join-Path $InternalRoot 'lib\Config.ps1') Write-Log -Level Info -Message '────── Runner started ──────' -Feature 'Runner'