Files
autofirmer-expanded/scripts/ensure-python-deps.mjs
T
Brandon LiandClaude Opus 5.5 6146031a38 Updater: detached worker, built-commit marker, restorable builds
PM2 runs update-check.mjs as a cron *restart*, and PM2 7 stops a process
that is still running at the next minute boundary. Any update that needed
npm install or next build (more than ~55s) was killed half-way: the lock
was left behind, HEAD had already moved at the pull so the next run saw
nothing to do, and next build had already wiped .next under the live
server — a box that broke after a weekend push and stayed broken.

- The cron run is now a quick CHECK: fetch, decide, and hand the work to a
  detached worker (--apply) that PM2's next tick can't reach.
- "Up to date" is judged against scripts/.built-commit (what .next was built
  from), not HEAD, so a pull that was never built finishes on the next check.
  First run on a machine adopts a complete build that is newer than HEAD's
  last move and rebuilds anything else — that rescues boxes the old updater
  already stranded.
- The previous build is set aside as .next-prev and restored if the build
  fails, so a broken push never leaves a box without a build; failures retry
  after 15 min, then hourly (scripts/.update-failed).
- The lock holds a pid and is atomic; build dirs are deleted without ever
  following the links in .next/node_modules into the real node_modules.
- Every instance in scripts/instances.json is restarted, and python/pip
  children stay hidden on Windows now that the worker has no console.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 20:18:33 -05:00

103 lines
4.2 KiB
JavaScript

/**
* Keep clicker/requirements.txt applied, cheaply.
*
* Called from start-all.mjs on every boot, and from update-check.mjs when a
* pull touches requirements.txt. Running `pip install` unconditionally would
* add seconds to every start and fail outright on a machine whose network is
* not up yet, so the work is guarded by a hash plus an import probe.
*
* Failure is never fatal: the dashboard and the trading loops do not need
* Python. Only the clicker does.
*/
import { createHash } from 'node:crypto';
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const REQUIREMENTS = path.join(ROOT, 'clicker', 'requirements.txt');
const HASH_FILE = path.join(ROOT, 'scripts', '.deps-hash');
const PYTHON_CMD_FILE = path.join(ROOT, 'scripts', '.python-cmd');
/** Run `<cmd> <args>`; cmd may carry arguments of its own, e.g. "py -3". */
function run(cmd, args, opts = {}) {
const parts = cmd.split(/\s+/);
return spawnSync(parts[0], [...parts.slice(1), ...args], {
cwd: ROOT,
encoding: 'utf8',
shell: process.platform === 'win32',
// The updater's worker is detached with no console, so without this
// every python/pip child pops a visible window on the trading desktop.
windowsHide: true,
...opts,
});
}
/** A real interpreter answers with its major version. The Microsoft Store stub
* that Windows puts on PATH produces nothing, which is how we tell them apart. */
function isRealPython(cmd) {
const r = run(cmd, ['-c', 'import sys;print(sys.version_info[0])']);
return r.status === 0 && (r.stdout ?? '').trim() === '3';
}
export function resolvePython() {
// setup-windows.bat already did this detection properly, stub check included,
// and recorded what it found. Prefer that over guessing again.
if (existsSync(PYTHON_CMD_FILE)) {
const saved = readFileSync(PYTHON_CMD_FILE, 'utf8').trim();
if (saved && isRealPython(saved)) return saved;
}
for (const candidate of ['py -3', 'python3', 'python']) {
if (isRealPython(candidate)) return candidate;
}
return null;
}
export function ensurePythonDeps({ log = console.log, force = false } = {}) {
if (!existsSync(REQUIREMENTS)) {
return { ok: true, action: 'skipped', reason: 'no requirements.txt' };
}
const python = resolvePython();
if (!python) {
log('[deps] python not found — clicker dependencies skipped (dashboard is unaffected)');
return { ok: false, action: 'skipped', reason: 'no python' };
}
const wanted = createHash('sha256').update(readFileSync(REQUIREMENTS)).digest('hex');
const recorded = existsSync(HASH_FILE) ? readFileSync(HASH_FILE, 'utf8').trim() : '';
const importsOk = run(python, ['-c', 'import pyautogui']).status === 0;
if (!force && wanted === recorded && importsOk) {
return { ok: true, action: 'up-to-date', python };
}
log(`[deps] installing clicker dependencies via "${python}"${importsOk ? '' : ' (pyautogui does not import)'}`);
const install = run(python, [
'-m', 'pip', 'install', '--disable-pip-version-check', '--quiet',
'-r', path.join('clicker', 'requirements.txt'),
], { stdio: 'inherit' });
if (install.status !== 0) {
log('[deps] pip install failed — the clicker will not work until this is fixed');
return { ok: false, action: 'failed', python };
}
if (run(python, ['-c', 'import pyautogui']).status !== 0) {
log('[deps] pip reported success but pyautogui still does not import');
return { ok: false, action: 'failed', python };
}
writeFileSync(HASH_FILE, wanted + '\n');
log('[deps] clicker dependencies ready');
return { ok: true, action: 'installed', python };
}
// Allow `node scripts/ensure-python-deps.mjs` directly.
if (import.meta.url === `file://${process.argv[1]}`) {
const result = ensurePythonDeps({ force: process.argv.includes('--force') });
console.log(JSON.stringify(result));
process.exit(result.ok ? 0 : 1);
}