param( [string]$ServerHost = '192.168.0.185', [string]$Username = 'sevenhill', [Parameter(Mandatory = $true)] [string]$Password, [string]$ArgusBaseUrl = 'https://argus.kusoft.xyz', [string]$ArgusInternalBaseUrl = 'http://127.0.0.1:5105', [string]$ArgusEnvPath = '/home/sevenhill/argus-deploy/.env.server', [string]$Slug = 'ikar-android', [string]$Notes = 'Android client update for Ikar', [string]$AppName = 'Ikar Android', [string]$Summary = 'Android client build', [string]$Description = 'Android release channel for the Ikar client.', [string]$HomepageUrl = 'https://ikar.kusoft.xyz', [string]$Platform = 'android', [string]$PackageKind = 'apk', [string]$Channel = 'stable' ) $ErrorActionPreference = 'Stop' Set-Location (Join-Path $PSScriptRoot '..') $repoRoot = (Get-Location).Path $projectPath = '.\src\Ikar.Client\Ikar.Client.csproj' [xml]$projectXml = Get-Content $projectPath $versionName = $projectXml.Project.PropertyGroup.ApplicationDisplayVersion | Select-Object -First 1 $versionCode = [int]($projectXml.Project.PropertyGroup.ApplicationVersion | Select-Object -First 1) $publishDir = Join-Path $repoRoot (".codex-temp\android-update-publish\v{0}" -f $versionCode) if (Test-Path $publishDir) { Remove-Item $publishDir -Recurse -Force } dotnet publish $projectPath -f net10.0-android -c Release -p:AndroidPackageFormat=apk -p:UseSharedCompilation=false -o $publishDir $sourceApk = Join-Path $publishDir 'com.seven.ikar-Signed.apk' if (-not (Test-Path $sourceApk)) { throw "Signed APK not found: $sourceApk" } $remoteFileName = "ikar-android-$versionCode.apk" $localCopy = Join-Path $publishDir $remoteFileName Copy-Item $sourceApk $localCopy -Force $manifest = [ordered]@{ versionCode = $versionCode versionName = $versionName packageFileName = $remoteFileName publishedAt = [DateTimeOffset]::UtcNow.ToString('O') notes = $Notes } $manifestPath = Join-Path $publishDir 'latest.json' $manifest | ConvertTo-Json -Depth 4 | Set-Content $manifestPath -Encoding UTF8 $argusPublishResult = @" import json import os import paramiko import posixpath host = os.environ['IKAR_UPDATE_HOST'] username = os.environ['IKAR_UPDATE_USERNAME'] password = os.environ['IKAR_UPDATE_PASSWORD'] env_path = os.environ['IKAR_UPDATE_ARGUS_ENV_PATH'] local_apk_path = os.environ['IKAR_UPDATE_APK_PATH'] remote_apk_name = os.environ['IKAR_UPDATE_REMOTE_APK_NAME'] argus_internal_base_url = os.environ['IKAR_UPDATE_ARGUS_INTERNAL_BASE_URL'] slug = os.environ['IKAR_UPDATE_ARGUS_SLUG'] app_name = os.environ['IKAR_UPDATE_ARGUS_NAME'] summary = os.environ['IKAR_UPDATE_ARGUS_SUMMARY'] description = os.environ['IKAR_UPDATE_ARGUS_DESCRIPTION'] homepage_url = os.environ['IKAR_UPDATE_ARGUS_HOMEPAGE_URL'] release_version = os.environ['IKAR_UPDATE_ARGUS_VERSION'] channel = os.environ['IKAR_UPDATE_ARGUS_CHANNEL'] platform = os.environ['IKAR_UPDATE_ARGUS_PLATFORM'] package_kind = os.environ['IKAR_UPDATE_ARGUS_PACKAGE_KIND'] notes = os.environ['IKAR_UPDATE_ARGUS_NOTES'] temp_root = f"/home/{username}/.argus-upload" client = paramiko.SSHClient() client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) client.connect(host, username=username, password=password, timeout=10) sftp = client.open_sftp() with sftp.open(env_path, 'r') as handle: content = handle.read().decode('utf-8', errors='replace') values = {} for raw_line in content.splitlines(): line = raw_line.strip() if not line or line.startswith('#') or '=' not in line: continue key, value = line.split('=', 1) values[key.strip()] = value.strip() username = values.get('Admin__Username') password = values.get('Admin__Password') if not username or not password: raise RuntimeError(f"Admin credentials were not found in {env_path}.") parts = temp_root.strip('/').split('/') current = '/' for part in parts: current = posixpath.join(current, part) try: sftp.stat(current) except FileNotFoundError: sftp.mkdir(current) remote_apk_path = posixpath.join(temp_root, remote_apk_name) sftp.put(local_apk_path, remote_apk_path) sftp.close() def run_remote_python(script: str) -> str: stdin, stdout, stderr = client.exec_command("python3 -", timeout=600) stdin.write(script) stdin.channel.shutdown_write() out = stdout.read().decode('utf-8', errors='replace') err = stderr.read().decode('utf-8', errors='replace') exit_status = stdout.channel.recv_exit_status() if exit_status != 0: raise RuntimeError(f"Remote publish failed with exit status {exit_status}\\nSTDOUT:\\n{out}\\nSTDERR:\\n{err}") return out remote_payload = json.dumps({ 'base_url': argus_internal_base_url.rstrip('/'), 'slug': slug, 'package_path': remote_apk_path, 'admin_username': username, 'admin_password': password, 'app_name': app_name, 'summary': summary, 'description': description, 'homepage_url': homepage_url, 'release_version': release_version, 'release_channel': channel, 'release_platform': platform, 'release_package_kind': package_kind, 'release_notes': notes, }) remote_script = ''' import json import os import urllib.request import uuid payload = json.loads(%s) base_url = payload['base_url'] slug = payload['slug'] package_path = payload['package_path'] admin_username = payload['admin_username'] admin_password = payload['admin_password'] app_name = payload['app_name'] summary = payload['summary'] description = payload['description'] homepage_url = payload['homepage_url'] release_version = payload['release_version'] release_channel = payload['release_channel'] release_platform = payload['release_platform'] release_package_kind = payload['release_package_kind'] release_notes = payload['release_notes'] def read_response(response): body = response.read().decode('utf-8', errors='replace') return response.getcode(), body opener = urllib.request.build_opener() login_payload = json.dumps({ 'username': admin_username, 'password': admin_password, }).encode('utf-8') login_request = urllib.request.Request( f"{base_url}/api/admin/session/login", data=login_payload, headers={'Content-Type': 'application/json'}, method='POST') login_response = opener.open(login_request, timeout=600) login_status, login_body = read_response(login_response) if login_status < 200 or login_status >= 300: raise RuntimeError(f"Login failed with status {login_status}: {login_body}") cookie_header = login_response.headers.get('Set-Cookie', '') auth_cookie = cookie_header.split(';', 1)[0].strip() if not auth_cookie: raise RuntimeError('Argus login did not return an auth cookie.') metadata_payload = json.dumps({ 'name': app_name, 'summary': summary, 'description': description, 'homepageUrl': homepage_url or None, 'repositoryUrl': None, 'isListed': True, }).encode('utf-8') metadata_request = urllib.request.Request( f"{base_url}/api/admin/apps/{slug}", data=metadata_payload, headers={ 'Content-Type': 'application/json', 'Cookie': auth_cookie, }, method='PUT') metadata_response = opener.open(metadata_request, timeout=600) metadata_status, metadata_body = read_response(metadata_response) if metadata_status < 200 or metadata_status >= 300: raise RuntimeError(f"Metadata update failed with status {metadata_status}: {metadata_body}") with open(package_path, 'rb') as handle: file_bytes = handle.read() boundary = f"----IkarArgusBoundary{uuid.uuid4().hex}" parts: list[bytes] = [] def add_field(name: str, value: str): parts.append(f"--{boundary}\\r\\n".encode('utf-8')) parts.append(f'Content-Disposition: form-data; name="{name}"\\r\\n\\r\\n'.encode('utf-8')) parts.append(value.encode('utf-8')) parts.append(b"\\r\\n") for field_name, field_value in [ ('Version', release_version), ('Channel', release_channel), ('Platform', release_platform), ('PackageKind', release_package_kind), ]: add_field(field_name, field_value) if release_notes: add_field('Notes', release_notes) file_name = os.path.basename(package_path) parts.append(f"--{boundary}\\r\\n".encode('utf-8')) parts.append( f'Content-Disposition: form-data; name="Package"; filename="{file_name}"\\r\\n'.encode('utf-8')) parts.append(b"Content-Type: application/vnd.android.package-archive\\r\\n\\r\\n") parts.append(file_bytes) parts.append(b"\\r\\n") parts.append(f"--{boundary}--\\r\\n".encode('utf-8')) release_request = urllib.request.Request( f"{base_url}/api/admin/apps/{slug}/releases", data=b''.join(parts), headers={ 'Content-Type': f'multipart/form-data; boundary={boundary}', 'Cookie': auth_cookie, }, method='POST') release_response = opener.open(release_request, timeout=600) release_status, release_body = read_response(release_response) if release_status < 200 or release_status >= 300: raise RuntimeError(f"Release upload failed with status {release_status}: {release_body}") print(release_body) ''' % json.dumps(remote_payload) publish_output = run_remote_python(remote_script) try: sftp = client.open_sftp() sftp.remove(remote_apk_path) sftp.close() except Exception: pass client.close() print(json.dumps({'status': 'UPLOAD_OK', 'release': publish_output.strip()})) "@ | ForEach-Object { $env:IKAR_UPDATE_HOST = $ServerHost $env:IKAR_UPDATE_USERNAME = $Username $env:IKAR_UPDATE_PASSWORD = $Password $env:IKAR_UPDATE_ARGUS_ENV_PATH = $ArgusEnvPath $env:IKAR_UPDATE_APK_PATH = (Resolve-Path $localCopy) $env:IKAR_UPDATE_REMOTE_APK_NAME = $remoteFileName $env:IKAR_UPDATE_ARGUS_INTERNAL_BASE_URL = $ArgusInternalBaseUrl $env:IKAR_UPDATE_ARGUS_SLUG = $Slug $env:IKAR_UPDATE_ARGUS_NAME = $AppName $env:IKAR_UPDATE_ARGUS_SUMMARY = $Summary $env:IKAR_UPDATE_ARGUS_DESCRIPTION = $Description $env:IKAR_UPDATE_ARGUS_HOMEPAGE_URL = $HomepageUrl $env:IKAR_UPDATE_ARGUS_VERSION = "$versionName+$versionCode" $env:IKAR_UPDATE_ARGUS_CHANNEL = $Channel $env:IKAR_UPDATE_ARGUS_PLATFORM = $Platform $env:IKAR_UPDATE_ARGUS_PACKAGE_KIND = $PackageKind $env:IKAR_UPDATE_ARGUS_NOTES = $Notes $_ } | python - $publishResult = $argusPublishResult | ConvertFrom-Json if ($publishResult.status -ne 'UPLOAD_OK') { throw "Argus publish failed." } Write-Output $publishResult.status