388 lines
11 KiB
TypeScript
388 lines
11 KiB
TypeScript
import { getCliConfigValue } from "./helpers.smartconfig.js";
|
|
|
|
export type TConfirmationMode = "prompt" | "auto" | "plan";
|
|
|
|
export type TCommitStep =
|
|
| "format"
|
|
| "analyze"
|
|
| "test"
|
|
| "build"
|
|
| "changelog"
|
|
| "commit"
|
|
| "push";
|
|
|
|
export type TReleaseTarget = "git" | "npm" | "docker";
|
|
|
|
export interface ICommitWorkflowConfig {
|
|
confirmation?: TConfirmationMode;
|
|
staging?: "all";
|
|
steps?: TCommitStep[];
|
|
alwaysTest?: boolean;
|
|
alwaysBuild?: boolean;
|
|
analyze?: {
|
|
provider?: "ai";
|
|
requireConfirmationFor?: string[];
|
|
};
|
|
test?: {
|
|
command?: string;
|
|
};
|
|
build?: {
|
|
command?: string;
|
|
verifyCleanTree?: boolean;
|
|
};
|
|
push?: {
|
|
remote?: string;
|
|
followTags?: boolean;
|
|
};
|
|
}
|
|
|
|
export interface IReleaseGitTargetConfig {
|
|
enabled?: boolean;
|
|
remote?: string;
|
|
pushBranch?: boolean;
|
|
pushTags?: boolean;
|
|
}
|
|
|
|
export interface IReleaseNpmTargetConfig {
|
|
enabled?: boolean;
|
|
registries?: string[];
|
|
accessLevel?: "public" | "private";
|
|
alreadyPublished?: "success" | "error";
|
|
}
|
|
|
|
export interface IReleaseDockerTargetConfig {
|
|
enabled?: boolean;
|
|
images?: string[];
|
|
}
|
|
|
|
export interface IReleaseWorkflowConfig {
|
|
confirmation?: TConfirmationMode;
|
|
version?: {
|
|
strategy?: "semver";
|
|
source?: "pendingChangelog" | "manual";
|
|
};
|
|
preflight?: {
|
|
requireCleanTree?: boolean;
|
|
test?: boolean;
|
|
build?: boolean;
|
|
testCommand?: string;
|
|
buildCommand?: string;
|
|
};
|
|
targets?: {
|
|
git?: IReleaseGitTargetConfig;
|
|
npm?: IReleaseNpmTargetConfig;
|
|
docker?: IReleaseDockerTargetConfig;
|
|
};
|
|
}
|
|
|
|
export interface IResolvedCommitWorkflow {
|
|
confirmation: TConfirmationMode;
|
|
steps: TCommitStep[];
|
|
staging: "all";
|
|
testCommand: string;
|
|
buildCommand: string;
|
|
changelogFile: "changelog.md";
|
|
changelogSection: "Pending";
|
|
pushRemote: string;
|
|
pushFollowTags: boolean;
|
|
releaseFlagRequested: boolean;
|
|
}
|
|
|
|
export interface IResolvedReleaseWorkflow {
|
|
confirmation: TConfirmationMode;
|
|
plan: string[];
|
|
targets: TReleaseTarget[];
|
|
requireCleanTree: boolean;
|
|
runTests: boolean;
|
|
runBuild: boolean;
|
|
testCommand: string;
|
|
buildCommand: string;
|
|
changelogFile: "changelog.md";
|
|
changelogPendingSection: "Pending";
|
|
changelogVersionHeading: "## {{date}} - {{version}}";
|
|
gitEnabled: boolean;
|
|
gitRemote: string;
|
|
pushBranch: boolean;
|
|
pushTags: boolean;
|
|
npmEnabled: boolean;
|
|
npmRegistries: string[];
|
|
npmAccessLevel: "public" | "private";
|
|
npmAlreadyPublished: "success" | "error";
|
|
dockerEnabled: boolean;
|
|
dockerImages: string[];
|
|
}
|
|
|
|
interface ICliWorkflowConfig {
|
|
commit?: ICommitWorkflowConfig;
|
|
release?: IReleaseWorkflowConfig;
|
|
}
|
|
|
|
const commitFlagToStep: Record<string, TCommitStep | undefined> = {
|
|
f: "format",
|
|
t: "test",
|
|
b: "build",
|
|
p: "push",
|
|
};
|
|
|
|
const unique = <T>(items: T[]): T[] => {
|
|
const result: T[] = [];
|
|
for (const item of items) {
|
|
if (!result.includes(item)) {
|
|
result.push(item);
|
|
}
|
|
}
|
|
return result;
|
|
};
|
|
|
|
const normalizeConfirmation = (
|
|
value: unknown,
|
|
fallback: TConfirmationMode,
|
|
): TConfirmationMode => {
|
|
if (value === "prompt" || value === "auto" || value === "plan") {
|
|
return value;
|
|
}
|
|
return fallback;
|
|
};
|
|
|
|
const normalizeRegistryUrl = (url: string): string => {
|
|
let normalizedUrl = url.trim();
|
|
if (!normalizedUrl.startsWith("http://") && !normalizedUrl.startsWith("https://")) {
|
|
normalizedUrl = `https://${normalizedUrl}`;
|
|
}
|
|
return normalizedUrl.endsWith("/") ? normalizedUrl.slice(0, -1) : normalizedUrl;
|
|
};
|
|
|
|
const isDisabled = (argvArg: any, ...keys: string[]): boolean => {
|
|
return keys.some((key) => argvArg[key] === false || argvArg[`no-${key}`] || argvArg[`no${key[0].toUpperCase()}${key.slice(1)}`]);
|
|
};
|
|
|
|
const readCliWorkflowConfig = async (): Promise<ICliWorkflowConfig> => {
|
|
return await getCliConfigValue<ICliWorkflowConfig>("", {});
|
|
};
|
|
|
|
const getOrderedArgsAfterCommand = (commandName: string): string[] => {
|
|
const rawArgs = process.argv.slice(2);
|
|
const commandIndex = rawArgs.indexOf(commandName);
|
|
if (commandIndex === -1) {
|
|
return rawArgs;
|
|
}
|
|
return rawArgs.slice(commandIndex + 1);
|
|
};
|
|
|
|
const getOrderedShortFlags = (commandName: string): string[] => {
|
|
const orderedFlags: string[] = [];
|
|
for (const arg of getOrderedArgsAfterCommand(commandName)) {
|
|
if (arg === "--") {
|
|
break;
|
|
}
|
|
if (arg.startsWith("--")) {
|
|
continue;
|
|
}
|
|
if (arg.startsWith("-") && arg.length > 1) {
|
|
orderedFlags.push(...arg.slice(1).split(""));
|
|
}
|
|
}
|
|
return orderedFlags;
|
|
};
|
|
|
|
const hasExplicitCommitWorkflowFlags = (argvArg: any): boolean => {
|
|
return Boolean(
|
|
argvArg.f ||
|
|
argvArg.format ||
|
|
argvArg.t ||
|
|
argvArg.test ||
|
|
argvArg.b ||
|
|
argvArg.build ||
|
|
argvArg.p ||
|
|
argvArg.push,
|
|
);
|
|
};
|
|
|
|
const normalizeCommitSteps = (rawSteps: TCommitStep[]): TCommitStep[] => {
|
|
const steps = unique(rawSteps.filter(Boolean));
|
|
const pushRequested = steps.includes("push");
|
|
const prePushSteps = steps.filter((step) => step !== "push");
|
|
|
|
if (!prePushSteps.includes("analyze")) {
|
|
prePushSteps.unshift("analyze");
|
|
}
|
|
|
|
if (!prePushSteps.includes("changelog")) {
|
|
const commitIndex = prePushSteps.indexOf("commit");
|
|
if (commitIndex === -1) {
|
|
prePushSteps.push("changelog");
|
|
} else {
|
|
prePushSteps.splice(commitIndex, 0, "changelog");
|
|
}
|
|
}
|
|
|
|
if (!prePushSteps.includes("commit")) {
|
|
prePushSteps.push("commit");
|
|
}
|
|
|
|
const analyzeIndex = prePushSteps.indexOf("analyze");
|
|
const commitIndex = prePushSteps.indexOf("commit");
|
|
if (analyzeIndex > commitIndex) {
|
|
throw new Error("Commit workflow requires analyze before commit.");
|
|
}
|
|
|
|
const changelogIndex = prePushSteps.indexOf("changelog");
|
|
if (changelogIndex === -1 || changelogIndex > commitIndex) {
|
|
throw new Error("Commit workflow requires changelog before commit.");
|
|
}
|
|
|
|
return pushRequested ? [...prePushSteps, "push"] : prePushSteps;
|
|
};
|
|
|
|
const getTargetOverride = (argvArg: any): TReleaseTarget[] | undefined => {
|
|
const validTargets: TReleaseTarget[] = ["git", "npm", "docker"];
|
|
const rawTargets = argvArg.target || argvArg.targets;
|
|
if (typeof rawTargets === "string") {
|
|
return rawTargets
|
|
.split(",")
|
|
.map((target) => target.trim())
|
|
.filter((target): target is TReleaseTarget => validTargets.includes(target as TReleaseTarget));
|
|
}
|
|
|
|
const targets: TReleaseTarget[] = [];
|
|
if (argvArg.git || argvArg.p || argvArg.push) targets.push("git");
|
|
if (argvArg.npm) targets.push("npm");
|
|
if (argvArg.docker) targets.push("docker");
|
|
return targets.length > 0 ? targets : undefined;
|
|
};
|
|
|
|
const buildReleasePlan = (options: {
|
|
requireCleanTree: boolean;
|
|
runTests: boolean;
|
|
runBuild: boolean;
|
|
targets: TReleaseTarget[];
|
|
}): string[] => {
|
|
const plan: string[] = [];
|
|
if (options.requireCleanTree) plan.push("preflight.cleanTree");
|
|
if (options.runTests) plan.push("preflight.test");
|
|
plan.push("core.version", "core.changelog", "core.commit", "core.tag");
|
|
if (options.runBuild) plan.push("core.build");
|
|
for (const target of options.targets) {
|
|
plan.push(`target.${target}`);
|
|
}
|
|
return plan;
|
|
};
|
|
|
|
export const resolveCommitWorkflow = async (argvArg: any): Promise<IResolvedCommitWorkflow> => {
|
|
const cliConfig = await readCliWorkflowConfig();
|
|
const commitConfig = cliConfig.commit || {};
|
|
const releaseFlagRequested = Boolean(argvArg.r || argvArg.release);
|
|
|
|
let confirmation = normalizeConfirmation(commitConfig.confirmation, "prompt");
|
|
if (argvArg.plan) {
|
|
confirmation = "plan";
|
|
} else if (argvArg.y || argvArg.yes) {
|
|
confirmation = "auto";
|
|
}
|
|
|
|
let rawSteps: TCommitStep[];
|
|
if (hasExplicitCommitWorkflowFlags(argvArg)) {
|
|
const orderedFlags = getOrderedShortFlags("commit");
|
|
rawSteps = ["analyze"];
|
|
for (const shortFlag of orderedFlags) {
|
|
const step = commitFlagToStep[shortFlag];
|
|
if (step) {
|
|
rawSteps.push(step);
|
|
}
|
|
}
|
|
if (argvArg.format && !rawSteps.includes("format")) rawSteps.push("format");
|
|
if (argvArg.test && !rawSteps.includes("test")) rawSteps.push("test");
|
|
if (argvArg.build && !rawSteps.includes("build")) rawSteps.push("build");
|
|
if (argvArg.push && !rawSteps.includes("push")) rawSteps.push("push");
|
|
rawSteps.push("changelog");
|
|
rawSteps.push("commit");
|
|
} else if (Array.isArray(commitConfig.steps) && commitConfig.steps.length > 0) {
|
|
rawSteps = commitConfig.steps;
|
|
} else {
|
|
rawSteps = ["analyze"];
|
|
if (commitConfig.alwaysTest) rawSteps.push("test");
|
|
if (commitConfig.alwaysBuild) rawSteps.push("build");
|
|
rawSteps.push("changelog");
|
|
rawSteps.push("commit");
|
|
}
|
|
|
|
return {
|
|
confirmation,
|
|
steps: normalizeCommitSteps(rawSteps),
|
|
staging: commitConfig.staging || "all",
|
|
testCommand: commitConfig.test?.command || "pnpm test",
|
|
buildCommand: commitConfig.build?.command || "pnpm build",
|
|
changelogFile: "changelog.md",
|
|
changelogSection: "Pending",
|
|
pushRemote: commitConfig.push?.remote || "origin",
|
|
pushFollowTags: commitConfig.push?.followTags || false,
|
|
releaseFlagRequested,
|
|
};
|
|
};
|
|
|
|
export const resolveReleaseWorkflow = async (argvArg: any): Promise<IResolvedReleaseWorkflow> => {
|
|
const cliConfig = await readCliWorkflowConfig();
|
|
const releaseConfig = cliConfig.release || {};
|
|
const targetConfig = releaseConfig.targets || {};
|
|
const gitConfig = targetConfig.git || {};
|
|
const npmConfig = targetConfig.npm || {};
|
|
const dockerConfig = targetConfig.docker || {};
|
|
const npmRegistries = (npmConfig.registries || []).map(normalizeRegistryUrl);
|
|
const npmEnabled = npmConfig.enabled ?? npmRegistries.length > 0;
|
|
const gitEnabled = gitConfig.enabled ?? true;
|
|
const dockerEnabled = dockerConfig.enabled ?? false;
|
|
|
|
let confirmation = normalizeConfirmation(releaseConfig.confirmation, "prompt");
|
|
if (argvArg.plan) {
|
|
confirmation = "plan";
|
|
} else if (argvArg.y || argvArg.yes) {
|
|
confirmation = "auto";
|
|
}
|
|
|
|
let requireCleanTree = releaseConfig.preflight?.requireCleanTree ?? true;
|
|
let runTests = releaseConfig.preflight?.test ?? false;
|
|
let runBuild = releaseConfig.preflight?.build ?? true;
|
|
if (argvArg.t || argvArg.test) runTests = true;
|
|
if (argvArg.b || argvArg.build) runBuild = true;
|
|
if (isDisabled(argvArg, "test")) runTests = false;
|
|
if (isDisabled(argvArg, "build")) runBuild = false;
|
|
if (isDisabled(argvArg, "preflight")) requireCleanTree = false;
|
|
|
|
const configuredTargets: TReleaseTarget[] = [];
|
|
if (gitEnabled) configuredTargets.push("git");
|
|
if (npmEnabled) configuredTargets.push("npm");
|
|
if (dockerEnabled) configuredTargets.push("docker");
|
|
let targets = getTargetOverride(argvArg) || configuredTargets;
|
|
if (isDisabled(argvArg, "git", "push")) {
|
|
targets = targets.filter((target) => target !== "git");
|
|
}
|
|
if (isDisabled(argvArg, "publish")) {
|
|
targets = targets.filter((target) => target === "git");
|
|
}
|
|
targets = unique(targets);
|
|
|
|
return {
|
|
confirmation,
|
|
plan: buildReleasePlan({ requireCleanTree, runTests, runBuild, targets }),
|
|
targets,
|
|
requireCleanTree,
|
|
runTests,
|
|
runBuild,
|
|
testCommand: releaseConfig.preflight?.testCommand || "pnpm test",
|
|
buildCommand: releaseConfig.preflight?.buildCommand || "pnpm build",
|
|
changelogFile: "changelog.md",
|
|
changelogPendingSection: "Pending",
|
|
changelogVersionHeading: "## {{date}} - {{version}}",
|
|
gitEnabled,
|
|
gitRemote: gitConfig.remote || "origin",
|
|
pushBranch: gitConfig.pushBranch ?? true,
|
|
pushTags: gitConfig.pushTags ?? true,
|
|
npmEnabled,
|
|
npmRegistries,
|
|
npmAccessLevel: npmConfig.accessLevel || "public",
|
|
npmAlreadyPublished: npmConfig.alreadyPublished || "success",
|
|
dockerEnabled,
|
|
dockerImages: dockerConfig.images || [],
|
|
};
|
|
};
|