#!/usr/bin/env node import { cancel, confirm, intro, isCancel, note, outro, select, text, } from "@clack/prompts"; import fs from "fs"; import path from "path"; import { CommitStack, Config } from "./types"; import simpleGit from "simple-git"; import { Command } from "commander"; import semver from "semver"; const GetConfig = async () => { if (fs.existsSync(path.join(process.cwd(), ".rczrc"))) { return JSON.parse( ( await fs.promises.readFile(path.join(process.cwd(), ".rczrc")) ).toString() ) as Config; } else if (fs.existsSync(path.join(process.cwd(), ".rczrc.json"))) { return JSON.parse( ( await fs.promises.readFile(path.join(process.cwd(), ".rczrc.json")) ).toString() ) as Config; } else if (fs.existsSync(path.join(process.cwd(), "rcz.config.json"))) { return JSON.parse( ( await fs.promises.readFile(path.join(process.cwd(), "rcz.config.json")) ).toString() ) as Config; } else { return null; } }; const program = new Command(); program .name("rcz") .description("Resultium commit standardization command-line interface") .version("1.4.0"); program .command("commit") .description("Create a conventional commit") .option("-S, --sign", "sign the commit") .option("--amend", "amend commit message to the last commit") .action(async (options) => { const sign = options.sign ? true : false; const amend = options.amend ? true : false; const config = await GetConfig(); intro("Creating a conventional commit"); if (!fs.existsSync(path.join(process.cwd(), ".git"))) { cancel("Git repository has not been initialized"); process.exit(0); } const stageAll = amend ? null : await confirm({ message: "Stage all changes?", initialValue: true, }); if (isCancel(stageAll)) { cancel("Commit creation cancelled"); process.exit(0); } const type: string | symbol = await select({ message: "Choose a commit type", options: config?.types || [ { label: "feat", value: "feat", hint: "new feature", }, { label: "fix", value: "fix", hint: "bug fix", }, { label: "build", value: "build", hint: "changes that affect the build system, configs or external dependencies", }, { label: "ci", value: "ci", hint: "change to CI/CD configurations and scripts e.g. CircleCI, GitHub workflows", }, { label: "docs", value: "docs", hint: "documentation changes e.g. README, CHANGELOG", }, { label: "perf", value: "perf", hint: "code change, that improves performance", }, { label: "refactor", value: "refactor", hint: "code change that neither fixes a bug nor adds a feature", }, { label: "chore", value: "chore", hint: "changes that are routinely, e.g. dependency update or a release commit", }, ], }); if (isCancel(type)) { cancel("Commit creation cancelled"); process.exit(0); } const scope: string | symbol = await text({ message: "Input a scope (e.g. router, forms, core) or leave empty", validate: (value) => { if (config?.scopes && value) { if (!config?.scopes.includes(value)) return "This scope is not allowed by local configuration"; } }, }); if (isCancel(scope)) { cancel("Commit creation cancelled"); process.exit(0); } const message = await text({ message: `Briefly describe made changes in imperative tense, maximum length 50`, validate: (value) => { if (value.length > 50) { return "Your message is too long"; } }, }); if (isCancel(message)) { cancel("Commit creation cancelled"); process.exit(0); } const body = await text({ message: `Insert a commit body, recommended length 100, can be left empty`, }); if (isCancel(body)) { cancel("Commit creation cancelled"); process.exit(0); } const footer = await text({ message: `Insert commit footer, can be left empty, e.g. Acked-by: @johndoe`, }); if (isCancel(footer)) { cancel("Commit creation cancelled"); process.exit(0); } const isBreaking = await confirm({ message: "Does this commit have breaking changes?", initialValue: false, }); if (isCancel(isBreaking)) { cancel("Commit creation cancelled"); process.exit(0); } const resolvesIssue = await confirm({ message: "Does this commit resolve an existing issue?", initialValue: false, }); if (isCancel(resolvesIssue)) { cancel("Commit creation cancelled"); process.exit(0); } const issue = resolvesIssue ? await text({ message: "Enter an issue identifier, e.g. #274" }) : null; if (isCancel(issue)) { cancel("Commit creation cancelled"); process.exit(0); } const commitMessage = `${type.toString()}${ scope ? `(${scope.toString()})` : `` }${isBreaking ? "!" : ""}: ${message.toString()}${ resolvesIssue ? ` (${issue?.toString()})` : `` }${body ? `\n\n${body}` : ``}${footer ? `\n\n${footer}` : ``}`; if (stageAll) { await simpleGit() .add(".") .commit( commitMessage, sign ? (amend ? ["-S", "--amend"] : ["-S"]) : amend ? ["--amend"] : [] ); } else { await simpleGit().commit( commitMessage, sign ? (amend ? ["-S", "--amend"] : ["-S"]) : amend ? ["--amend"] : [] ); } note(commitMessage); outro( `Finished ${ amend ? "amending" : "creating" } a conventional commit, feel free to push` ); }); program .command("changelog") .description("Outputs a markdown formatted changelog") .option("--show-hashes", "show first 9 characters of commit hashes") .option("--last-only", "display only latest release changes") .action(async (options) => { const showHashes = options.showHashes ? true : false; const lastOnly = options.lastOnly ? true : false; if ((await simpleGit().tags()).all.length === 0) { return console.log( "[rcz]: not even one release has yet been made, cannot make a changelog" ); } const commits = (await simpleGit().log()).all; let lastTag = ""; let parsedCommitStacks: Array = []; console.log("# Changelog"); console.log("Generation of this changelog is based on commits"); for (const commit of commits) { const tag = semver.sort( (await simpleGit().tags([`--contains=${commit.hash}`])).all )[0]!; const currentCommitStack = parsedCommitStacks.find( (commitStack) => commitStack.version === tag ) || { version: tag || "", breaking: [], features: [], fixes: [], miscellaneous: [], }; if (lastTag !== tag) { parsedCommitStacks = [currentCommitStack, ...parsedCommitStacks]; } if (commit.message.includes("!:")) { parsedCommitStacks = [ { ...currentCommitStack, breaking: [...currentCommitStack.breaking, commit], }, ...parsedCommitStacks.filter( (commitStack) => commitStack.version !== tag ), ]; } else if (commit.message.startsWith("feat")) { parsedCommitStacks = [ { ...currentCommitStack, features: [...currentCommitStack.features, commit], }, ...parsedCommitStacks.filter( (commitStack) => commitStack.version !== tag ), ]; } else if (commit.message.startsWith("fix")) { parsedCommitStacks = [ { ...currentCommitStack, fixes: [...currentCommitStack.fixes, commit], }, ...parsedCommitStacks.filter( (commitStack) => commitStack.version !== tag ), ]; } else { parsedCommitStacks = [ { ...currentCommitStack, miscellaneous: [...currentCommitStack.miscellaneous, commit], }, ...parsedCommitStacks.filter( (commitStack) => commitStack.version !== tag ), ]; } lastTag = tag; } parsedCommitStacks = parsedCommitStacks.reverse(); if (lastOnly) { parsedCommitStacks = [parsedCommitStacks[0]]; } for (const commitStack of parsedCommitStacks) { console.log(`## ${commitStack.version}`); if (commitStack.breaking.length > 0) { console.log(`### Breaking`); for (const commit of commitStack.breaking) { const shortHash = commit.hash.slice(0, 9); // Selects contents between parenthesis and a semicolon, via https://stackoverflow.com/a/17779833/14544732 const type = /\(([^)]+)\):/.exec(commit.message) ? /\(([^)]+)\):/.exec(commit.message)![1] : null; const firstMessageLine = commit.message.split("\n"); const briefMessage = firstMessageLine[0].includes(":") ? firstMessageLine[0].split(":")[1].trim() : firstMessageLine[0]; console.log( `${showHashes ? `- [${shortHash}]` : ``} - ${ type ? `**${type}**: ${briefMessage}` : briefMessage }` ); } } if (commitStack.features.length > 0) { console.log(`### Features`); for (const commit of commitStack.features) { const shortHash = commit.hash.slice(0, 9); const type = /\(([^)]+)\):/.exec(commit.message) ? /\(([^)]+)\):/.exec(commit.message)![1] : null; const firstMessageLine = commit.message.split("\n"); const briefMessage = firstMessageLine[0].includes(":") ? firstMessageLine[0].split(":")[1].trim() : firstMessageLine[0]; console.log( `${showHashes ? `- [${shortHash}]` : ``} - ${ type ? `**${type}**: ${briefMessage}` : briefMessage }` ); } } if (commitStack.fixes.length > 0) { console.log(`### Fixes`); for (const commit of commitStack.fixes) { const shortHash = commit.hash.slice(0, 9); const type = /\(([^)]+)\):/.exec(commit.message) ? /\(([^)]+)\):/.exec(commit.message)![1] : null; const firstMessageLine = commit.message.split("\n"); const briefMessage = firstMessageLine[0].includes(":") ? firstMessageLine[0].split(":")[1].trim() : firstMessageLine[0]; console.log( `${showHashes ? `- [${shortHash}]` : ``} - ${ type ? `**${type}**: ${briefMessage}` : briefMessage }` ); } } if (commitStack.miscellaneous.length > 0) { console.log(`### Miscellaneous`); for (const commit of commitStack.miscellaneous) { const shortHash = commit.hash.slice(0, 9); const type = /\(([^)]+)\):/.exec(commit.message) ? /\(([^)]+)\):/.exec(commit.message)![1] : null; const firstMessageLine = commit.message.split("\n"); const briefMessage = firstMessageLine[0].includes(":") ? firstMessageLine[0].split(":")[1].trim() : firstMessageLine[0]; console.log( `${showHashes ? `- [${shortHash}]` : ``} - ${ type ? `**${type}**: ${briefMessage}` : briefMessage }` ); } } } }); program .command("release") .description( "Changes package.json version and creates a new commit with a tag" ) .argument("", "new version formatted in SemVer") .option("-S, --sign", "sign the release commit and tag") .action(async (string: string, options) => { const sign = options.sign ? true : false; const version = string.replace("v", ""); const packageFile = JSON.parse( ( await fs.promises.readFile(path.join(process.cwd(), "package.json")) ).toString() ); if (!packageFile) { console.log("[rcz]: this directory does not have a package.json file"); } packageFile.version = version; await fs.promises.writeFile( path.join(process.cwd(), "package.json"), JSON.stringify(packageFile, null, 4) ); await simpleGit() .add(".") .commit(`chore(release): v${version}`, sign ? ["-S"] : []) .tag( sign ? [`-s`, `v${version}`, `-m`, `"Version ${version}"`] : [`-a`, `v${version}`, `-m`, `"Version ${version}"`] ); }); program .command("validate") .description("Validate whether a string fits given commit conventions") .argument("[message]", "string for validation") .option("-C, --code-only", "return code only") .action(async (string: string, options) => { try { const message = string || fs.readFileSync(0, "utf-8"); const codeOnly = options.codeOnly ? true : false; const config = await GetConfig(); // Regex for testing: // /(build|feat|docs)(\((commands|changelog)\))?!?: .* ?(\(..*\))?((\n\n..*)?(\n\n..*)?)?/gm const testRegex = new RegExp( `(${ config?.types?.map((type) => type.value).join("|") || "feat|fix|build|ci|docs|perf|refactor|chore" })(\\((${ config?.scopes ? [...config?.scopes, "release"] : "..*" })\\))?!?: .* ?(\\(..*\\))?((\n\n..*)?(\n\n..*)?)?`, "gm" ); if (codeOnly) { console.log(testRegex.test(message) ? 0 : 1); } else { console.log( testRegex.test(message) ? "[rcz]: valid message" : "[rcz]: invalid message" ); } } catch (err) { console.log("[rcz]: no stdin found"); } }); program.parse();