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
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Mist Admin</title>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
+49
View File
@@ -0,0 +1,49 @@
// Typed fetch client skeleton for Mist API.
// TODO: replace `any` returns with generated types once OpenAPI is wired.
const API_BASE = import.meta.env.VITE_API_BASE ?? '';
function token(): string | null {
if (typeof localStorage === 'undefined') return null;
return localStorage.getItem('mist_token');
}
async function call<T = unknown>(method: string, path: string, body?: unknown): Promise<T> {
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
const t = token();
if (t) headers['Authorization'] = `Bearer ${t}`;
const res = await fetch(`${API_BASE}${path}`, {
method,
headers,
body: body !== undefined ? JSON.stringify(body) : undefined
});
if (!res.ok) throw new Error(`${method} ${path} -> ${res.status}`);
if (res.status === 204) return undefined as T;
return res.json() as Promise<T>;
}
export const api = {
login: (username: string, password: string) =>
call<{ access_token: string }>('POST', '/auth/login', { username, password }),
me: () => call<{ id: number; username: string; is_admin: boolean }>('GET', '/auth/me'),
listGames: () => call<unknown[]>('GET', '/catalog/games'),
createGame: (data: { title: string; app_id?: number; is_private?: boolean }) =>
call('POST', '/admin/games', data),
updateGame: (id: number, patch: Record<string, unknown>) =>
call('PATCH', `/admin/games/${id}`, patch),
deleteGame: (id: number) => call('DELETE', `/admin/games/${id}`),
resyncSteam: (id: number) => call('POST', `/admin/games/${id}/resync-steam`),
listUsers: () => call<unknown[]>('GET', '/admin/users'),
createUser: (data: { username: string; password: string; is_admin?: boolean }) =>
call('POST', '/admin/users', data),
listBuildJobs: () => call<unknown[]>('GET', '/admin/build-jobs')
};
export function setToken(t: string) {
localStorage.setItem('mist_token', t);
}
export function clearToken() {
localStorage.removeItem('mist_token');
}
+19
View File
@@ -0,0 +1,19 @@
<script lang="ts">
let { children } = $props();
</script>
<div class="min-h-screen bg-neutral-950 text-neutral-100">
<header class="border-b border-neutral-800 px-6 py-4 flex items-center justify-between">
<div class="flex items-center gap-6">
<a href="/" class="text-xl font-semibold tracking-wide">Mist Admin</a>
<nav class="flex gap-4 text-sm text-neutral-400">
<a href="/games" class="hover:text-neutral-100">Games</a>
<a href="/users" class="hover:text-neutral-100">Users</a>
</nav>
</div>
<div class="text-xs text-neutral-500">v0.1.0 · skeleton</div>
</header>
<main class="p-6 max-w-5xl mx-auto">
{@render children()}
</main>
</div>
+26
View File
@@ -0,0 +1,26 @@
<script lang="ts">
// Landing page placeholder. Eventually: build job activity feed + quick stats.
</script>
<section class="space-y-4">
<h1 class="text-3xl font-semibold">Welcome to Mist Admin</h1>
<p class="text-neutral-400 max-w-2xl">
This is the admin portal. From here you manage games, push updates, and provision friend accounts.
Nothing is wired up yet — this is a skeleton.
</p>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 pt-4">
<a href="/games" class="block p-5 rounded-lg border border-neutral-800 hover:border-neutral-700 hover:bg-neutral-900 transition">
<h2 class="font-semibold">Games</h2>
<p class="text-sm text-neutral-500 mt-1">Catalog management, metadata, branches.</p>
</a>
<a href="/users" class="block p-5 rounded-lg border border-neutral-800 hover:border-neutral-700 hover:bg-neutral-900 transition">
<h2 class="font-semibold">Users</h2>
<p class="text-sm text-neutral-500 mt-1">Provision accounts for friends.</p>
</a>
<a href="/login" class="block p-5 rounded-lg border border-neutral-800 hover:border-neutral-700 hover:bg-neutral-900 transition">
<h2 class="font-semibold">Login</h2>
<p class="text-sm text-neutral-500 mt-1">Sign in with your admin account.</p>
</a>
</div>
</section>
+37
View File
@@ -0,0 +1,37 @@
<script lang="ts">
import { api, setToken } from '$lib/api';
let username = $state('');
let password = $state('');
let error = $state<string | null>(null);
async function submit(e: SubmitEvent) {
e.preventDefault();
error = null;
try {
const r = await api.login(username, password);
setToken(r.access_token);
window.location.href = '/games';
} catch (e) {
error = (e as Error).message;
}
}
</script>
<section class="max-w-sm mx-auto space-y-4 pt-12">
<h1 class="text-2xl font-semibold">Sign in</h1>
<form onsubmit={submit} class="space-y-3">
<label class="block">
<span class="text-sm text-neutral-400">Username</span>
<input bind:value={username} class="mt-1 w-full bg-neutral-900 border border-neutral-800 rounded px-3 py-2 outline-none focus:border-neutral-600" autocomplete="username" />
</label>
<label class="block">
<span class="text-sm text-neutral-400">Password</span>
<input type="password" bind:value={password} class="mt-1 w-full bg-neutral-900 border border-neutral-800 rounded px-3 py-2 outline-none focus:border-neutral-600" autocomplete="current-password" />
</label>
{#if error}
<p class="text-sm text-red-400">{error}</p>
{/if}
<button type="submit" class="w-full bg-neutral-100 text-neutral-900 rounded py-2 font-medium hover:bg-white transition">Sign in</button>
</form>
</section>
+35
View File
@@ -0,0 +1,35 @@
<script lang="ts">
import { api } from '$lib/api';
let users = $state<unknown[]>([]);
let error = $state<string | null>(null);
async function load() {
try {
users = await api.listUsers();
} catch (e) {
error = (e as Error).message;
}
}
</script>
<section class="space-y-4">
<div class="flex items-center justify-between">
<h1 class="text-2xl font-semibold">Users</h1>
<button onclick={load} class="text-sm border border-neutral-800 rounded px-3 py-1.5 hover:border-neutral-600">Refresh</button>
</div>
{#if error}
<p class="text-sm text-red-400">{error}</p>
{/if}
{#if users.length === 0}
<p class="text-sm text-neutral-500">No users provisioned yet. Use the "Create user" form (TODO) or `mist.scripts.create_user` from a container.</p>
{:else}
<ul class="divide-y divide-neutral-800">
{#each users as u}
<li class="py-3 text-sm">{JSON.stringify(u)}</li>
{/each}
</ul>
{/if}
</section>