Compare commits

...
Author SHA1 Message Date
Simon Daniel Streich 4bbde1dfe2 anpassungen 2026-10-21 12:37:59 +02:00
5 changed files with 365 additions and 79 deletions

No files matched your search

+2
View File
@@ -3,3 +3,5 @@
.vscode/*
__pycache__/
*.py[cod]
/tests/*
!/tests/test_update_manifests.py
+4 -4
View File
@@ -1,9 +1,9 @@
#requires -Version 5.1
#requires -Version 5.1
<#
.SYNOPSIS
Aktualisiert Scoop und alle benutzerspezifischen Pakete; elexis* bleibt gesperrt.
.DESCRIPTION
Fuehrt ausschliesslich den Update-Lauf aus. Die Aufgabe wird von update-bucket.ps1
Führt ausschliesslich den Update-Lauf aus. Die Aufgabe wird von update-bucket.ps1
eingerichtet und startet dieses Skript verborgen. Der letzte Lauf steht in
auto-update.log neben diesem Skript. Globale Pakete werden nicht aktualisiert.
#>
@@ -25,7 +25,7 @@ $exitCode = 0
try {
Start-Transcript -LiteralPath (Join-Path $PSScriptRoot 'auto-update.log') -Force | Out-Null
$transcriptStarted = $true
# Ein absoluter Scoop-Pfad und die shims im PATH funktionieren auch ohne Profil.
# Ein absoluter Scoop-Pfad und die Shims im PATH funktionieren auch ohne Profil.
$env:PATH = (Join-Path $ScoopRoot 'shims') + [IO.Path]::PathSeparator + $env:PATH
$installed = @(& $scoopCommand list)
if ($LASTEXITCODE -ne 0) { throw 'Installierte Scoop-Pakete konnten nicht ermittelt werden.' }
@@ -33,7 +33,7 @@ try {
if ($package.Name -notlike 'elexis*' -or $package.Info -match 'Global install|Install failed') { continue }
& $scoopCommand hold $package.Name
if ($LASTEXITCODE -ne 0) {
throw "Updatesperre fuer '$($package.Name)' fehlgeschlagen. Kein Update ausgefuehrt."
throw "Updatesperre für '$($package.Name)' fehlgeschlagen. Kein Update ausgeführt."
}
}
& $scoopCommand update
+115 -21
View File
@@ -1,13 +1,14 @@
#requires -Version 5.1
#requires -Version 5.1
<#
.SYNOPSIS
Fragt nach und richtet optional die taeglichen Scoop-Auto-Updates ein.
Fragt nach und richtet optional die täglichen Scoop-Auto-Updates ein.
.DESCRIPTION
Eigenstaendig ausfuehrbar, ohne Bucket-Migration. Bei y wird auto-update.ps1
heruntergeladen und eine Aufgabe fuer taeglich 03:00 Uhr erstellt.
Eigenständig ausführbar, ohne Bucket-Migration. Bei y wird auto-update.ps1
heruntergeladen und eine Aufgabe für täglich 03:00 Uhr erstellt.
Die Aufgabe startet das Updateskript verborgen und holt verpasste Termine nach,
sobald die Benutzersitzung und eine Netzwerkverbindung verfuegbar sind.
Bei n wird nichts eingerichtet; bestehende Aufgaben bleiben unveraendert.
sobald die Benutzersitzung und eine Netzwerkverbindung verfügbar sind.
Bestehende Aufgaben können nach Rückfrage repariert oder samt Updateskript
entfernt werden. Werden beide Fragen verneint, bleibt alles unverändert.
.EXAMPLE
.\scripts\setup-auto-update.ps1
#>
@@ -20,9 +21,48 @@ $ErrorActionPreference = 'Stop'
$ScoopRoot = [IO.Path]::GetFullPath($ScoopRoot)
$repository = 'https://gitea.medelexis.ch/public/scoop'
do {
$autoUpdateAnswer = (Read-Host 'Scoop Auto-Update einschalten? [y/n]').Trim().ToLowerInvariant()
} while ($autoUpdateAnswer -notin @('y', 'n'))
$userId = [Security.Principal.WindowsIdentity]::GetCurrent().Name
$userSid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value
$taskName = 'Medelexis AG - Scoop AutoUpdate'
$legacyTaskName = "Medelexis-Scoop-AutoUpdate-$userSid"
$autoUpdateDirectory = Join-Path $ScoopRoot 'scripts\medelexis'
$existingTasks = @(
foreach ($name in @($taskName, $legacyTaskName)) {
Get-ScheduledTask -TaskName $name -ErrorAction SilentlyContinue
}
)
$autoUpdateRemoved = $false
if ($existingTasks.Count -gt 0) {
do {
$autoUpdateAnswer = (Read-Host 'Bestehende Scoop-Auto-Update-Aufgabe reparieren (ersetzen)? [y/n]').Trim().ToLowerInvariant()
} while ($autoUpdateAnswer -notin @('y', 'n'))
if ($autoUpdateAnswer -eq 'n') {
do {
$removeAnswer = (Read-Host 'Scoop-Auto-Update entfernen? [y/n]').Trim().ToLowerInvariant()
} while ($removeAnswer -notin @('y', 'n'))
if ($removeAnswer -eq 'y') {
foreach ($existingTask in $existingTasks) {
if ($existingTask.State -eq 'Running') {
Stop-ScheduledTask -TaskName $existingTask.TaskName
}
Unregister-ScheduledTask -TaskName $existingTask.TaskName -Confirm:$false
}
# Nur die zugehörigen Dateien entfernen, niemals den Ordner rekursiv.
foreach ($fileName in @('auto-update.ps1', 'auto-update-hidden.exe', 'auto-update.log')) {
$filePath = Join-Path $autoUpdateDirectory $fileName
if (Test-Path -LiteralPath $filePath -PathType Leaf) {
Remove-Item -LiteralPath $filePath -Force
}
}
$autoUpdateRemoved = $true
Write-Host 'Scoop-Auto-Update entfernt (Aufgabe und zugehörige Dateien).'
}
}
} else {
do {
$autoUpdateAnswer = (Read-Host 'Scoop-Auto-Update einschalten? [y/n]').Trim().ToLowerInvariant()
} while ($autoUpdateAnswer -notin @('y', 'n'))
}
if ($autoUpdateAnswer -eq 'y') {
$scoopCommand = Join-Path $ScoopRoot 'shims\scoop.ps1'
@@ -41,16 +81,67 @@ if ($autoUpdateAnswer -eq 'y') {
$downloadedScript = [Management.Automation.Language.Parser]::ParseFile($downloadPath, [ref] $parseTokens, [ref] $parseErrors)
if ($parseErrors.Count -gt 0 -or -not $downloadedScript.ParamBlock -or
'ScoopRoot' -notin @($downloadedScript.ParamBlock.Parameters.Name.VariablePath.UserPath)) {
throw 'Das heruntergeladene Auto-Update-Skript ist kein gueltiges Updateskript.'
throw 'Das heruntergeladene Auto-Update-Skript ist kein gültiges Updateskript.'
}
Move-Item -LiteralPath $downloadPath -Destination $autoUpdateScript -Force
# Nur die Aufgabe einrichten; das Updateskript wird durch die Aufgabe gestartet.
$userId = [Security.Principal.WindowsIdentity]::GetCurrent().Name
$userSid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value
$taskName = "Medelexis-Scoop-AutoUpdate-$userSid"
$powershell = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe'
$arguments = '-NoLogo -NoProfile -NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -File "{0}" -ScoopRoot "{1}"' -f $autoUpdateScript, $ScoopRoot
$action = New-ScheduledTaskAction -Execute $powershell -Argument $arguments
# Bei einer Reparatur zuerst eine laufende Aufgabe anhalten,
# damit ihr Starter ersetzt werden kann.
foreach ($existingTask in $existingTasks) {
if ($existingTask.State -eq 'Running') {
Stop-ScheduledTask -TaskName $existingTask.TaskName
}
}
# Ein GUI-Programm startet PowerShell ohne Konsole, auch wenn Windows
# Terminal als Standardterminal eingerichtet ist. Es wartet auf das Ende
# des Updates und reicht den Exitcode weiter (Fehlerstatus / IgnoreNew).
$launcherPath = Join-Path $autoUpdateDirectory 'auto-update-hidden.exe'
$launcherSource = @'
using System;
using System.Diagnostics;
public static class ScoopAutoUpdateLauncher {
[STAThread]
public static int Main(string[] args) {
if (args.Length != 2) return 2;
try {
var start = new ProcessStartInfo();
start.FileName = System.IO.Path.Combine(Environment.GetEnvironmentVariable("SystemRoot"),
@"System32\WindowsPowerShell\v1.0\powershell.exe");
start.Arguments = "-NoLogo -NoProfile -NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -File \"" +
args[0] + "\" -ScoopRoot \"" + args[1] + "\"";
start.UseShellExecute = false;
start.CreateNoWindow = true;
start.WindowStyle = ProcessWindowStyle.Hidden;
using (var child = Process.Start(start)) {
child.WaitForExit();
return child.ExitCode;
}
} catch { return 1; }
}
}
'@
$compiledLauncher = Join-Path $autoUpdateDirectory ('auto-update-{0}.exe' -f [guid]::NewGuid())
$launcherSourcePath = [IO.Path]::ChangeExtension($compiledLauncher, '.cs')
# Der .NET-Framework-Compiler erzeugt eine Windows-Anwendung auch unter
# PowerShell 7, wo Add-Type -OutputType WindowsApplication nicht existiert.
$compiler = @(
(Join-Path $env:SystemRoot 'Microsoft.NET\Framework64\v4.0.30319\csc.exe'),
(Join-Path $env:SystemRoot 'Microsoft.NET\Framework\v4.0.30319\csc.exe')
) | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } | Select-Object -First 1
if (-not $compiler) { throw 'Der Windows-.NET-Framework-Compiler wurde nicht gefunden.' }
try {
[IO.File]::WriteAllText($launcherSourcePath, $launcherSource, [Text.Encoding]::UTF8)
$compilerOutput = & $compiler /nologo /target:winexe "/out:$compiledLauncher" $launcherSourcePath 2>&1
if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath $compiledLauncher -PathType Leaf)) {
throw "Der fensterlose Starter konnte nicht erstellt werden: $($compilerOutput | Out-String)"
}
Move-Item -LiteralPath $compiledLauncher -Destination $launcherPath -Force
} finally {
foreach ($temporaryFile in @($compiledLauncher, $launcherSourcePath)) {
if (Test-Path -LiteralPath $temporaryFile) { Remove-Item -LiteralPath $temporaryFile }
}
}
$action = New-ScheduledTaskAction -Execute $launcherPath -Argument ('"{0}" "{1}"' -f $autoUpdateScript, $ScoopRoot.TrimEnd('\'))
$trigger = New-ScheduledTaskTrigger -Daily -At '03:00'
$principal = New-ScheduledTaskPrincipal -UserId $userId -LogonType Interactive -RunLevel Limited
$settings = New-ScheduledTaskSettingsSet -StartWhenAvailable -RunOnlyIfNetworkAvailable `
@@ -58,12 +149,15 @@ if ($autoUpdateAnswer -eq 'y') {
-ExecutionTimeLimit (New-TimeSpan -Hours 4)
Register-ScheduledTask -TaskName $taskName -Action $action -Trigger $trigger `
-Principal $principal -Settings $settings -Force `
-Description 'Scoop taeglich um 03:00 aktualisieren; verpasste Termine nachholen; elexis* auf hold setzen.' | Out-Null
Write-Host "Scoop Auto-Update eingeschaltet: $taskName (taeglich 03:00, verpasste Termine nachholen)."
Write-Host "Ausfuehrung verborgen unter $userId, sobald der Benutzer angemeldet ist; kein Aufwecken des PCs."
-Description 'Tägliche Aktualisierung der installierten Elemente (Elexis ausgeschlossen)' | Out-Null
# Die frühere Aufgabe dieses Benutzers entfernen, damit nur ein Update läuft.
if (Get-ScheduledTask -TaskName $legacyTaskName -ErrorAction SilentlyContinue) {
Unregister-ScheduledTask -TaskName $legacyTaskName -Confirm:$false
}
Write-Host "Scoop-Auto-Update eingeschaltet: $taskName"
} finally {
if (Test-Path -LiteralPath $downloadPath) { Remove-Item -LiteralPath $downloadPath }
}
} else {
Write-Host 'Auto-Update nicht eingerichtet. Eine eventuell bestehende Aufgabe bleibt unveraendert.'
} elseif (-not $autoUpdateRemoved) {
Write-Host 'Keine Änderung an Scoop-Auto-Update vorgenommen.'
}
+109 -16
View File
@@ -9,7 +9,7 @@ Ein passender bestehender Bucket wird umbenannt. Danach werden elexis und streic
Andere Buckets wie main und extras bleiben bestehen.
Alte Paketnamen mit Unterstrich werden auf passende Manifestnamen mit Bindestrich umgestellt.
ee-iso/ee_iso und ee-cli werden deinstalliert. MyElexis CLI wird neu installiert.
Mit -Apply wird abschliessend gefragt, ob taegliche Auto-Updates um 03:00 Uhr
Mit -Apply wird abschliessend gefragt, ob tägliche Auto-Updates um 03:00 Uhr
eingerichtet werden sollen. Das separate Skript wird aus dem Repository geladen.
.EXAMPLE
.\scripts\update-bucket.ps1
@@ -269,9 +269,48 @@ Write-Host "Fertig: $($changes.Count) install.json-Dateien auf medelexis umgeste
Write-Host "Paketnamen umgestellt: $($renames.Count)."
Write-Host 'Von elexis/streichtech/medelexis bleibt nur medelexis bestehen. Prüfung: scoop status --local'
do {
$autoUpdateAnswer = (Read-Host 'Scoop Auto-Update einschalten? [y/n]').Trim().ToLowerInvariant()
} while ($autoUpdateAnswer -notin @('y', 'n'))
$userId = [Security.Principal.WindowsIdentity]::GetCurrent().Name
$userSid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value
$taskName = 'Medelexis AG - Scoop AutoUpdate'
$legacyTaskName = "Medelexis-Scoop-AutoUpdate-$userSid"
$autoUpdateDirectory = Join-Path $ScoopRoot 'scripts\medelexis'
$existingTasks = @(
foreach ($name in @($taskName, $legacyTaskName)) {
Get-ScheduledTask -TaskName $name -ErrorAction SilentlyContinue
}
)
$autoUpdateRemoved = $false
if ($existingTasks.Count -gt 0) {
do {
$autoUpdateAnswer = (Read-Host 'Bestehende Scoop-Auto-Update-Aufgabe reparieren (ersetzen)? [y/n]').Trim().ToLowerInvariant()
} while ($autoUpdateAnswer -notin @('y', 'n'))
if ($autoUpdateAnswer -eq 'n') {
do {
$removeAnswer = (Read-Host 'Scoop-Auto-Update entfernen? [y/n]').Trim().ToLowerInvariant()
} while ($removeAnswer -notin @('y', 'n'))
if ($removeAnswer -eq 'y') {
foreach ($existingTask in $existingTasks) {
if ($existingTask.State -eq 'Running') {
Stop-ScheduledTask -TaskName $existingTask.TaskName
}
Unregister-ScheduledTask -TaskName $existingTask.TaskName -Confirm:$false
}
# Nur die zugehörigen Dateien entfernen, niemals den Ordner rekursiv.
foreach ($fileName in @('auto-update.ps1', 'auto-update-hidden.exe', 'auto-update.log')) {
$filePath = Join-Path $autoUpdateDirectory $fileName
if (Test-Path -LiteralPath $filePath -PathType Leaf) {
Remove-Item -LiteralPath $filePath -Force
}
}
$autoUpdateRemoved = $true
Write-Host 'Scoop-Auto-Update entfernt (Aufgabe und zugehörige Dateien).'
}
}
} else {
do {
$autoUpdateAnswer = (Read-Host 'Scoop-Auto-Update einschalten? [y/n]').Trim().ToLowerInvariant()
} while ($autoUpdateAnswer -notin @('y', 'n'))
}
if ($autoUpdateAnswer -eq 'y') {
$autoUpdateDirectory = Join-Path $ScoopRoot 'scripts\medelexis'
@@ -286,16 +325,67 @@ if ($autoUpdateAnswer -eq 'y') {
$downloadedScript = [Management.Automation.Language.Parser]::ParseFile($downloadPath, [ref] $parseTokens, [ref] $parseErrors)
if ($parseErrors.Count -gt 0 -or -not $downloadedScript.ParamBlock -or
'ScoopRoot' -notin @($downloadedScript.ParamBlock.Parameters.Name.VariablePath.UserPath)) {
throw 'Das heruntergeladene Auto-Update-Skript ist kein gueltiges Updateskript.'
throw 'Das heruntergeladene Auto-Update-Skript ist kein gültiges Updateskript.'
}
Move-Item -LiteralPath $downloadPath -Destination $autoUpdateScript -Force
# Nur die Aufgabe einrichten; das Updateskript wird durch die Aufgabe gestartet.
$userId = [Security.Principal.WindowsIdentity]::GetCurrent().Name
$userSid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value
$taskName = "Medelexis-Scoop-AutoUpdate-$userSid"
$powershell = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe'
$arguments = '-NoLogo -NoProfile -NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -File "{0}" -ScoopRoot "{1}"' -f $autoUpdateScript, $ScoopRoot
$action = New-ScheduledTaskAction -Execute $powershell -Argument $arguments
# Bei einer Reparatur zuerst eine laufende Aufgabe anhalten,
# damit ihr Starter ersetzt werden kann.
foreach ($existingTask in $existingTasks) {
if ($existingTask.State -eq 'Running') {
Stop-ScheduledTask -TaskName $existingTask.TaskName
}
}
# Ein GUI-Programm startet PowerShell ohne Konsole, auch wenn Windows
# Terminal als Standardterminal eingerichtet ist. Es wartet auf das Ende
# des Updates und reicht den Exitcode weiter (Fehlerstatus / IgnoreNew).
$launcherPath = Join-Path $autoUpdateDirectory 'auto-update-hidden.exe'
$launcherSource = @'
using System;
using System.Diagnostics;
public static class ScoopAutoUpdateLauncher {
[STAThread]
public static int Main(string[] args) {
if (args.Length != 2) return 2;
try {
var start = new ProcessStartInfo();
start.FileName = System.IO.Path.Combine(Environment.GetEnvironmentVariable("SystemRoot"),
@"System32\WindowsPowerShell\v1.0\powershell.exe");
start.Arguments = "-NoLogo -NoProfile -NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -File \"" +
args[0] + "\" -ScoopRoot \"" + args[1] + "\"";
start.UseShellExecute = false;
start.CreateNoWindow = true;
start.WindowStyle = ProcessWindowStyle.Hidden;
using (var child = Process.Start(start)) {
child.WaitForExit();
return child.ExitCode;
}
} catch { return 1; }
}
}
'@
$compiledLauncher = Join-Path $autoUpdateDirectory ('auto-update-{0}.exe' -f [guid]::NewGuid())
$launcherSourcePath = [IO.Path]::ChangeExtension($compiledLauncher, '.cs')
# Der .NET-Framework-Compiler erzeugt eine Windows-Anwendung auch unter
# PowerShell 7, wo Add-Type -OutputType WindowsApplication nicht existiert.
$compiler = @(
(Join-Path $env:SystemRoot 'Microsoft.NET\Framework64\v4.0.30319\csc.exe'),
(Join-Path $env:SystemRoot 'Microsoft.NET\Framework\v4.0.30319\csc.exe')
) | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } | Select-Object -First 1
if (-not $compiler) { throw 'Der Windows-.NET-Framework-Compiler wurde nicht gefunden.' }
try {
[IO.File]::WriteAllText($launcherSourcePath, $launcherSource, [Text.Encoding]::UTF8)
$compilerOutput = & $compiler /nologo /target:winexe "/out:$compiledLauncher" $launcherSourcePath 2>&1
if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath $compiledLauncher -PathType Leaf)) {
throw "Der fensterlose Starter konnte nicht erstellt werden: $($compilerOutput | Out-String)"
}
Move-Item -LiteralPath $compiledLauncher -Destination $launcherPath -Force
} finally {
foreach ($temporaryFile in @($compiledLauncher, $launcherSourcePath)) {
if (Test-Path -LiteralPath $temporaryFile) { Remove-Item -LiteralPath $temporaryFile }
}
}
$action = New-ScheduledTaskAction -Execute $launcherPath -Argument ('"{0}" "{1}"' -f $autoUpdateScript, $ScoopRoot.TrimEnd('\'))
$trigger = New-ScheduledTaskTrigger -Daily -At '03:00'
$principal = New-ScheduledTaskPrincipal -UserId $userId -LogonType Interactive -RunLevel Limited
$settings = New-ScheduledTaskSettingsSet -StartWhenAvailable -RunOnlyIfNetworkAvailable `
@@ -303,12 +393,15 @@ if ($autoUpdateAnswer -eq 'y') {
-ExecutionTimeLimit (New-TimeSpan -Hours 4)
Register-ScheduledTask -TaskName $taskName -Action $action -Trigger $trigger `
-Principal $principal -Settings $settings -Force `
-Description 'Scoop taeglich um 03:00 aktualisieren; verpasste Termine nachholen; elexis* auf hold setzen.' | Out-Null
Write-Host "Scoop Auto-Update eingeschaltet: $taskName (taeglich 03:00, verpasste Termine nachholen)."
Write-Host "Ausfuehrung verborgen unter $userId, sobald der Benutzer angemeldet ist; kein Aufwecken des PCs."
-Description 'Tägliche Aktualisierung der installierten Elemente (Elexis ausgeschlossen)' | Out-Null
# Die frühere Aufgabe dieses Benutzers entfernen, damit nur ein Update läuft.
if (Get-ScheduledTask -TaskName $legacyTaskName -ErrorAction SilentlyContinue) {
Unregister-ScheduledTask -TaskName $legacyTaskName -Confirm:$false
}
Write-Host "Scoop-Auto-Update eingeschaltet: $taskName"
} finally {
if (Test-Path -LiteralPath $downloadPath) { Remove-Item -LiteralPath $downloadPath }
}
} else {
Write-Host 'Auto-Update nicht eingerichtet. Eine eventuell bestehende Aufgabe bleibt unveraendert.'
} elseif (-not $autoUpdateRemoved) {
Write-Host 'Keine Änderung an Scoop-Auto-Update vorgenommen.'
}
+135 -38
View File
@@ -1,4 +1,4 @@
#requires -Version 5.1
#requires -Version 5.1
# Isolated behavioural tests: no real downloads, Scoop updates or scheduled tasks.
$ErrorActionPreference = 'Stop'
$projectRoot = Split-Path $PSScriptRoot -Parent
@@ -11,6 +11,14 @@ $oldFailure = $env:SCOOP_AUTO_UPDATE_TEST_FAILURE
function Assert-True($Condition, [string] $Message) {
if (-not $Condition) { throw $Message }
}
function Assert-HiddenTask($Task, [string] $Runner, [string] $ScoopRoot) {
Assert-True ($Task.TaskName -eq 'Medelexis AG - Scoop AutoUpdate') 'Unexpected task name.'
$description = 'T' + [char]0x00e4 + 'gliche Aktualisierung der installierten Elemente (Elexis ausgeschlossen)'
Assert-True ($Task.Description -ceq $description) 'Unexpected task description.'
$launcher = Join-Path (Split-Path $Runner -Parent) 'auto-update-hidden.exe'
Assert-True ($Task.Action.Execute -eq $launcher -and (Test-Path -LiteralPath $launcher)) 'Task must use the GUI launcher.'
Assert-True ($Task.Action.Argument -eq ('"{0}" "{1}"' -f $Runner, $ScoopRoot)) 'Expected quoted runner and Scoop root.'
}
# These stand-ins capture scheduling settings without changing Windows.
function New-ScheduledTaskAction { param($Execute, $Argument) [pscustomobject]$PSBoundParameters }
@@ -25,13 +33,32 @@ function New-ScheduledTaskSettingsSet {
function Register-ScheduledTask {
param($TaskName, $Action, $Trigger, $Principal, $Settings, [switch]$Force, $Description)
$global:AutoUpdateTestTask = [pscustomobject]$PSBoundParameters
$global:AutoUpdateTestTasks[$TaskName] = $global:AutoUpdateTestTask
}
function Get-ScheduledTask {
param($TaskName, $ErrorAction)
if ($global:AutoUpdateTestTasks.ContainsKey($TaskName)) { $global:AutoUpdateTestTasks[$TaskName] }
}
function Unregister-ScheduledTask {
param($TaskName, [switch]$Confirm)
$expected = 'Medelexis-Scoop-AutoUpdate-' + [Security.Principal.WindowsIdentity]::GetCurrent().User.Value
Assert-True ($TaskName -in @($expected, 'Medelexis AG - Scoop AutoUpdate') -and -not $Confirm) 'Only remove the matching auto-update task.'
$global:AutoUpdateTestRemovedTask = $TaskName
$global:AutoUpdateTestTasks.Remove($TaskName)
}
function Stop-ScheduledTask {
param($TaskName)
$global:AutoUpdateTestStoppedTask = $TaskName
$global:AutoUpdateTestTasks[$TaskName].State = 'Ready'
}
function Read-Host {
param($Prompt)
$global:AutoUpdateTestPrompts.Add($Prompt)
$global:AutoUpdateTestAnswers.Dequeue()
}
function Invoke-WebRequest {
param($Uri, $OutFile, [switch]$UseBasicParsing)
$global:AutoUpdateTestDownloads++
Assert-True ($Uri -in @('https://example.test/scoop/raw/branch/master/scripts/auto-update.ps1',
'https://gitea.medelexis.ch/public/scoop/raw/branch/master/scripts/auto-update.ps1')) 'Unexpected download URL.'
if ($global:AutoUpdateTestInvalidDownload) {
@@ -46,7 +73,7 @@ throw 'The downloaded runner must not execute during setup.'
try {
# Spaces in the installation path exercise the scheduled command's quoting.
$scoopRoot = Join-Path $fixtureRoot 'scoop with spaces'
$scoopRoot = Join-Path $fixtureRoot ('scoop with spaces ' + [char]0x00e4)
$shims = Join-Path $scoopRoot 'shims'
New-Item -ItemType Directory -Path $shims -Force | Out-Null
$scriptPath = Join-Path $fixtureRoot 'auto-update.ps1'
@@ -85,66 +112,136 @@ exit 0
$commands = @(Get-Content $commandLog)
Assert-True ($commands[-1] -eq $failure) "Must abort immediately after $failure."
}
# Exercise the actual opt-in/download block without running the bucket migration.
# Test all entry points without migrating buckets or changing real tasks.
$tokens = $null
$errors = $null
$bucketAst = [Management.Automation.Language.Parser]::ParseFile((Join-Path $projectRoot 'scripts\update-bucket.ps1'), [ref]$tokens, [ref]$errors)
Assert-True ($errors.Count -eq 0) 'Invalid bucket script syntax.'
$optInBlock = [scriptblock]::Create((($bucketAst.EndBlock.Statements | Select-Object -Last 2 | ForEach-Object { $_.Extent.Text }) -join "`n"))
$setupStatements = @($bucketAst.EndBlock.Statements)
$setupStart = 0
while ($setupStart -lt $setupStatements.Count -and -not $setupStatements[$setupStart].Extent.Text.StartsWith('$userId =')) { $setupStart++ }
Assert-True ($setupStart -lt $setupStatements.Count) 'Setup block missing in bucket migration.'
$optInBlock = [scriptblock]::Create((($setupStatements[$setupStart..($setupStatements.Count - 1)] | ForEach-Object { $_.Extent.Text }) -join "`n"))
$repository = 'https://example.test/scoop'
Remove-Item -LiteralPath $commandLog
$global:AutoUpdateTestAnswers = [Collections.Generic.Queue[string]]::new()
$global:AutoUpdateTestAnswers.Enqueue('n')
& $optInBlock
$global:AutoUpdateTestPrompts = [Collections.Generic.List[string]]::new()
$global:AutoUpdateTestTasks = @{}
$global:AutoUpdateTestDownloads = 0
$downloadDirectory = Join-Path $scoopRoot 'scripts\medelexis'
Assert-True (-not (Test-Path $downloadDirectory)) 'Declining must not download or set up anything.'
$downloadedPath = Join-Path $downloadDirectory 'auto-update.ps1'
$launcherPath = Join-Path $downloadDirectory 'auto-update-hidden.exe'
$taskName = 'Medelexis AG - Scoop AutoUpdate'
$legacyName = 'Medelexis-Scoop-AutoUpdate-' + [Security.Principal.WindowsIdentity]::GetCurrent().User.Value
$setupEntries = @($optInBlock, (Join-Path $projectRoot 'scripts\setup-auto-update.ps1'))
foreach ($entry in $setupEntries) {
$global:AutoUpdateTestPrompts.Clear()
$global:AutoUpdateTestAnswers.Enqueue('n')
& $entry -ScoopRoot $scoopRoot
Assert-True ($global:AutoUpdateTestPrompts.Count -eq 1 -and $global:AutoUpdateTestPrompts[0] -eq 'Scoop-Auto-Update einschalten? [y/n]') 'Missing task must offer normal setup.'
Assert-True ($global:AutoUpdateTestTasks.Count -eq 0) 'Declining initial setup must not register a task.'
$global:AutoUpdateTestInvalidDownload = $false
$global:AutoUpdateTestAnswers.Enqueue('invalid')
$global:AutoUpdateTestAnswers.Enqueue('Y')
& $optInBlock
$downloadedPath = Join-Path $downloadDirectory 'auto-update.ps1'
Assert-True (Test-Path $downloadedPath) 'Expected downloaded runner.'
$task = $global:AutoUpdateTestTask
Assert-True ($task.Trigger.Daily -and $task.Trigger.At -eq '03:00') 'Expected daily 03:00 schedule.'
Assert-True ($task.Settings.StartWhenAvailable -and $task.Settings.RunOnlyIfNetworkAvailable) 'Expected catch-up and network settings.'
Assert-True ($task.Settings.MultipleInstances -eq 'IgnoreNew') 'Expected overlap protection.'
& $entry -ScoopRoot $scoopRoot
$task = $global:AutoUpdateTestTasks[$taskName]
Assert-HiddenTask $task $downloadedPath $scoopRoot
Assert-True ($task.Trigger.Daily -and $task.Trigger.At -eq '03:00' -and $task.Settings.StartWhenAvailable) 'Expected daily 03:00 with catch-up.'
Assert-True ($task.Settings.RunOnlyIfNetworkAvailable -and $task.Settings.MultipleInstances -eq 'IgnoreNew') 'Expected network and overlap settings.'
Assert-True ($task.Principal.LogonType -eq 'Interactive' -and $task.Principal.RunLevel -eq 'Limited') 'Expected normal user session.'
Assert-True ($task.Action.Argument.Contains('-WindowStyle Hidden') -and $task.Action.Argument.Contains('-NonInteractive')) 'Expected background execution.'
Assert-True ($task.Action.Argument.Contains('-File "' + $downloadedPath + '"') -and $task.Action.Argument.Contains('-ScoopRoot "' + $scoopRoot + '"')) 'Expected quoted paths pointing to the downloaded runner.'
Assert-True (-not (Test-Path $commandLog)) 'Setup must not execute Scoop.'
$taskName = $task.TaskName
Assert-True (-not (Test-Path $commandLog)) 'Setup must not execute Scoop or migrations.'
# No/no must leave task, runner and launcher byte-for-byte unchanged.
$downloads = $global:AutoUpdateTestDownloads
$runnerBefore = (Get-FileHash -LiteralPath $downloadedPath).Hash
$launcherBefore = (Get-FileHash -LiteralPath $launcherPath).Hash
$global:AutoUpdateTestPrompts.Clear()
$global:AutoUpdateTestAnswers.Enqueue('n')
$global:AutoUpdateTestAnswers.Enqueue('n')
& $entry -ScoopRoot $scoopRoot
Assert-True ($global:AutoUpdateTestPrompts.Count -eq 2 -and $global:AutoUpdateTestPrompts[0] -like '*reparieren*' -and $global:AutoUpdateTestPrompts[1] -like '*entfernen*') 'Expected repair then removal questions.'
Assert-True ($global:AutoUpdateTestTasks[$taskName] -eq $task -and $global:AutoUpdateTestDownloads -eq $downloads) 'No/no must preserve the task and avoid downloading.'
Assert-True ((Get-FileHash -LiteralPath $downloadedPath).Hash -eq $runnerBefore -and (Get-FileHash -LiteralPath $launcherPath).Hash -eq $launcherBefore) 'No/no must preserve files.'
# Repair downloads and replaces the existing task, without duplicating it.
$global:AutoUpdateTestPrompts.Clear()
$global:AutoUpdateTestAnswers.Enqueue('y')
& $optInBlock
Assert-True ($global:AutoUpdateTestTask.TaskName -eq $taskName -and $global:AutoUpdateTestTask.Force) 'Repeated setup must replace the same task.'
$originalDownload = [IO.File]::ReadAllText($downloadedPath)
Remove-Variable AutoUpdateTestTask -Scope Global
& $entry -ScoopRoot $scoopRoot
Assert-True ($global:AutoUpdateTestPrompts.Count -eq 1 -and $global:AutoUpdateTestPrompts[0] -like '*reparieren*') 'Repair must ask only the repair question.'
Assert-True ($global:AutoUpdateTestDownloads -eq $downloads + 1 -and $global:AutoUpdateTestTasks.Count -eq 1) 'Repair must download and replace the existing task.'
Assert-HiddenTask $global:AutoUpdateTestTasks[$taskName] $downloadedPath $scoopRoot
# Failed repair must retain the prior runner and task.
$task = $global:AutoUpdateTestTasks[$taskName]
$global:AutoUpdateTestInvalidDownload = $true
$global:AutoUpdateTestAnswers.Enqueue('y')
$rejected = $false
try { & $optInBlock } catch { $rejected = $true }
try { & $entry -ScoopRoot $scoopRoot } catch { $rejected = $true }
Assert-True $rejected 'Invalid download must be rejected.'
Assert-True ([IO.File]::ReadAllText($downloadedPath) -ceq $originalDownload) 'Invalid download must preserve the existing script.'
Assert-True ($null -eq (Get-Variable AutoUpdateTestTask -Scope Global -ErrorAction SilentlyContinue)) 'Invalid download must not register a task.'
Assert-True ($global:AutoUpdateTestTasks[$taskName] -eq $task -and (Get-FileHash -LiteralPath $downloadedPath).Hash -eq $runnerBefore) 'Failed repair must preserve the existing runner and task.'
Assert-True (@(Get-ChildItem $downloadDirectory -Filter '*.download').Count -eq 0) 'Temporary downloads must be removed.'
# The additional standalone installer must offer the same setup without migration.
$standaloneSetup = Join-Path $projectRoot 'scripts\setup-auto-update.ps1'
$global:AutoUpdateTestAnswers.Enqueue('n')
& $standaloneSetup -ScoopRoot $scoopRoot
Assert-True ($null -eq (Get-Variable AutoUpdateTestTask -Scope Global -ErrorAction SilentlyContinue)) 'Standalone n must not register a task.'
$global:AutoUpdateTestInvalidDownload = $false
# Also recognize the earlier task name, and remove only managed files.
$global:AutoUpdateTestTasks.Clear()
$global:AutoUpdateTestTasks[$legacyName] = [pscustomobject]@{ TaskName = $legacyName; State = 'Running' }
Set-Content -LiteralPath (Join-Path $downloadDirectory 'auto-update.log') -Value 'old log'
Set-Content -LiteralPath (Join-Path $downloadDirectory 'unrelated.txt') -Value 'keep'
$downloads = $global:AutoUpdateTestDownloads
$global:AutoUpdateTestAnswers.Enqueue('n')
$global:AutoUpdateTestAnswers.Enqueue('y')
& $standaloneSetup -ScoopRoot $scoopRoot
$task = $global:AutoUpdateTestTask
Assert-True ($task.TaskName -eq $taskName -and $task.Force) 'Standalone setup must use the same task.'
Assert-True ($task.Trigger.Daily -and $task.Trigger.At -eq '03:00' -and $task.Settings.StartWhenAvailable) 'Standalone setup must schedule daily 03:00 with catch-up.'
Assert-True ($task.Action.Argument.Contains('-WindowStyle Hidden') -and $task.Action.Argument.Contains('-File "' + $downloadedPath + '"')) 'Standalone task must invoke the downloaded runner hidden.'
Assert-True (-not (Test-Path $commandLog)) 'Standalone setup must not run Scoop or migrations.'
Write-Host 'PASS: schedule, repeat setup, hold filtering, command order, logging, failures, opt-in and download setup.'
& $entry -ScoopRoot $scoopRoot
Assert-True ($global:AutoUpdateTestTasks.Count -eq 0 -and $global:AutoUpdateTestStoppedTask -eq $legacyName) 'Remove must stop and unregister the existing legacy task.'
Assert-True (-not (Test-Path $downloadedPath) -and -not (Test-Path $launcherPath) -and -not (Test-Path (Join-Path $downloadDirectory 'auto-update.log'))) 'Remove must delete the runner, launcher and log.'
Assert-True ((Test-Path (Join-Path $downloadDirectory 'unrelated.txt')) -and $global:AutoUpdateTestDownloads -eq $downloads) 'Remove must preserve unrelated files and avoid downloading.'
# Repair migrates the legacy name to the requested task name.
$global:AutoUpdateTestTasks[$legacyName] = [pscustomobject]@{ TaskName = $legacyName; State = 'Ready' }
$global:AutoUpdateTestAnswers.Enqueue('y')
& $entry -ScoopRoot $scoopRoot
Assert-True ($global:AutoUpdateTestTasks.Count -eq 1 -and $global:AutoUpdateTestTasks.ContainsKey($taskName)) 'Repair must migrate the legacy task.'
# Removal of the current name must offer the same behaviour.
$global:AutoUpdateTestAnswers.Enqueue('n')
$global:AutoUpdateTestAnswers.Enqueue('y')
& $entry -ScoopRoot $scoopRoot
Assert-True ($global:AutoUpdateTestTasks.Count -eq 0 -and -not (Test-Path $downloadedPath)) 'Remove must also handle the current task name.'
}
# Prepare one local launcher for the real hidden-process smoke test below.
$global:AutoUpdateTestAnswers.Enqueue('y')
& (Join-Path $projectRoot 'scripts\setup-auto-update.ps1') -ScoopRoot $scoopRoot
$task = $global:AutoUpdateTestTasks[$taskName]
# Run only a harmless local fixture through the actual GUI launcher. No Scoop
# or scheduler calls: verify quoting, hidden child window and exit propagation.
@'
param([string] $ScoopRoot)
Add-Type -TypeDefinition @"
using System;
using System.Runtime.InteropServices;
public class AutoUpdateWindowTest {
[DllImport("kernel32.dll")] public static extern IntPtr GetConsoleWindow();
[DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr handle);
}
"@
$visible = [AutoUpdateWindowTest]::IsWindowVisible([AutoUpdateWindowTest]::GetConsoleWindow())
Set-Content -LiteralPath (Join-Path $ScoopRoot 'hidden-test.txt') -Value ([string]$visible)
exit 23
'@ | Set-Content -LiteralPath $downloadedPath -Encoding UTF8
$process = Start-Process -FilePath $task.Action.Execute -ArgumentList $task.Action.Argument -WindowStyle Hidden -PassThru
if (-not $process.WaitForExit(20000)) {
$process.Kill()
throw 'Hidden launcher did not finish within 20 seconds.'
}
Assert-True ($process.ExitCode -eq 23) 'Launcher must forward the runner exit code.'
Assert-True ((Get-Content -LiteralPath (Join-Path $scoopRoot 'hidden-test.txt')) -eq 'False') 'Runner must have no visible console window.'
Write-Host 'PASS: install, repair, no/no, removal, legacy tasks, hidden launcher, schedule, hold filtering and failures.'
} finally {
$env:SCOOP = $oldScoop
$env:PATH = $oldPath
$env:SCOOP_AUTO_UPDATE_TEST_ROOT = $oldFixture
$env:SCOOP_AUTO_UPDATE_TEST_FAILURE = $oldFailure
Remove-Variable AutoUpdateTestTask, AutoUpdateTestAnswers, AutoUpdateTestInvalidDownload -Scope Global -ErrorAction SilentlyContinue
Remove-Variable AutoUpdateTestTask, AutoUpdateTestAnswers, AutoUpdateTestInvalidDownload, AutoUpdateTestRemovedTask, AutoUpdateTestTasks, AutoUpdateTestDownloads, AutoUpdateTestPrompts, AutoUpdateTestStoppedTask -Scope Global -ErrorAction SilentlyContinue
# Delete only this test's unique directory, after checking its absolute parent.
if ((Test-Path -LiteralPath $fixtureRoot) -and
[IO.Path]::GetDirectoryName([IO.Path]::GetFullPath($fixtureRoot)) -eq [IO.Path]::GetFullPath($PSScriptRoot) -and