param( [string]$SshHost = $(if ($env:ARGUS_SSH_HOST) { $env:ARGUS_SSH_HOST } else { "192.168.0.185" }), [string]$SshUser = $(if ($env:ARGUS_SSH_USER) { $env:ARGUS_SSH_USER } else { "sevenhill" }), [string]$ArgusBaseUrl = $(if ($env:ARGUS_BASE_URL) { $env:ARGUS_BASE_URL } else { "https://argus.kusoft.xyz" }), [string]$ArgusData = $(if ($env:ARGUS_DATA) { $env:ARGUS_DATA } else { "/srv/argus-data" }), [string]$ApkPath = "keeperApp\build\outputs\apk\debug\keeperApp-debug.apk", [string]$Slug = "keeper-android", [string]$Name = "QKeep", [string]$Summary = "Cloud notes app for QSfera with local storage and sync.", [string]$Description = "QKeep stores notes locally on Android, uses QSfera browser authorization, syncs notes with the Keeper REST service, and installs SHA-256 verified Argus updates.", [string]$RepositoryUrl = "", [string]$HomepageUrl = "", [string]$Version = "0.1.22", [string]$Channel = "stable", [string]$Platform = "android", [string]$PackageKind = "apk", [string]$Notes = "Aligns the Android updater and publication flow with ARGUS_PUBLICATION.md." ) $ErrorActionPreference = "Stop" function ConvertTo-Base64Utf8([string]$Value) { return [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($Value)) } function Assert-LastExitCode([string]$Action) { if ($LASTEXITCODE -ne 0) { throw "$Action failed with exit code $LASTEXITCODE." } } $resolvedApk = (Resolve-Path -LiteralPath $ApkPath).Path $sshTarget = "$SshUser@$SshHost" $remoteFile = "/tmp/$Slug-$Version.apk" $manifestUrl = "$($ArgusBaseUrl.TrimEnd("/"))/api/apps/$Slug/manifest?platform=$Platform&channel=$Channel" & scp "$resolvedApk" "${sshTarget}:$remoteFile" Assert-LastExitCode "Copy APK to Argus host" $nameB64 = ConvertTo-Base64Utf8 $Name $summaryB64 = ConvertTo-Base64Utf8 $Summary $descriptionB64 = ConvertTo-Base64Utf8 $Description $repositoryUrlB64 = ConvertTo-Base64Utf8 $RepositoryUrl $homepageUrlB64 = ConvertTo-Base64Utf8 $HomepageUrl $notesB64 = ConvertTo-Base64Utf8 $Notes $remoteScript = @" set -euo pipefail export ARGUS_DATA="$ArgusData" export SOURCE_FILE="$remoteFile" export ARGUS_SLUG="$Slug" export ARGUS_NAME="`$(printf '%s' '$nameB64' | base64 -d)" export ARGUS_SUMMARY="`$(printf '%s' '$summaryB64' | base64 -d)" export ARGUS_DESCRIPTION="`$(printf '%s' '$descriptionB64' | base64 -d)" export ARGUS_REPOSITORY_URL="`$(printf '%s' '$repositoryUrlB64' | base64 -d)" export ARGUS_HOMEPAGE_URL="`$(printf '%s' '$homepageUrlB64' | base64 -d)" export ARGUS_IS_LISTED="1" export ARGUS_VERSION="$Version" export ARGUS_CHANNEL="$Channel" export ARGUS_PLATFORM="$Platform" export ARGUS_PACKAGE_KIND="$PackageKind" export ARGUS_NOTES="`$(printf '%s' '$notesB64' | base64 -d)" trap 'rm -f "$SOURCE_FILE"' EXIT python3 - "`$SOURCE_FILE" <<'PY' import hashlib import mimetypes import os import re import shutil import sqlite3 import sys import uuid from datetime import datetime, timezone from pathlib import Path source = Path(sys.argv[1]).resolve() data_root = Path(os.environ.get("ARGUS_DATA", "/srv/argus-data")).resolve() db_path = data_root / "argus.db" packages_root = data_root / "Packages" slug = os.environ["ARGUS_SLUG"].strip() name = os.environ["ARGUS_NAME"].strip() summary = os.environ["ARGUS_SUMMARY"].strip() description = os.environ["ARGUS_DESCRIPTION"].strip() repository_url = os.environ.get("ARGUS_REPOSITORY_URL", "").strip() or None homepage_url = os.environ.get("ARGUS_HOMEPAGE_URL", "").strip() or None is_listed = 1 if os.environ.get("ARGUS_IS_LISTED", "1").strip() != "0" else 0 version = os.environ["ARGUS_VERSION"].strip() channel = os.environ.get("ARGUS_CHANNEL", "stable").strip().lower() platform = os.environ.get("ARGUS_PLATFORM", "generic").strip().lower() package_kind = os.environ.get("ARGUS_PACKAGE_KIND", "binary").strip().lower() notes = os.environ.get("ARGUS_NOTES", "").strip() or None if not re.fullmatch(r"[a-z0-9][a-z0-9-]{0,99}", slug): raise SystemExit("ARGUS_SLUG must contain only lowercase letters, digits, and hyphens, max 100 chars.") if not source.is_file(): raise SystemExit(f"SOURCE_FILE does not exist: {source}") if not db_path.is_file(): raise SystemExit(f"Argus database does not exist: {db_path}") for key, value in { "ARGUS_NAME": name, "ARGUS_SUMMARY": summary, "ARGUS_DESCRIPTION": description, "ARGUS_VERSION": version, }.items(): if not value: raise SystemExit(f"{key} is required.") release_id = str(uuid.uuid4()).upper() now = datetime.now(timezone.utc).isoformat(timespec="microseconds") safe_version = re.sub(r"[^a-zA-Z0-9._-]+", "-", version).strip("-._") or "release" extension = source.suffix or ".bin" stored_name = f"{datetime.now(timezone.utc):%Y%m%d%H%M%S}-{safe_version}-{release_id.replace('-', '')}{extension}" stored_relative_path = f"{slug}/{stored_name}" target_dir = packages_root / slug target_path = target_dir / stored_name target_dir.mkdir(parents=True, exist_ok=True) shutil.copy2(source, target_path) size_bytes = target_path.stat().st_size sha256 = hashlib.sha256(target_path.read_bytes()).hexdigest() content_type = mimetypes.guess_type(source.name)[0] or "application/octet-stream" if source.suffix.lower() == ".apk": content_type = "application/vnd.android.package-archive" conn = sqlite3.connect(db_path) try: conn.execute("PRAGMA foreign_keys = ON") conn.execute("BEGIN") row = conn.execute('SELECT "Id" FROM "Apps" WHERE "Slug" = ?', (slug,)).fetchone() if row is None: app_id = str(uuid.uuid4()).upper() conn.execute( ''' INSERT INTO "Apps" ("Id", "Slug", "Name", "Summary", "Description", "RepositoryUrl", "HomepageUrl", "IsListed", "CreatedAt", "UpdatedAt") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ''', (app_id, slug, name, summary, description, repository_url, homepage_url, is_listed, now, now), ) else: app_id = row[0] conn.execute( ''' UPDATE "Apps" SET "Name" = ?, "Summary" = ?, "Description" = ?, "RepositoryUrl" = ?, "HomepageUrl" = ?, "IsListed" = ?, "UpdatedAt" = ? WHERE "Id" = ? ''', (name, summary, description, repository_url, homepage_url, is_listed, now, app_id), ) duplicate = conn.execute( ''' SELECT "Id" FROM "Releases" WHERE "CatalogAppId" = ? AND "Version" = ? AND "Channel" = ? AND "Platform" = ? ''', (app_id, version, channel, platform), ).fetchone() if duplicate is not None: raise RuntimeError(f"Release already exists for {slug} {version} {channel} {platform}.") conn.execute( ''' INSERT INTO "Releases" ("Id", "CatalogAppId", "Version", "Channel", "Platform", "PackageKind", "OriginalFileName", "StoredRelativePath", "ContentType", "PackageSizeBytes", "Sha256", "Notes", "PublishedAt") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ''', ( release_id, app_id, version, channel, platform, package_kind, source.name, stored_relative_path, content_type, size_bytes, sha256, notes, now, ), ) conn.commit() except Exception: conn.rollback() try: target_path.unlink() except FileNotFoundError: pass raise finally: conn.close() print(f"Published app={slug} version={version} channel={channel} platform={platform}") print(f"StoredRelativePath={stored_relative_path}") print(f"Size={size_bytes}") print(f"Sha256={sha256}") print(f"Manifest=$ArgusBaseUrl/api/apps/{slug}/manifest?platform={platform}&channel={channel}") PY curl -sS "http://127.0.0.1:5105/api/apps/`$ARGUS_SLUG/manifest?platform=`$ARGUS_PLATFORM&channel=`$ARGUS_CHANNEL" >/dev/null "@ $remoteScript | & ssh "$sshTarget" "bash -s" Assert-LastExitCode "Publish release on Argus host" $manifest = Invoke-RestMethod -Uri $manifestUrl -Headers @{ Accept = "application/json" } if ($manifest.release.version -ne $Version) { throw "Argus manifest verification failed: expected version $Version, got $($manifest.release.version)." } Write-Output "Published $Slug $Version to $manifestUrl"