diff --git a/.gitignore b/.gitignore index 2fd0289..2c1f209 100644 --- a/.gitignore +++ b/.gitignore @@ -53,3 +53,9 @@ autotrader.sqlite # python __pycache__/ *.pyc + +# generated by the Windows setup / autostart scripts +scripts/.deps-hash +scripts/.python-cmd +scripts/.update.lock +scripts/update.log diff --git a/README.md b/README.md index 7d21702..c00b805 100644 --- a/README.md +++ b/README.md @@ -152,44 +152,41 @@ dialog stealing focus fails the step. That makes an RDP session a poor host: disconnecting can suspend the desktop and break clicks in ways that are hard to diagnose. -## Keeping it running (PM2) +## Keeping it running -Install PM2 globally: +`setup-windows.bat` offers to set this up for you at step 6. To enable it later, +or after declining: ```powershell -npm install -g pm2 -npm install -g pm2-windows-startup +powershell -NoProfile -ExecutionPolicy Bypass -File scripts\install-autostart.ps1 ``` -Start the app and save the process list: +That registers PM2 for both processes and two scheduled tasks — one to start +everything at logon, one to check `master` for updates every 5 minutes. It needs +no elevation and is safe to re-run; it replaces the tasks rather than stacking +them up. ```powershell -cd C:\path\to\autofirmer -pm2 start "npm start" --name autofirmer -pm2 save -pm2-startup install +pm2 list # what is running +pm2 logs autofirmer # dashboard output +pm2 logs clicker # runner output ``` -To restart after pulling updates: +**Why scheduled tasks and not a Windows service.** The clicker sends real mouse +and keyboard input and has to own a desktop. A service runs in session 0, which +has none, so the clicks would go nowhere. Both tasks therefore run as you with +"run only when user is logged on" — which also means an unattended reboot leaves +the instance down until somebody logs in. + +To start everything by hand without waiting for a logon: ```powershell -cd C:\path\to\autofirmer -git pull -npm install -npm run build -pm2 restart autofirmer +node scripts\start-all.mjs ``` -If the update touched the AutoBuyer, two things do **not** reload themselves: +`start-autofirmer.bat` still runs the dashboard in a visible window without PM2, +which is the easier thing to watch when a build is misbehaving. -- **The extension** — click reload on its card in `chrome://extensions`. -- **The runner** — stop it with Ctrl-C and start it again. - -The dashboard reports the version it sees from each, and warns when either is -behind. Most AutoBuyer bugs that look mysterious are one of these two still -running the previous code. - ---- ## Firewall @@ -209,19 +206,33 @@ New-NetFirewallRule -DisplayName "AutoFirmer" -Direction Inbound -Protocol TCP - ## Updating +Once auto-start is installed, nothing here is manual. Every 5 minutes the update +task fetches `master`, and when it has moved it pulls, reinstalls dependencies if +`package-lock.json` or `clicker/requirements.txt` changed, rebuilds, restarts +AutoFirmer, and restarts the clicker if anything under `clicker/` changed. + +**A failed build is never deployed.** The build runs before anything restarts, so +a broken push leaves the previous build serving and logs the failure instead. + +Everything it does is appended to `scripts/update.log`. To see what it would do +without touching anything: + ```powershell -cd C:\path\to\autofirmer -git pull -npm install # only needed if dependencies changed -npm run build -pm2 restart autofirmer +node scripts\update-check.mjs --dry-run ``` -If the update touched the AutoBuyer, two things do **not** reload themselves: +To apply an update immediately rather than waiting for the next check: + +```powershell +node scripts\update-check.mjs +``` + +### The two things that still do not reload themselves - **The extension** — click reload on its card in `chrome://extensions`. -- **The runner** — stop it with Ctrl-C and start it again. +- **The scheduler is fine now.** It persists to the settings table and resumes + after a restart, so an update no longer silently stops automated trading. -The dashboard reports the version it sees from each, and warns when either is -behind. Most AutoBuyer bugs that look mysterious are one of these two still -running the previous code. +The dashboard reports the version it sees from the extension and the runner, and +warns when either is behind. Most AutoBuyer bugs that look mysterious are the +extension still running the previous code. diff --git a/lib/auto-trade.ts b/lib/auto-trade.ts index fecbf91..0bc9535 100644 --- a/lib/auto-trade.ts +++ b/lib/auto-trade.ts @@ -10,7 +10,7 @@ import { getFirms, isSymbolBanned, getInstruments, getBannedSymbols } from './db'; import { getClients } from './clients'; import { computeDailyTarget, resolveEffectiveConfig, POINT_VALUES } from './trading-logic'; -import { getSetting } from './db'; +import { getSetting, setSetting } from './db'; import type { FirmConfig, AccountConfig } from '@/types'; import type { FirmWithAccounts } from './db'; @@ -623,6 +623,13 @@ export function startScheduler(action: 'Buy' | 'Sell' | 'Auto', symbol: string, state.running = true; state.stopAfterAll = stopAfterAll; + // Mirror to the settings table so a restart can pick the schedule back up. + // The interval itself is in-memory only; resumeSchedulerIfPersisted() recreates it. + setSetting('scheduler_running', '1'); + setSetting('scheduler_action', action); + setSetting('scheduler_symbol', symbol); + setSetting('scheduler_stop_after_all', stopAfterAll ? '1' : '0'); + const tick = async () => { if (!state.running) return; state.lastRun = new Date(); @@ -677,9 +684,28 @@ export function stopScheduler() { state.intervalId = null; } state.running = false; + setSetting('scheduler_running', '0'); console.log('[scheduler] stopped'); } +/** + * Restart the schedule that was running before the process went down. + * + * No sync-wait here on purpose: tick() already skips while any client reports + * !syncComplete, and again while any account holds an open position. So the + * worst case is a few logged no-op ticks until the clients finish syncing. + */ +export function resumeSchedulerIfPersisted(): void { + if (getSetting('scheduler_running') !== '1') return; + + const action = (getSetting('scheduler_action') ?? 'Buy') as 'Buy' | 'Sell' | 'Auto'; + const symbol = getSetting('scheduler_symbol') ?? 'NQ'; + const stopAfterAll = getSetting('scheduler_stop_after_all') === '1'; + + console.log(`[scheduler] resuming persisted schedule — ${action} ${symbol}`); + startScheduler(action, symbol, stopAfterAll); +} + export function getSchedulerStatus() { const state = getState(); return { diff --git a/lib/clients.ts b/lib/clients.ts index 9ce17ba..3a5e559 100644 --- a/lib/clients.ts +++ b/lib/clients.ts @@ -2,6 +2,7 @@ import { TradovateClient } from './tradovate-class'; import { getFirms, getInstruments } from './db'; import { resolveContracts } from './contract-resolver'; import { startReporter } from './reporter'; +import { resumeSchedulerIfPersisted } from './auto-trade'; // Use global to persist the client pool across HMR reloads in dev mode const g = global as typeof globalThis & { @@ -63,6 +64,9 @@ export function getClients(): Map { // Start master dashboard reporter startReporter(); + + // Pick the auto-trade schedule back up if it was running before restart + resumeSchedulerIfPersisted(); } catch (err) { console.error('[clients] Failed to initialize clients', err); } diff --git a/lib/db.ts b/lib/db.ts index dbcf428..d855baf 100644 --- a/lib/db.ts +++ b/lib/db.ts @@ -185,6 +185,11 @@ seedSetting.run('tick_interval_seconds', '60'); seedSetting.run('master_dashboard_url', 'https://master.juicerroom.com'); seedSetting.run('instance_name', ''); seedSetting.run('trading_hours', 'full_cme'); +// Mirrors the in-memory scheduler state so a restart can resume it. +seedSetting.run('scheduler_running', '0'); +seedSetting.run('scheduler_action', 'Buy'); +seedSetting.run('scheduler_symbol', 'NQ'); +seedSetting.run('scheduler_stop_after_all', '0'); export function getSetting(key: string): string | null { const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key) as { value: string } | undefined; diff --git a/scripts/ensure-python-deps.mjs b/scripts/ensure-python-deps.mjs new file mode 100644 index 0000000..42601c1 --- /dev/null +++ b/scripts/ensure-python-deps.mjs @@ -0,0 +1,99 @@ +/** + * 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 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', + ...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); +} diff --git a/scripts/install-autostart.ps1 b/scripts/install-autostart.ps1 new file mode 100644 index 0000000..fcda8f6 --- /dev/null +++ b/scripts/install-autostart.ps1 @@ -0,0 +1,122 @@ +<# +.SYNOPSIS + Register AutoFirmer to start at logon and keep itself updated. + +.DESCRIPTION + The only Windows-specific piece of the setup. Everything it schedules is + plain Node, so porting to macOS or Linux means replacing this file alone. + + Two scheduled tasks are created, both running as the current user with + LogonType Interactive. That is not incidental: the clicker drives real mouse + and keyboard input and must own a desktop, which a Windows service (session + 0) does not have. + + Idempotent - re-running replaces the tasks rather than duplicating them. + No elevation required. + +.PARAMETER IntervalMinutes + How often to check master for updates. Default 5. + +.PARAMETER SkipClicker + Register only the dashboard, leaving the clicker to be run by hand. +#> +[CmdletBinding()] +param( + [int]$IntervalMinutes = 5, + [switch]$SkipClicker +) + +$ErrorActionPreference = 'Stop' +$Root = Split-Path -Parent $PSScriptRoot +$StartTask = 'AutoFirmer Start' +$UpdateTask = 'AutoFirmer Update' + +function Info($m) { Write-Host " $m" } +function Warn($m) { Write-Host " ! $m" -ForegroundColor Yellow } + +Info "project root: $Root" + +# ── PM2 ───────────────────────────────────────────────────────────────────── +if (-not (Get-Command pm2 -ErrorAction SilentlyContinue)) { + Info 'installing PM2 globally...' + npm install -g pm2 + if ($LASTEXITCODE -ne 0) { throw 'npm install -g pm2 failed' } + # A fresh global install is not on this session's PATH yet. + $npmPrefix = (npm prefix -g).Trim() + $env:PATH = "$npmPrefix;$env:PATH" + if (-not (Get-Command pm2 -ErrorAction SilentlyContinue)) { + throw "PM2 installed but not found on PATH. Open a new terminal and re-run." + } +} +Info "pm2: $((Get-Command pm2).Source)" + +Push-Location $Root +try { + # delete + recreate rather than reusing, so a changed path or interpreter + # actually takes effect + pm2 delete autofirmer 2>$null | Out-Null + pm2 start npm --name autofirmer -- start + if ($LASTEXITCODE -ne 0) { throw 'pm2 start autofirmer failed' } + Info 'pm2: autofirmer registered' + + if (-not $SkipClicker) { + # pm2 --interpreter wants one executable, so resolve the real path + # rather than passing something like "py -3". + $pyCmdFile = Join-Path $Root 'scripts\.python-cmd' + $pyCmd = if (Test-Path $pyCmdFile) { (Get-Content $pyCmdFile -Raw).Trim() } else { 'python' } + $pyExe = $null + try { $pyExe = (& ([scriptblock]::Create("$pyCmd -c `"import sys;print(sys.executable)`""))).Trim() } catch { } + + if ($pyExe -and (Test-Path $pyExe)) { + pm2 delete clicker 2>$null | Out-Null + pm2 start (Join-Path $Root 'clicker\runner.py') --name clicker --interpreter $pyExe + if ($LASTEXITCODE -eq 0) { Info "pm2: clicker registered ($pyExe)" } + else { Warn 'pm2 start clicker failed - the dashboard is unaffected' } + } else { + Warn 'python not found - skipping the clicker. Re-run setup once Python is installed.' + } + } + + pm2 save | Out-Null + Info 'pm2: process list saved' +} finally { + Pop-Location +} + +# ── Scheduled tasks ───────────────────────────────────────────────────────── +# LogonType Interactive is what grants the desktop the clicker needs. +$principal = New-ScheduledTaskPrincipal -UserId "$env:USERDOMAIN\$env:USERNAME" ` + -LogonType Interactive -RunLevel Limited + +$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries ` + -DontStopIfGoingOnBatteries -StartWhenAvailable ` + -MultipleInstances IgnoreNew -ExecutionTimeLimit (New-TimeSpan -Hours 1) + +function Register-Task($Name, $Action, $Triggers, $Description) { + Unregister-ScheduledTask -TaskName $Name -Confirm:$false -ErrorAction SilentlyContinue + Register-ScheduledTask -TaskName $Name -Action $Action -Trigger $Triggers ` + -Principal $principal -Settings $settings -Description $Description | Out-Null + Info "task registered: $Name" +} + +Register-Task $StartTask ` + (New-ScheduledTaskAction -Execute 'cmd.exe' ` + -Argument "/c `"$(Join-Path $Root 'scripts\start-all.bat')`"" -WorkingDirectory $Root) ` + (New-ScheduledTaskTrigger -AtLogOn) ` + 'Start AutoFirmer and the clicker at logon.' + +# AtLogOn covers a reboot; the repeating Once trigger covers the rest of the day. +$repeat = New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(1) ` + -RepetitionInterval (New-TimeSpan -Minutes $IntervalMinutes) ` + -RepetitionDuration (New-TimeSpan -Days 3650) + +Register-Task $UpdateTask ` + (New-ScheduledTaskAction -Execute 'node.exe' ` + -Argument 'scripts\update-check.mjs' -WorkingDirectory $Root) ` + @((New-ScheduledTaskTrigger -AtLogOn), $repeat) ` + "Check master for updates every $IntervalMinutes minutes; rebuild and restart when it moves." + +Write-Host '' +Info 'Done. Both processes are registered and will come back at logon.' +Info "Update checks run every $IntervalMinutes minutes; see scripts\update.log" +Info 'Useful: pm2 list | pm2 logs autofirmer | pm2 logs clicker' diff --git a/scripts/start-all.bat b/scripts/start-all.bat new file mode 100644 index 0000000..e1f98bb --- /dev/null +++ b/scripts/start-all.bat @@ -0,0 +1,5 @@ +@echo off +REM Thin wrapper so Task Scheduler has a single entry point. All the logic is in +REM start-all.mjs, which is platform-neutral. +cd /d "%~dp0.." +node scripts\start-all.mjs diff --git a/scripts/start-all.mjs b/scripts/start-all.mjs new file mode 100644 index 0000000..32e43a4 --- /dev/null +++ b/scripts/start-all.mjs @@ -0,0 +1,74 @@ +/** + * Bring an instance up: clicker dependencies, PM2 processes, then a warm-up. + * + * Run by the "at log on" scheduled task. Safe to run by hand at any time. + * + * The warm-up is not cosmetic. getClients() in lib/clients.ts is lazily + * bootstrapped — Tradovate clients, the contract resolver, the reporter and the + * persisted-schedule resume all start inside it — so until something makes an + * HTTP request the process sits idle and none of that happens. + */ +import { spawnSync } from 'node:child_process'; +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 PORT = process.env.AUTOFIRMER_PORT ?? '3000'; +const BASE = `http://127.0.0.1:${PORT}`; + +const log = (msg) => console.log(`[start-all] ${msg}`); + +function run(cmd, args) { + return spawnSync(cmd, args, { + cwd: ROOT, + encoding: 'utf8', + stdio: 'inherit', + shell: process.platform === 'win32', + }); +} + +async function waitForApp(timeoutMs = 120_000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const res = await fetch(`${BASE}/api/auto-trade`, { signal: AbortSignal.timeout(5_000) }); + if (res.ok) return true; + } catch { /* not up yet */ } + await new Promise((r) => setTimeout(r, 2_000)); + } + return false; +} + +// ── clicker dependencies (non-fatal) ──────────────────────────────────────── +ensurePythonDeps({ log }); + +// ── PM2 ───────────────────────────────────────────────────────────────────── +log('restoring PM2 processes'); +if (run('pm2', ['resurrect']).status !== 0) { + log('pm2 resurrect failed — is PM2 installed and has `pm2 save` been run?'); + process.exit(1); +} + +// ── warm-up ───────────────────────────────────────────────────────────────── +log('waiting for the dashboard to answer'); +if (!(await waitForApp())) { + log(`dashboard did not come up on ${BASE} within 120s — check \`pm2 logs autofirmer\``); + process.exit(1); +} + +log('warming up (this is what triggers the client bootstrap)'); +try { + await fetch(`${BASE}/api/state`, { signal: AbortSignal.timeout(60_000) }); +} catch (err) { + log(`warm-up request failed: ${err.message}`); +} + +try { + const status = await (await fetch(`${BASE}/api/auto-trade`)).json(); + log(status.running + ? `scheduler resumed — ${status.action} ${status.symbol}` + : 'scheduler is stopped'); +} catch { /* non-critical */ } + +log('up'); diff --git a/scripts/update-check.mjs b/scripts/update-check.mjs new file mode 100644 index 0000000..21b1075 --- /dev/null +++ b/scripts/update-check.mjs @@ -0,0 +1,153 @@ +/** + * 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()); diff --git a/setup-windows.bat b/setup-windows.bat index 43ee777..d87b126 100644 --- a/setup-windows.bat +++ b/setup-windows.bat @@ -184,7 +184,7 @@ REM The branch is named explicitly throughout. A bare `git clone` follows the REM remote's default branch, which is not necessarily master - and an existing REM checkout may be sitting on a stale branch from before that was corrected. if exist "%TARGET%\.git" goto :repo_update -echo [1/5] Cloning %REPO_URL% ... +echo [1/6] Cloning %REPO_URL% ... git clone --branch master "%REPO_URL%" "%TARGET%" if errorlevel 1 goto :clone_failed goto :repo_ready @@ -194,7 +194,7 @@ echo [X] Clone failed. Check network access to git.juicerroom.com. goto :fail :repo_update -echo [1/5] Existing checkout found - switching to master and pulling... +echo [1/6] Existing checkout found - switching to master and pulling... pushd "%TARGET%" git fetch origin if errorlevel 1 goto :pull_failed @@ -218,7 +218,7 @@ pushd "%TARGET%" REM ------------------------------------------------------------------- install echo. -echo [2/5] Installing npm dependencies ^(this takes a few minutes^)... +echo [2/6] Installing npm dependencies ^(this takes a few minutes^)... REM playwright is declared but unreferenced anywhere in the source; skipping its REM browser download saves several hundred MB and a lot of time. set "PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1" @@ -236,7 +236,7 @@ if errorlevel 1 ( REM --------------------------------------------------------------------- build echo. -echo [3/5] Building... +echo [3/6] Building... call npm run build if errorlevel 1 ( echo [X] Build failed. @@ -246,18 +246,21 @@ if errorlevel 1 ( REM ------------------------------------------------------------------ settings echo. -echo [4/5] Writing instance settings... +echo [4/6] Writing instance settings... node scripts\seed-settings.js "%MASTER_URL%" "%INSTANCE%" if errorlevel 1 ( echo [X] Could not write settings to autotrader.sqlite. popd goto :fail ) +REM Record the interpreter resolved above. The Node helpers read this rather +REM than repeating the Store-stub detection in a second language. +if defined PYCMD echo %PYCMD%> scripts\.python-cmd REM ------------------------------------------------------ python deps (option) echo. if not defined PYCMD goto :deps_skip -echo [5/5] Installing clicker Python dependencies... +echo [5/6] Installing clicker Python dependencies... REM requirements.txt guards its pyobjc entries with sys_platform == "darwin", REM so on Windows this resolves to pyautogui and its wheels only. %PYCMD% -m pip install --upgrade --quiet --disable-pip-version-check pip @@ -275,10 +278,39 @@ echo %PYCMD% -m pip install -r clicker\requirements.txt goto :deps_done :deps_skip -echo [5/5] Skipping Python dependencies ^(python not found^). +echo [5/6] Skipping Python dependencies ^(python not found^). :deps_done +REM ------------------------------------------------------------- autostart +REM Prompted, not automatic: turning a trading PC into something that rebuilds +REM and restarts itself on every push to master should be a decision. +echo. +echo [6/6] Auto-start and auto-update +echo Starts AutoFirmer and the clicker at logon, and checks master every +echo 5 minutes - rebuilding and restarting when it moves. A failed build +echo is never deployed. +set "DOAUTO=" +set /p "DOAUTO= Set this up now? [Y/n] " +if /i "%DOAUTO%"=="n" goto :autostart_skipped +REM -ExecutionPolicy Bypass applies to this invocation only; nothing machine-wide +REM changes. No elevation is needed for a per-user scheduled task. +powershell -NoProfile -ExecutionPolicy Bypass -File "%TARGET%\scripts\install-autostart.ps1" +if errorlevel 1 ( + echo [!] Auto-start setup failed. AutoFirmer still works - start it with + echo start-autofirmer.bat, and re-run this script to try again. + set "AUTOSTART=0" +) else ( + set "AUTOSTART=1" +) +goto :autostart_done + +:autostart_skipped +echo Skipped. Run scripts\install-autostart.ps1 later to enable it. +set "AUTOSTART=0" + +:autostart_done + REM ---------------------------------------------------------------- start file > "%~dp0start-autofirmer.bat" ( echo @echo off @@ -300,14 +332,26 @@ echo Instance name : %INSTANCE% echo Reporting to : %MASTER_URL% echo Installed in : %TARGET% echo. -echo Start it with : start-autofirmer.bat echo Dashboard at : http://localhost:3000 echo. +if "%AUTOSTART%"=="1" goto :summary_auto +echo Start it with : start-autofirmer.bat +echo Clicker : python clicker\runner.py +goto :summary_manual + +:summary_auto +echo Running now, and again at every logon ^(PM2^). +echo Updates : master is checked every 5 min; see scripts\update.log +echo Handy : pm2 list ^| pm2 logs autofirmer ^| pm2 logs clicker + +:summary_manual +echo. echo Still manual: echo - Load the Chrome extension: chrome://extensions, enable Developer echo mode, "Load unpacked", select %TARGET%\extension +echo ^(an update cannot reload it for you - the dashboard warns when +echo the extension or runner is behind^) echo - Add your firm credentials on the dashboard's Settings page -echo - Run the clicker when needed: python clicker\runner.py echo. pause exit /b 0