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>
229 lines
6.7 KiB
JavaScript
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);
|
|
});
|