yaba-release-cli
Version:
Yaba is a simple CLI tool that helps you manage releases of your Github projects.
297 lines (268 loc) • 11.2 kB
JavaScript
import yargs from "yargs";
import { createRequire } from "module";
const require = createRequire(import.meta.url);
const packageInfo = require("../../package.json");
import { hideBin } from 'yargs/helpers';
const rawArguments = hideBin(process.argv);
const commands = yargs(rawArguments)
.scriptName("yaba")
.usage("Usage: yaba <release|doctor|config> [options]")
.command("release create", "Create a GitHub release for a repository")
.command("release preview", "Preview release details without creating a GitHub release")
.command("release list", "List GitHub releases for a repository")
.command("release hotfix", "Create a hotfix GitHub release")
.command("doctor", "Run environment and connectivity diagnostics")
.command("config init", "Create yaba.config.json in the current directory")
.command("config validate", "Validate resolved yaba configuration")
.option("o", {alias: "owner", describe: "The repository owner.", type: "string"})
.option("r", {alias: "repo", describe: "The repository name.", type: "string"})
.option("t", {alias: "tag", describe: "The name of the tag.", type: "string"})
.option("tag-strategy", {
describe: "Tag generation strategy.",
choices: ["pattern", "semver", "sha"],
defaultDescription: "from config (fallback: pattern)",
type: "string"
})
.option("tag-on-conflict", {
describe: "How to handle already existing tags.",
choices: ["increment", "fail"],
defaultDescription: "from config (fallback: increment)",
type: "string"
})
.option("tag-max-attempts", {
describe: "Maximum attempts while resolving a unique tag with increment policy.",
defaultDescription: "from config (fallback: 20)",
type: "number"
})
.option("target", {
describe: "Target commit-ish (branch, tag, or SHA) to generate and create the release from.",
type: "string"
})
.option("allow-empty", {
describe: "Allow creating a release when no commits are found in changelog comparison.",
type: "boolean",
default: false
})
.option("fail-on-empty", {
describe: "Fail with non-zero exit code when no commits are found in changelog comparison.",
type: "boolean",
default: false
})
.option("max-commits", {
describe: "Fail when commit count exceeds this limit.",
type: "number"
})
.option("no-status-checks", {
describe: "Skip the pre-release GitHub status checks gate. By default, yaba verifies that all " +
"commit status checks and GitHub Actions check runs on the target ref have completed " +
"successfully before proceeding with a release. Pending checks indicate that CI is still " +
"running against the target, and failed checks indicate that something is broken — both " +
"block the release by default. Use this flag to bypass that gate, for example in automated " +
"pipelines where the release workflow itself is the only relevant check, or when you " +
"intentionally release from a ref with known non-blocking check results. Can also be set " +
"permanently via 'noStatusChecks: true' in your yaba.config.json.",
type: "boolean",
default: false
})
.option("n", {
alias: ["name", "release-name"],
describe: "The name of the release.",
type: "string"
})
.option("b", {
alias: "body",
describe: "Text describing the contents of the tag. If not provided, the default changelog " +
"will be generated with the usage of the difference of default branch and latest release.",
type: "string"
})
.option("d", {
alias: "draft",
describe: "Creates the release as draft.",
type: "boolean"
})
.option("c", {
alias: "changelog",
describe: "Shows only changelog without creating the release.",
type: "boolean"
})
.option("i", {
alias: "interactive",
describe: "Prompt before (draft) release is created (default true)",
type: "boolean"
})
.option("yes", {
describe: "DEPRECATED: Skip confirmation prompt and create release directly. Use --no-prompt.",
type: "boolean",
default: false
})
.option("no-prompt", {
describe: "Skip release confirmation prompt (same behavior as --yes).",
type: "boolean",
default: false
})
.option("notify", {
describe: "DEPRECATED: Send notifications after release is created. Use --publish.",
choices: ["slack"],
type: "string"
})
.option("p", {
alias: "publish",
describe: "Publishes the release announcement to the defined Slack channel",
type: "boolean"
})
.option("notifications", {
describe: "Preview the notification message for the given provider.",
choices: ["slack", "github"],
type: "string"
})
.option("limit", {
describe: "Number of releases to list (0 = all).",
type: "number"
})
.option("format", {
describe: "Output format.",
choices: ["human", "json"],
type: "string"
})
.option("config", {
describe: "Path to config file.",
type: "string"
})
.option("force", {
describe: "Overwrite generated files when they already exist.",
type: "boolean",
default: false
})
.alias('h', 'help')
.help('help')
.alias('v', 'version')
.version(packageInfo.version)
.parseSync();
const normalizedOptions = normalizeOptions(commands);
function normalizeOptions(parsed) {
const normalized = { ...parsed };
const noPromptProvided = wasFlagProvided("--no-prompt");
const yesProvided = wasFlagProvided("--yes");
const noPrompt = noPromptProvided || yesProvided;
const interactiveProvided = wasFlagProvided("--interactive") || wasFlagProvided("-i");
const publishProvided = wasFlagProvided("--publish") || wasFlagProvided("-p");
const draftProvided = wasFlagProvided("--draft") || wasFlagProvided("-d");
const formatProvided = wasFlagProvided("--format");
const maxCommitsProvided = wasFlagProvided("--max-commits");
const tagMaxAttemptsProvided = wasFlagProvided("--tag-max-attempts");
const commandName = resolveCommand(parsed);
normalized.releaseName = parsed.name ?? parsed["release-name"];
normalized.publish = parsed.notify === "slack"
? true
: publishProvided
? parsed.publish === true
: undefined;
normalized.interactive = noPrompt
? false
: interactiveProvided
? parsed.interactive
: undefined;
normalized.draft = draftProvided ? parsed.draft === true : undefined;
normalized.commandName = commandName;
normalized.releaseCommand = commandName?.startsWith("release.")
? commandName.split(".")[1]
: null;
normalized.outputFormat = formatProvided ? parsed.format : undefined;
normalized.configPath = parsed.config;
normalized.target = parsed.target;
normalized.tagStrategy = parsed["tag-strategy"];
normalized.tagOnConflict = parsed["tag-on-conflict"];
normalized.tagMaxAttempts = tagMaxAttemptsProvided ? parsed["tag-max-attempts"] : undefined;
normalized.allowEmpty = parsed["allow-empty"] === true;
normalized.failOnEmpty = parsed["fail-on-empty"] === true;
normalized.maxCommits = maxCommitsProvided ? parsed["max-commits"] : undefined;
normalized.noStatusChecks = wasFlagProvided("--no-status-checks") ? true : undefined;
normalized.releaseListLimit = typeof parsed.limit === 'number' ? parsed.limit : undefined;
normalized.notifications = parsed.notifications;
normalized.deprecationWarnings = collectDeprecationWarnings(parsed, commandName, yesProvided);
return normalized;
}
function collectDeprecationWarnings(parsed, commandName, yesProvided) {
const warnings = [];
const positional = parsed._ || [];
if (yesProvided) {
warnings.push("Flag '--yes' is deprecated and will be removed in v3. Use '--no-prompt' instead.");
}
if (wasFlagProvided("--notify")) {
warnings.push("Flag '--notify slack' is deprecated and will be removed in v3. Use '--publish' instead.");
}
if (wasFlagProvided("--release-name")) {
warnings.push("Flag '--release-name' is deprecated and will be removed in v3. Use '--name' instead.");
}
if (commandName === "release.create" && positional.length === 0) {
warnings.push("Implicit command invocation ('yaba' => 'yaba release create') is deprecated and will be removed in v3.");
}
return warnings;
}
function resolveCommand(parsed) {
const positional = (parsed._ || []).map(item => `${item}`);
if (positional.length === 0) {
return "release.create";
}
if (positional.length === 1) {
if (positional[0] === "doctor") {
return "doctor";
}
if (positional[0] === "release") {
const releaseAction = resolveReleaseAction(parsed);
if (releaseAction) {
return `release.${releaseAction}`;
}
}
if (positional[0] === "config") {
const configAction = resolveConfigAction(parsed);
if (configAction) {
return `config.${configAction}`;
}
}
}
if (positional.length === 2 && positional[0] === "config") {
if (positional[1] === "init" || positional[1] === "validate") {
return `config.${positional[1]}`;
}
}
if (positional.length === 2 && positional[0] === "release") {
if (positional[1] === "create" || positional[1] === "preview" || positional[1] === "list" || positional[1] === "hotfix") {
return `release.${positional[1]}`;
}
}
return null;
}
function resolveReleaseAction(parsed) {
const candidates = [parsed.preview, parsed.create, parsed.list, parsed.hotfix, parsed.action];
for (const candidate of candidates) {
if (typeof candidate !== "string") {
continue;
}
const normalized = candidate.trim().toLowerCase();
if (normalized === "create" || normalized === "preview" || normalized === "list" || normalized === "hotfix") {
return normalized;
}
}
return null;
}
function resolveConfigAction(parsed) {
const candidates = [parsed.init, parsed.validate, parsed.action];
for (const candidate of candidates) {
if (typeof candidate !== "string") {
continue;
}
const normalized = candidate.trim().toLowerCase();
if (normalized === "init" || normalized === "validate") {
return normalized;
}
}
return null;
}
function isSupportedReleaseCommand(parsed) {
return resolveCommand(parsed) !== null;
}
function wasFlagProvided(flag) {
return rawArguments.some(argument => argument === flag || argument.startsWith(`${flag}=`));
}
export { normalizedOptions as options, isSupportedReleaseCommand };