Initial commit — SvelteKit fleet manager for T5 Plutonium servers
Ports push_scripts.sh (1497 lines of bash) to a SvelteKit + SQLite web app that manages a fleet of T5 Plutonium servers running on Komodo. Same push and beta workflow as the shell script, with a live SSE pipeline view, a !rt-token-gated auth modal that hard-pauses deployments on stale tokens, per-file audit diffs, and a Komodo-container deployment target. - Push/beta engines port the exact filter, held-by-beta, conflict, and stale-backup rules from the shell script - SQLite (better-sqlite3) replaces script_metadata.json; a one-shot importer moves legacy state over on first boot - 16 vitest tests cover push add/update/skip, beta open→rollback, open→promote, conflict guards, sweep frozen/manual branches, and IW4M login/status parsing - docker/ + komodo/stack.toml deploy as a managed container on the same host as the game servers, /root/zombies bind-mounted at the same path the shell script used
This commit is contained in:
@@ -0,0 +1,12 @@
|
|||||||
|
node_modules
|
||||||
|
.svelte-kit
|
||||||
|
build
|
||||||
|
.git
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
tests
|
||||||
|
archive
|
||||||
|
*.log
|
||||||
|
test-fixtures
|
||||||
|
data
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Filesystem paths — inside the Komodo container, these mirror the host layout
|
||||||
|
SHARED_SCRIPTS=/root/zombies/shared/scripts
|
||||||
|
SERVERS_ROOT=/root/zombies/t5
|
||||||
|
REL_DEST=runtime/plutonium/storage/t5/raw/scripts/sp
|
||||||
|
|
||||||
|
# SQLite location
|
||||||
|
DB_PATH=/data/cherry.db
|
||||||
|
|
||||||
|
# Retention (days) for deployment rows and blob diffs
|
||||||
|
DEPLOY_KEEP_DAYS=7
|
||||||
|
|
||||||
|
# Komodo
|
||||||
|
KOMODO_ADDRESS=https://komodo.example.internal
|
||||||
|
KOMODO_API_KEY=
|
||||||
|
KOMODO_API_SECRET=
|
||||||
|
|
||||||
|
# IW4MAdmin
|
||||||
|
IW4M_ADDRESS=http://iw4m.example.internal
|
||||||
|
IW4M_CLIENT_ID=
|
||||||
|
# no password here — !rt token is provided at runtime through the modal
|
||||||
|
|
||||||
|
# HTTP
|
||||||
|
HTTP_TIMEOUT_MS=10000
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
node_modules
|
||||||
|
.svelte-kit
|
||||||
|
build
|
||||||
|
.claude/
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
*.db
|
||||||
|
*.db-journal
|
||||||
|
*.db-wal
|
||||||
|
*.db-shm
|
||||||
|
data/
|
||||||
|
test-fixtures/*/zombies/
|
||||||
|
!test-fixtures/*/zombies/.gitkeep
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
# electric-cherry
|
||||||
|
|
||||||
|
Web app that manages a fleet of T5 Plutonium servers running under Komodo.
|
||||||
|
Successor to `push_scripts.sh` — same job (push shared GSC scripts, run beta
|
||||||
|
tests, restart empty servers, track deployments) with a live UI, real audit
|
||||||
|
diffs, and a hard pause when the IW4MAdmin `!rt` token goes stale.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- SvelteKit (adapter-node) + TypeScript
|
||||||
|
- SQLite via better-sqlite3 (replaces `script_metadata.json` as the source of
|
||||||
|
truth)
|
||||||
|
- Server-sent events for live pipeline / auth-state / server-status streams
|
||||||
|
- Runs as a single Komodo-managed container on the same host as the game
|
||||||
|
servers, with `/root/zombies` bind-mounted in
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
- `src/lib/server/db/` — schema, migrations, better-sqlite3 handle
|
||||||
|
- `src/lib/server/fleet/` — file walking, push/beta engines
|
||||||
|
- `src/lib/server/integrations/` — Komodo, IW4MAdmin, `!rt` token state
|
||||||
|
- `src/lib/server/pipeline/` — sweep loop, event bus, worker singleton
|
||||||
|
- `src/routes/api/` — REST + SSE endpoints
|
||||||
|
- `src/routes/` — UI pages (dashboard, deployments, betas, per-server)
|
||||||
|
- `docker/` — Dockerfile, entrypoint, docker-compose
|
||||||
|
- `komodo/` — Komodo stack definition
|
||||||
|
- `scripts/` — legacy JSON importer, fixture seeder
|
||||||
|
- `tests/` — vitest unit tests
|
||||||
|
|
||||||
|
## Local dev
|
||||||
|
|
||||||
|
Windows or macOS — no `/root/zombies` on disk, so seed a fixture tree first.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run seed:fixture
|
||||||
|
|
||||||
|
# Point env at the fixture (PowerShell)
|
||||||
|
$env:SHARED_SCRIPTS = "$PWD/test-fixtures/zombies/shared/scripts"
|
||||||
|
$env:SERVERS_ROOT = "$PWD/test-fixtures/zombies/t5"
|
||||||
|
$env:DB_PATH = "$PWD/data/cherry.db"
|
||||||
|
|
||||||
|
npm run dev
|
||||||
|
# open http://localhost:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
The `!rt` modal will block deployments until you paste a valid token. In
|
||||||
|
local dev you can bypass it for exploration by leaving `IW4M_ADDRESS` unset
|
||||||
|
and calling `sweep()` in a test that force-transitions the auth state (see
|
||||||
|
`tests/unit/sweep.test.ts`).
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
Covers push (add/update/skip), beta lifecycle (open → rollback, open →
|
||||||
|
promote, conflict blocks), sweep (frozen / manual branches), and IW4M
|
||||||
|
login/status parsing (fetch mocked).
|
||||||
|
|
||||||
|
## Deploy to the Komodo host
|
||||||
|
|
||||||
|
1. Build the image once locally or on the host:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker/docker-compose.yml build
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Import the shape of the stack into Komodo using `komodo/stack.toml` (or
|
||||||
|
copy the compose file into a Komodo stack).
|
||||||
|
|
||||||
|
3. First boot triggers the legacy import: if
|
||||||
|
`/root/zombies/script_metadata.json` exists and `/data/cherry.db` is
|
||||||
|
empty, `docker/entrypoint.sh` runs `scripts/import-legacy.ts` before
|
||||||
|
starting the server. The source JSON gets renamed to `.migrated`.
|
||||||
|
|
||||||
|
4. Load the app on the LAN or over tailscale (no web-app auth by design).
|
||||||
|
Paste an `!rt` token to unfreeze the pipeline.
|
||||||
|
|
||||||
|
## What still lives in the shell script
|
||||||
|
|
||||||
|
Nothing that's needed at runtime. Move `push_scripts.sh` to `archive/` once
|
||||||
|
you've run a side-by-side parity check between the shell's dry-run and the
|
||||||
|
app's `POST /api/push { dryRun: true }`.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- **Editing GSC files in-browser.** Do that in your editor of choice; the
|
||||||
|
web app only cares about what's on disk in `SHARED_SCRIPTS`.
|
||||||
|
- **Multi-user auth.** LAN/tailscale scoping is the security model.
|
||||||
|
- **Two-way sync with `script_metadata.json`.** The importer runs once. If
|
||||||
|
the shell script is ever used again after that, it'll write to the JSON
|
||||||
|
file and the web app won't see it.
|
||||||
|
|
||||||
|
## References into the old script
|
||||||
|
|
||||||
|
Every port carries a `// Ports push_scripts.sh:NN` comment pointing back to
|
||||||
|
the original for the parts that had non-obvious behavior. Grep those if a
|
||||||
|
behavior seems wrong — the shell script is the reference implementation
|
||||||
|
until the parity check passes.
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# archive/
|
||||||
|
|
||||||
|
Holds retired code that's kept only for reference. Nothing here is executed
|
||||||
|
by the app.
|
||||||
|
|
||||||
|
## push_scripts.sh
|
||||||
|
|
||||||
|
The original shell CLI that this web app replaces. Move it here from the
|
||||||
|
repo root once you've verified electric-cherry is doing the same job:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git mv ../push_scripts.sh ./push_scripts.sh.retired
|
||||||
|
```
|
||||||
|
|
||||||
|
The web app's `docker/entrypoint.sh` will call `scripts/import-legacy.ts` on
|
||||||
|
first boot to move any existing `script_metadata.json` into SQLite, so
|
||||||
|
retiring the shell script does not lose deployment history or open betas.
|
||||||
|
|
||||||
|
Parity check (run before archiving):
|
||||||
|
|
||||||
|
1. On the Komodo host, run `./push_scripts.sh -n` and capture the plan output.
|
||||||
|
2. Hit `POST /api/push { "dryRun": true }` against the running web app.
|
||||||
|
3. Diff the two plans — the set of `added` / `updated` / `removed` files per
|
||||||
|
server must match exactly. Same for open beta listings vs `-lb`.
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# syntax=docker/dockerfile:1.7
|
||||||
|
FROM node:22-alpine AS build
|
||||||
|
|
||||||
|
# native builds for better-sqlite3
|
||||||
|
RUN apk add --no-cache python3 make g++ sqlite
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
RUN --mount=type=cache,target=/root/.npm \
|
||||||
|
npm install --include=dev
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build \
|
||||||
|
&& npm prune --omit=dev
|
||||||
|
|
||||||
|
# ---- runtime ----
|
||||||
|
FROM node:22-alpine
|
||||||
|
|
||||||
|
RUN apk add --no-cache sqlite tini rsync
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=build /app/build ./build
|
||||||
|
COPY --from=build /app/node_modules ./node_modules
|
||||||
|
COPY --from=build /app/package.json ./package.json
|
||||||
|
COPY --from=build /app/src/lib/server/db/schema.sql ./build/schema.sql
|
||||||
|
COPY docker/entrypoint.sh /entrypoint.sh
|
||||||
|
RUN chmod +x /entrypoint.sh
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV PORT=3000
|
||||||
|
ENV HOST=0.0.0.0
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
ENTRYPOINT ["/sbin/tini", "--", "/entrypoint.sh"]
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
services:
|
||||||
|
electric-cherry:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: docker/Dockerfile
|
||||||
|
image: electric-cherry:latest
|
||||||
|
container_name: electric-cherry
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
volumes:
|
||||||
|
# in prod (Komodo host), the game server tree is at /root/zombies
|
||||||
|
- /root/zombies:/root/zombies:rw
|
||||||
|
- /root/zombies/.electric-cherry:/data:rw
|
||||||
|
environment:
|
||||||
|
SHARED_SCRIPTS: /root/zombies/shared/scripts
|
||||||
|
SERVERS_ROOT: /root/zombies/t5
|
||||||
|
REL_DEST: runtime/plutonium/storage/t5/raw/scripts/sp
|
||||||
|
DB_PATH: /data/cherry.db
|
||||||
|
LEGACY_METADATA: /root/zombies/script_metadata.json
|
||||||
|
DEPLOY_KEEP_DAYS: "7"
|
||||||
|
KOMODO_ADDRESS: ${KOMODO_ADDRESS}
|
||||||
|
KOMODO_API_KEY: ${KOMODO_API_KEY}
|
||||||
|
KOMODO_API_SECRET: ${KOMODO_API_SECRET}
|
||||||
|
IW4M_ADDRESS: ${IW4M_ADDRESS}
|
||||||
|
IW4M_CLIENT_ID: ${IW4M_CLIENT_ID}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
# Legacy import: if /data/cherry.db is empty and script_metadata.json exists
|
||||||
|
# in the mounted zombies tree, run a one-shot import before starting.
|
||||||
|
if [ -n "${LEGACY_METADATA:-}" ] && [ -f "$LEGACY_METADATA" ]; then
|
||||||
|
if [ ! -f "${DB_PATH:-/data/cherry.db}" ] || [ ! -s "${DB_PATH:-/data/cherry.db}" ]; then
|
||||||
|
echo "[entrypoint] running legacy import from $LEGACY_METADATA"
|
||||||
|
node build/scripts/import-legacy.js || echo "[entrypoint] legacy import failed — continuing"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[entrypoint] starting node server on ${HOST:-0.0.0.0}:${PORT:-3000}"
|
||||||
|
exec node build/index.js
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Komodo stack definition. Deploys electric-cherry as a managed container on
|
||||||
|
# the same host running the T5 Plutonium servers. Mounts /root/zombies so the
|
||||||
|
# app can push scripts to the same paths push_scripts.sh used, and dedicates
|
||||||
|
# a small subdir for its SQLite state.
|
||||||
|
|
||||||
|
[[stack]]
|
||||||
|
name = "electric-cherry"
|
||||||
|
|
||||||
|
[stack.services.web]
|
||||||
|
image = "electric-cherry:latest"
|
||||||
|
restart = "unless-stopped"
|
||||||
|
ports = ["3000:3000"]
|
||||||
|
|
||||||
|
volumes = [
|
||||||
|
"/root/zombies:/root/zombies:rw",
|
||||||
|
"/root/zombies/.electric-cherry:/data:rw"
|
||||||
|
]
|
||||||
|
|
||||||
|
[stack.services.web.env]
|
||||||
|
SHARED_SCRIPTS = "/root/zombies/shared/scripts"
|
||||||
|
SERVERS_ROOT = "/root/zombies/t5"
|
||||||
|
REL_DEST = "runtime/plutonium/storage/t5/raw/scripts/sp"
|
||||||
|
DB_PATH = "/data/cherry.db"
|
||||||
|
LEGACY_METADATA = "/root/zombies/script_metadata.json"
|
||||||
|
DEPLOY_KEEP_DAYS = "7"
|
||||||
|
|
||||||
|
# secrets: bind via Komodo's env-file or secret store
|
||||||
|
KOMODO_ADDRESS = "https://komodo.example.internal"
|
||||||
|
KOMODO_API_KEY = ""
|
||||||
|
KOMODO_API_SECRET = ""
|
||||||
|
IW4M_ADDRESS = "http://iw4m.example.internal"
|
||||||
|
IW4M_CLIENT_ID = ""
|
||||||
Generated
+3244
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"name": "electric-cherry",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "T5 Plutonium fleet manager — replaces push_scripts.sh with a live web UI",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite dev",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"import-legacy": "tsx scripts/import-legacy.ts",
|
||||||
|
"seed:fixture": "tsx scripts/seed-fixture.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"better-sqlite3": "^11.3.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@sveltejs/adapter-node": "^5.2.9",
|
||||||
|
"@sveltejs/kit": "^2.8.0",
|
||||||
|
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
||||||
|
"@types/better-sqlite3": "^7.6.11",
|
||||||
|
"@types/node": "^22.7.0",
|
||||||
|
"svelte": "^5.1.0",
|
||||||
|
"svelte-check": "^4.0.0",
|
||||||
|
"tsx": "^4.19.0",
|
||||||
|
"typescript": "^5.6.0",
|
||||||
|
"vite": "^5.4.0",
|
||||||
|
"vitest": "^2.1.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
+1496
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,199 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* One-shot importer: reads script_metadata.json (the format push_scripts.sh
|
||||||
|
* writes) and populates the electric-cherry SQLite DB with the equivalent
|
||||||
|
* rows. Idempotent — safe to re-run, will INSERT OR IGNORE on collisions.
|
||||||
|
*
|
||||||
|
* Trigger: docker/entrypoint.sh calls this when LEGACY_METADATA is set and
|
||||||
|
* DB_PATH is empty.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFile, rename, stat } from 'node:fs/promises';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import { env, exit } from 'node:process';
|
||||||
|
import { db } from '../src/lib/server/db/index.js';
|
||||||
|
|
||||||
|
interface LegacyServer {
|
||||||
|
komodo_type?: string;
|
||||||
|
komodo_name?: string;
|
||||||
|
komodo_server?: string;
|
||||||
|
iw4m_match?: string;
|
||||||
|
iw4m_value?: string;
|
||||||
|
}
|
||||||
|
interface LegacyBetaScript {
|
||||||
|
staged_as: string;
|
||||||
|
target: string;
|
||||||
|
beta_file: string;
|
||||||
|
backup: string;
|
||||||
|
sha256: string;
|
||||||
|
revision?: number;
|
||||||
|
deployed?: Array<{ server: string; had_original: boolean }>;
|
||||||
|
}
|
||||||
|
interface LegacyBeta {
|
||||||
|
name: string;
|
||||||
|
folder: string;
|
||||||
|
opened: string;
|
||||||
|
updated?: string;
|
||||||
|
revision?: number;
|
||||||
|
servers?: string[];
|
||||||
|
scripts?: LegacyBetaScript[];
|
||||||
|
}
|
||||||
|
interface LegacyDeployServer {
|
||||||
|
server: string;
|
||||||
|
files: number;
|
||||||
|
restart: string;
|
||||||
|
players: number | null;
|
||||||
|
checked: string | null;
|
||||||
|
restarted: string | null;
|
||||||
|
attempts: number;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
interface LegacyDeployment {
|
||||||
|
id: string;
|
||||||
|
action: string;
|
||||||
|
started: string;
|
||||||
|
servers?: LegacyDeployServer[];
|
||||||
|
}
|
||||||
|
interface LegacyRoot {
|
||||||
|
servers?: Record<string, LegacyServer>;
|
||||||
|
betas?: Record<string, LegacyBeta>;
|
||||||
|
deployments?: LegacyDeployment[];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const src = env.LEGACY_METADATA;
|
||||||
|
if (!src) {
|
||||||
|
console.error('LEGACY_METADATA env var not set');
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
if (!existsSync(src)) {
|
||||||
|
console.log(`no legacy file at ${src} — nothing to import`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const stats = await stat(src);
|
||||||
|
if (stats.size === 0) {
|
||||||
|
console.log(`legacy file at ${src} is empty — nothing to import`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = await readFile(src, 'utf8');
|
||||||
|
const data = JSON.parse(raw) as LegacyRoot;
|
||||||
|
const handle = db();
|
||||||
|
|
||||||
|
let servers = 0;
|
||||||
|
let betas = 0;
|
||||||
|
let betaScripts = 0;
|
||||||
|
let betaDeploys = 0;
|
||||||
|
let deployments = 0;
|
||||||
|
let deploymentServers = 0;
|
||||||
|
|
||||||
|
const tx = handle.transaction(() => {
|
||||||
|
// servers
|
||||||
|
const insServer = handle.prepare(
|
||||||
|
`INSERT OR IGNORE INTO servers
|
||||||
|
(name, komodo_type, komodo_name, komodo_server, iw4m_match, iw4m_value)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)`
|
||||||
|
);
|
||||||
|
for (const [name, s] of Object.entries(data.servers ?? {})) {
|
||||||
|
const r = insServer.run(
|
||||||
|
name,
|
||||||
|
s.komodo_type ?? 'deployment',
|
||||||
|
s.komodo_name ?? name.replace(/_/g, '-'),
|
||||||
|
s.komodo_server ?? null,
|
||||||
|
s.iw4m_match ?? null,
|
||||||
|
s.iw4m_value ?? null
|
||||||
|
);
|
||||||
|
if (r.changes > 0) servers++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// betas
|
||||||
|
const insBeta = handle.prepare(
|
||||||
|
`INSERT OR IGNORE INTO betas (slug, name, folder, opened_at, updated_at, revision)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)`
|
||||||
|
);
|
||||||
|
const insScript = handle.prepare(
|
||||||
|
`INSERT INTO beta_scripts (beta_slug, staged_as, target, beta_file, backup, sha256, revision)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
||||||
|
);
|
||||||
|
const insDeploy = handle.prepare(
|
||||||
|
`INSERT OR IGNORE INTO beta_deploys (beta_script_id, server, had_original)
|
||||||
|
VALUES (?, ?, ?)`
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const [slug, b] of Object.entries(data.betas ?? {})) {
|
||||||
|
const br = insBeta.run(slug, b.name, b.folder, b.opened, b.updated ?? b.opened, b.revision ?? 1);
|
||||||
|
if (br.changes > 0) betas++;
|
||||||
|
for (const s of b.scripts ?? []) {
|
||||||
|
const sr = insScript.run(
|
||||||
|
slug,
|
||||||
|
s.staged_as,
|
||||||
|
s.target,
|
||||||
|
s.beta_file,
|
||||||
|
s.backup,
|
||||||
|
s.sha256,
|
||||||
|
s.revision ?? 1
|
||||||
|
);
|
||||||
|
betaScripts++;
|
||||||
|
const scriptId = Number(sr.lastInsertRowid);
|
||||||
|
for (const d of s.deployed ?? []) {
|
||||||
|
// Skip deploys whose server isn't tracked — matches the
|
||||||
|
// FK constraint. Log so we don't silently lose rows.
|
||||||
|
const exists = handle.prepare('SELECT 1 FROM servers WHERE name = ?').get(d.server);
|
||||||
|
if (!exists) {
|
||||||
|
console.warn(`skipping beta_deploy for unknown server ${d.server}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const dr = insDeploy.run(scriptId, d.server, d.had_original ? 1 : 0);
|
||||||
|
if (dr.changes > 0) betaDeploys++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// deployments
|
||||||
|
const insDep = handle.prepare(
|
||||||
|
`INSERT OR IGNORE INTO deployments (id, action, started_at, triggered_by)
|
||||||
|
VALUES (?, ?, ?, 'legacy')`
|
||||||
|
);
|
||||||
|
const insDepSrv = handle.prepare(
|
||||||
|
`INSERT OR IGNORE INTO deployment_servers
|
||||||
|
(deployment_id, server, files_changed, restart_state, players, checked_at, restarted_at, attempts, error)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||||
|
);
|
||||||
|
for (const d of data.deployments ?? []) {
|
||||||
|
const r = insDep.run(d.id, d.action, d.started);
|
||||||
|
if (r.changes > 0) deployments++;
|
||||||
|
for (const s of d.servers ?? []) {
|
||||||
|
const exists = handle.prepare('SELECT 1 FROM servers WHERE name = ?').get(s.server);
|
||||||
|
if (!exists) continue;
|
||||||
|
const sr = insDepSrv.run(
|
||||||
|
d.id,
|
||||||
|
s.server,
|
||||||
|
s.files ?? 0,
|
||||||
|
s.restart ?? 'manual',
|
||||||
|
s.players ?? null,
|
||||||
|
s.checked ?? null,
|
||||||
|
s.restarted ?? null,
|
||||||
|
s.attempts ?? 0,
|
||||||
|
s.error ?? null
|
||||||
|
);
|
||||||
|
if (sr.changes > 0) deploymentServers++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
tx();
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`imported: ${servers} servers, ${betas} betas (${betaScripts} scripts, ${betaDeploys} deploys), ` +
|
||||||
|
`${deployments} deployments (${deploymentServers} server rows)`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Move the source out of the way so we don't re-import on future boots.
|
||||||
|
const moved = `${src}.migrated`;
|
||||||
|
await rename(src, moved);
|
||||||
|
console.log(`renamed ${src} → ${moved}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error('legacy import failed:', err);
|
||||||
|
exit(2);
|
||||||
|
});
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Seed a test-fixtures/zombies/ tree so the app has something to point at
|
||||||
|
* during local dev on Windows/macOS (where /root/zombies doesn't exist).
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* npm run seed:fixture
|
||||||
|
* SHARED_SCRIPTS=./test-fixtures/zombies/shared/scripts \
|
||||||
|
* SERVERS_ROOT=./test-fixtures/zombies/t5 \
|
||||||
|
* npm run dev
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { mkdir, writeFile } from 'node:fs/promises';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
const root = process.env.FIXTURE_ROOT ?? './test-fixtures/zombies';
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const shared = join(root, 'shared/scripts');
|
||||||
|
const staging = join(shared, 'betas');
|
||||||
|
const t5 = join(root, 't5');
|
||||||
|
await mkdir(shared, { recursive: true });
|
||||||
|
await mkdir(staging, { recursive: true });
|
||||||
|
|
||||||
|
// A handful of representative GSC files
|
||||||
|
await writeFile(
|
||||||
|
join(shared, 'hud.gsc'),
|
||||||
|
'#include maps\\_utility;\n\ninit() { println("hud v1"); }\n'
|
||||||
|
);
|
||||||
|
await writeFile(
|
||||||
|
join(shared, 'zom.gsc'),
|
||||||
|
'#include maps\\_zombiemode;\n\ninit() { println("zom v1"); }\n'
|
||||||
|
);
|
||||||
|
await writeFile(
|
||||||
|
join(shared, 'perks.gsc'),
|
||||||
|
'#include maps\\_zombiemode_perks;\n\ninit() { println("perks v1"); }\n'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Three servers, one already up-to-date, one empty, one with old copies
|
||||||
|
const rel = 'runtime/plutonium/storage/t5/raw/scripts/sp';
|
||||||
|
for (const name of ['t5_kino_1', 't5_ascension_1', 't5_cotd_1']) {
|
||||||
|
const dest = join(t5, name, rel);
|
||||||
|
await mkdir(dest, { recursive: true });
|
||||||
|
}
|
||||||
|
// t5_kino_1 is already up-to-date
|
||||||
|
await writeFile(
|
||||||
|
join(t5, 't5_kino_1', rel, 'hud.gsc'),
|
||||||
|
'#include maps\\_utility;\n\ninit() { println("hud v1"); }\n'
|
||||||
|
);
|
||||||
|
// t5_cotd_1 has an older version of zom
|
||||||
|
await writeFile(
|
||||||
|
join(t5, 't5_cotd_1', rel, 'zom.gsc'),
|
||||||
|
'#include maps\\_zombiemode;\n\ninit() { println("zom v0"); }\n'
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`seeded ${root}`);
|
||||||
|
console.log(` set SHARED_SCRIPTS=${shared}`);
|
||||||
|
console.log(` set SERVERS_ROOT=${t5}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
Vendored
+5
@@ -0,0 +1,5 @@
|
|||||||
|
declare global {
|
||||||
|
namespace App {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {};
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<!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" />
|
||||||
|
<meta name="color-scheme" content="dark light" />
|
||||||
|
<title>Electric Cherry</title>
|
||||||
|
%sveltekit.head%
|
||||||
|
</head>
|
||||||
|
<body data-sveltekit-preload-data="hover">
|
||||||
|
<div style="display: contents">%sveltekit.body%</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import type { Handle } from '@sveltejs/kit';
|
||||||
|
import { migrate } from '$lib/server/db/migrate.js';
|
||||||
|
import { startWorker } from '$lib/server/pipeline/worker.js';
|
||||||
|
|
||||||
|
let booted = false;
|
||||||
|
function boot(): void {
|
||||||
|
if (booted) return;
|
||||||
|
booted = true;
|
||||||
|
migrate();
|
||||||
|
startWorker();
|
||||||
|
}
|
||||||
|
|
||||||
|
// SvelteKit calls handle() for every request; using the first request as the
|
||||||
|
// boot trigger keeps the whole app single-process without needing an
|
||||||
|
// out-of-band init hook.
|
||||||
|
export const handle: Handle = async ({ event, resolve }) => {
|
||||||
|
boot();
|
||||||
|
return resolve(event);
|
||||||
|
};
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { env } from 'node:process';
|
||||||
|
|
||||||
|
function req(name: string, fallback?: string): string {
|
||||||
|
const v = env[name] ?? fallback;
|
||||||
|
if (v === undefined) throw new Error(`missing env: ${name}`);
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
function num(name: string, fallback: number): number {
|
||||||
|
const raw = env[name];
|
||||||
|
if (!raw) return fallback;
|
||||||
|
const n = Number(raw);
|
||||||
|
if (!Number.isFinite(n)) throw new Error(`env ${name}=${raw} is not a number`);
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
sharedScripts: req('SHARED_SCRIPTS', '/root/zombies/shared/scripts'),
|
||||||
|
serversRoot: req('SERVERS_ROOT', '/root/zombies/t5'),
|
||||||
|
relDest: req('REL_DEST', 'runtime/plutonium/storage/t5/raw/scripts/sp'),
|
||||||
|
dbPath: req('DB_PATH', './data/cherry.db'),
|
||||||
|
deployKeepDays: num('DEPLOY_KEEP_DAYS', 7),
|
||||||
|
httpTimeoutMs: num('HTTP_TIMEOUT_MS', 10_000),
|
||||||
|
komodo: {
|
||||||
|
address: env.KOMODO_ADDRESS ?? '',
|
||||||
|
apiKey: env.KOMODO_API_KEY ?? '',
|
||||||
|
apiSecret: env.KOMODO_API_SECRET ?? ''
|
||||||
|
},
|
||||||
|
iw4m: {
|
||||||
|
address: env.IW4M_ADDRESS ?? '',
|
||||||
|
clientId: env.IW4M_CLIENT_ID ?? ''
|
||||||
|
}
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export function komodoConfigured(): boolean {
|
||||||
|
return !!(config.komodo.address && config.komodo.apiKey && config.komodo.apiSecret);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function iw4mConfigured(): boolean {
|
||||||
|
return !!(config.iw4m.address && config.iw4m.clientId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const betaSrc = () => `${config.sharedScripts}/betas`;
|
||||||
|
export const destOf = (server: string) => `${config.serversRoot}/${server}/${config.relDest}`;
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import Database from 'better-sqlite3';
|
||||||
|
import { mkdirSync } from 'node:fs';
|
||||||
|
import { dirname, resolve } from 'node:path';
|
||||||
|
import { config } from '../config.js';
|
||||||
|
import schemaSql from './schema.sql?raw';
|
||||||
|
|
||||||
|
let _db: Database.Database | null = null;
|
||||||
|
|
||||||
|
export function db(): Database.Database {
|
||||||
|
if (_db) return _db;
|
||||||
|
|
||||||
|
mkdirSync(dirname(resolve(config.dbPath)), { recursive: true });
|
||||||
|
_db = new Database(config.dbPath);
|
||||||
|
_db.pragma('journal_mode = WAL');
|
||||||
|
_db.pragma('foreign_keys = ON');
|
||||||
|
_db.exec(schemaSql);
|
||||||
|
|
||||||
|
return _db;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closeDb(): void {
|
||||||
|
if (_db) {
|
||||||
|
_db.close();
|
||||||
|
_db = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { db } from './index.js';
|
||||||
|
|
||||||
|
// The heavy lifting is idempotent DDL inside schema.sql, which db() runs on
|
||||||
|
// first handle. This module exists as an explicit hook for future migrations
|
||||||
|
// (numbered .sql files applied via a schema_version row) and to be called
|
||||||
|
// from hooks.server.ts so boot-time failures surface early.
|
||||||
|
export function migrate(): void {
|
||||||
|
db().prepare('SELECT 1').get();
|
||||||
|
}
|
||||||
Vendored
+4
@@ -0,0 +1,4 @@
|
|||||||
|
declare module '*.sql?raw' {
|
||||||
|
const content: string;
|
||||||
|
export default content;
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
-- Electric Cherry schema. Idempotent — safe to run at every boot via migrate.ts.
|
||||||
|
-- Mirrors the shapes tracked in the legacy script_metadata.json but in real SQL.
|
||||||
|
|
||||||
|
PRAGMA journal_mode = WAL;
|
||||||
|
PRAGMA foreign_keys = ON;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS servers (
|
||||||
|
name TEXT PRIMARY KEY,
|
||||||
|
komodo_type TEXT NOT NULL DEFAULT 'deployment', -- deployment|stack|container
|
||||||
|
komodo_name TEXT NOT NULL,
|
||||||
|
komodo_server TEXT,
|
||||||
|
iw4m_match TEXT, -- port|id|name
|
||||||
|
iw4m_value TEXT,
|
||||||
|
last_players INTEGER,
|
||||||
|
last_checked_at TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS betas (
|
||||||
|
slug TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
folder TEXT NOT NULL,
|
||||||
|
opened_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
revision INTEGER NOT NULL DEFAULT 1
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS beta_scripts (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
beta_slug TEXT NOT NULL REFERENCES betas(slug) ON DELETE CASCADE,
|
||||||
|
staged_as TEXT NOT NULL,
|
||||||
|
target TEXT NOT NULL,
|
||||||
|
beta_file TEXT NOT NULL,
|
||||||
|
backup TEXT NOT NULL,
|
||||||
|
sha256 TEXT NOT NULL,
|
||||||
|
revision INTEGER NOT NULL DEFAULT 1
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_beta_scripts_slug ON beta_scripts(beta_slug);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS beta_deploys (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
beta_script_id INTEGER NOT NULL REFERENCES beta_scripts(id) ON DELETE CASCADE,
|
||||||
|
server TEXT NOT NULL REFERENCES servers(name),
|
||||||
|
had_original INTEGER NOT NULL,
|
||||||
|
UNIQUE(beta_script_id, server)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_beta_deploys_server ON beta_deploys(server);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS deployments (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
started_at TEXT NOT NULL,
|
||||||
|
finished_at TEXT,
|
||||||
|
triggered_by TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_deployments_started ON deployments(started_at DESC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS deployment_servers (
|
||||||
|
deployment_id TEXT NOT NULL REFERENCES deployments(id) ON DELETE CASCADE,
|
||||||
|
server TEXT NOT NULL REFERENCES servers(name),
|
||||||
|
files_changed INTEGER NOT NULL,
|
||||||
|
restart_state TEXT NOT NULL, -- pending | done | failed | manual | held
|
||||||
|
players INTEGER,
|
||||||
|
checked_at TEXT,
|
||||||
|
restarted_at TEXT,
|
||||||
|
attempts INTEGER NOT NULL DEFAULT 0,
|
||||||
|
error TEXT,
|
||||||
|
PRIMARY KEY (deployment_id, server)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_deployment_servers_state ON deployment_servers(restart_state);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS deployment_files (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
deployment_id TEXT NOT NULL REFERENCES deployments(id) ON DELETE CASCADE,
|
||||||
|
server TEXT NOT NULL,
|
||||||
|
verb TEXT NOT NULL, -- added | updated | removed | held
|
||||||
|
rel_path TEXT NOT NULL,
|
||||||
|
sha256_before TEXT,
|
||||||
|
sha256_after TEXT,
|
||||||
|
content_before BLOB,
|
||||||
|
content_after BLOB
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_deployment_files_dep ON deployment_files(deployment_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL
|
||||||
|
);
|
||||||
@@ -0,0 +1,455 @@
|
|||||||
|
import { copyFile, mkdir, readdir, readFile, rename, rm, stat } from 'node:fs/promises';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import { dirname, join, posix } from 'node:path';
|
||||||
|
import { betaSrc, config, destOf } from '../config.js';
|
||||||
|
import { db } from '../db/index.js';
|
||||||
|
import {
|
||||||
|
betaFile,
|
||||||
|
findTargets,
|
||||||
|
listSourceFiles,
|
||||||
|
oldFile,
|
||||||
|
sha256,
|
||||||
|
slugify,
|
||||||
|
stagedFiles
|
||||||
|
} from './files.js';
|
||||||
|
import { allServers, ensureDest, matchServers, ownerOf } from './servers.js';
|
||||||
|
import { doPush } from './push.js';
|
||||||
|
import { emit } from '../pipeline/bus.js';
|
||||||
|
|
||||||
|
// -- shared row shapes --
|
||||||
|
|
||||||
|
interface BetaRow {
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
folder: string;
|
||||||
|
opened_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
revision: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BetaScriptRow {
|
||||||
|
id: number;
|
||||||
|
beta_slug: string;
|
||||||
|
staged_as: string;
|
||||||
|
target: string;
|
||||||
|
beta_file: string;
|
||||||
|
backup: string;
|
||||||
|
sha256: string;
|
||||||
|
revision: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BetaDeployRow {
|
||||||
|
beta_script_id: number;
|
||||||
|
server: string;
|
||||||
|
had_original: number;
|
||||||
|
target: string;
|
||||||
|
beta_file: string;
|
||||||
|
backup: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nowIso(): string {
|
||||||
|
return new Date().toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBeta(slug: string): BetaRow | undefined {
|
||||||
|
return db().prepare('SELECT * FROM betas WHERE slug = ?').get(slug) as BetaRow | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listBetas(): BetaRow[] {
|
||||||
|
return db().prepare('SELECT * FROM betas ORDER BY opened_at').all() as BetaRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listBetaScripts(slug: string): BetaScriptRow[] {
|
||||||
|
return db()
|
||||||
|
.prepare('SELECT * FROM beta_scripts WHERE beta_slug = ? ORDER BY id')
|
||||||
|
.all(slug) as BetaScriptRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listBetaDeploys(slug: string): BetaDeployRow[] {
|
||||||
|
return db()
|
||||||
|
.prepare(
|
||||||
|
`SELECT bd.beta_script_id, bd.server, bd.had_original,
|
||||||
|
bs.target, bs.beta_file, bs.backup
|
||||||
|
FROM beta_deploys bd
|
||||||
|
JOIN beta_scripts bs ON bs.id = bd.beta_script_id
|
||||||
|
WHERE bs.beta_slug = ?`
|
||||||
|
)
|
||||||
|
.all(slug) as BetaDeployRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listBetaServers(slug: string): string[] {
|
||||||
|
return (
|
||||||
|
db()
|
||||||
|
.prepare(
|
||||||
|
`SELECT DISTINCT bd.server AS server
|
||||||
|
FROM beta_deploys bd
|
||||||
|
JOIN beta_scripts bs ON bs.id = bd.beta_script_id
|
||||||
|
WHERE bs.beta_slug = ?
|
||||||
|
ORDER BY bd.server`
|
||||||
|
)
|
||||||
|
.all(slug) as Array<{ server: string }>
|
||||||
|
).map((r) => r.server);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- open a beta (do_beta, push_scripts.sh:983) --
|
||||||
|
|
||||||
|
export interface OpenBetaOptions {
|
||||||
|
name: string;
|
||||||
|
patterns?: string[];
|
||||||
|
dryRun?: boolean;
|
||||||
|
create?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OpenBetaResult {
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
servers: string[];
|
||||||
|
scripts: Array<{ stagedAs: string; target: string }>;
|
||||||
|
dryRun: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BetaError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
public readonly kind:
|
||||||
|
| 'name-empty'
|
||||||
|
| 'already-open'
|
||||||
|
| 'stale-beta-dir'
|
||||||
|
| 'no-staging-dir'
|
||||||
|
| 'nothing-staged'
|
||||||
|
| 'ambiguous'
|
||||||
|
| 'no-servers'
|
||||||
|
| 'conflict'
|
||||||
|
| 'blocked-backup'
|
||||||
|
| 'empty-plan'
|
||||||
|
| 'missing-staged'
|
||||||
|
| 'no-such-beta'
|
||||||
|
| 'no-match',
|
||||||
|
public readonly details?: unknown
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openBeta(opts: OpenBetaOptions): Promise<OpenBetaResult> {
|
||||||
|
const { name, patterns = ['*'], dryRun = false, create = false } = opts;
|
||||||
|
const slug = slugify(name);
|
||||||
|
if (!slug) throw new BetaError('beta name needs at least one letter or number', 'name-empty');
|
||||||
|
|
||||||
|
const betaDir = `${betaSrc()}/${slug}`;
|
||||||
|
|
||||||
|
if (getBeta(slug)) throw new BetaError(`a beta called '${name}' is already open`, 'already-open');
|
||||||
|
if (existsSync(betaDir)) {
|
||||||
|
throw new BetaError(
|
||||||
|
`${betaDir} already exists but no beta is open for it — move or delete it first`,
|
||||||
|
'stale-beta-dir'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!existsSync(betaSrc())) {
|
||||||
|
throw new BetaError(
|
||||||
|
`no staging folder at ${betaSrc()} — create it and drop the scripts you want to test`,
|
||||||
|
'no-staging-dir'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const staged = await stagedFiles();
|
||||||
|
if (staged.length === 0) {
|
||||||
|
throw new BetaError(`nothing staged in ${betaSrc()}`, 'nothing-staged');
|
||||||
|
}
|
||||||
|
|
||||||
|
const targets: Array<{ base: string; target: string }> = [];
|
||||||
|
const ambiguous: Array<{ base: string; hits: string[] }> = [];
|
||||||
|
for (const base of staged) {
|
||||||
|
const hits = await findTargets(base);
|
||||||
|
if (hits.length === 0) targets.push({ base, target: base });
|
||||||
|
else if (hits.length === 1) targets.push({ base, target: hits[0] });
|
||||||
|
else ambiguous.push({ base, hits });
|
||||||
|
}
|
||||||
|
if (ambiguous.length > 0) {
|
||||||
|
throw new BetaError('more than one script matches — cannot tell which to replace', 'ambiguous', ambiguous);
|
||||||
|
}
|
||||||
|
|
||||||
|
const servers = matchServers(await allServers(), patterns);
|
||||||
|
if (servers.length === 0) throw new BetaError('no servers matched', 'no-servers');
|
||||||
|
|
||||||
|
// build the plan (server × target) while checking conflicts and stale backups
|
||||||
|
const plan: Array<{ server: string; base: string; target: string; had: boolean }> = [];
|
||||||
|
const conflicts: Array<{ server: string; target: string; owner: string }> = [];
|
||||||
|
const blockedBackups: Array<{ server: string; ofile: string }> = [];
|
||||||
|
|
||||||
|
for (const server of servers) {
|
||||||
|
const dest = await ensureDest(server, create);
|
||||||
|
if (!dest.ok) continue;
|
||||||
|
for (const { base, target } of targets) {
|
||||||
|
const owner = ownerOf(server, target);
|
||||||
|
if (owner) {
|
||||||
|
conflicts.push({ server, target, owner });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const had = existsSync(join(dest.path, target));
|
||||||
|
const ofile = oldFile(target);
|
||||||
|
if (had && existsSync(join(dest.path, ofile))) {
|
||||||
|
blockedBackups.push({ server, ofile });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
plan.push({ server, base, target, had });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (conflicts.length > 0)
|
||||||
|
throw new BetaError('script already belongs to another beta', 'conflict', conflicts);
|
||||||
|
if (blockedBackups.length > 0)
|
||||||
|
throw new BetaError('stale .OLD backups in the way', 'blocked-backup', blockedBackups);
|
||||||
|
if (plan.length === 0) throw new BetaError('no usable servers — nothing was changed', 'empty-plan');
|
||||||
|
|
||||||
|
if (dryRun) {
|
||||||
|
return {
|
||||||
|
slug,
|
||||||
|
name,
|
||||||
|
servers: [...new Set(plan.map((p) => p.server))],
|
||||||
|
scripts: targets.map((t) => ({ stagedAs: t.base, target: t.target })),
|
||||||
|
dryRun: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- commit ----
|
||||||
|
|
||||||
|
const opened = nowIso();
|
||||||
|
const insertBeta = db().prepare(
|
||||||
|
`INSERT INTO betas (slug, name, folder, opened_at, updated_at, revision)
|
||||||
|
VALUES (?, ?, ?, ?, ?, 1)`
|
||||||
|
);
|
||||||
|
const insertScript = db().prepare(
|
||||||
|
`INSERT INTO beta_scripts (beta_slug, staged_as, target, beta_file, backup, sha256, revision)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, 1)`
|
||||||
|
);
|
||||||
|
const insertDeploy = db().prepare(
|
||||||
|
`INSERT INTO beta_deploys (beta_script_id, server, had_original) VALUES (?, ?, ?)`
|
||||||
|
);
|
||||||
|
|
||||||
|
const scriptIdByTarget = new Map<string, number>();
|
||||||
|
const tx = db().transaction(() => {
|
||||||
|
insertBeta.run(slug, name, `betas/${slug}`, opened, opened);
|
||||||
|
for (const { base, target } of targets) {
|
||||||
|
const bfile = betaFile(target);
|
||||||
|
const backup = oldFile(target);
|
||||||
|
const sha = 'pending'; // filled after we compute below
|
||||||
|
const res = insertScript.run(slug, base, target, bfile, backup, sha);
|
||||||
|
scriptIdByTarget.set(target, Number(res.lastInsertRowid));
|
||||||
|
}
|
||||||
|
for (const { server, target, had } of plan) {
|
||||||
|
insertDeploy.run(scriptIdByTarget.get(target)!, server, had ? 1 : 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
tx();
|
||||||
|
|
||||||
|
// Compute shas outside tx (I/O), then patch
|
||||||
|
for (const { base, target } of targets) {
|
||||||
|
const sha = await sha256(`${betaSrc()}/${base}`);
|
||||||
|
db().prepare('UPDATE beta_scripts SET sha256 = ? WHERE beta_slug = ? AND target = ?')
|
||||||
|
.run(sha, slug, target);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move files on disk
|
||||||
|
for (const { server, base, target, had } of plan) {
|
||||||
|
const dest = destOf(server);
|
||||||
|
const bfile = betaFile(target);
|
||||||
|
const ofile = oldFile(target);
|
||||||
|
await mkdir(join(dest, dirname(target)), { recursive: true });
|
||||||
|
if (had) {
|
||||||
|
await rename(join(dest, target), join(dest, ofile));
|
||||||
|
}
|
||||||
|
await copyFile(join(betaSrc(), base), join(dest, bfile));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move staged files into betas/<slug>/ under their beta_file name
|
||||||
|
await mkdir(betaDir, { recursive: true });
|
||||||
|
for (const { base, target } of targets) {
|
||||||
|
const bfile = betaFile(target);
|
||||||
|
await rename(join(betaSrc(), base), join(betaDir, posix.basename(bfile)));
|
||||||
|
}
|
||||||
|
|
||||||
|
emit({
|
||||||
|
type: 'deployment-opened',
|
||||||
|
deploymentId: `beta:${slug}`,
|
||||||
|
action: `beta:${slug}`,
|
||||||
|
servers: [...new Set(plan.map((p) => p.server))]
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
slug,
|
||||||
|
name,
|
||||||
|
servers: [...new Set(plan.map((p) => p.server))],
|
||||||
|
scripts: targets.map((t) => ({ stagedAs: t.base, target: t.target })),
|
||||||
|
dryRun: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- update an open beta (do_update_beta, push_scripts.sh:1153) --
|
||||||
|
|
||||||
|
export interface UpdateBetaResult {
|
||||||
|
slug: string;
|
||||||
|
replaced: string[];
|
||||||
|
skipped: string[];
|
||||||
|
revision: number;
|
||||||
|
dryRun: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateBeta(name: string, dryRun = false): Promise<UpdateBetaResult> {
|
||||||
|
const slug = slugify(name);
|
||||||
|
const beta = getBeta(slug);
|
||||||
|
if (!beta) throw new BetaError(`no open beta called '${name}'`, 'no-such-beta');
|
||||||
|
|
||||||
|
const staged = await stagedFiles();
|
||||||
|
if (staged.length === 0) throw new BetaError('nothing staged', 'nothing-staged');
|
||||||
|
|
||||||
|
const known = listBetaScripts(slug);
|
||||||
|
const matched: Array<{ base: string; target: string; betaFile: string }> = [];
|
||||||
|
const skipped: string[] = [];
|
||||||
|
for (const base of staged) {
|
||||||
|
const hit = known.find((k) => k.staged_as === base || posix.basename(k.target) === base);
|
||||||
|
if (hit) matched.push({ base, target: hit.target, betaFile: hit.beta_file });
|
||||||
|
else skipped.push(base);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matched.length === 0) throw new BetaError('none of the staged files belong to this beta', 'no-match');
|
||||||
|
|
||||||
|
if (dryRun) {
|
||||||
|
return { slug, replaced: matched.map((m) => m.target), skipped, revision: beta.revision, dryRun: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const betaDir = `${betaSrc()}/${slug}`;
|
||||||
|
const now = nowIso();
|
||||||
|
|
||||||
|
// For each matched target, copy staged file to every deployed server,
|
||||||
|
// move staged file into betas/<slug>/, bump revision.
|
||||||
|
for (const { base, target, betaFile: bfile } of matched) {
|
||||||
|
const deploys = listBetaDeploys(slug).filter((d) => d.target === target);
|
||||||
|
for (const d of deploys) {
|
||||||
|
const dest = destOf(d.server);
|
||||||
|
if (!existsSync(dest)) continue;
|
||||||
|
await mkdir(join(dest, dirname(target)), { recursive: true });
|
||||||
|
await copyFile(join(betaSrc(), base), join(dest, bfile));
|
||||||
|
}
|
||||||
|
await rename(join(betaSrc(), base), join(betaDir, posix.basename(bfile)));
|
||||||
|
const sha = await sha256(join(betaDir, posix.basename(bfile)));
|
||||||
|
db()
|
||||||
|
.prepare(
|
||||||
|
`UPDATE beta_scripts
|
||||||
|
SET sha256 = ?, revision = revision + 1
|
||||||
|
WHERE beta_slug = ? AND target = ?`
|
||||||
|
)
|
||||||
|
.run(sha, slug, target);
|
||||||
|
}
|
||||||
|
db()
|
||||||
|
.prepare('UPDATE betas SET revision = revision + 1, updated_at = ? WHERE slug = ?')
|
||||||
|
.run(now, slug);
|
||||||
|
|
||||||
|
const updated = getBeta(slug)!;
|
||||||
|
return { slug, replaced: matched.map((m) => m.target), skipped, revision: updated.revision, dryRun: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- rollback (do_rollback, push_scripts.sh:1253) --
|
||||||
|
|
||||||
|
export interface RollbackResult {
|
||||||
|
slug: string;
|
||||||
|
restored: string[];
|
||||||
|
dropped: string[];
|
||||||
|
warnings: string[];
|
||||||
|
dryRun: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rollbackBeta(name: string, dryRun = false): Promise<RollbackResult> {
|
||||||
|
const slug = slugify(name);
|
||||||
|
if (!getBeta(slug)) throw new BetaError(`no open beta called '${name}'`, 'no-such-beta');
|
||||||
|
|
||||||
|
const betaDir = `${betaSrc()}/${slug}`;
|
||||||
|
const deploys = listBetaDeploys(slug);
|
||||||
|
const restored: string[] = [];
|
||||||
|
const dropped: string[] = [];
|
||||||
|
const warnings: string[] = [];
|
||||||
|
|
||||||
|
if (dryRun) {
|
||||||
|
for (const d of deploys) {
|
||||||
|
if (d.had_original) restored.push(d.target);
|
||||||
|
else dropped.push(d.target);
|
||||||
|
}
|
||||||
|
return { slug, restored, dropped, warnings, dryRun: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const d of deploys) {
|
||||||
|
const dest = destOf(d.server);
|
||||||
|
const bpath = join(dest, d.beta_file);
|
||||||
|
const opath = join(dest, d.backup);
|
||||||
|
const tpath = join(dest, d.target);
|
||||||
|
if (existsSync(bpath)) await rm(bpath, { force: true });
|
||||||
|
if (d.had_original) {
|
||||||
|
if (existsSync(opath)) {
|
||||||
|
await rename(opath, tpath);
|
||||||
|
restored.push(d.target);
|
||||||
|
} else {
|
||||||
|
warnings.push(`${d.server}: no ${d.backup} to restore`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
dropped.push(d.target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existsSync(betaDir)) await rm(betaDir, { recursive: true, force: true });
|
||||||
|
db().prepare('DELETE FROM betas WHERE slug = ?').run(slug); // cascades to scripts/deploys
|
||||||
|
|
||||||
|
return { slug, restored, dropped, warnings, dryRun: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- promote (do_promote, push_scripts.sh:1325) --
|
||||||
|
|
||||||
|
export interface PromoteResult {
|
||||||
|
slug: string;
|
||||||
|
landed: string[];
|
||||||
|
dryRun: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function promoteBeta(name: string, dryRun = false): Promise<PromoteResult> {
|
||||||
|
const slug = slugify(name);
|
||||||
|
const beta = getBeta(slug);
|
||||||
|
if (!beta) throw new BetaError(`no open beta called '${name}'`, 'no-such-beta');
|
||||||
|
|
||||||
|
const betaDir = `${betaSrc()}/${slug}`;
|
||||||
|
const scripts = listBetaScripts(slug);
|
||||||
|
|
||||||
|
// Sanity: every beta_file must exist in the beta dir on disk
|
||||||
|
const lost: string[] = [];
|
||||||
|
for (const s of scripts) {
|
||||||
|
const p = join(betaDir, posix.basename(s.beta_file));
|
||||||
|
if (!existsSync(p)) lost.push(posix.basename(s.beta_file));
|
||||||
|
}
|
||||||
|
if (lost.length > 0)
|
||||||
|
throw new BetaError('scripts missing from betas dir — cannot promote', 'missing-staged', lost);
|
||||||
|
|
||||||
|
const deploys = listBetaDeploys(slug);
|
||||||
|
|
||||||
|
if (dryRun) {
|
||||||
|
return { slug, landed: scripts.map((s) => s.target), dryRun: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear beta files + backups off every server
|
||||||
|
for (const d of deploys) {
|
||||||
|
const dest = destOf(d.server);
|
||||||
|
await rm(join(dest, d.beta_file), { force: true });
|
||||||
|
if (d.had_original) await rm(join(dest, d.backup), { force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move staged betas/<slug>/*.gsc back into shared/scripts under real name
|
||||||
|
for (const s of scripts) {
|
||||||
|
await mkdir(join(config.sharedScripts, dirname(s.target)), { recursive: true });
|
||||||
|
await rename(join(betaDir, posix.basename(s.beta_file)), join(config.sharedScripts, s.target));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existsSync(betaDir)) await rm(betaDir, { recursive: true, force: true });
|
||||||
|
db().prepare('DELETE FROM betas WHERE slug = ?').run(slug);
|
||||||
|
|
||||||
|
// Then push to every server — same behavior as the shell script's promote (line 1391)
|
||||||
|
await doPush({ patterns: ['*'], action: `promote:${slug}`, create: false });
|
||||||
|
|
||||||
|
return { slug, landed: scripts.map((s) => s.target), dryRun: false };
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { readdir, readFile, stat } from 'node:fs/promises';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import { join, posix, relative, sep } from 'node:path';
|
||||||
|
import { betaSrc, config } from '../config.js';
|
||||||
|
|
||||||
|
// Ported from push_scripts.sh:414-464. These are the exact filter rules —
|
||||||
|
// change one and the parity check against the shell script will fail.
|
||||||
|
|
||||||
|
const EXCLUDE_NAMES_EXACT = new Set(['.git']);
|
||||||
|
const EXCLUDE_SUFFIXES = ['.swp', '.OLD'];
|
||||||
|
const EXCLUDE_TRAILING = ['~'];
|
||||||
|
|
||||||
|
function isExcluded(name: string): boolean {
|
||||||
|
if (EXCLUDE_NAMES_EXACT.has(name)) return true;
|
||||||
|
if (name.startsWith('.')) return false; // dotfiles allowed except .git
|
||||||
|
for (const s of EXCLUDE_SUFFIXES) if (name.endsWith(s)) return true;
|
||||||
|
for (const t of EXCLUDE_TRAILING) if (name.endsWith(t)) return true;
|
||||||
|
// *-beta.* — script line 417 excludes any file with "-beta." in its name
|
||||||
|
if (/-beta\./.test(name)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toPosix(p: string): string {
|
||||||
|
return p.split(sep).join(posix.sep);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function walk(root: string, prune: (rel: string) => boolean): Promise<string[]> {
|
||||||
|
const out: string[] = [];
|
||||||
|
async function recur(dir: string): Promise<void> {
|
||||||
|
let entries;
|
||||||
|
try {
|
||||||
|
entries = await readdir(dir, { withFileTypes: true });
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const e of entries) {
|
||||||
|
const abs = join(dir, e.name);
|
||||||
|
const rel = toPosix(relative(root, abs));
|
||||||
|
if (prune(rel)) continue;
|
||||||
|
if (e.isDirectory()) {
|
||||||
|
await recur(abs);
|
||||||
|
} else if (e.isFile()) {
|
||||||
|
if (isExcluded(e.name)) continue;
|
||||||
|
out.push(rel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await recur(root);
|
||||||
|
return out.sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every file under SHARED_SCRIPTS except the betas/ tree.
|
||||||
|
* Mirrors `list_source_files` in push_scripts.sh:414.
|
||||||
|
*/
|
||||||
|
export async function listSourceFiles(): Promise<string[]> {
|
||||||
|
return walk(config.sharedScripts, (rel) => rel === 'betas' || rel.startsWith('betas/'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every file directly in betas/ (one level deep, ignore subfolders — those
|
||||||
|
* are open betas). Mirrors `staged_files` in push_scripts.sh:420.
|
||||||
|
*/
|
||||||
|
export async function stagedFiles(): Promise<string[]> {
|
||||||
|
if (!existsSync(betaSrc())) return [];
|
||||||
|
let entries;
|
||||||
|
try {
|
||||||
|
entries = await readdir(betaSrc(), { withFileTypes: true });
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return entries
|
||||||
|
.filter((e) => e.isFile() && !isExcluded(e.name) && !e.name.startsWith('.'))
|
||||||
|
.map((e) => e.name)
|
||||||
|
.sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find every source file whose basename matches `base`.
|
||||||
|
* Mirrors `find_targets` in push_scripts.sh:426.
|
||||||
|
*/
|
||||||
|
export async function findTargets(base: string): Promise<string[]> {
|
||||||
|
const all = await listSourceFiles();
|
||||||
|
return all.filter((rel) => posix.basename(rel) === base);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `foo/bar.gsc` -> `foo/bar-beta.gsc`
|
||||||
|
* Mirrors `beta_file` in push_scripts.sh:450.
|
||||||
|
*/
|
||||||
|
export function betaFile(rel: string): string {
|
||||||
|
const dir = posix.dirname(rel);
|
||||||
|
const base = posix.basename(rel);
|
||||||
|
const dot = base.lastIndexOf('.');
|
||||||
|
const stem = dot === -1 ? base : base.slice(0, dot);
|
||||||
|
const ext = dot === -1 ? '' : base.slice(dot);
|
||||||
|
const named = `${stem}-beta${ext}`;
|
||||||
|
return dir === '.' ? named : `${dir}/${named}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `foo/bar.gsc` -> `foo/bar.OLD`
|
||||||
|
* Mirrors `old_file` in push_scripts.sh:458.
|
||||||
|
*/
|
||||||
|
export function oldFile(rel: string): string {
|
||||||
|
const dir = posix.dirname(rel);
|
||||||
|
const base = posix.basename(rel);
|
||||||
|
const dot = base.lastIndexOf('.');
|
||||||
|
const stem = dot === -1 ? base : base.slice(0, dot);
|
||||||
|
const named = `${stem}.OLD`;
|
||||||
|
return dir === '.' ? named : `${dir}/${named}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Slugify a beta name the same way the shell script does.
|
||||||
|
* Mirrors `slugify` in push_scripts.sh:399.
|
||||||
|
*/
|
||||||
|
export function slugify(name: string): string {
|
||||||
|
return name
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sha256(path: string): Promise<string> {
|
||||||
|
const buf = await readFile(path);
|
||||||
|
return createHash('sha256').update(buf).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sha256OrNull(path: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
await stat(path);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return sha256(path);
|
||||||
|
}
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
import { copyFile, mkdir, readdir, readFile, rm } from 'node:fs/promises';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import { config, destOf } from '../config.js';
|
||||||
|
import { db } from '../db/index.js';
|
||||||
|
import { listSourceFiles, sha256, sha256OrNull } from './files.js';
|
||||||
|
import { allServers, ensureDest, heldByBeta, matchServers } from './servers.js';
|
||||||
|
import { emit } from '../pipeline/bus.js';
|
||||||
|
|
||||||
|
export type FileVerb = 'added' | 'updated' | 'removed' | 'held';
|
||||||
|
|
||||||
|
export interface FileChange {
|
||||||
|
verb: FileVerb;
|
||||||
|
relPath: string;
|
||||||
|
sha256Before: string | null;
|
||||||
|
sha256After: string | null;
|
||||||
|
// held rows carry the beta slug in `error` for the audit log
|
||||||
|
heldBy?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServerPlan {
|
||||||
|
server: string;
|
||||||
|
dest: string;
|
||||||
|
changes: FileChange[];
|
||||||
|
heldTargets: string[];
|
||||||
|
skipped?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PushOptions {
|
||||||
|
patterns?: string[]; // server-name globs; default ['*']
|
||||||
|
dryRun?: boolean;
|
||||||
|
delete?: boolean; // remove dest files not in source
|
||||||
|
create?: boolean; // create sp/ if missing
|
||||||
|
triggeredBy?: 'ui' | 'sweep' | 'cron';
|
||||||
|
action?: string; // 'push' | 'beta:<slug>' | ...
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PushResult {
|
||||||
|
deploymentId: string | null; // null when dryRun or nothing changed
|
||||||
|
plans: ServerPlan[];
|
||||||
|
totalServers: number;
|
||||||
|
totalChangedServers: number;
|
||||||
|
totalFiles: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nowIso(): string {
|
||||||
|
return new Date().toISOString().replace(/\.\d+Z$/, (m) => m.replace(/\d{3}Z$/, 'Z'));
|
||||||
|
}
|
||||||
|
|
||||||
|
let _depSeq = 0;
|
||||||
|
function deploymentId(): string {
|
||||||
|
const now = new Date();
|
||||||
|
const stamp = now.toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z');
|
||||||
|
// Seconds-precision timestamp + monotonic counter — two pushes in the
|
||||||
|
// same second (e.g. a beta open followed by its post-promote push) must
|
||||||
|
// still get distinct IDs. Same shape as `dep-<utc>-<n>` in the plan.
|
||||||
|
return `dep-${stamp}-${(++_depSeq).toString(36)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute the change set for a single server without touching the disk.
|
||||||
|
* Splitting plan-from-apply is what lets dry-run share code with commit.
|
||||||
|
*/
|
||||||
|
async function planServer(
|
||||||
|
server: string,
|
||||||
|
sourceFiles: string[],
|
||||||
|
opts: { deleteExtras: boolean; create: boolean }
|
||||||
|
): Promise<ServerPlan | null> {
|
||||||
|
const dest = destOf(server);
|
||||||
|
const ok = await ensureDest(server, opts.create);
|
||||||
|
if (!ok.ok) return { server, dest, changes: [], heldTargets: [], skipped: ok.reason };
|
||||||
|
|
||||||
|
const held = heldByBeta(server);
|
||||||
|
const heldTargets = [...held.keys()];
|
||||||
|
const sourceSet = new Set(sourceFiles);
|
||||||
|
const changes: FileChange[] = [];
|
||||||
|
|
||||||
|
for (const rel of sourceFiles) {
|
||||||
|
if (held.has(rel)) continue;
|
||||||
|
const src = join(config.sharedScripts, rel);
|
||||||
|
const dst = join(dest, rel);
|
||||||
|
const dstSha = await sha256OrNull(dst);
|
||||||
|
const srcSha = await sha256(src);
|
||||||
|
if (dstSha === null) {
|
||||||
|
changes.push({ verb: 'added', relPath: rel, sha256Before: null, sha256After: srcSha });
|
||||||
|
} else if (dstSha !== srcSha) {
|
||||||
|
changes.push({ verb: 'updated', relPath: rel, sha256Before: dstSha, sha256After: srcSha });
|
||||||
|
}
|
||||||
|
// matching sha → skip
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts.deleteExtras) {
|
||||||
|
// walk dest and remove files that aren't in the source set (and
|
||||||
|
// aren't held-by-beta artifacts, which include -beta.gsc and .OLD)
|
||||||
|
const destFiles = await walkDest(dest);
|
||||||
|
for (const rel of destFiles) {
|
||||||
|
if (rel.endsWith('.OLD')) continue;
|
||||||
|
if (/-beta\./.test(rel)) continue;
|
||||||
|
if (sourceSet.has(rel)) continue;
|
||||||
|
if (held.has(rel)) continue;
|
||||||
|
const before = await sha256OrNull(join(dest, rel));
|
||||||
|
changes.push({ verb: 'removed', relPath: rel, sha256Before: before, sha256After: null });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { server, dest, changes, heldTargets };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function walkDest(root: string): Promise<string[]> {
|
||||||
|
const out: string[] = [];
|
||||||
|
async function recur(dir: string, prefix: string): Promise<void> {
|
||||||
|
let entries;
|
||||||
|
try {
|
||||||
|
entries = await readdir(dir, { withFileTypes: true });
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const e of entries) {
|
||||||
|
const rel = prefix ? `${prefix}/${e.name}` : e.name;
|
||||||
|
if (e.isDirectory()) await recur(join(dir, e.name), rel);
|
||||||
|
else if (e.isFile()) out.push(rel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await recur(root, '');
|
||||||
|
return out.sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a plan: write files, capture content blobs for the audit log.
|
||||||
|
*/
|
||||||
|
async function applyPlan(plan: ServerPlan): Promise<Array<FileChange & { contentBefore: Buffer | null; contentAfter: Buffer | null }>> {
|
||||||
|
const applied: Array<FileChange & { contentBefore: Buffer | null; contentAfter: Buffer | null }> = [];
|
||||||
|
for (const change of plan.changes) {
|
||||||
|
const dst = join(plan.dest, change.relPath);
|
||||||
|
if (change.verb === 'added' || change.verb === 'updated') {
|
||||||
|
const src = join(config.sharedScripts, change.relPath);
|
||||||
|
const contentBefore = change.verb === 'updated' ? await readFile(dst) : null;
|
||||||
|
const contentAfter = await readFile(src);
|
||||||
|
await mkdir(dirname(dst), { recursive: true });
|
||||||
|
await copyFile(src, dst);
|
||||||
|
applied.push({ ...change, contentBefore, contentAfter });
|
||||||
|
} else if (change.verb === 'removed') {
|
||||||
|
const contentBefore = existsSync(dst) ? await readFile(dst) : null;
|
||||||
|
await rm(dst, { force: true });
|
||||||
|
applied.push({ ...change, contentBefore, contentAfter: null });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return applied;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The main push entry point. Ports `do_push` in push_scripts.sh:897.
|
||||||
|
* Uses node:fs (not shelling out to rsync) but matches the same
|
||||||
|
* added/updated/removed classification and held-by-beta exclusion rules.
|
||||||
|
*/
|
||||||
|
export async function doPush(opts: PushOptions = {}): Promise<PushResult> {
|
||||||
|
const { patterns = ['*'], dryRun = false, delete: deleteExtras = false, create = false } = opts;
|
||||||
|
|
||||||
|
const sourceFiles = await listSourceFiles();
|
||||||
|
const servers = matchServers(await allServers(), patterns);
|
||||||
|
|
||||||
|
const plans: ServerPlan[] = [];
|
||||||
|
let totalServers = 0;
|
||||||
|
let totalChangedServers = 0;
|
||||||
|
let totalFiles = 0;
|
||||||
|
|
||||||
|
for (const server of servers) {
|
||||||
|
const plan = await planServer(server, sourceFiles, { deleteExtras, create });
|
||||||
|
if (!plan) continue;
|
||||||
|
totalServers++;
|
||||||
|
if (!plan.skipped && plan.changes.length > 0) {
|
||||||
|
totalChangedServers++;
|
||||||
|
totalFiles += plan.changes.length;
|
||||||
|
}
|
||||||
|
plans.push(plan);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dryRun) {
|
||||||
|
return { deploymentId: null, plans, totalServers, totalChangedServers, totalFiles };
|
||||||
|
}
|
||||||
|
|
||||||
|
// No changes → no deployment row. Same behavior as `deploy_finish` line 806.
|
||||||
|
const changedPlans = plans.filter((p) => !p.skipped && p.changes.length > 0);
|
||||||
|
if (changedPlans.length === 0) {
|
||||||
|
return { deploymentId: null, plans, totalServers, totalChangedServers, totalFiles };
|
||||||
|
}
|
||||||
|
|
||||||
|
const depId = deploymentId();
|
||||||
|
const action = opts.action ?? 'push';
|
||||||
|
const started = nowIso();
|
||||||
|
|
||||||
|
db().prepare('INSERT INTO deployments (id, action, started_at, triggered_by) VALUES (?, ?, ?, ?)')
|
||||||
|
.run(depId, action, started, opts.triggeredBy ?? 'ui');
|
||||||
|
|
||||||
|
const insServer = db().prepare(
|
||||||
|
`INSERT INTO deployment_servers
|
||||||
|
(deployment_id, server, files_changed, restart_state, players, checked_at, restarted_at, attempts, error)
|
||||||
|
VALUES (?, ?, ?, 'pending', NULL, NULL, NULL, 0, NULL)`
|
||||||
|
);
|
||||||
|
const insFile = db().prepare(
|
||||||
|
`INSERT INTO deployment_files
|
||||||
|
(deployment_id, server, verb, rel_path, sha256_before, sha256_after, content_before, content_after)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const plan of changedPlans) {
|
||||||
|
const applied = await applyPlan(plan);
|
||||||
|
const tx = db().transaction(() => {
|
||||||
|
insServer.run(depId, plan.server, plan.changes.length);
|
||||||
|
for (const a of applied) {
|
||||||
|
insFile.run(
|
||||||
|
depId,
|
||||||
|
plan.server,
|
||||||
|
a.verb,
|
||||||
|
a.relPath,
|
||||||
|
a.sha256Before,
|
||||||
|
a.sha256After,
|
||||||
|
a.contentBefore,
|
||||||
|
a.contentAfter
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const heldRel of plan.heldTargets) {
|
||||||
|
insFile.run(depId, plan.server, 'held', heldRel, null, null, null, null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
tx();
|
||||||
|
emit({ type: 'server-changed', deploymentId: depId, server: plan.server, files: plan.changes.length });
|
||||||
|
}
|
||||||
|
|
||||||
|
emit({ type: 'deployment-opened', deploymentId: depId, action, servers: changedPlans.map((p) => p.server) });
|
||||||
|
|
||||||
|
return {
|
||||||
|
deploymentId: depId,
|
||||||
|
plans,
|
||||||
|
totalServers,
|
||||||
|
totalChangedServers,
|
||||||
|
totalFiles
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import { readdir, mkdir, stat } from 'node:fs/promises';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import { config, destOf } from '../config.js';
|
||||||
|
import { db } from '../db/index.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All server folders under SERVERS_ROOT. Mirrors `all_servers` in
|
||||||
|
* push_scripts.sh:430.
|
||||||
|
*/
|
||||||
|
export async function allServers(): Promise<string[]> {
|
||||||
|
let entries;
|
||||||
|
try {
|
||||||
|
entries = await readdir(config.serversRoot, { withFileTypes: true });
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return entries
|
||||||
|
.filter((e) => e.isDirectory())
|
||||||
|
.map((e) => e.name)
|
||||||
|
.sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Match a server name against a list of glob-like patterns (supports `*` and
|
||||||
|
* `?` and comma-separated lists). Ports `match_servers` in
|
||||||
|
* push_scripts.sh:438.
|
||||||
|
*/
|
||||||
|
export function matchServers(all: string[], patterns: string[]): string[] {
|
||||||
|
if (patterns.length === 0 || (patterns.length === 1 && patterns[0] === '*')) {
|
||||||
|
return [...all];
|
||||||
|
}
|
||||||
|
const regexes = patterns
|
||||||
|
.flatMap((p) => p.split(',').map((s) => s.trim()).filter(Boolean))
|
||||||
|
.map((p) => new RegExp('^' + p.replace(/[.+^$()|{}\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.') + '$'));
|
||||||
|
return all.filter((name) => regexes.some((r) => r.test(name)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure a server has an sp/ destination. Mirrors `ensure_dest` in
|
||||||
|
* push_scripts.sh:466. Returns `{ ok: true }` when the dir exists (or was
|
||||||
|
* just created with `create: true`), or `{ ok: false, reason }` when it can't
|
||||||
|
* be used.
|
||||||
|
*/
|
||||||
|
export async function ensureDest(
|
||||||
|
server: string,
|
||||||
|
create: boolean
|
||||||
|
): Promise<{ ok: true; path: string } | { ok: false; reason: string }> {
|
||||||
|
const dest = destOf(server);
|
||||||
|
if (existsSync(dest)) {
|
||||||
|
try {
|
||||||
|
const s = await stat(dest);
|
||||||
|
if (!s.isDirectory()) return { ok: false, reason: 'dest is not a directory' };
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, reason: `stat failed: ${(err as Error).message}` };
|
||||||
|
}
|
||||||
|
return { ok: true, path: dest };
|
||||||
|
}
|
||||||
|
if (!create) return { ok: false, reason: 'no sp folder — use create=true' };
|
||||||
|
await mkdir(dest, { recursive: true });
|
||||||
|
return { ok: true, path: dest };
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- server registry (was .servers map in script_metadata.json) --
|
||||||
|
|
||||||
|
export interface ServerRow {
|
||||||
|
name: string;
|
||||||
|
komodo_type: 'deployment' | 'stack' | 'container';
|
||||||
|
komodo_name: string;
|
||||||
|
komodo_server: string | null;
|
||||||
|
iw4m_match: 'port' | 'id' | 'name' | null;
|
||||||
|
iw4m_value: string | null;
|
||||||
|
last_players: number | null;
|
||||||
|
last_checked_at: string | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seed the servers table with rows for any folder under SERVERS_ROOT that
|
||||||
|
* isn't yet tracked. Uses folder-name-with-dashes as the default komodo_name,
|
||||||
|
* mirroring `server_reg_seed` in push_scripts.sh:564.
|
||||||
|
*/
|
||||||
|
export async function seedServerRegistry(): Promise<number> {
|
||||||
|
const rows = await allServers();
|
||||||
|
const insert = db().prepare(
|
||||||
|
'INSERT OR IGNORE INTO servers (name, komodo_type, komodo_name) VALUES (?, ?, ?)'
|
||||||
|
);
|
||||||
|
let n = 0;
|
||||||
|
const tx = db().transaction((names: string[]) => {
|
||||||
|
for (const name of names) {
|
||||||
|
const res = insert.run(name, 'deployment', name.replace(/_/g, '-'));
|
||||||
|
if (res.changes > 0) n++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
tx(rows);
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getServer(name: string): ServerRow | undefined {
|
||||||
|
return db().prepare('SELECT * FROM servers WHERE name = ?').get(name) as ServerRow | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listServers(): ServerRow[] {
|
||||||
|
return db().prepare('SELECT * FROM servers ORDER BY name').all() as ServerRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setServerField(
|
||||||
|
name: string,
|
||||||
|
field: keyof ServerRow,
|
||||||
|
value: string | number | null
|
||||||
|
): void {
|
||||||
|
// whitelist columns — never interpolate arbitrary strings into SQL
|
||||||
|
const allowed: Array<keyof ServerRow> = [
|
||||||
|
'komodo_type',
|
||||||
|
'komodo_name',
|
||||||
|
'komodo_server',
|
||||||
|
'iw4m_match',
|
||||||
|
'iw4m_value',
|
||||||
|
'last_players',
|
||||||
|
'last_checked_at'
|
||||||
|
];
|
||||||
|
if (!allowed.includes(field)) throw new Error(`refusing to set ${field}`);
|
||||||
|
db().prepare(`UPDATE servers SET ${field} = ? WHERE name = ?`).run(value, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which relative-path targets are currently held by an open beta on this
|
||||||
|
* server? Returns `Map<relPath, betaSlug>`. Mirrors `held_by_beta` in
|
||||||
|
* push_scripts.sh:378.
|
||||||
|
*/
|
||||||
|
export function heldByBeta(server: string): Map<string, string> {
|
||||||
|
const rows = db()
|
||||||
|
.prepare(
|
||||||
|
`SELECT bs.target AS target, bs.beta_slug AS slug
|
||||||
|
FROM beta_deploys bd
|
||||||
|
JOIN beta_scripts bs ON bs.id = bd.beta_script_id
|
||||||
|
WHERE bd.server = ?`
|
||||||
|
)
|
||||||
|
.all(server) as Array<{ target: string; slug: string }>;
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
for (const r of rows) map.set(r.target, r.slug);
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which beta (if any) owns this target on this server?
|
||||||
|
* Mirrors `owner_of` in push_scripts.sh:366.
|
||||||
|
*/
|
||||||
|
export function ownerOf(server: string, target: string): string | null {
|
||||||
|
const row = db()
|
||||||
|
.prepare(
|
||||||
|
`SELECT bs.beta_slug AS slug
|
||||||
|
FROM beta_deploys bd
|
||||||
|
JOIN beta_scripts bs ON bs.id = bd.beta_script_id
|
||||||
|
WHERE bd.server = ? AND bs.target = ?
|
||||||
|
LIMIT 1`
|
||||||
|
)
|
||||||
|
.get(server, target) as { slug: string } | undefined;
|
||||||
|
return row?.slug ?? null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { emit } from '../pipeline/bus.js';
|
||||||
|
|
||||||
|
export type AuthState =
|
||||||
|
| { kind: 'ready'; token: string; cookie: string; expiresAt: number }
|
||||||
|
| { kind: 'stale'; reason: 'expired' | 'rejected' | 'unreachable' }
|
||||||
|
| { kind: 'missing' };
|
||||||
|
|
||||||
|
// Two minutes minus 5s slack — the !rt token really is single-use and dies
|
||||||
|
// fast. Better to preemptively mark stale than to fire a call with a dead
|
||||||
|
// session cookie mid-sweep.
|
||||||
|
export const TOKEN_TTL_MS = 115_000;
|
||||||
|
// How long before expiry the UI should re-prompt so the user has time to
|
||||||
|
// generate a new one in-game.
|
||||||
|
export const TOKEN_WARN_MS = 20_000;
|
||||||
|
|
||||||
|
let state: AuthState = { kind: 'missing' };
|
||||||
|
|
||||||
|
export function getAuthState(): AuthState {
|
||||||
|
// Auto-transition from ready → stale when we cross the expiry boundary
|
||||||
|
if (state.kind === 'ready' && Date.now() >= state.expiresAt) {
|
||||||
|
state = { kind: 'stale', reason: 'expired' };
|
||||||
|
emit({ type: 'auth-state', state });
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setAuthState(next: AuthState): void {
|
||||||
|
state = next;
|
||||||
|
const payload: Parameters<typeof emit>[0] =
|
||||||
|
next.kind === 'ready'
|
||||||
|
? { type: 'auth-state', state: next, msRemaining: Math.max(0, next.expiresAt - Date.now()) }
|
||||||
|
: { type: 'auth-state', state: next };
|
||||||
|
emit(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Web-app-side check used by the pipeline worker's tick loop. Returns true
|
||||||
|
* only when the token is ready AND has more than a couple seconds left. Any
|
||||||
|
* other state hard-freezes the pipeline.
|
||||||
|
*/
|
||||||
|
export function pipelineReady(): boolean {
|
||||||
|
const s = getAuthState();
|
||||||
|
if (s.kind !== 'ready') return false;
|
||||||
|
return s.expiresAt - Date.now() > 3000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The UI shows a warning modal once we're within TOKEN_WARN_MS of expiry.
|
||||||
|
*/
|
||||||
|
export function tokenNearExpiry(): boolean {
|
||||||
|
const s = getAuthState();
|
||||||
|
if (s.kind !== 'ready') return false;
|
||||||
|
return s.expiresAt - Date.now() <= TOKEN_WARN_MS;
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import { config, iw4mConfigured } from '../config.js';
|
||||||
|
import { getServer, setServerField } from '../fleet/servers.js';
|
||||||
|
import { getAuthState, setAuthState, TOKEN_TTL_MS } from './auth-state.js';
|
||||||
|
|
||||||
|
// Ports iw4m_login/fetch_status/players/autolink from push_scripts.sh:497-620.
|
||||||
|
// The one big difference: the script keeps a temp cookie-jar file, this holds
|
||||||
|
// the session cookie in memory (we're a single Node process, not a shell).
|
||||||
|
|
||||||
|
export interface StatusEntry {
|
||||||
|
id?: number;
|
||||||
|
port?: number;
|
||||||
|
name?: string;
|
||||||
|
currentPlayers?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let statusCache: { entries: StatusEntry[]; at: number } | null = null;
|
||||||
|
const STATUS_TTL_MS = 5_000;
|
||||||
|
|
||||||
|
function abortableFetch(url: string, init: RequestInit = {}): Promise<Response> {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), config.httpTimeoutMs);
|
||||||
|
return fetch(url, { ...init, signal: controller.signal }).finally(() => clearTimeout(timer));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exchange the 4-char !rt token for a session cookie. Called by the
|
||||||
|
* `/api/auth/token` route when the user pastes the modal input.
|
||||||
|
*/
|
||||||
|
export async function loginWithToken(
|
||||||
|
token: string
|
||||||
|
): Promise<{ ok: true; cookie: string; expiresAt: number } | { ok: false; reason: 'rejected' | 'unreachable' | 'unconfigured'; message: string }> {
|
||||||
|
if (!iw4mConfigured()) return { ok: false, reason: 'unconfigured', message: 'iw4m not configured' };
|
||||||
|
|
||||||
|
const url = `${config.iw4m.address.replace(/\/+$/, '')}/api/client/${config.iw4m.clientId}/login`;
|
||||||
|
let res: Response;
|
||||||
|
try {
|
||||||
|
res = await abortableFetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ password: token })
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, reason: 'unreachable', message: (err as Error).message };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.status === 401 || res.status === 403) return { ok: false, reason: 'rejected', message: 'iw4m rejected the token' };
|
||||||
|
if (!res.ok) return { ok: false, reason: 'unreachable', message: `HTTP ${res.status}` };
|
||||||
|
|
||||||
|
// Grab every Set-Cookie the server handed us and re-send them intact.
|
||||||
|
// `getSetCookie` is Node 20+; on older Node use `res.headers.raw()['set-cookie']`.
|
||||||
|
let setCookies: string[] = [];
|
||||||
|
if (typeof (res.headers as unknown as { getSetCookie?: () => string[] }).getSetCookie === 'function') {
|
||||||
|
setCookies = (res.headers as unknown as { getSetCookie: () => string[] }).getSetCookie();
|
||||||
|
} else {
|
||||||
|
const single = res.headers.get('set-cookie');
|
||||||
|
if (single) setCookies = [single];
|
||||||
|
}
|
||||||
|
const cookie = setCookies
|
||||||
|
.map((c) => c.split(';')[0])
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('; ');
|
||||||
|
if (!cookie) return { ok: false, reason: 'rejected', message: 'iw4m returned no session cookie' };
|
||||||
|
|
||||||
|
return { ok: true, cookie, expiresAt: Date.now() + TOKEN_TTL_MS };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch /api/status, using the current session cookie if we have one. Falls
|
||||||
|
* back to anonymous read — that's what the script does at line 542 — but the
|
||||||
|
* pipeline worker gates on the token being present anyway, so this is used
|
||||||
|
* mainly for the dashboard's non-blocking status refresh.
|
||||||
|
*/
|
||||||
|
export async function fetchStatus(): Promise<{ ok: true; entries: StatusEntry[] } | { ok: false; message: string }> {
|
||||||
|
if (!iw4mConfigured()) return { ok: false, message: 'iw4m not configured' };
|
||||||
|
|
||||||
|
if (statusCache && Date.now() - statusCache.at < STATUS_TTL_MS) {
|
||||||
|
return { ok: true, entries: statusCache.entries };
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `${config.iw4m.address.replace(/\/+$/, '')}/api/status`;
|
||||||
|
const s = getAuthState();
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
if (s.kind === 'ready') headers.cookie = s.cookie;
|
||||||
|
|
||||||
|
let res: Response;
|
||||||
|
try {
|
||||||
|
res = await abortableFetch(url, { headers });
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, message: (err as Error).message };
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
if ((res.status === 401 || res.status === 403) && s.kind === 'ready') {
|
||||||
|
// Cookie went sour — force a re-prompt.
|
||||||
|
setAuthState({ kind: 'stale', reason: 'rejected' });
|
||||||
|
}
|
||||||
|
return { ok: false, message: `HTTP ${res.status}` };
|
||||||
|
}
|
||||||
|
let json: unknown;
|
||||||
|
try {
|
||||||
|
json = await res.json();
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, message: `bad JSON: ${(err as Error).message}` };
|
||||||
|
}
|
||||||
|
if (!Array.isArray(json)) return { ok: false, message: 'status was not an array' };
|
||||||
|
|
||||||
|
// Normalize keys — the script does the same case-insensitive dance
|
||||||
|
// (line 589) to work across IW4MAdmin versions.
|
||||||
|
const entries: StatusEntry[] = json.map((e) => {
|
||||||
|
const o = e as Record<string, unknown>;
|
||||||
|
return {
|
||||||
|
id: (o.id ?? o.Id) as number | undefined,
|
||||||
|
port: (o.port ?? o.Port) as number | undefined,
|
||||||
|
name: (o.name ?? o.Name ?? '') as string,
|
||||||
|
currentPlayers: (o.currentPlayers ?? o.CurrentPlayers) as number | undefined
|
||||||
|
};
|
||||||
|
});
|
||||||
|
statusCache = { entries, at: Date.now() };
|
||||||
|
return { ok: true, entries };
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeName(s: string): string {
|
||||||
|
return s.toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many players are on this server right now, or null if we can't tell.
|
||||||
|
* Ports `iw4m_players` in push_scripts.sh:579.
|
||||||
|
*/
|
||||||
|
export async function playersOn(server: string): Promise<number | null> {
|
||||||
|
const row = getServer(server);
|
||||||
|
if (!row) return null;
|
||||||
|
const status = await fetchStatus();
|
||||||
|
if (!status.ok) return null;
|
||||||
|
|
||||||
|
let match = row.iw4m_match;
|
||||||
|
let value = row.iw4m_value;
|
||||||
|
if (!match || !value) {
|
||||||
|
const auto = autolink(server, status.entries);
|
||||||
|
if (!auto) return null;
|
||||||
|
setServerField(server, 'iw4m_match', auto.match);
|
||||||
|
setServerField(server, 'iw4m_value', auto.value);
|
||||||
|
match = auto.match;
|
||||||
|
value = auto.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hits = status.entries.filter((e) => {
|
||||||
|
if (match === 'port') return String(e.port ?? '') === value;
|
||||||
|
if (match === 'id') return String(e.id ?? '') === value;
|
||||||
|
return normalizeName(e.name ?? '') === normalizeName(value!);
|
||||||
|
});
|
||||||
|
if (hits.length !== 1) return null;
|
||||||
|
const players = hits[0].currentPlayers;
|
||||||
|
return typeof players === 'number' ? players : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* First-run guess: pick the first non-generic token in the server folder
|
||||||
|
* name and look for a matching IW4M server by name. Ports `iw4m_autolink`
|
||||||
|
* in push_scripts.sh:605.
|
||||||
|
*/
|
||||||
|
function autolink(server: string, entries: StatusEntry[]): { match: 'port'; value: string } | null {
|
||||||
|
const skip = new Set(['t4', 't5', 't6', 'iw5', 'iw4']);
|
||||||
|
const parts = server
|
||||||
|
.replace(/[^a-zA-Z0-9]+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
.split(' ')
|
||||||
|
.filter((p) => p && !skip.has(p.toLowerCase()) && !/^\d+$/.test(p));
|
||||||
|
if (parts.length === 0) return null;
|
||||||
|
const token = parts[0].toLowerCase();
|
||||||
|
|
||||||
|
const hits = entries.filter((e) => normalizeName(e.name ?? '').includes(token));
|
||||||
|
if (hits.length !== 1) return null;
|
||||||
|
const port = hits[0].port;
|
||||||
|
if (typeof port !== 'number') return null;
|
||||||
|
return { match: 'port', value: String(port) };
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { config, komodoConfigured } from '../config.js';
|
||||||
|
import { getServer } from '../fleet/servers.js';
|
||||||
|
|
||||||
|
// Ports `komodo_restart` in push_scripts.sh:622. Three shapes depending on
|
||||||
|
// the tracked komodo_type: deployment | container | stack.
|
||||||
|
|
||||||
|
export type KomodoRestartResult =
|
||||||
|
| { ok: true }
|
||||||
|
| { ok: false; kind: 'unconfigured' | 'no-name' | 'http-error' | 'unreachable'; message: string };
|
||||||
|
|
||||||
|
async function komodoCall(path: string, body: unknown): Promise<Response> {
|
||||||
|
const url = `${config.komodo.address.replace(/\/+$/, '')}/${path}`;
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), config.httpTimeoutMs);
|
||||||
|
try {
|
||||||
|
return await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Api-Key': config.komodo.apiKey,
|
||||||
|
'X-Api-Secret': config.komodo.apiSecret
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
signal: controller.signal
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function komodoRestart(serverName: string): Promise<KomodoRestartResult> {
|
||||||
|
if (!komodoConfigured()) {
|
||||||
|
return { ok: false, kind: 'unconfigured', message: 'komodo not configured' };
|
||||||
|
}
|
||||||
|
const row = getServer(serverName);
|
||||||
|
if (!row?.komodo_name) return { ok: false, kind: 'no-name', message: 'no komodo_name set for server' };
|
||||||
|
|
||||||
|
const type = row.komodo_type ?? 'deployment';
|
||||||
|
let path: string;
|
||||||
|
let body: Record<string, string>;
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'stack':
|
||||||
|
path = 'execute/RestartStack';
|
||||||
|
body = { stack: row.komodo_name };
|
||||||
|
break;
|
||||||
|
case 'container':
|
||||||
|
if (!row.komodo_server) return { ok: false, kind: 'no-name', message: 'container type needs komodo_server' };
|
||||||
|
path = 'execute/RestartContainer';
|
||||||
|
body = { server: row.komodo_server, container: row.komodo_name };
|
||||||
|
break;
|
||||||
|
case 'deployment':
|
||||||
|
default:
|
||||||
|
path = 'execute/RestartDeployment';
|
||||||
|
body = { deployment: row.komodo_name };
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let res: Response;
|
||||||
|
try {
|
||||||
|
res = await komodoCall(path, body);
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, kind: 'unreachable', message: (err as Error).message };
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
return { ok: false, kind: 'http-error', message: `HTTP ${res.status}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
// The script also checks the response body: if it's an object with
|
||||||
|
// .success === false, treat as failure. If .success is absent, trust the
|
||||||
|
// HTTP status (line 646).
|
||||||
|
try {
|
||||||
|
const json = await res.json();
|
||||||
|
if (json && typeof json === 'object' && 'success' in json && json.success === false) {
|
||||||
|
return { ok: false, kind: 'http-error', message: 'komodo returned success=false' };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Non-JSON body is fine — HTTP 2xx already indicates success.
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { EventEmitter } from 'node:events';
|
||||||
|
|
||||||
|
// One in-process event bus, multiplexed by all three SSE endpoints. Kept
|
||||||
|
// separate from any specific subscriber so push/beta/sweep can emit without
|
||||||
|
// pulling the whole worker module in.
|
||||||
|
|
||||||
|
export type PipelineEvent =
|
||||||
|
| { type: 'deployment-opened'; deploymentId: string; action: string; servers: string[] }
|
||||||
|
| { type: 'server-changed'; deploymentId: string; server: string; files: number }
|
||||||
|
| {
|
||||||
|
type: 'server-state';
|
||||||
|
deploymentId: string;
|
||||||
|
server: string;
|
||||||
|
state: 'pending' | 'done' | 'failed' | 'held' | 'manual';
|
||||||
|
players: number | null;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
| { type: 'sweep-tick'; frozen: boolean; reason?: string }
|
||||||
|
| { type: 'auth-state'; state: import('../integrations/auth-state.js').AuthState; msRemaining?: number }
|
||||||
|
| { type: 'server-status'; entries: Array<{ server: string; players: number | null; name?: string }> };
|
||||||
|
|
||||||
|
class Bus extends EventEmitter {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
// Deployments and sweep can produce a lot of events during a big push.
|
||||||
|
this.setMaxListeners(64);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const bus = new Bus();
|
||||||
|
|
||||||
|
export function emit(event: PipelineEvent): void {
|
||||||
|
bus.emit('event', event);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribe(listener: (event: PipelineEvent) => void): () => void {
|
||||||
|
bus.on('event', listener);
|
||||||
|
return () => bus.off('event', listener);
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import { config, iw4mConfigured, komodoConfigured } from '../config.js';
|
||||||
|
import { db } from '../db/index.js';
|
||||||
|
import { emit } from './bus.js';
|
||||||
|
import { fetchStatus, playersOn } from '../integrations/iw4m.js';
|
||||||
|
import { komodoRestart } from '../integrations/komodo.js';
|
||||||
|
import { pipelineReady } from '../integrations/auth-state.js';
|
||||||
|
|
||||||
|
interface PendingRow {
|
||||||
|
deployment_id: string;
|
||||||
|
server: string;
|
||||||
|
attempts: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nowIso(): string {
|
||||||
|
return new Date().toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function integrationsOn(): boolean {
|
||||||
|
return komodoConfigured() && iw4mConfigured();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-check every pending/failed deployment_server row, restart the ones with
|
||||||
|
* zero players, mark the busy ones held. Same-server dedup is per-sweep, so
|
||||||
|
* one restart resolves every pending row for that server. Ports `deploy_sweep`
|
||||||
|
* in push_scripts.sh:705.
|
||||||
|
*/
|
||||||
|
export async function sweep(): Promise<{ checked: number; restarted: number; held: number; failed: number; frozen: boolean }> {
|
||||||
|
if (!pipelineReady()) {
|
||||||
|
emit({ type: 'sweep-tick', frozen: true, reason: 'iw4m token stale' });
|
||||||
|
return { checked: 0, restarted: 0, held: 0, failed: 0, frozen: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!integrationsOn()) {
|
||||||
|
// Same behavior as line 714 — mark rows manual so they don't hang
|
||||||
|
// forever if integrations were never configured.
|
||||||
|
db()
|
||||||
|
.prepare(
|
||||||
|
`UPDATE deployment_servers
|
||||||
|
SET restart_state = 'manual', checked_at = ?, error = 'integrations not configured'
|
||||||
|
WHERE restart_state IN ('pending','failed')`
|
||||||
|
)
|
||||||
|
.run(nowIso());
|
||||||
|
return { checked: 0, restarted: 0, held: 0, failed: 0, frozen: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const pending = db()
|
||||||
|
.prepare(
|
||||||
|
`SELECT deployment_id, server, attempts
|
||||||
|
FROM deployment_servers
|
||||||
|
WHERE restart_state IN ('pending','failed')`
|
||||||
|
)
|
||||||
|
.all() as PendingRow[];
|
||||||
|
if (pending.length === 0) return { checked: 0, restarted: 0, held: 0, failed: 0, frozen: false };
|
||||||
|
|
||||||
|
// Warm the /api/status cache so per-server calls just hit memory
|
||||||
|
const status = await fetchStatus();
|
||||||
|
if (!status.ok) {
|
||||||
|
// Same as line 742 — mark held with the iw4m error
|
||||||
|
const stmt = db().prepare(
|
||||||
|
`UPDATE deployment_servers
|
||||||
|
SET checked_at = ?, error = ?, attempts = attempts + 1
|
||||||
|
WHERE restart_state IN ('pending','failed')`
|
||||||
|
);
|
||||||
|
stmt.run(nowIso(), `iw4m: ${status.message}`);
|
||||||
|
return { checked: 0, restarted: 0, held: pending.length, failed: 0, frozen: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dedup across deployments: if we resolved server X once this tick,
|
||||||
|
// reuse the outcome for every other pending row on X.
|
||||||
|
type Outcome = { state: 'done' | 'failed' | 'pending'; players: number | null; error: string | null };
|
||||||
|
const seen = new Map<string, Outcome>();
|
||||||
|
|
||||||
|
let checked = 0;
|
||||||
|
let restarted = 0;
|
||||||
|
let held = 0;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
const applyOutcome = db().prepare(
|
||||||
|
`UPDATE deployment_servers
|
||||||
|
SET restart_state = ?, checked_at = ?, players = ?, error = ?, attempts = attempts + 1,
|
||||||
|
restarted_at = CASE WHEN ? = 'done' THEN ? ELSE restarted_at END
|
||||||
|
WHERE deployment_id = ? AND server = ?`
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const row of pending) {
|
||||||
|
let outcome = seen.get(row.server);
|
||||||
|
if (!outcome) {
|
||||||
|
checked++;
|
||||||
|
const players = await playersOn(row.server);
|
||||||
|
if (players === null) {
|
||||||
|
outcome = { state: 'pending', players: null, error: 'no iw4m match' };
|
||||||
|
held++;
|
||||||
|
} else if (players > 0) {
|
||||||
|
outcome = { state: 'pending', players, error: null };
|
||||||
|
held++;
|
||||||
|
} else {
|
||||||
|
const r = await komodoRestart(row.server);
|
||||||
|
if (r.ok) {
|
||||||
|
outcome = { state: 'done', players: 0, error: null };
|
||||||
|
restarted++;
|
||||||
|
} else if (r.kind === 'no-name') {
|
||||||
|
outcome = { state: 'failed', players: 0, error: r.message };
|
||||||
|
failed++;
|
||||||
|
} else {
|
||||||
|
outcome = { state: 'failed', players: 0, error: `komodo: ${r.message}` };
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
seen.set(row.server, outcome);
|
||||||
|
}
|
||||||
|
const now = nowIso();
|
||||||
|
applyOutcome.run(
|
||||||
|
outcome.state,
|
||||||
|
now,
|
||||||
|
outcome.players,
|
||||||
|
outcome.error,
|
||||||
|
outcome.state,
|
||||||
|
now,
|
||||||
|
row.deployment_id,
|
||||||
|
row.server
|
||||||
|
);
|
||||||
|
emit({
|
||||||
|
type: 'server-state',
|
||||||
|
deploymentId: row.deployment_id,
|
||||||
|
server: row.server,
|
||||||
|
state: outcome.state,
|
||||||
|
players: outcome.players,
|
||||||
|
error: outcome.error
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finish deployments where every server is now non-pending
|
||||||
|
db()
|
||||||
|
.prepare(
|
||||||
|
`UPDATE deployments
|
||||||
|
SET finished_at = ?
|
||||||
|
WHERE finished_at IS NULL
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM deployment_servers
|
||||||
|
WHERE deployment_id = deployments.id
|
||||||
|
AND restart_state IN ('pending','failed')
|
||||||
|
)`
|
||||||
|
)
|
||||||
|
.run(nowIso());
|
||||||
|
|
||||||
|
// Prune rows older than DEPLOY_KEEP_DAYS that have no pending/failed servers
|
||||||
|
const cutoff = new Date(Date.now() - config.deployKeepDays * 24 * 60 * 60 * 1000).toISOString();
|
||||||
|
db()
|
||||||
|
.prepare(
|
||||||
|
`DELETE FROM deployments
|
||||||
|
WHERE started_at < ?
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM deployment_servers
|
||||||
|
WHERE deployment_id = deployments.id
|
||||||
|
AND restart_state IN ('pending','failed')
|
||||||
|
)`
|
||||||
|
)
|
||||||
|
.run(cutoff);
|
||||||
|
|
||||||
|
emit({ type: 'sweep-tick', frozen: false });
|
||||||
|
return { checked, restarted, held, failed, frozen: false };
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { sweep } from './sweep.js';
|
||||||
|
import { emit } from './bus.js';
|
||||||
|
import { fetchStatus } from '../integrations/iw4m.js';
|
||||||
|
import { listServers, seedServerRegistry } from '../fleet/servers.js';
|
||||||
|
import { getAuthState } from '../integrations/auth-state.js';
|
||||||
|
|
||||||
|
const TICK_MS = 15_000;
|
||||||
|
const STATUS_REFRESH_MS = 30_000;
|
||||||
|
|
||||||
|
let started = false;
|
||||||
|
let tickTimer: NodeJS.Timeout | null = null;
|
||||||
|
let statusTimer: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
async function tick(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await sweep();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[worker] sweep error:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pushServerStatus(): Promise<void> {
|
||||||
|
const st = await fetchStatus();
|
||||||
|
if (!st.ok) return;
|
||||||
|
const rows = listServers();
|
||||||
|
const byName = new Map(rows.map((r) => [r.name, r]));
|
||||||
|
// For the dashboard: emit a lightweight snapshot. Actual per-server
|
||||||
|
// resolution is handled by playersOn() during a sweep, which caches
|
||||||
|
// iw4m_match/value into the servers table.
|
||||||
|
emit({
|
||||||
|
type: 'server-status',
|
||||||
|
entries: st.entries.map((e) => ({
|
||||||
|
server: e.name ?? String(e.port ?? ''),
|
||||||
|
players: e.currentPlayers ?? null,
|
||||||
|
name: e.name
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
// Also fold current auth ttl into the auth channel so subscribers get a
|
||||||
|
// heartbeat and a countdown without waiting for a transition.
|
||||||
|
const auth = getAuthState();
|
||||||
|
emit({
|
||||||
|
type: 'auth-state',
|
||||||
|
state: auth,
|
||||||
|
msRemaining: auth.kind === 'ready' ? Math.max(0, auth.expiresAt - Date.now()) : undefined
|
||||||
|
});
|
||||||
|
void byName; // reserved: future join between /api/status and server registry
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startWorker(): void {
|
||||||
|
if (started) return;
|
||||||
|
started = true;
|
||||||
|
|
||||||
|
// Seed the servers table from the fs so first-boot has rows to reference.
|
||||||
|
seedServerRegistry().catch((err) => console.error('[worker] seed error:', err));
|
||||||
|
|
||||||
|
void tick();
|
||||||
|
void pushServerStatus();
|
||||||
|
tickTimer = setInterval(tick, TICK_MS);
|
||||||
|
statusTimer = setInterval(pushServerStatus, STATUS_REFRESH_MS);
|
||||||
|
|
||||||
|
console.log('[worker] started — sweep every', TICK_MS, 'ms, status every', STATUS_REFRESH_MS, 'ms');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopWorker(): void {
|
||||||
|
if (tickTimer) clearInterval(tickTimer);
|
||||||
|
if (statusTimer) clearInterval(statusTimer);
|
||||||
|
tickTimer = null;
|
||||||
|
statusTimer = null;
|
||||||
|
started = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* External trigger — called from `/api/push` and beta endpoints so a sweep
|
||||||
|
* fires immediately after a state change instead of waiting for the next tick.
|
||||||
|
*/
|
||||||
|
export async function kick(): Promise<void> {
|
||||||
|
await tick();
|
||||||
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
interface StagedHint {
|
||||||
|
base: string;
|
||||||
|
hits: string[];
|
||||||
|
}
|
||||||
|
interface BetaSummary {
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
opened_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
revision: number;
|
||||||
|
servers: string[];
|
||||||
|
scripts: Array<{ staged_as: string; target: string; revision: number }>;
|
||||||
|
}
|
||||||
|
interface Props {
|
||||||
|
staged: StagedHint[];
|
||||||
|
betas: BetaSummary[];
|
||||||
|
}
|
||||||
|
let { staged, betas }: Props = $props();
|
||||||
|
|
||||||
|
let newBetaName = $state('');
|
||||||
|
let newBetaPatterns = $state('*');
|
||||||
|
let submitting = $state(false);
|
||||||
|
let flash = $state<string | null>(null);
|
||||||
|
|
||||||
|
async function openNewBeta() {
|
||||||
|
if (!newBetaName.trim() || submitting) return;
|
||||||
|
submitting = true;
|
||||||
|
flash = null;
|
||||||
|
const res = await fetch('/api/betas', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: newBetaName.trim(),
|
||||||
|
patterns: newBetaPatterns.split(',').map((p) => p.trim()).filter(Boolean),
|
||||||
|
create: false
|
||||||
|
})
|
||||||
|
});
|
||||||
|
submitting = false;
|
||||||
|
const body = (await res.json()) as { ok: boolean; error?: string };
|
||||||
|
if (!res.ok || !body.ok) {
|
||||||
|
flash = body.error ?? `HTTP ${res.status}`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
newBetaName = '';
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function act(slug: string, action: 'rollback' | 'promote' | 'update') {
|
||||||
|
const path =
|
||||||
|
action === 'update' ? '/api/betas' : `/api/betas/${slug}/${action}`;
|
||||||
|
const method = action === 'update' ? 'PATCH' : 'POST';
|
||||||
|
const body = action === 'update' ? JSON.stringify({ name: slug }) : '{}';
|
||||||
|
const res = await fetch(path, {
|
||||||
|
method,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = (await res.json().catch(() => ({}))) as { error?: string };
|
||||||
|
flash = err.error ?? `HTTP ${res.status}`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h3>Staged</h3>
|
||||||
|
{#if staged.length === 0}
|
||||||
|
<p class="empty">nothing staged</p>
|
||||||
|
{:else}
|
||||||
|
<ul class="staged">
|
||||||
|
{#each staged as s (s.base)}
|
||||||
|
<li>
|
||||||
|
<code>{s.base}</code>
|
||||||
|
{#if s.hits.length === 0}
|
||||||
|
<span class="tag new">new</span>
|
||||||
|
{:else if s.hits.length === 1}
|
||||||
|
<span class="tag replaces">replaces {s.hits[0]}</span>
|
||||||
|
{:else}
|
||||||
|
<span class="tag ambig">ambiguous ({s.hits.length} hits)</span>
|
||||||
|
{/if}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<form onsubmit={(e) => { e.preventDefault(); openNewBeta(); }}>
|
||||||
|
<input type="text" placeholder="Beta name" bind:value={newBetaName} disabled={submitting} />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Server pattern (*, t5_kino_*, comma-sep)"
|
||||||
|
bind:value={newBetaPatterns}
|
||||||
|
disabled={submitting}
|
||||||
|
/>
|
||||||
|
<button type="submit" disabled={submitting || !newBetaName}>open beta</button>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<h3>Open betas</h3>
|
||||||
|
{#if betas.length === 0}
|
||||||
|
<p class="empty">no open betas</p>
|
||||||
|
{:else}
|
||||||
|
{#each betas as b (b.slug)}
|
||||||
|
<div class="beta">
|
||||||
|
<div class="head">
|
||||||
|
<strong>{b.name}</strong>
|
||||||
|
<span class="rev">rev {b.revision}</span>
|
||||||
|
</div>
|
||||||
|
<div class="row"><span class="k">scripts</span> {b.scripts.map((s) => s.target).join(', ')}</div>
|
||||||
|
<div class="row"><span class="k">servers</span> {b.servers.join(', ')}</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button onclick={() => act(b.slug, 'update')}>update</button>
|
||||||
|
<button onclick={() => act(b.slug, 'promote')}>promote</button>
|
||||||
|
<button class="danger" onclick={() => act(b.slug, 'rollback')}>rollback</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if flash}
|
||||||
|
<p class="flash">{flash}</p>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.panel {
|
||||||
|
background: #141414;
|
||||||
|
border: 1px solid #333;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
h3 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
h3:not(:first-of-type) { margin-top: 20px; }
|
||||||
|
.empty { color: #666; font-size: 13px; margin: 0; }
|
||||||
|
.staged { list-style: none; padding: 0; margin: 0 0 12px; }
|
||||||
|
.staged li {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 4px 0;
|
||||||
|
border-bottom: 1px solid #222;
|
||||||
|
}
|
||||||
|
.staged code { color: #ddd; font-family: ui-monospace, monospace; font-size: 13px; }
|
||||||
|
.tag {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
.tag.new { background: #2d4a2d; color: #a8e6a8; }
|
||||||
|
.tag.replaces { background: #2d3a4a; color: #a8c8e6; }
|
||||||
|
.tag.ambig { background: #4a2d2d; color: #ff8080; }
|
||||||
|
form {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
form input {
|
||||||
|
flex: 1;
|
||||||
|
background: #000;
|
||||||
|
color: #ddd;
|
||||||
|
border: 1px solid #444;
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
font-family: ui-monospace, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
background: #ff3030;
|
||||||
|
color: #fff;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
button:disabled { opacity: 0.5; cursor: default; }
|
||||||
|
button.danger { background: #7a2020; }
|
||||||
|
.beta {
|
||||||
|
padding: 8px 10px;
|
||||||
|
margin: 6px 0;
|
||||||
|
background: #1a1a1a;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid #2a2a2a;
|
||||||
|
}
|
||||||
|
.head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.rev { color: #666; font-size: 11px; }
|
||||||
|
.row { font-size: 12px; color: #bbb; margin: 2px 0; }
|
||||||
|
.k { color: #666; margin-right: 6px; font-family: ui-monospace, monospace; }
|
||||||
|
.actions { display: flex; gap: 4px; margin-top: 8px; }
|
||||||
|
.flash { color: #ff6060; font-size: 12px; margin: 12px 0 0; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
interface Props {
|
||||||
|
before: string | null;
|
||||||
|
after: string | null;
|
||||||
|
verb: string;
|
||||||
|
relPath: string;
|
||||||
|
}
|
||||||
|
let { before, after, verb, relPath }: Props = $props();
|
||||||
|
|
||||||
|
// A tiny line-by-line diff. Not LCS — just presence-based. For GSC scripts
|
||||||
|
// this reads fine; if we want proper Myers diff later, swap in `diff` npm
|
||||||
|
// package and render the same rows.
|
||||||
|
interface Row {
|
||||||
|
kind: 'ctx' | 'add' | 'del';
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let rows = $derived(computeRows(before, after));
|
||||||
|
|
||||||
|
function computeRows(a: string | null, b: string | null): Row[] {
|
||||||
|
const alines = (a ?? '').split(/\r?\n/);
|
||||||
|
const blines = (b ?? '').split(/\r?\n/);
|
||||||
|
const asetBase: Row[] = alines.map((t) => ({ kind: 'del' as const, text: t }));
|
||||||
|
const bsetBase: Row[] = blines.map((t) => ({ kind: 'add' as const, text: t }));
|
||||||
|
if (a === null) return bsetBase;
|
||||||
|
if (b === null) return asetBase;
|
||||||
|
// interleave shared-prefix / shared-suffix trimming for readability
|
||||||
|
let i = 0;
|
||||||
|
while (i < alines.length && i < blines.length && alines[i] === blines[i]) i++;
|
||||||
|
let j = 0;
|
||||||
|
while (
|
||||||
|
j < alines.length - i &&
|
||||||
|
j < blines.length - i &&
|
||||||
|
alines[alines.length - 1 - j] === blines[blines.length - 1 - j]
|
||||||
|
)
|
||||||
|
j++;
|
||||||
|
const prefix = alines.slice(0, i).map((t) => ({ kind: 'ctx' as const, text: t }));
|
||||||
|
const suffix = alines.slice(alines.length - j).map((t) => ({ kind: 'ctx' as const, text: t }));
|
||||||
|
const midA = alines.slice(i, alines.length - j).map((t) => ({ kind: 'del' as const, text: t }));
|
||||||
|
const midB = blines.slice(i, blines.length - j).map((t) => ({ kind: 'add' as const, text: t }));
|
||||||
|
return [...prefix, ...midA, ...midB, ...suffix];
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="diff">
|
||||||
|
<header>
|
||||||
|
<span class="verb verb-{verb}">{verb}</span>
|
||||||
|
<span class="path">{relPath}</span>
|
||||||
|
</header>
|
||||||
|
<pre>
|
||||||
|
{#each rows as r, idx (idx)}<span
|
||||||
|
class="ln ln-{r.kind}">{r.kind === 'add' ? '+' : r.kind === 'del' ? '-' : ' '} {r.text}
|
||||||
|
</span>{/each}</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.diff {
|
||||||
|
background: #0f0f0f;
|
||||||
|
border: 1px solid #333;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin: 8px 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
header {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
background: #1a1a1a;
|
||||||
|
border-bottom: 1px solid #333;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.verb {
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-family: ui-monospace, monospace;
|
||||||
|
}
|
||||||
|
.verb-added { background: #2d4a2d; color: #a8e6a8; }
|
||||||
|
.verb-updated { background: #2d3a4a; color: #a8c8e6; }
|
||||||
|
.verb-removed { background: #4a2d2d; color: #ff8080; }
|
||||||
|
.verb-held { background: #3a2f00; color: #ffcc55; }
|
||||||
|
.path { color: #ccc; font-family: ui-monospace, monospace; }
|
||||||
|
pre {
|
||||||
|
margin: 0;
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-family: ui-monospace, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #ccc;
|
||||||
|
max-height: 500px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.ln { display: block; }
|
||||||
|
.ln-add { background: rgba(45, 74, 45, 0.4); color: #a8e6a8; }
|
||||||
|
.ln-del { background: rgba(74, 45, 45, 0.4); color: #ff8080; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { deployments, frozen, frozenReason } from '$ui/stores/pipeline.js';
|
||||||
|
|
||||||
|
// Newest first, up to 8 rows on the dashboard.
|
||||||
|
let rows = $derived(
|
||||||
|
Array.from($deployments.values())
|
||||||
|
.sort((a, b) => b.openedAt - a.openedAt)
|
||||||
|
.slice(0, 8)
|
||||||
|
);
|
||||||
|
|
||||||
|
function stateColor(state: string): string {
|
||||||
|
switch (state) {
|
||||||
|
case 'done': return '#2d4a2d';
|
||||||
|
case 'failed': return '#4a2d2d';
|
||||||
|
case 'pending': return '#3a2f00';
|
||||||
|
case 'manual': return '#333';
|
||||||
|
default: return '#222';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stateFg(state: string): string {
|
||||||
|
switch (state) {
|
||||||
|
case 'done': return '#a8e6a8';
|
||||||
|
case 'failed': return '#ff8080';
|
||||||
|
case 'pending': return '#ffcc55';
|
||||||
|
case 'manual': return '#aaa';
|
||||||
|
default: return '#888';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="wrapper">
|
||||||
|
<header>
|
||||||
|
<h3>Pipeline</h3>
|
||||||
|
{#if $frozen}
|
||||||
|
<span class="frozen">frozen — {$frozenReason ?? 'no reason'}</span>
|
||||||
|
{/if}
|
||||||
|
</header>
|
||||||
|
{#if rows.length === 0}
|
||||||
|
<p class="empty">no active deployments</p>
|
||||||
|
{:else}
|
||||||
|
<div class="rows">
|
||||||
|
{#each rows as dep (dep.id)}
|
||||||
|
<a class="row" href="/deployments/{dep.id}">
|
||||||
|
<div class="row-head">
|
||||||
|
<span class="action">{dep.action || dep.id}</span>
|
||||||
|
<span class="count">{dep.servers.size} server{dep.servers.size === 1 ? '' : 's'}</span>
|
||||||
|
</div>
|
||||||
|
<div class="chips">
|
||||||
|
{#each [...dep.servers.entries()] as [srv, meta] (srv)}
|
||||||
|
<span
|
||||||
|
class="chip"
|
||||||
|
style="background:{stateColor(meta.state)};color:{stateFg(meta.state)}"
|
||||||
|
title={meta.error ?? (meta.players !== null ? `${meta.players}p` : meta.state)}
|
||||||
|
>
|
||||||
|
{srv}{meta.players !== null && meta.players > 0 ? ` ${meta.players}p` : ''}
|
||||||
|
</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.wrapper {
|
||||||
|
background: #141414;
|
||||||
|
border: 1px solid #333;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
h3 { margin: 0; font-size: 14px; text-transform: uppercase; letter-spacing: 0.5px; color: #aaa; }
|
||||||
|
.frozen { color: #ff6060; font-size: 12px; }
|
||||||
|
.empty { color: #666; font-size: 13px; margin: 0; }
|
||||||
|
.rows { display: grid; gap: 8px; }
|
||||||
|
.row {
|
||||||
|
display: block;
|
||||||
|
color: #ddd;
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 8px;
|
||||||
|
background: #1a1a1a;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
.row:hover { border-color: #444; }
|
||||||
|
.row-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.action { font-family: ui-monospace, monospace; font-size: 13px; }
|
||||||
|
.count { color: #666; font-size: 12px; }
|
||||||
|
.chips { display: flex; flex-wrap: wrap; gap: 4px; }
|
||||||
|
.chip {
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: ui-monospace, monospace;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
interface Held {
|
||||||
|
target: string;
|
||||||
|
slug: string;
|
||||||
|
}
|
||||||
|
interface Props {
|
||||||
|
name: string;
|
||||||
|
players?: number | null;
|
||||||
|
held?: Held[];
|
||||||
|
lastRestartedAt?: string | null;
|
||||||
|
restarting?: boolean;
|
||||||
|
}
|
||||||
|
let { name, players = null, held = [], lastRestartedAt = null, restarting = false }: Props = $props();
|
||||||
|
|
||||||
|
function ago(iso: string | null): string {
|
||||||
|
if (!iso) return 'never';
|
||||||
|
const d = Date.now() - new Date(iso).getTime();
|
||||||
|
if (d < 60_000) return 'just now';
|
||||||
|
if (d < 3_600_000) return `${Math.floor(d / 60_000)}m ago`;
|
||||||
|
if (d < 86_400_000) return `${Math.floor(d / 3_600_000)}h ago`;
|
||||||
|
return `${Math.floor(d / 86_400_000)}d ago`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<a class="card" href="/servers/{name}" class:restarting>
|
||||||
|
<div class="head">
|
||||||
|
<strong>{name}</strong>
|
||||||
|
<span class="players" class:has-players={players !== null && players > 0}>
|
||||||
|
{players ?? '—'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="meta">
|
||||||
|
restart: {ago(lastRestartedAt)}
|
||||||
|
</div>
|
||||||
|
{#if held.length > 0}
|
||||||
|
<div class="held">
|
||||||
|
{#each held as h (h.target)}
|
||||||
|
<span class="chip" title="held by beta: {h.slug}">β {h.target}</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if restarting}
|
||||||
|
<div class="restarting-bar" aria-label="restarting"></div>
|
||||||
|
{/if}
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.card {
|
||||||
|
display: block;
|
||||||
|
background: #1a1a1a;
|
||||||
|
border: 1px solid #333;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 12px;
|
||||||
|
color: #ddd;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
.card:hover { border-color: #555; }
|
||||||
|
.card.restarting { border-color: #ffab00; }
|
||||||
|
.head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
.players {
|
||||||
|
background: #222;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-family: ui-monospace, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
.players.has-players { background: #2d4a2d; color: #a8e6a8; }
|
||||||
|
.meta { color: #666; font-size: 12px; margin-top: 4px; }
|
||||||
|
.held { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 8px; }
|
||||||
|
.chip {
|
||||||
|
background: #3a2f00;
|
||||||
|
color: #ffcc55;
|
||||||
|
border: 1px solid #665500;
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: ui-monospace, monospace;
|
||||||
|
}
|
||||||
|
.restarting-bar {
|
||||||
|
height: 2px;
|
||||||
|
background: linear-gradient(90deg, transparent, #ffab00, transparent);
|
||||||
|
margin-top: 8px;
|
||||||
|
animation: slide 1.2s infinite linear;
|
||||||
|
}
|
||||||
|
@keyframes slide {
|
||||||
|
from { background-position: -100% 0; }
|
||||||
|
to { background-position: 200% 0; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { authState, msRemaining, shouldBlock, submitToken } from '$ui/stores/auth.js';
|
||||||
|
|
||||||
|
let value = $state('');
|
||||||
|
let submitting = $state(false);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
|
||||||
|
async function onSubmit(e: Event) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (submitting) return;
|
||||||
|
submitting = true;
|
||||||
|
error = null;
|
||||||
|
const res = await submitToken(value);
|
||||||
|
submitting = false;
|
||||||
|
if (!res.ok) {
|
||||||
|
error = res.error;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reactively re-open the modal when we cross the warning threshold. The
|
||||||
|
// server pushes msRemaining every 5s; that's enough for the UI to notice
|
||||||
|
// "about to expire" and coax the user in-game before things freeze.
|
||||||
|
let warnOpen = $state(false);
|
||||||
|
$effect(() => {
|
||||||
|
if ($authState.kind === 'ready' && $msRemaining < 20_000 && $msRemaining > 0) {
|
||||||
|
warnOpen = true;
|
||||||
|
} else {
|
||||||
|
warnOpen = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let showModal = $derived($shouldBlock || warnOpen);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if showModal}
|
||||||
|
<div class="scrim" role="dialog" aria-modal="true" aria-labelledby="tm-title">
|
||||||
|
<div class="modal">
|
||||||
|
<h2 id="tm-title">
|
||||||
|
{#if $shouldBlock}
|
||||||
|
Deployments paused
|
||||||
|
{:else}
|
||||||
|
Token expiring
|
||||||
|
{/if}
|
||||||
|
</h2>
|
||||||
|
<p class="explain">
|
||||||
|
{#if $authState.kind === 'missing'}
|
||||||
|
Run <code>!rt</code> in-game to generate a 4-character token, then paste it here.
|
||||||
|
{:else if $authState.kind === 'stale'}
|
||||||
|
The last token was {$authState.reason}. Generate a new one with <code>!rt</code> in-game.
|
||||||
|
{:else}
|
||||||
|
Your token expires in {Math.ceil($msRemaining / 1000)}s. Paste a new one to keep deployments flowing.
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
<form onsubmit={onSubmit}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputmode="text"
|
||||||
|
autocomplete="off"
|
||||||
|
autocorrect="off"
|
||||||
|
spellcheck="false"
|
||||||
|
maxlength="4"
|
||||||
|
minlength="4"
|
||||||
|
pattern="[a-zA-Z0-9]{'{'}4{'}'}"
|
||||||
|
placeholder="abcd"
|
||||||
|
bind:value
|
||||||
|
disabled={submitting}
|
||||||
|
aria-label="4-character !rt token"
|
||||||
|
/>
|
||||||
|
<button type="submit" disabled={submitting || value.length !== 4}>
|
||||||
|
{submitting ? '…' : 'unfreeze'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{#if error}
|
||||||
|
<p class="error">{error}</p>
|
||||||
|
{/if}
|
||||||
|
<p class="hint">
|
||||||
|
The <code>!rt</code> token is single-use and dies after 2 minutes. That's normal.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.scrim {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
z-index: 9999;
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
}
|
||||||
|
.modal {
|
||||||
|
background: #111;
|
||||||
|
color: #eee;
|
||||||
|
border: 1px solid #444;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 24px;
|
||||||
|
min-width: 360px;
|
||||||
|
max-width: 460px;
|
||||||
|
font-family: system-ui, -apple-system, sans-serif;
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 18px;
|
||||||
|
color: #ff6060;
|
||||||
|
}
|
||||||
|
.explain {
|
||||||
|
margin: 0 0 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #ccc;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
form {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
input {
|
||||||
|
flex: 1;
|
||||||
|
background: #000;
|
||||||
|
color: #eee;
|
||||||
|
border: 1px solid #666;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 20px;
|
||||||
|
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||||
|
letter-spacing: 8px;
|
||||||
|
text-align: center;
|
||||||
|
text-transform: lowercase;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
background: #ff3030;
|
||||||
|
color: #fff;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.error {
|
||||||
|
color: #ff6060;
|
||||||
|
margin: 12px 0 0;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.hint {
|
||||||
|
margin: 16px 0 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
code {
|
||||||
|
background: #222;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { writable, derived } from 'svelte/store';
|
||||||
|
import { browser } from '$app/environment';
|
||||||
|
|
||||||
|
export type AuthState =
|
||||||
|
| { kind: 'ready'; token: string; expiresAt: number }
|
||||||
|
| { kind: 'stale'; reason: 'expired' | 'rejected' | 'unreachable' }
|
||||||
|
| { kind: 'missing' };
|
||||||
|
|
||||||
|
interface AuthPayload {
|
||||||
|
type: 'auth-state';
|
||||||
|
state: AuthState;
|
||||||
|
msRemaining?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const authState = writable<AuthState>({ kind: 'missing' });
|
||||||
|
export const msRemaining = writable<number>(0);
|
||||||
|
|
||||||
|
export const isReady = derived(authState, ($s) => $s.kind === 'ready');
|
||||||
|
export const shouldBlock = derived(authState, ($s) => $s.kind !== 'ready');
|
||||||
|
|
||||||
|
let source: EventSource | null = null;
|
||||||
|
|
||||||
|
export function startAuthStream(): void {
|
||||||
|
if (!browser || source) return;
|
||||||
|
source = new EventSource('/api/events/auth');
|
||||||
|
source.addEventListener('auth-state', (evt) => {
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse((evt as MessageEvent).data) as AuthPayload;
|
||||||
|
authState.set(payload.state);
|
||||||
|
if (payload.msRemaining !== undefined) msRemaining.set(payload.msRemaining);
|
||||||
|
} catch {
|
||||||
|
/* ignore malformed */
|
||||||
|
}
|
||||||
|
});
|
||||||
|
source.addEventListener('error', () => {
|
||||||
|
// Browser will auto-reconnect. If the stream stays down long enough,
|
||||||
|
// the token will expire on the server anyway and the next reconnect
|
||||||
|
// will surface the transition.
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function submitToken(token: string): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||||
|
const res = await fetch('/api/auth/token', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ token })
|
||||||
|
});
|
||||||
|
const body = (await res.json()) as { ok: boolean; error?: string };
|
||||||
|
if (!res.ok || !body.ok) return { ok: false, error: body.error ?? `HTTP ${res.status}` };
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { writable } from 'svelte/store';
|
||||||
|
import { browser } from '$app/environment';
|
||||||
|
|
||||||
|
export type ServerState = 'pending' | 'done' | 'failed' | 'held' | 'manual';
|
||||||
|
|
||||||
|
export interface PipelineDeployment {
|
||||||
|
id: string;
|
||||||
|
action: string;
|
||||||
|
servers: Map<string, { state: ServerState; players: number | null; error: string | null }>;
|
||||||
|
openedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const deployments = writable<Map<string, PipelineDeployment>>(new Map());
|
||||||
|
export const frozen = writable<boolean>(false);
|
||||||
|
export const frozenReason = writable<string | null>(null);
|
||||||
|
|
||||||
|
let source: EventSource | null = null;
|
||||||
|
|
||||||
|
function updateDeployment(id: string, mutate: (d: PipelineDeployment) => void): void {
|
||||||
|
deployments.update((m) => {
|
||||||
|
const existing = m.get(id) ?? { id, action: '', servers: new Map(), openedAt: Date.now() };
|
||||||
|
mutate(existing);
|
||||||
|
m.set(id, existing);
|
||||||
|
return new Map(m);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startPipelineStream(): void {
|
||||||
|
if (!browser || source) return;
|
||||||
|
source = new EventSource('/api/events/pipeline');
|
||||||
|
|
||||||
|
source.addEventListener('deployment-opened', (evt) => {
|
||||||
|
const data = JSON.parse((evt as MessageEvent).data) as { deploymentId: string; action: string; servers: string[] };
|
||||||
|
updateDeployment(data.deploymentId, (d) => {
|
||||||
|
d.action = data.action;
|
||||||
|
for (const s of data.servers) {
|
||||||
|
if (!d.servers.has(s)) d.servers.set(s, { state: 'pending', players: null, error: null });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
source.addEventListener('server-state', (evt) => {
|
||||||
|
const data = JSON.parse((evt as MessageEvent).data) as {
|
||||||
|
deploymentId: string;
|
||||||
|
server: string;
|
||||||
|
state: ServerState;
|
||||||
|
players: number | null;
|
||||||
|
error: string | null;
|
||||||
|
};
|
||||||
|
updateDeployment(data.deploymentId, (d) => {
|
||||||
|
d.servers.set(data.server, { state: data.state, players: data.players, error: data.error });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
source.addEventListener('sweep-tick', (evt) => {
|
||||||
|
const data = JSON.parse((evt as MessageEvent).data) as { frozen: boolean; reason?: string };
|
||||||
|
frozen.set(data.frozen);
|
||||||
|
frozenReason.set(data.reason ?? null);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { writable } from 'svelte/store';
|
||||||
|
import { browser } from '$app/environment';
|
||||||
|
|
||||||
|
export interface ServerStatus {
|
||||||
|
server: string;
|
||||||
|
players: number | null;
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const statusByServer = writable<Map<string, ServerStatus>>(new Map());
|
||||||
|
|
||||||
|
let source: EventSource | null = null;
|
||||||
|
|
||||||
|
export function startServerStream(): void {
|
||||||
|
if (!browser || source) return;
|
||||||
|
source = new EventSource('/api/events/servers');
|
||||||
|
source.addEventListener('server-status', (evt) => {
|
||||||
|
const data = JSON.parse((evt as MessageEvent).data) as { entries: ServerStatus[] };
|
||||||
|
statusByServer.update(() => {
|
||||||
|
const m = new Map<string, ServerStatus>();
|
||||||
|
for (const e of data.entries) m.set(e.server, e);
|
||||||
|
return m;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { startAuthStream } from '$ui/stores/auth.js';
|
||||||
|
import { startPipelineStream } from '$ui/stores/pipeline.js';
|
||||||
|
import { startServerStream } from '$ui/stores/servers.js';
|
||||||
|
import TokenModal from '$ui/components/TokenModal.svelte';
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
|
||||||
|
interface Props { children: import('svelte').Snippet }
|
||||||
|
let { children }: Props = $props();
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
startAuthStream();
|
||||||
|
startPipelineStream();
|
||||||
|
startServerStream();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="shell">
|
||||||
|
<nav>
|
||||||
|
<a href="/" class:on={$page.url.pathname === '/'}>Fleet</a>
|
||||||
|
<a href="/deployments" class:on={$page.url.pathname.startsWith('/deployments')}>Deployments</a>
|
||||||
|
<a href="/betas" class:on={$page.url.pathname.startsWith('/betas')}>Betas</a>
|
||||||
|
<span class="brand">electric-cherry</span>
|
||||||
|
</nav>
|
||||||
|
<main>
|
||||||
|
{@render children()}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TokenModal />
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:global(html, body) {
|
||||||
|
margin: 0;
|
||||||
|
background: #0a0a0a;
|
||||||
|
color: #ddd;
|
||||||
|
font-family: system-ui, -apple-system, sans-serif;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
:global(*) { box-sizing: border-box; }
|
||||||
|
.shell { min-height: 100vh; }
|
||||||
|
nav {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-bottom: 1px solid #222;
|
||||||
|
background: #0f0f0f;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
nav a {
|
||||||
|
color: #999;
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
nav a:hover { color: #ddd; background: #1a1a1a; }
|
||||||
|
nav a.on { color: #ff6060; background: #1a1a1a; }
|
||||||
|
.brand {
|
||||||
|
margin-left: auto;
|
||||||
|
font-family: ui-monospace, monospace;
|
||||||
|
color: #444;
|
||||||
|
font-size: 12px;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
main { padding: 20px; max-width: 1400px; margin: 0 auto; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import ServerCard from '$ui/components/ServerCard.svelte';
|
||||||
|
import PipelineTimeline from '$ui/components/PipelineTimeline.svelte';
|
||||||
|
import { statusByServer } from '$ui/stores/servers.js';
|
||||||
|
|
||||||
|
interface HeldRow { target: string; slug: string; }
|
||||||
|
interface ServerRow {
|
||||||
|
name: string;
|
||||||
|
komodo_type: string;
|
||||||
|
komodo_name: string;
|
||||||
|
iw4m_match: string | null;
|
||||||
|
iw4m_value: string | null;
|
||||||
|
last_players: number | null;
|
||||||
|
held: HeldRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
let servers = $state<ServerRow[]>([]);
|
||||||
|
let pushing = $state(false);
|
||||||
|
let flash = $state<string | null>(null);
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
const res = await fetch('/api/servers');
|
||||||
|
const body = (await res.json()) as { servers: ServerRow[] };
|
||||||
|
servers = body.servers;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function push(dryRun: boolean) {
|
||||||
|
pushing = true;
|
||||||
|
flash = null;
|
||||||
|
const res = await fetch('/api/push', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ patterns: ['*'], dryRun, create: false, delete: false })
|
||||||
|
});
|
||||||
|
pushing = false;
|
||||||
|
const body = (await res.json()) as { totalChangedServers?: number; totalFiles?: number; deploymentId?: string | null };
|
||||||
|
if (!res.ok) {
|
||||||
|
flash = `push failed: HTTP ${res.status}`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
flash = dryRun
|
||||||
|
? `dry run: ${body.totalChangedServers ?? 0} servers would change, ${body.totalFiles ?? 0} files`
|
||||||
|
: `pushed to ${body.totalChangedServers ?? 0} servers · ${body.totalFiles ?? 0} files`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function playersFor(name: string): number | null {
|
||||||
|
const s = $statusByServer.get(name);
|
||||||
|
return s?.players ?? null;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
<section class="left">
|
||||||
|
<header class="hdr">
|
||||||
|
<h1>Fleet</h1>
|
||||||
|
<div class="actions">
|
||||||
|
<button onclick={() => push(true)} disabled={pushing}>dry run</button>
|
||||||
|
<button class="primary" onclick={() => push(false)} disabled={pushing}>push all</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
{#if flash}<p class="flash">{flash}</p>{/if}
|
||||||
|
{#if servers.length === 0}
|
||||||
|
<p class="empty">no servers registered yet — check SERVERS_ROOT</p>
|
||||||
|
{:else}
|
||||||
|
<div class="cards">
|
||||||
|
{#each servers as s (s.name)}
|
||||||
|
<ServerCard
|
||||||
|
name={s.name}
|
||||||
|
players={playersFor(s.name) ?? s.last_players}
|
||||||
|
held={s.held}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside class="right">
|
||||||
|
<PipelineTimeline />
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 380px;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.grid { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
.hdr {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 22px;
|
||||||
|
color: #ff6060;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
.actions { display: flex; gap: 8px; }
|
||||||
|
button {
|
||||||
|
background: #222;
|
||||||
|
color: #ddd;
|
||||||
|
border: 1px solid #444;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
button.primary { background: #ff3030; color: #fff; border-color: #ff3030; font-weight: 600; }
|
||||||
|
button:disabled { opacity: 0.5; cursor: default; }
|
||||||
|
.cards {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||||
|
}
|
||||||
|
.empty { color: #666; }
|
||||||
|
.flash { color: #aaa; margin: 0 0 12px; font-family: ui-monospace, monospace; font-size: 12px; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import { loginWithToken } from '$lib/server/integrations/iw4m.js';
|
||||||
|
import { getAuthState, setAuthState } from '$lib/server/integrations/auth-state.js';
|
||||||
|
import { kick } from '$lib/server/pipeline/worker.js';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
return json(getAuthState());
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST({ request }: { request: Request }) {
|
||||||
|
const body = (await request.json().catch(() => ({}))) as { token?: string };
|
||||||
|
const token = (body.token ?? '').trim();
|
||||||
|
if (!/^[a-zA-Z0-9]{4}$/.test(token)) {
|
||||||
|
return json({ ok: false, error: 'token must be exactly 4 alphanumeric characters' }, { status: 400 });
|
||||||
|
}
|
||||||
|
const res = await loginWithToken(token);
|
||||||
|
if (!res.ok) {
|
||||||
|
setAuthState({ kind: 'stale', reason: res.reason === 'rejected' ? 'rejected' : 'unreachable' });
|
||||||
|
return json({ ok: false, error: res.message }, { status: res.reason === 'rejected' ? 401 : 502 });
|
||||||
|
}
|
||||||
|
setAuthState({ kind: 'ready', token, cookie: res.cookie, expiresAt: res.expiresAt });
|
||||||
|
// Fresh cookie → immediately drain any queued restarts.
|
||||||
|
void kick();
|
||||||
|
return json({ ok: true, expiresAt: res.expiresAt });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE() {
|
||||||
|
setAuthState({ kind: 'missing' });
|
||||||
|
return json({ ok: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import { openBeta, updateBeta, listBetas, listBetaScripts, listBetaServers, BetaError } from '$lib/server/fleet/beta.js';
|
||||||
|
import { stagedFiles, findTargets } from '$lib/server/fleet/files.js';
|
||||||
|
import { kick } from '$lib/server/pipeline/worker.js';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const staged = await stagedFiles();
|
||||||
|
const withHints = await Promise.all(
|
||||||
|
staged.map(async (base) => ({
|
||||||
|
base,
|
||||||
|
hits: await findTargets(base)
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
const betas = listBetas().map((b) => ({
|
||||||
|
...b,
|
||||||
|
servers: listBetaServers(b.slug),
|
||||||
|
scripts: listBetaScripts(b.slug)
|
||||||
|
}));
|
||||||
|
return json({ staged: withHints, betas });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST({ request }: { request: Request }) {
|
||||||
|
const body = (await request.json().catch(() => ({}))) as {
|
||||||
|
name?: string;
|
||||||
|
patterns?: string[];
|
||||||
|
dryRun?: boolean;
|
||||||
|
create?: boolean;
|
||||||
|
};
|
||||||
|
if (!body.name) return json({ ok: false, error: 'name is required' }, { status: 400 });
|
||||||
|
try {
|
||||||
|
const res = await openBeta({
|
||||||
|
name: body.name,
|
||||||
|
patterns: body.patterns ?? ['*'],
|
||||||
|
dryRun: body.dryRun ?? false,
|
||||||
|
create: body.create ?? false
|
||||||
|
});
|
||||||
|
if (!res.dryRun) void kick();
|
||||||
|
return json({ ok: true, result: res });
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof BetaError) {
|
||||||
|
return json({ ok: false, error: err.message, kind: err.kind, details: err.details }, { status: 409 });
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH({ request }: { request: Request }) {
|
||||||
|
const body = (await request.json().catch(() => ({}))) as { name?: string; dryRun?: boolean };
|
||||||
|
if (!body.name) return json({ ok: false, error: 'name is required' }, { status: 400 });
|
||||||
|
try {
|
||||||
|
const res = await updateBeta(body.name, body.dryRun ?? false);
|
||||||
|
return json({ ok: true, result: res });
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof BetaError) {
|
||||||
|
return json({ ok: false, error: err.message, kind: err.kind, details: err.details }, { status: 409 });
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import { promoteBeta, BetaError } from '$lib/server/fleet/beta.js';
|
||||||
|
import { kick } from '$lib/server/pipeline/worker.js';
|
||||||
|
|
||||||
|
export async function POST({ params, request }: { params: { slug: string }; request: Request }) {
|
||||||
|
const body = (await request.json().catch(() => ({}))) as { dryRun?: boolean };
|
||||||
|
try {
|
||||||
|
const res = await promoteBeta(params.slug, body.dryRun ?? false);
|
||||||
|
if (!res.dryRun) void kick();
|
||||||
|
return json({ ok: true, result: res });
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof BetaError) {
|
||||||
|
return json({ ok: false, error: err.message, kind: err.kind, details: err.details }, { status: 409 });
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import { rollbackBeta, BetaError } from '$lib/server/fleet/beta.js';
|
||||||
|
import { kick } from '$lib/server/pipeline/worker.js';
|
||||||
|
|
||||||
|
export async function POST({ params, request }: { params: { slug: string }; request: Request }) {
|
||||||
|
const body = (await request.json().catch(() => ({}))) as { dryRun?: boolean };
|
||||||
|
try {
|
||||||
|
// callers pass name; slug in URL is the slugified name — either works
|
||||||
|
const res = await rollbackBeta(params.slug, body.dryRun ?? false);
|
||||||
|
if (!res.dryRun) void kick();
|
||||||
|
return json({ ok: true, result: res });
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof BetaError) {
|
||||||
|
return json({ ok: false, error: err.message, kind: err.kind, details: err.details }, { status: 409 });
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import { db } from '$lib/server/db/index.js';
|
||||||
|
|
||||||
|
export async function GET({ url }: { url: URL }) {
|
||||||
|
const activeOnly = url.searchParams.get('active') === 'true';
|
||||||
|
|
||||||
|
const deployments = db()
|
||||||
|
.prepare(
|
||||||
|
`SELECT id, action, started_at, finished_at, triggered_by
|
||||||
|
FROM deployments
|
||||||
|
ORDER BY started_at DESC
|
||||||
|
LIMIT 500`
|
||||||
|
)
|
||||||
|
.all() as Array<{
|
||||||
|
id: string;
|
||||||
|
action: string;
|
||||||
|
started_at: string;
|
||||||
|
finished_at: string | null;
|
||||||
|
triggered_by: string | null;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
const serversByDep = db()
|
||||||
|
.prepare(
|
||||||
|
`SELECT deployment_id, server, files_changed, restart_state, players, checked_at, restarted_at, attempts, error
|
||||||
|
FROM deployment_servers`
|
||||||
|
)
|
||||||
|
.all() as Array<{
|
||||||
|
deployment_id: string;
|
||||||
|
server: string;
|
||||||
|
files_changed: number;
|
||||||
|
restart_state: string;
|
||||||
|
players: number | null;
|
||||||
|
checked_at: string | null;
|
||||||
|
restarted_at: string | null;
|
||||||
|
attempts: number;
|
||||||
|
error: string | null;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
const grouped = new Map<string, typeof serversByDep>();
|
||||||
|
for (const s of serversByDep) {
|
||||||
|
const arr = grouped.get(s.deployment_id) ?? [];
|
||||||
|
arr.push(s);
|
||||||
|
grouped.set(s.deployment_id, arr);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = deployments
|
||||||
|
.map((d) => ({ ...d, servers: grouped.get(d.id) ?? [] }))
|
||||||
|
.filter((d) => (activeOnly ? d.servers.some((s) => s.restart_state === 'pending' || s.restart_state === 'failed') : true));
|
||||||
|
|
||||||
|
return json({ deployments: rows });
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import { db } from '$lib/server/db/index.js';
|
||||||
|
|
||||||
|
export async function GET({ params }: { params: { id: string } }) {
|
||||||
|
const dep = db()
|
||||||
|
.prepare('SELECT * FROM deployments WHERE id = ?')
|
||||||
|
.get(params.id) as Record<string, unknown> | undefined;
|
||||||
|
if (!dep) throw error(404, 'deployment not found');
|
||||||
|
|
||||||
|
const servers = db()
|
||||||
|
.prepare('SELECT * FROM deployment_servers WHERE deployment_id = ? ORDER BY server')
|
||||||
|
.all(params.id);
|
||||||
|
|
||||||
|
// Don't ship blobs in the summary — the client asks for a single file when
|
||||||
|
// it opens the diff viewer.
|
||||||
|
const files = db()
|
||||||
|
.prepare(
|
||||||
|
`SELECT id, server, verb, rel_path, sha256_before, sha256_after,
|
||||||
|
LENGTH(content_before) AS size_before,
|
||||||
|
LENGTH(content_after) AS size_after
|
||||||
|
FROM deployment_files
|
||||||
|
WHERE deployment_id = ?
|
||||||
|
ORDER BY server, rel_path`
|
||||||
|
)
|
||||||
|
.all(params.id);
|
||||||
|
|
||||||
|
return json({ deployment: dep, servers, files });
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import { db } from '$lib/server/db/index.js';
|
||||||
|
|
||||||
|
// Streams a single deployment_files row's before/after blobs as UTF-8 text
|
||||||
|
// so the DiffViewer can render a unified diff without a second query.
|
||||||
|
|
||||||
|
export async function GET({ params }: { params: { id: string; fileId: string } }) {
|
||||||
|
const row = db()
|
||||||
|
.prepare(
|
||||||
|
`SELECT verb, rel_path, sha256_before, sha256_after, content_before, content_after
|
||||||
|
FROM deployment_files
|
||||||
|
WHERE deployment_id = ? AND id = ?`
|
||||||
|
)
|
||||||
|
.get(params.id, Number(params.fileId)) as
|
||||||
|
| {
|
||||||
|
verb: string;
|
||||||
|
rel_path: string;
|
||||||
|
sha256_before: string | null;
|
||||||
|
sha256_after: string | null;
|
||||||
|
content_before: Buffer | null;
|
||||||
|
content_after: Buffer | null;
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
if (!row) throw error(404, 'file not found');
|
||||||
|
|
||||||
|
return json({
|
||||||
|
verb: row.verb,
|
||||||
|
relPath: row.rel_path,
|
||||||
|
sha256Before: row.sha256_before,
|
||||||
|
sha256After: row.sha256_after,
|
||||||
|
before: row.content_before ? row.content_before.toString('utf8') : null,
|
||||||
|
after: row.content_after ? row.content_after.toString('utf8') : null
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { subscribe } from '$lib/server/pipeline/bus.js';
|
||||||
|
import { getAuthState } from '$lib/server/integrations/auth-state.js';
|
||||||
|
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const HEARTBEAT_MS = 5_000;
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
let heartbeat: NodeJS.Timeout | null = null;
|
||||||
|
let unsubscribe: (() => void) | null = null;
|
||||||
|
|
||||||
|
const stream = new ReadableStream<Uint8Array>({
|
||||||
|
start(controller) {
|
||||||
|
const push = () => {
|
||||||
|
const s = getAuthState();
|
||||||
|
const payload = {
|
||||||
|
type: 'auth-state',
|
||||||
|
state: s,
|
||||||
|
msRemaining: s.kind === 'ready' ? Math.max(0, s.expiresAt - Date.now()) : undefined
|
||||||
|
};
|
||||||
|
controller.enqueue(encoder.encode(`event: auth-state\ndata: ${JSON.stringify(payload)}\n\n`));
|
||||||
|
};
|
||||||
|
|
||||||
|
unsubscribe = subscribe((event) => {
|
||||||
|
if (event.type === 'auth-state') {
|
||||||
|
controller.enqueue(encoder.encode(`event: auth-state\ndata: ${JSON.stringify(event)}\n\n`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
push();
|
||||||
|
// The auth stream double-serves as the token-countdown feed; the
|
||||||
|
// frontend uses the msRemaining to render a per-second ticker,
|
||||||
|
// so we push every 5s even without a state change.
|
||||||
|
heartbeat = setInterval(push, HEARTBEAT_MS);
|
||||||
|
},
|
||||||
|
cancel() {
|
||||||
|
if (heartbeat) clearInterval(heartbeat);
|
||||||
|
if (unsubscribe) unsubscribe();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(stream, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/event-stream',
|
||||||
|
'Cache-Control': 'no-cache, no-transform',
|
||||||
|
Connection: 'keep-alive'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { subscribe, type PipelineEvent } from '$lib/server/pipeline/bus.js';
|
||||||
|
|
||||||
|
// Multiplexed SSE. Every event with a type field goes out. The client filters
|
||||||
|
// by type (server-state | deployment-opened | sweep-tick | server-changed).
|
||||||
|
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const HEARTBEAT_MS = 20_000;
|
||||||
|
|
||||||
|
function send(controller: ReadableStreamDefaultController<Uint8Array>, event: PipelineEvent): void {
|
||||||
|
controller.enqueue(encoder.encode(`event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
let heartbeat: NodeJS.Timeout | null = null;
|
||||||
|
let unsubscribe: (() => void) | null = null;
|
||||||
|
|
||||||
|
const stream = new ReadableStream<Uint8Array>({
|
||||||
|
start(controller) {
|
||||||
|
unsubscribe = subscribe((event) => {
|
||||||
|
if (
|
||||||
|
event.type === 'server-state' ||
|
||||||
|
event.type === 'deployment-opened' ||
|
||||||
|
event.type === 'server-changed' ||
|
||||||
|
event.type === 'sweep-tick'
|
||||||
|
) {
|
||||||
|
send(controller, event);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
controller.enqueue(encoder.encode(': hello\n\n'));
|
||||||
|
heartbeat = setInterval(() => controller.enqueue(encoder.encode(': ping\n\n')), HEARTBEAT_MS);
|
||||||
|
},
|
||||||
|
cancel() {
|
||||||
|
if (heartbeat) clearInterval(heartbeat);
|
||||||
|
if (unsubscribe) unsubscribe();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(stream, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/event-stream',
|
||||||
|
'Cache-Control': 'no-cache, no-transform',
|
||||||
|
Connection: 'keep-alive'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { subscribe } from '$lib/server/pipeline/bus.js';
|
||||||
|
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const HEARTBEAT_MS = 20_000;
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
let heartbeat: NodeJS.Timeout | null = null;
|
||||||
|
let unsubscribe: (() => void) | null = null;
|
||||||
|
|
||||||
|
const stream = new ReadableStream<Uint8Array>({
|
||||||
|
start(controller) {
|
||||||
|
unsubscribe = subscribe((event) => {
|
||||||
|
if (event.type === 'server-status') {
|
||||||
|
controller.enqueue(encoder.encode(`event: server-status\ndata: ${JSON.stringify(event)}\n\n`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
controller.enqueue(encoder.encode(': hello\n\n'));
|
||||||
|
heartbeat = setInterval(() => controller.enqueue(encoder.encode(': ping\n\n')), HEARTBEAT_MS);
|
||||||
|
},
|
||||||
|
cancel() {
|
||||||
|
if (heartbeat) clearInterval(heartbeat);
|
||||||
|
if (unsubscribe) unsubscribe();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(stream, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/event-stream',
|
||||||
|
'Cache-Control': 'no-cache, no-transform',
|
||||||
|
Connection: 'keep-alive'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import { doPush } from '$lib/server/fleet/push.js';
|
||||||
|
import { kick } from '$lib/server/pipeline/worker.js';
|
||||||
|
|
||||||
|
export async function POST({ request }: { request: Request }) {
|
||||||
|
const body = (await request.json().catch(() => ({}))) as {
|
||||||
|
patterns?: string[];
|
||||||
|
dryRun?: boolean;
|
||||||
|
delete?: boolean;
|
||||||
|
create?: boolean;
|
||||||
|
};
|
||||||
|
const result = await doPush({
|
||||||
|
patterns: body.patterns ?? ['*'],
|
||||||
|
dryRun: body.dryRun ?? false,
|
||||||
|
delete: body.delete ?? false,
|
||||||
|
create: body.create ?? false,
|
||||||
|
triggeredBy: 'ui'
|
||||||
|
});
|
||||||
|
if (result.deploymentId) void kick();
|
||||||
|
return json(result);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import { listServers, heldByBeta } from '$lib/server/fleet/servers.js';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const servers = listServers().map((s) => ({
|
||||||
|
...s,
|
||||||
|
held: [...heldByBeta(s.name).entries()].map(([target, slug]) => ({ target, slug }))
|
||||||
|
}));
|
||||||
|
return json({ servers });
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import BetaPanel from '$ui/components/BetaPanel.svelte';
|
||||||
|
|
||||||
|
let staged = $state<Array<{ base: string; hits: string[] }>>([]);
|
||||||
|
let betas = $state<Array<{
|
||||||
|
slug: string; name: string; opened_at: string; updated_at: string; revision: number;
|
||||||
|
servers: string[]; scripts: Array<{ staged_as: string; target: string; revision: number }>
|
||||||
|
}>>([]);
|
||||||
|
let loading = $state(true);
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
const res = await fetch('/api/betas');
|
||||||
|
const body = (await res.json()) as { staged: typeof staged; betas: typeof betas };
|
||||||
|
staged = body.staged;
|
||||||
|
betas = body.betas;
|
||||||
|
loading = false;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<h1>Betas</h1>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<p class="empty">loading…</p>
|
||||||
|
{:else}
|
||||||
|
<BetaPanel {staged} {betas} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
h1 { color: #ff6060; margin: 0 0 20px; }
|
||||||
|
.empty { color: #666; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
|
interface ServerRow {
|
||||||
|
server: string;
|
||||||
|
restart_state: string;
|
||||||
|
files_changed: number;
|
||||||
|
players: number | null;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
interface Deployment {
|
||||||
|
id: string;
|
||||||
|
action: string;
|
||||||
|
started_at: string;
|
||||||
|
finished_at: string | null;
|
||||||
|
triggered_by: string | null;
|
||||||
|
servers: ServerRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
let deployments = $state<Deployment[]>([]);
|
||||||
|
let loading = $state(true);
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
const res = await fetch('/api/deployments');
|
||||||
|
const body = (await res.json()) as { deployments: Deployment[] };
|
||||||
|
deployments = body.deployments;
|
||||||
|
loading = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
function isActive(d: Deployment): boolean {
|
||||||
|
return d.servers.some((s) => s.restart_state === 'pending' || s.restart_state === 'failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
function chipColor(state: string): { bg: string; fg: string } {
|
||||||
|
switch (state) {
|
||||||
|
case 'done': return { bg: '#2d4a2d', fg: '#a8e6a8' };
|
||||||
|
case 'failed': return { bg: '#4a2d2d', fg: '#ff8080' };
|
||||||
|
case 'pending': return { bg: '#3a2f00', fg: '#ffcc55' };
|
||||||
|
case 'manual': return { bg: '#333', fg: '#aaa' };
|
||||||
|
default: return { bg: '#222', fg: '#888' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ago(iso: string): string {
|
||||||
|
const d = Date.now() - new Date(iso).getTime();
|
||||||
|
if (d < 60_000) return 'just now';
|
||||||
|
if (d < 3_600_000) return `${Math.floor(d / 60_000)}m ago`;
|
||||||
|
if (d < 86_400_000) return `${Math.floor(d / 3_600_000)}h ago`;
|
||||||
|
return `${Math.floor(d / 86_400_000)}d ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let active = $derived(deployments.filter(isActive));
|
||||||
|
let history = $derived(deployments.filter((d) => !isActive(d)));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<h1>Deployments</h1>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<p class="empty">loading…</p>
|
||||||
|
{:else}
|
||||||
|
<section>
|
||||||
|
<h2>Active</h2>
|
||||||
|
{#if active.length === 0}
|
||||||
|
<p class="empty">nothing active</p>
|
||||||
|
{:else}
|
||||||
|
{#each active as d (d.id)}
|
||||||
|
{@render deployRow(d)}
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>History (7 days)</h2>
|
||||||
|
{#if history.length === 0}
|
||||||
|
<p class="empty">no completed deployments in the retention window</p>
|
||||||
|
{:else}
|
||||||
|
{#each history as d (d.id)}
|
||||||
|
{@render deployRow(d)}
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#snippet deployRow(d: Deployment)}
|
||||||
|
<a class="row" href="/deployments/{d.id}">
|
||||||
|
<div class="head">
|
||||||
|
<span class="action">{d.action}</span>
|
||||||
|
<span class="when">{ago(d.started_at)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="chips">
|
||||||
|
{#each d.servers as s (s.server)}
|
||||||
|
{@const c = chipColor(s.restart_state)}
|
||||||
|
<span class="chip" style="background:{c.bg};color:{c.fg}" title={s.error ?? ''}>
|
||||||
|
{s.server}{s.players !== null && s.players > 0 ? ` (${s.players}p)` : ''}
|
||||||
|
</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<div class="id">{d.id}</div>
|
||||||
|
</a>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
h1 { color: #ff6060; margin: 0 0 20px; }
|
||||||
|
h2 {
|
||||||
|
margin: 30px 0 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
.row {
|
||||||
|
display: block;
|
||||||
|
background: #141414;
|
||||||
|
border: 1px solid #333;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 12px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: #ddd;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.row:hover { border-color: #555; }
|
||||||
|
.head { display: flex; justify-content: space-between; margin-bottom: 8px; }
|
||||||
|
.action { font-family: ui-monospace, monospace; font-size: 13px; }
|
||||||
|
.when { color: #666; font-size: 12px; }
|
||||||
|
.chips { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 6px; }
|
||||||
|
.chip {
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: ui-monospace, monospace;
|
||||||
|
}
|
||||||
|
.id { color: #444; font-family: ui-monospace, monospace; font-size: 11px; }
|
||||||
|
.empty { color: #666; font-size: 13px; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import DiffViewer from '$ui/components/DiffViewer.svelte';
|
||||||
|
|
||||||
|
interface FileRow {
|
||||||
|
id: number;
|
||||||
|
server: string;
|
||||||
|
verb: string;
|
||||||
|
rel_path: string;
|
||||||
|
sha256_before: string | null;
|
||||||
|
sha256_after: string | null;
|
||||||
|
size_before: number | null;
|
||||||
|
size_after: number | null;
|
||||||
|
}
|
||||||
|
interface ServerRow {
|
||||||
|
deployment_id: string;
|
||||||
|
server: string;
|
||||||
|
files_changed: number;
|
||||||
|
restart_state: string;
|
||||||
|
players: number | null;
|
||||||
|
checked_at: string | null;
|
||||||
|
restarted_at: string | null;
|
||||||
|
attempts: number;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
interface Detail {
|
||||||
|
deployment: {
|
||||||
|
id: string;
|
||||||
|
action: string;
|
||||||
|
started_at: string;
|
||||||
|
finished_at: string | null;
|
||||||
|
triggered_by: string | null;
|
||||||
|
};
|
||||||
|
servers: ServerRow[];
|
||||||
|
files: FileRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
let detail = $state<Detail | null>(null);
|
||||||
|
let openFile = $state<{ id: number; before: string | null; after: string | null; verb: string; relPath: string } | null>(null);
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
const id = $page.params.id;
|
||||||
|
const res = await fetch(`/api/deployments/${id}`);
|
||||||
|
detail = (await res.json()) as Detail;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadDiff(f: FileRow) {
|
||||||
|
const res = await fetch(`/api/deployments/${$page.params.id}/file/${f.id}`);
|
||||||
|
const body = (await res.json()) as { before: string | null; after: string | null; verb: string; relPath: string };
|
||||||
|
openFile = { id: f.id, ...body };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group files by server for the layout
|
||||||
|
let byServer = $derived(() => {
|
||||||
|
if (!detail) return new Map<string, FileRow[]>();
|
||||||
|
const m = new Map<string, FileRow[]>();
|
||||||
|
for (const f of detail.files) {
|
||||||
|
if (!m.has(f.server)) m.set(f.server, []);
|
||||||
|
m.get(f.server)!.push(f);
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if detail}
|
||||||
|
<h1>{detail.deployment.action}</h1>
|
||||||
|
<p class="meta">
|
||||||
|
started {detail.deployment.started_at}
|
||||||
|
{#if detail.deployment.finished_at} · finished {detail.deployment.finished_at}{/if}
|
||||||
|
· <code>{detail.deployment.id}</code>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Servers</h2>
|
||||||
|
<div class="server-grid">
|
||||||
|
{#each detail.servers as s (s.server)}
|
||||||
|
<div class="srv srv-{s.restart_state}">
|
||||||
|
<div class="name">{s.server}</div>
|
||||||
|
<div class="line">state: {s.restart_state}</div>
|
||||||
|
{#if s.players !== null}<div class="line">players: {s.players}</div>{/if}
|
||||||
|
{#if s.error}<div class="line err">{s.error}</div>{/if}
|
||||||
|
{#if s.restarted_at}<div class="line dim">restarted {s.restarted_at}</div>{/if}
|
||||||
|
<div class="line dim">attempts: {s.attempts}</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Files</h2>
|
||||||
|
{#each [...byServer().entries()] as [srv, files] (srv)}
|
||||||
|
<div class="file-group">
|
||||||
|
<h3>{srv}</h3>
|
||||||
|
{#each files as f (f.id)}
|
||||||
|
<button class="file-row" onclick={() => loadDiff(f)}>
|
||||||
|
<span class="verb verb-{f.verb}">{f.verb}</span>
|
||||||
|
<code>{f.rel_path}</code>
|
||||||
|
<span class="sizes">
|
||||||
|
{#if f.size_before !== null}{f.size_before}b{/if}
|
||||||
|
{#if f.size_before !== null && f.size_after !== null} → {/if}
|
||||||
|
{#if f.size_after !== null}{f.size_after}b{/if}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
{#if openFile}
|
||||||
|
<div class="diff-scrim" onclick={() => (openFile = null)} onkeydown={(e) => { if (e.key === 'Escape') openFile = null; }} role="button" tabindex="-1">
|
||||||
|
<div class="diff-modal" onclick={(e) => e.stopPropagation()} role="none">
|
||||||
|
<button class="close" onclick={() => (openFile = null)}>close</button>
|
||||||
|
<DiffViewer before={openFile.before} after={openFile.after} verb={openFile.verb} relPath={openFile.relPath} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<p class="empty">loading…</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
h1 { color: #ff6060; margin: 0 0 4px; font-family: ui-monospace, monospace; font-size: 18px; }
|
||||||
|
h2 {
|
||||||
|
margin: 24px 0 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
h3 { margin: 12px 0 4px; font-size: 13px; color: #ddd; font-family: ui-monospace, monospace; }
|
||||||
|
.meta { color: #666; font-size: 12px; margin: 0 0 20px; font-family: ui-monospace, monospace; }
|
||||||
|
.server-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||||
|
}
|
||||||
|
.srv {
|
||||||
|
background: #141414;
|
||||||
|
border: 1px solid #333;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
.srv-done { border-color: #2d4a2d; }
|
||||||
|
.srv-failed { border-color: #4a2d2d; }
|
||||||
|
.srv-pending { border-color: #3a2f00; }
|
||||||
|
.name { font-weight: 600; margin-bottom: 4px; }
|
||||||
|
.line { font-size: 12px; color: #bbb; }
|
||||||
|
.line.dim { color: #666; }
|
||||||
|
.line.err { color: #ff6060; }
|
||||||
|
.file-group { margin-bottom: 12px; }
|
||||||
|
.file-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
background: #141414;
|
||||||
|
border: 1px solid #2a2a2a;
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
margin: 2px 0;
|
||||||
|
color: #ddd;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.file-row:hover { border-color: #444; }
|
||||||
|
.verb {
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: ui-monospace, monospace;
|
||||||
|
}
|
||||||
|
.verb-added { background: #2d4a2d; color: #a8e6a8; }
|
||||||
|
.verb-updated { background: #2d3a4a; color: #a8c8e6; }
|
||||||
|
.verb-removed { background: #4a2d2d; color: #ff8080; }
|
||||||
|
.verb-held { background: #3a2f00; color: #ffcc55; }
|
||||||
|
.sizes { margin-left: auto; color: #666; font-size: 11px; font-family: ui-monospace, monospace; }
|
||||||
|
code { font-family: ui-monospace, monospace; color: #ddd; }
|
||||||
|
.empty { color: #666; }
|
||||||
|
.diff-scrim {
|
||||||
|
position: fixed; inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.75);
|
||||||
|
z-index: 100;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
.diff-modal {
|
||||||
|
max-width: 1000px;
|
||||||
|
width: 92%;
|
||||||
|
max-height: 92vh;
|
||||||
|
overflow: auto;
|
||||||
|
background: #0a0a0a;
|
||||||
|
border: 1px solid #444;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
.close {
|
||||||
|
float: right;
|
||||||
|
background: #222;
|
||||||
|
color: #ddd;
|
||||||
|
border: 1px solid #444;
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { statusByServer } from '$ui/stores/servers.js';
|
||||||
|
|
||||||
|
interface Server {
|
||||||
|
name: string;
|
||||||
|
komodo_type: string;
|
||||||
|
komodo_name: string;
|
||||||
|
komodo_server: string | null;
|
||||||
|
iw4m_match: string | null;
|
||||||
|
iw4m_value: string | null;
|
||||||
|
last_players: number | null;
|
||||||
|
last_checked_at: string | null;
|
||||||
|
held: Array<{ target: string; slug: string }>;
|
||||||
|
}
|
||||||
|
interface DeploymentRow {
|
||||||
|
id: string;
|
||||||
|
action: string;
|
||||||
|
started_at: string;
|
||||||
|
finished_at: string | null;
|
||||||
|
servers: Array<{ server: string; restart_state: string; players: number | null; error: string | null; files_changed: number }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
let server = $state<Server | null>(null);
|
||||||
|
let history = $state<DeploymentRow[]>([]);
|
||||||
|
let name = $derived($page.params.name ?? '');
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
const [srvRes, depRes] = await Promise.all([fetch('/api/servers'), fetch('/api/deployments')]);
|
||||||
|
const srvBody = (await srvRes.json()) as { servers: Server[] };
|
||||||
|
const depBody = (await depRes.json()) as { deployments: DeploymentRow[] };
|
||||||
|
server = srvBody.servers.find((s) => s.name === name) ?? null;
|
||||||
|
history = depBody.deployments.filter((d) => d.servers.some((s) => s.server === name));
|
||||||
|
});
|
||||||
|
|
||||||
|
let live = $derived($statusByServer.get(name)?.players);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if server}
|
||||||
|
<h1>{server.name}</h1>
|
||||||
|
<div class="stats">
|
||||||
|
<div class="stat">
|
||||||
|
<div class="k">players</div>
|
||||||
|
<div class="v">{live ?? server.last_players ?? '—'}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="k">komodo</div>
|
||||||
|
<div class="v">{server.komodo_type} · {server.komodo_name}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="k">iw4m</div>
|
||||||
|
<div class="v">{server.iw4m_match ?? '—'} · {server.iw4m_value ?? '—'}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="k">held by beta</div>
|
||||||
|
<div class="v">{server.held.length === 0 ? '—' : server.held.map((h) => h.target).join(', ')}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Deployment history</h2>
|
||||||
|
{#if history.length === 0}
|
||||||
|
<p class="empty">no deployments recorded on this server</p>
|
||||||
|
{:else}
|
||||||
|
{#each history as d (d.id)}
|
||||||
|
{@const mine = d.servers.find((s) => s.server === name)!}
|
||||||
|
<a class="row row-{mine.restart_state}" href="/deployments/{d.id}">
|
||||||
|
<span class="a">{d.action}</span>
|
||||||
|
<span class="s">{mine.restart_state}{mine.players !== null && mine.players > 0 ? ` (${mine.players}p)` : ''}</span>
|
||||||
|
<span class="f">{mine.files_changed} file{mine.files_changed === 1 ? '' : 's'}</span>
|
||||||
|
<span class="w">{d.started_at}</span>
|
||||||
|
{#if mine.error}<span class="err">{mine.error}</span>{/if}
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<p class="empty">loading…</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
h1 { color: #ff6060; margin: 0 0 20px; }
|
||||||
|
h2 {
|
||||||
|
margin: 30px 0 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
.stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.stat {
|
||||||
|
background: #141414;
|
||||||
|
border: 1px solid #333;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
.k { color: #666; font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||||
|
.v { color: #ddd; font-family: ui-monospace, monospace; margin-top: 4px; }
|
||||||
|
.row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 200px 160px 100px 220px 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: #141414;
|
||||||
|
border: 1px solid #2a2a2a;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin: 4px 0;
|
||||||
|
color: #ddd;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: ui-monospace, monospace;
|
||||||
|
}
|
||||||
|
.row-done { border-left: 3px solid #2d4a2d; }
|
||||||
|
.row-failed { border-left: 3px solid #4a2d2d; }
|
||||||
|
.row-pending { border-left: 3px solid #3a2f00; }
|
||||||
|
.a { color: #ddd; }
|
||||||
|
.s { color: #aaa; }
|
||||||
|
.f { color: #888; }
|
||||||
|
.w { color: #666; }
|
||||||
|
.err { color: #ff6060; }
|
||||||
|
.empty { color: #666; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import adapter from '@sveltejs/adapter-node';
|
||||||
|
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||||
|
|
||||||
|
/** @type {import('@sveltejs/kit').Config} */
|
||||||
|
const config = {
|
||||||
|
preprocess: vitePreprocess(),
|
||||||
|
kit: {
|
||||||
|
adapter: adapter({ out: 'build' }),
|
||||||
|
alias: {
|
||||||
|
$ui: 'src/lib/ui',
|
||||||
|
'$ui/*': 'src/lib/ui/*'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
export interface Fixture {
|
||||||
|
root: string;
|
||||||
|
shared: string;
|
||||||
|
staging: string;
|
||||||
|
t5: string;
|
||||||
|
dbPath: string;
|
||||||
|
cleanup: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a zombies/ tree in a temp dir and point env at it BEFORE the code
|
||||||
|
* under test is imported. Returns paths + a cleanup fn.
|
||||||
|
*/
|
||||||
|
export async function newFixture(files: Record<string, string> = {}, servers: string[] = ['t5_kino_1']): Promise<Fixture> {
|
||||||
|
const root = await mkdtemp(join(tmpdir(), 'cherry-'));
|
||||||
|
const shared = join(root, 'shared/scripts');
|
||||||
|
const staging = join(shared, 'betas');
|
||||||
|
const t5 = join(root, 't5');
|
||||||
|
const dbPath = join(root, 'cherry.db');
|
||||||
|
await mkdir(staging, { recursive: true });
|
||||||
|
await mkdir(t5, { recursive: true });
|
||||||
|
|
||||||
|
for (const [rel, body] of Object.entries(files)) {
|
||||||
|
const p = join(shared, rel);
|
||||||
|
await mkdir(join(p, '..'), { recursive: true });
|
||||||
|
await writeFile(p, body);
|
||||||
|
}
|
||||||
|
for (const s of servers) {
|
||||||
|
await mkdir(join(t5, s, 'runtime/plutonium/storage/t5/raw/scripts/sp'), { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
process.env.SHARED_SCRIPTS = shared;
|
||||||
|
process.env.SERVERS_ROOT = t5;
|
||||||
|
process.env.REL_DEST = 'runtime/plutonium/storage/t5/raw/scripts/sp';
|
||||||
|
process.env.DB_PATH = dbPath;
|
||||||
|
process.env.DEPLOY_KEEP_DAYS = '7';
|
||||||
|
process.env.HTTP_TIMEOUT_MS = '2000';
|
||||||
|
|
||||||
|
return {
|
||||||
|
root,
|
||||||
|
shared,
|
||||||
|
staging,
|
||||||
|
t5,
|
||||||
|
dbPath,
|
||||||
|
cleanup: async () => {
|
||||||
|
// On Windows the SQLite handle keeps a lock on cherry.db-shm/wal
|
||||||
|
// and rmdir refuses to remove the temp tree. Close the singleton
|
||||||
|
// (if this test imported it) before removing the dir. Fresh
|
||||||
|
// dynamic import so we grab whatever handle the test opened.
|
||||||
|
try {
|
||||||
|
const mod = (await import('../../src/lib/server/db/index.js')) as {
|
||||||
|
closeDb: () => void;
|
||||||
|
};
|
||||||
|
mod.closeDb();
|
||||||
|
} catch {
|
||||||
|
/* db module was never loaded — nothing to close */
|
||||||
|
}
|
||||||
|
await rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fresh module-level state. Vitest caches modules per file — this forces a
|
||||||
|
* re-import so the tests can rebuild the singleton DB handle against a new
|
||||||
|
* fixture.
|
||||||
|
*/
|
||||||
|
export async function importFresh<T>(mod: string): Promise<T> {
|
||||||
|
return (await import(`${mod}?t=${Date.now()}${Math.random()}`)) as T;
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { readFile, writeFile } from 'node:fs/promises';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
||||||
|
|
||||||
|
let fixture: Fixture;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.resetModules();
|
||||||
|
fixture = await newFixture(
|
||||||
|
{ 'hud.gsc': 'v1', 'zom.gsc': 'v1' },
|
||||||
|
['t5_kino_1', 't5_ascension_1']
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await fixture.cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('beta lifecycle', () => {
|
||||||
|
it('open → rollback restores original state', async () => {
|
||||||
|
const { doPush } = await import('../../src/lib/server/fleet/push.js');
|
||||||
|
const { openBeta, rollbackBeta } = await import('../../src/lib/server/fleet/beta.js');
|
||||||
|
const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js');
|
||||||
|
await seedServerRegistry();
|
||||||
|
|
||||||
|
// Populate servers with v1 first
|
||||||
|
await doPush({ patterns: ['*'] });
|
||||||
|
|
||||||
|
// Stage a new hud
|
||||||
|
await writeFile(join(fixture.staging, 'hud.gsc'), 'v2');
|
||||||
|
|
||||||
|
// Open beta on t5_kino_1 only
|
||||||
|
const opened = await openBeta({ name: 'test1', patterns: ['t5_kino_1'] });
|
||||||
|
expect(opened.slug).toBe('test1');
|
||||||
|
expect(opened.servers).toEqual(['t5_kino_1']);
|
||||||
|
|
||||||
|
// On kino: hud-beta.gsc = v2, hud.OLD = v1, hud.gsc gone
|
||||||
|
const kinoSp = join(fixture.t5, 't5_kino_1', 'runtime/plutonium/storage/t5/raw/scripts/sp');
|
||||||
|
expect(await readFile(join(kinoSp, 'hud-beta.gsc'), 'utf8')).toBe('v2');
|
||||||
|
expect(await readFile(join(kinoSp, 'hud.OLD'), 'utf8')).toBe('v1');
|
||||||
|
expect(existsSync(join(kinoSp, 'hud.gsc'))).toBe(false);
|
||||||
|
|
||||||
|
// ascension untouched
|
||||||
|
const ascSp = join(fixture.t5, 't5_ascension_1', 'runtime/plutonium/storage/t5/raw/scripts/sp');
|
||||||
|
expect(await readFile(join(ascSp, 'hud.gsc'), 'utf8')).toBe('v1');
|
||||||
|
|
||||||
|
// Rollback
|
||||||
|
const back = await rollbackBeta('test1');
|
||||||
|
expect(back.restored).toContain('hud.gsc');
|
||||||
|
expect(await readFile(join(kinoSp, 'hud.gsc'), 'utf8')).toBe('v1');
|
||||||
|
expect(existsSync(join(kinoSp, 'hud-beta.gsc'))).toBe(false);
|
||||||
|
expect(existsSync(join(kinoSp, 'hud.OLD'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('open → promote lands the beta on shared/scripts and clears beta files', async () => {
|
||||||
|
const { doPush } = await import('../../src/lib/server/fleet/push.js');
|
||||||
|
const { openBeta, promoteBeta } = await import('../../src/lib/server/fleet/beta.js');
|
||||||
|
const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js');
|
||||||
|
await seedServerRegistry();
|
||||||
|
|
||||||
|
await doPush({ patterns: ['*'] });
|
||||||
|
await writeFile(join(fixture.staging, 'hud.gsc'), 'v2');
|
||||||
|
await openBeta({ name: 'promote-me', patterns: ['t5_kino_1'] });
|
||||||
|
|
||||||
|
const kinoSp = join(fixture.t5, 't5_kino_1', 'runtime/plutonium/storage/t5/raw/scripts/sp');
|
||||||
|
expect(existsSync(join(kinoSp, 'hud-beta.gsc'))).toBe(true);
|
||||||
|
|
||||||
|
await promoteBeta('promote-me');
|
||||||
|
|
||||||
|
// shared has v2 now
|
||||||
|
expect(await readFile(join(fixture.shared, 'hud.gsc'), 'utf8')).toBe('v2');
|
||||||
|
// kino has plain hud.gsc = v2, no OLD, no -beta
|
||||||
|
expect(await readFile(join(kinoSp, 'hud.gsc'), 'utf8')).toBe('v2');
|
||||||
|
expect(existsSync(join(kinoSp, 'hud-beta.gsc'))).toBe(false);
|
||||||
|
expect(existsSync(join(kinoSp, 'hud.OLD'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses to open a beta whose staged file matches an already-owned target', async () => {
|
||||||
|
const { doPush } = await import('../../src/lib/server/fleet/push.js');
|
||||||
|
const { openBeta, BetaError } = await import('../../src/lib/server/fleet/beta.js');
|
||||||
|
const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js');
|
||||||
|
await seedServerRegistry();
|
||||||
|
await doPush({ patterns: ['*'] });
|
||||||
|
|
||||||
|
// First beta claims hud on kino
|
||||||
|
await writeFile(join(fixture.staging, 'hud.gsc'), 'v2');
|
||||||
|
await openBeta({ name: 'first', patterns: ['t5_kino_1'] });
|
||||||
|
|
||||||
|
// Second attempt on kino should conflict
|
||||||
|
await writeFile(join(fixture.staging, 'hud.gsc'), 'v3');
|
||||||
|
await expect(openBeta({ name: 'second', patterns: ['t5_kino_1'] })).rejects.toThrow(BetaError);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { betaFile, oldFile, slugify } from '../../src/lib/server/fleet/files.js';
|
||||||
|
|
||||||
|
describe('slugify', () => {
|
||||||
|
it('matches the shell script behavior', () => {
|
||||||
|
expect(slugify('New HUD Test')).toBe('new-hud-test');
|
||||||
|
expect(slugify(' --Weird!!! ')).toBe('weird');
|
||||||
|
expect(slugify('t5_kino_1')).toBe('t5-kino-1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('betaFile', () => {
|
||||||
|
it('inserts -beta before the extension', () => {
|
||||||
|
expect(betaFile('hud.gsc')).toBe('hud-beta.gsc');
|
||||||
|
expect(betaFile('perks/zom.gsc')).toBe('perks/zom-beta.gsc');
|
||||||
|
});
|
||||||
|
it('handles files without extension', () => {
|
||||||
|
expect(betaFile('README')).toBe('README-beta');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('oldFile', () => {
|
||||||
|
it('replaces extension with .OLD', () => {
|
||||||
|
expect(oldFile('hud.gsc')).toBe('hud.OLD');
|
||||||
|
expect(oldFile('perks/zom.gsc')).toBe('perks/zom.OLD');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
||||||
|
|
||||||
|
let fixture: Fixture;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.resetModules();
|
||||||
|
fixture = await newFixture();
|
||||||
|
process.env.IW4M_ADDRESS = 'http://iw4m.test';
|
||||||
|
process.env.IW4M_CLIENT_ID = '1';
|
||||||
|
vi.stubGlobal('fetch', vi.fn());
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await fixture.cleanup();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('loginWithToken', () => {
|
||||||
|
it('returns cookie + expiry on 2xx', async () => {
|
||||||
|
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
|
||||||
|
new Response('', {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Set-Cookie': '.AspNetCore.Cookies=abc; path=/; httponly' }
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const { loginWithToken } = await import('../../src/lib/server/integrations/iw4m.js');
|
||||||
|
const res = await loginWithToken('abcd');
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
if (res.ok) {
|
||||||
|
expect(res.cookie).toContain('.AspNetCore.Cookies=abc');
|
||||||
|
expect(res.expiresAt).toBeGreaterThan(Date.now());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns rejected on 401', async () => {
|
||||||
|
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce(new Response('', { status: 401 }));
|
||||||
|
const { loginWithToken } = await import('../../src/lib/server/integrations/iw4m.js');
|
||||||
|
const res = await loginWithToken('abcd');
|
||||||
|
expect(res.ok).toBe(false);
|
||||||
|
if (!res.ok) expect(res.reason).toBe('rejected');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns unreachable on network error', async () => {
|
||||||
|
(globalThis.fetch as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error('ECONNREFUSED'));
|
||||||
|
const { loginWithToken } = await import('../../src/lib/server/integrations/iw4m.js');
|
||||||
|
const res = await loginWithToken('abcd');
|
||||||
|
expect(res.ok).toBe(false);
|
||||||
|
if (!res.ok) expect(res.reason).toBe('unreachable');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fetchStatus', () => {
|
||||||
|
it('normalizes camelCase / PascalCase entries', async () => {
|
||||||
|
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
|
||||||
|
new Response(
|
||||||
|
JSON.stringify([
|
||||||
|
{ id: 1, port: 3072, name: 'kino der toten', currentPlayers: 3 },
|
||||||
|
{ Id: 2, Port: 3073, Name: 'ascension', CurrentPlayers: 0 }
|
||||||
|
]),
|
||||||
|
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const { fetchStatus } = await import('../../src/lib/server/integrations/iw4m.js');
|
||||||
|
const res = await fetchStatus();
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
if (res.ok) {
|
||||||
|
expect(res.entries[0].currentPlayers).toBe(3);
|
||||||
|
expect(res.entries[1].name).toBe('ascension');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
||||||
|
|
||||||
|
let fixture: Fixture;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.resetModules();
|
||||||
|
fixture = await newFixture(
|
||||||
|
{
|
||||||
|
'hud.gsc': 'v1',
|
||||||
|
'zom.gsc': 'v1',
|
||||||
|
'perks/perk1.gsc': 'p1'
|
||||||
|
},
|
||||||
|
['t5_kino_1', 't5_ascension_1']
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await fixture.cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('doPush', () => {
|
||||||
|
it('adds every source file on a fresh server', async () => {
|
||||||
|
const { doPush } = await import('../../src/lib/server/fleet/push.js');
|
||||||
|
const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js');
|
||||||
|
await seedServerRegistry();
|
||||||
|
|
||||||
|
const res = await doPush({ patterns: ['*'], dryRun: false, create: false });
|
||||||
|
expect(res.totalChangedServers).toBe(2);
|
||||||
|
expect(res.totalFiles).toBe(3 * 2);
|
||||||
|
expect(res.deploymentId).toBeTruthy();
|
||||||
|
|
||||||
|
const written = await readFile(join(fixture.t5, 't5_kino_1', 'runtime/plutonium/storage/t5/raw/scripts/sp/hud.gsc'), 'utf8');
|
||||||
|
expect(written).toBe('v1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dry-run makes no changes and creates no deployment row', async () => {
|
||||||
|
const { doPush } = await import('../../src/lib/server/fleet/push.js');
|
||||||
|
const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js');
|
||||||
|
await seedServerRegistry();
|
||||||
|
|
||||||
|
const res = await doPush({ patterns: ['*'], dryRun: true, create: false });
|
||||||
|
expect(res.deploymentId).toBeNull();
|
||||||
|
expect(res.totalChangedServers).toBe(2);
|
||||||
|
|
||||||
|
// no file created
|
||||||
|
await expect(readFile(join(fixture.t5, 't5_kino_1', 'runtime/plutonium/storage/t5/raw/scripts/sp/hud.gsc'))).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips servers that are already up to date', async () => {
|
||||||
|
const { doPush } = await import('../../src/lib/server/fleet/push.js');
|
||||||
|
const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js');
|
||||||
|
await seedServerRegistry();
|
||||||
|
await doPush({ patterns: ['*'], dryRun: false });
|
||||||
|
|
||||||
|
// second run: everything matches → no deployment
|
||||||
|
const res = await doPush({ patterns: ['*'], dryRun: false });
|
||||||
|
expect(res.deploymentId).toBeNull();
|
||||||
|
expect(res.totalChangedServers).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
||||||
|
|
||||||
|
let fixture: Fixture;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.resetModules();
|
||||||
|
fixture = await newFixture({ 'hud.gsc': 'v1' }, ['t5_kino_1', 't5_ascension_1']);
|
||||||
|
// No integrations → sweep marks rows manual, exercising that branch.
|
||||||
|
delete process.env.KOMODO_ADDRESS;
|
||||||
|
delete process.env.IW4M_ADDRESS;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await fixture.cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('sweep', () => {
|
||||||
|
it('marks all pending rows manual when integrations are unset', async () => {
|
||||||
|
const { doPush } = await import('../../src/lib/server/fleet/push.js');
|
||||||
|
const { sweep } = await import('../../src/lib/server/pipeline/sweep.js');
|
||||||
|
const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js');
|
||||||
|
const { setAuthState } = await import('../../src/lib/server/integrations/auth-state.js');
|
||||||
|
const { db } = await import('../../src/lib/server/db/index.js');
|
||||||
|
await seedServerRegistry();
|
||||||
|
|
||||||
|
// Force ready so pipelineReady() lets sweep run (even though
|
||||||
|
// integrations are off — that's the branch we want to test).
|
||||||
|
setAuthState({ kind: 'ready', token: 'abcd', cookie: 'x=y', expiresAt: Date.now() + 100_000 });
|
||||||
|
|
||||||
|
const push = await doPush({ patterns: ['*'] });
|
||||||
|
expect(push.deploymentId).not.toBeNull();
|
||||||
|
|
||||||
|
const result = await sweep();
|
||||||
|
expect(result.frozen).toBe(false);
|
||||||
|
|
||||||
|
const rows = db()
|
||||||
|
.prepare("SELECT restart_state FROM deployment_servers WHERE deployment_id = ?")
|
||||||
|
.all(push.deploymentId!) as Array<{ restart_state: string }>;
|
||||||
|
for (const r of rows) expect(r.restart_state).toBe('manual');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('freezes when auth is not ready', async () => {
|
||||||
|
const { doPush } = await import('../../src/lib/server/fleet/push.js');
|
||||||
|
const { sweep } = await import('../../src/lib/server/pipeline/sweep.js');
|
||||||
|
const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js');
|
||||||
|
await seedServerRegistry();
|
||||||
|
|
||||||
|
await doPush({ patterns: ['*'] });
|
||||||
|
const result = await sweep();
|
||||||
|
expect(result.frozen).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"extends": "./.svelte-kit/tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"allowJs": true,
|
||||||
|
"checkJs": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"strict": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { sveltekit } from '@sveltejs/kit/vite';
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [sveltekit()],
|
||||||
|
test: {
|
||||||
|
include: ['tests/**/*.{test,spec}.{js,ts}']
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 3000
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user