/** * Writes the reporter settings into the instance database before first launch, * and optionally restores a full backup from the master. * * lib/db.ts seeds its keys with INSERT OR IGNORE, so values written here * survive the app's own startup seeding. Must be run from the project root * unless AUTOFIRMER_DB points elsewhere — lib/db.ts opens the database at * process.cwd() by default, and this script must hit the same file. * * node scripts/seed-settings.js \ * [--token ] [--restore-from ] * * --restore-from pulls that instance's encrypted backup from the master * (authenticated by --token) and applies it — settings, firm credentials, * instruments, account assignments — before the app has ever booted, so the * very first hello to the master already presents the adopted uuid. The * backup's instance_name wins over the CLI argument. */ const path = require('path'); const { mkdirSync } = require('fs'); const Database = require('better-sqlite3'); const args = process.argv.slice(2); const positional = []; let token = ''; let restoreFrom = ''; for (let i = 0; i < args.length; i++) { if (args[i] === '--token') token = args[++i] ?? ''; else if (args[i] === '--restore-from') restoreFrom = args[++i] ?? ''; else positional.push(args[i]); } const [url, name] = positional; const DB_PATH = process.env.AUTOFIRMER_DB ? path.resolve(process.env.AUTOFIRMER_DB) : path.join(process.cwd(), 'autotrader.sqlite'); mkdirSync(path.dirname(DB_PATH), { recursive: true }); const db = new Database(DB_PATH); db.pragma('journal_mode = WAL'); db.pragma('busy_timeout = 5000'); // Mirrors the schema in lib/db.ts for every table a restore touches. The app // has usually never run at this point, so the tables may not exist yet; the // shapes must be kept in sync with lib/db.ts by hand. db.exec(` CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS firm_credentials ( firm_id TEXT PRIMARY KEY, firm_name TEXT NOT NULL, username TEXT NOT NULL, password TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS instruments ( symbol TEXT PRIMARY KEY, enabled INTEGER NOT NULL DEFAULT 1 ); CREATE TABLE IF NOT EXISTS account_assignments ( account_id INTEGER PRIMARY KEY, firm_id TEXT NOT NULL, account_type_id TEXT, manual INTEGER NOT NULL DEFAULT 0, resolved_at TEXT NOT NULL ); `); try { db.exec("ALTER TABLE firm_credentials ADD COLUMN api_key TEXT NOT NULL DEFAULT ''"); } catch { // Column already exists } const upsert = db.prepare( 'INSERT INTO settings (key, value) VALUES (?, ?) ' + 'ON CONFLICT(key) DO UPDATE SET value = excluded.value' ); if (url) { upsert.run('master_dashboard_url', url); console.log(' master_dashboard_url = ' + url); } if (name) { upsert.run('instance_name', name); console.log(' instance_name = ' + name); } if (token) { upsert.run('master_backup_token', token); console.log(' master_backup_token = (set)'); } // Runtime bookkeeping keys that describe the machine the backup was taken on, // not the one being provisioned. Kept in sync with lib/backup-restore.ts. const RESTORE_SKIP_KEYS = new Set([ 'runner_heartbeat', 'catalog_last_sync_at', 'catalog_last_error', 'backup_last_push_at', 'backup_last_error', ]); async function restore() { if (!restoreFrom) return; if (!url) { console.error('--restore-from needs the master url argument'); process.exit(1); } if (!token) { console.error('--restore-from needs --token'); process.exit(1); } const endpoint = new URL(`/api/instances/backup/${restoreFrom}`, url); console.log(` restoring from ${endpoint}`); const res = await fetch(endpoint, { headers: { Authorization: `Bearer ${token}` }, signal: AbortSignal.timeout(15_000), }); if (!res.ok) { console.error(` restore failed: master answered ${res.status} ${await res.text().catch(() => '')}`); process.exit(1); } const payload = await res.json(); if (payload?.v !== 1 || typeof payload.settings !== 'object') { console.error(' restore failed: unrecognized backup payload'); process.exit(1); } const setCred = db.prepare(` INSERT INTO firm_credentials (firm_id, firm_name, username, password, api_key, updated_at) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(firm_id) DO UPDATE SET firm_name = excluded.firm_name, username = excluded.username, password = excluded.password, api_key = excluded.api_key, updated_at = excluded.updated_at `); const setInstr = db.prepare( 'INSERT INTO instruments (symbol, enabled) VALUES (?, ?) ' + 'ON CONFLICT(symbol) DO UPDATE SET enabled = excluded.enabled' ); const setAssign = db.prepare(` INSERT INTO account_assignments (account_id, firm_id, account_type_id, manual, resolved_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT(account_id) DO UPDATE SET firm_id = excluded.firm_id, account_type_id = excluded.account_type_id, manual = excluded.manual, resolved_at = excluded.resolved_at `); db.transaction(() => { for (const [key, value] of Object.entries(payload.settings)) { if (RESTORE_SKIP_KEYS.has(key)) continue; if (typeof value !== 'string') continue; upsert.run(key, value); } // The freshly seeded master url wins over whatever the backup recorded: // the operator just typed it, and an old backup may point at a dead host. if (url) upsert.run('master_dashboard_url', url); if (token) upsert.run('master_backup_token', token); for (const c of payload.credentials ?? []) { setCred.run(c.firm_id, c.firm_name, c.username, c.password, c.api_key ?? '', c.updated_at ?? new Date().toISOString()); } for (const i of payload.instruments ?? []) { setInstr.run(i.symbol, i.enabled ? 1 : 0); } for (const a of payload.assignments ?? []) { setAssign.run(a.account_id, a.firm_id, a.account_type_id ?? null, a.manual ? 1 : 0, a.resolved_at ?? new Date().toISOString()); } })(); console.log(` restored: ${Object.keys(payload.settings).length} settings, ` + `${(payload.credentials ?? []).length} credentials, ` + `${(payload.instruments ?? []).length} instruments, ` + `${(payload.assignments ?? []).length} assignments`); console.log(` adopted identity: "${payload.settings.instance_name ?? ''}" (${payload.settings.instance_id ?? 'no uuid'})`); } restore() .catch((err) => { console.error(` restore failed: ${err.message}`); process.exit(1); }) .finally(() => db.close());