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>
|
||
|---|---|---|
| bot | ||
| config | ||
| docker/remote-agent | ||
| integrations | ||
| models | ||
| scripts | ||
| services | ||
| tests | ||
| utils | ||
| .dockerignore | ||
| .env.example | ||
| .gitignore | ||
| .prettierignore | ||
| .prettierrc.json | ||
| constants.js | ||
| eslint.config.js | ||
| nodemon.json | ||
| package-lock.json | ||
| package.json | ||
| README.md | ||
| remoteAgent.js | ||
| server.js | ||
StoreHealthAnalyzer
Webex bot that provides store-level network and device health analysis by correlating data from Meraki, SIW (Store Information Warehouse), and Workspace ONE MDM.
What it does
st [number]— Store info pulled from SIW (location, brand, status, environment). No upstream checks.st [number] network— Switch, AP, and store-server health (Meraki + MDM).st [number] pos— POS devices: registers, mobile registers, printers, payment terminals.st [number] ios— iOS devices (mainly iPhones).st [number] phone— wired Cisco 78xx IP phones (Meraki-matched by MAC) and DECT basestations + handsets via Webex Service App.st [number] av— A/V hardware snapshot: Atlas (Xyte) AMPs plus MDM-tracked Apple TVs, video walls (*VW*), music players (*MSC*), and LED displays (*LED*). Each row is MAC-matched into Meraki for the where-it's-connected line.
The bot helps operations teams quickly understand the connectivity and device state of a retail location.
Architecture
┌─────────────────┐ ┌─────────────────────┐
│ Webex Bot │◄────────►│ StoreHealthAnalyzer │
│ (Webex rooms) │ Webex │ (server.js) │
└─────────────────┘ └─────────┬───────────┘
│
│ WebSocket (authenticated)
▼
┌─────────────────────┐
│ Remote Agent │◄──► SIW API (Basic Auth)
│ (remoteAgent.js) │◄──► Workspace ONE MDM
└─────────────────────┘
│
▼
Meraki API (direct from server)
Why the remote agent?
SIW and some MDM systems are only reachable from specific internal networks. The remote agent runs in that environment and proxies requests back to the main StoreHealthAnalyzer server over an authenticated WebSocket.
Prerequisites
- Node.js >= 18
- A Webex bot account with access token
- Meraki API key with organization access
- Access to your organization's SIW API and Workspace ONE (AirWatch) environment
- Ability to run the remote agent on a machine that can reach SIW/MDM
Setup
-
Clone and install
git clone <repo> cd storehealthanalyzer npm install -
Configure environment
cp .env.example .env # Edit .env with your real credentialsRequired values are documented in
.env.example. -
(Optional but recommended) Rotate all secrets If you previously had credentials in the repository, rotate:
- Webex bot token
- Meraki API key
- SIW credentials
- Workspace ONE client secret + tenant code
- WS_TOKEN
Running
Main server + bot (where the Webex connection lives)
npm start
# or for development with auto-reload
npm run dev
This starts:
- The Webex bot framework (listens for messages in Webex spaces)
- The WebSocket server on the port defined in
WS_PORT(default 8080)
Remote agent (run on a machine that can reach internal systems)
Two options — pick whichever fits your host.
Option A: run directly with Node
# On the internal machine
node remoteAgent.js
Make sure WS_URL in its environment points to the main server with the correct WS_TOKEN.
Option B: run as a Docker container (build + run on the same host)
Ship the agent as a standalone container instead of installing Node on the host. Full instructions live in docker/remote-agent/README.md; the short version:
# Build (from the repo root)
docker build -f docker/remote-agent/Dockerfile -t sha-remote-agent:latest .
# Configure
cp docker/remote-agent/.env.example docker/remote-agent/.env
# Edit WS_URL + WS_TOKEN
# Run (compose is the easy path)
docker compose -f docker/remote-agent/docker-compose.yml up -d --build
The container is small (~60–80 MB), ships only remoteAgent.js + its three runtime deps (ws, axios, dotenv), and runs as the unprivileged node user. tini is PID 1 so docker stop closes the websocket cleanly.
Option C: package as a portable ZIP for offline / manual transfer
If the remote host can't reach a registry (or you just want a one-file drop), package everything into a self-contained ZIP:
# On the dev machine (from the repo root)
npm run agent:package
# → docker/remote-agent/dist/sha-remote-agent-<version>.zip
Then copy the ZIP 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
The bundle contains the Docker image tarball, a runtime docker-compose.yml, a SHA-256 checksum, and an installer that verifies + loads + starts. Details in docker/remote-agent/deploy/README.md (also included inside the ZIP as its top-level README.md).
Webex Service App (only needed for st [number] phone)
The phone command talks to Webex APIs (people, devices, telephony) as a Service App, separate from the bot identity. Cisco rotates the refresh token on every call, so the only manual step is seeding the initial tokens once.
-
Set
WEBEX_CLIENT_IDandWEBEX_CLIENT_SECRETin.env(from the Service App registration in the Webex Developer Portal). -
Seed the tokens file:
# Default: imports the tokens JSON from collabFinder, verifies it via one immediate refresh npm run webex:seed # Or import a tokens JSON from a specific path npm run webex:seed -- --from-file /path/to/webex-service-tokens.json # Or seed with a raw refresh token string npm run webex:seed -- --refresh-token <refresh-token-value>The seed step performs one
grant_type=refresh_tokencall againsthttps://webexapis.com/v1/access_tokenand writes the rotated pair toWEBEX_TOKENS_PATH(default./tokens/webex-service-tokens.json, gitignored). -
From then on, the auth singleton keeps the token fresh automatically (5-minute safety buffer ahead of the stated expiry). If the refresh token is ever revoked,
st [number] phonedegrades to a single warning banner ("Webex phone data unavailable") instead of erroring — re-seed and try again.
Atlas / Xyte (only needed for st [number] av)
The AV command pulls hardware data from two places:
- Atlas (
hub.xyte.io) — long-lived API key. AddATLAS_AUTH_KEY(and optionallyATLAS_BASE_URL) to.env. The Atlas client maintains a 1-hour in-process cache of the org-wide device list, then filters by zero-padded store number (e.g. store 782 →US000782*) to find that store's AMPs. - MDM (Workspace ONE) — uses the existing WebSocket-proxied MDM connection. The AV view filters MDM devices whose friendly name matches
/(VW|MSC|LED|AppleTV)/i, mirroring the collabFinder strict-AV filter.
If ATLAS_AUTH_KEY is unset (or the Atlas API is unreachable), the AV report still renders the MDM-tracked devices and shows an inline "Atlas AV data unavailable" banner above them. Likewise, an MDM outage doesn't suppress the Atlas section. Both sources empty → _No AV hardware registered for this store._.
Bot Commands
In any Webex space where the bot is a member:
st 782— store info (location, brand, status, environment)st 782 network— switches, APs, store server(s)st 782 pos— POS devices (registers, mobile registers, customer displays, printers, payment terminals)st 782 ios— iOS devices (iPhones)st 782 phone— wired (78xx) + DECT bases & handsets via Webexst 782 av— Atlas AMPs + MDM-tracked Apple TVs / video walls / music / LED displayshelp/help st— show this list inside Webex.
The bot answers single-message replies by default and only splits them into multiple messages when the output exceeds ~7000 characters (Webex's safe message-size cap).
Project Structure
.
├── bot/
│ └── handlers.js # Webex command handlers (store / analyze / help)
├── config/
│ └── index.js # Centralized env-driven configuration + validation
├── integrations/
│ ├── storeDetail.js # Per-mode store report builder (Meraki + SIW + MDM + Webex phones + Atlas AV)
│ ├── atlas/
│ │ ├── atlasClient.js # Atlas (hub.xyte.io) axios wrapper + AtlasUnavailableError
│ │ └── atlasDevices.js # Paginated org-device fetcher + 1h in-memory cache
│ └── webex/
│ └── WebexServiceAppAuth.js # Service App OAuth singleton w/ auto-rotating refresh
├── models/
│ └── Store.js # Store domain model (normalizes SIW location + general)
├── scripts/
│ ├── cleanupWebexDevices.js
│ └── seedWebexTokens.js # One-shot Webex Service App token seed
├── services/
│ ├── meraki.js # Meraki API client (cached, retried)
│ ├── siw.js # SIW calls (proxied via the remote agent)
│ ├── mdm.js # Workspace ONE MDM client (retried)
│ ├── webexService.js # Webex API axios wrapper (auto-refresh on 401)
│ ├── webexPhone.js # Phone discovery: 78xx + DECT bases + handsets
│ ├── avService.js # AV (Atlas) device shaper for `st [number] av`
│ └── websocket.js # WS server + proxyRequest helper
├── tests/
│ ├── *.test.js # Unit tests
│ ├── integration/ # Integration tests using mocks
│ └── mocks/ # Service mocks for tests
├── utils/
│ ├── chunkReport.js # Split markdown into <7000-char Webex messages
│ ├── logger.js # Structured JSON logger (LOG_LEVEL aware)
│ ├── merakiMatcher.js # Device ↔ Meraki client matching
│ ├── retry.js # withRetry wrapper (exponential backoff)
│ └── validate.js # Input parsers
├── docker/
│ └── remote-agent/ # Standalone Docker packaging for remoteAgent.js
│ ├── Dockerfile
│ ├── docker-compose.yml # Local build/run
│ ├── package.json # Minimal deps: ws + axios + dotenv
│ ├── package.sh # Build + save + zip → dist/
│ ├── .env.example
│ ├── README.md
│ ├── deploy/ # Files bundled into the deploy ZIP
│ │ ├── docker-compose.yml # Runtime-only (references loaded image)
│ │ ├── install.sh # docker load + .env bootstrap + up
│ │ └── README.md # Remote-host instructions (also in the ZIP)
│ └── dist/ # Generated ZIPs (gitignored)
├── constants.js # Shared constants (STORE_MODES, MDM device types)
├── remoteAgent.js # Lightweight proxy client (run on internal host)
├── server.js # Main entry point (bot + WS server)
├── package.json
├── eslint.config.js
├── .prettierrc.json
├── .dockerignore
└── .env.example
Logging
The app emits single-line JSON to stdout/stderr via utils/logger.js. Set LOG_LEVEL in .env to one of debug | info | warn | error (default: info).
Security Notes
- Never commit
.env(it is gitignored, along with.env.bak.*). - The WebSocket connection between server and remote agent is protected by a shared
WS_TOKEN. The remote agent now sends the token in anAuthorization: Bearerheader (the legacy?token=...query parameter still works for older deployments but should be migrated). - Only one remote agent may be connected at a time; a newer connection replaces the older one and any in-flight proxy requests are rejected (rather than silently hanging).
- All SIW communication uses Basic Auth and is only performed through the remote agent.
- Meraki and MDM calls use tokens that should be scoped to the minimum necessary permissions.
Development
npm run lint # eslint + prettier check
npm run lint:fix # auto-fix lint issues
npm run format # prettier write
npm test # jest (unit + integration tests using mocks)
npm run test:watch
License
ISC