Files
Brandon LiandClaude Opus 5 be8c628778 Self-trim update.log
Capped at 256 KB, trimmed back to the newest 500 lines. Done once per run
before anything is written: the no-change path logs nothing, so that is the
only point at which the file can have grown since the last run. Failures are
swallowed - housekeeping must never be the reason an update does not land.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 18:40:19 -05:00

173 lines
6.9 KiB
JavaScript

/**
* 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 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 */ }
}
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;
}
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());