grunt-force-developer
Version:
A grunt task for salesforce and force.com development
369 lines (286 loc) • 18.8 kB
JavaScript
/*
* grunt-force-developer
* https://github.com/jkentjnr/grunt-force-developer
*
* Copyright (c) 2015 James Kent
* Licensed under the MIT license.
*/
// To debug run 'node-inspector' in one terminal window.
// Then execute: node --debug-brk /usr/local/lib/node_modules/grunt-cli/bin/grunt from project root.
;
var crypto = require('crypto'),
path = require('path'),
nforce = require('nforce'),
meta = require('nforce-metadata')(nforce),
glob = require("glob");
var force = {
deletePackageOutput: function(grunt, options, cache) {
if (cache == true) {
if (grunt.file.exists('./' + options.outputDirectory))
grunt.file.delete('./' + options.outputDirectory, { force: true });
}
else {
if (grunt.file.exists('./' + options.outputDirectory + '/package.xml'))
grunt.file.delete('./' + options.outputDirectory + '/package.xml', { force: true });
if (grunt.file.exists('./' + options.outputDirectory + '/src'))
grunt.file.delete('./' + options.outputDirectory + '/src', { force: true });
}
},
commitChangesToHashfile: function(grunt, options) {
var fileDiffLive = './' + options.outputDirectory + '/' + options.fileChangeHashFile;
var fileDiffStage = './' + options.outputDirectory + '/' + options.fileChangeHashStagingFile;
// Commit the staged changes to the most recent hashfile.
grunt.file.copy(fileDiffStage, fileDiffLive);
},
evaluateProjectFiles: function(grunt, options) {
// TODO: Add support for delete (?)
// Used to track what actions need to take place.
var metadataAction = {};
var fileDiffLive = './' + options.outputDirectory + '/' + options.fileChangeHashFile;
var fileDiffStage = './' + options.outputDirectory + '/' + options.fileChangeHashStagingFile;
// Read the hash file (if possible)
var fileDiff = (grunt.file.exists(fileDiffLive))
? grunt.file.readJSON(fileDiffLive)
: {};
// Iterate through all folders under the project folder.
grunt.file.expand({ filter: 'isDirectory' }, './' + options.projectBaseDirectory + '/**').forEach(function(dir) {
// TODO - check for config file.
// If config file - check for processor. If custom processor, hand off processing
// customProc(dir, metadataAction, fillDiff)
// If no custom provider, iterate through all files in the folder.
grunt.file.expand({ filter: 'isFile' }, [dir + '/*', '!' + dir + '/force.config', '!' + dir + '/*-meta.xml']).forEach(function(f) {
// Read the file into memory
var data = grunt.file.read(f);
// Get any previous hash for the file.
var existingHash = fileDiff[f];
// Generate a hash for the data in the current file.
var currentHash = crypto
.createHash('md5')
.update(data)
.digest('hex');
// Check to see if there is any difference in the file.
if (existingHash != currentHash) {
// If yes -- put an 'add' action for the file in the action collection.
console.log('Change: ' + f);
metadataAction[f] = { add: true };
}
// Save the latest hash for the file.
fileDiff[f] = currentHash;
});
});
// Persist the hashes to the staging file.
grunt.file.write(fileDiffStage, JSON.stringify(fileDiff));
// Return the actions to be performed.
return metadataAction;
},
generatePackageStructure: function(grunt, options, metadataAction) {
// TODO: make the packager customisable.
// Suggestion: Make a packager for classes, pages and components that allows you to put the metadata in the source.
// Create a path to the temp output dir. This will house the unpackaged package.xml and source
var targetSrc = './' + options.outputDirectory + '/' + options.outputTempDirectory + '/';
var buildMetadata = function(f, metadataTarget, options) {
var ext = path.extname(f);
var name = path.basename(f, ext);
var data = null;
switch (ext) {
case '.cls':
data = '<?xml version=\"1.0\" encoding=\"UTF-8\"?>\r\n<ApexClass xmlns=\"http:\/\/soap.sforce.com\/2006\/04\/metadata\">\r\n <apiVersion>' + options.apiVersion + '.0<\/apiVersion>\r\n <status>Active<\/status>\r\n<\/ApexClass>';
break;
case '.page':
data = '<?xml version=\"1.0\" encoding=\"UTF-8\"?>\r\n<ApexPage xmlns=\"http:\/\/soap.sforce.com\/2006\/04\/metadata\">\r\n <apiVersion>' + options.apiVersion + '.0<\/apiVersion>\r\n <availableInTouch>false<\/availableInTouch>\r\n <confirmationTokenRequired>false<\/confirmationTokenRequired>\r\n <label>' + name + '<\/label>\r\n<\/ApexPage>';
break;
}
grunt.file.write(metadataTarget, data);
};
//
var copier = function(grunt, options, f, objectDir, hasMetadata) {
var target = targetSrc + objectDir;
var sourceFilename = path.basename(f);
grunt.file.copy(f, target + '/' + sourceFilename);
if (hasMetadata) {
var metadataFilename = sourceFilename + '-meta.xml';
var metadataTarget = target + '/' + metadataFilename;
var matches = glob.sync(
options.projectBaseDirectory + '/**/*' + metadataFilename
);
if (matches.length > 0) {
grunt.file.copy(matches[0], metadataTarget);
}
else {
var metadataSource = './' + options.projectBaseDirectory + '/' + options.metadataSourceDirectory + '/' + metadataFilename;
grunt.log.writeln('Generating metadata - ' + metadataTarget);
buildMetadata(f, metadataTarget, options);
}
}
};
for(var f in metadataAction) {
var ext = path.extname(f);
// TODO: Check for custom packager for a file ext.
switch (ext) {
case '.app':
copier(grunt, options, f, 'applications', false);
break;
case '.approvalProcess':
copier(grunt, options, f, 'approvalProcesses', false);
break;
case '.assignmentRules':
copier(grunt, options, f, 'assignmentRules', false);
break;
case '.authproviders':
copier(grunt, options, f, 'authprovider', false);
break;
case '.autoResponseRules':
copier(grunt, options, f, 'autoResponseRules', false);
break;
case '.cls':
copier(grunt, options, f, 'classes', true);
break;
case '.community':
copier(grunt, options, f, 'communities', false);
break;
case '.component':
copier(grunt, options, f, 'components', true);
break;
case '.group':
copier(grunt, options, f, 'group', false);
break;
case '.homePageLayout':
copier(grunt, options, f, 'homePageLayouts', false);
break;
case '.labels':
copier(grunt, options, f, 'labels', false);
break;
case '.layout':
copier(grunt, options, f, 'layouts', false);
break;
case '.letter':
copier(grunt, options, f, 'letterhead', false);
break;
case '.object':
copier(grunt, options, f, 'objects', false);
break;
case '.object':
copier(grunt, options, f, 'objects', false);
break;
case '.objectTranslation':
copier(grunt, options, f, 'objectTranslations', false);
break;
case '.page':
copier(grunt, options, f, 'pages', true);
break;
case '.permissionset':
copier(grunt, options, f, 'permissionsets', false);
break;
case '.profile':
copier(grunt, options, f, 'profiles', false);
break;
case '.queue':
copier(grunt, options, f, 'queues', false);
break;
case '.quickAction':
copier(grunt, options, f, 'quickActions', false);
break;
case '.remoteSite':
copier(grunt, options, f, 'remoteSiteSettings', false);
break;
case '.reportType':
copier(grunt, options, f, 'reportTypes', false);
break;
case '.role':
copier(grunt, options, f, 'role', false);
break;
case '.resource':
copier(grunt, options, f, 'staticresources', true);
break;
case '.tab':
copier(grunt, options, f, 'tabs', false);
break;
case '.trigger':
copier(grunt, options, f, 'triggers', true);
break;
}
}
// TODO: load generic package XML file from filesystem.
var packageXml = '<?xml version=\"1.0\" encoding=\"UTF-8\"?>\r\n<Package xmlns=\"http:\/\/soap.sforce.com\/2006\/04\/metadata\">\r\n <types>\r\n <members>*<\/members>\r\n <name>AnalyticSnapshot<\/name>\r\n <\/types>\r\n <types>\r\n <members>*<\/members>\r\n <name>ApexClass<\/name>\r\n <\/types>\r\n <types>\r\n <members>*<\/members>\r\n <name>ApexComponent<\/name>\r\n <\/types>\r\n <types>\r\n <members>*<\/members>\r\n <name>ApexPage<\/name>\r\n <\/types>\r\n <types>\r\n <members>*<\/members>\r\n <name>ApexTrigger<\/name>\r\n <\/types>\r\n <types>\r\n <members>*<\/members>\r\n <name>ApprovalProcess<\/name>\r\n <\/types>\r\n <types>\r\n <members>*<\/members>\r\n <name>AssignmentRules<\/name>\r\n <\/types>\r\n <types>\r\n <members>*<\/members>\r\n <name>AuthProvider<\/name>\r\n <\/types>\r\n <types>\r\n <members>*<\/members>\r\n <name>AutoResponseRules<\/name>\r\n <\/types>\r\n <types>\r\n <members>*<\/members>\r\n <name>BusinessProcess<\/name>\r\n <\/types>\r\n <types>\r\n <members>*<\/members>\r\n <name>CallCenter<\/name>\r\n <\/types>\r\n <types>\r\n <members>*<\/members>\r\n <name>Community<\/name>\r\n <\/types>\r\n <types>\r\n <members>*<\/members>\r\n <name>CompactLayout<\/name>\r\n <\/types>\r\n <types>\r\n <members>*<\/members>\r\n <name>ConnectedApp<\/name>\r\n <\/types>\r\n <types>\r\n <members>*<\/members>\r\n <name>CustomApplication<\/name>\r\n <\/types>\r\n <types>\r\n <members>*<\/members>\r\n <name>CustomApplication<\/name>\r\n <\/types>\r\n <types>\r\n <members>*<\/members>\r\n <name>CustomApplicationComponent<\/name>\r\n <\/types>\r\n <types>\r\n <members>*<\/members>\r\n <name>CustomField<\/name>\r\n <\/types>\r\n <types>\r\n <members>*<\/members>\r\n <name>CustomLabels<\/name>\r\n <\/types>\r\n <types>\r\n <members>*<\/members>\r\n <name>CustomObject<\/name>\r\n <\/types>\r\n <types>\r\n <members>*<\/members>\r\n <name>CustomObjectTranslation<\/name>\r\n <\/types>\r\n <types>\r\n <members>*<\/members>\r\n <name>CustomPageWebLink<\/name>\r\n <\/types>\r\n <types>\r\n <members>*<\/members>\r\n <name>CustomSite<\/name>\r\n <\/types>\r\n <types>\r\n <members>*<\/members>\r\n <name>CustomTab<\/name>\r\n <\/types>\r\n <types>\r\n <members>*<\/members>\r\n <name>Dashboard<\/name>\r\n <\/types>\r\n <types>\r\n <members>*<\/members>\r\n <name>DataCategoryGroup<\/name>\r\n <\/types>\r\n <types>\r\n <members>*<\/members>\r\n <name>Document<\/name>\r\n <\/types>\r\n <types>\r\n <members>*<\/members>\r\n <name>EmailTemplate<\/name>\r\n <\/types>\r\n <types>\r\n <members>*<\/members>\r\n <name>EntitlementProcess<\/name>\r\n <\/types>\r\n <types>\r\n <members>*<\/members>\r\n <name>EntitlementTemplate<\/name>\r\n <\/types>\r\n <types>\r\n <members>*<\/members>\r\n <name>ExternalDataSource<\/name>\r\n <\/types>\r\n <types>\r\n <members>*<\/members>\r\n <name>FieldSet<\/name>\r\n <\/types>\r\n <types>\r\n <members>*<\/members>\r\n <name>Flow<\/name>\r\n <\/types>\r\n <types>\r\n <members>*<\/members>\r\n <name>Group<\/name>\r\n <\/types>\r\n <types>\r\n <members>*<\/members>\r\n <name>HomePageComponent<\/name>\r\n <\/types>\r\n <types>\r\n <members>*<\/members>\r\n <name>HomePageLayout<\/name>\r\n <\/types>\r\n <types>\r\n <members>*<\/members>\r\n <name>Layout<\/name>\r\n <\/types>\r\n <types>\r\n <members>*<\/members>\r\n <name>Letterhead<\/name>\r\n <\/types>\r\n <types>\r\n <members>*<\/members>\r\n <name>ListView<\/name>\r\n <\/types>\r\n <types>\r\n <members>*<\/members>\r\n <name>LiveChatAgentConfig<\/name>\r\n <\/types>\r\n <types>\r\n <members>*<\/members>\r\n <name>LiveChatButton<\/name>\r\n <\/types>\r\n <types>\r\n <members>*<\/members>\r\n <name>LiveChatDeployment<\/name>\r\n <\/types>\r\n <types>\r\n <members>*<\/members>\r\n <name>MilestoneType<\/name>\r\n <\/types>\r\n <types>\r\n <members>*<\/members>\r\n <name>NamedFilter<\/name>\r\n <\/types>\r\n <types>\r\n <members>*<\/members>\r\n <name>Network<\/name>\r\n <\/types>\r\n <types>\r\n <members>*<\/members>\r\n <name>PermissionSet<\/name>\r\n <\/types>\r\n <types>\r\n <members>*<\/members>\r\n <name>Portal<\/name>\r\n <\/types>\r\n <types>\r\n <members>*<\/members>\r\n <name>PostTemplate<\/name>\r\n <\/types>\r\n <types>\r\n <members>*<\/members>\r\n <name>Profile<\/name>\r\n <\/types>\r\n <types>\r\n <members>*<\/members>\r\n <name>Queue<\/name>\r\n <\/types>\r\n <types>\r\n <members>*<\/members>\r\n <name>QuickAction<\/name>\r\n <\/types>\r\n <types>\r\n <members>*<\/members>\r\n <name>RecordType<\/name>\r\n <\/types>\r\n <types>\r\n <members>*<\/members>\r\n <name>RemoteSiteSetting<\/name>\r\n <\/types>\r\n <types>\r\n <members>*<\/members>\r\n <name>Report<\/name>\r\n <\/types>\r\n <types>\r\n <members>*<\/members>\r\n <name>ReportType<\/name>\r\n <\/types>\r\n <types>\r\n <members>*<\/members>\r\n <name>Role<\/name>\r\n <\/types>\r\n <types>\r\n <members>*<\/members>\r\n <name>SamlSsoConfig<\/name>\r\n <\/types>\r\n <types>\r\n <members>*<\/members>\r\n <name>Scontrol<\/name>\r\n <\/types>\r\n <types>\r\n <members>*<\/members>\r\n <name>SharingReason<\/name>\r\n <\/types>\r\n <types>\r\n <members>*<\/members>\r\n <name>Skill<\/name>\r\n <\/types>\r\n <types>\r\n <members>*<\/members>\r\n <name>StaticResource<\/name>\r\n <\/types>\r\n <types>\r\n <members>*<\/members>\r\n <name>Territory<\/name>\r\n <\/types>\r\n <types>\r\n <members>*<\/members>\r\n <name>Translations<\/name>\r\n <\/types>\r\n <types>\r\n <members>*<\/members>\r\n <name>ValidationRule<\/name>\r\n <\/types>\r\n <version>32.0<\/version>\r\n<\/Package>';
grunt.file.write(targetSrc + 'package.xml', packageXml);
},
deployPackage: function(done, grunt, options) {
var org = nforce.createConnection({
clientId: options.consumerKey,
clientSecret: options.consumerSecret,
redirectUri: 'http://localhost:3000/oauth/_callback',
version: 32,
mode: 'single',
metaOpts: { // options for nforce-metadata
interval: options.pollInterval // poll interval can be specified (optional)
},
plugins: ['meta'] // loads the plugin in this connection
});
console.log('Connected as \'' + options.username + '\'');
org.authenticate({ username: options.username, password: options.password, securityToken: options.token }).then(function() {
console.log('Authenticated.');
var fs = require("fs");
var data = fs.readFileSync(path.resolve(options.outputPackageZip));
console.log(data);
var promise = org.meta.deployAndPoll({
zipFile: data
});
promise.poller.on('poll', function(res) {
console.log('Poll status: ' + res.status);
});
return promise;
}).then(function(res) {
console.log('Deployment Completed (Status: ' + res.status + ')');
done();
}).error(function(err) {
console.error(err);
done();
});
}
};
// ---------------------------------------------------------------------------------------------------
module.exports = function(grunt) {
grunt.registerMultiTask('force', 'A task for salesforce and force.com development', function() {
// Merge task-specific and/or target-specific options with these defaults.
var options = this.options({
action: 'package',
apiVersion: 34,
fileChangeHashFile: '.force-developer.filehash.json',
fileChangeHashStagingFile: '.force-developer.filehash.staging.json',
projectBaseDirectory: 'project',
outputDirectory: '.package',
outputTempDirectory: 'src',
outputPackageZip: './.package/package.zip',
metadataSourceDirectory: 'app-metadata',
environment: 'production',
pollInterval: 500,
});
// TODO: consider moving output Directory to system temp dir & use https://www.npmjs.com/package/temporary
if (options.action == 'reset') {
// Clear the meta data output directory & difference cache file.
force.deletePackageOutput(grunt, options, true);
}
if (options.action == 'package') {
// Detect any new file or modified files.
var metadataAction = force.evaluateProjectFiles(grunt, options);
// Clear the meta data output directory.
force.deletePackageOutput(grunt, options, false);
// Check to see if ahny file changes were detected.
if (Object.keys(metadataAction).length == 0) {
grunt.fail.warn('No new or modified files detected.');
return;
}
// Generate package folder structure with new & modified files.
force.generatePackageStructure(grunt, options, metadataAction);
}
if (options.action == 'deploy') {
// Deploy async using nforce.
var done = this.async();
force.deployPackage(done, grunt, options);
}
if (options.action == 'commit') {
// Replace the hashfile with the staging hashfile.
force.commitChangesToHashfile(grunt, options);
}
});
};