Add scripts/reclaimWebexHosts.js — bulk host license reclaim
Reads a Control Hub "Meetings Inactive Users" CSV, filters to IS_HOST=Y AND DAYS_SINCE_LAST_ACTIVE > --min-days (default 120), cross-references against the current holders of --host-license-id (so no per-user /people lookup), then PATCHes /v1/licenses/users to atomically remove the host license and either (a) add a specific free-tier license (--free-license-id) or (b) add attendee-only siteUrl on --site (--free-attendee). Dry-run by default; enumerates every license on the site so the operator can pick the free tier. Bounded concurrency with 429/503 retry, optional --offset/--limit for staged rollouts, per-user outcome CSV via --report, and full audit trail via the existing webex:reclaim:audit log scope. Whitelisted in .gitignore so it stays version-controlled alongside the other tracked operational scripts.
This commit is contained in:
parent
59460b849b
commit
c996d5d32e
2 changed files with 530 additions and 1 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -26,7 +26,9 @@ storage/
|
|||
|
||||
# Dev / test artifacts (local only)
|
||||
characterization-runs/
|
||||
scripts/
|
||||
scripts/*
|
||||
# Tracked operational scripts (whitelisted; keep local dev helpers ignored above)
|
||||
!scripts/reclaimWebexHosts.js
|
||||
characterize-*.js
|
||||
|
||||
# Backup & temp files
|
||||
|
|
|
|||
527
scripts/reclaimWebexHosts.js
Normal file
527
scripts/reclaimWebexHosts.js
Normal file
|
|
@ -0,0 +1,527 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* Reclaim Webex Meetings host licenses from long-inactive users.
|
||||
*
|
||||
* Feeds off a Control Hub "Meetings Inactive Users" report CSV
|
||||
* (Analyzer → Meetings → Inactive Users, Export CSV). For every row
|
||||
* that is currently marked as a host (IS_HOST=Y) AND has been inactive
|
||||
* for more than `--min-days` days, we PATCH `/v1/licenses/users` to:
|
||||
* 1. remove the configured host license on the target site, and
|
||||
* 2. atomically add either a specific "free tier" license, or
|
||||
* an attendee-only siteUrl on the site (accountType=attendee).
|
||||
* Both operations go in one PATCH so the user is never briefly
|
||||
* license-less.
|
||||
*
|
||||
* Detection is authoritative: we fetch the assignee roster of the host
|
||||
* license once up-front (paginated) and cross-reference the CSV emails
|
||||
* against it. Anyone in the CSV who isn't currently a holder is skipped
|
||||
* silently — no per-user /people search needed, and the personId comes
|
||||
* straight off the assignee record.
|
||||
*
|
||||
* DRY-RUN by default. Nothing mutates without `--execute`. In dry-run,
|
||||
* the script lists every license on the site so you can pick the
|
||||
* `--free-license-id` (or decide to use `--free-attendee` instead).
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/reclaimWebexHosts.js \
|
||||
* --csv "/path/to/Meetings Inactive Users_....csv" \
|
||||
* [--site aeo2go.webex.com] \
|
||||
* [--min-days 120] \
|
||||
* [--host-license-id <id>] \
|
||||
* [--free-license-id <id> | --free-attendee] \
|
||||
* [--concurrency 5] \
|
||||
* [--limit N] [--offset N] \
|
||||
* [--report reclaim-report.csv] \
|
||||
* [--execute]
|
||||
*
|
||||
* Environment defaults (read from .env):
|
||||
* WEBEX_HOST_SITE_URL → --site (default aeo2go.webex.com)
|
||||
* WEBEX_HOST_LICENSE_ID → --host-license-id
|
||||
* WEBEX_FREE_LICENSE_ID → --free-license-id (optional)
|
||||
*
|
||||
* Required Webex service-app scopes:
|
||||
* spark-admin:licenses_read
|
||||
* spark-admin:people_read
|
||||
* spark-admin:people_write
|
||||
*/
|
||||
|
||||
import 'dotenv/config';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { logger } from '../utils/logger.js';
|
||||
import webex from '../integrations/webex/WebexClient.js';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// CLI parsing
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function parseArgs(argv) {
|
||||
const out = {
|
||||
csv: null,
|
||||
site: process.env.WEBEX_HOST_SITE_URL || 'aeo2go.webex.com',
|
||||
minDays: 120,
|
||||
hostLicenseId: process.env.WEBEX_HOST_LICENSE_ID || null,
|
||||
freeLicenseId: process.env.WEBEX_FREE_LICENSE_ID || null,
|
||||
freeAttendee: false,
|
||||
concurrency: 5,
|
||||
limit: null,
|
||||
offset: 0,
|
||||
report: null,
|
||||
execute: false,
|
||||
help: false,
|
||||
};
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
const next = () => argv[++i];
|
||||
switch (a) {
|
||||
case '--csv': out.csv = next(); break;
|
||||
case '--site': out.site = next().toLowerCase(); break;
|
||||
case '--min-days': out.minDays = Number(next()); break;
|
||||
case '--host-license-id': out.hostLicenseId = next(); break;
|
||||
case '--free-license-id': out.freeLicenseId = next(); break;
|
||||
case '--free-attendee': out.freeAttendee = true; break;
|
||||
case '--concurrency': out.concurrency = Math.max(1, Number(next())); break;
|
||||
case '--limit': out.limit = Number(next()); break;
|
||||
case '--offset': out.offset = Number(next()); break;
|
||||
case '--report': out.report = next(); break;
|
||||
case '--execute': out.execute = true; break;
|
||||
case '-h': case '--help': out.help = true; break;
|
||||
default:
|
||||
if (a.startsWith('--')) {
|
||||
console.error(`Unknown flag: ${a}`);
|
||||
process.exit(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function printHelp() {
|
||||
// Print the file's top comment block so `--help` matches source docs.
|
||||
const src = fs.readFileSync(new URL(import.meta.url), 'utf8');
|
||||
const m = src.match(/\/\*\*([\s\S]*?)\*\//);
|
||||
if (m) console.log(m[1].replace(/^\s*\*\s?/gm, ''));
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// CSV parsing (RFC 4180-ish; handles quoted fields, escaped "")
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function parseCsvLine(line) {
|
||||
const cells = [];
|
||||
let cur = '';
|
||||
let inQ = false;
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const c = line[i];
|
||||
if (inQ) {
|
||||
if (c === '"' && line[i + 1] === '"') { cur += '"'; i++; }
|
||||
else if (c === '"') inQ = false;
|
||||
else cur += c;
|
||||
} else {
|
||||
if (c === '"') inQ = true;
|
||||
else if (c === ',') { cells.push(cur); cur = ''; }
|
||||
else cur += c;
|
||||
}
|
||||
}
|
||||
cells.push(cur);
|
||||
return cells;
|
||||
}
|
||||
|
||||
function readCsv(filePath) {
|
||||
const raw = fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/, '');
|
||||
const lines = raw.split(/\r?\n/).filter((l) => l.length > 0);
|
||||
if (lines.length === 0) return { header: [], rows: [] };
|
||||
const header = parseCsvLine(lines[0]).map((h) => h.trim().toUpperCase());
|
||||
const rows = lines.slice(1).map((l) => {
|
||||
const cells = parseCsvLine(l);
|
||||
const row = {};
|
||||
for (let i = 0; i < header.length; i++) row[header[i]] = cells[i] ?? '';
|
||||
return row;
|
||||
});
|
||||
return { header, rows };
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Bounded-concurrency worker pool
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function runPool(items, limit, worker) {
|
||||
const results = new Array(items.length);
|
||||
let idx = 0;
|
||||
const workers = new Array(Math.min(limit, items.length)).fill(null).map(async () => {
|
||||
while (true) {
|
||||
const i = idx++;
|
||||
if (i >= items.length) return;
|
||||
try {
|
||||
results[i] = { ok: true, value: await worker(items[i], i) };
|
||||
} catch (err) {
|
||||
results[i] = { ok: false, error: err };
|
||||
}
|
||||
}
|
||||
});
|
||||
await Promise.all(workers);
|
||||
return results;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Webex helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function fetchSiteLicenses(siteUrl) {
|
||||
const data = await webex.listLicenses();
|
||||
const items = Array.isArray(data?.items) ? data.items : [];
|
||||
return items.filter((l) => (l.siteUrl || '').toLowerCase() === siteUrl.toLowerCase());
|
||||
}
|
||||
|
||||
function seatsFree(l) {
|
||||
const total = Number(l.totalUnits ?? 0);
|
||||
const used = Number(l.consumedUnits ?? 0);
|
||||
return Math.max(0, total - used);
|
||||
}
|
||||
|
||||
async function fetchHostAssignees(licenseId) {
|
||||
// Returns Map<lowercased-email, { id, displayName, email }>. If an
|
||||
// assignee record has no email (shouldn't happen for internal users)
|
||||
// it's dropped — the CSV keys on email so we couldn't match anyway.
|
||||
const users = await webex.listLicenseAssignees(licenseId);
|
||||
const byEmail = new Map();
|
||||
for (const u of users) {
|
||||
const email = (u?.email || '').toLowerCase();
|
||||
if (!email || !u?.id) continue;
|
||||
byEmail.set(email, { id: u.id, displayName: u.displayName || email, email });
|
||||
}
|
||||
return byEmail;
|
||||
}
|
||||
|
||||
function explainWebexError(err) {
|
||||
const status = err?.response?.status;
|
||||
const apiMsg =
|
||||
err?.response?.data?.message ||
|
||||
err?.response?.data?.errors?.[0]?.description ||
|
||||
err?.message ||
|
||||
String(err);
|
||||
return status ? `${apiMsg} (HTTP ${status})` : apiMsg;
|
||||
}
|
||||
|
||||
// Simple 429-aware retry. Webex returns Retry-After (seconds).
|
||||
async function callWithRetry(fn, { tries = 4, baseDelayMs = 500 } = {}) {
|
||||
let lastErr;
|
||||
for (let attempt = 0; attempt < tries; attempt++) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (err) {
|
||||
lastErr = err;
|
||||
const status = err?.response?.status;
|
||||
if (status !== 429 && status !== 503) throw err;
|
||||
const retryAfter = Number(err?.response?.headers?.['retry-after']);
|
||||
const wait = Number.isFinite(retryAfter) && retryAfter > 0
|
||||
? retryAfter * 1000
|
||||
: baseDelayMs * Math.pow(2, attempt);
|
||||
await new Promise((r) => setTimeout(r, wait));
|
||||
}
|
||||
}
|
||||
throw lastErr;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Main
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
if (args.help) { printHelp(); process.exit(0); }
|
||||
|
||||
if (!args.csv) {
|
||||
console.error('❌ --csv <path> is required. Use --help for usage.');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const csvPath = path.resolve(args.csv);
|
||||
if (!fs.existsSync(csvPath)) {
|
||||
console.error(`❌ CSV not found: ${csvPath}`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
console.log(`📄 Reading ${csvPath}`);
|
||||
const { header, rows } = readCsv(csvPath);
|
||||
console.log(` → ${rows.length} rows, columns: ${header.join(', ')}`);
|
||||
|
||||
const required = ['EMAIL', 'IS_HOST', 'DAYS_SINCE_LAST_ACTIVE'];
|
||||
const missing = required.filter((c) => !header.includes(c));
|
||||
if (missing.length > 0) {
|
||||
console.error(`❌ CSV missing required columns: ${missing.join(', ')}`);
|
||||
console.error(` Expected a Control Hub "Meetings Inactive Users" export.`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
// Filter: hosts inactive over --min-days. Emails are lowercased for
|
||||
// the assignee cross-reference below.
|
||||
const candidates = [];
|
||||
let skippedNotHost = 0;
|
||||
let skippedRecent = 0;
|
||||
for (const r of rows) {
|
||||
const isHost = (r.IS_HOST || '').trim().toUpperCase() === 'Y';
|
||||
const days = Number(r.DAYS_SINCE_LAST_ACTIVE || 0);
|
||||
const email = (r.EMAIL || '').trim().toLowerCase();
|
||||
if (!email) continue;
|
||||
if (!isHost) { skippedNotHost++; continue; }
|
||||
if (!(days > args.minDays)) { skippedRecent++; continue; }
|
||||
candidates.push({
|
||||
email,
|
||||
days,
|
||||
firstName: r.FIRST_NAME || '',
|
||||
lastName: r.LAST_NAME || '',
|
||||
lastActive: r.LAST_ACTIVE_DATE || '',
|
||||
});
|
||||
}
|
||||
console.log(
|
||||
` → ${candidates.length} candidates (host + >${args.minDays}d inactive); ` +
|
||||
`skipped ${skippedNotHost} non-host, ${skippedRecent} recent`,
|
||||
);
|
||||
|
||||
// Enumerate the site's licenses so we can (a) verify the host license
|
||||
// id, (b) let the operator pick the free license id in dry-run, and
|
||||
// (c) show seat headroom (relevant if --free-license-id is finite).
|
||||
console.log(`\n🔎 Fetching Webex Meetings licenses on \`${args.site}\`…`);
|
||||
let siteLicenses;
|
||||
try {
|
||||
siteLicenses = await fetchSiteLicenses(args.site);
|
||||
} catch (err) {
|
||||
console.error(`❌ Failed to list licenses: ${explainWebexError(err)}`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (siteLicenses.length === 0) {
|
||||
console.error(`❌ No licenses found on \`${args.site}\`. Wrong site URL?`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(` Licenses on \`${args.site}\`:`);
|
||||
for (const l of siteLicenses) {
|
||||
const free = seatsFree(l);
|
||||
const markers = [];
|
||||
if (l.id === args.hostLicenseId) markers.push('HOST (to reclaim)');
|
||||
if (l.id === args.freeLicenseId) markers.push('FREE (to assign)');
|
||||
const mark = markers.length ? ` ← ${markers.join(', ')}` : '';
|
||||
console.log(` • ${l.name} — ${free}/${l.totalUnits} free — id=${l.id}${mark}`);
|
||||
}
|
||||
|
||||
if (!args.hostLicenseId) {
|
||||
console.error(
|
||||
`\n❌ --host-license-id not set (and WEBEX_HOST_LICENSE_ID env is empty).\n` +
|
||||
` Pick the "host" license id from the list above and re-run with\n` +
|
||||
` --host-license-id <id>.`,
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
const hostLic = siteLicenses.find((l) => l.id === args.hostLicenseId);
|
||||
if (!hostLic) {
|
||||
console.error(
|
||||
`\n❌ --host-license-id ${args.hostLicenseId} does not match any license\n` +
|
||||
` on \`${args.site}\`. Double-check the id.`,
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
let freeLic = null;
|
||||
if (args.freeLicenseId) {
|
||||
freeLic = siteLicenses.find((l) => l.id === args.freeLicenseId);
|
||||
if (!freeLic) {
|
||||
console.error(
|
||||
`\n❌ --free-license-id ${args.freeLicenseId} does not match any license\n` +
|
||||
` on \`${args.site}\`. Double-check the id.`,
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
}
|
||||
|
||||
// Cross-reference: pull the host license's current assignee roster
|
||||
// and keep only the CSV candidates who actually still hold it.
|
||||
console.log(`\n📥 Fetching current holders of \`${hostLic.name}\` (paginated)…`);
|
||||
let holders;
|
||||
try {
|
||||
holders = await fetchHostAssignees(hostLic.id);
|
||||
} catch (err) {
|
||||
console.error(`❌ Failed to fetch assignees: ${explainWebexError(err)}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(` → ${holders.size} current holders`);
|
||||
|
||||
const toReclaim = [];
|
||||
let skippedNotHolder = 0;
|
||||
for (const c of candidates) {
|
||||
const holder = holders.get(c.email);
|
||||
if (!holder) { skippedNotHolder++; continue; }
|
||||
toReclaim.push({ ...c, personId: holder.id, displayName: holder.displayName });
|
||||
}
|
||||
console.log(
|
||||
` → ${toReclaim.length} to reclaim ` +
|
||||
`(${skippedNotHolder} CSV candidates no longer hold the license)`,
|
||||
);
|
||||
|
||||
// Optional slicing for staged rollouts / restart-after-failure.
|
||||
const sliced = toReclaim.slice(args.offset, args.limit ? args.offset + args.limit : undefined);
|
||||
if (sliced.length !== toReclaim.length) {
|
||||
console.log(` → sliced to ${sliced.length} (offset=${args.offset}, limit=${args.limit ?? 'none'})`);
|
||||
}
|
||||
|
||||
// Plan sanity: if a specific free license is provided, ensure it has
|
||||
// enough seats for the run. (If it doesn't we still let --execute
|
||||
// proceed, but Webex will start rejecting after seats run out — flag
|
||||
// it up top so the operator can pick a different license or split.)
|
||||
if (freeLic) {
|
||||
const free = seatsFree(freeLic);
|
||||
if (free < sliced.length) {
|
||||
console.warn(
|
||||
`\n⚠️ Free license \`${freeLic.name}\` has ${free} free seats but ` +
|
||||
`${sliced.length} assignments are planned. Extras will fail.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Decide what mutation each user will get.
|
||||
const mutation = describeMutation(args, hostLic, freeLic);
|
||||
console.log(`\n🛠 Planned mutation per user: ${mutation.human}`);
|
||||
|
||||
// Dry-run bail-out.
|
||||
if (!args.execute) {
|
||||
console.log(`\n🚦 DRY-RUN (no changes made). Re-run with --execute to commit.`);
|
||||
if (!freeLic && !args.freeAttendee) {
|
||||
console.log(
|
||||
` Note: neither --free-license-id nor --free-attendee provided.\n` +
|
||||
` Pick one before --execute:\n` +
|
||||
` --free-license-id <id> (assigns a specific meetings license)\n` +
|
||||
` --free-attendee (attendee-only on ${args.site}, no license)`,
|
||||
);
|
||||
}
|
||||
if (sliced.length > 0) {
|
||||
console.log(`\n Sample of first 5 candidates:`);
|
||||
for (const s of sliced.slice(0, 5)) {
|
||||
console.log(` - ${s.displayName} <${s.email}> (${s.days}d inactive, personId=${s.personId})`);
|
||||
}
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (!freeLic && !args.freeAttendee) {
|
||||
console.error(
|
||||
`\n❌ --execute requires one of:\n` +
|
||||
` --free-license-id <id> (assign a specific meetings license)\n` +
|
||||
` --free-attendee (attendee-only on ${args.site}, no license)`,
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
// Execute with bounded concurrency + 429 retry.
|
||||
console.log(
|
||||
`\n🚀 EXECUTING against ${sliced.length} users ` +
|
||||
`(concurrency=${args.concurrency}). Ctrl-C to abort.\n`,
|
||||
);
|
||||
logger(
|
||||
'webex:reclaim:audit',
|
||||
`START reclaim: site=${args.site} host=${hostLic.id} ` +
|
||||
`free=${freeLic ? freeLic.id : args.freeAttendee ? 'ATTENDEE' : 'NONE'} ` +
|
||||
`count=${sliced.length} csv=${path.basename(csvPath)}`,
|
||||
);
|
||||
|
||||
let processed = 0;
|
||||
const results = await runPool(sliced, args.concurrency, async (user) => {
|
||||
const body = {
|
||||
personId: user.personId,
|
||||
licenses: [{ id: hostLic.id, operation: 'remove' }],
|
||||
};
|
||||
if (freeLic) body.licenses.push({ id: freeLic.id, operation: 'add' });
|
||||
if (args.freeAttendee) {
|
||||
body.siteUrls = [{ siteUrl: args.site, accountType: 'attendee', operation: 'add' }];
|
||||
}
|
||||
|
||||
const resp = await callWithRetry(() => webex.assignLicensesToUser(body));
|
||||
|
||||
processed++;
|
||||
if (processed % 25 === 0 || processed === sliced.length) {
|
||||
console.log(` … ${processed}/${sliced.length}`);
|
||||
}
|
||||
return resp;
|
||||
});
|
||||
|
||||
// Summarise + audit.
|
||||
let ok = 0;
|
||||
let failed = 0;
|
||||
const failures = [];
|
||||
const perUser = [];
|
||||
for (let i = 0; i < results.length; i++) {
|
||||
const r = results[i];
|
||||
const u = sliced[i];
|
||||
if (r.ok) {
|
||||
ok++;
|
||||
const grantedIds = new Set(r.value?.licenses || []);
|
||||
const removedOk = !new Set(r.value?.licenses || []).has(hostLic.id);
|
||||
const freeOk = !freeLic || grantedIds.has(freeLic.id);
|
||||
const outcome = removedOk && freeOk ? 'granted' : 'partial';
|
||||
logger(
|
||||
'webex:reclaim:audit',
|
||||
`OK ${u.email} personId=${u.personId} outcome=${outcome}`,
|
||||
);
|
||||
perUser.push({
|
||||
email: u.email,
|
||||
displayName: u.displayName,
|
||||
days_inactive: u.days,
|
||||
personId: u.personId,
|
||||
outcome,
|
||||
error: '',
|
||||
});
|
||||
} else {
|
||||
failed++;
|
||||
const msg = explainWebexError(r.error);
|
||||
failures.push({ user: u, msg });
|
||||
logger(
|
||||
'webex:reclaim:audit',
|
||||
`FAIL ${u.email} personId=${u.personId}: ${msg}`,
|
||||
'error',
|
||||
);
|
||||
perUser.push({
|
||||
email: u.email,
|
||||
displayName: u.displayName,
|
||||
days_inactive: u.days,
|
||||
personId: u.personId,
|
||||
outcome: 'error',
|
||||
error: msg,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n✅ Done. ${ok} succeeded, ${failed} failed, ${sliced.length} total.`);
|
||||
if (failures.length > 0) {
|
||||
console.log(`\nFirst up to 10 failures:`);
|
||||
for (const f of failures.slice(0, 10)) {
|
||||
console.log(` - ${f.user.email}: ${f.msg}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (args.report) {
|
||||
const reportPath = path.resolve(args.report);
|
||||
const cols = ['email', 'displayName', 'days_inactive', 'personId', 'outcome', 'error'];
|
||||
const escape = (v) => `"${String(v ?? '').replace(/"/g, '""')}"`;
|
||||
const lines = [cols.join(',')];
|
||||
for (const p of perUser) lines.push(cols.map((c) => escape(p[c])).join(','));
|
||||
fs.writeFileSync(reportPath, lines.join('\n') + '\n', 'utf8');
|
||||
console.log(`\n📝 Report written to ${reportPath}`);
|
||||
}
|
||||
|
||||
logger(
|
||||
'webex:reclaim:audit',
|
||||
`END reclaim: ok=${ok} failed=${failed} total=${sliced.length}`,
|
||||
);
|
||||
process.exit(failed === 0 ? 0 : 1);
|
||||
}
|
||||
|
||||
function describeMutation(args, hostLic, freeLic) {
|
||||
const parts = [`remove \`${hostLic.name}\``];
|
||||
if (freeLic) parts.push(`add \`${freeLic.name}\``);
|
||||
else if (args.freeAttendee) parts.push(`add attendee-only on \`${args.site}\``);
|
||||
else parts.push(`(no replacement — TBD)`);
|
||||
return { human: parts.join(', ') };
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(`\n💥 Unhandled: ${err?.stack || err}`);
|
||||
process.exit(1);
|
||||
});
|
||||
Loading…
Reference in a new issue