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)
474 lines
19 KiB
JavaScript
474 lines
19 KiB
JavaScript
#!/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);
|
|
});
|