@kwaeri/steward
Version:
The @kwaeri/steward component module of the @kwaeri/cli user-executable framework.
541 lines • 25 kB
JavaScript
/**
* SPDX-PackageName: kwaeri/steward
* SPDX-PackageVersion: 0.5.0
* SPDX-FileCopyrightText: © 2014 - 2022 Richard Winters <kirvedx@gmail.com> and contributors
* SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception OR MIT
*/
;
import { kdt } from '@kwaeri/developer-tools';
import { Filesystem } from '@kwaeri/filesystem';
import { Configuration } from '@kwaeri/configuration';
import { Progress } from '@kwaeri/progress';
import { Console } from '@kwaeri/console';
import debug from 'debug';
// DEFINES
const _ = new kdt(),
//let dbconf = dbConfig.default;
filesystem = new Filesystem(), output = new Console({ color: false, background: false, decor: [] }), progress = new Progress({ spinner: true, spinAnim: "dots", percentage: false });
/* Some constants for use in logic control: */
export const DEFAULTS = {
PROVIDER_LIST: [
"@kwaeri/node-kit-project-generator",
//"@kwaeri/node-kit-end-point-generator",
//"@kwaeri/react-component-generator",
"@kwaeri/mysql-migrator",
"@kwaeri/mysql-migration-generator",
]
};
/* Some constants for use in code flow control: */
export const DELEGATION = {
FILE_GENERATOR: "file-generator",
MIGRATION_GENERATOR: "migration-generator",
PASSWORD_GENERATOR: "password-generator",
MIGRATOR: "migrator"
};
export const KUE_QUEST = {
NEW: "new",
ADD: "add",
GENERATE: "generate",
MIGRATIONS: "migrations"
};
export const KUE_SPECIFICATION = {
PROJECT: "project",
ENDPOINT: "end-point",
COMPONENT: "component",
MIGRATION: "migration",
PASSWORD: "password"
};
/* Configure Debug module support */
const DEBUG = debug('kue:steward');
/**
* Steward Class
*
* The Steward serves the CLI by mitigating the responsibility of
* discovering service providers and their subscriptions, delegating
* responsbility for commands received by the CLI for the end-user,
* and responding back to the end user upon contract fulfillment.
*/
export class Steward {
/**
* @var { KWAERI.ENV.DEFAULT | KWAERI.ENV.PRODUCTION | KWAERI.ENV.TEST | undefined } environment
*/
environment;
/**
* @var { Configuration } configuration;
*/
configuration;
/**
* @var { ServiceProvidersList } serviceProviders
*/
serviceProviders = {};
/**
* @var { Automaton } automaton
*/
//public automaton: Automaton;
/**
* @var { Migrator } migrator
*/
migrator;
/**
* @var { Cryptographer } cryptographer
*/
//public cryptographer: Cryptographer;
/**
* Class constructor
*/
constructor(environment) {
this.environment = environment;
// Prepare the CLI configuration object
this.configuration = new Configuration('conf', 'cli.default.json', 'cli');
// Create an instance of the Automaton, which performs file/folder generating:
//this.automaton = new Automaton();
// Create an instance of the Migrator, which handles migrations.
//this.migrator = new Migration();
// Create an instance of the Cryptographer, which handles facilitating the
// manipulation of strings, such as hashing strings for use as passwords:
//this.cryptographer = new Cryptographer();
}
/**
* Method to get a service provider's name based on the module import string.
*
* @param { String } moduleName The module import string
*
* @returns { String } The service provider's class name
*/
getServiceProvider(moduleName) {
// We have a naming convention to follow:
//
// Whether the service provider is under a scope or not we can split the string from '/':
const bits = moduleName.split('/');
// Then return the service provider class name by formatting it according to standards:
return bits[bits.length - 1] // Take from after the scope part of the module import string (i.e. the 'y' in @xxx/<yyyyy>)
.split('-') // Split the string by its separators
.map(// Run map over the array
(str) => str.charAt(0).toUpperCase() + str.slice(1) // Capitalizing the first letter of each part (UpperCamelCase)
)
.join(''); // And finally join them all together.
}
/**
* Method to load the service provider list from the cli configuration (or fallback
* to the default list of service providers)
*
* @params none
*
* @returns { ProvidersConfigurationPromise } A list of service provider's (their module import strings)
*/
async getProviders() {
let providers = [];
// First let's read in the cli.json configuration and get our list of 'providers':
let context = false;
DEBUG(`Check CWD for CLI configuration`);
// Let's check that the configuration is in the current working directory first (overpowers the conf directory):
this.configuration = new Configuration('', 'cli.json', 'cli');
context = await this.configuration.get();
if (!context.success) {
context = false;
DEBUG(`CLI configuration in CWD: [%o]`, context);
DEBUG(`Check ./conf for CLI configuration`);
// from here we'll check the conf diretory
this.configuration = new Configuration('conf', 'cli.default.json', 'cli');
context = await this.configuration.get();
if (!context.success) {
// Let's go ahead and check the users
context = false;
DEBUG(`CLI configuration in ./conf directory: [%o]`, context);
DEBUG(`Check user profile directory for CLI configuration`);
this.configuration = new Configuration(Filesystem.getPathToCUD(), 'cli.default.json', 'cli');
context = await this.configuration.get();
// If we didn't find the configuration in the users directory,
// then load the defaults:
if (!context.success) {
context = false;
DEBUG(`CLI configuration not found; Load default providers: [%o]`, DEFAULTS.PROVIDER_LIST);
providers = DEFAULTS.PROVIDER_LIST;
return Promise.resolve({ list: providers });
}
DEBUG(`CLI configuration found in user profile directory:`);
DEBUG(context);
// Otherwise, load the user config:
providers = context.configuration.providers;
return Promise.resolve({ list: providers });
}
DEBUG(`CLI configuration found in ./conf directory:`);
DEBUG(context);
// Otherwise, load the conf directory config:
providers = context.configuration.providers;
return Promise.resolve({ list: providers });
}
DEBUG(`CLI configuration found in CWD:`);
DEBUG(context);
// Otherwise, load the CWD config:
providers = context.configuration.providers;
return Promise.resolve({ list: providers });
}
/**
* Method to discover service providers and the service contracts they offer
*
* @param none
*
* @returns { Promise<DiscoveryPromise> } Returns providers contract metadata
*/
async getContracts() {
DEBUG(`Load providers from CLI configuration`);
// Start by getting a list of providers
const providers = (await this.getProviders()).list;
DEBUG(`Discover provider published subscriptions`);
// Our list of providers can now be used to fetch providerMetaData, and discover which
// contracts for each, our CLI can fulfill:
let contracts = {};
for (let providerPath of providers) {
// The provider's class name for convenience:
const providerName = this.getServiceProvider(providerPath).toString();
DEBUG(`Import provider '%s' from '%s'`, providerName, providerPath);
// Require the provider module, it returns an object of module exports:
const providerExports = await import(providerPath);
DEBUG(`Call provider '$s' constructor`, providerName);
DEBUG(providerExports);
// Instantiate the service provider, stowing the instance in the steward for later
// use, here we must call providerExports[providerName] so as the call the
// proper export for instantiation:
this.serviceProviders[providerName] = {
instance: new providerExports[providerName](this.environment !== "test" ? progress.getHandler() : undefined, { environment: this.environment }),
contracts: null,
helpText: null
};
// Fetch a copy of the service provider's contracts, similarly stowing
// the copy in the steward for later use:
this.serviceProviders[providerName].contracts = this.serviceProviders[providerName].instance.getServiceProviderSubscriptions();
/*
* Here we do a clone of the contracts from each provider - taking them from
* the `this.serviceproviders` object we built as we collected the contracts
* from each provider, and packing th em into a `contracts` object to return
* to the CLI.
*
* We must be mindful that as we take the 'contracts' property from each provider
* stowed within the `this.serviceProviders` object, we're making a clone of each
* of the first `contracts` property, but referencing all of its values.
*
* As we do this to the first provider's contracts, all seems well. As we do this
* to the second what happens locally is that our `contracts` variable - which now
* has a set of { commands: {}, required: {}, optional: {} }, is getting a second
* set of those same nested properties merged into it (for the sake of our CLI
* being able to more easily parse through it for input validation ).
*
* This seems intended - and is. However, the act of merging the properties within
* the local `contracts` object bubbles up and actually merges each subsequent
* provider's contracts into the contracts of the first provider's contracts
* within the `this.serviceProviders` object.
*
* This then messes up our `matchProviders` method because every providers contracts
* seem to be fulfillable by the first provider.
*
* To avoid this we have 2 options:
*
* 1. We can use the monty python programming method: `JSON.parse(JSON.stringify())`
* 2 We can use `structuredClone()`
*
* Obviously number 2 is preferrable. At the time of this writing, its support in
* all major browsers as well as in Node.js (considering its based on Chrome's JS
* runtime)
*
* https://developer.mozilla.org/en-US/docs/Web/API/structuredClone
*/
contracts = _.extend(contracts, structuredClone(this.serviceProviders[providerName].contracts));
//contracts = _.extend( contracts, this.serviceProviders[providerName].instance.getServiceProviderSubscriptions() );
}
DEBUG(`Return providers and published contracts:`);
DEBUG(providers);
DEBUG(contracts);
return Promise.resolve({ providers, contracts });
}
/**
* Method to get help text from providers
*
* @param none
*
* @returns { Promise<DiscoveryPromise> } Returns providers contract metadata
*/
getHelpTexts() {
// We'll reuse our list of providers
// collect the help text for each of our providers, our CLI can leverage for
// the user's benefit:
let helpTexts = {};
for (let provider in this.serviceProviders) {
// Fetch a copy of the service provider's helptext
this.serviceProviders[provider].helpText = this.serviceProviders[provider].instance.getServiceProviderSubscriptionHelpText().helpText;
// Take the copy of the service provider's helptext as stowed in the steward,
// and exclusively pack it into a new object to return to the CLI
helpTexts[provider] = this.serviceProviders[provider].helpText;
}
DEBUG(`Return providers help texts:`);
DEBUG(helpTexts);
return { helpTexts };
}
/**
* Method which matches providers to the contracts requested.
*
* Finds the provider's name associated with the contract requisitioned by
* the end user, by searching the contracts listed in the service provider
* instance list that is stored with the steward, allowing the contract to
* be delegated..
*
* @param { NodeKitOptions } options
*
* @returns { Array<string> } An array of provider names
*/
matchProviders(options) {
let returnable = [], { quest, specification } = options;
for (let candidate in this.serviceProviders) {
const commands = this.serviceProviders[candidate].contracts.commands;
if (Object.keys(commands).indexOf(quest) > -1 && // Check that the candidate supports the command in question
((options.specification && options.specification !== "") ? // Check if there's a specification ⇨ If so
_.get(commands, quest).hasOwnProperty(specification) : // The spec must be present, otherwise
_.get(commands, quest, false) === false) // There cannot be a specification provided
)
returnable.push(candidate); // Return the candidate if all checks passed.
}
return returnable;
}
/**
* Delegate fulfillment of user [subscriber] requisition [subscription] to
* service provider [publisher]
*
* @param { NodeKitOptions } options Options for a user-requested action/command.
*
* @return { Promise<DelegationPromise> }
*/
async delegate(options) {
let contracts = [], type = null, { quest, specification } = options;
// We need to take the command/quest and specification passed, matching
// it to the proper contract(s) and respective provider(s).
let providers = this.matchProviders(options);
//
// Then we need to invoke said provider(s) passing the options in
// order to have any contract(s) fulfilled.
try {
DEBUG(`Call '%s' to delegate the '%s %s' command`, providers.join(', and '), quest, specification);
// The standard configuration object - we'll provide it for insurance,
// but it should be fetched from disk by the providers that maintain
// them except in the case of project generators; which should
// generate one along with the new resource if necessary:.
let configuration = {
project: {
name: "",
type: "",
tech: "",
root: ".",
repository: "",
author: {
fullName: "",
first: "",
last: "",
email: ""
},
copyright: "",
copyrightEmail: "",
license: {
identifier: "",
name: ""
}
}
};
options.configuration = configuration;
// Go ahead and actually delegate:
for (const provider of providers) {
if (this.environment !== "test")
progress.init();
const providerResult = await this.serviceProviders[provider].instance.renderService(options);
// Before the progress bar resets - or in case it doesn't,
// update it to show a completed state!
if (this.environment !== "test")
this.serviceProviders[provider].instance.updateProgress(provider.toUpperCase(), { progress: -1 });
contracts.push(providerResult);
}
// [using promise.all - which is running each promise in parallel]
//contracts = await Promise.all(
// providers.map(
// async ( provider ) => await this.serviceProviders[provider].instance.renderService( options )
// ) as any
//);
// [Or using Reduce, ES6 and Functional JavaScript]
//const production = providers.map( async ( provider ) => this.serviceProviders[provider].instance.renderService( options ) );
//contracts = Steward.serially( production );
// Error check for each provider
let count = 0, result = true, errorMessage = [];
for (let contract of contracts) {
if (!contract || !contract.result) {
errorMessage.push(output.normalize().color('blue').decor(['bright'])
.buffer(`[STEWARD][DELEGATE][${providers[count]}] `)
.normalize().color('red')
.buffer(`There was an issue delegating the `)
.color('blue').decor(['bright'])
.buffer(`${options.quest}`)
.normalize().color('red')
.buffer(` command. `)
.dump());
result = false;
}
count++;
}
if (!result)
return Promise.reject(new Error(`[ERROR] ${errorMessage.join(', ')} `));
// TODO: Consider the removal of `type`
return Promise.resolve({ specification, result, contracts });
}
catch (error) {
let catchError = output.normalize().color('blue').decor(['bright'])
.buffer(`[STEWARD::DELEGATE] `)
.normalize()
.buffer(error.message)
.dump();
return Promise.reject(new Error(catchError));
}
}
/**
* Invokes an array of factory methods; Each a method that executes a promise
* thus that each is run sequentially.
*
* @param { () => Promise<any> } promiseFactories An array of promise executing factory methods
*
* @returns { any[] } An array of Fulfilled promises
*/
static serially = (promiseFactories) => promiseFactories.reduce((promise, factory) => promise.then(result => factory().then(Array.prototype.concat.bind(result))), Promise.resolve([]));
/**
* Invokes the { Automaton } for generating files/folders based upon the request
* of the end-user
*
* @deprecated `generate()` has been deprecated in favor of the `delegate` metohd, as part of the user-executable proe
*
*
* @param { GenerateFileType } options A { GenerateFileType } object which specifies options used by the { Automaton } in generating files/folders
*
* @return { void }
*/
async generate(options) {
let generated = null;
try {
switch (options.specification) {
case KUE_SPECIFICATION.PROJECT:
{
const parameters = {
generator: options.specification,
type: options.args.type,
name: options.subCommands[0] || "MyProject",
skipWizard: options.args.skipWizard || false,
configuration: {
version: options.version,
project: {
author: {}
}
}
};
DEBUG(`Call 'automaton.generate' with '${options.specification}' specification `);
//generated = await this.automaton.generate( parameters );
}
break;
case KUE_SPECIFICATION.COMPONENT:
{
const parameters = {
generator: options.specification,
type: options.args.type,
redux: ((options.args.type == "container") ? (options.args.redux) ? true : false : false),
name: options.subCommands[0] || 'MyComponent'
};
DEBUG(`Call 'automaton.generate' with '${options.specification}' specification `);
//generated = await this.automaton.generate( parameters );
}
break;
case KUE_SPECIFICATION.ENDPOINT:
{
const parameters = {
generator: "endpoint",
type: options.args.type || "api",
name: options.subCommands[0] || "MyEndpoint"
};
DEBUG(`Call 'automaton.generate' with '${options.specification}' specification `);
//generated = await this.automaton.generate( parameters );
}
break;
case KUE_SPECIFICATION.MIGRATION:
{
const parameters = {
generator: options.specification,
type: options.args.type || "standard",
name: options.subCommands[0] || 'MyMigration',
migrations: {
version: options.version
},
environment: options.args.environment || 'development',
};
DEBUG(`Call 'automaton.generate' with '${options.specification}' specification `);
//generated = await this.automaton.generate( parameters );
}
break;
case KUE_SPECIFICATION.PASSWORD:
{
}
break;
}
if (!generated || !generated.result) {
const errorMessage = output.normalize().color('blue').decor(['bright'])
.buffer(`[STEWARD][GENERATE] `)
.normalize().color('red')
.buffer(`There was an issue running the `)
.color('blue').decor(['bright'])
.buffer(`${options.specification}`)
.normalize().color('red')
.buffer(` generator. `)
.dump();
return Promise.reject(new Error(`${errorMessage}`));
}
}
catch (error) {
const catchError = output.normalize().color('blue').decor(['bright'])
.buffer(`[STEWARD][GENERATE] `)
.normalize().color('red')
.buffer(error.message)
.dump();
return Promise.reject(new Error(catchError));
}
return Promise.resolve({ specification: options.specification, ...generated });
}
/**
* Method for running migrations
*
* @deprecated The migrations system is implemented no differently than anything else; Through the template
*
* @param { string } migrationPath A string representing the path to a directory where migrations exist
*
* @return { Promise<T> }
*/
async migrate(options) {
let migrated;
const parameters = {
migrations: {
version: options?.projectVersion || options.version
},
environment: options.args.environment || 'default',
stepBack: options.args['step-back'] || null
};
DEBUG(`Call 'migrator.migrate'`);
migrated = await this?.migrator?.migrate(parameters);
return Promise.resolve({ ...migrated });
}
/**
* Hashes a string password using sha-256 encryption
*
* @param theString
*
* @return { string } The hashed string
*/
async hash(theString) {
DEBUG(` Call 'Cryptographer.hashIt_sha256' with ${theString}`);
// Hash the string:
let hashed = ""; //= this.cryptographer.hashIt_sha256( theString );
// Log it for evidence:
DEBUG(`Received '${hashed}' from hashIt_256 `);
// Return the hashed string:
return Promise.resolve(hashed);
}
}
//# sourceMappingURL=steward.mjs.map