Initial Mist scaffold
admin-web / build (push) Successful in 22s
backend / test (push) Failing after 52s
mistpipe / test (push) Successful in 10s
admin-web / build-and-push (push) Failing after 5s
backend / build-and-push (push) Has been skipped

Successor to the Josh Steam prototypes. Single-VM Docker Compose stack with
the load-bearing core/ logic ported from JoshSteam CDN with bug fixes.

Contents:
- backend/  FastAPI + Celery (same image, two entrypoints)
            core/  hdiff, librsync, chain_replay, manifest, compression,
                   discord, steam, unrealpak, paths
            api/   auth, catalog, admin, builds (skeletons) + downloads (real)
            worker/  Celery factory replacing the missing prototype Tasks/__init__.py
            db/    SQLAlchemy models + Alembic initial migration
- admin-web/  SvelteKit + Tailwind skeleton
- client/    Tauri 2 + Svelte skeleton (Mist placeholder UI)
- mistpipe/  click-based admin CLI with subcommand stubs
- docs/      ARCHITECTURE, DECISIONS (9 ADRs), RUNBOOK
- docker-compose.yml + dev overlay + .github/workflows

Bugs fixed during port:
- Routes/download.py:2 stray backslash on import line
- Utils/celery.py inspect.reserved() missing parens + double active() typo
- Hardcoded OneDrive/Desktop paths replaced with pydantic-settings config
- Discord webhook URL + RabbitMQ password moved to env vars
- Missing Tasks/__init__.py reconstructed as worker/__init__.py

Out of scope for this commit: route bodies, UI screens, mistpipe subcommand
bodies, real image builds.
This commit is contained in:
2026-06-07 19:39:25 -04:00
commit bfd6771a9a
76 changed files with 3890 additions and 0 deletions
+32
View File
@@ -0,0 +1,32 @@
# mistpipe
Admin CLI for [Mist](../README.md). Inspired by Steam's SteamPipe and itch.io's butler.
`mistpipe` is what you run on your laptop to upload new games and push updates to the Mist backend. It handles authentication, local archive prep, and the HTTP upload to the `api` service.
## Install
```sh
pip install -e .
```
## Usage
```sh
mistpipe login # interactive; stores JWT in OS keychain
mistpipe new-game --title "Satisfactory" \
--app-id 526870 \
--version 1.0.0.0 \
--path ./satisfactory
mistpipe push satisfactory 1.0.0.3 \
--path ./satisfactory \
--notes-url https://example.com/notes
mistpipe ls
mistpipe resync-steam satisfactory
mistpipe rm satisfactory # soft delete
mistpipe logout
```
## Status
Subcommands are scaffolded; bodies print "TODO" and exit cleanly. Real implementations land alongside the corresponding backend routes.
+25
View File
@@ -0,0 +1,25 @@
[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "mistpipe"
version = "0.1.0"
description = "Mist admin upload CLI — SteamPipe but tiny."
readme = "README.md"
requires-python = ">=3.11"
license = { text = "Proprietary" }
authors = [{ name = "Josh" }]
dependencies = [
"click>=8.1",
"httpx>=0.27",
"keyring>=25.0",
"rich>=13.9",
]
[project.scripts]
mistpipe = "mistpipe.cli:cli"
[tool.setuptools.packages.find]
where = ["src"]
+3
View File
@@ -0,0 +1,3 @@
"""mistpipe — admin CLI for the Mist game distribution platform."""
__version__ = "0.1.0"
+4
View File
@@ -0,0 +1,4 @@
from mistpipe.cli import cli
if __name__ == "__main__":
cli()
+134
View File
@@ -0,0 +1,134 @@
"""mistpipe — click-based CLI.
Subcommand bodies are skeletons that print TODO and exit. The HTTP wiring
in `mistpipe.client.MistClient` is real where it can be; backend routes
those calls hit are skeletons themselves (501 Not Implemented) until the
product roadmap fills them in.
"""
from __future__ import annotations
from pathlib import Path
import click
from rich.console import Console
from rich.table import Table
from mistpipe import keychain
from mistpipe.client import DEFAULT_BASE_URL, MistClient
console = Console()
@click.group()
@click.option("--api-url", default=DEFAULT_BASE_URL, envvar="MIST_API_URL", show_default=True)
@click.pass_context
def cli(ctx: click.Context, api_url: str) -> None:
"""mistpipe — admin CLI for Mist."""
ctx.ensure_object(dict)
ctx.obj["api_url"] = api_url
@cli.command()
@click.option("--username", prompt=True)
@click.password_option(confirmation_prompt=False)
@click.pass_context
def login(ctx: click.Context, username: str, password: str) -> None:
"""Authenticate and store a JWT in the OS keychain."""
client = MistClient(ctx.obj["api_url"])
try:
client.login(username, password)
console.print(f"[green]Signed in as[/] {username}")
except Exception as e:
console.print(f"[red]Login failed:[/] {e}")
raise SystemExit(1)
@cli.command()
def logout() -> None:
"""Clear the stored JWT."""
keychain.clear_token()
console.print("[green]Signed out.[/]")
@cli.command("new-game")
@click.option("--title", required=True, help="Display title of the game")
@click.option("--app-id", type=int, default=None, help="Steam app id (optional)")
@click.option("--version", required=True, help="Initial version string, e.g. 1.0.0.0")
@click.option(
"--path",
required=True,
type=click.Path(exists=True, file_okay=False, dir_okay=True, path_type=Path),
help="Folder containing the initial version's files",
)
@click.option("--private", "is_private", is_flag=True, help="Mark game as private (admin-only)")
@click.pass_context
def new_game(ctx: click.Context, title: str, app_id: int | None, version: str, path: Path, is_private: bool) -> None:
"""Register a new game and upload its base version."""
console.print(f"[yellow]TODO[/] would register {title} ({version}) from {path}, app_id={app_id}, private={is_private}")
# TODO:
# 1. POST /admin/games with title/app_id/is_private
# 2. Pack `path` into .7z (matches NAS base_version.7z convention)
# 3. POST /builds/upload with the archive + version metadata
# 4. Poll /builds/jobs/<id> for completion
@cli.command()
@click.argument("game")
@click.argument("version")
@click.option(
"--path",
required=True,
type=click.Path(exists=True, file_okay=False, dir_okay=True, path_type=Path),
help="Folder containing the new version's files",
)
@click.option("--notes-url", default=None, help="URL to the patch notes")
@click.pass_context
def push(ctx: click.Context, game: str, version: str, path: Path, notes_url: str | None) -> None:
"""Upload a new version of an existing game."""
console.print(f"[yellow]TODO[/] would push {game}@{version} from {path}, notes={notes_url}")
# TODO:
# 1. Tar+zstd `path` into temp archive
# 2. POST /builds/upload (streaming)
# 3. Poll /builds/jobs/<id>
@cli.command("ls")
@click.pass_context
def list_games(ctx: click.Context) -> None:
"""List games in the Mist catalog."""
client = MistClient(ctx.obj["api_url"])
try:
games = client.list_games()
except NotImplementedError:
console.print("[yellow]TODO[/] backend not implemented")
return
except Exception as e:
console.print(f"[red]Failed:[/] {e}")
raise SystemExit(1)
table = Table(show_header=True, header_style="bold")
table.add_column("ID")
table.add_column("Title")
table.add_column("Latest version")
table.add_column("Private")
for g in games:
table.add_row(str(g.get("id")), g.get("title", ""), g.get("latest_version", ""), str(g.get("is_private", False)))
console.print(table)
@cli.command("resync-steam")
@click.argument("game_id", type=int)
@click.pass_context
def resync_steam(ctx: click.Context, game_id: int) -> None:
"""Re-pull Steam appdetails for a game."""
console.print(f"[yellow]TODO[/] would resync steam metadata for game {game_id}")
@cli.command("rm")
@click.argument("game_id", type=int)
@click.confirmation_option(prompt="Soft-delete this game?")
@click.pass_context
def remove(ctx: click.Context, game_id: int) -> None:
"""Soft-delete a game (sets deleted_at)."""
console.print(f"[yellow]TODO[/] would soft-delete game {game_id}")
+67
View File
@@ -0,0 +1,67 @@
"""HTTP client for the Mist backend API."""
from __future__ import annotations
import os
from pathlib import Path
import httpx
from mistpipe import keychain
DEFAULT_BASE_URL = os.environ.get("MIST_API_URL", "https://api.mist.example")
class MistClient:
def __init__(self, base_url: str | None = None) -> None:
self.base_url = base_url or DEFAULT_BASE_URL
self._http = httpx.Client(base_url=self.base_url, timeout=httpx.Timeout(60.0, read=600.0))
# ---- Auth ----
def login(self, username: str, password: str) -> str:
r = self._http.post("/auth/login", json={"username": username, "password": password})
r.raise_for_status()
token: str = r.json()["access_token"]
keychain.save_token(token)
return token
def _headers(self) -> dict[str, str]:
token = keychain.load_token()
if not token:
raise RuntimeError("Not logged in. Run `mistpipe login` first.")
return {"Authorization": f"Bearer {token}"}
# ---- Catalog ----
def list_games(self) -> list[dict]:
r = self._http.get("/catalog/games", headers=self._headers())
r.raise_for_status()
return r.json()
# ---- Admin ----
def create_game(
self, title: str, app_id: int | None = None, is_private: bool = False
) -> dict:
body = {"title": title, "app_id": app_id, "is_private": is_private}
r = self._http.post("/admin/games", json=body, headers=self._headers())
r.raise_for_status()
return r.json()
def resync_steam(self, game_id: int) -> dict:
r = self._http.post(f"/admin/games/{game_id}/resync-steam", headers=self._headers())
r.raise_for_status()
return r.json()
def delete_game(self, game_id: int) -> None:
r = self._http.delete(f"/admin/games/{game_id}", headers=self._headers())
r.raise_for_status()
# ---- Build ingest ----
def upload_version(
self, archive_path: Path, game_title: str, version: str, notes_url: str | None = None
) -> dict:
# TODO: stream the archive instead of buffering. For now, raise.
raise NotImplementedError("upload_version not implemented yet")
+29
View File
@@ -0,0 +1,29 @@
"""Cross-platform secret storage for the mistpipe JWT.
Uses the `keyring` library, which talks to OS-native secret stores:
- Windows: Windows Credential Manager
- macOS: Keychain
- Linux: Secret Service (gnome-keyring, KWallet, etc.)
"""
from __future__ import annotations
import keyring
SERVICE = "mistpipe"
USERNAME = "default"
def save_token(token: str) -> None:
keyring.set_password(SERVICE, USERNAME, token)
def load_token() -> str | None:
return keyring.get_password(SERVICE, USERNAME)
def clear_token() -> None:
try:
keyring.delete_password(SERVICE, USERNAME)
except keyring.errors.PasswordDeleteError:
pass