UNPKG

fork-version

Version:

Fork-Version automates version control tasks such as determining, updating, and committing versions, files, and changelogs, simplifying the process when adhering to the conventional commit standard.

1,547 lines (1,526 loc) 64.7 kB
'use strict'; var zod = require('zod'); var child_process = require('child_process'); var semver = require('semver'); var path = require('path'); var glob = require('glob'); var conventionalChangelogConfigSpec = require('conventional-changelog-config-spec'); var fs = require('fs'); var JoyCon = require('joycon'); var bundleRequire = require('bundle-require'); var jsoncParser = require('jsonc-parser'); var yaml = require('yaml'); var cheerio = require('cheerio/slim'); var conventionalChangelog = require('conventional-changelog'); function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; } function _interopNamespace(e) { if (e && e.__esModule) return e; var n = Object.create(null); if (e) { Object.keys(e).forEach(function (k) { if (k !== 'default') { var d = Object.getOwnPropertyDescriptor(e, k); Object.defineProperty(n, k, d.get ? d : { enumerable: true, get: function () { return e[k]; } }); } }); } n.default = e; return Object.freeze(n); } var semver__default = /*#__PURE__*/_interopDefault(semver); var conventionalChangelogConfigSpec__default = /*#__PURE__*/_interopDefault(conventionalChangelogConfigSpec); var JoyCon__default = /*#__PURE__*/_interopDefault(JoyCon); var cheerio__namespace = /*#__PURE__*/_interopNamespace(cheerio); var conventionalChangelog__default = /*#__PURE__*/_interopDefault(conventionalChangelog); // src/config/schema.js var ChangelogPresetConfigTypeSchema = zod.z.object({ /** * The type of commit message. * @example "feat", "fix", "chore", etc.. */ type: zod.z.string().describe('The type of commit message, such as "feat", "fix", "chore".'), /** * The scope of the commit message. */ scope: zod.z.string().optional().describe("The scope of the commit message."), /** * The section of the `CHANGELOG` the commit should show up in. */ section: zod.z.string().optional().describe("The section of the `CHANGELOG` the commit should show up in."), /** * Should show in the generated changelog message? */ hidden: zod.z.boolean().optional().describe("Should show in the generated changelog message?") }); var ChangelogPresetConfigSchema = zod.z.object({ /** * List of explicitly supported commit message types. */ types: zod.z.array(ChangelogPresetConfigTypeSchema).describe("List of explicitly supported commit message types."), /** * A URL representing a specific commit at a hash. * @default "{{host}}/{{owner}}/{{repository}}/commit/{{hash}}" */ commitUrlFormat: zod.z.string().describe("A URL representing a specific commit at a hash."), /** * A URL representing the comparison between two git SHAs. * @default "{{host}}/{{owner}}/{{repository}}/compare/{{previousTag}}...{{currentTag}}" */ compareUrlFormat: zod.z.string().describe("A URL representing the comparison between two git SHAs."), /** * A URL representing the issue format (allowing a different URL format to be swapped in * for Gitlab, Bitbucket, etc). * @default "{{host}}/{{owner}}/{{repository}}/issues/{{id}}" */ issueUrlFormat: zod.z.string().describe("A URL representing the issue format."), /** * A URL representing a user's profile on GitHub, Gitlab, etc. This URL is used * for substituting @eglavin with https://github.com/eglavin in commit messages. * @default "{{host}}/{{user}}" */ userUrlFormat: zod.z.string().describe("A URL representing a user's profile on GitHub, Gitlab, etc."), /** * A string to be used to format the auto-generated release commit message. * @default "chore(release): {{currentTag}}" */ releaseCommitMessageFormat: zod.z.string().describe("A string to be used to format the auto-generated release commit message."), /** * List of prefixes used to detect references to issues. * @default ["#"] */ issuePrefixes: zod.z.array(zod.z.string()).describe("List of prefixes used to detect references to issues.") }); var ForkConfigSchema = zod.z.object({ // Commands // /** * The command to run, can be one of the following: * * - `main` - Bumps the version, update files, generate changelog, commit, and tag. * - `inspect-version` - Prints the current version and exits. * - `inspect-tag` - Prints the current git tag and exits. * - `validate-config` - Validates the configuration and exits. * * @default "main" */ command: zod.z.literal(["main", "inspect-version", "inspect-tag", "validate-config"]).describe( "The command to run. Can be one of: main, inspect-version, inspect-tag, validate-config. Defaults to main." ), /** * If set, Fork-Version will print the current version and exit. * @default false * * @deprecated Set the `inspect-version` command instead. */ inspectVersion: zod.z.boolean().optional().describe("If set, Fork-Version will print the current version and exit."), // Options // /** * List of the files to be updated. * @default * ```js * ["bower.json", "deno.json", "deno.jsonc", "jsr.json", "jsr.jsonc", "manifest.json", "npm-shrinkwrap.json", "package-lock.json", "package.json"] * ``` */ files: zod.z.array(zod.z.string()).describe("List of the files to be updated."), /** * Glob pattern to match files to be updated. * * Internally we're using [glob](https://github.com/isaacs/node-glob) to match files. * * Read more about the pattern syntax [here](https://github.com/isaacs/node-glob/tree/v10.3.12?tab=readme-ov-file#glob-primer). * * @default undefined * @example "*.json" */ glob: zod.z.string().optional().describe("Glob pattern to match files to be updated."), /** * The path Fork-Version will run from. * @default * ```js * process.cwd() * ``` */ path: zod.z.string().describe('The path Fork-Version will run from. Defaults to "process.cwd()".'), /** * Name of the changelog file. * @default "CHANGELOG.md" */ changelog: zod.z.string().describe('Name of the changelog file. Defaults to "CHANGELOG.md".'), /** * The header text for the changelog. * @default * ```markdown * # Changelog * * All notable changes to this project will be documented in this file. See [fork-version](https://github.com/eglavin/fork-version) for commit guidelines. * ``` */ header: zod.z.string().describe("The header text for the changelog."), /** * Specify a prefix for the created tag. * * For instance if your version tag is prefixed by "version/" instead of "v" you have to specify * `tagPrefix: "version/"`. * * `tagPrefix` can also be used for a monorepo environment where you might want to deploy * multiple package from the same repository. In this case you can specify a prefix for * each package: * * | Example Value | Tag Created | * |:-------------------------|:------------------------------| * | "" | `1.2.3` | * | "version/" | `version/1.2.3` | * | "@eglavin/fork-version-" | `@eglavin/fork-version-1.2.3` | * * @example "", "version/", "@eglavin/fork-version-" * @default "v" */ tagPrefix: zod.z.string().describe('Specify a prefix for the created tag. Defaults to "v".'), /** * Make a pre-release with optional label if given value is a string. * * | Example Value | Produced Version | * |:--------------|:-----------------| * | true | `1.2.3-0` | * | "alpha" | `1.2.3-alpha-0` | * | "beta" | `1.2.3-beta-0` | * * @example true, "alpha", "beta", "rc" * @default undefined */ preRelease: zod.z.string().or(zod.z.boolean()).optional().describe("Make a pre-release with optional label if given value is a string."), /** * If set, Fork-Version will use this version instead of trying to determine one. * @example "1.0.0" * @default undefined */ currentVersion: zod.z.string().optional().describe("If set, Fork-Version will use this version instead of trying to determine one."), /** * If set, Fork-Version will attempt to update to this version, instead of incrementing using "conventional-commit". * @example "2.0.0" * @default undefined */ nextVersion: zod.z.string().optional().describe( 'If set, Fork-Version will attempt to update to this version, instead of incrementing using "conventional-commit".' ), /** * Release as increments the version by the specified level. Overrides the default behaviour of "conventional-commit". * @example "major", "minor", "patch" * @default undefined */ releaseAs: zod.z.union([zod.z.literal("major"), zod.z.literal("minor"), zod.z.literal("patch")]).optional().describe( 'Release as increments the version by the specified level. Overrides the default behaviour of "conventional-commit".' ), // Flags // /** * Don't throw an error if multiple versions are found in the given files. * @default true */ allowMultipleVersions: zod.z.boolean().describe("Don't throw an error if multiple versions are found in the given files."), /** * Commit all changes, not just files updated by Fork-Version. * @default false */ commitAll: zod.z.boolean().describe("Commit all changes, not just files updated by Fork-Version."), /** * By default the conventional-changelog spec will only add commit types of `feat` and `fix` to the generated changelog. * If this flag is set, all [default commit types](https://github.com/conventional-changelog/conventional-changelog-config-spec/blob/238093090c14bd7d5151eb5316e635623ce633f9/versions/2.2.0/schema.json#L18) * will be added to the changelog. * @default false */ changelogAll: zod.z.boolean().describe( "If this flag is set, all default commit types will be added to the changelog, not just `feat` and `fix`." ), /** * Output debug information. * @default false */ debug: zod.z.boolean().describe("Output debug information."), /** * No output will be written to disk or committed. * @default false */ dryRun: zod.z.boolean().describe("No output will be written to disk or committed."), /** * Run without logging to the terminal. * @default false */ silent: zod.z.boolean().describe("Run without logging to the terminal."), /** * If unable to find a version in the given files, fallback and attempt to use the latest git tag. * @default true */ gitTagFallback: zod.z.boolean().describe( "If unable to find a version in the given files, fallback and attempt to use the latest git tag. Defaults to true." ), /** * If true, git will sign the commit with the systems GPG key. * @see {@link https://git-scm.com/docs/git-commit#Documentation/git-commit.txt--Sltkeyidgt Git - GPG Sign Commits} * @default false */ sign: zod.z.boolean().describe("If true, git will sign the commit with the systems GPG key."), /** * If true, git will run user defined git hooks before committing. * @see {@link https://git-scm.com/docs/githooks Git - Git Hooks} * @default false */ verify: zod.z.boolean().describe("If true, git will run user defined git hooks before committing."), // Skip Steps // /** * Skip the bump step. * @default false */ skipBump: zod.z.boolean().describe("Skip the bump step."), /** * Skip the changelog step. * @default false */ skipChangelog: zod.z.boolean().describe("Skip the changelog step."), /** * Skip the commit step. * @default false */ skipCommit: zod.z.boolean().describe("Skip the commit step."), /** * Skip the tag step. * @default false */ skipTag: zod.z.boolean().describe("Skip the tag step."), /** * Override the default "conventional-changelog-conventionalcommits" preset configuration. */ changelogPresetConfig: ChangelogPresetConfigSchema.partial().describe( 'Override the default "conventional-changelog-conventionalcommits" preset configuration.' ), /** * Add a suffix to the release commit message. * @example "[skip ci]" */ releaseMessageSuffix: zod.z.string().optional().describe("Add a suffix to the release commit message.") }); var Git = class { constructor(config) { this.config = config; this.add = this.add.bind(this); this.commit = this.commit.bind(this); this.tag = this.tag.bind(this); this.log = this.log.bind(this); this.isIgnored = this.isIgnored.bind(this); this.getBranchName = this.getBranchName.bind(this); this.getRemoteUrl = this.getRemoteUrl.bind(this); this.getTags = this.getTags.bind(this); this.getMostRecentTag = this.getMostRecentTag.bind(this); this.getCleanedTags = this.getCleanedTags.bind(this); this.getHighestSemverVersionFromTags = this.getHighestSemverVersionFromTags.bind(this); this.getCommits = this.getCommits.bind(this); } async #execGit(command, args) { return new Promise((onResolve, onReject) => { child_process.execFile( "git", [command, ...args], { cwd: this.config.path, maxBuffer: Infinity }, (error, stdout, stderr) => { if (error) { onReject(error); } else { onResolve(stdout ? stdout : stderr); } } ); }); } /** * Add file contents to the index * * [git-add Documentation](https://git-scm.com/docs/git-add) * * @example * ```ts * await git.add("CHANGELOG.md"); * ``` */ async add(...args) { if (this.config.dryRun) { return ""; } return this.#execGit("add", args.filter(Boolean)); } /** * Record changes to the repository * * [git-commit Documentation](https://git-scm.com/docs/git-commit) * * @example * ```ts * await git.commit("--message", "chore(release): 1.2.3"); * ``` */ async commit(...args) { if (this.config.dryRun) { return ""; } return this.#execGit("commit", args.filter(Boolean)); } /** * Create, list, delete or verify a tag object * * [git-tag Documentation](https://git-scm.com/docs/git-tag) * * @example * ```ts * await git.tag("--annotate", "v1.2.3", "--message", "chore(release): 1.2.3"); * ``` */ async tag(...args) { if (this.config.dryRun) { return ""; } return this.#execGit("tag", args.filter(Boolean)); } /** * Show commit logs * * - [git-log Documentation](https://git-scm.com/docs/git-log) * - [pretty-formats Documentation](https://git-scm.com/docs/pretty-formats) * * @example * ```ts * await git.log("--oneline"); * ``` */ async log(...args) { try { return await this.#execGit("log", args.filter(Boolean)); } catch { return ""; } } /** * Check if a file is ignored by git * * [git-check-ignore Documentation](https://git-scm.com/docs/git-check-ignore) * * @example * ```ts * await git.isIgnored("src/my-file.txt"); * ``` */ async isIgnored(file) { try { await this.#execGit("check-ignore", ["--no-index", file]); return true; } catch (_error) { return false; } } /** * Get the name of the current branch * * [git-rev-parse Documentation](https://git-scm.com/docs/git-rev-parse) * * @example * ```ts * await git.getBranchName(); // "main" * ``` */ async getBranchName() { try { const branchName = await this.#execGit("rev-parse", ["--abbrev-ref", "HEAD"]); return branchName.trim(); } catch { return ""; } } /** * Get the URL of the remote repository * * [git-config Documentation](https://git-scm.com/docs/git-config) * * @example * ```ts * await git.getRemoteUrl(); // "https://github.com/eglavin/fork-version" * ``` */ async getRemoteUrl() { try { const remoteUrl = await this.#execGit("config", ["--get", "remote.origin.url"]); return remoteUrl.trim(); } catch (_error) { return ""; } } /** * `getTags` returns valid semver version tags in order of the commit history * * Using `git log` to get the commit history, we then parse the tags from the * commit details which is expected to be in the following format: * ```txt * commit 3841b1d05750d42197fe958e3d8e06df378a842d (HEAD -> main, tag: v1.0.2, tag: v1.0.1, tag: v1.0.0) * Author: Username <username@example.com> * Date: Sat Nov 9 15:00:00 2024 +0000 * * chore(release): v1.0.0 * ``` * * - [Functionality extracted from the conventional-changelog - git-semver-tags project](https://github.com/conventional-changelog/conventional-changelog/blob/fac8045242099c016f5f3905e54e02b7d466bd7b/packages/git-semver-tags/index.js) * - [conventional-changelog git-semver-tags MIT Licence](https://github.com/conventional-changelog/conventional-changelog/blob/fac8045242099c016f5f3905e54e02b7d466bd7b/packages/git-semver-tags/LICENSE.md) * * @example * ```ts * await git.getTags("v"); // ["v1.0.2", "v1.0.1", "v1.0.0"] * ``` */ async getTags(tagPrefix) { const logOutput = await this.log("--decorate", "--no-color", "--date-order"); const TAG_REGEX = /tag:\s*(?<tag>.+?)[,)]/gi; const tags = []; let tagMatch = null; while (tagMatch = TAG_REGEX.exec(logOutput)) { const { tag = "" } = tagMatch.groups ?? {}; if (tagPrefix) { if (tag.startsWith(tagPrefix)) { const tagWithoutPrefix = tag.replace(new RegExp(`^${tagPrefix}`), ""); if (semver__default.default.valid(tagWithoutPrefix)) { tags.push(tag); } } } else if (/^\d/.test(tag) && semver__default.default.valid(tag)) { tags.push(tag); } } return tags; } /** * Returns the latest git tag based on commit date * * @example * ```ts * await git.getMostRecentTag("v"); // "1.2.3" * ``` */ async getMostRecentTag(tagPrefix) { const tags = await this.getTags(tagPrefix); return tags[0] || void 0; } /** * Get cleaned semver tags, with any tag prefix's removed * * @example * ```ts * await git.getCleanedTags("v"); // ["1.2.3", "1.2.2", "1.2.1"] * ``` */ async getCleanedTags(tagPrefix) { const tags = await this.getTags(tagPrefix); const cleanedTags = []; for (const tag of tags) { const tagWithoutPrefix = tag.replace(new RegExp(`^${tagPrefix}`), ""); const cleanedTag = semver__default.default.clean(tagWithoutPrefix); if (cleanedTag) { cleanedTags.push(cleanedTag); } } return cleanedTags; } /** * Get the highest semver version from git tags. This will return the highest * semver version found for the given tag prefix, regardless of the commit date. * * @example * ```ts * await git.getHighestSemverVersionFromTags("v"); // "1.2.3" * ``` */ async getHighestSemverVersionFromTags(tagPrefix) { const cleanedTags = await this.getCleanedTags(tagPrefix); return cleanedTags.sort(semver__default.default.rcompare)[0] || void 0; } /** * Get commit history in a parsable format * * An array of strings with commit details is returned in the following format: * ```txt * subject * body * hash * committer date * committer name * committer email * ``` * * @example * ```ts * await git.getCommits("v1.0.0", "HEAD", "src/utils"); * ``` */ async getCommits(from = "", to = "HEAD", ...paths) { const SCISSOR = "^----------- FORK VERSION -----------^"; const LOG_FORMAT = [ "%s", // subject "%b", // body "%H", // hash "%cI", // committer date "%cN", // committer name "%cE", // committer email SCISSOR ].join("%n"); const commits = await this.log( `--format=${LOG_FORMAT}`, [from, to].filter(Boolean).join(".."), paths.length ? "--" : "", ...paths ); const splitCommits = commits.split(` ${SCISSOR} `); if (splitCommits.length === 0) { return splitCommits; } if (splitCommits[0] === SCISSOR) { splitCommits.shift(); } if (splitCommits[splitCommits.length - 1] === "") { splitCommits.pop(); } return splitCommits; } }; function getChangelogPresetConfig(mergedConfig, cliArguments, detectedGitHost) { const preset = { name: "conventionalcommits" }; if (typeof conventionalChangelogConfigSpec__default.default.properties === "object") { Object.entries(conventionalChangelogConfigSpec__default.default.properties).forEach(([key, value]) => { if ("default" in value && value.default !== void 0) { if (mergedConfig?.changelogAll && key === "types") { const parsedTypes = zod.z.array(ChangelogPresetConfigTypeSchema).safeParse(value.default); if (parsedTypes.success) { parsedTypes.data.forEach((type) => { if (!type.section) { delete type.hidden; type.section = "Other Changes"; } }); preset[key] = parsedTypes.data; return; } } preset[key] = value.default; } }); } if (detectedGitHost) { Object.entries(detectedGitHost).forEach(([key, value]) => { if (value !== void 0) { preset[key] = value; } }); } if (mergedConfig?.changelogPresetConfig && typeof mergedConfig.changelogPresetConfig === "object") { Object.entries(mergedConfig.changelogPresetConfig).forEach(([key, value]) => { if (value !== void 0) { preset[key] = value; } }); } if (mergedConfig?.releaseMessageSuffix && !cliArguments?.releaseMessageSuffix) { preset.releaseCommitMessageFormat = `${preset.releaseCommitMessageFormat} ${mergedConfig.releaseMessageSuffix}`; } if (cliArguments?.commitUrlFormat) { preset.commitUrlFormat = cliArguments.commitUrlFormat; } if (cliArguments?.compareUrlFormat) { preset.compareUrlFormat = cliArguments.compareUrlFormat; } if (cliArguments?.issueUrlFormat) { preset.issueUrlFormat = cliArguments.issueUrlFormat; } if (cliArguments?.userUrlFormat) { preset.userUrlFormat = cliArguments.userUrlFormat; } if (cliArguments?.releaseCommitMessageFormat) { preset.releaseCommitMessageFormat = cliArguments.releaseCommitMessageFormat; } if (cliArguments?.releaseMessageSuffix) { preset.releaseCommitMessageFormat = `${preset.releaseCommitMessageFormat} ${cliArguments.releaseMessageSuffix}`; } return ChangelogPresetConfigSchema.passthrough().parse(preset); } // src/config/defaults.ts var DEFAULT_CONFIG = { // Commands command: "main", // Options files: [ "package.json", "package-lock.json", "npm-shrinkwrap.json", "jsr.json", "jsr.jsonc", "deno.json", "deno.jsonc", "manifest.json", // Chrome extensions "bower.json" ], path: process.cwd(), changelog: "CHANGELOG.md", header: `# Changelog All notable changes to this project will be documented in this file. See [fork-version](https://github.com/eglavin/fork-version) for commit guidelines. `, tagPrefix: "v", // Flags allowMultipleVersions: true, commitAll: false, changelogAll: false, debug: false, dryRun: false, silent: false, gitTagFallback: true, sign: false, verify: false, // Skip Steps skipBump: false, skipChangelog: false, skipCommit: false, skipTag: false, changelogPresetConfig: {} }; // src/config/detect-git-host.ts async function detectGitHost(cwd) { const remoteUrl = await new Git({ path: cwd, dryRun: false }).getRemoteUrl(); if (remoteUrl.startsWith("https://") && remoteUrl.includes("@dev.azure.com/")) { const match = /^https:\/\/(?<atorganisation>.*?)@dev.azure.com\/(?<organisation>.*?)\/(?<project>.*?)\/_git\/(?<repository>.*?)(?:\.git)?$/.exec( remoteUrl ); if (match?.groups) { const { organisation = "", project = "", repository = "" } = match.groups; return { detectedGitHost: "Azure", commitUrlFormat: `{{host}}/${organisation}/${project}/_git/${repository}/commit/{{hash}}`, compareUrlFormat: `{{host}}/${organisation}/${project}/_git/${repository}/branchCompare?baseVersion=GT{{previousTag}}&targetVersion=GT{{currentTag}}`, issueUrlFormat: `{{host}}/${organisation}/${project}/_workitems/edit/{{id}}` }; } } else if (remoteUrl.startsWith("git@ssh.dev.azure.com:")) { const match = /^git@ssh.dev.azure.com:v\d\/(?<organisation>.*?)\/(?<project>.*?)\/(?<repository>.*?)(?:\.git)?$/.exec( remoteUrl ); if (match?.groups) { const { organisation = "", project = "", repository = "" } = match.groups; return { detectedGitHost: "Azure", commitUrlFormat: `{{host}}/${organisation}/${project}/_git/${repository}/commit/{{hash}}`, compareUrlFormat: `{{host}}/${organisation}/${project}/_git/${repository}/branchCompare?baseVersion=GT{{previousTag}}&targetVersion=GT{{currentTag}}`, issueUrlFormat: `{{host}}/${organisation}/${project}/_workitems/edit/{{id}}` }; } } return null; } var PACKAGE_JSON_CONFIG_KEY = "fork-version"; async function loadConfigFile(cwd) { const joycon = new JoyCon__default.default({ cwd, packageKey: PACKAGE_JSON_CONFIG_KEY, stopDir: path.parse(cwd).root }); const configFilePath = await joycon.resolve([ "fork.config.ts", "fork.config.js", "fork.config.cjs", "fork.config.mjs", "fork.config.json", "package.json" ]); if (!configFilePath) { return {}; } if (configFilePath.endsWith("json")) { const fileContent2 = JSON.parse(fs.readFileSync(configFilePath).toString()); if (configFilePath.endsWith("package.json")) { if (fileContent2[PACKAGE_JSON_CONFIG_KEY] && typeof fileContent2[PACKAGE_JSON_CONFIG_KEY] === "object") { const parsed3 = ForkConfigSchema.partial().safeParse(fileContent2[PACKAGE_JSON_CONFIG_KEY]); if (!parsed3.success) { throw new Error(`Validation error in: ${configFilePath}`, { cause: parsed3.error }); } return parsed3.data; } return {}; } const parsed2 = ForkConfigSchema.partial().safeParse(fileContent2); if (!parsed2.success) { throw new Error(`Validation error in: ${configFilePath}`, { cause: parsed2.error }); } return parsed2.data; } const fileContent = await bundleRequire.bundleRequire({ filepath: configFilePath }); const parsed = ForkConfigSchema.partial().safeParse(fileContent.mod.default || fileContent.mod); if (!parsed.success) { throw new Error(`Validation error in: ${configFilePath}`, { cause: parsed.error }); } return parsed.data; } // src/config/merge-files.ts function mergeFiles(configFiles, cliFiles, globResults) { const listOfFiles = /* @__PURE__ */ new Set(); if (Array.isArray(configFiles)) { configFiles.forEach((file) => listOfFiles.add(file)); } if (Array.isArray(cliFiles)) { cliFiles.forEach((file) => listOfFiles.add(file)); } globResults.forEach((file) => listOfFiles.add(file)); if (listOfFiles.size) { return Array.from(listOfFiles); } return DEFAULT_CONFIG.files; } // src/config/user-config.ts async function getUserConfig(cliArguments) { const cwd = cliArguments.flags.path ? path.resolve(cliArguments.flags.path) : process.cwd(); const configFile = await loadConfigFile(cwd); const mergedConfig = { ...DEFAULT_CONFIG, ...configFile, ...cliArguments.flags }; let globResults = []; if (mergedConfig.glob) { globResults = await glob.glob(mergedConfig.glob, { cwd, ignore: ["node_modules/**"], nodir: true }); } const files = mergeFiles(configFile?.files, cliArguments.flags.files, globResults); const detectedGitHost = await detectGitHost(cwd); const changelogPresetConfig = getChangelogPresetConfig( mergedConfig, cliArguments.flags, detectedGitHost ); let command = DEFAULT_CONFIG.command; if (cliArguments.input.length > 0 && cliArguments.input[0].trim()) { command = cliArguments.input[0].trim().toLowerCase(); } else if (mergedConfig.command.trim()) { command = mergedConfig.command.trim().toLowerCase(); } if (mergedConfig.inspectVersion) { command = "inspect-version"; } const shouldBeSilent = ![DEFAULT_CONFIG.command].includes(command); return { ...mergedConfig, command, files, path: cwd, preRelease: ( // Meow doesn't support multiple flags with the same name, so we need to check both. cliArguments.flags.preReleaseTag ?? cliArguments.flags.preRelease ?? configFile.preRelease ), silent: shouldBeSilent || mergedConfig.silent, changelogPresetConfig }; } // src/services/logger.ts var Logger = class { constructor(config) { this.config = config; this.log = this.log.bind(this); this.warn = this.warn.bind(this); this.error = this.error.bind(this); this.debug = this.debug.bind(this); this.disableLogs = this.config.silent; } disableLogs = false; log(...messages) { if (!this.disableLogs) { console.log(...messages); } } warn(...messages) { if (!this.disableLogs) { console.warn(...messages); } } error(...messages) { if (!this.disableLogs) { console.error(...messages); } } debug(...messages) { if (this.config.debug && !this.disableLogs) { console.debug(...messages); } } }; function fileExists(filePath) { try { return fs.lstatSync(filePath).isFile(); } catch (_error) { return false; } } // src/files/json-package.ts var JSONPackage = class { constructor(config, logger) { this.config = config; this.logger = logger; } /** Options for parsing JSON and JSONC files. */ PARSE_OPTIONS = { allowEmptyContent: false, allowTrailingComma: true, disallowComments: false }; /** * Sets a new string value at the given path in a JSON or JSONC string. * @param jsonc the JSON or JSONC string (the contents of a file) * @param jsonPath path to the value to set, as an array of segments * @param newString string to set the value to * @returns the JSON or JSONC string with the value set */ setStringInJsonc(jsonc, jsonPath, newString) { const edits = jsoncParser.modify(jsonc, jsonPath, newString, {}); return jsoncParser.applyEdits(jsonc, edits); } read(fileName) { const filePath = path.resolve(this.config.path, fileName); if (fileExists(filePath)) { const fileContents = fs.readFileSync(filePath, "utf8"); const parseErrors = []; const parsedJson = jsoncParser.parse(fileContents, parseErrors, this.PARSE_OPTIONS); if (parseErrors.length) { this.logger.warn(`[File Manager] Unable to parse JSON: ${fileName}`, parseErrors); return void 0; } if (parsedJson?.version) { return { name: fileName, path: filePath, version: parsedJson.version, isPrivate: typeof parsedJson?.private === "boolean" ? parsedJson.private : true }; } this.logger.warn(`[File Manager] Unable to determine json version: ${fileName}`); } } write(fileState, newVersion) { let fileContents = fs.readFileSync(fileState.path, "utf8"); const parseErrors = []; const parsedJson = jsoncParser.parse(fileContents, parseErrors, this.PARSE_OPTIONS); if (parseErrors.length) { this.logger.warn(`[File Manager] Unable to parse JSON: ${fileState.path}`, parseErrors); return; } fileContents = this.setStringInJsonc(fileContents, ["version"], newVersion); if (parsedJson?.packages?.[""]) { fileContents = this.setStringInJsonc(fileContents, ["packages", "", "version"], newVersion); } fs.writeFileSync(fileState.path, fileContents, "utf8"); } isSupportedFile(fileName) { return fileName.endsWith(".json") || fileName.endsWith(".jsonc"); } }; var YAMLPackage = class { constructor(config, logger) { this.config = config; this.logger = logger; } /** * If the version is returned with a "+" symbol in the value then the version might be from a * flutter `pubspec.yaml` file, if so we want to retain the input builderNumber by splitting it * and joining it again later. */ handleBuildNumber(fileVersion) { const [version, builderNumber] = fileVersion.split("+"); if (/^\d+$/.test(builderNumber)) { return { version, builderNumber }; } return { version: fileVersion }; } read(fileName) { const filePath = path.resolve(this.config.path, fileName); if (fileExists(filePath)) { const fileContents = fs.readFileSync(filePath, "utf-8"); const fileVersion = yaml.parse(fileContents)?.version; if (fileVersion) { const parsedVersion = this.handleBuildNumber(fileVersion); return { name: fileName, path: filePath, version: parsedVersion.version || "", builderNumber: parsedVersion.builderNumber ?? void 0 }; } } this.logger.warn(`[File Manager] Unable to determine yaml version: ${fileName}`); } write(fileState, newVersion) { const fileContents = fs.readFileSync(fileState.path, "utf8"); const yamlDocument = yaml.parseDocument(fileContents); let newFileVersion = newVersion; if (fileState.builderNumber !== void 0) { newFileVersion += `+${fileState.builderNumber}`; } yamlDocument.set("version", newFileVersion); fs.writeFileSync(fileState.path, yamlDocument.toString(), "utf8"); } isSupportedFile(fileName) { return fileName.endsWith(".yaml") || fileName.endsWith(".yml"); } }; var PlainText = class { constructor(config, logger) { this.config = config; this.logger = logger; } read(fileName) { const filePath = path.resolve(this.config.path, fileName); if (fileExists(filePath)) { const fileContents = fs.readFileSync(filePath, "utf8"); return { name: fileName, path: filePath, version: fileContents || "" }; } this.logger.warn(`[File Manager] Unable to determine plain text version: ${fileName}`); } write(fileState, newVersion) { fs.writeFileSync(fileState.path, newVersion, "utf8"); } isSupportedFile(fileName) { return fileName.endsWith("version.txt"); } }; var MSBuildProject = class { constructor(config, logger) { this.config = config; this.logger = logger; } read(fileName) { const filePath = path.resolve(this.config.path, fileName); if (fileExists(filePath)) { const fileContents = fs.readFileSync(filePath, "utf8"); const $ = cheerio__namespace.load(fileContents, { xmlMode: true, xml: { decodeEntities: false } }); const version = $("Project > PropertyGroup > Version").text(); if (version) { return { name: fileName, path: filePath, version }; } this.logger.warn(`[File Manager] Unable to determine ms-build version: ${fileName}`); } } write(fileState, newVersion) { const fileContents = fs.readFileSync(fileState.path, "utf8"); const $ = cheerio__namespace.load(fileContents, { xmlMode: true, xml: { decodeEntities: false } }); $("Project > PropertyGroup > Version").text(newVersion); const updatedContent = $.xml().replaceAll('"/>', '" />'); fs.writeFileSync(fileState.path, updatedContent, "utf8"); } isSupportedFile(fileName) { return [".csproj", ".dbproj", ".esproj", ".fsproj", ".props", ".vbproj", ".vcxproj"].findIndex( (ext) => fileName.endsWith(ext) ) !== -1; } }; var ARMBicep = class { constructor(config, logger) { this.config = config; this.logger = logger; } /** https://regex101.com/r/Lriphb/2 */ metadataRegex = /(metadata contentVersion *= *['"])(?<version>[^'"]+)(['"])/; /** https://regex101.com/r/iKCTF9/1 */ varRegex = /(var contentVersion(?: string)? *= *['"])(?<version>[^'"]+)(['"])/; read(fileName) { const filePath = path.resolve(this.config.path, fileName); if (fileExists(filePath)) { const fileContents = fs.readFileSync(filePath, "utf8"); const metadataMatch = this.metadataRegex.exec(fileContents); const varMatch = this.varRegex.exec(fileContents); if (metadataMatch?.groups?.version && varMatch?.groups?.version) { return { name: fileName, path: filePath, version: metadataMatch.groups.version }; } if (!metadataMatch) { this.logger.warn( `[File Manager] Missing 'metadata contentVersion' in bicep file: ${fileName}` ); } if (!varMatch) { this.logger.warn(`[File Manager] Missing 'var contentVersion' in bicep file: ${fileName}`); } } } write(fileState, newVersion) { const fileContents = fs.readFileSync(fileState.path, "utf8"); const updatedContent = fileContents.replace(this.metadataRegex, `$1${newVersion}$3`).replace(this.varRegex, `$1${newVersion}$3`); fs.writeFileSync(fileState.path, updatedContent, "utf8"); } isSupportedFile(fileName) { return fileName.endsWith(".bicep"); } }; // src/files/file-manager.ts var FileManager = class { constructor(config, logger) { this.config = config; this.logger = logger; this.JSONPackage = new JSONPackage(config, logger); this.YAMLPackage = new YAMLPackage(config, logger); this.PlainText = new PlainText(config, logger); this.MSBuildProject = new MSBuildProject(config, logger); this.ARMBicep = new ARMBicep(config, logger); } JSONPackage; YAMLPackage; PlainText; MSBuildProject; ARMBicep; /** * Get the state from the given file name. * * @example * ```ts * fileManager.read("package.json"); * ``` * * @returns * ```json * { "name": "package.json", "path": "/path/to/package.json", "version": "1.2.3", "isPrivate": true } * ``` */ read(fileName) { const _fileName = fileName.toLowerCase(); if (this.JSONPackage.isSupportedFile(_fileName)) { return this.JSONPackage.read(fileName); } if (this.YAMLPackage.isSupportedFile(_fileName)) { return this.YAMLPackage.read(fileName); } if (this.PlainText.isSupportedFile(_fileName)) { return this.PlainText.read(fileName); } if (this.MSBuildProject.isSupportedFile(_fileName)) { return this.MSBuildProject.read(fileName); } if (this.ARMBicep.isSupportedFile(_fileName)) { return this.ARMBicep.read(fileName); } this.logger.error(`[File Manager] Unsupported file: ${fileName}`); } /** * Write the new version to the given file. * * @example * ```ts * fileManager.write( * { name: "package.json", path: "/path/to/package.json", version: "1.2.2" }, * "1.2.3" * ); * ``` */ write(fileState, newVersion) { if (this.config.dryRun) { return; } const _fileName = fileState.name.toLowerCase(); if (this.JSONPackage.isSupportedFile(_fileName)) { return this.JSONPackage.write(fileState, newVersion); } if (this.YAMLPackage.isSupportedFile(_fileName)) { return this.YAMLPackage.write(fileState, newVersion); } if (this.PlainText.isSupportedFile(_fileName)) { return this.PlainText.write(fileState, newVersion); } if (this.MSBuildProject.isSupportedFile(_fileName)) { return this.MSBuildProject.write(fileState, newVersion); } if (this.ARMBicep.isSupportedFile(_fileName)) { return this.ARMBicep.write(fileState, newVersion); } this.logger.error(`[File Manager] Unsupported file: ${fileState.path}`); } }; // src/commands/validate-config.ts function validateConfig(config) { console.log(` \u2699\uFE0F Configuration: ${JSON.stringify(config, null, 2)} \u2705 Configuration is valid. `); } async function getCurrentVersion(config, logger, git, fileManager, filesToUpdate) { const files = []; const versions = /* @__PURE__ */ new Set(); for (const file of filesToUpdate) { if (await git.isIgnored(file)) { logger.debug(`[Git Ignored] ${file}`); continue; } const fileState = fileManager.read(file); if (fileState) { files.push(fileState); if (!config.currentVersion) { versions.add(fileState.version); } } } if (config.currentVersion) { versions.add(config.currentVersion); } if (versions.size === 0 && config.gitTagFallback) { const version = await git.getHighestSemverVersionFromTags(config.tagPrefix); if (version) { logger.warn(`Using latest git tag as fallback`); versions.add(version); } } if (versions.size === 0) { throw new Error("Unable to find current version"); } else if (versions.size > 1) { if (!config.allowMultipleVersions) { throw new Error("Found multiple versions"); } logger.warn( `Found multiple versions (${Array.from(versions).join(", ")}), using the higher semver version` ); } const currentVersion = semver__default.default.rsort(Array.from(versions))[0]; logger.log(`Current version: ${currentVersion}`); return { files, version: currentVersion }; } // src/commands/inspect-version.ts async function inspectVersion(config, logger, fileManager, git) { let foundVersion = ""; try { const currentVersion = await getCurrentVersion(config, logger, git, fileManager, config.files); if (currentVersion) foundVersion = currentVersion.version; } catch { } console.log(foundVersion); } // src/commands/inspect-tag.ts async function inspectTag(config, git) { const tag = await git.getMostRecentTag(config.tagPrefix); console.log(tag ?? ""); } // src/utils/trim-string-array.ts function trimStringArray(array) { const items = []; if (Array.isArray(array)) { for (const item of array) { const _item = item.trim(); if (_item) { items.push(_item); } } } if (items.length === 0) { return void 0; } return items; } // src/commit-parser/options.ts function createParserOptions(userOptions) { const referenceActions = trimStringArray(userOptions?.referenceActions) ?? [ "close", "closes", "closed", "fix", "fixes", "fixed", "resolve", "resolves", "resolved" ]; const joinedReferenceActions = referenceActions.join("|"); const issuePrefixes = trimStringArray(userOptions?.issuePrefixes) ?? ["#"]; const joinedIssuePrefixes = issuePrefixes.join("|"); const noteKeywords = trimStringArray(userOptions?.noteKeywords) ?? [ "BREAKING CHANGE", "BREAKING-CHANGE" ]; const joinedNoteKeywords = noteKeywords.join("|"); return { subjectPattern: /^(?<type>\w+)(?:\((?<scope>.*)\))?(?<breakingChange>!)?:\s+(?<title>.*)/, mergePattern: /^Merge pull request #(?<id>\d*) from (?<source>.*)/, revertPattern: /^[Rr]evert "(?<subject>.*)"(\s*This reverts commit (?<hash>[a-zA-Z0-9]*)\.)?/, commentPattern: /^#(?!\d+\s)/, mentionPattern: /(?<!\w)@(?<username>[\w-]+)/, referenceActions, referenceActionPattern: joinedReferenceActions ? new RegExp( `(?<action>${joinedReferenceActions})(?:\\s+(?<reference>.*?))(?=(?:${joinedReferenceActions})|$)` ) : void 0, issuePrefixes, issuePattern: joinedIssuePrefixes ? new RegExp( `(?:.*?)??\\s*(?<repository>[\\w-\\.\\/]*?)??(?<prefix>${joinedIssuePrefixes})(?<issue>[\\w-]*\\d+)` ) : void 0, noteKeywords, notePattern: joinedNoteKeywords ? new RegExp(`^(?<title>${joinedNoteKeywords}):(\\s*(?<text>.*))`) : void 0, // Override defaults with user options ...userOptions }; } // src/commit-parser/parser-error.ts var ParserError = class extends Error { detail; constructor(message, detail) { super(message); this.name = "ParserError"; this.detail = detail; } }; // src/commit-parser/commit-parser.ts var CommitParser = class { #options; #logger; constructor(userOptions) { this.#options = createParserOptions(userOptions); this.setLogger = this.setLogger.bind(this); this.createCommit = this.createCommit.bind(this); this.parseRawCommit = this.parseRawCommit.bind(this); this.parseSubject = this.parseSubject.bind(this); this.parseMerge = this.parseMerge.bind(this); this.parseRevert = this.parseRevert.bind(this); this.parseMentions = this.parseMentions.bind(this); this.parseReferenceParts = this.parseReferenceParts.bind(this); this.parseReferences = this.parseReferences.bind(this); this.parseNotes = this.parseNotes.bind(this); this.parseRawLines = this.parseRawLines.bind(this); this.parse = this.parse.bind(this); } setLogger(logger) { this.#logger = logger; return this; } createCommit() { return { raw: "", subject: "", body: "", hash: "", date: "", name: "", email: "", type: "", scope: "", breakingChange: "", title: "", merge: null, revert: null, notes: [], mentions: [], references: [] }; } /** * Parse the raw commit message into its expected parts * - subject * - body * - hash * - date * - name * - email * * @throws {ParserError} */ parseRawCommit(rawCommit) { const parsedCommit = this.createCommit(); const parts = rawCommit.split(/\r?\n/); if (parts.length < 6) { throw new ParserError("Commit doesn't contain enough parts", rawCommit); } const email = parts.pop(); const name = parts.pop(); const date = parts.pop(); const hash = parts.pop(); if (email) parsedCommit.email = email.trim(); if (name) parsedCommit.name = name.trim(); if (date) { parsedCommit.date = date.trim(); if (Number.isNaN(Date.parse(parsedCommit.date))) { throw new ParserError("Unable to parse commit date", rawCommit); } } if (hash) parsedCommit.hash = hash.trim(); const subject = parts.shift()?.trimStart(); if (subject) { parsedCommit.subject = subject; parsedCommit.raw = subject; } parsedCommit.body = parts.filter((line) => { if (this.#options.commentPattern) { return !this.#options.commentPattern.test(line.trim()); } return true; }).join("\n").trim(); const raw = parts.join("\n").trim(); if (raw) parsedCommit.raw += "\n" + raw; return parsedCommit; } /** * Parse the commit subject into its expected parts * - type * - scope (optional) * - breaking change (optional) * - title * * @throws {ParserError} */ parseSubject(commit) { if (!this.#options.subjectPattern) return false; const subjectMatch = new RegExp(this.#options.subjectPattern, "i").exec(commit.subject); if (subjectMatch?.groups) { const { type = "", scope = "", breakingChange = "", title = "" } = subjectMatch.groups; if (!type || !title) { throw new ParserError("Unable to parse commit subject", commit); } commit.type = type; commit.scope = scope; if (breakingChange) commit.breakingChange = breakingChange; commit.title = title; return true; } return false; } /** * Parse merge information from the commit subject * @example * ```txt * "Merge pull request #123 from fork-version/feature" * ``` */ parseMerge(commit) { if (!this.#options.mergePattern) return false; const mergeMatch = new RegExp(this.#options.mergePattern).exec(commit.subject); if (mergeMatch?.groups) { const { id = "", source = "" } = mergeMatch.groups; commit.merge = { id, source }; return true; } return false; } /** * Parse revert information from the commit body * @example * ```txt * "Revert "feat: initial commit" * * This reverts commit 4a79e9e546b4020d2882b7810dc549fa71960f4f." * ``` */ parseRevert(commit) { if (!this.#options.revertPattern) return false; const revertMatch = new RegExp(this.#options.revertPattern).exec(commit.raw); if (revertMatch?.groups) { const { hash = "", subject = "" } = revertMatch.groups; commit.revert = { hash, subject }; return true; } return false; } /** * Search for mentions from the commit line * @example * ```txt * "@fork-version" * ``` */ parseMentions(line, outMentions) { if (!this.#options.mentionPattern) return false; const mentionRegex = new RegExp(this.#options.mentionPattern, "g"); let foundMention = false; let mentionMatch; while (mentionMatch = mentionRegex.exec(line)) { if (!mentionMatch) { break; } const { username = "" } = mentionMatch.groups ?? {}; outMentions.add(username); foundMention = true; } return foundMention; } /** * Search for references from the commit line * @example * ```txt * "#1234" * "owner/repo#1234" * ``` */ parseReferenceParts(referenceText, action) { if (!this.#options.issuePattern) return void 0; const references = []; const issueRegex = new RegExp(this.#options.issuePattern, "gi"); let issueMatch; while (issueMatch = issueRegex.exec(referenceText)) { if (!issueMatch) { break; } const { repository = "", prefix = "", issue = "" } = issueMatch.groups ?? {}; const reference = { prefix, issue, action, owner: null, repository: null }; if (repository) { const slashIndex = repository.indexOf("/"); if (slashIndex !== -1) { reference.owner = repository.slice(0, slashIndex); reference.repository = repository.slice(slashIndex + 1); } else { reference.reposi