From 8c40bbeff130925d69ec07cf135780b992b82ba3 Mon Sep 17 00:00:00 2001 From: josh Date: Sat, 12 Sep 2026 19:11:44 -0400 Subject: [PATCH] config and plugin --- src/lib/server/config.ts | 25 +- src/lib/server/db/index.ts | 23 +- src/lib/server/db/schema.sql | 18 +- src/lib/server/fleet/beta.ts | 396 ++++++++++++++++++++++--- src/lib/server/fleet/config-compose.ts | 124 ++++++++ src/lib/server/fleet/rollback.ts | 40 ++- src/lib/server/fleet/scripts.ts | 129 +++++++- src/routes/+layout.svelte | 2 + src/routes/api/betas/+server.ts | 23 +- src/routes/api/betas/[id]/+server.ts | 1 + src/routes/api/configs/+server.ts | 25 ++ src/routes/api/plugins/+server.ts | 22 ++ src/routes/api/scripts/+server.ts | 56 +++- src/routes/betas/+page.svelte | 19 +- src/routes/betas/[id]/+page.svelte | 6 +- src/routes/configs/+page.svelte | 373 +++++++++++++++++++++++ src/routes/plugins/+page.svelte | 248 ++++++++++++++++ tests/unit/config-compose.test.ts | 88 ++++++ tests/unit/config-flow.test.ts | 153 ++++++++++ 19 files changed, 1685 insertions(+), 86 deletions(-) create mode 100644 src/lib/server/fleet/config-compose.ts create mode 100644 src/routes/api/configs/+server.ts create mode 100644 src/routes/api/plugins/+server.ts create mode 100644 src/routes/configs/+page.svelte create mode 100644 src/routes/plugins/+page.svelte create mode 100644 tests/unit/config-compose.test.ts create mode 100644 tests/unit/config-flow.test.ts diff --git a/src/lib/server/config.ts b/src/lib/server/config.ts index 70adf01..91def4f 100644 --- a/src/lib/server/config.ts +++ b/src/lib/server/config.ts @@ -17,6 +17,13 @@ function num(name: string, fallback: number): number { export const config = { serversRoot: req('SERVERS_ROOT', '/root/zombies/t5'), relDest: req('REL_DEST', 'runtime/plutonium/storage/t5/raw/scripts/sp'), + // Per-kind destination sub-paths under each server's directory. `relDest` + // above stays as the script fallback so existing deploys keep working. + relDestScript: env.REL_DEST_SCRIPT ?? env.REL_DEST ?? 'runtime/plutonium/storage/t5/raw/scripts/sp', + relDestConfig: env.REL_DEST_CONFIG ?? 'configs', + relDestPlugin: env.REL_DEST_PLUGIN ?? 'runtime/plutonium/plugins', + // Filename the baseline+overlays compose into. Same for every scope today. + configTargetFile: env.CONFIG_TARGET_FILE ?? 'dedicated_sp.cfg', // Where on-disk copies of active beta files live for operator visibility. // Cleared per-beta on promote/rollback. betaArchiveDir: req('BETA_ARCHIVE_DIR', '/root/zombies/beta-archive'), @@ -48,5 +55,21 @@ export function rconConfigured(): boolean { return !!config.rcon.password; } -export const destOf = (server: string) => `${config.serversRoot}/${server}/${config.relDest}`; +export type ArtifactKind = 'script' | 'config' | 'plugin' | 'baseline'; + +export function relDestForKind(kind: ArtifactKind): string { + switch (kind) { + case 'script': return config.relDestScript; + case 'config': return config.relDestConfig; + case 'baseline': return config.relDestConfig; + case 'plugin': return config.relDestPlugin; + } +} + +export const destOfKind = (server: string, kind: ArtifactKind) => + `${config.serversRoot}/${server}/${relDestForKind(kind)}`; + +// Back-compat: existing call sites written before the multi-kind refactor +// resolve to the script destination. +export const destOf = (server: string) => destOfKind(server, 'script'); export const betaArchiveOf = (betaId: number) => `${config.betaArchiveDir}/beta-${betaId}`; diff --git a/src/lib/server/db/index.ts b/src/lib/server/db/index.ts index 0a9dc63..6cb691a 100644 --- a/src/lib/server/db/index.ts +++ b/src/lib/server/db/index.ts @@ -44,6 +44,10 @@ function migrateServers(handle: Database.Database): void { // Additive columns on tables the script model owns. Same pattern as // migrateServers — CREATE TABLE IF NOT EXISTS won't backfill columns onto // pre-existing tables, so we introspect and ALTER as needed. +const SCRIPTS_COLUMNS: Array<{ name: string; ddl: string }> = [ + { name: 'kind', ddl: "ALTER TABLE scripts ADD COLUMN kind TEXT NOT NULL DEFAULT 'script'" } +]; + const SCRIPT_VERSIONS_COLUMNS: Array<{ name: string; ddl: string }> = [ // state was added post-launch to make untested/tested/test-failed visible // in the UI. Existing rows default to 'untested' — this is safe because @@ -51,7 +55,16 @@ const SCRIPT_VERSIONS_COLUMNS: Array<{ name: string; ddl: string }> = [ // the authoritative "promoted" signal), and any version already gated // through a ready-promote beta will be re-marked next time that state // machine fires. - { name: 'state', ddl: "ALTER TABLE script_versions ADD COLUMN state TEXT NOT NULL DEFAULT 'untested'" } + { name: 'state', ddl: "ALTER TABLE script_versions ADD COLUMN state TEXT NOT NULL DEFAULT 'untested'" }, + { name: 'kind', ddl: "ALTER TABLE script_versions ADD COLUMN kind TEXT NOT NULL DEFAULT 'script'" } +]; + +const BETA_SCRIPTS_COLUMNS: Array<{ name: string; ddl: string }> = [ + { name: 'kind', ddl: "ALTER TABLE beta_scripts ADD COLUMN kind TEXT NOT NULL DEFAULT 'script'" } +]; + +const DEPLOYMENT_SCRIPTS_COLUMNS: Array<{ name: string; ddl: string }> = [ + { name: 'kind', ddl: "ALTER TABLE deployment_scripts ADD COLUMN kind TEXT NOT NULL DEFAULT 'script'" } ]; const DEPLOYMENTS_COLUMNS: Array<{ name: string; ddl: string }> = [ @@ -146,8 +159,16 @@ export function db(): Database.Database { dropLegacyBetaTables(_db); _db.exec(schemaSql); migrateServers(_db); + migrateTable(_db, 'scripts', SCRIPTS_COLUMNS); migrateTable(_db, 'script_versions', SCRIPT_VERSIONS_COLUMNS); + migrateTable(_db, 'beta_scripts', BETA_SCRIPTS_COLUMNS); + migrateTable(_db, 'deployment_scripts', DEPLOYMENT_SCRIPTS_COLUMNS); migrateTable(_db, 'deployments', DEPLOYMENTS_COLUMNS); + _db.exec( + `CREATE UNIQUE INDEX IF NOT EXISTS idx_scripts_one_baseline_per_scope + ON scripts(scope_type, scope_value) WHERE kind = 'baseline'` + ); + _db.exec(`CREATE INDEX IF NOT EXISTS idx_scripts_kind ON scripts(kind)`); backfillMultiScriptJoins(_db); return _db; diff --git a/src/lib/server/db/schema.sql b/src/lib/server/db/schema.sql index 2f0a71b..aef4431 100644 --- a/src/lib/server/db/schema.sql +++ b/src/lib/server/db/schema.sql @@ -26,8 +26,14 @@ CREATE TABLE IF NOT EXISTS servers ( created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) ); --- One row per script (a logical thing, e.g. "HUD tweaks"). Name is unique and --- doubles as the slug in URLs. +-- One row per artifact (a logical thing, e.g. "HUD tweaks"). Name is unique and +-- doubles as the slug in URLs. The `kind` column discriminates: +-- 'script' — GSC files deployed to runtime/plutonium/storage/t5/raw/scripts/sp +-- 'plugin' — .dll files deployed to runtime/plutonium/plugins +-- 'config' — cfg overlay (set K "V" lines) layered onto the baseline +-- 'baseline' — full dedicated_sp.cfg; exactly one per scope +-- Table + column names keep the "scripts" prefix for continuity; treat them +-- polymorphically at the app layer. CREATE TABLE IF NOT EXISTS scripts ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, @@ -35,9 +41,14 @@ CREATE TABLE IF NOT EXISTS scripts ( scope_type TEXT, -- NULL | 'game' | 'map' scope_value TEXT, -- NULL | 't5' | 'zombie_theater' current_version_id INTEGER, -- FK → script_versions(id) after first promote + kind TEXT NOT NULL DEFAULT 'script', -- 'script' | 'config' | 'plugin' | 'baseline' created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) ); CREATE INDEX IF NOT EXISTS idx_scripts_scope ON scripts(scope_type, scope_value); +CREATE INDEX IF NOT EXISTS idx_scripts_kind ON scripts(kind); +-- At most one baseline per (scope_type, scope_value). Partial unique index. +CREATE UNIQUE INDEX IF NOT EXISTS idx_scripts_one_baseline_per_scope + ON scripts(scope_type, scope_value) WHERE kind = 'baseline'; CREATE TABLE IF NOT EXISTS script_versions ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -48,6 +59,7 @@ CREATE TABLE IF NOT EXISTS script_versions ( content BLOB NOT NULL, notes TEXT, state TEXT NOT NULL DEFAULT 'untested', -- 'untested' | 'tested' | 'test-failed' + kind TEXT NOT NULL DEFAULT 'script', -- denormalized from scripts.kind for join-free filtering uploaded_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), UNIQUE(script_id, version) ); @@ -105,6 +117,7 @@ CREATE TABLE IF NOT EXISTS beta_scripts ( beta_id INTEGER NOT NULL REFERENCES betas(id) ON DELETE CASCADE, script_id INTEGER NOT NULL REFERENCES scripts(id) ON DELETE CASCADE, script_version_id INTEGER NOT NULL REFERENCES script_versions(id) ON DELETE CASCADE, + kind TEXT NOT NULL DEFAULT 'script', -- denormalized from scripts.kind PRIMARY KEY (beta_id, script_id) ); CREATE INDEX IF NOT EXISTS idx_beta_scripts_beta ON beta_scripts(beta_id); @@ -175,6 +188,7 @@ CREATE TABLE IF NOT EXISTS deployment_scripts ( script_id INTEGER NOT NULL REFERENCES scripts(id) ON DELETE CASCADE, script_version_id INTEGER NOT NULL REFERENCES script_versions(id) ON DELETE CASCADE, prior_version_id INTEGER REFERENCES script_versions(id), + kind TEXT NOT NULL DEFAULT 'script', -- denormalized from scripts.kind PRIMARY KEY (deployment_id, script_id) ); CREATE INDEX IF NOT EXISTS idx_deployment_scripts_dep ON deployment_scripts(deployment_id); diff --git a/src/lib/server/fleet/beta.ts b/src/lib/server/fleet/beta.ts index 858f955..01ad132 100644 --- a/src/lib/server/fleet/beta.ts +++ b/src/lib/server/fleet/beta.ts @@ -1,22 +1,121 @@ import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'; import { existsSync } from 'node:fs'; import { dirname, join, posix } from 'node:path'; -import { betaArchiveOf, destOf } from '../config.js'; +import { betaArchiveOf, config, destOf, destOfKind, type ArtifactKind } from '../config.js'; import { db } from '../db/index.js'; import { betaFile, oldFile, sha256Buffer } from './files.js'; import { ensureDest, getServer, heldByBeta } from './servers.js'; import { + getBaselineForScope, getScript, getVersion, + listPromotedOverlaysForScope, listVersions, serversMatchingScope, + type ScopeType, type ScriptRow, type ScriptVersionRow } from './scripts.js'; +import { composeCfg } from './config-compose.js'; import { recordScript, removeScript, getServerScript } from './inventory.js'; import { emit } from '../pipeline/bus.js'; import type { BetaStage } from '../pipeline/bus.js'; +// Whether an artifact writes a dedicated on-disk file (script/plugin) or +// contributes to a composed shared file (config/baseline). +function isCollapsingKind(kind: ArtifactKind): boolean { + return kind === 'config' || kind === 'baseline'; +} + +// The path we should write to for the composed cfg on this server. +function composedCfgPath(server: string): string { + return join(destOfKind(server, 'config'), config.configTargetFile); +} + +// Compose the effective dedicated_sp.cfg for a given beta+server, taking +// into account: +// • baseline: from the beta if present, else the currently-promoted +// baseline for the server's scope +// • promoted overlays for the server's scope (excluding scripts being +// replaced by this beta) +// • this beta's overlays whose scope matches the server +// +// Returns null if no baseline is available (nothing to compose against). +interface ComposeInputs { + inBetaBaseline?: ScriptVersionRow; + inBetaOverlays: Array<{ script: ScriptRow; version: ScriptVersionRow }>; +} + +function scopeAppliesToServer( + scope: { scope_type: string | null; scope_value: string | null }, + server: { game: string | null; map: string | null } +): boolean { + if (!scope.scope_type || !scope.scope_value) return false; + const field = scope.scope_type === 'game' ? server.game : server.map; + return field === scope.scope_value; +} + +function composeCfgForServer( + serverName: string, + serverGame: string | null, + serverMap: string | null, + inputs: ComposeInputs +): { text: string; baselineScriptId: number | null } | null { + // Baseline: prefer the beta's baseline if its scope matches this server. + let baselineText: string | null = null; + let baselineScriptId: number | null = null; + if (inputs.inBetaBaseline) { + const s = getScript(inputs.inBetaBaseline.script_id); + if (s && scopeAppliesToServer(s, { game: serverGame, map: serverMap })) { + baselineText = inputs.inBetaBaseline.content.toString('utf8'); + baselineScriptId = s.id; + } + } + // Fall back to the currently-promoted baseline for the server's game. + if (baselineText === null && serverGame) { + const promoted = getBaselineForScope('game', serverGame); + if (promoted?.current_version_id) { + const ver = getVersion(promoted.current_version_id); + if (ver) { + baselineText = ver.content.toString('utf8'); + baselineScriptId = promoted.id; + } + } + } + if (baselineText === null) return null; + + // In-beta script ids that are replacing promoted overlays — those get + // skipped from the promoted overlay list to avoid double-applying an + // older version of the same script. + const inBetaScriptIds = new Set(inputs.inBetaOverlays.map((o) => o.script.id)); + + // Collect promoted overlays scoped to this server's game AND map. + const promotedOverlays: Array<{ id: number; text: string }> = []; + const promotedIdsSeen = new Set(); + const pushIfScoped = (rows: ScriptRow[]) => { + for (const s of rows) { + if (promotedIdsSeen.has(s.id)) continue; + if (inBetaScriptIds.has(s.id)) continue; + if (!scopeAppliesToServer(s, { game: serverGame, map: serverMap })) continue; + if (!s.current_version_id) continue; + const ver = getVersion(s.current_version_id); + if (!ver) continue; + promotedOverlays.push({ id: s.id, text: ver.content.toString('utf8') }); + promotedIdsSeen.add(s.id); + } + }; + if (serverGame) pushIfScoped(listPromotedOverlaysForScope('game', serverGame)); + if (serverMap) pushIfScoped(listPromotedOverlaysForScope('map', serverMap)); + + // In-beta overlays scoped to this server. + const inBetaScopedOverlays = inputs.inBetaOverlays + .filter(({ script }) => scopeAppliesToServer(script, { game: serverGame, map: serverMap })) + .map(({ script, version }) => ({ id: script.id, text: version.content.toString('utf8') })); + + const composed = composeCfg(baselineText, [...promotedOverlays, ...inBetaScopedOverlays]); + return { text: composed, baselineScriptId }; +} + export interface BetaRow { id: number; // Deprecated: the columns are still populated (NOT NULL on legacy schemas) @@ -51,6 +150,7 @@ export interface BetaScriptRow { beta_id: number; script_id: number; script_version_id: number; + kind: ArtifactKind; } export interface BetaServerScriptRow { @@ -168,7 +268,12 @@ export async function openBeta(input: OpenBetaInput): Promise { interface Resolved { script: ScriptRow; version: ScriptVersionRow } const resolved: Resolved[] = []; const seenScriptIds = new Set(); + // For non-collapsing kinds (script/plugin), two artifacts targeting the + // same rel_path within one beta is an internal race — reject up front. + // Collapsing kinds (config/baseline) intentionally share a target file + // (dedicated_sp.cfg) and compose, so they skip this check. const seenTargets = new Map(); + let baselineInBeta: Resolved | null = null; for (const s of input.scripts) { if (seenScriptIds.has(s.scriptId)) { throw new BetaError(`script ${s.scriptId} listed twice in one beta`, 'duplicate-script'); @@ -184,15 +289,25 @@ export async function openBeta(input: OpenBetaInput): Promise { 'version-script-mismatch' ); } - // Two scripts writing to the same target rel_path in one beta is an - // internal race — reject up front. - if (seenTargets.has(script.target_rel_path)) { - throw new BetaError( - `scripts ${seenTargets.get(script.target_rel_path)} and ${script.id} share target ${script.target_rel_path}`, - 'duplicate-target' - ); + if (!isCollapsingKind(script.kind)) { + const targetKey = `${script.kind}:${script.target_rel_path}`; + if (seenTargets.has(targetKey)) { + throw new BetaError( + `${script.kind}s ${seenTargets.get(targetKey)} and ${script.id} share target ${script.target_rel_path}`, + 'duplicate-target' + ); + } + seenTargets.set(targetKey, script.id); + } + if (script.kind === 'baseline') { + if (baselineInBeta) { + throw new BetaError( + `two baselines in one beta (${baselineInBeta.script.id}, ${script.id}) — betas support at most one baseline`, + 'duplicate-target' + ); + } + baselineInBeta = { script, version }; } - seenTargets.set(script.target_rel_path, script.id); resolved.push({ script, version }); } @@ -231,7 +346,7 @@ export async function openBeta(input: OpenBetaInput): Promise { for (const { script } of resolved) { const owner = held.get(script.target_rel_path); if (owner) conflicts.push({ server: name, scriptId: script.id, heldByBetaId: owner }); - const dest = destOf(name); + const dest = destOfKind(name, script.kind); const targetPath = join(dest, script.target_rel_path); const backupPath = join(dest, oldFile(script.target_rel_path)); if (existsSync(targetPath) && existsSync(backupPath)) { @@ -242,7 +357,9 @@ export async function openBeta(input: OpenBetaInput): Promise { if (conflicts.length > 0) throw new BetaError('target already held by another beta', 'conflict', conflicts); if (blocked.length > 0) throw new BetaError('stale .OLD backups in the way', 'blocked-backup', blocked); - // Ensure destinations exist (create=true so a fresh server picks up cleanly). + // Ensure destinations exist. The `ensureDest` helper covers only the + // script destination today; config/plugin destinations are created + // on-demand via mkdir during the write loop. for (const name of input.servers) { const ok = await ensureDest(name, true); if (!ok.ok) throw new BetaError(`dest not usable for ${name}: ${ok.reason}`, 'dest-fail'); @@ -255,7 +372,7 @@ export async function openBeta(input: OpenBetaInput): Promise { VALUES (?, ?, 'bundle', ?, ?, 'running', ?)` ); const insertBetaScript = db().prepare( - `INSERT INTO beta_scripts (beta_id, script_id, script_version_id) VALUES (?, ?, ?)` + `INSERT INTO beta_scripts (beta_id, script_id, script_version_id, kind) VALUES (?, ?, ?, ?)` ); const insertBetaServer = db().prepare( `INSERT INTO beta_servers (beta_id, server, had_original, prior_version_id, stage, updated_at) @@ -277,7 +394,7 @@ export async function openBeta(input: OpenBetaInput): Promise { ); betaId = Number(res.lastInsertRowid); for (const r of resolved) { - insertBetaScript.run(betaId, r.script.id, r.version.id); + insertBetaScript.run(betaId, r.script.id, r.version.id, r.script.kind); } for (const name of input.servers) { insertBetaServer.run(betaId, name, nowIso()); @@ -294,17 +411,24 @@ export async function openBeta(input: OpenBetaInput): Promise { } })(); - // Archive: one -beta.gsc per script. + // Split resolved artifacts into non-collapsing (each writes its own file) + // and collapsing (config/baseline overlay onto one shared dedicated_sp.cfg). + const nonCollapsing = resolved.filter((r) => !isCollapsingKind(r.script.kind)); + const collapsingOverlays = resolved.filter((r) => r.script.kind === 'config'); + const collapsingBaseline = resolved.find((r) => r.script.kind === 'baseline'); + + // Archive: one -beta.gsc per non-collapsing artifact, plus one composed + // preview per beta server for the config layer. const archive = betaArchiveOf(betaId); await mkdir(archive, { recursive: true }); - for (const r of resolved) { + for (const r of nonCollapsing) { const bfile = betaFile(r.script.target_rel_path); await writeFile(join(archive, posix.basename(bfile)), r.version.content); } - // On-disk deploy per (server, script). If any script write fails on a - // server, mark the whole server 'failed' — we can't restart a partially - // deployed server safely. Remaining scripts on that server are skipped. + // On-disk deploy per server. If any write fails on a server, mark the + // whole server 'failed' — we can't restart a partially deployed server + // safely. Remaining artifacts on that server are skipped. const updateStage = db().prepare( `UPDATE beta_servers SET stage = ?, error = ?, deployed_at = ?, updated_at = ? @@ -312,10 +436,12 @@ export async function openBeta(input: OpenBetaInput): Promise { ); for (const name of input.servers) { - const dest = destOf(name); + const serverRow = serverRows.find((r) => r.name === name)!; let firstErr: string | null = null; - for (const { script, version } of resolved) { + // 1. Non-collapsing artifacts: script/plugin — one file each. + for (const { script, version } of nonCollapsing) { if (firstErr) break; + const dest = destOfKind(name, script.kind); const bfile = betaFile(script.target_rel_path); const ofile = oldFile(script.target_rel_path); const targetPath = join(dest, script.target_rel_path); @@ -337,6 +463,51 @@ export async function openBeta(input: OpenBetaInput): Promise { firstErr = `${script.name}: ${(err as Error).message}`; } } + // 2. Collapsing layer: compose the effective dedicated_sp.cfg from + // baseline + all applicable overlays, write in place (no -beta + // suffix — the game reads the exact filename), backup the prior + // file as .OLD once. + const hasCollapsing = collapsingBaseline || collapsingOverlays.length > 0; + if (!firstErr && hasCollapsing) { + try { + const composedResult = composeCfgForServer(name, serverRow.game, serverRow.map, { + inBetaBaseline: collapsingBaseline?.version, + inBetaOverlays: collapsingOverlays + }); + if (!composedResult) { + throw new Error( + `no baseline available for ${name} (game=${serverRow.game ?? '?'}) — upload one before running config overlays` + ); + } + const dest = destOfKind(name, 'config'); + await mkdir(dest, { recursive: true }); + const targetRel = config.configTargetFile; + const targetPath = join(dest, targetRel); + const backupPath = join(dest, oldFile(targetRel)); + if (existsSync(targetPath)) { + await rename(targetPath, backupPath); + const prior = getServerScript(name, targetRel); + removeScript(name, targetRel); + if (prior) { + recordScript(name, oldFile(targetRel), prior.script_version_id, prior.sha256, prior.size, `beta:${betaId}`); + } + } + const composedBuf = Buffer.from(composedResult.text, 'utf8'); + await writeFile(targetPath, composedBuf); + recordScript( + name, + targetRel, + null, + sha256Buffer(composedBuf), + composedBuf.byteLength, + `beta:${betaId}` + ); + // Archive the composed preview for operator visibility. + await writeFile(join(archive, `${name}.${config.configTargetFile}`), composedBuf); + } catch (err) { + firstErr = `configs: ${(err as Error).message}`; + } + } const t = nowIso(); if (firstErr) { updateStage.run('failed', `deploy: ${firstErr}`, t, t, betaId, name); @@ -390,16 +561,19 @@ export async function rollbackBeta(betaId: number): Promise { const dropped = new Set(); const warnings: string[] = []; + const hasCollapsing = bundle.some((b) => isCollapsingKind(b.script.kind)); + for (const bs of servers) { - const dest = destOf(bs.server); for (const { script } of bundle) { + if (isCollapsingKind(script.kind)) continue; // handled once per server below + const dest = destOfKind(bs.server, script.kind); const bfile = betaFile(script.target_rel_path); const ofile = oldFile(script.target_rel_path); const betaPath = join(dest, bfile); const backupPath = join(dest, ofile); const targetPath = join(dest, script.target_rel_path); - // Remove -beta.gsc regardless of stage. + // Remove -beta. regardless of stage. if (existsSync(betaPath)) await rm(betaPath, { force: true }); removeScript(bs.server, bfile); @@ -430,6 +604,36 @@ export async function rollbackBeta(betaId: number): Promise { dropped.add(bs.server); } } + + // Collapsing config layer: single restore for the shared cfg file. + if (hasCollapsing) { + const dest = destOfKind(bs.server, 'config'); + const targetRel = config.configTargetFile; + const backupPath = join(dest, oldFile(targetRel)); + const targetPath = join(dest, targetRel); + if (existsSync(backupPath)) { + if (existsSync(targetPath)) await rm(targetPath, { force: true }); + await rename(backupPath, targetPath); + removeScript(bs.server, oldFile(targetRel)); + const buf = await readFile(targetPath); + recordScript( + bs.server, + targetRel, + null, + sha256Buffer(buf), + buf.byteLength, + `rollback:${betaId}` + ); + restored.add(bs.server); + } else if (existsSync(targetPath)) { + // No .OLD ⇒ the beta wrote the cfg into a server that had + // no pre-existing file. Remove the beta-written cfg to leave + // the server clean. + await rm(targetPath, { force: true }); + removeScript(bs.server, targetRel); + dropped.add(bs.server); + } + } } // Wipe archive + close out beta. @@ -500,7 +704,13 @@ export async function promoteBeta(betaId: number): Promise { const betaServerSet = new Set(listBetaServers(betaId).map((r) => r.server)); - // Build a per-(script, server) plan: prior snapshot + intended write. + // Split bundle into non-collapsing (per-file) and collapsing (composed cfg). + const nonCollapsing = bundle.filter((b) => !isCollapsingKind(b.script.kind)); + const collapsing = bundle.filter((b) => isCollapsingKind(b.script.kind)); + const collapsingBaseline = collapsing.find((b) => b.script.kind === 'baseline'); + const collapsingOverlays = collapsing.filter((b) => b.script.kind === 'config'); + + // Build a per-(script, server) plan for non-collapsing artifacts. interface Plan { script: ScriptRow; version: ScriptVersionRow; @@ -512,18 +722,18 @@ export async function promoteBeta(betaId: number): Promise { } const plans: Plan[] = []; - for (const { script, version } of bundle) { + for (const { script, version } of nonCollapsing) { const scopeServers = script.scope_type && script.scope_value ? serversMatchingScope(script.scope_type, script.scope_value) : []; const targets = new Set([...betaServerSet, ...scopeServers]); for (const name of targets) { + const dest = destOfKind(name, script.kind); let plan: Plan; if (betaServerSet.has(name)) { const bss = getBetaServerScript(betaId, name, script.id); if (bss?.had_original) { - const dest = destOf(name); const backupPath = join(dest, oldFile(script.target_rel_path)); let priorContent: Buffer | null = null; let priorSha: string | null = null; @@ -544,7 +754,6 @@ export async function promoteBeta(betaId: number): Promise { } else { // Non-beta matching-scope server: current on-disk file (if any) // is the "before" state for a promote-then-rollback round trip. - const dest = destOf(name); const targetPath = join(dest, script.target_rel_path); if (existsSync(targetPath)) { const priorContent = await readFile(targetPath); @@ -567,7 +776,7 @@ export async function promoteBeta(betaId: number): Promise { const planWritten = new Map(); const serverErr = new Map(); for (const p of plans) { - const dest = destOf(p.server); + const dest = destOfKind(p.server, p.script.kind); const bfile = betaFile(p.script.target_rel_path); const ofile = oldFile(p.script.target_rel_path); const targetPath = join(dest, p.script.target_rel_path); @@ -587,6 +796,91 @@ export async function promoteBeta(betaId: number): Promise { } } + // Collapsing config layer: one composed cfg per server. Union of beta + // servers + scope-matching servers for every collapsing artifact. + interface CfgPlan { + server: string; + priorContent: Buffer | null; + priorSha: string | null; + composedContent: Buffer; + composedSha: string; + hadFile: boolean; + } + const cfgPlans: CfgPlan[] = []; + if (collapsing.length > 0) { + const cfgTargets = new Set(betaServerSet); + for (const { script } of collapsing) { + if (script.scope_type && script.scope_value) { + for (const s of serversMatchingScope(script.scope_type, script.scope_value)) { + cfgTargets.add(s); + } + } + } + for (const name of cfgTargets) { + const srv = getServer(name); + if (!srv) { + serverErr.set(name, 'unknown server during config promote'); + continue; + } + const composedResult = composeCfgForServer(name, srv.game, srv.map, { + inBetaBaseline: collapsingBaseline?.version, + inBetaOverlays: collapsingOverlays + }); + if (!composedResult) { + serverErr.set(name, `no baseline available for ${name} (game=${srv.game ?? '?'})`); + continue; + } + const composedBuf = Buffer.from(composedResult.text, 'utf8'); + const composedSha = sha256Buffer(composedBuf); + const dest = destOfKind(name, 'config'); + const targetRel = config.configTargetFile; + const targetPath = join(dest, targetRel); + const backupPath = join(dest, oldFile(targetRel)); + + let priorContent: Buffer | null = null; + let priorSha: string | null = null; + let hadFile = false; + // Beta server: prior was the pre-beta content saved as .OLD. + if (betaServerSet.has(name) && existsSync(backupPath)) { + priorContent = await readFile(backupPath); + priorSha = sha256Buffer(priorContent); + hadFile = true; + } else if (existsSync(targetPath)) { + // Scope-expansion server: current on-disk cfg is the "before". + priorContent = await readFile(targetPath); + priorSha = sha256Buffer(priorContent); + hadFile = true; + } + cfgPlans.push({ server: name, priorContent, priorSha, composedContent: composedBuf, composedSha, hadFile }); + } + } + + // Write composed cfg to each target. + const cfgWritten = new Map(); + for (const cp of cfgPlans) { + try { + const dest = destOfKind(cp.server, 'config'); + await mkdir(dest, { recursive: true }); + const targetRel = config.configTargetFile; + const targetPath = join(dest, targetRel); + const backupPath = join(dest, oldFile(targetRel)); + // Beta server already has composed content at targetPath; overwrite + // idempotently. Scope-expansion server: write in place. Either way + // clear any lingering .OLD from the beta stage. + await writeFile(targetPath, cp.composedContent); + if (existsSync(backupPath)) { + await rm(backupPath, { force: true }); + removeScript(cp.server, oldFile(targetRel)); + } + cfgWritten.set(cp, true); + } catch (err) { + cfgWritten.set(cp, false); + const msg = `configs: ${(err as Error).message}`; + if (!serverErr.has(cp.server)) serverErr.set(cp.server, msg); + console.error(`[promote] ${cp.server}/configs failed:`, err); + } + } + // Build the aggregate deployment. const depId = promoteDeploymentId(betaId); const startedAt = nowIso(); @@ -597,8 +891,8 @@ export async function promoteBeta(betaId: number): Promise { VALUES (?, 'promote', ?, 'ui', NULL, NULL, NULL, ?)` ); const insertDepScript = db().prepare( - `INSERT INTO deployment_scripts (deployment_id, script_id, script_version_id, prior_version_id) - VALUES (?, ?, ?, ?)` + `INSERT INTO deployment_scripts (deployment_id, script_id, script_version_id, prior_version_id, kind) + VALUES (?, ?, ?, ?, ?)` ); const insertDepServer = db().prepare( `INSERT INTO deployment_servers @@ -611,14 +905,19 @@ export async function promoteBeta(betaId: number): Promise { VALUES (?, ?, ?, ?, ?, ?, ?, ?)` ); - const uniqueServers = Array.from(new Set(plans.map((p) => p.server))); + const uniqueServers = Array.from(new Set([ + ...plans.map((p) => p.server), + ...cfgPlans.map((c) => c.server) + ])); db().transaction(() => { insertDep.run(depId, startedAt, betaId); for (const { script, version } of bundle) { - insertDepScript.run(depId, script.id, version.id, script.current_version_id); + insertDepScript.run(depId, script.id, version.id, script.current_version_id, script.kind); } - // deployment_files — one row per successful (script, server) write. + // deployment_files — one row per successful (script, server) write for + // non-collapsing artifacts, plus one row per (server, dedicated_sp.cfg) + // for the composed config layer. for (const p of plans) { if (!planWritten.get(p)) continue; const verb = p.hadFile ? 'updated' : 'added'; @@ -641,6 +940,28 @@ export async function promoteBeta(betaId: number): Promise { `promote:${depId}` ); } + for (const cp of cfgPlans) { + if (!cfgWritten.get(cp)) continue; + const verb = cp.hadFile ? 'updated' : 'added'; + insertDepFile.run( + depId, + cp.server, + verb, + config.configTargetFile, + cp.priorSha, + cp.composedSha, + cp.priorContent, + cp.composedContent + ); + recordScript( + cp.server, + config.configTargetFile, + null, + cp.composedSha, + cp.composedContent.byteLength, + `promote:${depId}` + ); + } // deployment_servers — one row per unique server. Beta servers are // already restarted; scope-only servers still need the sweep to drain // and restart them. @@ -650,7 +971,9 @@ export async function promoteBeta(betaId: number): Promise { insertDepServer.run(depId, name, 0, 'failed', null, err); continue; } - const filesChanged = plans.filter((p) => p.server === name && planWritten.get(p)).length; + const filesChanged = + plans.filter((p) => p.server === name && planWritten.get(p)).length + + cfgPlans.filter((c) => c.server === name && cfgWritten.get(c)).length; if (betaServerSet.has(name)) { const bsRestartedAt = db() .prepare(`SELECT restarted_at FROM beta_servers WHERE beta_id = ? AND server = ?`) @@ -671,7 +994,10 @@ export async function promoteBeta(betaId: number): Promise { const archive = betaArchiveOf(betaId); if (existsSync(archive)) await rm(archive, { recursive: true, force: true }); - const landed = Array.from(new Set(plans.filter((p) => planWritten.get(p)).map((p) => p.server))).sort(); + const landed = Array.from(new Set([ + ...plans.filter((p) => planWritten.get(p)).map((p) => p.server), + ...cfgPlans.filter((c) => cfgWritten.get(c)).map((c) => c.server) + ])).sort(); emit({ type: 'beta-state', betaId, state: 'promoted' }); emit({ type: 'deployment-opened', deploymentId: depId, action: 'promote', servers: landed }); @@ -706,7 +1032,7 @@ export async function unsetScopeAndCleanup(scriptId: number): Promise const matched = serversMatchingScope(script.scope_type, script.scope_value); const cleaned: string[] = []; for (const name of matched) { - const dest = destOf(name); + const dest = destOfKind(name, script.kind); const targetPath = join(dest, script.target_rel_path); if (existsSync(targetPath)) { await rm(targetPath, { force: true }); diff --git a/src/lib/server/fleet/config-compose.ts b/src/lib/server/fleet/config-compose.ts new file mode 100644 index 0000000..d9416a4 --- /dev/null +++ b/src/lib/server/fleet/config-compose.ts @@ -0,0 +1,124 @@ +// Kino-style dedicated_sp.cfg files are line-oriented: `set KEY VALUE` lines +// interleaved with `//` comments and blank lines. This module parses a +// baseline, applies overlays (each a bag of `set KEY ...` lines that should +// override the baseline's line for that KEY), and renders the composed file +// back to text while preserving structure. +// +// Semantics: +// - Overlays are matched to baseline lines by KEY only. Overlay's full raw +// line replaces the baseline's line at that position. +// - Overlay KEYs not present in the baseline are appended at the end. +// - Multiple overlays apply in the order given. Later overlays override +// earlier ones on the same KEY. +// - Comments (// ...) and blank lines are preserved as-is. +// - Line endings default to \n for the composed output. Baseline CRLF is +// preserved if the baseline uses CRLF throughout. + +interface CfgLine { + kind: 'set' | 'raw'; + key?: string; + line: string; // full line text without trailing newline +} + +interface ParsedCfg { + order: CfgLine[]; + entries: Map; // KEY → index in `order` + eol: '\n' | '\r\n'; +} + +// Matches `set KEY `. KEY is any non-whitespace token. Leading +// whitespace and inline `//` comments after the value are preserved via the +// captured full line. +const SET_LINE_RE = /^\s*set\s+(\S+)\s+/; + +export function parseCfg(text: string): ParsedCfg { + // Detect CRLF vs LF. If either \r\n appears we treat the file as CRLF. + const eol: '\n' | '\r\n' = /\r\n/.test(text) ? '\r\n' : '\n'; + const rawLines = text.split(/\r?\n/); + // Trailing newline in the source produces an empty final split element — + // drop it so we don't emit a spurious blank line on the round-trip. + if (rawLines.length > 0 && rawLines[rawLines.length - 1] === '') rawLines.pop(); + + const order: CfgLine[] = []; + const entries = new Map(); + for (const line of rawLines) { + const m = SET_LINE_RE.exec(line); + if (m) { + const key = m[1]; + // If the same KEY appears twice in the baseline, later occurrence + // wins (matches how the Plutonium runtime reads the cfg top-down). + entries.set(key, order.length); + order.push({ kind: 'set', key, line }); + } else { + order.push({ kind: 'raw', line }); + } + } + return { order, entries, eol }; +} + +/** + * Parse an overlay's text. Returns KEY → full replacement line. Blank/comment + * lines are ignored. If the overlay lists the same KEY twice, the last wins. + */ +export function parseOverlay(text: string): Map { + const out = new Map(); + const lines = text.split(/\r?\n/); + for (const line of lines) { + const m = SET_LINE_RE.exec(line); + if (!m) continue; + out.set(m[1], line); + } + return out; +} + +export interface OverlayInput { + /** Stable identifier used to determine application order (asc). */ + id: number; + text: string; +} + +/** + * Compose a baseline cfg with a set of overlays. Overlays apply in `id` + * ascending order — deterministic across scopes. + */ +export function composeCfg(baselineText: string, overlays: OverlayInput[]): string { + const parsed = parseCfg(baselineText); + + // Determine application order deterministically. + const sorted = [...overlays].sort((a, b) => a.id - b.id); + + // Merge overlay entries: later overlays win per KEY. + const merged = new Map(); + for (const overlay of sorted) { + const kvs = parseOverlay(overlay.text); + for (const [k, line] of kvs) merged.set(k, line); + } + + // Apply: replace in-place where the KEY exists in the baseline, else + // append. Appended lines preserve overlay order (Map iteration order is + // insertion order — which matches overlay id ascending). + const appended: string[] = []; + for (const [k, line] of merged) { + const idx = parsed.entries.get(k); + if (idx !== undefined) { + parsed.order[idx] = { kind: 'set', key: k, line }; + } else { + appended.push(line); + } + } + + const bodyLines = parsed.order.map((l) => l.line); + if (appended.length > 0) { + // Ensure a visual separator before appended lines if the baseline + // doesn't already end with a blank line — cheap ergonomics for + // operators inspecting the composed file. + const last = bodyLines[bodyLines.length - 1]; + if (last !== undefined && last.trim() !== '') bodyLines.push(''); + bodyLines.push('// --- appended by electric-cherry overlays ---'); + for (const line of appended) bodyLines.push(line); + } + + // Preserve trailing newline: Plutonium tolerates either, but adding one + // matches upstream cfg conventions. + return bodyLines.join(parsed.eol) + parsed.eol; +} diff --git a/src/lib/server/fleet/rollback.ts b/src/lib/server/fleet/rollback.ts index 50f9764..527bc3a 100644 --- a/src/lib/server/fleet/rollback.ts +++ b/src/lib/server/fleet/rollback.ts @@ -1,7 +1,7 @@ import { mkdir, rm, writeFile } from 'node:fs/promises'; import { existsSync } from 'node:fs'; import { dirname, join } from 'node:path'; -import { destOf } from '../config.js'; +import { destOfKind, type ArtifactKind } from '../config.js'; import { db } from '../db/index.js'; import { emit } from '../pipeline/bus.js'; import { recordScript, removeScript } from './inventory.js'; @@ -33,6 +33,7 @@ interface DepScriptRow { script_id: number; script_version_id: number; prior_version_id: number | null; + kind: ArtifactKind; } interface FileRow { @@ -49,6 +50,7 @@ interface FileRow { interface ScriptTargetRow { id: number; target_rel_path: string; + kind: ArtifactKind; } function nowIso(): string { @@ -91,7 +93,8 @@ export async function rollbackPromoteDeployment(promoteId: string): Promise(); + const kindByScriptId = new Map(); if (scripts.length > 0) { const placeholders = scripts.map(() => '?').join(','); const targetRows = db() - .prepare(`SELECT id, target_rel_path FROM scripts WHERE id IN (${placeholders})`) + .prepare(`SELECT id, target_rel_path, kind FROM scripts WHERE id IN (${placeholders})`) .all(...scripts.map((s) => s.script_id)) as ScriptTargetRow[]; const targetById = new Map(); - for (const t of targetRows) targetById.set(t.id, t.target_rel_path); + for (const t of targetRows) { + targetById.set(t.id, t.target_rel_path); + kindByScriptId.set(t.id, t.kind); + } for (const s of scripts) { const t = targetById.get(s.script_id); if (t) scriptByTarget.set(t, s); } } + const kindForRelPath = (relPath: string): ArtifactKind => { + const owner = scriptByTarget.get(relPath); + if (owner) return owner.kind ?? kindByScriptId.get(owner.script_id) ?? 'script'; + // Config/baseline aggregate rows have no per-artifact target_rel_path + // mapping — the deployment_files row is keyed on the shared cfg name. + // Any script/baseline in this deployment tagged as config/baseline + // means the rollback for that path lives under the config destination. + for (const s of scripts) if (s.kind === 'config' || s.kind === 'baseline') return 'config'; + return 'script'; + }; const newDepId = rollbackDeploymentId(promoteId); const startedAt = nowIso(); @@ -133,9 +150,10 @@ export async function rollbackPromoteDeployment(promoteId: string): Promise(); for (const [server, files] of perServer) { - const dest = destOf(server); try { for (const f of files) { + const kind = kindForRelPath(f.rel_path); + const dest = destOfKind(server, kind); const abs = join(dest, f.rel_path); await mkdir(join(dest, dirname(f.rel_path)), { recursive: true }); const owningScript = scriptByTarget.get(f.rel_path); @@ -173,8 +191,8 @@ export async function rollbackPromoteDeployment(promoteId: string): Promise = ['script', 'config', 'plugin', 'baseline']; + +export function isArtifactKind(v: unknown): v is ArtifactKind { + return typeof v === 'string' && (ARTIFACT_KINDS as readonly string[]).includes(v); +} export type ScopeType = 'game' | 'map'; @@ -10,6 +19,7 @@ export interface ScriptRow { scope_type: ScopeType | null; scope_value: string | null; current_version_id: number | null; + kind: ArtifactKind; created_at: string; } @@ -24,6 +34,7 @@ export interface ScriptVersionRow { content: Buffer; notes: string | null; state: ScriptVersionState; + kind: ArtifactKind; uploaded_at: string; } @@ -51,6 +62,10 @@ export class ScriptError extends Error { | 'in-use-beta' | 'has-scope' | 'has-active-beta' + | 'invalid-kind' + | 'baseline-requires-scope' + | 'baseline-exists' + | 'empty-overlay' ) { super(message); } @@ -68,10 +83,51 @@ export function getScriptByName(name: string): ScriptRow | undefined { return db().prepare('SELECT * FROM scripts WHERE name = ?').get(name) as ScriptRow | undefined; } -export function listScripts(): ScriptRow[] { +export function listScripts(kind?: ArtifactKind): ScriptRow[] { + if (kind) { + return db().prepare('SELECT * FROM scripts WHERE kind = ? ORDER BY name').all(kind) as ScriptRow[]; + } return db().prepare('SELECT * FROM scripts ORDER BY name').all() as ScriptRow[]; } +// Look up the current baseline for a given scope. Baselines have exactly one +// current_version_id after their first promote; before that they are still +// gated through the beta pipeline. `includeUntested` returns the baseline row +// even without a promoted version (used at beta-open time when the baseline is +// being introduced in the same beta as the overlays). +export function getBaselineForScope(scopeType: ScopeType, scopeValue: string): ScriptRow | undefined { + return db() + .prepare( + `SELECT * FROM scripts + WHERE kind = 'baseline' AND scope_type = ? AND scope_value = ? + LIMIT 1` + ) + .get(scopeType, scopeValue) as ScriptRow | undefined; +} + +// All promoted config overlays for a scope. `exceptScriptIds` lets callers +// skip overlays whose new version is bundled in a beta — avoids applying the +// prior version twice when composing. +export function listPromotedOverlaysForScope( + scopeType: ScopeType, + scopeValue: string, + exceptScriptIds: number[] = [] +): ScriptRow[] { + const rows = db() + .prepare( + `SELECT * FROM scripts + WHERE kind = 'config' + AND scope_type = ? + AND scope_value = ? + AND current_version_id IS NOT NULL + ORDER BY id` + ) + .all(scopeType, scopeValue) as ScriptRow[]; + if (exceptScriptIds.length === 0) return rows; + const skip = new Set(exceptScriptIds); + return rows.filter((r) => !skip.has(r.id)); +} + export function listVersions(scriptId: number): ScriptVersionRow[] { return db() .prepare('SELECT * FROM script_versions WHERE script_id = ? ORDER BY version DESC') @@ -94,18 +150,57 @@ export function getLatestVersion(scriptId: number): ScriptVersionRow | undefined export interface CreateScriptInput { name: string; - targetRelPath: string; + /** Optional: defaults to the upload filename for script/plugin, and to + * the configured config target file for config/baseline. */ + targetRelPath?: string; scopeType?: ScopeType | null; scopeValue?: string | null; content: Buffer; notes?: string; + /** Defaults to 'script' for back-compat with existing callers. */ + kind?: ArtifactKind; } // Insert script + version 1 in one transaction. Scope may be set at creation // because the script has no current_version_id yet (nothing to gate). export function createScript(input: CreateScriptInput): { script: ScriptRow; version: ScriptVersionRow } { if (!input.name.trim()) throw new ScriptError('script name is required', 'name-empty'); - if (!input.targetRelPath.trim()) throw new ScriptError('target rel path is required', 'path-empty'); + const kind: ArtifactKind = input.kind ?? 'script'; + if (!isArtifactKind(kind)) throw new ScriptError(`invalid kind: ${input.kind}`, 'invalid-kind'); + + // Target file defaulting per kind. Configs and baselines all compose into + // the same on-disk cfg file, so the operator never picks it. + let targetRelPath = input.targetRelPath?.trim() ?? ''; + if (!targetRelPath && (kind === 'config' || kind === 'baseline')) { + targetRelPath = config.configTargetFile; + } + if (!targetRelPath) throw new ScriptError('target rel path is required', 'path-empty'); + + // Kind-specific validation. + if (kind === 'baseline') { + if (!input.scopeType || !input.scopeValue) { + throw new ScriptError('baselines require a scope (game or map)', 'baseline-requires-scope'); + } + if (getBaselineForScope(input.scopeType, input.scopeValue)) { + throw new ScriptError( + `a baseline already exists for ${input.scopeType}=${input.scopeValue}`, + 'baseline-exists' + ); + } + } + if (kind === 'config') { + if (!input.scopeType || !input.scopeValue) { + throw new ScriptError('config overlays require a scope', 'invalid-scope'); + } + // Reject empty or comment-only overlays — nothing to apply. + const text = input.content.toString('utf8'); + if (!/^\s*set\s+\S+\s+/m.test(text)) { + throw new ScriptError( + 'config overlay must contain at least one `set KEY VALUE` line', + 'empty-overlay' + ); + } + } validateScope(input.scopeType ?? null, input.scopeValue ?? null); if (getScriptByName(input.name)) throw new ScriptError(`script "${input.name}" already exists`, 'name-taken'); @@ -113,20 +208,20 @@ export function createScript(input: CreateScriptInput): { script: ScriptRow; ver const size = input.content.byteLength; const insertScript = db().prepare( - `INSERT INTO scripts (name, target_rel_path, scope_type, scope_value) - VALUES (?, ?, ?, ?)` + `INSERT INTO scripts (name, target_rel_path, scope_type, scope_value, kind) + VALUES (?, ?, ?, ?, ?)` ); const insertVersion = db().prepare( - `INSERT INTO script_versions (script_id, version, sha256, size, content, notes) - VALUES (?, 1, ?, ?, ?, ?)` + `INSERT INTO script_versions (script_id, version, sha256, size, content, notes, kind) + VALUES (?, 1, ?, ?, ?, ?, ?)` ); let scriptId!: number; let versionId!: number; db().transaction(() => { - const s = insertScript.run(input.name, input.targetRelPath, input.scopeType ?? null, input.scopeValue ?? null); + const s = insertScript.run(input.name, targetRelPath, input.scopeType ?? null, input.scopeValue ?? null, kind); scriptId = Number(s.lastInsertRowid); - const v = insertVersion.run(scriptId, sha, size, input.content, input.notes ?? null); + const v = insertVersion.run(scriptId, sha, size, input.content, input.notes ?? null, kind); versionId = Number(v.lastInsertRowid); })(); @@ -140,16 +235,26 @@ export function uploadNewVersion(scriptId: number, content: Buffer, notes?: stri const script = getScript(scriptId); if (!script) throw new ScriptError(`no such script (id=${scriptId})`, 'no-such-script'); + if (script.kind === 'config') { + const text = content.toString('utf8'); + if (!/^\s*set\s+\S+\s+/m.test(text)) { + throw new ScriptError( + 'config overlay must contain at least one `set KEY VALUE` line', + 'empty-overlay' + ); + } + } + const latest = getLatestVersion(scriptId); const nextVersion = (latest?.version ?? 0) + 1; const sha = sha256(content); const res = db() .prepare( - `INSERT INTO script_versions (script_id, version, sha256, size, content, notes) - VALUES (?, ?, ?, ?, ?, ?)` + `INSERT INTO script_versions (script_id, version, sha256, size, content, notes, kind) + VALUES (?, ?, ?, ?, ?, ?, ?)` ) - .run(scriptId, nextVersion, sha, content.byteLength, content, notes ?? null); + .run(scriptId, nextVersion, sha, content.byteLength, content, notes ?? null, script.kind); return getVersion(Number(res.lastInsertRowid))!; } diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index c072123..fe16598 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -25,6 +25,8 @@ diff --git a/src/routes/api/betas/+server.ts b/src/routes/api/betas/+server.ts index 725e814..712104c 100644 --- a/src/routes/api/betas/+server.ts +++ b/src/routes/api/betas/+server.ts @@ -24,6 +24,7 @@ export async function GET({ url }: { url: URL }) { id: bs.script_id, name: script?.name ?? '(deleted)', target_rel_path: script?.target_rel_path ?? '', + kind: script?.kind ?? bs.kind ?? 'script', version: version ? { id: version.id, version: version.version, sha256: version.sha256 } : null @@ -38,9 +39,13 @@ export async function GET({ url }: { url: URL }) { return json({ betas: rows }); } -// POST body: { scripts: [{ scriptId, scriptVersionId }], servers[], waitForHealth, waitForMatch, notes? } +// POST body accepts either: +// { artifacts: [{ id, versionId }], ... } — polymorphic (script/config/plugin/baseline) +// or the legacy form: +// { scripts: [{ scriptId, scriptVersionId }], ... } export async function POST({ request }: { request: Request }) { const body = (await request.json().catch(() => ({}))) as { + artifacts?: Array<{ id?: number; versionId?: number }>; scripts?: Array<{ scriptId?: number; scriptVersionId?: number }>; servers?: string[]; waitForHealth?: boolean; @@ -48,9 +53,17 @@ export async function POST({ request }: { request: Request }) { notes?: string; }; - if (!Array.isArray(body.scripts) || body.scripts.length === 0 || !Array.isArray(body.servers)) { + // Normalize into the openBeta shape. Both fields feed the same list. + const rawEntries: Array<{ scriptId?: number; scriptVersionId?: number }> = []; + if (Array.isArray(body.artifacts)) { + for (const a of body.artifacts) rawEntries.push({ scriptId: a.id, scriptVersionId: a.versionId }); + } + if (Array.isArray(body.scripts)) { + for (const s of body.scripts) rawEntries.push({ scriptId: s.scriptId, scriptVersionId: s.scriptVersionId }); + } + if (rawEntries.length === 0 || !Array.isArray(body.servers)) { return json( - { ok: false, error: 'scripts[] (non-empty) and servers[] are required' }, + { ok: false, error: 'artifacts[]/scripts[] (non-empty) and servers[] are required' }, { status: 400 } ); } @@ -58,10 +71,10 @@ export async function POST({ request }: { request: Request }) { return json({ ok: false, error: 'at least one server is required' }, { status: 400 }); } const normalized: Array<{ scriptId: number; scriptVersionId: number }> = []; - for (const s of body.scripts) { + for (const s of rawEntries) { if (typeof s.scriptId !== 'number' || typeof s.scriptVersionId !== 'number') { return json( - { ok: false, error: 'each scripts[] entry needs scriptId and scriptVersionId' }, + { ok: false, error: 'each entry needs scriptId/id and scriptVersionId/versionId' }, { status: 400 } ); } diff --git a/src/routes/api/betas/[id]/+server.ts b/src/routes/api/betas/[id]/+server.ts index c1474a0..b9cae80 100644 --- a/src/routes/api/betas/[id]/+server.ts +++ b/src/routes/api/betas/[id]/+server.ts @@ -20,6 +20,7 @@ export async function GET({ params }: { params: { id: string } }) { id: bs.script_id, name: script?.name ?? '(deleted)', target_rel_path: script?.target_rel_path ?? '', + kind: script?.kind ?? bs.kind ?? 'script', version: version ? { id: version.id, version: version.version, sha256: version.sha256 } : null diff --git a/src/routes/api/configs/+server.ts b/src/routes/api/configs/+server.ts new file mode 100644 index 0000000..50c5c6e --- /dev/null +++ b/src/routes/api/configs/+server.ts @@ -0,0 +1,25 @@ +// Thin wrapper around /api/scripts pinned to config-kind artifacts. Both +// overlays (kind='config') and baselines (kind='baseline') share this endpoint +// — the caller sets `kind` in the form (defaults to 'config'). +import { GET as scriptsGET, POST as scriptsPOST } from '../scripts/+server.js'; + +function withDefaultKind(url: URL, fallback: string): URL { + if (!url.searchParams.has('kind')) { + const clone = new URL(url.href); + clone.searchParams.set('kind', fallback); + return clone; + } + return url; +} + +export async function GET(event: { url: URL }) { + // Default to listing overlays. Baselines live at /api/configs?kind=baseline. + return scriptsGET({ url: withDefaultKind(event.url, 'config') }); +} + +export async function POST(event: { request: Request; url: URL }) { + return scriptsPOST({ + request: event.request, + url: withDefaultKind(event.url, 'config') + }); +} diff --git a/src/routes/api/plugins/+server.ts b/src/routes/api/plugins/+server.ts new file mode 100644 index 0000000..b862213 --- /dev/null +++ b/src/routes/api/plugins/+server.ts @@ -0,0 +1,22 @@ +// Thin wrapper around /api/scripts pinned to plugin-kind artifacts. +import { GET as scriptsGET, POST as scriptsPOST } from '../scripts/+server.js'; + +function withDefaultKind(url: URL, fallback: string): URL { + if (!url.searchParams.has('kind')) { + const clone = new URL(url.href); + clone.searchParams.set('kind', fallback); + return clone; + } + return url; +} + +export async function GET(event: { url: URL }) { + return scriptsGET({ url: withDefaultKind(event.url, 'plugin') }); +} + +export async function POST(event: { request: Request; url: URL }) { + return scriptsPOST({ + request: event.request, + url: withDefaultKind(event.url, 'plugin') + }); +} diff --git a/src/routes/api/scripts/+server.ts b/src/routes/api/scripts/+server.ts index db0db0e..78a3e5c 100644 --- a/src/routes/api/scripts/+server.ts +++ b/src/routes/api/scripts/+server.ts @@ -1,10 +1,12 @@ import { json } from '@sveltejs/kit'; import { createScript, + isArtifactKind, listScripts, listVersions, serversMatchingScope, ScriptError, + type ArtifactKind, type ScopeType } from '$lib/server/fleet/scripts.js'; import { findRunningBetaForScript } from '$lib/server/fleet/beta.js'; @@ -17,8 +19,18 @@ function scopeTypeOrNull(v: FormDataEntryValue | null): ScopeType | null { throw new ScriptError(`invalid scope_type: ${s}`, 'invalid-scope'); } -export async function GET() { - const scripts = listScripts().map((s) => { +function kindOrDefault(v: FormDataEntryValue | string | null, fallback: ArtifactKind = 'script'): ArtifactKind { + if (v === null || v === undefined || v === '') return fallback; + const s = String(v); + if (!isArtifactKind(s)) throw new ScriptError(`invalid kind: ${s}`, 'invalid-kind'); + return s; +} + +export async function GET({ url }: { url: URL }) { + const kindParam = url.searchParams.get('kind'); + const kindFilter = kindParam ? (isArtifactKind(kindParam) ? kindParam : null) : null; + const rows = kindFilter ? listScripts(kindFilter) : listScripts(); + const scripts = rows.map((s) => { const versions = listVersions(s.id); const matched = s.scope_type && s.scope_value ? serversMatchingScope(s.scope_type as ScopeType, s.scope_value) @@ -44,28 +56,45 @@ export async function GET() { return json({ scripts }); } -// Multipart POST — creates a script + version 1 from a single file upload. -// Fields: name (text), scopeType (optional: 'game'|'map'), scopeValue -// (optional string), notes (optional), content (file). The target rel path -// defaults to the uploaded filename — T5 servers keep all scripts in a flat -// sp/ directory, so there's no sub-path to specify. -export async function POST({ request }: { request: Request }) { +// Multipart POST — creates an artifact + version 1 from either an uploaded +// file (script/plugin/baseline) or a text body (config overlay). Fields: +// name, scopeType, scopeValue, notes, kind (defaults to 'script'), +// content (file). Configs may alternatively send `text` as a form field. +export async function POST({ request, url }: { request: Request; url: URL }) { const form = await request.formData(); const name = String(form.get('name') ?? '').trim(); const notes = form.get('notes') ? String(form.get('notes')) : undefined; let scopeType: ScopeType | null; + let kind: ArtifactKind; try { scopeType = scopeTypeOrNull(form.get('scopeType')); + // Kind may come from the form or the query string (thin wrappers use ?kind=). + const kindRaw = form.get('kind') ?? url.searchParams.get('kind'); + kind = kindOrDefault(kindRaw); } catch (err) { return json({ ok: false, error: (err as Error).message }, { status: 400 }); } const scopeValueRaw = form.get('scopeValue'); const scopeValue = scopeValueRaw ? String(scopeValueRaw).trim() : null; - const file = form.get('content'); - if (!(file instanceof File)) return json({ ok: false, error: 'content file is required' }, { status: 400 }); - const targetRelPath = file.name; - const content = Buffer.from(await file.arrayBuffer()); + let content: Buffer; + let uploadedFilename: string | null = null; + const file = form.get('content'); + const text = form.get('text'); + if (file instanceof File && file.size > 0) { + content = Buffer.from(await file.arrayBuffer()); + uploadedFilename = file.name; + } else if (typeof text === 'string' && text.length > 0) { + content = Buffer.from(text, 'utf8'); + } else { + return json({ ok: false, error: 'content file or text is required' }, { status: 400 }); + } + + // Target rel path: for script/plugin, default to the upload filename. + // For config/baseline, createScript() defaults to config.configTargetFile. + const targetRelPath = kind === 'script' || kind === 'plugin' + ? uploadedFilename ?? undefined + : undefined; try { const { script, version } = createScript({ @@ -74,7 +103,8 @@ export async function POST({ request }: { request: Request }) { scopeType, scopeValue, content, - notes + notes, + kind }); return json({ ok: true, script, versionId: version.id }); } catch (err) { diff --git a/src/routes/betas/+page.svelte b/src/routes/betas/+page.svelte index 1778e1c..1f93004 100644 --- a/src/routes/betas/+page.svelte +++ b/src/routes/betas/+page.svelte @@ -2,6 +2,7 @@ import { onMount } from 'svelte'; import { goto } from '$app/navigation'; + type ArtifactKind = 'script' | 'config' | 'plugin' | 'baseline'; interface BetaListRow { id: number; state: 'running' | 'ready-promote' | 'promoted' | 'rolled-back'; @@ -13,6 +14,7 @@ id: number; name: string; target_rel_path: string; + kind: ArtifactKind; version: { id: number; version: number; sha256: string } | null; }>; servers: Array<{ server: string; stage: string; error: string | null }>; @@ -25,6 +27,7 @@ scope_type: 'game' | 'map' | null; scope_value: string | null; current_version_id: number | null; + kind: ArtifactKind; latestVersion: { id: number; version: number; @@ -229,12 +232,14 @@ ); function summarizeScripts(b: BetaListRow): string { - if (b.scripts.length === 0) return '(no scripts)'; + if (b.scripts.length === 0) return '(no artifacts)'; if (b.scripts.length === 1) { const s = b.scripts[0]; - return `${s.name} · v${s.version?.version ?? '?'}`; + return `[${s.kind}] ${s.name} · v${s.version?.version ?? '?'}`; } - return `${b.scripts.length} scripts`; + const byKind: Record = {}; + for (const s of b.scripts) byKind[s.kind] = (byKind[s.kind] ?? 0) + 1; + return Object.entries(byKind).map(([k, n]) => `${n} ${k}${n === 1 ? '' : 's'}`).join(', '); } @@ -246,7 +251,7 @@

Open a beta

{#if eligibleScripts.length === 0} -

no scripts with untested versions — upload a new version from a script's page

+

no artifacts with untested versions — upload a new version from a scripts / configs / plugins page

{:else}
@@ -256,9 +261,9 @@ bind:value={row.scriptId} onchange={() => onPickScript(idx)} > - + {#each eligibleForRow(idx) as s (s.id)} - + {/each} + + + + + + +
+ +
+

Create a config overlay

+
+ + + + + +
+ {#if flash}

{flash}

{/if} +
+ +
+

Baselines · {baselines.length}

+ {#if loading} +

loading…

+ {:else if baselines.length === 0} +

no baseline yet — upload one above

+ {:else} + + {/if} +
+ +
+

Overlays · {overlays.length}

+ {#if loading} +

loading…

+ {:else if overlays.length === 0} +

no overlays yet — create one above

+ {:else} + + {/if} +
+ + diff --git a/src/routes/plugins/+page.svelte b/src/routes/plugins/+page.svelte new file mode 100644 index 0000000..f900bd4 --- /dev/null +++ b/src/routes/plugins/+page.svelte @@ -0,0 +1,248 @@ + + +

Plugins

+ +
+

Upload a plugin

+ {#if successBanner} + + {/if} +
+ + + + + +
+ {#if flash}

{flash}

{/if} +
+ +
+

All plugins

+ {#if loading} +

loading…

+ {:else if plugins.length === 0} +

no plugins yet — upload one above

+ {:else} + + {/if} +
+ + diff --git a/tests/unit/config-compose.test.ts b/tests/unit/config-compose.test.ts new file mode 100644 index 0000000..441c99f --- /dev/null +++ b/tests/unit/config-compose.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest'; +import { composeCfg, parseCfg, parseOverlay } from '../../src/lib/server/fleet/config-compose.js'; + +const BASELINE = [ + '// GENERAL', + 'set g_inactivity "190"', + 'set sv_voice "1"', + '', + '// GAMEPLAY', + 'set g_playerCollision "0"', + 'set sv_maprotation "map zombie_theater"' +].join('\n'); + +describe('parseCfg', () => { + it('captures set keys and preserves comments/blanks', () => { + const p = parseCfg(BASELINE); + expect(p.entries.get('g_inactivity')).toBe(1); + expect(p.entries.get('sv_voice')).toBe(2); + expect(p.entries.get('sv_maprotation')).toBe(6); + expect(p.order[0]).toEqual({ kind: 'raw', line: '// GENERAL' }); + expect(p.order[3]).toEqual({ kind: 'raw', line: '' }); + }); + + it('detects CRLF line endings', () => { + const crlf = 'set a "1"\r\nset b "2"\r\n'; + expect(parseCfg(crlf).eol).toBe('\r\n'); + }); +}); + +describe('parseOverlay', () => { + it('reads set lines and ignores comments/blanks', () => { + const overlay = '// disable AFK\nset g_inactivity "0"\n'; + const kv = parseOverlay(overlay); + expect(kv.size).toBe(1); + expect(kv.get('g_inactivity')).toBe('set g_inactivity "0"'); + }); + + it('handles bare (unquoted) values', () => { + const overlay = 'set g_logSync 1'; + const kv = parseOverlay(overlay); + expect(kv.get('g_logSync')).toBe('set g_logSync 1'); + }); + + it('later duplicate wins within an overlay', () => { + const overlay = 'set x "1"\nset x "2"'; + expect(parseOverlay(overlay).get('x')).toBe('set x "2"'); + }); +}); + +describe('composeCfg', () => { + it('replaces matching keys in place', () => { + const result = composeCfg(BASELINE, [{ id: 1, text: 'set g_inactivity "0"' }]); + expect(result).toContain('set g_inactivity "0"'); + expect(result).not.toContain('set g_inactivity "190"'); + // Structure preserved: sv_voice should still be at its original spot. + const lines = result.split('\n'); + expect(lines[1]).toBe('set g_inactivity "0"'); + expect(lines[2]).toBe('set sv_voice "1"'); + }); + + it('appends new keys not present in baseline', () => { + const result = composeCfg(BASELINE, [{ id: 1, text: 'set new_flag "yes"' }]); + expect(result).toContain('set new_flag "yes"'); + expect(result).toContain('// --- appended by electric-cherry overlays ---'); + }); + + it('applies multiple overlays deterministically by id', () => { + const result = composeCfg(BASELINE, [ + { id: 2, text: 'set g_inactivity "60"' }, + { id: 1, text: 'set g_inactivity "0"' } + ]); + // id=2 is later → wins + expect(result).toContain('set g_inactivity "60"'); + expect(result).not.toContain('set g_inactivity "0"'); + }); + + it('preserves inline comments on the overlay line', () => { + const result = composeCfg(BASELINE, [ + { id: 1, text: 'set g_inactivity "0" // AFK disabled' } + ]); + expect(result).toContain('set g_inactivity "0" // AFK disabled'); + }); + + it('is a no-op when no overlays are given', () => { + const result = composeCfg(BASELINE, []); + expect(result.trim()).toBe(BASELINE.trim()); + }); +}); diff --git a/tests/unit/config-flow.test.ts b/tests/unit/config-flow.test.ts new file mode 100644 index 0000000..f49aa36 --- /dev/null +++ b/tests/unit/config-flow.test.ts @@ -0,0 +1,153 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { readFile, mkdir } 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(['t5_kino_1']); + delete process.env.KOMODO_ADDRESS; + delete process.env.RCON_PASSWORD; +}); + +afterEach(async () => { + await fixture.cleanup(); +}); + +const BASELINE_CFG = [ + '// GENERAL', + 'set g_inactivity "190"', + 'set sv_voice "1"', + '', + 'set sv_maprotation "map zombie_theater"' +].join('\n'); + +async function seedFleet(): Promise { + const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js'); + await seedServerRegistry(); + // Pre-create the configs directory so the config write path finds a + // hospitable server tree. + await mkdir(join(fixture.t5, 't5_kino_1', 'configs'), { recursive: true }); +} + +describe('config baseline + overlay flow', () => { + it('composes baseline + overlay and writes dedicated_sp.cfg on beta open, restores .OLD on rollback', async () => { + await seedFleet(); + const { createScript, getLatestVersion } = await import('../../src/lib/server/fleet/scripts.js'); + const { openBeta, rollbackBeta } = await import('../../src/lib/server/fleet/beta.js'); + + // 1. Upload baseline for game=t5. + const { script: baselineScript } = createScript({ + name: 't5 baseline', + kind: 'baseline', + scopeType: 'game', + scopeValue: 't5', + content: Buffer.from(BASELINE_CFG, 'utf8') + }); + const bVer = getLatestVersion(baselineScript.id)!; + + // 2. Upload an overlay for game=t5. + const { script: overlayScript } = createScript({ + name: 'disable afk', + kind: 'config', + scopeType: 'game', + scopeValue: 't5', + content: Buffer.from('set g_inactivity "0"', 'utf8') + }); + const oVer = getLatestVersion(overlayScript.id)!; + + // 3. Bundle both into one beta. + const { betaId } = await openBeta({ + scripts: [ + { scriptId: baselineScript.id, scriptVersionId: bVer.id }, + { scriptId: overlayScript.id, scriptVersionId: oVer.id } + ], + servers: ['t5_kino_1'], + waitForHealth: false, + waitForMatch: false + }); + + // 4. Verify: composed cfg lands at configs/dedicated_sp.cfg (in place, + // NO -beta suffix — the game reads the exact filename). + const cfgPath = join(fixture.t5, 't5_kino_1', 'configs', 'dedicated_sp.cfg'); + expect(existsSync(cfgPath)).toBe(true); + const composed = (await readFile(cfgPath)).toString('utf8'); + expect(composed).toContain('set g_inactivity "0"'); + expect(composed).not.toContain('set g_inactivity "190"'); + // Baseline lines that weren't overridden are preserved. + expect(composed).toContain('set sv_voice "1"'); + + // 5. Rollback restores the pre-beta state. There was no cfg on disk + // beforehand, so rollback should just remove the beta-written file. + await rollbackBeta(betaId); + expect(existsSync(cfgPath)).toBe(false); + }); + + it('requires an existing baseline before overlays can compose', async () => { + await seedFleet(); + const { createScript, getLatestVersion } = await import('../../src/lib/server/fleet/scripts.js'); + const { openBeta } = await import('../../src/lib/server/fleet/beta.js'); + const { db } = await import('../../src/lib/server/db/index.js'); + + const { script: overlayScript } = createScript({ + name: 'lonely overlay', + kind: 'config', + scopeType: 'game', + scopeValue: 't5', + content: Buffer.from('set g_inactivity "0"', 'utf8') + }); + const oVer = getLatestVersion(overlayScript.id)!; + + // openBeta returns success at the SQL level but the deploy step + // marks the server 'failed' with a "no baseline" error because no + // baseline is available. + const { betaId } = await openBeta({ + scripts: [{ scriptId: overlayScript.id, scriptVersionId: oVer.id }], + servers: ['t5_kino_1'], + waitForHealth: false, + waitForMatch: false + }); + + const row = db() + .prepare(`SELECT stage, error FROM beta_servers WHERE beta_id = ? AND server = ?`) + .get(betaId, 't5_kino_1') as { stage: string; error: string | null }; + expect(row.stage).toBe('failed'); + expect(row.error ?? '').toMatch(/no baseline available/); + }); +}); + +describe('plugin kind writes to plugin destination', () => { + it('deploys a plugin .dll under runtime/plutonium/plugins with -beta suffix', async () => { + await seedFleet(); + // Pre-create the plugins dir so the write can succeed. + await mkdir(join(fixture.t5, 't5_kino_1', 'runtime/plutonium/plugins'), { recursive: true }); + const { createScript, getLatestVersion } = await import('../../src/lib/server/fleet/scripts.js'); + const { openBeta } = await import('../../src/lib/server/fleet/beta.js'); + + const { script } = createScript({ + name: 'demo plugin', + kind: 'plugin', + targetRelPath: 'demo.dll', + scopeType: 'game', + scopeValue: 't5', + content: Buffer.from([0x00, 0x01, 0x02, 0x03]) + }); + const v = getLatestVersion(script.id)!; + + const { betaId } = await openBeta({ + scripts: [{ scriptId: script.id, scriptVersionId: v.id }], + servers: ['t5_kino_1'], + waitForHealth: false, + waitForMatch: false + }); + + const pluginPath = join(fixture.t5, 't5_kino_1', 'runtime/plutonium/plugins', 'demo-beta.dll'); + expect(existsSync(pluginPath)).toBe(true); + const bytes = await readFile(pluginPath); + expect(bytes.equals(Buffer.from([0x00, 0x01, 0x02, 0x03]))).toBe(true); + expect(betaId).toBeGreaterThan(0); + }); +});