param( [string]$ManifestPath, [string]$SshHost = "192.168.0.25", [string]$SshUser = "sevenhill", [string]$ArgusDataPath = "/opt/argus/data", [switch]$RestartArgus ) Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" function Resolve-ProjectRoot { return (Resolve-Path (Join-Path $PSScriptRoot "..")).Path } function Resolve-LatestManifestPath { param( [Parameter(Mandatory = $true)] [string]$ProjectRoot ) $argusRoot = Join-Path (Join-Path $ProjectRoot "artifacts") "argus" if (-not (Test-Path -LiteralPath $argusRoot)) { throw "Argus artifacts directory not found: $argusRoot" } $latest = Get-ChildItem -LiteralPath $argusRoot -Recurse -Filter "argus-release.json" -File | Sort-Object LastWriteTimeUtc -Descending | Select-Object -First 1 if (-not $latest) { throw "No argus-release.json files found under $argusRoot" } return $latest.FullName } function Assert-RequiredString { param( [Parameter(Mandatory = $true)] [object]$Manifest, [Parameter(Mandatory = $true)] [string]$PropertyName ) if (-not ($Manifest.PSObject.Properties.Name -contains $PropertyName)) { throw "Argus manifest is missing required property: $PropertyName" } $value = [string]$Manifest.$PropertyName if ([string]::IsNullOrWhiteSpace($value)) { throw "Argus manifest property is blank: $PropertyName" } return $value } function ConvertTo-BashSingleQuoted { param([AllowNull()][string]$Value) if ($null -eq $Value) { return "''" } return "'" + ($Value -replace "'", "'\''") + "'" } function Get-PublicCatalogFlag { param([object]$Manifest) if ($Manifest.PSObject.Properties.Name -contains "publicCatalog") { if ([bool]$Manifest.publicCatalog) { return "1" } return "0" } return "1" } function Invoke-CheckedNativeCommand { param( [Parameter(Mandatory = $true)] [string]$FilePath, [Parameter(Mandatory = $true)] [string[]]$Arguments ) & $FilePath @Arguments if ($LASTEXITCODE -ne 0) { throw "$FilePath exited with code $LASTEXITCODE." } } $projectRoot = Resolve-ProjectRoot $resolvedManifestPath = if ($ManifestPath) { (Resolve-Path -LiteralPath $ManifestPath).Path } else { Resolve-LatestManifestPath -ProjectRoot $projectRoot } $manifestDirectory = Split-Path -Parent $resolvedManifestPath $manifest = Get-Content -LiteralPath $resolvedManifestPath -Raw | ConvertFrom-Json $slug = Assert-RequiredString -Manifest $manifest -PropertyName "slug" $name = Assert-RequiredString -Manifest $manifest -PropertyName "name" $summary = Assert-RequiredString -Manifest $manifest -PropertyName "summary" $description = Assert-RequiredString -Manifest $manifest -PropertyName "description" $version = Assert-RequiredString -Manifest $manifest -PropertyName "version" $channel = Assert-RequiredString -Manifest $manifest -PropertyName "channel" $platform = Assert-RequiredString -Manifest $manifest -PropertyName "platform" $packageKind = Assert-RequiredString -Manifest $manifest -PropertyName "packageKind" $packageFile = Assert-RequiredString -Manifest $manifest -PropertyName "packageFile" $packageSha256 = Assert-RequiredString -Manifest $manifest -PropertyName "packageSha256" $releaseNotes = if ($manifest.PSObject.Properties.Name -contains "releaseNotes") { [string]$manifest.releaseNotes } else { "" } $repositoryUrl = if ($manifest.PSObject.Properties.Name -contains "repositoryUrl") { [string]$manifest.repositoryUrl } else { "" } $homepageUrl = if ($manifest.PSObject.Properties.Name -contains "homepageUrl") { [string]$manifest.homepageUrl } else { "" } $isListed = Get-PublicCatalogFlag -Manifest $manifest if ($slug -notmatch "^[a-z0-9][a-z0-9-]{0,99}$") { throw "Argus slug must contain only lowercase letters, digits, and hyphens, max 100 chars: $slug" } if ($packageSha256 -notmatch "^[0-9a-f]{64}$") { throw "packageSha256 must be a lowercase SHA-256 hex digest: $packageSha256" } $packagePath = Join-Path $manifestDirectory $packageFile if (-not (Test-Path -LiteralPath $packagePath)) { throw "Package from Argus manifest not found: $packagePath" } if ([IO.Path]::GetFileName($packageFile) -ne $packageFile) { throw "packageFile must be a file name without directories: $packageFile" } $actualSha256 = ((Get-FileHash -LiteralPath $packagePath -Algorithm SHA256).Hash).ToLowerInvariant() if ($actualSha256 -ne $packageSha256) { throw "Package SHA-256 mismatch. Manifest: $packageSha256 Actual: $actualSha256" } $remoteTarget = "$SshUser@$SshHost" $remoteSourceFile = "/tmp/$packageFile" Invoke-CheckedNativeCommand -FilePath "scp" -Arguments @( $packagePath, "$remoteTarget`:$remoteSourceFile" ) $pythonBlock = @' 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=https://argus.kusoft.xyz/api/apps/{slug}/manifest?platform={platform}&channel={channel}") '@ $remoteScriptLines = @( "set -euo pipefail", "export ARGUS_DATA=$(ConvertTo-BashSingleQuoted $ArgusDataPath)", "export SOURCE_FILE=$(ConvertTo-BashSingleQuoted $remoteSourceFile)", "export ARGUS_SLUG=$(ConvertTo-BashSingleQuoted $slug)", "export ARGUS_NAME=$(ConvertTo-BashSingleQuoted $name)", "export ARGUS_SUMMARY=$(ConvertTo-BashSingleQuoted $summary)", "export ARGUS_DESCRIPTION=$(ConvertTo-BashSingleQuoted $description)", "export ARGUS_REPOSITORY_URL=$(ConvertTo-BashSingleQuoted $repositoryUrl)", "export ARGUS_HOMEPAGE_URL=$(ConvertTo-BashSingleQuoted $homepageUrl)", "export ARGUS_IS_LISTED=$(ConvertTo-BashSingleQuoted $isListed)", "export ARGUS_VERSION=$(ConvertTo-BashSingleQuoted $version)", "export ARGUS_CHANNEL=$(ConvertTo-BashSingleQuoted $channel)", "export ARGUS_PLATFORM=$(ConvertTo-BashSingleQuoted $platform)", "export ARGUS_PACKAGE_KIND=$(ConvertTo-BashSingleQuoted $packageKind)", "export ARGUS_NOTES=$(ConvertTo-BashSingleQuoted $releaseNotes)", 'trap ''rm -f -- "$SOURCE_FILE"'' EXIT', 'python3 - "$SOURCE_FILE" <<''PY''', $pythonBlock, "PY", 'curl -sS "http://127.0.0.1:5105/api/apps/$ARGUS_SLUG/manifest?platform=$ARGUS_PLATFORM&channel=$ARGUS_CHANNEL"', "# end" ) if ($RestartArgus) { $remoteScriptLines += "docker restart argus" } $remoteScript = $remoteScriptLines -join "`n" $tempRemoteScript = New-TemporaryFile try { Set-Content -LiteralPath $tempRemoteScript -Value $remoteScript -Encoding utf8 Get-Content -LiteralPath $tempRemoteScript -Raw | & ssh $remoteTarget "bash" "-s" if ($LASTEXITCODE -ne 0) { throw "ssh exited with code $LASTEXITCODE." } } finally { Remove-Item -LiteralPath $tempRemoteScript -Force -ErrorAction SilentlyContinue } $publicManifestUrl = "https://argus.kusoft.xyz/api/apps/$slug/manifest?platform=$platform&channel=$channel" $publicManifest = Invoke-RestMethod -Uri $publicManifestUrl -TimeoutSec 30 if ([string]$publicManifest.release.version -ne $version) { throw "Public Argus manifest version mismatch. Expected $version, got $($publicManifest.release.version)." } if ([string]$publicManifest.release.sha256 -ne $packageSha256) { throw "Public Argus manifest SHA-256 mismatch. Expected $packageSha256, got $($publicManifest.release.sha256)." } Write-Host "Argus publication verified:" Write-Host "Manifest: $publicManifestUrl" Write-Host "Version: $($publicManifest.release.version)" Write-Host "SHA-256: $($publicManifest.release.sha256)"