Add Prettier and ESLint tooling (Refs #77)

This commit is contained in:
2026-10-09 15:56:13 -04:00
parent 8ed059b8d3
commit 16469e2d43
711 changed files with 99737 additions and 28286 deletions
+248 -68
View File
@@ -1,12 +1,20 @@
import { packFileJson, unpackFileJson } from '../db/file-blobs.js';
import { randomUUID } from 'node:crypto';
import type { BlueprintSummary, LaunchRecipeSummary, LaunchFileSummary } from '$lib/deployment-plan.js';
import type {
BlueprintSummary,
LaunchRecipeSummary,
LaunchFileSummary
} from '$lib/deployment-plan.js';
import { config, configFileForGame } from '../config.js';
import { db } from '../db/index.js';
import { zombieMapBySlug, type ZombieGame } from './map-aliases.js';
import { promotedArtifactMetadata, promotedArtifacts } from './promoted-artifacts.js';
import { getPromotedServerConfig, getServerMapConfig, MAP_CONFIG_FILE } from './server-config.js';
import { getBaselineForScope, getVersionMetadata, listPromotedOverlaysForScope } from './scripts.js';
import {
getBaselineForScope,
getVersionMetadata,
listPromotedOverlaysForScope
} from './scripts.js';
import { sha256Buffer } from './files.js';
export interface LaunchRecipePlan {
@@ -28,165 +36,337 @@ export interface LaunchRecipe extends LaunchRecipePlan {
files: (LaunchFileSummary & { content: string })[];
}
export interface BlueprintEntry { count: number; recipe: LaunchRecipePlan }
interface BlueprintSelection { count: number; game: ZombieGame; mapSlug: string }
interface BlueprintRow { id: string; name: string; description: string; created_at: string; recipe: string }
export interface BlueprintEntry {
count: number;
recipe: LaunchRecipePlan;
}
interface BlueprintSelection {
count: number;
game: ZombieGame;
mapSlug: string;
}
interface BlueprintRow {
id: string;
name: string;
description: string;
created_at: string;
recipe: string;
}
/** Legacy snapshots provide the lineup only; their file versions never govern a new launch. */
function readEntries(raw: string): BlueprintSelection[] {
const saved = JSON.parse(raw) as LaunchRecipe | { format: 2; entries: BlueprintEntry[] }
const saved = JSON.parse(raw) as
| LaunchRecipe
| { format: 2; entries: BlueprintEntry[] }
| { format: 3; entries: BlueprintSelection[] };
if (saved.format === 3) return saved.entries;
const entries = saved.format === 2 ? saved.entries : [{ count: 1, recipe: saved }];
return entries.map(({ count, recipe }) => ({ count, game: recipe.game, mapSlug: recipe.mapSlug }));
return entries.map(({ count, recipe }) => ({
count,
game: recipe.game,
mapSlug: recipe.mapSlug
}));
}
function summarizeBlueprint(row: BlueprintRow): BlueprintSummary {
const entries = readEntries(row.recipe).map(entry => ({ ...entry,
mapName: zombieMapBySlug(entry.game, entry.mapSlug)?.friendly ?? entry.mapSlug }));
return { id: row.id, name: row.name, description: row.description, createdAt: row.created_at,
entries, totalServers: entries.reduce((total, entry) => total + entry.count, 0) };
const entries = readEntries(row.recipe).map((entry) => ({
...entry,
mapName: zombieMapBySlug(entry.game, entry.mapSlug)?.friendly ?? entry.mapSlug
}));
return {
id: row.id,
name: row.name,
description: row.description,
createdAt: row.created_at,
entries,
totalServers: entries.reduce((total, entry) => total + entry.count, 0)
};
}
export function blueprintEntries(id: string): { entries: BlueprintEntry[]; blueprintName: string } {
const row = db().prepare('SELECT * FROM server_blueprints WHERE id = ?').get(id) as BlueprintRow | undefined;
if (!row) throw new Error('This collection is no longer available. Choose another starting point.');
const entries = readEntries(row.recipe).map(({ count, game, mapSlug }) => ({ count, recipe: captureRecipePlan(game, mapSlug) }));
const row = db().prepare('SELECT * FROM server_blueprints WHERE id = ?').get(id) as
BlueprintRow | undefined;
if (!row)
throw new Error('This collection is no longer available. Choose another starting point.');
const entries = readEntries(row.recipe).map(({ count, game, mapSlug }) => ({
count,
recipe: captureRecipePlan(game, mapSlug)
}));
return { entries, blueprintName: row.name };
}
function buildEntries(input: unknown): BlueprintSelection[] {
if (!Array.isArray(input) || input.length < 1 || input.length > 40) throw new Error('Add between 1 and 40 map selections.');
if (!Array.isArray(input) || input.length < 1 || input.length > 40)
throw new Error('Add between 1 and 40 map selections.');
const seen = new Set<string>();
const entries = input.map(entry => {
if (!entry || (entry.game !== 't5' && entry.game !== 't6') || typeof entry.mapSlug !== 'string'
|| !zombieMapBySlug(entry.game, entry.mapSlug)) throw new Error('Each selection needs a supported game and map.');
if (!Number.isSafeInteger(entry.count) || entry.count < 1 || entry.count > 15) throw new Error('Choose 1–15 lobbies for each map.');
const entries = input.map((entry) => {
if (
!entry ||
(entry.game !== 't5' && entry.game !== 't6') ||
typeof entry.mapSlug !== 'string' ||
!zombieMapBySlug(entry.game, entry.mapSlug)
)
throw new Error('Each selection needs a supported game and map.');
if (!Number.isSafeInteger(entry.count) || entry.count < 1 || entry.count > 15)
throw new Error('Choose 1–15 lobbies for each map.');
const key = `${entry.game}:${entry.mapSlug}`;
if (seen.has(key)) throw new Error('Each game/map can appear once. Increase its lobby count instead.');
if (seen.has(key))
throw new Error('Each game/map can appear once. Increase its lobby count instead.');
seen.add(key);
return { count: entry.count as number, game: entry.game as ZombieGame, mapSlug: entry.mapSlug as string };
return {
count: entry.count as number,
game: entry.game as ZombieGame,
mapSlug: entry.mapSlug as string
};
});
if (entries.reduce((total, entry) => total + entry.count, 0) > 100) throw new Error('A collection can launch at most 100 servers.');
if (entries.reduce((total, entry) => total + entry.count, 0) > 100)
throw new Error('A collection can launch at most 100 servers.');
return entries;
}
export function captureRecipe(game: ZombieGame, mapSlug: string, rconPassword?: string): LaunchRecipe {
export function captureRecipe(
game: ZombieGame,
mapSlug: string,
rconPassword?: string
): LaunchRecipe {
return buildRecipe(game, mapSlug, true, rconPassword);
}
/** Preview and admission keep file references; execution captures the current bytes. */
export function captureRecipePlan(game: ZombieGame, mapSlug: string, rconPassword?: string): LaunchRecipePlan {
export function captureRecipePlan(
game: ZombieGame,
mapSlug: string,
rconPassword?: string
): LaunchRecipePlan {
return buildRecipe(game, mapSlug, false, rconPassword);
}
function buildRecipe(game: ZombieGame, mapSlug: string, includeContent: true, rconPassword?: string): LaunchRecipe;
function buildRecipe(game: ZombieGame, mapSlug: string, includeContent: false, rconPassword?: string): LaunchRecipePlan;
function buildRecipe(game: ZombieGame, mapSlug: string, includeContent: boolean, rconPassword?: string): LaunchRecipePlan {
function buildRecipe(
game: ZombieGame,
mapSlug: string,
includeContent: true,
rconPassword?: string
): LaunchRecipe;
function buildRecipe(
game: ZombieGame,
mapSlug: string,
includeContent: false,
rconPassword?: string
): LaunchRecipePlan;
function buildRecipe(
game: ZombieGame,
mapSlug: string,
includeContent: boolean,
rconPassword?: string
): LaunchRecipePlan {
const map = zombieMapBySlug(game, mapSlug);
if (!map) throw new Error('Choose a supported game and map.');
const metadata = ({ kind, script, version }: ReturnType<typeof promotedArtifactMetadata>[number]): LaunchFileSummary => ({
name: script.name, kind, path: script.target_rel_path, versionId: version.id,
version: version.version, sha256: version.sha256, size: version.size
const metadata = ({
kind,
script,
version
}: ReturnType<typeof promotedArtifactMetadata>[number]): LaunchFileSummary => ({
name: script.name,
kind,
path: script.target_rel_path,
versionId: version.id,
version: version.version,
sha256: version.sha256,
size: version.size
});
const files: LaunchRecipePlan['files'] = includeContent
? promotedArtifacts(game, map.canonical).map(artifact => ({ ...metadata(artifact), content: artifact.version.content.toString('base64') }))
? promotedArtifacts(game, map.canonical).map((artifact) => ({
...metadata(artifact),
content: artifact.version.content.toString('base64')
}))
: promotedArtifactMetadata(game, map.canonical).map(metadata);
const addConfig = (name: string, path: string, content: Buffer) => files.push({
name, path, kind: 'config', versionId: null, version: null,
sha256: sha256Buffer(content), size: content.length, ...(includeContent ? { content: content.toString('base64') } : {})
});
const addConfig = (name: string, path: string, content: Buffer) =>
files.push({
name,
path,
kind: 'config',
versionId: null,
version: null,
sha256: sha256Buffer(content),
size: content.length,
...(includeContent ? { content: content.toString('base64') } : {})
});
const composed = getPromotedServerConfig(game, map.canonical, map.slug, rconPassword);
let configSource = 'Plutainer defaults, seeded on first boot';
if (composed) {
const baseline = getBaselineForScope('game', game)!;
const layers = [baseline, ...listPromotedOverlaysForScope('game', game), ...listPromotedOverlaysForScope('map', map.canonical)];
configSource = layers.map(layer => `${layer.name} v${getVersionMetadata(layer.current_version_id!)!.version}`).join(' + ');
const layers = [
baseline,
...listPromotedOverlaysForScope('game', game),
...listPromotedOverlaysForScope('map', map.canonical)
];
configSource = layers
.map(
(layer) =>
`${layer.name} v${getVersionMetadata(layer.current_version_id!)!.version}`
)
.join(' + ');
addConfig('Composed server configuration', configFileForGame(game), composed);
}
addConfig('Selected map', MAP_CONFIG_FILE, getServerMapConfig(game, map.canonical, map.slug)!);
return { format: 1, game, mapSlug, mapName: map.friendly, expectedMap: map.canonical,
capturedAt: new Date().toISOString(), image: config.defaultRuntimeImage,
configFile: configFileForGame(game), configSource, files };
return {
format: 1,
game,
mapSlug,
mapName: map.friendly,
expectedMap: map.canonical,
capturedAt: new Date().toISOString(),
image: config.defaultRuntimeImage,
configFile: configFileForGame(game),
configSource,
files
};
}
export function summarizeRecipe(recipe: LaunchRecipePlan, fingerprint = sha256Buffer(Buffer.from(JSON.stringify(recipe)))): LaunchRecipeSummary {
export function summarizeRecipe(
recipe: LaunchRecipePlan,
fingerprint = sha256Buffer(Buffer.from(JSON.stringify(recipe)))
): LaunchRecipeSummary {
const { format: _format, files, ...summary } = recipe;
return { ...summary, files: files.map(({ content: _content, ...file }) => file),
fingerprint };
return { ...summary, files: files.map(({ content: _content, ...file }) => file), fingerprint };
}
/** Keep the small detail response beside the immutable attempted file bytes. */
export function recipeStorage(recipe: LaunchRecipePlan): { recipe: string; recipeSummary: string } {
const encoded = JSON.stringify(recipe);
return { recipe: JSON.stringify(packFileJson(recipe).value), recipeSummary: JSON.stringify(summarizeRecipe(recipe, sha256Buffer(Buffer.from(encoded)))) };
return {
recipe: JSON.stringify(packFileJson(recipe).value),
recipeSummary: JSON.stringify(summarizeRecipe(recipe, sha256Buffer(Buffer.from(encoded))))
};
}
export function recipeSummaryForDeployment(id: string): { recipe: LaunchRecipeSummary; blueprintName: string | null } | null {
const row = db().prepare('SELECT recipe_summary, blueprint_name FROM server_launch_recipes WHERE deployment_id = ?')
export function recipeSummaryForDeployment(
id: string
): { recipe: LaunchRecipeSummary; blueprintName: string | null } | null {
const row = db()
.prepare(
'SELECT recipe_summary, blueprint_name FROM server_launch_recipes WHERE deployment_id = ?'
)
.get(id) as { recipe_summary: string | null; blueprint_name: string | null } | undefined;
if (!row) return null;
if (row.recipe_summary !== null) return { recipe: JSON.parse(row.recipe_summary), blueprintName: row.blueprint_name };
if (row.recipe_summary !== null)
return { recipe: JSON.parse(row.recipe_summary), blueprintName: row.blueprint_name };
// Old snapshots remain readable. Populate on first access, without making
// controller startup scan and decode every retained plugin in its history.
const launch = recipeForDeployment(id)!;
const summary = summarizeRecipe(launch.recipe);
db().prepare('UPDATE server_launch_recipes SET recipe_summary = ? WHERE deployment_id = ?').run(JSON.stringify(summary), id);
db()
.prepare('UPDATE server_launch_recipes SET recipe_summary = ? WHERE deployment_id = ?')
.run(JSON.stringify(summary), id);
return { recipe: summary, blueprintName: row.blueprint_name };
}
export function recipeForDeployment(id: string): { recipe: LaunchRecipePlan; blueprintName: string | null } | null {
const row = db().prepare('SELECT recipe, blueprint_name FROM server_launch_recipes WHERE deployment_id = ?')
export function recipeForDeployment(
id: string
): { recipe: LaunchRecipePlan; blueprintName: string | null } | null {
const row = db()
.prepare('SELECT recipe, blueprint_name FROM server_launch_recipes WHERE deployment_id = ?')
.get(id) as { recipe: string; blueprint_name: string | null } | undefined;
return row ? { recipe: unpackFileJson(JSON.parse(row.recipe)), blueprintName: row.blueprint_name } : null;
return row
? { recipe: unpackFileJson(JSON.parse(row.recipe)), blueprintName: row.blueprint_name }
: null;
}
export function blueprintRecipe(id: string): { recipe: LaunchRecipePlan; blueprintName: string } {
const { entries, blueprintName } = blueprintEntries(id);
if (entries.length !== 1 || entries[0].count !== 1) throw new Error('This collection launches multiple servers. Use the collection preview.');
if (entries.length !== 1 || entries[0].count !== 1)
throw new Error('This collection launches multiple servers. Use the collection preview.');
return { recipe: entries[0].recipe, blueprintName };
}
export function listBlueprints(): BlueprintSummary[] {
return (db().prepare('SELECT * FROM server_blueprints ORDER BY name COLLATE NOCASE').all() as BlueprintRow[]).map(summarizeBlueprint);
return (
db()
.prepare('SELECT * FROM server_blueprints ORDER BY name COLLATE NOCASE')
.all() as BlueprintRow[]
).map(summarizeBlueprint);
}
function blueprintName(input: { name: unknown; description?: unknown }, exceptId = ''): string {
if (typeof input.name !== 'string' || !input.name.trim() || input.name.trim().length > 80) throw new Error('Give the collection a name of 1–80 characters.');
if (input.description !== undefined && (typeof input.description !== 'string' || input.description.length > 500)) throw new Error('Keep the description to 500 characters.');
if (db().prepare('SELECT id FROM server_blueprints WHERE name = ? COLLATE NOCASE AND id != ?').get(input.name.trim(), exceptId)) throw new Error('A collection with this name already exists. Choose a new name.');
if (typeof input.name !== 'string' || !input.name.trim() || input.name.trim().length > 80)
throw new Error('Give the collection a name of 1–80 characters.');
if (
input.description !== undefined &&
(typeof input.description !== 'string' || input.description.length > 500)
)
throw new Error('Keep the description to 500 characters.');
if (
db()
.prepare('SELECT id FROM server_blueprints WHERE name = ? COLLATE NOCASE AND id != ?')
.get(input.name.trim(), exceptId)
)
throw new Error('A collection with this name already exists. Choose a new name.');
return input.name.trim();
}
export function saveBlueprint(input: { name: unknown; description?: unknown; previewId?: unknown; deploymentId?: unknown; entries?: unknown }): BlueprintSummary {
export function saveBlueprint(input: {
name: unknown;
description?: unknown;
previewId?: unknown;
deploymentId?: unknown;
entries?: unknown;
}): BlueprintSummary {
const name = blueprintName(input);
if ([input.previewId, input.deploymentId, input.entries].filter(value => value !== undefined).length !== 1) throw new Error('Choose exactly one launch preview, recorded deployment, or set of maps.');
if (
[input.previewId, input.deploymentId, input.entries].filter((value) => value !== undefined)
.length !== 1
)
throw new Error('Choose exactly one launch preview, recorded deployment, or set of maps.');
let recipe: LaunchRecipePlan | undefined;
if (typeof input.previewId === 'string') {
const row = db().prepare('SELECT recipe FROM server_launch_previews WHERE id = ? AND expires_at > ?')
const row = db()
.prepare('SELECT recipe FROM server_launch_previews WHERE id = ? AND expires_at > ?')
.get(input.previewId, new Date().toISOString()) as { recipe: string } | undefined;
if (row) recipe = JSON.parse(row.recipe);
} else if (typeof input.deploymentId === 'string') recipe = recipeForDeployment(input.deploymentId)?.recipe;
if (!recipe && input.entries === undefined) throw new Error('The launch recipe is unavailable or the preview expired. Generate a fresh preview.');
const entries = input.entries !== undefined ? buildEntries(input.entries)
: buildEntries([{ count: 1, game: recipe!.game, mapSlug: recipe!.mapSlug }]);
} else if (typeof input.deploymentId === 'string')
recipe = recipeForDeployment(input.deploymentId)?.recipe;
if (!recipe && input.entries === undefined)
throw new Error(
'The launch recipe is unavailable or the preview expired. Generate a fresh preview.'
);
const entries =
input.entries !== undefined
? buildEntries(input.entries)
: buildEntries([{ count: 1, game: recipe!.game, mapSlug: recipe!.mapSlug }]);
const id = randomUUID();
const createdAt = new Date().toISOString();
const saved = JSON.stringify({ format: 3, entries });
db().prepare('INSERT INTO server_blueprints (id, name, description, created_at, recipe) VALUES (?, ?, ?, ?, ?)')
db()
.prepare(
'INSERT INTO server_blueprints (id, name, description, created_at, recipe) VALUES (?, ?, ?, ?, ?)'
)
.run(id, name, input.description ?? '', createdAt, saved);
return summarizeBlueprint({ id, name, description: (input.description as string) ?? '', created_at: createdAt, recipe: saved });
return summarizeBlueprint({
id,
name,
description: (input.description as string) ?? '',
created_at: createdAt,
recipe: saved
});
}
export function updateBlueprint(id: string, input: { name: unknown; description?: unknown; entries?: unknown }): BlueprintSummary {
export function updateBlueprint(
id: string,
input: { name: unknown; description?: unknown; entries?: unknown }
): BlueprintSummary {
return db().transaction(() => {
const row = db().prepare('SELECT * FROM server_blueprints WHERE id = ?').get(id) as BlueprintRow | undefined;
const row = db().prepare('SELECT * FROM server_blueprints WHERE id = ?').get(id) as
BlueprintRow | undefined;
if (!row) throw new Error('Collection not found.');
const name = blueprintName(input, id);
const entries = buildEntries(input.entries);
const recipe = JSON.stringify({ format: 3, entries });
const description = (input.description as string) ?? '';
db().prepare('UPDATE server_blueprints SET name = ?, description = ?, recipe = ? WHERE id = ?').run(name, description, recipe, id);
db()
.prepare(
'UPDATE server_blueprints SET name = ?, description = ?, recipe = ? WHERE id = ?'
)
.run(name, description, recipe, id);
return summarizeBlueprint({ ...row, name, description, recipe });
})();
}