Skip to content

Commit

Permalink
Merge pull request #143 from texei/create-empty-profile
Browse files Browse the repository at this point in the history
Create empty profile
  • Loading branch information
FabienTaillon authored Nov 16, 2023
2 parents da335a8 + 8eb20b3 commit c600025
Show file tree
Hide file tree
Showing 10 changed files with 3,656 additions and 3,080 deletions.
19 changes: 19 additions & 0 deletions messages/skinnyprofile.create.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# summary

create a profile on target org with minimum access

# description

command description

# examples

sf texei skinnyprofile create

# flags.path.summary

path to profiles folder. Default: default package directory

# flags.ignoreerrors.summary

if any profile creation fails, command exits as succeeded anyway
3,215 changes: 1,701 additions & 1,514 deletions oclif.lock

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "texei-sfdx-plugin",
"description": "Texeï's plugin for sfdx",
"version": "2.1.3",
"version": "2.2.0",
"author": "Texeï",
"bugs": "https://github.com/texei/texei-sfdx-plugin/issues",
"dependencies": {
Expand All @@ -17,6 +17,7 @@
"chalk": "^4.1.2",
"child-process-promise": "^2.2.1",
"csvtojson": "^2.0.10",
"fast-xml-parser": "^4.3.2",
"puppeteer": "^21.0.3",
"tslib": "^2",
"xml2js": "^0.5.0"
Expand Down
31 changes: 5 additions & 26 deletions src/commands/texei/profile/clean.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import util = require('util');
import { SfCommand, Flags, loglevel } from '@salesforce/sf-plugins-core';
import { Messages, SfError } from '@salesforce/core';
import xml2js = require('xml2js');
import { getDefaultPackagePath } from '../../../shared/sfdxProjectFolder';
import { getDefaultPackagePath, getProfilesInPath } from '../../../shared/sfdxProjectFolder';

// Initialize Messages with the current plugin directory
Messages.importMessagesDirectory(__dirname);
Expand Down Expand Up @@ -45,12 +45,12 @@ export default class Clean extends SfCommand<ProfileCleanResult> {
public async run(): Promise<ProfileCleanResult> {
const { flags } = await this.parse(Clean);

const cleanResult = [];
const cleanResult: string[] = [];

// TODO: Keep default recordTypeVisibilities & applicationVisibilities like in skinnyprofile:retrieve
const defaultKeep = ['layoutAssignments', 'loginHours', 'loginIpRanges', 'custom', 'userLicense'];
const nodesToKeep = flags.keep ? flags.keep : defaultKeep;
let profilesToClean = [];
let profilesToClean: string[] = [];

// Get profiles files path
if (flags.path) {
Expand All @@ -66,13 +66,13 @@ export default class Clean extends SfCommand<ProfileCleanResult> {
} else {
// Flag provided value doesn't end like a Profile source metadata
// Expect it's a folder
profilesToClean = await this.getProfilesInPath(currentPath);
profilesToClean = await getProfilesInPath(currentPath, true);
}
}
} else {
// Else look in the default package directory
const defaultPackageDirectory = path.join(await getDefaultPackagePath(), 'profiles');
profilesToClean = await this.getProfilesInPath(defaultPackageDirectory);
profilesToClean = await getProfilesInPath(defaultPackageDirectory, true);
}

// eslint-disable-next-line eqeqeq
Expand Down Expand Up @@ -127,25 +127,4 @@ export default class Clean extends SfCommand<ProfileCleanResult> {

return { profilesCleaned: cleanResult };
}

private async getProfilesInPath(pathToRead: string) {
const profilesInPath = [];

const readDirectory = util.promisify(fs.readdir);
const filesInDir = await readDirectory(pathToRead);

for (const fileInDir of filesInDir) {
const dirOrFilePath = path.join(process.cwd(), pathToRead, fileInDir);

// If it's a Profile file, add it
if (!fs.lstatSync(dirOrFilePath).isDirectory() && fileInDir.endsWith('.profile-meta.xml')) {
const profileFoundPath = path.join(pathToRead, fileInDir);

// @ts-ignore: TODO: working code, but look at TS warning
profilesInPath.push(profileFoundPath);
}
}

return profilesInPath;
}
}
11 changes: 10 additions & 1 deletion src/commands/texei/skinnyprofile/MetadataTypes.d.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,12 @@
interface ProfileMetadataType {
Profile: string;
Profile: {
custom: boolean;
userLicense: string;
};
}

interface PermissionSetRecord {
Profile: {
Name: string;
};
}
27 changes: 3 additions & 24 deletions src/commands/texei/skinnyprofile/check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import xml2js = require('xml2js');
import { SfCommand, Flags, loglevel } from '@salesforce/sf-plugins-core';
import { Messages, SfError } from '@salesforce/core';
import { nodesNotAllowed } from '../../../shared/skinnyProfileHelper';
import { getDefaultPackagePath } from '../../../shared/sfdxProjectFolder';
import { getDefaultPackagePath, getProfilesInPath } from '../../../shared/sfdxProjectFolder';

// Initialize Messages with the current plugin directory
Messages.importMessagesDirectory(__dirname);
Expand Down Expand Up @@ -42,11 +42,11 @@ export default class Check extends SfCommand<CheckResult> {
// Get profiles files path
if (flags.path) {
// A path was provided with the flag
profilesToCheck = this.getProfilesInPath(flags.path);
profilesToCheck = getProfilesInPath(flags.path, true);
} else {
// Else look in the default package directory
const defaultPackageDirectory = path.join(await getDefaultPackagePath(), 'profiles');
profilesToCheck = this.getProfilesInPath(defaultPackageDirectory);
profilesToCheck = getProfilesInPath(defaultPackageDirectory, true);
}

if (profilesToCheck === undefined || profilesToCheck.length === 0) {
Expand Down Expand Up @@ -98,25 +98,4 @@ export default class Check extends SfCommand<CheckResult> {

return { message: commandResult };
}

private getProfilesInPath(pathToRead: string): string[] {
this.debug(`getProfilesInPath --> pathToRead:${pathToRead}`);

const profilesInPath: string[] = [];

const filesInDir = fs.readdirSync(pathToRead);

for (const fileInDir of filesInDir) {
const dirOrFilePath = path.join(process.cwd(), pathToRead, fileInDir);

// If it's a Profile file, add it
if (!fs.lstatSync(dirOrFilePath).isDirectory() && fileInDir.endsWith('.profile-meta.xml')) {
const profileFoundPath = path.join(pathToRead, fileInDir);

profilesInPath.push(profileFoundPath);
}
}

return profilesInPath;
}
}
190 changes: 190 additions & 0 deletions src/commands/texei/skinnyprofile/create.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
import * as fs from 'fs';
import * as path from 'path';
import { SfCommand, Flags } from '@salesforce/sf-plugins-core';
import { Messages, SfError } from '@salesforce/core';
import { XMLParser } from 'fast-xml-parser';
import { Connection, Record } from 'jsforce';
import { Error } from 'jsforce/lib/api/soap/schema';
import { getDefaultPackagePath, getProfilesInPath } from '../../../shared/sfdxProjectFolder';

Messages.importMessagesDirectory(__dirname);
const messages = Messages.loadMessages('texei-sfdx-plugin', 'skinnyprofile.create');

export type SkinnyprofileCreateResult = {
commandResult: string;
profilesCreated: string[];
profilesStandardSkipped: string[];
profilesAlreadyInOrg: string[];
profilesWithError: profileWithError[];
};

export type profileWithError = {
name: string;
errors: Error[];
};

export default class Create extends SfCommand<SkinnyprofileCreateResult> {
// Minimum Access - Salesforce
public static readonly summary = messages.getMessage('summary');
public static readonly description = messages.getMessage('description');
public static readonly examples = messages.getMessages('examples');

public static readonly flags = {
'target-org': Flags.requiredOrg(),
'api-version': Flags.orgApiVersion(),
path: Flags.string({ char: 'p', required: false, summary: messages.getMessage('flags.path.summary') }),
ignoreerrors: Flags.boolean({ char: 'i', summary: messages.getMessage('flags.ignoreerrors.summary') }),
};

private connection: Connection;

public async run(): Promise<SkinnyprofileCreateResult> {
const noProfile = 'No Profile found';
const profileSucceed = 'Creation succeeded';
const profileFailed = 'Some Profiles creation failed, beware that some profiles may have been created anyway';

const { flags } = await this.parse(Create);
const parser = new XMLParser();

// Create a connection to the org
this.connection = flags['target-org']?.getConnection(flags['api-version']) as Connection;

let profilesInPath: string[] = [];
const profilesCreated: string[] = [];
const profilesStandardSkipped: string[] = [];
const profilesAlreadyInOrg: string[] = [];
const profilesWithError: profileWithError[] = [];

const profileMetadata: Record[] = [];
let commandResult = '';

// Get profiles files path
const profilePath = flags.path ? flags.path : path.join(await getDefaultPackagePath(), 'profiles');
profilesInPath = getProfilesInPath(profilePath, false);

if (profilesInPath === undefined || profilesInPath.length === 0) {
commandResult = noProfile;
} else {
// Get existing custom profiles in target org
// Profile can be queried via PermissionSet, only way to find if a Profile is Custom ?
// https://salesforce.stackexchange.com/questions/38447/determine-custom-profile
const existingCustomProfiles = (
(
await this.connection.query(
'SELECT Profile.Name FROM PermissionSet Where IsCustom = true AND ProfileId != null'
)
).records as PermissionSetRecord[]
).map((record) => record.Profile.Name);

// Get User Licenses Ids from target org
const userLicensesMap = await this.getUserLicensesMap();

for (const profile of profilesInPath) {
// Generate path
const filePath = path.join(process.cwd(), profilePath, profile);

// Read data file
const data = fs.readFileSync(filePath, 'utf8');

// Parsing file
const profileJson: ProfileMetadataType = parser.parse(data) as ProfileMetadataType;

const profileName = profile.replace('.profile-meta.xml', '');

if (profileJson.Profile.custom) {
if (existingCustomProfiles.includes(profileName)) {
// Profile is custom but already exists, don't create it
profilesAlreadyInOrg.push(profileName);
} else {
const userLicense = userLicensesMap.get(profileJson.Profile.userLicense);

if (userLicense === undefined) {
// User License not found in org
profilesWithError.push({
name: profileName,
errors: [
{
message: `userLicense '${profileJson.Profile.userLicense}' does not exist in target org`,
statusCode: 'USER_LICENSE_NOT_IN_ORG',
},
],
});
} else {
profileMetadata.push({
Name: profileName,
UserLicenseId: userLicense,
type: 'Profile',
});
}
}
} else {
// It's a standard Profile, don't create it
profilesStandardSkipped.push(profileName);
}
}
}

if (profileMetadata.length > 0) {
const results = await this.connection?.soap.create(profileMetadata);

for (let i = 0; i < results.length; i++) {
const profile = profileMetadata[i];
const result = results[i];
if (result.success) {
profilesCreated.push(profile.Name as string);
} else {
profilesWithError.push({
name: profile.Name as string,
errors: result.errors,
});
}
}
}

if (commandResult === noProfile) {
this.log(commandResult);
} else {
commandResult = profilesWithError.length > 0 ? profileFailed : profileSucceed;

this.log(`>> Profiles created:\n ${profilesCreated.join('\n ')}\n`);
this.log(`>> Standard Profiles (skipped):\n ${profilesStandardSkipped.join('\n ')}\n`);
this.log(`>> Profiles already in target org (skipped):\n ${profilesAlreadyInOrg.join('\n ')}\n`);
this.log(
`>> Profiles with errors:\n ${profilesWithError
.map(
(profileWithError) =>
`${profileWithError.name}:\n${profileWithError.errors
.map(
(error) =>
` ${error.statusCode} - ${error.message}${error.fields ? ' - ' + error.fields.join(',') : ''}`
)
.join('\n')}`
)
.join('\n ')}\n`
);
}

const finalResult: SkinnyprofileCreateResult = {
commandResult,
profilesCreated,
profilesStandardSkipped,
profilesAlreadyInOrg,
profilesWithError,
};

if (profilesWithError && !flags['ignoreerrors']) {
const finalError = new SfError(profileFailed);
finalError.setData(finalResult);
throw finalError;
}

return finalResult;
}

private async getUserLicensesMap(): Promise<Map<string, string>> {
const userLicenses = await this.connection.query('SELECT Id, Name FROM UserLicense');
const userLicensesMap = new Map(userLicenses.records.map((record) => [record.Name as string, record.Id]));

return userLicensesMap as Map<string, string>;
}
}
23 changes: 23 additions & 0 deletions src/shared/sfdxProjectFolder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,3 +111,26 @@ export async function getDefaultPackagePath(): Promise<string> {

return foundPath;
}

export function getProfilesInPath(pathToRead: string, returnWithPath: boolean): string[] {
const profilesInPath: string[] = [];

const filesInDir = fs.readdirSync(pathToRead);

for (const fileInDir of filesInDir) {
const dirOrFilePath = path.join(process.cwd(), pathToRead, fileInDir);

// If it's a Profile file, add it
if (!fs.lstatSync(dirOrFilePath).isDirectory() && fileInDir.endsWith('.profile-meta.xml')) {
let profileFoundPath = fileInDir;

if (returnWithPath) {
profileFoundPath = path.join(pathToRead, fileInDir);
}

profilesInPath.push(profileFoundPath);
}
}

return profilesInPath;
}
2 changes: 2 additions & 0 deletions src/shared/skinnyProfileHelper.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
// import * as puppeteer from 'puppeteer';

// This should be on a Permission Set
export const nodesNotAllowed = [
'userPermissions',
Expand Down
Loading

0 comments on commit c600025

Please sign in to comment.