collabSupport/scripts/rebootStoreIpads.js
jmcqueen f06dd2d09a 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>
2026-07-31 12:24:57 -04:00

229 lines
6.7 KiB
JavaScript

#!/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);
});