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>
142 lines
4.3 KiB
JavaScript
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).)`);
|
|
}
|
|
})();
|