Files
shd.build/scripts/environment-bootstrap-import.mjs
T
jb dd84dff055
Validate and deploy production / validate (push) Successful in 23s
Validate and deploy staging / validate (push) Successful in 50s
Validate and deploy production / deploy (push) Failing after 40s
Validate and deploy staging / deploy (push) Failing after 7s
feat: add community publishing and news syndication
2026-08-01 21:42:09 -04:00

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}`);