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
JavaScript
'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