Compare commits
23
Commits
37adbf67fc
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c5c946d3cc | ||
|
|
3cc632ac40 | ||
|
|
9d777cb2c6 | ||
|
|
be8c628778 | ||
|
|
fbe2c47ed4 | ||
|
|
724ba5581c | ||
|
|
7e4714c33d | ||
|
|
97ee03d13b | ||
|
|
a0ef06d933 | ||
|
|
4288d8c298 | ||
|
|
7e7bd985c2 | ||
|
|
a718caeb08 | ||
|
|
8e64d8a34f | ||
|
|
d378712e79 | ||
|
|
9bdd20f83a | ||
|
|
3bef7dea9e | ||
|
|
fc08a41c4b | ||
|
|
3cc7ddcc5c | ||
|
|
3ac9fe060f | ||
|
|
731fafda0e | ||
|
|
3ff5728af9 | ||
|
|
a8853c56d1 | ||
|
|
7686301a70 |
@@ -53,3 +53,12 @@ autotrader.sqlite
|
||||
# python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# generated by the Windows setup / autostart scripts
|
||||
scripts/.deps-hash
|
||||
scripts/.python-cmd
|
||||
scripts/.update.lock
|
||||
scripts/update.log
|
||||
|
||||
# clicker virtualenv (Linux/Pi)
|
||||
clicker/.venv/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Claude Instructions
|
||||
|
||||
## 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`.
|
||||
|
||||
@@ -4,77 +4,65 @@ Automated futures trading dashboard for prop firm accounts via Tradovate.
|
||||
|
||||
---
|
||||
|
||||
## VPS Setup (Windows Server 2019 / 2022)
|
||||
## Setup
|
||||
|
||||
All commands are run in **PowerShell** (run as Administrator).
|
||||
### Windows
|
||||
|
||||
### 1. Install Git
|
||||
|
||||
Download and install from https://git-scm.com/download/win, or via winget:
|
||||
In **PowerShell**, from wherever you want the instance to live:
|
||||
|
||||
```powershell
|
||||
winget install --id Git.Git -e --source winget
|
||||
irm https://git.juicerroom.com/senofy/autofirmer-expanded/raw/branch/master/setup-windows.bat -OutFile setup-windows.bat
|
||||
```
|
||||
|
||||
Restart PowerShell after installing so `git` is on the PATH.
|
||||
|
||||
### 2. Install Node.js 22+
|
||||
|
||||
```powershell
|
||||
winget install --id OpenJS.NodeJS.LTS -e --source winget
|
||||
.\setup-windows.bat
|
||||
```
|
||||
|
||||
Restart PowerShell, then verify:
|
||||
That is the whole install. It clones into an `autofirmer` folder beside itself,
|
||||
installs anything missing (Git, Node, Python) via winget, installs dependencies,
|
||||
builds, asks for the master dashboard URL and an instance name, and offers to
|
||||
set up auto-start and auto-update.
|
||||
|
||||
```powershell
|
||||
node --version # should be v22.x or higher
|
||||
npm --version
|
||||
Re-running it updates an existing checkout rather than cloning again, so it
|
||||
doubles as a repair tool.
|
||||
|
||||
Two things it deliberately does, worth knowing:
|
||||
|
||||
- **Node LTS, not latest.** `better-sqlite3` ships prebuilt binaries for LTS
|
||||
only. On a newer major it falls back to compiling with node-gyp and fails
|
||||
without Visual Studio Build Tools, so the script warns before that happens.
|
||||
- **It looks for tools off `PATH`.** A tool installed in the same session it is
|
||||
needed — or installed without its "add to PATH" option — is invisible to a
|
||||
running shell, so the script checks the standard install directories too.
|
||||
|
||||
### Raspberry Pi 5 / Debian / Ubuntu
|
||||
|
||||
```bash
|
||||
curl -fsSLO https://git.juicerroom.com/senofy/autofirmer-expanded/raw/branch/master/setup-linux.sh
|
||||
```
|
||||
|
||||
### 3. Install Windows Build Tools (required for better-sqlite3)
|
||||
|
||||
`better-sqlite3` compiles a native C++ module and needs the Visual Studio build tools:
|
||||
|
||||
```powershell
|
||||
npm install -g windows-build-tools
|
||||
```bash
|
||||
bash setup-linux.sh
|
||||
```
|
||||
|
||||
If that fails on newer Node, install manually:
|
||||
- Download **Build Tools for Visual Studio** from https://visualstudio.microsoft.com/downloads/#build-tools-for-visual-studio-2022
|
||||
- During install, select **"Desktop development with C++"**
|
||||
Same idea, apt instead of winget. It installs Node 22 from NodeSource (Pi OS
|
||||
ships one too old for Next 16), `xdotool` and `scrot` for the clicker, and puts
|
||||
the Python dependencies in a virtualenv — Pi OS Bookworm enforces PEP 668, so a
|
||||
system-wide `pip install` fails outright.
|
||||
|
||||
### 4. Clone the repo
|
||||
**The clicker needs X11.** Pi OS on a Pi 5 defaults to Wayland, which does not
|
||||
let one client synthesise input into another or read the focused window, so
|
||||
neither `xdotool` nor pyautogui can work there. The dashboard is unaffected, but
|
||||
for the AutoBuyer:
|
||||
|
||||
```powershell
|
||||
git clone https://github.com/Senofy/autofirmer.git
|
||||
cd autofirmer
|
||||
```bash
|
||||
sudo raspi-config # Advanced Options -> Wayland -> X11, then reboot
|
||||
```
|
||||
|
||||
### 5. Install dependencies
|
||||
`python clicker/diagnose.py` reports the session type, `DISPLAY` and whether
|
||||
`xdotool` is present — run it before trusting a new machine.
|
||||
|
||||
```powershell
|
||||
npm install
|
||||
```
|
||||
|
||||
### 6. Build
|
||||
|
||||
```powershell
|
||||
npm run build
|
||||
```
|
||||
|
||||
### 7. Run
|
||||
|
||||
**Development (with hot reload):**
|
||||
```powershell
|
||||
npm run dev
|
||||
```
|
||||
|
||||
**Production:**
|
||||
```powershell
|
||||
npm start
|
||||
```
|
||||
|
||||
The app runs on **port 3000**. Access it at `http://<your-vps-ip>:3000`.
|
||||
The dashboard runs on port **3000**: `http://localhost:3000`.
|
||||
|
||||
---
|
||||
|
||||
@@ -119,11 +107,13 @@ Adding a new firm also means adding its host to `host_permissions` in
|
||||
`extension/manifest.json` and reloading. Without it the extension cannot read
|
||||
that site, and every step fails to locate.
|
||||
|
||||
### 2. Install the clicker
|
||||
### 2. The clicker
|
||||
|
||||
`setup-windows.bat` installs its Python dependencies, and re-applies them
|
||||
whenever `clicker/requirements.txt` changes. To do it by hand:
|
||||
|
||||
```powershell
|
||||
cd clicker
|
||||
pip install -r requirements.txt
|
||||
python -m pip install -r clicker\requirements.txt
|
||||
```
|
||||
|
||||
See `clicker/README.md` for the per-platform notes — display scaling and
|
||||
@@ -132,11 +122,14 @@ run before letting it click anything on a new machine.
|
||||
|
||||
### 3. Start the runner
|
||||
|
||||
Auto-start runs it under PM2, so normally there is nothing to do. To run it by
|
||||
hand instead:
|
||||
|
||||
```powershell
|
||||
python clicker\runner.py
|
||||
```
|
||||
|
||||
Leave it running. It reports in every two seconds, and the dashboard greys out
|
||||
It reports in every two seconds, and the dashboard greys out
|
||||
the automation buttons when it is not there. A running Python process does not
|
||||
reload when the source changes, so restart it after pulling updates; the
|
||||
dashboard warns when its version is behind.
|
||||
@@ -152,44 +145,44 @@ dialog stealing focus fails the step. That makes an RDP session a poor host:
|
||||
disconnecting can suspend the desktop and break clicks in ways that are hard to
|
||||
diagnose.
|
||||
|
||||
## Keeping it running (PM2)
|
||||
## Keeping it running
|
||||
|
||||
Install PM2 globally:
|
||||
`setup-windows.bat` offers to set this up for you at step 6. To enable it later,
|
||||
or after declining:
|
||||
|
||||
```powershell
|
||||
npm install -g pm2
|
||||
npm install -g pm2-windows-startup
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\install-autostart.ps1
|
||||
```
|
||||
|
||||
Start the app and save the process list:
|
||||
That puts the dashboard, the clicker and an update checker under PM2, adds a
|
||||
Startup-folder entry so PM2 comes back at logon, and lets PM2's cron restart run
|
||||
the update check every 5 minutes. It needs no administrator rights, changes
|
||||
nothing machine-wide, and is safe to re-run.
|
||||
|
||||
```powershell
|
||||
cd C:\path\to\autofirmer
|
||||
pm2 start "npm start" --name autofirmer
|
||||
pm2 save
|
||||
pm2-startup install
|
||||
pm2 list # what is running
|
||||
pm2 logs autofirmer # dashboard output
|
||||
pm2 logs clicker # runner output
|
||||
```
|
||||
|
||||
To restart after pulling updates:
|
||||
**Why not a Windows service.** The clicker sends real mouse and keyboard input
|
||||
and has to own a desktop. A service runs in session 0, which has none, so the
|
||||
clicks would go nowhere. Everything therefore runs in your logged-in session —
|
||||
which also means an unattended reboot leaves the instance down until somebody
|
||||
logs in.
|
||||
|
||||
To stop it starting at logon, delete the `AutoFirmer.cmd` shortcut from your
|
||||
Startup folder (`shell:startup` in the Run dialog).
|
||||
|
||||
To start everything by hand without waiting for a logon:
|
||||
|
||||
```powershell
|
||||
cd C:\path\to\autofirmer
|
||||
git pull
|
||||
npm install
|
||||
npm run build
|
||||
pm2 restart autofirmer
|
||||
node scripts\start-all.mjs
|
||||
```
|
||||
|
||||
If the update touched the AutoBuyer, two things do **not** reload themselves:
|
||||
`start-autofirmer.bat` still runs the dashboard in a visible window without PM2,
|
||||
which is the easier thing to watch when a build is misbehaving.
|
||||
|
||||
- **The extension** — click reload on its card in `chrome://extensions`.
|
||||
- **The runner** — stop it with Ctrl-C and start it again.
|
||||
|
||||
The dashboard reports the version it sees from each, and warns when either is
|
||||
behind. Most AutoBuyer bugs that look mysterious are one of these two still
|
||||
running the previous code.
|
||||
|
||||
---
|
||||
|
||||
## Firewall
|
||||
|
||||
@@ -209,19 +202,33 @@ New-NetFirewallRule -DisplayName "AutoFirmer" -Direction Inbound -Protocol TCP -
|
||||
|
||||
## Updating
|
||||
|
||||
Once auto-start is installed, nothing here is manual. Every 5 minutes the update
|
||||
task fetches `master`, and when it has moved it pulls, reinstalls dependencies if
|
||||
`package-lock.json` or `clicker/requirements.txt` changed, rebuilds, restarts
|
||||
AutoFirmer, and restarts the clicker if anything under `clicker/` changed.
|
||||
|
||||
**A failed build is never deployed.** The build runs before anything restarts, so
|
||||
a broken push leaves the previous build serving and logs the failure instead.
|
||||
|
||||
Everything it does is appended to `scripts/update.log`. To see what it would do
|
||||
without touching anything:
|
||||
|
||||
```powershell
|
||||
cd C:\path\to\autofirmer
|
||||
git pull
|
||||
npm install # only needed if dependencies changed
|
||||
npm run build
|
||||
pm2 restart autofirmer
|
||||
node scripts\update-check.mjs --dry-run
|
||||
```
|
||||
|
||||
If the update touched the AutoBuyer, two things do **not** reload themselves:
|
||||
To apply an update immediately rather than waiting for the next check:
|
||||
|
||||
```powershell
|
||||
node scripts\update-check.mjs
|
||||
```
|
||||
|
||||
### The two things that still do not reload themselves
|
||||
|
||||
- **The extension** — click reload on its card in `chrome://extensions`.
|
||||
- **The runner** — stop it with Ctrl-C and start it again.
|
||||
- **The scheduler is fine now.** It persists to the settings table and resumes
|
||||
after a restart, so an update no longer silently stops automated trading.
|
||||
|
||||
The dashboard reports the version it sees from each, and warns when either is
|
||||
behind. Most AutoBuyer bugs that look mysterious are one of these two still
|
||||
running the previous code.
|
||||
The dashboard reports the version it sees from the extension and the runner, and
|
||||
warns when either is behind. Most AutoBuyer bugs that look mysterious are the
|
||||
extension still running the previous code.
|
||||
|
||||
@@ -14,6 +14,7 @@ export async function POST() {
|
||||
urlPattern: row.url_pattern,
|
||||
openUrl: row.open_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". */
|
||||
export async function POST(req: NextRequest) {
|
||||
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()) {
|
||||
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 openUrl = typeof body.openUrl === 'string' ? body.openUrl : '';
|
||||
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 });
|
||||
} catch (err: any) {
|
||||
return corsJson({ error: err?.message ?? 'Failed to queue request' }, { status: 500 });
|
||||
|
||||
@@ -98,7 +98,7 @@ export async function GET() {
|
||||
fundTransactions: displayFundTxns,
|
||||
};
|
||||
});
|
||||
return { firm: f.name, connected: true, accounts, perContractFees: client.perContractFees };
|
||||
return { firm: f.name, connected: true, accounts };
|
||||
});
|
||||
|
||||
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
|
||||
selector.
|
||||
|
||||
### Linux (including Raspberry Pi)
|
||||
|
||||
`setup-linux.sh` installs `xdotool` and `scrot` and builds a virtualenv for the
|
||||
Python side.
|
||||
|
||||
**X11 only.** pygetwindow has no X11 backend, so raising the browser goes
|
||||
through `xdotool` instead. Neither that nor pyautogui works under Wayland — it
|
||||
refuses by design to let one client drive another — and Raspberry Pi OS on a Pi
|
||||
5 defaults to Wayland. Switch with `sudo raspi-config` -> Advanced Options ->
|
||||
Wayland -> X11 and reboot. `diagnose.py` prints which session you are in.
|
||||
|
||||
### Windows
|
||||
|
||||
No extra permissions, but two things differ.
|
||||
|
||||
+3
-1
@@ -162,7 +162,9 @@ def perform(
|
||||
# A click on a background window is consumed activating it and never reaches
|
||||
# the page, so raise the browser immediately before pressing.
|
||||
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)
|
||||
if not focused.ok:
|
||||
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 json
|
||||
import re
|
||||
import random
|
||||
import sys
|
||||
import time
|
||||
@@ -30,6 +31,22 @@ import urllib.request
|
||||
import actions
|
||||
import focus
|
||||
|
||||
|
||||
def force_utf8_output() -> None:
|
||||
"""Make stdout/stderr accept the status glyphs this tool prints.
|
||||
|
||||
Windows picks the console code page for stdout, and under PM2 (where stdout
|
||||
is a pipe, not a console) that is cp1252. It can encode em dashes but not
|
||||
the run markers, so the first one raised UnicodeEncodeError mid-run and the
|
||||
runner reported it as a step failure. errors="replace" is a backstop for any
|
||||
stream that cannot be reconfigured at all.
|
||||
"""
|
||||
for stream in (sys.stdout, sys.stderr):
|
||||
try:
|
||||
stream.reconfigure(encoding="utf-8", errors="replace")
|
||||
except (AttributeError, ValueError):
|
||||
pass
|
||||
|
||||
DEFAULT_API = "http://localhost:3000"
|
||||
POLL_INTERVAL = 0.25
|
||||
|
||||
@@ -38,6 +55,16 @@ class DashboardError(RuntimeError):
|
||||
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):
|
||||
"""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")
|
||||
|
||||
|
||||
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:
|
||||
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:
|
||||
return json.loads(resp.read().decode())
|
||||
except urllib.error.HTTPError as exc:
|
||||
body = exc.read().decode(errors="replace")
|
||||
try:
|
||||
message = json.loads(body).get("error", body)
|
||||
except json.JSONDecodeError:
|
||||
message = body
|
||||
raise DashboardError(f"{method} {path} -> HTTP {exc.code}: {message}") from None
|
||||
message = _summarise_error_body(exc.read().decode(errors="replace"))
|
||||
cls = TransientError if exc.code >= 500 else DashboardError
|
||||
raise cls(f"{method} {path} -> HTTP {exc.code}: {message}") from None
|
||||
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:
|
||||
return self._request("/api/autobuyer/status")
|
||||
|
||||
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.
|
||||
|
||||
`open_url` is the page the extension should open if no tab matches
|
||||
@@ -95,13 +140,23 @@ class Dashboard:
|
||||
"/api/autobuyer/locate",
|
||||
"POST",
|
||||
{"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"]
|
||||
|
||||
deadline = time.monotonic() + timeout
|
||||
last_transient = None
|
||||
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":
|
||||
return row["result"]
|
||||
if row["status"] == "error":
|
||||
@@ -110,6 +165,11 @@ class Dashboard:
|
||||
raise cls(f"Extension could not locate it: {detail}")
|
||||
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(
|
||||
f"No answer within {timeout:g}s. Is the extension installed, is Chrome "
|
||||
f"running, and is the AutoBuyer switch ON?"
|
||||
@@ -132,6 +192,7 @@ def describe(found: dict, factor: float) -> str:
|
||||
|
||||
|
||||
def main() -> int:
|
||||
force_utf8_output()
|
||||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument("action", choices=["locate", "click", "type"],
|
||||
help="locate = measure only; click = measure then click; "
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
# Chrome ships under several bundle ids; accept whichever is installed.
|
||||
MAC_BUNDLES = (
|
||||
"com.google.Chrome",
|
||||
"com.google.Chrome.beta",
|
||||
"com.google.Chrome.dev",
|
||||
"com.google.Chrome.canary",
|
||||
"com.brave.Browser",
|
||||
"com.microsoft.edgemac",
|
||||
)
|
||||
# Per browser, how to recognise it on each platform. The extension reports which
|
||||
# one is hosting it, because picking by list order raises the wrong browser as
|
||||
# soon as two are installed — and then clicks land in a window the coordinates
|
||||
# were never measured from.
|
||||
BROWSERS = {
|
||||
"chrome": {
|
||||
"darwin": ("com.google.Chrome", "com.google.Chrome.beta",
|
||||
"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
|
||||
# collide that way.
|
||||
WINDOWS_PROCESSES = ("chrome.exe", "msedge.exe", "brave.exe")
|
||||
|
||||
def _platform_key() -> str:
|
||||
if sys.platform == "darwin":
|
||||
return "darwin"
|
||||
if sys.platform.startswith("linux"):
|
||||
return "linux"
|
||||
return "win32"
|
||||
|
||||
|
||||
def _ids_for(browser: str | None) -> tuple[str, ...]:
|
||||
"""Identifiers to accept on this platform. Without a named browser, every
|
||||
known one — the old behaviour, and still right on a single-browser box."""
|
||||
key = _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
|
||||
|
||||
@@ -103,12 +157,68 @@ def _mac_workspace():
|
||||
return NSWorkspace.sharedWorkspace()
|
||||
|
||||
|
||||
def browser_ids() -> tuple[str, ...]:
|
||||
"""What counts as "the browser" on this platform."""
|
||||
if sys.platform == "darwin":
|
||||
return MAC_BUNDLES
|
||||
if sys.platform == "win32":
|
||||
return WINDOWS_PROCESSES
|
||||
def _linux_session_problem() -> str | None:
|
||||
"""Why the clicker cannot drive this desktop, or None if it can.
|
||||
|
||||
Raspberry Pi OS on a Pi 5 defaults to Wayland (labwc). Neither xdotool nor
|
||||
pyautogui works there: both speak X11 protocol, and Wayland deliberately
|
||||
refuses to let one client synthesise input into another or read the focused
|
||||
window. There is no workaround short of switching the session to X11, so say
|
||||
so plainly rather than failing every step with something cryptic.
|
||||
"""
|
||||
if not os.environ.get("DISPLAY"):
|
||||
if os.environ.get("WAYLAND_DISPLAY"):
|
||||
return ("this is a Wayland session and the clicker needs X11 — on Raspberry Pi OS: "
|
||||
"sudo raspi-config -> Advanced Options -> Wayland -> X11, then reboot")
|
||||
return "no DISPLAY is set — the clicker needs a graphical session"
|
||||
if shutil.which("xdotool") is None:
|
||||
return "xdotool is not installed — run: sudo apt install -y xdotool"
|
||||
return None
|
||||
|
||||
|
||||
def _xdotool(*args: str) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(["xdotool", *args], capture_output=True, text=True, timeout=5)
|
||||
|
||||
|
||||
def _linux_frontmost() -> str | None:
|
||||
"""WM_CLASS of the focused window, lowercased to match the ids above."""
|
||||
try:
|
||||
result = _xdotool("getactivewindow", "getwindowclassname")
|
||||
except Exception:
|
||||
return None
|
||||
name = (result.stdout or "").strip().lower()
|
||||
return name or None
|
||||
|
||||
|
||||
def _linux_activate(browser: str | None = None) -> FocusResult:
|
||||
problem = _linux_session_problem()
|
||||
if problem:
|
||||
return FocusResult(False, problem)
|
||||
|
||||
for cls in _ids_for(browser):
|
||||
try:
|
||||
found = _xdotool("search", "--onlyvisible", "--class", cls)
|
||||
except Exception as exc:
|
||||
return FocusResult(False, f"xdotool failed ({exc})")
|
||||
|
||||
ids = (found.stdout or "").split()
|
||||
if not ids:
|
||||
continue
|
||||
|
||||
# Last match is the most recently mapped window — the one a person would
|
||||
# mean by "the browser" when several are open.
|
||||
activated = _xdotool("windowactivate", "--sync", ids[-1])
|
||||
if activated.returncode == 0:
|
||||
return FocusResult(True, f"activated {cls} (window {ids[-1]})")
|
||||
return FocusResult(False, f"could not activate {cls}: {(activated.stderr or '').strip()}")
|
||||
|
||||
return FocusResult(False, f"no {browser or 'browser'} window found")
|
||||
|
||||
|
||||
def browser_ids(browser: str | None = None) -> tuple[str, ...]:
|
||||
"""What counts as "the browser" on this platform, optionally narrowed to one."""
|
||||
if sys.platform in ("darwin", "win32") or sys.platform.startswith("linux"):
|
||||
return _ids_for(browser)
|
||||
return ()
|
||||
|
||||
|
||||
@@ -135,10 +245,13 @@ def frontmost() -> str | None:
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
if sys.platform.startswith("linux"):
|
||||
return _linux_frontmost()
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _mac_activate() -> FocusResult:
|
||||
def _mac_activate(browser: str | None = None) -> FocusResult:
|
||||
ws = _mac_workspace()
|
||||
if ws is None:
|
||||
# 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})")
|
||||
|
||||
running = {a.bundleIdentifier(): a for a in ws.runningApplications()}
|
||||
for bundle in MAC_BUNDLES:
|
||||
for bundle in _ids_for(browser):
|
||||
app = running.get(bundle)
|
||||
if app is None:
|
||||
continue
|
||||
@@ -162,10 +275,11 @@ def _mac_activate() -> FocusResult:
|
||||
app.activateWithOptions_(1 << 1)
|
||||
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.
|
||||
|
||||
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:
|
||||
name = _win_process_name(win._hWnd)
|
||||
if name:
|
||||
return name in WINDOWS_PROCESSES
|
||||
return name in _ids_for(browser)
|
||||
except Exception:
|
||||
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)."""
|
||||
try:
|
||||
import pygetwindow
|
||||
@@ -189,14 +303,15 @@ def _other_activate() -> FocusResult:
|
||||
return FocusResult(False, "pygetwindow unavailable — cannot raise the browser")
|
||||
|
||||
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:
|
||||
# pygetwindow has no X11 backend; say so rather than looking like no
|
||||
# browser is open.
|
||||
return FocusResult(False, f"window management is unsupported on {sys.platform}")
|
||||
|
||||
if not wins:
|
||||
return FocusResult(False, "no browser window found")
|
||||
return FocusResult(False, f"no {browser or 'browser'} window found")
|
||||
|
||||
try:
|
||||
win = wins[0]
|
||||
@@ -211,25 +326,35 @@ def _other_activate() -> FocusResult:
|
||||
return FocusResult(False, f"could not activate window ({exc})")
|
||||
|
||||
|
||||
def activate_browser() -> FocusResult:
|
||||
"""Raise the browser application above everything else."""
|
||||
result = _mac_activate() if sys.platform == "darwin" else _other_activate()
|
||||
def activate_browser(browser: str | None = None) -> FocusResult:
|
||||
"""Raise the browser application above everything else.
|
||||
|
||||
`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:
|
||||
time.sleep(SETTLE)
|
||||
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.
|
||||
|
||||
Returning ok=False does not mean the click will fail — only that we could not
|
||||
verify. The caller decides whether to proceed.
|
||||
"""
|
||||
result = activate_browser()
|
||||
result = activate_browser(browser)
|
||||
if not result.ok:
|
||||
return result
|
||||
|
||||
ids = browser_ids()
|
||||
ids = browser_ids(browser)
|
||||
if not ids:
|
||||
return FocusResult(True, result.detail + " (unverified)")
|
||||
|
||||
|
||||
@@ -4,3 +4,7 @@ pyautogui>=0.9.54
|
||||
pyobjc-core>=10.0; sys_platform == "darwin"
|
||||
pyobjc-framework-Quartz>=10.0; sys_platform == "darwin"
|
||||
pyobjc-framework-Cocoa>=10.0; sys_platform == "darwin"
|
||||
# Linux: pyautogui drives X11 through Xlib. Raising the browser window is done
|
||||
# with xdotool, which is an apt package rather than a wheel - setup-linux.sh
|
||||
# installs it.
|
||||
python-xlib>=0.33; sys_platform == "linux"
|
||||
|
||||
+49
-10
@@ -24,7 +24,8 @@ import threading
|
||||
import time
|
||||
|
||||
import actions
|
||||
from clicker import Dashboard, DashboardError, NotFoundError
|
||||
import focus
|
||||
from clicker import Dashboard, DashboardError, NotFoundError, force_utf8_output
|
||||
|
||||
POLL_SECONDS = 1.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
|
||||
# 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.
|
||||
VERSION = "0.12.0"
|
||||
VERSION = "0.16.0"
|
||||
|
||||
# Shared with the heartbeat thread: whether a run is currently executing.
|
||||
_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)}")
|
||||
|
||||
elif step["action"] == "waitFor":
|
||||
# A gate, not an action: block until the element exists. Nothing is
|
||||
# clicked or typed. Whatever has to make it appear — a person solving a
|
||||
# challenge, a slow server, a background job — happens outside this run.
|
||||
# A gate, not an action: block until the element exists — or, with
|
||||
# `absent`, until it is gone. Nothing is clicked or typed. Whatever has to
|
||||
# change the page — a person solving a challenge, a modal closing itself,
|
||||
# a slow server — happens outside this run.
|
||||
selector = step["selector"]
|
||||
absent = bool(step.get("absent"))
|
||||
goal = "disappear" if absent else "appear"
|
||||
timeout_s = float(step.get("timeoutSeconds", 120))
|
||||
deadline = time.time() + timeout_s
|
||||
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)),
|
||||
step.get("urlPattern", "") or opts.url,
|
||||
opts.timeout, step.get("openUrl", "") or "")
|
||||
report(f"{selector} appeared")
|
||||
break
|
||||
if not absent:
|
||||
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:
|
||||
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:
|
||||
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:
|
||||
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
|
||||
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"):
|
||||
# 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
|
||||
@@ -324,6 +362,7 @@ def finish(dash, run_id, error):
|
||||
|
||||
|
||||
def main() -> int:
|
||||
force_utf8_output()
|
||||
parser = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument("--api", default="http://localhost:3000", help="dashboard URL")
|
||||
|
||||
+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 ────────────────────
|
||||
|
||||
/** 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) {
|
||||
// Every step carries its firm's pattern; the manifest hosts are the fallback
|
||||
// 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 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
|
||||
// mount and paint. Retry briefly rather than declaring the element missing,
|
||||
// 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);
|
||||
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) {
|
||||
error = err.message;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"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.",
|
||||
"permissions": ["scripting", "tabs", "storage", "alarms"],
|
||||
"host_permissions": [
|
||||
|
||||
+29
-2
@@ -7,10 +7,10 @@
|
||||
* 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 { computeDailyTarget, resolveEffectiveConfig, POINT_VALUES } from './trading-logic';
|
||||
import { getSetting } from './db';
|
||||
import { getSetting, setSetting } from './db';
|
||||
import type { FirmConfig, AccountConfig } from '@/types';
|
||||
import type { FirmWithAccounts } from './db';
|
||||
|
||||
@@ -33,6 +33,7 @@ function mapFirmConfig(firm: FirmWithAccounts): FirmConfig {
|
||||
firm: firm.name,
|
||||
username: firm.username,
|
||||
password: firm.password,
|
||||
bannedSymbols: getBannedSymbols(firm.id),
|
||||
accounts: firm.accounts.map((a) => ({
|
||||
prefix: a.prefix,
|
||||
profitTarget: a.profit_target,
|
||||
@@ -622,6 +623,13 @@ export function startScheduler(action: 'Buy' | 'Sell' | 'Auto', symbol: string,
|
||||
state.running = true;
|
||||
state.stopAfterAll = stopAfterAll;
|
||||
|
||||
// Mirror to the settings table so a restart can pick the schedule back up.
|
||||
// The interval itself is in-memory only; resumeSchedulerIfPersisted() recreates it.
|
||||
setSetting('scheduler_running', '1');
|
||||
setSetting('scheduler_action', action);
|
||||
setSetting('scheduler_symbol', symbol);
|
||||
setSetting('scheduler_stop_after_all', stopAfterAll ? '1' : '0');
|
||||
|
||||
const tick = async () => {
|
||||
if (!state.running) return;
|
||||
state.lastRun = new Date();
|
||||
@@ -676,9 +684,28 @@ export function stopScheduler() {
|
||||
state.intervalId = null;
|
||||
}
|
||||
state.running = false;
|
||||
setSetting('scheduler_running', '0');
|
||||
console.log('[scheduler] stopped');
|
||||
}
|
||||
|
||||
/**
|
||||
* Restart the schedule that was running before the process went down.
|
||||
*
|
||||
* No sync-wait here on purpose: tick() already skips while any client reports
|
||||
* !syncComplete, and again while any account holds an open position. So the
|
||||
* worst case is a few logged no-op ticks until the clients finish syncing.
|
||||
*/
|
||||
export function resumeSchedulerIfPersisted(): void {
|
||||
if (getSetting('scheduler_running') !== '1') return;
|
||||
|
||||
const action = (getSetting('scheduler_action') ?? 'Buy') as 'Buy' | 'Sell' | 'Auto';
|
||||
const symbol = getSetting('scheduler_symbol') ?? 'NQ';
|
||||
const stopAfterAll = getSetting('scheduler_stop_after_all') === '1';
|
||||
|
||||
console.log(`[scheduler] resuming persisted schedule — ${action} ${symbol}`);
|
||||
startScheduler(action, symbol, stopAfterAll);
|
||||
}
|
||||
|
||||
export function getSchedulerStatus() {
|
||||
const state = getState();
|
||||
return {
|
||||
|
||||
+31
-6
@@ -21,9 +21,24 @@ export type AutomationStep =
|
||||
// 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.
|
||||
| { action: 'navigate'; url?: string; label?: string }
|
||||
// Block until `selector` exists, then carry on. Nothing is clicked or typed —
|
||||
// this is a gate, for conditions something outside the run has to satisfy.
|
||||
| { action: 'waitFor'; selector: string; index?: number; timeoutSeconds?: number; label?: string }
|
||||
// Block until `selector` exists — or, with `absent`, until it is gone from the
|
||||
// DOM. Nothing is clicked or typed; this is a gate, for conditions something
|
||||
// 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`
|
||||
// takes it from an input the user fills in on the dashboard. The block is
|
||||
// unrolled before the runner ever sees it — see resolveSteps.
|
||||
@@ -156,11 +171,13 @@ export const FIRMS: Firm[] = [
|
||||
|
||||
{ action: 'repeat', timesFrom: 'count', steps: [
|
||||
// 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: '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: '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') {
|
||||
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') {
|
||||
// expand() peels these off first; reaching here means a caller bypassed it.
|
||||
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 'wait': return `wait ${step.seconds}s`;
|
||||
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': {
|
||||
const inner = step.steps.length;
|
||||
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 { resolveContracts } from './contract-resolver';
|
||||
import { startReporter } from './reporter';
|
||||
import { resumeSchedulerIfPersisted } from './auto-trade';
|
||||
|
||||
// Use global to persist the client pool across HMR reloads in dev mode
|
||||
const g = global as typeof globalThis & {
|
||||
@@ -19,7 +20,7 @@ function ensureMap(): Map<number, TradovateClient> {
|
||||
|
||||
export function initClient(id: number, username: string, password: string, firmName: string): TradovateClient {
|
||||
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)`);
|
||||
});
|
||||
map.set(id, client);
|
||||
@@ -63,6 +64,9 @@ export function getClients(): Map<number, TradovateClient> {
|
||||
|
||||
// Start master dashboard reporter
|
||||
startReporter();
|
||||
|
||||
// Pick the auto-trade schedule back up if it was running before restart
|
||||
resumeSchedulerIfPersisted();
|
||||
} catch (err) {
|
||||
console.error('[clients] Failed to initialize clients', err);
|
||||
}
|
||||
|
||||
@@ -182,9 +182,14 @@ db.exec(`
|
||||
const seedSetting = db.prepare(`INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)`);
|
||||
seedSetting.run('max_concurrent_accounts', '5');
|
||||
seedSetting.run('tick_interval_seconds', '60');
|
||||
seedSetting.run('master_dashboard_url', '');
|
||||
seedSetting.run('master_dashboard_url', 'https://master.juicerroom.com');
|
||||
seedSetting.run('instance_name', '');
|
||||
seedSetting.run('trading_hours', 'full_cme');
|
||||
// Mirrors the in-memory scheduler state so a restart can resume it.
|
||||
seedSetting.run('scheduler_running', '0');
|
||||
seedSetting.run('scheduler_action', 'Buy');
|
||||
seedSetting.run('scheduler_symbol', 'NQ');
|
||||
seedSetting.run('scheduler_stop_after_all', '0');
|
||||
|
||||
export function getSetting(key: string): string | null {
|
||||
const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key) as { value: string } | undefined;
|
||||
@@ -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'];
|
||||
|
||||
// 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
|
||||
const instrCount = (db.prepare('SELECT COUNT(*) as count FROM instruments').get() as { count: number }).count;
|
||||
if (instrCount === 0) {
|
||||
const ins = db.prepare('INSERT INTO instruments (symbol, enabled) VALUES (?, 1)');
|
||||
for (const s of SYMBOLS) ins.run(s);
|
||||
const ins = db.prepare('INSERT INTO instruments (symbol, enabled) VALUES (?, ?)');
|
||||
for (const s of SYMBOLS) ins.run(s, DEFAULT_ENABLED.has(s) ? 1 : 0);
|
||||
}
|
||||
|
||||
export interface InstrumentRow {
|
||||
@@ -394,6 +403,7 @@ export interface LocateRow {
|
||||
url_pattern: string;
|
||||
open_url: string;
|
||||
navigate_url: string;
|
||||
options: string; // JSON, per-request extras (scroll parameters, ...)
|
||||
status: 'pending' | 'claimed' | 'done' | 'error';
|
||||
result: string | null;
|
||||
error: string | null;
|
||||
@@ -403,10 +413,10 @@ export interface LocateRow {
|
||||
|
||||
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(
|
||||
'INSERT INTO autobuyer_locate (selector, match_index, url_pattern, open_url, navigate_url, created_at) VALUES (?, ?, ?, ?, ?, ?)'
|
||||
).run(selector, matchIndex, urlPattern, openUrl, navigateUrl, Date.now());
|
||||
'INSERT INTO autobuyer_locate (selector, match_index, url_pattern, open_url, navigate_url, options, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)'
|
||||
).run(selector, matchIndex, urlPattern, openUrl, navigateUrl, options, Date.now());
|
||||
|
||||
db.prepare(`
|
||||
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.
|
||||
try {
|
||||
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
|
||||
* doesn't reload when the source changes, so an older one silently fails on
|
||||
* 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 {
|
||||
at: number;
|
||||
|
||||
+21
-20
@@ -7,10 +7,27 @@ import { POINT_VALUES } from './trading-logic';
|
||||
import { getCachedContract, resolveContracts } from './contract-resolver';
|
||||
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 {
|
||||
private name: string;
|
||||
private password: string;
|
||||
private accessInfo: AuthLoginResponse;
|
||||
private accessInfo!: AuthLoginResponse;
|
||||
private deviceId = randomUUIDV4();
|
||||
|
||||
public accountList: AccountItem[] = [];
|
||||
@@ -70,7 +87,7 @@ export class TradovateClient {
|
||||
public syncComplete = false;
|
||||
|
||||
|
||||
private ws: WebSocket;
|
||||
private ws!: WebSocket;
|
||||
private callbackOnSyncRequest: () => Promise<void>;
|
||||
|
||||
/** 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;
|
||||
} = {};
|
||||
private indirectEventCallbacks: {
|
||||
entityType:
|
||||
| 'order'
|
||||
| 'orderVersion'
|
||||
| 'auditUserAction'
|
||||
| 'command'
|
||||
| 'commandReport'
|
||||
| 'fill'
|
||||
| 'executionReport'
|
||||
| 'cashBalance';
|
||||
entityType: TradovateEntityType;
|
||||
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
|
||||
validator: (response: any) => boolean;
|
||||
@@ -167,12 +176,7 @@ export class TradovateClient {
|
||||
| {
|
||||
e?: string;
|
||||
d?: {
|
||||
entityType:
|
||||
| 'order'
|
||||
| 'orderVersion'
|
||||
| 'auditUserAction'
|
||||
| 'command'
|
||||
| 'commandReport';
|
||||
entityType: TradovateEntityType;
|
||||
eventType: 'Created' | 'Updated';
|
||||
entity: any;
|
||||
};
|
||||
@@ -279,9 +283,6 @@ export class TradovateClient {
|
||||
const isSentinel = limit >= 999999999;
|
||||
const floor = (!isSentinel && limit > 0 && drawdown > 0) ? limit - drawdown : 0;
|
||||
this.autoLiqThresholds[accountId] = floor;
|
||||
if (floor > 0) {
|
||||
console.log(`[autoLiq] account ${accountId} → floor $${floor} (hwm=$${limit} drawdown=$${drawdown})`);
|
||||
}
|
||||
}
|
||||
|
||||
this.accountList = ((response.accounts ?? []) as AccountItem[]).map((account) => {
|
||||
|
||||
Generated
+2
-62
@@ -11,7 +11,6 @@
|
||||
"axios": "^1.13.6",
|
||||
"better-sqlite3": "^12.6.2",
|
||||
"next": "16.1.6",
|
||||
"playwright": "^1.58.2",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"recharts": "^3.8.0"
|
||||
@@ -72,7 +71,6 @@
|
||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
@@ -1688,7 +1686,6 @@
|
||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
@@ -1754,7 +1751,6 @@
|
||||
"integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.56.1",
|
||||
"@typescript-eslint/types": "8.56.1",
|
||||
@@ -2280,7 +2276,6 @@
|
||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -2695,7 +2690,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -3486,7 +3480,6 @@
|
||||
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -3672,7 +3665,6 @@
|
||||
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@rtsao/scc": "^1.1.0",
|
||||
"array-includes": "^3.1.9",
|
||||
@@ -4116,20 +4108,6 @@
|
||||
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
@@ -5976,36 +5954,6 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
|
||||
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.58.2"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
|
||||
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/possible-typed-array-names": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
||||
@@ -6170,7 +6118,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
|
||||
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -6180,7 +6127,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
|
||||
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
@@ -6192,15 +6138,13 @@
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-redux": {
|
||||
"version": "9.2.0",
|
||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/use-sync-external-store": "^0.0.6",
|
||||
"use-sync-external-store": "^1.4.0"
|
||||
@@ -6267,8 +6211,7 @@
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/redux-thunk": {
|
||||
"version": "3.1.0",
|
||||
@@ -7067,7 +7010,6 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -7242,7 +7184,6 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -7561,7 +7502,6 @@
|
||||
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
"axios": "^1.13.6",
|
||||
"better-sqlite3": "^12.6.2",
|
||||
"next": "16.1.6",
|
||||
"playwright": "^1.58.2",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"recharts": "^3.8.0"
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Keep clicker/requirements.txt applied, cheaply.
|
||||
*
|
||||
* Called from start-all.mjs on every boot, and from update-check.mjs when a
|
||||
* pull touches requirements.txt. Running `pip install` unconditionally would
|
||||
* add seconds to every start and fail outright on a machine whose network is
|
||||
* not up yet, so the work is guarded by a hash plus an import probe.
|
||||
*
|
||||
* Failure is never fatal: the dashboard and the trading loops do not need
|
||||
* Python. Only the clicker does.
|
||||
*/
|
||||
import { createHash } from 'node:crypto';
|
||||
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const REQUIREMENTS = path.join(ROOT, 'clicker', 'requirements.txt');
|
||||
const HASH_FILE = path.join(ROOT, 'scripts', '.deps-hash');
|
||||
const PYTHON_CMD_FILE = path.join(ROOT, 'scripts', '.python-cmd');
|
||||
|
||||
/** Run `<cmd> <args>`; cmd may carry arguments of its own, e.g. "py -3". */
|
||||
function run(cmd, args, opts = {}) {
|
||||
const parts = cmd.split(/\s+/);
|
||||
return spawnSync(parts[0], [...parts.slice(1), ...args], {
|
||||
cwd: ROOT,
|
||||
encoding: 'utf8',
|
||||
shell: process.platform === 'win32',
|
||||
...opts,
|
||||
});
|
||||
}
|
||||
|
||||
/** A real interpreter answers with its major version. The Microsoft Store stub
|
||||
* that Windows puts on PATH produces nothing, which is how we tell them apart. */
|
||||
function isRealPython(cmd) {
|
||||
const r = run(cmd, ['-c', 'import sys;print(sys.version_info[0])']);
|
||||
return r.status === 0 && (r.stdout ?? '').trim() === '3';
|
||||
}
|
||||
|
||||
export function resolvePython() {
|
||||
// setup-windows.bat already did this detection properly, stub check included,
|
||||
// and recorded what it found. Prefer that over guessing again.
|
||||
if (existsSync(PYTHON_CMD_FILE)) {
|
||||
const saved = readFileSync(PYTHON_CMD_FILE, 'utf8').trim();
|
||||
if (saved && isRealPython(saved)) return saved;
|
||||
}
|
||||
for (const candidate of ['py -3', 'python3', 'python']) {
|
||||
if (isRealPython(candidate)) return candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function ensurePythonDeps({ log = console.log, force = false } = {}) {
|
||||
if (!existsSync(REQUIREMENTS)) {
|
||||
return { ok: true, action: 'skipped', reason: 'no requirements.txt' };
|
||||
}
|
||||
|
||||
const python = resolvePython();
|
||||
if (!python) {
|
||||
log('[deps] python not found — clicker dependencies skipped (dashboard is unaffected)');
|
||||
return { ok: false, action: 'skipped', reason: 'no python' };
|
||||
}
|
||||
|
||||
const wanted = createHash('sha256').update(readFileSync(REQUIREMENTS)).digest('hex');
|
||||
const recorded = existsSync(HASH_FILE) ? readFileSync(HASH_FILE, 'utf8').trim() : '';
|
||||
const importsOk = run(python, ['-c', 'import pyautogui']).status === 0;
|
||||
|
||||
if (!force && wanted === recorded && importsOk) {
|
||||
return { ok: true, action: 'up-to-date', python };
|
||||
}
|
||||
|
||||
log(`[deps] installing clicker dependencies via "${python}"${importsOk ? '' : ' (pyautogui does not import)'}`);
|
||||
const install = run(python, [
|
||||
'-m', 'pip', 'install', '--disable-pip-version-check', '--quiet',
|
||||
'-r', path.join('clicker', 'requirements.txt'),
|
||||
], { stdio: 'inherit' });
|
||||
|
||||
if (install.status !== 0) {
|
||||
log('[deps] pip install failed — the clicker will not work until this is fixed');
|
||||
return { ok: false, action: 'failed', python };
|
||||
}
|
||||
|
||||
if (run(python, ['-c', 'import pyautogui']).status !== 0) {
|
||||
log('[deps] pip reported success but pyautogui still does not import');
|
||||
return { ok: false, action: 'failed', python };
|
||||
}
|
||||
|
||||
writeFileSync(HASH_FILE, wanted + '\n');
|
||||
log('[deps] clicker dependencies ready');
|
||||
return { ok: true, action: 'installed', python };
|
||||
}
|
||||
|
||||
// Allow `node scripts/ensure-python-deps.mjs` directly.
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
const result = ensurePythonDeps({ force: process.argv.includes('--force') });
|
||||
console.log(JSON.stringify(result));
|
||||
process.exit(result.ok ? 0 : 1);
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Register AutoFirmer to start at logon and keep itself updated.
|
||||
|
||||
.DESCRIPTION
|
||||
The only Windows-specific piece of the setup. Everything it schedules is
|
||||
plain Node, so porting to macOS or Linux means replacing this file alone.
|
||||
|
||||
Deliberately needs no administrator rights, and changes nothing
|
||||
machine-wide:
|
||||
|
||||
* PM2 supervises the dashboard, the clicker and the update checker.
|
||||
* A Startup-folder entry runs scripts\start-all.bat at logon.
|
||||
* PM2's own cron restart drives the update checks.
|
||||
|
||||
Task Scheduler is avoided on purpose - Register-ScheduledTask needs
|
||||
elevation to write to the root task folder.
|
||||
|
||||
Everything runs in the logged-in user's interactive session. That is not
|
||||
incidental: the clicker sends real mouse and keyboard input and must own a
|
||||
desktop, which a Windows service (session 0) does not have. The trade-off
|
||||
is that an unattended reboot leaves the instance down until someone logs in.
|
||||
|
||||
Idempotent - safe to re-run.
|
||||
|
||||
.PARAMETER IntervalMinutes
|
||||
How often to check master for updates. Default 5.
|
||||
|
||||
.PARAMETER SkipClicker
|
||||
Register only the dashboard, leaving the clicker to be run by hand.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[int]$IntervalMinutes = 5,
|
||||
[switch]$SkipClicker
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$Root = Split-Path -Parent $PSScriptRoot
|
||||
|
||||
function Info($m) { Write-Host " $m" }
|
||||
function Warn($m) { Write-Host " ! $m" -ForegroundColor Yellow }
|
||||
|
||||
<#
|
||||
PM2 ships a PowerShell shim, and anything it writes to stderr becomes a
|
||||
NativeCommandError. With $ErrorActionPreference = 'Stop' that is terminating,
|
||||
so a routine "process not found" from `pm2 delete` aborts the whole install.
|
||||
Redirecting at the call site does not help: the error is raised inside
|
||||
pm2.ps1, not here. Drop to Continue for the duration and judge the result by
|
||||
$LASTEXITCODE instead.
|
||||
#>
|
||||
function Invoke-Native {
|
||||
param([string]$Exe, [string[]]$Arguments)
|
||||
$prev = $ErrorActionPreference
|
||||
$ErrorActionPreference = 'Continue'
|
||||
try {
|
||||
& $Exe @Arguments 2>&1 | ForEach-Object { Write-Host " $_" -ForegroundColor DarkGray }
|
||||
return $LASTEXITCODE
|
||||
} finally {
|
||||
$ErrorActionPreference = $prev
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-Pm2 {
|
||||
param([string[]]$Arguments, [switch]$Quiet)
|
||||
$prev = $ErrorActionPreference
|
||||
$ErrorActionPreference = 'Continue'
|
||||
try {
|
||||
$output = & pm2 @Arguments 2>&1
|
||||
$code = $LASTEXITCODE
|
||||
if (-not $Quiet -and $code -ne 0) {
|
||||
$output | ForEach-Object { Write-Host " $_" -ForegroundColor DarkGray }
|
||||
}
|
||||
return $code
|
||||
} finally {
|
||||
$ErrorActionPreference = $prev
|
||||
}
|
||||
}
|
||||
|
||||
Info "project root: $Root"
|
||||
|
||||
# -- PM2 ---------------------------------------------------------------------
|
||||
if (-not (Get-Command pm2 -ErrorAction SilentlyContinue)) {
|
||||
Info 'installing PM2 globally...'
|
||||
if ((Invoke-Native 'npm' @('install', '-g', 'pm2')) -ne 0) { throw 'npm install -g pm2 failed' }
|
||||
# A fresh global install is not on this session's PATH yet.
|
||||
$prevEap = $ErrorActionPreference
|
||||
$ErrorActionPreference = 'Continue'
|
||||
$npmPrefix = (& npm prefix -g 2>$null | Select-Object -First 1)
|
||||
$ErrorActionPreference = $prevEap
|
||||
if ($npmPrefix) { $npmPrefix = $npmPrefix.Trim() }
|
||||
$env:PATH = "$npmPrefix;$env:PATH"
|
||||
if (-not (Get-Command pm2 -ErrorAction SilentlyContinue)) {
|
||||
throw 'PM2 installed but not found on PATH. Open a new terminal and re-run.'
|
||||
}
|
||||
}
|
||||
Info "pm2: $((Get-Command pm2).Source)"
|
||||
|
||||
Push-Location $Root
|
||||
try {
|
||||
# Run Next directly rather than through `npm start`. On Windows `npm` is a
|
||||
# .cmd shim, so PM2 would supervise the shim while the real server ran as
|
||||
# its child - restarts and stops then miss the process that matters.
|
||||
$nextBin = Join-Path $Root 'node_modules\next\dist\bin\next'
|
||||
if (-not (Test-Path $nextBin)) { throw "next not found at $nextBin - run npm install first" }
|
||||
|
||||
Invoke-Pm2 @('delete', 'autofirmer') -Quiet | Out-Null # absent is fine
|
||||
if ((Invoke-Pm2 @('start', $nextBin, '--name', 'autofirmer', '--interpreter', 'node', '--', 'start')) -ne 0) {
|
||||
throw 'pm2 start autofirmer failed'
|
||||
}
|
||||
Info 'pm2: autofirmer registered'
|
||||
|
||||
if (-not $SkipClicker) {
|
||||
# --interpreter wants one executable, so resolve the real path rather
|
||||
# than passing something like "py -3".
|
||||
$pyCmdFile = Join-Path $Root 'scripts\.python-cmd'
|
||||
$pyCmd = if (Test-Path $pyCmdFile) { (Get-Content $pyCmdFile -Raw).Trim() } else { 'python' }
|
||||
$pyExe = $null
|
||||
$prevEap = $ErrorActionPreference
|
||||
$ErrorActionPreference = 'Continue'
|
||||
try {
|
||||
$pyExe = (& ([scriptblock]::Create("$pyCmd -c `"import sys;print(sys.executable)`"")) 2>$null | Select-Object -First 1)
|
||||
} catch { } finally { $ErrorActionPreference = $prevEap }
|
||||
|
||||
if ($pyExe -and (Test-Path $pyExe)) {
|
||||
Invoke-Pm2 @('delete', 'clicker') -Quiet | Out-Null
|
||||
if ((Invoke-Pm2 @('start', (Join-Path $Root 'clicker\runner.py'), '--name', 'clicker', '--interpreter', $pyExe)) -eq 0) {
|
||||
Info "pm2: clicker registered ($pyExe)"
|
||||
} else {
|
||||
Warn 'pm2 start clicker failed - the dashboard is unaffected'
|
||||
}
|
||||
} else {
|
||||
Warn 'python not found - skipping the clicker. Re-run setup once Python is installed.'
|
||||
}
|
||||
}
|
||||
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
|
||||
# -- Start at logon (Startup folder) -----------------------------------------
|
||||
# Not Task Scheduler: Register-ScheduledTask needs elevation to write to the
|
||||
# root task folder, and the whole point is an install that never asks for
|
||||
# admin. A Startup-folder entry needs no privileges and runs in the user's
|
||||
# interactive session, which is exactly what the clicker requires - a service
|
||||
# in session 0 has no desktop and its clicks would go nowhere.
|
||||
$startupDir = [Environment]::GetFolderPath('Startup')
|
||||
$startupCmd = Join-Path $startupDir 'AutoFirmer.cmd'
|
||||
$startAll = Join-Path $Root 'scripts\start-all.bat'
|
||||
|
||||
@"
|
||||
@echo off
|
||||
REM Written by scripts\install-autostart.ps1 - delete this file to stop
|
||||
REM AutoFirmer starting at logon.
|
||||
call "$startAll"
|
||||
"@ | Set-Content -Path $startupCmd -Encoding ASCII
|
||||
|
||||
Info "logon entry: $startupCmd"
|
||||
|
||||
# -- Update checks (PM2 cron) ------------------------------------------------
|
||||
# Also not Task Scheduler, same reason. PM2 is already running and already
|
||||
# comes back at logon, so it can own the schedule too: --cron-restart fires the
|
||||
# script on a schedule and --no-autorestart stops PM2 relaunching it the moment
|
||||
# it exits.
|
||||
$updateScript = Join-Path $Root 'scripts\update-check.mjs'
|
||||
Invoke-Pm2 @('delete', 'autofirmer-update') -Quiet | Out-Null
|
||||
$cron = "*/$IntervalMinutes * * * *"
|
||||
if ((Invoke-Pm2 @('start', $updateScript, '--name', 'autofirmer-update',
|
||||
'--no-autorestart', '--cron-restart', $cron)) -eq 0) {
|
||||
Info "update checks: every $IntervalMinutes minutes ($cron)"
|
||||
} else {
|
||||
Warn 'could not register the update checker - run scripts\update-check.mjs by hand to update'
|
||||
}
|
||||
|
||||
# Save again so `pm2 resurrect` at logon brings the updater back too.
|
||||
if ((Invoke-Pm2 @('save')) -ne 0) { Warn 'pm2 save failed - processes may not return after a reboot' }
|
||||
|
||||
Write-Host ''
|
||||
Info 'Done. No admin was needed and nothing machine-wide was changed.'
|
||||
Info 'Running now, and again at every logon.'
|
||||
Info "Updates checked every $IntervalMinutes minutes; see scripts\update.log"
|
||||
Info 'Useful: pm2 list | pm2 logs autofirmer | pm2 logs clicker'
|
||||
Info "To disable autostart: delete $startupCmd"
|
||||
Executable
+113
@@ -0,0 +1,113 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Register AutoFirmer to start at login and keep itself updated. Linux/Pi
|
||||
# counterpart of install-autostart.ps1 - the same two mechanisms, expressed the
|
||||
# way this desktop does them:
|
||||
#
|
||||
# * a ~/.config/autostart entry runs scripts/start-all.sh at login
|
||||
# * PM2's own cron restart drives the update checks
|
||||
#
|
||||
# No sudo, nothing system-wide. Everything runs inside the graphical login
|
||||
# session, which is not incidental: the clicker synthesises real X11 input and
|
||||
# needs a desktop with DISPLAY set. A systemd system service has neither.
|
||||
#
|
||||
# ./scripts/install-autostart.sh [--interval-minutes 5] [--skip-clicker]
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
INTERVAL=5
|
||||
SKIP_CLICKER=0
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--interval-minutes) INTERVAL="$2"; shift 2 ;;
|
||||
--skip-clicker) SKIP_CLICKER=1; shift ;;
|
||||
*) echo "unknown option: $1" >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
info() { printf ' %s\n' "$1"; }
|
||||
warn() { printf ' ! %s\n' "$1" >&2; }
|
||||
|
||||
info "project root: $ROOT"
|
||||
|
||||
# ── PM2 ─────────────────────────────────────────────────────────────────────
|
||||
if ! command -v pm2 >/dev/null 2>&1; then
|
||||
info 'installing PM2 globally...'
|
||||
npm install -g pm2
|
||||
command -v pm2 >/dev/null 2>&1 || { echo "PM2 installed but not on PATH" >&2; exit 1; }
|
||||
fi
|
||||
info "pm2: $(command -v pm2)"
|
||||
|
||||
cd "$ROOT"
|
||||
|
||||
# Run Next directly rather than through `npm start`, so PM2 supervises the
|
||||
# server itself instead of an npm wrapper that spawns it.
|
||||
NEXT_BIN="$ROOT/node_modules/next/dist/bin/next"
|
||||
[ -f "$NEXT_BIN" ] || { echo "next not found at $NEXT_BIN - run npm install first" >&2; exit 1; }
|
||||
|
||||
pm2 delete autofirmer >/dev/null 2>&1 || true
|
||||
pm2 start "$NEXT_BIN" --name autofirmer --interpreter node -- start
|
||||
info 'pm2: autofirmer registered'
|
||||
|
||||
if [ "$SKIP_CLICKER" -eq 0 ]; then
|
||||
PY_CMD_FILE="$ROOT/scripts/.python-cmd"
|
||||
PY_CMD="$( [ -f "$PY_CMD_FILE" ] && cat "$PY_CMD_FILE" || echo python3 )"
|
||||
PY_EXE="$($PY_CMD -c 'import sys;print(sys.executable)' 2>/dev/null || true)"
|
||||
|
||||
if [ -n "$PY_EXE" ] && [ -x "$PY_EXE" ]; then
|
||||
pm2 delete clicker >/dev/null 2>&1 || true
|
||||
if pm2 start "$ROOT/clicker/runner.py" --name clicker --interpreter "$PY_EXE"; then
|
||||
info "pm2: clicker registered ($PY_EXE)"
|
||||
else
|
||||
warn 'pm2 start clicker failed - the dashboard is unaffected'
|
||||
fi
|
||||
else
|
||||
warn 'python not found - skipping the clicker'
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Update checks (PM2 cron) ────────────────────────────────────────────────
|
||||
pm2 delete autofirmer-update >/dev/null 2>&1 || true
|
||||
if pm2 start "$ROOT/scripts/update-check.mjs" --name autofirmer-update \
|
||||
--no-autorestart --cron-restart "*/$INTERVAL * * * *"; then
|
||||
info "update checks: every $INTERVAL minutes (*/$INTERVAL * * * *)"
|
||||
else
|
||||
warn 'could not register the update checker - run scripts/update-check.mjs by hand'
|
||||
fi
|
||||
|
||||
pm2 save >/dev/null
|
||||
info 'pm2: process list saved'
|
||||
|
||||
# ── Start at login (XDG autostart) ──────────────────────────────────────────
|
||||
# The desktop equivalent of the Windows Startup folder: runs inside the
|
||||
# graphical session, so DISPLAY is set and the clicker can reach the X server.
|
||||
AUTOSTART_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/autostart"
|
||||
DESKTOP_FILE="$AUTOSTART_DIR/autofirmer.desktop"
|
||||
mkdir -p "$AUTOSTART_DIR"
|
||||
cat > "$DESKTOP_FILE" <<DESKTOP
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=AutoFirmer
|
||||
Comment=Start the AutoFirmer dashboard, clicker and update checker
|
||||
Exec=$ROOT/scripts/start-all.sh
|
||||
Terminal=false
|
||||
X-GNOME-Autostart-enabled=true
|
||||
DESKTOP
|
||||
info "login entry: $DESKTOP_FILE"
|
||||
|
||||
# ── Wayland warning ─────────────────────────────────────────────────────────
|
||||
# Worth saying now rather than letting every automation fail later.
|
||||
if [ -n "${WAYLAND_DISPLAY:-}" ] && [ -z "${DISPLAY:-}" ]; then
|
||||
echo
|
||||
warn 'This is a Wayland session. The dashboard is fine, but the clicker cannot'
|
||||
warn 'work: xdotool and pyautogui both speak X11, which Wayland does not expose.'
|
||||
warn 'Switch with: sudo raspi-config -> Advanced Options -> Wayland -> X11, then reboot.'
|
||||
fi
|
||||
|
||||
echo
|
||||
info 'Done. No sudo was needed and nothing system-wide was changed.'
|
||||
info 'Running now, and again at every login.'
|
||||
info "Updates checked every $INTERVAL minutes; see scripts/update.log"
|
||||
info 'Useful: pm2 list | pm2 logs autofirmer | pm2 logs clicker'
|
||||
info "To disable autostart: rm $DESKTOP_FILE"
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Writes the reporter settings into autotrader.sqlite before first launch.
|
||||
*
|
||||
* lib/db.ts seeds these keys with INSERT OR IGNORE, so values written here
|
||||
* survive the app's own startup seeding. Must be run from the project root —
|
||||
* lib/db.ts opens the database at process.cwd().
|
||||
*
|
||||
* node scripts/seed-settings.js <master_dashboard_url> <instance_name>
|
||||
*/
|
||||
const path = require('path');
|
||||
const Database = require('better-sqlite3');
|
||||
|
||||
const [, , url, name] = process.argv;
|
||||
|
||||
const db = new Database(path.join(process.cwd(), 'autotrader.sqlite'));
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
const upsert = db.prepare(
|
||||
'INSERT INTO settings (key, value) VALUES (?, ?) ' +
|
||||
'ON CONFLICT(key) DO UPDATE SET value = excluded.value'
|
||||
);
|
||||
|
||||
if (url) { upsert.run('master_dashboard_url', url); console.log(' master_dashboard_url = ' + url); }
|
||||
if (name) { upsert.run('instance_name', name); console.log(' instance_name = ' + name); }
|
||||
|
||||
db.close();
|
||||
@@ -0,0 +1,5 @@
|
||||
@echo off
|
||||
REM Thin wrapper so Task Scheduler has a single entry point. All the logic is in
|
||||
REM start-all.mjs, which is platform-neutral.
|
||||
cd /d "%~dp0.."
|
||||
node scripts\start-all.mjs
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Bring an instance up: clicker dependencies, PM2 processes, then a warm-up.
|
||||
*
|
||||
* Run by the "at log on" scheduled task. Safe to run by hand at any time.
|
||||
*
|
||||
* The warm-up is not cosmetic. getClients() in lib/clients.ts is lazily
|
||||
* bootstrapped — Tradovate clients, the contract resolver, the reporter and the
|
||||
* persisted-schedule resume all start inside it — so until something makes an
|
||||
* HTTP request the process sits idle and none of that happens.
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { ensurePythonDeps } from './ensure-python-deps.mjs';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const PORT = process.env.AUTOFIRMER_PORT ?? '3000';
|
||||
const BASE = `http://127.0.0.1:${PORT}`;
|
||||
|
||||
const log = (msg) => console.log(`[start-all] ${msg}`);
|
||||
|
||||
function run(cmd, args) {
|
||||
return spawnSync(cmd, args, {
|
||||
cwd: ROOT,
|
||||
encoding: 'utf8',
|
||||
stdio: 'inherit',
|
||||
shell: process.platform === 'win32',
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForApp(timeoutMs = 120_000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const res = await fetch(`${BASE}/api/auto-trade`, { signal: AbortSignal.timeout(5_000) });
|
||||
if (res.ok) return true;
|
||||
} catch { /* not up yet */ }
|
||||
await new Promise((r) => setTimeout(r, 2_000));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── clicker dependencies (non-fatal) ────────────────────────────────────────
|
||||
ensurePythonDeps({ log });
|
||||
|
||||
// ── PM2 ─────────────────────────────────────────────────────────────────────
|
||||
log('restoring PM2 processes');
|
||||
if (run('pm2', ['resurrect']).status !== 0) {
|
||||
log('pm2 resurrect failed — is PM2 installed and has `pm2 save` been run?');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ── warm-up ─────────────────────────────────────────────────────────────────
|
||||
log('waiting for the dashboard to answer');
|
||||
if (!(await waitForApp())) {
|
||||
log(`dashboard did not come up on ${BASE} within 120s — check \`pm2 logs autofirmer\``);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
log('warming up (this is what triggers the client bootstrap)');
|
||||
try {
|
||||
await fetch(`${BASE}/api/state`, { signal: AbortSignal.timeout(60_000) });
|
||||
} catch (err) {
|
||||
log(`warm-up request failed: ${err.message}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const status = await (await fetch(`${BASE}/api/auto-trade`)).json();
|
||||
log(status.running
|
||||
? `scheduler resumed — ${status.action} ${status.symbol}`
|
||||
: 'scheduler is stopped');
|
||||
} catch { /* non-critical */ }
|
||||
|
||||
log('up');
|
||||
Executable
+6
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
# Thin wrapper so the desktop autostart entry has a single entry point.
|
||||
# All the logic is in start-all.mjs, which is platform-neutral.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
exec node scripts/start-all.mjs
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* 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 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 */ }
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
try {
|
||||
git('fetch', 'origin', BRANCH);
|
||||
const local = git('rev-parse', 'HEAD');
|
||||
const remote = git('rev-parse', `origin/${BRANCH}`);
|
||||
|
||||
if (local === remote) return 0; // the common path: silent no-op
|
||||
|
||||
log(`update available: ${local.slice(0, 8)} -> ${remote.slice(0, 8)}`);
|
||||
const changed = git('diff', '--name-only', local, remote).split('\n').filter(Boolean);
|
||||
log(`${changed.length} file(s) changed`);
|
||||
|
||||
if (DRY_RUN) {
|
||||
log('dry run — stopping before any change');
|
||||
log(`would run: ${[
|
||||
changed.includes('package-lock.json') && 'npm install',
|
||||
changed.includes('clicker/requirements.txt') && 'pip install',
|
||||
'npm run build',
|
||||
'pm2 restart autofirmer',
|
||||
changed.some((f) => f.startsWith('clicker/')) && 'pm2 restart clicker',
|
||||
].filter(Boolean).join(', ')}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
git('pull', '--ff-only', 'origin', BRANCH);
|
||||
|
||||
if (changed.includes('package-lock.json')) {
|
||||
log('package-lock.json changed — npm install');
|
||||
if (run('npm', ['install']).status !== 0) { log('ABORTED: npm install failed'); return 1; }
|
||||
}
|
||||
|
||||
if (changed.includes('clicker/requirements.txt')) {
|
||||
log('clicker/requirements.txt changed — refreshing python deps');
|
||||
ensurePythonDeps({ log: (m) => log(m) }); // non-fatal by design
|
||||
}
|
||||
|
||||
// Build BEFORE restarting. A failed build leaves the running process
|
||||
// untouched, which is the whole point of doing it in this order.
|
||||
log('building');
|
||||
if (run('npm', ['run', 'build']).status !== 0) {
|
||||
log('ABORTED: build failed — the previous build is still serving, nothing was restarted');
|
||||
return 1;
|
||||
}
|
||||
|
||||
log('restarting autofirmer');
|
||||
if (run('pm2', ['restart', 'autofirmer']).status !== 0) { log('pm2 restart autofirmer failed'); return 1; }
|
||||
|
||||
if (changed.some((f) => f.startsWith('clicker/'))) {
|
||||
log('clicker changed — restarting it too');
|
||||
run('pm2', ['restart', 'clicker']);
|
||||
}
|
||||
|
||||
await warmUp();
|
||||
log(`updated to ${remote.slice(0, 8)}`);
|
||||
return 0;
|
||||
} catch (err) {
|
||||
log(`ERROR: ${err.message}`);
|
||||
return 1;
|
||||
} finally {
|
||||
if (!DRY_RUN) releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
async function warmUp() {
|
||||
const deadline = Date.now() + 120_000;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
if ((await fetch(`${BASE}/api/auto-trade`, { signal: AbortSignal.timeout(5_000) })).ok) break;
|
||||
} catch { /* still restarting */ }
|
||||
await new Promise((r) => setTimeout(r, 2_000));
|
||||
}
|
||||
try {
|
||||
await fetch(`${BASE}/api/state`, { signal: AbortSignal.timeout(60_000) });
|
||||
const status = await (await fetch(`${BASE}/api/auto-trade`)).json();
|
||||
log(status.running ? `scheduler resumed — ${status.action} ${status.symbol}` : 'scheduler is stopped');
|
||||
const runner = await (await fetch(`${BASE}/api/autobuyer/runner`)).json();
|
||||
log(`runner ${runner.online ? 'online' : 'OFFLINE'}${runner.stale ? ' (version stale — reload the extension)' : ''}`);
|
||||
} catch (err) {
|
||||
log(`post-restart check failed: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
process.exit(await main());
|
||||
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