import { connect } from '@tursodatabase/database'; import { spawnSync } from 'node:child_process'; import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { join, resolve } from 'node:path'; import process from 'node:process'; const bundleArgument = process.argv.slice(2).find((argument) => !argument.startsWith('--')); const targetIndex = process.argv.indexOf('--target'); const target = targetIndex >= 0 ? process.argv[targetIndex + 1] : ''; const allowed = target === 'staging' ? process.env.ALLOW_STAGING_BOOTSTRAP === 'true' : target === 'production' ? process.env.ALLOW_PRODUCTION_BOOTSTRAP === 'true' : false; if (!bundleArgument || !process.argv.includes('--confirm-empty-target-replace') || !allowed) { console.error('Environment bootstrap requires the matching ALLOW_*_BOOTSTRAP=true guard and:'); console.error('node scripts/environment-bootstrap-import.mjs --target --confirm-empty-target-replace'); process.exit(2); } const bundlePath = resolve(bundleArgument); const bundle = JSON.parse(await readFile(bundlePath, 'utf8')); if (bundle?.format !== 'shd-build-environment-bootstrap' || bundle?.version !== 1 || bundle?.target !== target || bundle.database?.format !== 'shd-build-database-backup' || bundle.database?.version !== 1 || !Array.isArray(bundle.database.tables) || !Array.isArray(bundle.files?.avatars) || !Array.isArray(bundle.files?.gameIcons)) { throw new Error('The selected file is not a supported SHD.build bootstrap bundle for the selected target.'); } for (const file of bundle.files.avatars) { if (typeof file.name !== 'string' || !/^[0-9a-f-]{36}\.webp$/i.test(file.name) || typeof file.contentBase64 !== 'string') { throw new Error('The bootstrap bundle contains an invalid avatar file.'); } } for (const file of bundle.files.gameIcons) { if (typeof file.name !== 'string' || !/^[a-z_]+-[0-9a-f-]{36}\.webp$/i.test(file.name) || typeof file.contentBase64 !== 'string') { throw new Error('The bootstrap bundle contains an invalid game-data icon file.'); } } const databasePath = resolve(process.env.TDR_DATABASE_PATH || `./data/shd-build-${target}.db`); const safetyBackup = spawnSync(process.execPath, ['scripts/db-backup.mjs'], { cwd: process.cwd(), stdio: 'inherit', env: process.env }); if (safetyBackup.error) throw safetyBackup.error; if (safetyBackup.status !== 0) throw new Error(`${target} safety backup failed; the target was not changed.`); const database = await connect(databasePath); for (const tableName of ['users', 'builds', 'audit_log', 'data_change_proposals']) { const row = await (await database.prepare(`SELECT COUNT(*) AS count FROM "${tableName}"`)).get(); if (Number(row?.count ?? 0) > 0) { throw new Error(`${target} bootstrap refused: target table "${tableName}" is not empty.`); } } const targetTables = await (await database.prepare(` SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY rowid `)).all(); const targetNames = new Set(targetTables.map((row) => String(row.name))); const sourceNames = new Set(bundle.database.tables.map((table) => String(table.name))); for (const row of targetTables) { if (!sourceNames.has(String(row.name))) { throw new Error(`Bootstrap snapshot is missing target-schema table "${String(row.name)}".`); } } const sourceOnlyTables = bundle.database.tables .map((table) => String(table.name)) .filter((name) => !targetNames.has(name)); if (sourceOnlyTables.length) { console.log(`Skipping source-only legacy tables: ${sourceOnlyTables.join(', ')}`); } const quoteIdentifier = (identifier) => `"${String(identifier).replaceAll('"', '""')}"`; let restoredRows = 0; await database.exec('PRAGMA foreign_keys=OFF'); await database.exec('BEGIN'); try { for (const row of [...targetTables].reverse()) { await database.exec(`DELETE FROM ${quoteIdentifier(row.name)}`); } for (const table of bundle.database.tables.filter((candidate) => targetNames.has(candidate.name))) { for (const row of table.rows) { const columns = Object.keys(row); if (!columns.length) continue; const insert = await database.prepare( `INSERT INTO ${quoteIdentifier(table.name)} (${columns.map(quoteIdentifier).join(', ')}) VALUES (${columns.map(() => '?').join(', ')})` ); await insert.run(...columns.map((column) => row[column])); restoredRows++; } } await database.exec('COMMIT'); } catch (error) { await database.exec('ROLLBACK'); throw error; } finally { await database.exec('PRAGMA foreign_keys=ON'); } const avatarDirectory = resolve(process.env.TDR_AVATAR_STORAGE_PATH || './data/avatars'); const gameIconDirectory = resolve(process.env.TDR_GAME_ICON_STORAGE_PATH || './data/game-icons'); await mkdir(avatarDirectory, { recursive: true }); await mkdir(gameIconDirectory, { recursive: true }); for (const file of bundle.files.avatars) { await writeFile(join(avatarDirectory, file.name), Buffer.from(file.contentBase64, 'base64'), { mode: 0o600 }); } for (const file of bundle.files.gameIcons) { await writeFile(join(gameIconDirectory, file.name), Buffer.from(file.contentBase64, 'base64'), { mode: 0o600 }); } console.log(`Bootstrapped ${target} with ${restoredRows} database rows, ${bundle.files.avatars.length} avatars, and ${bundle.files.gameIcons.length} managed image assets.`); console.log(`Source bundle: ${bundlePath}`);