Add bulk store iPad reboot script with WS1 SoftReset support.

Fleet-wide dry-run/execute script for MRD/MR1/CD iPads, plus MDM client helper and filter tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jmcqueen 2026-07-31 12:24:57 -04:00
parent 5e54ea6f57
commit f06dd2d09a
6 changed files with 436 additions and 1 deletions

View file

@ -424,7 +424,8 @@ DIGICERT_SEAT_EMAIL=your-email@company.com
# -----------------------------------------------------------------------------
# MDM / Workspace ONE (two instances)
# -----------------------------------------------------------------------------
# Standard / Store MDM
# Standard / Store MDM (also used by scripts/rebootStoreIpads.js SoftReset)
# OAuth client needs REST API Devices Execute for remote reboot.
WS1_CLIENT_ID=...
WS1_CLIENT_SECRET=...
WS1_TENANT_CODE=...

1
.gitignore vendored
View file

@ -31,6 +31,7 @@ scripts/*
!scripts/reclaimWebexHosts.js
!scripts/removeAdvancedMessaging.js
!scripts/findEmptyLocations.js
!scripts/rebootStoreIpads.js
!scripts/prismaProbe.js
!scripts/lib/
!scripts/lib/**

View file

@ -194,4 +194,67 @@ export async function getAVMDMDevices(storeNum) {
logger('mdm:av', `MDM AV filter: ${allDevices.length} total → ${avDevices.length} AV devices`, 'debug');
return avDevices;
}
/**
* Send a SoftReset (reboot) command via Workspace ONE UEM.
* Mirrors appspace/mdm.js sendMDMRebootCommand proven in production.
*
* @param {string|number} deviceId WS1 numeric Id (device.Id.Value), NOT serial
* @returns {Promise<{ success: boolean, status?: number, error?: string }>}
*/
export async function sendMDMRebootCommand(deviceId) {
if (!deviceId) {
return { success: false, error: 'No WS1 deviceId supplied' };
}
const url = `${MDM_BASE_URL}/api/mdm/devices/commands`;
const params = { command: 'SoftReset', searchBy: 'DeviceId', id: deviceId };
async function doRequest(token) {
return axios.post(url, null, {
params,
headers: {
Authorization: `Bearer ${token}`,
'aw-tenant-code': process.env.WS1_TENANT_CODE,
Accept: 'application/json',
},
timeout: 20000,
});
}
let token;
try {
token = await getMDMToken();
} catch (err) {
return { success: false, error: `Could not obtain MDM token: ${err.message}` };
}
try {
const resp = await doRequest(token);
return { success: true, status: resp.status };
} catch (err) {
const status = err.response?.status;
if (status === 401 || status === 403) {
logger('mdm:reboot', `Auth failed on SoftReset for ${deviceId} — retrying once`, 'warn');
try {
const retryResp = await doRequest(await getMDMToken());
return { success: true, status: retryResp.status };
} catch (retryErr) {
return {
success: false,
status: retryErr.response?.status,
error: retryErr.response?.data?.message
|| retryErr.response?.data?.errorCode
|| retryErr.message,
};
}
}
return {
success: false,
status,
error: err.response?.data?.message || err.response?.data?.errorCode || err.message,
};
}
}
export default mdmAxios;

View file

@ -0,0 +1,70 @@
// scripts/lib/mdmIpadFilter.js
// Fleet filter for store iPads whose DeviceFriendlyName contains MRD, MR1, or CD.
const NAME_PATTERN = /(MRD|MR1|CD)/i;
const IPAD_PATTERN = /iPad/i;
/**
* @param {object} device raw WS1 search record
* @returns {boolean}
*/
export function isIpadDevice(device) {
if (!device) return false;
const platform = String(device.Platform || device.platform || '');
const model = String(device.Model || device.model || '');
const os = String(device.OperatingSystem || device.osVersion || '');
return IPAD_PATTERN.test(platform) || IPAD_PATTERN.test(model) || /iPadOS/i.test(os);
}
/**
* @param {string} friendlyName DeviceFriendlyName only (per operator requirement)
* @returns {boolean}
*/
export function matchesStoreIpadName(friendlyName) {
if (!friendlyName) return false;
return NAME_PATTERN.test(String(friendlyName));
}
/**
* @param {object} device raw WS1 search record
* @returns {boolean}
*/
export function isTargetStoreIpad(device) {
if (!device) return false;
const friendlyName = device.DeviceFriendlyName || '';
return isIpadDevice(device) && matchesStoreIpadName(friendlyName);
}
/**
* WS1 numeric device Id required for SoftReset (appspace pattern).
* @param {object} device
* @returns {string|number|null}
*/
export function mdmDeviceId(device) {
if (!device) return null;
return device.Id?.Value ?? device.Id ?? device.id ?? device.DeviceId ?? null;
}
/**
* Extract store number from DeviceFriendlyName for reporting.
* @param {string} rawName
* @returns {string|null}
*/
export function extractStoreFromFriendlyName(rawName) {
if (!rawName) return null;
return (
rawName.match(/(\d{6})/)?.[1]
|| rawName.match(/(\d{5})/)?.[1]
|| rawName.match(/\d{2,4}/)?.[0]?.padStart(5, '0')
|| null
);
}
/**
* @param {object[]} devices
* @returns {object[]}
*/
export function filterTargetStoreIpads(devices) {
if (!Array.isArray(devices)) return [];
return devices.filter(isTargetStoreIpad);
}

229
scripts/rebootStoreIpads.js Normal file
View file

@ -0,0 +1,229 @@
#!/usr/bin/env node
/**
* Bulk reboot store iPads (MRD / MR1 / CD in DeviceFriendlyName).
*
* Fleet-wide scan of Store Workspace ONE MDM, filter iPads by name, issue
* SoftReset (same API as appspace restart-offline). Default is dry-run.
*
* Usage:
* node scripts/rebootStoreIpads.js
* node scripts/rebootStoreIpads.js --execute --yes --report reboot-ipads.csv
*
* Auth: WS1_CLIENT_ID, WS1_CLIENT_SECRET, WS1_TENANT_CODE (Store MDM).
* Requires REST API Devices Execute on the OAuth client.
*
* Note: SoftReset only executes on Supervised (DEP) iOS/iPadOS devices.
*/
import 'dotenv/config';
import fs from 'node:fs';
import readline from 'node:readline/promises';
import { stdin as input, stdout as output } from 'node:process';
import {
filterTargetStoreIpads,
mdmDeviceId,
extractStoreFromFriendlyName,
} from './lib/mdmIpadFilter.js';
const DEFAULT_CONCURRENCY = 3;
const MAX_FLEET_DEVICES = 25000;
function parseArgs(argv) {
const out = {
execute: false,
yes: false,
concurrency: DEFAULT_CONCURRENCY,
limit: null,
report: null,
help: false,
};
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
const next = () => argv[++i];
switch (a) {
case '--execute': out.execute = true; break;
case '--yes': out.yes = true; break;
case '--concurrency': out.concurrency = Math.max(1, Number(next()) || DEFAULT_CONCURRENCY); break;
case '--limit': out.limit = Number(next()); break;
case '--report': out.report = next(); break;
case '-h':
case '--help':
out.help = true;
break;
default:
throw new Error(`Unknown argument: ${a}`);
}
}
return out;
}
function printHelp() {
console.log(`Usage: node scripts/rebootStoreIpads.js [options]
Fleet-wide: scans Store WS1 MDM for iPads whose DeviceFriendlyName
contains MRD, MR1, or CD, then issues SoftReset (reboot).
Options:
--execute Send reboot commands (default: dry-run only)
--yes Skip interactive confirm when --execute
--concurrency <n> Parallel reboots (default: ${DEFAULT_CONCURRENCY})
--limit <n> Cap number of devices processed
--report <file.csv> Write audit CSV
-h, --help Show this help
iOS/iPadOS devices must be Supervised (DEP) for SoftReset to execute.
`);
}
function toCsvRow(cells) {
return cells.map((c) => {
const s = String(c ?? '');
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
}).join(',');
}
function summarizeDevice(device) {
const friendlyName = device.DeviceFriendlyName || '—';
return {
storeNum: extractStoreFromFriendlyName(friendlyName) || '—',
friendlyName,
serial: device.SerialNumber || '—',
mdmId: mdmDeviceId(device),
model: device.Model || '—',
lastSeen: device.LastSeen || device.LastSystemSampleTime || '—',
raw: device,
};
}
async function confirmExecute(count) {
const rl = readline.createInterface({ input, output });
try {
const answer = await rl.question(
`About to SoftReset ${count} iPad(s). Type YES to continue: `,
);
return answer.trim().toUpperCase() === 'YES';
} finally {
rl.close();
}
}
function printPreview(rows) {
console.log(`\nMatched ${rows.length} iPad(s):\n`);
const preview = rows.slice(0, 30);
for (const r of preview) {
console.log(
` store=${r.storeNum} id=${r.mdmId} serial=${r.serial} name=${r.friendlyName}`,
);
}
if (rows.length > preview.length) {
console.log(` ... and ${rows.length - preview.length} more`);
}
}
async function main() {
const opts = parseArgs(process.argv.slice(2));
if (opts.help) {
printHelp();
return;
}
if (!process.env.WS1_CLIENT_ID || !process.env.WS1_CLIENT_SECRET || !process.env.WS1_TENANT_CODE) {
console.error('Missing WS1_CLIENT_ID, WS1_CLIENT_SECRET, or WS1_TENANT_CODE in environment.');
process.exit(1);
}
const { getMDMDevicesByPlatform, sendMDMRebootCommand } = await import('../integrations/mdm/client.js');
const { runPool } = await import('./lib/webexBulk.js');
console.log('Fetching fleet devices from Store MDM (paginated)...');
const allDevices = await getMDMDevicesByPlatform(null, MAX_FLEET_DEVICES);
console.log(`MDM returned ${allDevices.length} device(s).`);
let matches = filterTargetStoreIpads(allDevices).map(summarizeDevice);
matches = matches.filter((m) => m.mdmId != null);
const skippedNoId = filterTargetStoreIpads(allDevices).length - matches.length;
if (Number.isFinite(opts.limit) && opts.limit > 0) {
matches = matches.slice(0, opts.limit);
}
if (skippedNoId > 0) {
console.warn(`Skipped ${skippedNoId} match(es) with no WS1 Id.Value.`);
}
printPreview(matches);
if (matches.length === 0) {
console.log('\nNo matching iPads found. Nothing to do.');
return;
}
if (!opts.execute) {
console.log('\nDry-run only — pass --execute to send SoftReset commands.');
return;
}
if (!opts.yes) {
const ok = await confirmExecute(matches.length);
if (!ok) {
console.log('Cancelled.');
return;
}
}
console.log(`\nSending SoftReset to ${matches.length} device(s) (concurrency=${opts.concurrency})...`);
const results = await runPool(matches, opts.concurrency, async (row) => {
const reboot = await sendMDMRebootCommand(row.mdmId);
return {
...row,
status: reboot.success ? 'sent' : 'failed',
httpStatus: reboot.status ?? '',
error: reboot.error ?? '',
};
});
const sent = results.filter((r) => r.ok && r.value.status === 'sent').length;
const failed = results.filter((r) => !r.ok || r.value.status === 'failed').length;
console.log(`\nDone: ${sent} sent, ${failed} failed.`);
const reportRows = results.map((r, i) => {
if (r.ok) return r.value;
const base = matches[i];
return {
...base,
status: 'error',
httpStatus: '',
error: r.error?.message || String(r.error),
};
});
for (const row of reportRows) {
if (row.status !== 'sent') {
console.log(` FAIL ${row.friendlyName} (${row.serial}): ${row.error || 'unknown'}`);
}
}
if (opts.report) {
const header = ['store', 'friendlyName', 'serial', 'mdmId', 'model', 'lastSeen', 'status', 'httpStatus', 'error'];
const lines = [
toCsvRow(header),
...reportRows.map((r) => toCsvRow([
r.storeNum, r.friendlyName, r.serial, r.mdmId, r.model, r.lastSeen,
r.status, r.httpStatus ?? '', r.error ?? '',
])),
];
fs.writeFileSync(opts.report, `${lines.join('\n')}\n`);
console.log(`\nWrote ${opts.report}`);
}
console.log('\nNote: WS1 queues SoftReset; iPadOS must be Supervised for reboot to execute.');
}
main().catch((err) => {
console.error(err.message || err);
process.exit(1);
});

View file

@ -0,0 +1,71 @@
// tests/rebootStoreIpads.filter.test.js
import test from 'node:test';
import assert from 'node:assert/strict';
import {
isIpadDevice,
matchesStoreIpadName,
isTargetStoreIpad,
filterTargetStoreIpads,
mdmDeviceId,
extractStoreFromFriendlyName,
} from '../scripts/lib/mdmIpadFilter.js';
test('matchesStoreIpadName matches MRD MR1 CD tokens', () => {
assert.equal(matchesStoreIpadName('Store 0782 MRD iPad'), true);
assert.equal(matchesStoreIpadName('0782-MR1-Fitting'), true);
assert.equal(matchesStoreIpadName('Store CD Queue iPad'), true);
assert.equal(matchesStoreIpadName('Store 0782 VW'), false);
assert.equal(matchesStoreIpadName(''), false);
});
test('isIpadDevice detects iPad platform/model', () => {
assert.equal(isIpadDevice({ Platform: 'Apple', Model: 'iPad (9th generation)' }), true);
assert.equal(isIpadDevice({ Platform: 'Apple', Model: 'Apple TV' }), false);
});
test('isTargetStoreIpad requires iPad and friendly name match', () => {
assert.equal(isTargetStoreIpad({
DeviceFriendlyName: 'Store 0782 MRD iPad',
Platform: 'Apple',
Model: 'iPad Pro',
Id: { Value: 12345 },
}), true);
assert.equal(isTargetStoreIpad({
DeviceFriendlyName: 'Store 0782 MRD Apple TV',
Platform: 'Apple',
Model: 'Apple TV',
Id: { Value: 1 },
}), false);
assert.equal(isTargetStoreIpad({
DeviceFriendlyName: 'Store 0782 VW iPad',
Platform: 'Apple',
Model: 'iPad',
Id: { Value: 1 },
}), false);
});
test('mdmDeviceId reads Id.Value', () => {
assert.equal(mdmDeviceId({ Id: { Value: 999 } }), 999);
assert.equal(mdmDeviceId({ id: 'abc' }), 'abc');
assert.equal(mdmDeviceId({}), null);
});
test('extractStoreFromFriendlyName parses store numbers', () => {
assert.equal(extractStoreFromFriendlyName('Store 007821 MRD iPad'), '007821');
assert.equal(extractStoreFromFriendlyName('0782-MR1-Fitting'), '0782');
assert.equal(extractStoreFromFriendlyName('no digits'), null);
});
test('filterTargetStoreIpads returns only matches', () => {
const devices = [
{ DeviceFriendlyName: 'A MRD', Platform: 'Apple', Model: 'iPad', Id: { Value: 1 } },
{ DeviceFriendlyName: 'B VW', Platform: 'Apple', Model: 'iPad', Id: { Value: 2 } },
{ DeviceFriendlyName: 'C MR1', Platform: 'Apple', Model: 'iPad', Id: { Value: 3 } },
];
const out = filterTargetStoreIpads(devices);
assert.equal(out.length, 2);
assert.equal(out[0].Id.Value, 1);
assert.equal(out[1].Id.Value, 3);
});