/** * Pull master, rebuild, restart — once per invocation. * * A scheduled task fires this every few minutes. Deliberately not a long-lived * loop: if a run dies, the next fire is a clean slate. * * The important guarantee is that a broken push cannot take a trading PC down. * The build runs before anything is restarted, and a failed build stops the run * with the previous build still serving. * * node scripts/update-check.mjs # normal * node scripts/update-check.mjs --dry-run # report only, change nothing */ import { spawnSync } from 'node:child_process'; import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; 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 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}`; const DRY_RUN = process.argv.includes('--dry-run'); const STALE_LOCK_MS = 60 * 60 * 1000; const LOG_MAX_BYTES = 256 * 1024; const LOG_KEEP_LINES = 500; /** * Keep update.log from growing without bound. * * Trimmed once per run, before anything is written, rather than on every line: * the no-change path writes nothing at all, so this is the only moment the file * can have grown since last time. Housekeeping must never break an update, so * any failure here is swallowed. */ function trimLog() { try { if (!existsSync(LOG_FILE) || statSync(LOG_FILE).size <= LOG_MAX_BYTES) return; const kept = readFileSync(LOG_FILE, 'utf8').split('\n').filter(Boolean).slice(-LOG_KEEP_LINES); writeFileSync(LOG_FILE, kept.join('\n') + '\n'); } catch { /* ignore */ } } function log(msg) { const line = `${new Date().toISOString()} ${msg}`; console.log(line); try { appendFileSync(LOG_FILE, line + '\n'); } catch { /* logging must never throw */ } } /** * Record that a check happened, whether or not it found anything. * * Overwritten rather than appended, so it stays one line and needs no trimming. * Without it there is no way to tell "running, nothing to pull" from "not * running at all": the no-change path logs nothing by design, and PM2 reports a * cron-restart process as `stopped` with a restart count of 0 even while it is * firing on schedule. */ function recordCheck(status) { try { writeFileSync(LAST_CHECK_FILE, `${new Date().toISOString()} ${status}\n`); } catch { /* never let bookkeeping break an update */ } } function run(cmd, args, { capture = false } = {}) { return spawnSync(cmd, args, { cwd: ROOT, encoding: 'utf8', stdio: capture ? 'pipe' : 'inherit', shell: process.platform === 'win32', }); } function git(...args) { const r = run('git', args, { capture: true }); if (r.status !== 0) throw new Error(`git ${args.join(' ')} failed: ${(r.stderr ?? '').trim()}`); return (r.stdout ?? '').trim(); } // ── lock ──────────────────────────────────────────────────────────────────── // A build outlasts the schedule interval, so overlapping runs are otherwise a // certainty rather than a risk. function acquireLock() { if (existsSync(LOCK_FILE)) { const age = Date.now() - Number(readFileSync(LOCK_FILE, 'utf8').trim() || 0); if (age < STALE_LOCK_MS) return false; log(`clearing a stale lock (${Math.round(age / 60000)} min old)`); } writeFileSync(LOCK_FILE, String(Date.now())); return true; } const releaseLock = () => { try { rmSync(LOCK_FILE, { force: true }); } catch { /* ignore */ } }; async function main() { mkdirSync(path.join(ROOT, 'scripts'), { recursive: true }); trimLog(); if (!DRY_RUN && !acquireLock()) { console.log('another update is already running — exiting'); return 0; } let outcome = 'interrupted'; try { git('fetch', 'origin', BRANCH); const local = git('rev-parse', 'HEAD'); const remote = git('rev-parse', `origin/${BRANCH}`); if (local === remote) { outcome = `up to date at ${local.slice(0, 8)}`; return 0; // silent in the log by design } log(`update available: ${local.slice(0, 8)} -> ${remote.slice(0, 8)}`); const changed = git('diff', '--name-only', local, remote).split('\n').filter(Boolean); log(`${changed.length} file(s) changed`); if (DRY_RUN) { log('dry run — stopping before any change'); log(`would run: ${[ changed.includes('package-lock.json') && 'npm install', changed.includes('clicker/requirements.txt') && 'pip install', 'npm run build', 'pm2 restart autofirmer', changed.some((f) => f.startsWith('clicker/')) && 'pm2 restart clicker', ].filter(Boolean).join(', ')}`); return 0; } git('pull', '--ff-only', 'origin', BRANCH); if (changed.includes('package-lock.json')) { log('package-lock.json changed — npm install'); if (run('npm', ['install']).status !== 0) { log('ABORTED: npm install failed'); outcome = 'npm install failed'; return 1; } } if (changed.includes('clicker/requirements.txt')) { log('clicker/requirements.txt changed — refreshing python deps'); ensurePythonDeps({ log: (m) => log(m) }); // non-fatal by design } // Build BEFORE restarting. A failed build leaves the running process // untouched, which is the whole point of doing it in this order. log('building'); if (run('npm', ['run', 'build']).status !== 0) { log('ABORTED: build failed — the previous build is still serving, nothing was restarted'); outcome = 'build failed — not deployed'; return 1; } log('restarting autofirmer'); if (run('pm2', ['restart', 'autofirmer']).status !== 0) { log('pm2 restart autofirmer failed'); outcome = 'pm2 restart failed'; return 1; } if (changed.some((f) => f.startsWith('clicker/'))) { log('clicker changed — restarting it too'); run('pm2', ['restart', 'clicker']); } await warmUp(); log(`updated to ${remote.slice(0, 8)}`); outcome = `updated to ${remote.slice(0, 8)}`; return 0; } catch (err) { log(`ERROR: ${err.message}`); outcome = `ERROR: ${err.message}`; return 1; } finally { if (!DRY_RUN) { releaseLock(); recordCheck(outcome); } } } async function warmUp() { 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 { 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}`); } } process.exit(await main());