#!/usr/bin/env node /* Copyright 2024 Resultium LLC This file is part of RCZ. RCZ is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or any later version. RCZ is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with RCZ. If not, see . */ 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"; import { execSync } from "child_process"; import { tmpdir } from "os"; 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 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 GetOnReleaseScript = async () => { if (fs.existsSync(path.join(process.cwd(), ".rcz.onrelease.js"))) { return ( await fs.promises.readFile(path.join(process.cwd(), ".rcz.onrelease.js")) ).toString(); } else if (fs.existsSync(path.join(process.cwd(), ".rcz", "onrelease.js"))) { return ( await fs.promises.readFile( path.join(process.cwd(), ".rcz", "onrelease.js"), ) ).toString(); } else { return null; } }; const GetPostReleaseScript = async () => { if (fs.existsSync(path.join(process.cwd(), ".rcz.postrelease.js"))) { return ( await fs.promises.readFile( path.join(process.cwd(), ".rcz.postrelease.js"), ) ).toString(); } else if ( fs.existsSync(path.join(process.cwd(), ".rcz", "postrelease.js")) ) { return ( await fs.promises.readFile( path.join(process.cwd(), ".rcz", "postrelease.js"), ) ).toString(); } else { return null; } }; const CheckForUpdates = async () => { const updateText = "You are running on an outdated version of Resultium Commitizen, in order to update run\n\nnpm install -g @resultium/rcz@latest"; const cachedVersion = fs.existsSync(path.join(tmpdir(), "rcz-server-version")) ? (await fs.promises.readFile(path.join(tmpdir(), "rcz-server-version"))) .toString() .trim() : null; const localVersion = execSync("rcz --version").toString().trim(); // even if cached once in a while it should get newest data if ((cachedVersion && Math.random() < 0.1) || cachedVersion === null) { const isOnline = (await fetch("https://icanhazip.com")).status === 200; if (!isOnline) return; const serverVersion = execSync("npm show @resultium/rcz version") .toString() .trim(); fs.promises.writeFile( path.join(tmpdir(), "rcz-server-version"), serverVersion, ); if (semver.gt(serverVersion, localVersion)) note(updateText); } else if (cachedVersion) { if (semver.gt(cachedVersion, localVersion)) note(updateText); } }; const program = new Command(); program .name("rcz") .description("Resultium commit standardization command-line interface") .version("1.12.1"); program .command("commit") .alias("c") .description("Create a conventional commit") .option("-S, --sign", "sign the commit") .option("--amend", "amend commit message to the last commit") .option("--sudo", "remove any validation") .action(async (options) => { await CheckForUpdates(); const config = await GetConfig(); const sign = config?.autoSignCommits || options.sign ? true : false; const amend = options.amend ? true : false; const sudo = options.sudo ? true : false; 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: (await simpleGit().diff(["--cached"])).toString() ? false : true, }); if (isCancel(stageAll)) { cancel("Commit creation cancelled"); process.exit(0); } const changedLines = ( ( await simpleGit().diff(["--numstat", stageAll ? "HEAD" : "--cached"]) ).match(/\d+/gm) || [] ).reduce((partialSum, num) => partialSum + Number(num), 0); if (changedLines > 250 && !sudo) { const proceedCommitting = await confirm({ message: "You are about to commit changes to more than 250 lines, are you sure you want to proceed?", initialValue: false, }); if (isCancel(proceedCommitting)) { cancel("Commit creation cancelled"); process.exit(0); } if (!proceedCommitting) { cancel("Cancelled, please split this commit into smaller ones"); 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: "functionality 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 (subsystem which change is relevant to e.g. router, forms, core) or leave empty", placeholder: "router", validate: (value) => { if (sudo) { return; } if (config?.scopes && value) { if (!config?.scopes.includes(value)) return "This scope is not allowed by local configuration"; } if (value.includes(" ")) { return "Must not include a space"; } }, }); 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`, placeholder: "warn upon suspicious router requests", validate: (value) => { if (sudo) { return; } 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 (motivation or elaboration for the change), can be left empty`, placeholder: "improves regex for suspicious character check in router requests", }); 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`, placeholder: "Acked-by: @security", }); 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") .alias("ch") .description("Outputs a markdown formatted changelog") .option("--show-hashes", "show first 9 characters of commit hashes") .option("--last-only", "display only latest release changes") .option( "--unreleased-as ", "show unreleased changes as different version", ) .action(async (options) => { const showHashes = options.showHashes ? true : false; const lastOnly = options.lastOnly ? true : false; const unreleased = options.unreleasedAs || "Unreleased"; 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]! || unreleased; const currentCommitStack = parsedCommitStacks.find( (commitStack) => commitStack.version === tag, ) || { version: tag || unreleased, 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") .alias("rel") .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 config = await GetConfig(); const sign = config?.autoSignReleases || options.sign ? true : false; const version = string.replace("v", ""); const onReleaseFile = await GetOnReleaseScript(); if (onReleaseFile) { const releaseScript = ` const __NEW_VERSION__ = "${version}"; const __IS_SIGNED__ = ${sign}; ${onReleaseFile}`; eval(releaseScript); } else { 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`, `"v${version}"`] : [`-a`, `v${version}`, `-m`, `"v${version}"`], ); const postReleaseFile = await GetPostReleaseScript(); if (postReleaseFile) { const postReleaseScript = ` const __NEW_VERSION__ = "${version}"; const __IS_SIGNED__ = ${sign}; ${postReleaseFile}`; eval(postReleaseScript); } }); 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"].join("|") : "..*" })\\))?!?: .* ?(\\(..*\\))?((\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();