Compare commits

...
2 Commits
Author SHA1 Message Date
Simon Daniel Streich 4bbde1dfe2 anpassungen 2026-10-21 12:37:59 +02:00
Simon Daniel Streich ee6d5b9b1e init 2026-10-21 12:06:08 +02:00
6 changed files with 650 additions and 2 deletions

No files matched your search

+2
View File
@@ -3,3 +3,5 @@
.vscode/* .vscode/*
__pycache__/ __pycache__/
*.py[cod] *.py[cod]
/tests/*
!/tests/test_update_manifests.py
+49
View File
@@ -0,0 +1,49 @@
#requires -Version 5.1
<#
.SYNOPSIS
Aktualisiert Scoop und alle benutzerspezifischen Pakete; elexis* bleibt gesperrt.
.DESCRIPTION
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.
#>
[CmdletBinding()]
param(
[string] $ScoopRoot = $(if ($env:SCOOP) { $env:SCOOP } else { Join-Path $env:USERPROFILE 'scoop' })
)
$ErrorActionPreference = 'Stop'
$ScoopRoot = [IO.Path]::GetFullPath($ScoopRoot)
$env:SCOOP = $ScoopRoot
$scoopCommand = Join-Path $ScoopRoot 'shims\scoop.ps1'
if (-not (Test-Path -LiteralPath $scoopCommand -PathType Leaf)) {
throw "Scoop wurde nicht gefunden: $scoopCommand"
}
$transcriptStarted = $false
$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.
$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.' }
foreach ($package in $installed) {
if ($package.Name -notlike 'elexis*' -or $package.Info -match 'Global install|Install failed') { continue }
& $scoopCommand hold $package.Name
if ($LASTEXITCODE -ne 0) {
throw "Updatesperre für '$($package.Name)' fehlgeschlagen. Kein Update ausgeführt."
}
}
& $scoopCommand update
if ($LASTEXITCODE -ne 0) { throw 'scoop update fehlgeschlagen.' }
& $scoopCommand update '*'
if ($LASTEXITCODE -ne 0) { throw 'scoop update * fehlgeschlagen.' }
} catch {
Write-Error $_ -ErrorAction Continue
$exitCode = 1
} finally {
if ($transcriptStarted) { Stop-Transcript | Out-Null }
}
exit $exitCode
+1 -1
View File
@@ -1,4 +1,4 @@
# 1. SCOOP CHECK & INSTALL # 1. SCOOP CHECK & INSTALL
$bucketList = scoop bucket list 2>$null | Out-String $bucketList = scoop bucket list 2>$null | Out-String
if ($bucketList) { if ($bucketList) {
+163
View File
@@ -0,0 +1,163 @@
#requires -Version 5.1
<#
.SYNOPSIS
Fragt nach und richtet optional die täglichen Scoop-Auto-Updates ein.
.DESCRIPTION
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 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
#>
[CmdletBinding()]
param(
[string] $ScoopRoot = $(if ($env:SCOOP) { $env:SCOOP } else { Join-Path $env:USERPROFILE 'scoop' })
)
$ErrorActionPreference = 'Stop'
$ScoopRoot = [IO.Path]::GetFullPath($ScoopRoot)
$repository = 'https://gitea.medelexis.ch/public/scoop'
$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'
if (-not (Test-Path -LiteralPath $scoopCommand -PathType Leaf)) {
throw "Scoop wurde nicht gefunden: $scoopCommand"
}
$autoUpdateDirectory = Join-Path $ScoopRoot 'scripts\medelexis'
New-Item -ItemType Directory -Path $autoUpdateDirectory -Force | Out-Null
$autoUpdateScript = Join-Path $autoUpdateDirectory 'auto-update.ps1'
$downloadPath = Join-Path $autoUpdateDirectory ('auto-update-{0}.download' -f [guid]::NewGuid())
try {
$autoUpdateUrl = "$repository/raw/branch/master/scripts/auto-update.ps1"
Invoke-WebRequest -Uri $autoUpdateUrl -OutFile $downloadPath -UseBasicParsing
$parseTokens = $null
$parseErrors = $null
$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 gültiges Updateskript.'
}
Move-Item -LiteralPath $downloadPath -Destination $autoUpdateScript -Force
# Nur die Aufgabe einrichten; das Updateskript wird durch die Aufgabe gestartet.
# 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 `
-AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -MultipleInstances IgnoreNew `
-ExecutionTimeLimit (New-TimeSpan -Hours 4)
Register-ScheduledTask -TaskName $taskName -Action $action -Trigger $trigger `
-Principal $principal -Settings $settings -Force `
-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 }
}
} elseif (-not $autoUpdateRemoved) {
Write-Host 'Keine Änderung an Scoop-Auto-Update vorgenommen.'
}
+184 -1
View File
@@ -3,11 +3,14 @@
.SYNOPSIS .SYNOPSIS
Stellt bestehende Scoop-Installationen von elexis/streichtech auf medelexis um. Stellt bestehende Scoop-Installationen von elexis/streichtech auf medelexis um.
.DESCRIPTION .DESCRIPTION
Ohne -Apply nur ein Probelauf. Keine Sicherungen, Neuinstallationen oder Git-Pushes. Ohne -Apply nur ein Probelauf. Keine Sicherungen oder Git-Pushes.
Bearbeitet alle Versionsverzeichnisse der benutzerspezifischen Scoop-Installation. Bearbeitet alle Versionsverzeichnisse der benutzerspezifischen Scoop-Installation.
Ein passender bestehender Bucket wird umbenannt. Danach werden elexis und streichtech entfernt. Ein passender bestehender Bucket wird umbenannt. Danach werden elexis und streichtech entfernt.
Andere Buckets wie main und extras bleiben bestehen. Andere Buckets wie main und extras bleiben bestehen.
Alte Paketnamen mit Unterstrich werden auf passende Manifestnamen mit Bindestrich umgestellt. 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 tägliche Auto-Updates um 03:00 Uhr
eingerichtet werden sollen. Das separate Skript wird aus dem Repository geladen.
.EXAMPLE .EXAMPLE
.\scripts\update-bucket.ps1 .\scripts\update-bucket.ps1
.EXAMPLE .EXAMPLE
@@ -40,6 +43,7 @@ function Test-MedelexisRepository([string] $Path) {
function Get-PackageRenames([string] $BucketPath) { function Get-PackageRenames([string] $BucketPath) {
if (-not $BucketPath -or -not (Test-Path -LiteralPath $BucketPath)) { return } if (-not $BucketPath -or -not (Test-Path -LiteralPath $BucketPath)) { return }
foreach ($app in Get-ChildItem -LiteralPath $apps -Directory) { foreach ($app in Get-ChildItem -LiteralPath $apps -Directory) {
if ($app.Name -in @('ee-iso', 'ee_iso')) { continue }
if (-not $app.Name.Contains('_')) { continue } if (-not $app.Name.Contains('_')) { continue }
$newName = $app.Name.Replace('_', '-') $newName = $app.Name.Replace('_', '-')
$newManifest = Join-Path $BucketPath "bucket\$newName.json" $newManifest = Join-Path $BucketPath "bucket\$newName.json"
@@ -78,6 +82,19 @@ function Get-PackageRenames([string] $BucketPath) {
} }
} }
$installCli = (Test-Path -LiteralPath (Join-Path $apps 'ee-cli')) -or
-not (Test-Path -LiteralPath (Join-Path $apps 'myelexis-cli'))
$obsoleteApps = @(@('ee-iso', 'ee_iso', 'ee-cli') | Where-Object {
Test-Path -LiteralPath (Join-Path $apps $_)
})
if ($installCli -and (Test-Path -LiteralPath (Join-Path $apps 'myelexis-cli'))) {
$obsoleteApps += 'myelexis-cli'
}
foreach ($obsolete in $obsoleteApps) {
Write-Host "Veraltetes Paket deinstallieren: $obsolete"
}
if ($installCli) { Write-Host 'MyElexis CLI wird aus medelexis neu installiert.' }
$renameSource = $null $renameSource = $null
if (Test-Path -LiteralPath $target) { if (Test-Path -LiteralPath $target) {
if (-not (Test-MedelexisRepository $target)) { if (-not (Test-MedelexisRepository $target)) {
@@ -98,6 +115,7 @@ if (Test-Path -LiteralPath $target) {
$changes = @( $changes = @(
foreach ($app in Get-ChildItem -LiteralPath $apps -Directory) { foreach ($app in Get-ChildItem -LiteralPath $apps -Directory) {
if ($app.Name -in $obsoleteApps) { continue }
foreach ($version in Get-ChildItem -LiteralPath $app.FullName -Directory) { foreach ($version in Get-ChildItem -LiteralPath $app.FullName -Directory) {
# current und andere Verknüpfungen nicht nochmals bearbeiten. # current und andere Verknüpfungen nicht nochmals bearbeiten.
if ($version.Attributes -band [IO.FileAttributes]::ReparsePoint) { continue } if ($version.Attributes -band [IO.FileAttributes]::ReparsePoint) { continue }
@@ -127,10 +145,17 @@ if (-not $Apply) {
} }
Write-Host "Probelauf: $($changes.Count) install.json-Dateien würden geändert." Write-Host "Probelauf: $($changes.Count) install.json-Dateien würden geändert."
Write-Host "Probelauf: $($renames.Count) Paketnamen würden umgestellt." Write-Host "Probelauf: $($renames.Count) Paketnamen würden umgestellt."
Write-Host "Probelauf: $(@($obsoleteApps).Count) alte Pakete würden deinstalliert."
Write-Host 'Mit -Apply wird optional ein Auto-Update um 03:00 Uhr mit Nachholen eingerichtet (y/n).'
Write-Host 'Mit -Apply anwenden. Es werden keine Sicherungen erstellt.' Write-Host 'Mit -Apply anwenden. Es werden keine Sicherungen erstellt.'
return return
} }
$activeRoot = $(if ($env:SCOOP) { $env:SCOOP } else { Join-Path $env:USERPROFILE 'scoop' })
if ([IO.Path]::GetFullPath($ScoopRoot).TrimEnd('\') -ne [IO.Path]::GetFullPath($activeRoot).TrimEnd('\')) {
throw 'ScoopRoot muss der aktiven Scoop-Installation entsprechen.'
}
if (-not (Test-Path -LiteralPath $target)) { if (-not (Test-Path -LiteralPath $target)) {
if ($renameSource) { if ($renameSource) {
$bucketRoot = (Resolve-Path -LiteralPath (Join-Path $ScoopRoot 'buckets')).Path $bucketRoot = (Resolve-Path -LiteralPath (Join-Path $ScoopRoot 'buckets')).Path
@@ -166,6 +191,27 @@ foreach ($change in $changes) {
foreach ($change in $changes) { foreach ($change in $changes) {
[IO.File]::WriteAllText($change.File, $change.Updated, [Text.UTF8Encoding]::new($false)) [IO.File]::WriteAllText($change.File, $change.Updated, [Text.UTF8Encoding]::new($false))
} }
if ($installCli -and -not (Test-Path -LiteralPath (Join-Path $target 'bucket\myelexis-cli.json')) -and
-not (Test-Path -LiteralPath (Join-Path $target 'myelexis-cli.json'))) {
throw 'Manifest myelexis-cli fehlt im Ziel-Bucket. Keine Deinstallation.'
}
foreach ($obsolete in $obsoleteApps) {
$activeRoot = $(if ($env:SCOOP) { $env:SCOOP } else { Join-Path $env:USERPROFILE 'scoop' })
if ([IO.Path]::GetFullPath($ScoopRoot).TrimEnd('\') -ne [IO.Path]::GetFullPath($activeRoot).TrimEnd('\')) {
throw 'Für Deinstallationen muss ScoopRoot der aktiven Scoop-Installation entsprechen.'
}
& scoop uninstall $obsolete
if ($LASTEXITCODE -ne 0 -or (Test-Path -LiteralPath (Join-Path $apps $obsolete))) {
throw "'$obsolete' konnte nicht deinstalliert werden."
}
}
if ($installCli) {
Write-Host 'MyElexis CLI aus medelexis neu installieren ...'
& scoop install 'medelexis/myelexis-cli'
if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath (Join-Path $apps 'myelexis-cli\current\myelexis-cli.exe'))) {
throw 'MyElexis CLI konnte nicht vollständig installiert werden.'
}
}
foreach ($rename in $renames) { foreach ($rename in $renames) {
$activeRoot = $(if ($env:SCOOP) { $env:SCOOP } else { Join-Path $env:USERPROFILE 'scoop' }) $activeRoot = $(if ($env:SCOOP) { $env:SCOOP } else { Join-Path $env:USERPROFILE 'scoop' })
if ([IO.Path]::GetFullPath($ScoopRoot).TrimEnd('\') -ne [IO.Path]::GetFullPath($activeRoot).TrimEnd('\')) { if ([IO.Path]::GetFullPath($ScoopRoot).TrimEnd('\') -ne [IO.Path]::GetFullPath($activeRoot).TrimEnd('\')) {
@@ -222,3 +268,140 @@ foreach ($name in @((Get-ChildItem -LiteralPath $apps -Directory).Name)) {
Write-Host "Fertig: $($changes.Count) install.json-Dateien auf medelexis umgestellt." Write-Host "Fertig: $($changes.Count) install.json-Dateien auf medelexis umgestellt."
Write-Host "Paketnamen umgestellt: $($renames.Count)." Write-Host "Paketnamen umgestellt: $($renames.Count)."
Write-Host 'Von elexis/streichtech/medelexis bleibt nur medelexis bestehen. Prüfung: scoop status --local' Write-Host 'Von elexis/streichtech/medelexis bleibt nur medelexis bestehen. Prüfung: scoop status --local'
$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'
New-Item -ItemType Directory -Path $autoUpdateDirectory -Force | Out-Null
$autoUpdateScript = Join-Path $autoUpdateDirectory 'auto-update.ps1'
$downloadPath = Join-Path $autoUpdateDirectory ('auto-update-{0}.download' -f [guid]::NewGuid())
try {
$autoUpdateUrl = "$repository/raw/branch/master/scripts/auto-update.ps1"
Invoke-WebRequest -Uri $autoUpdateUrl -OutFile $downloadPath -UseBasicParsing
$parseTokens = $null
$parseErrors = $null
$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 gültiges Updateskript.'
}
Move-Item -LiteralPath $downloadPath -Destination $autoUpdateScript -Force
# Nur die Aufgabe einrichten; das Updateskript wird durch die Aufgabe gestartet.
# 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 `
-AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -MultipleInstances IgnoreNew `
-ExecutionTimeLimit (New-TimeSpan -Hours 4)
Register-ScheduledTask -TaskName $taskName -Action $action -Trigger $trigger `
-Principal $principal -Settings $settings -Force `
-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 }
}
} elseif (-not $autoUpdateRemoved) {
Write-Host 'Keine Änderung an Scoop-Auto-Update vorgenommen.'
}
+251
View File
@@ -0,0 +1,251 @@
#requires -Version 5.1
# Isolated behavioural tests: no real downloads, Scoop updates or scheduled tasks.
$ErrorActionPreference = 'Stop'
$projectRoot = Split-Path $PSScriptRoot -Parent
$fixtureRoot = Join-Path $PSScriptRoot ('.auto-update-test-' + [guid]::NewGuid())
$oldScoop = $env:SCOOP
$oldPath = $env:PATH
$oldFixture = $env:SCOOP_AUTO_UPDATE_TEST_ROOT
$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 }
function New-ScheduledTaskTrigger { param([switch]$Daily, $At) [pscustomobject]$PSBoundParameters }
function New-ScheduledTaskPrincipal { param($UserId, $LogonType, $RunLevel) [pscustomobject]$PSBoundParameters }
function New-ScheduledTaskSettingsSet {
param([switch]$StartWhenAvailable, [switch]$RunOnlyIfNetworkAvailable,
[switch]$AllowStartIfOnBatteries, [switch]$DontStopIfGoingOnBatteries,
$MultipleInstances, $ExecutionTimeLimit)
[pscustomobject]$PSBoundParameters
}
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) {
'This is not the setup script.' | Set-Content -LiteralPath $OutFile
} else {
@'
param([string] $ScoopRoot)
throw 'The downloaded runner must not execute during setup.'
'@ | Set-Content -LiteralPath $OutFile
}
}
try {
# Spaces in the installation path exercise the scheduled command's quoting.
$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'
Copy-Item -LiteralPath (Join-Path $projectRoot 'scripts\auto-update.ps1') -Destination $scriptPath
$env:SCOOP_AUTO_UPDATE_TEST_ROOT = $fixtureRoot
$commandLog = Join-Path $fixtureRoot 'commands.log'
@'
param([Parameter(ValueFromRemainingArguments = $true)][string[]] $ScoopArguments)
Add-Content -LiteralPath (Join-Path $env:SCOOP_AUTO_UPDATE_TEST_ROOT 'commands.log') -Value ($ScoopArguments -join ' ')
if ($env:SCOOP_AUTO_UPDATE_TEST_FAILURE -eq ($ScoopArguments -join ' ')) { exit 1 }
if ($ScoopArguments[0] -eq 'list') {
[pscustomobject]@{ Name = 'elexis'; Info = '' }
[pscustomobject]@{ Name = 'Elexis-test'; Info = 'Held package' }
[pscustomobject]@{ Name = 'myelexis-cli'; Info = '' }
[pscustomobject]@{ Name = 'git'; Info = '' }
[pscustomobject]@{ Name = 'elexis-global'; Info = 'Global install' }
[pscustomobject]@{ Name = 'elexis-broken'; Info = 'Install failed' }
}
exit 0
'@ | Set-Content -LiteralPath (Join-Path $shims 'scoop.ps1') -Encoding UTF8
& $scriptPath -ScoopRoot $scoopRoot
Assert-True ($LASTEXITCODE -eq 0) 'Expected successful update.'
Assert-True (((Get-Content $commandLog) -join '|') -eq 'list|hold elexis|hold Elexis-test|update|update *') 'Expected only local elexis* held before updates.'
Assert-True (Test-Path (Join-Path $fixtureRoot 'auto-update.log')) 'Expected update log.'
foreach ($failure in @('list', 'hold elexis', 'update', 'update *')) {
Remove-Item -LiteralPath $commandLog
$env:SCOOP_AUTO_UPDATE_TEST_FAILURE = $failure
# Expected errors are logged; do not let test ErrorActionPreference terminate the caller.
$ErrorActionPreference = 'Continue'
& $scriptPath -ScoopRoot $scoopRoot 2>$null
$result = $LASTEXITCODE
$ErrorActionPreference = 'Stop'
Assert-True ($result -eq 1) "Expected failure status for $failure."
$commands = @(Get-Content $commandLog)
Assert-True ($commands[-1] -eq $failure) "Must abort immediately after $failure."
}
# 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.'
$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:AutoUpdateTestPrompts = [Collections.Generic.List[string]]::new()
$global:AutoUpdateTestTasks = @{}
$global:AutoUpdateTestDownloads = 0
$downloadDirectory = Join-Path $scoopRoot 'scripts\medelexis'
$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')
& $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 (-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')
& $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 { & $entry -ScoopRoot $scoopRoot } catch { $rejected = $true }
Assert-True $rejected 'Invalid download must be rejected.'
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.'
$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')
& $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, 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
[IO.Path]::GetFileName($fixtureRoot).StartsWith('.auto-update-test-')) {
Remove-Item -LiteralPath $fixtureRoot -Recurse -Force
}
}