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:
2026-09-12 00:23:51 -04:00
commit d99711563e
67 changed files with 9415 additions and 0 deletions
+455
View File
@@ -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 };
}
+138
View File
@@ -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);
}
+239
View File
@@ -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
};
}
+159
View File
@@ -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;
}