UNPKG

sfdx-hardis

Version:

Swiss-army-knife Toolbox for Salesforce. Allows you to define a complete CD/CD Pipeline. Orchestrate base commands and assist users with interactive wizards

605 lines (598 loc) • 31.6 kB
// QUESTION: Where are the objects extracted in this code? // ANSWER: The objects are extracted in the generateObjectsList function. // This function calls conn.describeGlobal() to get all SObjects, then queries each one to check if it has records. // The user is then prompted to select which objects to extract. The selected objects are returned and used in the rest of the extraction process. import { SfCommand, Flags } from '@salesforce/sf-plugins-core'; import { prompts } from '../../../../common/utils/prompts.js'; import { uxLog } from '../../../../common/utils/index.js'; import c from 'chalk'; import { generateCsvFile, generateReportPath, createXlsxFromCsvFiles } from '../../../../common/utils/filesUtils.js'; import { bulkQuery } from '../../../../common/utils/apiUtils.js'; import * as path from 'path'; import { getReportDirectory } from '../../../../config/index.js'; import { Messages } from '@salesforce/core'; Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); const messages = Messages.loadMessages('sfdx-hardis', 'org'); // Main command class for extracting Salesforce org profiles and related metadata export default class ProfilesExtract extends SfCommand { static description = ` ## Command Behavior **Guides administrators through extracting Salesforce profiles, personas, and related metadata into structured CSV/XLSX deliverables.** The command inventories SObjects that contain data, lets the user pick the ones to document, and then produces persona-centric spreadsheets that cover users, personas, relationships, record types, apps, permissions, tabs, fields, and permission sets. The output consolidates everything into both CSV files and a single Excel workbook, making it easy to audit access models or prepare remediation plans. Key capabilities: - **Interactive object discovery:** Lists queryable objects with records and allows multi-selection. - **Persona modeling:** Lets users define the number of personas and generates cross-object matrices that leverage Excel formulas for faster updates. - **Comprehensive metadata export:** Captures users, record types, apps, permissions, tabs, fields, and permission sets with persona/profile visibility indicators. - **Profile field access coverage:** Retrieves FieldPermissions to surface read/edit status per profile and field. - **Consolidated reporting:** Produces standalone CSVs plus an aggregated XLSX stored in the report directory. <details markdown="1"> <summary>Technical explanations</summary> - **Salesforce connectivity:** Uses the requested target org connection from \`Flags.requiredOrg\` to fetch metadata and records. - **Bulk/REST queries:** Relies on \`bulkQuery\` and standard SOQL to evaluate record counts and pull FieldPermissions, Users, RecordTypes, Applications, Tabs, and PermissionSets. - **Describe calls:** Invokes \`describeGlobal\` and \`describeSObject\` to enumerate objects and field-level metadata, including picklists and formulas. - **Prompt-driven input:** Utilizes the shared \`prompts\` utility to collect object selections and persona counts, ensuring consistent CLI UX. - **Reporting pipeline:** Writes intermediate CSV files via \`generateCsvFile\`, stores them under the report directory from \`getReportDirectory\`, and finally merges them using \`createXlsxFromCsvFiles\`. - **Logging & diagnostics:** Uses \`uxLog\` with chalk coloring for progress, warnings, and debug output, integrating with the project-wide logging style. </details> `; static examples = [ `$ sf hardis:project:clean:profiles-extract`, `$ sf hardis:project:clean:profiles-extract --target-org my-org`, ]; /* jscpd:ignore-start */ static flags = { 'target-org': Flags.requiredOrg({ char: 'o', description: 'The target Salesforce org to fetch SObjects from.', }), debug: Flags.boolean({ char: 'd', default: false, description: messages.getMessage('debugMode'), }), websocket: Flags.string({ description: messages.getMessage('websocket'), }), skipauth: Flags.boolean({ description: 'Skip authentication check when a default username is required', }), }; /* jscpd:ignore-end */ csvFiles = []; outputFile = ''; activeProfileNames = new Set(); /** * Main entry point for the command. Orchestrates the extraction process: * - Prompts user to select objects * - Extracts users, personas, relations, record types, apps, permissions, tabs, and object fields * - Generates CSV and XLSX reports */ async run() { const { flags } = await this.parse(ProfilesExtract); const conn = flags['target-org'].getConnection(); let selectedObjects = []; let numberOfPersonas = 1; try { selectedObjects = await this.generateObjectsList(conn); uxLog("action", this, c.green('Handling extract of Users...')); await this.generateUsersExtract(conn); numberOfPersonas = await this.generatePersonaExtract(); uxLog("action", this, c.green(`Generating extracts for ${numberOfPersonas} personas...`)); await this.generateRelationExtract(selectedObjects, numberOfPersonas); await this.generateRTExtract(conn, selectedObjects, numberOfPersonas); await this.generateAppsExtract(conn, numberOfPersonas); await this.generatePermissionsExtract(conn, numberOfPersonas); await this.generateTabsExtract(conn, numberOfPersonas); await this.generatePermissionSetsExtract(conn, numberOfPersonas); // 1. Extract profile field access and get all profiles uxLog("action", this, c.green('Extracting profile field access...')); const profileFieldAccess = await this.getProfileFieldAccessData(conn, selectedObjects); const profileNames = Array.from(new Set(profileFieldAccess.map(r => r.Profile).filter(Boolean))); // 2. Pass profileNames to generateObjectFieldsExtract await this.generateObjectFieldsExtract(conn, selectedObjects, numberOfPersonas, profileNames, profileFieldAccess); // 3. Write the profile field access CSV (as before) if (profileFieldAccess.length > 0) { const reportDir = await getReportDirectory(); this.outputFile = path.join(reportDir, 'ProfileFieldAccess.csv'); await generateCsvFile(profileFieldAccess, this.outputFile, { fileTitle: 'Profile Field Access', noExcel: true }); this.csvFiles.push(this.outputFile); } else { uxLog('log', this, c.yellow('No profile field access records found, skipping ProfileFieldAccess.csv.')); } this.outputFile = ''; this.outputFile = await generateReportPath('profiles-extract', this.outputFile); uxLog("action", this, c.green('Generating final XLSX report...')); await createXlsxFromCsvFiles(this.csvFiles, this.outputFile, { fileTitle: 'profiles extract' }); } catch (error) { uxLog('log', this, c.red('Failed to fetch SObjects.')); throw error; } } /** * Extracts field-level access for profiles using FieldPermissions object. * Generates a CSV report of profile-field access. * @param conn Salesforce connection */ // Returns all profile field access records as an array async getProfileFieldAccessData(conn, selectedObjects) { const fieldAccessRecords = []; try { let soql = `SELECT Field, PermissionsRead, PermissionsEdit, SObjectType, Parent.Profile.Name FROM FieldPermissions WHERE Parent.ProfileId != null`; if (selectedObjects && selectedObjects.length > 0) { const objectList = selectedObjects.map(obj => `'${obj}'`).join(", "); soql += ` AND SObjectType IN (${objectList})`; } // Add filter for active profiles if (this.activeProfileNames && this.activeProfileNames.size > 0) { const profileList = Array.from(this.activeProfileNames) .filter((name) => !!name) .map(name => `'${name.replace(/'/g, "''")}'`).join(", "); soql += ` AND Parent.Profile.Name IN (${profileList})`; } const result = await bulkQuery(soql, conn); result.records.forEach((rec) => { fieldAccessRecords.push({ Profile: rec['Parent.Profile.Name'], SObjectType: rec['SobjectType'], Field: rec['Field'], PermissionsRead: rec['PermissionsRead'] === true || rec['PermissionsRead'] === 'true' ? 'Yes' : 'No', PermissionsEdit: rec['PermissionsEdit'] === true || rec['PermissionsEdit'] === 'true' ? 'Yes' : 'No', }); }); uxLog('log', this, c.green(`Fetched ${fieldAccessRecords.length} profile field access records.`)); } catch (error) { uxLog('warning', this, c.yellow(`Failed to query FieldPermissions: ${error.message}`)); } return fieldAccessRecords; } /** * Prompts the user to select Salesforce objects (SObjects) that have records in the org. * Generates a CSV report of the selected objects. * @param conn Salesforce connection * @returns Array of selected object API names */ async generateObjectsList(conn) { let selectedObjects = []; uxLog('action', this, c.green('Fetching SObjects list...')); let sobjectsList = []; try { // Exclude objects whose API names start with any of these prefixes const excludedPrefixes = [ 'Active', 'Apex', 'AuraDefinition', 'Business', 'Content', 'Dashboard', 'Email', 'Flow', 'Forecasting', 'Formula', 'ListView', 'LoginGeo', 'Marketing', 'MatchingRule', 'PermissionSet', 'UiFormula', 'WebLink', 'pi__' ]; // Exclude objects whose API names are in this explicit list const excludedObjects = [ 'AppDefinition', 'AppMenu', 'AssignmentRule', 'AsyncApexJob', 'AuthProvider', 'AuthSession', 'BrowserPolicyViolation', 'CampaignInfluenceModel', 'CaseStatus', 'ClientBrowser', 'Community', 'ConnectedApplication', 'ContractStatus', 'CronJobDetail', 'CronTrigger', 'CustomNotificationType', 'CustomPermission', 'DataType', 'DeleteEvent', 'Domain', 'DuplicateRule', 'EntityDefinition', 'FeedItem', 'FieldPermissions', 'FieldSecurityClassification', 'FileSearchActivity', 'FiscalYearSettings', 'Folder', 'Group', 'GroupMember', 'NamedCredential', 'OauthToken', 'ObjectPermissions', 'OrderStatus', 'OrgWideEmailAddress', 'Organization', 'PackageLicense', 'PartnerRole', 'Period', 'Profile', 'Publisher', 'QueueSobject', 'RecentlyViewed', 'RecordType', 'Report', 'Scontrol', 'SetupAuditTrail', 'SetupEntityAccess', 'SolutionStatus', 'StandardInvocableActionType', 'StaticResource', 'TabDefinition', 'TenantUsageEntitlement', 'Translation', 'VerificationHistory' ]; const sobjects = await conn.describeGlobal(); sobjectsList = sobjects.sobjects .filter(sobj => sobj.queryable && !excludedPrefixes.some(prefix => sobj.name.startsWith(prefix)) && !excludedObjects.includes(sobj.name) && !sobj.name.endsWith('History') && !sobj.name.endsWith('Share')) .map((sobject) => ({ label: sobject.label, name: sobject.name, masterObject: '', objectType: sobject.name.endsWith('__c') ? 'Custom' : 'Standard', })); uxLog('log', this, c.green('Fetching SObjects completed.')); uxLog('log', this, c.green(`Fetched ${sobjectsList.length} SObjects.`)); const sobjectsWithRecords = []; uxLog('action', this, 'Checking SObjects for records...'); for (const sobject of sobjectsList) { try { const result = await conn.query(`SELECT COUNT() FROM ${sobject.name}`); if (result.totalSize > 0) { sobjectsWithRecords.push({ Object_Label: sobject.label, API_Name: sobject.name, Object_Type: sobject.objectType }); } uxLog('log', this, `Checked ${sobject.name}: ${result.totalSize} records.`); } catch (error) { uxLog('warning', this, c.yellow(`Failed to query ${sobject.name}: ${error.message}`)); } } this.spinner.stop(); if (sobjectsWithRecords.length === 0) { uxLog('warning', this, c.red('No SObjects with records found.')); return []; } const choices = []; for (const sobject of sobjectsWithRecords) { choices.push({ title: `${sobject.API_Name} - ${sobject.Object_Label} - ${sobject.Object_Type}`, value: sobject.API_Name, }); } const statusRes = await prompts({ message: "Please select SObjects to add in the output Excel file", type: "multiselect", description: "Be careful, you can't update the selection later without re-running the command :)", choices: choices, }); if (statusRes && statusRes.value !== "all") { selectedObjects = statusRes.value; uxLog('log', this, `You selected ${selectedObjects.length} objects.`); } uxLog("log", this, c.green('Generating Objects.csv report...')); const reportDir = await getReportDirectory(); this.outputFile = path.join(reportDir, 'Objects.csv'); // Without xlsx await generateCsvFile(sobjectsWithRecords.filter((sobj) => selectedObjects.includes(sobj.API_Name)), this.outputFile, { fileTitle: 'profiles extract', noExcel: true }); // With xlsx // this.outputFilesRes = await generateCsvFile(sobjectsWithRecords.filter((sobj) => selectedObjects.includes(sobj.API_Name)), this.outputFile, { fileTitle: 'profiles extract' }); this.csvFiles.push(this.outputFile); } catch (error) { uxLog('log', this, c.red('Failed to fetch SObjects.')); throw error; } return selectedObjects; } /** * Extracts active users from the org, including their username, role, and profile. * Generates a CSV report of users. * @param conn Salesforce connection */ async generateUsersExtract(conn) { const usersRecords = []; const userQuery = "SELECT username, UserRole.Name, Profile.Name FROM User WHERE IsActive = true order by username"; const userResult = await bulkQuery(userQuery, conn); usersRecords.push(...userResult.records.map((user) => ({ User: user.Username, Role: user['UserRole.Name'], Profile: user['Profile.Name'], Profile_to_Associate: '', New_Persona: '', New_Role: '', }))); // Build set of active profile names (filter out empty/null) this.activeProfileNames = new Set(userResult.records.map((user) => user['Profile.Name']).filter((n) => !!n)); uxLog('log', this, c.green(`Fetched ${userResult.records.length} active users.`)); uxLog('log', this, c.cyan(`Active profiles: ${Array.from(this.activeProfileNames).join(', ')}`)); const reportDir = await getReportDirectory(); this.outputFile = path.join(reportDir, 'Users.csv'); await generateCsvFile(usersRecords, this.outputFile, { fileTitle: 'Users extract', noExcel: true }); this.csvFiles.push(this.outputFile); return; } /** * Prompts the user for the number of personas to create. * Generates a CSV report listing the personas. * @returns The number of personas */ async generatePersonaExtract() { let numberOfPersonas = 1; const statusRes = await prompts({ message: "Please enter the number of personas to create", type: "number", description: "One tab by personal will be created in the final Excel file", placeholder: "Input a number of personas (better too much than too few!)", }); if (statusRes && statusRes.value !== 0) { numberOfPersonas = statusRes.value; uxLog('log', this, `Creation of ${numberOfPersonas} personas.`); } const persona = []; for (let i = 1; i <= numberOfPersonas; i++) { persona.push({ 'Persona Name': `Persona${i}` }); } const reportDir = await getReportDirectory(); this.outputFile = path.join(reportDir, 'persona.csv'); await generateCsvFile(persona, this.outputFile, { fileTitle: 'persona extract', noExcel: true }); this.csvFiles.push(this.outputFile); return numberOfPersonas; } /** * Generates a CSV mapping each selected object to persona permissions (Read, Create, Edit, Delete). * @param selectedObjects Array of object API names * @param numberOfPersonas Number of personas */ async generateRelationExtract(selectedObjects, numberOfPersonas) { const relationRecords = []; // Generate dynamic persona columns using Excel formulas selectedObjects.forEach((objName) => { const personaCols = {}; for (let i = 1; i <= numberOfPersonas; i++) { // Persona row in persona sheet is i+1 (header is row 1) const personaRow = i + 1; personaCols[`=persona!A${personaRow}&"_Read"`] = ''; personaCols[`=persona!A${personaRow}&"_Create"`] = ''; personaCols[`=persona!A${personaRow}&"_Edit"`] = ''; personaCols[`=persona!A${personaRow}&"_Delete"`] = ''; personaCols[`=persona!A${personaRow}&"_ViewAll"`] = ''; personaCols[`=persona!A${personaRow}&"_ModifyAll"`] = ''; } relationRecords.push({ Object: objName, ...personaCols, }); }); const reportDir = await getReportDirectory(); this.outputFile = path.join(reportDir, 'Relation.csv'); await generateCsvFile(relationRecords, this.outputFile, { fileTitle: 'Relation Object Persona', noExcel: true }); this.csvFiles.push(this.outputFile); return; } /** * Extracts record types for each selected object and maps persona access (Active, Default). * Generates a CSV report of record types per object. * @param conn Salesforce connection * @param selectedObjects Array of object API names * @param numberOfPersonas Number of personas */ async generateRTExtract(conn, selectedObjects, numberOfPersonas) { const recordTypesRecords = []; for (const objName of selectedObjects) { try { const rtResult = await conn.query(`SELECT Id, Name, DeveloperName FROM RecordType WHERE SobjectType='${objName}' ORDER BY Name`); rtResult.records.forEach((rt) => { const personaCols = {}; for (let i = 1; i <= numberOfPersonas; i++) { const personaRow = i + 1; personaCols[`=persona!A${personaRow}&"_Active"`] = ''; personaCols[`=persona!A${personaRow}&"_Default"`] = ''; } recordTypesRecords.push({ Object: objName, Record_Type: rt.Name, ...personaCols, }); }); } catch (error) { uxLog('warning', this, c.yellow(`Failed to query RecordTypes for ${objName}: ${error.message}`)); } } const reportDir = await getReportDirectory(); this.outputFile = path.join(reportDir, 'RecordTypes.csv'); await generateCsvFile(recordTypesRecords, this.outputFile, { fileTitle: 'Record Types extract', noExcel: true }); this.csvFiles.push(this.outputFile); return; } /** * Extracts custom applications (AppDefinition) and maps persona access (Active, Default). * Generates a CSV report of applications. * @param conn Salesforce connection * @param numberOfPersonas Number of personas */ /* jscpd:ignore-start */ async generateAppsExtract(conn, numberOfPersonas) { const appsRecords = []; try { const rtResult = await conn.query(`SELECT Id, DurableId, DeveloperName, MasterLabel FROM AppDefinition WHERE NamespacePrefix != 'standard'`); rtResult.records.forEach((rt) => { const personaCols = {}; for (let i = 1; i <= numberOfPersonas; i++) { const personaRow = i + 1; personaCols[`=persona!A${personaRow}&"_Active"`] = ''; personaCols[`=persona!A${personaRow}&"_Default"`] = ''; } appsRecords.push({ Application: rt.MasterLabel, DeveloperName: rt.DeveloperName, ...personaCols, }); }); } catch (error) { uxLog('warning', this, c.yellow(`Failed to query Applications : ${error.message}`)); } const reportDir = await getReportDirectory(); this.outputFile = path.join(reportDir, 'Applications.csv'); await generateCsvFile(appsRecords, this.outputFile, { fileTitle: 'Applications extract', noExcel: true }); this.csvFiles.push(this.outputFile); return; } /* jscpd:ignore-end */ /** * Extracts all boolean permission fields from PermissionSet object. * Generates a CSV report mapping each permission to personas. * @param conn Salesforce connection * @param numberOfPersonas Number of personas */ async generatePermissionsExtract(conn, numberOfPersonas) { const permissionsRecords = []; // Describe the PermissionSet object const desc = await conn.describeSObject("PermissionSet"); // Filter only fields that start with "Permissions" const permissionFields = desc.fields.filter(f => f.name.startsWith("Permissions") && f.type === "boolean"); // Print API name + Label permissionFields.forEach(field => { const personaCols = {}; for (let i = 1; i <= numberOfPersonas; i++) { const personaRow = i + 1; personaCols[`=persona!A${personaRow}`] = ''; } permissionsRecords.push({ Permission_Label: field.label, Permission_API_Name: field.name, ...personaCols, }); }); const reportDir = await getReportDirectory(); this.outputFile = path.join(reportDir, 'Permissions.csv'); await generateCsvFile(permissionsRecords, this.outputFile, { fileTitle: 'Permissions extract', noExcel: true }); this.csvFiles.push(this.outputFile); return; } /** * Extracts custom tabs and maps persona access. * Generates a CSV report of tabs. * @param conn Salesforce connection * @param numberOfPersonas Number of personas */ async generateTabsExtract(conn, numberOfPersonas) { const tabsRecords = []; try { const tabsResult = await conn.query(`SELECT Name, Label FROM TabDefinition WHERE IsCustom = true`); tabsResult.records.forEach((tab) => { const personaCols = {}; for (let i = 1; i <= numberOfPersonas; i++) { const personaRow = i + 1; personaCols[`=persona!A${personaRow}`] = ''; } tabsRecords.push({ Tab_Label: tab.Label, Tab_API_Name: tab.Name, ...personaCols, }); }); const reportDir = await getReportDirectory(); this.outputFile = path.join(reportDir, 'Tabs.csv'); await generateCsvFile(tabsRecords, this.outputFile, { fileTitle: 'Tabs extract', noExcel: true }); this.csvFiles.push(this.outputFile); } catch (error) { uxLog('warning', this, c.yellow(`Failed to query Tabs : ${error.message}`)); } return; } /** * For each selected object, extracts all fields and their metadata (label, type, picklist values, etc.). * Maps persona visibility and read-only status for each field. * Generates a CSV report per object. * @param conn Salesforce connection * @param selectedObjects Array of object API names * @param numberOfPersonas Number of personas */ async generateObjectFieldsExtract(conn, selectedObjects, numberOfPersonas, profileNames = [], profileFieldAccess = []) { const fieldsRecords = []; for (const objName of selectedObjects) { try { fieldsRecords.length = 0; const desc = await conn.describeSObject(objName); desc.fields.forEach((field) => { let picklistValues = ''; if (field.picklistValues && field.picklistValues.length > 0) { picklistValues = field.picklistValues.map(pv => pv.value).join('; '); } // Add persona columns using formula logic const personaCols = {}; for (let i = 1; i <= numberOfPersonas; i++) { const personaRow = i + 1; personaCols[`=persona!A${personaRow}&"_View"`] = ''; personaCols[`=persona!A${personaRow}&"_Edit"`] = ''; } // Add one column per profile, fill with 'none', 'edit', or 'read' const profileCols = (profileNames || []).reduce((acc, profile) => { // Find access for this field/profile/object (case-insensitive, and check both SObjectType and API_Name) const access = profileFieldAccess.find((rec) => { const profileMatch = rec.Profile === profile; const objectMatch = (rec.SObjectType === objName || rec.SObjectType?.toLowerCase() === objName.toLowerCase()); // rec.Field can be 'ObjectName.FieldName' or just 'FieldName' let recFieldName = rec.Field; if (recFieldName && recFieldName.includes('.')) { recFieldName = recFieldName.split('.').pop(); } const fieldMatch = (recFieldName === field.name || recFieldName?.toLowerCase() === field.name.toLowerCase()); return profileMatch && objectMatch && fieldMatch; }); let value = 'none'; if (access) { if (access.PermissionsEdit === 'Yes') { value = 'edit'; } else if (access.PermissionsRead === 'Yes') { value = 'read'; } } acc[`Profile_${profile}`] = value; return acc; }, {}); fieldsRecords.push({ Field_Label: field.label, API_Name: field.name, Data_Type: field.type, Length: field.length ? field.length.toString() : '', Field_Type: field.calculated ? 'Formula' : (field.type === 'reference' ? 'Lookup' : field.type), Required: field.nillable ? 'No' : 'Yes', PicklistValues: picklistValues, Formula: field.calculated ? field.calculatedFormula : '', ExternalId: field.externalId ? 'Yes' : 'No', TrackHistory: field.trackHistory ? 'Yes' : 'No', Description: field.description ? field.description : '', HelpText: field.inlineHelpText ? field.inlineHelpText : '', ...personaCols, ...profileCols, }); }); } catch (error) { uxLog('warning', this, c.yellow(`Failed to describe fields for ${objName}: ${error.message}`)); } const reportDir = await getReportDirectory(); this.outputFile = path.join(reportDir, `${objName} Fields.csv`); await generateCsvFile(fieldsRecords, this.outputFile, { fileTitle: `${objName} Fields extract`, noExcel: true }); this.csvFiles.push(this.outputFile); } return; } /** * Extracts all Permission Sets in the org. * Generates a CSV report mapping each permission set to personas. * @param conn * @param numberOfPersonas */ async generatePermissionSetsExtract(conn, numberOfPersonas) { const permissionSetsRecords = []; try { const result = await conn.query(`SELECT Name, Label, Description, IsCustom FROM PermissionSet WHERE IsOwnedByProfile = false`); result.records.forEach((ps) => { const personaCols = {}; for (let i = 1; i <= numberOfPersonas; i++) { const personaRow = i + 1; personaCols[`=persona!A${personaRow}&"_Assigned"`] = ''; // Dynamic column for each persona } permissionSetsRecords.push({ Name: ps.Name, Label: ps.Label || '', Description: ps.Description || '', IsCustom: ps.IsCustom ? 'Yes' : 'No', ...personaCols, }); }); const reportDir = await getReportDirectory(); this.outputFile = path.join(reportDir, 'PermissionSets.csv'); await generateCsvFile(permissionSetsRecords, this.outputFile, { fileTitle: 'Permission Sets extract', noExcel: true, }); this.csvFiles.push(this.outputFile); } catch (error) { uxLog('warning', this, c.yellow(`Failed to query PermissionSets: ${error.message}`)); } } } //# sourceMappingURL=profiles-extract.js.map