Rebrand NetAnalyzer -> StoreHealthAnalyzer and consolidate the store
reporting surface into a single `st [number]` command with focused
sub-modes.
Commands
- st [number] - general info (SIW + brands + Meraki net link)
- st [number] network - switches, APs, store server
- st [number] pos - registers, payment terminals, customer display
- st [number] ios - MDM-tracked iOS hardware
- st [number] phone - wired 78xx + DECT basestations/handsets with
registration state, extensions and main DID
- st [number] av - Atlas AMPs + MDM-tracked Apple TVs, video
walls, music players, LED displays
- Removed `analyze` in favor of the unified `st` surface
Integrations
- integrations/webex: Service App OAuth with rotating refresh tokens,
seed + cleanup scripts, tokens/ storage (git-ignored)
- integrations/atlas: Xyte client + cached device discovery keyed on
zero-padded 6-digit store numbers, cold-cache failure -> unavailable
banner instead of a misleading empty result
- services/webexPhone, services/webexService, services/avService: shape
raw upstream data into the report layer's contract
- utils/merakiMatcher: FQDN hostname extraction so payment terminals
match Meraki descriptions; case-insensitive lookup
- utils/chunkReport: split long markdown replies at 7000-char boundaries
Reliability / ops
- server.js: awaited framework.stop() + 8s hard-kill timer so nodemon /
Docker restarts don't leak WDM device registrations ("excessive device
registrations")
- nodemon.json: SIGINT so the graceful path always runs
- scripts/cleanupWebexDevices.js: one-shot WDM cleanup utility
- Group-space routing: hears() regexes tolerate the leading @BotName
prefix Webex prepends to mentions
- Replaced HTML-unsafe <number> placeholders with [number] in all help
strings
Remote agent containerization
- docker/remote-agent/: multi-stage node:22-alpine image, non-root user,
tini for signal handling, minimal deps (ws/axios/dotenv)
- docker/remote-agent/package.sh: docker buildx build defaulting to
linux/amd64 (with override), saves image + assembles deploy/ + writes
SHA256 + zips for offline transfer
- docker/remote-agent/deploy/: runtime docker-compose.yml, install.sh
with platform sanity check, remote-host README
- .dockerignore + .gitignore updates for build artifacts and dist bundles
- npm run agent:package convenience script
Cleanup
- Dropped storeHealth.js / HealthReport.js and their tests/mocks in favor
of the shared storeDetail pipeline
- Store model handles null SIW records gracefully; toSummary always
ends with a newline so the Meraki link sits on its own line
Tests
- 144 tests across 14 suites passing; new coverage for atlasClient,
atlasDevices, avService, avCategory classification, webexPhone,
webexServiceAppAuth, storeDetail integration, siw, chunkReport and
the updated meraki matcher
Co-authored-by: Cursor <cursoragent@cursor.com>
7.4 KiB
StoreHealthAnalyzer Remote Agent — Docker
Standalone container for the remote agent that proxies SIW / MDM requests from an internal network back to the main StoreHealthAnalyzer server over an authenticated WebSocket.
What ships in the image
node:22-alpineruntime withtinias PID 1 sodocker stopreaches Node's SIGTERM handler and the websocket closes cleanly.- Just the agent script (
remoteAgent.js) and its three runtime deps (ws,axios,dotenv). No bot framework, no Express, no test tooling. - Runs as the unprivileged
nodeuser.
Final image size is small (roughly 60–80 MB depending on architecture),
compared to ~180 MB if the root package.json were installed.
Files in this folder
| File | Purpose |
|---|---|
Dockerfile |
Two-stage build (deps → runtime). Uses the repo root as the build context so it can pull in remoteAgent.js. |
package.json |
Minimal manifest: ws, axios, dotenv. |
docker-compose.yml |
Convenience wrapper for local builds; run from the repo root. |
.env.example |
Copy to .env, fill in WS_URL + WS_TOKEN. |
package.sh |
Builds the image and produces a self-contained deploy ZIP under dist/. |
deploy/ |
Files that get bundled into the deploy ZIP (runtime compose, install.sh, remote README). |
dist/ |
Generated ZIPs (gitignored). |
Prerequisites
- Docker 24+ (BuildKit is default and required for the
syntax=line). - The main StoreHealthAnalyzer server reachable from the host that will run this container (outbound only — the agent doesn't listen on any port).
- A shared
WS_TOKENvalue matching the one configured on the server.
Build
Always build from the repository root — the Dockerfile expects that
context so it can copy remoteAgent.js:
# From the repo root
docker build \
-f docker/remote-agent/Dockerfile \
-t sha-remote-agent:latest \
.
Tag with a version too if you plan to ship it to a registry:
docker tag sha-remote-agent:latest ghcr.io/<owner>/sha-remote-agent:1.0.0
docker push ghcr.io/<owner>/sha-remote-agent:1.0.0
Configure
cp docker/remote-agent/.env.example docker/remote-agent/.env
$EDITOR docker/remote-agent/.env
Required values:
WS_URL— websocket URL of the main server (e.g.wss://sha.example.com/ws).WS_TOKEN— shared secret matching the server'sWS_TOKEN.
Both .env and .env.* are excluded by the top-level .dockerignore, so
the file is never baked into the image.
Run
Docker CLI
docker run --rm -it \
--name sha-remote-agent \
--env-file docker/remote-agent/.env \
sha-remote-agent:latest
Add -d for detached mode and --restart unless-stopped if you want it to
auto-recover on host reboots.
Docker Compose (recommended)
# From the repo root
docker compose -f docker/remote-agent/docker-compose.yml up -d --build
# Tail logs
docker compose -f docker/remote-agent/docker-compose.yml logs -f
# Stop
docker compose -f docker/remote-agent/docker-compose.yml down
Compose sets restart: unless-stopped and 10 MB / 3-file JSON log rotation
so the container survives host restarts and doesn't fill the disk with
reconnect chatter.
Deploy elsewhere (ZIP bundle — recommended)
For hosts you can't reach with a registry, use the packaging script — it produces a single ZIP with the image, a runtime compose file, an installer, and a checksum:
# From the repo root — defaults to building for linux/amd64
npm run agent:package
# or, equivalently:
./docker/remote-agent/package.sh
Output lands in docker/remote-agent/dist/sha-remote-agent-<version>.zip
(the folder is gitignored). Transfer that one file to the remote host and:
unzip sha-remote-agent-<version>.zip
cd sha-remote-agent-<version>
./install.sh # loads the image, seeds .env, starts the container
Full remote-host instructions ship inside the ZIP as README.md and are
also visible here for reference: deploy/README.md.
The script tags the image both sha-remote-agent:<version> and
sha-remote-agent:latest, so local docker compose still works after
packaging.
Target-platform selection (very important on Apple Silicon)
Docker images are architecture-specific. If you build on an Apple Silicon
Mac with docker build, you get an arm64 image — which will fail to
start on a typical x86_64 Linux server (RHEL, Rocky, CentOS, Ubuntu)
with exec /sbin/tini: exec format error. The packaging script uses
docker buildx build --platform ... to avoid that.
The default target is linux/amd64. Override with --platform when your
remote host is different:
# x86_64 Linux (the default — Linux RH / Rocky / CentOS / Ubuntu on Intel/AMD)
./docker/remote-agent/package.sh --platform linux/amd64
# ARM Linux (Raspberry Pi 4/5, Ampere servers, etc.)
./docker/remote-agent/package.sh --platform linux/arm64
# For local testing on Apple Silicon
./docker/remote-agent/package.sh --platform linux/arm64
install.sh on the remote host also detects image_arch != host_arch and
refuses to start with a clear message pointing at the right rebuild command,
so a wrong-arch ZIP fails fast instead of after docker run.
Cross-building requires docker buildx — Docker Desktop ships it by
default; on Linux install the docker-buildx-plugin package if it isn't
already there.
Manual export (without the packaging script)
If you'd rather do it by hand:
# Export from the build host
docker save sha-remote-agent:latest | gzip > sha-remote-agent.tar.gz
# Import on the target host
gunzip -c sha-remote-agent.tar.gz | docker load
# On the target: only .env is needed; no source tree required
docker run --rm -d \
--name sha-remote-agent \
--restart unless-stopped \
--env-file /path/to/remote-agent.env \
sha-remote-agent:latest
Networking
The agent is a websocket client — nothing listens inside the container, so there's no port to publish. You just need outbound network access from the container to:
- The main StoreHealthAnalyzer server (
WS_URL). - Whatever internal APIs the agent proxies for (SIW, MDM, ...).
If the internal APIs live only on the container host's network (e.g. a
private VLAN accessible only from the host), uncomment network_mode: host
in docker-compose.yml (Linux only). On Docker Desktop for macOS/Windows,
prefer running the container on a user-defined bridge network that has route
access to the required endpoints.
Verifying it works
Startup logs from a healthy agent look like:
🔄 Connecting to wss://sha.example.com/ws...
✅ Remote Agent connected to StoreHealthAnalyzer
On the main server side you should see a matching Remote agent connected
log line. From then on, st [number] commands that need SIW data will
succeed instead of degrading to the "Remote agent is not connected" banner.
Signals and shutdown
The agent handles SIGTERM and SIGINT explicitly (see
remoteAgent.js), closing the websocket before exiting. Because we run
tini as PID 1, docker stop (which sends SIGTERM then kills after the
grace period) reaches Node correctly and the exit is clean.
Rebuilding after code changes
Because remoteAgent.js is copied in during the runtime stage, changing
the script requires a rebuild (--build with compose, or a fresh
docker build). The deps stage is cached whenever package.json is
unchanged, so incremental rebuilds are fast.