# Argus Publication And Update Integration

Эта инструкция является единой точкой входа для любого приложения, которое нужно публиковать через Argus или подключить к системе обновлений Argus.

Argus в текущей архитектуре является read-only web catalog. У него нет HTTP admin API, upload endpoint, токена публикации или браузерной формы публикации. Публикация выполняется только на сервере через SSH: файл релиза кладётся в `Data/Packages`, а метаданные записываются в SQLite `Data/argus.db`.

## Production Argus

- Public base URL: `https://argus.kusoft.xyz`
- SSH host: `192.168.0.185`
- SSH user: `sevenhill`
- Server project path: `/home/sevenhill/argus`
- Data path: `/srv/argus-data`
- SQLite database: `/srv/argus-data/argus.db`
- Package root: `/srv/argus-data/Packages`

Do not put SSH passwords, private keys, or local `.env` files into application repositories.

## App Values

Every app must choose stable values:

- `slug`: public Argus id. Use lowercase letters, digits, and hyphens only, for example `sample-android` or `my-desktop-app`.
- `name`: display name.
- `summary`: short catalog text.
- `description`: full catalog text.
- `version`: release version. Semantic versions are recommended, for example `1.2.3`.
- `channel`: release channel, normally `stable`.
- `platform`: target platform, for example `windows-x64`, `linux-arm64`, `android`, `web`.
- `packageKind`: package type, for example `zip`, `msi`, `deb`, `apk`, `binary`.

Argus selects the latest release by semantic version first, then by publish time. The running service currently prunes to one retained release per app on service startup.

## Publish A Release Over SSH

From the development machine, copy the built artifact to the Pi:

```bash
scp ./path/to/my-app-1.2.3.zip sevenhill@192.168.0.185:/tmp/my-app-1.2.3.zip
```

Then connect to the Pi:

```bash
ssh sevenhill@192.168.0.185
```

Run this publication block on the Pi after editing the variables at the top:

```bash
set -euo pipefail

export ARGUS_DATA="/srv/argus-data"
export SOURCE_FILE="/tmp/my-app-1.2.3.zip"

export ARGUS_SLUG="my-app"
export ARGUS_NAME="My App"
export ARGUS_SUMMARY="Short summary for the Argus catalog."
export ARGUS_DESCRIPTION="Full description shown in the Argus catalog."
export ARGUS_REPOSITORY_URL=""
export ARGUS_HOMEPAGE_URL=""
export ARGUS_IS_LISTED="1"

export ARGUS_VERSION="1.2.3"
export ARGUS_CHANNEL="stable"
export ARGUS_PLATFORM="windows-x64"
export ARGUS_PACKAGE_KIND="zip"
export ARGUS_NOTES="What changed in this release."

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=https://argus.kusoft.xyz/api/apps/{slug}/manifest?platform={platform}&channel={channel}")
PY
```

Verify publication from the Pi:

```bash
curl -sS "http://127.0.0.1:5105/api/apps/$ARGUS_SLUG/manifest?platform=$ARGUS_PLATFORM&channel=$ARGUS_CHANNEL"
```

Verify publication from outside the Pi:

```bash
curl -sS "https://argus.kusoft.xyz/api/apps/$ARGUS_SLUG/manifest?platform=$ARGUS_PLATFORM&channel=$ARGUS_CHANNEL"
```

If you want Argus to apply its one-release retention rule immediately after manual publication, restart the container:

```bash
docker restart argus
```

The restart is optional for making a newer semantic version visible. It is only needed to force startup pruning immediately.

## Public API Contract For Update Clients

Update clients must use the public read-only API. They must not call `/api/admin/*`; those routes are not part of this system.

Check for an update:

```text
GET https://argus.kusoft.xyz/api/apps/{slug}/manifest?platform={platform}&channel={channel}
```

A `404` response means one of these states:

- the app slug is not listed;
- there is no release for the requested `platform`;
- there is no release for the requested `channel`.

A successful manifest response contains:

```json
{
  "slug": "my-app",
  "name": "My App",
  "summary": "Short summary.",
  "description": "Full description.",
  "repositoryUrl": null,
  "homepageUrl": null,
  "release": {
    "id": "release-guid",
    "version": "1.2.3",
    "channel": "stable",
    "platform": "windows-x64",
    "packageKind": "zip",
    "downloadPath": "/api/apps/my-app/releases/release-guid/download",
    "originalFileName": "my-app-1.2.3.zip",
    "contentType": "application/zip",
    "packageSizeBytes": 123456,
    "sha256": "hex-encoded-sha256",
    "publishedAt": "2026-06-13T18:00:00+00:00",
    "notes": "What changed."
  }
}
```

Download URL:

```text
https://argus.kusoft.xyz{release.downloadPath}
```

There is also a latest-download endpoint:

```text
GET https://argus.kusoft.xyz/api/apps/{slug}/download/latest?platform={platform}&channel={channel}
```

The manifest path is preferred for update clients because it provides `sha256`, `packageSizeBytes`, `version`, and release metadata before downloading.

## Required Update Client Behavior

Every app integrating with Argus must implement this flow:

1. Store its current installed version locally.
2. Request the manifest for its `slug`, `platform`, and `channel`.
3. Treat HTTP `404` as "no update available for this app/platform/channel".
4. Compare local version to `release.version`.
5. If update is needed, download `baseUrl + release.downloadPath` to a temporary file.
6. Calculate SHA-256 of the downloaded file.
7. Compare the calculated SHA-256 to `release.sha256`.
8. Reject and delete the temporary file if SHA-256 does not match.
9. Apply the update with the app platform's own installer or replacement mechanism.
10. Store the new installed version only after a successful install or successful staged update.

Argus does not install files on client devices. It only publishes metadata and serves package bytes. Installation logic belongs to each app.

## Versioning Rule

Use semantic versions when possible:

```text
1.2.3
1.2.4
1.3.0
2.0.0
```

Argus latest selection is semantic-version aware. If an app uses a non-semantic version string, Argus falls back to case-insensitive string comparison for ordering. For predictable updates, keep release versions semantic.

## Recommended Prompt For App Repositories

When asking another app project to integrate with Argus, use this instruction:

```text
Read ARGUS_PUBLICATION.md from the Argus repository.
Implement the "Required Update Client Behavior" section for this app.
Use:
- baseUrl: https://argus.kusoft.xyz
- slug: <app-slug>
- platform: <platform>
- channel: stable
Do not implement publishing over HTTP. Publishing is SSH-only and happens on the Argus server.
Always verify release.sha256 before installing or applying an update.
```
