Files
scoop/scripts/update-bucket.ps1
T
2026-10-21 12:37:59 +02:00

408 lines
21 KiB
PowerShell

#requires -Version 5.1
<#
.SYNOPSIS
Stellt bestehende Scoop-Installationen von elexis/streichtech auf medelexis um.
.DESCRIPTION
Ohne -Apply nur ein Probelauf. Keine Sicherungen oder Git-Pushes.
Bearbeitet alle Versionsverzeichnisse der benutzerspezifischen Scoop-Installation.
Ein passender bestehender Bucket wird umbenannt. Danach werden elexis und streichtech entfernt.
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 tägliche Auto-Updates um 03:00 Uhr
eingerichtet werden sollen. Das separate Skript wird aus dem Repository geladen.
.EXAMPLE
.\scripts\update-bucket.ps1
.EXAMPLE
.\scripts\update-bucket.ps1 -Apply
#>
[CmdletBinding()]
param(
[switch] $Apply,
[string] $ScoopRoot = $(if ($env:SCOOP) { $env:SCOOP } else { Join-Path $env:USERPROFILE 'scoop' })
)
$ErrorActionPreference = 'Stop'
$repository = 'https://gitea.medelexis.ch/public/scoop'
$targetName = 'medelexis'
$target = Join-Path $ScoopRoot "buckets\$targetName"
$apps = Join-Path $ScoopRoot 'apps'
if (-not (Test-Path -LiteralPath $apps -PathType Container)) {
throw "Kein Scoop-Apps-Verzeichnis gefunden: $apps"
}
function Test-MedelexisRepository([string] $Path) {
if (-not (Test-Path -LiteralPath $Path -PathType Container)) { return $false }
$remote = & git -C $Path remote get-url origin 2>$null
if ($LASTEXITCODE -ne 0) { return $false }
try { $uri = [Uri] ([string] $remote).Trim() } catch { return $false }
return ($uri.Scheme -eq 'https' -and $uri.Host -eq 'gitea.medelexis.ch' -and
$uri.AbsolutePath.TrimEnd('/').Replace('.git', '') -eq '/public/scoop')
}
function Get-PackageRenames([string] $BucketPath) {
if (-not $BucketPath -or -not (Test-Path -LiteralPath $BucketPath)) { return }
foreach ($app in Get-ChildItem -LiteralPath $apps -Directory) {
if ($app.Name -in @('ee-iso', 'ee_iso')) { continue }
if (-not $app.Name.Contains('_')) { continue }
$newName = $app.Name.Replace('_', '-')
$newManifest = Join-Path $BucketPath "bucket\$newName.json"
if (-not (Test-Path -LiteralPath $newManifest)) {
$newManifest = Join-Path $BucketPath "$newName.json"
}
if (-not (Test-Path -LiteralPath $newManifest)) { continue }
$current = Join-Path $app.FullName 'current'
$installFile = Join-Path $current 'install.json'
if (-not (Test-Path -LiteralPath $installFile)) { continue }
$install = [IO.File]::ReadAllText($installFile) | ConvertFrom-Json
if ($install.bucket -notin @('elexis', 'streichtech', 'medelexis')) { continue }
$destination = Join-Path $apps $newName
if (Test-Path -LiteralPath $destination) {
throw "'$($app.Name)' und '$newName' sind beide installiert. Keine automatische Zusammenführung."
}
$link = Get-Item -LiteralPath $current -Force
if ($link.LinkType -ne 'Junction') {
throw "Für '$($app.Name)' wird eine Scoop-current-Junction erwartet. Keine automatische Umbenennung."
}
$versionPath = [IO.Path]::GetFullPath([string] @($link.Target)[0])
if ((Split-Path $versionPath -Parent) -ne $app.FullName) {
throw "Die current-Verknüpfung von '$($app.Name)' zeigt ausserhalb des Paketordners."
}
$persistSource = Join-Path $ScoopRoot "persist\$($app.Name)"
$persistTarget = Join-Path $ScoopRoot "persist\$newName"
if ((Test-Path -LiteralPath $persistSource) -and (Test-Path -LiteralPath $persistTarget)) {
throw "Beide Persistenzordner für '$($app.Name)' und '$newName' existieren. Keine automatische Zusammenführung."
}
Write-Host "Paket umbenennen: $($app.Name) -> $newName"
[pscustomobject]@{
OldName = $app.Name; NewName = $newName; Source = $app.FullName
Destination = $destination; Version = (Split-Path $versionPath -Leaf)
PersistSource = $persistSource; PersistTarget = $persistTarget
}
}
}
$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
if (Test-Path -LiteralPath $target) {
if (-not (Test-MedelexisRepository $target)) {
throw 'Der vorhandene Bucket medelexis zeigt auf ein anderes Repository. Keine Änderung.'
}
Write-Host 'Bucket medelexis ist bereits korrekt eingerichtet.'
} else {
foreach ($oldName in @('elexis', 'streichtech')) {
$candidate = Join-Path $ScoopRoot "buckets\$oldName"
if (Test-MedelexisRepository $candidate) {
$renameSource = $candidate
Write-Host "Bucket umbenennen: $oldName -> $targetName"
break
}
}
if (-not $renameSource) { Write-Host "Bucket hinzufügen: $targetName -> $repository" }
}
$changes = @(
foreach ($app in Get-ChildItem -LiteralPath $apps -Directory) {
if ($app.Name -in $obsoleteApps) { continue }
foreach ($version in Get-ChildItem -LiteralPath $app.FullName -Directory) {
# current und andere Verknüpfungen nicht nochmals bearbeiten.
if ($version.Attributes -band [IO.FileAttributes]::ReparsePoint) { continue }
$file = Join-Path $version.FullName 'install.json'
if (-not (Test-Path -LiteralPath $file -PathType Leaf)) { continue }
$original = [IO.File]::ReadAllText($file)
$data = $original | ConvertFrom-Json
if ($data.bucket -notin @('elexis', 'streichtech')) { continue }
$pattern = '(?i)("bucket"\s*:\s*)"(?:elexis|streichtech)"'
$updated = [regex]::Replace($original, $pattern, '${1}"medelexis"')
if (($updated | ConvertFrom-Json).bucket -ne $targetName) {
throw "Bucket-Zuordnung konnte nicht angepasst werden: $file"
}
Write-Host "$($app.Name) / $($version.Name): $($data.bucket) -> $targetName"
[pscustomobject]@{ File = $file; Original = $original; Updated = $updated; App = $app.Name }
}
}
)
$manifestRoot = $(if (Test-Path -LiteralPath $target) { $target } else { $renameSource })
$renames = @(Get-PackageRenames $manifestRoot)
if (-not $Apply) {
foreach ($oldName in @('elexis', 'streichtech')) {
if (Test-Path -LiteralPath (Join-Path $ScoopRoot "buckets\$oldName")) {
Write-Host "Alter Bucket wird nach der Umstellung entfernt oder umbenannt: $oldName"
}
}
Write-Host "Probelauf: $($changes.Count) install.json-Dateien würden geändert."
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.'
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 ($renameSource) {
$bucketRoot = (Resolve-Path -LiteralPath (Join-Path $ScoopRoot 'buckets')).Path
$sourcePath = (Resolve-Path -LiteralPath $renameSource).Path
$targetPath = [IO.Path]::GetFullPath($target)
if ((Split-Path $sourcePath -Parent) -ne $bucketRoot -or
(Split-Path $targetPath -Parent) -ne $bucketRoot) {
throw 'Bucket-Pfade liegen ausserhalb des erwarteten Verzeichnisses. Keine Umbenennung.'
}
[IO.Directory]::Move($sourcePath, $targetPath)
} else {
# scoop verwendet dieselbe Root-Variable für benutzerdefinierte Installationen.
if ([IO.Path]::GetFullPath($ScoopRoot).TrimEnd('\') -ne
[IO.Path]::GetFullPath($(if ($env:SCOOP) { $env:SCOOP } else { Join-Path $env:USERPROFILE 'scoop' })).TrimEnd('\')) {
throw 'ScoopRoot weicht von der aktiven Scoop-Installation ab; Bucket bitte dort zuerst einrichten.'
}
& scoop bucket add $targetName $repository
if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath $target)) {
throw 'Bucket konnte nicht hinzugefügt werden. Installationsmetadaten bleiben unverändert.'
}
}
}
# Falls der Ziel-Bucket erst hinzugefügt wurde, stehen seine Manifestnamen jetzt bereit.
if (-not $manifestRoot) { $renames = @(Get-PackageRenames $target) }
# Vor dem ersten Schreiben prüfen, ob während des Probelaufs Dateien geändert wurden.
foreach ($change in $changes) {
if ([IO.File]::ReadAllText($change.File) -cne $change.Original) {
throw 'Installationsmetadaten wurden zwischenzeitlich geändert. Skript bitte erneut starten.'
}
}
foreach ($change in $changes) {
[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) {
$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 Paketumbenennungen muss ScoopRoot der aktiven Scoop-Installation entsprechen.'
}
$appsRoot = (Resolve-Path -LiteralPath $apps).Path
if ((Split-Path $rename.Source -Parent) -ne $appsRoot -or
(Split-Path $rename.Destination -Parent) -ne $appsRoot) {
throw 'Paketpfade liegen ausserhalb des Apps-Verzeichnisses.'
}
if (Test-Path -LiteralPath $rename.PersistSource) {
$persistRoot = (Resolve-Path -LiteralPath (Join-Path $ScoopRoot 'persist')).Path
if ((Split-Path $rename.PersistSource -Parent) -ne $persistRoot -or
(Split-Path $rename.PersistTarget -Parent) -ne $persistRoot) {
throw 'Persistenzpfade liegen ausserhalb des Persist-Verzeichnisses.'
}
[IO.Directory]::Move($rename.PersistSource, $rename.PersistTarget)
}
$oldCurrent = Join-Path $rename.Source 'current'
& attrib -R /L $oldCurrent
if ($LASTEXITCODE -ne 0) { throw 'current-Verknüpfung konnte nicht freigegeben werden.' }
# Nur die Junction entfernen, ohne rekursiv ihren Inhalt zu löschen.
[IO.Directory]::Delete($oldCurrent)
[IO.Directory]::Move($rename.Source, $rename.Destination)
$newCurrent = Join-Path $rename.Destination 'current'
$newVersion = Join-Path $rename.Destination $rename.Version
New-Item -ItemType Junction -Path $newCurrent -Target $newVersion | Out-Null
Write-Host "Startverknüpfungen für $($rename.NewName) erneuern ..."
& scoop reset "$($rename.NewName)@$($rename.Version)"
if ($LASTEXITCODE -ne 0) { throw "Scoop reset für '$($rename.NewName)' fehlgeschlagen." }
}
foreach ($oldName in @('elexis', 'streichtech')) {
$oldPath = Join-Path $ScoopRoot "buckets\$oldName"
if (-not (Test-Path -LiteralPath $oldPath)) { continue }
$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 'Zum Entfernen alter Buckets muss ScoopRoot der aktiven Scoop-Installation entsprechen.'
}
Write-Host "Alten Bucket entfernen: $oldName"
& scoop bucket rm $oldName
if ($LASTEXITCODE -ne 0 -or (Test-Path -LiteralPath $oldPath)) {
throw "Alter Bucket '$oldName' konnte nicht entfernt werden. Die Umstellung kann erneut ausgeführt werden."
}
}
foreach ($name in @((Get-ChildItem -LiteralPath $apps -Directory).Name)) {
$installFile = Join-Path $apps "$name\current\install.json"
if (-not (Test-Path -LiteralPath $installFile)) { continue }
if (([IO.File]::ReadAllText($installFile) | ConvertFrom-Json).bucket -ne $targetName) { continue }
if (-not (Test-Path -LiteralPath (Join-Path $target "bucket\$name.json")) -and
-not (Test-Path -LiteralPath (Join-Path $target "$name.json"))) {
Write-Warning "Für '$name' fehlt weiterhin ein gleichnamiges Manifest."
}
}
Write-Host "Fertig: $($changes.Count) install.json-Dateien auf medelexis umgestellt."
Write-Host "Paketnamen umgestellt: $($renames.Count)."
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.'
}