Add scripts/removeAdvancedMessaging.js + extract shared bulk lib
New scripts/removeAdvancedMessaging.js reads a Users Export CSV
and bulk-removes the Advanced Messaging and Advanced Space Meetings
licenses from every listed user (with optional add of a Basic
Messaging license, though in most Webex orgs Basic Messaging is a
derived entitlement and no explicit add is required).
Detection is authoritative like the reclaim script: the assignee
rosters of the two Advanced licenses are fetched once up-front,
unioned by email, and the CSV is cross-referenced. PersonIds come
straight off the roster (no per-user /people lookup). Only the
remove ops the user actually still needs are emitted — the PATCH
body is trimmed per user based on which licenses they hold.
Dry-run enumerates every org license whose name matches
/message|advanced|space|basic/i so the operator can discover the
three ids without prior knowledge. --advanced-messaging-license-id,
--advanced-space-meetings-license-id, and --basic-messaging-license-id
also read WEBEX_ADV_MSG_LICENSE_ID / WEBEX_ADV_SPACE_MTG_LICENSE_ID
/ WEBEX_BASIC_MSG_LICENSE_ID from .env if set.
Also extracted the CSV parsing, format detection, pool/retry
helpers, and Webex license helpers from reclaimWebexHosts.js into
a shared scripts/lib/webexBulk.js module. reclaimWebexHosts.js now
imports from it — no behavior change (verified against both CSV
formats: 1028 candidates on the Meetings Inactive Users report,
665 on the Users Export report). Net -106 lines from the reclaim
script.
.gitignore updates:
- whitelist scripts/lib/ and the new removeAdvancedMessaging.js
file so they get tracked
- exclude reclaim-*.csv and remove-*.csv (per-user report CSVs
generated by --report contain PII and must never be committed)
This commit is contained in:
parent
8777e51d10
commit
07152a467b
4 changed files with 665 additions and 121 deletions
8
.gitignore
vendored
8
.gitignore
vendored
|
|
@ -29,6 +29,9 @@ characterization-runs/
|
||||||
scripts/*
|
scripts/*
|
||||||
# Tracked operational scripts (whitelisted; keep local dev helpers ignored above)
|
# Tracked operational scripts (whitelisted; keep local dev helpers ignored above)
|
||||||
!scripts/reclaimWebexHosts.js
|
!scripts/reclaimWebexHosts.js
|
||||||
|
!scripts/removeAdvancedMessaging.js
|
||||||
|
!scripts/lib/
|
||||||
|
!scripts/lib/**
|
||||||
characterize-*.js
|
characterize-*.js
|
||||||
|
|
||||||
# Backup & temp files
|
# Backup & temp files
|
||||||
|
|
@ -75,3 +78,8 @@ config/config.bak
|
||||||
|
|
||||||
# Local spike samples pulled from lab DBS-210 (never commit)
|
# Local spike samples pulled from lab DBS-210 (never commit)
|
||||||
.dect-samples/
|
.dect-samples/
|
||||||
|
|
||||||
|
# Per-user report CSVs written by the bulk admin scripts.
|
||||||
|
# These contain emails, personIds, and per-user outcome — PII, never commit.
|
||||||
|
reclaim-*.csv
|
||||||
|
remove-*.csv
|
||||||
|
|
|
||||||
168
scripts/lib/webexBulk.js
Normal file
168
scripts/lib/webexBulk.js
Normal file
|
|
@ -0,0 +1,168 @@
|
||||||
|
// scripts/lib/webexBulk.js
|
||||||
|
//
|
||||||
|
// Shared utilities for bulk Webex admin scripts driven off Control Hub
|
||||||
|
// CSV exports (reclaimWebexHosts.js, removeAdvancedMessaging.js, etc.).
|
||||||
|
// Kept intentionally dependency-free — everything the operator needs is
|
||||||
|
// already in the repo (WebexClient, logger). No dev deps to install.
|
||||||
|
//
|
||||||
|
// Contents:
|
||||||
|
// CSV
|
||||||
|
// parseCsvLine(line) → string[]
|
||||||
|
// readCsv(path) → { header, rows }
|
||||||
|
// detectFormat(header) → 'meetings-inactive' | 'users-export' | null
|
||||||
|
// FORMAT_* constants
|
||||||
|
//
|
||||||
|
// Concurrency + retry
|
||||||
|
// runPool(items, limit, worker) → results[] with { ok, value? , error? }
|
||||||
|
// callWithRetry(fn, opts) → retries 429/503 with Retry-After
|
||||||
|
//
|
||||||
|
// Webex helpers
|
||||||
|
// fetchAllLicenses() → all org licenses
|
||||||
|
// fetchSiteLicenses(siteUrl) → subset with siteUrl matching (case-insensitive)
|
||||||
|
// seatsFree(license) → number
|
||||||
|
// explainWebexError(err) → concise `${apiMsg} (HTTP ${status})`
|
||||||
|
//
|
||||||
|
// All Webex calls go through the shared WebexClient singleton which
|
||||||
|
// handles service-app token refresh; nothing to configure per-script.
|
||||||
|
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import webex from '../../integrations/webex/WebexClient.js';
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// CSV parsing (RFC 4180-ish; handles quoted fields, escaped "")
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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: [] };
|
||||||
|
// Preserve original header text — Control Hub exports vary between
|
||||||
|
// UPPER_SNAKE and Title Case With Punctuation, and Users Export
|
||||||
|
// license columns are literally "aeo2go.webex.com - WebEx Meetings
|
||||||
|
// Free [Sub601269]". Case-preserving avoids ambiguity.
|
||||||
|
const header = parseCsvLine(lines[0]).map((h) => h.trim());
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Format detection
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const FORMAT_MEETINGS_INACTIVE = 'meetings-inactive';
|
||||||
|
export const FORMAT_USERS_EXPORT = 'users-export';
|
||||||
|
|
||||||
|
export function detectFormat(header) {
|
||||||
|
const set = new Set(header);
|
||||||
|
if (set.has('EMAIL') && set.has('IS_HOST') && set.has('DAYS_SINCE_LAST_ACTIVE')) {
|
||||||
|
return FORMAT_MEETINGS_INACTIVE;
|
||||||
|
}
|
||||||
|
if (set.has('User ID/Email (Required)') && set.has('Days since Last Service Accessed')) {
|
||||||
|
return FORMAT_USERS_EXPORT;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Bounded-concurrency worker pool
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Runs `worker(item, idx)` across `items` with at most `limit` in flight.
|
||||||
|
// Never throws — each slot in the result array is either `{ok: true, value}`
|
||||||
|
// or `{ok: false, error}` so the caller can accumulate a per-item report.
|
||||||
|
|
||||||
|
export 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// 429/503-aware retry helper. Honors Retry-After (seconds).
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Webex license helpers
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function fetchAllLicenses() {
|
||||||
|
const data = await webex.listLicenses();
|
||||||
|
return Array.isArray(data?.items) ? data.items : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchSiteLicenses(siteUrl) {
|
||||||
|
const items = await fetchAllLicenses();
|
||||||
|
const want = (siteUrl || '').toLowerCase();
|
||||||
|
return items.filter((l) => (l.siteUrl || '').toLowerCase() === want);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function seatsFree(l) {
|
||||||
|
const total = Number(l.totalUnits ?? 0);
|
||||||
|
const used = Number(l.consumedUnits ?? 0);
|
||||||
|
return Math.max(0, total - used);
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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;
|
||||||
|
}
|
||||||
|
|
@ -65,6 +65,17 @@ import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { logger } from '../utils/logger.js';
|
import { logger } from '../utils/logger.js';
|
||||||
import webex from '../integrations/webex/WebexClient.js';
|
import webex from '../integrations/webex/WebexClient.js';
|
||||||
|
import {
|
||||||
|
readCsv,
|
||||||
|
detectFormat,
|
||||||
|
FORMAT_MEETINGS_INACTIVE,
|
||||||
|
FORMAT_USERS_EXPORT,
|
||||||
|
runPool,
|
||||||
|
callWithRetry,
|
||||||
|
fetchSiteLicenses,
|
||||||
|
seatsFree,
|
||||||
|
explainWebexError,
|
||||||
|
} from './lib/webexBulk.js';
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
// CLI parsing
|
// CLI parsing
|
||||||
|
|
@ -119,64 +130,10 @@ function printHelp() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
// CSV parsing (RFC 4180-ish; handles quoted fields, escaped "")
|
// Candidate extraction (CSV parsing, format detection, pool/retry helpers,
|
||||||
|
// and Webex license/error helpers live in ./lib/webexBulk.js)
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
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: [] };
|
|
||||||
// Preserve original header text (both reports we accept use different
|
|
||||||
// casings and one uses punctuation like "User ID/Email (Required)").
|
|
||||||
// Per-format extractors know the exact column names they need.
|
|
||||||
const header = parseCsvLine(lines[0]).map((h) => h.trim());
|
|
||||||
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 };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
// CSV format detection + candidate extraction
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const FORMAT_MEETINGS_INACTIVE = 'meetings-inactive';
|
|
||||||
const FORMAT_USERS_EXPORT = 'users-export';
|
|
||||||
|
|
||||||
function detectFormat(header) {
|
|
||||||
const set = new Set(header);
|
|
||||||
if (set.has('EMAIL') && set.has('IS_HOST') && set.has('DAYS_SINCE_LAST_ACTIVE')) {
|
|
||||||
return FORMAT_MEETINGS_INACTIVE;
|
|
||||||
}
|
|
||||||
if (set.has('User ID/Email (Required)') && set.has('Days since Last Service Accessed')) {
|
|
||||||
return FORMAT_USERS_EXPORT;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Users Export status values Webex considers "not currently in use".
|
// Users Export status values Webex considers "not currently in use".
|
||||||
// "Active" users are excluded regardless of last-access date.
|
// "Active" users are excluded regardless of last-access date.
|
||||||
const USERS_EXPORT_ELIGIBLE_STATUSES = new Set(['Inactive', 'Verified']);
|
const USERS_EXPORT_ELIGIBLE_STATUSES = new Set(['Inactive', 'Verified']);
|
||||||
|
|
@ -247,43 +204,10 @@ function extractCandidates(format, rows, minDays) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
// Bounded-concurrency worker pool
|
// Host license assignee lookup (this one stays local because reclaim is the
|
||||||
|
// only script that needs to map "email → personId" via the assignee roster)
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
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) {
|
async function fetchHostAssignees(licenseId) {
|
||||||
// Returns Map<lowercased-email, { id, displayName, email }>. If an
|
// Returns Map<lowercased-email, { id, displayName, email }>. If an
|
||||||
// assignee record has no email (shouldn't happen for internal users)
|
// assignee record has no email (shouldn't happen for internal users)
|
||||||
|
|
@ -298,36 +222,6 @@ async function fetchHostAssignees(licenseId) {
|
||||||
return byEmail;
|
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
|
// Main
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
474
scripts/removeAdvancedMessaging.js
Normal file
474
scripts/removeAdvancedMessaging.js
Normal file
|
|
@ -0,0 +1,474 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Bulk-remove Advanced Messaging + Advanced Space Meetings licenses.
|
||||||
|
*
|
||||||
|
* Reads a Control Hub "Users Export" CSV (Users → Manage users →
|
||||||
|
* Export). For every row that currently has either "Advanced Messaging
|
||||||
|
* [SubXXX]" = TRUE or "Advanced Space Meetings [SubXXX]" = TRUE, we
|
||||||
|
* PATCH `/v1/licenses/users` to atomically:
|
||||||
|
* 1. remove the Advanced Messaging license (if user has it), and
|
||||||
|
* 2. remove the Advanced Space Meetings license (if user has it), and
|
||||||
|
* 3. optionally add a Basic Messaging license if `--basic-messaging-
|
||||||
|
* license-id` is provided. Note: in most Webex orgs, "Basic
|
||||||
|
* Messaging" is a derived entitlement that's on automatically for
|
||||||
|
* any user with a base license — you probably do NOT need to add
|
||||||
|
* it explicitly. Removing the Advanced overlay leaves the user
|
||||||
|
* with the basic tier. Use dry-run to see what licenses your org
|
||||||
|
* actually has (the enumeration below filters on names matching
|
||||||
|
* /message|advanced|space|basic/i).
|
||||||
|
*
|
||||||
|
* Detection is authoritative: we fetch the assignee rosters of both
|
||||||
|
* Advanced licenses once up-front (paginated) and cross-reference the
|
||||||
|
* CSV emails. Anyone in the CSV who no longer holds either license is
|
||||||
|
* silently skipped, and personIds come straight off the assignee
|
||||||
|
* records — no per-user /people lookup.
|
||||||
|
*
|
||||||
|
* DRY-RUN by default. Nothing mutates without `--execute`. In dry-run
|
||||||
|
* we enumerate org licenses whose names look messaging-relevant so you
|
||||||
|
* can pick the right IDs.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* node scripts/removeAdvancedMessaging.js \
|
||||||
|
* --csv "/path/to/AdvanceMessaging.csv" \
|
||||||
|
* [--advanced-messaging-license-id <id>] \
|
||||||
|
* [--advanced-space-meetings-license-id <id>] \
|
||||||
|
* [--basic-messaging-license-id <id>] \
|
||||||
|
* [--concurrency 5] \
|
||||||
|
* [--limit N] [--offset N] \
|
||||||
|
* [--report remove-advmsg-report.csv] \
|
||||||
|
* [--execute]
|
||||||
|
*
|
||||||
|
* Environment defaults (read from .env):
|
||||||
|
* WEBEX_ADV_MSG_LICENSE_ID → --advanced-messaging-license-id
|
||||||
|
* WEBEX_ADV_SPACE_MTG_LICENSE_ID → --advanced-space-meetings-license-id
|
||||||
|
* WEBEX_BASIC_MSG_LICENSE_ID → --basic-messaging-license-id
|
||||||
|
*
|
||||||
|
* 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';
|
||||||
|
import {
|
||||||
|
readCsv,
|
||||||
|
detectFormat,
|
||||||
|
FORMAT_USERS_EXPORT,
|
||||||
|
runPool,
|
||||||
|
callWithRetry,
|
||||||
|
fetchAllLicenses,
|
||||||
|
seatsFree,
|
||||||
|
explainWebexError,
|
||||||
|
} from './lib/webexBulk.js';
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// CLI parsing
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function parseArgs(argv) {
|
||||||
|
const out = {
|
||||||
|
csv: null,
|
||||||
|
advancedMessagingLicenseId: process.env.WEBEX_ADV_MSG_LICENSE_ID || null,
|
||||||
|
advancedSpaceMeetingsLicenseId: process.env.WEBEX_ADV_SPACE_MTG_LICENSE_ID || null,
|
||||||
|
basicMessagingLicenseId: process.env.WEBEX_BASIC_MSG_LICENSE_ID || null,
|
||||||
|
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 '--advanced-messaging-license-id': out.advancedMessagingLicenseId = next(); break;
|
||||||
|
case '--advanced-space-meetings-license-id': out.advancedSpaceMeetingsLicenseId = next(); break;
|
||||||
|
case '--basic-messaging-license-id': out.basicMessagingLicenseId = next(); 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() {
|
||||||
|
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 column resolution
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// The subscription suffix `[Sub601269]` is org-specific. Match by
|
||||||
|
// prefix so orgs with different subscription ids still resolve.
|
||||||
|
|
||||||
|
function findColumn(header, prefix) {
|
||||||
|
const p = prefix.toLowerCase();
|
||||||
|
return header.find((h) => h.toLowerCase().startsWith(p)) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTrueCell(v) {
|
||||||
|
return (v || '').trim().toUpperCase() === 'TRUE';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Assignee roster union
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// For each provided license id, fetch its assignee roster and build a
|
||||||
|
// combined Map<email, { personId, displayName, holds: {advMsg, advSpace} }>.
|
||||||
|
// Anyone in either roster ends up here; the `holds` flags tell us
|
||||||
|
// which licenses to actually remove per user.
|
||||||
|
|
||||||
|
async function buildAssigneeUnion({ advMsgId, advSpaceId }) {
|
||||||
|
const union = new Map();
|
||||||
|
|
||||||
|
async function fold(licenseId, holdKey) {
|
||||||
|
if (!licenseId) return;
|
||||||
|
const users = await webex.listLicenseAssignees(licenseId);
|
||||||
|
for (const u of users) {
|
||||||
|
const email = (u?.email || '').toLowerCase();
|
||||||
|
if (!email || !u?.id) continue;
|
||||||
|
const existing = union.get(email);
|
||||||
|
if (existing) {
|
||||||
|
existing.holds[holdKey] = true;
|
||||||
|
} else {
|
||||||
|
union.set(email, {
|
||||||
|
personId: u.id,
|
||||||
|
displayName: u.displayName || email,
|
||||||
|
email,
|
||||||
|
holds: { advMsg: false, advSpace: false, [holdKey]: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await fold(advMsgId, 'advMsg');
|
||||||
|
await fold(advSpaceId, 'advSpace');
|
||||||
|
return union;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// 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`);
|
||||||
|
|
||||||
|
const format = detectFormat(header);
|
||||||
|
if (format !== FORMAT_USERS_EXPORT) {
|
||||||
|
console.error(
|
||||||
|
`❌ This script requires a "Users Export" CSV (needs the per-license\n` +
|
||||||
|
` TRUE/FALSE columns). Detected format: ${format ?? 'unknown'}.`,
|
||||||
|
);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
console.log(` → detected format: ${format}`);
|
||||||
|
|
||||||
|
const advMsgCol = findColumn(header, 'Advanced Messaging [');
|
||||||
|
const advSpaceCol = findColumn(header, 'Advanced Space Meetings [');
|
||||||
|
if (!advMsgCol || !advSpaceCol) {
|
||||||
|
console.error(
|
||||||
|
`❌ CSV missing expected license columns:\n` +
|
||||||
|
` Advanced Messaging → ${advMsgCol || '(not found)'}\n` +
|
||||||
|
` Advanced Space Meetings → ${advSpaceCol || '(not found)'}`,
|
||||||
|
);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
console.log(` → license columns: "${advMsgCol}", "${advSpaceCol}"`);
|
||||||
|
|
||||||
|
// Filter to rows that actually need one of the removals. Emails
|
||||||
|
// lowercased for the assignee cross-reference below.
|
||||||
|
const candidates = [];
|
||||||
|
let skipNoLicense = 0;
|
||||||
|
let skipBlankEmail = 0;
|
||||||
|
for (const r of rows) {
|
||||||
|
const email = (r['User ID/Email (Required)'] || '').trim().toLowerCase();
|
||||||
|
if (!email) { skipBlankEmail++; continue; }
|
||||||
|
const hasAdvMsg = isTrueCell(r[advMsgCol]);
|
||||||
|
const hasAdvSpace = isTrueCell(r[advSpaceCol]);
|
||||||
|
if (!hasAdvMsg && !hasAdvSpace) { skipNoLicense++; continue; }
|
||||||
|
candidates.push({
|
||||||
|
email,
|
||||||
|
displayName: r['Display Name'] || `${r['First Name'] || ''} ${r['Last Name'] || ''}`.trim() || email,
|
||||||
|
status: (r['User Status'] || '').trim(),
|
||||||
|
csvHasAdvMsg: hasAdvMsg,
|
||||||
|
csvHasAdvSpace: hasAdvSpace,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
` → ${candidates.length} candidates (rows with Adv Messaging=TRUE OR Adv Space Meetings=TRUE); ` +
|
||||||
|
`skipped ${skipNoLicense} rows with neither, ${skipBlankEmail} blank-email`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Enumerate org licenses that look messaging/space-relevant. The
|
||||||
|
// operator uses this list to pick the three ids for --execute.
|
||||||
|
console.log(`\n🔎 Fetching org licenses…`);
|
||||||
|
let allLicenses;
|
||||||
|
try {
|
||||||
|
allLicenses = await fetchAllLicenses();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`❌ Failed to list licenses: ${explainWebexError(err)}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const relevantRe = /message|advanced|space meeting|basic/i;
|
||||||
|
const relevant = allLicenses.filter((l) => relevantRe.test(l.name || ''));
|
||||||
|
console.log(` Relevant org licenses (${relevant.length} of ${allLicenses.length}):`);
|
||||||
|
for (const l of relevant) {
|
||||||
|
const free = seatsFree(l);
|
||||||
|
const markers = [];
|
||||||
|
if (l.id === args.advancedMessagingLicenseId) markers.push('ADV_MSG (to remove)');
|
||||||
|
if (l.id === args.advancedSpaceMeetingsLicenseId) markers.push('ADV_SPACE (to remove)');
|
||||||
|
if (l.id === args.basicMessagingLicenseId) markers.push('BASIC_MSG (to add)');
|
||||||
|
const mark = markers.length ? ` ← ${markers.join(', ')}` : '';
|
||||||
|
const site = l.siteUrl ? ` site=${l.siteUrl}` : '';
|
||||||
|
console.log(` • ${l.name} — ${free}/${l.totalUnits} free${site} — id=${l.id}${mark}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!args.advancedMessagingLicenseId && !args.advancedSpaceMeetingsLicenseId) {
|
||||||
|
console.error(
|
||||||
|
`\n❌ Need at least one of the following ids to proceed:\n` +
|
||||||
|
` --advanced-messaging-license-id <id> (env: WEBEX_ADV_MSG_LICENSE_ID)\n` +
|
||||||
|
` --advanced-space-meetings-license-id <id> (env: WEBEX_ADV_SPACE_MTG_LICENSE_ID)\n` +
|
||||||
|
` Pick from the list above.`,
|
||||||
|
);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate provided ids resolve to real licenses.
|
||||||
|
const licById = new Map(allLicenses.map((l) => [l.id, l]));
|
||||||
|
const advMsgLic = args.advancedMessagingLicenseId ? licById.get(args.advancedMessagingLicenseId) : null;
|
||||||
|
const advSpaceLic = args.advancedSpaceMeetingsLicenseId ? licById.get(args.advancedSpaceMeetingsLicenseId) : null;
|
||||||
|
const basicMsgLic = args.basicMessagingLicenseId ? licById.get(args.basicMessagingLicenseId) : null;
|
||||||
|
|
||||||
|
const badIds = [];
|
||||||
|
if (args.advancedMessagingLicenseId && !advMsgLic) badIds.push(['--advanced-messaging-license-id', args.advancedMessagingLicenseId]);
|
||||||
|
if (args.advancedSpaceMeetingsLicenseId && !advSpaceLic) badIds.push(['--advanced-space-meetings-license-id', args.advancedSpaceMeetingsLicenseId]);
|
||||||
|
if (args.basicMessagingLicenseId && !basicMsgLic) badIds.push(['--basic-messaging-license-id', args.basicMessagingLicenseId]);
|
||||||
|
if (badIds.length > 0) {
|
||||||
|
console.error(`\n❌ Invalid license ids:`);
|
||||||
|
for (const [flag, id] of badIds) console.error(` ${flag} ${id}`);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cross-reference: fetch the assignee union (up to two paginated
|
||||||
|
// sweeps) so we can (a) resolve personId per email and (b) only
|
||||||
|
// send the remove ops for licenses the user actually still holds.
|
||||||
|
console.log(`\n📥 Fetching current assignees…`);
|
||||||
|
let assignees;
|
||||||
|
try {
|
||||||
|
assignees = await buildAssigneeUnion({
|
||||||
|
advMsgId: advMsgLic?.id,
|
||||||
|
advSpaceId: advSpaceLic?.id,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`❌ Failed to fetch assignees: ${explainWebexError(err)}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log(` → ${assignees.size} distinct users hold at least one of the target licenses`);
|
||||||
|
|
||||||
|
const toProcess = [];
|
||||||
|
let skipNotHolder = 0;
|
||||||
|
for (const c of candidates) {
|
||||||
|
const a = assignees.get(c.email);
|
||||||
|
if (!a) { skipNotHolder++; continue; }
|
||||||
|
toProcess.push({
|
||||||
|
...c,
|
||||||
|
personId: a.personId,
|
||||||
|
displayName: a.displayName || c.displayName,
|
||||||
|
holdsAdvMsg: a.holds.advMsg,
|
||||||
|
holdsAdvSpace: a.holds.advSpace,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
` → ${toProcess.length} to process ` +
|
||||||
|
`(${skipNotHolder} CSV candidates no longer hold either license)`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const sliced = toProcess.slice(args.offset, args.limit ? args.offset + args.limit : undefined);
|
||||||
|
if (sliced.length !== toProcess.length) {
|
||||||
|
console.log(` → sliced to ${sliced.length} (offset=${args.offset}, limit=${args.limit ?? 'none'})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Basic-messaging capacity check (if the operator supplied one and
|
||||||
|
// it's a finite-seat license — some orgs meter Basic Messaging).
|
||||||
|
if (basicMsgLic && seatsFree(basicMsgLic) < sliced.length) {
|
||||||
|
console.warn(
|
||||||
|
`\n⚠️ Basic Messaging license \`${basicMsgLic.name}\` has ` +
|
||||||
|
`${seatsFree(basicMsgLic)} free seats but ${sliced.length} adds are planned. ` +
|
||||||
|
`Extras will fail.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mutation = describeMutation({ advMsgLic, advSpaceLic, basicMsgLic });
|
||||||
|
console.log(`\n🛠 Planned mutation per user: ${mutation}`);
|
||||||
|
|
||||||
|
if (!args.execute) {
|
||||||
|
console.log(`\n🚦 DRY-RUN (no changes made). Re-run with --execute to commit.`);
|
||||||
|
if (sliced.length > 0) {
|
||||||
|
console.log(`\n Sample of first 5 candidates:`);
|
||||||
|
for (const s of sliced.slice(0, 5)) {
|
||||||
|
const ops = [];
|
||||||
|
if (advMsgLic && s.holdsAdvMsg) ops.push('-adv-msg');
|
||||||
|
if (advSpaceLic && s.holdsAdvSpace) ops.push('-adv-space');
|
||||||
|
if (basicMsgLic) ops.push('+basic-msg');
|
||||||
|
console.log(` - ${s.displayName} <${s.email}> status=${s.status || '?'} ops=[${ops.join(', ')}] personId=${s.personId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute with bounded concurrency + 429 retry.
|
||||||
|
console.log(
|
||||||
|
`\n🚀 EXECUTING against ${sliced.length} users ` +
|
||||||
|
`(concurrency=${args.concurrency}). Ctrl-C to abort.\n`,
|
||||||
|
);
|
||||||
|
logger(
|
||||||
|
'webex:advmsg:audit',
|
||||||
|
`START remove-advmsg: adv-msg=${advMsgLic?.id || 'skip'} ` +
|
||||||
|
`adv-space=${advSpaceLic?.id || 'skip'} basic-msg=${basicMsgLic?.id || 'skip'} ` +
|
||||||
|
`count=${sliced.length} csv=${path.basename(csvPath)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
let processed = 0;
|
||||||
|
const results = await runPool(sliced, args.concurrency, async (user) => {
|
||||||
|
const licenses = [];
|
||||||
|
if (advMsgLic && user.holdsAdvMsg) licenses.push({ id: advMsgLic.id, operation: 'remove' });
|
||||||
|
if (advSpaceLic && user.holdsAdvSpace) licenses.push({ id: advSpaceLic.id, operation: 'remove' });
|
||||||
|
if (basicMsgLic) licenses.push({ id: basicMsgLic.id, operation: 'add' });
|
||||||
|
|
||||||
|
// Shouldn't happen — every entry in `sliced` holds at least one
|
||||||
|
// of the two Advanced licenses. Defensive skip anyway so we don't
|
||||||
|
// send an empty PATCH body.
|
||||||
|
if (licenses.length === 0) {
|
||||||
|
return { skipped: 'no-op' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = { personId: user.personId, licenses };
|
||||||
|
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 currentLicenses = new Set(r.value?.licenses || []);
|
||||||
|
const advMsgGone = !advMsgLic || !currentLicenses.has(advMsgLic.id);
|
||||||
|
const advSpaceGone = !advSpaceLic || !currentLicenses.has(advSpaceLic.id);
|
||||||
|
const basicOk = !basicMsgLic || currentLicenses.has(basicMsgLic.id);
|
||||||
|
const outcome = advMsgGone && advSpaceGone && basicOk ? 'ok' : 'partial';
|
||||||
|
logger('webex:advmsg:audit', `OK ${u.email} personId=${u.personId} outcome=${outcome}`);
|
||||||
|
perUser.push({
|
||||||
|
email: u.email,
|
||||||
|
displayName: u.displayName,
|
||||||
|
status: u.status || '',
|
||||||
|
removed_adv_msg: advMsgLic && u.holdsAdvMsg ? 'yes' : 'no',
|
||||||
|
removed_adv_space: advSpaceLic && u.holdsAdvSpace ? 'yes' : 'no',
|
||||||
|
added_basic_msg: basicMsgLic ? 'yes' : 'no',
|
||||||
|
personId: u.personId,
|
||||||
|
outcome,
|
||||||
|
error: '',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
failed++;
|
||||||
|
const msg = explainWebexError(r.error);
|
||||||
|
failures.push({ user: u, msg });
|
||||||
|
logger('webex:advmsg:audit', `FAIL ${u.email} personId=${u.personId}: ${msg}`, 'error');
|
||||||
|
perUser.push({
|
||||||
|
email: u.email,
|
||||||
|
displayName: u.displayName,
|
||||||
|
status: u.status || '',
|
||||||
|
removed_adv_msg: advMsgLic && u.holdsAdvMsg ? 'attempted' : 'no',
|
||||||
|
removed_adv_space: advSpaceLic && u.holdsAdvSpace ? 'attempted' : 'no',
|
||||||
|
added_basic_msg: basicMsgLic ? 'attempted' : 'no',
|
||||||
|
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', 'status',
|
||||||
|
'removed_adv_msg', 'removed_adv_space', 'added_basic_msg',
|
||||||
|
'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:advmsg:audit',
|
||||||
|
`END remove-advmsg: ok=${ok} failed=${failed} total=${sliced.length}`,
|
||||||
|
);
|
||||||
|
process.exit(failed === 0 ? 0 : 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeMutation({ advMsgLic, advSpaceLic, basicMsgLic }) {
|
||||||
|
const parts = [];
|
||||||
|
if (advMsgLic) parts.push(`remove \`${advMsgLic.name}\` (if held)`);
|
||||||
|
if (advSpaceLic) parts.push(`remove \`${advSpaceLic.name}\` (if held)`);
|
||||||
|
if (basicMsgLic) parts.push(`add \`${basicMsgLic.name}\``);
|
||||||
|
if (parts.length === 0) return '(nothing — no ids provided)';
|
||||||
|
return parts.join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error(`\n💥 Unhandled: ${err?.stack || err}`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue