diff --git a/.gitea/workflows/autoupdate.yml b/.gitea/workflows/autoupdate.yml new file mode 100644 index 0000000..95b6324 --- /dev/null +++ b/.gitea/workflows/autoupdate.yml @@ -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" diff --git a/.gitignore b/.gitignore index 9ad71e2..b4c4bb9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ *.bak *.exe scripts/* -.forgejo/* -.vscode/* \ No newline at end of file +!scripts/update-manifests.py +.vscode/* +__pycache__/ +*.py[cod] diff --git a/bucket/chat-medelexis.json b/bucket/chat-medelexis.json index 8b4a1c5..7a90ed2 100644 --- a/bucket/chat-medelexis.json +++ b/bucket/chat-medelexis.json @@ -5,11 +5,11 @@ "depends": [ "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", "post_install": [ "$configDir = Join-Path $env:APPDATA 'Element'", "New-Item -ItemType Directory -Path $configDir -Force | Out-Null", "Copy-Item (Join-Path $dir 'config.json') (Join-Path $configDir 'config.json') -Force" ] -} \ No newline at end of file +} diff --git a/bucket/ee-iso.json b/bucket/ee-iso.json deleted file mode 100644 index b5b28f0..0000000 --- a/bucket/ee-iso.json +++ /dev/null @@ -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(?[\\d.]+)$" - }, - "autoupdate": { - "url": "https://git.streichtech.ch/elexis/ee-iso-builder/releases/download/v$version/ElexisEnvironment.iso#/ElexisEnvironment.dat" - } -} diff --git a/bucket/p.intern.json b/bucket/p.intern.json index ffd26b8..15d3f1e 100644 --- a/bucket/p.intern.json +++ b/bucket/p.intern.json @@ -13,12 +13,12 @@ "extras/keepass", "extras/teamviewer", - "ee-cli", - "elexis_3.10", - "elexis_3.12", - "elexis_3.13", - "elexis_master", - "chat_medelexis" + "myelexis-cli", + "elexis-3.10", + "elexis-3.12", + "elexis-3.13", + "elexis-master", + "chat-medelexis" ], - "url": "https://git.streichtech.ch/elexis/scoop/raw/branch/master/packages/p.intern.txt#/p.intern.txt" -} \ No newline at end of file + "url": "https://gitea.medelexis.ch/public/scoop/raw/branch/master/packages/p.intern.txt#/p.intern.txt" +} diff --git a/installer/scoop.PS1 b/installer/scoop.PS1 index 2fa127b..8d26040 100644 --- a/installer/scoop.PS1 +++ b/installer/scoop.PS1 @@ -33,7 +33,7 @@ if ($bucketList -match "elexis") { Write-Host "elexis Bucket ist bereits vorhanden." -ForegroundColor White } 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 } @@ -47,6 +47,6 @@ else { Write-Host "" 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 "" -Read-Host "Enter drücken zum Beenden" \ No newline at end of file +Read-Host "Enter drücken zum Beenden" diff --git a/scripts/update-manifests.py b/scripts/update-manifests.py new file mode 100644 index 0000000..94776a9 --- /dev/null +++ b/scripts/update-manifests.py @@ -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'(?\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('(?', '(?P') + 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()) diff --git a/test.txt b/test.txt deleted file mode 100644 index d800886..0000000 --- a/test.txt +++ /dev/null @@ -1 +0,0 @@ -123 \ No newline at end of file diff --git a/tests/test_update_manifests.py b/tests/test_update_manifests.py new file mode 100644 index 0000000..01cad6e --- /dev/null +++ b/tests/test_update_manifests.py @@ -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'login', {**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(?[\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()