UNPKG

@kwaeri/node-kit-project-generator

Version:

The @kwaeri/node-kit-project-generator component module of the @kwaeri/node-kit platform.

458 lines 25.1 kB
/** * SPDX-PackageName: kwaeri/node-kit-project-generator * SPDX-PackageVersion: 0.9.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 * as _fs from 'fs/promises'; import * as _path from 'path'; import { GeneratorServiceProvider } from '@kwaeri/generator'; import { Filesystem } from '@kwaeri/filesystem'; import { Http2Request } from '@kwaeri/http2-request'; import { kdt } from '@kwaeri/developer-tools'; import debug from 'debug'; // DEFINES const _ = new kdt(); /* Configure Debug module support */ const DEBUG = debug('kue:project-generator'); export const PARAMATERIZATION = { TYPE: { 'rest': 'REST_PROJECT', 'react': 'REACT_PROJECT', 'mvc': 'MVC_PROJECT', 'xrm': 'XRM_PROJECT', 'typescript': 'TYPESCRIPT', 'javascript': 'JAVASCRIPT' } }; export const TEMPLATE_CONSTANT_OPTIONS = { REST_PROJECT: { JAVASCRIPT: 37335298, TYPESCRIPT: 37335286 }, REACT_PROJECT: { JAVASCRIPT: 37336498, TYPESCRIPT: 37336498 }, MVC_PROJECT: { JAVASCRIPT: 37336498, TYPESCRIPT: 37336498 }, //REST_ENDPOINT: { JAVASCRIPT: 37336498, TYPESCRIPT: 37336498 }, //REACT_COMPONENT: { JAVASCRIPT: 37336498, TYPESCRIPT: 37336498 }, }; export const TEMPLATE_CONSTANTS = { //API_PROJECT: 22650617, REST_PROJECT: TEMPLATE_CONSTANT_OPTIONS.REST_PROJECT, REACT_PROJECT: TEMPLATE_CONSTANT_OPTIONS.REACT_PROJECT, MVC_PROJECT: TEMPLATE_CONSTANT_OPTIONS.MVC_PROJECT, XRM_PROJECT: 22650647, //REST_ENDPOINT: TEMPLATE_CONSTANT_OPTIONS.REST_ENDPOINT, //REACT_COMPONENT: TEMPLATE_CONSTANT_OPTIONS.REACT_COMPONENT, //MYSQL_MIGRATION: 22650670, //PG_MIGRATION: 22650675, //MONGO_MIGRATION: 22962774, //PROGRESS_TEST: 20989565 }; const DEFAULT_GENERATOR_OPTIONS = { quest: "new", specification: "project", version: "", args: { type: 'rest', // just a default of sorts lang: 'typescript' }, subCommands: [ 'My New Project' ], configuration: { project: { name: "", type: "", tech: "", root: ".", author: { first: "", last: "", fullName: "", email: "" }, copyright: "", copyrightEmail: "", license: { identifier: "" }, repository: "" } } }; /** * ProjectGenerator * * Extends the {@link GeneratorServiceProvider } class, which implements the { BaseGenerator } * Interface. */ export class NodeKitProjectGenerator extends GeneratorServiceProvider { /** * Class constructor */ constructor(handler, configuration) { super(handler); } getServiceProviderSubscriptions(options) { return { commands: { "new": { "project": true // The project specification has a required flag (type) } }, required: { "new": { "project": { "type": [ "api", "react" ] } } }, optional: { "new": { "project": { "redux": { "for": "type=react", // Or false, if it's not related to an option/value, rather only to the specification. "flag": true // True insists that no value is given. Its existance equates to <option>=1, the lack of its }, // existence is similar to <option>=0. "lang": { "for": false, "flag": false, "values": [ "typescript", "javascript" ] }, "skip-wizard": { "for": false, "flag": true } } } } }; } getServiceProviderSubscriptionHelpText(options) { return { helpText: { "commands": { "new": { "description": "The 'new' command automates content creation.", "specifications": { "project": { "description": "Creates a new empty project of the type specified, and according to options provided.", "options": { "required": { "type": { "description": "Denotes the type of the project that will be generated.", "values": { "api": { "desccription": "A NodeKit based MV(A)C API project." }, "react": { "description": "A NodeKit based client-side React project", } } } }, "optional": { "specification": { "language": { "description": "Denotes the programming language for the project being generated.", "values": [ "typescript", "javascript" ] } }, // ⇦ The various required options that allow optional flags "type": { "react": { "redux": { "description": "Denotes that the project should include redux support", "values": false } } } } } } }, "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 * begin to populate the project configuration with what information we can * from the user's command. We can [will] also launch the wizard from here. * * @param { NodeKitOptions } options * * @returns { NodeKitOptions } The options object, with the configuration partially populated with user-provided information */ assembleOptions(options) { // Setting some defaults let returnable, nko, nkcb, nkpb, nkab; // Ensure a senibly complete set of options returnable = _.extend(options, DEFAULT_GENERATOR_OPTIONS); DEBUG(`[ASSEMBLE_OPTIONS] Sanitize settings`); // Ensure the proper project type was provided - else fail gracefully. We can use the // "in" operator to check if properties or indices exist within an object or array, and // it is far more compact then try/catch if (!(returnable.args.type in PARAMATERIZATION.TYPE)) throw new Error(`[ASSEMBLE_OPTIONS] Provided project type '${returnable.args.type}' not supported.`); // Ensure where existing options may exist in configuration that we use // them - else fall back to defaults returnable.configuration.project.name = returnable.subCommands[0]; const fileSafeName = Filesystem.getFileSafeName(returnable.configuration.project.name); //returnable.configuration.project.type = ( returnable.args.type in PARAMATERIZATION.TYPE ) ? PARAMATERIZATION.TYPE[returnable.args.type] : 'REST_PROJECT'; returnable.configuration.project.type = (returnable.args.type in PARAMATERIZATION.TYPE) ? returnable.args.type : 'rest'; returnable.configuration.project.tech = "typescript"; returnable.configuration.project.root = (returnable.configuration.project.root === "" || returnable.configuration.project.root === ".") ? `${Filesystem.getPathToCWD()}/${fileSafeName}` : `${returnable.configuration.project.root}/${fileSafeName}`; returnable.configuration.project.repository = "https://www.gitlab.com/user-or-group/path/to/project"; return returnable; } async renderService(options) { try { this.updateProgress('NodeKitProjectGenerator', { progressLevel: 0, notice: `Preparing to generate '${options.args.type}' type project infrastructure '${options.configuration.project.root}'` }); DEBUG(`[RENDER_SERVICE] Resettle options`); options = this.assembleOptions(options); DEBUG(`[RENDER_SERVICE] Set 'root' to '${options.configuration.project.root}'`); DEBUG(`[RENDER_SERVICE] Call 'resolve' on 'createProject()'`); const result = await this.createProject({ type: options.args.type, language: options.configuration.project.tech, path: options.configuration.project.root }); return Promise.resolve(result); } catch (error) { DEBUG(`[RENDER_SERVICE] Call 'reject' on 'Error' with '${error}'`); return Promise.reject(error); } } /** * Method to generate a project infrastructure fed by a repository tree. * * If a cached source of the requested resource exists locally, and its commit * id matches the latest commit id for its respective remote, this method * prefers the local cache. If a cached source exists locally and any error * occurs when an attempt to fetch its remote occurs, this method defers to * the local cache. In any other case, the remote resource is preferred. * * @param { Object } options The options required for generating a project infrastructure (i.e. type and path) * * @returns { Promise<FilesystemPromise|ClientHttp2Promise> } */ async createProject(options) { const { type, language, path } = options, host = 'https://gitlab.com', prefix = '/api/v4/projects', projectId = (_.type(TEMPLATE_CONSTANTS[PARAMATERIZATION.TYPE[type]]) == "object") ? TEMPLATE_CONSTANTS[PARAMATERIZATION.TYPE[type]][PARAMATERIZATION.TYPE[language]].toString() : TEMPLATE_CONSTANTS[PARAMATERIZATION.TYPE[type]].toString(), projectPath = _path.join(prefix, projectId), //projectPath = _path.join( prefix, ( ( TEMPLATE_CONSTANTS[type] ) ? // TODO: REDUCE THIS! (Don't need a fallback on PROGRESS_TEST) // ( ( _.type( TEMPLATE_CONSTANTS[type] ) == "object" ) ? TEMPLATE_CONSTANTS[type][lang].toString() : TEMPLATE_CONSTANTS[type].toString() ) : // TEMPLATE_CONSTANTS.PROGRESS_TEST.toString() ) ), cachePath = _path.join(Filesystem.getPathToCWD(), `.kue_cache/projects`, projectId), metadataPath = _path.join(cachePath, 'metadata.json'), commitsPath = _path.join(projectPath, 'repository/commits'), latestCommitsQuery = _path.join(commitsPath, 'main'), baseTreePath = _path.join(projectPath, 'repository/tree'), baseTreeQuery = baseTreePath + '?recursive=true', // queryTreeByPath = baseTreeQuery + '?path=', baseFilesQuery = _path.join(projectPath, 'repository/files'), context = this; this.updateProgress('NodeKitProjectGenerator', { progressLevel: 0, notice: `Assessing source options` }); DEBUG(`Call assessRoute with type: '%s'`, type); const { cacheValidated, remoteValidated, deferToCache, preferRemote, commit, localCommit } = await this.assessRoute({ type, path, host, cachePath, metadataPath, latestCommitsQuery }); // If no source(s) available, abort if (!cacheValidated && !remoteValidated && !deferToCache && !preferRemote) return Promise.resolve({ result: false, type: 'create_project' }); // Otherwise, start by creating the directory the project will be generated // in:: //DEBUG( `Write directory %s`, path ); //if( !( await this.createDirectory( path ) ) ) // return Promise.reject( new Error( `[CREATE_PROJECT] There was an issue writing the destination directory '${path}'.` ) ); // Next fetch resources from the appropriate source if (deferToCache) return Promise.resolve(await this.fetchFromCache(type, path, cachePath, localCommit)); else return Promise.resolve(await this.fetchFromRemote(type, path, host, baseTreeQuery, baseFilesQuery, cachePath, metadataPath, commit)); } /** * Method to determine whether to prefer or defer to cache - or to prefer remote * sources. * * @param { ...AssessmentOptions } options * * @returns {AssessmentBits} An {@link AssessmentBits} object. */ async assessRoute(options) { const { type, path, host, cachePath, metadataPath, latestCommitsQuery } = options; let cacheValidated = false, remoteValidated = false, preferRemote = false, deferToCache = false, commit, localCommit; // Start with fetching the remote's latest commit id, we'll need to write // it if we don't have a cache, and we'll need it for comparison if we do this.updateProgress('NodeKitProjectGenerator', { progressLevel: 0, notice: `Fetching remote metadata from '${latestCommitsQuery}'.` }); DEBUG(`Fetch remote metadata from '${latestCommitsQuery}'`); try { commit = (await Http2Request.makeRequest(host, latestCommitsQuery)).payload; if (commit?.id) remoteValidated = true; DEBUG(`Remote validated: %s [commit: %o]`, remoteValidated, commit); } catch (exception) { DEBUG(`There was an error fetching remote metadata: %o`, exception); } // Next check for cache this.updateProgress('NodeKitProjectGenerator', { progressLevel: 0, notice: `Checking cache path '${cachePath}'.`, log: `Commit id '${commit?.id}' read from remote '${latestCommitsQuery}'` }); DEBUG(`Check cache path '%s' exists`, cachePath); if (await this.exists(metadataPath)) { // If cache does exist, check if a cached version of the requested // resource exists, by checking for its stowed commit id this.updateProgress('NodeKitProjectGenerator', { progressLevel: 0, notice: `Reading cache metadata from '${metadataPath}'.`, log: `Cache exists at '${cachePath}'` }); DEBUG(`Read commit id for remote comparison`); try { localCommit = JSON.parse((await _fs.readFile(metadataPath, { encoding: "utf8" }))); if (localCommit?.id) cacheValidated = true; DEBUG(`Local cache validated: %s [commit: %o]`, cacheValidated, localCommit); } catch (exception) { DEBUG(`There was an error reading cache metadata: %o`, exception); } } else { // Create the cache directory and put a copy of things over to it if (!(await _fs.mkdir(cachePath, { recursive: true }))) DEBUG(`Failed to create cache path at %s`, cachePath); } // Here we filter what to do based on whether we were able to validate // a local cache and a remote source. // // If we validated both, we check that the latest remote commit is the // same as the one stored in local cache. If so, we defer to cache. If // not, we prefer the remote. // // If we are only able to validate the remote, we prefer the remote, and // stow a local cache of it for future reference. // // If we are only able to validate the local cache, we defer to it. // // If we are unable to validate either a remote or local cache source, we // cannot proceed. this.updateProgress('NodeKitProjectGenerator', { progressLevel: 0, notice: `Analyzing source options'.`, log: `Commit id '${localCommit?.id}' read from '${cachePath}'` }); DEBUG(`Analyze source options`); if (cacheValidated && remoteValidated) if (commit?.id && localCommit?.id && commit?.id === localCommit?.id) { this.updateProgress('NodeKitProjectGenerator', { progressLevel: 0, notice: `Local cache and remote available and match, deferring to cache'.` }); DEBUG(`Local cache and remote match, defer to cache`); deferToCache = true; } else { this.updateProgress('NodeKitProjectGenerator', { progressLevel: 0, notice: `Local cache and remote available but do not match, preferring remote'.` }); DEBUG(`Local cache and remote mismatch, prefer remote`); preferRemote = true; } else if (!cacheValidated && remoteValidated) { this.updateProgress('NodeKitProjectGenerator', { progressLevel: 0, notice: `Only remote available (cache unavailable), preferring remote'.` }); DEBUG(`Remote available, local cache unavailable, prefer remote`); preferRemote = true; } else if (cacheValidated && !remoteValidated) { this.updateProgress('NodeKitProjectGenerator', { progressLevel: 0, notice: `Only local cache available (remote unavailable), deferring to cache'.` }); DEBUG(`Local cache available, remote unavailable, defer to cache`); deferToCache = true; } else if (!cacheValidated && !remoteValidated) { this.updateProgress('NodeKitProjectGenerator', { progressLevel: 0, notice: `Neither local cache nor remote available, aborting'.` }); DEBUG(`Neither local cache nor remote available, abort: %o`, commit); console.error(`Neither local cache nor remote available: ${commit}`); } return Promise.resolve({ cacheValidated, remoteValidated, commit, localCommit, deferToCache, preferRemote }); } /** * Method to fetch project sources from local cache * * @param { string } type * @param { string } path * @param { string } cachePath * @param { GitLabCommitBits } localCommit * @returns { ServicePromiseBits } */ async fetchFromCache(type, path, cachePath, localCommit) { const projectCachePath = _path.join(cachePath, localCommit?.id); this.updateProgress('NodeKitProjectGenerator', { progressLevel: 99, notice: `Copying cached ${type} project infrastructure to ${path}` }); if (!(await this.copy(projectCachePath, path, true))) return Promise.reject(new Error(`[FETCH_FROM_REMOTE] There was an issue copying cached ${type} project infrastructure to '${path}'`)); this.updateProgress('NodeKitProjectGenerator', { progressLevel: 100, notice: `Done.`, log: `Finished creating project` }); return Promise.resolve({ type: "create_project_from_cache", result: true }); } /** * Method to fetch project sources from a remote resource * * @param { string } type * @param { string } path * @param { string } host * @param { string } baseTreeQuery * @param { string } baseFilesQuery * @param { string } cachePath * @param { GitLabCommitBits } commit * @returns { ServicePromiseBits } */ async fetchFromRemote(type, path, host, baseTreeQuery, baseFilesQuery, cachePath, metadataPath, commit) { // Prepare cache directory for specific commit (version) of project type const projectCachePath = _path.join(cachePath, commit?.id); DEBUG(`Create project cache directory '%s'`, projectCachePath); if (!(await this.createDirectory(projectCachePath, true))) return Promise.reject(new Error(`[FETCH_FROM_REMOTE] There was an issue writing the cache directory at '${projectCachePath}`)); // Begin the process of fetching the remote source(s) DEBUG(`Get tree from '%s%s'`, host, baseTreeQuery); const tree = (await Http2Request.makeRequest(host, baseTreeQuery, false)).payload; const total = tree.length; let progress = 1; this.updateProgress('NodeKitProjectGenerator', { progressLevel: 0, notice: `Generating ${type} project infrastructure in ${projectCachePath}` }); for (const item of tree) { const fullPath = _path.join(projectCachePath, item.path), // Obviously needed basePath = _path.dirname(fullPath); // 'dirname()' gives the path leading to the last item (nested // file(s) must be extracted from their paths to be written) // completed = this.getProgress( progress, total ); DEBUG(`Write '%s' resource '%s' as '%s'`, item.type, item.path, fullPath); // Blob files will need to be decoded from base64 (recent change) switch (item.type) { case 'blob': const fileName = _path.basename(fullPath), getFileByPathQuery = baseFilesQuery + '/' + encodeURIComponent(item.path).replace('.', '%2E') + '?ref=main', payload = (await Http2Request.makeRequest(host, getFileByPathQuery, false)).payload; if (!(await this.createFile(basePath, fileName, payload.content, true))) return Promise.reject(new Error(`[CREATE_PROJECT] There was an issue creating the file '${fullPath}'.`)); break; case 'tree': if (!(await this.createDirectory(fullPath))) return Promise.reject(new Error(`[CREATE_PROJECT] There was an issue creating the directory '${fullPath}'.`)); break; } const percentComplete = (progress * 100) / total; this.updateProgress('CREATE_PROJECT', { progressLevel: percentComplete, log: `Wrote '${item.type}' resource '${item.path}' as '${fullPath}'` }); progress++; } DEBUG(`Wrote tree from '%s%s.`, host, baseTreeQuery); // Write cache metadata this.updateProgress('NodeKitProjectGenerator', { progressLevel: 99, notice: `Writing cache metadata to ${metadataPath}` }); if (!(await this.createFile(cachePath, 'metadata.json', JSON.stringify(commit)))) return Promise.reject(new Error(`[FETCH_FROM_REMOTE] There was an issue writing cache metadata to '${metadataPath}`)); // Copy the newly cached source(s) this.updateProgress('NodeKitProjectGenerator', { progressLevel: 99, notice: `Copying cached ${type} project infrastructure to ${path}`, log: `Wrote cache metadata to '${metadataPath}'` }); if (!(await this.copy(projectCachePath, path, true))) return Promise.reject(new Error(`[FETCH_FROM_REMOTE] There was an issue copying cached ${type} project infrastructure to '${path}'`)); this.updateProgress('NodeKitProjectGenerator', { progressLevel: 100, notice: `Done.`, log: `Finished creating project` }); return Promise.resolve({ type: "create_project_from_remote", result: true }); } } //# sourceMappingURL=project-generator.mjs.map