diff --git a/src/commands/changelog.ts b/src/commands/changelog.ts new file mode 100644 index 0000000..b26d472 --- /dev/null +++ b/src/commands/changelog.ts @@ -0,0 +1,209 @@ +/* +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 { Command } from "commander"; +import simpleGit from "simple-git"; +import { CommitStack } from "../types"; +import { sort } from "semver"; + +const command = new 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 = + 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 + }`, + ); + } + } + } + }); + +export default command; diff --git a/src/commands/commit.ts b/src/commands/commit.ts new file mode 100644 index 0000000..d5b5714 --- /dev/null +++ b/src/commands/commit.ts @@ -0,0 +1,276 @@ +/* +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 { Command } from "commander"; +import { checkForUpdates, getConfig } from "../utils/functions"; +import { + cancel, + confirm, + intro, + isCancel, + note, + outro, + select, + text, +} from "@clack/prompts"; +import { existsSync } from "fs"; +import { join } from "path"; +import simpleGit from "simple-git"; + +const command = new 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 (!existsSync(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`, + ); + }); + +export default command; diff --git a/src/commands/release.ts b/src/commands/release.ts new file mode 100644 index 0000000..fffd4b9 --- /dev/null +++ b/src/commands/release.ts @@ -0,0 +1,92 @@ +/* +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 { Command } from "commander"; +import { + getConfig, + getOnReleaseScript, + getPostReleaseScript, +} from "../utils/functions"; +import { readFile, writeFile } from "fs/promises"; +import { join } from "path"; +import { cwd } from "process"; +import simpleGit from "simple-git"; + +const command = new 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 readFile(join(cwd(), "package.json"))).toString(), + ); + + if (!packageFile) { + console.log("[rcz]: this directory does not have a package.json file"); + } + + packageFile.version = version; + await writeFile( + join(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); + } + }); + +export default command; diff --git a/src/commands/validate.ts b/src/commands/validate.ts new file mode 100644 index 0000000..7b2c79d --- /dev/null +++ b/src/commands/validate.ts @@ -0,0 +1,62 @@ +/* +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 { Command } from "commander"; +import { getConfig } from "../utils/functions"; +import { readFileSync } from "fs"; + +const command = new 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 || 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"); + } + }); + +export default command; diff --git a/src/index.ts b/src/index.ts index 12d19c1..da5c074 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,674 +19,27 @@ 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"; -import { request } from "http"; +import { readdir } from "fs/promises"; +import { join } from "path"; -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; +(async () => { + const program = new Command(); + + program + .name("rcz") + .description("Resultium commit standardization command-line interface") + .version("1.12.4"); + + const commandFiles = await readdir(join(__dirname, "commands")); + + for (const commandFile of commandFiles) { + const command = ( + await import(join(__dirname, "commands", commandFile.split(".")[0])) + ).default; + + program.addCommand(command); } -}; -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 isOnline = () => { - return new Promise((resolve) => { - request({ method: "GET", hostname: "icanhazip.com" }, (res) => { - res.on("data", () => {}); - - res.on("end", () => { - resolve(res.statusCode === 200); - }); - }) - .on("error", () => { - resolve(false); - }) - .end(); - }); -}; - -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) { - if (!(await 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.4"); - -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(); + program.parse(); +})(); diff --git a/src/utils/functions.ts b/src/utils/functions.ts new file mode 100644 index 0000000..b31a131 --- /dev/null +++ b/src/utils/functions.ts @@ -0,0 +1,112 @@ +/* +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 { existsSync } from "fs"; +import { readFile, writeFile } from "fs/promises"; +import { join } from "path"; +import { cwd } from "process"; +import { Config } from "../types"; +import { request } from "http"; +import { tmpdir } from "os"; +import { execSync } from "child_process"; +import { gt } from "semver"; +import { note } from "@clack/prompts"; + +export const getConfig = async () => { + if (existsSync(join(cwd(), ".rczrc"))) { + return JSON.parse( + (await readFile(join(cwd(), ".rczrc"))).toString(), + ) as Config; + } else if (existsSync(join(cwd(), ".rczrc.json"))) { + return JSON.parse( + (await readFile(join(cwd(), ".rczrc.json"))).toString(), + ) as Config; + } else if (existsSync(join(cwd(), "rcz.config.json"))) { + return JSON.parse( + (await readFile(join(cwd(), "rcz.config.json"))).toString(), + ) as Config; + } else if (existsSync(join(cwd(), ".rcz", "config.json"))) { + return JSON.parse( + (await readFile(join(cwd(), ".rcz", "config.json"))).toString(), + ) as Config; + } else { + return null; + } +}; + +export const getOnReleaseScript = async () => { + if (existsSync(join(cwd(), ".rcz.onrelease.js"))) { + return (await readFile(join(cwd(), ".rcz.onrelease.js"))).toString(); + } else if (existsSync(join(cwd(), ".rcz", "onrelease.js"))) { + return (await readFile(join(cwd(), ".rcz", "onrelease.js"))).toString(); + } else { + return null; + } +}; + +export const getPostReleaseScript = async () => { + if (existsSync(join(cwd(), ".rcz.postrelease.js"))) { + return (await readFile(join(cwd(), ".rcz.postrelease.js"))).toString(); + } else if (existsSync(join(cwd(), ".rcz", "postrelease.js"))) { + return (await readFile(join(cwd(), ".rcz", "postrelease.js"))).toString(); + } else { + return null; + } +}; + +export const isOnline = () => { + return new Promise((resolve) => { + request({ method: "GET", hostname: "icanhazip.com" }, (res) => { + res.on("data", () => {}); + + res.on("end", () => { + resolve(res.statusCode === 200); + }); + }) + .on("error", () => { + resolve(false); + }) + .end(); + }); +}; + +export 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 = existsSync(join(tmpdir(), "rcz-server-version")) + ? (await readFile(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) { + if (!(await isOnline())) return; + + const serverVersion = execSync("npm show @resultium/rcz version") + .toString() + .trim(); + + writeFile(join(tmpdir(), "rcz-server-version"), serverVersion); + + if (gt(serverVersion, localVersion)) note(updateText); + } else if (cachedVersion) { + if (gt(cachedVersion, localVersion)) note(updateText); + } +};