import { db } from '../db/index.js'; import { nativeInstance, runtimePasswordForServer } from '../backends/native.js'; import { getServer } from '../fleet/servers.js'; import { listServerScripts } from '../fleet/inventory.js'; import { recipeStorage, recipeSummaryForDeployment, type LaunchRecipePlan } from '../fleet/launch-recipes.js'; /** Preserve the recorded deployed inventory, including changes made after launch. */ export function captureMoveRecipe(server: string): LaunchRecipePlan { const instance = nativeInstance(server); const row = getServer(server); if (!instance || !row?.map_slug) throw new Error('A live native instance and saved map are required before moving.'); if (!runtimePasswordForServer(server)) throw new Error('The deployed runtime credentials are unavailable.'); const launch = db() .prepare( `SELECT d.id FROM server_deployments d JOIN native_provisioning n ON n.deployment_id=d.id JOIN server_launch_recipes r ON r.deployment_id=d.id WHERE n.instance_id=? AND d.state IN ('done','warning') AND d.server_deleted_at IS NULL ORDER BY d.rowid DESC LIMIT 1` ) .get(instance.id) as { id: string } | undefined; const previous = launch && recipeSummaryForDeployment(launch.id)?.recipe; if (!previous || previous.game !== row.game || previous.mapSlug !== row.map_slug) { throw new Error('The deployed launch recipe is unavailable or differs from the saved map.'); } const inventory = listServerScripts(server); if (!inventory.length) throw new Error('The deployed file inventory is unavailable.'); const files = inventory.map((file) => { const before = previous.files.find((item) => item.path === file.rel_path); const version = file.script_version_id === null ? undefined : (db() .prepare( `SELECT v.version,v.release_version,v.kind,s.name FROM script_versions v JOIN scripts s ON s.id=v.script_id WHERE v.id=?` ) .get(file.script_version_id) as | { version: number; release_version: string | null; kind: string; name: string; } | undefined); const kind = version?.kind === 'baseline' ? 'config' : (version?.kind ?? before?.kind); if (!['script', 'plugin', 'config'].includes(kind ?? '')) throw new Error(`The deployed file type is unavailable for ${file.rel_path}.`); if ( !db() .prepare('SELECT 1 FROM file_blobs WHERE sha256=? AND size=?') .get(file.sha256, file.size) ) { throw new Error(`The deployed bytes are unavailable for ${file.rel_path}.`); } return { name: version?.name ?? before?.name ?? file.rel_path, kind: kind as 'script' | 'plugin' | 'config', path: file.rel_path, versionId: file.script_version_id, version: version?.version ?? null, releaseVersion: version?.release_version ?? null, sha256: file.sha256, size: file.size }; }); if (!files.some((file) => file.path === 'electric_cherry_map.cfg' && file.kind === 'config')) { throw new Error('The deployed map configuration is unavailable.'); } return { format: 1, game: previous.game, mapSlug: previous.mapSlug, mapName: previous.mapName, expectedMap: previous.expectedMap, configFile: previous.configFile, configSource: 'Preserved deployed files', capturedAt: new Date().toISOString(), image: instance.image, native: { libraryId: instance.library_id, image: instance.image }, files }; } /** Immutable hash references keep deployed bytes alive without decoding plugins on the controller. */ export function moveRecipeStorage(recipe: LaunchRecipePlan): ReturnType { const metadata = { ...recipe, files: recipe.files.map(({ content: _content, ...file }) => file) }; const stored = recipeStorage(metadata); return { recipeSummary: stored.recipeSummary, recipe: JSON.stringify({ ...metadata, files: metadata.files.map((file) => ({ ...file, contentHash: file.sha256 })) }) }; } export function moveRecipeMatches(server: string, recipe: LaunchRecipePlan): boolean { const inventory = listServerScripts(server); return ( inventory.length === recipe.files.length && inventory.every((file) => recipe.files.some( (saved) => saved.path === file.rel_path && saved.sha256 === file.sha256 && saved.size === file.size ) ) ); }