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>
103 lines
4.2 KiB
JavaScript
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);
|
|
}
|