Compare commits
25
Commits
37adbf67fc
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ba27161ce | ||
|
|
27a61fd25c | ||
|
|
c5c946d3cc | ||
|
|
3cc632ac40 | ||
|
|
9d777cb2c6 | ||
|
|
be8c628778 | ||
|
|
fbe2c47ed4 | ||
|
|
724ba5581c | ||
|
|
7e4714c33d | ||
|
|
97ee03d13b | ||
|
|
a0ef06d933 | ||
|
|
4288d8c298 | ||
|
|
7e7bd985c2 | ||
|
|
a718caeb08 | ||
|
|
8e64d8a34f | ||
|
|
d378712e79 | ||
|
|
9bdd20f83a | ||
|
|
3bef7dea9e | ||
|
|
fc08a41c4b | ||
|
|
3cc7ddcc5c | ||
|
|
3ac9fe060f | ||
|
|
731fafda0e | ||
|
|
3ff5728af9 | ||
|
|
a8853c56d1 | ||
|
|
7686301a70 |
+10
@@ -53,3 +53,13 @@ autotrader.sqlite
|
|||||||
# python
|
# python
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.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
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Claude Instructions
|
# Claude Instructions
|
||||||
|
|
||||||
## Working Directory
|
## Working Directory
|
||||||
Always work directly on `main`. Do **not** create worktrees or feature branches unless explicitly asked.
|
Always work directly on `master` — this repo has no `main`. Do **not** create worktrees or feature branches unless explicitly asked.
|
||||||
|
|
||||||
The project root is `D:\Development\market-dev\autotrader-firms\autotrader`.
|
The project root is `D:\Development\market-dev\autotrader-firms\autotrader`.
|
||||||
|
|||||||
@@ -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
|
In **PowerShell**, from wherever you want the instance to live:
|
||||||
|
|
||||||
Download and install from https://git-scm.com/download/win, or via winget:
|
|
||||||
|
|
||||||
```powershell
|
```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
|
```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
|
Re-running it updates an existing checkout rather than cloning again, so it
|
||||||
node --version # should be v22.x or higher
|
doubles as a repair tool.
|
||||||
npm --version
|
|
||||||
|
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)
|
```bash
|
||||||
|
bash setup-linux.sh
|
||||||
`better-sqlite3` compiles a native C++ module and needs the Visual Studio build tools:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
npm install -g windows-build-tools
|
|
||||||
```
|
```
|
||||||
|
|
||||||
If that fails on newer Node, install manually:
|
Same idea, apt instead of winget. It installs Node 22 from NodeSource (Pi OS
|
||||||
- Download **Build Tools for Visual Studio** from https://visualstudio.microsoft.com/downloads/#build-tools-for-visual-studio-2022
|
ships one too old for Next 16), `xdotool` and `scrot` for the clicker, and puts
|
||||||
- During install, select **"Desktop development with C++"**
|
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
|
```bash
|
||||||
git clone https://github.com/Senofy/autofirmer.git
|
sudo raspi-config # Advanced Options -> Wayland -> X11, then reboot
|
||||||
cd autofirmer
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 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
|
The dashboard runs on port **3000**: `http://localhost:3000`.
|
||||||
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`.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -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
|
`extension/manifest.json` and reloading. Without it the extension cannot read
|
||||||
that site, and every step fails to locate.
|
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
|
```powershell
|
||||||
cd clicker
|
python -m pip install -r clicker\requirements.txt
|
||||||
pip install -r requirements.txt
|
|
||||||
```
|
```
|
||||||
|
|
||||||
See `clicker/README.md` for the per-platform notes — display scaling and
|
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
|
### 3. Start the runner
|
||||||
|
|
||||||
|
Auto-start runs it under PM2, so normally there is nothing to do. To run it by
|
||||||
|
hand instead:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
python clicker\runner.py
|
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
|
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
|
reload when the source changes, so restart it after pulling updates; the
|
||||||
dashboard warns when its version is behind.
|
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
|
disconnecting can suspend the desktop and break clicks in ways that are hard to
|
||||||
diagnose.
|
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
|
```powershell
|
||||||
npm install -g pm2
|
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\install-autostart.ps1
|
||||||
npm install -g pm2-windows-startup
|
|
||||||
```
|
```
|
||||||
|
|
||||||
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
|
```powershell
|
||||||
cd C:\path\to\autofirmer
|
pm2 list # what is running
|
||||||
pm2 start "npm start" --name autofirmer
|
pm2 logs autofirmer # dashboard output
|
||||||
pm2 save
|
pm2 logs clicker # runner output
|
||||||
pm2-startup install
|
|
||||||
```
|
```
|
||||||
|
|
||||||
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
|
```powershell
|
||||||
cd C:\path\to\autofirmer
|
node scripts\start-all.mjs
|
||||||
git pull
|
|
||||||
npm install
|
|
||||||
npm run build
|
|
||||||
pm2 restart autofirmer
|
|
||||||
```
|
```
|
||||||
|
|
||||||
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
|
## Firewall
|
||||||
|
|
||||||
@@ -209,19 +202,48 @@ New-NetFirewallRule -DisplayName "AutoFirmer" -Direction Inbound -Protocol TCP -
|
|||||||
|
|
||||||
## Updating
|
## Updating
|
||||||
|
|
||||||
```powershell
|
Once auto-start is installed, nothing here is manual. Every 5 minutes the update
|
||||||
cd C:\path\to\autofirmer
|
task fetches `master`, and when it has moved it pulls, reinstalls dependencies if
|
||||||
git pull
|
`package-lock.json` or `clicker/requirements.txt` changed, rebuilds, restarts
|
||||||
npm install # only needed if dependencies changed
|
AutoFirmer, and restarts the clicker if anything under `clicker/` changed.
|
||||||
npm run build
|
|
||||||
pm2 restart autofirmer
|
**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 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
|
The dashboard reports the version it sees from the extension and the runner, and
|
||||||
behind. Most AutoBuyer bugs that look mysterious are one of these two still
|
warns when either is behind. Most AutoBuyer bugs that look mysterious are the
|
||||||
running the previous code.
|
extension still running the previous code.
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export async function POST() {
|
|||||||
urlPattern: row.url_pattern,
|
urlPattern: row.url_pattern,
|
||||||
openUrl: row.open_url,
|
openUrl: row.open_url,
|
||||||
navigateUrl: row.navigate_url,
|
navigateUrl: row.navigate_url,
|
||||||
|
options: (() => { try { return JSON.parse(row.options); } catch { return {}; } })(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { corsJson, corsPreflight } from '../cors';
|
|||||||
/** Python enqueues "find this selector and tell me where it is on screen". */
|
/** Python enqueues "find this selector and tell me where it is on screen". */
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const body = await req.json() as { selector?: unknown; index?: unknown; urlPattern?: unknown; openUrl?: unknown; navigateUrl?: unknown };
|
const body = await req.json() as { selector?: unknown; index?: unknown; urlPattern?: unknown; openUrl?: unknown; navigateUrl?: unknown; options?: unknown };
|
||||||
if (typeof body.selector !== 'string' || !body.selector.trim()) {
|
if (typeof body.selector !== 'string' || !body.selector.trim()) {
|
||||||
return corsJson({ error: '`selector` is required' }, { status: 400 });
|
return corsJson({ error: '`selector` is required' }, { status: 400 });
|
||||||
}
|
}
|
||||||
@@ -13,8 +13,9 @@ export async function POST(req: NextRequest) {
|
|||||||
const urlPattern = typeof body.urlPattern === 'string' ? body.urlPattern : '';
|
const urlPattern = typeof body.urlPattern === 'string' ? body.urlPattern : '';
|
||||||
const openUrl = typeof body.openUrl === 'string' ? body.openUrl : '';
|
const openUrl = typeof body.openUrl === 'string' ? body.openUrl : '';
|
||||||
const navigateUrl = typeof body.navigateUrl === 'string' ? body.navigateUrl : '';
|
const navigateUrl = typeof body.navigateUrl === 'string' ? body.navigateUrl : '';
|
||||||
|
const options = body.options && typeof body.options === 'object' ? JSON.stringify(body.options) : '{}';
|
||||||
|
|
||||||
const row = createLocateRequest(body.selector.trim(), index, urlPattern, openUrl, navigateUrl);
|
const row = createLocateRequest(body.selector.trim(), index, urlPattern, openUrl, navigateUrl, options);
|
||||||
return corsJson({ id: row.id, status: row.status });
|
return corsJson({ id: row.id, status: row.status });
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
return corsJson({ error: err?.message ?? 'Failed to queue request' }, { status: 500 });
|
return corsJson({ error: err?.message ?? 'Failed to queue request' }, { status: 500 });
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ export async function GET() {
|
|||||||
fundTransactions: displayFundTxns,
|
fundTransactions: displayFundTxns,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
return { firm: f.name, connected: true, accounts, perContractFees: client.perContractFees };
|
return { firm: f.name, connected: true, accounts };
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json(state);
|
return NextResponse.json(state);
|
||||||
|
|||||||
@@ -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
|
`pyautogui` moves nothing and fails silently, which looks identical to a bad
|
||||||
selector.
|
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
|
### Windows
|
||||||
|
|
||||||
No extra permissions, but two things differ.
|
No extra permissions, but two things differ.
|
||||||
|
|||||||
+3
-1
@@ -162,7 +162,9 @@ def perform(
|
|||||||
# A click on a background window is consumed activating it and never reaches
|
# A click on a background window is consumed activating it and never reaches
|
||||||
# the page, so raise the browser immediately before pressing.
|
# the page, so raise the browser immediately before pressing.
|
||||||
if activate:
|
if activate:
|
||||||
focused = focus.ensure_frontmost()
|
# Raise the browser the extension actually measured from, not whichever
|
||||||
|
# one happens to be first in the list.
|
||||||
|
focused = focus.ensure_frontmost(browser=found.get("browser"))
|
||||||
say(focused.detail)
|
say(focused.detail)
|
||||||
if not focused.ok:
|
if not focused.ok:
|
||||||
raise StepError(
|
raise StepError(
|
||||||
|
|||||||
+71
-10
@@ -21,6 +21,7 @@ Requires the AutoBuyer page switch to be ON — that's the master arming switch.
|
|||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
import random
|
import random
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
@@ -30,6 +31,22 @@ import urllib.request
|
|||||||
import actions
|
import actions
|
||||||
import focus
|
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"
|
DEFAULT_API = "http://localhost:3000"
|
||||||
POLL_INTERVAL = 0.25
|
POLL_INTERVAL = 0.25
|
||||||
|
|
||||||
@@ -38,6 +55,16 @@ class DashboardError(RuntimeError):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class TransientError(DashboardError):
|
||||||
|
"""A failure that is worth retrying: a 5xx, or the server briefly unreachable.
|
||||||
|
|
||||||
|
Next's dev server intermittently serves a 500 while it recompiles a route —
|
||||||
|
it reads a build manifest mid-write and fails to parse it. That is a blip in
|
||||||
|
the pipeline, not a verdict about the page, and it should not kill a run that
|
||||||
|
is halfway through spending money.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
class NotFoundError(DashboardError):
|
class NotFoundError(DashboardError):
|
||||||
"""The extension reached the page and the element simply isn't there.
|
"""The extension reached the page and the element simply isn't there.
|
||||||
|
|
||||||
@@ -53,6 +80,26 @@ class NotFoundError(DashboardError):
|
|||||||
_ABSENT_MARKERS = ("No element matches", "no index")
|
_ABSENT_MARKERS = ("No element matches", "no index")
|
||||||
|
|
||||||
|
|
||||||
|
def _summarise_error_body(body: str, limit: int = 200) -> str:
|
||||||
|
"""Keep an error readable.
|
||||||
|
|
||||||
|
A dev-server 500 answers with a full HTML page — several kilobytes of script
|
||||||
|
tags and a stack trace — and printing it raw buries the one line that says
|
||||||
|
what went wrong.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return str(json.loads(body).get("error", body))[:limit]
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if "<!DOCTYPE" in body or "<html" in body:
|
||||||
|
match = re.search(r'"message":"(.*?)"', body)
|
||||||
|
detail = match.group(1) if match else "no detail in the page"
|
||||||
|
return f"server returned an HTML error page ({detail[:limit]})"
|
||||||
|
|
||||||
|
return body[:limit]
|
||||||
|
|
||||||
|
|
||||||
def _is_absent(message: str) -> bool:
|
def _is_absent(message: str) -> bool:
|
||||||
return any(marker in message for marker in _ABSENT_MARKERS)
|
return any(marker in message for marker in _ABSENT_MARKERS)
|
||||||
|
|
||||||
@@ -72,20 +119,18 @@ class Dashboard:
|
|||||||
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
||||||
return json.loads(resp.read().decode())
|
return json.loads(resp.read().decode())
|
||||||
except urllib.error.HTTPError as exc:
|
except urllib.error.HTTPError as exc:
|
||||||
body = exc.read().decode(errors="replace")
|
message = _summarise_error_body(exc.read().decode(errors="replace"))
|
||||||
try:
|
cls = TransientError if exc.code >= 500 else DashboardError
|
||||||
message = json.loads(body).get("error", body)
|
raise cls(f"{method} {path} -> HTTP {exc.code}: {message}") from None
|
||||||
except json.JSONDecodeError:
|
|
||||||
message = body
|
|
||||||
raise DashboardError(f"{method} {path} -> HTTP {exc.code}: {message}") from None
|
|
||||||
except urllib.error.URLError as exc:
|
except urllib.error.URLError as exc:
|
||||||
raise DashboardError(f"Cannot reach {self.base} — {exc.reason}") from None
|
raise TransientError(f"Cannot reach {self.base} — {exc.reason}") from None
|
||||||
|
|
||||||
def status(self) -> dict:
|
def status(self) -> dict:
|
||||||
return self._request("/api/autobuyer/status")
|
return self._request("/api/autobuyer/status")
|
||||||
|
|
||||||
def locate(self, selector: str, index: int, url_pattern: str, timeout: float,
|
def locate(self, selector: str, index: int, url_pattern: str, timeout: float,
|
||||||
open_url: str = "", navigate_url: str = "") -> dict:
|
open_url: str = "", navigate_url: str = "",
|
||||||
|
options: dict | None = None) -> dict:
|
||||||
"""Queue a lookup and block until the extension answers it.
|
"""Queue a lookup and block until the extension answers it.
|
||||||
|
|
||||||
`open_url` is the page the extension should open if no tab matches
|
`open_url` is the page the extension should open if no tab matches
|
||||||
@@ -95,13 +140,23 @@ class Dashboard:
|
|||||||
"/api/autobuyer/locate",
|
"/api/autobuyer/locate",
|
||||||
"POST",
|
"POST",
|
||||||
{"selector": selector, "index": index, "urlPattern": url_pattern,
|
{"selector": selector, "index": index, "urlPattern": url_pattern,
|
||||||
"openUrl": open_url, "navigateUrl": navigate_url},
|
"openUrl": open_url, "navigateUrl": navigate_url,
|
||||||
|
"options": options or {}},
|
||||||
)
|
)
|
||||||
request_id = queued["id"]
|
request_id = queued["id"]
|
||||||
|
|
||||||
deadline = time.monotonic() + timeout
|
deadline = time.monotonic() + timeout
|
||||||
|
last_transient = None
|
||||||
while time.monotonic() < deadline:
|
while time.monotonic() < deadline:
|
||||||
row = self._request(f"/api/autobuyer/locate?id={request_id}")
|
try:
|
||||||
|
row = self._request(f"/api/autobuyer/locate?id={request_id}")
|
||||||
|
except TransientError as exc:
|
||||||
|
# The extension may well answer while the server is having a
|
||||||
|
# moment; keep polling rather than failing the run over a blip.
|
||||||
|
last_transient = exc
|
||||||
|
time.sleep(POLL_INTERVAL)
|
||||||
|
continue
|
||||||
|
|
||||||
if row["status"] == "done":
|
if row["status"] == "done":
|
||||||
return row["result"]
|
return row["result"]
|
||||||
if row["status"] == "error":
|
if row["status"] == "error":
|
||||||
@@ -110,6 +165,11 @@ class Dashboard:
|
|||||||
raise cls(f"Extension could not locate it: {detail}")
|
raise cls(f"Extension could not locate it: {detail}")
|
||||||
time.sleep(POLL_INTERVAL)
|
time.sleep(POLL_INTERVAL)
|
||||||
|
|
||||||
|
if last_transient is not None:
|
||||||
|
raise DashboardError(
|
||||||
|
f"No answer within {timeout:g}s, and the dashboard kept erroring "
|
||||||
|
f"({last_transient})"
|
||||||
|
)
|
||||||
raise DashboardError(
|
raise DashboardError(
|
||||||
f"No answer within {timeout:g}s. Is the extension installed, is Chrome "
|
f"No answer within {timeout:g}s. Is the extension installed, is Chrome "
|
||||||
f"running, and is the AutoBuyer switch ON?"
|
f"running, and is the AutoBuyer switch ON?"
|
||||||
@@ -132,6 +192,7 @@ def describe(found: dict, factor: float) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
|
force_utf8_output()
|
||||||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||||
parser.add_argument("action", choices=["locate", "click", "type"],
|
parser.add_argument("action", choices=["locate", "click", "type"],
|
||||||
help="locate = measure only; click = measure then click; "
|
help="locate = measure only; click = measure then click; "
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Check whether this machine can actually be driven.
|
||||||
|
|
||||||
|
Run it on the box that will do the clicking, before trusting a run:
|
||||||
|
|
||||||
|
python diagnose.py
|
||||||
|
|
||||||
|
It answers the question a remote desktop makes hard — is the mouse really moving,
|
||||||
|
or is the viewer just not drawing it? The cursor position is read back from the
|
||||||
|
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}")
|
||||||
|
|
||||||
|
|
||||||
|
def check_platform() -> None:
|
||||||
|
print("\nPlatform")
|
||||||
|
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
|
||||||
|
|
||||||
|
import ctypes
|
||||||
|
|
||||||
|
# SM_REMOTESESSION: non-zero when this process is running inside an RDP
|
||||||
|
# session rather than at the physical console.
|
||||||
|
remote = ctypes.windll.user32.GetSystemMetrics(0x1000)
|
||||||
|
line("remote session", "yes — RDP/terminal services" if remote else "no — physical console")
|
||||||
|
if remote:
|
||||||
|
print(" Input injection still works over RDP, but the session's desktop")
|
||||||
|
print(" is locked when you disconnect, and clicks go nowhere until you")
|
||||||
|
print(" reconnect. Keep the window open for the duration of a run.")
|
||||||
|
|
||||||
|
|
||||||
|
def check_dpi() -> None:
|
||||||
|
print("\nDisplay")
|
||||||
|
try:
|
||||||
|
import focus
|
||||||
|
except ImportError:
|
||||||
|
line("dpi awareness", "focus.py not importable — run this from clicker/")
|
||||||
|
return
|
||||||
|
|
||||||
|
result = focus.enable_dpi_awareness()
|
||||||
|
line("dpi awareness", result or "n/a (not Windows)")
|
||||||
|
|
||||||
|
try:
|
||||||
|
import pyautogui
|
||||||
|
except ImportError:
|
||||||
|
line("screen size", "pyautogui not installed")
|
||||||
|
return
|
||||||
|
size = pyautogui.size()
|
||||||
|
line("screen size", f"{size.width}x{size.height} (as the OS reports it)")
|
||||||
|
print(" Compare against what `clicker.py locate` reports for screenSize.")
|
||||||
|
print(" A mismatch that isn't a clean scaling factor means clicks land off.")
|
||||||
|
|
||||||
|
|
||||||
|
def check_mouse() -> bool:
|
||||||
|
"""Move the cursor and read it back. Returns True if the OS agreed."""
|
||||||
|
print("\nMouse")
|
||||||
|
try:
|
||||||
|
import pyautogui
|
||||||
|
except ImportError:
|
||||||
|
line("result", "pyautogui not installed — run: pip install -r requirements.txt")
|
||||||
|
return False
|
||||||
|
|
||||||
|
pyautogui.FAILSAFE = False # a deliberate corner move would abort us
|
||||||
|
start = pyautogui.position()
|
||||||
|
line("start position", f"{start[0]},{start[1]}")
|
||||||
|
|
||||||
|
width, height = pyautogui.size()
|
||||||
|
targets = [(width // 4, height // 4), (width // 2, height // 2)]
|
||||||
|
|
||||||
|
agreed = True
|
||||||
|
for x, y in targets:
|
||||||
|
pyautogui.moveTo(x, y, duration=0.3)
|
||||||
|
time.sleep(0.1)
|
||||||
|
got = pyautogui.position()
|
||||||
|
ok = abs(got[0] - x) <= 2 and abs(got[1] - y) <= 2
|
||||||
|
agreed &= ok
|
||||||
|
line("moved to", f"{x},{y} -> OS reports {got[0]},{got[1]} {'OK' if ok else 'MISMATCH'}")
|
||||||
|
|
||||||
|
pyautogui.moveTo(start[0], start[1], duration=0.2)
|
||||||
|
|
||||||
|
print()
|
||||||
|
if agreed:
|
||||||
|
print(" The OS moved the cursor to every requested point.")
|
||||||
|
print(" If you saw nothing move, that is your viewer not drawing it —")
|
||||||
|
print(" the clicks are landing where they should.")
|
||||||
|
else:
|
||||||
|
print(" The cursor did NOT land where it was asked to.")
|
||||||
|
print(" On Windows this is usually display scaling: the process is being")
|
||||||
|
print(" fed virtualised coordinates. Check the dpi awareness line above,")
|
||||||
|
print(" and pass --scale to clicker.py to compensate.")
|
||||||
|
return agreed
|
||||||
|
|
||||||
|
|
||||||
|
def check_foreground() -> None:
|
||||||
|
print("\nForeground window")
|
||||||
|
try:
|
||||||
|
import focus
|
||||||
|
except ImportError:
|
||||||
|
line("frontmost", "focus.py not importable")
|
||||||
|
return
|
||||||
|
|
||||||
|
front = focus.frontmost()
|
||||||
|
line("frontmost", front or "could not determine on this platform")
|
||||||
|
line("browsers known", ", ".join(focus.browser_ids()) or "none for this platform")
|
||||||
|
|
||||||
|
result = focus.ensure_frontmost()
|
||||||
|
line("raise browser", f"{'OK' if result.ok else 'FAILED'} — {result.detail}")
|
||||||
|
print(" With no browser named, any known one counts — that is this check")
|
||||||
|
print(" only. A real run raises the browser the extension reported, so if")
|
||||||
|
print(" this raised one you do not automate, that is not a fault.")
|
||||||
|
if not result.ok:
|
||||||
|
print(" Every click is refused while this fails: a click on an")
|
||||||
|
print(" unfocused window is consumed activating it.")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
force_utf8_output()
|
||||||
|
print("AutoFirmer clicker diagnostics")
|
||||||
|
check_platform()
|
||||||
|
check_dpi()
|
||||||
|
ok = check_mouse()
|
||||||
|
check_foreground()
|
||||||
|
print()
|
||||||
|
return 0 if ok else 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
+159
-34
@@ -12,24 +12,78 @@ 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.
|
still in the background. This module raises the application itself.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
|
|
||||||
# Chrome ships under several bundle ids; accept whichever is installed.
|
# Per browser, how to recognise it on each platform. The extension reports which
|
||||||
MAC_BUNDLES = (
|
# one is hosting it, because picking by list order raises the wrong browser as
|
||||||
"com.google.Chrome",
|
# soon as two are installed — and then clicks land in a window the coordinates
|
||||||
"com.google.Chrome.beta",
|
# were never measured from.
|
||||||
"com.google.Chrome.dev",
|
BROWSERS = {
|
||||||
"com.google.Chrome.canary",
|
"chrome": {
|
||||||
"com.brave.Browser",
|
"darwin": ("com.google.Chrome", "com.google.Chrome.beta",
|
||||||
"com.microsoft.edgemac",
|
"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",),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
# Windows browsers, matched on the owning process. A title match would also hit
|
|
||||||
# an editor with chrome.js open or a folder named Chrome; a process name cannot
|
def _platform_key() -> str:
|
||||||
# collide that way.
|
if sys.platform == "darwin":
|
||||||
WINDOWS_PROCESSES = ("chrome.exe", "msedge.exe", "brave.exe")
|
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 = _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])
|
||||||
|
|
||||||
|
|
||||||
|
def _titles_for(browser: str | None) -> tuple[str, ...]:
|
||||||
|
if browser and browser in BROWSERS:
|
||||||
|
return BROWSERS[browser]["titles"]
|
||||||
|
return tuple(t for b in BROWSERS.values() for t in b["titles"])
|
||||||
|
|
||||||
|
|
||||||
|
# Kept for callers that just want "any known browser".
|
||||||
|
MAC_BUNDLES = _ids_for(None) if sys.platform == "darwin" else BROWSERS["chrome"]["darwin"]
|
||||||
|
WINDOWS_PROCESSES = BROWSERS["chrome"]["win32"] + BROWSERS["edge"]["win32"] + BROWSERS["brave"]["win32"]
|
||||||
|
|
||||||
SETTLE = 0.20 # let the window manager finish raising before measuring or clicking
|
SETTLE = 0.20 # let the window manager finish raising before measuring or clicking
|
||||||
|
|
||||||
@@ -103,12 +157,68 @@ def _mac_workspace():
|
|||||||
return NSWorkspace.sharedWorkspace()
|
return NSWorkspace.sharedWorkspace()
|
||||||
|
|
||||||
|
|
||||||
def browser_ids() -> tuple[str, ...]:
|
def _linux_session_problem() -> str | None:
|
||||||
"""What counts as "the browser" on this platform."""
|
"""Why the clicker cannot drive this desktop, or None if it can.
|
||||||
if sys.platform == "darwin":
|
|
||||||
return MAC_BUNDLES
|
Raspberry Pi OS on a Pi 5 defaults to Wayland (labwc). Neither xdotool nor
|
||||||
if sys.platform == "win32":
|
pyautogui works there: both speak X11 protocol, and Wayland deliberately
|
||||||
return WINDOWS_PROCESSES
|
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") or sys.platform.startswith("linux"):
|
||||||
|
return _ids_for(browser)
|
||||||
return ()
|
return ()
|
||||||
|
|
||||||
|
|
||||||
@@ -135,10 +245,13 @@ def frontmost() -> str | None:
|
|||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
if sys.platform.startswith("linux"):
|
||||||
|
return _linux_frontmost()
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _mac_activate() -> FocusResult:
|
def _mac_activate(browser: str | None = None) -> FocusResult:
|
||||||
ws = _mac_workspace()
|
ws = _mac_workspace()
|
||||||
if ws is None:
|
if ws is None:
|
||||||
# pyobjc's AppKit isn't present. osascript works but may prompt for
|
# pyobjc's AppKit isn't present. osascript works but may prompt for
|
||||||
@@ -153,7 +266,7 @@ def _mac_activate() -> FocusResult:
|
|||||||
return FocusResult(False, f"could not activate Chrome ({exc})")
|
return FocusResult(False, f"could not activate Chrome ({exc})")
|
||||||
|
|
||||||
running = {a.bundleIdentifier(): a for a in ws.runningApplications()}
|
running = {a.bundleIdentifier(): a for a in ws.runningApplications()}
|
||||||
for bundle in MAC_BUNDLES:
|
for bundle in _ids_for(browser):
|
||||||
app = running.get(bundle)
|
app = running.get(bundle)
|
||||||
if app is None:
|
if app is None:
|
||||||
continue
|
continue
|
||||||
@@ -162,10 +275,11 @@ def _mac_activate() -> FocusResult:
|
|||||||
app.activateWithOptions_(1 << 1)
|
app.activateWithOptions_(1 << 1)
|
||||||
return FocusResult(True, f"activated {bundle}")
|
return FocusResult(True, f"activated {bundle}")
|
||||||
|
|
||||||
return FocusResult(False, "no Chrome-family browser is running")
|
wanted = browser or "any known browser"
|
||||||
|
return FocusResult(False, f"{wanted} is not running")
|
||||||
|
|
||||||
|
|
||||||
def _is_browser_window(win) -> bool:
|
def _is_browser_window(win, browser: str | None = None) -> bool:
|
||||||
"""Match on the owning process where we can, title only as a fallback.
|
"""Match on the owning process where we can, title only as a fallback.
|
||||||
|
|
||||||
A title match alone catches an editor with chrome.js open, or a folder window
|
A title match alone catches an editor with chrome.js open, or a folder window
|
||||||
@@ -175,13 +289,13 @@ def _is_browser_window(win) -> bool:
|
|||||||
try:
|
try:
|
||||||
name = _win_process_name(win._hWnd)
|
name = _win_process_name(win._hWnd)
|
||||||
if name:
|
if name:
|
||||||
return name in WINDOWS_PROCESSES
|
return name in _ids_for(browser)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass # fall through to the title check
|
pass # fall through to the title check
|
||||||
return bool(win.title) and "Chrome" in win.title
|
return bool(win.title) and any(t in win.title for t in _titles_for(browser))
|
||||||
|
|
||||||
|
|
||||||
def _other_activate() -> FocusResult:
|
def _other_activate(browser: str | None = None) -> FocusResult:
|
||||||
"""Windows (and any platform pygetwindow supports)."""
|
"""Windows (and any platform pygetwindow supports)."""
|
||||||
try:
|
try:
|
||||||
import pygetwindow
|
import pygetwindow
|
||||||
@@ -189,14 +303,15 @@ def _other_activate() -> FocusResult:
|
|||||||
return FocusResult(False, "pygetwindow unavailable — cannot raise the browser")
|
return FocusResult(False, "pygetwindow unavailable — cannot raise the browser")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
wins = [w for w in pygetwindow.getAllWindows() if w.visible and _is_browser_window(w)]
|
wins = [w for w in pygetwindow.getAllWindows()
|
||||||
|
if w.visible and _is_browser_window(w, browser)]
|
||||||
except NotImplementedError:
|
except NotImplementedError:
|
||||||
# pygetwindow has no X11 backend; say so rather than looking like no
|
# pygetwindow has no X11 backend; say so rather than looking like no
|
||||||
# browser is open.
|
# browser is open.
|
||||||
return FocusResult(False, f"window management is unsupported on {sys.platform}")
|
return FocusResult(False, f"window management is unsupported on {sys.platform}")
|
||||||
|
|
||||||
if not wins:
|
if not wins:
|
||||||
return FocusResult(False, "no browser window found")
|
return FocusResult(False, f"no {browser or 'browser'} window found")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
win = wins[0]
|
win = wins[0]
|
||||||
@@ -211,25 +326,35 @@ def _other_activate() -> FocusResult:
|
|||||||
return FocusResult(False, f"could not activate window ({exc})")
|
return FocusResult(False, f"could not activate window ({exc})")
|
||||||
|
|
||||||
|
|
||||||
def activate_browser() -> FocusResult:
|
def activate_browser(browser: str | None = None) -> FocusResult:
|
||||||
"""Raise the browser application above everything else."""
|
"""Raise the browser application above everything else.
|
||||||
result = _mac_activate() if sys.platform == "darwin" else _other_activate()
|
|
||||||
|
`browser` is the id the extension reported ("chrome", "edge", ...). Without
|
||||||
|
it, any known browser will do — fine on a machine with one installed, wrong
|
||||||
|
on a machine with two.
|
||||||
|
"""
|
||||||
|
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:
|
if result.ok:
|
||||||
time.sleep(SETTLE)
|
time.sleep(SETTLE)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
def ensure_frontmost(timeout: float = 1.5) -> FocusResult:
|
def ensure_frontmost(timeout: float = 1.5, browser: str | None = None) -> FocusResult:
|
||||||
"""Raise the browser and, where we can check, confirm it actually came forward.
|
"""Raise the browser and, where we can check, confirm it actually came forward.
|
||||||
|
|
||||||
Returning ok=False does not mean the click will fail — only that we could not
|
Returning ok=False does not mean the click will fail — only that we could not
|
||||||
verify. The caller decides whether to proceed.
|
verify. The caller decides whether to proceed.
|
||||||
"""
|
"""
|
||||||
result = activate_browser()
|
result = activate_browser(browser)
|
||||||
if not result.ok:
|
if not result.ok:
|
||||||
return result
|
return result
|
||||||
|
|
||||||
ids = browser_ids()
|
ids = browser_ids(browser)
|
||||||
if not ids:
|
if not ids:
|
||||||
return FocusResult(True, result.detail + " (unverified)")
|
return FocusResult(True, result.detail + " (unverified)")
|
||||||
|
|
||||||
|
|||||||
@@ -4,3 +4,7 @@ pyautogui>=0.9.54
|
|||||||
pyobjc-core>=10.0; sys_platform == "darwin"
|
pyobjc-core>=10.0; sys_platform == "darwin"
|
||||||
pyobjc-framework-Quartz>=10.0; sys_platform == "darwin"
|
pyobjc-framework-Quartz>=10.0; sys_platform == "darwin"
|
||||||
pyobjc-framework-Cocoa>=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"
|
||||||
|
|||||||
+49
-10
@@ -24,7 +24,8 @@ import threading
|
|||||||
import time
|
import time
|
||||||
|
|
||||||
import actions
|
import actions
|
||||||
from clicker import Dashboard, DashboardError, NotFoundError
|
import focus
|
||||||
|
from clicker import Dashboard, DashboardError, NotFoundError, force_utf8_output
|
||||||
|
|
||||||
POLL_SECONDS = 1.0
|
POLL_SECONDS = 1.0
|
||||||
HEARTBEAT_SECONDS = 2.0
|
HEARTBEAT_SECONDS = 2.0
|
||||||
@@ -32,7 +33,7 @@ HEARTBEAT_SECONDS = 2.0
|
|||||||
# Bumped whenever the step vocabulary or the locate protocol changes. Reported in
|
# Bumped whenever the step vocabulary or the locate protocol changes. Reported in
|
||||||
# the heartbeat so the dashboard can say "restart your runner" instead of letting
|
# the heartbeat so the dashboard can say "restart your runner" instead of letting
|
||||||
# a stale process fail on a step type it has never heard of.
|
# a stale process fail on a step type it has never heard of.
|
||||||
VERSION = "0.12.0"
|
VERSION = "0.16.0"
|
||||||
|
|
||||||
# Shared with the heartbeat thread: whether a run is currently executing.
|
# Shared with the heartbeat thread: whether a run is currently executing.
|
||||||
_busy = threading.Event()
|
_busy = threading.Event()
|
||||||
@@ -74,10 +75,13 @@ def run_one_step(dash: Dashboard, step: dict, opts, rng, report, run_id: int | N
|
|||||||
report(f"tab is on {landed.get('url', target)}")
|
report(f"tab is on {landed.get('url', target)}")
|
||||||
|
|
||||||
elif step["action"] == "waitFor":
|
elif step["action"] == "waitFor":
|
||||||
# A gate, not an action: block until the element exists. Nothing is
|
# A gate, not an action: block until the element exists — or, with
|
||||||
# clicked or typed. Whatever has to make it appear — a person solving a
|
# `absent`, until it is gone. Nothing is clicked or typed. Whatever has to
|
||||||
# challenge, a slow server, a background job — happens outside this run.
|
# change the page — a person solving a challenge, a modal closing itself,
|
||||||
|
# a slow server — happens outside this run.
|
||||||
selector = step["selector"]
|
selector = step["selector"]
|
||||||
|
absent = bool(step.get("absent"))
|
||||||
|
goal = "disappear" if absent else "appear"
|
||||||
timeout_s = float(step.get("timeoutSeconds", 120))
|
timeout_s = float(step.get("timeoutSeconds", 120))
|
||||||
deadline = time.time() + timeout_s
|
deadline = time.time() + timeout_s
|
||||||
announced = False
|
announced = False
|
||||||
@@ -91,20 +95,54 @@ def run_one_step(dash: Dashboard, step: dict, opts, rng, report, run_id: int | N
|
|||||||
dash.locate(selector, int(step.get("index", 0)),
|
dash.locate(selector, int(step.get("index", 0)),
|
||||||
step.get("urlPattern", "") or opts.url,
|
step.get("urlPattern", "") or opts.url,
|
||||||
opts.timeout, step.get("openUrl", "") or "")
|
opts.timeout, step.get("openUrl", "") or "")
|
||||||
report(f"{selector} appeared")
|
if not absent:
|
||||||
break
|
report(f"{selector} appeared")
|
||||||
|
break
|
||||||
|
# Still there. Keep waiting for it to go.
|
||||||
|
except NotFoundError:
|
||||||
|
if absent:
|
||||||
|
report(f"{selector} is gone")
|
||||||
|
break
|
||||||
|
# Not there yet; keep waiting for it to arrive.
|
||||||
except DashboardError:
|
except DashboardError:
|
||||||
pass # not there yet, or the tab is mid-render
|
# A dead extension or an unreachable dashboard must not be read
|
||||||
|
# as "the element is gone" — that would satisfy an absent gate
|
||||||
|
# for entirely the wrong reason.
|
||||||
|
pass
|
||||||
|
|
||||||
if time.time() >= deadline:
|
if time.time() >= deadline:
|
||||||
raise actions.StepError(
|
raise actions.StepError(
|
||||||
f"{selector} did not appear within {timeout_s:.0f}s"
|
f"{selector} did not {goal} within {timeout_s:.0f}s"
|
||||||
)
|
)
|
||||||
if not announced:
|
if not announced:
|
||||||
report(f"waiting for {selector} (up to {timeout_s:.0f}s)")
|
report(f"waiting for {selector} to {goal} (up to {timeout_s:.0f}s)")
|
||||||
announced = True
|
announced = True
|
||||||
time.sleep(2.0)
|
time.sleep(2.0)
|
||||||
|
|
||||||
|
elif step["action"] == "scrollToLoad":
|
||||||
|
# Lists that load progressively need walking to the bottom before the
|
||||||
|
# steps that act on their items can see everything.
|
||||||
|
result = dash.locate(
|
||||||
|
step["selector"], 0,
|
||||||
|
step.get("urlPattern", "") or opts.url,
|
||||||
|
max(opts.timeout, 120.0), # scrolling a long list outlasts a normal step
|
||||||
|
step.get("openUrl", "") or "",
|
||||||
|
options={
|
||||||
|
"op": "scrollToLoad",
|
||||||
|
"containerSelector": step.get("containerSelector", ""),
|
||||||
|
"maxScrolls": int(step.get("maxScrolls", 25)),
|
||||||
|
"settleMs": int(step.get("settleMs", 800)),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
found_n = result.get("after", 0)
|
||||||
|
report(f"{found_n} match(es) after {result.get('scrolls', 0)} scroll(s) "
|
||||||
|
f"of {result.get('container', '?')} (was {result.get('before', 0)})")
|
||||||
|
if not result.get("exhausted"):
|
||||||
|
# Stopping on the scroll cap is not a failure, but it does mean the
|
||||||
|
# list may still have more below — worth saying so rather than
|
||||||
|
# letting a later step quietly work on a partial list.
|
||||||
|
report("hit the scroll limit — there may be more not loaded")
|
||||||
|
|
||||||
elif step["action"] not in ("click", "type"):
|
elif step["action"] not in ("click", "type"):
|
||||||
# Almost always a stale runner: the server defines the step vocabulary,
|
# Almost always a stale runner: the server defines the step vocabulary,
|
||||||
# so a step type this process has never heard of means automations.ts has
|
# so a step type this process has never heard of means automations.ts has
|
||||||
@@ -324,6 +362,7 @@ def finish(dash, run_id, error):
|
|||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
|
force_utf8_output()
|
||||||
parser = argparse.ArgumentParser(description=__doc__,
|
parser = argparse.ArgumentParser(description=__doc__,
|
||||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||||
parser.add_argument("--api", default="http://localhost:3000", help="dashboard URL")
|
parser.add_argument("--api", default="http://localhost:3000", help="dashboard URL")
|
||||||
|
|||||||
+117
-1
@@ -154,6 +154,24 @@ async function captureAndSend(cfg) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Which browser is hosting this extension.
|
||||||
|
*
|
||||||
|
* The clicker has to raise *this* browser before clicking, and picking by list
|
||||||
|
* order gets it wrong the moment two are installed — it would raise Edge while
|
||||||
|
* the coordinates came from a tab in Chrome, landing every click in the wrong
|
||||||
|
* window. So the answer travels with the measurement.
|
||||||
|
*/
|
||||||
|
function detectBrowser() {
|
||||||
|
const ua = navigator.userAgent || '';
|
||||||
|
if (/\bEdg\//.test(ua)) return 'edge';
|
||||||
|
if (/\bOPR\//.test(ua)) return 'opera';
|
||||||
|
if (/\bVivaldi\//.test(ua)) return 'vivaldi';
|
||||||
|
try {
|
||||||
|
if (navigator.brave) return 'brave'; // Brave otherwise reports as Chrome
|
||||||
|
} catch { /* not Brave */ }
|
||||||
|
return 'chrome';
|
||||||
|
}
|
||||||
|
|
||||||
// ── Locate: turn a CSS selector into desktop coordinates ────────────────────
|
// ── Locate: turn a CSS selector into desktop coordinates ────────────────────
|
||||||
|
|
||||||
/** Runs in the page. Scrolls the element into view, then reports where it ended up. */
|
/** Runs in the page. Scrolls the element into view, then reports where it ended up. */
|
||||||
@@ -245,6 +263,71 @@ function waitForTabLoad(tabId, timeoutMs = 15000) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Runs in the page. Scrolls until the list stops growing, then reports what it
|
||||||
|
* ended up with.
|
||||||
|
*
|
||||||
|
* The scrolling element is often NOT the window — lists like this usually live
|
||||||
|
* in a div with its own overflow, and scrolling the document does nothing at
|
||||||
|
* all. So walk up from a matched item looking for the ancestor that actually
|
||||||
|
* scrolls, and let the caller name one outright when the guess is wrong.
|
||||||
|
*/
|
||||||
|
async function pageScrollToLoad(selector, containerSelector, maxScrolls, settleMs, stableRounds) {
|
||||||
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
const count = () => document.querySelectorAll(selector).length;
|
||||||
|
|
||||||
|
function scroller() {
|
||||||
|
if (containerSelector) {
|
||||||
|
const named = document.querySelector(containerSelector);
|
||||||
|
if (!named) return { error: `No element matches container ${containerSelector}` };
|
||||||
|
return { el: named };
|
||||||
|
}
|
||||||
|
|
||||||
|
// An ancestor that can actually scroll: overflow allows it, and there is
|
||||||
|
// more content than fits.
|
||||||
|
let node = document.querySelector(selector)?.parentElement ?? null;
|
||||||
|
while (node && node !== document.body) {
|
||||||
|
const overflow = getComputedStyle(node).overflowY;
|
||||||
|
if (/(auto|scroll)/.test(overflow) && node.scrollHeight > node.clientHeight + 4) {
|
||||||
|
return { el: node };
|
||||||
|
}
|
||||||
|
node = node.parentElement;
|
||||||
|
}
|
||||||
|
return { el: document.scrollingElement || document.documentElement, isDocument: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const found = scroller();
|
||||||
|
if (found.error) return { error: found.error };
|
||||||
|
const el = found.el;
|
||||||
|
|
||||||
|
const before = count();
|
||||||
|
let last = before;
|
||||||
|
let stable = 0;
|
||||||
|
let scrolls = 0;
|
||||||
|
|
||||||
|
while (scrolls < maxScrolls) {
|
||||||
|
const wasAtBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 4;
|
||||||
|
el.scrollTop = el.scrollHeight;
|
||||||
|
scrolls++;
|
||||||
|
await sleep(settleMs);
|
||||||
|
|
||||||
|
const now = count();
|
||||||
|
// Nothing new AND we were already pinned to the bottom: the list is done
|
||||||
|
// growing, not merely slow.
|
||||||
|
stable = (now > last) ? 0 : stable + (wasAtBottom ? 1 : 0);
|
||||||
|
last = now;
|
||||||
|
if (stable >= stableRounds) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
before,
|
||||||
|
after: last,
|
||||||
|
scrolls,
|
||||||
|
exhausted: stable >= stableRounds,
|
||||||
|
container: found.isDocument ? 'document' : (el.className || el.tagName || 'element').toString().slice(0, 60),
|
||||||
|
url: location.href,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function resolveLocateTab(cfg, request) {
|
async function resolveLocateTab(cfg, request) {
|
||||||
// Every step carries its firm's pattern; the manifest hosts are the fallback
|
// Every step carries its firm's pattern; the manifest hosts are the fallback
|
||||||
// for a bare request (the CLI's locate without --url).
|
// for a bare request (the CLI's locate without --url).
|
||||||
@@ -305,6 +388,33 @@ async function serveLocateRequest(cfg) {
|
|||||||
await chrome.tabs.update(tab.id, { active: true });
|
await chrome.tabs.update(tab.id, { active: true });
|
||||||
await new Promise((r) => setTimeout(r, 250)); // let the OS finish raising it
|
await new Promise((r) => setTimeout(r, 250)); // let the OS finish raising it
|
||||||
|
|
||||||
|
// A scroll request is a different operation on the same channel: no
|
||||||
|
// element is measured, the page is just walked to the bottom.
|
||||||
|
if (request.options && request.options.op === 'scrollToLoad') {
|
||||||
|
const [scrolled] = await chrome.scripting.executeScript({
|
||||||
|
target: { tabId: tab.id },
|
||||||
|
func: pageScrollToLoad,
|
||||||
|
args: [
|
||||||
|
request.selector,
|
||||||
|
request.options.containerSelector || '',
|
||||||
|
Number(request.options.maxScrolls) || 25,
|
||||||
|
Number(request.options.settleMs) || 800,
|
||||||
|
Number(request.options.stableRounds) || 2,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const out = scrolled?.result;
|
||||||
|
if (!out) throw new Error('Scroll injection returned nothing');
|
||||||
|
if (out.error) throw new Error(out.error);
|
||||||
|
result = { ...out, tabId: tab.id, windowId: tab.windowId, browser: detectBrowser() };
|
||||||
|
|
||||||
|
await fetch(`${cfg.apiBase}/api/autobuyer/locate/result`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ id: request.id, result, error: null }),
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// `complete` only means the document loaded — a React app still has to
|
// `complete` only means the document loaded — a React app still has to
|
||||||
// mount and paint. Retry briefly rather than declaring the element missing,
|
// mount and paint. Retry briefly rather than declaring the element missing,
|
||||||
// with a longer budget when we just opened the page from cold.
|
// with a longer budget when we just opened the page from cold.
|
||||||
@@ -322,7 +432,13 @@ async function serveLocateRequest(cfg) {
|
|||||||
if (Date.now() >= deadline) throw new Error(out.error);
|
if (Date.now() >= deadline) throw new Error(out.error);
|
||||||
await new Promise((r) => setTimeout(r, 350));
|
await new Promise((r) => setTimeout(r, 350));
|
||||||
}
|
}
|
||||||
result = { ...out, tabId: tab.id, windowId: tab.windowId, openedTab: opened };
|
result = {
|
||||||
|
...out,
|
||||||
|
tabId: tab.id,
|
||||||
|
windowId: tab.windowId,
|
||||||
|
openedTab: opened,
|
||||||
|
browser: detectBrowser(),
|
||||||
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error = err.message;
|
error = err.message;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "AutoFirmer Capture",
|
"name": "AutoFirmer Capture",
|
||||||
"version": "0.6.0",
|
"version": "0.8.0",
|
||||||
"description": "Scrapes the HTML of the target tab and posts it to the AutoFirmer dashboard while the AutoBuyer is switched on.",
|
"description": "Scrapes the HTML of the target tab and posts it to the AutoFirmer dashboard while the AutoBuyer is switched on.",
|
||||||
"permissions": ["scripting", "tabs", "storage", "alarms"],
|
"permissions": ["scripting", "tabs", "storage", "alarms"],
|
||||||
"host_permissions": [
|
"host_permissions": [
|
||||||
|
|||||||
+29
-2
@@ -7,10 +7,10 @@
|
|||||||
* signal but have since exited and are now eligible.
|
* signal but have since exited and are now eligible.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { getFirms, isSymbolBanned, getInstruments } from './db';
|
import { getFirms, isSymbolBanned, getInstruments, getBannedSymbols } from './db';
|
||||||
import { getClients } from './clients';
|
import { getClients } from './clients';
|
||||||
import { computeDailyTarget, resolveEffectiveConfig, POINT_VALUES } from './trading-logic';
|
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 { FirmConfig, AccountConfig } from '@/types';
|
||||||
import type { FirmWithAccounts } from './db';
|
import type { FirmWithAccounts } from './db';
|
||||||
|
|
||||||
@@ -33,6 +33,7 @@ function mapFirmConfig(firm: FirmWithAccounts): FirmConfig {
|
|||||||
firm: firm.name,
|
firm: firm.name,
|
||||||
username: firm.username,
|
username: firm.username,
|
||||||
password: firm.password,
|
password: firm.password,
|
||||||
|
bannedSymbols: getBannedSymbols(firm.id),
|
||||||
accounts: firm.accounts.map((a) => ({
|
accounts: firm.accounts.map((a) => ({
|
||||||
prefix: a.prefix,
|
prefix: a.prefix,
|
||||||
profitTarget: a.profit_target,
|
profitTarget: a.profit_target,
|
||||||
@@ -622,6 +623,13 @@ export function startScheduler(action: 'Buy' | 'Sell' | 'Auto', symbol: string,
|
|||||||
state.running = true;
|
state.running = true;
|
||||||
state.stopAfterAll = stopAfterAll;
|
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 () => {
|
const tick = async () => {
|
||||||
if (!state.running) return;
|
if (!state.running) return;
|
||||||
state.lastRun = new Date();
|
state.lastRun = new Date();
|
||||||
@@ -676,9 +684,28 @@ export function stopScheduler() {
|
|||||||
state.intervalId = null;
|
state.intervalId = null;
|
||||||
}
|
}
|
||||||
state.running = false;
|
state.running = false;
|
||||||
|
setSetting('scheduler_running', '0');
|
||||||
console.log('[scheduler] stopped');
|
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() {
|
export function getSchedulerStatus() {
|
||||||
const state = getState();
|
const state = getState();
|
||||||
return {
|
return {
|
||||||
|
|||||||
+32
-7
@@ -21,9 +21,24 @@ export type AutomationStep =
|
|||||||
// Point the firm's tab at a page. Omit `url` to use the firm's own. Skipped
|
// Point the firm's tab at a page. Omit `url` to use the firm's own. Skipped
|
||||||
// when the tab is already there, so it doesn't reload and lose page state.
|
// when the tab is already there, so it doesn't reload and lose page state.
|
||||||
| { action: 'navigate'; url?: string; label?: string }
|
| { action: 'navigate'; url?: string; label?: string }
|
||||||
// Block until `selector` exists, then carry on. Nothing is clicked or typed —
|
// Block until `selector` exists — or, with `absent`, until it is gone from the
|
||||||
// this is a gate, for conditions something outside the run has to satisfy.
|
// DOM. Nothing is clicked or typed; this is a gate, for conditions something
|
||||||
| { action: 'waitFor'; selector: string; index?: number; timeoutSeconds?: number; label?: string }
|
// outside the run has to satisfy. Note `absent` means removed, not merely
|
||||||
|
// hidden: an element still in the DOM with display:none keeps matching, so
|
||||||
|
// for those use a selector that only matches while it is visible.
|
||||||
|
| { action: 'waitFor'; selector: string; index?: number; absent?: boolean; timeoutSeconds?: number; label?: string }
|
||||||
|
// Scroll until the page stops adding elements matching `selector`, for lists
|
||||||
|
// that load progressively. `containerSelector` names the scrolling element
|
||||||
|
// when the automatic guess is wrong — these lists usually scroll inside a div
|
||||||
|
// rather than the window, and scrolling the document does nothing.
|
||||||
|
| {
|
||||||
|
action: 'scrollToLoad';
|
||||||
|
selector: string;
|
||||||
|
containerSelector?: string;
|
||||||
|
maxScrolls?: number;
|
||||||
|
settleMs?: number;
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
// Run `steps` several times over. `times` fixes the count here; `timesFrom`
|
// Run `steps` several times over. `times` fixes the count here; `timesFrom`
|
||||||
// takes it from an input the user fills in on the dashboard. The block is
|
// takes it from an input the user fills in on the dashboard. The block is
|
||||||
// unrolled before the runner ever sees it — see resolveSteps.
|
// unrolled before the runner ever sees it — see resolveSteps.
|
||||||
@@ -127,7 +142,7 @@ export const FIRMS: Firm[] = [
|
|||||||
// every site build, so they are not safe to select on.
|
// every site build, so they are not safe to select on.
|
||||||
|
|
||||||
{ action: 'click', selector: 'a.add_account_btn', label: 'Open Add Account' },
|
{ 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(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.account_types:nth-child(7) span:last-child'},
|
||||||
{ action: 'click', selector: 'div.summary_section div.MuiTextField-root input'},
|
{ action: 'click', selector: 'div.summary_section div.MuiTextField-root input'},
|
||||||
@@ -156,11 +171,13 @@ export const FIRMS: Firm[] = [
|
|||||||
|
|
||||||
{ action: 'repeat', timesFrom: 'count', steps: [
|
{ action: 'repeat', timesFrom: 'count', steps: [
|
||||||
// one purchase — the steps you already have
|
// one purchase — the steps you already have
|
||||||
{ action: 'click', selector: 'button.reset_btn', label: 'Reset Account' },
|
{ action: 'click', selector: '.status-failed button.reset_btn', label: 'Reset Account' },
|
||||||
{ action: 'waitFor', selector: "div.captcha-solver[data-state='ready']", timeoutSeconds: 30, label: 'Wait for the solver' },
|
{ action: 'waitFor', selector: "div.captcha-solver[data-state='ready']", timeoutSeconds: 30, label: 'Wait for the solver' },
|
||||||
{ action: 'click', selector: 'div.captcha-solver[data-state="ready"]'},
|
{ action: 'click', selector: 'div.captcha-solver[data-state="ready"]'},
|
||||||
{ action: 'waitFor', selector: "div.captcha-solver[data-state='solved']", timeoutSeconds: 120, label: 'Wait for the challenge' },
|
{ action: 'waitFor', selector: "div.captcha-solver[data-state='solved']", timeoutSeconds: 120, label: 'Wait for the challenge' },
|
||||||
{ action: 'click', selector: 'button.cancelBtn + button.modalActionBtn' }
|
{ action: 'click', selector: 'button.cancelBtn + button.modalActionBtn' },
|
||||||
|
|
||||||
|
{ action: 'waitFor', selector: '.reset_acc_modal', absent: true, timeoutSeconds: 30, label: 'Wait for the modal to close' },
|
||||||
]},
|
]},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -265,6 +282,10 @@ function resolveOne(firm: Firm, step: AutomationStep): ResolvedStep {
|
|||||||
if (step.action === 'waitFor') {
|
if (step.action === 'waitFor') {
|
||||||
return { ...step, urlPattern: firm.urlPattern, openUrl: firm.url };
|
return { ...step, urlPattern: firm.urlPattern, openUrl: firm.url };
|
||||||
}
|
}
|
||||||
|
if (step.action === 'scrollToLoad') {
|
||||||
|
// Acts on nothing, so no signed-out guard — same treatment as waitFor.
|
||||||
|
return { ...step, urlPattern: firm.urlPattern, openUrl: firm.url };
|
||||||
|
}
|
||||||
if (step.action === 'repeat') {
|
if (step.action === 'repeat') {
|
||||||
// expand() peels these off first; reaching here means a caller bypassed it.
|
// expand() peels these off first; reaching here means a caller bypassed it.
|
||||||
throw new Error('repeat steps must be expanded, not resolved directly');
|
throw new Error('repeat steps must be expanded, not resolved directly');
|
||||||
@@ -341,7 +362,11 @@ export function describeStep(step: AutomationStep): string {
|
|||||||
case 'type': return `type into ${step.selector}`;
|
case 'type': return `type into ${step.selector}`;
|
||||||
case 'wait': return `wait ${step.seconds}s`;
|
case 'wait': return `wait ${step.seconds}s`;
|
||||||
case 'navigate': return `open ${step.url ?? 'the firm page'}`;
|
case 'navigate': return `open ${step.url ?? 'the firm page'}`;
|
||||||
case 'waitFor': return `wait for ${step.selector}`;
|
case 'waitFor':
|
||||||
|
return step.absent
|
||||||
|
? `wait for ${step.selector} to disappear`
|
||||||
|
: `wait for ${step.selector}`;
|
||||||
|
case 'scrollToLoad': return `scroll to load all ${step.selector}`;
|
||||||
case 'repeat': {
|
case 'repeat': {
|
||||||
const inner = step.steps.length;
|
const inner = step.steps.length;
|
||||||
const count = step.timesFrom ? `{${step.timesFrom}}` : `${step.times ?? 1}`;
|
const count = step.timesFrom ? `{${step.timesFrom}}` : `${step.times ?? 1}`;
|
||||||
|
|||||||
+5
-1
@@ -2,6 +2,7 @@ import { TradovateClient } from './tradovate-class';
|
|||||||
import { getFirms, getInstruments } from './db';
|
import { getFirms, getInstruments } from './db';
|
||||||
import { resolveContracts } from './contract-resolver';
|
import { resolveContracts } from './contract-resolver';
|
||||||
import { startReporter } from './reporter';
|
import { startReporter } from './reporter';
|
||||||
|
import { resumeSchedulerIfPersisted } from './auto-trade';
|
||||||
|
|
||||||
// Use global to persist the client pool across HMR reloads in dev mode
|
// Use global to persist the client pool across HMR reloads in dev mode
|
||||||
const g = global as typeof globalThis & {
|
const g = global as typeof globalThis & {
|
||||||
@@ -19,7 +20,7 @@ function ensureMap(): Map<number, TradovateClient> {
|
|||||||
|
|
||||||
export function initClient(id: number, username: string, password: string, firmName: string): TradovateClient {
|
export function initClient(id: number, username: string, password: string, firmName: string): TradovateClient {
|
||||||
const map = ensureMap();
|
const map = ensureMap();
|
||||||
const client = new TradovateClient(username, password, () => {
|
const client = new TradovateClient(username, password, async () => {
|
||||||
console.log(`[${firmName}] sync complete — ${client.accountList.length} account(s)`);
|
console.log(`[${firmName}] sync complete — ${client.accountList.length} account(s)`);
|
||||||
});
|
});
|
||||||
map.set(id, client);
|
map.set(id, client);
|
||||||
@@ -63,6 +64,9 @@ export function getClients(): Map<number, TradovateClient> {
|
|||||||
|
|
||||||
// Start master dashboard reporter
|
// Start master dashboard reporter
|
||||||
startReporter();
|
startReporter();
|
||||||
|
|
||||||
|
// Pick the auto-trade schedule back up if it was running before restart
|
||||||
|
resumeSchedulerIfPersisted();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[clients] Failed to initialize clients', 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 (?, ?)`);
|
const seedSetting = db.prepare(`INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)`);
|
||||||
seedSetting.run('max_concurrent_accounts', '5');
|
seedSetting.run('max_concurrent_accounts', '5');
|
||||||
seedSetting.run('tick_interval_seconds', '60');
|
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('instance_name', '');
|
||||||
seedSetting.run('trading_hours', 'full_cme');
|
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 {
|
export function getSetting(key: string): string | null {
|
||||||
const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key) as { value: string } | undefined;
|
const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key) as { value: string } | undefined;
|
||||||
@@ -199,11 +204,15 @@ export function setSetting(key: string, value: string): void {
|
|||||||
|
|
||||||
const SYMBOLS = ['NQ','MNQ','ES','MES','YM','MYM','RTY','M2K','GC','MGC','SI','CL','MCL','NG','ZB','ZN','ZF','6E','6J','6B'];
|
const SYMBOLS = ['NQ','MNQ','ES','MES','YM','MYM','RTY','M2K','GC','MGC','SI','CL','MCL','NG','ZB','ZN','ZF','6E','6J','6B'];
|
||||||
|
|
||||||
|
// Enabled on a fresh install. The rest still seed, so they can be switched on
|
||||||
|
// from the Instruments page, they just start off.
|
||||||
|
const DEFAULT_ENABLED = new Set(['NQ', 'GC', 'CL']);
|
||||||
|
|
||||||
// Seed instruments table if empty
|
// Seed instruments table if empty
|
||||||
const instrCount = (db.prepare('SELECT COUNT(*) as count FROM instruments').get() as { count: number }).count;
|
const instrCount = (db.prepare('SELECT COUNT(*) as count FROM instruments').get() as { count: number }).count;
|
||||||
if (instrCount === 0) {
|
if (instrCount === 0) {
|
||||||
const ins = db.prepare('INSERT INTO instruments (symbol, enabled) VALUES (?, 1)');
|
const ins = db.prepare('INSERT INTO instruments (symbol, enabled) VALUES (?, ?)');
|
||||||
for (const s of SYMBOLS) ins.run(s);
|
for (const s of SYMBOLS) ins.run(s, DEFAULT_ENABLED.has(s) ? 1 : 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InstrumentRow {
|
export interface InstrumentRow {
|
||||||
@@ -394,6 +403,7 @@ export interface LocateRow {
|
|||||||
url_pattern: string;
|
url_pattern: string;
|
||||||
open_url: string;
|
open_url: string;
|
||||||
navigate_url: string;
|
navigate_url: string;
|
||||||
|
options: string; // JSON, per-request extras (scroll parameters, ...)
|
||||||
status: 'pending' | 'claimed' | 'done' | 'error';
|
status: 'pending' | 'claimed' | 'done' | 'error';
|
||||||
result: string | null;
|
result: string | null;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
@@ -403,10 +413,10 @@ export interface LocateRow {
|
|||||||
|
|
||||||
const LOCATE_HISTORY = 20;
|
const LOCATE_HISTORY = 20;
|
||||||
|
|
||||||
export function createLocateRequest(selector: string, matchIndex: number, urlPattern: string, openUrl = '', navigateUrl = ''): LocateRow {
|
export function createLocateRequest(selector: string, matchIndex: number, urlPattern: string, openUrl = '', navigateUrl = '', options = '{}'): LocateRow {
|
||||||
const res = db.prepare(
|
const res = db.prepare(
|
||||||
'INSERT INTO autobuyer_locate (selector, match_index, url_pattern, open_url, navigate_url, created_at) VALUES (?, ?, ?, ?, ?, ?)'
|
'INSERT INTO autobuyer_locate (selector, match_index, url_pattern, open_url, navigate_url, options, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)'
|
||||||
).run(selector, matchIndex, urlPattern, openUrl, navigateUrl, Date.now());
|
).run(selector, matchIndex, urlPattern, openUrl, navigateUrl, options, Date.now());
|
||||||
|
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
DELETE FROM autobuyer_locate
|
DELETE FROM autobuyer_locate
|
||||||
@@ -459,6 +469,14 @@ db.exec(`
|
|||||||
);
|
);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
// Migration: free-form per-request options, so a new kind of request doesn't
|
||||||
|
// need a new column each time.
|
||||||
|
try {
|
||||||
|
db.exec("ALTER TABLE autobuyer_locate ADD COLUMN options TEXT NOT NULL DEFAULT '{}'");
|
||||||
|
} catch {
|
||||||
|
// Column already exists
|
||||||
|
}
|
||||||
|
|
||||||
// Migration: the page to send the tab to before locating.
|
// Migration: the page to send the tab to before locating.
|
||||||
try {
|
try {
|
||||||
db.exec("ALTER TABLE autobuyer_locate ADD COLUMN navigate_url TEXT NOT NULL DEFAULT ''");
|
db.exec("ALTER TABLE autobuyer_locate ADD COLUMN navigate_url TEXT NOT NULL DEFAULT ''");
|
||||||
@@ -604,7 +622,7 @@ export const RUNNER_TIMEOUT_MS = 7000;
|
|||||||
/** The runner version this server's step vocabulary requires. A running process
|
/** The runner version this server's step vocabulary requires. A running process
|
||||||
* doesn't reload when the source changes, so an older one silently fails on
|
* doesn't reload when the source changes, so an older one silently fails on
|
||||||
* steps it predates — the dashboard warns instead. */
|
* steps it predates — the dashboard warns instead. */
|
||||||
export const RUNNER_EXPECTED_VERSION = '0.12.0';
|
export const RUNNER_EXPECTED_VERSION = '0.16.0';
|
||||||
|
|
||||||
export interface RunnerHeartbeat {
|
export interface RunnerHeartbeat {
|
||||||
at: number;
|
at: number;
|
||||||
|
|||||||
+21
-20
@@ -7,10 +7,27 @@ import { POINT_VALUES } from './trading-logic';
|
|||||||
import { getCachedContract, resolveContracts } from './contract-resolver';
|
import { getCachedContract, resolveContracts } from './contract-resolver';
|
||||||
import { saveDailyPnL, loadDailyPnL, saveAccountMeta, loadAccountMeta, saveFundTransactions, loadFundTransactions } from './db';
|
import { saveDailyPnL, loadDailyPnL, saveAccountMeta, loadAccountMeta, saveFundTransactions, loadFundTransactions } from './db';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entity types Tradovate sends over the user-sync socket. Keep this as the
|
||||||
|
* single source of truth: the inline socket-payload type and the indirect
|
||||||
|
* callback list previously declared it separately and fell out of sync, which
|
||||||
|
* made the 'position' and 'cashBalance' handlers unreachable to the compiler.
|
||||||
|
*/
|
||||||
|
type TradovateEntityType =
|
||||||
|
| 'order'
|
||||||
|
| 'orderVersion'
|
||||||
|
| 'auditUserAction'
|
||||||
|
| 'command'
|
||||||
|
| 'commandReport'
|
||||||
|
| 'fill'
|
||||||
|
| 'executionReport'
|
||||||
|
| 'position'
|
||||||
|
| 'cashBalance';
|
||||||
|
|
||||||
export class TradovateClient {
|
export class TradovateClient {
|
||||||
private name: string;
|
private name: string;
|
||||||
private password: string;
|
private password: string;
|
||||||
private accessInfo: AuthLoginResponse;
|
private accessInfo!: AuthLoginResponse;
|
||||||
private deviceId = randomUUIDV4();
|
private deviceId = randomUUIDV4();
|
||||||
|
|
||||||
public accountList: AccountItem[] = [];
|
public accountList: AccountItem[] = [];
|
||||||
@@ -70,7 +87,7 @@ export class TradovateClient {
|
|||||||
public syncComplete = false;
|
public syncComplete = false;
|
||||||
|
|
||||||
|
|
||||||
private ws: WebSocket;
|
private ws!: WebSocket;
|
||||||
private callbackOnSyncRequest: () => Promise<void>;
|
private callbackOnSyncRequest: () => Promise<void>;
|
||||||
|
|
||||||
/** Incrementing ID for outgoing WebSocket messages — ensures concurrent orders don't clobber each other's callbacks. */
|
/** Incrementing ID for outgoing WebSocket messages — ensures concurrent orders don't clobber each other's callbacks. */
|
||||||
@@ -82,15 +99,7 @@ export class TradovateClient {
|
|||||||
[id: number]: (response: any) => void;
|
[id: number]: (response: any) => void;
|
||||||
} = {};
|
} = {};
|
||||||
private indirectEventCallbacks: {
|
private indirectEventCallbacks: {
|
||||||
entityType:
|
entityType: TradovateEntityType;
|
||||||
| 'order'
|
|
||||||
| 'orderVersion'
|
|
||||||
| 'auditUserAction'
|
|
||||||
| 'command'
|
|
||||||
| 'commandReport'
|
|
||||||
| 'fill'
|
|
||||||
| 'executionReport'
|
|
||||||
| 'cashBalance';
|
|
||||||
eventType: 'Created' | 'Updated';
|
eventType: 'Created' | 'Updated';
|
||||||
// Since the entity is not always the same, we need a validator to check if the response is the one we are looking for
|
// Since the entity is not always the same, we need a validator to check if the response is the one we are looking for
|
||||||
validator: (response: any) => boolean;
|
validator: (response: any) => boolean;
|
||||||
@@ -167,12 +176,7 @@ export class TradovateClient {
|
|||||||
| {
|
| {
|
||||||
e?: string;
|
e?: string;
|
||||||
d?: {
|
d?: {
|
||||||
entityType:
|
entityType: TradovateEntityType;
|
||||||
| 'order'
|
|
||||||
| 'orderVersion'
|
|
||||||
| 'auditUserAction'
|
|
||||||
| 'command'
|
|
||||||
| 'commandReport';
|
|
||||||
eventType: 'Created' | 'Updated';
|
eventType: 'Created' | 'Updated';
|
||||||
entity: any;
|
entity: any;
|
||||||
};
|
};
|
||||||
@@ -279,9 +283,6 @@ export class TradovateClient {
|
|||||||
const isSentinel = limit >= 999999999;
|
const isSentinel = limit >= 999999999;
|
||||||
const floor = (!isSentinel && limit > 0 && drawdown > 0) ? limit - drawdown : 0;
|
const floor = (!isSentinel && limit > 0 && drawdown > 0) ? limit - drawdown : 0;
|
||||||
this.autoLiqThresholds[accountId] = floor;
|
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) => {
|
this.accountList = ((response.accounts ?? []) as AccountItem[]).map((account) => {
|
||||||
|
|||||||
Generated
+2
-62
@@ -11,7 +11,6 @@
|
|||||||
"axios": "^1.13.6",
|
"axios": "^1.13.6",
|
||||||
"better-sqlite3": "^12.6.2",
|
"better-sqlite3": "^12.6.2",
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
"playwright": "^1.58.2",
|
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3",
|
||||||
"recharts": "^3.8.0"
|
"recharts": "^3.8.0"
|
||||||
@@ -72,7 +71,6 @@
|
|||||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.29.0",
|
"@babel/code-frame": "^7.29.0",
|
||||||
"@babel/generator": "^7.29.0",
|
"@babel/generator": "^7.29.0",
|
||||||
@@ -1688,7 +1686,6 @@
|
|||||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
}
|
}
|
||||||
@@ -1754,7 +1751,6 @@
|
|||||||
"integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==",
|
"integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.56.1",
|
"@typescript-eslint/scope-manager": "8.56.1",
|
||||||
"@typescript-eslint/types": "8.56.1",
|
"@typescript-eslint/types": "8.56.1",
|
||||||
@@ -2280,7 +2276,6 @@
|
|||||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -2695,7 +2690,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.9.0",
|
"baseline-browser-mapping": "^2.9.0",
|
||||||
"caniuse-lite": "^1.0.30001759",
|
"caniuse-lite": "^1.0.30001759",
|
||||||
@@ -3486,7 +3480,6 @@
|
|||||||
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
|
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.8.0",
|
"@eslint-community/eslint-utils": "^4.8.0",
|
||||||
"@eslint-community/regexpp": "^4.12.1",
|
"@eslint-community/regexpp": "^4.12.1",
|
||||||
@@ -3672,7 +3665,6 @@
|
|||||||
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@rtsao/scc": "^1.1.0",
|
"@rtsao/scc": "^1.1.0",
|
||||||
"array-includes": "^3.1.9",
|
"array-includes": "^3.1.9",
|
||||||
@@ -4116,20 +4108,6 @@
|
|||||||
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
|
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/function-bind": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||||
@@ -5976,36 +5954,6 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"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": {
|
"node_modules/possible-typed-array-names": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
|
||||||
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
|
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
@@ -6180,7 +6127,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
|
||||||
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
|
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"scheduler": "^0.27.0"
|
"scheduler": "^0.27.0"
|
||||||
},
|
},
|
||||||
@@ -6192,15 +6138,13 @@
|
|||||||
"version": "16.13.1",
|
"version": "16.13.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/react-redux": {
|
"node_modules/react-redux": {
|
||||||
"version": "9.2.0",
|
"version": "9.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||||
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/use-sync-external-store": "^0.0.6",
|
"@types/use-sync-external-store": "^0.0.6",
|
||||||
"use-sync-external-store": "^1.4.0"
|
"use-sync-external-store": "^1.4.0"
|
||||||
@@ -6267,8 +6211,7 @@
|
|||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/redux-thunk": {
|
"node_modules/redux-thunk": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
@@ -7067,7 +7010,6 @@
|
|||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -7242,7 +7184,6 @@
|
|||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@@ -7561,7 +7502,6 @@
|
|||||||
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,6 @@
|
|||||||
"axios": "^1.13.6",
|
"axios": "^1.13.6",
|
||||||
"better-sqlite3": "^12.6.2",
|
"better-sqlite3": "^12.6.2",
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
"playwright": "^1.58.2",
|
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3",
|
||||||
"recharts": "^3.8.0"
|
"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,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
|
||||||
@@ -0,0 +1,378 @@
|
|||||||
|
@echo off
|
||||||
|
setlocal EnableExtensions
|
||||||
|
title AutoFirmer - Windows setup
|
||||||
|
|
||||||
|
REM ============================================================================
|
||||||
|
REM AutoFirmer instance setup for Windows.
|
||||||
|
REM
|
||||||
|
REM Standalone: drop this file anywhere and run it. It clones the repo into a
|
||||||
|
REM subfolder next to itself, installs, builds, and points the instance at the
|
||||||
|
REM master dashboard. Safe to re-run - it pulls and rebuilds instead of cloning.
|
||||||
|
REM ============================================================================
|
||||||
|
|
||||||
|
set "REPO_URL=https://git.juicerroom.com/senofy/autofirmer-expanded.git"
|
||||||
|
set "DEFAULT_MASTER=https://master.juicerroom.com"
|
||||||
|
set "TARGET=%~dp0autofirmer"
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo ============================================
|
||||||
|
echo AutoFirmer - Windows setup
|
||||||
|
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 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 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%
|
||||||
|
|
||||||
|
REM better-sqlite3 ships prebuilt binaries only for released Node ABIs. On a
|
||||||
|
REM newer major it falls back to node-gyp, which needs Visual Studio Build
|
||||||
|
REM Tools - a long, confusing failure if it is not installed.
|
||||||
|
if %NODEMAJOR% GEQ 23 goto :node_warn
|
||||||
|
goto :node_ok
|
||||||
|
|
||||||
|
:node_warn
|
||||||
|
echo.
|
||||||
|
echo [!] Node %NODEMAJOR% is newer than better-sqlite3's prebuilt binaries.
|
||||||
|
echo Install may try to compile from source and fail without Visual
|
||||||
|
echo Studio Build Tools. Node 22 LTS is the safe choice here.
|
||||||
|
echo.
|
||||||
|
set "GOON="
|
||||||
|
set /p "GOON= Continue anyway? [y/N] "
|
||||||
|
if /i not "%GOON%"=="y" goto :fail
|
||||||
|
:node_ok
|
||||||
|
|
||||||
|
REM ------------------------------------------------------------------- python
|
||||||
|
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.
|
||||||
|
set "MASTER_URL="
|
||||||
|
set /p "MASTER_URL= Master dashboard URL [%DEFAULT_MASTER%]: "
|
||||||
|
if "%MASTER_URL%"=="" set "MASTER_URL=%DEFAULT_MASTER%"
|
||||||
|
|
||||||
|
set "INSTANCE="
|
||||||
|
set /p "INSTANCE= Instance name [%COMPUTERNAME%]: "
|
||||||
|
if "%INSTANCE%"=="" set "INSTANCE=%COMPUTERNAME%"
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo Installing to: %TARGET%
|
||||||
|
echo.
|
||||||
|
|
||||||
|
REM -------------------------------------------------------------- clone / pull
|
||||||
|
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/6] Installing npm dependencies ^(this takes a few minutes^)...
|
||||||
|
call npm install
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo.
|
||||||
|
echo [X] npm install failed.
|
||||||
|
echo If the error mentions node-gyp, MSBuild, or better_sqlite3.cpp,
|
||||||
|
echo the native module could not find a prebuilt binary. Either switch
|
||||||
|
echo to Node 22 LTS, or install "Desktop development with C++" from the
|
||||||
|
echo Visual Studio Build Tools installer.
|
||||||
|
popd
|
||||||
|
goto :fail
|
||||||
|
)
|
||||||
|
|
||||||
|
REM --------------------------------------------------------------------- build
|
||||||
|
echo.
|
||||||
|
echo [3/6] Building...
|
||||||
|
call npm run build
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo [X] Build failed.
|
||||||
|
popd
|
||||||
|
goto :fail
|
||||||
|
)
|
||||||
|
|
||||||
|
REM ------------------------------------------------------------------ settings
|
||||||
|
echo.
|
||||||
|
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/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 (
|
||||||
|
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
|
||||||
|
echo title AutoFirmer - %INSTANCE%
|
||||||
|
echo cd /d "%TARGET%"
|
||||||
|
echo echo Dashboard starting on http://localhost:3000
|
||||||
|
echo call npm run start
|
||||||
|
echo pause
|
||||||
|
)
|
||||||
|
|
||||||
|
popd
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo ============================================
|
||||||
|
echo Done.
|
||||||
|
echo ============================================
|
||||||
|
echo.
|
||||||
|
echo Instance name : %INSTANCE%
|
||||||
|
echo Reporting to : %MASTER_URL%
|
||||||
|
echo Installed in : %TARGET%
|
||||||
|
echo.
|
||||||
|
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.
|
||||||
|
pause
|
||||||
|
exit /b 0
|
||||||
|
|
||||||
|
:fail
|
||||||
|
echo.
|
||||||
|
echo Setup did not complete.
|
||||||
|
echo.
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
Reference in New Issue
Block a user