Compare commits

..
19 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
28 changed files with 2200 additions and 1176 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
+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 -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.
+17
View File
@@ -31,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
@@ -176,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; "
+18
View File
@@ -12,9 +12,13 @@ OS after each move, so the answer doesn't depend on anything being rendered.
Nothing is clicked. The cursor is moved and put back where it started. Nothing is clicked. The cursor is moved and put back where it started.
""" """
import os
import shutil
import sys import sys
import time import time
from clicker import force_utf8_output
def line(label: str, value: str) -> None: def line(label: str, value: str) -> None:
print(f" {label:<22} {value}") print(f" {label:<22} {value}")
@@ -25,6 +29,19 @@ def check_platform() -> None:
line("os", sys.platform) line("os", sys.platform)
line("python", sys.version.split()[0]) 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": if sys.platform != "win32":
return return
@@ -125,6 +142,7 @@ def check_foreground() -> None:
def main() -> int: def main() -> int:
force_utf8_output()
print("AutoFirmer clicker diagnostics") print("AutoFirmer clicker diagnostics")
check_platform() check_platform()
check_dpi() check_dpi()
+84 -3
View File
@@ -12,6 +12,8 @@ which it is, because that's where this script was launched — Chrome as a whole
still in the background. This module raises the application itself. 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
@@ -26,35 +28,48 @@ BROWSERS = {
"com.google.Chrome.dev", "com.google.Chrome.canary", "com.google.Chrome.dev", "com.google.Chrome.canary",
"org.chromium.Chromium"), "org.chromium.Chromium"),
"win32": ("chrome.exe",), "win32": ("chrome.exe",),
"linux": ("google-chrome", "chromium", "chromium-browser", "google-chrome-stable"),
"titles": ("Chrome", "Chromium"), "titles": ("Chrome", "Chromium"),
}, },
"edge": { "edge": {
"darwin": ("com.microsoft.edgemac",), "darwin": ("com.microsoft.edgemac",),
"win32": ("msedge.exe",), "win32": ("msedge.exe",),
"linux": ("microsoft-edge", "msedge"),
"titles": ("Edge",), "titles": ("Edge",),
}, },
"brave": { "brave": {
"darwin": ("com.brave.Browser",), "darwin": ("com.brave.Browser",),
"win32": ("brave.exe",), "win32": ("brave.exe",),
"linux": ("brave-browser", "brave"),
"titles": ("Brave",), "titles": ("Brave",),
}, },
"opera": { "opera": {
"darwin": ("com.operasoftware.Opera",), "darwin": ("com.operasoftware.Opera",),
"win32": ("opera.exe", "launcher.exe"), "win32": ("opera.exe", "launcher.exe"),
"linux": ("opera",),
"titles": ("Opera",), "titles": ("Opera",),
}, },
"vivaldi": { "vivaldi": {
"darwin": ("com.vivaldi.Vivaldi",), "darwin": ("com.vivaldi.Vivaldi",),
"win32": ("vivaldi.exe",), "win32": ("vivaldi.exe",),
"linux": ("vivaldi-stable", "vivaldi"),
"titles": ("Vivaldi",), "titles": ("Vivaldi",),
}, },
} }
def _platform_key() -> str:
if sys.platform == "darwin":
return "darwin"
if sys.platform.startswith("linux"):
return "linux"
return "win32"
def _ids_for(browser: str | None) -> tuple[str, ...]: def _ids_for(browser: str | None) -> tuple[str, ...]:
"""Identifiers to accept on this platform. Without a named browser, every """Identifiers to accept on this platform. Without a named browser, every
known one — the old behaviour, and still right on a single-browser box.""" known one — the old behaviour, and still right on a single-browser box."""
key = "darwin" if sys.platform == "darwin" else "win32" key = _platform_key()
if browser and browser in BROWSERS: if browser and browser in BROWSERS:
return BROWSERS[browser][key] return BROWSERS[browser][key]
return tuple(i for b in BROWSERS.values() for i in b[key]) return tuple(i for b in BROWSERS.values() for i in b[key])
@@ -142,9 +157,67 @@ def _mac_workspace():
return NSWorkspace.sharedWorkspace() return NSWorkspace.sharedWorkspace()
def _linux_session_problem() -> str | None:
"""Why the clicker cannot drive this desktop, or None if it can.
Raspberry Pi OS on a Pi 5 defaults to Wayland (labwc). Neither xdotool nor
pyautogui works there: both speak X11 protocol, and Wayland deliberately
refuses to let one client synthesise input into another or read the focused
window. There is no workaround short of switching the session to X11, so say
so plainly rather than failing every step with something cryptic.
"""
if not os.environ.get("DISPLAY"):
if os.environ.get("WAYLAND_DISPLAY"):
return ("this is a Wayland session and the clicker needs X11 — on Raspberry Pi OS: "
"sudo raspi-config -> Advanced Options -> Wayland -> X11, then reboot")
return "no DISPLAY is set — the clicker needs a graphical session"
if shutil.which("xdotool") is None:
return "xdotool is not installed — run: sudo apt install -y xdotool"
return None
def _xdotool(*args: str) -> subprocess.CompletedProcess:
return subprocess.run(["xdotool", *args], capture_output=True, text=True, timeout=5)
def _linux_frontmost() -> str | None:
"""WM_CLASS of the focused window, lowercased to match the ids above."""
try:
result = _xdotool("getactivewindow", "getwindowclassname")
except Exception:
return None
name = (result.stdout or "").strip().lower()
return name or None
def _linux_activate(browser: str | None = None) -> FocusResult:
problem = _linux_session_problem()
if problem:
return FocusResult(False, problem)
for cls in _ids_for(browser):
try:
found = _xdotool("search", "--onlyvisible", "--class", cls)
except Exception as exc:
return FocusResult(False, f"xdotool failed ({exc})")
ids = (found.stdout or "").split()
if not ids:
continue
# Last match is the most recently mapped window — the one a person would
# mean by "the browser" when several are open.
activated = _xdotool("windowactivate", "--sync", ids[-1])
if activated.returncode == 0:
return FocusResult(True, f"activated {cls} (window {ids[-1]})")
return FocusResult(False, f"could not activate {cls}: {(activated.stderr or '').strip()}")
return FocusResult(False, f"no {browser or 'browser'} window found")
def browser_ids(browser: str | None = None) -> tuple[str, ...]: def browser_ids(browser: str | None = None) -> tuple[str, ...]:
"""What counts as "the browser" on this platform, optionally narrowed to one.""" """What counts as "the browser" on this platform, optionally narrowed to one."""
if sys.platform in ("darwin", "win32"): if sys.platform in ("darwin", "win32") or sys.platform.startswith("linux"):
return _ids_for(browser) return _ids_for(browser)
return () return ()
@@ -172,6 +245,9 @@ def frontmost() -> str | None:
except Exception: except Exception:
return None return None
if sys.platform.startswith("linux"):
return _linux_frontmost()
return None return None
@@ -257,7 +333,12 @@ def activate_browser(browser: str | None = None) -> FocusResult:
it, any known browser will do — fine on a machine with one installed, wrong it, any known browser will do — fine on a machine with one installed, wrong
on a machine with two. on a machine with two.
""" """
result = _mac_activate(browser) if sys.platform == "darwin" else _other_activate(browser) if sys.platform == "darwin":
result = _mac_activate(browser)
elif sys.platform.startswith("linux"):
result = _linux_activate(browser)
else:
result = _other_activate(browser)
if result.ok: if result.ok:
time.sleep(SETTLE) time.sleep(SETTLE)
return result return result
+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"
+2 -1
View File
@@ -25,7 +25,7 @@ import time
import actions import actions
import focus import focus
from clicker import Dashboard, DashboardError, NotFoundError 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
@@ -362,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")
+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 {
+1 -1
View File
@@ -142,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'},
+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);
} }
+12 -3
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 {
+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