Compare commits
16
Commits
9bdd20f83a
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ba27161ce | ||
|
|
27a61fd25c | ||
|
|
c5c946d3cc | ||
|
|
3cc632ac40 | ||
|
|
9d777cb2c6 | ||
|
|
be8c628778 | ||
|
|
fbe2c47ed4 | ||
|
|
724ba5581c | ||
|
|
7e4714c33d | ||
|
|
97ee03d13b | ||
|
|
a0ef06d933 | ||
|
|
4288d8c298 | ||
|
|
7e7bd985c2 | ||
|
|
a718caeb08 | ||
|
|
8e64d8a34f | ||
|
|
d378712e79 |
+10
@@ -53,3 +53,13 @@ autotrader.sqlite
|
||||
# python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# generated by the Windows setup / autostart scripts
|
||||
scripts/.deps-hash
|
||||
scripts/.python-cmd
|
||||
scripts/.update.lock
|
||||
scripts/update.log
|
||||
|
||||
# clicker virtualenv (Linux/Pi)
|
||||
clicker/.venv/
|
||||
scripts/.last-check
|
||||
|
||||
@@ -4,77 +4,65 @@ Automated futures trading dashboard for prop firm accounts via Tradovate.
|
||||
|
||||
---
|
||||
|
||||
## VPS Setup (Windows Server 2019 / 2022)
|
||||
## Setup
|
||||
|
||||
All commands are run in **PowerShell** (run as Administrator).
|
||||
### Windows
|
||||
|
||||
### 1. Install Git
|
||||
|
||||
Download and install from https://git-scm.com/download/win, or via winget:
|
||||
In **PowerShell**, from wherever you want the instance to live:
|
||||
|
||||
```powershell
|
||||
winget install --id Git.Git -e --source winget
|
||||
irm https://git.juicerroom.com/senofy/autofirmer-expanded/raw/branch/master/setup-windows.bat -OutFile setup-windows.bat
|
||||
```
|
||||
|
||||
Restart PowerShell after installing so `git` is on the PATH.
|
||||
|
||||
### 2. Install Node.js 22+
|
||||
|
||||
```powershell
|
||||
winget install --id OpenJS.NodeJS.LTS -e --source winget
|
||||
.\setup-windows.bat
|
||||
```
|
||||
|
||||
Restart PowerShell, then verify:
|
||||
That is the whole install. It clones into an `autofirmer` folder beside itself,
|
||||
installs anything missing (Git, Node, Python) via winget, installs dependencies,
|
||||
builds, asks for the master dashboard URL and an instance name, and offers to
|
||||
set up auto-start and auto-update.
|
||||
|
||||
```powershell
|
||||
node --version # should be v22.x or higher
|
||||
npm --version
|
||||
Re-running it updates an existing checkout rather than cloning again, so it
|
||||
doubles as a repair tool.
|
||||
|
||||
Two things it deliberately does, worth knowing:
|
||||
|
||||
- **Node LTS, not latest.** `better-sqlite3` ships prebuilt binaries for LTS
|
||||
only. On a newer major it falls back to compiling with node-gyp and fails
|
||||
without Visual Studio Build Tools, so the script warns before that happens.
|
||||
- **It looks for tools off `PATH`.** A tool installed in the same session it is
|
||||
needed — or installed without its "add to PATH" option — is invisible to a
|
||||
running shell, so the script checks the standard install directories too.
|
||||
|
||||
### Raspberry Pi 5 / Debian / Ubuntu
|
||||
|
||||
```bash
|
||||
curl -fsSLO https://git.juicerroom.com/senofy/autofirmer-expanded/raw/branch/master/setup-linux.sh
|
||||
```
|
||||
|
||||
### 3. Install Windows Build Tools (required for better-sqlite3)
|
||||
|
||||
`better-sqlite3` compiles a native C++ module and needs the Visual Studio build tools:
|
||||
|
||||
```powershell
|
||||
npm install -g windows-build-tools
|
||||
```bash
|
||||
bash setup-linux.sh
|
||||
```
|
||||
|
||||
If that fails on newer Node, install manually:
|
||||
- Download **Build Tools for Visual Studio** from https://visualstudio.microsoft.com/downloads/#build-tools-for-visual-studio-2022
|
||||
- During install, select **"Desktop development with C++"**
|
||||
Same idea, apt instead of winget. It installs Node 22 from NodeSource (Pi OS
|
||||
ships one too old for Next 16), `xdotool` and `scrot` for the clicker, and puts
|
||||
the Python dependencies in a virtualenv — Pi OS Bookworm enforces PEP 668, so a
|
||||
system-wide `pip install` fails outright.
|
||||
|
||||
### 4. Clone the repo
|
||||
**The clicker needs X11.** Pi OS on a Pi 5 defaults to Wayland, which does not
|
||||
let one client synthesise input into another or read the focused window, so
|
||||
neither `xdotool` nor pyautogui can work there. The dashboard is unaffected, but
|
||||
for the AutoBuyer:
|
||||
|
||||
```powershell
|
||||
git clone https://github.com/Senofy/autofirmer.git
|
||||
cd autofirmer
|
||||
```bash
|
||||
sudo raspi-config # Advanced Options -> Wayland -> X11, then reboot
|
||||
```
|
||||
|
||||
### 5. Install dependencies
|
||||
`python clicker/diagnose.py` reports the session type, `DISPLAY` and whether
|
||||
`xdotool` is present — run it before trusting a new machine.
|
||||
|
||||
```powershell
|
||||
npm install
|
||||
```
|
||||
|
||||
### 6. Build
|
||||
|
||||
```powershell
|
||||
npm run build
|
||||
```
|
||||
|
||||
### 7. Run
|
||||
|
||||
**Development (with hot reload):**
|
||||
```powershell
|
||||
npm run dev
|
||||
```
|
||||
|
||||
**Production:**
|
||||
```powershell
|
||||
npm start
|
||||
```
|
||||
|
||||
The app runs on **port 3000**. Access it at `http://<your-vps-ip>:3000`.
|
||||
The dashboard runs on port **3000**: `http://localhost:3000`.
|
||||
|
||||
---
|
||||
|
||||
@@ -119,11 +107,13 @@ Adding a new firm also means adding its host to `host_permissions` in
|
||||
`extension/manifest.json` and reloading. Without it the extension cannot read
|
||||
that site, and every step fails to locate.
|
||||
|
||||
### 2. Install the clicker
|
||||
### 2. The clicker
|
||||
|
||||
`setup-windows.bat` installs its Python dependencies, and re-applies them
|
||||
whenever `clicker/requirements.txt` changes. To do it by hand:
|
||||
|
||||
```powershell
|
||||
cd clicker
|
||||
pip install -r requirements.txt
|
||||
python -m pip install -r clicker\requirements.txt
|
||||
```
|
||||
|
||||
See `clicker/README.md` for the per-platform notes — display scaling and
|
||||
@@ -132,11 +122,14 @@ run before letting it click anything on a new machine.
|
||||
|
||||
### 3. Start the runner
|
||||
|
||||
Auto-start runs it under PM2, so normally there is nothing to do. To run it by
|
||||
hand instead:
|
||||
|
||||
```powershell
|
||||
python clicker\runner.py
|
||||
```
|
||||
|
||||
Leave it running. It reports in every two seconds, and the dashboard greys out
|
||||
It reports in every two seconds, and the dashboard greys out
|
||||
the automation buttons when it is not there. A running Python process does not
|
||||
reload when the source changes, so restart it after pulling updates; the
|
||||
dashboard warns when its version is behind.
|
||||
@@ -152,44 +145,44 @@ 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 puts the dashboard, the clicker and an update checker under PM2, adds a
|
||||
Startup-folder entry so PM2 comes back at logon, and lets PM2's cron restart run
|
||||
the update check every 5 minutes. It needs no administrator rights, changes
|
||||
nothing machine-wide, and is safe to re-run.
|
||||
|
||||
```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 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. Everything therefore runs in your logged-in session —
|
||||
which also means an unattended reboot leaves the instance down until somebody
|
||||
logs in.
|
||||
|
||||
To stop it starting at logon, delete the `AutoFirmer.cmd` shortcut from your
|
||||
Startup folder (`shell:startup` in the Run dialog).
|
||||
|
||||
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 +202,48 @@ New-NetFirewallRule -DisplayName "AutoFirmer" -Direction Inbound -Protocol TCP -
|
||||
|
||||
## Updating
|
||||
|
||||
```powershell
|
||||
cd C:\path\to\autofirmer
|
||||
git pull
|
||||
npm install # only needed if dependencies changed
|
||||
npm run build
|
||||
pm2 restart autofirmer
|
||||
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.
|
||||
|
||||
### Checking it is alive
|
||||
|
||||
`scripts/.last-check` is rewritten on every check, whether or not anything came
|
||||
down:
|
||||
|
||||
```
|
||||
2026-08-31T00:43:27.155Z up to date at 27a61fd2
|
||||
```
|
||||
|
||||
If the update touched the AutoBuyer, two things do **not** reload themselves:
|
||||
This exists because the alternatives mislead. `update.log` only records real
|
||||
events, so it stays empty for days when nothing is pushed — and PM2 reports a
|
||||
cron-restart process as `stopped` with a restart count of `0` even while it is
|
||||
firing on schedule. Neither is evidence of a problem; `.last-check` is the
|
||||
signal to trust.
|
||||
|
||||
Everything that actually happens is appended to `scripts/update.log`. To see
|
||||
what it would do without touching anything:
|
||||
|
||||
```powershell
|
||||
node scripts\update-check.mjs --dry-run
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
@@ -53,6 +53,17 @@ VS Code — under System Settings → Privacy & Security → Accessibility. With
|
||||
`pyautogui` moves nothing and fails silently, which looks identical to a bad
|
||||
selector.
|
||||
|
||||
### Linux (including Raspberry Pi)
|
||||
|
||||
`setup-linux.sh` installs `xdotool` and `scrot` and builds a virtualenv for the
|
||||
Python side.
|
||||
|
||||
**X11 only.** pygetwindow has no X11 backend, so raising the browser goes
|
||||
through `xdotool` instead. Neither that nor pyautogui works under Wayland — it
|
||||
refuses by design to let one client drive another — and Raspberry Pi OS on a Pi
|
||||
5 defaults to Wayland. Switch with `sudo raspi-config` -> Advanced Options ->
|
||||
Wayland -> X11 and reboot. `diagnose.py` prints which session you are in.
|
||||
|
||||
### Windows
|
||||
|
||||
No extra permissions, but two things differ.
|
||||
|
||||
@@ -31,6 +31,22 @@ import urllib.request
|
||||
import actions
|
||||
import focus
|
||||
|
||||
|
||||
def force_utf8_output() -> None:
|
||||
"""Make stdout/stderr accept the status glyphs this tool prints.
|
||||
|
||||
Windows picks the console code page for stdout, and under PM2 (where stdout
|
||||
is a pipe, not a console) that is cp1252. It can encode em dashes but not
|
||||
the run markers, so the first one raised UnicodeEncodeError mid-run and the
|
||||
runner reported it as a step failure. errors="replace" is a backstop for any
|
||||
stream that cannot be reconfigured at all.
|
||||
"""
|
||||
for stream in (sys.stdout, sys.stderr):
|
||||
try:
|
||||
stream.reconfigure(encoding="utf-8", errors="replace")
|
||||
except (AttributeError, ValueError):
|
||||
pass
|
||||
|
||||
DEFAULT_API = "http://localhost:3000"
|
||||
POLL_INTERVAL = 0.25
|
||||
|
||||
@@ -176,6 +192,7 @@ def describe(found: dict, factor: float) -> str:
|
||||
|
||||
|
||||
def main() -> int:
|
||||
force_utf8_output()
|
||||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument("action", choices=["locate", "click", "type"],
|
||||
help="locate = measure only; click = measure then click; "
|
||||
|
||||
@@ -12,9 +12,13 @@ OS after each move, so the answer doesn't depend on anything being rendered.
|
||||
Nothing is clicked. The cursor is moved and put back where it started.
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import time
|
||||
|
||||
from clicker import force_utf8_output
|
||||
|
||||
|
||||
def line(label: str, value: str) -> None:
|
||||
print(f" {label:<22} {value}")
|
||||
@@ -25,6 +29,19 @@ def check_platform() -> None:
|
||||
line("os", sys.platform)
|
||||
line("python", sys.version.split()[0])
|
||||
|
||||
if sys.platform.startswith("linux"):
|
||||
# The two things that decide whether the clicker can work at all here.
|
||||
session = os.environ.get("XDG_SESSION_TYPE", "unknown")
|
||||
display = os.environ.get("DISPLAY") or "(unset)"
|
||||
line("session type", session)
|
||||
line("DISPLAY", display)
|
||||
line("xdotool", shutil.which("xdotool") or "NOT INSTALLED — sudo apt install -y xdotool")
|
||||
if session == "wayland" or (os.environ.get("WAYLAND_DISPLAY") and not os.environ.get("DISPLAY")):
|
||||
print(" Wayland cannot be automated: xdotool and pyautogui both speak X11,")
|
||||
print(" and Wayland does not let one client drive another. On Raspberry Pi OS:")
|
||||
print(" sudo raspi-config -> Advanced Options -> Wayland -> X11, then reboot.")
|
||||
return
|
||||
|
||||
if sys.platform != "win32":
|
||||
return
|
||||
|
||||
@@ -125,6 +142,7 @@ def check_foreground() -> None:
|
||||
|
||||
|
||||
def main() -> int:
|
||||
force_utf8_output()
|
||||
print("AutoFirmer clicker diagnostics")
|
||||
check_platform()
|
||||
check_dpi()
|
||||
|
||||
+84
-3
@@ -12,6 +12,8 @@ which it is, because that's where this script was launched — Chrome as a whole
|
||||
still in the background. This module raises the application itself.
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
@@ -26,35 +28,48 @@ BROWSERS = {
|
||||
"com.google.Chrome.dev", "com.google.Chrome.canary",
|
||||
"org.chromium.Chromium"),
|
||||
"win32": ("chrome.exe",),
|
||||
"linux": ("google-chrome", "chromium", "chromium-browser", "google-chrome-stable"),
|
||||
"titles": ("Chrome", "Chromium"),
|
||||
},
|
||||
"edge": {
|
||||
"darwin": ("com.microsoft.edgemac",),
|
||||
"win32": ("msedge.exe",),
|
||||
"linux": ("microsoft-edge", "msedge"),
|
||||
"titles": ("Edge",),
|
||||
},
|
||||
"brave": {
|
||||
"darwin": ("com.brave.Browser",),
|
||||
"win32": ("brave.exe",),
|
||||
"linux": ("brave-browser", "brave"),
|
||||
"titles": ("Brave",),
|
||||
},
|
||||
"opera": {
|
||||
"darwin": ("com.operasoftware.Opera",),
|
||||
"win32": ("opera.exe", "launcher.exe"),
|
||||
"linux": ("opera",),
|
||||
"titles": ("Opera",),
|
||||
},
|
||||
"vivaldi": {
|
||||
"darwin": ("com.vivaldi.Vivaldi",),
|
||||
"win32": ("vivaldi.exe",),
|
||||
"linux": ("vivaldi-stable", "vivaldi"),
|
||||
"titles": ("Vivaldi",),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _platform_key() -> str:
|
||||
if sys.platform == "darwin":
|
||||
return "darwin"
|
||||
if sys.platform.startswith("linux"):
|
||||
return "linux"
|
||||
return "win32"
|
||||
|
||||
|
||||
def _ids_for(browser: str | None) -> tuple[str, ...]:
|
||||
"""Identifiers to accept on this platform. Without a named browser, every
|
||||
known one — the old behaviour, and still right on a single-browser box."""
|
||||
key = "darwin" if sys.platform == "darwin" else "win32"
|
||||
key = _platform_key()
|
||||
if browser and browser in BROWSERS:
|
||||
return BROWSERS[browser][key]
|
||||
return tuple(i for b in BROWSERS.values() for i in b[key])
|
||||
@@ -142,9 +157,67 @@ def _mac_workspace():
|
||||
return NSWorkspace.sharedWorkspace()
|
||||
|
||||
|
||||
def _linux_session_problem() -> str | None:
|
||||
"""Why the clicker cannot drive this desktop, or None if it can.
|
||||
|
||||
Raspberry Pi OS on a Pi 5 defaults to Wayland (labwc). Neither xdotool nor
|
||||
pyautogui works there: both speak X11 protocol, and Wayland deliberately
|
||||
refuses to let one client synthesise input into another or read the focused
|
||||
window. There is no workaround short of switching the session to X11, so say
|
||||
so plainly rather than failing every step with something cryptic.
|
||||
"""
|
||||
if not os.environ.get("DISPLAY"):
|
||||
if os.environ.get("WAYLAND_DISPLAY"):
|
||||
return ("this is a Wayland session and the clicker needs X11 — on Raspberry Pi OS: "
|
||||
"sudo raspi-config -> Advanced Options -> Wayland -> X11, then reboot")
|
||||
return "no DISPLAY is set — the clicker needs a graphical session"
|
||||
if shutil.which("xdotool") is None:
|
||||
return "xdotool is not installed — run: sudo apt install -y xdotool"
|
||||
return None
|
||||
|
||||
|
||||
def _xdotool(*args: str) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(["xdotool", *args], capture_output=True, text=True, timeout=5)
|
||||
|
||||
|
||||
def _linux_frontmost() -> str | None:
|
||||
"""WM_CLASS of the focused window, lowercased to match the ids above."""
|
||||
try:
|
||||
result = _xdotool("getactivewindow", "getwindowclassname")
|
||||
except Exception:
|
||||
return None
|
||||
name = (result.stdout or "").strip().lower()
|
||||
return name or None
|
||||
|
||||
|
||||
def _linux_activate(browser: str | None = None) -> FocusResult:
|
||||
problem = _linux_session_problem()
|
||||
if problem:
|
||||
return FocusResult(False, problem)
|
||||
|
||||
for cls in _ids_for(browser):
|
||||
try:
|
||||
found = _xdotool("search", "--onlyvisible", "--class", cls)
|
||||
except Exception as exc:
|
||||
return FocusResult(False, f"xdotool failed ({exc})")
|
||||
|
||||
ids = (found.stdout or "").split()
|
||||
if not ids:
|
||||
continue
|
||||
|
||||
# Last match is the most recently mapped window — the one a person would
|
||||
# mean by "the browser" when several are open.
|
||||
activated = _xdotool("windowactivate", "--sync", ids[-1])
|
||||
if activated.returncode == 0:
|
||||
return FocusResult(True, f"activated {cls} (window {ids[-1]})")
|
||||
return FocusResult(False, f"could not activate {cls}: {(activated.stderr or '').strip()}")
|
||||
|
||||
return FocusResult(False, f"no {browser or 'browser'} window found")
|
||||
|
||||
|
||||
def browser_ids(browser: str | None = None) -> tuple[str, ...]:
|
||||
"""What counts as "the browser" on this platform, optionally narrowed to one."""
|
||||
if sys.platform in ("darwin", "win32"):
|
||||
if sys.platform in ("darwin", "win32") or sys.platform.startswith("linux"):
|
||||
return _ids_for(browser)
|
||||
return ()
|
||||
|
||||
@@ -172,6 +245,9 @@ def frontmost() -> str | None:
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
if sys.platform.startswith("linux"):
|
||||
return _linux_frontmost()
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@@ -257,7 +333,12 @@ def activate_browser(browser: str | None = None) -> FocusResult:
|
||||
it, any known browser will do — fine on a machine with one installed, wrong
|
||||
on a machine with two.
|
||||
"""
|
||||
result = _mac_activate(browser) if sys.platform == "darwin" else _other_activate(browser)
|
||||
if sys.platform == "darwin":
|
||||
result = _mac_activate(browser)
|
||||
elif sys.platform.startswith("linux"):
|
||||
result = _linux_activate(browser)
|
||||
else:
|
||||
result = _other_activate(browser)
|
||||
if result.ok:
|
||||
time.sleep(SETTLE)
|
||||
return result
|
||||
|
||||
@@ -4,3 +4,7 @@ pyautogui>=0.9.54
|
||||
pyobjc-core>=10.0; sys_platform == "darwin"
|
||||
pyobjc-framework-Quartz>=10.0; sys_platform == "darwin"
|
||||
pyobjc-framework-Cocoa>=10.0; sys_platform == "darwin"
|
||||
# Linux: pyautogui drives X11 through Xlib. Raising the browser window is done
|
||||
# with xdotool, which is an apt package rather than a wheel - setup-linux.sh
|
||||
# installs it.
|
||||
python-xlib>=0.33; sys_platform == "linux"
|
||||
|
||||
+2
-1
@@ -25,7 +25,7 @@ import time
|
||||
|
||||
import actions
|
||||
import focus
|
||||
from clicker import Dashboard, DashboardError, NotFoundError
|
||||
from clicker import Dashboard, DashboardError, NotFoundError, force_utf8_output
|
||||
|
||||
POLL_SECONDS = 1.0
|
||||
HEARTBEAT_SECONDS = 2.0
|
||||
@@ -362,6 +362,7 @@ def finish(dash, run_id, error):
|
||||
|
||||
|
||||
def main() -> int:
|
||||
force_utf8_output()
|
||||
parser = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument("--api", default="http://localhost:3000", help="dashboard URL")
|
||||
|
||||
+27
-1
@@ -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 {
|
||||
|
||||
+1
-1
@@ -142,7 +142,7 @@ export const FIRMS: Firm[] = [
|
||||
// every site build, so they are not safe to select on.
|
||||
|
||||
{ action: 'click', selector: 'a.add_account_btn', label: 'Open Add Account' },
|
||||
{ action: 'wait', seconds: 2, label: 'Wait for the form' },
|
||||
{ action: 'waitFor', selector: 'div.account_types:nth-child(3) > div[role="radiogroup"] > div > div:nth-child(2)', timeoutSeconds: 30, label: 'Wait for form' },
|
||||
{ action: 'click', selector: 'div.account_types:nth-child(3) > div[role="radiogroup"] > div > div:nth-child(2)'},
|
||||
{ action: 'click', selector: 'div.account_types:nth-child(7) span:last-child'},
|
||||
{ action: 'click', selector: 'div.summary_section div.MuiTextField-root input'},
|
||||
|
||||
@@ -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<number, TradovateClient> {
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
@@ -182,9 +182,14 @@ db.exec(`
|
||||
const seedSetting = db.prepare(`INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)`);
|
||||
seedSetting.run('max_concurrent_accounts', '5');
|
||||
seedSetting.run('tick_interval_seconds', '60');
|
||||
seedSetting.run('master_dashboard_url', '');
|
||||
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;
|
||||
|
||||
@@ -283,9 +283,6 @@ export class TradovateClient {
|
||||
const isSentinel = limit >= 999999999;
|
||||
const floor = (!isSentinel && limit > 0 && drawdown > 0) ? limit - drawdown : 0;
|
||||
this.autoLiqThresholds[accountId] = floor;
|
||||
if (floor > 0) {
|
||||
console.log(`[autoLiq] account ${accountId} → floor $${floor} (hwm=$${limit} drawdown=$${drawdown})`);
|
||||
}
|
||||
}
|
||||
|
||||
this.accountList = ((response.accounts ?? []) as AccountItem[]).map((account) => {
|
||||
|
||||
Generated
+2
-62
@@ -11,7 +11,6 @@
|
||||
"axios": "^1.13.6",
|
||||
"better-sqlite3": "^12.6.2",
|
||||
"next": "16.1.6",
|
||||
"playwright": "^1.58.2",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"recharts": "^3.8.0"
|
||||
@@ -72,7 +71,6 @@
|
||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
@@ -1688,7 +1686,6 @@
|
||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
@@ -1754,7 +1751,6 @@
|
||||
"integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.56.1",
|
||||
"@typescript-eslint/types": "8.56.1",
|
||||
@@ -2280,7 +2276,6 @@
|
||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -2695,7 +2690,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -3486,7 +3480,6 @@
|
||||
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -3672,7 +3665,6 @@
|
||||
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@rtsao/scc": "^1.1.0",
|
||||
"array-includes": "^3.1.9",
|
||||
@@ -4116,20 +4108,6 @@
|
||||
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
@@ -5976,36 +5954,6 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
|
||||
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.58.2"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
|
||||
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/possible-typed-array-names": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
||||
@@ -6170,7 +6118,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
|
||||
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -6180,7 +6127,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
|
||||
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
@@ -6192,15 +6138,13 @@
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-redux": {
|
||||
"version": "9.2.0",
|
||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/use-sync-external-store": "^0.0.6",
|
||||
"use-sync-external-store": "^1.4.0"
|
||||
@@ -6267,8 +6211,7 @@
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/redux-thunk": {
|
||||
"version": "3.1.0",
|
||||
@@ -7067,7 +7010,6 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -7242,7 +7184,6 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -7561,7 +7502,6 @@
|
||||
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
"axios": "^1.13.6",
|
||||
"better-sqlite3": "^12.6.2",
|
||||
"next": "16.1.6",
|
||||
"playwright": "^1.58.2",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"recharts": "^3.8.0"
|
||||
|
||||
@@ -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> <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',
|
||||
...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);
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
<#
|
||||
.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.
|
||||
|
||||
Deliberately needs no administrator rights, and changes nothing
|
||||
machine-wide:
|
||||
|
||||
* PM2 supervises the dashboard, the clicker and the update checker.
|
||||
* A Startup-folder entry runs scripts\start-all.bat at logon.
|
||||
* PM2's own cron restart drives the update checks.
|
||||
|
||||
Task Scheduler is avoided on purpose - Register-ScheduledTask needs
|
||||
elevation to write to the root task folder.
|
||||
|
||||
Everything runs in the logged-in user's interactive session. That is not
|
||||
incidental: the clicker sends real mouse and keyboard input and must own a
|
||||
desktop, which a Windows service (session 0) does not have. The trade-off
|
||||
is that an unattended reboot leaves the instance down until someone logs in.
|
||||
|
||||
Idempotent - safe to re-run.
|
||||
|
||||
.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
|
||||
|
||||
function Info($m) { Write-Host " $m" }
|
||||
function Warn($m) { Write-Host " ! $m" -ForegroundColor Yellow }
|
||||
|
||||
<#
|
||||
PM2 ships a PowerShell shim, and anything it writes to stderr becomes a
|
||||
NativeCommandError. With $ErrorActionPreference = 'Stop' that is terminating,
|
||||
so a routine "process not found" from `pm2 delete` aborts the whole install.
|
||||
Redirecting at the call site does not help: the error is raised inside
|
||||
pm2.ps1, not here. Drop to Continue for the duration and judge the result by
|
||||
$LASTEXITCODE instead.
|
||||
#>
|
||||
function Invoke-Native {
|
||||
param([string]$Exe, [string[]]$Arguments)
|
||||
$prev = $ErrorActionPreference
|
||||
$ErrorActionPreference = 'Continue'
|
||||
try {
|
||||
& $Exe @Arguments 2>&1 | ForEach-Object { Write-Host " $_" -ForegroundColor DarkGray }
|
||||
return $LASTEXITCODE
|
||||
} finally {
|
||||
$ErrorActionPreference = $prev
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-Pm2 {
|
||||
param([string[]]$Arguments, [switch]$Quiet)
|
||||
$prev = $ErrorActionPreference
|
||||
$ErrorActionPreference = 'Continue'
|
||||
try {
|
||||
$output = & pm2 @Arguments 2>&1
|
||||
$code = $LASTEXITCODE
|
||||
if (-not $Quiet -and $code -ne 0) {
|
||||
$output | ForEach-Object { Write-Host " $_" -ForegroundColor DarkGray }
|
||||
}
|
||||
return $code
|
||||
} finally {
|
||||
$ErrorActionPreference = $prev
|
||||
}
|
||||
}
|
||||
|
||||
Info "project root: $Root"
|
||||
|
||||
# -- PM2 ---------------------------------------------------------------------
|
||||
if (-not (Get-Command pm2 -ErrorAction SilentlyContinue)) {
|
||||
Info 'installing PM2 globally...'
|
||||
if ((Invoke-Native 'npm' @('install', '-g', 'pm2')) -ne 0) { throw 'npm install -g pm2 failed' }
|
||||
# A fresh global install is not on this session's PATH yet.
|
||||
$prevEap = $ErrorActionPreference
|
||||
$ErrorActionPreference = 'Continue'
|
||||
$npmPrefix = (& npm prefix -g 2>$null | Select-Object -First 1)
|
||||
$ErrorActionPreference = $prevEap
|
||||
if ($npmPrefix) { $npmPrefix = $npmPrefix.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 {
|
||||
# Run Next directly rather than through `npm start`. On Windows `npm` is a
|
||||
# .cmd shim, so PM2 would supervise the shim while the real server ran as
|
||||
# its child - restarts and stops then miss the process that matters.
|
||||
$nextBin = Join-Path $Root 'node_modules\next\dist\bin\next'
|
||||
if (-not (Test-Path $nextBin)) { throw "next not found at $nextBin - run npm install first" }
|
||||
|
||||
Invoke-Pm2 @('delete', 'autofirmer') -Quiet | Out-Null # absent is fine
|
||||
if ((Invoke-Pm2 @('start', $nextBin, '--name', 'autofirmer', '--interpreter', 'node', '--', 'start')) -ne 0) {
|
||||
throw 'pm2 start autofirmer failed'
|
||||
}
|
||||
Info 'pm2: autofirmer registered'
|
||||
|
||||
if (-not $SkipClicker) {
|
||||
# --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
|
||||
$prevEap = $ErrorActionPreference
|
||||
$ErrorActionPreference = 'Continue'
|
||||
try {
|
||||
$pyExe = (& ([scriptblock]::Create("$pyCmd -c `"import sys;print(sys.executable)`"")) 2>$null | Select-Object -First 1)
|
||||
} catch { } finally { $ErrorActionPreference = $prevEap }
|
||||
|
||||
if ($pyExe -and (Test-Path $pyExe)) {
|
||||
Invoke-Pm2 @('delete', 'clicker') -Quiet | Out-Null
|
||||
if ((Invoke-Pm2 @('start', (Join-Path $Root 'clicker\runner.py'), '--name', 'clicker', '--interpreter', $pyExe)) -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.'
|
||||
}
|
||||
}
|
||||
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
|
||||
# -- Start at logon (Startup folder) -----------------------------------------
|
||||
# Not Task Scheduler: Register-ScheduledTask needs elevation to write to the
|
||||
# root task folder, and the whole point is an install that never asks for
|
||||
# admin. A Startup-folder entry needs no privileges and runs in the user's
|
||||
# interactive session, which is exactly what the clicker requires - a service
|
||||
# in session 0 has no desktop and its clicks would go nowhere.
|
||||
$startupDir = [Environment]::GetFolderPath('Startup')
|
||||
$startupCmd = Join-Path $startupDir 'AutoFirmer.cmd'
|
||||
$startAll = Join-Path $Root 'scripts\start-all.bat'
|
||||
|
||||
@"
|
||||
@echo off
|
||||
REM Written by scripts\install-autostart.ps1 - delete this file to stop
|
||||
REM AutoFirmer starting at logon.
|
||||
call "$startAll"
|
||||
"@ | Set-Content -Path $startupCmd -Encoding ASCII
|
||||
|
||||
Info "logon entry: $startupCmd"
|
||||
|
||||
# -- Update checks (PM2 cron) ------------------------------------------------
|
||||
# Also not Task Scheduler, same reason. PM2 is already running and already
|
||||
# comes back at logon, so it can own the schedule too: --cron-restart fires the
|
||||
# script on a schedule and --no-autorestart stops PM2 relaunching it the moment
|
||||
# it exits.
|
||||
$updateScript = Join-Path $Root 'scripts\update-check.mjs'
|
||||
Invoke-Pm2 @('delete', 'autofirmer-update') -Quiet | Out-Null
|
||||
$cron = "*/$IntervalMinutes * * * *"
|
||||
if ((Invoke-Pm2 @('start', $updateScript, '--name', 'autofirmer-update',
|
||||
'--no-autorestart', '--cron-restart', $cron)) -eq 0) {
|
||||
Info "update checks: every $IntervalMinutes minutes ($cron)"
|
||||
} else {
|
||||
Warn 'could not register the update checker - run scripts\update-check.mjs by hand to update'
|
||||
}
|
||||
|
||||
# Save again so `pm2 resurrect` at logon brings the updater back too.
|
||||
if ((Invoke-Pm2 @('save')) -ne 0) { Warn 'pm2 save failed - processes may not return after a reboot' }
|
||||
|
||||
Write-Host ''
|
||||
Info 'Done. No admin was needed and nothing machine-wide was changed.'
|
||||
Info 'Running now, and again at every logon.'
|
||||
Info "Updates checked every $IntervalMinutes minutes; see scripts\update.log"
|
||||
Info 'Useful: pm2 list | pm2 logs autofirmer | pm2 logs clicker'
|
||||
Info "To disable autostart: delete $startupCmd"
|
||||
Executable
+113
@@ -0,0 +1,113 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Register AutoFirmer to start at login and keep itself updated. Linux/Pi
|
||||
# counterpart of install-autostart.ps1 - the same two mechanisms, expressed the
|
||||
# way this desktop does them:
|
||||
#
|
||||
# * a ~/.config/autostart entry runs scripts/start-all.sh at login
|
||||
# * PM2's own cron restart drives the update checks
|
||||
#
|
||||
# No sudo, nothing system-wide. Everything runs inside the graphical login
|
||||
# session, which is not incidental: the clicker synthesises real X11 input and
|
||||
# needs a desktop with DISPLAY set. A systemd system service has neither.
|
||||
#
|
||||
# ./scripts/install-autostart.sh [--interval-minutes 5] [--skip-clicker]
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
INTERVAL=5
|
||||
SKIP_CLICKER=0
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--interval-minutes) INTERVAL="$2"; shift 2 ;;
|
||||
--skip-clicker) SKIP_CLICKER=1; shift ;;
|
||||
*) echo "unknown option: $1" >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
info() { printf ' %s\n' "$1"; }
|
||||
warn() { printf ' ! %s\n' "$1" >&2; }
|
||||
|
||||
info "project root: $ROOT"
|
||||
|
||||
# ── PM2 ─────────────────────────────────────────────────────────────────────
|
||||
if ! command -v pm2 >/dev/null 2>&1; then
|
||||
info 'installing PM2 globally...'
|
||||
npm install -g pm2
|
||||
command -v pm2 >/dev/null 2>&1 || { echo "PM2 installed but not on PATH" >&2; exit 1; }
|
||||
fi
|
||||
info "pm2: $(command -v pm2)"
|
||||
|
||||
cd "$ROOT"
|
||||
|
||||
# Run Next directly rather than through `npm start`, so PM2 supervises the
|
||||
# server itself instead of an npm wrapper that spawns it.
|
||||
NEXT_BIN="$ROOT/node_modules/next/dist/bin/next"
|
||||
[ -f "$NEXT_BIN" ] || { echo "next not found at $NEXT_BIN - run npm install first" >&2; exit 1; }
|
||||
|
||||
pm2 delete autofirmer >/dev/null 2>&1 || true
|
||||
pm2 start "$NEXT_BIN" --name autofirmer --interpreter node -- start
|
||||
info 'pm2: autofirmer registered'
|
||||
|
||||
if [ "$SKIP_CLICKER" -eq 0 ]; then
|
||||
PY_CMD_FILE="$ROOT/scripts/.python-cmd"
|
||||
PY_CMD="$( [ -f "$PY_CMD_FILE" ] && cat "$PY_CMD_FILE" || echo python3 )"
|
||||
PY_EXE="$($PY_CMD -c 'import sys;print(sys.executable)' 2>/dev/null || true)"
|
||||
|
||||
if [ -n "$PY_EXE" ] && [ -x "$PY_EXE" ]; then
|
||||
pm2 delete clicker >/dev/null 2>&1 || true
|
||||
if pm2 start "$ROOT/clicker/runner.py" --name clicker --interpreter "$PY_EXE"; then
|
||||
info "pm2: clicker registered ($PY_EXE)"
|
||||
else
|
||||
warn 'pm2 start clicker failed - the dashboard is unaffected'
|
||||
fi
|
||||
else
|
||||
warn 'python not found - skipping the clicker'
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Update checks (PM2 cron) ────────────────────────────────────────────────
|
||||
pm2 delete autofirmer-update >/dev/null 2>&1 || true
|
||||
if pm2 start "$ROOT/scripts/update-check.mjs" --name autofirmer-update \
|
||||
--no-autorestart --cron-restart "*/$INTERVAL * * * *"; then
|
||||
info "update checks: every $INTERVAL minutes (*/$INTERVAL * * * *)"
|
||||
else
|
||||
warn 'could not register the update checker - run scripts/update-check.mjs by hand'
|
||||
fi
|
||||
|
||||
pm2 save >/dev/null
|
||||
info 'pm2: process list saved'
|
||||
|
||||
# ── Start at login (XDG autostart) ──────────────────────────────────────────
|
||||
# The desktop equivalent of the Windows Startup folder: runs inside the
|
||||
# graphical session, so DISPLAY is set and the clicker can reach the X server.
|
||||
AUTOSTART_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/autostart"
|
||||
DESKTOP_FILE="$AUTOSTART_DIR/autofirmer.desktop"
|
||||
mkdir -p "$AUTOSTART_DIR"
|
||||
cat > "$DESKTOP_FILE" <<DESKTOP
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=AutoFirmer
|
||||
Comment=Start the AutoFirmer dashboard, clicker and update checker
|
||||
Exec=$ROOT/scripts/start-all.sh
|
||||
Terminal=false
|
||||
X-GNOME-Autostart-enabled=true
|
||||
DESKTOP
|
||||
info "login entry: $DESKTOP_FILE"
|
||||
|
||||
# ── Wayland warning ─────────────────────────────────────────────────────────
|
||||
# Worth saying now rather than letting every automation fail later.
|
||||
if [ -n "${WAYLAND_DISPLAY:-}" ] && [ -z "${DISPLAY:-}" ]; then
|
||||
echo
|
||||
warn 'This is a Wayland session. The dashboard is fine, but the clicker cannot'
|
||||
warn 'work: xdotool and pyautogui both speak X11, which Wayland does not expose.'
|
||||
warn 'Switch with: sudo raspi-config -> Advanced Options -> Wayland -> X11, then reboot.'
|
||||
fi
|
||||
|
||||
echo
|
||||
info 'Done. No sudo was needed and nothing system-wide was changed.'
|
||||
info 'Running now, and again at every login.'
|
||||
info "Updates checked every $INTERVAL minutes; see scripts/update.log"
|
||||
info 'Useful: pm2 list | pm2 logs autofirmer | pm2 logs clicker'
|
||||
info "To disable autostart: rm $DESKTOP_FILE"
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Writes the reporter settings into autotrader.sqlite before first launch.
|
||||
*
|
||||
* lib/db.ts seeds these keys with INSERT OR IGNORE, so values written here
|
||||
* survive the app's own startup seeding. Must be run from the project root —
|
||||
* lib/db.ts opens the database at process.cwd().
|
||||
*
|
||||
* node scripts/seed-settings.js <master_dashboard_url> <instance_name>
|
||||
*/
|
||||
const path = require('path');
|
||||
const Database = require('better-sqlite3');
|
||||
|
||||
const [, , url, name] = process.argv;
|
||||
|
||||
const db = new Database(path.join(process.cwd(), 'autotrader.sqlite'));
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
const upsert = db.prepare(
|
||||
'INSERT INTO settings (key, value) VALUES (?, ?) ' +
|
||||
'ON CONFLICT(key) DO UPDATE SET value = excluded.value'
|
||||
);
|
||||
|
||||
if (url) { upsert.run('master_dashboard_url', url); console.log(' master_dashboard_url = ' + url); }
|
||||
if (name) { upsert.run('instance_name', name); console.log(' instance_name = ' + name); }
|
||||
|
||||
db.close();
|
||||
@@ -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
|
||||
@@ -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');
|
||||
Executable
+6
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
# Thin wrapper so the desktop autostart entry has a single entry point.
|
||||
# All the logic is in start-all.mjs, which is platform-neutral.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
exec node scripts/start-all.mjs
|
||||
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* 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 LAST_CHECK_FILE = path.join(ROOT, 'scripts', '.last-check');
|
||||
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 */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* Record that a check happened, whether or not it found anything.
|
||||
*
|
||||
* Overwritten rather than appended, so it stays one line and needs no trimming.
|
||||
* Without it there is no way to tell "running, nothing to pull" from "not
|
||||
* running at all": the no-change path logs nothing by design, and PM2 reports a
|
||||
* cron-restart process as `stopped` with a restart count of 0 even while it is
|
||||
* firing on schedule.
|
||||
*/
|
||||
function recordCheck(status) {
|
||||
try {
|
||||
writeFileSync(LAST_CHECK_FILE, `${new Date().toISOString()} ${status}\n`);
|
||||
} catch { /* never let bookkeeping break an update */ }
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
let outcome = 'interrupted';
|
||||
|
||||
try {
|
||||
git('fetch', 'origin', BRANCH);
|
||||
const local = git('rev-parse', 'HEAD');
|
||||
const remote = git('rev-parse', `origin/${BRANCH}`);
|
||||
|
||||
if (local === remote) {
|
||||
outcome = `up to date at ${local.slice(0, 8)}`;
|
||||
return 0; // silent in the log by design
|
||||
}
|
||||
|
||||
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'); outcome = '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');
|
||||
outcome = 'build failed — not deployed';
|
||||
return 1;
|
||||
}
|
||||
|
||||
log('restarting autofirmer');
|
||||
if (run('pm2', ['restart', 'autofirmer']).status !== 0) { log('pm2 restart autofirmer failed'); outcome = 'pm2 restart 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)}`);
|
||||
outcome = `updated to ${remote.slice(0, 8)}`;
|
||||
return 0;
|
||||
} catch (err) {
|
||||
log(`ERROR: ${err.message}`);
|
||||
outcome = `ERROR: ${err.message}`;
|
||||
return 1;
|
||||
} finally {
|
||||
if (!DRY_RUN) {
|
||||
releaseLock();
|
||||
recordCheck(outcome);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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());
|
||||
Executable
+170
@@ -0,0 +1,170 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# AutoFirmer instance setup for Raspberry Pi OS / Debian / Ubuntu.
|
||||
#
|
||||
# Standalone: download this one file and run it. It clones the repo into a
|
||||
# subfolder next to itself, installs, builds, and points the instance at the
|
||||
# master dashboard. Safe to re-run - it pulls and rebuilds instead of cloning.
|
||||
#
|
||||
# curl -fsSLO https://git.juicerroom.com/senofy/autofirmer-expanded/raw/branch/master/setup-linux.sh
|
||||
# bash setup-linux.sh
|
||||
set -euo pipefail
|
||||
|
||||
REPO_URL="https://git.juicerroom.com/senofy/autofirmer-expanded.git"
|
||||
DEFAULT_MASTER="https://master.juicerroom.com"
|
||||
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||
TARGET="$HERE/autofirmer"
|
||||
NODE_MAJOR_MIN=20
|
||||
|
||||
ok() { printf ' [ok] %s\n' "$1"; }
|
||||
info() { printf ' %s\n' "$1"; }
|
||||
warn() { printf ' [!] %s\n' "$1" >&2; }
|
||||
die() { printf '\n [X] %s\n\n' "$1" >&2; exit 1; }
|
||||
|
||||
echo
|
||||
echo " ============================================"
|
||||
echo " AutoFirmer - Linux setup"
|
||||
echo " ============================================"
|
||||
echo
|
||||
|
||||
# ── sudo ────────────────────────────────────────────────────────────────────
|
||||
command -v apt-get >/dev/null 2>&1 || die "this script expects apt (Raspberry Pi OS, Debian, Ubuntu)"
|
||||
SUDO=""
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
command -v sudo >/dev/null 2>&1 || die "not root and sudo is not installed"
|
||||
SUDO="sudo"
|
||||
info "some steps need sudo; you may be prompted"
|
||||
fi
|
||||
|
||||
# ── system packages ─────────────────────────────────────────────────────────
|
||||
# xdotool raises the browser window (pygetwindow has no X11 backend, so the
|
||||
# clicker drives xdotool instead). scrot backs pyautogui's screen reads.
|
||||
# build-essential/python3-dev are only the fallback path for better-sqlite3 if
|
||||
# no arm64 prebuilt binary matches this Node ABI.
|
||||
info "installing system packages..."
|
||||
$SUDO apt-get update -qq
|
||||
$SUDO apt-get install -y -qq \
|
||||
git curl ca-certificates \
|
||||
python3 python3-venv python3-dev \
|
||||
xdotool scrot \
|
||||
build-essential
|
||||
ok "system packages"
|
||||
|
||||
# ── node ────────────────────────────────────────────────────────────────────
|
||||
node_major() { command -v node >/dev/null 2>&1 && node -p 'process.versions.node.split(".")[0]' 2>/dev/null || echo 0; }
|
||||
if [ "$(node_major)" -lt "$NODE_MAJOR_MIN" ]; then
|
||||
# Raspberry Pi OS ships a Node too old for Next 16, so take the LTS line
|
||||
# from NodeSource. LTS also matters for better-sqlite3, which publishes
|
||||
# prebuilt arm64 binaries only for released ABIs.
|
||||
info "installing Node 22 LTS (found major $(node_major))..."
|
||||
curl -fsSL https://deb.nodesource.com/setup_22.x | $SUDO -E bash - >/dev/null
|
||||
$SUDO apt-get install -y -qq nodejs
|
||||
fi
|
||||
[ "$(node_major)" -ge "$NODE_MAJOR_MIN" ] || die "Node $NODE_MAJOR_MIN+ required, found $(node -v 2>/dev/null || echo none)"
|
||||
ok "node $(node -v) npm $(npm -v)"
|
||||
ok "python $(python3 -V 2>&1 | awk '{print $2}')"
|
||||
|
||||
# ── memory ──────────────────────────────────────────────────────────────────
|
||||
TOTAL_MB=$(awk '/MemTotal/ {print int($2/1024)}' /proc/meminfo)
|
||||
SWAP_MB=$(awk '/SwapTotal/ {print int($2/1024)}' /proc/meminfo)
|
||||
if [ "$TOTAL_MB" -lt 3500 ] && [ "$SWAP_MB" -lt 1024 ]; then
|
||||
warn "${TOTAL_MB}MB RAM and only ${SWAP_MB}MB swap - 'next build' may be OOM-killed."
|
||||
warn "Consider raising CONF_SWAPSIZE in /etc/dphys-swapfile to 2048 and rebooting."
|
||||
fi
|
||||
|
||||
# ── prompts ─────────────────────────────────────────────────────────────────
|
||||
echo
|
||||
read -r -p " Master dashboard URL [$DEFAULT_MASTER]: " MASTER_URL
|
||||
MASTER_URL="${MASTER_URL:-$DEFAULT_MASTER}"
|
||||
read -r -p " Instance name [$(hostname)]: " INSTANCE
|
||||
INSTANCE="${INSTANCE:-$(hostname)}"
|
||||
echo
|
||||
info "installing to: $TARGET"
|
||||
echo
|
||||
|
||||
# ── 1. clone / pull ─────────────────────────────────────────────────────────
|
||||
# The branch is named explicitly: a bare clone follows the remote's default
|
||||
# branch, which is not necessarily master.
|
||||
if [ -d "$TARGET/.git" ]; then
|
||||
info "[1/6] existing checkout - switching to master and pulling..."
|
||||
git -C "$TARGET" fetch origin
|
||||
git -C "$TARGET" checkout master
|
||||
git -C "$TARGET" pull --ff-only origin master
|
||||
else
|
||||
info "[1/6] cloning $REPO_URL ..."
|
||||
git clone --branch master "$REPO_URL" "$TARGET"
|
||||
fi
|
||||
|
||||
cd "$TARGET"
|
||||
|
||||
# ── 2. npm ──────────────────────────────────────────────────────────────────
|
||||
info "[2/6] installing npm dependencies (a few minutes on a Pi)..."
|
||||
npm install
|
||||
|
||||
# ── 3. build ────────────────────────────────────────────────────────────────
|
||||
info "[3/6] building..."
|
||||
npm run build
|
||||
|
||||
# ── 4. settings ─────────────────────────────────────────────────────────────
|
||||
info "[4/6] writing instance settings..."
|
||||
node scripts/seed-settings.js "$MASTER_URL" "$INSTANCE"
|
||||
|
||||
# ── 5. python ───────────────────────────────────────────────────────────────
|
||||
# A virtualenv rather than a system pip install: Raspberry Pi OS Bookworm
|
||||
# enforces PEP 668, so pip into the system interpreter fails outright with
|
||||
# "externally-managed-environment".
|
||||
info "[5/6] installing clicker dependencies into a virtualenv..."
|
||||
VENV="$TARGET/clicker/.venv"
|
||||
[ -d "$VENV" ] || python3 -m venv "$VENV"
|
||||
"$VENV/bin/python" -m pip install --quiet --upgrade pip
|
||||
if "$VENV/bin/python" -m pip install --quiet -r clicker/requirements.txt; then
|
||||
echo "$VENV/bin/python" > scripts/.python-cmd
|
||||
ok "clicker dependencies ready"
|
||||
else
|
||||
warn "pip install failed - the dashboard still works, the clicker will not"
|
||||
fi
|
||||
|
||||
# ── 6. autostart ────────────────────────────────────────────────────────────
|
||||
echo
|
||||
info "[6/6] Auto-start and auto-update"
|
||||
info " Starts AutoFirmer and the clicker at login, and checks master every"
|
||||
info " 5 minutes - rebuilding and restarting when it moves. A failed build"
|
||||
info " is never deployed."
|
||||
read -r -p " Set this up now? [Y/n] " DOAUTO
|
||||
AUTOSTART=0
|
||||
if [ "${DOAUTO,,}" != "n" ]; then
|
||||
if bash scripts/install-autostart.sh; then AUTOSTART=1; else
|
||||
warn "auto-start setup failed - start by hand with: node scripts/start-all.mjs"
|
||||
fi
|
||||
else
|
||||
info " Skipped. Run scripts/install-autostart.sh later to enable it."
|
||||
fi
|
||||
|
||||
# ── summary ─────────────────────────────────────────────────────────────────
|
||||
echo
|
||||
echo " ============================================"
|
||||
echo " Done."
|
||||
echo " ============================================"
|
||||
echo
|
||||
info "Instance name : $INSTANCE"
|
||||
info "Reporting to : $MASTER_URL"
|
||||
info "Installed in : $TARGET"
|
||||
info "Dashboard at : http://localhost:3000"
|
||||
echo
|
||||
if [ "$AUTOSTART" -eq 1 ]; then
|
||||
info "Running now, and again at every login (PM2)."
|
||||
info "Handy: pm2 list | pm2 logs autofirmer | pm2 logs clicker"
|
||||
else
|
||||
info "Start it with : node scripts/start-all.mjs"
|
||||
fi
|
||||
echo
|
||||
info "Still manual:"
|
||||
info " - Load the Chrome extension: chrome://extensions -> Developer mode"
|
||||
info " -> Load unpacked -> $TARGET/extension"
|
||||
info " - Add your firm credentials on the dashboard's Settings page"
|
||||
if [ -n "${WAYLAND_DISPLAY:-}" ] && [ -z "${DISPLAY:-}" ]; then
|
||||
echo
|
||||
warn "This is a Wayland session - the clicker needs X11."
|
||||
warn "sudo raspi-config -> Advanced Options -> Wayland -> X11, then reboot."
|
||||
fi
|
||||
echo
|
||||
+236
-54
@@ -21,21 +21,93 @@ echo ============================================
|
||||
echo.
|
||||
|
||||
REM ---------------------------------------------------------------- git check
|
||||
REM Git for Windows only lands on PATH if "Git from the command line" was chosen
|
||||
REM at install time, and an already-open cmd keeps its old PATH regardless. So:
|
||||
REM probe the usual homes, then offer to install, then probe again - a fresh
|
||||
REM install is never visible to `where` in this session.
|
||||
:git_probe
|
||||
where git >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
echo [X] Git is not installed, or not on PATH.
|
||||
echo Install it from https://git-scm.com/download/win then re-run.
|
||||
goto :fail
|
||||
)
|
||||
if not errorlevel 1 goto :git_done
|
||||
if exist "%ProgramFiles%\Git\cmd\git.exe" set "PATH=%ProgramFiles%\Git\cmd;%PATH%"
|
||||
if exist "%ProgramFiles(x86)%\Git\cmd\git.exe" set "PATH=%ProgramFiles(x86)%\Git\cmd;%PATH%"
|
||||
if exist "%LOCALAPPDATA%\Programs\Git\cmd\git.exe" set "PATH=%LOCALAPPDATA%\Programs\Git\cmd;%PATH%"
|
||||
where git >nul 2>&1
|
||||
if not errorlevel 1 goto :git_found_offpath
|
||||
if "%GIT_TRIED%"=="1" goto :git_missing
|
||||
where winget >nul 2>&1
|
||||
if errorlevel 1 goto :git_missing
|
||||
echo.
|
||||
echo [--] Git is not installed.
|
||||
set "DOIT="
|
||||
set /p "DOIT= Install it now with winget? [Y/n] "
|
||||
if /i "%DOIT%"=="n" goto :git_missing
|
||||
set "GIT_TRIED=1"
|
||||
echo Installing Git ^(a UAC prompt may appear^)...
|
||||
winget install --id Git.Git -e --source winget --accept-source-agreements --accept-package-agreements --silent
|
||||
goto :git_probe
|
||||
|
||||
:git_found_offpath
|
||||
echo [ok] git ^(found off PATH, added for this run^)
|
||||
goto :git_done
|
||||
|
||||
:git_missing
|
||||
echo [X] Git not found on PATH, and not in any of:
|
||||
echo %ProgramFiles%\Git\cmd
|
||||
echo %LOCALAPPDATA%\Programs\Git\cmd
|
||||
echo.
|
||||
echo Install it from https://git-scm.com/download/win
|
||||
echo If you JUST installed it: close this window, open a new Command
|
||||
echo Prompt and run the script again - an open window keeps its old PATH.
|
||||
goto :fail
|
||||
|
||||
:git_done
|
||||
echo [ok] git
|
||||
|
||||
REM --------------------------------------------------------------- node check
|
||||
REM winget's LTS package is deliberate: better-sqlite3 publishes prebuilt
|
||||
REM binaries for LTS Node, so installing LTS sidesteps the node-gyp build.
|
||||
:node_probe
|
||||
where node >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
echo [X] Node.js is not installed, or not on PATH.
|
||||
echo Install Node 22 LTS from https://nodejs.org/ then re-run.
|
||||
goto :fail
|
||||
)
|
||||
if not errorlevel 1 goto :node_found
|
||||
if exist "%ProgramFiles%\nodejs\node.exe" set "PATH=%ProgramFiles%\nodejs;%PATH%"
|
||||
if exist "%LOCALAPPDATA%\Programs\nodejs\node.exe" set "PATH=%LOCALAPPDATA%\Programs\nodejs;%PATH%"
|
||||
where node >nul 2>&1
|
||||
if not errorlevel 1 goto :node_found
|
||||
if "%NODE_TRIED%"=="1" goto :node_missing
|
||||
where winget >nul 2>&1
|
||||
if errorlevel 1 goto :node_missing
|
||||
echo.
|
||||
echo [--] Node.js is not installed.
|
||||
set "DOIT="
|
||||
set /p "DOIT= Install Node 22 LTS now with winget? [Y/n] "
|
||||
if /i "%DOIT%"=="n" goto :node_missing
|
||||
set "NODE_TRIED=1"
|
||||
echo Installing Node LTS ^(a UAC prompt may appear^)...
|
||||
winget install --id OpenJS.NodeJS.LTS -e --source winget --accept-source-agreements --accept-package-agreements --silent
|
||||
goto :node_probe
|
||||
|
||||
:node_missing
|
||||
echo [X] Node.js not found on PATH, and not in any of:
|
||||
echo %ProgramFiles%\nodejs
|
||||
echo %LOCALAPPDATA%\Programs\nodejs
|
||||
echo.
|
||||
echo Install Node 22 LTS from https://nodejs.org/ then re-run.
|
||||
echo If you JUST installed it, open a NEW Command Prompt first.
|
||||
goto :fail
|
||||
|
||||
:node_found
|
||||
if exist "%ProgramFiles%\nodejs\node.exe" set "PATH=%ProgramFiles%\nodejs;%PATH%"
|
||||
if exist "%LOCALAPPDATA%\Programs\nodejs\node.exe" set "PATH=%LOCALAPPDATA%\Programs\nodejs;%PATH%"
|
||||
where node >nul 2>&1
|
||||
if not errorlevel 1 goto :node_found
|
||||
echo [X] Node.js not found on PATH, and not in any of:
|
||||
echo %ProgramFiles%\nodejs
|
||||
echo %LOCALAPPDATA%\Programs\nodejs
|
||||
echo.
|
||||
echo Install Node 22 LTS from https://nodejs.org/ then re-run.
|
||||
echo If you JUST installed it, open a NEW Command Prompt first.
|
||||
goto :fail
|
||||
:node_found
|
||||
for /f "tokens=* usebackq" %%v in (`node -p "process.versions.node"`) do set "NODEVER=%%v"
|
||||
for /f "tokens=1 delims=." %%m in ("%NODEVER%") do set "NODEMAJOR=%%m"
|
||||
echo [ok] node v%NODEVER%
|
||||
@@ -58,15 +130,57 @@ if /i not "%GOON%"=="y" goto :fail
|
||||
:node_ok
|
||||
|
||||
REM ------------------------------------------------------------------- python
|
||||
where python >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
echo [--] python not found - the dashboard will still work.
|
||||
echo Needed only for the clicker ^(clicker\runner.py^).
|
||||
set "HAVE_PY=0"
|
||||
) else (
|
||||
echo [ok] python
|
||||
set "HAVE_PY=1"
|
||||
)
|
||||
REM Only the clicker needs Python; the dashboard runs without it.
|
||||
REM
|
||||
REM Detection is deliberately "run it and see" rather than `where python`:
|
||||
REM Windows ships a stub python.exe in WindowsApps that only opens the Microsoft
|
||||
REM Store. `where` finds it, running it does nothing useful. Asking it to print
|
||||
REM its major version separates a real interpreter from the stub. The py
|
||||
REM launcher is preferred when present - it is not shadowed by the stub.
|
||||
:py_probe
|
||||
set "PYCMD="
|
||||
for /f "tokens=*" %%v in ('py -3 -c "import sys;print(sys.version_info[0])" 2^>nul') do if "%%v"=="3" set "PYCMD=py -3"
|
||||
if not defined PYCMD for /f "tokens=*" %%v in ('python -c "import sys;print(sys.version_info[0])" 2^>nul') do if "%%v"=="3" set "PYCMD=python"
|
||||
if defined PYCMD goto :py_found
|
||||
|
||||
REM Not on PATH. Same story as Git and Node: a Python installed just now - or
|
||||
REM installed without "Add to PATH" ticked - is invisible to this session, which
|
||||
REM inherited its PATH at start. Prepend the standard install homes and retry.
|
||||
REM The version is globbed rather than hardcoded so a 3.13 install still works.
|
||||
for /d %%D in ("%LOCALAPPDATA%\Programs\Python\Python3*") do if exist "%%~D\python.exe" set "PATH=%%~D;%%~D\Scripts;%PATH%"
|
||||
for /d %%D in ("%ProgramFiles%\Python3*") do if exist "%%~D\python.exe" set "PATH=%%~D;%%~D\Scripts;%PATH%"
|
||||
if exist "%LOCALAPPDATA%\Programs\Python\Launcher\py.exe" set "PATH=%LOCALAPPDATA%\Programs\Python\Launcher;%PATH%"
|
||||
if exist "%SystemRoot%\py.exe" set "PATH=%SystemRoot%;%PATH%"
|
||||
|
||||
for /f "tokens=*" %%v in ('py -3 -c "import sys;print(sys.version_info[0])" 2^>nul') do if "%%v"=="3" set "PYCMD=py -3"
|
||||
if not defined PYCMD for /f "tokens=*" %%v in ('python -c "import sys;print(sys.version_info[0])" 2^>nul') do if "%%v"=="3" set "PYCMD=python"
|
||||
if defined PYCMD goto :py_found
|
||||
if "%PY_TRIED%"=="1" goto :py_skip
|
||||
where winget >nul 2>&1
|
||||
if errorlevel 1 goto :py_skip
|
||||
echo.
|
||||
echo [--] Python is not installed ^(needed only for the clicker^).
|
||||
set "DOIT="
|
||||
set /p "DOIT= Install Python 3.12 now with winget? [Y/n] "
|
||||
if /i "%DOIT%"=="n" goto :py_skip
|
||||
set "PY_TRIED=1"
|
||||
echo Installing Python ^(a UAC prompt may appear^)...
|
||||
winget install --id Python.Python.3.12 -e --source winget --accept-source-agreements --accept-package-agreements --silent
|
||||
goto :py_probe
|
||||
|
||||
:py_skip
|
||||
echo [--] python not found on PATH, and not in any of:
|
||||
echo %LOCALAPPDATA%\Programs\Python\Python3*
|
||||
echo %ProgramFiles%\Python3*
|
||||
echo The dashboard will still work - python is only needed for the
|
||||
echo clicker ^(clicker\runner.py^). If you just installed it, close this
|
||||
echo window and re-run to pick up the new PATH.
|
||||
goto :py_end
|
||||
|
||||
:py_found
|
||||
for /f "tokens=*" %%v in ('%PYCMD% -c "import sys;print(sys.version.split()[0])" 2^>nul') do set "PYVER=%%v"
|
||||
echo [ok] python %PYVER% ^(via "%PYCMD%"^)
|
||||
:py_end
|
||||
|
||||
REM ------------------------------------------------------------------- prompts
|
||||
echo.
|
||||
@@ -83,33 +197,45 @@ echo Installing to: %TARGET%
|
||||
echo.
|
||||
|
||||
REM -------------------------------------------------------------- clone / pull
|
||||
if exist "%TARGET%\.git" (
|
||||
echo [1/5] Existing checkout found - pulling latest...
|
||||
pushd "%TARGET%"
|
||||
git pull --ff-only
|
||||
if errorlevel 1 (
|
||||
popd
|
||||
echo [X] git pull failed. Resolve local changes and re-run.
|
||||
goto :fail
|
||||
)
|
||||
popd
|
||||
) else (
|
||||
echo [1/5] Cloning %REPO_URL% ...
|
||||
git clone "%REPO_URL%" "%TARGET%"
|
||||
if errorlevel 1 (
|
||||
echo [X] Clone failed. Check network access to git.juicerroom.com.
|
||||
goto :fail
|
||||
)
|
||||
)
|
||||
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/6] Cloning %REPO_URL% ...
|
||||
git clone --branch master "%REPO_URL%" "%TARGET%"
|
||||
if errorlevel 1 goto :clone_failed
|
||||
goto :repo_ready
|
||||
|
||||
:clone_failed
|
||||
echo [X] Clone failed. Check network access to git.juicerroom.com.
|
||||
goto :fail
|
||||
|
||||
:repo_update
|
||||
echo [1/6] Existing checkout found - switching to master and pulling...
|
||||
pushd "%TARGET%"
|
||||
git fetch origin
|
||||
if errorlevel 1 goto :pull_failed
|
||||
git checkout master
|
||||
if errorlevel 1 goto :pull_failed
|
||||
git pull --ff-only origin master
|
||||
if errorlevel 1 goto :pull_failed
|
||||
popd
|
||||
goto :repo_ready
|
||||
|
||||
:pull_failed
|
||||
popd
|
||||
echo [X] Could not update the checkout. If you have local changes, either
|
||||
echo commit them or delete this folder and re-run to clone fresh:
|
||||
echo %TARGET%
|
||||
goto :fail
|
||||
|
||||
:repo_ready
|
||||
|
||||
pushd "%TARGET%"
|
||||
|
||||
REM ------------------------------------------------------------------- install
|
||||
echo.
|
||||
echo [2/5] 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"
|
||||
echo [2/6] Installing npm dependencies ^(this takes a few minutes^)...
|
||||
call npm install
|
||||
if errorlevel 1 (
|
||||
echo.
|
||||
@@ -124,7 +250,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.
|
||||
@@ -134,26 +260,70 @@ 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)
|
||||
if "%HAVE_PY%"=="1" (
|
||||
echo.
|
||||
echo [5/5] Installing clicker Python dependencies...
|
||||
python -m pip install --quiet --disable-pip-version-check -r clicker\requirements.txt
|
||||
if errorlevel 1 (
|
||||
echo [!] pip install failed - the dashboard still works, the clicker will not.
|
||||
)
|
||||
echo.
|
||||
if not defined PYCMD goto :deps_skip
|
||||
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
|
||||
%PYCMD% -m pip install --quiet --disable-pip-version-check -r clicker\requirements.txt
|
||||
if errorlevel 1 goto :deps_failed
|
||||
%PYCMD% -c "import pyautogui" 2>nul
|
||||
if errorlevel 1 goto :deps_failed
|
||||
echo pyautogui imports cleanly.
|
||||
goto :deps_done
|
||||
|
||||
:deps_failed
|
||||
echo [!] Python dependency install failed - the dashboard still works,
|
||||
echo the clicker will not. Retry by hand with:
|
||||
echo %PYCMD% -m pip install -r clicker\requirements.txt
|
||||
goto :deps_done
|
||||
|
||||
:deps_skip
|
||||
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 (
|
||||
echo.
|
||||
echo [5/5] Skipping Python dependencies ^(python not found^).
|
||||
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" (
|
||||
@@ -176,14 +346,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
|
||||
|
||||
Reference in New Issue
Block a user