Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee6d5b9b1e | ||
|
|
6c1c651839 | ||
|
|
795a056564 | ||
|
|
53f3f6bd45 | ||
|
|
079e69963d | ||
|
|
3a807da54c | ||
|
|
71c35c0e45 |
No files matched your search
@@ -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"
|
||||||
+2
-2
@@ -1,5 +1,5 @@
|
|||||||
*.bak
|
*.bak
|
||||||
*.exe
|
*.exe
|
||||||
scripts/*
|
|
||||||
.forgejo/*
|
|
||||||
.vscode/*
|
.vscode/*
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
"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'",
|
||||||
|
|||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -37,7 +37,6 @@
|
|||||||
"}"
|
"}"
|
||||||
],
|
],
|
||||||
"persist": [
|
"persist": [
|
||||||
"Medelexis.ini",
|
|
||||||
"workspace\\.metadata\\.plugins\\org.eclipse.e4.workbench"
|
"workspace\\.metadata\\.plugins\\org.eclipse.e4.workbench"
|
||||||
],
|
],
|
||||||
"notes": [
|
"notes": [
|
||||||
|
|||||||
@@ -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,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": [
|
||||||
|
|||||||
@@ -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,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",
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
@@ -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.'
|
||||||
|
}
|
||||||
@@ -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.'
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
Reference in new issue
Block a user