/** * 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, 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 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; function log(msg) { const line = `${new Date().toISOString()} ${msg}`; console.log(line); try { appendFileSync(LOG_FILE, line + '\n'); } catch { /* logging must never throw */ } } 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 }); if (!DRY_RUN && !acquireLock()) { console.log('another update is already running — exiting'); return 0; } try { git('fetch', 'origin', BRANCH); const local = git('rev-parse', 'HEAD'); const remote = git('rev-parse', `origin/${BRANCH}`); if (local === remote) return 0; // the common path: silent no-op 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'); 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'); return 1; } log('restarting autofirmer'); if (run('pm2', ['restart', 'autofirmer']).status !== 0) { log('pm2 restart autofirmer 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)}`); return 0; } catch (err) { log(`ERROR: ${err.message}`); return 1; } finally { if (!DRY_RUN) releaseLock(); } } 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());