Backups to master, replacement-VPS restore, broker proxy, multi-instance

Four features:

- Backup: lib/backup-sync.ts pushes the full settings table and firm
  credentials (the deliberate exception to "credentials never leave the
  machine") to the master's bearer-gated /api/instances/backup — every
  5 minutes when the content hash changed, within seconds of a settings
  or credential edit, and on the new backup-now command. Enabled by the
  master_backup_token setting.

- Restore: a dead VPS's replacement pulls the backup and adopts its
  uuid/name, so roster, payouts and card links carry on. Pre-boot via
  seed-settings.js --restore-from (setup-linux.sh prompts for it) or
  the Settings page's restore picker (lib/backup-restore.ts). Stop the
  old machine first; the master 409s a live-name rename.

- Proxy: the proxy_url setting routes BROKER traffic only — TopstepX
  REST + SignalR hub, Tradovate REST + websocket, Yahoo quotes — via
  lib/outbound-proxy.ts (axios agents, undici dispatcher, ws agent).
  The master link, reporter, catalog and backup push stay direct, so a
  dead proxy cannot take down the control channel. Restart to apply.

- Multi-instance: AUTOFIRMER_DB moves the SQLite file so one checkout
  runs N instances; scripts/instances.json (per-device, gitignored)
  drives start-all/update-check, and add-instance.sh / remove-instance.sh
  manage the extra PM2 processes. Absent registry = single-instance
  behavior, unchanged. Instance 1 keeps port 3000 for the clicker and
  extension.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Brandon Li
2026-09-22 13:55:35 -05:00
co-authored by Claude Fable 5
parent 97947bffde
commit 8154f25bb9
25 changed files with 1396 additions and 76 deletions
+31 -19
View File
@@ -16,14 +16,16 @@ import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync, statSync,
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { ensurePythonDeps } from './ensure-python-deps.mjs';
import { loadInstances } from './instances.mjs';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const LOG_FILE = path.join(ROOT, 'scripts', 'update.log');
const LOCK_FILE = path.join(ROOT, 'scripts', '.update.lock');
const LAST_CHECK_FILE = path.join(ROOT, 'scripts', '.last-check');
const BRANCH = process.env.AUTOFIRMER_BRANCH ?? 'master';
const PORT = process.env.AUTOFIRMER_PORT ?? '3000';
const BASE = `http://127.0.0.1:${PORT}`;
// One checkout, one build — but possibly several PM2 processes to restart and
// warm afterwards (see scripts/instances.mjs).
const INSTANCES = loadInstances();
const DRY_RUN = process.argv.includes('--dry-run');
const STALE_LOCK_MS = 60 * 60 * 1000;
const LOG_MAX_BYTES = 256 * 1024;
@@ -126,7 +128,7 @@ async function main() {
changed.includes('package-lock.json') && 'npm install',
changed.includes('clicker/requirements.txt') && 'pip install',
'npm run build',
'pm2 restart autofirmer',
...INSTANCES.map((i) => `pm2 restart ${i.name}`),
changed.some((f) => f.startsWith('clicker/')) && 'pm2 restart clicker',
].filter(Boolean).join(', ')}`);
return 0;
@@ -153,8 +155,10 @@ async function main() {
return 1;
}
log('restarting autofirmer');
if (run('pm2', ['restart', 'autofirmer']).status !== 0) { log('pm2 restart autofirmer failed'); outcome = 'pm2 restart failed'; return 1; }
for (const instance of INSTANCES) {
log(`restarting ${instance.name}`);
if (run('pm2', ['restart', instance.name]).status !== 0) { log(`pm2 restart ${instance.name} failed`); outcome = 'pm2 restart failed'; return 1; }
}
if (changed.some((f) => f.startsWith('clicker/'))) {
log('clicker changed — restarting it too');
@@ -178,21 +182,29 @@ async function main() {
}
async function warmUp() {
const deadline = Date.now() + 120_000;
while (Date.now() < deadline) {
for (const instance of INSTANCES) {
const base = `http://127.0.0.1:${instance.port}`;
const deadline = Date.now() + 120_000;
while (Date.now() < deadline) {
try {
if ((await fetch(`${base}/api/auto-trade`, { signal: AbortSignal.timeout(5_000) })).ok) break;
} catch { /* still restarting */ }
await new Promise((r) => setTimeout(r, 2_000));
}
try {
if ((await fetch(`${BASE}/api/auto-trade`, { signal: AbortSignal.timeout(5_000) })).ok) break;
} catch { /* still restarting */ }
await new Promise((r) => setTimeout(r, 2_000));
}
try {
await fetch(`${BASE}/api/state`, { signal: AbortSignal.timeout(60_000) });
const status = await (await fetch(`${BASE}/api/auto-trade`)).json();
log(status.running ? `scheduler resumed — ${status.action} ${status.symbol}` : 'scheduler is stopped');
const runner = await (await fetch(`${BASE}/api/autobuyer/runner`)).json();
log(`runner ${runner.online ? 'online' : 'OFFLINE'}${runner.stale ? ' (version stale — reload the extension)' : ''}`);
} catch (err) {
log(`post-restart check failed: ${err.message}`);
await fetch(`${base}/api/state`, { signal: AbortSignal.timeout(60_000) });
const status = await (await fetch(`${base}/api/auto-trade`)).json();
log(status.running
? `${instance.name}: scheduler resumed — ${status.action} ${status.symbol}`
: `${instance.name}: scheduler is stopped`);
// The clicker/extension runner only ever serves instance 1.
if (instance.port === 3000) {
const runner = await (await fetch(`${base}/api/autobuyer/runner`)).json();
log(`runner ${runner.online ? 'online' : 'OFFLINE'}${runner.stale ? ' (version stale — reload the extension)' : ''}`);
}
} catch (err) {
log(`${instance.name}: post-restart check failed: ${err.message}`);
}
}
}