Compare commits

..
Author SHA1 Message Date
Simon Daniel Streich ee6d5b9b1e init 2026-10-21 12:06:08 +02:00
Simon Daniel Streich 6c1c651839 Merge branch 'master' of https://gitea.medelexis.ch/public/scoop 2026-10-21 10:34:30 +02:00
Simon Daniel Streich 795a056564 bucket-update-scripts für neues repo 2026-10-21 10:34:05 +02:00
Gitea Actions 53f3f6bd45 Update Scoop manifests 2026-10-21 08:08:40 +00:00
Simon Daniel Streich 079e69963d vollständige umstellung auf gitea repo 2026-10-21 10:03:34 +02:00
Simon Daniel Streich 3a807da54c gitea push test 2026-09-11 13:33:34 +02:00
Simon Daniel Streich 71c35c0e45 fix: stop persisting Medelexis.ini
Medelexis.ini contains a version-specific Java path. Persisting it caused Medelexis to reference the old Java folder after an update.
2026-08-19 12:09:53 +02:00
17 changed files with 1017 additions and 49 deletions

No files matched your search

+49
View File
@@ -0,0 +1,49 @@
name: Update Scoop manifests
on:
workflow_dispatch:
schedule:
- cron: '0 * * * *'
jobs:
update:
runs-on: ubuntu-latest
permissions:
code: write
timeout-minutes: 55
steps:
- name: Checkout repository
uses: https://github.com/actions/checkout@v4
with:
ref: ${{ gitea.event.repository.default_branch }}
token: ${{ secrets.GITEA_TOKEN }}
fetch-depth: 0
- name: Check Python runtime
run: python3 -c "import sys; assert sys.version_info >= (3, 10), 'Python 3.10+ erforderlich'"
- name: Test manifest updater
run: python3 -B -m unittest discover -s tests -v
- name: Update manifests
id: update
run: python3 -B scripts/update-manifests.py --write
# Gültige Updates auch dann speichern, wenn ein anderer Download scheitert.
# Der fehlgeschlagene Update-Schritt bleibt im Workflow sichtbar.
- name: Commit and push changed manifests
if: ${{ always() && steps.update.outcome != 'skipped' }}
env:
DEFAULT_BRANCH: ${{ gitea.event.repository.default_branch }}
run: |
set -eu
git add -- bucket/*.json
if git diff --cached --quiet; then
echo 'Keine Manifest-Änderungen.'
exit 0
fi
git config user.name 'Gitea Actions'
git config user.email 'actions@gitea.medelexis.ch'
git commit -m 'Update Scoop manifests'
git pull --rebase origin "$DEFAULT_BRANCH"
git push origin "HEAD:$DEFAULT_BRANCH"
+3 -3
View File
@@ -1,5 +1,5 @@
*.bak *.bak
*.exe *.exe
scripts/* .vscode/*
.forgejo/* __pycache__/
.vscode/* *.py[cod]
+2 -2
View File
@@ -5,11 +5,11 @@
"depends": [ "depends": [
"extras/element" "extras/element"
], ],
"url": "https://git.streichtech.ch/elexis/scoop/raw/branch/master/config/matrix/medelexis/config.json#/config.json", "url": "https://gitea.medelexis.ch/public/scoop/raw/branch/master/config/matrix/medelexis/config.json#/config.json",
"hash": "b5ed72c551ff700d86a4ae293c871528d71fa53440c46d2594916a6d1273d131", "hash": "b5ed72c551ff700d86a4ae293c871528d71fa53440c46d2594916a6d1273d131",
"post_install": [ "post_install": [
"$configDir = Join-Path $env:APPDATA 'Element'", "$configDir = Join-Path $env:APPDATA 'Element'",
"New-Item -ItemType Directory -Path $configDir -Force | Out-Null", "New-Item -ItemType Directory -Path $configDir -Force | Out-Null",
"Copy-Item (Join-Path $dir 'config.json') (Join-Path $configDir 'config.json') -Force" "Copy-Item (Join-Path $dir 'config.json') (Join-Path $configDir 'config.json') -Force"
] ]
} }
-23
View File
@@ -1,23 +0,0 @@
{
"version": "13.6.0",
"url": "https://git.streichtech.ch/elexis/ee-iso-builder/releases/download/v13.6.0/ElexisEnvironment.iso#/ElexisEnvironment.dat",
"hash": "58fd7e00b7f4771fd4ee2165f3ebaff205cab780461340fc2c286f1b12faae90",
"installer": {
"script": "Copy-Item \"$dir\\$fname\" \"$HOME\\Downloads\\ElexisEnvironment.iso\" -Force"
},
"post_install": [
"$scoopCommand = (Get-Command scoop).Source",
"$cleanupCommand = \"Start-Sleep -Seconds 10; & '$scoopCommand' cleanup ee_iso --cache; & '$scoopCommand' uninstall ee_iso --purge\"",
"$cleanupBytes = [Text.Encoding]::Unicode.GetBytes($cleanupCommand)",
"$encodedCommand = [Convert]::ToBase64String($cleanupBytes)",
"Start-Process powershell.exe -WorkingDirectory $HOME -WindowStyle Hidden -ArgumentList @('-NoProfile', '-EncodedCommand', $encodedCommand)"
],
"checkver": {
"url": "https://git.streichtech.ch/api/v1/repos/elexis/ee-iso-builder/releases/latest",
"jsonpath": "$.tag_name",
"regex": "^v(?<version>[\\d.]+)$"
},
"autoupdate": {
"url": "https://git.streichtech.ch/elexis/ee-iso-builder/releases/download/v$version/ElexisEnvironment.iso#/ElexisEnvironment.dat"
}
}
-1
View File
@@ -37,7 +37,6 @@
"}" "}"
], ],
"persist": [ "persist": [
"Medelexis.ini",
"workspace\\.metadata\\.plugins\\org.eclipse.e4.workbench" "workspace\\.metadata\\.plugins\\org.eclipse.e4.workbench"
], ],
"notes": [ "notes": [
-1
View File
@@ -37,7 +37,6 @@
"}" "}"
], ],
"persist": [ "persist": [
"Medelexis.ini",
"workspace\\.metadata\\.plugins\\org.eclipse.e4.workbench" "workspace\\.metadata\\.plugins\\org.eclipse.e4.workbench"
], ],
"notes": [ "notes": [
+3 -4
View File
@@ -1,8 +1,8 @@
{ {
"version": "20260629T074811Z", "version": "20261012T102907Z",
"description": "Elexis 3.13", "description": "Elexis 3.13",
"url": "https://download.medelexis.ch/medelexis/3.13/products/ch.medelexis.application.product.Medelexis-win32.win32.x86_64-signed.zip#/Elexis-20260629T074811Z.zip", "url": "https://download.medelexis.ch/medelexis/3.13/products/ch.medelexis.application.product.Medelexis-win32.win32.x86_64-signed.zip#/Elexis-20261012T102907Z.zip",
"hash": "6180ed7b1efae077e506c2e6d3ce9f2f8a4d88e33f24cf50c6f325eb4ebfd239", "hash": "967629296b7bbdb2915c6045071c0918b8f87fec8388984bfed084046d2227eb",
"bin": [ "bin": [
[ [
"Medelexis.exe", "Medelexis.exe",
@@ -37,7 +37,6 @@
"}" "}"
], ],
"persist": [ "persist": [
"Medelexis.ini",
"workspace\\.metadata\\.plugins\\org.eclipse.e4.workbench" "workspace\\.metadata\\.plugins\\org.eclipse.e4.workbench"
], ],
"notes": [ "notes": [
-1
View File
@@ -37,7 +37,6 @@
"}" "}"
], ],
"persist": [ "persist": [
"Medelexis.ini",
"workspace\\.metadata\\.plugins\\org.eclipse.e4.workbench" "workspace\\.metadata\\.plugins\\org.eclipse.e4.workbench"
], ],
"notes": [ "notes": [
+2 -2
View File
@@ -1,8 +1,8 @@
{ {
"version": "20260715T124927Z", "version": "20261020T080626Z",
"description": "MyElexis CLI", "description": "MyElexis CLI",
"url": "https://download.medelexis.ch/myelexis-cli/windows/myelexis-cli-1.0.0-SNAPSHOT-runner.exe#/myelexis-cli.exe", "url": "https://download.medelexis.ch/myelexis-cli/windows/myelexis-cli-1.0.0-SNAPSHOT-runner.exe#/myelexis-cli.exe",
"hash": "b9df1355c599acf7e816fe490ca9e1a7c6aef4de1a5f0b45e6343e7ccbe74fc8", "hash": "a397bae3e6c340f40b4b0a35ea22bafd3c30a02990e0b26bde7957a803902078",
"bin": [ "bin": [
[ [
"myelexis-cli.exe", "myelexis-cli.exe",
+8 -8
View File
@@ -13,12 +13,12 @@
"extras/keepass", "extras/keepass",
"extras/teamviewer", "extras/teamviewer",
"ee-cli", "myelexis-cli",
"elexis_3.10", "elexis-3.10",
"elexis_3.12", "elexis-3.12",
"elexis_3.13", "elexis-3.13",
"elexis_master", "elexis-master",
"chat_medelexis" "chat-medelexis"
], ],
"url": "https://git.streichtech.ch/elexis/scoop/raw/branch/master/packages/p.intern.txt#/p.intern.txt" "url": "https://gitea.medelexis.ch/public/scoop/raw/branch/master/packages/p.intern.txt#/p.intern.txt"
} }
+49
View File
@@ -0,0 +1,49 @@
#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
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 fuer '$($package.Name)' fehlgeschlagen. Kein Update ausgefuehrt."
}
}
& $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
+4 -4
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) {
@@ -33,7 +33,7 @@ if ($bucketList -match "elexis") {
Write-Host "elexis Bucket ist bereits vorhanden." -ForegroundColor White Write-Host "elexis Bucket ist bereits vorhanden." -ForegroundColor White
} }
else { else {
scoop bucket add elexis https://git.streichtech.ch/elexis/scoop scoop bucket add elexis https://gitea.medelexis.ch/public/scoop
Write-Host "elexis Bucket hinzugefügt." -ForegroundColor Green Write-Host "elexis Bucket hinzugefügt." -ForegroundColor Green
} }
@@ -47,6 +47,6 @@ else {
Write-Host "" Write-Host ""
Write-Host "Fertig! Verfügbare Apps anzeigen mit: scoop search" -ForegroundColor Cyan Write-Host "Fertig! Verfügbare Apps anzeigen mit: scoop search" -ForegroundColor Cyan
Write-Host "Bsp. scoop search ee-cli" -ForegroundColor Cyan Write-Host "Bsp. scoop search myelexis-cli" -ForegroundColor Cyan
Write-Host "" Write-Host ""
Read-Host "Enter drücken zum Beenden" Read-Host "Enter drücken zum Beenden"
+69
View File
@@ -0,0 +1,69 @@
#requires -Version 5.1
<#
.SYNOPSIS
Fragt nach und richtet optional die taeglichen 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.
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.
.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'
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 gueltiges 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
$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 '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."
} finally {
if (Test-Path -LiteralPath $downloadPath) { Remove-Item -LiteralPath $downloadPath }
}
} else {
Write-Host 'Auto-Update nicht eingerichtet. Eine eventuell bestehende Aufgabe bleibt unveraendert.'
}
+314
View File
@@ -0,0 +1,314 @@
#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 taegliche 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'
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 gueltiges 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
$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 '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."
} finally {
if (Test-Path -LiteralPath $downloadPath) { Remove-Item -LiteralPath $downloadPath }
}
} else {
Write-Host 'Auto-Update nicht eingerichtet. Eine eventuell bestehende Aufgabe bleibt unveraendert.'
}
+225
View File
@@ -0,0 +1,225 @@
"""Update timestamp and release manifests using only Python 3.10+.
Without --write, calculate changes without saving them. Never commit or push.
PowerShell statements in manifests are not executed.
"""
import argparse
from datetime import timezone
from email.utils import parsedate_to_datetime
import hashlib
import json
from pathlib import Path
import re
import sys
import tempfile
import time
from urllib.error import HTTPError, URLError
from urllib.parse import urldefrag, urlsplit
from urllib.request import Request, urlopen
BUCKET = Path(__file__).resolve().parents[1] / 'bucket'
TIMESTAMP_REGEX = r'(?<version>\d{8}T\d{6}Z)'
def open_url(url, method='GET', headers=None):
parsed = urlsplit(url)
if parsed.scheme != 'https' or not parsed.hostname or parsed.username or parsed.password:
raise ValueError('Nur HTTPS-URLs ohne eingebettete Zugangsdaten sind erlaubt.')
return urlopen(Request(url, method=method, headers={
'User-Agent': 'Medelexis-Scoop-Updater', 'Cache-Control': 'no-cache',
'Accept-Encoding': 'identity', **(headers or {})}), timeout=180)
def metadata(headers):
return {key: headers.get(key, '') for key in ('Last-Modified', 'ETag')}
def read_head(url):
with open_url(url, 'HEAD') as response:
return metadata(response.headers)
def release_version(checkver):
if checkver.get('jsonpath') != '$.tag_name' or not checkver.get('regex'):
raise ValueError('Unterstützt werden Last-Modified oder jsonpath $.tag_name mit regex.')
with open_url(checkver['url']) as response:
tag = json.load(response)['tag_name']
pattern = checkver['regex'].replace('(?<version>', '(?P<version>')
match = re.search(pattern, tag)
if not match:
raise ValueError('Release-Tag passt nicht zum Versionsmuster.')
version = match.groupdict().get('version') or match.group(1)
if not re.fullmatch(r'[0-9A-Za-z][0-9A-Za-z._+-]*', version):
raise ValueError('Ungültige Release-Version.')
return version
def download_file(url, observed, target):
conditions = {}
if observed['ETag'] and not observed['ETag'].startswith('W/'):
conditions['If-Match'] = observed['ETag']
elif observed['Last-Modified']:
conditions['If-Unmodified-Since'] = observed['Last-Modified']
size = 0
prefix = b''
with open_url(url, headers=conditions) as response:
if metadata(response.headers) != observed:
raise ValueError('Datei hat sich zwischen HEAD und GET geändert; erneut versuchen.')
if response.headers.get('Content-Type', '').split(';')[0].lower() in (
'text/html', 'application/json'):
raise ValueError('Download liefert HTML/JSON statt einer Programmdatei.')
expected = response.headers.get('Content-Length')
total = int(expected) if expected is not None else None
last_report = time.monotonic()
if total is not None:
print(f' Download gestartet: {total / 1048576:.1f} MiB.', flush=True)
else:
print(' Download gestartet; Gesamtgrösse unbekannt.', flush=True)
with target.open('wb') as output:
while chunk := response.read(1024 * 1024):
if not size:
prefix = chunk[:4]
output.write(chunk)
size += len(chunk)
now = time.monotonic()
if now - last_report >= 5:
progress = f'{size / 1048576:.1f} MiB'
if total:
progress += f' / {total / 1048576:.1f} MiB ({size / total:.0%})'
print(f' Heruntergeladen: {progress}', flush=True)
last_report = now
if not size or (expected is not None and size != int(expected)):
raise ValueError('Leerer oder unvollständiger Download.')
suffix = Path(urlsplit(url).path).suffix.lower()
if suffix == '.exe' and not prefix.startswith(b'MZ'):
raise ValueError('Download hat keine Windows-EXE-Signatur.')
if suffix == '.zip' and not prefix.startswith(b'PK'):
raise ValueError('Download hat keine ZIP-Signatur.')
print(f' Download abgeschlossen: {size / 1048576:.1f} MiB.', flush=True)
return size
def hash_file(path):
total = path.stat().st_size
print(f' SHA-256-Berechnung gestartet: {total / 1048576:.1f} MiB.', flush=True)
digest = hashlib.sha256()
size = 0
last_report = time.monotonic()
with path.open('rb') as source:
while chunk := source.read(1024 * 1024):
digest.update(chunk)
size += len(chunk)
now = time.monotonic()
if now - last_report >= 5:
print(f' Gehasht: {size / 1048576:.1f} / {total / 1048576:.1f} MiB '
f'({size / total:.0%})', flush=True)
last_report = now
print(' SHA-256-Berechnung abgeschlossen.', flush=True)
return digest.hexdigest()
def download_hash(url, observed):
with tempfile.TemporaryDirectory(prefix='scoop-update-') as directory:
target = Path(directory) / 'download.bin'
size = download_file(url, observed, target)
return hash_file(target), size
def save_manifest(path, manifest):
original = path.read_bytes()
newline = '\r\n' if b'\r\n' in original else '\n'
content = json.dumps(manifest, ensure_ascii=False, indent=4) + '\n'
temporary = None
try:
with tempfile.NamedTemporaryFile(dir=path.parent, suffix='.tmp', delete=False) as output:
temporary = Path(output.name)
output.write(content.replace('\n', newline).encode('utf-8'))
temporary.replace(path)
finally:
if temporary is not None:
temporary.unlink(missing_ok=True)
def update_manifest(path, write=False):
manifest = json.loads(path.read_text(encoding='utf-8-sig'))
checkver, autoupdate = manifest.get('checkver'), manifest.get('autoupdate')
if not checkver or not autoupdate:
print(f'{path.stem}: kein automatisches Update konfiguriert; übersprungen.')
return False
if not isinstance(checkver, dict) or not isinstance(autoupdate.get('url'), str):
raise ValueError('Diese checkver/autoupdate-Konfiguration wird noch nicht unterstützt.')
template = autoupdate['url']
observed = None
if checkver.get('regex') == TIMESTAMP_REGEX:
source = urldefrag(manifest['url'])[0]
print(f'{path.stem}: HEAD-Anfrage an {source} ...', flush=True)
observed = read_head(source)
if not observed['Last-Modified']:
raise ValueError('Last-Modified-Header fehlt.')
date = parsedate_to_datetime(observed['Last-Modified'])
if date.tzinfo is None:
raise ValueError('Last-Modified enthält keine Zeitzone.')
version = date.astimezone(timezone.utc).strftime('%Y%m%dT%H%M%SZ')
if urldefrag(template.replace('$version', version))[0] != source:
raise ValueError('Zeitstempel-Updates benötigen eine feste Download-URL.')
elif 'script' not in checkver:
print(f'{path.stem}: Release-Version abfragen: {checkver.get("url", "")} ...', flush=True)
version = release_version(checkver)
else:
raise ValueError('Dieses checkver-Skript wird nicht ausgeführt oder interpretiert.')
if version == manifest['version']:
print(f'{path.stem}: {version} unverändert; kein Download.')
return False
url = template.replace('$version', version)
if '$' in url:
raise ValueError('Unterstützt wird nur der Platzhalter $version.')
source = urldefrag(url)[0]
if observed is None:
print(f'{path.stem}: HEAD-Anfrage an {source} ...', flush=True)
observed = read_head(source)
print(f'{path.stem}: neue Version {version}; GET-Download ...', flush=True)
print(' Für den SHA-256-Hash muss die gesamte Datei heruntergeladen werden. '
'Danach wird der Hash berechnet. Bei grossen Dateien kann dies mehrere Minuten dauern, '
'auch im Probelauf.', flush=True)
digest, size = download_hash(source, observed)
print(f'{path.stem}: {manifest["version"]} -> {version}; {size} Bytes; SHA-256 {digest}')
manifest.update(version=version, url=url, hash=digest)
if write:
save_manifest(path, manifest)
print(f'{path.stem}: Manifest gespeichert.')
else:
print(f'{path.stem}: Probelauf; Manifest bleibt unverändert.')
return True
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--bucket', type=Path, default=BUCKET)
parser.add_argument('--app', action='append', help='Manifestname; mehrfach möglich, sonst alle.')
parser.add_argument('--write', action='store_true', help='Geprüfte Änderungen speichern.')
args = parser.parse_args(argv)
if not args.bucket.is_dir():
parser.error('Bucket-Verzeichnis existiert nicht.')
if args.app and any(not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9._-]*', app) for app in args.app):
parser.error('--app erwartet einen Manifestnamen ohne Pfad oder .json-Endung.')
paths = ([args.bucket / f'{app}.json' for app in args.app] if args.app else
sorted(args.bucket.glob('*.json')))
errors = 0
for path in paths:
try:
update_manifest(path, args.write)
except HTTPError as error:
print(f'{path.stem}: HTTP {error.code}; Manifest unverändert.', file=sys.stderr)
errors += 1
except (ValueError, KeyError, TypeError, OSError, URLError) as error:
print(f'{path.stem}: {error}; Manifest unverändert.', file=sys.stderr)
errors += 1
return int(errors > 0)
if __name__ == '__main__':
sys.exit(main())
+154
View File
@@ -0,0 +1,154 @@
#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 }
}
# 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
}
function Read-Host {
param($Prompt)
$global:AutoUpdateTestAnswers.Dequeue()
}
function Invoke-WebRequest {
param($Uri, $OutFile, [switch]$UseBasicParsing)
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'
$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."
}
# Exercise the actual opt-in/download block without running the bucket migration.
$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"))
$repository = 'https://example.test/scoop'
Remove-Item -LiteralPath $commandLog
$global:AutoUpdateTestAnswers = [Collections.Generic.Queue[string]]::new()
$global:AutoUpdateTestAnswers.Enqueue('n')
& $optInBlock
$downloadDirectory = Join-Path $scoopRoot 'scripts\medelexis'
Assert-True (-not (Test-Path $downloadDirectory)) 'Declining must not download or set up anything.'
$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.'
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
$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
$global:AutoUpdateTestInvalidDownload = $true
$global:AutoUpdateTestAnswers.Enqueue('y')
$rejected = $false
try { & $optInBlock } 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 (@(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
$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.'
} 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
# 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
}
}
+135
View File
@@ -0,0 +1,135 @@
import hashlib
import importlib.util
import io
import json
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
spec = importlib.util.spec_from_file_location('updater', ROOT / 'scripts/update-manifests.py')
updater = importlib.util.module_from_spec(spec)
spec.loader.exec_module(updater)
MODIFIED = 'Wed, 21 Oct 2026 06:00:00 GMT'
HEADERS = {'Last-Modified': MODIFIED, 'ETag': '"example"'}
def response(data=b'', headers=None):
result = io.BytesIO(data)
result.headers = dict(headers or HEADERS)
return result
class UpdaterTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.path = Path(self.temp.name) / 'myelexis-cli.json'
self.manifest = json.loads((ROOT / 'bucket/myelexis-cli.json').read_text(encoding='utf-8'))
self.path.write_text(json.dumps(self.manifest, indent=4) + '\n', encoding='utf-8')
self.original = self.path.read_bytes()
def test_same_version_only_uses_head(self):
self.manifest['version'] = '20261021T060000Z'
self.path.write_text(json.dumps(self.manifest), encoding='utf-8')
before = self.path.read_bytes()
with patch.object(updater, 'open_url', return_value=response()) as request:
self.assertFalse(updater.update_manifest(self.path, write=True))
self.assertEqual(request.call_count, 1)
self.assertEqual(request.call_args.args[1], 'HEAD')
self.assertEqual(self.path.read_bytes(), before)
def test_dry_run_downloads_and_hashes_without_writing(self):
binary = b'MZexample'
with patch.object(updater, 'open_url', side_effect=[response(), response(binary)]) as request:
self.assertTrue(updater.update_manifest(self.path))
self.assertEqual(request.call_args.kwargs['headers']['If-Match'], HEADERS['ETag'])
self.assertEqual(self.path.read_bytes(), self.original)
def test_write_updates_only_version_url_and_hash(self):
binary = b'MZexample'
with patch.object(updater, 'open_url', side_effect=[response(), response(binary)]):
updater.update_manifest(self.path, write=True)
actual = json.loads(self.path.read_text())
expected = {**self.manifest, 'version': '20261021T060000Z',
'hash': hashlib.sha256(binary).hexdigest()}
self.assertEqual(actual, expected)
def test_download_errors_leave_manifest_unchanged(self):
cases = [
response(b'MZexample', {**HEADERS, 'ETag': '"changed"'}),
response(b'MZexample', {**HEADERS, 'Content-Length': '100'}),
response(b'<html>login</html>', {**HEADERS, 'Content-Type': 'text/html'}),
response(b'not-an-executable'),
]
for download in cases:
with self.subTest(headers=download.headers):
with patch.object(updater, 'open_url', side_effect=[response(), download]):
with self.assertRaises(ValueError):
updater.update_manifest(self.path, write=True)
self.assertEqual(self.path.read_bytes(), self.original)
def test_missing_last_modified_leaves_manifest_unchanged(self):
with patch.object(updater, 'open_url', return_value=response(headers={'ETag': 'x'})):
with self.assertRaises(ValueError):
updater.update_manifest(self.path, write=True)
self.assertEqual(self.path.read_bytes(), self.original)
def test_hash_runs_after_download_and_temporary_file_is_removed(self):
binary = b'MZexample'
temporary_paths = []
original_hash = updater.hash_file
def inspect_download(path):
self.assertEqual(path.read_bytes(), binary)
temporary_paths.append(path)
return original_hash(path)
with patch.object(updater, 'open_url', return_value=response(binary)), \
patch.object(updater, 'hash_file', side_effect=inspect_download):
digest, size = updater.download_hash('https://example.org/tool.exe', HEADERS)
self.assertEqual(digest, hashlib.sha256(binary).hexdigest())
self.assertEqual(size, len(binary))
self.assertFalse(temporary_paths[0].parent.exists())
def test_existing_release_manifest_substitutes_version(self):
manifest = {
'version': '13.6.0',
'url': 'https://example.org/releases/download/v13.6.0/tool.exe',
'hash': 'a' * 64,
'checkver': {'url': 'https://example.org/api/releases/latest',
'jsonpath': '$.tag_name', 'regex': r'^v(?<version>[\d.]+)$'},
'autoupdate': {'url': 'https://example.org/releases/download/v$version/tool.exe'},
}
self.path.write_text(json.dumps(manifest), encoding='utf-8')
binary = b'MZexample executable'
with patch.object(updater, 'open_url', side_effect=[
response(b'{"tag_name":"v13.7.0"}'), response(), response(binary)]) as request:
updater.update_manifest(self.path, write=True)
updated = json.loads(self.path.read_text())
self.assertEqual(updated['version'], '13.7.0')
self.assertIn('/v13.7.0/', updated['url'])
self.assertNotIn('#', request.call_args.args[0])
self.assertEqual(updated['hash'], hashlib.sha256(binary).hexdigest())
def test_archive_filename_is_updated_but_download_url_has_no_fragment(self):
manifest = json.loads((ROOT / 'bucket/elexis-3.13.json').read_text(encoding='utf-8'))
self.path.write_text(json.dumps(manifest), encoding='utf-8')
with patch.object(updater, 'open_url', side_effect=[response(), response(b'PKexample')]) as request:
updater.update_manifest(self.path, write=True)
self.assertNotIn('#', request.call_args.args[0])
self.assertTrue(json.loads(self.path.read_text())['url'].endswith('#/Elexis-20261021T060000Z.zip'))
def test_http_error_is_reported_and_does_not_stop_other_manifests(self):
other = self.path.with_name('other.json')
other.write_text(json.dumps({'version': '1.0.0'}), encoding='utf-8')
error = updater.HTTPError('https://example.org/file', 401, 'Unauthorized', {}, None)
with patch.object(updater, 'open_url', side_effect=error):
self.assertEqual(updater.main(['--bucket', self.temp.name, '--write']), 1)
self.assertEqual(self.path.read_bytes(), self.original)
if __name__ == '__main__':
unittest.main()