param( [string]$MetadataPath, [string]$PublicBaseUrl = 'https://argus.kusoft.xyz', [string]$RemoteHost = '192.168.0.185', [string]$RemoteUser = 'sevenhill', [int]$RemotePort = 22, [string]$RemoteTempDirectory = '/tmp', [string]$ArgusData = '/srv/argus-data', [string]$GeneratedScriptPath, [switch]$PrepareOnly, [switch]$RestartArgus ) $ErrorActionPreference = 'Stop' function Fail([string]$Message) { Write-Error $Message exit 1 } function Get-LatestMetadataPath { $repoRoot = Split-Path -Parent $PSScriptRoot $publicationRoot = Join-Path $repoRoot 'build\argus-publication' if (-not (Test-Path -LiteralPath $publicationRoot)) { Fail "Argus publication directory was not found: $publicationRoot" } $metadata = Get-ChildItem -LiteralPath $publicationRoot -Recurse -File -Filter 'argus-publication.json' | Sort-Object LastWriteTimeUtc -Descending | Select-Object -First 1 if (-not $metadata) { Fail "No argus-publication.json file was found under $publicationRoot" } return $metadata.FullName } function Get-RequiredString($Object, [string]$Name) { $property = $Object.PSObject.Properties[$Name] if ($null -eq $property -or [string]::IsNullOrWhiteSpace([string]$property.Value)) { Fail "Publication metadata is missing '$Name'." } return [string]$property.Value } function Quote-BashSingle([string]$Value) { if ($null -eq $Value) { $Value = '' } return "'" + ($Value -replace "'", "'\''") + "'" } function Join-RemotePath([string]$Directory, [string]$Leaf) { return $Directory.TrimEnd('/') + '/' + $Leaf } function Invoke-CheckedNative([string]$Program, [string[]]$Arguments) { & $Program @Arguments if ($LASTEXITCODE -ne 0) { Fail "$Program failed with exit code $LASTEXITCODE." } } function Assert-Slug([string]$Slug) { if ($Slug -notmatch '^[a-z0-9][a-z0-9-]{0,99}$') { Fail "Argus slug must contain only lowercase letters, digits, and hyphens, max 100 chars: $Slug" } } function Assert-ManifestValue($Manifest, [string]$Name, $Actual, $Expected) { if ([string]$Actual -ne [string]$Expected) { Fail "Published manifest $Name expected '$Expected' but got '$Actual'." } } if ([string]::IsNullOrWhiteSpace($MetadataPath)) { $MetadataPath = Get-LatestMetadataPath } $resolvedMetadataPath = (Resolve-Path -LiteralPath $MetadataPath).Path $metadata = Get-Content -LiteralPath $resolvedMetadataPath -Raw -Encoding UTF8 | ConvertFrom-Json $packagePath = (Resolve-Path -LiteralPath (Get-RequiredString $metadata 'packagePath')).Path $slug = Get-RequiredString $metadata 'slug' $name = Get-RequiredString $metadata 'name' $summary = Get-RequiredString $metadata 'summary' $description = Get-RequiredString $metadata 'description' $version = Get-RequiredString $metadata 'version' $channel = (Get-RequiredString $metadata 'channel').ToLowerInvariant() $platform = (Get-RequiredString $metadata 'platform').ToLowerInvariant() $packageKind = (Get-RequiredString $metadata 'packageKind').ToLowerInvariant() $notes = if ($metadata.PSObject.Properties['notes']) { [string]$metadata.notes } else { '' } $repositoryUrl = if ($metadata.PSObject.Properties['repositoryUrl']) { [string]$metadata.repositoryUrl } else { '' } $homepageUrl = if ($metadata.PSObject.Properties['homepageUrl']) { [string]$metadata.homepageUrl } else { '' } $isListed = if ($metadata.PSObject.Properties['isListed'] -and [bool]$metadata.isListed) { '1' } else { '0' } Assert-Slug $slug if ($platform -ne 'android') { Fail "QSfera Android publication requires platform=android. Actual platform: $platform" } if ($packageKind -ne 'apk') { Fail "QSfera Android publication requires packageKind=apk. Actual packageKind: $packageKind" } if (-not $packagePath.EndsWith('.apk', [System.StringComparison]::OrdinalIgnoreCase)) { Fail "QSfera Android publication requires an APK package: $packagePath" } $scp = Get-Command scp -ErrorAction SilentlyContinue $ssh = Get-Command ssh -ErrorAction SilentlyContinue if (-not $scp -or -not $ssh) { Fail 'Both scp and ssh must be available for SSH-only Argus publication.' } $localSize = (Get-Item -LiteralPath $packagePath).Length $localSha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $packagePath).Hash.ToLowerInvariant() $packageFileName = Split-Path -Leaf $packagePath $remotePackagePath = Join-RemotePath $RemoteTempDirectory $packageFileName $token = [guid]::NewGuid().ToString('N') $remoteScriptPath = Join-RemotePath $RemoteTempDirectory "$slug-publish-$token.sh" $localScriptPath = if ([string]::IsNullOrWhiteSpace($GeneratedScriptPath)) { Join-Path ([System.IO.Path]::GetTempPath()) "$slug-publish-$token.sh" } else { $GeneratedScriptPath } $remote = "$RemoteUser@$RemoteHost" $sshPort = [string]$RemotePort $restartCommand = if ($RestartArgus) { 'docker restart argus >/dev/null' } else { ':' } $scriptTemplate = @' set -euo pipefail cleanup() { rm -f "$SOURCE_FILE" "$0" } trap cleanup EXIT export ARGUS_DATA=__ARGUS_DATA__ export SOURCE_FILE=__SOURCE_FILE__ export ARGUS_SLUG=__ARGUS_SLUG__ export ARGUS_NAME=__ARGUS_NAME__ export ARGUS_SUMMARY=__ARGUS_SUMMARY__ export ARGUS_DESCRIPTION=__ARGUS_DESCRIPTION__ export ARGUS_REPOSITORY_URL=__ARGUS_REPOSITORY_URL__ export ARGUS_HOMEPAGE_URL=__ARGUS_HOMEPAGE_URL__ export ARGUS_IS_LISTED=__ARGUS_IS_LISTED__ export ARGUS_VERSION=__ARGUS_VERSION__ export ARGUS_CHANNEL=__ARGUS_CHANNEL__ export ARGUS_PLATFORM=__ARGUS_PLATFORM__ export ARGUS_PACKAGE_KIND=__ARGUS_PACKAGE_KIND__ export ARGUS_NOTES=__ARGUS_NOTES__ 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()) 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()) 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=https://argus.kusoft.xyz/api/apps/{slug}/manifest?platform={platform}&channel={channel}") PY curl -fsS "http://127.0.0.1:5105/api/apps/$ARGUS_SLUG/manifest?platform=$ARGUS_PLATFORM&channel=$ARGUS_CHANNEL" __RESTART_COMMAND__ '@ $publicationScript = $scriptTemplate. Replace('__ARGUS_DATA__', (Quote-BashSingle $ArgusData)). Replace('__SOURCE_FILE__', (Quote-BashSingle $remotePackagePath)). Replace('__ARGUS_SLUG__', (Quote-BashSingle $slug)). Replace('__ARGUS_NAME__', (Quote-BashSingle $name)). Replace('__ARGUS_SUMMARY__', (Quote-BashSingle $summary)). Replace('__ARGUS_DESCRIPTION__', (Quote-BashSingle $description)). Replace('__ARGUS_REPOSITORY_URL__', (Quote-BashSingle $repositoryUrl)). Replace('__ARGUS_HOMEPAGE_URL__', (Quote-BashSingle $homepageUrl)). Replace('__ARGUS_IS_LISTED__', (Quote-BashSingle $isListed)). Replace('__ARGUS_VERSION__', (Quote-BashSingle $version)). Replace('__ARGUS_CHANNEL__', (Quote-BashSingle $channel)). Replace('__ARGUS_PLATFORM__', (Quote-BashSingle $platform)). Replace('__ARGUS_PACKAGE_KIND__', (Quote-BashSingle $packageKind)). Replace('__ARGUS_NOTES__', (Quote-BashSingle $notes)). Replace('__RESTART_COMMAND__', $restartCommand) [System.IO.File]::WriteAllText($localScriptPath, $publicationScript, [System.Text.UTF8Encoding]::new($false)) if ($PrepareOnly) { Write-Output 'QSfera Android Argus SSH publication script prepared.' Write-Output "Script: $localScriptPath" Write-Output "APK: $packagePath" Write-Output "Remote APK: $remotePackagePath" Write-Output "Size: $localSize" Write-Output "SHA-256: $localSha256" exit 0 } try { Invoke-CheckedNative $scp.Source @('-P', $sshPort, $packagePath, "${remote}:$remotePackagePath") Invoke-CheckedNative $scp.Source @('-P', $sshPort, $localScriptPath, "${remote}:$remoteScriptPath") Invoke-CheckedNative $ssh.Source @('-p', $sshPort, $remote, "bash $(Quote-BashSingle $remoteScriptPath)") } finally { Remove-Item -LiteralPath $localScriptPath -Force -ErrorAction SilentlyContinue } $manifestUrl = "$($PublicBaseUrl.TrimEnd('/'))/api/apps/$slug/manifest?platform=$platform&channel=$channel" $manifest = Invoke-RestMethod -Uri $manifestUrl -TimeoutSec 30 $release = $manifest.release if ($null -eq $release) { Fail "Published manifest did not return a release: $manifestUrl" } Assert-ManifestValue $manifest 'slug' $manifest.slug $slug Assert-ManifestValue $release 'version' $release.version $version Assert-ManifestValue $release 'channel' $release.channel $channel Assert-ManifestValue $release 'platform' $release.platform $platform Assert-ManifestValue $release 'packageKind' $release.packageKind $packageKind Assert-ManifestValue $release 'originalFileName' $release.originalFileName $packageFileName Assert-ManifestValue $release 'packageSizeBytes' $release.packageSizeBytes $localSize Assert-ManifestValue $release 'sha256' $release.sha256 $localSha256 Write-Output 'QSfera Android Argus SSH publication completed.' Write-Output "Manifest: $manifestUrl" Write-Output "Size: $localSize" Write-Output "SHA-256: $localSha256"