foalts2-cli
Version:
CLI tool for FoalTS
552 lines (551 loc) • 19.8 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.FileSystem = exports.ClientError = void 0;
// std
const assert_1 = require("assert");
const fs_1 = require("fs");
const path_1 = require("path");
// 3p
const safe_1 = require("colors/safe");
function rmDirAndFiles(path) {
const files = fs_1.readdirSync(path);
for (const file of files) {
const stats = fs_1.statSync(path_1.join(path, file));
if (stats.isDirectory()) {
rmDirAndFiles(path_1.join(path, file));
}
else {
fs_1.unlinkSync(path_1.join(path, file));
}
}
fs_1.rmdirSync(path);
}
/**
* Error thrown by the FileSystem which aims to be pretty
* printed without the stacktrace.
*
* @export
* @class ClientError
* @extends {Error}
*/
class ClientError extends Error {
constructor() {
super(...arguments);
this.name = 'ClientError';
}
}
exports.ClientError = ClientError;
/**
* This class provides more methods that Node std "fs".
* It also allows to create an isolated directory during directory.
*
* @export
* @class FileSystem
*/
class FileSystem {
constructor() {
this.currentDir = '';
this.testDir = 'test-generators/subdir';
this.logs = true;
}
/**
* Do not show create and update logs.
*
* @returns {this}
* @memberof FileSystem
*/
hideLogs() {
this.logs = false;
return this;
}
/**
* Changes the current working directory.
*
* @param {string} path - Relative path of the directory.
* @returns {this}
* @memberof FileSystem
*/
cd(path) {
this.currentDir = path_1.join(this.currentDir, path);
return this;
}
/**
* Changes the current working directory to the project root
* directory.
*
* It searches for the closer package.json containing @foal/core
* as dependency.
*
* @returns {this}
* @memberof FileSystem
*/
cdProjectRootDir() {
// "/" on Unix, C:\ on Windows
const root = path_1.parse(process.cwd()).root;
while (!this.exists('package.json')) {
if (path_1.join(process.cwd(), this.parse('.')) === root) {
throw new ClientError('This project is not a FoalTS project. No package.json found.');
}
this.cd('..');
}
const content = fs_1.readFileSync(this.parse('package.json'), 'utf8');
let pkg;
try {
pkg = JSON.parse(content);
}
catch (error) {
throw new ClientError(`The file package.json is not a valid JSON. ${error.message}`);
}
if (!pkg.dependencies || !pkg.dependencies['@foal/core']) {
throw new ClientError('This project is not a FoalTS project. The dependency @foal/core is missing in package.json.');
}
return this;
}
/**
* Checks if a file or directory exists.
*
* @param {string} path - The path relative to the client directory.
* @returns {boolean}
* @memberof FileSystem
*/
exists(path) {
return fs_1.existsSync(this.parse(path));
}
/**
* Recursively ensures that a directory exists. If the directory structure does not
* exist, it is created.
*
* @param {string} path - The path relative to the client directory.
* @returns {this}
* @memberof FileSystem
*/
ensureDir(path) {
const dir = path_1.dirname(path);
if (dir !== '.') {
this.ensureDir(dir);
}
if (!fs_1.existsSync(this.parse(path))) {
fs_1.mkdirSync(this.parse(path));
}
return this;
}
/**
* Recursively ensures that a directory exists if the condition is true.
* If the directory structure does not exist, it is created.
*
* @param {boolean} condition - The condition.
* @param {string} path - The path relative to the client directory.
* @returns {this}
* @memberof FileSystem
*/
ensureDirOnlyIf(condition, path) {
if (condition) {
this.ensureDir(path);
}
return this;
}
/**
* Ensures that the file exists. If the file does not exist, it is created.
*
* @param {string} path - The path relative to the client directory.
* @returns {this}
* @memberof FileSystem
*/
ensureFile(path) {
if (!fs_1.existsSync(this.parse(path))) {
this.logCreate(path);
fs_1.writeFileSync(this.parse(path), '', 'utf8');
}
return this;
}
/**
* Copies a file from the `templates` directory.
*
* @param {string} src - The source path relative to the `templates/` directory.
* @param {string} dest - The destination path relative to the client directory.
* @returns {this}
* @memberof FileSystem
*/
copy(src, dest) {
const templatePath = path_1.join(__dirname, 'templates', src);
if (!fs_1.existsSync(templatePath)) {
throw new Error(`The template "${src}" does not exist.`);
}
this.logCreate(dest);
fs_1.copyFileSync(templatePath, this.parse(dest));
return this;
}
/**
* Copies a file from the `templates` directory if the condition is true.
*
* @param {boolean} condition - The condition.
* @param {string} src - The source path relative to the `templates/` directory.
* @param {string} dest - The destination path relative to the client directory.
* @returns {this}
* @memberof FileSystem
*/
copyOnlyIf(condition, src, dest) {
if (condition) {
this.copy(src, dest);
}
return this;
}
/**
* Copies and renders a template from the `templates` directory.
*
* @param {string} src - The source path relative to the `templates/` directory.
* @param {string} dest - The destination path relative to the client directory.
* @param {*} locals - The template variables.
* @returns {this}
* @memberof FileSystem
*/
render(src, dest, locals) {
const templatePath = path_1.join(__dirname, 'templates', src);
if (!fs_1.existsSync(templatePath)) {
throw new Error(`The template "${src}" does not exist.`);
}
let content = fs_1.readFileSync(templatePath, 'utf8');
for (const key in locals) {
content = content.split(`/* ${key} */`).join(locals[key]);
}
this.logCreate(dest);
fs_1.writeFileSync(this.parse(dest), content, 'utf8');
return this;
}
/**
* Copies and renders a template from the `templates` directory if the condition is true.
*
* @param {boolean} condition - The condition.
* @param {string} src - The source path relative to the `templates/` directory.
* @param {string} dest - The destination path relative to the client directory.
* @param {*} locals - The template variables.
* @returns {this}
* @memberof FileSystem
*/
renderOnlyIf(condition, src, dest, locals) {
if (condition) {
this.render(src, dest, locals);
}
return this;
}
/**
* Reads and modifies the content of a file.
*
* @param {string} path - The path relative to the client directory.
* @param {(content: string) => string} callback - The callback that modifies the content.
* @returns {this}
* @memberof FileSystem
*/
modify(path, callback) {
if (!fs_1.existsSync(this.parse(path))) {
throw new ClientError(`Impossible to modify "${path}": the file does not exist.`);
}
const content = fs_1.readFileSync(this.parse(path), 'utf8');
this.logUpdate(path);
fs_1.writeFileSync(this.parse(path), callback(content), 'utf8');
return this;
}
/**
* Reads and modifies the content of a file if the condition is true.
*
* @param {boolean} condition - The condition.
* @param {string} path - The path relative to the client directory.
* @param {(content: string) => string} callback - The callback that modifies the content.
* @returns {this}
* @memberof FileSystem
*/
modifyOnlyfIf(condition, path, callback) {
if (condition) {
this.modify(path, callback);
}
return this;
}
/**
* Add a named import at the bottom of the file.
*
* @param {string} path - The file path relative to the client directory.
* @param {string} specifier - The import specifier.
* @param {string} source - The import source.
* @returns {this}
* @memberof FileSystem
*/
addNamedExportIn(path, specifier, source) {
this.modify(path, content => `${content}export { ${specifier} } from '${source}';\n`);
return this;
}
/**
* Adds or extends a named import at the beginning of the file.
*
* If an import already exists with this source path, it is completed.
* If it does not already exist, it is added at the end of all imports.
*
* @param {string} path - The file path relative to the client directory.
* @param {string} specifier - The import specifier.
* @param {string} source - The import source.
* @returns {this}
* @memberof FileSystem
*/
addOrExtendNamedImportIn(path, specifier, source, options) {
const initialLogs = this.logs;
if (options) {
this.logs = options.logs;
}
this.modify(path, content => {
// TODO: add tests to support double quotes.
const regex = /import (.*) from '(.*)';/g;
let endPos = 0;
let specifierAlreadyExists = false;
const replacedContent = content.replace(regex, (match, p1, p2, offset) => {
endPos = offset + match.length;
const namedImportRegex = new RegExp(`import {(.*)} from \'(.*)\';`);
return match.replace(namedImportRegex, (subString, specifiersStr, path) => {
if (path !== source) {
return subString;
}
const specifiers = specifiersStr
.split(',')
.map(imp => imp.trim());
if (specifiers.includes(specifier)) {
specifierAlreadyExists = true;
return subString;
}
const newSpecifiers = specifiers
.concat(specifier)
.sort((a, b) => a.localeCompare(b))
.join(', ');
return `import { ${newSpecifiers} } from '${source}';`;
});
});
if (specifierAlreadyExists) {
return content;
}
if (replacedContent !== content) {
return replacedContent;
}
const newImport = `import { ${specifier} } from '${source}';`;
if (endPos === 0) {
return `${newImport}\n\n${content}`;
}
return content.substr(0, endPos) + '\n' + newImport + content.substr(endPos);
});
this.logs = initialLogs;
return this;
}
/**
* Creates or adds an element to the array property of a class.
*
* If the class does not exist, this method does nothing.
*
* @param {string} path - The file path relative to the client directory.
* @param {string} className - The class name.
* @param {string} propertyName - The property name.
* @param {string} element - The item to add to the array.
* @returns {this}
* @memberof FileSystem
*/
addOrExtendClassArrayPropertyIn(path, propertyName, element, options) {
const initialLogs = this.logs;
if (options) {
this.logs = options.logs;
}
this.modify(path, content => content.replace(new RegExp(`class (\\w*|\\w* implements \\w*) {(.*)}`, 's'), (match, className, p2) => {
if (/^(\s)*$/.test(p2)) {
return `class ${className} {\n ${propertyName} = [\n ${element}\n ];\n}`;
}
const replacedMatch = match.replace(new RegExp(`( *)${propertyName} = \\[(.*)\\];`, 's'), (_, spaces, content) => {
const items = content
.replace(/,\n/g, '\n')
.split('\n')
.map(e => e.trim())
.concat(element)
.map(e => `${spaces}${spaces}${e}`);
const cleanItems = [];
for (const item of items) {
if (item.trim() !== '') {
cleanItems.push(item);
}
}
return `${spaces}${propertyName} = [\n${cleanItems.join(',\n')}\n${spaces}];`;
});
if (replacedMatch !== match) {
return replacedMatch;
}
return `class ${className} {\n ${propertyName} = [\n ${element}\n ];\n${p2}}`;
}));
this.logs = initialLogs;
return this;
}
/**
* Returns true if the project package.json has this dependency.
* Returns false otherwise.
*
* @param {string} name - The name of the dependency.
* @returns {boolean}
* @memberof FileSystem
*/
projectHasDependency(name) {
const initialCurrentDir = this.currentDir;
this.cdProjectRootDir();
const pkg = JSON.parse(fs_1.readFileSync(this.parse('package.json'), 'utf8'));
this.currentDir = initialCurrentDir;
return pkg.dependencies.hasOwnProperty(name);
}
/**
* Returns the dependencies of the project package.json.
*
* @returns {{ name: string, version: string }[]}
*/
getProjectDependencies() {
const initialCurrentDir = this.currentDir;
this.cdProjectRootDir();
const pkg = JSON.parse(fs_1.readFileSync(this.parse('package.json'), 'utf8'));
this.currentDir = initialCurrentDir;
return Object.keys(pkg.dependencies)
.map(name => ({ name, version: pkg.dependencies[name] }));
}
/**
* Returns the dev dependencies of the project package.json.
*
* @returns {{ name: string, version: string }[]}
*/
getProjectDevDependencies() {
const initialCurrentDir = this.currentDir;
this.cdProjectRootDir();
const pkg = JSON.parse(fs_1.readFileSync(this.parse('package.json'), 'utf8'));
this.currentDir = initialCurrentDir;
return Object.keys(pkg.devDependencies)
.map(name => ({ name, version: pkg.devDependencies[name] }));
}
/************************
Testing Methods
************************/
/**
* Creates the test client directory. Sets current directory to none.
*
* @memberof FileSystem
*/
setUp() {
const [firstDir] = this.testDir.split('/');
fs_1.mkdirSync(firstDir);
fs_1.mkdirSync(this.testDir);
this.currentDir = '';
}
/**
* Empties and removes the test client directory. Sets current directory to none.
*
* @memberof FileSystem
*/
tearDown() {
const [firstDir] = this.testDir.split('/');
rmDirAndFiles(firstDir);
this.currentDir = '';
}
/**
* Throws an error if the file or directory does not exist.
*
* @param {string} path - The path relative to the client directory.
* @returns {this}
* @memberof FileSystem
*/
assertExists(path) {
if (!fs_1.existsSync(this.parse(path))) {
throw new Error(`The file "${path}" does not exist.`);
}
return this;
}
/**
* Throws an error if the file or directory exists.
*
* @param {string} path - The path relative to the client directory.
* @returns {this}
* @memberof FileSystem
*/
assertNotExists(path) {
if (fs_1.existsSync(this.parse(path))) {
throw new Error(`The file "${path}" should not exist.`);
}
return this;
}
/**
* Throws an error if the directory is not empty.
*
* @param {string} path - The path relative to the client directory.
* @memberof FileSystem
*/
assertEmptyDir(path) {
if (fs_1.readdirSync(this.parse(path)).length > 0) {
throw new Error(`The directory "${path}" should be empty.`);
}
}
/**
* Throws an error if the two files are different.
*
* @param {string} actual - The path relative to the client directory.
* @param {string} expected - The path relative to the `specs/` directory.
* @param {{ binary: boolean }} [{ binary }={ binary: true }] - Specify if the file is binary.
* @returns {this}
* @memberof FileSystem
*/
assertEqual(actual, expected, { binary } = { binary: false }) {
const specPath = path_1.join(__dirname, 'specs', expected);
if (!fs_1.existsSync(specPath)) {
throw new Error(`The spec file "${expected}" does not exist.`);
}
if (binary) {
assert_1.deepStrictEqual(fs_1.readFileSync(this.parse(actual)), fs_1.readFileSync(specPath));
}
else {
assert_1.strictEqual(fs_1.readFileSync(this.parse(actual), 'utf8'), fs_1.readFileSync(specPath, 'utf8'));
}
return this;
}
/**
* Copies a file from the `fixtures` directory.
*
* @param {string} src - The source path relative to the `fixtures/` directory.
* @param {string} dest - The destination path relative to the client directory.
* @returns {this}
* @memberof FileSystem
*/
copyFixture(src, dest) {
const fixturePath = path_1.join(__dirname, 'fixtures', src);
if (!fs_1.existsSync(fixturePath)) {
throw new Error(`The fixture file "${src}" does not exist.`);
}
fs_1.copyFileSync(fixturePath, this.parse(dest));
return this;
}
/**
* Removes a file.
*
* @param {string} path - The path relative to the client directory.
* @memberof FileSystem
*/
rmfile(path) {
fs_1.unlinkSync(this.parse(path));
}
isTestingEnvironment() {
return process.env.P1Z7kEbSUUPMxF8GqPwD8Gx_FOAL_CLI_TEST === 'true';
}
parse(path) {
if (this.isTestingEnvironment()) {
return path_1.join(this.testDir, this.currentDir, path);
}
return path_1.join(this.currentDir, path);
}
logCreate(path) {
path = path_1.join(this.currentDir, path);
// && !this.options.noLogs
if (!this.isTestingEnvironment() && this.logs) {
console.log(`${safe_1.green('CREATE')} ${path}`);
}
}
logUpdate(path) {
// && !this.options.noLogs
path = path_1.join(this.currentDir, path);
if (!this.isTestingEnvironment() && this.logs) {
console.log(`${safe_1.cyan('UPDATE')} ${path}`);
}
}
}
exports.FileSystem = FileSystem;