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'; import { parseServerName } from './map-aliases.js'; /** * All server folders under SERVERS_ROOT. Used by seedServerRegistry to backfill * the servers table for any on-disk server directory that isn't yet tracked. */ export async function allServers(): Promise { 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). */ 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. Creates it when create=true. */ 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 -- export interface ServerRow { name: string; komodo_type: 'deployment' | 'stack' | 'container'; komodo_name: string; komodo_server: string | null; game_port: number | null; rcon_host: string | null; game: string | null; map: string | null; last_players: number | null; last_checked_at: string | null; last_map: string | null; last_gametype: string | null; last_roster: string | null; last_seen_at: string | null; last_error: string | null; last_reply_snippet: string | null; created_at: string; } export async function seedServerRegistry(): Promise { const rows = await allServers(); const insert = db().prepare( 'INSERT OR IGNORE INTO servers (name, komodo_type, komodo_name, game, map) VALUES (?, ?, ?, ?, ?)' ); // Also backfill existing rows where game/map were never set (upgrade path // from before name-based auto-derivation existed). const backfill = db().prepare( 'UPDATE servers SET game = COALESCE(game, ?), map = COALESCE(map, ?) WHERE name = ? AND (game IS NULL OR map IS NULL)' ); let n = 0; const tx = db().transaction((names: string[]) => { for (const name of names) { const parsed = parseServerName(name); const game = parsed?.game ?? null; const map = parsed?.mapCanonical ?? null; const res = insert.run(name, 'deployment', name.replace(/_/g, '-'), game, map); if (res.changes > 0) { n++; } else { backfill.run(game, map, name); } if (parsed && !parsed.mapCanonical) { console.warn(`[servers] unknown map alias '${parsed.mapAlias}' for ${name} — add to T5_MAP_ALIASES`); } } }); 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 { const allowed: Array = [ 'komodo_type', 'komodo_name', 'komodo_server', 'game_port', 'rcon_host', 'game', 'map', 'last_players', 'last_checked_at', 'last_map', 'last_gametype', 'last_roster', 'last_seen_at', 'last_error', 'last_reply_snippet' ]; if (!allowed.includes(field)) throw new Error(`refusing to set ${field}`); db().prepare(`UPDATE servers SET ${field} = ? WHERE name = ?`).run(value, name); } /** * Which target rel_paths on this server are currently held by a running beta? * Returns Map. A target held by beta X blocks other betas AND * blocks unrelated writes to that same target on that server. */ export function heldByBeta(server: string): Map { const rows = db() .prepare( `SELECT s.target_rel_path AS target, b.id AS beta_id FROM beta_servers bs JOIN betas b ON b.id = bs.beta_id JOIN scripts s ON s.id = b.script_id WHERE bs.server = ? AND b.state = 'running'` ) .all(server) as Array<{ target: string; beta_id: number }>; const map = new Map(); for (const r of rows) map.set(r.target, r.beta_id); return map; } /** * Which running beta (if any) owns a specific target rel_path on a server? */ export function ownerOf(server: string, target: string): number | null { const row = db() .prepare( `SELECT b.id AS beta_id FROM beta_servers bs JOIN betas b ON b.id = bs.beta_id JOIN scripts s ON s.id = b.script_id WHERE bs.server = ? AND s.target_rel_path = ? AND b.state = 'running' LIMIT 1` ) .get(server, target) as { beta_id: number } | undefined; return row?.beta_id ?? null; }