UNPKG

crondo

Version:

Command line tool to schedule and run tasks with output written to the console, a file or emailed.

440 lines (386 loc) 13.7 kB
#!/usr/bin/env node "use strict"; /*eslint-disable no-console*/ /** Command line tool to schedule and run tasks. Development funded by NASA. author: Todd King version: 1.00 2022-04-04 **/ const fs = require('fs'); const path = require('path'); const yargs = require('yargs'); const nodemailer = require('nodemailer'); const CronJob = require('cron').CronJob; const { exec } = require('child_process'); // Configure the app var options = yargs .version('0.1.4') .usage('Command line tool to schedule and run tasks.\n\nUsage:\n\n$0 [args] crontab.json') .example('$0 example.json', 'Run tasks on the schedule defined in example.json') .epilog("Development funded by NASA's HPDE project at UCLA.") .showHelpOnFail(false, "Specify --help for available options") .help('h') // version .options({ // help text 'h' : { alias : 'help', description: 'Show information about the app.' }, 'v' : { alias: 'verbose', describe : 'Show information while processing files', type: 'boolean', default: false }, // Config file 'c' : { alias: 'config', describe : 'File containing cron configuration and task specifications.', type: 'string', default: null }, // From e-mail address for the email message. 'f' : { alias: 'from', describe : 'From e-mail address. If missing user name in mailer configuration is used as the from address.', type: 'string', default: null }, // Write output to a file 'o' : { alias: 'output', describe : 'Write output to a file. Default: console', type: 'string', default: null }, }) .argv ; // Globals var args = options._; // Remaining non-hyphenated arguments var outputFile = null; // None defined. var transporter = null; // Email transporter var timezone = null; // "America/Los_Angeles"; /** * Write text to the choosen output stream. **/ var outputWrite = function(str) { if(outputFile == null) { console.log(str); } else { outputFile.write(str); } } /** * Close an output file if one is assigned. **/ var outputEnd = function() { if(outputFile) { outputFile.end(); outputFile = null } } /** * Send email to the designated destination **/ var sendmail = function(from, to, subject, message, attachments) { if( ! transporter) { outputWrite("No mail transporter defined. Unable to send email."); return; } var mailOptions = { from: from, to: to, subject: replaceTokens(subject), text: message, attachments: attachments }; if(options.verbose) { outputWrite("Sending:") outputWrite(mailOptions); } transporter.sendMail(mailOptions, function(error, info) { if (error) { console.error(error); } else { console.log('Email sent: ' + info.response); // remove attachment if(attachments) { // Remove files for(let i = 0; i < attachments.length; i++) { fs.unlinkSync(attachments[i].pathname); } } } }); } /** * Replace tokens in a string. * * Token Replaced with * ${date} Current date in YYYY-MM-DD format * ${time} Current time in HHMM.SS format **/ var replaceTokens = function(text) { const now = new Date(Date().toLocaleString("en-US", { timeZone: timezone })); // Create YYYY-MM-DD format let datestamp = now.getFullYear() + "-" + ("0" + (now.getMonth() + 1)).slice(-2) + "-" + ("0" + (now.getDate())).slice(-2) let timestamp = ("0" + (now.getHours() + 1)).slice(-2) + ("0" + (now.getMinutes() + 1)).slice(-2) + "." + ("0" + (now.getSeconds())).slice(-2) return text.replace(/\${date}/g, datestamp).replace(/\${time}/g, timestamp); // Replace "${date}" with current date } /** * Write content to a file and return name of file. **/ var createAttachment = function(pattern, content) { let filename = replaceTokens(pattern); try { // Do we need safe gaurds about name and path?? fs.writeFileSync(filename, content); } catch(e) { console.error(e.message); } return filename; } /** * Report on the results of running a task. This will write to the console, a file or send email based on job configuration. **/ var report = function(job, content, suffix) { let body = ""; let attachment = null; let attachments = null; if(job.description) body = replaceTokens(job.description); if(job.logAs) { // Write content into file attachment = createAttachment(job.logAs, content); } if(job.mailTo) { // Send email if(transporter) { if( ! job.notifyOnly) { // Attach or include output if(job.attachAs) { // Write content into file attachment = createAttachment(job.attachAs, content); attachments = [ { // stream as an attachment pathname : attachment, // Our custom payload filename: path.basename(attachment), content: fs.createReadStream(attachment) } ] } else { body += content; } } sendmail((job.from ? job.from : options.from), job.mailTo, replaceTokens(job.subject) + suffix, body, attachments); } else { if( ! transporter) outputWrite('Warning: Mail transporter not configured, but a mailTo is specified.'); } } else { // Write to output (console or file) outputWrite(content); } } /** * Convert a month cron field if it contains month names. * Long and short (3-letter) month names are converted to month number * January is month number 0. **/ var convertMonths = function(text) { text = text.toLowerCase(); // Normalize text // Do long names first text = text.replace(/january/g, 0); text = text.replace(/february/g, 1); text = text.replace(/march/g, 2); text = text.replace(/april/g, 3); text = text.replace(/may/g, 4); text = text.replace(/june/g, 5); text = text.replace(/july/g, 6); text = text.replace(/august/g, 7); text = text.replace(/september/g, 8); text = text.replace(/october/g, 9); text = text.replace(/november/g, 10); text = text.replace(/december/g, 11); // Now do short names first text = text.replace(/jan/g, 0); text = text.replace(/feb/g, 1); text = text.replace(/mar/g, 2); text = text.replace(/apr/g, 3); text = text.replace(/may/g, 4); text = text.replace(/jun/g, 5); text = text.replace(/jul/g, 6); text = text.replace(/aug/g, 7); text = text.replace(/sep/g, 8); text = text.replace(/oct/g, 9); text = text.replace(/nov/g, 10); text = text.replace(/dec/g, 11); return text; } /** * Convert a day cron field if it contains day names. * Long and short (3-letter) day names are converted to day number * Monday is day number 0. **/ var convertDayOfWeek = function(text) { text = text.toLowerCase(); // Normalize text // Do long names first text = text.replace(/sunday/g, 0); text = text.replace(/monday/g, 1); text = text.replace(/tuesday/g, 2); text = text.replace(/wednesday/g, 3); text = text.replace(/thursday/g, 4); text = text.replace(/friday/g, 5); text = text.replace(/saturday/g, 6); // Now do short names first text = text.replace(/sun/g, 0); text = text.replace(/mon/g, 1); text = text.replace(/tue/g, 2); text = text.replace(/wed/g, 3); text = text.replace(/thu/g, 4); text = text.replace(/fri/g, 5); text = text.replace(/sat/g, 6); return text; } /** * Create a schedule string from job specification. * Format of job spec is * ┌────────────── second (optional) * │ ┌──────────── minute * │ │ ┌────────── hour * │ │ │ ┌──────── day of month * │ │ │ │ ┌────── month * │ │ │ │ │ ┌──── day of week * │ │ │ │ │ │ * │ │ │ │ │ │ * * * * * * * * * For month you can use month number or names. For example: Jan, Feb, Mar * For day or week you can use day names. For example: Monday, Tuesday, etc. **/ var getSchedule = function(job) { if( ! job) return ""; var onTick = ""; // A little fix - if time segment specified, make any unspecified more frequent segment 0. if( typeof job.every.months !== 'undefined' && job.every.months !== null ) { // Defined if( typeof job.every.hours === 'undefined' ) job.every.hours = 0; if( typeof job.every.minutes === 'undefined' ) job.every.minutes = 0; if( typeof job.every.seconds === 'undefined' ) job.every.seconds = 0; } if( typeof job.every.hours !== 'undefined' && job.every.hours !== null ) { // Defined if( typeof job.every.minutes === 'undefined' ) job.every.minutes = 0; if( typeof job.every.seconds === 'undefined' ) job.every.seconds = 0; } if( typeof job.every.minutes !== 'undefined' && job.every.minutes !== null ) { // Defined if( typeof job.every.seconds === 'undefined' ) job.every.seconds = 0; } // Now create "cron" schedule if (typeof job.every === 'string' || job.every instanceof String) { // If a token if(job.every == "@yearly" || job.every == "@annually") return("0 0 0 1 1 *"); if(job.every == "@monthly") return("0 0 0 1 * *"); if(job.every == "@weekly") return("0 0 0 * * 0"); if(job.every == "@daily" || job.every == "@midnight") return("0 0 0 * * *"); if(job.every == "@hourly") return("0 0 * * * *"); } else { // Build up based on what is present if( typeof job.every.seconds === 'undefined' ) { onTick += "*"; } else { onTick += job.every.seconds; }; onTick += " "; if( typeof job.every.minutes === 'undefined' ) { onTick += "*"; } else { onTick += job.every.minutes; }; onTick += " "; if( typeof job.every.hours === 'undefined' ) { onTick += "*"; } else { onTick += job.every.hours; }; onTick += " "; if( typeof job.every.dayOfMonth === 'undefined' ) { onTick += "*"; } else { onTick += job.every.dayOfMonth; }; onTick += " "; if( typeof job.every.months === 'undefined' ) { onTick += "*"; } else { onTick += convertMonths(job.every.months); }; onTick += " "; if( typeof job.every.dayOfWeek === 'undefined' ) { onTick += "*"; } else { onTick += convertDayOfWeek(job.every.dayOfWeek); } } if(options.verbose) console.log("onTick: " + onTick); return onTick; } /** * Show the job queue **/ var showQueue = function(jobs) { if( ! jobs ) return; for(let i = 0; i < jobs.length; i++) { let job = jobs[i]; console.log(job.subject); console.log(job.proc.lastDate()); console.log(job.proc.nextDate()); } } /** * Application entry point. **/ var main = async function(args) { if (options.config == null && args.length == 0) { yargs.showHelp(); return; } // Output if(options.output) { outputFile = fs.createWriteStream(options.output); } // Set contab file name. Priority is if passed as option. var pathname = (options.config ? options.config : args[0]); // Parse cron config file var config = null; try { config = JSON.parse(fs.readFileSync(pathname)); } catch (e) { outputWrite(e.message); return; } // Set-up if(config.mailer && config.mailer.active) { if(options.verbose) { outputWrite("Setting up mailer ...") outputWrite(JSON.stringify(config.mailer, null, 3)); } try { // Create mail transporter transporter = nodemailer.createTransport(config.mailer); /* transporter = nodemailer.createTransport({ service: config.mailer.service, auth: { user: config.mailer.auth.user, pass: config.mailer.auth.password } }); */ if(options.from == null) { // If by not set, options.from = config.mailer.user; } } catch (e) { outputWrite(e.message); return; } } // Set defaults if( config.timezone ) { timezone = config.timezone; } if( ! config.jobs ) { outputWrite("Error: No jobs defined are defined. Nothing will be done."); return; } // Create each job if(options.verbose) { outputWrite("Creating jobs..."); } for(let i = 0; i < config.jobs.length; i++) { let job = config.jobs[i] if(job.active !== undefined && ! job.active) { job.proc = null; continue; } // Don't create job if(options.verbose) { outputWrite("Defining: "); outputWrite(job); } job.proc = new CronJob(getSchedule(job), function() { const subprocess = exec(job.task, (error, stdout, stderr) => { if (error) { report(job, error, ": Error occurred"); } else { // It ran OK report(job, stdout + stderr, ""); } }, null, false, timezone ); }); } // Start each job if(options.verbose) { outputWrite("Starting all jobs..."); } for(let i = 0; i < config.jobs.length; i++) { let job = config.jobs[i] if(job.proc) job.proc.start(); } } main(args);