@kwaeri/xfmr
Version:
The @kwaeri/xfmr component module of the @kwaeri application framework.
575 lines • 27.7 kB
JavaScript
/*******************************************************************************
* @module kwaeri/xfmr
* @version 0.4.0
* @license
* Copyright © 2014 - 2022 Richard Winters <kirvedx@gmail.com> and contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* SPDX-License-Identifier: Apache-2.0
******************************************************************************/
;
// INCLUDES
import { kdt } from '@kwaeri/developer-tools';
import { ServiceProvider } from '@kwaeri/service';
import { Configuration } from '@kwaeri/configuration';
import { glob } from "glob";
import * as fs from 'fs';
import * as _fs from 'fs/promises';
import * as path from 'path';
import VersionBump from './transformations/version-bump.mjs';
import debug from 'debug';
/* Configure Debug module support */
const DEBUG = debug('kue:xfmr');
/* Prepare KDT */
const _ = new kdt();
// Example of progress bar usage:
//progress.update( 25 ); progress.log( `Added 25% progress.` ).notify( "Adding another 25% progress.." );
//progress.updateAndNotify( 50, "Adding, again, another 25% progress..." );
// DEFINES
const TRANSFORMATION_TYPES = {
BUMP: "bump",
REPLACE: "replace"
};
const BUMP_TRANSFORMATIONS = {
VERSION: "version",
COPYRIGHT: "copyright",
};
const DEFAULT_TRANSFORMER_OPTIONS = {
environment: "default",
quest: "bump",
specification: "project-version",
args: {
// "step-back": "5",
},
subCommands: [],
version: "",
configuration: {
project: {
name: "",
type: "",
tech: "",
root: ".",
author: {
first: "",
last: "",
fullName: "",
email: ""
},
copyright: "",
copyrightEmail: "",
license: {
identifier: ""
},
repository: ""
}
},
xfmOptions: {
version: {
byType: undefined,
toVersion: undefined,
projectFlag: undefined,
sourceFlag: undefined,
testFlag: undefined,
},
copyright: {
copyrightType: "range",
toYear: undefined,
testFlag: undefined
}
},
xfmConf: {
source: {
match: [],
globOptions: {
"base": "./"
},
key: {
version: " * @version ",
copyright: " * Copyright © "
},
destination: undefined,
testDestination: "tests"
},
module: {
match: [],
globOptions: {
"base": "./"
},
key: "\"version\": \"",
destination: undefined,
testDestination: "tests"
}
}
};
/**
* The Transformer class is a replacement for gulp-bump-version within
* our newly redesigned project template that excludes gulp altogether
* as the primary build tool in favor of our own end-to-end tooling.
*
* The service provider can also be used as a template for other similar
* service provider types.
*/
export class Transformer extends ServiceProvider {
/**
* @var { Configuration }
*/
configuration;
/**
* Class constructor
*/
constructor(handler, configuration) {
super(handler);
// Organize all of the uncertainty:
//let environment = ( configuration && configuration.environment ) ? configuration.environment : "default";
// Instantiate a new configuration object to wrap the migrations configuration:
this.configuration = new Configuration(undefined, ".xfmrc");
}
getServiceProviderSubscriptions(options) {
return {
commands: {
"bump": {
"version": false, // The project specification has a required flag (type)
"copyright": false
}
},
required: {
"bump": {
"version": { // for the project specifications of the 'new' command:
//"type": [ // The flag's possible acceptable values:
// "api",
// "react"
//]
},
"copyright": {}
}
},
optional: {
"bump": {
"version": {
"component": {
"for": false, // Or true, if it related to an option/value, rather only to the specification.
"flag": false, // True insists that no value is given. Its existance equates to <option>=1, the lack of its
"values": [
"major", // Pairs with --bump-version to specify which semver component to bump, leaving this option out when specifying
"minor" // --bump-version indicates the patch component
]
},
"to-version": {
"for": false, // Or true, if it related to an option/value, rather only to the specification.
"flag": false, // True insists that no value is given. Its existance equates to <option>=1, the lack of its
"values": ["*"] // Indicates any string value is expected (though internal logic will validate it)
},
"project": {
"for": false,
"flag": true
},
"source": {
"for": false,
"flag": true
}
},
"copyright": {
"type": {
"for": false,
"flag": false,
"values": [
"range", // Default
"each",
"current"
]
}
}
}
}
};
}
getServiceProviderSubscriptionHelpText(options) {
return {
helpText: {
"commands": {
"bump": {
"description": "The 'bump' command automates semver transformation.",
"specifications": {
"version": {
"description": "Bumps the semver string within source files of the project, and according to options provided.",
"options": {
"required": { // ⇦ Required options are specific to the specification, 'project' in this case,
//"type": { // ⇦ For the "type" required option
// "react": { // ⇦ For the required options value, can be 'any'
// "redux": { // ⇦ List options
// "description": "Denotes that the project should include redux support",
// "values": false
// }
// }
//}
},
"optional": {
"specification": {
"component": {
"description": "Denotes that the version string should be bumped based on a component.",
"values": [
"major",
"minor"
]
},
"to-version": {
"description": "Denotes that the version string should be set to a specific version.",
"values": [
"Any semantic version (i.e. x.x.x)",
]
},
"project": {
"description": "Denotes that the version should be bumped within the project's `package.json` file by leveraging it's alternate key.",
"values": false
},
"source": {
"description": "Denotes that the version should be bumped within project source files.",
"values": false
}
} // ⇦ For the various required options that allow optional flags
}
}
},
"copyright": {
"description": "Bumps the copyright string within source files of the project, and according to options provided.",
"options": {
"required": {},
"optional": {
"specification": {
"type": {
"description": "Denotes that the copyright string is of the type specified.",
"values": [
"range",
"each",
"current"
]
}
}
}
}
}
},
"options": {
"optional": {
//"command": { // ⇦ For the command itself
// "example-option": { // ⇦ List options
// "description": "",
// "values": []
// }
//},
//"optional-option": { // ⇦ For the optional options of the command
// "optional-value": { // ⇦ For the optional options value, can be 'any'
// "example-option": { // ⇦ List options
// "description": "",
// "values": []
// }
// }
//}
}
}
}
}
}
};
}
/**
* Method to resettle the { NodeKitProjectGeneratorOptions }. Essentially we
* merge NodeKitOptions with FilesystemDescriptor by combining provided
* command options with either a stored configuration or sane default.
*
* @param { ReactComponentGeneratorOptions } options
*
* @returns { ReactComponentGeneratorOptions } The options object, with the configuration partially populated with user-provided information
*/
async assembleOptions(options) {
DEBUG(`[ASSEMBLE_OPTIONS] Read configuration '.xfmrc'`);
const conf = await this.configuration.get();
if (!conf)
return Promise.reject(new Error("[ASSEMBLE_OPTIONS] There was an issue reading .xfmrc"));
DEBUG(`[ASSEMBLE_OPTIONS] Extend missing settings with defaults`);
const xfmConf = _.extend(conf, DEFAULT_TRANSFORMER_OPTIONS.xfmConf);
let returnable;
returnable = _.extend(options, DEFAULT_TRANSFORMER_OPTIONS);
returnable.xfmConf = xfmConf;
DEBUG(`[ASSEMBLE_OPTIONS] Sanitize settings`);
//returnable.type = ( returnable.args.type in PARAMATERIZATION.TYPE ) ? returnable.args.type : 'mysql';
returnable.xfmOptions.version.toVersion = (returnable.args['to-version']) ? returnable.args['to-version'] : undefined;
returnable.xfmOptions.version.byType = (returnable.args['component']) ? returnable.args['component'] : undefined;
returnable.xfmOptions.version.sourceFlag = (returnable.args['source']) ? returnable.args['source'] : undefined;
returnable.xfmOptions.version.projectFlag = (returnable.args['project']) ? returnable.args['project'] : undefined;
returnable.xfmOptions.version.testFlag = (returnable.args['test']) ? returnable.args['test'] : undefined;
returnable.xfmOptions.copyright.copyrightType = (returnable.args['copyright-type']) ? returnable.args['copyright-type'] : "range";
returnable.xfmOptions.copyright.toYear = (returnable.args['to-year']) ? returnable.args['to-year'] : undefined;
returnable.xfmOptions.copyright.testFlag = (returnable.args['test']) ? returnable.args['test'] : undefined;
return Promise.resolve(returnable);
}
async renderService(options) {
this.updateProgress('RENDER_TRANSFORM_SERVICE', { progressLevel: 0, notice: `Preparing to transform ${(options.args['project'] ? `project ` : ``)}${options.specification}` });
try {
DEBUG(`[RENDER_TRANSFORM_SERVICE] Resettle options`);
const opts = await this.assembleOptions(options);
DEBUG(`\n`, `\n`, ` Start transform-files routine:\n`, ` ⇨ Test run: ${opts.xfmOptions.version.testFlag}\n`, ` ⇨ Source bump: ${opts.xfmOptions.version.sourceFlag}\n`, ` ⇨ Module bump: ${opts.xfmOptions.version.projectFlag}\n`, ` ⇨ Bump options:\n `, {
type: opts.xfmOptions.version.byType,
version: opts.xfmOptions.version.toVersion,
key: opts.xfmConf.source.key.version
}, `\n`);
/**
* We'll eventually add other types of transformations than just 'bump version'
*/
switch (options.quest) {
case TRANSFORMATION_TYPES.BUMP:
{
if (options.specification == BUMP_TRANSFORMATIONS.VERSION)
return Promise.resolve(await this.transformTarget(opts));
else {
if (options.specification == BUMP_TRANSFORMATIONS.COPYRIGHT)
return Promise.resolve({ result: false, type: TRANSFORMATION_TYPES.BUMP, message: `Transformation of type '${options.quest} ${options.specification}' is not yet implemented.` });
}
}
break;
case TRANSFORMATION_TYPES.REPLACE:
{
return Promise.resolve({ result: false, type: TRANSFORMATION_TYPES.REPLACE, message: `Transformation of type '${options.quest} ${options.specification}' is not yet implemented.` });
}
break;
default:
return Promise.resolve({ result: false, type: "unknown", message: `Transformation of type '${options.quest} ${options.specification}' is not supported.` });
break;
}
}
catch (error) {
return Promise.reject(error);
}
}
/**
* Method to simplify emitting progress bar events and debugging
*
* @param tag The tag to note for debugging purposes
* @param serviceEventBits The progress bar event metadata to leverage in setting service event metadata
*
* @returns { void }
*/
updateProgress(tag, serviceEventBits) {
DEBUG(`[${tag}] call 'setServiceEventMetadata' with '${serviceEventBits}`);
this.setServiceEventMetadata(serviceEventBits);
//this.progressHandler( serviceEventBits );
}
/**
* Gets an array of string paths returned by the glob processor
*
* @param { string|string[] } target The string or string array of glob patterns to match
* @param { any } options The glob processor's options (see {@link glob})
*
* @returns { Promise<string[]> } The promise of an array of string paths
*/
async getTargetPaths(target, options) {
DEBUG(`Getting files for processing`);
// Fix non-arrays into arrays
const globs = (_.type(target) != "array" && _.type(target) == 'string') ?
[target] :
target;
DEBUG(`Source globs:\n`, `⇨ ${globs}`, `\n`, `Ignore globs:\n`, `⇨ ${options.ignore}`);
DEBUG(`Globbing sources`);
let paths = [];
for (let query in globs) {
DEBUG(`⇨ globbing source'${globs[query]}'`);
paths = [...paths, ...(await this.globIt(globs[query], options))];
//glob( path.join( ((options.base )?options.base:""), globs[query] ), callback );
}
return Promise.resolve(paths);
}
/**
* Wraps `glob` with a promise
*
* @param { string } target A string pattern for glob to match against
* @param { any } options `glob`'s options object
*
* @returns { string[] } An array of matched PlatformPaths as strings
*/
async globIt(target, options) {
try {
let globbed = await glob(target, options);
// If all goes as expected, return the promised globbed paths:
return Promise.resolve(globbed);
}
catch (exception) {
// Otherwise, reject the promise with the exception:
return Promise.reject(exception);
}
}
/**
* Applies a transform stream to each target path provided. This method wraps the
* process and responds appropriately for the service provider
*
* @param { XfmrOptions } options
*
* @returns { Promise<any> } A promise of any - though expect an extended object of type {@link ServicePromiseBits}
*/
async transformTarget(options) {
const { xfmOptions, xfmConf } = options;
let source = [], module = [];
if (options.xfmOptions.version.sourceFlag)
source = await this.process(await this.getTargetPaths(xfmConf.source.match, xfmConf.source.globOptions), xfmOptions.version, xfmConf.source, true);
if (options.xfmOptions.version.projectFlag)
module = await this.process(await this.getTargetPaths(xfmConf.module.match, xfmConf.source.globOptions), xfmOptions.version, xfmConf.module, true);
// TODO: Add support for Copyright bump!
return Promise.resolve({ result: true, type: `bump_version`, targets: [...source, ...module] });
}
;
/**
* A middle-man method that eases readability due to needing to support
* several related transformations, each of which have certain catche
* cases that differentiate and nuance them from one another.
*
* A different method is called from here depending on whether we are
* transforming the version or the copyright; If the former, we may
* make upwards of two calls to support the differentiation between
* the version within typical source and that of the module's
* package.json file.
*
*
* @param { string[] } files An array of target paths matched for transformation
* @param { VersionTransformOptions | CopyrightTransformOptions } opts A {@link VersionTransformOptions} or {@link CopyrightTransformOptions} object
* @param { XfmConfigurationBits } conf Eitehr the source or module {@link XfmConfigurationBits} object from the {@link XfmConfiguration} objecct
* @param { boolean } version Denotes whether or not we're doing a version transform. Defaults to false
* @param { boolean } copyright Denotes whether or not we're doing a copyright transform. Defaults to false
*
* @returns { Promise<string[]> } The promise of an array of strings
*/
async process(files, opts, conf, version = false, copyright = false) {
const totalFiles = files.length;
let processed = [], lastFile = "";
// Process each target
for (let file in files) {
// Ensure the file destination path exists:
const target = await this.satisfyDestFs(files[file].toString(), opts.testFlag, conf.testDestination, conf.destination);
// Do not await this within assignment, it will not run in context!
this.updateProgress("XFMR", {
progressLevel: ((parseInt(file) / totalFiles) * 100),
notice: `Processing '${files[file]}'`
});
if (lastFile && lastFile !== "")
this.updateProgress("XFMR", {
log: `Processed '${lastFile}'`
});
const transformed = await this.processFile(files[file].toString(), target, conf, opts, version, copyright);
this.updateProgress("XFMR", {
progressLevel: ((parseInt(file) / totalFiles) * 100),
notice: `Processing '${files[file]}'`
});
// Assign only after the file has been processed!
processed.push(transformed);
lastFile = files[file].toString();
}
return Promise.resolve(processed);
}
/**
* Ensures that the destination for a target exists so that the
* target can be written .
*
* @param { string } target The path to the target, relative the cwd
* @param { boolean } test Denotes whether this is a test run
* @param { string } destination The destination of the target, relative to cwd
*
* @return { Promise<string> } A promise of the new string path to target
*/
async satisfyDestFs(target, test = false, testDestination = "tests", destination = "") {
// Add a new base to the path if necessary:
const dest = (test && path.join(testDestination, target)) ||
path.join(destination, target);
DEBUG(`Verify target destination '${dest}'`);
// Split the path to the target by the directory separator
const ancestors = dest.split("/");
DEBUG(`Target destination ancestors: `);
DEBUG(ancestors);
// Let's build a path to the parent of the target, if it's not
// the wurrent working directory
if (ancestors.length > 1) {
DEBUG(`Assure target destination path`);
// Remove the file name, which should be the last
// array index
const file = ancestors.pop();
DEBUG(`Pop target '${file}' from ancestry `);
// If undefined was not returned, there was a child directory
// in our current working directory that is home to the target.
// We need to make sure that that path exists, we'll call
// mkdir with recursive set to true; There's no error if
// the directory already exists, and it makes all parent paths:
await _fs.mkdir(path.join(...ancestors), { recursive: true });
// Otherwise we don't need to do anything else
}
return Promise.resolve(dest);
}
/**
* Applies a transform stream between a read and write stream so as to bump
* the semantic version string found within each target according to options
* provided.
*
* @param { string } file The string path of the target file
* @param { XfmConfigurationBits } conf A {@link XfmConfigurationBits} object
* @param { VersionTransformOptions } options A {@link VersionTransformOptions} object
* @param { boolean } version Denotes whether to transform the version
* @param { boolean } copyright Denotes whether to transform the copyright
*
* @returns { string } The string destination path of the target
*/
async processFile(source, target, conf, options, version = false, copyright = false) {
try {
DEBUG(`Read source '${source}'`);
const read = await fs.promises.readFile(source, { encoding: "utf8" });
DEBUG(`Transform data`);
const output = this.transformData(read, conf, options, version, copyright);
DEBUG(`Write source '${target}'`);
await fs.promises.writeFile(target, output, { encoding: "utf8" });
}
catch (exception) {
DEBUG(exception);
return Promise.reject(exception);
}
DEBUG(`Resolve processed file '${source}'`);
return Promise.resolve(source);
}
;
/**
* Method intended to apply the correct transformation of those available, based on the request
*
* @param { string } data The read in data
* @param { XfmConfigurationBits } conf A {@link XfmConfigurationBits} object
* @param { VersionTransformOptions } options A {@link VersionTransformOptions} object
* @param { boolean } version Denotes whether to transform the version
* @param { copyright } copyright Denotes whether to transform the copyright
*
* @returns { string } The transformed data. If an error is caught, its `throw`n
*/
transformData(data, conf, options, version = false, copyright = false) {
const { toVersion, byType, } = options, key = options.projectFlag ? conf.key : conf.key.version;
DEBUG(`Set version bump options`);
const opts = toVersion ? // If we're setting "to version", then
{ version: toVersion, key: key } :
byType ? // Otherwise, check if it is being set "by component"
{ type: byType, key: key } :
{ type: "patch", key: key }; // Fallback on patch bump.
const bumper = new VersionBump();
DEBUG(`Apply version transformation`);
const output = bumper.bumpVersion(data, opts);
const error = (output == -3) ? new Error(`ERROR: You must provide an options argument as an object with a property of version or type, appropriately set for modifying file versions as you prefer. \n` +
`Visit http://gitlab.com/mmod/gulp-bump-version#basic-usage-examples for more information.\n\n`) : null;
DEBUG(`Error?: ${error}`);
if (error !== null)
throw error;
DEBUG(`Return Transformed data`);
return output;
}
}
//# sourceMappingURL=xfmr.mjs.map