@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
JavaScript
/**
* 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
*/
;
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