@pos-360/auto-git-update
Version:
Update node app from git repo.
395 lines (352 loc) • 16.2 kB
JavaScript
import path from 'path';
import fs from 'fs-extra';
import {spawn, exec} from 'child_process';
import https from 'https';
import appRootPath from 'app-root-path';
import git from 'simple-git';
import Logger from 'chegs-simple-logger';
/**
* @typedef {Object} Config - Configuration for Auto Git Update
* @property {String} repository - The url to the root of a git repository to update from, or /latest GitHub release.
* @property {String} branch - The branch to update from. Defaults to master.
* @property {Boolean} performBackup - If a backup of the application should be created before updating.
* @property {Boolean} fromReleases - Updated based off of latest published GitHub release instead of branch package.json.
* @property {String} token - A personal access token used for accessions private repositories.
* @property {Logger.Options} logConfig - An object with the logging configuration, see https://github.com/chegele/Logger
* @property {String} tempLocation - The local dir to save temporary information for Auto Git Update.
* @property {Array[String]} ignoreFiles - An array of files to not install when updating. Useful for config files.
* @property {String} executeOnComplete - A command to execute after an update completes. Good for restarting the app.
* @property {String} executeBeforeDependenciesInstall - A command to execute before dependencies are installed.
* @property {Boolean} exitOnComplete - Use process exit to stop the app after a successful update.
*/
/** @type {Config} */
let config = {}
// Subdirectories to use within the configured tempLocation from above.
const cloneSubdirectory = '/AutoGitUpdate/repo/';
const backupSubdirectory = '/AutoGitUpdate/backup/';
// Enable during testing to prevent overwrite of Auto Git Update
const testing = false;
// Create a new simple logger. This can be updated to use a new configuration by calling setLogConfig()
// https://github.com/chegele/Logger
let log = new Logger({});
log.logGeneral = true;
log.logWarning = true;
log.logError = true;
log.logDetail = false;
log.logDebug = false;
// Toggles if performing async setup task
let ready = true;
export default class AutoGitUpdate {
/**
* Creates an object which can be used to automatically update an application from a remote git repository.
* @param {Config} updateConfig
*/
constructor(updateConfig) {
// validate config has required properties
if (updateConfig == undefined) throw new Error('You must pass a config object to AutoGitUpdate.');
if (updateConfig.repository == undefined) throw new Error('You must include a repository link.');
if (updateConfig.branch == undefined) updateConfig.branch = 'master';
if (updateConfig.tempLocation == undefined) throw new Error('You must define a temp location for cloning the repository');
// Update the logger configuration if provided.
if (updateConfig.logConfig) this.setLogConfig(updateConfig.logConfig);
// Update config and retrieve current tag if configured to use releases
config = updateConfig;
if (config.fromReleases) {
ready = false;
setBranchToReleaseTag(config.repository);
}
// Validate that Auto Git Update is being used as a dependency or testing is enabled
// This is to prevent the Auto Git Update module from being overwritten on accident during development
if (!testing) {
let file = path.join(appRootPath.path, 'package.json');
let appPackage = fs.readFileSync(file);
appPackage = JSON.parse(appPackage);
if (appPackage.name == 'auto-git-update') throw new Error('Auto Git Update is not being ran as a dependency & testing is not enabled.');
}
}
/**
* Checks local version against the remote version & then updates if different.
*/
async autoUpdate() {
while (!ready) { await sleep(1000); log.general('Auto Git Update - Not ready to update...')};
let versionCheck = await this.compareVersions();
if (versionCheck.upToDate) return true;
return await this.forceUpdate();
}
/**
* @typedef VersionResults
* @param {Boolean} UpToDate - If the local version is the same as the remote version.
* @param {String} currentVersion - The version of the local application.
* @param {String} remoteVersion - The version of the application in the git repository.
*
* Checks the local version of the application against the remote repository.
* @returns {VersionResults} - An object with the results of the version comparison.
*/
async compareVersions() {
try {
log.general('Auto Git Update - Comparing versions...');
let currentVersion = readAppVersion();
let remoteVersion = await readRemoteVersion();
log.general('Auto Git Update - Current version: ' + currentVersion);
log.general('Auto Git Update - Remote Version: ' + remoteVersion);
if (currentVersion == remoteVersion) return {upToDate: true, currentVersion};
return {upToDate: false, currentVersion, remoteVersion};
}catch(err) {
log.error('Auto Git Update - Error comparing local and remote versions.');
log.error(err);
return {upToDate: false, currentVersion: 'Error', remoteVersion: 'Error'}
}
}
/**
* Clones the git repository, purges ignored files, and installs the update over the local application.
* A backup of the application is created before the update is installed.
* If configured, a completion command will be executed and the process for the app will be stopped.
* @returns {Boolean} The result of the update.
*/
async forceUpdate() {
try {
log.general('Auto Git Update - Updating application from ' + config.repository);
await downloadUpdate();
if(config.performBackup) await backupApp();
await installUpdate();
if (config.executeBeforeDependenciesInstall) await promiseCommandExec(config.executeBeforeDependenciesInstall);
await installDependencies();
log.general('Auto Git Update - Finished installing updated version.');
if (config.executeOnComplete) await promiseBlindSpawn(config.executeOnComplete);
if (config.exitOnComplete) process.exit(1);
return true;
}catch(err) {
log.error('Auto Git Update - Error updating application');
log.error(err);
return false;
}
}
/**
* Updates the simple logger to use the provided configuration.
* Reference the readme for configuration options.
* https://github.com/chegele/Logger
* @param {Logger.Options} logConfig - An object with the logging configuration
*/
setLogConfig(logConfig) {
log = new Logger(logConfig);
}
}
////////////////////////////
// AUTO GIT UPDATE FUNCTIONS
/**
* Creates a backup of the application, including node modules.
* The backup is stored in the configured tempLocation. Only one backup is kept at a time.
*/
async function backupApp() {
let destination = path.join(config.tempLocation, backupSubdirectory);
log.detail('Auto Git Update - Backing up app to ' + destination);
await fs.ensureDir(destination);
await fs.copy(appRootPath.path, destination, {dereference: true});
return true;
}
/**
* Downloads the update from the configured git repository.
* The repo is cloned to the configured tempLocation.
*/
async function downloadUpdate() {
// Inject token for private repositories
let repo = config.repository;
if (config.token) {
repo = repo.replace('http://', '').replace('https://', '');
repo = `https://${config.token}@${repo}`;
}
// Empty destination directory & clone repo
let destination = path.join(config.tempLocation, cloneSubdirectory);
log.detail('Auto Git Update - Cloning ' + repo);
log.detail('Auto Git Update - Destination: ' + destination);
await fs.ensureDir(destination);
await fs.emptyDir(destination);
await promiseClone(repo, destination, config.branch);
return true;
}
/**
* Runs npm install to update/install application dependencies.
*/
function installDependencies() {
return new Promise(function(resolve, reject) {
//If testing is enabled, use alternative path to prevent overwrite of app.
let destination = testing ? path.join(appRootPath.path, '/testing/'): appRootPath.path;
log.detail('Auto Git Update - Installing application dependencies in ' + destination);
// Generate and execute command
let command = `cd ${destination} && npm install && npm rebuild`;
let child = exec(command);
// Wait for results
child.stdout.on('end', resolve);
child.stdout.on('data', data => log.general('Auto Git Update - npm install: ' + data.replace(/\r?\n|\r/g, '')));
child.stderr.on('data', data => {
if (data.toLowerCase().includes('error')) {
// npm passes warnings as errors, only reject if "error" is included
data = data.replace(/\r?\n|\r/g, '');
log.error('Auto Git Update - Error installing dependencies');
log.error('Auto Git Update - ' + data);
reject();
}else{
log.warning('Auto Git Update - ' + data);
}
});
});
}
/**
* Purge ignored files from the update, copy the files to the app directory, and install new modules
* The update is installed from the configured tempLocation.
*/
async function installUpdate() {
// Remove ignored files from the new version
if (config.ignoreFiles) {
log.detail('Auto Git Update - Purging ignored files from the update');
config.ignoreFiles.forEach(file => {
file = path.join(config.tempLocation, cloneSubdirectory, file);
log.detail('Auto Git Update - Removing ' + file);
fs.unlinkSync(file);
});
}
// Install updated files
let source = path.join(config.tempLocation, cloneSubdirectory);
//If testing is enabled, use alternative path to prevent overwrite of app.
let destination = testing ? path.join(appRootPath.path, '/testing/'): appRootPath.path;
log.detail('Auto Git Update - Installing update...');
log.detail('Auto Git Update - Source: ' + source);
log.detail('Auto Git Update - Destination: ' + destination);
await fs.ensureDir(destination);
await fs.copy(source, destination);
return true;
}
/**
* Reads the applications version from the package.json file.
*/
function readAppVersion() {
let file = path.join(appRootPath.path, 'package.json');
log.detail('Auto Git Update - Reading app version from ' + file);
let appPackage = fs.readFileSync(file);
return JSON.parse(appPackage).version;
}
/**
* Reads the applications version from the git repository.
*/
async function readRemoteVersion() {
// Generate request details
let options = {}
let url = config.repository + `/${config.branch}/package.json`;
if (url.includes('github')) url = url.replace('github.com', 'raw.githubusercontent.com');
if (config.token) options.headers = {Authorization: `token ${config.token}`}
log.detail('Auto Git Update - Reading remote version from ' + url);
// Send request for repositories raw package.json file
try {
let body = await promiseHttpsRequest(url, options);
let remotePackage = JSON.parse(body);
let version = remotePackage.version;
return version;
}catch(err) {
if (err = 404) throw new Error('This repository requires a token or does not exist. \n ' + url);
throw err;
}
}
/**
* Updates the configuration for this updater to use the latest release as the repo branch
* @param {String} repository - The link to the repo
*/
async function setBranchToReleaseTag(repository) {
// Validate the configuration & generate request details
let options = {headers: {"User-Agent": "Auto-Git-Update - " + repository}}
if (config.token) options.headers.Authorization = `token ${config.token}`;
repository = repository.toLocaleLowerCase().replace('github.com/', 'api.github.com/repos/');
if (!repository.includes('github')) throw new Error('fromReleases is enabled but this does not seem to be a GitHub repo.');
if (repository.endsWith('/')) repository = repository.slice(0, -1);
const url = (repository + '/releases/latest')
log.general('Auto Git Update - Checking release tag from ' + url);
// Attempt to identify the tag/version of the latest release
try {
let body = await promiseHttpsRequest(url, options);
let response = JSON.parse(body);
let tag = response.tag_name;
config.branch = tag;
ready = true;
}catch(err) {
if (err = 404) throw new Error('This repository requires a token or does not exist. \n ' + url);
throw err;
}
}
////////////////////////////
// HELPER & MISC FUNCTIONS
/**
* A promise wrapper for the simple-git clone function
* @param {String} repo - The url of the repository to clone.
* @param {String} destination - The local path to clone into.
* @param {String} branch - The repo branch to clone.
*/
function promiseClone(repo, destination, branch) {
return new Promise(function(resolve, reject) {
git().clone(repo, destination, [`--branch=${branch}`], result => {
if (result != null) reject(`Unable to clone repo \n ${repo} \n ${result}`);
resolve();
});
});
}
/**
* A promise wrapper for the child-process spawn function. Does not listen for results.
* @param {String} command - The command to execute.
*/
function promiseBlindSpawn(command) {
return new Promise(function(resolve, reject) {
spawn(command, [], {shell: true, detached: true});
setTimeout(resolve, 1000);
});
}
/**
* A promise wrapper for the child-process exec function.
* @param {String} command - The command to execute.
*/
function promiseCommandExec(command) {
if (!command) return Promise.resolve();
if (command === "") return Promise.resolve();
return new Promise(function(resolve, reject) {
const child = exec(command);
// Wait for results
child.stdout.on('end', resolve);
child.stdout.on('data', data => log.general(`Auto Git Update - Command: "${command}" - ${data.replace(/\r?\n|\r/g, '')}`));
child.stderr.on('data', data => {
if (data.toLowerCase().includes('error')) {
// npm passes warnings as errors, only reject if "error" is included
data = data.replace(/\r?\n|\r/g, '');
log.error('Auto Git Update - Error Executing Command: ' + data);
log.error('Auto Git Update - ' + data);
reject();
}else{
log.warning('Auto Git Update - ' + data);
}
});
});
}
/**
* A promise wrapper for sending a get https requests.
* @param {String} url - The Https address to request.
* @param {String} options - The request options.
*/
function promiseHttpsRequest(url, options) {
return new Promise(function(resolve, reject) {
let req = https.request(url, options, res => {
//Construct response
let body = '';
res.on('data', data => {body += data});
res.on('end', function() {
if (res.statusCode == '200') return resolve(body);
log.detail('Auto Git Update - Bad Response ' + res.statusCode);
reject(res.statusCode);
});
});
log.detail('Auto Git Update - Sending request to ' + url);
log.detail('Auto Git Update - Options: ' + JSON.stringify(options));
req.on('error', reject);
req.end();
});
}
async function sleep(time) {
return new Promise(function(resolve, reject) {
setTimeout(resolve, time);
});
}