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
+355 -373
View File
@@ -1,455 +1,437 @@
import { copyFile, mkdir, readdir, readFile, rename, rm, stat } from 'node:fs/promises';
import { copyFile, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { dirname, join, posix } from 'node:path';
import { betaSrc, config, destOf } from '../config.js';
import { betaArchiveOf, config, destOf } from '../config.js';
import { db } from '../db/index.js';
import { betaFile, oldFile, sha256Buffer } from './files.js';
import { ensureDest, getServer, heldByBeta } from './servers.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';
getScript,
getVersion,
listVersions,
serversMatchingScope,
type ScriptRow,
type ScriptVersionRow
} from './scripts.js';
import { recordScript, removeScript, getServerScript } from './inventory.js';
import { emit } from '../pipeline/bus.js';
import type { BetaStage } from '../pipeline/bus.js';
// -- shared row shapes --
export type BetaTriggerKind = 'mark-required' | 'version-update' | 'unscoped';
interface BetaRow {
slug: string;
name: string;
folder: string;
opened_at: string;
updated_at: string;
revision: number;
}
interface BetaScriptRow {
export interface BetaRow {
id: number;
beta_slug: string;
staged_as: string;
target: string;
beta_file: string;
backup: string;
sha256: string;
revision: number;
script_id: number;
script_version_id: number;
trigger_kind: BetaTriggerKind;
wait_for_health: number;
wait_for_match: number;
state: 'running' | 'ready-promote' | 'promoted' | 'rolled-back';
opened_at: string;
closed_at: string | null;
notes: string | null;
}
interface BetaDeployRow {
beta_script_id: number;
export interface BetaServerRow {
beta_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;
prior_version_id: number | null;
stage: BetaStage;
error: string | null;
deployed_at: string | null;
restarted_at: string | null;
healthy_at: string | null;
match_seen_at: string | null;
updated_at: string;
}
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-such-script'
| 'no-such-version'
| 'version-script-mismatch'
| 'no-servers'
| 'unknown-server'
| 'out-of-scope'
| 'conflict'
| 'blocked-backup'
| 'empty-plan'
| 'missing-staged'
| 'no-such-beta'
| 'no-match',
| 'not-ready-promote'
| 'not-running'
| 'dest-fail'
| 'invalid-state',
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');
function nowIso(): string {
return new Date().toISOString();
}
const betaDir = `${betaSrc()}/${slug}`;
// ---------- Reads ----------
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'
export function getBeta(id: number): BetaRow | undefined {
return db().prepare('SELECT * FROM betas WHERE id = ?').get(id) as BetaRow | undefined;
}
export function listBetas(state?: BetaRow['state']): BetaRow[] {
if (state) {
return db().prepare('SELECT * FROM betas WHERE state = ? ORDER BY opened_at DESC').all(state) as BetaRow[];
}
return db().prepare('SELECT * FROM betas ORDER BY opened_at DESC').all() as BetaRow[];
}
export function listBetaServers(betaId: number): BetaServerRow[] {
return db()
.prepare('SELECT * FROM beta_servers WHERE beta_id = ? ORDER BY server')
.all(betaId) as BetaServerRow[];
}
export function findRunningBetaForScript(scriptId: number): BetaRow | undefined {
return db()
.prepare(`SELECT * FROM betas WHERE script_id = ? AND state = 'running' LIMIT 1`)
.get(scriptId) as BetaRow | undefined;
}
// ---------- openBeta ----------
export interface OpenBetaInput {
scriptId: number;
scriptVersionId: number;
servers: string[];
waitForHealth: boolean;
waitForMatch: boolean;
triggerKind: BetaTriggerKind;
notes?: string;
}
export interface OpenBetaResult {
betaId: number;
servers: string[];
}
export async function openBeta(input: OpenBetaInput): Promise<OpenBetaResult> {
const script = getScript(input.scriptId);
if (!script) throw new BetaError(`no such script (id=${input.scriptId})`, 'no-such-script');
const version = getVersion(input.scriptVersionId);
if (!version) throw new BetaError(`no such version (id=${input.scriptVersionId})`, 'no-such-version');
if (version.script_id !== script.id) {
throw new BetaError('version does not belong to this script', 'version-script-mismatch');
}
if (!input.servers || input.servers.length === 0) {
throw new BetaError('at least one server is required', 'no-servers');
}
// Look up server rows + validate scope up front.
const rows: Array<{ name: string; game: string | null; map: string | null }> = [];
for (const name of input.servers) {
const row = getServer(name);
if (!row) throw new BetaError(`unknown server: ${name}`, 'unknown-server');
rows.push({ name, game: row.game, map: row.map });
}
if (script.scope_type && script.scope_value) {
const outOfScope = rows.filter(
(r) => (script.scope_type === 'game' ? r.game : r.map) !== script.scope_value
);
}
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 (outOfScope.length > 0) {
throw new BetaError(
`servers not in scope ${script.scope_type}=${script.scope_value}`,
'out-of-scope',
outOfScope.map((r) => r.name)
);
}
}
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');
// Per-server conflict check: is another running beta already holding this
// target on this server?
const conflicts: Array<{ server: string; betaId: number }> = [];
const blocked: Array<{ server: string; ofile: string }> = [];
for (const name of input.servers) {
const held = heldByBeta(name);
const ownerId = held.get(script.target_rel_path);
if (ownerId) conflicts.push({ server: name, betaId: ownerId });
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
};
// If the target exists on-disk and .OLD already exists, refuse — that
// .OLD is either from a crashed prior beta or manual editing.
const dest = destOf(name);
const targetPath = join(dest, script.target_rel_path);
const backupPath = join(dest, oldFile(script.target_rel_path));
if (existsSync(targetPath) && existsSync(backupPath)) {
blocked.push({ server: name, ofile: oldFile(script.target_rel_path) });
}
}
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).
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');
}
// ---- commit ----
const opened = nowIso();
// Insert rows in a transaction — disk work happens after commit so we can
// await file I/O without holding the DB lock.
const insertBeta = db().prepare(
`INSERT INTO betas (slug, name, folder, opened_at, updated_at, revision)
VALUES (?, ?, ?, ?, ?, 1)`
`INSERT INTO betas (script_id, script_version_id, trigger_kind, wait_for_health, wait_for_match, state, notes)
VALUES (?, ?, ?, ?, ?, 'running', ?)`
);
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 insertBetaServer = db().prepare(
`INSERT INTO beta_servers (beta_id, server, had_original, prior_version_id, stage, updated_at)
VALUES (?, ?, ?, ?, 'deploying', ?)`
);
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();
let betaId!: number;
db().transaction(() => {
const res = insertBeta.run(
script.id,
version.id,
input.triggerKind,
input.waitForHealth ? 1 : 0,
input.waitForMatch ? 1 : 0,
input.notes ?? null
);
betaId = Number(res.lastInsertRowid);
// 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);
for (const name of input.servers) {
const prior = getServerScript(name, script.target_rel_path);
insertBetaServer.run(
betaId,
name,
prior ? 1 : 0,
prior?.script_version_id ?? null,
nowIso()
);
}
})();
// On-disk deploy: for each server, backup target → .OLD (if any) and write
// -beta.gsc from the version's content.
const bfile = betaFile(script.target_rel_path);
const ofile = oldFile(script.target_rel_path);
const archive = betaArchiveOf(betaId);
await mkdir(archive, { recursive: true });
await writeFile(join(archive, posix.basename(bfile)), version.content);
const updateStage = db().prepare(
`UPDATE beta_servers
SET stage = ?, error = ?, deployed_at = ?, updated_at = ?
WHERE beta_id = ? AND server = ?`
);
for (const name of input.servers) {
const dest = destOf(name);
const targetPath = join(dest, script.target_rel_path);
const backupPath = join(dest, ofile);
const betaPath = join(dest, bfile);
try {
await mkdir(join(dest, dirname(script.target_rel_path)), { recursive: true });
if (existsSync(targetPath)) {
await rename(targetPath, backupPath);
// Move inventory row: target → .OLD (keep prior sha/version).
const prior = getServerScript(name, script.target_rel_path);
removeScript(name, script.target_rel_path);
if (prior) {
recordScript(name, ofile, prior.script_version_id, prior.sha256, prior.size, `beta:${betaId}`);
}
}
await writeFile(betaPath, version.content);
recordScript(name, bfile, version.id, version.sha256, version.size, `beta:${betaId}`);
const t = nowIso();
updateStage.run('awaiting-empty', null, t, t, betaId, name);
emit({ type: 'beta-server-stage', betaId, server: name, stage: 'awaiting-empty', error: null });
} catch (err) {
const t = nowIso();
const msg = (err as Error).message;
updateStage.run('failed', `deploy: ${msg}`, t, t, betaId, name);
emit({ type: 'beta-server-stage', betaId, server: name, stage: 'failed', error: `deploy: ${msg}` });
}
}
// 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));
}
emit({ type: 'beta-opened', betaId, scriptId: script.id, servers: input.servers });
// 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
};
return { betaId, servers: input.servers };
}
// -- 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) --
// ---------- rollback ----------
export interface RollbackResult {
slug: string;
betaId: number;
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');
export async function rollbackBeta(betaId: number): Promise<RollbackResult> {
const beta = getBeta(betaId);
if (!beta) throw new BetaError(`no such beta (id=${betaId})`, 'no-such-beta');
if (beta.state !== 'running' && beta.state !== 'ready-promote') {
throw new BetaError(`beta is ${beta.state}, cannot rollback`, 'invalid-state');
}
const script = getScript(beta.script_id)!;
const bfile = betaFile(script.target_rel_path);
const ofile = oldFile(script.target_rel_path);
const betaDir = `${betaSrc()}/${slug}`;
const deploys = listBetaDeploys(slug);
const rows = listBetaServers(betaId);
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 bs of rows) {
const dest = destOf(bs.server);
const betaPath = join(dest, bfile);
const backupPath = join(dest, ofile);
const targetPath = join(dest, script.target_rel_path);
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);
// Remove -beta.gsc regardless of stage.
if (existsSync(betaPath)) await rm(betaPath, { force: true });
removeScript(bs.server, bfile);
if (bs.had_original) {
if (existsSync(backupPath)) {
await rename(backupPath, targetPath);
removeScript(bs.server, ofile);
// Restore inventory row for target with prior version bytes.
const prior = bs.prior_version_id ? getVersion(bs.prior_version_id) : null;
if (prior) {
recordScript(bs.server, script.target_rel_path, prior.id, prior.sha256, prior.size, `rollback:${betaId}`);
} else {
// We had_original but lost the prior version reference — hash the file to record what's there.
const buf = await readFile(targetPath);
recordScript(
bs.server,
script.target_rel_path,
null,
sha256Buffer(buf),
buf.byteLength,
`rollback:${betaId}`
);
}
restored.push(bs.server);
} else {
warnings.push(`${d.server}: no ${d.backup} to restore`);
warnings.push(`${bs.server}: no ${ofile} to restore`);
}
} else {
dropped.push(d.target);
dropped.push(bs.server);
}
}
if (existsSync(betaDir)) await rm(betaDir, { recursive: true, force: true });
db().prepare('DELETE FROM betas WHERE slug = ?').run(slug); // cascades to scripts/deploys
// Wipe archive + close out beta.
const archive = betaArchiveOf(betaId);
if (existsSync(archive)) await rm(archive, { recursive: true, force: true });
return { slug, restored, dropped, warnings, dryRun: false };
db()
.prepare(`UPDATE betas SET state = 'rolled-back', closed_at = ? WHERE id = ?`)
.run(nowIso(), betaId);
emit({ type: 'beta-state', betaId, state: 'rolled-back' });
return { betaId, restored, dropped, warnings };
}
// -- promote (do_promote, push_scripts.sh:1325) --
// ---------- promote ----------
export interface PromoteResult {
slug: string;
landed: string[];
dryRun: boolean;
betaId: number;
landed: string[]; // servers we wrote the promoted target to
versionId: number;
}
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));
export async function promoteBeta(betaId: number): Promise<PromoteResult> {
const beta = getBeta(betaId);
if (!beta) throw new BetaError(`no such beta (id=${betaId})`, 'no-such-beta');
if (beta.state !== 'ready-promote') {
throw new BetaError(`beta must be ready-promote (is ${beta.state})`, 'not-ready-promote');
}
if (lost.length > 0)
throw new BetaError('scripts missing from betas dir — cannot promote', 'missing-staged', lost);
const script = getScript(beta.script_id)!;
const version = getVersion(beta.script_version_id)!;
const bfile = betaFile(script.target_rel_path);
const ofile = oldFile(script.target_rel_path);
const deploys = listBetaDeploys(slug);
const betaServers = listBetaServers(betaId).map((r) => r.server);
// For scoped scripts, promotion applies to every matching server (whether
// or not they were in the beta). For unscoped, just the beta servers.
const scopeServers = script.scope_type && script.scope_value
? serversMatchingScope(script.scope_type, script.scope_value)
: [];
const allTargets = Array.from(new Set([...betaServers, ...scopeServers]));
if (dryRun) {
return { slug, landed: scripts.map((s) => s.target), dryRun: true };
const landed: string[] = [];
for (const name of allTargets) {
const dest = destOf(name);
const targetPath = join(dest, script.target_rel_path);
const betaPath = join(dest, bfile);
const backupPath = join(dest, ofile);
try {
await mkdir(join(dest, dirname(script.target_rel_path)), { recursive: true });
if (existsSync(betaPath)) {
await rm(betaPath, { force: true });
removeScript(name, bfile);
}
if (existsSync(backupPath)) {
await rm(backupPath, { force: true });
removeScript(name, ofile);
}
await writeFile(targetPath, version.content);
recordScript(name, script.target_rel_path, version.id, version.sha256, version.size, `promote:${betaId}`);
landed.push(name);
} catch (err) {
// Best-effort continue; error will surface via the beta detail UI.
console.error(`[promote] ${name} failed:`, err);
}
}
// 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 });
}
// Update the script's canonical version and close out the beta + wipe archive.
db().prepare('UPDATE scripts SET current_version_id = ? WHERE id = ?').run(version.id, script.id);
db().prepare(`UPDATE betas SET state = 'promoted', closed_at = ? WHERE id = ?`).run(nowIso(), betaId);
// 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));
}
const archive = betaArchiveOf(betaId);
if (existsSync(archive)) await rm(archive, { recursive: true, force: true });
if (existsSync(betaDir)) await rm(betaDir, { recursive: true, force: true });
db().prepare('DELETE FROM betas WHERE slug = ?').run(slug);
emit({ type: 'beta-state', betaId, state: 'promoted' });
// 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 };
return { betaId, landed, versionId: version.id };
}
// ---------- helpers used by API layer ----------
// Latest of the script's versions — useful for the "open beta with this
// version" default in the UI.
export function latestVersionForScript(script: ScriptRow): ScriptVersionRow | null {
const rows = listVersions(script.id);
return rows[0] ?? null;
}
/**
* Un-set a script's scope. When scope is being removed and matching servers
* currently have the script, remove it from them for consistency (per plan:
* "un-marking auto-removes from matching servers, no beta"). Returns the
* server names that had files deleted.
*/
export async function unsetScopeAndCleanup(scriptId: number): Promise<string[]> {
const script = getScript(scriptId);
if (!script) throw new BetaError(`no such script (id=${scriptId})`, 'no-such-script');
if (!script.scope_type || !script.scope_value) return [];
const matched = serversMatchingScope(script.scope_type, script.scope_value);
const cleaned: string[] = [];
for (const name of matched) {
const dest = destOf(name);
const targetPath = join(dest, script.target_rel_path);
if (existsSync(targetPath)) {
await rm(targetPath, { force: true });
removeScript(name, script.target_rel_path);
cleaned.push(name);
}
}
db().prepare('UPDATE scripts SET scope_type = NULL, scope_value = NULL WHERE id = ?').run(scriptId);
return cleaned;
}