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>
173 lines
6.9 KiB
JavaScript
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());
|