c4dslbuilder
Version:
A CLI tool designed to compile a folder structure of markdowns and mermaid files into a site, pdf, single file markdown or a collection of markdowns with links - inspired by c4builder
363 lines (362 loc) • 15.4 kB
JavaScript
import Configstore from 'configstore';
import inquirer from 'inquirer';
import chalk from 'chalk';
import path from 'node:path';
import { createHash } from 'node:crypto';
import { CliLogger } from './cli-logger.js';
import { SafeFiles } from './safe-files.js';
import * as Constants from '../types/constants.js';
export class ConfigManager {
logger;
safeFiles;
constructor(logger = new CliLogger(ConfigManager.name), safeFiles = new SafeFiles()) {
this.logger = logger;
this.safeFiles = safeFiles;
}
openConfigStore() {
let config;
try {
config = new Configstore(process.cwd().split(path.sep).splice(1).join('_'), {}, {
configPath: path.join(process.cwd(), Constants.CONFIG_FILENAME),
});
}
catch (error) {
this.logger.error('Error accessing config store.', error);
return null;
}
return config;
}
getStoredValue(key) {
const config = this.openConfigStore();
if (!config) {
this.logger.error('Failed to open config store.');
return undefined;
}
try {
const configValue = config.get(key);
if (configValue === undefined) {
this.logger.warn(`Configuration key ${key} not found.`);
return undefined;
}
return configValue;
}
catch (error) {
this.logger.error(`Error retrieving config value for ${key}.`, error);
return undefined;
}
}
printConfigValue(title, value) {
this.logger.log(`${title.padEnd(40)} : ${value}`);
}
/* c8 ignore next 6 -- @preserve -- c8 stubbornly refuses to acknowledge we're testing this */
boolValueToString(value) {
if (value) {
return 'Yes';
}
return 'No';
}
numValueToString(value) {
return value.toString();
}
getPrintValue(value) {
return value?.trim?.() && value !== 'undefined' ? chalk.green(value) : chalk.red('Not set');
}
isValidProjectName(input) {
return (/^[\w\s-]{2,}$/.test(input.trim()) ||
'Project name must be at least 2 characters and only contain letters, numbers, spaces, hyphens, or underscores.');
}
hashPassword(password) {
return createHash('sha256').update(password).digest('hex');
}
isValidUrl(input) {
if (!input?.trim())
return true;
try {
new URL(input);
return true;
}
catch {
return 'Please enter a valid URL.';
}
}
getStrConfigValue(key) {
const configValue = this.getStoredValue(key);
if (typeof configValue === 'string') {
return configValue;
}
this.logger.info(`Expected string for ${key}, but got ${typeof configValue}`);
return '';
}
getBoolConfigValue(key) {
const configValue = this.getStoredValue(key);
if (typeof configValue === 'boolean') {
return configValue;
}
// cater for string representations of booleans
if (typeof configValue === 'string') {
if (configValue.toLowerCase() === 'true' || configValue.toLowerCase() === 'yes') {
return true;
}
if (configValue.toLowerCase() === 'false' || configValue.toLowerCase() === 'no') {
return false;
}
}
this.logger.info(`Expected boolean for ${key}, but got ${typeof configValue}`);
return false;
}
getNumConfigValue(key) {
const configValue = this.getStoredValue(key);
const num = typeof configValue === 'number' ? configValue : Number(configValue);
if (Number.isNaN(num)) {
this.logger.info(`Expected number for ${key}, but got ${typeof configValue}`);
return 0;
}
return num;
}
setConfigValue(key, value) {
const config = this.openConfigStore();
if (!config) {
this.logger.error('Failed to open config store.');
return;
}
config.set(key, value);
}
deleteConfig(key) {
const config = this.openConfigStore();
if (!config) {
this.logger.error('Failed to open config store.');
return;
}
config.delete(key);
}
resetConfig() {
const config = this.openConfigStore();
if (!config) {
this.logger.error('Failed to open config store.');
return;
}
const projectName = this.getStrConfigValue('projectName');
config.clear();
config.set('projectName', projectName);
this.logger.log(chalk.yellow(`✅ Configuration has been reset for project ${projectName}.`));
}
listConfig() {
this.logger.log(chalk.cyan('Current Configuration\n'));
this.printConfigValue('Project name', this.getPrintValue(this.getStrConfigValue('projectName')));
this.printConfigValue('Homepage Name', this.getPrintValue(this.getStrConfigValue('homepageName')));
this.printConfigValue('Root Folder', this.getPrintValue(this.getStrConfigValue('rootFolder')));
this.printConfigValue('Destination Folder', this.getPrintValue(this.getStrConfigValue('distFolder')));
this.printConfigValue('Structurizr DSL CLI to use', this.getPrintValue(this.getStrConfigValue('dslCli')));
this.printConfigValue('Where Structurizr starts looking for diagrams to extract', this.getPrintValue(this.getStrConfigValue('workspaceDsl')));
this.printConfigValue('Embed Mermaid diagrams?', this.getPrintValue(this.boolValueToString(this.getBoolConfigValue('embedMermaidDiagrams'))));
this.printConfigValue('PDF CSS', this.getPrintValue(this.getStrConfigValue('pdfCss')));
this.printConfigValue('Port Number', this.getPrintValue(this.numValueToString(this.getNumConfigValue('servePort'))));
this.printConfigValue('Repo URL', this.getPrintValue(this.getStrConfigValue('repoName')));
this.printConfigValue('Docsify stylesheet', this.getPrintValue(this.getStrConfigValue('webTheme')));
this.printConfigValue('Enable website search', this.getPrintValue(this.boolValueToString(this.getBoolConfigValue('webSearch'))));
this.printConfigValue('Docsify template', this.getPrintValue(this.getStrConfigValue('docsifyTemplate')));
this.printConfigValue('Password protected', this.getPrintValue(this.boolValueToString(this.getBoolConfigValue('passwordProtected'))));
this.printConfigValue('Logo', this.getPrintValue(this.getStrConfigValue('logo')));
this.printConfigValue('Logo alignment', this.getPrintValue(this.getStrConfigValue('logoAlign')));
this.printConfigValue('Logo position', this.getPrintValue(this.getStrConfigValue('logoPosition')));
}
async setConfig() {
this.logger.log(chalk.cyan('Configure your project settings:\n'));
const questions = [
{
type: 'input',
name: 'projectName',
message: 'Project name:',
default: this.getStrConfigValue('projectName'),
validate: this.isValidProjectName.bind(this),
},
{
type: 'input',
name: 'homepageName',
message: 'Homepage name:',
default: this.getStrConfigValue('homepageName') || 'Overview',
},
{
type: 'input',
name: 'rootFolder',
message: 'Root folder:',
default: this.getStrConfigValue('rootFolder') || Constants.DEFAULT_ROOT,
},
{
type: 'input',
name: 'distFolder',
message: 'Destination folder:',
default: this.getStrConfigValue('distFolder') || Constants.DEFAULT_DIST,
},
{
type: 'confirm',
name: 'embedMermaidDiagrams',
message: 'Embed Mermaid diagrams? (Set this to NO / false to replace mermaid diagrams with a link to an image)',
default: this.getBoolConfigValue('embedMermaidDiagrams') || true,
},
{
type: 'list',
name: 'dslCli',
message: 'Which Structurizr CLI would you prefer to use:',
choices: ['structurizr-cli', 'docker'],
default: this.getStrConfigValue('dslCli') || 'structurizr-cli',
},
{
type: 'input',
name: 'workspaceDsl',
message: 'Where should the Structurizr CLI start looking when exporting diagrams:',
default: this.getStrConfigValue('workspaceDsl') || 'workspace.dsl',
},
{
type: 'input',
name: 'pdfCss',
message: 'PDF CSS file path:',
default: this.getStrConfigValue('pdfCss') || '_resources/pdf.css',
},
{
type: 'number',
name: 'servePort',
message: 'Port number:',
default: this.getNumConfigValue('servePort') || 3030,
},
{
type: 'input',
name: 'repoName',
message: 'Repository URL:',
default: this.getStrConfigValue('repoName') || '',
validate: this.isValidUrl.bind(this),
},
{
type: 'input',
name: 'webTheme',
message: 'Website Docsify theme (URL):',
default: this.getStrConfigValue('webTheme') || 'https://unpkg.com/docsify/lib/themes/vue.css',
validate: this.isValidUrl.bind(this),
},
{
type: 'confirm',
name: 'webSearch',
message: 'Enable website search?',
default: this.getBoolConfigValue('webSearch') || true,
},
{
type: 'input',
name: 'docsifyTemplate',
message: 'Local path to a custom Docsify template:',
default: this.getStrConfigValue('docsifyTemplate') || '',
},
{
type: 'confirm',
name: 'passwordProtected',
message: 'Password protect the documentation site?',
default: this.getBoolConfigValue('passwordProtected') || false,
},
{
type: 'password',
name: 'password',
message: 'Enter password:',
when: (answers) => answers.passwordProtected,
validate: (input) => input.length >= 1 || 'Password cannot be empty',
},
{
type: 'input',
name: 'logo',
message: 'Logo file path (relative to root folder):',
default: this.getStrConfigValue('logo') || '',
},
{
type: 'list',
name: 'logoAlign',
message: 'Logo alignment:',
choices: ['left', 'center', 'right'],
default: this.getStrConfigValue('logoAlign') || 'left',
when: (answers) => !!answers.logo,
},
{
type: 'list',
name: 'logoPosition',
message: 'Logo position:',
choices: ['above', 'below'],
default: this.getStrConfigValue('logoPosition') || 'above',
when: (answers) => !!answers.logo,
},
{
type: 'list',
name: 'projectTemplate',
message: 'Choose project template:',
choices: [
{ name: 'Clean start (empty project)', value: 'clean' },
{ name: 'Sample project (with example documents)', value: 'sample' },
],
default: 'clean',
},
];
const answers = (await inquirer.prompt(questions));
// Handle password separately: hash and store if enabled, clear if disabled
if (answers.passwordProtected && answers.password) {
this.setConfigValue('passwordHash', this.hashPassword(answers.password));
}
else if (!answers.passwordProtected) {
this.deleteConfig('passwordHash');
}
Object.entries(answers).forEach(([key, value]) => {
if (key !== 'password') {
this.setConfigValue(key, String(value));
}
});
this.logger.log(chalk.green('\n✅ Configuration updated successfully.'));
// Handle template selection
if (answers.projectTemplate === 'clean') {
await this.cleanSampleFiles(answers.rootFolder);
}
}
async cleanSampleFiles(rootFolder) {
const sampleFiles = [
'c4dslbuilder.figlet.png',
'context.md',
'context.mmd',
'inline.md',
'kanban.mmd',
'linked.md',
];
this.logger.log(chalk.yellow('\nCleaning sample files for fresh start...'));
for (const file of sampleFiles) {
const filePath = path.join(process.cwd(), rootFolder, file);
if (await this.safeFiles.pathExists(filePath)) {
await this.safeFiles.removeFile(filePath);
this.logger.log(chalk.gray(` Removed: ${file}`));
}
}
// Remove Other Files folder
const otherFilesPath = path.join(process.cwd(), rootFolder, 'Other Files');
if (await this.safeFiles.pathExists(otherFilesPath)) {
await this.safeFiles.removeDir(otherFilesPath);
this.logger.log(chalk.gray(` Removed: Other Files/`));
}
this.logger.log(chalk.green('Sample files cleaned successfully.\n'));
}
async getAllStoredConfig() {
return {
projectName: this.getStrConfigValue('projectName'),
homepageName: this.getStrConfigValue('homepageName'),
rootFolder: this.getStrConfigValue('rootFolder'),
distFolder: this.getStrConfigValue('distFolder'),
dslCli: this.getStrConfigValue('dslCli') === 'docker' ? 'docker' : 'structurizr-cli',
workspaceDsl: this.getStrConfigValue('workspaceDsl'),
embedMermaidDiagrams: this.getBoolConfigValue('embedMermaidDiagrams'),
pdfCss: this.getStrConfigValue('pdfCss'),
servePort: this.getNumConfigValue('servePort'),
repoName: this.getStrConfigValue('repoName'),
webTheme: this.getStrConfigValue('webTheme'),
webSearch: this.getBoolConfigValue('webSearch'),
docsifyTemplate: this.getStrConfigValue('docsifyTemplate'),
passwordProtected: this.getBoolConfigValue('passwordProtected'),
passwordHash: this.getStrConfigValue('passwordHash'),
logo: this.getStrConfigValue('logo'),
logoAlign: this.getStrConfigValue('logoAlign'),
logoPosition: this.getStrConfigValue('logoPosition'),
serve: this.getBoolConfigValue('serve'),
generateWebsite: this.getBoolConfigValue('generateWebsite'),
};
}
}