UNPKG

@log4brains/init

Version:

Log4brains architecture knowledge base initialization CLI

272 lines (219 loc) 10.6 kB
import commander from 'commander'; import fs, { promises } from 'fs'; import terminalLink from 'terminal-link'; import chalk from 'chalk'; import execa from 'execa'; import mkdirp from 'mkdirp'; import yaml from 'yaml'; import path from 'path'; import moment from 'moment-timezone'; import { FailureExit } from '@log4brains/cli-common'; function escapeRegExp(str) { return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); // $& means the whole matched string } /** * string.replaceAll() polyfill * TODO: remove when support down to Node 15 * @param str * @param search * @param replacement */ function replaceAll(str, search, replacement) { return str.replace(new RegExp(escapeRegExp(search), "g"), replacement); } async function replaceAllInFile(path, replacements) { let content = await promises.readFile(path, "utf-8"); content = replacements.reduce((prevContent, replacement) => { return replaceAll(prevContent, replacement[0], replacement[1]); }, content); await promises.writeFile(path, content, "utf-8"); } /* eslint-disable no-await-in-loop */ const assetsPath = path.resolve(path.join(__dirname, "../assets")); // only one level up because bundled with microbundle const docLink = "https://github.com/thomvaill/log4brains"; function forceUnixPath(p) { return p.replace(/\\/g, "/"); } class InitCommand { constructor({ appConsole }) { this.console = appConsole; } guessMainAdrFolderPath(cwd) { const usualPaths = ["./docs/adr", "./docs/adrs", "./docs/architecture-decisions", "./doc/adr", "./doc/adrs", "./doc/architecture-decisions", "./adr", "./adrs", "./architecture-decisions"]; // eslint-disable-next-line no-restricted-syntax for (const possiblePath of usualPaths) { if (fs.existsSync(path.join(cwd, possiblePath))) { return possiblePath; } } return undefined; } // eslint-disable-next-line sonarjs/cognitive-complexity async buildLog4brainsConfigInteractively(cwd, noInteraction) { this.console.println(chalk.bold("👋 Welcome to Log4brains!")); this.console.println(); this.console.println("This interactive script will help you configure Log4brains for your project."); this.console.println(`It will create the ${chalk.cyan(".log4brains.yml")} config file,`); this.console.println(" copy the default ADR template,"); this.console.println(" and create your first ADR for you!"); this.console.println(); this.console.println("Before going further, please check that you are running this command"); this.console.println("from the root folder of your project's git repository:"); this.console.println(chalk.cyan(cwd)); // Continue? if (!noInteraction && !(await this.console.askYesNoQuestion("Continue?", true))) { process.exit(0); } this.console.println(); this.console.println("👍 We will now ask you several questions to get you started:"); // Name let name; try { // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,global-require,import/no-dynamic-require,@typescript-eslint/no-var-requires name = require(path.join(cwd, "package.json")).name; } catch (e) {// ignore } name = noInteraction ? name || "untitled" : await this.console.askInputQuestionAndValidate("What is the name of your project?", answer => !!answer.trim(), name); // Project type const type = noInteraction ? "mono" : await this.console.askListQuestion("Which statement describes the best your project?", [{ name: "Simple project (only one ADR folder)", value: "mono", short: "Mono-package project" }, { name: "Multi-package project (one ADR folder per package + a global one)", value: "multi", short: "Multi-package project" }]); // Main ADR folder location let adrFolder = this.guessMainAdrFolderPath(cwd); if (adrFolder) { this.console.println(); this.console.println(`${chalk.blue.bold("i We have detected a folder with existing ADRs:")} ${chalk.cyan(adrFolder)}`); adrFolder = noInteraction || (await this.console.askYesNoQuestion("Do you want to use it? (existing ADRs will be kept)", true)) ? adrFolder : undefined; } if (!adrFolder) { adrFolder = noInteraction ? "./docs/adr" : await this.console.askInputQuestionAndValidate(`In which directory do you plan to store your ${type === "multi" ? "global " : ""}ADRs? (will be automatically created)`, answer => !!answer.trim(), "./docs/adr"); } await mkdirp(path.join(cwd, adrFolder)); this.console.println(); // Packages const packages = []; if (type === "multi") { this.console.println("We will now define your packages..."); this.console.println(); let oneMorePackage = false; let packageNumber = 1; do { this.console.println(); this.console.println(` ${chalk.underline(`Package #${packageNumber}`)}:`); const pkgName = await this.console.askInputQuestionAndValidate("Name? (short, lowercase, without special characters, nor spaces)", answer => !!answer.trim()); const pkgCodeFolder = await this.askPathWhileNotFound("Where is the source code of this package located?", cwd, `./packages/${pkgName}`); const pkgAdrFolder = await this.console.askInputQuestionAndValidate(`In which directory do you plan to store the ADRs of this package? (will be automatically created)`, answer => !!answer.trim(), `${pkgCodeFolder}/docs/adr`); await mkdirp(path.join(cwd, pkgAdrFolder)); packages.push({ name: pkgName, path: forceUnixPath(pkgCodeFolder), adrFolder: forceUnixPath(pkgAdrFolder) }); oneMorePackage = await this.console.askYesNoQuestion(`We are done with package #${packageNumber}. Do you want to add another one?`, false); packageNumber += 1; } while (oneMorePackage); } return { project: { name, tz: moment.tz.guess(), adrFolder: forceUnixPath(adrFolder), packages } }; } async createAdr(cwd, adrFolder, title, source, replacements = []) { const slug = (await execa("log4brains", ["adr", "new", "--quiet", "--from", forceUnixPath(path.join(assetsPath, source)), `"${title}"`], { cwd })).stdout; await replaceAllInFile(forceUnixPath(path.join(cwd, adrFolder, `${slug}.md`)), [["{DATE_YESTERDAY}", moment().subtract(1, "days").format("YYYY-MM-DD")], ...replacements]); return slug; } async copyFileIfAbsent(cwd, adrFolder, filename, contentCb) { const outPath = path.join(cwd, adrFolder, filename); if (!fs.existsSync(outPath)) { let content = await promises.readFile(path.join(assetsPath, filename), "utf-8"); if (contentCb) { content = contentCb(content); } await promises.writeFile(outPath, content); } } printSuccess() { this.console.success("Log4brains is configured! 🎉🎉🎉"); this.console.println(); this.console.println("You can now use the CLI to create a new ADR:"); this.console.println(` ${chalk.cyan(`log4brains adr new`)}`); this.console.println(""); this.console.println("And start the web UI to preview your architecture knowledge base:"); this.console.println(` ${chalk.cyan(`log4brains preview`)}`); this.console.println(); this.console.println("Do not forget to set up your CI/CD to automatically publish your knowledge base"); this.console.println(`Check out the ${terminalLink("documentation", docLink)} to see some examples`); } async askPathWhileNotFound(question, cwd, defaultValue) { const p = await this.console.askInputQuestion(question, defaultValue); if (!p.trim() || !fs.existsSync(path.join(cwd, p))) { this.console.warn("This path does not exist. Please try again..."); return this.askPathWhileNotFound(question, cwd, defaultValue); } return p; } /** * Command flow. * * @param options * @param customCwd */ async execute(options, customCwd) { const noInteraction = options.defaults; const cwd = customCwd ? path.resolve(customCwd) : process.cwd(); if (!fs.existsSync(cwd)) { this.console.fatal(`The given path does not exist: ${chalk.cyan(cwd)}`); throw new FailureExit(); } // Terminate now if already configured if (fs.existsSync(path.join(cwd, ".log4brains.yml"))) { this.console.warn(`${chalk.bold(".log4brains.yml")} already exists`); this.console.warn("Please delete it and re-run this command if you want to configure it again"); this.console.println(); this.printSuccess(); return; } // Create .log4brains.yml interactively const config = await this.buildLog4brainsConfigInteractively(cwd, noInteraction); this.console.startSpinner("Writing config file..."); const { adrFolder } = config.project; await promises.writeFile(path.join(cwd, ".log4brains.yml"), yaml.stringify(config), "utf-8"); // Copy template, index and README if not already created this.console.updateSpinner("Copying template files..."); await this.copyFileIfAbsent(cwd, adrFolder, "template.md"); await this.copyFileIfAbsent(cwd, adrFolder, "index.md", content => content.replace(/{PROJECT_NAME}/g, config.project.name)); await this.copyFileIfAbsent(cwd, adrFolder, "README.md"); // List existing ADRs this.console.updateSpinner("Creating your first ADRs..."); const adrListRes = await execa("log4brains", ["adr", "list", "--raw"], { cwd }); // Create Log4brains ADR const l4bAdrSlug = await this.createAdr(cwd, adrFolder, "Use Log4brains to manage the ADRs", "use-log4brains-to-manage-the-adrs.md"); // Create MADR ADR if there was no ADR in the repository if (!adrListRes.stdout) { await this.createAdr(cwd, adrFolder, "Use Markdown Architectural Decision Records", "use-markdown-architectural-decision-records.md", [["{LOG4BRAINS_ADR_SLUG}", l4bAdrSlug]]); } // End this.console.stopSpinner(); this.printSuccess(); } } function createInitCli({ appConsole }) { const program = new commander.Command(); program.command("init").arguments("[path]").description("Configures Log4brains for your project", { path: "Path of your project. Default: current directory" }).option("-d, --defaults", "Run in non-interactive mode and use the common default options", false).action((path, options) => { return new InitCommand({ appConsole }).execute(options, path); }); return program; } export { createInitCli }; //# sourceMappingURL=index.module.js.map