UNPKG

@kwaeri/steward

Version:

The @kwaeri/steward component module of the @kwaeri/cli user-executable framework.

541 lines 25 kB
/** * 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 */ 'use strict'; 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