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:
co-authored by
Claude Fable 5
parent
97947bffde
commit
8154f25bb9
Executable
+106
@@ -0,0 +1,106 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Register an additional autofirmer instance on this device: same checkout,
|
||||
# same build, its own PM2 process, port and database.
|
||||
#
|
||||
# ./scripts/add-instance.sh <N> [--name <instance_name>] [--master <url>]
|
||||
# [--port <port>] [--token <backup_token>]
|
||||
# [--restore-from <instance_uuid>]
|
||||
#
|
||||
# N instance number, 2 or higher (1 is the legacy install:
|
||||
# pm2 name `autofirmer`, port 3000, ./autotrader.sqlite —
|
||||
# registered by install-autostart.sh, never by this script)
|
||||
# --name instance_name to seed (default: <hostname>-N)
|
||||
# --master master dashboard url to seed (default: leave the app's seed)
|
||||
# --port listen port (default: 3000 + N - 1; NOTE the master dev
|
||||
# server uses 3001 — pick another port on a dev machine)
|
||||
# --token master_backup_token to seed
|
||||
# --restore-from pull that instance's backup from the master before first
|
||||
# boot and adopt its identity (requires --master and --token)
|
||||
#
|
||||
# The instance lands in scripts/instances.json, which start-all.mjs and
|
||||
# update-check.mjs iterate. The first run of this script also records the
|
||||
# legacy instance 1 there so updates keep restarting it.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
REGISTRY="$ROOT/scripts/instances.json"
|
||||
|
||||
info() { printf ' %s\n' "$1"; }
|
||||
die() { printf ' [X] %s\n' "$1" >&2; exit 1; }
|
||||
|
||||
[ $# -ge 1 ] || die "usage: $0 <N> [--name ...] [--master ...] [--port ...] [--token ...] [--restore-from ...]"
|
||||
N="$1"; shift
|
||||
case "$N" in (*[!0-9]*|'') die "N must be a number, got: $N" ;; esac
|
||||
[ "$N" -ge 2 ] || die "N starts at 2 — instance 1 is the legacy install (install-autostart.sh)"
|
||||
|
||||
NAME="$(hostname)-$N"
|
||||
MASTER=""
|
||||
PORT=$((3000 + N - 1))
|
||||
TOKEN=""
|
||||
RESTORE_FROM=""
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--name) NAME="$2"; shift 2 ;;
|
||||
--master) MASTER="$2"; shift 2 ;;
|
||||
--port) PORT="$2"; shift 2 ;;
|
||||
--token) TOKEN="$2"; shift 2 ;;
|
||||
--restore-from) RESTORE_FROM="$2"; shift 2 ;;
|
||||
*) die "unknown option: $1" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
PM2_NAME="autofirmer-$N"
|
||||
DB_REL=".data/instance-$N/autotrader.sqlite"
|
||||
DB_ABS="$ROOT/$DB_REL"
|
||||
|
||||
if [ -n "$RESTORE_FROM" ]; then
|
||||
[ -n "$MASTER" ] || die "--restore-from needs --master"
|
||||
[ -n "$TOKEN" ] || die "--restore-from needs --token"
|
||||
fi
|
||||
|
||||
NEXT_BIN="$ROOT/node_modules/next/dist/bin/next"
|
||||
[ -f "$NEXT_BIN" ] || die "next not found at $NEXT_BIN - run npm install first"
|
||||
[ -d "$ROOT/.next" ] || die "no production build - run npm run build first"
|
||||
command -v pm2 >/dev/null 2>&1 || die "PM2 not installed - run install-autostart.sh first"
|
||||
|
||||
# ── registry: create with the legacy entry, refuse duplicates, append ───────
|
||||
node - "$REGISTRY" "$PM2_NAME" "$PORT" "$DB_REL" <<'NODE'
|
||||
const { existsSync, readFileSync, writeFileSync } = require('node:fs');
|
||||
const [, file, name, port, db] = process.argv;
|
||||
const entries = existsSync(file)
|
||||
? JSON.parse(readFileSync(file, 'utf8'))
|
||||
: [{ name: 'autofirmer', port: 3000, db: 'autotrader.sqlite' }];
|
||||
for (const e of entries) {
|
||||
if (e.name === name) { console.error(` [X] ${name} is already registered`); process.exit(1); }
|
||||
if (e.port === Number(port)) { console.error(` [X] port ${port} is already used by ${e.name}`); process.exit(1); }
|
||||
if (e.db === db) { console.error(` [X] db ${db} is already used by ${e.name}`); process.exit(1); }
|
||||
}
|
||||
entries.push({ name, port: Number(port), db });
|
||||
writeFileSync(file, JSON.stringify(entries, null, 4) + '\n');
|
||||
console.log(` registry: ${name} on :${port} -> ${db}`);
|
||||
NODE
|
||||
|
||||
mkdir -p "$(dirname "$DB_ABS")"
|
||||
|
||||
# ── seed (and optionally restore) the new database ──────────────────────────
|
||||
cd "$ROOT"
|
||||
SEED_ARGS=()
|
||||
[ -n "$TOKEN" ] && SEED_ARGS+=(--token "$TOKEN")
|
||||
[ -n "$RESTORE_FROM" ] && SEED_ARGS+=(--restore-from "$RESTORE_FROM")
|
||||
AUTOFIRMER_DB="$DB_ABS" node scripts/seed-settings.js "$MASTER" "$NAME" ${SEED_ARGS[@]+"${SEED_ARGS[@]}"}
|
||||
|
||||
# ── PM2 ─────────────────────────────────────────────────────────────────────
|
||||
pm2 delete "$PM2_NAME" >/dev/null 2>&1 || true
|
||||
PORT="$PORT" AUTOFIRMER_DB="$DB_ABS" pm2 start "$NEXT_BIN" --name "$PM2_NAME" --interpreter node --update-env -- start
|
||||
pm2 save >/dev/null
|
||||
info "pm2: $PM2_NAME registered and saved"
|
||||
|
||||
echo
|
||||
info "instance $N is up: http://localhost:$PORT"
|
||||
info "database: $DB_REL"
|
||||
if [ -n "$RESTORE_FROM" ]; then
|
||||
info "restored from backup $RESTORE_FROM - stop the old machine if it is still running"
|
||||
fi
|
||||
info "remove later with: ./scripts/remove-instance.sh $N"
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* The per-device instance registry: which autofirmer processes run on this
|
||||
* machine, on which ports, against which databases.
|
||||
*
|
||||
* scripts/instances.json is created and edited by add-instance.sh /
|
||||
* remove-instance.sh. When the file is absent the device is a legacy
|
||||
* single-instance install and everything behaves exactly as it always has:
|
||||
* one PM2 process named `autofirmer` on port 3000 using ./autotrader.sqlite.
|
||||
*
|
||||
* Entry shape: { "name": "autofirmer-2", "port": 3100, "db": ".data/instance-2/autotrader.sqlite" }
|
||||
* `db` is relative to the project root. Instance 1 keeps the legacy
|
||||
* name/port/db so the clicker and extension (hardwired to :3000) still work.
|
||||
*/
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
export const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
export const REGISTRY_FILE = path.join(ROOT, 'scripts', 'instances.json');
|
||||
|
||||
const LEGACY = [{
|
||||
name: 'autofirmer',
|
||||
port: Number(process.env.AUTOFIRMER_PORT ?? '3000'),
|
||||
db: 'autotrader.sqlite',
|
||||
}];
|
||||
|
||||
export function loadInstances() {
|
||||
if (!existsSync(REGISTRY_FILE)) return LEGACY;
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(readFileSync(REGISTRY_FILE, 'utf8'));
|
||||
} catch (err) {
|
||||
throw new Error(`scripts/instances.json is not valid JSON: ${err.message}`);
|
||||
}
|
||||
if (!Array.isArray(parsed) || parsed.length === 0) return LEGACY;
|
||||
for (const e of parsed) {
|
||||
if (typeof e?.name !== 'string' || !e.name
|
||||
|| !Number.isInteger(e?.port)
|
||||
|| typeof e?.db !== 'string' || !e.db) {
|
||||
throw new Error(`scripts/instances.json entry is malformed: ${JSON.stringify(e)}`);
|
||||
}
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
Executable
+36
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Unregister an instance added by add-instance.sh: stops its PM2 process and
|
||||
# drops it from scripts/instances.json. The database directory is left on
|
||||
# disk — deleting credentials is a decision for a human with rm.
|
||||
#
|
||||
# ./scripts/remove-instance.sh <N>
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
REGISTRY="$ROOT/scripts/instances.json"
|
||||
|
||||
info() { printf ' %s\n' "$1"; }
|
||||
die() { printf ' [X] %s\n' "$1" >&2; exit 1; }
|
||||
|
||||
[ $# -eq 1 ] || die "usage: $0 <N>"
|
||||
N="$1"
|
||||
case "$N" in (*[!0-9]*|'') die "N must be a number, got: $N" ;; esac
|
||||
[ "$N" -ge 2 ] || die "instance 1 is the legacy install - remove it with pm2 delete autofirmer"
|
||||
[ -f "$REGISTRY" ] || die "no scripts/instances.json - nothing to remove"
|
||||
|
||||
PM2_NAME="autofirmer-$N"
|
||||
|
||||
node - "$REGISTRY" "$PM2_NAME" <<'NODE'
|
||||
const { readFileSync, writeFileSync } = require('node:fs');
|
||||
const [, file, name] = process.argv;
|
||||
const entries = JSON.parse(readFileSync(file, 'utf8'));
|
||||
if (!entries.some((e) => e.name === name)) { console.error(` [X] ${name} is not registered`); process.exit(1); }
|
||||
writeFileSync(file, JSON.stringify(entries.filter((e) => e.name !== name), null, 4) + '\n');
|
||||
console.log(` registry: ${name} removed`);
|
||||
NODE
|
||||
|
||||
pm2 delete "$PM2_NAME" >/dev/null 2>&1 || info "pm2: $PM2_NAME was not running"
|
||||
pm2 save >/dev/null
|
||||
info "pm2: $PM2_NAME deleted and process list saved"
|
||||
info "database left at .data/instance-$N/ - delete by hand if you mean it"
|
||||
+153
-10
@@ -1,31 +1,174 @@
|
||||
/**
|
||||
* Writes the reporter settings into autotrader.sqlite before first launch.
|
||||
* Writes the reporter settings into the instance database before first launch,
|
||||
* and optionally restores a full backup from the master.
|
||||
*
|
||||
* lib/db.ts seeds these keys with INSERT OR IGNORE, so values written here
|
||||
* survive the app's own startup seeding. Must be run from the project root —
|
||||
* lib/db.ts opens the database at process.cwd().
|
||||
* 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 <master_dashboard_url> <instance_name>
|
||||
* node scripts/seed-settings.js <master_dashboard_url> <instance_name> \
|
||||
* [--token <backup_token>] [--restore-from <instance_uuid>]
|
||||
*
|
||||
* --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 [, , url, name] = process.argv;
|
||||
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 = new Database(path.join(process.cwd(), 'autotrader.sqlite'));
|
||||
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 (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)'); }
|
||||
|
||||
db.close();
|
||||
// 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());
|
||||
|
||||
+31
-28
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* Bring an instance up: clicker dependencies, PM2 processes, then a warm-up.
|
||||
* Bring the device up: clicker dependencies, PM2 processes, then a warm-up of
|
||||
* every registered instance.
|
||||
*
|
||||
* Run by the "at log on" scheduled task. Safe to run by hand at any time.
|
||||
*
|
||||
@@ -9,13 +10,8 @@
|
||||
* HTTP request the process sits idle and none of that happens.
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { ensurePythonDeps } from './ensure-python-deps.mjs';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const PORT = process.env.AUTOFIRMER_PORT ?? '3000';
|
||||
const BASE = `http://127.0.0.1:${PORT}`;
|
||||
import { ROOT, loadInstances } from './instances.mjs';
|
||||
|
||||
const log = (msg) => console.log(`[start-all] ${msg}`);
|
||||
|
||||
@@ -28,11 +24,11 @@ function run(cmd, args) {
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForApp(timeoutMs = 120_000) {
|
||||
async function waitForApp(base, timeoutMs = 120_000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const res = await fetch(`${BASE}/api/auto-trade`, { signal: AbortSignal.timeout(5_000) });
|
||||
const res = await fetch(`${base}/api/auto-trade`, { signal: AbortSignal.timeout(5_000) });
|
||||
if (res.ok) return true;
|
||||
} catch { /* not up yet */ }
|
||||
await new Promise((r) => setTimeout(r, 2_000));
|
||||
@@ -50,25 +46,32 @@ if (run('pm2', ['resurrect']).status !== 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ── warm-up ─────────────────────────────────────────────────────────────────
|
||||
log('waiting for the dashboard to answer');
|
||||
if (!(await waitForApp())) {
|
||||
log(`dashboard did not come up on ${BASE} within 120s — check \`pm2 logs autofirmer\``);
|
||||
process.exit(1);
|
||||
// ── warm-up, one instance at a time ─────────────────────────────────────────
|
||||
let failed = false;
|
||||
for (const instance of loadInstances()) {
|
||||
const base = `http://127.0.0.1:${instance.port}`;
|
||||
|
||||
log(`waiting for ${instance.name} to answer on ${base}`);
|
||||
if (!(await waitForApp(base))) {
|
||||
log(`${instance.name} did not come up on ${base} within 120s — check \`pm2 logs ${instance.name}\``);
|
||||
failed = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
log(`warming up ${instance.name} (this is what triggers the client bootstrap)`);
|
||||
try {
|
||||
await fetch(`${base}/api/state`, { signal: AbortSignal.timeout(60_000) });
|
||||
} catch (err) {
|
||||
log(`warm-up request failed: ${err.message}`);
|
||||
}
|
||||
|
||||
try {
|
||||
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`);
|
||||
} catch { /* non-critical */ }
|
||||
}
|
||||
|
||||
log('warming up (this is what triggers the client bootstrap)');
|
||||
try {
|
||||
await fetch(`${BASE}/api/state`, { signal: AbortSignal.timeout(60_000) });
|
||||
} catch (err) {
|
||||
log(`warm-up request failed: ${err.message}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const status = await (await fetch(`${BASE}/api/auto-trade`)).json();
|
||||
log(status.running
|
||||
? `scheduler resumed — ${status.action} ${status.symbol}`
|
||||
: 'scheduler is stopped');
|
||||
} catch { /* non-critical */ }
|
||||
|
||||
if (failed) process.exit(1);
|
||||
log('up');
|
||||
|
||||
+31
-19
@@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user