/** * 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('busy_timeout = 5000'); try { db.pragma('journal_mode = WAL'); } catch { // Another connection holds the file; keep its journal mode. } // 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, rules_date TEXT, rules_date_manual INTEGER NOT NULL DEFAULT 0 ); `); try { db.exec("ALTER TABLE firm_credentials ADD COLUMN api_key TEXT NOT NULL DEFAULT ''"); } catch { // Column already exists } // The rules-date migration, as lib/db.ts runs it — including its backfill: // adding the column here means lib/db.ts's own ALTER never runs on this file, // and rows the restore doesn't overwrite must not stay undated. Dated as // lib/db.ts undatedRowsDate dates them: now, never before the catalog's last // change (a clock running behind must not date a row into an old rule set). function undatedRowsDate() { const row = db.prepare("SELECT value FROM settings WHERE key = 'catalog_updated_at'").get(); const floor = row && typeof row.value === 'string' ? Date.parse(row.value) : NaN; return new Date(Math.max(Date.now(), Number.isFinite(floor) ? floor : 0)).toISOString(); } // lib/kept-rules.ts sortableIso: the only rules dates stored are plain // four-digit-year ISO strings from 2000 on, so string order is time order. function sortableIso(ms) { if (!Number.isFinite(ms) || ms < Date.UTC(2000, 0, 1) || Math.abs(ms) > 8.64e15) return null; const iso = new Date(ms).toISOString(); return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(iso) ? iso : null; } try { db.transaction(() => { db.exec('ALTER TABLE account_assignments ADD COLUMN rules_date TEXT'); db.prepare('UPDATE account_assignments SET rules_date = ? WHERE rules_date IS NULL').run(undatedRowsDate()); }).immediate(); } catch { // Column already exists } try { db.exec('ALTER TABLE account_assignments ADD COLUMN rules_date_manual INTEGER NOT NULL DEFAULT 0'); } 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' ); // lib/db.ts restoreAssignment, by hand: the backup's rules date (and how // it was set) wins; a backup from an older build has none, so an existing // row keeps its own and a new one is dated at the restore. const setAssignDated = db.prepare(` INSERT INTO account_assignments (account_id, firm_id, account_type_id, manual, resolved_at, rules_date, rules_date_manual) 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, rules_date = excluded.rules_date, rules_date_manual = excluded.rules_date_manual `); const setAssignUndated = db.prepare(` INSERT INTO account_assignments (account_id, firm_id, account_type_id, manual, resolved_at, rules_date) 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, rules_date = COALESCE(account_assignments.rules_date, excluded.rules_date) `); 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 ?? []) { const now = new Date().toISOString(); const resolvedAt = a.resolved_at ?? now; const rulesDate = typeof a.rules_date === 'string' ? sortableIso(Date.parse(a.rules_date)) : null; if (rulesDate) { setAssignDated.run(a.account_id, a.firm_id, a.account_type_id ?? null, a.manual ? 1 : 0, resolvedAt, rulesDate, a.rules_date_manual === 1 ? 1 : 0); } else { setAssignUndated.run(a.account_id, a.firm_id, a.account_type_id ?? null, a.manual ? 1 : 0, resolvedAt, now); } } })(); 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());