xpm
Version:
The xPack project manager command line tool
377 lines • 17.5 kB
JavaScript
import assert from 'node:assert';
import * as util from 'node:util';
import * as readline from 'node:readline/promises';
import * as path from 'node:path';
import * as fs from 'node:fs/promises';
import { isString, isObject, isBoolean, isNumber, } from '../functions/is-something.js';
import { JsonSyntaxError, InputError, OutputError, ConfigurationError, } from './errors.js';
import { LiquidEngine } from './liquid-engine.js';
import { liquidSubstitutionsVariablesBase } from '../data/substitutions-variables.js';
export class InitTemplateBase {
context;
log;
propertiesDefinitions = {};
__dirname;
templatesPath;
engine;
substitutionsVariables;
isInteractive = false;
process;
policies;
constructor({ context, __dirname, templatesPath, propertiesDefinitions, process: _process = process, options, policies, }) {
assert(context, 'context is required');
assert(context.log, 'context.log is required');
assert(context.config, 'context.config is required');
assert(context.config.projectName, 'context.config.projectName is required');
assert(context.config.properties, 'context.config.properties is required');
assert(__dirname, '__dirname is required');
assert(templatesPath, 'templatesPath is required');
assert(propertiesDefinitions, 'propertiesDefinitions is required');
this.context = context;
this.log = context.log;
this.propertiesDefinitions = propertiesDefinitions;
this.__dirname = __dirname;
this.templatesPath = templatesPath;
this.process = _process;
this.policies = policies;
this._validatePropertiesDefinitions();
this.engine = new LiquidEngine({
options: {
...options,
root: this.templatesPath,
},
});
}
async run() {
const log = this.log;
log.trace(`${this.constructor.name}.run()`);
log.info();
const context = this.context;
const config = context.config;
assert(config.properties, 'config.properties is required');
const validationErrors = [];
for (const [key, val] of Object.entries(config.properties)) {
try {
config.properties[key] = this.validatePropertyValue(key, val);
}
catch (error) {
if (error instanceof Error) {
const errorMessage = `${key}: ${error.message}`;
log.error(errorMessage);
validationErrors.push(errorMessage);
}
}
}
if (validationErrors.length > 0) {
throw new JsonSyntaxError(validationErrors.length === 1
? '1 invalid property'
: `${String(validationErrors.length)} invalid properties`);
}
const mustAsk = Object.keys(this.propertiesDefinitions).some((key) => {
return (this.propertiesDefinitions[key].isMandatory && !config.properties?.[key]);
});
let isInteractive;
if (mustAsk) {
if (!(this.process.stdin.isTTY && this.process.stdout.isTTY)) {
throw new JsonSyntaxError('Interactive mode not possible without a TTY.');
}
await this._askForMoreValues();
log.trace(util.inspect(config.properties));
context.startTime = Date.now();
isInteractive = true;
}
else {
Object.entries(this.propertiesDefinitions).forEach(([key, val]) => {
assert(config.properties, 'config.properties is required');
if (!config.properties[key] && val.default !== undefined) {
config.properties[key] = val.default;
}
});
isInteractive = false;
}
this.isInteractive = isInteractive;
const currentTime = new Date();
let substitutionsVariables;
if (this.policies.topPropertiesXpmInitTemplate) {
substitutionsVariables = {
...config.properties,
properties: config.properties,
propertiesNames: Object.keys(config.properties),
projectName: config.projectName,
year: currentTime.getFullYear().toString(),
};
}
else {
substitutionsVariables = {
...liquidSubstitutionsVariablesBase,
matrix: {
...config.properties,
},
propertiesNames: Object.keys(config.properties),
projectName: config.projectName,
year: currentTime.getFullYear().toString(),
};
}
this.substitutionsVariables = substitutionsVariables;
await this.generate();
return 0;
}
isPlatformSupported(platforms) {
assert(platforms && platforms.length !== 0, 'platforms array is required');
if (platforms.includes(`${this.process.platform}-${this.process.arch}`)) {
return true;
}
if (platforms.includes(this.process.platform)) {
return true;
}
return false;
}
async copyFile({ sourceFileRelativePath, destinationFilePath = sourceFileRelativePath, }) {
assert(sourceFileRelativePath, 'sourceFileRelativePath is required');
assert(destinationFilePath, 'destinationFilePath is required');
const log = this.log;
await fs.mkdir(path.dirname(destinationFilePath), { recursive: true });
const sourceFileAbsolutePath = path.resolve(this.templatesPath, sourceFileRelativePath);
await fs.copyFile(sourceFileAbsolutePath, destinationFilePath);
const destinationFileRelativePath = path.relative(this.context.config.cwd, destinationFilePath);
log.info(`File '${destinationFileRelativePath}' copied.`);
}
async copyFolder({ sourceFolderRelativePath, destinationFolderPath = sourceFolderRelativePath, }) {
assert(sourceFolderRelativePath, 'sourceFolderRelativePath is required');
assert(destinationFolderPath, 'destinationFolderPath is required');
const log = this.log;
const sourceFolderAbsolutePath = path.resolve(this.templatesPath, sourceFolderRelativePath);
await this._copyFolderRecursively({
sourceFolderPath: sourceFolderAbsolutePath,
destinationFolderPath: path.resolve(destinationFolderPath),
});
log.info(`Folder '${destinationFolderPath}' copied.`);
}
async render({ sourceFilePath, destinationFilePath, substitutionsVariables = this.substitutionsVariables, }) {
assert(sourceFilePath, 'sourceFilePath is required');
assert(destinationFilePath, 'destinationFilePath is required');
assert(substitutionsVariables !== undefined, 'substitutionsVariables is required for rendering templates. ' +
'Ensure that run() has been called to prepare the variables.');
const log = this.log;
const context = this.context;
const config = context.config;
const cwd = config.cwd;
const sourceFileRelativePath = path.relative(cwd, sourceFilePath);
const destinationFileRelativePath = path.relative(cwd, destinationFilePath);
log.info(`Rendering template '${sourceFileRelativePath}' to ` +
`'${destinationFileRelativePath}'`);
log.trace(`render(${sourceFilePath}, ${destinationFilePath})`);
try {
const fileContent = (await this.engine.renderFile(sourceFilePath, substitutionsVariables));
await fs.mkdir(path.dirname(destinationFilePath), { recursive: true });
await fs.writeFile(destinationFilePath, fileContent, 'utf8');
}
catch (error) {
if (error instanceof Error) {
throw new OutputError(error.message);
}
}
log.info(`File '${destinationFileRelativePath}' generated.`);
}
validatePropertyValue(name, value) {
const propDef = this.propertiesDefinitions[name];
if (propDef === undefined) {
throw new ConfigurationError(`Unsupported property '${name}'`);
}
const trimmedValue = value.trim();
if (trimmedValue === '') {
if (propDef.default !== undefined) {
return propDef.default;
}
}
else {
switch (propDef.type) {
case 'select':
assert(propDef.items, `Property '${name}' of type 'select' has no items.`);
if (propDef.items[value]) {
if (typeof propDef.items[value] === 'string') {
return value;
}
else if (typeof propDef.items[value] === 'object' &&
this.isPlatformSupported(propDef.items[value].platforms)) {
return value;
}
}
break;
case 'boolean':
if (trimmedValue === 'true') {
return true;
}
else if (trimmedValue === 'false') {
return false;
}
break;
case 'number': {
const num = Number(trimmedValue);
if (isFinite(num)) {
return num;
}
break;
}
case 'string':
return value;
}
}
throw new ConfigurationError(`Unsupported value '${value}' for property '${name}'`);
}
async _askForMoreValues() {
const context = this.context;
const config = context.config;
assert(config.properties, 'config.properties is required');
const rl = readline.createInterface({
input: this.process.stdin,
output: this.process.stdout,
});
for (const name of Object.keys(this.propertiesDefinitions)) {
if (config.properties[name]) {
continue;
}
const definition = this.propertiesDefinitions[name];
let prompt = `${definition.label}?`;
switch (definition.type) {
case 'select': {
prompt += ' (';
const validItems = [];
assert(definition.items, 'definition.items is required');
for (const [ikey, ival] of Object.entries(definition.items)) {
if (isString(ival)) {
validItems.push(ikey);
}
else if (isObject(ival) &&
this.isPlatformSupported(ival.platforms)) {
validItems.push(ikey);
}
}
prompt += validItems.join(', ');
prompt += ', ?)';
break;
}
case 'string':
prompt += ' (string, ?)';
break;
case 'number':
prompt += ' (number, ?)';
break;
case 'boolean':
prompt += ' (true, false, ?)';
break;
}
if (definition.default !== undefined) {
prompt += ` [${String(definition.default)}]`;
}
prompt += ': ';
const MAX_RETRIES = 42;
let retryCount = 0;
while (true) {
if (++retryCount > MAX_RETRIES) {
throw new InputError(`Too many invalid attempts for property '${name}' ` +
`(limit: ${String(MAX_RETRIES)})`);
}
const answer = (await rl.question(prompt)).trim();
try {
const value = this.validatePropertyValue(name, answer);
config.properties[name] = value;
break;
}
catch (error) {
if (error instanceof Error) {
this.log.trace(error.message);
}
this.process.stdout.write(`${definition.description}\n`);
if (definition.type === 'select') {
assert(definition.items, 'definition.items is required');
for (const [ikey, ival] of Object.entries(definition.items)) {
if (isString(ival)) {
this.process.stdout.write(`- ${ikey}: ${ival}\n`);
}
else if (isObject(ival) &&
this.isPlatformSupported(ival.platforms)) {
this.process.stdout.write(`- ${ikey}: ${ival.message}\n`);
}
}
}
}
}
}
}
async _copyFolderRecursively({ sourceFolderPath, destinationFolderPath, }) {
await fs.mkdir(destinationFolderPath, { recursive: true });
const dirents = await fs.readdir(sourceFolderPath, {
withFileTypes: true,
});
for (const dirent of dirents) {
if (dirent.isDirectory()) {
await this._copyFolderRecursively({
sourceFolderPath: path.join(sourceFolderPath, dirent.name),
destinationFolderPath: path.join(destinationFolderPath, dirent.name),
});
}
else {
await fs.copyFile(path.join(sourceFolderPath, dirent.name), path.join(destinationFolderPath, dirent.name));
}
}
}
_validatePropertiesDefinitions() {
assert(isObject(this.propertiesDefinitions), 'propertiesDefinitions is not an object.');
assert(Object.keys(this.propertiesDefinitions).length > 0, 'propertiesDefinitions is an empty object.');
for (const [key, val] of Object.entries(this.propertiesDefinitions)) {
assert(isString(val.label), `Property '${key}' must have a string label`);
assert(val.label.trim() !== '', `Property '${key}' has an empty label`);
assert(isString(val.description), `Property '${key}' must have a string description`);
assert(val.description.trim() !== '', `Property '${key}' has an empty description`);
if (val.isMandatory !== undefined) {
assert(isBoolean(val.isMandatory), `Property '${key}' has a non boolean isMandatory value.`);
}
assert(val.type !== undefined, `Property '${key}' has no type defined.`);
switch (val.type) {
case 'select':
assert(val.items !== undefined, `Property '${key}' of type 'select' has no items.`);
assert(isObject(val.items), `Property '${key}' of type 'select' has invalid items.`);
assert(Object.keys(val.items).length !== 0, `Property '${key}' of type 'select' has no items.`);
for (const [ikey, ival] of Object.entries(val.items)) {
assert(isString(ival) ||
(isObject(ival) &&
Array.isArray(ival.platforms) &&
isString(ival.message)), `Property '${key}' has invalid item '${ikey}'.`);
}
if (!val.isMandatory) {
assert(val.default !== undefined, `Property '${key}' of type 'select' ` +
`must have a default value if not mandatory.`);
}
if (val.default !== undefined) {
assert(isString(val.default), `Property '${key}' has a non string default value.`);
assert(val.default.trim() !== '', `Property '${key}' has an empty default value.`);
}
if (val.default !== undefined) {
assert(Object.keys(val.items).includes(String(val.default)), `Property '${key}' has a default value not in items list.`);
}
break;
case 'string':
if (val.default !== undefined) {
assert(isString(val.default), `Property '${key}' has a non string default value.`);
assert(val.default.trim() !== '', `Property '${key}' has an empty default value.`);
}
break;
case 'number':
if (val.default !== undefined) {
assert(isNumber(val.default), `Property '${key}' has a non number default value.`);
}
break;
case 'boolean':
if (val.default !== undefined) {
assert(isBoolean(val.default), `Property '${key}' has a non boolean default value.`);
}
break;
default:
assert(false, `Property '${key}' has unsupported type '${String(val.type)}'.`);
break;
}
}
}
}
//# sourceMappingURL=init-template-base.js.map