Initial Mist scaffold
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:
@@ -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.
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,3 @@
|
||||
"""mistpipe — admin CLI for the Mist game distribution platform."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,4 @@
|
||||
from mistpipe.cli import cli
|
||||
|
||||
if __name__ == "__main__":
|
||||
cli()
|
||||
@@ -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}")
|
||||
@@ -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")
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user