netanalyzer/scripts/cleanupWebexDevices.js
Joseph McQueen b3c37bd7df feat: st command suite, Webex phone + Atlas AV integrations, dockerized remote agent
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>
2026-07-06 09:54:41 -04:00

142 lines
4.3 KiB
JavaScript

#!/usr/bin/env node
/**
* Cleanup stale Webex Device Manager (WDM) registrations for the bot token.
*
* Webex caps each user/bot at a fixed number of device registrations
* (currently ~100). Every time webex-node-bot-framework starts, it registers
* a new device; if the process is killed before framework.stop() runs (e.g.
* by nodemon SIGKILL, OOM, or a crash), the registration is orphaned.
* Once you hit the cap, new logins fail with:
* "User has excessive device registrations"
*
* Usage:
* node scripts/cleanupWebexDevices.js # dry-run (lists only)
* node scripts/cleanupWebexDevices.js --delete # actually delete them
* node scripts/cleanupWebexDevices.js --delete --keep-newest=1
*
* Requires WEBEX_ACCESS_TOKEN in .env.
*/
require('dotenv').config();
const axios = require('axios');
const WDM_BASE = 'https://wdm-a.wbx2.com/wdm/api/v1';
const TOKEN = process.env.WEBEX_ACCESS_TOKEN;
const args = process.argv.slice(2);
const doDelete = args.includes('--delete');
const keepNewestArg = args.find(a => a.startsWith('--keep-newest='));
const keepNewest = keepNewestArg ? parseInt(keepNewestArg.split('=')[1], 10) || 0 : 0;
if (!TOKEN) {
console.error('❌ WEBEX_ACCESS_TOKEN is not set in .env');
process.exit(1);
}
const api = axios.create({
baseURL: WDM_BASE,
headers: { Authorization: `Bearer ${TOKEN}` },
timeout: 15000,
});
function fmtDate(s) {
if (!s) return 'unknown';
try {
return new Date(s).toISOString();
} catch (_e) {
return String(s);
}
}
async function listDevices() {
try {
const res = await api.get('/devices');
const devices = res.data?.devices || res.data || [];
return Array.isArray(devices) ? devices : [];
} catch (err) {
const status = err.response?.status;
const body = err.response?.data;
console.error('❌ Failed to list devices', { status, error: err.message, body });
process.exit(1);
}
}
async function deleteDevice(device) {
// The WDM API returns either a `url` (full URL) or a `deviceUrl`. Prefer the
// explicit URL; otherwise fall back to /devices/{id}.
const url = device.url || device.deviceUrl;
try {
if (url) {
await axios.delete(url, { headers: { Authorization: `Bearer ${TOKEN}` }, timeout: 15000 });
} else if (device.id) {
await api.delete(`/devices/${device.id}`);
} else {
throw new Error('device has no url or id; skipping');
}
return { ok: true };
} catch (err) {
return { ok: false, error: err.response?.data || err.message };
}
}
(async function main() {
const devices = await listDevices();
console.log(`Found ${devices.length} device registration(s) for this token.\n`);
if (devices.length === 0) {
console.log('Nothing to clean up. ✅');
return;
}
// Sort newest → oldest by modificationTime / creationTime so --keep-newest
// keeps the most recently-touched registrations.
const sorted = [...devices].sort((a, b) => {
const ta = new Date(a.modificationTime || a.creationTime || 0).getTime();
const tb = new Date(b.modificationTime || b.creationTime || 0).getTime();
return tb - ta;
});
sorted.forEach((d, i) => {
console.log(
[
`[${i}]`,
d.deviceType || 'unknown-type',
`name=${d.name || d.userAgent || 'n/a'}`,
`created=${fmtDate(d.creationTime)}`,
`modified=${fmtDate(d.modificationTime)}`,
`id=${d.id || (d.url || '').split('/').pop()}`,
].join(' ')
);
});
const toDelete = sorted.slice(keepNewest);
if (!doDelete) {
console.log(`\nDry run — would delete ${toDelete.length} device(s).`);
console.log(`Re-run with --delete to actually remove them.`);
if (keepNewest > 0) {
console.log(`(Keeping the ${keepNewest} newest registration(s).)`);
}
return;
}
console.log(`\nDeleting ${toDelete.length} device(s)...`);
let okCount = 0;
let failCount = 0;
for (const d of toDelete) {
const result = await deleteDevice(d);
const tag = `[${d.id || (d.url || '').split('/').pop()}]`;
if (result.ok) {
okCount++;
console.log(` ✅ deleted ${tag}`);
} else {
failCount++;
console.log(` ❌ failed ${tag} ${JSON.stringify(result.error)}`);
}
}
console.log(`\nDone. Deleted ${okCount}, failed ${failCount}.`);
if (keepNewest > 0) {
console.log(`(Kept the ${keepNewest} newest registration(s).)`);
}
})();