Compare commits

...
25 Commits
Author SHA1 Message Date
Brandon LiandClaude Opus 5 0ba27161ce Record a heartbeat so a silent update checker is distinguishable from a dead one
There was no way to tell "running, nothing to pull" from "not running". The
no-change path logs nothing by design, and PM2 reports a cron-restart process
as `stopped` with ↺ 0 even while firing on schedule — I verified that against
PM2 7.0.4: a one-minute cron fired four times without the counter moving once.

scripts/.last-check is now rewritten on every run with the outcome. It is a
single overwritten line, so it needs no trimming.

The outcome is set by each exit path and written once in `finally`, rather than
calling record at each return — the first version of this did the latter and
already missed the build-failure path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 19:43:43 -05:00
Brandon Li 27a61fd25c Wait for the form but with an actual selector 2026-08-30 19:25:59 -05:00
Brandon LiandClaude Opus 5 c5c946d3cc Add Linux/Raspberry Pi setup and port the clicker to X11
The clicker had no Linux support at all: pygetwindow has no X11 backend, so
_other_activate returned "window management is unsupported" and every step
failed before it could click. focus.py now has a third backend that raises the
browser with xdotool and reads the focused window's WM_CLASS to verify it came
forward - the same activate-then-confirm shape as the macOS and Windows paths.

setup-linux.sh mirrors setup-windows.bat with apt instead of winget. Three
things are specific to this platform rather than incidental:

- Node comes from NodeSource. Pi OS ships one too old for Next 16, and the LTS
  line is also what better-sqlite3 publishes prebuilt arm64 binaries for.
- Python dependencies go in a virtualenv. Pi OS Bookworm enforces PEP 668, so
  pip into the system interpreter fails with externally-managed-environment.
- Autostart uses an XDG ~/.config/autostart entry, the direct analogue of the
  Windows Startup folder: no sudo, and it runs inside the graphical session,
  which the clicker needs for DISPLAY.

Wayland is called out in four places - the session guard, diagnose.py, the
installer and both READMEs - because Pi OS on a Pi 5 defaults to it and the
clicker simply cannot work there. Wayland does not let one client synthesise
input into another, so this is a switch-to-X11 situation, not a bug to fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 18:53:25 -05:00
Brandon LiandClaude Opus 5 3cc632ac40 Replace the manual install walkthrough with the one-liner
Seven sections of PowerShell - install Git, install Node, install
windows-build-tools, clone, npm install, build, run - all of which
setup-windows.bat now does, and better. The clone URL in step 4 was stale
anyway, pointing at a repo that no longer exists.

Kept the parts the script cannot do: adding firms through the UI, loading the
extension, and the AutoBuyer notes on foreground lock and display scaling. The
clicker sections now say what setup already handles and how to do it by hand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 18:44:26 -05:00
Brandon LiandClaude Opus 5 9d777cb2c6 Stop logging the autoLiq floor for every account
One line per account on every sync, which on a multi-firm instance buries
anything worth reading in `pm2 logs autofirmer`. The threshold is still
recorded on the client and still drives the dead-account checks; it just is
not narrated any more.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 18:41:20 -05:00
Brandon LiandClaude Opus 5 be8c628778 Self-trim update.log
Capped at 256 KB, trimmed back to the newest 500 lines. Done once per run
before anything is written: the no-change path logs nothing, so that is the
only point at which the file can have grown since the last run. Failures are
swallowed - housekeeping must never be the reason an update does not land.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 18:40:19 -05:00
Brandon LiandClaude Opus 5 fbe2c47ed4 Force UTF-8 on clicker output so Windows stops failing runs
A run died with "'charmap' codec can't encode character '▶'" at step 1.
Python picks the console code page for stdout, and under PM2 - where stdout is
a pipe rather than a console - that is cp1252 on Windows. cp1252 handles the em
dashes in these files but not the run markers, so the first one raised
UnicodeEncodeError from inside run_steps and the runner reported it as a step
failure rather than an output problem.

Reconfigure stdout and stderr to UTF-8 at the top of each entry point, with
errors="replace" as a backstop for streams that cannot be reconfigured. Fixing
the encoding once beats stripping the glyphs from ~30 call sites, and covers
manual runs in a plain console too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 18:28:39 -05:00
Brandon LiandClaude Opus 5 724ba5581c Drop Task Scheduler; autostart without administrator rights
Register-ScheduledTask failed with Access denied (0x80070005). Writing to the
root task folder needs elevation, so the claim that this install needed no
admin was simply wrong. Rather than demand UAC, use two mechanisms that need
no privileges at all:

- a Startup-folder entry (AutoFirmer.cmd) runs start-all.bat at logon
- PM2's own --cron-restart with --no-autorestart drives the 5-minute update
  check, so PM2 owns the schedule it was already going to resurrect anyway

Both still run in the logged-in interactive session, which is the requirement
that ruled out a Windows service in the first place: the clicker sends real
input and needs a desktop.

pm2 save now runs after the updater is registered, so `pm2 resurrect` brings
back all three processes rather than two.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 18:24:47 -05:00
Brandon LiandClaude Opus 5 7e4714c33d Stop PM2's stderr from aborting the autostart installer
`pm2 delete autofirmer` on a machine that has never registered it writes
"[PM2][ERROR] Process or Namespace autofirmer not found" to stderr. PM2 ships a
PowerShell shim, and under $ErrorActionPreference = 'Stop' any native stderr
becomes a terminating NativeCommandError - so the routine "remove it if it is
there" line killed the install before a single task was registered. The 2>$null
at the call site was useless: the error is raised inside pm2.ps1.

Route every pm2 and npm call through helpers that drop to 'Continue' and judge
by $LASTEXITCODE. npm had the same latent problem, since its warnings also go
to stderr.

Also start Next directly instead of via `npm start`. On Windows npm is a .cmd
shim, so PM2 was supervising the shim while the real server ran as its child -
restarts and stops would have missed the process that actually matters.

The file is now pure ASCII. PowerShell 5.1 reads a non-BOM UTF-8 script as
ANSI, so the box-drawing characters in the comments were a latent hazard.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 18:21:59 -05:00
Brandon LiandClaude Opus 5 97ee03d13b Drop the unused playwright dependency
playwright was declared but referenced nowhere in the source — no import, no
require, no script. Its postinstall downloads several hundred MB of browsers
on every install, which setup-windows.bat had been working around with
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD. Removing the dependency removes the need for
the workaround, so that goes too.

Nothing else depends on it; the remaining lockfile mentions are Next declaring
@playwright/test as an optional peer, which installs nothing.

Instances pick this up through the normal update path: package-lock.json
changed, so update-check runs npm install and playwright disappears from
node_modules. Any browsers already downloaded on a machine live outside
node_modules and are not removed by this — see README.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 18:15:18 -05:00
Brandon LiandClaude Opus 5 a0ef06d933 Find Python after installing it, like Git and Node
winget installed Python successfully and the very next line reported it
missing. The re-probe only checked `py -3` and `python` on PATH, and a
just-installed interpreter is not on the PATH this session inherited at start
— the same failure Git and Node already had directory probes for. Python
never got them.

Probe the standard install homes before giving up:

  %LOCALAPPDATA%\Programs\Python\Python3*      (per-user, winget's default)
  %ProgramFiles%\Python3*                      (all-users)
  ...\Python\Launcher\py.exe, %SystemRoot%\py.exe

Globbed rather than version-pinned so a 3.13 install is found too, and the
loop body uses %%~D: a quoted for /d pattern carries its quotes through, which
would otherwise mangle every path built from "C:\Program Files\Python312".

This also covers Python installed without "Add python.exe to PATH" ticked,
which is the more common way to end up here without winget involved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 18:13:43 -05:00
Brandon LiandClaude Opus 5 4288d8c298 Auto-start both processes at logon and self-update from master
Instances were started by hand and updated by hand, so they drifted behind
master silently. Now: PM2 supervises the dashboard and the clicker, a logon
task brings them up, and a 5-minute task pulls, rebuilds and restarts when
master moves.

Restarting on every push is only safe because the scheduler now survives it.
It was pure in-memory state (_global.__autoTrader), so any restart silently
stopped automated trading with the dashboard simply showing it as off. It now
mirrors running/action/symbol/stopAfterAll to the settings table, and
resumeSchedulerIfPersisted() picks it back up from the getClients() bootstrap.
No sync-wait was needed there: tick() already skips while a client reports
!syncComplete and while any account holds a position.

A failed build is never deployed — the build runs before anything restarts, so
a broken push leaves the previous build serving.

start-all and update-check both warm the app with a request afterwards. That is
load-bearing: getClients() is lazily bootstrapped, so until something makes an
HTTP request the Tradovate clients, the reporter and the resumed schedule never
start. That was already true of manual restarts.

Logic lives in Node so a macOS or Linux port only needs an equivalent of
install-autostart.ps1. Python deps are hash-guarded, so the common path is one
hash and one import with no network, and failure is non-fatal since only the
clicker needs them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 17:36:25 -05:00
Brandon LiandClaude Opus 5 7e7bd985c2 Name the master branch explicitly when cloning and pulling
A bare `git clone` follows whatever the remote advertises as its default
branch. Pushing --all to a fresh Gitea repo left that default pointing at
autobuyer, so setup cloned a branch predating the build fixes and failed at
`next build` on an error that had already been fixed on master.

The Gitea default is corrected, but the script no longer depends on it:
clone passes --branch master, and the update path fetches, checks out master
and pulls it by name. That also recovers a checkout already stranded on the
wrong branch, rather than needing the folder deleted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 17:04:04 -05:00
Brandon LiandClaude Opus 5 a718caeb08 Auto-install Git, Node and Python via winget
Each prerequisite now follows probe -> offer install -> probe again. The
second probe matters: a freshly installed tool is never visible to `where`
in the session that installed it, since the process inherited its PATH at
start. A *_TRIED guard stops the loop at one attempt.

Node installs from the LTS package on purpose - better-sqlite3 publishes
prebuilt binaries for LTS, so this sidesteps the node-gyp compile that the
existing Node >= 23 warning covers.

Python detection runs the interpreter instead of calling `where python`.
Windows ships a stub python.exe under WindowsApps that only opens the
Microsoft Store; `where` finds it but it cannot execute anything. Asking for
sys.version_info distinguishes the two, and the py launcher is preferred
because the stub does not shadow it.

Dependency install now upgrades pip first and verifies pyautogui actually
imports, rather than trusting pip's exit code alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 16:55:29 -05:00
Brandon LiandClaude Opus 5 8e64d8a34f Find Git and Node when they are installed but off PATH
Git for Windows only adds itself to PATH when "Git from the command line"
is selected during install, and an already-open Command Prompt keeps its
old PATH regardless — so a correct install still failed the check.

Probe the standard install locations before giving up, and when that also
fails, say which directories were searched and call out the just-installed
case explicitly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 16:50:58 -05:00
Brandon LiandClaude Opus 5 d378712e79 Default master_dashboard_url to https://master.juicerroom.com
Fresh installs point at the master dashboard without manual configuration.
Seeded with INSERT OR IGNORE, so existing databases are untouched.

Note that reporter.ts requires both master_dashboard_url and instance_name
to be non-empty, so this alone does not start reporting — instance_name is
still seeded blank and set per machine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 16:48:38 -05:00
Brandon LiandClaude Opus 5 9bdd20f83a Add Windows setup script for new instances
Standalone batch file: clones (or pulls), installs, builds, seeds the
reporter settings, and writes a start-autofirmer.bat. Prompts for the master
dashboard URL and instance name, defaulting to %COMPUTERNAME%.

Two install hazards it handles:
- better-sqlite3 has no prebuilt binary above Node 22, so install silently
  falls back to node-gyp and dies without Visual Studio Build Tools. The
  script warns first and names the fix if install fails anyway
- playwright is declared but referenced nowhere in the source, so its
  postinstall browser download is skipped

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 16:16:06 -05:00
Brandon LiandClaude Opus 5 3bef7dea9e Seed only NQ, GC and CL as enabled instruments
The other 17 symbols still seed, so they remain listed and can be switched
on from the Instruments page — they just start disabled.

Affects fresh installs only: the seed block runs only when the instruments
table is empty, so existing databases keep their current selection.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 16:16:06 -05:00
Brandon LiandClaude Opus 5 fc08a41c4b Fix seven type errors that broke next build
`npm run build` failed on a clean checkout, so nothing on master could be
built for production. `npm run dev` does not hard-fail on type errors, which
is why it went unnoticed.

- state route returned client.perContractFees, which has never existed on
  TradovateClient on any branch; nothing consumed it
- mapFirmConfig omitted bannedSymbols. Type gap only: the trade path calls
  isSymbolBanned() against the DB directly, so bans were always enforced
- initClient's sync callback was sync where the constructor wants
  () => Promise<void>
- accessInfo and ws are assigned during async connect/auth, never in the
  constructor, so they take definite-assignment assertions
- the socket payload's inline entityType union had drifted five members
  behind the indirect-callback union above it, making the 'position' and
  'cashBalance' branches unreachable to the compiler. Both now share a
  TradovateEntityType alias. Type-only: those handlers ran fine at runtime

Behaviour is unchanged throughout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 16:16:06 -05:00
Brandon LiandClaude Opus 5 3cc7ddcc5c Add scrollToLoad and an absent variant of waitFor
scrollToLoad walks a progressively-loading list to the bottom before the steps
that act on its items run. Stopping is two-part: no new matches appeared AND the
container was already pinned to the bottom — counting alone stops early on a slow
fetch. Hitting the scroll cap is reported rather than passed off as done, so a
later step never works quietly on a partial list.

The scrolling element is usually not the window. Lists like this live in a div
with its own overflow, and scrolling the document does nothing at all, so the
step walks up from a matched item to the ancestor that actually scrolls —
overflow allows it and there is more content than fits — with containerSelector
to name one outright when the guess is wrong. Verified against a page whose
document also scrolls, which is the case that tells the two apart: it found the
inner div and pulled 12 items up to 60 in 7 scrolls.

waitFor gains `absent`, for waiting on something to go rather than arrive — a
modal closing after a reset. It only accepts a genuine "selector matched
nothing"; an unreachable extension looks the same from a distance and would
otherwise satisfy the gate for the wrong reason, sending the next iteration into
a page that still has the modal open.

The locate queue carries a free-form options blob now, so a new kind of request
stops meaning a new column each time.

Also fixes a missing comma in the reset flow that broke the build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 14:31:36 -05:00
Brandon LiandClaude Opus 5 3ac9fe060f Raise the browser the extension reported, and add a diagnostics script
focus.py raised the first browser in its list that happened to be running. On a
machine with both Chrome and Edge installed that is a coin flip, and losing it is
silent: coordinates measured from a tab in one browser, the click delivered into
a window of the other. It presents as selectors failing for no reason. A VPS with
both installed hit exactly this.

The extension now reports which browser is hosting it, and that travels with the
measurement, so the clicker raises the browser the coordinates actually came
from. Asking for a browser that is not running now fails honestly instead of
quietly raising a different one, and the verification step rejects the wrong
browser coming forward. Chromium, Opera and Vivaldi are recognised alongside
Chrome, Edge and Brave, on both platforms.

diagnose.py answers the question a remote desktop makes hard: whether the mouse
is really moving or the viewer simply is not drawing it. It moves the cursor and
reads the position back from the OS, so the answer does not depend on anything
being rendered, and it reports DPI mode, screen size, whether this is an RDP
session, and whether the browser can be raised at all. Nothing is clicked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 14:13:18 -05:00
Brandon Li 731fafda0e Scope the reset button to failed accounts
`button.reset_btn` matched the reset control on any account row; qualifying it
with `.status-failed` keeps the repeat block from resetting healthy accounts.
2026-08-30 14:13:18 -05:00
Brandon LiandClaude Opus 5 3ff5728af9 Survive transient dashboard errors instead of failing the run
Next's dev server intermittently answers a 500 while recompiling a route: it
reads a build manifest mid-write and cannot parse it. A single one of those
during the locate poll was fatal, so a blip in the pipeline killed a run partway
through an auth flow on Windows.

5xx responses and dropped connections are now a distinct TransientError, retried
until the step's own timeout. A 4xx still fails immediately — those are verdicts
about the request, not blips. If the errors persist all the way to the timeout,
the message says so rather than blaming a missing extension.

Error bodies are also summarised. A dev-server 500 replies with a full HTML page,
and printing it raw buried the one line that said what went wrong under kilobytes
of script tags.

This makes the client tolerant of the fault, which is not the same as fixing it:
the real answer on an automation host is to run a production build rather than
`next dev`, so those manifests are written once instead of continuously.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 14:00:23 -05:00
Brandon LiandClaude Opus 5 a8853c56d1 Name the actual branch in CLAUDE.md
The file said to work on `main`, but this repo's mainline is `master` and no
`main` exists — which is how a feature branch got created instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 13:46:17 -05:00
Brandon LiandClaude Opus 5 7686301a70 Import focus in runner.py
The DPI awareness call added for Windows went into both entry points, but the
import only went into clicker.py, so starting the runner died immediately with
NameError: name 'focus' is not defined.

py_compile does not catch this — a missing import is a runtime error, not a
syntax one — and the tests around it stub the modules rather than starting the
process, so nothing exercised the real startup path. Verified this time by
booting the runner against a dead port, which reaches the claim loop.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 13:45:35 -05:00
34 changed files with 2684 additions and 1242 deletions
+10
View File
@@ -53,3 +53,13 @@ autotrader.sqlite
# python # python
__pycache__/ __pycache__/
*.pyc *.pyc
# generated by the Windows setup / autostart scripts
scripts/.deps-hash
scripts/.python-cmd
scripts/.update.lock
scripts/update.log
# clicker virtualenv (Linux/Pi)
clicker/.venv/
scripts/.last-check
+1 -1
View File
@@ -1,6 +1,6 @@
# Claude Instructions # Claude Instructions
## Working Directory ## Working Directory
Always work directly on `main`. Do **not** create worktrees or feature branches unless explicitly asked. Always work directly on `master` — this repo has no `main`. Do **not** create worktrees or feature branches unless explicitly asked.
The project root is `D:\Development\market-dev\autotrader-firms\autotrader`. The project root is `D:\Development\market-dev\autotrader-firms\autotrader`.
+113 -91
View File
@@ -4,77 +4,65 @@ Automated futures trading dashboard for prop firm accounts via Tradovate.
--- ---
## VPS Setup (Windows Server 2019 / 2022) ## Setup
All commands are run in **PowerShell** (run as Administrator). ### Windows
### 1. Install Git In **PowerShell**, from wherever you want the instance to live:
Download and install from https://git-scm.com/download/win, or via winget:
```powershell ```powershell
winget install --id Git.Git -e --source winget irm https://git.juicerroom.com/senofy/autofirmer-expanded/raw/branch/master/setup-windows.bat -OutFile setup-windows.bat
``` ```
Restart PowerShell after installing so `git` is on the PATH.
### 2. Install Node.js 22+
```powershell ```powershell
winget install --id OpenJS.NodeJS.LTS -e --source winget .\setup-windows.bat
``` ```
Restart PowerShell, then verify: That is the whole install. It clones into an `autofirmer` folder beside itself,
installs anything missing (Git, Node, Python) via winget, installs dependencies,
builds, asks for the master dashboard URL and an instance name, and offers to
set up auto-start and auto-update.
```powershell Re-running it updates an existing checkout rather than cloning again, so it
node --version # should be v22.x or higher doubles as a repair tool.
npm --version
Two things it deliberately does, worth knowing:
- **Node LTS, not latest.** `better-sqlite3` ships prebuilt binaries for LTS
only. On a newer major it falls back to compiling with node-gyp and fails
without Visual Studio Build Tools, so the script warns before that happens.
- **It looks for tools off `PATH`.** A tool installed in the same session it is
needed — or installed without its "add to PATH" option — is invisible to a
running shell, so the script checks the standard install directories too.
### Raspberry Pi 5 / Debian / Ubuntu
```bash
curl -fsSLO https://git.juicerroom.com/senofy/autofirmer-expanded/raw/branch/master/setup-linux.sh
``` ```
### 3. Install Windows Build Tools (required for better-sqlite3) ```bash
bash setup-linux.sh
`better-sqlite3` compiles a native C++ module and needs the Visual Studio build tools:
```powershell
npm install -g windows-build-tools
``` ```
If that fails on newer Node, install manually: Same idea, apt instead of winget. It installs Node 22 from NodeSource (Pi OS
- Download **Build Tools for Visual Studio** from https://visualstudio.microsoft.com/downloads/#build-tools-for-visual-studio-2022 ships one too old for Next 16), `xdotool` and `scrot` for the clicker, and puts
- During install, select **"Desktop development with C++"** the Python dependencies in a virtualenv — Pi OS Bookworm enforces PEP 668, so a
system-wide `pip install` fails outright.
### 4. Clone the repo **The clicker needs X11.** Pi OS on a Pi 5 defaults to Wayland, which does not
let one client synthesise input into another or read the focused window, so
neither `xdotool` nor pyautogui can work there. The dashboard is unaffected, but
for the AutoBuyer:
```powershell ```bash
git clone https://github.com/Senofy/autofirmer.git sudo raspi-config # Advanced Options -> Wayland -> X11, then reboot
cd autofirmer
``` ```
### 5. Install dependencies `python clicker/diagnose.py` reports the session type, `DISPLAY` and whether
`xdotool` is present — run it before trusting a new machine.
```powershell The dashboard runs on port **3000**: `http://localhost:3000`.
npm install
```
### 6. Build
```powershell
npm run build
```
### 7. Run
**Development (with hot reload):**
```powershell
npm run dev
```
**Production:**
```powershell
npm start
```
The app runs on **port 3000**. Access it at `http://<your-vps-ip>:3000`.
--- ---
@@ -119,11 +107,13 @@ Adding a new firm also means adding its host to `host_permissions` in
`extension/manifest.json` and reloading. Without it the extension cannot read `extension/manifest.json` and reloading. Without it the extension cannot read
that site, and every step fails to locate. that site, and every step fails to locate.
### 2. Install the clicker ### 2. The clicker
`setup-windows.bat` installs its Python dependencies, and re-applies them
whenever `clicker/requirements.txt` changes. To do it by hand:
```powershell ```powershell
cd clicker python -m pip install -r clicker\requirements.txt
pip install -r requirements.txt
``` ```
See `clicker/README.md` for the per-platform notes — display scaling and See `clicker/README.md` for the per-platform notes — display scaling and
@@ -132,11 +122,14 @@ run before letting it click anything on a new machine.
### 3. Start the runner ### 3. Start the runner
Auto-start runs it under PM2, so normally there is nothing to do. To run it by
hand instead:
```powershell ```powershell
python clicker\runner.py python clicker\runner.py
``` ```
Leave it running. It reports in every two seconds, and the dashboard greys out It reports in every two seconds, and the dashboard greys out
the automation buttons when it is not there. A running Python process does not the automation buttons when it is not there. A running Python process does not
reload when the source changes, so restart it after pulling updates; the reload when the source changes, so restart it after pulling updates; the
dashboard warns when its version is behind. dashboard warns when its version is behind.
@@ -152,44 +145,44 @@ dialog stealing focus fails the step. That makes an RDP session a poor host:
disconnecting can suspend the desktop and break clicks in ways that are hard to disconnecting can suspend the desktop and break clicks in ways that are hard to
diagnose. diagnose.
## Keeping it running (PM2) ## Keeping it running
Install PM2 globally: `setup-windows.bat` offers to set this up for you at step 6. To enable it later,
or after declining:
```powershell ```powershell
npm install -g pm2 powershell -NoProfile -ExecutionPolicy Bypass -File scripts\install-autostart.ps1
npm install -g pm2-windows-startup
``` ```
Start the app and save the process list: That puts the dashboard, the clicker and an update checker under PM2, adds a
Startup-folder entry so PM2 comes back at logon, and lets PM2's cron restart run
the update check every 5 minutes. It needs no administrator rights, changes
nothing machine-wide, and is safe to re-run.
```powershell ```powershell
cd C:\path\to\autofirmer pm2 list # what is running
pm2 start "npm start" --name autofirmer pm2 logs autofirmer # dashboard output
pm2 save pm2 logs clicker # runner output
pm2-startup install
``` ```
To restart after pulling updates: **Why not a Windows service.** The clicker sends real mouse and keyboard input
and has to own a desktop. A service runs in session 0, which has none, so the
clicks would go nowhere. Everything therefore runs in your logged-in session —
which also means an unattended reboot leaves the instance down until somebody
logs in.
To stop it starting at logon, delete the `AutoFirmer.cmd` shortcut from your
Startup folder (`shell:startup` in the Run dialog).
To start everything by hand without waiting for a logon:
```powershell ```powershell
cd C:\path\to\autofirmer node scripts\start-all.mjs
git pull
npm install
npm run build
pm2 restart autofirmer
``` ```
If the update touched the AutoBuyer, two things do **not** reload themselves: `start-autofirmer.bat` still runs the dashboard in a visible window without PM2,
which is the easier thing to watch when a build is misbehaving.
- **The extension** — click reload on its card in `chrome://extensions`.
- **The runner** — stop it with Ctrl-C and start it again.
The dashboard reports the version it sees from each, and warns when either is
behind. Most AutoBuyer bugs that look mysterious are one of these two still
running the previous code.
---
## Firewall ## Firewall
@@ -209,19 +202,48 @@ New-NetFirewallRule -DisplayName "AutoFirmer" -Direction Inbound -Protocol TCP -
## Updating ## Updating
```powershell Once auto-start is installed, nothing here is manual. Every 5 minutes the update
cd C:\path\to\autofirmer task fetches `master`, and when it has moved it pulls, reinstalls dependencies if
git pull `package-lock.json` or `clicker/requirements.txt` changed, rebuilds, restarts
npm install # only needed if dependencies changed AutoFirmer, and restarts the clicker if anything under `clicker/` changed.
npm run build
pm2 restart autofirmer **A failed build is never deployed.** The build runs before anything restarts, so
a broken push leaves the previous build serving and logs the failure instead.
### Checking it is alive
`scripts/.last-check` is rewritten on every check, whether or not anything came
down:
```
2026-08-31T00:43:27.155Z up to date at 27a61fd2
``` ```
If the update touched the AutoBuyer, two things do **not** reload themselves: This exists because the alternatives mislead. `update.log` only records real
events, so it stays empty for days when nothing is pushed — and PM2 reports a
cron-restart process as `stopped` with a restart count of `0` even while it is
firing on schedule. Neither is evidence of a problem; `.last-check` is the
signal to trust.
Everything that actually happens is appended to `scripts/update.log`. To see
what it would do without touching anything:
```powershell
node scripts\update-check.mjs --dry-run
```
To apply an update immediately rather than waiting for the next check:
```powershell
node scripts\update-check.mjs
```
### The two things that still do not reload themselves
- **The extension** — click reload on its card in `chrome://extensions`. - **The extension** — click reload on its card in `chrome://extensions`.
- **The runner** — stop it with Ctrl-C and start it again. - **The scheduler is fine now.** It persists to the settings table and resumes
after a restart, so an update no longer silently stops automated trading.
The dashboard reports the version it sees from each, and warns when either is The dashboard reports the version it sees from the extension and the runner, and
behind. Most AutoBuyer bugs that look mysterious are one of these two still warns when either is behind. Most AutoBuyer bugs that look mysterious are the
running the previous code. extension still running the previous code.
+1
View File
@@ -14,6 +14,7 @@ export async function POST() {
urlPattern: row.url_pattern, urlPattern: row.url_pattern,
openUrl: row.open_url, openUrl: row.open_url,
navigateUrl: row.navigate_url, navigateUrl: row.navigate_url,
options: (() => { try { return JSON.parse(row.options); } catch { return {}; } })(),
}, },
}); });
} }
+3 -2
View File
@@ -5,7 +5,7 @@ import { corsJson, corsPreflight } from '../cors';
/** Python enqueues "find this selector and tell me where it is on screen". */ /** Python enqueues "find this selector and tell me where it is on screen". */
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
try { try {
const body = await req.json() as { selector?: unknown; index?: unknown; urlPattern?: unknown; openUrl?: unknown; navigateUrl?: unknown }; const body = await req.json() as { selector?: unknown; index?: unknown; urlPattern?: unknown; openUrl?: unknown; navigateUrl?: unknown; options?: unknown };
if (typeof body.selector !== 'string' || !body.selector.trim()) { if (typeof body.selector !== 'string' || !body.selector.trim()) {
return corsJson({ error: '`selector` is required' }, { status: 400 }); return corsJson({ error: '`selector` is required' }, { status: 400 });
} }
@@ -13,8 +13,9 @@ export async function POST(req: NextRequest) {
const urlPattern = typeof body.urlPattern === 'string' ? body.urlPattern : ''; const urlPattern = typeof body.urlPattern === 'string' ? body.urlPattern : '';
const openUrl = typeof body.openUrl === 'string' ? body.openUrl : ''; const openUrl = typeof body.openUrl === 'string' ? body.openUrl : '';
const navigateUrl = typeof body.navigateUrl === 'string' ? body.navigateUrl : ''; const navigateUrl = typeof body.navigateUrl === 'string' ? body.navigateUrl : '';
const options = body.options && typeof body.options === 'object' ? JSON.stringify(body.options) : '{}';
const row = createLocateRequest(body.selector.trim(), index, urlPattern, openUrl, navigateUrl); const row = createLocateRequest(body.selector.trim(), index, urlPattern, openUrl, navigateUrl, options);
return corsJson({ id: row.id, status: row.status }); return corsJson({ id: row.id, status: row.status });
} catch (err: any) { } catch (err: any) {
return corsJson({ error: err?.message ?? 'Failed to queue request' }, { status: 500 }); return corsJson({ error: err?.message ?? 'Failed to queue request' }, { status: 500 });
+1 -1
View File
@@ -98,7 +98,7 @@ export async function GET() {
fundTransactions: displayFundTxns, fundTransactions: displayFundTxns,
}; };
}); });
return { firm: f.name, connected: true, accounts, perContractFees: client.perContractFees }; return { firm: f.name, connected: true, accounts };
}); });
return NextResponse.json(state); return NextResponse.json(state);
+11
View File
@@ -53,6 +53,17 @@ VS Code — under System Settings → Privacy & Security → Accessibility. With
`pyautogui` moves nothing and fails silently, which looks identical to a bad `pyautogui` moves nothing and fails silently, which looks identical to a bad
selector. selector.
### Linux (including Raspberry Pi)
`setup-linux.sh` installs `xdotool` and `scrot` and builds a virtualenv for the
Python side.
**X11 only.** pygetwindow has no X11 backend, so raising the browser goes
through `xdotool` instead. Neither that nor pyautogui works under Wayland — it
refuses by design to let one client drive another — and Raspberry Pi OS on a Pi
5 defaults to Wayland. Switch with `sudo raspi-config` -> Advanced Options ->
Wayland -> X11 and reboot. `diagnose.py` prints which session you are in.
### Windows ### Windows
No extra permissions, but two things differ. No extra permissions, but two things differ.
+3 -1
View File
@@ -162,7 +162,9 @@ def perform(
# A click on a background window is consumed activating it and never reaches # A click on a background window is consumed activating it and never reaches
# the page, so raise the browser immediately before pressing. # the page, so raise the browser immediately before pressing.
if activate: if activate:
focused = focus.ensure_frontmost() # Raise the browser the extension actually measured from, not whichever
# one happens to be first in the list.
focused = focus.ensure_frontmost(browser=found.get("browser"))
say(focused.detail) say(focused.detail)
if not focused.ok: if not focused.ok:
raise StepError( raise StepError(
+71 -10
View File
@@ -21,6 +21,7 @@ Requires the AutoBuyer page switch to be ON — that's the master arming switch.
import argparse import argparse
import json import json
import re
import random import random
import sys import sys
import time import time
@@ -30,6 +31,22 @@ import urllib.request
import actions import actions
import focus import focus
def force_utf8_output() -> None:
"""Make stdout/stderr accept the status glyphs this tool prints.
Windows picks the console code page for stdout, and under PM2 (where stdout
is a pipe, not a console) that is cp1252. It can encode em dashes but not
the run markers, so the first one raised UnicodeEncodeError mid-run and the
runner reported it as a step failure. errors="replace" is a backstop for any
stream that cannot be reconfigured at all.
"""
for stream in (sys.stdout, sys.stderr):
try:
stream.reconfigure(encoding="utf-8", errors="replace")
except (AttributeError, ValueError):
pass
DEFAULT_API = "http://localhost:3000" DEFAULT_API = "http://localhost:3000"
POLL_INTERVAL = 0.25 POLL_INTERVAL = 0.25
@@ -38,6 +55,16 @@ class DashboardError(RuntimeError):
pass pass
class TransientError(DashboardError):
"""A failure that is worth retrying: a 5xx, or the server briefly unreachable.
Next's dev server intermittently serves a 500 while it recompiles a route —
it reads a build manifest mid-write and fails to parse it. That is a blip in
the pipeline, not a verdict about the page, and it should not kill a run that
is halfway through spending money.
"""
class NotFoundError(DashboardError): class NotFoundError(DashboardError):
"""The extension reached the page and the element simply isn't there. """The extension reached the page and the element simply isn't there.
@@ -53,6 +80,26 @@ class NotFoundError(DashboardError):
_ABSENT_MARKERS = ("No element matches", "no index") _ABSENT_MARKERS = ("No element matches", "no index")
def _summarise_error_body(body: str, limit: int = 200) -> str:
"""Keep an error readable.
A dev-server 500 answers with a full HTML page — several kilobytes of script
tags and a stack trace — and printing it raw buries the one line that says
what went wrong.
"""
try:
return str(json.loads(body).get("error", body))[:limit]
except json.JSONDecodeError:
pass
if "<!DOCTYPE" in body or "<html" in body:
match = re.search(r'"message":"(.*?)"', body)
detail = match.group(1) if match else "no detail in the page"
return f"server returned an HTML error page ({detail[:limit]})"
return body[:limit]
def _is_absent(message: str) -> bool: def _is_absent(message: str) -> bool:
return any(marker in message for marker in _ABSENT_MARKERS) return any(marker in message for marker in _ABSENT_MARKERS)
@@ -72,20 +119,18 @@ class Dashboard:
with urllib.request.urlopen(req, timeout=self.timeout) as resp: with urllib.request.urlopen(req, timeout=self.timeout) as resp:
return json.loads(resp.read().decode()) return json.loads(resp.read().decode())
except urllib.error.HTTPError as exc: except urllib.error.HTTPError as exc:
body = exc.read().decode(errors="replace") message = _summarise_error_body(exc.read().decode(errors="replace"))
try: cls = TransientError if exc.code >= 500 else DashboardError
message = json.loads(body).get("error", body) raise cls(f"{method} {path} -> HTTP {exc.code}: {message}") from None
except json.JSONDecodeError:
message = body
raise DashboardError(f"{method} {path} -> HTTP {exc.code}: {message}") from None
except urllib.error.URLError as exc: except urllib.error.URLError as exc:
raise DashboardError(f"Cannot reach {self.base}{exc.reason}") from None raise TransientError(f"Cannot reach {self.base}{exc.reason}") from None
def status(self) -> dict: def status(self) -> dict:
return self._request("/api/autobuyer/status") return self._request("/api/autobuyer/status")
def locate(self, selector: str, index: int, url_pattern: str, timeout: float, def locate(self, selector: str, index: int, url_pattern: str, timeout: float,
open_url: str = "", navigate_url: str = "") -> dict: open_url: str = "", navigate_url: str = "",
options: dict | None = None) -> dict:
"""Queue a lookup and block until the extension answers it. """Queue a lookup and block until the extension answers it.
`open_url` is the page the extension should open if no tab matches `open_url` is the page the extension should open if no tab matches
@@ -95,13 +140,23 @@ class Dashboard:
"/api/autobuyer/locate", "/api/autobuyer/locate",
"POST", "POST",
{"selector": selector, "index": index, "urlPattern": url_pattern, {"selector": selector, "index": index, "urlPattern": url_pattern,
"openUrl": open_url, "navigateUrl": navigate_url}, "openUrl": open_url, "navigateUrl": navigate_url,
"options": options or {}},
) )
request_id = queued["id"] request_id = queued["id"]
deadline = time.monotonic() + timeout deadline = time.monotonic() + timeout
last_transient = None
while time.monotonic() < deadline: while time.monotonic() < deadline:
row = self._request(f"/api/autobuyer/locate?id={request_id}") try:
row = self._request(f"/api/autobuyer/locate?id={request_id}")
except TransientError as exc:
# The extension may well answer while the server is having a
# moment; keep polling rather than failing the run over a blip.
last_transient = exc
time.sleep(POLL_INTERVAL)
continue
if row["status"] == "done": if row["status"] == "done":
return row["result"] return row["result"]
if row["status"] == "error": if row["status"] == "error":
@@ -110,6 +165,11 @@ class Dashboard:
raise cls(f"Extension could not locate it: {detail}") raise cls(f"Extension could not locate it: {detail}")
time.sleep(POLL_INTERVAL) time.sleep(POLL_INTERVAL)
if last_transient is not None:
raise DashboardError(
f"No answer within {timeout:g}s, and the dashboard kept erroring "
f"({last_transient})"
)
raise DashboardError( raise DashboardError(
f"No answer within {timeout:g}s. Is the extension installed, is Chrome " f"No answer within {timeout:g}s. Is the extension installed, is Chrome "
f"running, and is the AutoBuyer switch ON?" f"running, and is the AutoBuyer switch ON?"
@@ -132,6 +192,7 @@ def describe(found: dict, factor: float) -> str:
def main() -> int: def main() -> int:
force_utf8_output()
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("action", choices=["locate", "click", "type"], parser.add_argument("action", choices=["locate", "click", "type"],
help="locate = measure only; click = measure then click; " help="locate = measure only; click = measure then click; "
+156
View File
@@ -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
View File
@@ -12,24 +12,78 @@ which it is, because that's where this script was launched — Chrome as a whole
still in the background. This module raises the application itself. still in the background. This module raises the application itself.
""" """
import os
import shutil
import subprocess import subprocess
import sys import sys
import time import time
# Chrome ships under several bundle ids; accept whichever is installed. # Per browser, how to recognise it on each platform. The extension reports which
MAC_BUNDLES = ( # one is hosting it, because picking by list order raises the wrong browser as
"com.google.Chrome", # soon as two are installed — and then clicks land in a window the coordinates
"com.google.Chrome.beta", # were never measured from.
"com.google.Chrome.dev", BROWSERS = {
"com.google.Chrome.canary", "chrome": {
"com.brave.Browser", "darwin": ("com.google.Chrome", "com.google.Chrome.beta",
"com.microsoft.edgemac", "com.google.Chrome.dev", "com.google.Chrome.canary",
) "org.chromium.Chromium"),
"win32": ("chrome.exe",),
"linux": ("google-chrome", "chromium", "chromium-browser", "google-chrome-stable"),
"titles": ("Chrome", "Chromium"),
},
"edge": {
"darwin": ("com.microsoft.edgemac",),
"win32": ("msedge.exe",),
"linux": ("microsoft-edge", "msedge"),
"titles": ("Edge",),
},
"brave": {
"darwin": ("com.brave.Browser",),
"win32": ("brave.exe",),
"linux": ("brave-browser", "brave"),
"titles": ("Brave",),
},
"opera": {
"darwin": ("com.operasoftware.Opera",),
"win32": ("opera.exe", "launcher.exe"),
"linux": ("opera",),
"titles": ("Opera",),
},
"vivaldi": {
"darwin": ("com.vivaldi.Vivaldi",),
"win32": ("vivaldi.exe",),
"linux": ("vivaldi-stable", "vivaldi"),
"titles": ("Vivaldi",),
},
}
# Windows browsers, matched on the owning process. A title match would also hit
# an editor with chrome.js open or a folder named Chrome; a process name cannot def _platform_key() -> str:
# collide that way. if sys.platform == "darwin":
WINDOWS_PROCESSES = ("chrome.exe", "msedge.exe", "brave.exe") return "darwin"
if sys.platform.startswith("linux"):
return "linux"
return "win32"
def _ids_for(browser: str | None) -> tuple[str, ...]:
"""Identifiers to accept on this platform. Without a named browser, every
known one — the old behaviour, and still right on a single-browser box."""
key = _platform_key()
if browser and browser in BROWSERS:
return BROWSERS[browser][key]
return tuple(i for b in BROWSERS.values() for i in b[key])
def _titles_for(browser: str | None) -> tuple[str, ...]:
if browser and browser in BROWSERS:
return BROWSERS[browser]["titles"]
return tuple(t for b in BROWSERS.values() for t in b["titles"])
# Kept for callers that just want "any known browser".
MAC_BUNDLES = _ids_for(None) if sys.platform == "darwin" else BROWSERS["chrome"]["darwin"]
WINDOWS_PROCESSES = BROWSERS["chrome"]["win32"] + BROWSERS["edge"]["win32"] + BROWSERS["brave"]["win32"]
SETTLE = 0.20 # let the window manager finish raising before measuring or clicking SETTLE = 0.20 # let the window manager finish raising before measuring or clicking
@@ -103,12 +157,68 @@ def _mac_workspace():
return NSWorkspace.sharedWorkspace() return NSWorkspace.sharedWorkspace()
def browser_ids() -> tuple[str, ...]: def _linux_session_problem() -> str | None:
"""What counts as "the browser" on this platform.""" """Why the clicker cannot drive this desktop, or None if it can.
if sys.platform == "darwin":
return MAC_BUNDLES Raspberry Pi OS on a Pi 5 defaults to Wayland (labwc). Neither xdotool nor
if sys.platform == "win32": pyautogui works there: both speak X11 protocol, and Wayland deliberately
return WINDOWS_PROCESSES refuses to let one client synthesise input into another or read the focused
window. There is no workaround short of switching the session to X11, so say
so plainly rather than failing every step with something cryptic.
"""
if not os.environ.get("DISPLAY"):
if os.environ.get("WAYLAND_DISPLAY"):
return ("this is a Wayland session and the clicker needs X11 — on Raspberry Pi OS: "
"sudo raspi-config -> Advanced Options -> Wayland -> X11, then reboot")
return "no DISPLAY is set — the clicker needs a graphical session"
if shutil.which("xdotool") is None:
return "xdotool is not installed — run: sudo apt install -y xdotool"
return None
def _xdotool(*args: str) -> subprocess.CompletedProcess:
return subprocess.run(["xdotool", *args], capture_output=True, text=True, timeout=5)
def _linux_frontmost() -> str | None:
"""WM_CLASS of the focused window, lowercased to match the ids above."""
try:
result = _xdotool("getactivewindow", "getwindowclassname")
except Exception:
return None
name = (result.stdout or "").strip().lower()
return name or None
def _linux_activate(browser: str | None = None) -> FocusResult:
problem = _linux_session_problem()
if problem:
return FocusResult(False, problem)
for cls in _ids_for(browser):
try:
found = _xdotool("search", "--onlyvisible", "--class", cls)
except Exception as exc:
return FocusResult(False, f"xdotool failed ({exc})")
ids = (found.stdout or "").split()
if not ids:
continue
# Last match is the most recently mapped window — the one a person would
# mean by "the browser" when several are open.
activated = _xdotool("windowactivate", "--sync", ids[-1])
if activated.returncode == 0:
return FocusResult(True, f"activated {cls} (window {ids[-1]})")
return FocusResult(False, f"could not activate {cls}: {(activated.stderr or '').strip()}")
return FocusResult(False, f"no {browser or 'browser'} window found")
def browser_ids(browser: str | None = None) -> tuple[str, ...]:
"""What counts as "the browser" on this platform, optionally narrowed to one."""
if sys.platform in ("darwin", "win32") or sys.platform.startswith("linux"):
return _ids_for(browser)
return () return ()
@@ -135,10 +245,13 @@ def frontmost() -> str | None:
except Exception: except Exception:
return None return None
if sys.platform.startswith("linux"):
return _linux_frontmost()
return None return None
def _mac_activate() -> FocusResult: def _mac_activate(browser: str | None = None) -> FocusResult:
ws = _mac_workspace() ws = _mac_workspace()
if ws is None: if ws is None:
# pyobjc's AppKit isn't present. osascript works but may prompt for # pyobjc's AppKit isn't present. osascript works but may prompt for
@@ -153,7 +266,7 @@ def _mac_activate() -> FocusResult:
return FocusResult(False, f"could not activate Chrome ({exc})") return FocusResult(False, f"could not activate Chrome ({exc})")
running = {a.bundleIdentifier(): a for a in ws.runningApplications()} running = {a.bundleIdentifier(): a for a in ws.runningApplications()}
for bundle in MAC_BUNDLES: for bundle in _ids_for(browser):
app = running.get(bundle) app = running.get(bundle)
if app is None: if app is None:
continue continue
@@ -162,10 +275,11 @@ def _mac_activate() -> FocusResult:
app.activateWithOptions_(1 << 1) app.activateWithOptions_(1 << 1)
return FocusResult(True, f"activated {bundle}") return FocusResult(True, f"activated {bundle}")
return FocusResult(False, "no Chrome-family browser is running") wanted = browser or "any known browser"
return FocusResult(False, f"{wanted} is not running")
def _is_browser_window(win) -> bool: def _is_browser_window(win, browser: str | None = None) -> bool:
"""Match on the owning process where we can, title only as a fallback. """Match on the owning process where we can, title only as a fallback.
A title match alone catches an editor with chrome.js open, or a folder window A title match alone catches an editor with chrome.js open, or a folder window
@@ -175,13 +289,13 @@ def _is_browser_window(win) -> bool:
try: try:
name = _win_process_name(win._hWnd) name = _win_process_name(win._hWnd)
if name: if name:
return name in WINDOWS_PROCESSES return name in _ids_for(browser)
except Exception: except Exception:
pass # fall through to the title check pass # fall through to the title check
return bool(win.title) and "Chrome" in win.title return bool(win.title) and any(t in win.title for t in _titles_for(browser))
def _other_activate() -> FocusResult: def _other_activate(browser: str | None = None) -> FocusResult:
"""Windows (and any platform pygetwindow supports).""" """Windows (and any platform pygetwindow supports)."""
try: try:
import pygetwindow import pygetwindow
@@ -189,14 +303,15 @@ def _other_activate() -> FocusResult:
return FocusResult(False, "pygetwindow unavailable — cannot raise the browser") return FocusResult(False, "pygetwindow unavailable — cannot raise the browser")
try: try:
wins = [w for w in pygetwindow.getAllWindows() if w.visible and _is_browser_window(w)] wins = [w for w in pygetwindow.getAllWindows()
if w.visible and _is_browser_window(w, browser)]
except NotImplementedError: except NotImplementedError:
# pygetwindow has no X11 backend; say so rather than looking like no # pygetwindow has no X11 backend; say so rather than looking like no
# browser is open. # browser is open.
return FocusResult(False, f"window management is unsupported on {sys.platform}") return FocusResult(False, f"window management is unsupported on {sys.platform}")
if not wins: if not wins:
return FocusResult(False, "no browser window found") return FocusResult(False, f"no {browser or 'browser'} window found")
try: try:
win = wins[0] win = wins[0]
@@ -211,25 +326,35 @@ def _other_activate() -> FocusResult:
return FocusResult(False, f"could not activate window ({exc})") return FocusResult(False, f"could not activate window ({exc})")
def activate_browser() -> FocusResult: def activate_browser(browser: str | None = None) -> FocusResult:
"""Raise the browser application above everything else.""" """Raise the browser application above everything else.
result = _mac_activate() if sys.platform == "darwin" else _other_activate()
`browser` is the id the extension reported ("chrome", "edge", ...). Without
it, any known browser will do — fine on a machine with one installed, wrong
on a machine with two.
"""
if sys.platform == "darwin":
result = _mac_activate(browser)
elif sys.platform.startswith("linux"):
result = _linux_activate(browser)
else:
result = _other_activate(browser)
if result.ok: if result.ok:
time.sleep(SETTLE) time.sleep(SETTLE)
return result return result
def ensure_frontmost(timeout: float = 1.5) -> FocusResult: def ensure_frontmost(timeout: float = 1.5, browser: str | None = None) -> FocusResult:
"""Raise the browser and, where we can check, confirm it actually came forward. """Raise the browser and, where we can check, confirm it actually came forward.
Returning ok=False does not mean the click will fail — only that we could not Returning ok=False does not mean the click will fail — only that we could not
verify. The caller decides whether to proceed. verify. The caller decides whether to proceed.
""" """
result = activate_browser() result = activate_browser(browser)
if not result.ok: if not result.ok:
return result return result
ids = browser_ids() ids = browser_ids(browser)
if not ids: if not ids:
return FocusResult(True, result.detail + " (unverified)") return FocusResult(True, result.detail + " (unverified)")
+4
View File
@@ -4,3 +4,7 @@ pyautogui>=0.9.54
pyobjc-core>=10.0; sys_platform == "darwin" pyobjc-core>=10.0; sys_platform == "darwin"
pyobjc-framework-Quartz>=10.0; sys_platform == "darwin" pyobjc-framework-Quartz>=10.0; sys_platform == "darwin"
pyobjc-framework-Cocoa>=10.0; sys_platform == "darwin" pyobjc-framework-Cocoa>=10.0; sys_platform == "darwin"
# Linux: pyautogui drives X11 through Xlib. Raising the browser window is done
# with xdotool, which is an apt package rather than a wheel - setup-linux.sh
# installs it.
python-xlib>=0.33; sys_platform == "linux"
+49 -10
View File
@@ -24,7 +24,8 @@ import threading
import time import time
import actions import actions
from clicker import Dashboard, DashboardError, NotFoundError import focus
from clicker import Dashboard, DashboardError, NotFoundError, force_utf8_output
POLL_SECONDS = 1.0 POLL_SECONDS = 1.0
HEARTBEAT_SECONDS = 2.0 HEARTBEAT_SECONDS = 2.0
@@ -32,7 +33,7 @@ HEARTBEAT_SECONDS = 2.0
# Bumped whenever the step vocabulary or the locate protocol changes. Reported in # Bumped whenever the step vocabulary or the locate protocol changes. Reported in
# the heartbeat so the dashboard can say "restart your runner" instead of letting # the heartbeat so the dashboard can say "restart your runner" instead of letting
# a stale process fail on a step type it has never heard of. # a stale process fail on a step type it has never heard of.
VERSION = "0.12.0" VERSION = "0.16.0"
# Shared with the heartbeat thread: whether a run is currently executing. # Shared with the heartbeat thread: whether a run is currently executing.
_busy = threading.Event() _busy = threading.Event()
@@ -74,10 +75,13 @@ def run_one_step(dash: Dashboard, step: dict, opts, rng, report, run_id: int | N
report(f"tab is on {landed.get('url', target)}") report(f"tab is on {landed.get('url', target)}")
elif step["action"] == "waitFor": elif step["action"] == "waitFor":
# A gate, not an action: block until the element exists. Nothing is # A gate, not an action: block until the element exists — or, with
# clicked or typed. Whatever has to make it appear — a person solving a # `absent`, until it is gone. Nothing is clicked or typed. Whatever has to
# challenge, a slow server, a background job — happens outside this run. # change the page — a person solving a challenge, a modal closing itself,
# a slow server — happens outside this run.
selector = step["selector"] selector = step["selector"]
absent = bool(step.get("absent"))
goal = "disappear" if absent else "appear"
timeout_s = float(step.get("timeoutSeconds", 120)) timeout_s = float(step.get("timeoutSeconds", 120))
deadline = time.time() + timeout_s deadline = time.time() + timeout_s
announced = False announced = False
@@ -91,20 +95,54 @@ def run_one_step(dash: Dashboard, step: dict, opts, rng, report, run_id: int | N
dash.locate(selector, int(step.get("index", 0)), dash.locate(selector, int(step.get("index", 0)),
step.get("urlPattern", "") or opts.url, step.get("urlPattern", "") or opts.url,
opts.timeout, step.get("openUrl", "") or "") opts.timeout, step.get("openUrl", "") or "")
report(f"{selector} appeared") if not absent:
break report(f"{selector} appeared")
break
# Still there. Keep waiting for it to go.
except NotFoundError:
if absent:
report(f"{selector} is gone")
break
# Not there yet; keep waiting for it to arrive.
except DashboardError: except DashboardError:
pass # not there yet, or the tab is mid-render # A dead extension or an unreachable dashboard must not be read
# as "the element is gone" — that would satisfy an absent gate
# for entirely the wrong reason.
pass
if time.time() >= deadline: if time.time() >= deadline:
raise actions.StepError( raise actions.StepError(
f"{selector} did not appear within {timeout_s:.0f}s" f"{selector} did not {goal} within {timeout_s:.0f}s"
) )
if not announced: if not announced:
report(f"waiting for {selector} (up to {timeout_s:.0f}s)") report(f"waiting for {selector} to {goal} (up to {timeout_s:.0f}s)")
announced = True announced = True
time.sleep(2.0) time.sleep(2.0)
elif step["action"] == "scrollToLoad":
# Lists that load progressively need walking to the bottom before the
# steps that act on their items can see everything.
result = dash.locate(
step["selector"], 0,
step.get("urlPattern", "") or opts.url,
max(opts.timeout, 120.0), # scrolling a long list outlasts a normal step
step.get("openUrl", "") or "",
options={
"op": "scrollToLoad",
"containerSelector": step.get("containerSelector", ""),
"maxScrolls": int(step.get("maxScrolls", 25)),
"settleMs": int(step.get("settleMs", 800)),
},
)
found_n = result.get("after", 0)
report(f"{found_n} match(es) after {result.get('scrolls', 0)} scroll(s) "
f"of {result.get('container', '?')} (was {result.get('before', 0)})")
if not result.get("exhausted"):
# Stopping on the scroll cap is not a failure, but it does mean the
# list may still have more below — worth saying so rather than
# letting a later step quietly work on a partial list.
report("hit the scroll limit — there may be more not loaded")
elif step["action"] not in ("click", "type"): elif step["action"] not in ("click", "type"):
# Almost always a stale runner: the server defines the step vocabulary, # Almost always a stale runner: the server defines the step vocabulary,
# so a step type this process has never heard of means automations.ts has # so a step type this process has never heard of means automations.ts has
@@ -324,6 +362,7 @@ def finish(dash, run_id, error):
def main() -> int: def main() -> int:
force_utf8_output()
parser = argparse.ArgumentParser(description=__doc__, parser = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter) formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--api", default="http://localhost:3000", help="dashboard URL") parser.add_argument("--api", default="http://localhost:3000", help="dashboard URL")
+117 -1
View File
@@ -154,6 +154,24 @@ async function captureAndSend(cfg) {
}); });
} }
/** Which browser is hosting this extension.
*
* The clicker has to raise *this* browser before clicking, and picking by list
* order gets it wrong the moment two are installed — it would raise Edge while
* the coordinates came from a tab in Chrome, landing every click in the wrong
* window. So the answer travels with the measurement.
*/
function detectBrowser() {
const ua = navigator.userAgent || '';
if (/\bEdg\//.test(ua)) return 'edge';
if (/\bOPR\//.test(ua)) return 'opera';
if (/\bVivaldi\//.test(ua)) return 'vivaldi';
try {
if (navigator.brave) return 'brave'; // Brave otherwise reports as Chrome
} catch { /* not Brave */ }
return 'chrome';
}
// ── Locate: turn a CSS selector into desktop coordinates ──────────────────── // ── Locate: turn a CSS selector into desktop coordinates ────────────────────
/** Runs in the page. Scrolls the element into view, then reports where it ended up. */ /** Runs in the page. Scrolls the element into view, then reports where it ended up. */
@@ -245,6 +263,71 @@ function waitForTabLoad(tabId, timeoutMs = 15000) {
}); });
} }
/** Runs in the page. Scrolls until the list stops growing, then reports what it
* ended up with.
*
* The scrolling element is often NOT the window — lists like this usually live
* in a div with its own overflow, and scrolling the document does nothing at
* all. So walk up from a matched item looking for the ancestor that actually
* scrolls, and let the caller name one outright when the guess is wrong.
*/
async function pageScrollToLoad(selector, containerSelector, maxScrolls, settleMs, stableRounds) {
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const count = () => document.querySelectorAll(selector).length;
function scroller() {
if (containerSelector) {
const named = document.querySelector(containerSelector);
if (!named) return { error: `No element matches container ${containerSelector}` };
return { el: named };
}
// An ancestor that can actually scroll: overflow allows it, and there is
// more content than fits.
let node = document.querySelector(selector)?.parentElement ?? null;
while (node && node !== document.body) {
const overflow = getComputedStyle(node).overflowY;
if (/(auto|scroll)/.test(overflow) && node.scrollHeight > node.clientHeight + 4) {
return { el: node };
}
node = node.parentElement;
}
return { el: document.scrollingElement || document.documentElement, isDocument: true };
}
const found = scroller();
if (found.error) return { error: found.error };
const el = found.el;
const before = count();
let last = before;
let stable = 0;
let scrolls = 0;
while (scrolls < maxScrolls) {
const wasAtBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 4;
el.scrollTop = el.scrollHeight;
scrolls++;
await sleep(settleMs);
const now = count();
// Nothing new AND we were already pinned to the bottom: the list is done
// growing, not merely slow.
stable = (now > last) ? 0 : stable + (wasAtBottom ? 1 : 0);
last = now;
if (stable >= stableRounds) break;
}
return {
before,
after: last,
scrolls,
exhausted: stable >= stableRounds,
container: found.isDocument ? 'document' : (el.className || el.tagName || 'element').toString().slice(0, 60),
url: location.href,
};
}
async function resolveLocateTab(cfg, request) { async function resolveLocateTab(cfg, request) {
// Every step carries its firm's pattern; the manifest hosts are the fallback // Every step carries its firm's pattern; the manifest hosts are the fallback
// for a bare request (the CLI's locate without --url). // for a bare request (the CLI's locate without --url).
@@ -305,6 +388,33 @@ async function serveLocateRequest(cfg) {
await chrome.tabs.update(tab.id, { active: true }); await chrome.tabs.update(tab.id, { active: true });
await new Promise((r) => setTimeout(r, 250)); // let the OS finish raising it await new Promise((r) => setTimeout(r, 250)); // let the OS finish raising it
// A scroll request is a different operation on the same channel: no
// element is measured, the page is just walked to the bottom.
if (request.options && request.options.op === 'scrollToLoad') {
const [scrolled] = await chrome.scripting.executeScript({
target: { tabId: tab.id },
func: pageScrollToLoad,
args: [
request.selector,
request.options.containerSelector || '',
Number(request.options.maxScrolls) || 25,
Number(request.options.settleMs) || 800,
Number(request.options.stableRounds) || 2,
],
});
const out = scrolled?.result;
if (!out) throw new Error('Scroll injection returned nothing');
if (out.error) throw new Error(out.error);
result = { ...out, tabId: tab.id, windowId: tab.windowId, browser: detectBrowser() };
await fetch(`${cfg.apiBase}/api/autobuyer/locate/result`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: request.id, result, error: null }),
});
return true;
}
// `complete` only means the document loaded — a React app still has to // `complete` only means the document loaded — a React app still has to
// mount and paint. Retry briefly rather than declaring the element missing, // mount and paint. Retry briefly rather than declaring the element missing,
// with a longer budget when we just opened the page from cold. // with a longer budget when we just opened the page from cold.
@@ -322,7 +432,13 @@ async function serveLocateRequest(cfg) {
if (Date.now() >= deadline) throw new Error(out.error); if (Date.now() >= deadline) throw new Error(out.error);
await new Promise((r) => setTimeout(r, 350)); await new Promise((r) => setTimeout(r, 350));
} }
result = { ...out, tabId: tab.id, windowId: tab.windowId, openedTab: opened }; result = {
...out,
tabId: tab.id,
windowId: tab.windowId,
openedTab: opened,
browser: detectBrowser(),
};
} catch (err) { } catch (err) {
error = err.message; error = err.message;
} }
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"manifest_version": 3, "manifest_version": 3,
"name": "AutoFirmer Capture", "name": "AutoFirmer Capture",
"version": "0.6.0", "version": "0.8.0",
"description": "Scrapes the HTML of the target tab and posts it to the AutoFirmer dashboard while the AutoBuyer is switched on.", "description": "Scrapes the HTML of the target tab and posts it to the AutoFirmer dashboard while the AutoBuyer is switched on.",
"permissions": ["scripting", "tabs", "storage", "alarms"], "permissions": ["scripting", "tabs", "storage", "alarms"],
"host_permissions": [ "host_permissions": [
+29 -2
View File
@@ -7,10 +7,10 @@
* signal but have since exited and are now eligible. * signal but have since exited and are now eligible.
*/ */
import { getFirms, isSymbolBanned, getInstruments } from './db'; import { getFirms, isSymbolBanned, getInstruments, getBannedSymbols } from './db';
import { getClients } from './clients'; import { getClients } from './clients';
import { computeDailyTarget, resolveEffectiveConfig, POINT_VALUES } from './trading-logic'; import { computeDailyTarget, resolveEffectiveConfig, POINT_VALUES } from './trading-logic';
import { getSetting } from './db'; import { getSetting, setSetting } from './db';
import type { FirmConfig, AccountConfig } from '@/types'; import type { FirmConfig, AccountConfig } from '@/types';
import type { FirmWithAccounts } from './db'; import type { FirmWithAccounts } from './db';
@@ -33,6 +33,7 @@ function mapFirmConfig(firm: FirmWithAccounts): FirmConfig {
firm: firm.name, firm: firm.name,
username: firm.username, username: firm.username,
password: firm.password, password: firm.password,
bannedSymbols: getBannedSymbols(firm.id),
accounts: firm.accounts.map((a) => ({ accounts: firm.accounts.map((a) => ({
prefix: a.prefix, prefix: a.prefix,
profitTarget: a.profit_target, profitTarget: a.profit_target,
@@ -622,6 +623,13 @@ export function startScheduler(action: 'Buy' | 'Sell' | 'Auto', symbol: string,
state.running = true; state.running = true;
state.stopAfterAll = stopAfterAll; state.stopAfterAll = stopAfterAll;
// Mirror to the settings table so a restart can pick the schedule back up.
// The interval itself is in-memory only; resumeSchedulerIfPersisted() recreates it.
setSetting('scheduler_running', '1');
setSetting('scheduler_action', action);
setSetting('scheduler_symbol', symbol);
setSetting('scheduler_stop_after_all', stopAfterAll ? '1' : '0');
const tick = async () => { const tick = async () => {
if (!state.running) return; if (!state.running) return;
state.lastRun = new Date(); state.lastRun = new Date();
@@ -676,9 +684,28 @@ export function stopScheduler() {
state.intervalId = null; state.intervalId = null;
} }
state.running = false; state.running = false;
setSetting('scheduler_running', '0');
console.log('[scheduler] stopped'); console.log('[scheduler] stopped');
} }
/**
* Restart the schedule that was running before the process went down.
*
* No sync-wait here on purpose: tick() already skips while any client reports
* !syncComplete, and again while any account holds an open position. So the
* worst case is a few logged no-op ticks until the clients finish syncing.
*/
export function resumeSchedulerIfPersisted(): void {
if (getSetting('scheduler_running') !== '1') return;
const action = (getSetting('scheduler_action') ?? 'Buy') as 'Buy' | 'Sell' | 'Auto';
const symbol = getSetting('scheduler_symbol') ?? 'NQ';
const stopAfterAll = getSetting('scheduler_stop_after_all') === '1';
console.log(`[scheduler] resuming persisted schedule — ${action} ${symbol}`);
startScheduler(action, symbol, stopAfterAll);
}
export function getSchedulerStatus() { export function getSchedulerStatus() {
const state = getState(); const state = getState();
return { return {
+32 -7
View File
@@ -21,9 +21,24 @@ export type AutomationStep =
// Point the firm's tab at a page. Omit `url` to use the firm's own. Skipped // Point the firm's tab at a page. Omit `url` to use the firm's own. Skipped
// when the tab is already there, so it doesn't reload and lose page state. // when the tab is already there, so it doesn't reload and lose page state.
| { action: 'navigate'; url?: string; label?: string } | { action: 'navigate'; url?: string; label?: string }
// Block until `selector` exists, then carry on. Nothing is clicked or typed — // Block until `selector` exists — or, with `absent`, until it is gone from the
// this is a gate, for conditions something outside the run has to satisfy. // DOM. Nothing is clicked or typed; this is a gate, for conditions something
| { action: 'waitFor'; selector: string; index?: number; timeoutSeconds?: number; label?: string } // outside the run has to satisfy. Note `absent` means removed, not merely
// hidden: an element still in the DOM with display:none keeps matching, so
// for those use a selector that only matches while it is visible.
| { action: 'waitFor'; selector: string; index?: number; absent?: boolean; timeoutSeconds?: number; label?: string }
// Scroll until the page stops adding elements matching `selector`, for lists
// that load progressively. `containerSelector` names the scrolling element
// when the automatic guess is wrong — these lists usually scroll inside a div
// rather than the window, and scrolling the document does nothing.
| {
action: 'scrollToLoad';
selector: string;
containerSelector?: string;
maxScrolls?: number;
settleMs?: number;
label?: string;
}
// Run `steps` several times over. `times` fixes the count here; `timesFrom` // Run `steps` several times over. `times` fixes the count here; `timesFrom`
// takes it from an input the user fills in on the dashboard. The block is // takes it from an input the user fills in on the dashboard. The block is
// unrolled before the runner ever sees it — see resolveSteps. // unrolled before the runner ever sees it — see resolveSteps.
@@ -127,7 +142,7 @@ export const FIRMS: Firm[] = [
// every site build, so they are not safe to select on. // every site build, so they are not safe to select on.
{ action: 'click', selector: 'a.add_account_btn', label: 'Open Add Account' }, { action: 'click', selector: 'a.add_account_btn', label: 'Open Add Account' },
{ action: 'wait', seconds: 2, label: 'Wait for the form' }, { action: 'waitFor', selector: 'div.account_types:nth-child(3) > div[role="radiogroup"] > div > div:nth-child(2)', timeoutSeconds: 30, label: 'Wait for form' },
{ action: 'click', selector: 'div.account_types:nth-child(3) > div[role="radiogroup"] > div > div:nth-child(2)'}, { action: 'click', selector: 'div.account_types:nth-child(3) > div[role="radiogroup"] > div > div:nth-child(2)'},
{ action: 'click', selector: 'div.account_types:nth-child(7) span:last-child'}, { action: 'click', selector: 'div.account_types:nth-child(7) span:last-child'},
{ action: 'click', selector: 'div.summary_section div.MuiTextField-root input'}, { action: 'click', selector: 'div.summary_section div.MuiTextField-root input'},
@@ -156,11 +171,13 @@ export const FIRMS: Firm[] = [
{ action: 'repeat', timesFrom: 'count', steps: [ { action: 'repeat', timesFrom: 'count', steps: [
// one purchase — the steps you already have // one purchase — the steps you already have
{ action: 'click', selector: 'button.reset_btn', label: 'Reset Account' }, { action: 'click', selector: '.status-failed button.reset_btn', label: 'Reset Account' },
{ action: 'waitFor', selector: "div.captcha-solver[data-state='ready']", timeoutSeconds: 30, label: 'Wait for the solver' }, { action: 'waitFor', selector: "div.captcha-solver[data-state='ready']", timeoutSeconds: 30, label: 'Wait for the solver' },
{ action: 'click', selector: 'div.captcha-solver[data-state="ready"]'}, { action: 'click', selector: 'div.captcha-solver[data-state="ready"]'},
{ action: 'waitFor', selector: "div.captcha-solver[data-state='solved']", timeoutSeconds: 120, label: 'Wait for the challenge' }, { action: 'waitFor', selector: "div.captcha-solver[data-state='solved']", timeoutSeconds: 120, label: 'Wait for the challenge' },
{ action: 'click', selector: 'button.cancelBtn + button.modalActionBtn' } { action: 'click', selector: 'button.cancelBtn + button.modalActionBtn' },
{ action: 'waitFor', selector: '.reset_acc_modal', absent: true, timeoutSeconds: 30, label: 'Wait for the modal to close' },
]}, ]},
] ]
} }
@@ -265,6 +282,10 @@ function resolveOne(firm: Firm, step: AutomationStep): ResolvedStep {
if (step.action === 'waitFor') { if (step.action === 'waitFor') {
return { ...step, urlPattern: firm.urlPattern, openUrl: firm.url }; return { ...step, urlPattern: firm.urlPattern, openUrl: firm.url };
} }
if (step.action === 'scrollToLoad') {
// Acts on nothing, so no signed-out guard — same treatment as waitFor.
return { ...step, urlPattern: firm.urlPattern, openUrl: firm.url };
}
if (step.action === 'repeat') { if (step.action === 'repeat') {
// expand() peels these off first; reaching here means a caller bypassed it. // expand() peels these off first; reaching here means a caller bypassed it.
throw new Error('repeat steps must be expanded, not resolved directly'); throw new Error('repeat steps must be expanded, not resolved directly');
@@ -341,7 +362,11 @@ export function describeStep(step: AutomationStep): string {
case 'type': return `type into ${step.selector}`; case 'type': return `type into ${step.selector}`;
case 'wait': return `wait ${step.seconds}s`; case 'wait': return `wait ${step.seconds}s`;
case 'navigate': return `open ${step.url ?? 'the firm page'}`; case 'navigate': return `open ${step.url ?? 'the firm page'}`;
case 'waitFor': return `wait for ${step.selector}`; case 'waitFor':
return step.absent
? `wait for ${step.selector} to disappear`
: `wait for ${step.selector}`;
case 'scrollToLoad': return `scroll to load all ${step.selector}`;
case 'repeat': { case 'repeat': {
const inner = step.steps.length; const inner = step.steps.length;
const count = step.timesFrom ? `{${step.timesFrom}}` : `${step.times ?? 1}`; const count = step.timesFrom ? `{${step.timesFrom}}` : `${step.times ?? 1}`;
+5 -1
View File
@@ -2,6 +2,7 @@ import { TradovateClient } from './tradovate-class';
import { getFirms, getInstruments } from './db'; import { getFirms, getInstruments } from './db';
import { resolveContracts } from './contract-resolver'; import { resolveContracts } from './contract-resolver';
import { startReporter } from './reporter'; import { startReporter } from './reporter';
import { resumeSchedulerIfPersisted } from './auto-trade';
// Use global to persist the client pool across HMR reloads in dev mode // Use global to persist the client pool across HMR reloads in dev mode
const g = global as typeof globalThis & { const g = global as typeof globalThis & {
@@ -19,7 +20,7 @@ function ensureMap(): Map<number, TradovateClient> {
export function initClient(id: number, username: string, password: string, firmName: string): TradovateClient { export function initClient(id: number, username: string, password: string, firmName: string): TradovateClient {
const map = ensureMap(); const map = ensureMap();
const client = new TradovateClient(username, password, () => { const client = new TradovateClient(username, password, async () => {
console.log(`[${firmName}] sync complete — ${client.accountList.length} account(s)`); console.log(`[${firmName}] sync complete — ${client.accountList.length} account(s)`);
}); });
map.set(id, client); map.set(id, client);
@@ -63,6 +64,9 @@ export function getClients(): Map<number, TradovateClient> {
// Start master dashboard reporter // Start master dashboard reporter
startReporter(); startReporter();
// Pick the auto-trade schedule back up if it was running before restart
resumeSchedulerIfPersisted();
} catch (err) { } catch (err) {
console.error('[clients] Failed to initialize clients', err); console.error('[clients] Failed to initialize clients', err);
} }
+25 -7
View File
@@ -182,9 +182,14 @@ db.exec(`
const seedSetting = db.prepare(`INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)`); const seedSetting = db.prepare(`INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)`);
seedSetting.run('max_concurrent_accounts', '5'); seedSetting.run('max_concurrent_accounts', '5');
seedSetting.run('tick_interval_seconds', '60'); seedSetting.run('tick_interval_seconds', '60');
seedSetting.run('master_dashboard_url', ''); seedSetting.run('master_dashboard_url', 'https://master.juicerroom.com');
seedSetting.run('instance_name', ''); seedSetting.run('instance_name', '');
seedSetting.run('trading_hours', 'full_cme'); seedSetting.run('trading_hours', 'full_cme');
// Mirrors the in-memory scheduler state so a restart can resume it.
seedSetting.run('scheduler_running', '0');
seedSetting.run('scheduler_action', 'Buy');
seedSetting.run('scheduler_symbol', 'NQ');
seedSetting.run('scheduler_stop_after_all', '0');
export function getSetting(key: string): string | null { export function getSetting(key: string): string | null {
const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key) as { value: string } | undefined; const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key) as { value: string } | undefined;
@@ -199,11 +204,15 @@ export function setSetting(key: string, value: string): void {
const SYMBOLS = ['NQ','MNQ','ES','MES','YM','MYM','RTY','M2K','GC','MGC','SI','CL','MCL','NG','ZB','ZN','ZF','6E','6J','6B']; const SYMBOLS = ['NQ','MNQ','ES','MES','YM','MYM','RTY','M2K','GC','MGC','SI','CL','MCL','NG','ZB','ZN','ZF','6E','6J','6B'];
// Enabled on a fresh install. The rest still seed, so they can be switched on
// from the Instruments page, they just start off.
const DEFAULT_ENABLED = new Set(['NQ', 'GC', 'CL']);
// Seed instruments table if empty // Seed instruments table if empty
const instrCount = (db.prepare('SELECT COUNT(*) as count FROM instruments').get() as { count: number }).count; const instrCount = (db.prepare('SELECT COUNT(*) as count FROM instruments').get() as { count: number }).count;
if (instrCount === 0) { if (instrCount === 0) {
const ins = db.prepare('INSERT INTO instruments (symbol, enabled) VALUES (?, 1)'); const ins = db.prepare('INSERT INTO instruments (symbol, enabled) VALUES (?, ?)');
for (const s of SYMBOLS) ins.run(s); for (const s of SYMBOLS) ins.run(s, DEFAULT_ENABLED.has(s) ? 1 : 0);
} }
export interface InstrumentRow { export interface InstrumentRow {
@@ -394,6 +403,7 @@ export interface LocateRow {
url_pattern: string; url_pattern: string;
open_url: string; open_url: string;
navigate_url: string; navigate_url: string;
options: string; // JSON, per-request extras (scroll parameters, ...)
status: 'pending' | 'claimed' | 'done' | 'error'; status: 'pending' | 'claimed' | 'done' | 'error';
result: string | null; result: string | null;
error: string | null; error: string | null;
@@ -403,10 +413,10 @@ export interface LocateRow {
const LOCATE_HISTORY = 20; const LOCATE_HISTORY = 20;
export function createLocateRequest(selector: string, matchIndex: number, urlPattern: string, openUrl = '', navigateUrl = ''): LocateRow { export function createLocateRequest(selector: string, matchIndex: number, urlPattern: string, openUrl = '', navigateUrl = '', options = '{}'): LocateRow {
const res = db.prepare( const res = db.prepare(
'INSERT INTO autobuyer_locate (selector, match_index, url_pattern, open_url, navigate_url, created_at) VALUES (?, ?, ?, ?, ?, ?)' 'INSERT INTO autobuyer_locate (selector, match_index, url_pattern, open_url, navigate_url, options, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)'
).run(selector, matchIndex, urlPattern, openUrl, navigateUrl, Date.now()); ).run(selector, matchIndex, urlPattern, openUrl, navigateUrl, options, Date.now());
db.prepare(` db.prepare(`
DELETE FROM autobuyer_locate DELETE FROM autobuyer_locate
@@ -459,6 +469,14 @@ db.exec(`
); );
`); `);
// Migration: free-form per-request options, so a new kind of request doesn't
// need a new column each time.
try {
db.exec("ALTER TABLE autobuyer_locate ADD COLUMN options TEXT NOT NULL DEFAULT '{}'");
} catch {
// Column already exists
}
// Migration: the page to send the tab to before locating. // Migration: the page to send the tab to before locating.
try { try {
db.exec("ALTER TABLE autobuyer_locate ADD COLUMN navigate_url TEXT NOT NULL DEFAULT ''"); db.exec("ALTER TABLE autobuyer_locate ADD COLUMN navigate_url TEXT NOT NULL DEFAULT ''");
@@ -604,7 +622,7 @@ export const RUNNER_TIMEOUT_MS = 7000;
/** The runner version this server's step vocabulary requires. A running process /** The runner version this server's step vocabulary requires. A running process
* doesn't reload when the source changes, so an older one silently fails on * doesn't reload when the source changes, so an older one silently fails on
* steps it predates — the dashboard warns instead. */ * steps it predates — the dashboard warns instead. */
export const RUNNER_EXPECTED_VERSION = '0.12.0'; export const RUNNER_EXPECTED_VERSION = '0.16.0';
export interface RunnerHeartbeat { export interface RunnerHeartbeat {
at: number; at: number;
+21 -20
View File
@@ -7,10 +7,27 @@ import { POINT_VALUES } from './trading-logic';
import { getCachedContract, resolveContracts } from './contract-resolver'; import { getCachedContract, resolveContracts } from './contract-resolver';
import { saveDailyPnL, loadDailyPnL, saveAccountMeta, loadAccountMeta, saveFundTransactions, loadFundTransactions } from './db'; import { saveDailyPnL, loadDailyPnL, saveAccountMeta, loadAccountMeta, saveFundTransactions, loadFundTransactions } from './db';
/**
* Entity types Tradovate sends over the user-sync socket. Keep this as the
* single source of truth: the inline socket-payload type and the indirect
* callback list previously declared it separately and fell out of sync, which
* made the 'position' and 'cashBalance' handlers unreachable to the compiler.
*/
type TradovateEntityType =
| 'order'
| 'orderVersion'
| 'auditUserAction'
| 'command'
| 'commandReport'
| 'fill'
| 'executionReport'
| 'position'
| 'cashBalance';
export class TradovateClient { export class TradovateClient {
private name: string; private name: string;
private password: string; private password: string;
private accessInfo: AuthLoginResponse; private accessInfo!: AuthLoginResponse;
private deviceId = randomUUIDV4(); private deviceId = randomUUIDV4();
public accountList: AccountItem[] = []; public accountList: AccountItem[] = [];
@@ -70,7 +87,7 @@ export class TradovateClient {
public syncComplete = false; public syncComplete = false;
private ws: WebSocket; private ws!: WebSocket;
private callbackOnSyncRequest: () => Promise<void>; private callbackOnSyncRequest: () => Promise<void>;
/** Incrementing ID for outgoing WebSocket messages — ensures concurrent orders don't clobber each other's callbacks. */ /** Incrementing ID for outgoing WebSocket messages — ensures concurrent orders don't clobber each other's callbacks. */
@@ -82,15 +99,7 @@ export class TradovateClient {
[id: number]: (response: any) => void; [id: number]: (response: any) => void;
} = {}; } = {};
private indirectEventCallbacks: { private indirectEventCallbacks: {
entityType: entityType: TradovateEntityType;
| 'order'
| 'orderVersion'
| 'auditUserAction'
| 'command'
| 'commandReport'
| 'fill'
| 'executionReport'
| 'cashBalance';
eventType: 'Created' | 'Updated'; eventType: 'Created' | 'Updated';
// Since the entity is not always the same, we need a validator to check if the response is the one we are looking for // Since the entity is not always the same, we need a validator to check if the response is the one we are looking for
validator: (response: any) => boolean; validator: (response: any) => boolean;
@@ -167,12 +176,7 @@ export class TradovateClient {
| { | {
e?: string; e?: string;
d?: { d?: {
entityType: entityType: TradovateEntityType;
| 'order'
| 'orderVersion'
| 'auditUserAction'
| 'command'
| 'commandReport';
eventType: 'Created' | 'Updated'; eventType: 'Created' | 'Updated';
entity: any; entity: any;
}; };
@@ -279,9 +283,6 @@ export class TradovateClient {
const isSentinel = limit >= 999999999; const isSentinel = limit >= 999999999;
const floor = (!isSentinel && limit > 0 && drawdown > 0) ? limit - drawdown : 0; const floor = (!isSentinel && limit > 0 && drawdown > 0) ? limit - drawdown : 0;
this.autoLiqThresholds[accountId] = floor; this.autoLiqThresholds[accountId] = floor;
if (floor > 0) {
console.log(`[autoLiq] account ${accountId} → floor $${floor} (hwm=$${limit} drawdown=$${drawdown})`);
}
} }
this.accountList = ((response.accounts ?? []) as AccountItem[]).map((account) => { this.accountList = ((response.accounts ?? []) as AccountItem[]).map((account) => {
+2 -62
View File
@@ -11,7 +11,6 @@
"axios": "^1.13.6", "axios": "^1.13.6",
"better-sqlite3": "^12.6.2", "better-sqlite3": "^12.6.2",
"next": "16.1.6", "next": "16.1.6",
"playwright": "^1.58.2",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3", "react-dom": "19.2.3",
"recharts": "^3.8.0" "recharts": "^3.8.0"
@@ -72,7 +71,6 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.29.0", "@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0", "@babel/generator": "^7.29.0",
@@ -1688,7 +1686,6 @@
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"csstype": "^3.2.2" "csstype": "^3.2.2"
} }
@@ -1754,7 +1751,6 @@
"integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/scope-manager": "8.56.1",
"@typescript-eslint/types": "8.56.1", "@typescript-eslint/types": "8.56.1",
@@ -2280,7 +2276,6 @@
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@@ -2695,7 +2690,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.9.0", "baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759", "caniuse-lite": "^1.0.30001759",
@@ -3486,7 +3480,6 @@
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1", "@eslint-community/regexpp": "^4.12.1",
@@ -3672,7 +3665,6 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@rtsao/scc": "^1.1.0", "@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9", "array-includes": "^3.1.9",
@@ -4116,20 +4108,6 @@
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/function-bind": { "node_modules/function-bind": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@@ -5976,36 +5954,6 @@
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/playwright": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.58.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/possible-typed-array-names": { "node_modules/possible-typed-array-names": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
@@ -6170,7 +6118,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@@ -6180,7 +6127,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"scheduler": "^0.27.0" "scheduler": "^0.27.0"
}, },
@@ -6192,15 +6138,13 @@
"version": "16.13.1", "version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/react-redux": { "node_modules/react-redux": {
"version": "9.2.0", "version": "9.2.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/use-sync-external-store": "^0.0.6", "@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0" "use-sync-external-store": "^1.4.0"
@@ -6267,8 +6211,7 @@
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/redux-thunk": { "node_modules/redux-thunk": {
"version": "3.1.0", "version": "3.1.0",
@@ -7067,7 +7010,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -7242,7 +7184,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@@ -7561,7 +7502,6 @@
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"
} }
-1
View File
@@ -12,7 +12,6 @@
"axios": "^1.13.6", "axios": "^1.13.6",
"better-sqlite3": "^12.6.2", "better-sqlite3": "^12.6.2",
"next": "16.1.6", "next": "16.1.6",
"playwright": "^1.58.2",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3", "react-dom": "19.2.3",
"recharts": "^3.8.0" "recharts": "^3.8.0"
+99
View File
@@ -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);
}
+183
View File
@@ -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"
+113
View File
@@ -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"
+31
View 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();
+31
View 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();
+5
View File
@@ -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
+74
View File
@@ -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');
+6
View File
@@ -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
+199
View File
@@ -0,0 +1,199 @@
/**
* Pull master, rebuild, restart once per invocation.
*
* A scheduled task fires this every few minutes. Deliberately not a long-lived
* loop: if a run dies, the next fire is a clean slate.
*
* The important guarantee is that a broken push cannot take a trading PC down.
* The build runs before anything is restarted, and a failed build stops the run
* with the previous build still serving.
*
* node scripts/update-check.mjs # normal
* node scripts/update-check.mjs --dry-run # report only, change nothing
*/
import { spawnSync } from 'node:child_process';
import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { ensurePythonDeps } from './ensure-python-deps.mjs';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const LOG_FILE = path.join(ROOT, 'scripts', 'update.log');
const LOCK_FILE = path.join(ROOT, 'scripts', '.update.lock');
const LAST_CHECK_FILE = path.join(ROOT, 'scripts', '.last-check');
const BRANCH = process.env.AUTOFIRMER_BRANCH ?? 'master';
const PORT = process.env.AUTOFIRMER_PORT ?? '3000';
const BASE = `http://127.0.0.1:${PORT}`;
const DRY_RUN = process.argv.includes('--dry-run');
const STALE_LOCK_MS = 60 * 60 * 1000;
const LOG_MAX_BYTES = 256 * 1024;
const LOG_KEEP_LINES = 500;
/**
* Keep update.log from growing without bound.
*
* Trimmed once per run, before anything is written, rather than on every line:
* the no-change path writes nothing at all, so this is the only moment the file
* can have grown since last time. Housekeeping must never break an update, so
* any failure here is swallowed.
*/
function trimLog() {
try {
if (!existsSync(LOG_FILE) || statSync(LOG_FILE).size <= LOG_MAX_BYTES) return;
const kept = readFileSync(LOG_FILE, 'utf8').split('\n').filter(Boolean).slice(-LOG_KEEP_LINES);
writeFileSync(LOG_FILE, kept.join('\n') + '\n');
} catch { /* ignore */ }
}
function log(msg) {
const line = `${new Date().toISOString()} ${msg}`;
console.log(line);
try { appendFileSync(LOG_FILE, line + '\n'); } catch { /* logging must never throw */ }
}
/**
* Record that a check happened, whether or not it found anything.
*
* Overwritten rather than appended, so it stays one line and needs no trimming.
* Without it there is no way to tell "running, nothing to pull" from "not
* running at all": the no-change path logs nothing by design, and PM2 reports a
* cron-restart process as `stopped` with a restart count of 0 even while it is
* firing on schedule.
*/
function recordCheck(status) {
try {
writeFileSync(LAST_CHECK_FILE, `${new Date().toISOString()} ${status}\n`);
} catch { /* never let bookkeeping break an update */ }
}
function run(cmd, args, { capture = false } = {}) {
return spawnSync(cmd, args, {
cwd: ROOT,
encoding: 'utf8',
stdio: capture ? 'pipe' : 'inherit',
shell: process.platform === 'win32',
});
}
function git(...args) {
const r = run('git', args, { capture: true });
if (r.status !== 0) throw new Error(`git ${args.join(' ')} failed: ${(r.stderr ?? '').trim()}`);
return (r.stdout ?? '').trim();
}
// ── lock ────────────────────────────────────────────────────────────────────
// A build outlasts the schedule interval, so overlapping runs are otherwise a
// certainty rather than a risk.
function acquireLock() {
if (existsSync(LOCK_FILE)) {
const age = Date.now() - Number(readFileSync(LOCK_FILE, 'utf8').trim() || 0);
if (age < STALE_LOCK_MS) return false;
log(`clearing a stale lock (${Math.round(age / 60000)} min old)`);
}
writeFileSync(LOCK_FILE, String(Date.now()));
return true;
}
const releaseLock = () => { try { rmSync(LOCK_FILE, { force: true }); } catch { /* ignore */ } };
async function main() {
mkdirSync(path.join(ROOT, 'scripts'), { recursive: true });
trimLog();
if (!DRY_RUN && !acquireLock()) {
console.log('another update is already running — exiting');
return 0;
}
let outcome = 'interrupted';
try {
git('fetch', 'origin', BRANCH);
const local = git('rev-parse', 'HEAD');
const remote = git('rev-parse', `origin/${BRANCH}`);
if (local === remote) {
outcome = `up to date at ${local.slice(0, 8)}`;
return 0; // silent in the log by design
}
log(`update available: ${local.slice(0, 8)} -> ${remote.slice(0, 8)}`);
const changed = git('diff', '--name-only', local, remote).split('\n').filter(Boolean);
log(`${changed.length} file(s) changed`);
if (DRY_RUN) {
log('dry run — stopping before any change');
log(`would run: ${[
changed.includes('package-lock.json') && 'npm install',
changed.includes('clicker/requirements.txt') && 'pip install',
'npm run build',
'pm2 restart autofirmer',
changed.some((f) => f.startsWith('clicker/')) && 'pm2 restart clicker',
].filter(Boolean).join(', ')}`);
return 0;
}
git('pull', '--ff-only', 'origin', BRANCH);
if (changed.includes('package-lock.json')) {
log('package-lock.json changed — npm install');
if (run('npm', ['install']).status !== 0) { log('ABORTED: npm install failed'); outcome = 'npm install failed'; return 1; }
}
if (changed.includes('clicker/requirements.txt')) {
log('clicker/requirements.txt changed — refreshing python deps');
ensurePythonDeps({ log: (m) => log(m) }); // non-fatal by design
}
// Build BEFORE restarting. A failed build leaves the running process
// untouched, which is the whole point of doing it in this order.
log('building');
if (run('npm', ['run', 'build']).status !== 0) {
log('ABORTED: build failed — the previous build is still serving, nothing was restarted');
outcome = 'build failed — not deployed';
return 1;
}
log('restarting autofirmer');
if (run('pm2', ['restart', 'autofirmer']).status !== 0) { log('pm2 restart autofirmer failed'); outcome = 'pm2 restart failed'; return 1; }
if (changed.some((f) => f.startsWith('clicker/'))) {
log('clicker changed — restarting it too');
run('pm2', ['restart', 'clicker']);
}
await warmUp();
log(`updated to ${remote.slice(0, 8)}`);
outcome = `updated to ${remote.slice(0, 8)}`;
return 0;
} catch (err) {
log(`ERROR: ${err.message}`);
outcome = `ERROR: ${err.message}`;
return 1;
} finally {
if (!DRY_RUN) {
releaseLock();
recordCheck(outcome);
}
}
}
async function warmUp() {
const deadline = Date.now() + 120_000;
while (Date.now() < deadline) {
try {
if ((await fetch(`${BASE}/api/auto-trade`, { signal: AbortSignal.timeout(5_000) })).ok) break;
} catch { /* still restarting */ }
await new Promise((r) => setTimeout(r, 2_000));
}
try {
await fetch(`${BASE}/api/state`, { signal: AbortSignal.timeout(60_000) });
const status = await (await fetch(`${BASE}/api/auto-trade`)).json();
log(status.running ? `scheduler resumed — ${status.action} ${status.symbol}` : 'scheduler is stopped');
const runner = await (await fetch(`${BASE}/api/autobuyer/runner`)).json();
log(`runner ${runner.online ? 'online' : 'OFFLINE'}${runner.stale ? ' (version stale — reload the extension)' : ''}`);
} catch (err) {
log(`post-restart check failed: ${err.message}`);
}
}
process.exit(await main());
Executable
+170
View File
@@ -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
+378
View File
@@ -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
+581 -990
View File
File diff suppressed because it is too large Load Diff