Rework as scripts-centric, upload-only, gated beta lifecycle

Scripts become first-class: named, versioned, uploaded via the UI, optionally
scoped to a game or map. Any change that would land on scope-matched servers
(marking-required or new version of a scoped script) must first pass a beta.
The beta runs a per-server state machine — deploy -beta.gsc → wait empty →
Komodo restart → wait healthy → optionally wait for a real match end
(games_sp.log 'ExitLevel: executed') — before it's eligible for promotion.
Rollback is always available and restores from .OLD.

The shared/scripts mirror and its betas/ staging folder are gone. Every
server now has a tracked script inventory (server_scripts table), populated
only by explicit upload flows.

New: /scripts, /scripts/[id], /betas/[id] UI; /api/scripts + /api/betas +
/api/servers/[name]/scripts endpoints; scripts.ts, inventory.ts, beta.ts
(rewritten), beta-runner.ts, match-log.ts, komodoInspectHealthy().
Removed: push.ts, /api/push, BetaPanel, legacy importer.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-09-12 13:25:06 -04:00
co-authored by Claude Opus 4.7
parent 47051dc79a
commit 93d5a8e2e5
52 changed files with 2605 additions and 1689 deletions
+11 -99
View File
@@ -1,93 +1,12 @@
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';
import { readFile, stat } from 'node:fs/promises';
import { posix } from 'node:path';
// 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();
}
// Naming rules for beta artifacts, preserved from the legacy shell script.
/**
* 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.
* `foo/bar.gsc` → `foo/bar-beta.gsc`. Used to name the beta copy that lives
* next to (or in place of) the target file on each server during a beta.
*/
export function betaFile(rel: string): string {
const dir = posix.dirname(rel);
@@ -100,8 +19,8 @@ export function betaFile(rel: string): string {
}
/**
* `foo/bar.gsc` -> `foo/bar.OLD`
* Mirrors `old_file` in push_scripts.sh:458.
* `foo/bar.gsc` → `foo/bar.OLD`. Used to back up the previous target when a
* beta lands on top of it, so rollback can restore.
*/
export function oldFile(rel: string): string {
const dir = posix.dirname(rel);
@@ -112,17 +31,6 @@ export function oldFile(rel: string): string {
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');
@@ -136,3 +44,7 @@ export async function sha256OrNull(path: string): Promise<string | null> {
}
return sha256(path);
}
export function sha256Buffer(buf: Buffer): string {
return createHash('sha256').update(buf).digest('hex');
}