193 lines
8.0 KiB
TypeScript
193 lines
8.0 KiB
TypeScript
import { randomBytes } from 'node:crypto';
|
|
import { createServer as createHttpServer } from 'node:http';
|
|
import { mkdir, rm, writeFile } from 'node:fs/promises';
|
|
import { dirname, join, resolve } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { createServer, type ViteDevServer } from 'vite';
|
|
import type Database from 'better-sqlite3';
|
|
import {
|
|
FIXTURE_KEY,
|
|
FIXTURE_VERSION,
|
|
fixtureServers,
|
|
refreshFixtureData,
|
|
seedFixtureData
|
|
} from './fixture-data.js';
|
|
import { parseFixtureOptions, reserveFixture } from './fixture-options.js';
|
|
|
|
const help = `Create an isolated synthetic UI preview database.
|
|
|
|
npm run seed:fixture -- --db /tmp/ec-preview/cherry.db [--now 2026-10-07T12:00:00Z]
|
|
npm run seed:fixture -- --db /tmp/ec-live-preview/cherry.db --watch
|
|
|
|
The database's directory must not exist; its parent must exist. Existing files,
|
|
databases, symlinks and environment DB_PATH are never reused. --watch refreshes
|
|
only this new fixture's observations every 15 seconds until Ctrl+C. --now fixes
|
|
all sample times for reproducible snapshots and cannot be combined with --watch.
|
|
No web server, worker, game agent or network request is started by this command.
|
|
`;
|
|
|
|
async function main() {
|
|
const args = process.argv.slice(2);
|
|
if (args.length === 1 && ['--help', '-h'].includes(args[0])) {
|
|
console.log(help);
|
|
return;
|
|
}
|
|
const options = parseFixtureOptions(args);
|
|
process.umask(0o077);
|
|
const destination = await reserveFixture(options.dbPath);
|
|
let vite: ViteDevServer | undefined;
|
|
let closeDb: (() => void) | undefined;
|
|
let complete = false;
|
|
try {
|
|
const paths = {
|
|
DB_PATH: destination.dbPath,
|
|
SERVERS_ROOT: join(destination.root, 't5'),
|
|
T6_SERVERS_ROOT: join(destination.root, 't6'),
|
|
BETA_ARCHIVE_DIR: join(destination.root, 'beta-archive'),
|
|
GAME_FILES_ROOT: join(destination.root, 'game-files'),
|
|
SQLITE_TMPDIR: join(destination.root, 'sqlite-tmp')
|
|
};
|
|
for (const [key, value] of Object.entries(paths)) {
|
|
process.env[key] = value;
|
|
if (key !== 'DB_PATH') await mkdir(value, { mode: 0o700 });
|
|
}
|
|
Object.assign(process.env, {
|
|
RCON_PASSWORD: '',
|
|
RCON_HOST: '127.0.0.1',
|
|
EC_AUTH_ALLOW_HTTP: 'true',
|
|
DEPLOY_KEEP_DAYS: '7',
|
|
DEPLOYMENT_RESTART_CONCURRENCY: '1',
|
|
DEPLOYMENT_CREATE_CONCURRENCY: '1',
|
|
EC_SQLITE_MMAP_MB: '0',
|
|
PLUTAINER_IMAGE: 'fixture.invalid/electric-cherry/preview:local'
|
|
});
|
|
const repository = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
vite = await createServer({
|
|
root: repository,
|
|
cacheDir: join(destination.root, 'vite-cache'),
|
|
configFile: false,
|
|
envFile: false,
|
|
appType: 'custom',
|
|
// Vite 5 otherwise opens its HMR WebSocket port even with hmr:false.
|
|
// Attach it to an HTTP server that is deliberately never listened on.
|
|
server: { middlewareMode: true, hmr: { server: createHttpServer() }, watch: null },
|
|
optimizeDeps: { noDiscovery: true },
|
|
resolve: {
|
|
alias: { $lib: join(repository, 'src/lib'), $ui: join(repository, 'src/lib/ui') }
|
|
},
|
|
logLevel: 'error'
|
|
});
|
|
// Load the real migrations through Vite's ?raw support. Importing hooks
|
|
// would start queues and agents; this script never loads the app router.
|
|
const database = (await vite.ssrLoadModule('/src/lib/server/db/index.ts')) as {
|
|
db: () => Database.Database;
|
|
closeDb: () => void;
|
|
};
|
|
closeDb = database.closeDb;
|
|
const handle = database.db();
|
|
const hosts = await vite.ssrLoadModule('/src/lib/server/hosts/schema.ts');
|
|
const auth = await vite.ssrLoadModule('/src/lib/server/auth/schema.ts');
|
|
const files = await vite.ssrLoadModule('/src/lib/server/game-files/schema.ts');
|
|
hosts.ensureHostSchema(handle);
|
|
auth.ensureAuthSchema(handle);
|
|
files.ensureGameFileSchema(handle);
|
|
seedFixtureData(handle, options.now);
|
|
const password = randomBytes(24).toString('base64url');
|
|
const { hashPassword } = await vite.ssrLoadModule('/src/lib/server/auth/passwords.js');
|
|
handle
|
|
.prepare(
|
|
`INSERT INTO auth_users(id,username,display_name,password_hash,role,created_at,updated_at)
|
|
VALUES('fixture-admin','demo','Demo operator',?,'admin',?,?)`
|
|
)
|
|
.run(await hashPassword(password), options.now, options.now);
|
|
if (
|
|
(handle.pragma('foreign_key_check') as unknown[]).length ||
|
|
handle.pragma('integrity_check', { simple: true }) !== 'ok'
|
|
) {
|
|
throw new Error('Fixture integrity validation failed.');
|
|
}
|
|
const environment = {
|
|
...paths,
|
|
RCON_PASSWORD: '',
|
|
RCON_HOST: '127.0.0.1',
|
|
EC_AUTH_ALLOW_HTTP: 'true',
|
|
PLUTAINER_IMAGE: process.env.PLUTAINER_IMAGE!
|
|
};
|
|
const shellQuote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`;
|
|
await writeFile(
|
|
join(destination.root, 'preview.env'),
|
|
Object.entries(environment)
|
|
.map(([key, value]) => `export ${key}=${shellQuote(value)}`)
|
|
.join('\n') + '\n',
|
|
{ flag: 'wx', mode: 0o600 }
|
|
);
|
|
await writeFile(
|
|
join(destination.root, 'fixture.json'),
|
|
JSON.stringify(
|
|
{
|
|
format: FIXTURE_VERSION,
|
|
marker: FIXTURE_KEY,
|
|
dbPath: destination.dbPath,
|
|
createdAt: new Date(options.now).toISOString(),
|
|
username: 'demo',
|
|
password,
|
|
hosts: 3,
|
|
servers: fixtureServers.map((server) => server.name),
|
|
matches: 500,
|
|
note: 'Synthetic local data only. Host credentials cannot authenticate; game images and library IDs are fictitious.'
|
|
},
|
|
null,
|
|
2
|
|
) + '\n',
|
|
{ flag: 'wx', mode: 0o600 }
|
|
);
|
|
handle.pragma('wal_checkpoint(TRUNCATE)');
|
|
complete = true;
|
|
console.log(
|
|
`Created ${destination.dbPath}\n3 hosts · 12 servers · 500 matches · 7 days of metrics\nLocal sign-in details: ${join(destination.root, 'fixture.json')}\nPreview environment: ${join(destination.root, 'preview.env')}`
|
|
);
|
|
if (options.watch) {
|
|
refreshFixtureData(handle, Date.now());
|
|
console.log(
|
|
'Refreshing synthetic observations every 15 seconds. Leave this terminal open; Ctrl+C stops the feeder.'
|
|
);
|
|
await new Promise<void>((resolvePromise, reject) => {
|
|
const finish = (error?: unknown) => {
|
|
clearInterval(timer);
|
|
process.off('SIGINT', stop);
|
|
process.off('SIGTERM', stop);
|
|
if (error) reject(error);
|
|
else resolvePromise();
|
|
};
|
|
const stop = () => finish();
|
|
const timer = setInterval(() => {
|
|
try {
|
|
refreshFixtureData(handle, Date.now());
|
|
} catch (error) {
|
|
finish(error);
|
|
}
|
|
}, 15_000);
|
|
process.on('SIGINT', stop);
|
|
process.on('SIGTERM', stop);
|
|
});
|
|
}
|
|
} finally {
|
|
try {
|
|
closeDb?.();
|
|
} finally {
|
|
try {
|
|
await vite?.close();
|
|
} finally {
|
|
if (!complete) await rm(destination.root, { recursive: true, force: true });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
main().catch((error) => {
|
|
console.error(
|
|
`Fixture creation failed: ${error instanceof Error ? error.message : 'Unknown error'}`
|
|
);
|
|
process.exitCode = 1;
|
|
});
|