/* eslint-disable no-console */ import { Chalk, cyanBright, gray, green, red, white, yellow } from 'chalk'; import * as Diff from 'diff'; import * as envfile from 'envfile'; import * as fs from 'fs'; import { copyFileSync, existsSync, readFileSync, writeFileSync } from 'fs'; import glob from 'glob'; import * as path from 'path'; import * as readline from 'readline'; import * as yargs from 'yargs'; import { CommandModule } from 'yargs'; import { exitCode, getTimestamp } from '../../common'; import { BitwardenVault, Vault } from './bitwarden-vault'; export interface ApplyTemplatesOptions { only: string; yes: boolean; no: boolean; verbose: boolean; replace: boolean; bitwardenUrl: string | undefined; commentObsolete: boolean; } export interface DiffResult { result: 'created' | 'same' | 'different' | 'differentLayout'; diff: Diff.Change[]; } export const applyTemplates: CommandModule = { command: 'apply-templates', describe: 'Creates a local copy of each *.template file. ' + 'A diff and confirmation prompt will be displayed before any changes are made.\n' + '\n' + 'Env (.env) files have special treatment... the value for each key defined in the target file will be kept in these cases:\n' + ' a. A value for the key is not defined, or is blank, in the template\n' + ' b. The value in the template looks like \n' + ' c. The line from the template is included in the .env file and commented out\n' + '\n' + 'When a .env file is created from a template, an extra line will be prefixed in the form "#@TEMPLATE = {template-file}". ' + 'This directive will then be used to match the output file to the original template even after renaming the output file. ' + 'This simplifies management of .env files when multiple template files are provided for alternative environments.', builder: (yargs) => yargs .option('only', { alias: 'o', describe: 'A glob where files should be searched within.\n(Note: the script will attach "/*.template" to the given value)', string: true, default: '**', }) .option('yes', { alias: ['y'], describe: 'The script will overwrite files without prompting for user confirmation.', default: false, boolean: true, }) .option('no', { alias: 'n', type: 'boolean', describe: 'The script will not overwrite any files.', default: false, boolean: true, }) .option('verbose', { alias: 'v', type: 'boolean', describe: 'List all files and show all diff lines.', default: false, boolean: true, }) .option('replace', { type: 'boolean', describe: 'Replace all files reverting to template values. WARNING: this is destructive!', default: false, boolean: true, }) .option('bitwardenUrl', { type: 'string', describe: 'URL of a Bitwarden server to use as a source to resolve placeholders in the template. ' + 'Bitwarden CLI must be installed as a dev dependency, and the vault unlocked (see: https://bitwarden.com/help/cli/). ' + "Placeholders must be in the form: where fieldname can be 'username', 'password', or the name of a custom field.", default: undefined, hidden: true, }) .option('commentObsolete', { alias: 'co', type: 'boolean', describe: 'Comments out all variables that are no longer in the template.', default: false, boolean: true, }), handler: compareTemplates, }; /** * Represents a mapping between a config template and target file. */ export class ConfigTransform { constructor( public readonly template: string, public readonly target: string, ) { this.targetExists = existsSync(this.target); } readonly targetExists: boolean; compare(_: boolean, __: boolean, ___: boolean): DiffResult { const templateContent = readFileSync(this.template).toString(); const targetContent = readFileSync(this.target).toString(); const diff = Diff.diffLines(targetContent, templateContent); const changes = diff.filter((d) => d.added || d.removed); return { result: changes.length ? 'different' : 'same', diff, }; } writeTarget(): void { copyFileSync(this.template, this.target); } } /** * Represents a mapping between a .env config template and target file. */ export class EnvConfigTransform extends ConfigTransform { constructor(template: string, target: string, bitwardenVault?: Vault) { super(template, target); this.bitwardenVault = bitwardenVault; } readonly bitwardenVault?: Vault; private newContent?: string; private readTemplate(): string { const content = readFileSync(this.template).toString(); return this.bitwardenVault === undefined ? content : content.replace( /]*>/g, (match: string, identifier: string) => { if (!identifier.includes('::')) { console.warn( yellow( `WARNING: Bitwarden placeholder in unsupported format: ${match} in ${this.template}.`, ), ); return match; } const [name, fieldName] = identifier.split('::', 2); const value = this.bitwardenVault?.getValue(name, fieldName); if (value === undefined && !this.bitwardenVault?.error) { console.warn( yellow( `WARNING: A value was not found in Bitwarden for placeholder ${match} in ${this.template}.`, ), ); } return value ?? match; }, ); } private mergeContent( templateContent: string, targetContent: string, commentObsolete: boolean, ): string { const templateVars = envfile.parse(templateContent); const targetVars = envfile.parse(targetContent); const optionalCommentTag = commentObsolete ? '# ' : ''; let mergedContent = targetContent; let additionalContent = ''; for (const [k, v] of Object.entries(templateVars)) { // if template variable is not in the .env file - append it at the end if (!(k in targetVars)) { additionalContent += `${envfile.stringify({ [k]: v })}`; } } for (const [k, v] of Object.entries(targetVars)) { if (!(k in templateVars)) { // Handle obsolete variables in the existing .env file // Add a comment (if not already present) that the variable is obsolete // Comment the variable out if explicit flag is provided const lineRegex = new RegExp(`(?<=(\n|^))${k}=`); const line = targetContent.match(lineRegex); const obsoleteVarDescription = `# Variable ${k} is not a part of the template.\n`; const prefixToAttach = mergedContent.includes(obsoleteVarDescription) ? '' : obsoleteVarDescription; mergedContent = mergedContent.replace( lineRegex, prefixToAttach + optionalCommentTag + line?.[0], ); } else if (templateVars[k] !== v) { const commentRegex = new RegExp(`(?<=(\n|^))#${k}=`); const commented = targetContent.match(commentRegex); // if there is no dedicated comment for variable to preserve custom // value, template value is not a placeholder, and template value is not // empty - .env variable value is outdated and replaced with template value if ( !commented && !isPlaceholder(templateVars[k]) && templateVars[k] !== '' ) { mergedContent = mergedContent.replace( new RegExp(`(?<=(\n|^))${k}=.*?(\r?\n|$)`), envfile.stringify({ [k]: templateVars[k] }), ); } } } if (additionalContent !== '') { mergedContent += `\n# ${getTimestamp()} Newly added variables:\n${additionalContent}`; } return mergedContent; } compare( replace: boolean, verbose: boolean, commentObsolete: boolean, ): DiffResult { const templateContent = this.readTemplate(); const targetContent = readFileSync(this.target).toString(); this.newContent = replace ? templateContent : this.mergeContent(templateContent, targetContent, commentObsolete); // set the #@TARGET prefix line this.newContent = setDefinedTemplate( this.newContent, path.basename(this.template), ); const changed = this.newContent !== targetContent; let diff = [] as Diff.Change[]; if (changed) { // diff of sorted variable def lines diff = Diff.diffLines( envfile.stringify(orderByKey(envfile.parse(targetContent))), envfile.stringify(orderByKey(envfile.parse(this.newContent))), ).filter((d) => d.added || d.removed); } const result = !changed ? 'same' : diff.length > 0 ? 'different' : 'differentLayout'; if (result !== 'same' && verbose) { // full diff of all lines diff = Diff.diffLines(targetContent, this.newContent); } return { result, diff }; } writeTarget(): void { writeFileSync( this.target, this.newContent ?? // set defined template on newly created files: setDefinedTemplate(this.readTemplate(), this.template), ); } } /* * Use glob to iterate matching files. */ function compareTemplates(options: ApplyTemplatesOptions): void { interface Results { created: ConfigTransform[]; same: ConfigTransform[]; different: ConfigTransform[]; differentLayout: ConfigTransform[]; } const results: Results = { created: [], same: [], different: [], differentLayout: [], }; const bitwardenVault = options.bitwardenUrl !== undefined ? new BitwardenVault(options.bitwardenUrl) : undefined; const templatesPath = `${options.only}/*.template`; glob( templatesPath, { dot: true, ignore: '**/node_modules/**' }, (err, templates) => { if (err) { console.log(err); process.exit(exitCode); } for (const template of templates) { const isEnv = isEnvFile(path.basename(template)); const transforms = isEnv ? getEnvConfigTransforms(template).map( (target) => new EnvConfigTransform(template, target, bitwardenVault), ) : [new ConfigTransform(template, defaultTargetFor(template))]; for (const transform of transforms) { const resultKey = transformComparison( transform, options.replace, options.verbose, options.commentObsolete, ); results[resultKey].push(transform); } } const total = results.created.length + results.same.length + results.different.length + results.differentLayout.length; console.log( `Found ${total} template file${total !== 1 ? 's' : ''}. ${ results.created.length } target file${ results.created.length !== 1 ? 's were' : ' was' } created. ${ results.different.length + results.differentLayout.length } target file${ results.different.length + results.differentLayout.length !== 1 ? 's have' : ' has' } changes`, ); if (bitwardenVault?.error !== undefined) { console.warn( yellow( `WARNING: Bitwarden placeholders were not resolved. ${bitwardenVault.error}`, ), ); } if (results.different.length + results.differentLayout.length !== 0) { promptChanges(); } }, ); function promptChanges(): void { if (options.yes) { writeOutputFiles(); return; } else if (options.no) { console.log('Changes not applied.'); return; } const rl = readline.createInterface({ input: process.stdin, output: process.stdout, }); console.log( yellow( "NOTE: To prevent changes to .env files from being overwritten, don't remove or replace the original line but comment it out.", ), ); rl.question( cyanBright(`Do you want to apply the changes above? (y/N)\n`), (answer) => { if (answer === 'y') { writeOutputFiles(); } else { console.log('Changes not applied.'); } rl.close(); }, ); } function writeOutputFiles(): void { const transforms = [...results.different, ...results.differentLayout]; for (const transform of transforms) { console.log(`Applying ${transform.template} => ${transform.target}`); transform.writeTarget(); } console.log( `Changes applied to ${transforms.length} file${ transforms.length !== 1 ? 's' : '' }!`, ); } } function setDefinedTemplate(targetText: string, templateName: string): string { return getDefinedTemplate(targetText) ? targetText : `#@TEMPLATE = ${path.basename(templateName)}\n` + targetText; } function getDefinedTemplate(targetText: string): string | undefined { const match = targetText.match(/^#\s*@TEMPLATE\s*=(.+)/); return match ? match[1].trim() : undefined; } export function getEnvConfigTransforms(template: string): string[] { const results: string[] = []; const defaultTarget = defaultTargetFor(template); const dir = path.posix.dirname(defaultTarget); const defaultTargetName = path.posix.basename(defaultTarget); const templateName = path.posix.basename(template); // check all files in the same directory for (const fileName of fs.readdirSync(dir)) { // target must include '.env' and not end with '.template' if (isEnvFile(fileName) && !fileName.endsWith('.template')) { const file = path.posix.join(dir, fileName); const fileText = readFileSync(file).toString(); const definedTemplate = getDefinedTemplate(fileText); // file is a valid target if: it defines this template in a directive, // or: its the default target and it has no template directive if ( definedTemplate === templateName || (!definedTemplate && fileName === defaultTargetName) ) { results.push(file); } } } // if no targets were found and the default target doesn't exist then create it if (results.length === 0 && !fs.existsSync(defaultTarget)) { return [defaultTarget]; } return results; } /* * Compare a single template and target. * If target file doesn't exist then create it. * If both exist and there are differences, return 'different' and do nothing. */ export function transformComparison( transform: ConfigTransform, replace: boolean, verbose: boolean, commentObsolete: boolean, ): DiffResult['result'] { if (!transform.targetExists) { transform.writeTarget(); console.log( `Target file ${transform.target} did not exist and has been created.`, ); return 'created'; } const { result, diff } = transform.compare(replace, verbose, commentObsolete); if (result === 'same') { if (verbose) { console.log(gray(`File ${transform.target} has no changes.`)); } return result; } console.log( cyanBright( `File ${transform.target} has changes` + (transform.target !== defaultTargetFor(transform.template) ? `. Using template ${path.basename(transform.template)}` : '') + `${verbose ? '' : ' (-v to show full diff)'}:`, ), ); if (result === 'differentLayout' && !verbose) { console.log( gray(` (Changes to whitespace, comments and line-order only)`), ); return result; } logDiffLines(diff); return result; } function logDiffLines(diff: Diff.Change[]): void { for (const part of diff) { let value = part.value; // last line without newline causes formatting issues if (value !== '' && !value.endsWith('\n')) { value += '\n'; } const color: Chalk = part.added ? green : part.removed ? red : white; const prefix = part.added ? '+ ' : part.removed ? '- ' : ' '; value = value.replace(/(.*)\n/g, `${prefix}$1\n`); process.stdout.write(color(value)); } } function defaultTargetFor(template: string): string { return template.slice(0, -9); // remove ".template" } /* * Determine if the file is a .env file. Matches globs *.env.* & *.env */ function isEnvFile(file: string): boolean { return file.match(/\.env\b/) ? true : false; } /* * Determine if a value looks like a placeholder */ function isPlaceholder(value: string): boolean { // placeholders look like: <{aaa}> or return value.match(/^<[^>]*?\{[^>]*?\}[^>]*?>$/) ? true : false; } /** Orders an object by keys */ function orderByKey(unordered: Record): Record { return Object.keys(unordered) .sort() .reduce((obj, key) => { obj[key] = unordered[key]; return obj; }, {}); } // Dev helper. Script can be run directly with command (without building the lib): // yarn ts-node libs/cli/src/commands/apply-templates/apply-templates.ts apply-templates if (require.main === module) { yargs.command(applyTemplates).demandCommand().argv; }