cron-git-updater
Version:
Cronjob to Git Pull Repository
588 lines (548 loc) • 20.6 kB
JavaScript
const git = require('simple-git'),
cron = require('node-cron'),
https = require('https'),
fs = require('fs-extra'),
path = require('path'),
appRootPath = require('app-root-path'),
{ spawn, exec } = require('child_process')
const pkg = require('./package.json')
/**
* @typedef {Object} CGU_Config - Configuration for Cron Git Updater
* @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} 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 {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 {Boolean} keepAllBackup - To keep all backup in separate folder, not overwrite previous backups.
* @property {String} testing_dir - A test directory for testing so app will not overwrite all files.
* @property {String} executeOnComplete - A command to execute after an update completes. Good for restarting the app.
* @property {Boolean} exitOnComplete - Use process exit to stop the app after update.
*/
/** @type {CGU_Config} */
let config = {}
let ready = true
// Subdirectories to use within the configured tempLocation from above.
const cloneSubdirectory = `/${pkg.name}/repo/`
const backupSubdirectory = `/${pkg.name}/backup/`
module.exports = class CronGitUpdate {
/**
* Create new Instance of Updater.
*
* @param {CGU_Config} newConfig
*/
constructor(newConfig) {
// Validate config has required properties
if (newConfig == undefined && Object.prototype.toString.call(newConfig) !== '[object Object]')
throw new Error('You must pass a valid config object.')
if (newConfig.repository == undefined) throw new Error('You must include a repository link.')
if (newConfig.branch == undefined) newConfig.branch = 'master'
if (newConfig.tempLocation == undefined)
throw new Error('You must define a temp location for cloning the repository')
// Clean Repository URL
newConfig.repository = this.genRepoURL(newConfig.repository)
// Set Config
config = newConfig
if (config.fromReleases) {
setBranchToReleaseTag(config.repository)
}
let file = path.join(appRootPath.path, 'package.json')
if (!fs.existsSync(file)) throw new Error('Missing package.json')
let appPackage = fs.readFileSync(file)
appPackage = JSON.parse(appPackage)
if (appPackage.name == pkg.name) throw new Error('Cron Git Updater is not being run as a dependency.')
}
/**
* First check for an active internet connection then...
*
* Return false if no internet, else
*
* Will check local version against the remote version & then updates if different.
*/
async update() {
while (!ready) {
await this.sleep(1000)
log.info('Not ready to update...')
}
log.info('Running update...')
this.isOnline()
.then(async () => {
log.info('Checking update from the remote repository...')
let versionCheck = await this.compareVersions()
if (versionCheck.upToDate) {
log.info('Local app is up to date.')
return true
} else if (!versionCheck.currentVersion || !versionCheck.remoteVersion) {
log.error('Failed getting versions')
return false
} else {
this.forceUpdate()
return true
}
})
.catch(() => {
log.error('Cannot Update: No Active Internet Connection')
return false
})
.finally(() => {
if (config.exitOnComplete) process.exit(1)
})
}
/**
* @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.info('Comparing app version...')
log.info('Getting local app version...')
const appInfo = await this.readAppInfo()
let currentVersion = appInfo ? appInfo.version : undefined
log.info('Getting remote app version...')
const remoteInfo = await this.readRemoteInfo(appInfo.repository.url)
let remoteVersion = remoteInfo ? remoteInfo.version : null
log[currentVersion ? 'info' : 'error']('Current Version: ' + currentVersion)
log[remoteVersion ? 'info' : 'error']('Remote Version: ' + remoteVersion)
return { upToDate: currentVersion == remoteVersion, currentVersion, remoteVersion }
} catch (err) {
log.error('Error comparing local and remote versions.')
log.error(err)
return {
upToDate: false,
currentVersion: undefined,
remoteVersion: null,
}
}
}
/**
* Reads the applications version from the package.json file.
*/
async readAppInfo() {
let file = path.join(appRootPath.path, 'package.json')
log.info('Reading app information from ' + file)
let appPackage = fs.readFileSync(file)
return JSON.parse(appPackage)
}
/**
* Reads the applications version from the git repository.
*
* @param {String} repo Remote Repository Url
* @param {String} branch Remote branch to check
* @param {String} token For private repository
*/
async readRemoteInfo() {
// Generate request details
const options = {}
const url = this.genRepoURL(config.repository, 'raw', config.branch, 'package.json')
if (config.token) options.headers = { Authorization: `token ${config.token}` }
log.info('Reading remote information from ' + url)
// Send request for repositories raw package.json file
try {
const body = await this.getHttpsRequest(url, options)
return JSON.parse(body)
} catch (err) {
if ((err = 404)) log.error('This repository requires a token or does not exist. ' + url)
else log.error(err)
return
}
}
/**
* Downloads the update from the configured git repository.
*
* The repo is cloned to the configured tempLocation.
*/
async remoteDownload() {
// Inject token for private
let repo = config.repository
if (config.token) {
repo = repo.replace('http://', '').replace('https://', '')
repo = `https://${config.token}@${config.repository}`
}
// Empty destination directory & clone repo
let destination = path.join(config.tempLocation, cloneSubdirectory)
log.info('Cloning ' + repo)
log.info('Destination: ' + destination)
await fs.ensureDir(destination)
await fs.emptyDir(destination)
await gitClone(repo, destination, config.branch)
return true
}
/**
* 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 {
ready = false
log.info('Updating application from ' + config.repository)
await downloadUpdate()
await backupApp()
await installUpdate()
await installDependencies()
log.info('Finished installing updated version.')
if (config.executeOnComplete) await this.blindExecute(config.executeOnComplete)
if (config.exitOnComplete) process.exit(1)
return true
} catch (err) {
log.error('Error updating application')
log.error(err)
return false
} finally {
ready = true
}
}
/**
* Schedule a task
*
* @param {String} cron_expression Schedule when the task should run.
*
* Validate the expression using .{@link validateSchedule}()
*
* @param {string} timezone The timezone that is used for job scheduling. Default is `Asia/Manila`
*/
async schedule(cron_expression, timezone) {
if (!cron_expression) throw new Error('Cron Expression is Required')
if (!this.validateSchedule(cron_expression)) throw new Error('Cron Syntax Error')
if (!timezone) timezone = process.env.TZ || 'Asia/Manila'
cron.schedule(
cron_expression,
() => {
log.info('Running Scheduled task...')
this.update()
},
{ scheduled: true, timezone: timezone }
)
}
/**
* Validate Cron Expression
*
* Allowed fields
* ```js
* # ┌────────────── second (optional)
* # │ ┌──────────── minute
* # │ │ ┌────────── hour
* # │ │ │ ┌──────── day of month
* # │ │ │ │ ┌────── month
* # │ │ │ │ │ ┌──── day of week
* # │ │ │ │ │ │
* # │ │ │ │ │ │
* # * * * * * *
* ```
*/
validateSchedule(cron_expression) {
if (!cron_expression) throw new Error('Cron Expression is Required')
return cron.validate(cron_expression)
}
/**
* A promise wrapper for the child-process spawn function. Does not listen for results.
*
* @param {String} command - The command to execute.
*/
blindExecute(command) {
return new Promise(function (resolve, reject) {
spawn(command, [], { shell: true, detached: true })
setTimeout(resolve, 1000)
})
}
/**
* A promise wrapper for sending a get https requests.
*
* @param {String} url - The Https address to request.
* @param {String} options - The request options.
*/
getHttpsRequest(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.error('Bad Response ' + res.statusCode)
reject(res.statusCode)
})
})
log.info('Sending request to ' + url)
log.info('Options: ' + JSON.stringify(options))
req.on('error', reject)
req.end()
})
}
/**
* Generate Git Repo URL
*
* @param {String} repo Repository url
* @param {'raw' | 'release'} type Type of url to generate
* @param {String?} branch Branch to use: `required` if type is `raw`
* @param {String?} file Raw file to access: `required` if type is `raw`
* @returns {String} Clean url for specified type
*/
genRepoURL(repo, type, branch, file) {
if (!repo) throw new Error('Repository is required')
if (type == 'raw' && !file) throw new Error('File is required when using raw type')
if (type == 'raw' && !branch) throw new Error('Branch is required when using raw type')
let url = repo.toLowerCase()
// Branch Default is using release for production otherwise development
if (url.startsWith('git+')) url = url.slice(4)
if (url.endsWith('/')) url = url.slice(0, -1)
if (url.endsWith('.git')) url = url.slice(0, -4)
switch (type) {
case 'raw':
// Git Raw URL for Supported Library
if (url.includes('gitlab')) url = `${url}/-/raw/${branch}/${file}`
if (url.includes('github')) url = `${url.replace('github.com', 'raw.githubusercontent.com')}/${branch}/${file}`
break
case 'release':
// Git Raw URL for Supported Library
if (url.includes('github')) url = `${url.replace('github.com/', 'api.github.com/repos/')}releases/latest`
break
}
return url
}
/**
*
* @typedef {Object} InternetCheckerConfig Settings to check
* @property {Number} timeout Execution time in milliseconds
* @property {Number} retries Total query attempts made during timeout
* @property {String} domainName Domain to check for connection by default google.com
* @property {Number} port Port where the DNS lookup should check by default 53
* @property {String} host DNS Host where lookup should check by default '8.8.8.8' (Google Public DNS)
*/
/**
* Internet available is a very simple method that allows you to check if there's an active
* internet connection by resolving a DNS address and it's developer friendly.
*
* @param {InternetCheckerConfig} config
* @returns {Promise<void>} True if online
*/
isOnline(config = {}) {
const dns = require('dns-socket')
log.info('Checking if online...')
return new Promise(function (resolve, reject) {
// Create instance of the DNS resolver
const socket = dns({
timeout: config.timeout || 5000,
retries: config.retries || 5,
})
// Run the dns lowlevel lookup
socket.query(
{
questions: [
{
type: 'A',
name: config.domainName || 'google.com',
},
],
},
config.port || 53,
config.host || '8.8.8.8'
)
// DNS Address solved, internet available
socket.on('response', () => {
socket.destroy(() => {
resolve()
})
})
// Verify for timeout of the request (cannot reach server)
socket.on('timeout', () => {
socket.destroy(() => {
reject()
})
})
})
}
/**
* Put a proccess on sleep for a specific time
*
* @param {Number} time sleep duration in `milliseconds`
*/
sleep(time) {
return new Promise(function (resolve, reject) {
setTimeout(resolve, time)
})
}
}
////////////////////////////
// HELPER & MISC FUNCTIONS
/**
* Console Log in color base on message type
*/
const log = {
/**
* Log information `green`
*
* @param {Object | String} message A message to log can be and object or a string
*/
info: (message) => {
console.log(
'\x1b[32m%s\x1b[0m', // green
'[' + new Date() + '] [' + pkg.displayName + ']', // Timestamp and Log Type
typeof message == 'object' ? JSON.stringify(message) : message // Message
)
},
/**
* Log warning `yellow`
*
* @param {Object | String} message A message to log can be and object or a string
*/
warning: (message) => {
console.log(
'\x1b[33m%s\x1b[0m', // yellow
'[' + new Date() + '] WARNING [' + pkg.displayName + ']', // Timestamp and Log Type
typeof message == 'object' ? JSON.stringify(message) : message // Message
)
},
/**
* Log error `red`
*
* @param {Object | String} message A message to log can be and object or a string
* @param {Boolean} trace To log only error message stack, if `message` is an instance of Error
*/
error: (message, trace = true) => {
if (trace && message instanceof Error) message = message.stack
console.log(
'\x1b[31m%s\x1b[0m', // red
'[' + new Date() + '] ERROR [' + pkg.displayName + ']', // Timestamp and Log Type
typeof message == 'object' ? JSON.stringify(message) : message // Message
)
},
}
/**
* Creates a backup of the application, including node modules.
* The backup is stored in the configured tempLocation.
*/
async function backupApp() {
let destination = path.join(config.tempLocation, backupSubdirectory)
if (config.keepAllBackup !== false) destination = path.join(destination, String(Date.now()))
log.info('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.info('Cloning ' + repo)
log.info('Destination: ' + destination)
await fs.ensureDir(destination)
await fs.emptyDir(destination)
await gitClone(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 = config.testing_dir ? path.join(appRootPath.path, config.testing_dir) : appRootPath.path
log.info('Installing application dependencies in ' + destination)
// Generate and execute command
let command = `cd ${destination} && npm install`
let child = exec(command)
// Wait for results
child.stdout.on('end', resolve)
child.stdout.on('data', (data) => log.info('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('Error installing dependencies')
log.error('' + data)
reject()
} else {
log.info('' + 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.info('Purging ignored files from the update')
config.ignoreFiles.forEach((file) => {
file = path.join(config.tempLocation, cloneSubdirectory, file)
if (fs.existsSync(file)) {
log.info('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 = config.testing_dir ? path.join(appRootPath.path, config.testing_dir) : appRootPath.path
log.info('Auto Git Update - Installing update...')
log.info('Auto Git Update - Source: ' + source)
log.info('Auto Git Update - Destination: ' + destination)
await fs.ensureDir(destination)
await fs.copy(source, destination)
return true
}
/**
* 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': pkg.displayName + ' - ' + repository } }
if (config.token) options.headers.Authorization = `token ${config.token}`
repository = this.genRepoURL(repository, 'release')
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.info('Checking release tag from ' + url)
// Attempt to identify the tag/version of the latest release
try {
let body = await this.getHttpsRequest(url, options)
let response = JSON.parse(body)
let tag = response.tag_name
config.branch = tag
} catch (err) {
if ((err = 404)) throw new Error('This repository requires a token or does not exist. \n ' + url)
throw err
}
}
/**
* Clone a repository with `simple-git`
*
* @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 gitClone(repo, destination, branch) {
return new Promise(function (resolve, reject) {
git().clone(repo, destination, [`--branch=${branch}`], (result) => {
if (result != null) reject(`Unable to clone repository\n ${repo}\n ${result}`)
resolve()
})
})
}