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,14 @@
|
||||
# Build stage
|
||||
FROM node:22-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package.json ./
|
||||
RUN npm install --no-audit --no-fund
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# Serve stage
|
||||
FROM nginx:1.27-alpine
|
||||
COPY --from=build /app/build /usr/share/nginx/html
|
||||
EXPOSE 80
|
||||
# Default nginx config serves /usr/share/nginx/html; for SPA fallback, drop in a
|
||||
# minimal config here later if SvelteKit routes need the fallback to index.html.
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "mist-admin-web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev --host 0.0.0.0 --port 5173",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-static": "^3.0.0",
|
||||
"@sveltejs/kit": "^2.7.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
||||
"@tailwindcss/typography": "^0.5.15",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.49",
|
||||
"svelte": "^5.0.0",
|
||||
"svelte-check": "^4.0.0",
|
||||
"tailwindcss": "^3.4.14",
|
||||
"tslib": "^2.7.0",
|
||||
"typescript": "^5.6.0",
|
||||
"vite": "^5.4.0"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,21 @@
|
||||
import adapter from '@sveltejs/adapter-static';
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
preprocess: vitePreprocess(),
|
||||
kit: {
|
||||
adapter: adapter({
|
||||
pages: 'build',
|
||||
assets: 'build',
|
||||
fallback: 'index.html',
|
||||
precompress: false,
|
||||
strict: true
|
||||
}),
|
||||
alias: {
|
||||
$lib: 'src/lib'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [sveltekit()],
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 5173
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user