182 lines
5.5 KiB
JavaScript
182 lines
5.5 KiB
JavaScript
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 <bundle.json> --target <staging|production> --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) {
|
|
throw new Error(
|
|
`Bootstrap snapshot contains tables outside the current target schema: ${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) {
|
|
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}`);
|